Compare commits

...

5 Commits

Author SHA1 Message Date
aravind-segu 683432ebf3 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
2026-06-24 21:50:04 +00:00
Ankush Bhatiya 820e0a4df2 Merge branch 'main' into feat/kimi-code-harness
# Conflicts:
#	README.md
#	docs/AGENT_YAML_SPEC.md
#	omnigent/cli.py
#	omnigent/harness_aliases.py
#	omnigent/model_catalog.py
#	omnigent/model_override.py
#	omnigent/onboarding/harness_readiness.py
#	omnigent/onboarding/provider_config.py
#	omnigent/runner/app.py
#	omnigent/runtime/workflow.py
#	omnigent/spec/_omnigent_compat.py
#	tests/onboarding/test_harness_readiness.py
2026-06-23 18:57:49 +02:00
Ankush Bhatiya 83e628e135 Resolve merge conflicts from main 2026-06-19 15:49:22 +02:00
Ankush Bhatiya a5a3a89c06 merge: resolve README conflict with main
Main updated the README's intro sentence to position Omnigent as an
"open-source AI agent framework" for SEO; this branch added "Kimi Code"
to the harness list. Merge keeps both.

Signed-off-by: Ankush Bhatiya <ankushb@gmail.com>
2026-06-17 19:23:02 +02:00
Ankush Bhatiya 24fcf5916d 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>
2026-06-17 17:30:34 +02:00
50 changed files with 5703 additions and 25 deletions
+3 -2
View File
@@ -4,7 +4,7 @@
### The open-source AI agent framework and meta-harness for all your AI agents.
Omnigent is an open-source **AI agent framework** and meta-harness that gives you a common orchestration layer over Claude Code, Codex, Cursor, Pi, and the agents you write yourself: swap or combine harnesses without rewriting, enforce policies and sandboxing, and collaborate in real time from any device.
Omnigent is an open-source **AI agent framework** and meta-harness that gives you a common orchestration layer over Claude Code, Codex, Cursor, Kimi Code, Pi, and the agents you write yourself: swap or combine harnesses without rewriting, enforce policies and sandboxing, and collaborate in real time from any device.
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://github.com/omnigent-ai/omnigent/blob/main/LICENSE)
![Status: alpha](https://img.shields.io/badge/status-alpha-orange.svg)
@@ -161,6 +161,7 @@ Or launch a specific agent runtime, or your own agent:
```bash
omnigent claude # Claude Code, in a session your team can join
omnigent codex # Codex
omnigent kimi # Kimi Code (https://kimi.com), headless
omnigent run path/to/agent.yaml # your own agent (see "Write your own agent")
```
@@ -367,7 +368,7 @@ name: my_agent
prompt: You are a helpful data analyst.
executor:
harness: claude-sdk # or: claude-native, codex, codex-native, cursor, cursor-native, openai-agents, pi, pi-native, antigravity, qwen
harness: claude-sdk # or: claude-native, codex, codex-native, cursor, cursor-native, openai-agents, pi, pi-native, antigravity, qwen, kimi
tools:
# A local Python function (schema auto-generated from the signature)
+4
View File
@@ -2,6 +2,7 @@ import { BotIcon } from "lucide-react";
import { ClaudeIcon } from "@/components/icons/ClaudeIcon";
import { CodexIcon } from "@/components/icons/CodexIcon";
import { CursorIcon } from "@/components/icons/CursorIcon";
import { KimiIcon } from "@/components/icons/KimiIcon";
import { NessieIcon } from "@/components/icons/NessieIcon";
import { PiIcon } from "@/components/icons/PiIcon";
import type { ComponentType, SVGProps } from "react";
@@ -28,11 +29,14 @@ function iconForAgent(agent: AvailableAgent): ComponentType<SVGProps<SVGSVGEleme
if (nativeAgent?.iconKind === "codex") return CodexIcon;
if (nativeAgent?.iconKind === "pi") return PiIcon;
if (nativeAgent?.iconKind === "cursor") return CursorIcon;
if (nativeAgent?.iconKind === "kimi") return KimiIcon;
// A null harness (spec couldn't load) flows through to the bot fallback.
if (agent.harness?.includes("codex")) return CodexIcon;
if (agent.harness?.includes("claude")) return ClaudeIcon;
// Both the SDK "cursor" harness and "cursor-native" get the Cursor glyph.
if (agent.harness?.includes("cursor")) return CursorIcon;
// Both the SDK "kimi"/"kimi-code" harness and "kimi-native" get the Kimi glyph.
if (agent.harness?.includes("kimi")) return KimiIcon;
// qwen falls back to generic BotIcon for now; see docs/QWEN_FOLLOWUPS.md
// Exact match — a substring check would false-match e.g. "openapi".
if (agent.harness === "pi") return PiIcon;
+9
View File
@@ -0,0 +1,9 @@
// Import the Mono glyph directly instead of the package index. The Kimi index
// barrel pulls in a `Color` component whose transitive `@lobehub/fluent-emoji`
// dependency uses an ESM directory import that vitest can't resolve (it breaks
// AgentCard.test collection). `Mono` is the monochrome `currentColor` glyph the
// other harness icons (Cursor/Claude/…) render anyway, and it only depends on
// React. See node_modules/@lobehub/icons/es/Kimi/index.d.ts.
import Kimi from "@lobehub/icons/es/Kimi/components/Mono";
export const KimiIcon = Kimi;
+13
View File
@@ -483,6 +483,12 @@ describe("inventoryTerminals", () => {
session: "main",
running: true,
};
const kimiPane: TerminalInfo = {
id: "terminal_kimi_main",
name: "kimi",
session: "main",
running: true,
};
const bash: TerminalInfo = {
id: "terminal_bash_s1",
name: "bash",
@@ -504,6 +510,13 @@ describe("inventoryTerminals", () => {
expect(inventoryTerminals([cursorPane, bash], true)).toEqual([bash]);
});
it("drops the kimi vendor pane for native Kimi sessions", () => {
// Regression: terminal_kimi_main was missing from AGENT_TERMINAL_IDS,
// same failure mode as the pi/cursor panes above — leaked into Shells
// and hid the Chat/Terminal pill in Terminal view.
expect(inventoryTerminals([kimiPane, bash], true)).toEqual([bash]);
});
it("drops the embedded REPL terminal for terminal-first SDK sessions", () => {
// The REPL terminal backs the pill's Terminal view; listing it in
// the rail reads as a phantom "main" terminal on agents that don't
+3 -1
View File
@@ -50,7 +50,8 @@ export const PANEL_NO_TERMINAL_KEY = "";
* connection pill's Terminal view, runner-created per session shape:
* the embedded Omnigent REPL (``tui``/``main``) for SDK sessions,
* and the vendor pane (``claude``/``main``, ``codex``/``main``,
* ``pi``/``main``, or ``cursor``/``main``) for native-wrapper sessions.
* ``pi``/``main``, ``cursor``/``main``, or ``kimi``/``main``) for
* native-wrapper sessions.
* These are plumbing, not
* part of the session's shell inventory, and at most one exists per session.
*
@@ -65,6 +66,7 @@ export const AGENT_TERMINAL_IDS: ReadonlySet<string> = new Set([
"terminal_codex_main",
"terminal_pi_main",
"terminal_cursor_main",
"terminal_kimi_main",
]);
/**
+11 -1
View File
@@ -4,7 +4,7 @@ export const WRAPPER_LABEL_KEY = "omnigent.wrapper";
export const UI_MODE_LABEL_KEY = "omnigent.ui";
export const UI_MODE_TERMINAL_VALUE = "terminal";
export type NativeCodingAgentIconKind = "claude" | "codex" | "pi" | "cursor";
export type NativeCodingAgentIconKind = "claude" | "codex" | "pi" | "cursor" | "kimi";
export type NativeCodingAgentCapability = "permissionMode" | "approvalMode";
export interface NativeCodingAgentSpec {
@@ -57,6 +57,15 @@ export const NATIVE_CODING_AGENTS = [
iconKind: "pi",
sortRank: 40,
},
{
key: "kimi",
agentName: "kimi-native-ui",
harness: "kimi-native",
wrapperLabel: "kimi-native-ui",
displayName: "Kimi",
iconKind: "kimi",
sortRank: 50,
},
] as const satisfies readonly NativeCodingAgentSpec[];
const BY_AGENT_NAME: Map<string, NativeCodingAgentSpec> = new Map(
@@ -75,6 +84,7 @@ const BY_WRAPPER: Map<string, NativeCodingAgentSpec> = new Map(
const HARNESS_ALIASES: Record<string, string> = {
"native-pi": "pi-native",
"native-cursor": "cursor-native",
"native-kimi": "kimi-native",
};
export function nativeCodingAgentForAgentName(
+6 -4
View File
@@ -71,7 +71,7 @@ import { AgentRowTooltip } from "@/components/AgentHoverCard";
// returns agents newest-registered first (agent_store.list sorts by
// created_at desc), so pin the order users expect; any agent not listed
// here falls after, in server order.
const AGENT_DISPLAY_ORDER = ["Claude Code", "Codex", "Cursor", "Pi", "Polly", "Debby"];
const AGENT_DISPLAY_ORDER = ["Claude Code", "Codex", "Cursor", "Pi", "Kimi", "Polly", "Debby"];
// Built-in agents (by name slug) — the long-lived agents the server
// ships out of the box. The picker groups these first, then a divider,
@@ -82,13 +82,15 @@ const BUILTIN_AGENTS = new Set([
"codex-native-ui", // Codex
"pi-native-ui", // Pi
"cursor-native-ui", // Cursor
"kimi-native-ui", // Kimi
"polly",
"debby",
]);
// Hidden on the new-session picker only (superseded by polly; older
// deployments still carry a seeded nessie row this filter keeps out).
const NEW_SESSION_HIDDEN_AGENTS = new Set(["nessie"]);
// Hidden from the new-session picker only. `nessie` is superseded by polly.
// `kimi` / `kimi-code` are the headless SDK harness (kept for sub-agent / `run
// --harness kimi` use) — the picker offers only the native TUI (`kimi-native-ui`).
const NEW_SESSION_HIDDEN_AGENTS = new Set(["nessie", "kimi", "kimi-code"]);
// Short picker-row blurbs — the spec descriptions are long paragraphs that
// truncate badly in the dropdown; other dialogs keep the server values.
+7 -3
View File
@@ -33,6 +33,7 @@ import { Badge } from "@/components/ui/badge";
import { ClaudeIcon } from "@/components/icons/ClaudeIcon";
import { CodexIcon } from "@/components/icons/CodexIcon";
import { CursorIcon } from "@/components/icons/CursorIcon";
import { KimiIcon } from "@/components/icons/KimiIcon";
import { NessieIcon } from "@/components/icons/NessieIcon";
import { OttoIcon } from "@/components/icons/OttoIcon";
import { PiIcon } from "@/components/icons/PiIcon";
@@ -306,6 +307,7 @@ function brandChildIcon(child: ChildSessionInfo): AgentRowIcon | null {
if (nativeAgent?.iconKind === "codex") return CodexIcon;
if (nativeAgent?.iconKind === "pi") return PiIcon;
if (nativeAgent?.iconKind === "cursor") return CursorIcon;
if (nativeAgent?.iconKind === "kimi") return KimiIcon;
// Exact match — substring checks would false-match names like "pipeline".
if (child.tool === PI_AGENT_NAME) return PiIcon;
return null;
@@ -466,9 +468,11 @@ function MainRow({ rootSessionId, isActive }: { rootSessionId: string; isActive:
? PiIcon
: nativeAgent?.iconKind === "cursor"
? CursorIcon
: isNessie
? NessieIcon
: BotIcon;
: nativeAgent?.iconKind === "kimi"
? KimiIcon
: isNessie
? NessieIcon
: BotIcon;
// Native wrappers show the product name (mirroring the sidebar) instead
// of the spec's YAML name (e.g. "claude-native-ui"); other agents show
// their agent name, with "main" only while the session loads or when it
+8 -1
View File
@@ -21,7 +21,14 @@ export const CLAUDE_NATIVE_DEFAULT_LABEL = "Claude Code";
export const CODEX_NATIVE_DEFAULT_LABEL = "Codex";
export const PI_NATIVE_DEFAULT_LABEL = "Pi";
export type ConversationIconKind = "claude" | "codex" | "pi" | "cursor" | "nessie" | null;
export type ConversationIconKind =
| "claude"
| "codex"
| "pi"
| "cursor"
| "kimi"
| "nessie"
| null;
// Display label for a session with no title and no native-wrapper name —
// shown in the sidebar row and as the browser tab title fallback.
+22 -1
View File
@@ -49,7 +49,7 @@ resolved from the YAML file's directory.
```yaml
executor:
harness: claude-sdk # claude-sdk, openai-agents, codex, cursor, pi, antigravity, qwen, ...
harness: claude-sdk # claude-sdk, openai-agents, codex, cursor, pi, antigravity, qwen, kimi, ...
model: databricks-claude-opus-4-7
auth:
type: databricks
@@ -89,6 +89,27 @@ To route through OpenRouter / a gateway, declare a key/gateway provider in
or set `auth.base_url` to the OpenAI-compatible endpoint alongside the key.
For Databricks, use `auth: {type: databricks, profile: …}`.
### Kimi Code
`harness: kimi` runs the agent through Moonshot AI's
[Kimi Code CLI](https://github.com/MoonshotAI/Kimi-Code) headlessly via
`kimi --print --output-format stream-json` per turn. Install the binary
with `curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash`
and authenticate once with `kimi login` (OAuth or a Moonshot API key).
```yaml
executor:
harness: kimi # alias: kimi-code
model: kimi-k2-turbo
```
By default Kimi authenticates against Moonshot AI's backend — Omnigent
declares no `executor.auth` block. To route through a gateway, either set
`HARNESS_KIMI_GATEWAY_BASE_URL` + `HARNESS_KIMI_GATEWAY_API_KEY` in the
shell, declare a key/gateway provider in `~/.omnigent/config.yaml`, or use
`executor.auth: {type: databricks, profile: …}` and let Omnigent resolve
the workspace.
CLI flags such as `--harness` and `--model` can override or supply missing
executor values for a run. Databricks credentials come from the spec's
`executor.auth` block or your `omnigent setup` provider config — there is
+206
View File
@@ -0,0 +1,206 @@
# Kimi Code harness — known follow-ups
The Kimi Code CLI harness landed in #271 with the runtime, CLI,
onboarding, frontend, and gateway-routing wiring complete. This file
tracks the gaps deliberately deferred so they don't get lost. Each item
lists what the gap is, why it was deferred, and a concrete starting
point for whoever picks it up.
## 1. Omnigent-side provider injection (MCP + provider routing)
**Gap.** Two related gaps for the same reason — upstream Kimi Code CLI
has no per-spawn config override flag (no `--config-file`, no
`--mcp-config-file`):
- **Tools.** Spec-declared tools (`tools:` block in the agent YAML) are
not exposed to the kimi subprocess. `KimiExecutor.run_turn` accepts
the `tools` argument for ABI parity and logs a one-time warning per
session.
- **Providers.** A spec that declares
`executor.auth: {type: provider, name: X}` or
`{type: databricks, profile: P}` cannot be threaded through to kimi.
`configure_agent_harness_with_provider` for `harness_type="kimi"`
raises rather than silently routing through whatever default kimi
already had — so users understand why their auth didn't take effect.
For v1, both are managed out-of-band:
- Tools: not exposed.
- Providers: configure via `kimi provider add` in
`~/.kimi/config.toml`, then pin the resulting model id in the agent
spec.
**Why deferred.** Two paths exist, both substantial:
1. **MCP-via-config-file.** Wait for upstream kimi to grow a
`--mcp-config-file` (or equivalent stdin-injection mechanism); then
boot a FastMCP server bound to `127.0.0.1:0` per session and inject
its URL via env. Plumbing-heavy (~300500 lines, see the Codex
`dynamicTools` analogue at ~2300 lines total in
`omnigent/inner/codex_executor.py`).
2. **ACP-server long-lived process.** Switch off the per-turn
subprocess to a single `kimi acp` server speaking the Agent Client
Protocol (https://agentclientprotocol.com/) over stdio. ACP has
first-class tool registration + cancellation + new-message
injection. This is the right long-term shape — it also unlocks
mid-turn interrupt + live message queue (follow-up #5) — but is a
substantial rewrite.
**Starting point.** Read kimi's `kimi acp` reference; mirror the Codex
App-Server JSONL bridge structure but speak ACP instead. The ACP spec
is at https://agentclientprotocol.com/ and kimi documents its server
under `kimi acp --help`.
## 2. Native TUI launch (tmux-pane parity with `omnigent claude`)
**Gap.** `omnigent kimi` is a discoverability shortcut for
`omnigent run --harness kimi` — it runs Kimi headlessly behind the
standard Omnigent REPL, not the kimi TUI in a tmux pane. There is no
`kimi-native` harness analogous to `claude-native` / `codex-native`.
**Why deferred.** Native TUI integration is a separate piece of work
(~300500 lines): a `tmux`-based pane manager, a `KimiNativeExecutor`
that wraps the subprocess and bridges input/output through Omnigent's
terminal layer, and the equivalent of `omnigent/claude_native_*.py` /
`omnigent/codex_native_*.py`. Kimi's CLI already supports `kimi acp`
(Agent Client Protocol over stdio), which Zed and JetBrains use for IDE
integration, so the implementation would be easier than
Claude/Codex's were.
**Starting point.** Model on `omnigent/codex_native_executor.py` +
`omnigent/codex_native_harness.py`. Adding `kimi-native` to
`OMNIGENT_HARNESSES`, `_HARNESS_MODULES`, `NATIVE_HARNESSES`, and the
`omnigent.kimi_native_*` analogue file set is the bulk of the work. The
`kimi acp` flag means most of the TUI-rendering plumbing already lives
in Kimi itself.
## 3. Dedicated Kimi glyph
**Gap.** `ap-web/src/components/AgentCard.tsx` falls through to
`BotIcon` for kimi agents. The other CLI harnesses each have their own
SVG glyph under `ap-web/src/components/icons/`.
**Why deferred.** No canonical SVG to copy from Kimi's repo yet.
Trivially small once an asset lands — add a `KimiIcon.tsx`, import it
in `AgentCard.tsx`, and switch the fallback comment to a real branch.
## 4. Multimodal input (incl. video)
**Gap.** Image / file / audio blocks (`input_image`, `input_file`,
`input_audio`) on a user message are dropped with a warning per
`_latest_user_text` in `omnigent/inner/kimi_executor.py`. Only text
content reaches kimi.
**Why deferred.** Kimi advertises **video input** as a first-class
feature (drop a screen recording or demo clip into the chat) — so the
multimodal story here is richer than for the other harnesses, but
plumbing it from Omnigent specs into kimi's CLI needs file
materialisation (write each input block to a temp file, pass the path,
clean up afterward) and a decision on the API surface. Out of scope
for the initial harness wrap.
**Starting point.** Kimi's CLI accepts the file as a positional
argument after the prompt (see kimi's `kimi-command` reference).
Extend `_latest_user_text` to also return a list of materialised file
paths; thread them into `_build_argv`; clean them up in the `finally`
block of `run_turn`.
## 5. Mid-turn interrupt + live message queue
**Gap.** `KimiExecutor.interrupt_session` terminates the active process
but doesn't preserve a queued message; `enqueue_session_message` always
returns `False`. Cancellation works via the standard async-gen close
path (the runtime cancels the wrapping HTTP request, `run_turn`'s
`finally` block terminates the subprocess), but there's no way to
inject a new user message mid-turn.
**Why deferred.** The per-turn-subprocess design genuinely lacks this
surface. Kimi's `kimi acp` long-lived path supports the Agent Client
Protocol — a proper bidirectional stdio protocol with cancel /
new-prompt messages — and would unlock both features, but switching off
the per-turn subprocess model is the substantial rewrite mentioned in
the executor docstring ("HTTP / SSE transport is a natural follow-up
once the contract is firm").
**Starting point.** Spawn `kimi acp` once per Omnigent session; cache
the stdio handles. Replace the per-turn `kimi --print` with a sequence
of ACP `prompt` / `cancel` messages. Drive the event stream off ACP's
`session/update` events instead of parsing JSONL from stdout. Wire
`interrupt_session` and `enqueue_session_message` against the ACP
cancel / prompt endpoints. The ACP spec is at
https://agentclientprotocol.com/.
## 6. Token usage / cost reporting
**Gap.** `TurnComplete.usage` is set to `None`. Kimi's `step_finish`
events (if present in stream-json output) carry token counts that we
currently drop.
**Why deferred.** Easy follow-up; deferred only because the
stream-json output schema is still settling and the kimi-cli docs
don't yet pin the field names. The cost-advisor already integrates
with other executors that report usage.
**Starting point.** Inspect stream-json output via
`kimi --print --output-format stream-json --debug` against a real
session and capture the usage field names. Update `_translate_event`
in `omnigent/inner/kimi_executor.py` to accumulate them on the
executor instance; pass the totals into `TurnComplete(usage=...)` at
end of turn.
## 7. Plan mode + thinking-mode controls in the spec
**Gap.** `KimiExecutor` honours `HARNESS_KIMI_PLAN` and
`HARNESS_KIMI_THINKING` env vars, but there is no spec-level field
that surfaces them on an Omnigent agent YAML. A user wanting to pin
plan mode for a research agent has to export the env var rather than
declare it inline.
**Why deferred.** Adding spec-level fields means updating the
`ExecutorSpec` parser, the workflow spawn-env builder, and the
single-file launcher generator — a larger surface for a relatively
niche feature.
**Starting point.** Add an optional `executor.config.kimi.{plan,
thinking}` block (parsed in `omnigent/spec/parser.py`), thread it
through `_build_kimi_spawn_env` into the env vars.
## 8. Built-in agent specs (`okabe` and friends)
**Gap.** Kimi ships built-in agent specs (`--agent default` /
`--agent okabe`) that customise the system prompt + tool set.
`KimiExecutor` honours `HARNESS_KIMI_AGENT` and `HARNESS_KIMI_AGENT_FILE`,
but there is no surface to select them from an Omnigent spec — users
must export the env var.
**Why deferred.** Same shape as #7 — needs spec parser + workflow
plumbing. Doc-only follow-up until the built-in agent surface is more
broadly used.
**Starting point.** Same pattern as #7 above, with
`executor.config.kimi.agent` / `executor.config.kimi.agent_file`.
## 9. Test coverage gaps
- **Live web-UI verification.** The `configured_harness_map()` daemon
hello frame includes `kimi`, but I never started `omnigent server`
+ `omnigent host` + `npm run dev` to visually confirm Kimi shows up
in the new-session picker. Probably works; would be cheap to verify.
- **Workflow `_apply_databricks_profile_to_kimi` real-creds test.**
The unit test path doesn't exercise the Databricks resolver. A
Databricks-creds-required test would catch real-world drift in the
gateway endpoint shape (`/serving-endpoints` vs
`/serving-endpoints/anthropic`).
## Out of scope
- **Subscription-style auth detection** (the
`_SUBSCRIPTION_AUTH_HARNESSES` set in `omnigent/spec/omnigent.py`).
Kimi's `kimi login` is OAuth or a single Moonshot API key, not a
multi-vendor subscription, so it doesn't fit that mental model.
- **`ucode` integration** (`_UCODE_HARNESS_CONFIGS` in
`omnigent/runtime/workflow.py`). The ucode path pre-caches gateway
state for SDK-wrapping harnesses; Kimi reads its config per-spawn
from `HARNESS_KIMI_CONFIG_CONTENT` (synthesised into a temp
`--config-file`), so the ucode cache layer is genuinely redundant
for it.
+48
View File
@@ -0,0 +1,48 @@
name: kimi-hello
description: >-
Smallest possible Kimi Code agent — single-file launcher YAML that hands
every turn to Moonshot AI's Kimi Code CLI (https://github.com/MoonshotAI/Kimi-Code)
running headlessly behind the standard Omnigent REPL. Useful as a sanity
check that the harness wires up end-to-end on a fresh machine, and as a
starting point for a real Kimi-backed agent.
# Single-file launcher shape — matches what ``omnigent run --harness kimi``
# generates internally. For multi-agent bundles, prefer the directory layout
# under ``examples/polly/`` / ``examples/debby/``.
executor:
harness: kimi
# Override per-invocation via ``-m kimi-k2-turbo`` or ``/model`` in the REPL.
# With no model pinned, Kimi picks the default model set in its config.
model: kimi-k2-turbo
prompt: |
You are Kimi Code, running headlessly inside Omnigent. Help the user with
software engineering tasks — read files, edit code, run tests, and explain
your reasoning. Keep responses concise and prefer showing the user diffs /
commands over describing them in prose.
# Kimi Code is a multi-provider coding agent (Moonshot AI's Kimi models by
# default; also OpenAI / Anthropic / OpenRouter via its config). Credentials
# live inside Kimi's own ``kimi login`` flow (OAuth or a Moonshot API key),
# NOT in Omnigent's provider config — so this spec declares no ``executor.auth``
# block.
#
# To route through a gateway (Databricks AI gateway / vendor-neutral proxy)
# instead, either set ``HARNESS_KIMI_GATEWAY_BASE_URL`` +
# ``HARNESS_KIMI_GATEWAY_API_KEY`` in the shell, or declare a
# ``executor.auth: {type: databricks, profile: …}`` block and let Omnigent
# resolve the workspace.
#
# Kimi owns its own file/shell tools (bash, edit, read, …) and runs them
# inside its own loop. The harness therefore advertises
# ``handles_tools_internally=True`` and does NOT need an ``os_env`` block to
# inject ``sys_os_*`` tools — adding one would just duplicate every operation
# against Omnigent's dispatch path.
# Try it:
# omnigent run examples/kimi_hello.yaml
# omnigent run examples/kimi_hello.yaml -p "summarise the README"
# omnigent run examples/kimi_hello.yaml -m kimi-k2-turbo
#
# Or as a shortcut for any kimi-harness run:
# omnigent kimi -p "list the files in the current directory"
+4
View File
@@ -53,3 +53,7 @@ PI_NATIVE_WRAPPER_VALUE = "pi-native-ui"
# Value the ``omnigent cursor`` wrapper writes into
# ``conversations.labels[WRAPPER_LABEL_KEY]``.
CURSOR_NATIVE_WRAPPER_VALUE = "cursor-native-ui"
# Value the ``omnigent kimi`` wrapper writes into
# ``conversations.labels[WRAPPER_LABEL_KEY]``.
KIMI_NATIVE_WRAPPER_VALUE = "kimi-native-ui"
+46
View File
@@ -1057,6 +1057,14 @@ def _redirect_native_resume_if_needed(
progress=progress,
)
return True
if native_agent.key == "kimi":
_run_kimi_native_resume_redirect(
base_url=base_url,
conversation_id=conversation_id,
auto_open_conversation=auto_open_conversation,
progress=progress,
)
return True
return False
@@ -1228,6 +1236,44 @@ def _run_cursor_native_resume_redirect(
)
def _run_kimi_native_resume_redirect(
*,
base_url: str,
conversation_id: str,
auto_open_conversation: bool,
progress: RunnerStartupProgress | None,
) -> None:
"""
Hand a kimi-native conversation back to ``omnigent kimi``.
The kimi-native session is driven by the ``kimi`` TUI in a runner-owned
tmux pane. Resuming through the Omnigent REPL would run an Omnigent turn
per message instead of attaching to the live TUI; redirecting to
``omnigent kimi``'s direct tmux attach keeps the TUI the single source of
turns. Mirrors :func:`_run_cursor_native_resume_redirect`.
:param base_url: Omnigent server base URL.
:param conversation_id: Omnigent conversation id.
:param auto_open_conversation: Browser-open preference for the wrapper.
:param progress: Optional Omnigent startup spinner to finish before redirect.
:returns: None.
"""
_finish_native_redirect_progress(
progress=progress,
conversation_id=conversation_id,
wrapper_name="kimi-native",
native_command="kimi",
)
from omnigent.kimi_native import run_kimi_native
run_kimi_native(
server=base_url,
session_id=conversation_id,
kimi_args=(),
auto_open_conversation=auto_open_conversation,
)
def _wrapper_label_for_conversation(
*,
base_url: str,
+247 -2
View File
@@ -480,6 +480,15 @@ def _pick_first_run_harness() -> _FirstRunPlan | None:
return _FirstRunPlan(harness="codex", agent=None)
if default_provider_for_harness(config, "pi") is not None:
return _FirstRunPlan(harness="pi", agent=None)
# Kimi authenticates against its own backend (``kimi login`` OAuth or a
# Moonshot API key) rather than the ambient-detected provider config, so
# ``default_provider_for_harness`` can't gate it. Fall back to "binary
# installed" as the readiness proxy: the executor will fail loud at the
# first turn if no provider is actually configured.
from omnigent.onboarding.harness_install import KIMI_KEY, harness_cli_installed
if harness_cli_installed(KIMI_KEY):
return _FirstRunPlan(harness="kimi", agent=None)
return None
@@ -1168,6 +1177,7 @@ _CLICK_SUBCOMMANDS: frozenset[str] = frozenset(
"debby",
"debug",
"host",
"kimi",
"lakebox",
"login",
"pane-picker",
@@ -4656,6 +4666,95 @@ def debby(run_args: tuple[str, ...]) -> None:
_run_bundled_agent("debby", run_args)
@cli.command(
context_settings={
"ignore_unknown_options": True,
"allow_extra_args": True,
}
)
@click.option(
"--server",
default=None,
help=(
"Remote omnigent URL. Ensures the host daemon, asks the "
"daemon-spawned runner to launch the Kimi TUI, and attaches this TTY. "
'Pass --server "" to auto-spawn a persistent local server in the '
"background and use that instead of a remote one."
),
)
@click.option(
"-r",
"--resume",
"resume",
is_flag=False,
flag_value=_RESUME_PICKER_SENTINEL,
default=None,
help=(
"Resume a prior Omnigent conversation. With a conversation id "
"(e.g. ``--resume conv_abc123``) attaches directly; with no value "
"opens an interactive picker scoped to kimi-native sessions."
),
)
@click.option(
"--session",
"session_id",
metavar="SESSION_ID",
default=None,
hidden=True,
help="Deprecated alias for ``--resume <id>``; kept for one release.",
)
@click.argument("kimi_args", nargs=-1, type=click.UNPROCESSED)
def kimi(
server: str | None,
resume: str | None,
session_id: str | None,
kimi_args: tuple[str, ...],
) -> None:
"""Launch the Kimi Code TUI in an Omnigent terminal.
Boots Moonshot AI's interactive ``kimi`` TUI
(https://github.com/MoonshotAI/Kimi-Code) in a runner-owned terminal and
attaches your TTY the native experience, embedded in the Omnigent web
UI. No Omnigent provider config is needed: kimi authenticates against its
own backend (``kimi login`` for OAuth, or a Moonshot API key).
For the headless SDK harness (per-turn ``kimi -p`` behind the Omnigent
REPL) use ``omnigent run --harness kimi`` instead.
\b
Examples:
omnigent kimi
omnigent kimi --resume conv_abc123
omnigent kimi --resume # interactive picker
"""
choice = _split_resume_value(resume)
if session_id is not None and (choice.picker or choice.conversation_id is not None):
raise click.UsageError(
"--session and --resume are mutually exclusive; "
"prefer --resume (--session is deprecated).",
)
from omnigent.kimi_native import run_kimi_native
cfg = _load_effective_config()
if server is None:
server = cfg.get("server")
auto_open_conversation = _resolve_auto_open_conversation_from_config(cfg)
server = _ensure_backend(server)
resolved_session_id = (
choice.conversation_id if choice.conversation_id is not None else session_id
)
run_kimi_native(
server=server,
session_id=resolved_session_id,
resume_picker=choice.picker,
kimi_args=kimi_args,
auto_open_conversation=auto_open_conversation,
)
@cli.command()
@click.argument("target", required=False, metavar="[CONV_ID]")
@click.option(
@@ -4715,7 +4814,7 @@ def resume(
# into a materialized copy of the spec before the server starts.
_HARNESS_CHOICES_HELP = (
"'claude' (alias for 'claude-sdk'), 'claude-sdk', 'codex', "
"'cursor', "
"'cursor', 'kimi', "
"'openai-agents', 'open-responses', 'pi', 'antigravity', or 'qwen'"
)
_HARNESS_HELP = f"Harness to use for a local agent: {_HARNESS_CHOICES_HELP}."
@@ -4748,6 +4847,10 @@ _DEFAULT_HARNESS_PROMPTS = {
"cursor": (
"You are Cursor, running through Omnigent. Help the user with software engineering tasks."
),
"kimi": (
"You are Kimi Code, running through Omnigent. "
"Help the user with software engineering tasks."
),
"qwen": (
"You are Qwen Code, running through Omnigent. "
"Help the user with software engineering tasks."
@@ -5069,6 +5172,10 @@ def _dispatch_native_terminal_harness(
from omnigent.cursor_native import run_cursor_native
run_cursor_native(cursor_args=passthrough, **common)
elif native_agent.key == "kimi":
from omnigent.kimi_native import run_kimi_native
run_kimi_native(kimi_args=passthrough, **common)
else: # pragma: no cover - new native agent added without a dispatch arm
raise click.ClickException(f"No native terminal launcher wired for harness {harness!r}.")
return True
@@ -9088,6 +9195,111 @@ def _manage_qwen_harness() -> None:
status = None
def _print_kimi_auth_help() -> None:
"""Print Kimi Code's authentication options.
Kimi authenticates against Moonshot AI's backend rather than an Omnigent
credential: ``kimi login`` (OAuth or a Moonshot API key) for the default
provider, and ``kimi provider add`` to register any other provider (an
OpenAI-compatible endpoint, a Databricks gateway, ) in
``~/.kimi/config.toml``. Omnigent has no per-spawn provider override for
upstream kimi, so all of this lives in the kimi CLI's own config — see
``docs/KIMI_FOLLOWUPS.md`` for the deferred Omnigent-side injection work.
"""
from omnigent.onboarding.interactive import console
console.print(
"\n [bold]Authenticate Kimi Code[/bold] (kimi manages its own config in "
"~/.kimi/config.toml):\n"
" • Default provider: run [bold]kimi login[/bold] "
"(Moonshot OAuth, or paste a Moonshot API key)\n"
" • Other providers: run [bold]kimi provider add[/bold] "
"(OpenAI-compatible endpoint, gateway, …), then pin that model id in "
"the agent spec\n"
" • Omnigent stores no kimi credential and cannot thread one per "
"spawn — configure it once in the kimi CLI\n"
)
def _manage_kimi_harness() -> None:
"""Run the level-2 loop for Kimi Code: install the CLI and drive ``kimi login``.
Unlike Qwen (which has no ``login`` subcommand), Kimi ships a real
``kimi login`` (Moonshot OAuth or API key) and ``kimi logout``, so this
drill-in offers sign-in / sign-out directly. Kimi has no first-class
"am I logged in?" probe (its install spec sets ``status_args=None``), so
:func:`~omnigent.onboarding.harness_install.harness_cli_logged_in` always
reports ``False`` for it meaning ``harness_login`` runs ``kimi login``
every time it is asked (the interactive flow lets the user cancel if
already authenticated) and its boolean return is not a reliable success
signal. We therefore treat login / logout as best-effort side effects and
report that the flow finished rather than asserting an auth state.
Like the other CLI-backed harnesses, a missing CLI gates the drill-in
there is nothing to configure for a harness you can't run.
:returns: None. Side effects: may install the kimi CLI and run
``kimi login`` / ``kimi logout`` in the foreground.
"""
from omnigent.onboarding.harness_install import (
KIMI_KEY,
harness_cli_installed,
harness_install_spec,
harness_login,
harness_logout,
)
from omnigent.onboarding.interactive import console, select
# Gate on the CLI. Kimi ships a single binary via a curl installer (not
# npm), so there's no in-process auto-install — name the command and let
# the user run it, then re-open. Mirrors how ``harness_setup_hint`` treats
# the other curl-installed CLI (cursor-agent).
if not harness_cli_installed(KIMI_KEY):
spec = harness_install_spec(KIMI_KEY)
hint = (spec.install_hint if spec else None) or "see Kimi Code docs"
console.print(
" Kimi Code's CLI isn't installed. Install it with:\n"
f" [bold]{hint}[/bold]\n"
" then re-open this menu to sign in."
)
return
# Carry the prior action's confirmation as a transient status line.
status: str | None = None
while True:
rows: list[_HarnessMenuRow] = [
_HarnessMenuRow("Sign in (kimi login)", action="login"),
_HarnessMenuRow("Sign out (kimi logout)", action="logout"),
_HarnessMenuRow("Show auth options", action="help"),
_HarnessMenuRow("← Back", action="back"),
]
idx = select(
"Kimi Code — authentication is managed by the kimi CLI",
[r.label for r in rows],
clear_on_exit=True,
status=status,
)
if idx < 0: # Esc / q
return
action = rows[idx].action
if action == "back":
return
if action == "login":
# ``kimi login`` runs in the foreground (OAuth / API-key prompt);
# its boolean return is unreliable for kimi (no status probe), so
# don't assert success — just confirm the flow finished.
console.print(" [dim]Signing in to Kimi (its login will open)…[/dim]")
harness_login(KIMI_KEY)
status = "kimi login flow finished — kimi stores its own credentials"
elif action == "logout":
console.print(" [dim]Signing out of Kimi…[/dim]")
harness_logout(KIMI_KEY)
status = "kimi logout flow finished"
elif action == "help":
_print_kimi_auth_help()
status = None
def _manage_credential(provider: str, family: str) -> str | None:
"""Run the level-3 loop for one credential: make default / remove.
@@ -9360,7 +9572,7 @@ def _run_configure_harnesses_interactive() -> None:
provider and adopts any ambient-detected credential announcing the
newly auto-configured machine credentials in a callout then loops on
the level-1 harness overview (Claude / Codex / Pi / Cursor / Antigravity /
Qwen Code / Quit) until the user quits or presses Esc.
Qwen Code / Kimi Code / Quit) until the user quits or presses Esc.
:returns: None. Side effect: may write ``~/.omnigent/config.yaml`` via
the backfill/adopt steps and any add/set-default/remove the user
@@ -9380,9 +9592,11 @@ def _run_configure_harnesses_interactive() -> None:
)
from omnigent.onboarding.harness_install import (
CURSOR_KEY,
KIMI_KEY,
QWEN_KEY,
harness_cli_installed,
harness_install_command,
harness_install_spec,
)
from omnigent.onboarding.interactive import select
from omnigent.onboarding.provider_config import (
@@ -9426,6 +9640,11 @@ def _run_configure_harnesses_interactive() -> None:
# provider family (its v1 auth is the CLI's own env vars / ``/auth`` flow,
# not an Omnigent credential), so it dispatches to its own drill-in.
_QWEN = "\x00qwen"
# Sentinel marking the Kimi Code row — like Cursor/Antigravity/Qwen it is
# not a provider family. Auth lives entirely in the kimi CLI (``kimi login``
# / ``kimi provider add`` → ~/.kimi/config.toml), so it dispatches to its
# own drill-in rather than ``_manage_harness_providers``.
_KIMI = "\x00kimi"
families = [ANTHROPIC_FAMILY, OPENAI_FAMILY, PI_SURFACE]
while True:
config = _load_global_config()
@@ -9556,6 +9775,30 @@ def _run_configure_harnesses_interactive() -> None:
options.append(f" {qwen_sub}")
selectable.append(False)
row_target.append(None)
# Kimi Code (Moonshot AI's multi-provider CLI, no provider family — like
# Cursor / Antigravity / Qwen). Auth lives entirely in the kimi CLI and
# Omnigent stores no kimi credential, so "ready" is just whether the
# binary is installed; the drill-in runs install + ``kimi login``. Kimi
# has no status probe, so the overview can't claim "signed in" — it only
# distinguishes installed vs. not.
kimi_installed = harness_cli_installed(KIMI_KEY)
options.append(f"{' ' if kimi_installed else '[red]✗[/] '}Kimi Code")
selectable.append(True)
row_target.append(_KIMI)
if not kimi_installed:
from rich.markup import escape as _rich_escape
# Kimi is curl-installed (package=None), so use its install_hint —
# ``harness_install_command`` raises ValueError for non-npm specs.
_kimi_spec = harness_install_spec(KIMI_KEY)
kimi_hint = (_kimi_spec.install_hint if _kimi_spec else None) or "see Kimi Code docs"
kimi_cmd = _rich_escape(kimi_hint)
kimi_sub = f"[dim]not installed — open to install ({kimi_cmd})[/]"
else:
kimi_sub = "[dim]installed — open to sign in (kimi login)[/]"
options.append(f" {kimi_sub}")
selectable.append(False)
row_target.append(None)
options.append("Quit")
selectable.append(True)
row_target.append(_QUIT)
@@ -9576,6 +9819,8 @@ def _run_configure_harnesses_interactive() -> None:
_manage_antigravity_harness()
elif target == _QWEN:
_manage_qwen_harness()
elif target == _KIMI:
_manage_kimi_harness()
else: # Quit row (or, defensively, a non-family row)
return
+8
View File
@@ -15,6 +15,12 @@ HARNESS_ALIASES: dict[str, str] = {
# canonical id is "antigravity" (matches the registry / workflow type).
"agy": "antigravity",
"google-antigravity": "antigravity",
# User-facing spelling for Moonshot AI's Kimi Code CLI; the canonical id
# is "kimi" (matches the binary and the registry / workflow type).
"kimi-code": "kimi",
# Reversed spelling for the native Kimi Code TUI harness; canonical id is
# "kimi-native" (the SDK/headless harness keeps the bare "kimi" id).
"native-kimi": "kimi-native",
# Qwen Code harness alias.
"qwen-code": "qwen",
}
@@ -35,6 +41,8 @@ NATIVE_HARNESSES: frozenset[str] = frozenset(
"native-pi",
"cursor-native",
"native-cursor",
"kimi-native",
"native-kimi",
}
)
+532
View File
@@ -0,0 +1,532 @@
"""Kimi Code CLI executor.
Drives Moonshot AI's upstream ``kimi`` CLI from
https://github.com/MoonshotAI/Kimi-Code (the curl-installed
single-binary build at https://code.kimi.com/kimi-code/install.sh).
The legacy pypi ``kimi-cli`` package is **not** supported — its
command-line surface (``--print``, list-of-blocks content, etc.) is
incompatible with the upstream binary the issue (#271) targets.
One ``kimi -p <prompt> --output-format stream-json`` subprocess per
Omnigent turn:
- parses each JSONL line on stdout into one or more
:class:`ExecutorEvent` (assistant text, tool-call request, tool-call
result, session metadata),
- captures the kimi session id from the ``role:"meta"`` /
``type:"session.resume_hint"`` line for resume on the next turn,
- uses the subprocess's ``cwd=`` for the working directory (upstream
has no ``--work-dir`` flag).
Kimi runs its own agent loop and its own tools (Bash, edit, read, web,
…) — Omnigent does not re-execute them. The executor advertises
``handles_tools_internally=True`` and forwards ``tool_calls`` /
``role:"tool"`` events from kimi's transcript as informational
:class:`ToolCallRequest` / :class:`ToolCallComplete` so the Omnigent
UI can render them, but the Session layer does not dispatch them.
Env-var contract (read once at construction by
:mod:`omnigent.inner.kimi_harness`):
- ``HARNESS_KIMI_MODEL``: Kimi-side model id, e.g. ``"kimi-k2-turbo"``.
``None`` lets the kimi config's ``default_model`` win.
- ``HARNESS_KIMI_CWD``: working directory the kimi subprocess runs in.
Upstream has no ``--work-dir`` flag so this is threaded through
``cwd=`` on the subprocess. ``None`` falls back to the runner's cwd.
- ``HARNESS_KIMI_PATH``: explicit path to the ``kimi`` binary, e.g.
``"/Users/x/.kimi-code/bin/kimi"``. Defaults to ``"kimi"`` looked up
on ``PATH``.
- ``HARNESS_KIMI_PLAN``: truthy → ``--plan`` (read-only plan mode).
- ``HARNESS_KIMI_CONTINUE_LAST``: truthy → ``--continue`` (resume the
most recent session for the working directory). Mutually exclusive
with ``HARNESS_KIMI_SESSION_ID``; the explicit session id wins.
- ``HARNESS_KIMI_SKILLS_DIRS``: JSON list of paths forwarded as one
``--skills-dir <path>`` per entry. Empty / unset = use kimi's
default skill discovery (user + project dirs).
Per-invocation provider routing (``--config-file`` / ``--mcp-config-file``
/ gateway env vars) is **not** wired: upstream kimi has no per-spawn
config override. Provider configuration lives in ``~/.kimi/config.toml``
and is managed out-of-band via ``kimi provider add``. See
``docs/KIMI_FOLLOWUPS.md`` for the deferred provider-injection follow-up.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import os
import re
import shutil
import time
import uuid
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
from omnigent.inner.datamodel import OSEnvSpec
from omnigent.inner.executor import (
EnqueuedContent,
Executor,
ExecutorConfig,
ExecutorError,
ExecutorEvent,
Message,
TextChunk,
ToolArgs,
ToolCallComplete,
ToolCallRequest,
ToolCallStatus,
ToolSpec,
TurnComplete,
)
_logger = logging.getLogger(__name__)
# Matches the resume hint kimi also prints to stderr / stdout (best-effort
# fallback for when the ``role:"meta"`` JSON event isn't seen). The session
# id format is ``session_<hex-uuid>`` — we accept the broader ``\S+`` to
# survive minor format drift.
_SESSION_RESUME_RE = re.compile(
r"To resume this session:\s+\S+\s+-r\s+(\S+)",
re.IGNORECASE,
)
def _parse_truthy(value: str | None) -> bool:
"""Return True for "1"/"true"/"yes"/"on" (case-insensitive)."""
if value is None:
return False
return value.strip().lower() in {"1", "true", "yes", "on", "y"}
def _resolve_kimi_binary() -> str:
"""Resolve the ``kimi`` binary path.
``HARNESS_KIMI_PATH`` wins (lets users point at a custom build or a
non-standard install location). Otherwise default to ``"kimi"`` and
rely on ``shutil.which`` so a missing binary surfaces clearly at
``run_turn``.
The legacy pypi ``kimi-cli`` package is intentionally NOT detected —
its command-line surface is incompatible with the upstream binary
Omnigent supports.
"""
explicit = os.environ.get("HARNESS_KIMI_PATH", "").strip()
if explicit:
return explicit
return "kimi"
def _latest_user_text(messages: list[Message]) -> str:
"""Extract the most recent user message's text.
Kimi receives the conversation history via ``--session <id>``, not
via stdin, so we only need the most recent user turn to drive
``-p <text>``. Image / file / audio content blocks are dropped with
a single warning per turn (multimodal input is deferred — see
``docs/KIMI_FOLLOWUPS.md``).
"""
dropped_blocks = 0
for message in reversed(messages):
if message.get("role") != "user":
continue
content = message.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
text_parts: list[str] = []
for block in content:
if not isinstance(block, dict):
continue
block_type = block.get("type")
if block_type in ("text", "input_text") and isinstance(block.get("text"), str):
text_parts.append(block["text"])
elif block_type in ("input_image", "input_file", "input_audio"):
dropped_blocks += 1
if dropped_blocks:
_logger.warning(
"kimi harness: dropped %d non-text content block(s) on the "
"latest user message (multimodal input not yet wired — see "
"docs/KIMI_FOLLOWUPS.md)",
dropped_blocks,
)
return "".join(text_parts)
return ""
def _resolve_skills_dirs(raw: str | None) -> list[str]:
"""Parse ``HARNESS_KIMI_SKILLS_DIRS`` (JSON list of paths) into a list.
Returns ``[]`` when unset / malformed so kimi falls back to its
default discovery (user + project skill dirs).
"""
if not raw or not raw.strip():
return []
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
_logger.warning("HARNESS_KIMI_SKILLS_DIRS is not valid JSON (%s); ignoring", exc)
return []
if not isinstance(parsed, list) or not all(isinstance(p, str) for p in parsed):
_logger.warning(
"HARNESS_KIMI_SKILLS_DIRS must be a JSON array of strings; got %r; ignoring",
parsed,
)
return []
return list(parsed)
class KimiExecutor(Executor):
"""Drive ``kimi -p`` per Omnigent turn.
See module docstring for env-var contract and lifecycle.
"""
def __init__(
self,
*,
cwd: str | None = None,
os_env: OSEnvSpec | None = None,
model: str | None = None,
binary_path: str | None = None,
plan: bool = False,
continue_last_session: bool = False,
skills_dirs: list[str] | None = None,
) -> None:
self._cwd = cwd
self._os_env = os_env
self._model = model
self._binary_path = binary_path or _resolve_kimi_binary()
self._plan = plan
self._continue_last_session = continue_last_session
self._skills_dirs = list(skills_dirs or [])
# Per-session state: kimi session id captured from the prior turn's
# ``role:"meta"`` event, fed to ``-S <id>`` on the next turn.
self._session_id: str | None = None
# Tracks whether we've already warned this session about tools
# being declared without a provider-injection bridge (one warning
# per session — see docs/KIMI_FOLLOWUPS.md item 1).
self._warned_tools_without_bridge = False
# Active subprocess handle, captured so interrupt can target it.
self._active_process: asyncio.subprocess.Process | None = None
# -- capabilities --------------------------------------------------------
def handles_tools_internally(self) -> bool:
return True
def supports_streaming(self) -> bool:
return True
def supports_tool_calling(self) -> bool:
return True
# -- helpers -------------------------------------------------------------
def _build_spawn_env(self) -> dict[str, str]:
"""The env handed to the kimi subprocess.
Inherits the harness wrap's own env (so ``KIMI_*`` auth vars
the user exported reach the subprocess) and adds nothing — all
``HARNESS_KIMI_*`` knobs are read on the wrap side and
translated into CLI flags.
"""
return dict(os.environ)
def _build_argv(self, *, prompt_text: str) -> list[str]:
"""Assemble the kimi argv for one turn.
Upstream ``-p <text>`` is the headless print mode (mutually
exclusive with ``--yolo`` because ``-p`` already auto-approves).
``--output-format stream-json`` makes stdout a JSONL transcript.
``-S`` resumes a session; ``-C`` continues the last session for
this cwd. The explicit session id wins when both are set.
"""
argv: list[str] = [
self._binary_path,
"--output-format",
"stream-json",
]
if self._model:
argv.extend(["-m", self._model])
if self._plan:
argv.append("--plan")
for skills_dir in self._skills_dirs:
argv.extend(["--skills-dir", skills_dir])
if self._session_id:
argv.extend(["-S", self._session_id])
elif self._continue_last_session:
argv.append("-C")
# ``-p`` must come last because it consumes a single argument; placing
# other flags after it would be parsed as part of the prompt.
argv.extend(["-p", prompt_text])
return argv
def _translate_event(self, payload: dict[str, Any]) -> list[ExecutorEvent]:
"""Translate one kimi stream-json line into Omnigent events.
Upstream emits whole messages (not deltas). Roles seen:
- ``"assistant"``: may carry ``content`` (a plain string with the
assistant's reply) and/or ``tool_calls`` (the model invoking
one of kimi's internal tools).
- ``"tool"``: kimi's own tool execution result delivered back to
its loop. Surfaced as a ``ToolCallComplete`` so the Omnigent
UI can render it; the Session layer does not re-execute
(``handles_tools_internally=True``).
- ``"meta"`` with ``type:"session.resume_hint"``: carries the
kimi session id we capture for resume on the next turn.
Unknown roles / types are silently ignored — kimi may grow new
event types in future versions.
"""
events: list[ExecutorEvent] = []
role = payload.get("role")
if role == "assistant":
content = payload.get("content")
if isinstance(content, str) and content:
events.append(TextChunk(text=content))
tool_calls = payload.get("tool_calls") or []
if isinstance(tool_calls, list):
for call in tool_calls:
if not isinstance(call, dict):
continue
fn = call.get("function") or {}
if not isinstance(fn, dict):
continue
name = fn.get("name") or ""
raw_args = fn.get("arguments")
args: ToolArgs = {}
if isinstance(raw_args, str):
with contextlib.suppress(json.JSONDecodeError):
parsed = json.loads(raw_args)
if isinstance(parsed, dict):
args = parsed
elif isinstance(raw_args, dict):
args = raw_args
call_id = call.get("id") or ""
if name:
events.append(
ToolCallRequest(
name=name,
args=args,
metadata={"call_id": call_id} if call_id else {},
)
)
elif role == "tool":
# Kimi has already executed the tool. Emit a synthetic completion
# so the Omnigent UI can render the result. The Session layer
# will not double-execute (handles_tools_internally=True).
result = payload.get("content")
call_id = payload.get("tool_call_id") or ""
events.append(
ToolCallComplete(
name="", # kimi doesn't repeat the name in tool results
status=ToolCallStatus.SUCCESS,
result=result,
metadata={"call_id": call_id} if call_id else {},
)
)
elif role == "meta" and payload.get("type") == "session.resume_hint":
captured = payload.get("session_id")
if isinstance(captured, str) and captured:
self._session_id = captured
# Anything else: ignore silently.
return events
# -- main entrypoint -----------------------------------------------------
async def run_turn(
self,
messages: list[Message],
tools: list[ToolSpec],
system_prompt: str, # noqa: ARG002 — kimi's own agent spec carries instructions
config: ExecutorConfig | None = None, # noqa: ARG002 — per-turn override not yet plumbed
) -> AsyncIterator[ExecutorEvent]:
if tools and not self._warned_tools_without_bridge:
_logger.warning(
"kimi executor received %d declared tool(s) but Omnigent has no "
"tool-injection bridge for the upstream kimi binary yet (no "
"per-spawn --mcp-config-file). The tools will not be exposed to "
"kimi for this session. See docs/KIMI_FOLLOWUPS.md for the MCP "
"follow-up.",
len(tools),
)
self._warned_tools_without_bridge = True
if shutil.which(self._binary_path) is None and not Path(self._binary_path).exists():
yield ExecutorError(
message=(
f"kimi harness: binary {self._binary_path!r} not found on PATH. "
"Install via `curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash` "
"or set HARNESS_KIMI_PATH to its absolute location."
),
retryable=False,
)
return
prompt_text = _latest_user_text(messages)
if not prompt_text:
yield TurnComplete(response=None)
return
argv = self._build_argv(prompt_text=prompt_text)
env = self._build_spawn_env()
started_at = time.monotonic()
process: asyncio.subprocess.Process | None = None
stderr_buf = bytearray()
any_text_emitted = False
final_text_parts: list[str] = []
try:
process = await _create_subprocess_exec(
*argv,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=self._cwd or None,
env=env,
)
self._active_process = process
assert process.stdout is not None
assert process.stderr is not None
async def _drain_stderr() -> None:
"""Buffer stderr so the resume-hint fallback regex can read it after exit."""
assert process is not None and process.stderr is not None
while True:
chunk = await process.stderr.read(4096)
if not chunk:
return
stderr_buf.extend(chunk)
stderr_task = asyncio.create_task(_drain_stderr())
try:
async for raw_line in process.stdout:
line = raw_line.decode("utf-8", errors="replace").strip()
if not line:
continue
try:
payload = json.loads(line)
except json.JSONDecodeError:
# Kimi sometimes prints informational lines on stdout
# (e.g. ``Shell cwd was reset to ...``). Log at debug
# and move on — never crash on non-JSON.
_logger.debug("kimi executor: non-JSON stdout line: %s", line[:200])
continue
if not isinstance(payload, dict):
continue
for event in self._translate_event(payload):
if isinstance(event, TextChunk):
any_text_emitted = True
final_text_parts.append(event.text)
yield event
finally:
with contextlib.suppress(asyncio.CancelledError):
await stderr_task
except asyncio.CancelledError:
if process is not None:
with contextlib.suppress(ProcessLookupError):
process.terminate()
raise
finally:
self._active_process = None
if process is not None:
try:
await asyncio.wait_for(process.wait(), timeout=2.0)
except asyncio.TimeoutError:
with contextlib.suppress(ProcessLookupError):
process.kill()
with contextlib.suppress(Exception):
await process.wait()
# Fallback: if the ``role:"meta"`` JSON event wasn't seen but the
# stderr footer carries the resume hint, capture from there. Mostly
# belt-and-suspenders against minor stream-json schema drift.
if not self._session_id:
stderr_text = stderr_buf.decode("utf-8", errors="replace")
match = _SESSION_RESUME_RE.search(stderr_text)
if match:
self._session_id = match.group(1)
if not self._session_id:
# No id surfaced anywhere — mint one so the next turn at least
# has a stable handle. ``-S <unknown>`` will start a fresh
# session on kimi's side, which is the desired graceful fallback.
self._session_id = uuid.uuid4().hex
elapsed_ms = (time.monotonic() - started_at) * 1000.0
if process is not None and process.returncode not in (None, 0):
stderr_text = stderr_buf.decode("utf-8", errors="replace")
yield ExecutorError(
message=(
f"kimi exited with code {process.returncode} after "
f"{elapsed_ms:.0f}ms. stderr: {stderr_text.strip()[:500]}"
),
retryable=False,
)
return
yield TurnComplete(
response="".join(final_text_parts) if any_text_emitted else None,
)
# -- session lifecycle ---------------------------------------------------
async def close_session(self, session_key: str) -> None: # noqa: ARG002 — per-session id is the kimi UUID, no extra teardown
"""Drop the captured session id so the next turn starts fresh.
The kimi subprocess is per-turn, so there is no long-lived
resource to release. We just forget the cached session id.
"""
self._session_id = None
async def interrupt_session(self, session_key: str) -> bool: # noqa: ARG002 — best-effort process terminate
"""Terminate the active kimi process, if any.
Returns True when a process was actually signalled. The next
``run_turn`` will start a fresh process (and a fresh ``-S``
resume if the cached id is still valid).
"""
process = self._active_process
if process is None or process.returncode is not None:
return False
with contextlib.suppress(ProcessLookupError):
process.terminate()
return True
return False
async def enqueue_session_message(
self,
session_key: str, # noqa: ARG002 — per-turn subprocess model; no live queue
content: EnqueuedContent, # noqa: ARG002 — per-turn subprocess model; no live queue
) -> bool:
"""Not supported under the per-turn subprocess model.
The ``kimi acp`` long-lived path would unlock this — see
``docs/KIMI_FOLLOWUPS.md``.
"""
return False
async def _create_subprocess_exec(
*args: Any, # type: ignore[explicit-any]
**kwargs: Any, # type: ignore[explicit-any]
) -> asyncio.subprocess.Process:
"""Indirection point so tests can stub subprocess creation.
Direct patching of ``asyncio.create_subprocess_exec`` in tests is
tricky because asyncio caches the bound method. Tests patch this
module-level helper instead.
"""
return await asyncio.create_subprocess_exec(*args, **kwargs)
+134
View File
@@ -0,0 +1,134 @@
"""``harness: kimi`` wrap.
Thin module exposing :func:`create_app` — the entrypoint the shared
:mod:`omnigent.runtime.harnesses._runner` invokes after the parent
process resolves ``"kimi"`` to this module via
:data:`omnigent.runtime.harnesses._HARNESS_MODULES`.
Wraps a :class:`omnigent.inner.kimi_executor.KimiExecutor` that drives
the upstream Moonshot AI ``kimi`` CLI
(https://github.com/MoonshotAI/Kimi-Code) headlessly via
``kimi -p <prompt> --output-format stream-json`` per turn.
Env vars read at startup (full contract in
``omnigent.inner.kimi_executor``):
- ``HARNESS_KIMI_MODEL`` — model id (e.g. ``kimi-k2-turbo``); ``None``
lets kimi's ``default_model`` from ``~/.kimi/config.toml`` win.
- ``HARNESS_KIMI_CWD`` — working directory the kimi subprocess runs in
(upstream has no ``--work-dir`` flag, so this is threaded as
subprocess ``cwd=``).
- ``HARNESS_KIMI_PATH`` — path to the ``kimi`` binary. Default
``"kimi"``.
- ``HARNESS_KIMI_PLAN`` — truthy → ``--plan`` (read-only plan mode).
- ``HARNESS_KIMI_CONTINUE_LAST`` — truthy → ``-C`` (continue the
previous session for the working directory). Mutually exclusive with
an active resume id; the explicit id wins.
- ``HARNESS_KIMI_SKILLS_DIRS`` — JSON array of paths, each forwarded
as ``--skills-dir <path>``.
- ``HARNESS_KIMI_OS_ENV`` — JSON-encoded :class:`OSEnvSpec`. ``None``
falls back to ``caller_process + sandbox=none`` (kimi handles its
own sandbox + approval flow internally).
Provider routing for kimi happens via ``kimi provider add`` / its
``~/.kimi/config.toml`` (out-of-band from Omnigent) — upstream kimi
has no per-spawn ``--config-file`` or env-var provider override. See
``docs/KIMI_FOLLOWUPS.md`` for the deferred Omnigent-side provider
injection work.
"""
from __future__ import annotations
import json
import logging
import os
from fastapi import FastAPI
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
from omnigent.inner.executor import Executor
from omnigent.inner.kimi_executor import KimiExecutor, _resolve_skills_dirs
from omnigent.runtime.harnesses._executor_adapter import ExecutorAdapter
_logger = logging.getLogger(__name__)
_ENV_MODEL = "HARNESS_KIMI_MODEL"
_ENV_CWD = "HARNESS_KIMI_CWD"
_ENV_BIN = "HARNESS_KIMI_PATH"
_ENV_PLAN = "HARNESS_KIMI_PLAN"
_ENV_CONTINUE_LAST = "HARNESS_KIMI_CONTINUE_LAST"
_ENV_SKILLS_DIRS = "HARNESS_KIMI_SKILLS_DIRS"
_ENV_OS_ENV = "HARNESS_KIMI_OS_ENV"
def _parse_truthy_with_default(value: str | None, *, default: bool) -> bool:
"""Same as ``_parse_truthy`` but with an explicit default for unset/empty."""
if value is None or value.strip() == "":
return default
return value.strip().lower() in {"1", "true", "yes", "on", "y"}
def _resolve_os_env() -> OSEnvSpec:
"""Resolve the inner :class:`OSEnvSpec` from :data:`_ENV_OS_ENV`.
Mirrors the cursor / antigravity wraps' default: when no spec was
serialised, fall back to ``caller_process + sandbox=none``. Kimi
has its own internal sandbox / approval flow, so Omnigent does not
wrap the subprocess in bwrap / seatbelt by default — the user can
still set a sandbox via the spec's ``os_env`` block.
"""
raw = os.environ.get(_ENV_OS_ENV, "").strip()
if raw:
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
_logger.warning(
"%s is not valid JSON (%s); falling back to default os_env",
_ENV_OS_ENV,
exc,
)
payload = None
if isinstance(payload, dict):
sandbox_payload = payload.get("sandbox")
sandbox = (
OSEnvSandboxSpec(**sandbox_payload) if isinstance(sandbox_payload, dict) else None
)
return OSEnvSpec(
type=str(payload.get("type", "caller_process")),
cwd=payload.get("cwd"),
sandbox=sandbox,
fork=bool(payload.get("fork", False)),
)
return OSEnvSpec(
type="caller_process",
cwd=None,
sandbox=OSEnvSandboxSpec(type="none"),
fork=False,
)
def _build_kimi_executor() -> Executor:
"""Construct a :class:`KimiExecutor` from env-var config.
Called lazily by :class:`ExecutorAdapter` on the first turn, so a
missing ``kimi`` binary surfaces as a request-time error (not an
app-boot crash) — matching how the cursor / antigravity wraps
defer their SDK / binary lookup.
"""
return KimiExecutor(
cwd=os.environ.get(_ENV_CWD) or None,
os_env=_resolve_os_env(),
model=os.environ.get(_ENV_MODEL) or None,
binary_path=os.environ.get(_ENV_BIN) or None,
plan=_parse_truthy_with_default(os.environ.get(_ENV_PLAN), default=False),
continue_last_session=_parse_truthy_with_default(
os.environ.get(_ENV_CONTINUE_LAST), default=False
),
skills_dirs=_resolve_skills_dirs(os.environ.get(_ENV_SKILLS_DIRS)),
)
def create_app() -> FastAPI:
"""Build the kimi harness's FastAPI app (required entry point)."""
adapter = ExecutorAdapter(executor_factory=_build_kimi_executor)
return adapter.build()
+143
View File
@@ -0,0 +1,143 @@
"""Executor that bridges Omnigent web-chat turns into the native Kimi TUI.
It does not launch ``kimi`` — the ``omnigent kimi`` wrapper already
launched the interactive TUI in the session terminal. Each web-UI turn injects
the latest user message into that same tmux pane (bracketed paste + Enter), so
the message appears in the running Kimi TUI (and, since the web UI embeds the
pane, in both surfaces). Output is terminal-originated; the embedded terminal
renders it live.
This is a DIFFERENT executor from the headless :class:`~omnigent.inner.
kimi_executor.KimiExecutor`, which shells ``kimi -p … --output-format
stream-json`` per turn. This one types into a resident ``kimi`` TUI.
"""
from __future__ import annotations
import asyncio
import logging
import os
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
from omnigent.inner.executor import (
Executor,
ExecutorConfig,
ExecutorError,
ExecutorEvent,
Message,
ToolSpec,
TurnComplete,
)
from omnigent.kimi_native_bridge import BRIDGE_DIR_ENV_VAR, inject_user_message
logger = logging.getLogger(__name__)
class KimiNativeExecutor(Executor):
"""Harness-side executor for ``omnigent kimi`` web-UI turns.
Injects each web-UI message into the running Kimi TUI's tmux pane. Does not
stream output (the embedded terminal shows it); accepts mid-turn steering.
:param bridge_dir: Optional bridge dir override; ``None`` reads
:data:`BRIDGE_DIR_ENV_VAR` from the harness spawn env.
"""
def __init__(self, bridge_dir: Path | None = None) -> None:
self._bridge_dir = bridge_dir or _bridge_dir_from_env()
# Serializes writes to the shared tmux pane: run_turn (initiating
# message) and enqueue_session_message (steering) run concurrently
# against one cached executor, and injection is multi-step (clear +
# paste + Enter) — without the lock their keystrokes interleave.
self._inject_lock = asyncio.Lock()
def supports_streaming(self) -> bool:
""":returns: ``False`` — output is shown by the embedded terminal, not this executor."""
return False
def supports_live_message_queue(self) -> bool:
""":returns: ``True`` — messages can be injected mid-turn (steering)."""
return True
async def enqueue_session_message(self, session_key: str, content: Any) -> bool:
"""Inject a live steering message into the Kimi terminal."""
del session_key
text = _content_to_text(content, self._bridge_dir)
if not text:
return False
try:
async with self._inject_lock:
await asyncio.to_thread(inject_user_message, self._bridge_dir, content=text)
except RuntimeError:
return False
return True
async def run_turn(
self,
messages: list[Message],
tools: list[ToolSpec],
system_prompt: str,
config: ExecutorConfig | None = None,
) -> AsyncIterator[ExecutorEvent]:
"""Inject the latest web-UI user message into the Kimi TUI pane."""
del tools, system_prompt, config
text = _latest_user_text(messages, self._bridge_dir)
if not text:
yield ExecutorError(message="kimi native turn had no user text to send")
return
try:
async with self._inject_lock:
await asyncio.to_thread(inject_user_message, self._bridge_dir, content=text)
except RuntimeError as exc:
yield ExecutorError(message=str(exc))
return
yield TurnComplete(response=None)
def _bridge_dir_from_env() -> Path:
"""Resolve the kimi-native bridge dir from the harness spawn env."""
raw = os.environ.get(BRIDGE_DIR_ENV_VAR, "").strip()
if not raw:
raise RuntimeError(f"{BRIDGE_DIR_ENV_VAR} is required for the kimi-native harness")
return Path(raw)
def _latest_user_text(messages: list[Message], bridge_dir: Path) -> str:
"""Return the latest user message's text (attachments materialized to disk)."""
for message in reversed(messages):
if message.get("role") == "user":
return _content_to_text(message.get("content"), bridge_dir)
return ""
def _content_to_text(content: Any, bridge_dir: Path) -> str:
"""Normalize executor content into text the Kimi TUI receives.
Text blocks are extracted directly. Image/file blocks carrying a base64
data URI are materialized to the bridge dir and referenced by absolute path
(``[Attached: <path>]``) so kimi can open them with its Read tool —
otherwise web-UI attachments are silently dropped. Mirrors claude-native.
"""
if isinstance(content, str):
return content
if isinstance(content, list):
from omnigent.inner.native_attachments import materialize_attachment
attachment_lines: list[str] = []
text_parts: list[str] = []
for block in content:
if not isinstance(block, dict):
continue
block_type = block.get("type", "")
if block_type in ("input_text", "text"):
text = block.get("text")
if isinstance(text, str):
text_parts.append(text)
elif block_type in ("input_image", "input_file"):
path = materialize_attachment(block, bridge_dir)
if path is not None:
attachment_lines.append(f"[Attached: {path}]")
return "\n\n".join(attachment_lines + text_parts)
return ""
+43
View File
@@ -0,0 +1,43 @@
"""``harness: kimi-native`` wrap (the native Kimi Code TUI).
Thin module exposing :func:`create_app` — the entry point the shared
:mod:`omnigent.runtime.harnesses._runner` invokes after the parent process
resolves ``"kimi-native"`` to this module via
:data:`omnigent.runtime.harnesses._HARNESS_MODULES`.
Wraps a :class:`omnigent.inner.kimi_native_executor.KimiNativeExecutor`,
which injects web-UI messages into the running ``kimi`` TUI (launched by
``omnigent kimi`` in the session terminal) via tmux. The bridge dir is read
from :data:`~omnigent.kimi_native_bridge.BRIDGE_DIR_ENV_VAR` in the spawn env.
Tool policies: kimi-native enforces Omnigent's tool deny-policy via a
``PreToolUse`` hook (registered in the per-session ``config.toml`` built by
:mod:`omnigent.kimi_native_credentials`, dispatched to
:mod:`omnigent.kimi_native_hook`). A ``POLICY_ACTION_DENY`` verdict blocks the
tool with the policy reason; everything else is "no opinion", so ``kimi``'s own
in-TUI approval prompt still runs — the deployment's deny-gate and the user's
own consent are kept as two independent gates. A companion ``PermissionRequest``
hook surfaces the pending approval in the web UI read-only (the yes/no is
answered in the TUI, which Omnigent cannot intercept). Connector/tool ASK
policies are not enforced (kimi owns the ask); treat the kimi TUI as the
approval surface, with Omnigent able to hard-deny.
"""
from __future__ import annotations
from fastapi import FastAPI
from omnigent.inner.executor import Executor
from omnigent.inner.kimi_native_executor import KimiNativeExecutor
from omnigent.runtime.harnesses._executor_adapter import ExecutorAdapter
def _build_kimi_native_executor() -> Executor:
"""Construct a :class:`KimiNativeExecutor` (reads the bridge dir from env)."""
return KimiNativeExecutor()
def create_app() -> FastAPI:
"""Build the kimi-native harness's FastAPI app (required entry point)."""
adapter = ExecutorAdapter(executor_factory=_build_kimi_native_executor)
return adapter.build()
+637
View File
@@ -0,0 +1,637 @@
"""Native Kimi TUI wrapper for the Omnigent CLI.
``omnigent kimi`` launches the Kimi CLI's interactive TUI (``kimi``
with no args) inside an Omnigent-runner-owned tmux terminal and attaches the
local TTY — the kimi analog of ``omnigent codex`` / ``omnigent pi``. The runner
spawns the process (see :func:`omnigent.runner.app._auto_create_kimi_terminal`);
this module owns the CLI-side orchestration: session create/resume, daemon
runner bind, terminal-ready poll, and the direct tmux attach.
Auth is the ambient ``kimi login`` (``$HOME/.kimi``); no API key is
required. Unlike Pi there is no extension bridge — the runner sets up the
terminal environment directly.
"""
from __future__ import annotations
import asyncio
import json
import os
import shutil
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any
import click
import httpx
import yaml
from omnigent._native_resume_hint import echo_native_cold_resume_hint, echo_native_resume_hint
from omnigent._runner_startup import RunnerStartupProgress, runner_startup_progress
from omnigent._wrapper_labels import KIMI_NATIVE_WRAPPER_VALUE as _WRAPPER_LABEL_VALUE
from omnigent._wrapper_labels import WRAPPER_LABEL_KEY as _WRAPPER_LABEL_KEY
from omnigent.conversation_browser import conversation_url, open_conversation_link_if_enabled
from omnigent.entities.session_resources import terminal_resource_id
from omnigent.host.daemon_launch import (
error_text,
launch_or_reuse_daemon_runner,
wait_for_host_online,
wait_for_runner_online,
)
from omnigent.native_terminal import (
DAEMON_HOST_ONLINE_TIMEOUT_S as _DAEMON_HOST_ONLINE_TIMEOUT_S,
)
from omnigent.native_terminal import (
DAEMON_RUNNER_ONLINE_TIMEOUT_S as _DAEMON_RUNNER_ONLINE_TIMEOUT_S,
)
from omnigent.native_terminal import (
DAEMON_TERMINAL_READY_TIMEOUT_S as _DAEMON_TERMINAL_READY_TIMEOUT_S,
)
from omnigent.native_terminal import bind_session_runner as _bind_session_runner
from omnigent.native_terminal import url_component
_DEFAULT_KIMI_COMMAND = "kimi"
_KIMI_PATH_ENV = "OMNIGENT_KIMI_PATH"
_AGENT_NAME = "kimi-native-ui"
_TERMINAL_NAME = "kimi"
_TERMINAL_SESSION_KEY = "main"
_SESSION_LABELS = {
"omnigent.ui": "terminal",
_WRAPPER_LABEL_KEY: _WRAPPER_LABEL_VALUE,
}
@dataclass(frozen=True)
class NativeKimiLaunch:
"""Resolved native Kimi process launch."""
executable: str
argv: list[str]
@dataclass(frozen=True)
class LaunchedKimiTerminal:
"""Terminal resource returned by the Omnigent runner launch path."""
terminal_id: str
tmux_socket: Path | None
tmux_target: str | None
@dataclass(frozen=True)
class PreparedKimiTerminal:
"""Prepared native Kimi terminal attachment details.
:param reattached: ``True`` when an existing, still-running session
terminal was reused (the live-reattach path: prior chat is
intact).
:param cold_resumed: ``True`` when resuming an existing Omnigent
session whose terminal had already exited, so a *fresh*
``kimi`` TUI was launched with none of the prior turns.
Kimi records no resumable chat id, so this is genuinely a new
chat - distinct from a brand-new session (``resolved_session_id
is None``) and from a live reattach. Drives the honest
cold-resume stderr hint. Note: kimi deliberately treats
``cold_resumed`` and ``reattached`` as mutually exclusive (the
cold-resume path leaves ``reattached`` at its ``False`` default)
- unlike ``claude_native`` which models them independently. This
is safe because kimi never reads ``reattached`` for teardown
ownership; do not "fix" the apparent inconsistency.
"""
session_id: str
terminal_id: str
tmux_socket: Path | None
tmux_target: str | None
reattached: bool
cold_resumed: bool = False
def _configured_kimi_command(env: Mapping[str, str]) -> str:
"""Return the configured kimi executable name/path from *env*."""
value = env.get(_KIMI_PATH_ENV, "").strip()
return value or _DEFAULT_KIMI_COMMAND
def resolve_kimi_executable(
*,
env: Mapping[str, str] | None = None,
which: Callable[[str], str | None] | None = None,
) -> str:
"""
Resolve the native Kimi (``kimi``) executable.
:param env: Environment mapping to inspect. Defaults to ``os.environ``.
:param which: Resolver hook for tests; defaults to ``shutil.which``.
:returns: Absolute executable path.
:raises click.ClickException: If no kimi CLI is available.
"""
env = os.environ if env is None else env
which = shutil.which if which is None else which
command = _configured_kimi_command(env)
resolved = which(command)
if resolved is None:
raise click.ClickException(
"Native Kimi requires the 'kimi' CLI on PATH. Install it with: "
"curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash, then "
f"run 'kimi login'. You can also set {_KIMI_PATH_ENV}=/path/to/kimi."
)
return resolved
def build_kimi_launch(
kimi_args: Sequence[str],
*,
env: Mapping[str, str] | None = None,
which: Callable[[str], str | None] | None = None,
) -> NativeKimiLaunch:
"""Build the argv for a native Kimi process."""
executable = resolve_kimi_executable(env=env, which=which)
return NativeKimiLaunch(executable=executable, argv=[executable, *kimi_args])
def run_kimi_native(
*,
server: str | None,
session_id: str | None,
kimi_args: tuple[str, ...],
resume_picker: bool = False,
auto_open_conversation: bool = False,
) -> None:
"""
Launch the Kimi TUI in an Omnigent terminal.
:param server: Resolved Omnigent server URL.
:param session_id: Optional existing Omnigent conversation id.
:param kimi_args: Raw kimi CLI args to persist for the runner-owned TUI.
:param resume_picker: ``True`` runs the kimi-native picker.
:param auto_open_conversation: When ``True``, open the browser
conversation URL after launch.
:returns: None after the terminal attach session ends.
"""
_preflight_local_tools()
if server is None:
raise click.ClickException(
"Kimi requires a resolved Omnigent server URL. The CLI should call "
"_ensure_backend before run_kimi_native."
)
with TemporaryDirectory(prefix="omnigent-kimi-native-") as tmpdir:
spec_path = _materialize_kimi_agent_spec(Path(tmpdir))
_run_with_remote_server(
server.rstrip("/"),
spec_path,
session_id=session_id,
resume_picker=resume_picker,
kimi_args=kimi_args,
auto_open_conversation=auto_open_conversation,
)
def _materialize_kimi_agent_spec(tmpdir: Path) -> Path:
"""
Write the terminal-first agent spec used by ``omnigent kimi``.
:param tmpdir: Temporary directory for the generated YAML file.
:returns: Path to the generated YAML spec.
"""
yaml_path = tmpdir / "kimi-native-ui.yaml"
raw: dict[str, Any] = {
"name": _AGENT_NAME,
"prompt": (
"Kimi is running in the session terminal. The user drives the "
"kimi TUI directly."
),
"executor": {"harness": "kimi-native"},
"spawn": True,
"os_env": {
"type": "caller_process",
"cwd": ".",
"sandbox": {"type": "none"},
},
"terminals": {
"shell": {
"command": "bash",
"allow_cwd_override": True,
"os_env": {
"type": "caller_process",
"cwd": ".",
"sandbox": {"type": "none"},
},
},
},
}
yaml_path.write_text(yaml.safe_dump(raw, sort_keys=False), encoding="utf-8")
return yaml_path
def _run_with_remote_server(
base_url: str,
spec_path: Path,
*,
session_id: str | None,
resume_picker: bool,
kimi_args: tuple[str, ...],
auto_open_conversation: bool = False,
) -> None:
"""
Launch Kimi on an Omnigent server via a daemon-spawned runner.
:param base_url: Omnigent server base URL.
:param spec_path: Generated Kimi wrapper agent spec.
:param session_id: Optional existing Omnigent session id.
:param resume_picker: When ``True``, run the kimi-native picker.
:param kimi_args: Raw kimi CLI args.
:param auto_open_conversation: Whether to open the web conversation URL.
"""
from omnigent.chat import _bundle_agent, _remote_headers
from omnigent.cli import _ensure_host_daemon
from omnigent.host.identity import load_or_create_host_identity
headers = _remote_headers(server_url=base_url)
try:
resolved_session_id = _resolve_session_id_for_resume(
base_url=base_url,
headers=headers,
session_id=session_id,
resume_picker=resume_picker,
)
if resolved_session_id is None and resume_picker and session_id is None:
return
async def _drive() -> None:
with runner_startup_progress(initial_message="Preparing Kimi...") as progress:
_update_startup_progress(progress, "Connecting to local daemon...")
_ensure_host_daemon(base_url)
host_id = load_or_create_host_identity().host_id
bundle = None if resolved_session_id is not None else _bundle_agent(spec_path)
prepared = await _prepare_kimi_terminal_via_daemon(
base_url=base_url,
headers=headers,
session_id=resolved_session_id,
session_bundle=bundle,
kimi_args=kimi_args,
host_id=host_id,
workspace=str(Path.cwd().resolve()),
startup_progress=progress,
)
click.echo(f"Web UI: {conversation_url(base_url, prepared.session_id)}", err=True)
open_conversation_link_if_enabled(
base_url=base_url,
conversation_id=prepared.session_id,
enabled=auto_open_conversation,
warn=lambda message: click.echo(message, err=True),
)
if prepared.cold_resumed:
echo_native_cold_resume_hint(agent_label="Kimi")
await _attach_terminal_resource(prepared)
if resolved_session_id is None:
echo_native_resume_hint(
native_command="kimi",
session_id=prepared.session_id,
server=base_url,
)
asyncio.run(_drive())
except httpx.ConnectError as exc:
raise click.ClickException(
f"Could not reach the omnigent server at {base_url}. "
"Confirm the server is running and reachable from here "
f"(e.g. `curl {base_url}/health`), and that --server is correct."
) from exc
async def _prepare_kimi_terminal_via_daemon(
*,
base_url: str,
headers: dict[str, str],
session_id: str | None,
session_bundle: bytes | None,
kimi_args: tuple[str, ...],
host_id: str,
workspace: str,
startup_progress: RunnerStartupProgress | None = None,
) -> PreparedKimiTerminal:
"""
Create or resume a kimi-native session through a daemon runner.
:returns: Prepared terminal details for attaching.
"""
persist_args = list(kimi_args)
timeout = httpx.Timeout(30.0, read=120.0)
async with httpx.AsyncClient(base_url=base_url, headers=headers, timeout=timeout) as client:
# Resuming an existing session can either reattach to a live
# terminal (prior chat intact) or, if that terminal has exited,
# cold-start a fresh TUI. We only know which after probing for a
# running terminal below, so default both flags off here.
reattached = False
cold_resumed = False
if session_id is None:
if session_bundle is None:
raise click.ClickException("Creating a Kimi session requires a session bundle.")
_update_startup_progress(startup_progress, "Creating Kimi session...")
session_id = await _create_kimi_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
)
else:
_update_startup_progress(startup_progress, "Loading Kimi session...")
payload = await _fetch_kimi_session(client, session_id)
labels = payload.get("labels") if isinstance(payload, dict) else None
if (
not isinstance(labels, dict)
or labels.get(_WRAPPER_LABEL_KEY) != _WRAPPER_LABEL_VALUE
):
raise click.ClickException(
f"Conversation {session_id!r} is not a kimi-native session."
)
existing_terminal = await _find_running_kimi_terminal(client, session_id)
if existing_terminal is not None:
if persist_args:
click.echo(
"Ignoring Kimi launch args for an already-running terminal; "
"restart the session terminal to apply them.",
err=True,
)
_update_startup_progress(startup_progress, "Kimi terminal ready.")
return PreparedKimiTerminal(
session_id=session_id,
terminal_id=existing_terminal.terminal_id,
tmux_socket=existing_terminal.tmux_socket,
tmux_target=existing_terminal.tmux_target,
reattached=True,
)
# Session exists but its terminal has exited. Kimi records no
# resumable chat id, so the launch below starts a fresh TUI with
# no prior turns. Flag it so the caller can say so honestly.
# Mutually exclusive with the reattach path above: we leave
# reattached at False here (unlike claude_native, which treats
# cold_resumed/reattached as independent). Safe because kimi
# never uses reattached for teardown ownership.
cold_resumed = True
if persist_args:
_update_startup_progress(startup_progress, "Updating Kimi session...")
resp = await client.patch(
f"/v1/sessions/{url_component(session_id)}",
json={"terminal_launch_args": persist_args},
)
if resp.status_code >= 400:
raise click.ClickException(
f"Kimi session launch config update failed "
f"({resp.status_code}): {error_text(resp)}"
)
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
_update_startup_progress(startup_progress, "Starting runner...")
runner_id = await launch_or_reuse_daemon_runner(
client,
host_id=host_id,
session_id=session_id,
workspace=workspace,
)
_update_startup_progress(startup_progress, "Waiting for runner...")
await wait_for_runner_online(client, runner_id, timeout_s=_DAEMON_RUNNER_ONLINE_TIMEOUT_S)
await _bind_session_runner(client, session_id, runner_id)
_update_startup_progress(startup_progress, "Starting Kimi terminal...")
await _ensure_kimi_terminal_on_runner(client, session_id)
terminal = await _wait_for_kimi_terminal_ready(
client,
session_id,
timeout_s=_DAEMON_TERMINAL_READY_TIMEOUT_S,
)
_update_startup_progress(startup_progress, "Kimi terminal ready.")
return PreparedKimiTerminal(
session_id=session_id,
terminal_id=terminal.terminal_id,
tmux_socket=terminal.tmux_socket,
tmux_target=terminal.tmux_target,
reattached=reattached,
cold_resumed=cold_resumed,
)
async def _create_kimi_session(
client: httpx.AsyncClient,
bundle: bytes,
*,
terminal_launch_args: list[str] | None = None,
) -> str:
"""Create a bundled terminal-first kimi-native session."""
metadata: dict[str, Any] = {"labels": dict(_SESSION_LABELS)}
if terminal_launch_args:
metadata["terminal_launch_args"] = terminal_launch_args
resp = await client.post(
"/v1/sessions",
data={"metadata": json.dumps(metadata)},
files={"bundle": ("kimi-native-ui.tar.gz", bundle, "application/gzip")},
timeout=120.0,
)
if resp.status_code >= 400:
raise click.ClickException(
f"Kimi session creation failed ({resp.status_code}): {error_text(resp)}"
)
body = resp.json()
new_session_id = body.get("session_id")
if not isinstance(new_session_id, str) or not new_session_id:
raise click.ClickException("Kimi session creation response did not include session_id.")
return new_session_id
async def _fetch_kimi_session(client: httpx.AsyncClient, session_id: str) -> dict[str, Any]:
"""Fetch an existing Omnigent session."""
resp = await client.get(f"/v1/sessions/{url_component(session_id)}")
if resp.status_code == 404:
raise click.ClickException(f"Conversation {session_id!r} not found on the server.")
if resp.status_code >= 400:
raise click.ClickException(
f"Failed to fetch conversation {session_id!r} ({resp.status_code}): {error_text(resp)}"
)
payload = resp.json()
if not isinstance(payload, dict):
raise click.ClickException("Conversation fetch returned non-object JSON.")
return payload
async def _ensure_kimi_terminal_on_runner(client: httpx.AsyncClient, session_id: str) -> None:
"""Ask the bound runner to ensure the Kimi terminal exists."""
resp = await client.post(
f"/v1/sessions/{url_component(session_id)}/resources/terminals",
json={
"terminal": _TERMINAL_NAME,
"session_key": _TERMINAL_SESSION_KEY,
"ensure_native_terminal": True,
},
timeout=60.0,
)
if resp.status_code >= 400:
raise click.ClickException(
f"Kimi terminal ensure failed ({resp.status_code}): {error_text(resp)}"
)
async def _wait_for_kimi_terminal_ready(
client: httpx.AsyncClient,
session_id: str,
*,
timeout_s: float,
) -> LaunchedKimiTerminal:
"""Wait until the runner exposes the Kimi terminal resource."""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout_s
while loop.time() < deadline:
terminal = await _find_running_kimi_terminal(client, session_id)
if terminal is not None:
return terminal
await asyncio.sleep(0.2)
raise click.ClickException(
f"The runner did not create the Kimi terminal for {session_id!r} "
f"within {timeout_s:.0f}s."
)
async def _find_running_kimi_terminal(
client: httpx.AsyncClient,
session_id: str,
) -> LaunchedKimiTerminal | None:
"""Return the existing running Kimi terminal id if present."""
terminal_id = kimi_terminal_resource_id()
resp = await client.get(
f"/v1/sessions/{url_component(session_id)}"
f"/resources/terminals/{url_component(terminal_id)}"
)
if resp.status_code == 404:
return None
if resp.status_code >= 400:
text = error_text(resp)
if resp.status_code in {409, 503} and (
"not bound to a runner" in text or "offline" in text
):
return None
raise click.ClickException(f"Failed to fetch Kimi terminal ({resp.status_code}): {text}")
payload = resp.json()
metadata = payload.get("metadata") if isinstance(payload, dict) else None
if isinstance(metadata, dict) and metadata.get("running") is False:
return None
return _launched_kimi_terminal_from_payload(payload)
def _launched_kimi_terminal_from_payload(payload: object) -> LaunchedKimiTerminal:
"""Decode terminal launch metadata returned by the runner."""
if not isinstance(payload, dict):
raise click.ClickException("Kimi terminal launch returned non-object JSON.")
terminal_id = payload.get("id")
if not isinstance(terminal_id, str) or not terminal_id:
raise click.ClickException("Kimi terminal launch response did not include terminal id.")
metadata = payload.get("metadata")
tmux_socket: Path | None = None
tmux_target: str | None = None
if isinstance(metadata, dict):
raw_socket = metadata.get("tmux_socket")
raw_target = metadata.get("tmux_target")
if isinstance(raw_socket, str) and raw_socket:
tmux_socket = Path(raw_socket)
if isinstance(raw_target, str) and raw_target:
tmux_target = raw_target
return LaunchedKimiTerminal(
terminal_id=terminal_id,
tmux_socket=tmux_socket,
tmux_target=tmux_target,
)
async def _attach_terminal_resource(prepared: PreparedKimiTerminal) -> None:
"""Attach the current terminal to the prepared Kimi terminal resource."""
direct_tmux_error = _direct_tmux_unavailable_reason(prepared)
if direct_tmux_error is not None:
raise click.ClickException(
f"Runner-owned Kimi terminal requires direct tmux attach, but {direct_tmux_error}"
)
if prepared.tmux_socket is None or prepared.tmux_target is None:
raise click.ClickException("Kimi tmux attach metadata was incomplete.")
await _attach_direct_tmux(prepared.tmux_socket, prepared.tmux_target)
async def _attach_direct_tmux(socket_path: Path, tmux_target: str) -> None:
"""Attach the current terminal directly to the runner-owned tmux pane."""
env = dict(os.environ)
env.pop("TMUX", None)
process = await asyncio.create_subprocess_exec(
"tmux",
"-S",
str(socket_path),
"-f",
os.devnull,
"attach",
"-t",
tmux_target,
env=env,
)
await process.wait()
def _direct_tmux_unavailable_reason(prepared: PreparedKimiTerminal) -> str | None:
"""Explain why direct tmux attach is unavailable."""
if prepared.tmux_socket is None:
return "the terminal resource did not include a tmux socket path."
if prepared.tmux_target is None:
return "the terminal resource did not include a tmux target."
if not prepared.tmux_socket.exists():
return f"tmux socket {prepared.tmux_socket} is not reachable from this CLI process."
if shutil.which("tmux") is None:
return "tmux is not available on PATH."
return None
def _resolve_session_id_for_resume(
*,
base_url: str,
headers: dict[str, str],
session_id: str | None,
resume_picker: bool,
) -> str | None:
"""Translate resume inputs into a concrete kimi-native session id."""
if session_id is not None:
return session_id
if not resume_picker:
return None
from omnigent_client import OmnigentClient
from omnigent.repl._resume_picker import pick_conversation_by_wrapper_label_from_sdk
async def _drive() -> str | None:
async with OmnigentClient(
base_url=base_url,
headers=headers if headers else None,
) as client:
return await pick_conversation_by_wrapper_label_from_sdk(
client,
wrapper_value=_WRAPPER_LABEL_VALUE,
agent_name=_AGENT_NAME,
)
return asyncio.run(_drive())
def _update_startup_progress(
startup_progress: RunnerStartupProgress | None,
message: str,
) -> None:
"""Show one concise Kimi startup milestone when a renderer is active."""
if startup_progress is not None:
startup_progress.update(message)
def _preflight_local_tools() -> None:
"""Verify local executables required by the native Kimi wrapper."""
if shutil.which("tmux") is None:
raise click.ClickException(
"tmux was not found on local PATH. The native Kimi wrapper "
"attaches to the runner-owned Kimi tmux terminal."
)
def kimi_terminal_resource_id() -> str:
"""Return the deterministic terminal resource id for Kimi."""
return terminal_resource_id(_TERMINAL_NAME, _TERMINAL_SESSION_KEY)
+437
View File
@@ -0,0 +1,437 @@
"""Filesystem bridge + tmux injection for the kimi-native terminal harness.
The runner launches the ``kimi`` TUI in a private tmux pane and records
that pane's socket + target here via :func:`write_tmux_target`. The harness
executor then delivers Omnigent web-UI messages into the *same* pane via
:func:`inject_user_message` (tmux bracketed paste + Enter) — the kimi analog
of claude-native's tmux send-keys bridge. This is what wires the web-UI chat box
to the running Kimi TUI (and, since the web UI embeds that pane, the message
shows in both surfaces).
"""
from __future__ import annotations
import contextlib
import hashlib
import json
import os
import subprocess
import tempfile
import time
from pathlib import Path
from typing import Any
#: Env var carrying the bridge dir into the harness executor process.
BRIDGE_DIR_ENV_VAR = "HARNESS_KIMI_NATIVE_BRIDGE_DIR"
_BRIDGE_ROOT = Path(os.environ.get("TMPDIR", "/tmp")) / f"omnigent-{os.getuid()}" / "kimi-native"
_TMUX_FILE = "tmux.json"
# Omnigent routing details the kimi hook subprocess reads to reach the server.
# Mirrors claude-native's ``permission_hook.json`` (server URL + auth headers +
# the active Omnigent session). Written by the runner at terminal-create time;
# read by :mod:`omnigent.kimi_native_hook` (PreToolUse deny-gate + the
# PermissionRequest read-only surface).
_HOOK_CONFIG_FILE = "hook_config.json"
_TMUX_READY_TIMEOUT_S = 30.0
_TMUX_SEND_TIMEOUT_S = 10.0
_POLL_INTERVAL_S = 0.2
_PASTE_SETTLE_S = 0.3
_PASTE_BUFFER = "omnigent-kimi-paste"
# How long to wait for the pasted text to become visible in the pane before
# sending Enter — submitting before the TUI commits the paste folds the Enter
# into the paste as a newline and the message sits unsent.
_PASTE_COMMIT_TIMEOUT_S = 5.0
# kimi TUI readiness markers. TODO(kimi-native): these strings are carried
# over from cursor-native and are NOT yet verified against a live kimi TUI.
# They only gate the pre-paste settle wait in ``_settle_pane`` (idle prompt
# vs. first-run trust modal); a wrong marker is non-fatal — the settle simply
# falls through on ``_PASTE_COMMIT_TIMEOUT_S`` and the paste still lands. Pin
# the real kimi placeholder / trust-prompt strings once verified on a real
# session, and drop ``_TRUST_MARKER`` if kimi has no first-run trust modal.
_IDLE_MARKERS = ("Plan, search, build", "Add a follow-up")
_TRUST_MARKER = "Trust this workspace"
def bridge_dir_for_session_id(session_id: str) -> Path:
"""Return the per-session bridge dir, e.g. ``/tmp/omnigent-<uid>/kimi-native/<hash>``."""
digest = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:32]
return _BRIDGE_ROOT / digest
def bridge_root() -> Path:
"""Return the configured Kimi-native bridge root."""
return _BRIDGE_ROOT
def _ensure_dir(path: Path) -> None:
"""Create *path* (and parents) with owner-only permissions."""
path.mkdir(parents=True, exist_ok=True)
with contextlib.suppress(OSError):
os.chmod(path, 0o700)
def build_kimi_native_spawn_env(session_id: str) -> dict[str, str]:
"""Build the ``HARNESS_KIMI_NATIVE_*`` env the harness executor reads."""
bridge_dir = bridge_dir_for_session_id(session_id)
_ensure_dir(bridge_dir)
return {
BRIDGE_DIR_ENV_VAR: str(bridge_dir),
}
def write_hook_config(
bridge_dir: Path,
*,
server_url: str,
headers: dict[str, str],
session_id: str,
) -> None:
"""Record the Omnigent routing details the kimi hook subprocess reads.
The PreToolUse / PermissionRequest hook commands receive only
``--bridge-dir`` on their command line (no secrets); they read the
server URL, auth headers, and active session id from this file. Mirrors
:func:`omnigent.claude_native_bridge` ``permission_hook.json`` plumbing.
:param bridge_dir: The kimi-native bridge dir.
:param server_url: Omnigent server base URL, e.g. ``"http://127.0.0.1:8787"``.
:param headers: Auth headers to replay on the hook's POSTs (may be empty).
:param session_id: The Omnigent session the hook events belong to.
"""
_ensure_dir(bridge_dir)
payload = {
"ap_server_url": server_url,
"ap_auth_headers": dict(headers),
"session_id": session_id,
"updated_at": time.time(),
}
tmp = bridge_dir / (_HOOK_CONFIG_FILE + ".tmp")
tmp.write_text(json.dumps(payload), encoding="utf-8")
with contextlib.suppress(OSError):
os.chmod(tmp, 0o600)
os.replace(tmp, bridge_dir / _HOOK_CONFIG_FILE)
def read_hook_config(bridge_dir: Path) -> dict[str, Any]:
"""Read Omnigent routing details for the kimi hook subprocess.
:param bridge_dir: The kimi-native bridge dir.
:returns: ``{"ap_server_url", "ap_auth_headers", "session_id"}`` (or an
empty dict when the file is absent or malformed).
"""
try:
raw = (bridge_dir / _HOOK_CONFIG_FILE).read_text(encoding="utf-8")
except OSError:
return {}
try:
data = json.loads(raw)
except ValueError:
return {}
return data if isinstance(data, dict) else {}
def read_active_session_id(bridge_dir: Path) -> str | None:
"""Return the Omnigent session id recorded for the hook subprocess.
:param bridge_dir: The kimi-native bridge dir.
:returns: The session id, or ``None`` when unset / malformed.
"""
session_id = read_hook_config(bridge_dir).get("session_id")
return session_id if isinstance(session_id, str) and session_id else None
def write_tmux_target(
bridge_dir: Path,
*,
socket_path: Path,
tmux_target: str,
pid: int | None = None,
) -> None:
"""Advertise the tmux socket + target for the running Kimi terminal."""
_ensure_dir(bridge_dir)
payload: dict[str, Any] = {
"socket_path": str(socket_path),
"tmux_target": tmux_target,
"updated_at": time.time(),
}
if pid is not None:
payload["pid"] = pid
tmp = bridge_dir / (_TMUX_FILE + ".tmp")
tmp.write_text(json.dumps(payload), encoding="utf-8")
os.replace(tmp, bridge_dir / _TMUX_FILE)
def read_tmux_info(bridge_dir: Path) -> dict[str, str] | None:
"""Return ``{socket_path, tmux_target}`` from ``tmux.json``, or ``None``."""
try:
raw = (bridge_dir / _TMUX_FILE).read_text(encoding="utf-8")
except OSError:
return None
try:
data = json.loads(raw)
except ValueError:
return None
socket_path = data.get("socket_path")
tmux_target = data.get("tmux_target")
if (
isinstance(socket_path, str)
and socket_path
and isinstance(tmux_target, str)
and tmux_target
):
return {"socket_path": socket_path, "tmux_target": tmux_target}
return None
def _wait_for_tmux_info(bridge_dir: Path, *, timeout_s: float) -> dict[str, str]:
"""Block until ``tmux.json`` is advertised, or raise on timeout."""
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
info = read_tmux_info(bridge_dir)
if info is not None:
return info
time.sleep(_POLL_INTERVAL_S)
raise RuntimeError(f"kimi-native tmux target was not advertised within {timeout_s:.0f}s")
def _run_tmux(socket_path: str, *args: str) -> None:
"""Invoke ``tmux -S <socket> <args...>`` and raise on failure."""
try:
proc = subprocess.run(
["tmux", "-S", socket_path, *args],
check=False,
capture_output=True,
text=True,
timeout=_TMUX_SEND_TIMEOUT_S,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(f"tmux command timed out after {_TMUX_SEND_TIMEOUT_S}s") from exc
if proc.returncode != 0:
detail = proc.stderr.strip() or proc.stdout.strip() or "<no output>"
raise RuntimeError(f"tmux command failed (rc={proc.returncode}): {detail}")
def _capture_pane(socket_path: str, tmux_target: str) -> str:
"""Capture the visible pane contents; ``""`` on any failure (treat as not-ready)."""
try:
proc = subprocess.run(
["tmux", "-S", socket_path, "capture-pane", "-p", "-t", tmux_target],
check=False,
capture_output=True,
text=True,
timeout=_TMUX_SEND_TIMEOUT_S,
)
except (subprocess.TimeoutExpired, OSError):
return ""
return proc.stdout if proc.returncode == 0 else ""
def _paste_payload_bytes(text: str) -> bytes:
r"""Encode text for ``tmux load-buffer``: line breaks → CR, tabs kept, other
control bytes dropped (a stray ESC would close the bracketed-paste early)."""
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
body = bytearray()
for ch in normalized:
if ch == "\n":
body.append(0x0D)
continue
if ch == "\t":
body.append(0x09)
continue
if ord(ch) < 0x20:
continue
body.extend(ch.encode("utf-8"))
return bytes(body)
def _session_alive(socket_path: str, tmux_target: str) -> bool:
"""Return whether the tmux session/pane still exists (the TUI is running)."""
try:
proc = subprocess.run(
["tmux", "-S", socket_path, "has-session", "-t", tmux_target],
check=False,
capture_output=True,
text=True,
timeout=_TMUX_SEND_TIMEOUT_S,
)
except (subprocess.TimeoutExpired, OSError):
return False
return proc.returncode == 0
def _submit_needle(content: str) -> str:
"""A stable single-line substring used to confirm the paste rendered in the pane."""
for line in content.splitlines():
stripped = line.strip()
if len(stripped) >= 4:
return stripped[:24]
stripped = content.strip()
return stripped[:24] if len(stripped) >= 4 else ""
def _settle_pane(socket_path: str, tmux_target: str, *, timeout_s: float) -> None:
"""Best-effort wait until the Kimi input box is ready to receive a paste.
Accepts the first-run "Trust this workspace" modal (sends ``a`` at most once)
so the input box can mount, then waits for an idle/running input marker. Falls
through after the timeout (mid-turn steering has no idle placeholder) rather
than raising.
"""
deadline = time.monotonic() + timeout_s
trust_accepted = False
while time.monotonic() < deadline:
pane = _capture_pane(socket_path, tmux_target)
if any(marker in pane for marker in _IDLE_MARKERS):
return
# One-shot, only when no input marker is up (so a later transcript that
# merely echoes the phrase can't spray repeated keystrokes into the TUI).
if not trust_accepted and _TRUST_MARKER in pane:
trust_accepted = True
with contextlib.suppress(RuntimeError):
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "a")
time.sleep(_POLL_INTERVAL_S)
def inject_user_message(
bridge_dir: Path,
*,
content: str,
timeout_s: float = _TMUX_READY_TIMEOUT_S,
) -> None:
"""Deliver a web-UI user message into the Kimi TUI via a tmux bracketed paste.
Clears any leftover draft, pastes *content* (multi-line safe via
``load-buffer``/``paste-buffer -p`` so interior newlines stay data, not
submits), settles, then submits with Enter.
:param bridge_dir: The kimi-native bridge dir holding ``tmux.json``.
:param content: User text (non-empty).
:param timeout_s: Per-readiness-gate timeout.
:raises RuntimeError: If the tmux target is never advertised or a tmux
command fails.
"""
if not content:
raise RuntimeError("kimi-native injection requires non-empty content")
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
socket_path = info["socket_path"]
tmux_target = info["tmux_target"]
# Fast-fail if the TUI already exited: otherwise _settle_pane polls a dead
# pane for the full timeout and the web message is silently lost. A clear
# error lets run_turn surface ExecutorError so the UI can say "restart".
if not _session_alive(socket_path, tmux_target):
raise RuntimeError(
"kimi terminal is no longer running (the TUI exited); restart the session"
)
_settle_pane(socket_path, tmux_target, timeout_s=timeout_s)
# Clear any leftover draft: Home (C-a) + kill-to-end (C-k).
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "C-a")
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "C-k")
with tempfile.NamedTemporaryFile(
dir=bridge_dir, prefix="paste_", suffix=".bin", delete=False
) as paste_file:
# Trailing newline absorbs any trailing backslash so it can't escape Enter.
paste_file.write(_paste_payload_bytes(content + "\n"))
paste_path = paste_file.name
try:
_run_tmux(socket_path, "load-buffer", "-b", _PASTE_BUFFER, paste_path)
_run_tmux(
socket_path,
"paste-buffer",
"-p", # bracketed-paste markers — the TUI keeps newlines as data
"-d", # drop the buffer after pasting
"-b",
_PASTE_BUFFER,
"-t",
tmux_target,
)
finally:
with contextlib.suppress(OSError):
os.unlink(paste_path)
# Wait until the paste is visibly committed to the input box before Enter.
# Submitting mid-paste folds the Enter in as a newline (the kimi TUI
# coalesces rapid stdin bursts), leaving the message unsent. Poll for the
# text, then submit; fall through to a blind submit if no needle is usable.
needle = _submit_needle(content)
if needle:
deadline = time.monotonic() + _PASTE_COMMIT_TIMEOUT_S
while time.monotonic() < deadline:
if needle in _capture_pane(socket_path, tmux_target):
break
time.sleep(_POLL_INTERVAL_S)
time.sleep(_PASTE_SETTLE_S)
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "Enter")
def inject_interrupt(bridge_dir: Path, *, timeout_s: float = _TMUX_READY_TIMEOUT_S) -> None:
"""Cancel the in-flight Kimi turn by sending ``Escape`` to the pane.
kimi stops a running turn on a single ``Escape`` (verified live).
The harness ``run_turn`` returns right after the paste, so the runner's
in-process cancel floor can't reach the turn — this is the analog of
:func:`inject_user_message` for the web UI's Stop button.
:raises RuntimeError: If the tmux target is not advertised or send-keys fails.
"""
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
# No ``-l``: tmux must interpret ``Escape`` as a key name.
_run_tmux(info["socket_path"], "send-keys", "-t", info["tmux_target"], "Escape")
#: Tool-independent label proving kimi's permission menu is on screen — guards
#: against injecting a stray digit once the prompt was already answered.
_PERMISSION_PROMPT_MARKER = "Approve once"
#: Web-UI approve/deny → option digit in kimi's fixed numbered menu
#: (1=Approve once, 2=Approve for session, 3=Reject, 4=Reject with feedback).
#: "Approve once" re-prompts each call so Omnigent governs every one.
APPROVE_KEY = "1"
DENY_KEY = "3"
def inject_approval_keystroke(
bridge_dir: Path, *, key: str, timeout_s: float = _TMUX_READY_TIMEOUT_S
) -> bool:
"""Answer kimi's tool-permission menu by typing an option digit + Enter.
kimi's permission prompt is a numbered select whose footer documents
``1/2/3/4 choose · ↵ confirm``; the web-UI Approve/Deny buttons map to
:data:`APPROVE_KEY` / :data:`DENY_KEY`. This types *key* then ``Enter``.
Captures the pane first and injects ONLY when the permission menu is
actually showing (:data:`_PERMISSION_PROMPT_MARKER`), so a web verdict that
lands after the user already answered in the terminal (or after the prompt
closed) is a no-op rather than a stray keystroke leaking into whatever is on
screen next.
:param bridge_dir: The kimi-native bridge dir holding ``tmux.json``.
:param key: The option digit to select (e.g. :data:`APPROVE_KEY`).
:returns: ``True`` if the keystroke was injected; ``False`` if the
permission menu was not present (already answered / closed / TUI gone).
:raises RuntimeError: If the tmux target is not advertised or send-keys fails.
"""
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
socket_path = info["socket_path"]
tmux_target = info["tmux_target"]
if not _session_alive(socket_path, tmux_target):
return False
if _PERMISSION_PROMPT_MARKER not in _capture_pane(socket_path, tmux_target):
return False
# ``key`` is a single documented option digit; Enter confirms (the footer
# lists "choose" and "confirm" separately, so a digit selects and ↵ commits).
_run_tmux(socket_path, "send-keys", "-t", tmux_target, key)
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "Enter")
return True
def kill_session(bridge_dir: Path, *, timeout_s: float = _TMUX_READY_TIMEOUT_S) -> None:
"""Hard-stop the Kimi session by killing its tmux session.
Terminates ``kimi`` and the pane outright — the analog of the
user manually exiting the attached TUI, for the web UI's "Stop session"
affordance. Mirrors :func:`omnigent.claude_native_bridge.kill_session`.
:raises RuntimeError: If the tmux target is not advertised or kill-session fails.
"""
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
_run_tmux(info["socket_path"], "kill-session", "-t", info["tmux_target"])
+125
View File
@@ -0,0 +1,125 @@
"""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
base = f"{shlex.quote(python)} -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.
return (
"\n"
"# --- Omnigent native hooks (auto-generated; do not edit) ---\n"
"[[hooks]]\n"
'event = "PreToolUse"\n'
f'command = "{pre}"\n'
"\n"
"[[hooks]]\n"
'event = "PermissionRequest"\n'
f'command = "{perm}"\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)}
+360
View File
@@ -0,0 +1,360 @@
"""Mirror a kimi-native TUI session's transcript into the Omnigent web chat.
The kimi-native harness launches the interactive ``kimi`` TUI in a tmux pane and
injects web-UI turns into it (see :mod:`omnigent.kimi_native_bridge`). The TUI's
reply renders live in the embedded terminal, but — unlike the SDK ``KimiExecutor``
— nothing flows the assistant's response back into Omnigent's conversation
transcript (the chat bubbles). This module closes that gap, the kimi analog of
:mod:`omnigent.cursor_native_forwarder`.
Data source: kimi persists each session to an append-only JSONL "wire" log at
``$KIMI_CODE_HOME/sessions/<wd_…>/<session_…>/agents/main/wire.jsonl``. The
native harness points ``KIMI_CODE_HOME`` at ``<bridge_dir>/kimi-code-home`` whose
``sessions/`` is symlinked to the user's global store, so several workspaces'
sessions share the tree; we disambiguate by ``workDir`` (via ``session_index.jsonl``)
and recency. Relevant wire events:
- ``{"type": "turn.prompt", "input": [{"type":"text","text":…}], "origin": {"kind":"user"}}``
→ a user message.
- ``{"type": "context.append_loop_event", "event": {"type": "content.part",
"part": {"type": "text", "text": …}, "uuid": …}}`` → an assistant message.
(``part.type == "think"`` is reasoning and is skipped for v1; ``tool.call`` /
``tool.result`` events are likewise skipped — the embedded terminal shows them.)
Each mirrored turn is POSTed as an ``external_conversation_item`` to
``/v1/sessions/{id}/events`` (the same shape :mod:`omnigent.kimi_native_hook`
uses for its read-only approval surface). A per-session line offset is persisted
in ``<bridge_dir>/kimi_forwarder.json`` so restarts resume without double-posting.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
from dataclasses import dataclass
from pathlib import Path
import httpx
_logger = logging.getLogger(__name__)
#: Poll cadence for new wire-log lines (matches cursor_native_forwarder).
_POLL_INTERVAL_S = 0.7
#: Persisted forwarder state (discovered wire path + high-water line count).
_STATE_FILE = "kimi_forwarder.json"
#: Clock-skew tolerance when matching a session created at/after launch.
_DISCOVER_SKEW_MS = 10_000
#: Supervisor backoff bounds.
_BACKOFF_INITIAL_S = 1.0
_BACKOFF_MAX_S = 30.0
@dataclass
class _ForwardState:
"""Durable cursor for the wire-log tail."""
wire_path: str
last_line: int
@dataclass
class _MirrorItem:
"""One conversation item to POST, plus the line index it came from."""
line_no: int
role: str
text: str
response_id: str
def clear_kimi_bridge_state(bridge_dir: Path) -> None:
"""Drop any stale forwarder state so a new terminal starts a fresh tail.
Mirrors ``cursor_native_forwarder.clear_cursor_bridge_state``: without this,
a re-created terminal would resume the prior session's line offset against a
different wire log.
"""
with contextlib.suppress(OSError):
(bridge_dir / _STATE_FILE).unlink()
def _read_state(bridge_dir: Path) -> _ForwardState | None:
try:
raw = (bridge_dir / _STATE_FILE).read_text(encoding="utf-8")
except OSError:
return None
try:
data = json.loads(raw)
except ValueError:
return None
if not isinstance(data, dict):
return None
wire_path = data.get("wire_path")
last_line = data.get("last_line")
if isinstance(wire_path, str) and isinstance(last_line, int):
return _ForwardState(wire_path=wire_path, last_line=last_line)
return None
def _write_state(bridge_dir: Path, state: _ForwardState) -> None:
payload = {"wire_path": state.wire_path, "last_line": state.last_line}
tmp = bridge_dir / (_STATE_FILE + ".tmp")
with contextlib.suppress(OSError):
tmp.write_text(json.dumps(payload), encoding="utf-8")
tmp.replace(bridge_dir / _STATE_FILE)
def _workdirs_for_sessions(kimi_home: Path) -> dict[str, str]:
"""Map each session dir → its ``workDir`` from ``session_index.jsonl``.
Returns ``{}`` when the index is absent/unreadable (a brand-new home before
kimi has written any session).
"""
index = kimi_home / "session_index.jsonl"
mapping: dict[str, str] = {}
try:
text = index.read_text(encoding="utf-8")
except OSError:
return mapping
for line in text.splitlines():
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except ValueError:
continue
if isinstance(row, dict):
session_dir = row.get("sessionDir")
work_dir = row.get("workDir")
if isinstance(session_dir, str) and isinstance(work_dir, str):
mapping[session_dir] = work_dir
return mapping
def _discover_wire(kimi_home: Path, workspace: str, launch_epoch_ms: int) -> Path | None:
"""Locate the wire log for *workspace*'s newest session created at/after launch.
Globs ``sessions/*/session_*/agents/main/wire.jsonl`` under *kimi_home*,
keeps only sessions whose ``session_index`` ``workDir`` matches *workspace*
(when the index lists them), and returns the most-recently-modified wire log
whose mtime is at/after ``launch_epoch_ms`` (minus skew). Returns ``None``
until kimi has created the session.
"""
sessions_root = kimi_home / "sessions"
if not sessions_root.exists():
return None
workdirs = _workdirs_for_sessions(kimi_home)
floor_s = (launch_epoch_ms - _DISCOVER_SKEW_MS) / 1000.0
best: tuple[float, Path] | None = None
for wire in sessions_root.glob("*/session_*/agents/main/wire.jsonl"):
# session_index keys on the session dir (…/<wd_…>/<session_…>).
session_dir = str(wire.parent.parent.parent)
work_dir = workdirs.get(session_dir)
# When the index doesn't list it yet, fall back to recency alone — a
# freshly created session may not be indexed until its first turn.
if work_dir is not None and work_dir != workspace:
continue
try:
mtime = wire.stat().st_mtime
except OSError:
continue
if mtime < floor_s:
continue
if best is None or mtime > best[0]:
best = (mtime, wire)
return best[1] if best is not None else None
def _input_text(blocks: object) -> str:
"""Concatenate the ``text`` of an ``input`` / ``content`` block list."""
if not isinstance(blocks, list):
return ""
parts: list[str] = []
for block in blocks:
if isinstance(block, dict) and block.get("type") == "text":
text = block.get("text")
if isinstance(text, str):
parts.append(text)
return "".join(parts)
def _row_to_item(line_no: int, row: dict[str, object]) -> _MirrorItem | None:
"""Map one wire-log row to a conversation item, or ``None`` to skip it."""
row_type = row.get("type")
if row_type == "turn.prompt":
origin = row.get("origin")
if isinstance(origin, dict) and origin.get("kind") != "user":
return None
text = _input_text(row.get("input"))
if not text:
return None
return _MirrorItem(
line_no=line_no,
role="user",
text=text,
response_id=f"kimi:turn:{line_no}",
)
if row_type == "context.append_loop_event":
event = row.get("event")
if not isinstance(event, dict) or event.get("type") != "content.part":
return None
part = event.get("part")
if not isinstance(part, dict) or part.get("type") != "text":
return None
text = part.get("text")
if not isinstance(text, str) or not text:
return None
uuid = event.get("uuid")
response_id = f"kimi:{uuid}" if isinstance(uuid, str) and uuid else f"kimi:line:{line_no}"
return _MirrorItem(line_no=line_no, role="assistant", text=text, response_id=response_id)
return None
def _read_new_items(wire_path: Path, last_line: int) -> list[_MirrorItem]:
"""Parse wire-log lines beyond *last_line* into conversation items.
The wire log is append-only JSONL, so a line count is a stable high-water
mark. Non-JSON / unrecognized lines advance the cursor without emitting.
"""
try:
lines = wire_path.read_text(encoding="utf-8").splitlines()
except OSError:
return []
items: list[_MirrorItem] = []
for idx in range(last_line, len(lines)):
line = lines[idx].strip()
if not line or not line.startswith("{"):
continue
try:
row = json.loads(line)
except ValueError:
continue
if not isinstance(row, dict):
continue
item = _row_to_item(idx, row)
if item is not None:
items.append(item)
return items
async def _post_conversation_item(
client: httpx.AsyncClient,
*,
base_url: str,
headers: dict[str, str],
session_id: str,
item: _MirrorItem,
agent_name: str,
) -> None:
"""POST one mirrored turn as an external conversation item."""
content_type = "input_text" if item.role == "user" else "output_text"
item_data: dict[str, object] = {
"role": item.role,
"content": [{"type": content_type, "text": item.text}],
}
if item.role == "assistant":
item_data["agent"] = agent_name
body = {
"type": "external_conversation_item",
"data": {
"item_type": "message",
"item_data": item_data,
"response_id": item.response_id,
},
}
url = f"{base_url.rstrip('/')}/v1/sessions/{session_id}/events"
resp = await client.post(url, headers=headers, json=body)
resp.raise_for_status()
async def forward_kimi_wire_to_session(
*,
base_url: str,
headers: dict[str, str],
session_id: str,
bridge_dir: Path,
kimi_home: Path,
workspace: str,
launch_epoch_ms: int,
agent_name: str = "kimi-native-ui",
) -> None:
"""Poll the kimi session wire log and mirror new turns into the chat.
Runs until cancelled. Discovers the wire log lazily (kimi writes it after the
first turn), then tails it, POSTing each new user/assistant turn and
persisting the line offset after every post.
"""
state = _read_state(bridge_dir)
wire_path = Path(state.wire_path) if state is not None else None
last_line = state.last_line if state is not None else 0
async with httpx.AsyncClient(timeout=15.0) as client:
while True:
if wire_path is None or not wire_path.exists():
discovered = await asyncio.to_thread(
_discover_wire, kimi_home, workspace, launch_epoch_ms
)
if discovered is not None and discovered != wire_path:
wire_path = discovered
last_line = 0
_write_state(bridge_dir, _ForwardState(str(wire_path), last_line))
if wire_path is not None and wire_path.exists():
items = await asyncio.to_thread(_read_new_items, wire_path, last_line)
for item in items:
try:
await _post_conversation_item(
client,
base_url=base_url,
headers=headers,
session_id=session_id,
item=item,
agent_name=agent_name,
)
except httpx.HTTPError as exc:
_logger.warning("kimi forwarder: POST failed (will retry): %s", exc)
break
last_line = item.line_no + 1
_write_state(bridge_dir, _ForwardState(str(wire_path), last_line))
await asyncio.sleep(_POLL_INTERVAL_S)
async def supervise_kimi_forwarder(
*,
base_url: str,
headers: dict[str, str],
session_id: str,
bridge_dir: Path,
kimi_home: Path,
workspace: str,
launch_epoch_ms: int,
agent_name: str = "kimi-native-ui",
) -> None:
"""Run :func:`forward_kimi_wire_to_session` with restart-on-crash backoff.
Propagates :class:`asyncio.CancelledError` cleanly (terminal teardown), but
restarts on any other exception with exponential backoff — mirrors
``cursor_native_forwarder.supervise_cursor_forwarder``.
"""
backoff = _BACKOFF_INITIAL_S
while True:
try:
await forward_kimi_wire_to_session(
base_url=base_url,
headers=headers,
session_id=session_id,
bridge_dir=bridge_dir,
kimi_home=kimi_home,
workspace=workspace,
launch_epoch_ms=launch_epoch_ms,
agent_name=agent_name,
)
except asyncio.CancelledError:
raise
except Exception:
_logger.exception("kimi forwarder crashed for session %s; restarting", session_id)
await asyncio.sleep(backoff)
backoff = min(backoff * 2, _BACKOFF_MAX_S)
else:
return
+320
View File
@@ -0,0 +1,320 @@
"""Kimi Code hook commands for the native Omnigent wrapper.
Registered into a per-session ``config.toml`` ``[[hooks]]`` array (see
:mod:`omnigent.kimi_native_credentials`) so the running ``kimi`` TUI invokes
them. Kimi spawns each hook with ``shell: true``, feeds the event JSON on
stdin, and reads the decision back from stdout as
``{"hookSpecificOutput": {"permissionDecision": ..., "permissionDecisionReason": ...}}``
(``permissionDecision == "deny"`` blocks the tool). Two subcommands:
- ``evaluate-policy`` — the ``PreToolUse`` deny-gate. Mirrors
:func:`omnigent.claude_native_hook._main_evaluate_policy`: it converts the
Kimi hook payload into an Omnigent ``EvaluationRequest`` (the snake-cased
Kimi fields ``tool_name`` / ``tool_input`` / ``hook_event_name`` line up
with :func:`omnigent.native_policy_hook.hook_payload_to_evaluation_request`),
POSTs to ``/v1/sessions/{id}/policies/evaluate``, and emits a ``deny`` only
for a constraining ``POLICY_ACTION_DENY`` verdict. ``ALLOW`` (the engine's
no-match default) emits nothing, so kimi's own in-TUI approval prompt still
runs — Omnigent enforces its deny-policy without silencing the user's
consent. Fails CLOSED (deny) when an already-governed session can't reach a
verdict, matching the claude-native gate.
- ``permission-request`` — the interactive web-UI approval. Kimi fires
``PermissionRequest`` fire-and-forget (it does NOT read this hook's output —
approval is answered by kimi's own TUI menu), so the hook cannot return an
honored decision. Instead it drives a real web-UI Approve/Deny: it POSTs the
gated tool to ``/v1/sessions/{id}/hooks/permission-request`` (the same
endpoint claude-native uses — the server publishes the approval card and
long-polls for the web verdict), then types the answer back into kimi's
prompt via ``inject_approval_keystroke`` (option digit + Enter:
:data:`~omnigent.kimi_native_bridge.APPROVE_KEY` "Approve once" /
:data:`~omnigent.kimi_native_bridge.DENY_KEY` "Reject"). Fail-safe: on no
verdict (timeout / unreachable / already answered in the terminal) it injects
nothing and kimi's own TUI prompt stands. Never blocks the TUI.
"""
from __future__ import annotations
import argparse
import json
import secrets
import sys
import urllib.parse
from pathlib import Path
import httpx
from omnigent.kimi_native_bridge import (
APPROVE_KEY,
DENY_KEY,
inject_approval_keystroke,
read_active_session_id,
read_hook_config,
)
from omnigent.native_policy_hook import (
evaluation_response_to_hook_output,
fail_closed_hook_output,
hook_payload_to_evaluation_request,
post_evaluate_with_retry,
)
# PreToolUse evaluations are normally a quick request/reply. (Unlike
# claude-native, a TOOL_CALL ASK does NOT park here — kimi owns the ask via
# its own TUI prompt, so the policy layer only ever DENY/ALLOWs for kimi.)
_EVALUATE_POLICY_TIMEOUT_S = 70.0
# Short timeout for the keystroke-injection tmux round-trip; never delay the TUI.
_SURFACE_TIMEOUT_S = 10.0
# Long-poll budget for the web approval verdict — the human may take a while.
# On timeout the server returns an empty 200 and we fall back to kimi's own TUI
# prompt (manual approval in the terminal).
_PERMISSION_REQUEST_TIMEOUT_S = 3600.0
_HARNESS = "kimi-native"
def _url_component(value: str) -> str:
"""Percent-encode one URL path component (slashes escaped)."""
return urllib.parse.quote(value, safe="")
def _headers_from_config(config: dict[str, object]) -> dict[str, str]:
"""Extract replayable auth headers from the bridge hook config."""
raw = config.get("ap_auth_headers")
if not isinstance(raw, dict):
return {}
return {str(key): str(value) for key, value in raw.items()}
def _read_stdin_payload() -> dict[str, object] | None:
"""Parse the hook event JSON from stdin; ``None`` when unusable."""
raw = sys.stdin.read()
try:
payload = json.loads(raw or "{}")
except json.JSONDecodeError as exc:
print(f"omnigent kimi hook: malformed JSON: {exc}", file=sys.stderr)
return None
if not isinstance(payload, dict):
print("omnigent kimi hook: expected JSON object", file=sys.stderr)
return None
return payload
def _main_evaluate_policy(argv: list[str]) -> int:
"""Evaluate a kimi ``PreToolUse`` hook against Omnigent policies.
Reads the hook payload from stdin, POSTs an ``EvaluationRequest`` to
``/v1/sessions/{id}/policies/evaluate``, and writes kimi's hook decision
to stdout. Only ``POLICY_ACTION_DENY`` produces a ``deny``; everything
else emits nothing ("no opinion") so kimi's own approval prompt still
fires. An already-governed session that cannot obtain a verdict fails
CLOSED with a ``deny`` (this hook is the sole Omnigent enforcement point
for kimi tool calls).
:param argv: CLI argv after the ``evaluate-policy`` subcommand.
:returns: Always ``0`` — verdicts are expressed via JSON, not exit codes.
"""
args = _parse_bridge_dir_args(argv, "evaluate-policy")
payload = _read_stdin_payload()
if payload is None:
return 0
bridge_dir = Path(args.bridge_dir)
session_id = read_active_session_id(bridge_dir)
if not session_id:
return 0 # not a governed session — no opinion
config = read_hook_config(bridge_dir)
ap_server_url = config.get("ap_server_url")
if not isinstance(ap_server_url, str) or not ap_server_url:
return 0
headers = _headers_from_config(config)
hook_event = payload.get("hook_event_name", "")
if not isinstance(hook_event, str):
return 0
eval_request = hook_payload_to_evaluation_request(hook_event, payload)
if eval_request is None:
# Unrecognized event or an mcp__omnigent__* tool already gated on the
# relay path — no policy to evaluate here.
return 0
# hook_payload_to_evaluation_request always returns an event with a
# "context" dict; index it directly (fail loud if that contract changes).
context = eval_request["event"]["context"]
context["harness"] = _HARNESS
def _fail_closed() -> int:
out = fail_closed_hook_output(hook_event)
if out is not None:
sys.stdout.write(json.dumps(out))
return 0
url = (
f"{ap_server_url.rstrip('/')}"
f"/v1/sessions/{_url_component(session_id)}/policies/evaluate"
)
resp = post_evaluate_with_retry(
url, headers, eval_request, _EVALUATE_POLICY_TIMEOUT_S, "kimi evaluate-policy hook"
)
if resp is None or not resp.content:
return _fail_closed()
try:
eval_response = resp.json()
except json.JSONDecodeError:
print("omnigent kimi evaluate-policy hook: malformed Omnigent response", file=sys.stderr)
return _fail_closed()
hook_output = evaluation_response_to_hook_output(hook_event, eval_response)
if hook_output is not None:
sys.stdout.write(json.dumps(hook_output))
return 0
def _main_permission_request(argv: list[str]) -> int:
"""Mirror a kimi ``PermissionRequest`` to the web UI and inject the verdict.
Kimi fires this hook **fire-and-forget** — it answers approval in its own
TUI and does NOT read the hook's stdout — so we cannot return a decision it
honors. Instead we drive an interactive web-UI approval and type the answer
back into kimi's prompt:
1. POST the gated tool to ``/v1/sessions/{id}/hooks/permission-request`` —
the server publishes the standard ``response.elicitation_request``
approval card and long-polls for the web verdict (the very endpoint
claude-native uses).
2. On ``allow`` / ``deny``, inject the matching kimi permission-menu option
digit + Enter into the TUI pane via :func:`inject_approval_keystroke`
(:data:`APPROVE_KEY` "Approve once" / :data:`DENY_KEY` "Reject").
Fail-safe: on no verdict (timeout / server unreachable / the prompt was
already answered in the terminal) it injects nothing and kimi's own TUI
prompt stands for manual approval. Always returns 0 (kimi ignores output).
:param argv: CLI argv after the ``permission-request`` subcommand.
:returns: Always ``0``.
"""
args = _parse_bridge_dir_args(argv, "permission-request")
payload = _read_stdin_payload()
if payload is None:
return 0
bridge_dir = Path(args.bridge_dir)
session_id = read_active_session_id(bridge_dir)
if not session_id:
return 0
config = read_hook_config(bridge_dir)
ap_server_url = config.get("ap_server_url")
if not isinstance(ap_server_url, str) or not ap_server_url:
return 0
headers = _headers_from_config(config)
tool_name = payload.get("tool_name")
if not isinstance(tool_name, str) or not tool_name:
return 0
body: dict[str, object] = {
"tool_name": tool_name,
# Stable re-attach id so a severed long-poll re-parks the SAME
# elicitation (mirrors the claude permission hook).
"_omnigent_elicitation_id": f"elicit_kimi_{secrets.token_hex(16)}",
}
tool_input = payload.get("tool_input")
if isinstance(tool_input, dict):
body["tool_input"] = tool_input
url = (
f"{ap_server_url.rstrip('/')}/v1/sessions/"
f"{_url_component(session_id)}/hooks/permission-request"
)
verdict = _request_web_approval(url, headers, body)
if verdict is None:
# No web verdict: leave kimi's own TUI prompt for manual approval.
return 0
key = APPROVE_KEY if verdict == "allow" else DENY_KEY
try:
inject_approval_keystroke(bridge_dir, key=key, timeout_s=_SURFACE_TIMEOUT_S)
except RuntimeError as exc:
print(
f"omnigent kimi permission-request hook: keystroke inject failed: {exc}",
file=sys.stderr,
)
return 0
def _request_web_approval(
url: str, headers: dict[str, str], body: dict[str, object]
) -> str | None:
"""POST the approval card and long-poll for the web verdict.
:returns: ``"allow"`` / ``"deny"``, or ``None`` on timeout (server returns
an empty 200), transport failure, or an unparseable verdict — all of
which fall back to kimi's own TUI prompt.
"""
timeout = httpx.Timeout(_PERMISSION_REQUEST_TIMEOUT_S, connect=_SURFACE_TIMEOUT_S)
try:
with httpx.Client(headers=headers, timeout=timeout) as client:
resp = client.post(url, json=body)
resp.raise_for_status()
except httpx.HTTPError as exc:
print(
f"omnigent kimi permission-request hook: approval request failed: {exc}",
file=sys.stderr,
)
return None
if not resp.content:
return None
try:
data = resp.json()
except json.JSONDecodeError:
return None
return _verdict_from_response(data)
def _verdict_from_response(data: object) -> str | None:
"""Extract ``"allow"`` / ``"deny"`` from the PermissionRequest hook response.
The endpoint returns Claude's PermissionRequest contract
(``hookSpecificOutput.decision.behavior``), with ``permissionDecision`` as a
fallback shape. Any persistent-allow variant (``allow_*``) maps to allow.
"""
if not isinstance(data, dict):
return None
hook_output = data.get("hookSpecificOutput")
if not isinstance(hook_output, dict):
return None
decision = hook_output.get("decision")
behavior = decision.get("behavior") if isinstance(decision, dict) else None
raw = behavior if isinstance(behavior, str) else hook_output.get("permissionDecision")
if not isinstance(raw, str):
return None
low = raw.lower()
if low.startswith("allow") or low in ("approve", "approved", "accept"):
return "allow"
if low in ("deny", "reject", "rejected", "block"):
return "deny"
return None
def _parse_bridge_dir_args(argv: list[str], prog: str) -> argparse.Namespace:
"""Parse the shared ``--bridge-dir`` argument for a hook subcommand."""
parser = argparse.ArgumentParser(prog=f"omnigent.kimi_native_hook {prog}")
parser.add_argument("--bridge-dir", required=True)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
"""Dispatch a kimi hook subcommand.
:param argv: Process argv tail (defaults to ``sys.argv[1:]``).
:returns: Process exit code.
"""
args = list(sys.argv[1:] if argv is None else argv)
if not args:
print("usage: kimi_native_hook {evaluate-policy|permission-request} ...", file=sys.stderr)
return 2
subcommand, rest = args[0], args[1:]
if subcommand == "evaluate-policy":
return _main_evaluate_policy(rest)
if subcommand == "permission-request":
return _main_permission_request(rest)
print(f"omnigent kimi hook: unknown subcommand {subcommand!r}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+8
View File
@@ -99,6 +99,14 @@ _PROVIDER_RESOLUTION_HARNESS: dict[str, str] = {
"antigravity": "antigravity",
"agy": "antigravity",
"google-antigravity": "antigravity",
# Kimi Code CLI is multi-provider; it shares no resolution path with an
# existing harness. The identity entry keeps callers that iterate this
# map (e.g. ``list_models_for_worker``) finding the harness so they
# don't fall through to a noisy "unknown harness" branch.
"kimi": "kimi",
"kimi-code": "kimi",
# Native Kimi TUI harness shares the multi-provider kimi resolution path.
"kimi-native": "kimi",
"qwen": "qwen",
}
+1 -1
View File
@@ -26,7 +26,7 @@ _MODEL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/\[\]-]*$")
# SDK harnesses whose model override lands in the spawn env — must stay
# in sync with ``_HARNESS_MODEL_ENV_KEY`` in ``omnigent/runner/app.py``.
_SDK_MODEL_OVERRIDE_HARNESSES: frozenset[str] = frozenset(
{"claude-sdk", "codex", "pi", "openai-agents", "cursor", "antigravity", "qwen"}
{"claude-sdk", "codex", "pi", "openai-agents", "cursor", "antigravity", "kimi", "qwen"}
)
+11
View File
@@ -8,6 +8,7 @@ from omnigent._wrapper_labels import (
CLAUDE_NATIVE_WRAPPER_VALUE,
CODEX_NATIVE_WRAPPER_VALUE,
CURSOR_NATIVE_WRAPPER_VALUE,
KIMI_NATIVE_WRAPPER_VALUE,
PI_NATIVE_WRAPPER_VALUE,
UI_MODE_LABEL_KEY,
UI_MODE_TERMINAL_VALUE,
@@ -75,11 +76,21 @@ CURSOR_NATIVE_CODING_AGENT = NativeCodingAgent(
terminal_name="cursor",
)
KIMI_NATIVE_CODING_AGENT = NativeCodingAgent(
key="kimi",
display_name="Kimi",
agent_name="kimi-native-ui",
harness="kimi-native",
wrapper_label=KIMI_NATIVE_WRAPPER_VALUE,
terminal_name="kimi",
)
NATIVE_CODING_AGENTS: tuple[NativeCodingAgent, ...] = (
CLAUDE_NATIVE_CODING_AGENT,
CODEX_NATIVE_CODING_AGENT,
PI_NATIVE_CODING_AGENT,
CURSOR_NATIVE_CODING_AGENT,
KIMI_NATIVE_CODING_AGENT,
)
_BY_AGENT_NAME = {agent.agent_name: agent for agent in NATIVE_CODING_AGENTS}
+29 -1
View File
@@ -56,6 +56,11 @@ QWEN_KEY = "qwen"
# installer rather than npm — so it carries an ``install_hint``, not a ``package``.
CURSOR_KEY = "cursor"
# Kimi authenticates against Moonshot AI's backend (``kimi login`` OAuth or a
# Moonshot API key), not via the ambient provider config; like Cursor it ships
# via a curl installer rather than npm, so it carries an ``install_hint``.
KIMI_KEY = "kimi"
@dataclass(frozen=True)
class HarnessInstallSpec:
@@ -141,6 +146,21 @@ _HARNESS_INSTALL: dict[str, HarnessInstallSpec] = {
install_hint="curl https://cursor.com/install -fsS | bash",
login_status_key="isAuthenticated",
),
# Kimi Code CLI ships a single-binary ``kimi`` via a curl installer (no
# npm). ``kimi login`` is the interactive provider login (OAuth or a
# Moonshot API key). ``status_args`` is intentionally ``None``: kimi has
# no first-class "am I logged in?" exit-code probe — login state is
# only inspected interactively. With ``None`` the login path runs every
# time the operator asks for it (interactive, so they can cancel if
# already authenticated).
KIMI_KEY: HarnessInstallSpec(
"Kimi",
"kimi",
package=None,
login_args=("login",),
logout_args=("logout",),
install_hint="curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash",
),
}
@@ -162,8 +182,16 @@ _HARNESS_NAME_TO_KEY: dict[str, str] = {
"codex-native": OPENAI_FAMILY,
PI_KEY: PI_KEY,
"pi-native": PI_KEY,
# Kimi is multi-provider but binary-gated: cannot launch without the
# ``kimi`` CLI on PATH. Listed here so ``required_cli_for_harness``
# returns its install spec and ``missing_harness_cli`` fails loud
# before a subagent spawn.
KIMI_KEY: KIMI_KEY,
"cursor-native": CURSOR_KEY,
"native-cursor": CURSOR_KEY,
# Native Kimi TUI harness — same binary gate as the bare ``kimi`` surface.
"kimi-native": KIMI_KEY,
"native-kimi": KIMI_KEY,
QWEN_KEY: QWEN_KEY,
"qwen-code": QWEN_KEY,
}
@@ -255,7 +283,7 @@ def harness_cli_installed(key: str) -> bool:
``claude-sdk`` harness can run without the ``claude`` CLI.
:param key: A harness family (``"anthropic"`` / ``"openai"``) or
:data:`PI_KEY`.
:data:`PI_KEY` / :data:`KIMI_KEY`.
:returns: ``True`` when the CLI is on ``PATH``; ``False`` when it isn't or
the key has no associated CLI.
"""
+28 -3
View File
@@ -27,7 +27,13 @@ from __future__ import annotations
import os
from omnigent.harness_aliases import HARNESS_ALIASES, canonicalize_harness
from omnigent.onboarding.harness_install import CURSOR_KEY, PI_KEY, QWEN_KEY, harness_cli_installed
from omnigent.onboarding.harness_install import (
CURSOR_KEY,
KIMI_KEY,
PI_KEY,
QWEN_KEY,
harness_cli_installed,
)
from omnigent.onboarding.provider_config import (
_EXECUTOR_TYPE_HARNESS_ALIASES,
_HARNESS_FAMILY,
@@ -50,6 +56,11 @@ _SDK_HARNESSES: frozenset[str] = frozenset(
# be gated explicitly or they fail open like an unknown harness.
_PI_HARNESSES: frozenset[str] = frozenset({PI_SURFACE, "pi-native"})
# Surface name for Kimi Code in the readiness map. Mirrors :data:`PI_SURFACE`
# — kimi is a CLI-backed harness with its own backend (Moonshot AI's), not a
# member of the anthropic/openai families that :data:`_HARNESS_FAMILY` keys.
KIMI_SURFACE = "kimi"
# Native Cursor harnesses. These boot the ``cursor-agent`` TUI (``omni cursor``)
# and so, like the other native CLI harnesses, can't launch without that binary
# on ``PATH`` — gate them on it. Distinct from the SDK ``cursor`` harness
@@ -58,6 +69,12 @@ _PI_HARNESSES: frozenset[str] = frozenset({PI_SURFACE, "pi-native"})
# unknown harness, letting a binary-less launch die inside the executor.
_CURSOR_NATIVE_HARNESSES: frozenset[str] = frozenset({"cursor-native", "native-cursor"})
# Native Kimi TUI harnesses (``omnigent kimi``). Like the other native CLIs,
# they wrap the resident ``kimi`` binary and can't launch without it on
# ``PATH`` — gate on it. Distinct from the bare ``kimi`` SDK surface
# (:data:`KIMI_SURFACE`), which gates on the same binary but renders headlessly.
_KIMI_NATIVE_HARNESSES: frozenset[str] = frozenset({"kimi-native", "native-kimi"})
# CLI-wrapping qwen harnesses. Both ``qwen`` and ``qwen-code`` resolve to the
# same ``qwen`` binary (canonicalize_harness folds ``qwen-code`` → ``qwen``).
# Unlike claude/codex they have no ``_HARNESS_FAMILY`` entry, so they must
@@ -86,11 +103,15 @@ def _install_key(canonical: str) -> str:
"""Return the install-spec key whose CLI binary *canonical* requires.
:param canonical: A canonical CLI-wrapping harness id keyed in
``_HARNESS_FAMILY`` (e.g. ``"codex-native"``), or ``"pi"``.
``_HARNESS_FAMILY`` (e.g. ``"codex-native"``), ``"pi"``, or
``"kimi"``.
:returns: ``"anthropic"`` / ``"openai"`` for the claude/codex CLIs,
:data:`~omnigent.onboarding.harness_install.PI_KEY` for pi, or
:data:`~omnigent.onboarding.harness_install.PI_KEY` for pi,
:data:`~omnigent.onboarding.harness_install.KIMI_KEY` for kimi, or
:data:`~omnigent.onboarding.harness_install.QWEN_KEY` for qwen.
"""
if canonical == KIMI_SURFACE or canonical in _KIMI_NATIVE_HARNESSES:
return KIMI_KEY
if canonical in _QWEN_HARNESSES:
return QWEN_KEY
return _HARNESS_FAMILY.get(canonical) or PI_KEY
@@ -143,6 +164,8 @@ def harness_is_configured(harness: str) -> bool:
if (
canonical not in _HARNESS_FAMILY
and canonical not in _PI_HARNESSES
and canonical != KIMI_SURFACE
and canonical not in _KIMI_NATIVE_HARNESSES
and canonical not in _QWEN_HARNESSES
):
# Unknown harness — the daemon has no install metadata for it, so
@@ -170,6 +193,8 @@ def configured_harness_map() -> dict[str, bool]:
spellings.update(HARNESS_ALIASES)
spellings.update(_PI_HARNESSES)
spellings.update(_CURSOR_NATIVE_HARNESSES)
spellings.update(_KIMI_NATIVE_HARNESSES)
spellings.update(_QWEN_HARNESSES)
spellings.add(CURSOR_KEY)
spellings.add(KIMI_SURFACE)
return {spelling: harness_is_configured(spelling) for spelling in spellings}
+5 -1
View File
@@ -136,7 +136,11 @@ _HARNESS_FAMILY: dict[str, str] = {
# Antigravity is Gemini-native but routes generic-provider traffic over
# the OpenAI-compatible wire, so it consumes the ``openai`` family.
"antigravity": OPENAI_FAMILY,
# Qwen Code uses an OpenAI-compatible provider (like Kimi v1).
# NB: ``kimi`` is intentionally absent. Upstream Kimi Code CLI has no
# per-spawn provider override flag, so Omnigent cannot thread a generic
# provider through. Provider routing for kimi lives in ``~/.kimi/config.toml``
# and is managed out-of-band via ``kimi provider add``.
# Qwen Code uses an OpenAI-compatible provider.
"qwen": OPENAI_FAMILY,
}
+9
View File
@@ -252,6 +252,15 @@ def _dispatch_wrapper(
cursor_args=(),
)
return True
if native_agent.key == "kimi":
from omnigent.kimi_native import run_kimi_native
run_kimi_native(
server=server,
session_id=session_id,
kimi_args=(),
)
return True
return False
+342 -1
View File
@@ -57,6 +57,7 @@ from omnigent.runner.resource_registry import (
CLAUDE_NATIVE_TERMINAL_ROLE,
CODEX_NATIVE_TERMINAL_ROLE,
CURSOR_NATIVE_TERMINAL_ROLE,
KIMI_NATIVE_TERMINAL_ROLE,
OMNIGENT_REPL_TERMINAL_ROLE,
PI_NATIVE_TERMINAL_ROLE,
SessionResourceRegistry,
@@ -1017,6 +1018,161 @@ async def _auto_create_cursor_terminal(
return terminal_view
async def _auto_create_kimi_terminal(
session_id: str,
resource_registry: SessionResourceRegistry,
publish_event: Callable[[str, dict[str, Any]], None],
*,
server_client: httpx.AsyncClient | None,
ensure_comment_relay: Callable[..., Awaitable[None]] | None = None,
agent_spec: AgentSpec | ResolvedSpec | None = None,
) -> SessionResourceView:
"""
Auto-create the Kimi TUI terminal for a kimi-native session.
Launches ``kimi`` (no args interactive TUI) in a runner-owned tmux pane,
then advertises the pane's tmux socket+target so the kimi-native harness
executor can inject web-UI turns into the same pane (tmux paste).
The pane runs with a session-scoped ``KIMI_CODE_HOME`` (built by
:func:`omnigent.kimi_native_credentials.build_kimi_session_home`) that
mirrors the user's global ``kimi login`` (symlinked ``oauth`` / providers)
and adds the Omnigent tool-policy hooks a ``PreToolUse`` deny-gate and a
``PermissionRequest`` read-only surface dispatched to
:mod:`omnigent.kimi_native_hook`. The hook subprocess reads its routing
from ``hook_config.json`` in the bridge dir.
A background forwarder (:func:`omnigent.kimi_native_forwarder.
supervise_kimi_forwarder`) tails kimi's per-session ``wire.jsonl`` transcript
and mirrors each user prompt + assistant reply into the Omnigent chat, so the
response shows in the web UI not only the embedded terminal. Tool calls and
reasoning are NOT mirrored (the embedded terminal renders those); see
docs/KIMI_FOLLOWUPS.md. NO MCP plumbing (upstream kimi has no per-spawn MCP
config).
:param session_id: Session/conversation identifier.
:param resource_registry: Session resource registry for launching the
terminal.
:param publish_event: Runner session event publisher.
:param server_client: Runner Omnigent server client (used only for the
workspace snapshot read).
:param ensure_comment_relay: Unused; kept for call-site parity with the
other native auto-create helpers.
:param agent_spec: Unused for now (model pinning via the kimi TUI is a
follow-up); kept for call-site parity.
:returns: Created terminal resource view.
"""
del ensure_comment_relay, agent_spec
from omnigent.inner.datamodel import OSEnvSpec, TerminalEnvSpec
from omnigent.kimi_native import resolve_kimi_executable
from omnigent.kimi_native_bridge import (
bridge_dir_for_session_id,
write_hook_config,
write_tmux_target,
)
from omnigent.kimi_native_credentials import build_kimi_session_home
from omnigent.kimi_native_forwarder import clear_kimi_bridge_state, supervise_kimi_forwarder
from omnigent.runner._entry import _make_auth_token_factory
bridge_dir = bridge_dir_for_session_id(session_id)
# Stamp launch time before the TUI starts so the forwarder only adopts a kimi
# session created for THIS launch. Tear down any prior forwarder + its line
# offset so a re-created terminal tails the fresh wire log (mirrors cursor).
launch_epoch_ms = int(time.time() * 1000)
await _cancel_auto_forwarder_task(session_id)
clear_kimi_bridge_state(bridge_dir)
# ``_pi_native_launch_config`` is a generic session-snapshot reader
# (workspace + terminal_launch_args); reused here, not Pi-specific.
launch_config = await _pi_native_launch_config(
session_id=session_id,
server_client=server_client,
)
workspace = os.path.realpath(str(launch_config.workspace))
kimi_command = resolve_kimi_executable()
# No subcommand: bare ``kimi`` launches the interactive TUI. Pass-through
# launch args (``omnigent kimi -- <args>``) are persisted on the session
# snapshot and threaded here.
kimi_args = list(launch_config.terminal_launch_args or [])
# Wire the Omnigent tool-policy hooks: kimi reads a single
# ``$KIMI_CODE_HOME/config.toml``, so point it at a session-scoped home that
# mirrors the user's global kimi config (symlinked auth) plus a PreToolUse
# deny-gate and a PermissionRequest read-only surface, both dispatched to
# ``omnigent.kimi_native_hook``. The hook subprocess reads the server URL +
# auth + session id from ``hook_config.json`` in the bridge dir, so persist
# those first. The hook gets a one-shot token snapshot (a quick
# request/reply, like claude-native's permission hook); ``None`` factory is
# a safe no-op for local unauthenticated runs.
server_url = os.environ.get("RUNNER_SERVER_URL", "http://localhost:6767").rstrip("/")
_auth_factory = _make_auth_token_factory()
_auth_token = _auth_factory() if _auth_factory is not None else None
_runner_headers = {"Authorization": f"Bearer {_auth_token}"} if _auth_token else {}
write_hook_config(
bridge_dir,
server_url=server_url,
headers=_runner_headers,
session_id=session_id,
)
kimi_env = build_kimi_session_home(
bridge_dir / "kimi-code-home",
bridge_dir=bridge_dir,
)
terminal_view = await resource_registry.launch_required_terminal(
session_id=session_id,
terminal_name="kimi",
session_key="main",
resource_role=KIMI_NATIVE_TERMINAL_ROLE,
spec=TerminalEnvSpec(
os_env=OSEnvSpec(type="caller_process", cwd=workspace),
command=kimi_command,
args=kimi_args,
env=kimi_env,
scrollback=100_000,
tmux_allow_passthrough=True,
tmux_start_on_attach=False,
),
)
# Advertise the tmux socket+target so the kimi-native harness executor can
# inject web-UI messages into this same pane (tmux paste), wiring the web
# chat box to the running TUI.
terminal_registry = resource_registry.terminal_registry
if terminal_registry is not None:
instance = terminal_registry.get(session_id, "kimi", "main")
if instance is not None and instance.running:
write_tmux_target(
bridge_dir,
socket_path=instance.socket_path,
tmux_target=instance.tmux_target,
)
publish_event(
session_id,
{
"type": "session.resource.created",
"resource": session_resource_view_to_dict(terminal_view),
},
)
# Mirror the kimi TUI transcript into the Omnigent chat: tail the per-session
# wire.jsonl and POST each user/assistant turn, so the reply renders in the
# web UI (not just the embedded pane). Reuses the shared auto-forwarder
# registry so terminal teardown / stop cancels it.
_forwarder_task = asyncio.create_task(
supervise_kimi_forwarder(
base_url=server_url,
headers=_runner_headers,
session_id=session_id,
bridge_dir=bridge_dir,
kimi_home=bridge_dir / "kimi-code-home",
workspace=workspace,
launch_epoch_ms=launch_epoch_ms,
),
name=f"kimi-forwarder-{session_id}",
)
_register_auto_forwarder_task(session_id, _forwarder_task)
_logger.info("Auto-created kimi terminal + forwarder for session %s", session_id)
return terminal_view
async def _auto_create_codex_terminal(
session_id: str,
resource_registry: SessionResourceRegistry,
@@ -4602,6 +4758,7 @@ def create_runner_app(
_codex_terminal_ensure_locks: dict[str, asyncio.Lock] = {}
_pi_terminal_ensure_locks: dict[str, asyncio.Lock] = {}
_cursor_terminal_ensure_locks: dict[str, asyncio.Lock] = {}
_kimi_terminal_ensure_locks: dict[str, asyncio.Lock] = {}
# Per-session lock guarding the claude-native terminal auto-create in
# ``create_session``. Two ``POST /v1/sessions`` calls can land
# concurrently on a host-launched runner — ``_on_runner_connect``
@@ -5458,6 +5615,10 @@ def create_runner_app(
from omnigent.cursor_native_bridge import build_cursor_native_spawn_env
spawn_env = build_cursor_native_spawn_env(session_id)
if harness_name == "kimi-native" and spawn_env is None:
from omnigent.kimi_native_bridge import build_kimi_native_spawn_env
spawn_env = build_kimi_native_spawn_env(session_id)
_session_spec_cache[session_id] = spec_entry
from omnigent.llms.context_window import get_model_context_window
from omnigent.runtime.workflow import _resolve_spec_model
@@ -5815,6 +5976,44 @@ def create_runner_app(
finally:
_publish_terminal_pending(_publish_event, session_id, False)
if harness_name == "kimi-native":
_kimi_ensure_lock = _kimi_terminal_ensure_locks.setdefault(
session_id, asyncio.Lock()
)
async with _kimi_ensure_lock:
_tr = resource_registry.terminal_registry
_has_kimi_terminal = (
_tr is not None and _tr.get(session_id, "kimi", "main") is not None
)
if not _has_kimi_terminal:
_publish_terminal_pending(_publish_event, session_id, True)
try:
try:
_kimi_spec = await _resolve_session_agent_spec(session_id)
except OmnigentError:
_kimi_spec = None
await _auto_create_kimi_terminal(
session_id,
resource_registry,
_publish_event,
server_client=server_client,
ensure_comment_relay=_ensure_comment_relay_started,
agent_spec=_kimi_spec,
)
except Exception as exc:
_logger.exception(
"Failed to auto-create kimi terminal for %s",
session_id,
)
_publish_native_terminal_start_error(
_publish_event,
session_id,
"Kimi",
exc,
)
finally:
_publish_terminal_pending(_publish_event, session_id, False)
# Auto-bootstrap the Omnigent REPL terminal for non-native
# (SDK-harness) top-level sessions: host the framework's own TUI
# (``omnigent attach``) in a tmux pane so the web UI can embed it
@@ -6083,6 +6282,7 @@ def create_runner_app(
_claude_terminal_ensure_locks.pop(session_id, None)
_pi_terminal_ensure_locks.pop(session_id, None)
_cursor_terminal_ensure_locks.pop(session_id, None)
_kimi_terminal_ensure_locks.pop(session_id, None)
_repl_terminal_ensure_locks.pop(session_id, None)
_interrupted_sessions.discard(session_id)
@@ -6810,7 +7010,12 @@ def create_runner_app(
# source — fall through and publish. Suppress only once we positively
# know the harness/edge is terminal-owned.
harness = _session_harness_name(conv_id)
if status != "failed" and harness in {"claude-native", "pi-native", "cursor-native"}:
if status != "failed" and harness in {
"claude-native",
"pi-native",
"cursor-native",
"kimi-native",
}:
return
if status == "idle" and harness == "codex-native":
return
@@ -7522,6 +7727,80 @@ def create_runner_app(
)
return Response(status_code=204)
async def _handle_kimi_native_interrupt(conv_id: str) -> Response:
"""Cancel the in-flight kimi turn by sending ``Escape`` to its TUI pane.
kimi-native turns run inside the kimi TUI; the runner harness task
returns right after the tmux paste, so the in-process cancel floor has
nothing to cancel. ``Escape`` stops a running kimi turn.
:param conv_id: Session/conversation identifier.
:returns: 204 when Escape was sent; 503 if the tmux target is unavailable.
"""
from omnigent.kimi_native_bridge import bridge_dir_for_session_id, inject_interrupt
try:
await asyncio.to_thread(
inject_interrupt, bridge_dir_for_session_id(conv_id), timeout_s=1.0
)
except RuntimeError as exc:
return JSONResponse(
status_code=503,
content={
"error": "kimi_native_interrupt_failed",
"detail": _client_safe_error_detail(exc, context="kimi-native interrupt"),
},
)
_wake_parent_after_native_interrupt(conv_id)
return Response(status_code=204)
async def _handle_kimi_native_stop(conv_id: str) -> Response:
"""Hard-stop a kimi-native session by killing its tmux session.
Mirrors :func:`_handle_cursor_native_stop`: kill the pane (ends kimi),
cancel the transcript forwarder (the chat store is now frozen nothing
left to mirror), tear the terminal resource down so the web UI stops
showing a live terminal, publish ``idle`` so the spinner clears, and
reclaim any sub-agent work entry.
:param conv_id: Session/conversation identifier.
:returns: 204 on success; 503 if the tmux target is unavailable.
"""
from omnigent.kimi_native_bridge import bridge_dir_for_session_id, kill_session
try:
await asyncio.to_thread(
kill_session, bridge_dir_for_session_id(conv_id), timeout_s=1.0
)
except RuntimeError as exc:
return JSONResponse(
status_code=503,
content={
"error": "kimi_native_stop_failed",
"detail": _client_safe_error_detail(exc, context="kimi-native stop"),
},
)
await _teardown_session_terminals(conv_id)
# Stop mirroring: the wire log is now frozen, so cancel the forwarder so
# it isn't left polling a dead session.
await _cancel_auto_forwarder_task(conv_id)
_publish_event(conv_id, {"type": "session.status", "status": "idle"})
delivery_ack = _mark_subagent_terminal_and_wake(
conv_id,
status="cancelled",
output="[System: sub-agent stopped]",
)
if not delivery_ack.delivered and (
delivery_ack.entry is not None or conv_id in _session_sub_agent_names
):
_logger.warning(
"Kimi-native stop succeeded but sub-agent delivery was "
"not confirmed; session=%s reason=%s",
conv_id,
delivery_ack.reason,
)
return Response(status_code=204)
async def _handle_claude_native_effort_change(
conv_id: str,
effort: str | None,
@@ -9548,6 +9827,10 @@ def create_runner_app(
from omnigent.cursor_native_bridge import build_cursor_native_spawn_env
spawn_env = build_cursor_native_spawn_env(conv_id)
if harness_name == "kimi-native" and spawn_env is None:
from omnigent.kimi_native_bridge import build_kimi_native_spawn_env
spawn_env = build_kimi_native_spawn_env(conv_id)
agent_version = dispatch.agent_version if dispatch else body.get("agent_version")
if agent_version is not None and conv_id in _version_cache:
@@ -10464,6 +10747,9 @@ def create_runner_app(
if _harness == "cursor-native":
# cursor turn lives in the cursor-agent TUI; send Escape to stop it.
return await _handle_cursor_native_interrupt(conversation_id)
if _harness == "kimi-native":
# kimi turn lives in the kimi TUI; send Escape to stop it.
return await _handle_kimi_native_interrupt(conversation_id)
# In-process harness: mark interrupted, forward an interrupt to the
# harness, and force-cancel the runner turn task so the turn ends
# promptly even if the harness can't honor the interrupt in time.
@@ -10534,6 +10820,9 @@ def create_runner_app(
if _harness == "cursor-native":
# Hard-kill the cursor-agent tmux pane (the TUI is the runtime).
return await _handle_cursor_native_stop(conversation_id)
if _harness == "kimi-native":
# Hard-kill the kimi tmux pane (the TUI is the runtime).
return await _handle_kimi_native_stop(conversation_id)
await _cancel_inprocess_turn(conversation_id)
return Response(status_code=204)
@@ -11169,6 +11458,49 @@ def create_runner_app(
content=session_resource_view_to_dict(terminal_view),
)
if (
body.get("ensure_native_terminal")
and terminal_name == "kimi"
and session_key == "main"
):
kimi_terminal_id = terminal_resource_id("kimi", "main")
ensure_lock = _kimi_terminal_ensure_locks.setdefault(session_id, asyncio.Lock())
async with ensure_lock:
existing = await resource_registry.get_terminal_resource(
session_id, kimi_terminal_id
)
if existing is not None:
return JSONResponse(
status_code=200,
content=session_resource_view_to_dict(existing),
)
try:
# The spec only feeds optional model injection (a follow-up),
# so a resolution failure must not block launching the
# terminal — fall back to None like the cursor/Pi paths.
try:
kimi_agent_spec = await _resolve_session_agent_spec(session_id)
except OmnigentError:
kimi_agent_spec = None
terminal_view = await _auto_create_kimi_terminal(
session_id,
resource_registry,
_publish_event,
server_client=server_client,
ensure_comment_relay=_ensure_comment_relay_started,
agent_spec=kimi_agent_spec,
)
except Exception as exc:
_logger.exception(
"Kimi terminal ensure failed for session=%s",
session_id,
)
return _native_terminal_start_error_response(exc, "Kimi")
return JSONResponse(
status_code=200,
content=session_resource_view_to_dict(terminal_view),
)
from omnigent.inner.datamodel import OSEnvSpec, TerminalEnvSpec
cwd_override = body.get("cwd")
@@ -12644,6 +12976,7 @@ def create_runner_app(
_claude_terminal_ensure_locks.pop(session_id, None)
_pi_terminal_ensure_locks.pop(session_id, None)
_cursor_terminal_ensure_locks.pop(session_id, None)
_kimi_terminal_ensure_locks.pop(session_id, None)
_repl_terminal_ensure_locks.pop(session_id, None)
await resource_registry.cleanup_session(session_id)
return JSONResponse(
@@ -12697,6 +13030,7 @@ def create_runner_app(
_claude_terminal_ensure_locks.pop(session_id, None)
_pi_terminal_ensure_locks.pop(session_id, None)
_cursor_terminal_ensure_locks.pop(session_id, None)
_kimi_terminal_ensure_locks.pop(session_id, None)
_repl_terminal_ensure_locks.pop(session_id, None)
# Close terminals with ``session.resource.deleted`` events BEFORE
# cleanup_session — cleanup_conversation would silently pop them
@@ -13466,6 +13800,10 @@ _HARNESS_MODEL_ENV_KEY: dict[str, str] = {
# (claude-native, codex-native) it honors the spec model via a launch
# ``--model`` arg in _auto_create_cursor_terminal, not via an env var.
"antigravity": "HARNESS_ANTIGRAVITY_MODEL",
# Kimi reads ``HARNESS_KIMI_MODEL`` in
# :mod:`omnigent.inner.kimi_executor`; without this mapping a per-session
# ``/model`` override would silently drop on the kimi harness path.
"kimi": "HARNESS_KIMI_MODEL",
"qwen": "HARNESS_QWEN_MODEL",
}
@@ -13498,6 +13836,7 @@ def _build_spawn_env_from_spec(
_build_claude_sdk_spawn_env,
_build_codex_spawn_env,
_build_cursor_spawn_env,
_build_kimi_spawn_env,
_build_openai_agents_sdk_spawn_env,
_build_pi_spawn_env,
_build_qwen_spawn_env,
@@ -13515,6 +13854,8 @@ def _build_spawn_env_from_spec(
env = _build_cursor_spawn_env(spec, workdir=workdir)
elif harness == "antigravity":
env = _build_antigravity_spawn_env(spec)
elif harness == "kimi":
env = _build_kimi_spawn_env(spec, workdir=workdir)
elif harness == "qwen":
env = _build_qwen_spawn_env(spec, workdir=workdir)
else:
+5
View File
@@ -51,6 +51,7 @@ CODEX_NATIVE_TERMINAL_ROLE = "codex-native"
CLAUDE_NATIVE_TERMINAL_ROLE = "claude-native"
PI_NATIVE_TERMINAL_ROLE = "pi-native"
CURSOR_NATIVE_TERMINAL_ROLE = "cursor-native"
KIMI_NATIVE_TERMINAL_ROLE = "kimi-native"
# Role marker for the embedded Omnigent REPL terminal auto-created for
# runner-hosted SDK sessions (``omnigent attach`` in a tmux pane — the
# SDK mirror of the native terminals above). The attach WebSocket uses
@@ -961,6 +962,10 @@ class SessionResourceRegistry:
# after the paste), so — like pi/claude — the PTY watcher is its only
# status source. Without this the web "Working…" badge never clears.
CURSOR_NATIVE_TERMINAL_ROLE,
# kimi-native also has no forwarder/hook (the injection run_turn
# returns right after the tmux paste), so the PTY watcher is its
# only running/idle status source — same as cursor/pi/claude.
KIMI_NATIVE_TERMINAL_ROLE,
}
if activity_publisher is None and not emit_status and exit_publisher is None:
return
+13
View File
@@ -62,11 +62,24 @@ _HARNESS_MODULES: dict[str, str] = {
# cursor harness wrap (Cursor's ``cursor-agent`` CLI, headless). See
# omnigent/inner/cursor_harness.py.
"cursor": "omnigent.inner.cursor_harness",
# Kimi Code CLI harness wrap (Moonshot AI's ``kimi`` CLI, headless). See
# omnigent/inner/kimi_harness.py. Drives ``kimi --print --output-format
# stream-json`` per turn; resumes via ``--session <uuid>`` captured from
# the prior turn's stderr.
"kimi": "omnigent.inner.kimi_harness",
# User-facing alias matching the upstream product name ("Kimi Code").
"kimi-code": "omnigent.inner.kimi_harness",
# cursor-native harness wrap. Drives the resident ``cursor-agent`` TUI by
# injecting each web-UI turn into its tmux pane and mirroring the transcript
# back — a native-CLI harness like claude/codex/pi-native, so it IS in
# ``NATIVE_HARNESSES``. See omnigent/inner/cursor_native_harness.py.
"cursor-native": "omnigent.inner.cursor_native_harness",
# Native Kimi Code TUI bridge used by ``omnigent kimi``. Drives the resident
# ``kimi`` TUI by injecting each web-UI turn into its tmux pane (tmux paste)
# — a native-CLI harness like claude/codex/cursor-native, so it IS in
# ``NATIVE_HARNESSES``. Distinct from the headless ``kimi`` SDK harness
# above. See omnigent/inner/kimi_native_harness.py.
"kimi-native": "omnigent.inner.kimi_native_harness",
# Google Antigravity SDK harness wrap. See
# omnigent/inner/antigravity_harness.py. In-process SDK harness
# (``google-antigravity``), like openai-agents — Omnigent spawns no CLI
+71 -1
View File
@@ -140,7 +140,9 @@ _logger = logging.getLogger(__name__)
# (hyphen), e.g. ``"claude_sdk"`` → ``"claude-sdk"`` used by ``_HARNESS_MODULES``.
AgentHarnessType = Literal["claude-sdk", "codex", "pi", "openai-agents-sdk", "antigravity", "qwen"]
AgentHarnessType = Literal[
"claude-sdk", "codex", "pi", "openai-agents-sdk", "antigravity", "kimi", "qwen"
]
@dataclass(frozen=True)
@@ -431,6 +433,10 @@ _HARNESS_DATABRICKS_PROFILE: dict[AgentHarnessType, str] = {
"openai-agents-sdk": "HARNESS_OPENAI_AGENTS_DATABRICKS_PROFILE",
"qwen": "HARNESS_QWEN_DATABRICKS_PROFILE",
# NB: no ``antigravity`` — it has no Databricks/gateway path (Gemini-native).
# NB: no ``kimi`` — upstream kimi has no per-spawn provider override flag,
# so Omnigent cannot thread a Databricks gateway through. Users configure
# providers via ``kimi provider add`` in ``~/.kimi/config.toml`` (see
# docs/KIMI_FOLLOWUPS.md for the deferred provider-injection follow-up).
}
@@ -615,6 +621,21 @@ def configure_agent_harness_with_provider(
# the Databricks profile (Databricks-specific, used by the executor
# for token refresh), then delegate gateway enrichment to ucode.
profile = entry.profile
if harness_type == "kimi":
# Kimi has no per-spawn provider override (no ``--config-file``
# on the upstream binary). Provider routing lives in
# ``~/.kimi/config.toml`` and is managed out-of-band via
# ``kimi provider add``. Fail loud so the user understands why
# their Databricks auth didn't take effect rather than silently
# routing through whatever default kimi already had.
raise OmnigentError(
"The 'kimi' harness does not support per-invocation Databricks "
"routing. Run `kimi provider add` once to configure your "
"Databricks provider in ~/.kimi/config.toml, then declare the "
"kimi-side model in the agent spec. See docs/KIMI_FOLLOWUPS.md "
"for the deferred Omnigent-side provider injection work.",
code=ErrorCode.INVALID_INPUT,
)
flag = _HARNESS_GATEWAY_FLAG.get(harness_type)
if flag is not None:
env[flag] = "true"
@@ -627,6 +648,20 @@ def configure_agent_harness_with_provider(
if harness_type == "pi":
_apply_provider_to_pi(env, entry)
return
if harness_type == "kimi":
# Same reasoning as the Databricks branch above: upstream kimi has no
# per-spawn provider override, so an inline-family provider on the
# spec cannot be threaded through. Fail loud rather than emit gateway
# env vars the executor no longer reads.
raise OmnigentError(
"The 'kimi' harness does not support per-invocation generic "
"providers (kimi has no ``--config-file`` flag). Configure the "
"provider once via `kimi provider add` in ~/.kimi/config.toml, "
"then pin the resulting model id in the agent spec. See "
"docs/KIMI_FOLLOWUPS.md for the deferred Omnigent-side provider "
"injection work.",
code=ErrorCode.INVALID_INPUT,
)
family_name = _PROVIDER_HARNESS_FAMILY[harness_type]
family = entry.family(family_name)
if family is None:
@@ -1590,6 +1625,41 @@ def _build_cursor_spawn_env(
return env
def _build_kimi_spawn_env(
spec: AgentSpec,
*,
workdir: Path | None = None,
) -> dict[str, str]:
"""Build the env-var dict the kimi harness wrap reads.
Maps ``spec.executor`` fields → the ``HARNESS_KIMI_*`` env vars
defined in :mod:`omnigent.inner.kimi_harness`.
The upstream Kimi Code CLI has no per-spawn provider override flag
(no ``--config-file`` / ``--mcp-config-file``), so this builder
only threads the model and working directory. Provider routing for
kimi lives in ``~/.kimi/config.toml`` and is managed out-of-band
via ``kimi provider add``. A spec that declares an explicit
provider / Databricks / api_key auth raises in
:func:`configure_agent_harness_with_provider` so the user
understands why their auth didn't take effect rather than silently
routing through whatever default kimi already had.
:param spec: The agent spec.
:param workdir: The bundle's on-disk path. Threaded as
``HARNESS_KIMI_CWD`` so the kimi subprocess runs with its cwd
pointed at the bundle (upstream has no ``--work-dir`` flag).
:returns: A dict of env-var overrides.
"""
env: dict[str, str] = {}
model = _resolve_spec_model(spec)
if model is not None:
env["HARNESS_KIMI_MODEL"] = model
if workdir is not None:
env["HARNESS_KIMI_CWD"] = str(workdir)
return env
def _build_antigravity_spawn_env(spec: AgentSpec) -> dict[str, str]:
"""
Map ``spec.executor`` fields → the ``HARNESS_ANTIGRAVITY_*`` env vars the
+46
View File
@@ -23,6 +23,7 @@ from omnigent.native_coding_agents import (
CLAUDE_NATIVE_CODING_AGENT,
CODEX_NATIVE_CODING_AGENT,
CURSOR_NATIVE_CODING_AGENT,
KIMI_NATIVE_CODING_AGENT,
PI_NATIVE_CODING_AGENT,
)
from omnigent.resources import examples as _examples_resources
@@ -78,6 +79,7 @@ _CLAUDE_NATIVE_AGENT_NAME = CLAUDE_NATIVE_CODING_AGENT.agent_name
_CODEX_NATIVE_AGENT_NAME = CODEX_NATIVE_CODING_AGENT.agent_name
_PI_NATIVE_AGENT_NAME = PI_NATIVE_CODING_AGENT.agent_name
_CURSOR_NATIVE_AGENT_NAME = CURSOR_NATIVE_CODING_AGENT.agent_name
_KIMI_NATIVE_AGENT_NAME = KIMI_NATIVE_CODING_AGENT.agent_name
_DEBBY_AGENT_NAME = "debby"
_POLLY_AGENT_NAME = "polly"
_UNMATCHED_ROUTE_TEMPLATE = "<unmatched>"
@@ -353,6 +355,7 @@ def _ensure_default_agents(
_ensure_default_codex_agent(agent_store, artifact_store, agent_cache)
_ensure_default_pi_agent(agent_store, artifact_store, agent_cache)
_ensure_default_cursor_agent(agent_store, artifact_store, agent_cache)
_ensure_default_kimi_native_agent(agent_store, artifact_store, agent_cache)
_ensure_default_debby_agent(agent_store, artifact_store, agent_cache)
_ensure_default_polly_agent(agent_store, artifact_store, agent_cache)
_ensure_extra_builtin_agents(agent_store, artifact_store, agent_cache)
@@ -600,6 +603,49 @@ def _ensure_default_cursor_agent(
)
def _build_kimi_native_bundle() -> bytes:
"""
Build a gzipped tarball of the kimi-native-ui agent spec.
:returns: Gzipped tarball bytes suitable for the artifact store.
"""
import tempfile
from omnigent.kimi_native import _materialize_kimi_agent_spec
from omnigent.spec import materialize_bundle
with tempfile.TemporaryDirectory() as tmpdir:
spec_path = _materialize_kimi_agent_spec(Path(tmpdir))
bundle_dir = materialize_bundle(spec_path, Path(tmpdir) / "bundle")
return _tar_gz_dir(bundle_dir)
def _ensure_default_kimi_native_agent(
agent_store: AgentStore,
artifact_store: ArtifactStore,
agent_cache: Any,
) -> None:
"""
Register or refresh the kimi-native-ui agent.
Called during server lifespan startup so the Web UI offers Kimi as a
built-in native-terminal agent on every deployment (not only after the
``omnigent kimi`` CLI first registers it). Content-aware via
:func:`_ensure_builtin_agent`.
:param agent_store: Store for agent metadata.
:param artifact_store: Store for agent bundles.
:param agent_cache: Cache for loaded agent specs.
"""
_ensure_builtin_agent(
agent_store,
artifact_store,
agent_cache,
name=_KIMI_NATIVE_AGENT_NAME,
bundle_bytes=_build_kimi_native_bundle(),
)
def _build_debby_bundle() -> bytes:
"""
Build a gzipped tarball of the ``examples/debby`` agent bundle.
+13 -1
View File
@@ -81,6 +81,9 @@ OMNIGENT_HARNESSES = frozenset(
"codex",
"codex-native",
"cursor",
"databricks_supervisor",
"kimi",
"kimi-native",
"cursor-native",
"openai-agents",
"open-responses",
@@ -91,7 +94,16 @@ OMNIGENT_HARNESSES = frozenset(
)
# User-facing aliases accepted in specs and normalized before runtime dispatch.
OMNIGENT_HARNESS_ALIASES = frozenset(
{"claude", "native-pi", "openai-agents-sdk", "agy", "google-antigravity", "qwen-code"}
{
"claude",
"native-pi",
"openai-agents-sdk",
"agy",
"google-antigravity",
"kimi-code",
"native-kimi",
"qwen-code",
}
)
_OMNIGENT_ACCEPTED_HARNESSES = OMNIGENT_HARNESSES | OMNIGENT_HARNESS_ALIASES
+101
View File
@@ -34,6 +34,7 @@ from omnigent.cli import (
_is_removed_ad_hoc_invocation,
_is_run_shorthand,
_load_global_config,
_manage_kimi_harness,
_manage_qwen_harness,
_materialize_harness_launcher_file,
_node_dependency_problem,
@@ -2248,6 +2249,13 @@ def test_run_without_agent_drops_into_configure_when_unconfigured(
"omnigent.onboarding.provider_config.default_provider_for_harness",
_fake_provider_for(), # nothing configured
)
# The kimi fallback in ``_pick_first_run_harness`` gates on the ``kimi``
# binary being on PATH. Stub it to False so the test stays deterministic
# on machines where the developer has kimi installed.
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_cli_installed",
lambda _key: False,
)
# The configure picker would block on a real terminal; stub it.
configure = Mock()
monkeypatch.setattr("omnigent.cli._run_configure_harnesses_interactive", configure)
@@ -4168,6 +4176,13 @@ def test_pick_first_run_harness_none_when_unconfigured(monkeypatch: pytest.Monke
"omnigent.onboarding.provider_config.default_provider_for_harness",
_fake_provider_for(), # nothing configured
)
# The kimi fallback in _pick_first_run_harness gates on the ``kimi``
# binary being on PATH. Stub it to False so the test stays deterministic
# on machines where the developer has kimi installed.
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_cli_installed",
lambda _key: False,
)
assert _pick_first_run_harness() is None
@@ -4250,6 +4265,13 @@ def test_resolve_first_run_plan_drops_into_configure_when_empty(
"omnigent.onboarding.provider_config.default_provider_for_harness",
_fake_provider_for(), # nothing configured, before and after configure
)
# The kimi fallback in ``_pick_first_run_harness`` gates on the ``kimi``
# binary being on PATH. Stub it to False so the test stays deterministic
# regardless of the developer's local install.
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_cli_installed",
lambda _key: False,
)
configure = Mock()
monkeypatch.setattr("omnigent.cli._run_configure_harnesses_interactive", configure)
@@ -4678,3 +4700,82 @@ def test_manage_qwen_harness_back_does_not_launch(
_manage_qwen_harness()
launch.assert_not_called()
# ── omnigent setup: Kimi Code drill-in (_manage_kimi_harness) ────────────
def test_manage_kimi_harness_not_installed_shows_hint_returns(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A missing kimi CLI shows the curl install_hint and returns.
Kimi is curl-installed (no npm ``package``), so the drill-in can't
auto-install it — it must surface the install_hint and bail without
touching login / logout.
"""
import omnigent.onboarding.harness_install as hi
import omnigent.onboarding.interactive as it
monkeypatch.setattr(hi, "harness_cli_installed", lambda key: False)
console = Mock()
monkeypatch.setattr(it, "console", console)
login = Mock()
logout = Mock()
monkeypatch.setattr(hi, "harness_login", login)
monkeypatch.setattr(hi, "harness_logout", logout)
# If the drill-in wrongly reached the menu loop, this select would drive it.
monkeypatch.setattr(it, "select", lambda *a, **k: 0)
_manage_kimi_harness()
login.assert_not_called()
logout.assert_not_called()
# The curl install command was surfaced to the user.
printed = " ".join(str(c.args[0]) for c in console.print.call_args_list if c.args)
assert "code.kimi.com/kimi-code/install.sh" in printed
def test_manage_kimi_harness_back_does_not_login(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""With the CLI installed, choosing "← Back" exits without signing in."""
import omnigent.onboarding.harness_install as hi
import omnigent.onboarding.interactive as it
monkeypatch.setattr(hi, "harness_cli_installed", lambda key: True)
monkeypatch.setattr(it, "console", Mock())
login = Mock()
logout = Mock()
monkeypatch.setattr(hi, "harness_login", login)
monkeypatch.setattr(hi, "harness_logout", logout)
# rows = [Sign in, Sign out, Show auth options, ← Back]; pick Back (3).
monkeypatch.setattr(it, "select", lambda *a, **k: 3)
_manage_kimi_harness()
login.assert_not_called()
logout.assert_not_called()
def test_manage_kimi_harness_login_runs_kimi_login(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Selecting "Sign in" drives ``harness_login(KIMI_KEY)`` then loops; Back exits."""
import omnigent.onboarding.harness_install as hi
import omnigent.onboarding.interactive as it
monkeypatch.setattr(hi, "harness_cli_installed", lambda key: True)
monkeypatch.setattr(it, "console", Mock())
login = Mock(return_value=False) # kimi has no status probe; return is ignored
logout = Mock()
monkeypatch.setattr(hi, "harness_login", login)
monkeypatch.setattr(hi, "harness_logout", logout)
# First iteration: Sign in (0); second: ← Back (3) to exit the loop.
choices = iter([0, 3])
monkeypatch.setattr(it, "select", lambda *a, **k: next(choices))
_manage_kimi_harness()
login.assert_called_once_with(hi.KIMI_KEY)
logout.assert_not_called()
+92
View File
@@ -0,0 +1,92 @@
"""End-to-end tests for :class:`omnigent.inner.kimi_executor.KimiExecutor`.
Real-binary tests gated on:
- ``OMNIGENT_E2E_KIMI=1`` in the environment, and
- the ``kimi`` binary (or whichever ``HARNESS_KIMI_PATH`` points at)
present on PATH.
When either gate fails the test is skipped keeps CI green without the
upstream binary while still letting maintainers run the happy path locally
with ``OMNIGENT_E2E_KIMI=1 uv run pytest tests/e2e/test_kimi_executor_e2e.py``.
Mirrors ``tests/e2e/test_cursor_executor_e2e.py`` / ``test_pi_executor_e2e.py``
in shape.
"""
from __future__ import annotations
import asyncio
import os
import shutil
from typing import Any
import pytest
from omnigent.inner.executor import TextChunk, TurnComplete
from omnigent.inner.kimi_executor import KimiExecutor, _resolve_kimi_binary
def _kimi_e2e_enabled() -> bool:
if os.environ.get("OMNIGENT_E2E_KIMI") != "1":
return False
return shutil.which(_resolve_kimi_binary()) is not None
pytestmark = pytest.mark.skipif(
not _kimi_e2e_enabled(),
reason=(
"Real-binary e2e: requires OMNIGENT_E2E_KIMI=1 and the ``kimi`` (or "
"HARNESS_KIMI_PATH) binary on PATH. Install via "
"`curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash` and "
"run ``kimi login`` once, then re-run with OMNIGENT_E2E_KIMI=1."
),
)
async def _collect_events(executor: KimiExecutor, prompt: str) -> list[Any]:
out: list[Any] = []
async for event in executor.run_turn(
messages=[{"role": "user", "content": prompt}],
tools=[],
system_prompt="",
):
out.append(event)
return out
def test_kimi_run_turn_streams_text_against_real_binary() -> None:
"""Real kimi-cli driven by KimiExecutor produces a text response.
Asks for a one-word answer to keep the run fast and the assertion
deterministic without relying on exact wording (auth + model
variability).
"""
executor = KimiExecutor()
events = asyncio.run(_collect_events(executor, "Reply with the single word: pong"))
text_chunks = [e for e in events if isinstance(e, TextChunk)]
turn_completes = [e for e in events if isinstance(e, TurnComplete)]
assert text_chunks, "kimi produced no TextChunk events"
assert turn_completes, "kimi did not emit TurnComplete"
assert executor._session_id, "kimi did not surface a resume session id on stderr"
def test_kimi_run_turn_session_resume_carries_history() -> None:
"""A second run_turn with the same executor should see the prior turn.
Verifies the executor captured the kimi UUID from the first turn's
stderr footer and threaded it via ``--session`` on the next spawn.
"""
executor = KimiExecutor()
asyncio.run(_collect_events(executor, "Remember the word cactus. Reply with: ok."))
first_session_id = executor._session_id
assert first_session_id, "first turn did not surface a session id"
events = asyncio.run(_collect_events(executor, "What single word did I ask you to remember?"))
text_chunks = [e for e in events if isinstance(e, TextChunk)]
response = " ".join(c.text for c in text_chunks).lower()
assert "cactus" in response, f"second turn lost prior context: {response!r}"
# The session id should be the same (resume reused the existing kimi session).
assert executor._session_id == first_session_id
+680
View File
@@ -0,0 +1,680 @@
"""Tests for the ``harness: kimi`` wrap + the inner ``KimiExecutor``.
Covers the harness registry, FastAPI app shape, env-var-driven
construction, and the executor's argv / event-translation / run-turn
flows with the upstream ``kimi`` subprocess stubbed out (so the suite
passes on machines without the binary).
"""
from __future__ import annotations
import asyncio
import json
from typing import Any
from unittest.mock import patch
import pytest
from omnigent.inner import kimi_executor, kimi_harness
from omnigent.inner.executor import (
ExecutorError,
TextChunk,
ToolCallComplete,
ToolCallRequest,
TurnComplete,
)
from omnigent.inner.kimi_executor import (
_SESSION_RESUME_RE,
KimiExecutor,
_latest_user_text,
_parse_truthy,
_resolve_kimi_binary,
_resolve_skills_dirs,
)
from omnigent.runtime.harnesses import _HARNESS_MODULES
from omnigent.spec._omnigent_compat import OMNIGENT_HARNESS_ALIASES, OMNIGENT_HARNESSES
# ---------------------------------------------------------------------------
# Registry / allowlist
# ---------------------------------------------------------------------------
def test_kimi_in_module_registry() -> None:
assert _HARNESS_MODULES.get("kimi") == "omnigent.inner.kimi_harness"
assert _HARNESS_MODULES.get("kimi-code") == "omnigent.inner.kimi_harness"
def test_kimi_in_omnigent_harnesses_allowlist() -> None:
assert "kimi" in OMNIGENT_HARNESSES
assert "kimi-code" in OMNIGENT_HARNESS_ALIASES
def test_kimi_canonical_alias_resolution() -> None:
from omnigent.harness_aliases import canonicalize_harness
assert canonicalize_harness("kimi-code") == "kimi"
assert canonicalize_harness("kimi") == "kimi"
# ---------------------------------------------------------------------------
# FastAPI app + factory
# ---------------------------------------------------------------------------
def test_create_app_returns_fastapi_with_required_routes() -> None:
app = kimi_harness.create_app()
paths = {route.path for route in app.routes} # type: ignore[attr-defined]
assert "/health" in paths
assert "/v1/sessions/{conversation_id}/events" in paths
def test_executor_factory_reads_env_vars(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("HARNESS_KIMI_MODEL", "kimi-k2-turbo")
monkeypatch.setenv("HARNESS_KIMI_CWD", "/tmp/kimi-cwd")
monkeypatch.setenv("HARNESS_KIMI_PATH", "/custom/bin/kimi")
monkeypatch.setenv("HARNESS_KIMI_PLAN", "yes")
monkeypatch.setenv("HARNESS_KIMI_CONTINUE_LAST", "true")
monkeypatch.setenv("HARNESS_KIMI_SKILLS_DIRS", json.dumps(["/a", "/b"]))
captured: dict[str, Any] = {}
def _fake_init(self: Any, **kwargs: Any) -> None:
captured.update(kwargs)
with patch(
"omnigent.inner.kimi_harness.KimiExecutor.__init__",
_fake_init,
):
kimi_harness._build_kimi_executor()
assert captured["model"] == "kimi-k2-turbo"
assert captured["cwd"] == "/tmp/kimi-cwd"
assert captured["binary_path"] == "/custom/bin/kimi"
assert captured["plan"] is True
assert captured["continue_last_session"] is True
assert captured["skills_dirs"] == ["/a", "/b"]
def test_executor_factory_defaults_when_env_unset(monkeypatch: pytest.MonkeyPatch) -> None:
for var in (
"HARNESS_KIMI_MODEL",
"HARNESS_KIMI_CWD",
"HARNESS_KIMI_PATH",
"HARNESS_KIMI_PLAN",
"HARNESS_KIMI_CONTINUE_LAST",
"HARNESS_KIMI_SKILLS_DIRS",
):
monkeypatch.delenv(var, raising=False)
captured: dict[str, Any] = {}
def _fake_init(self: Any, **kwargs: Any) -> None:
captured.update(kwargs)
with patch(
"omnigent.inner.kimi_harness.KimiExecutor.__init__",
_fake_init,
):
kimi_harness._build_kimi_executor()
assert captured["plan"] is False
assert captured["continue_last_session"] is False
assert captured["binary_path"] is None # passes through; executor resolves
assert captured["model"] is None
assert captured["cwd"] is None
assert captured["skills_dirs"] == []
def test_malformed_os_env_falls_back_to_default(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("HARNESS_KIMI_OS_ENV", "{not-json")
captured: dict[str, Any] = {}
def _fake_init(self: Any, **kwargs: Any) -> None:
captured["os_env"] = kwargs["os_env"]
with patch(
"omnigent.inner.kimi_harness.KimiExecutor.__init__",
_fake_init,
):
kimi_harness._build_kimi_executor()
assert captured["os_env"].type == "caller_process"
assert captured["os_env"].sandbox.type == "none"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"value,expected",
[
("1", True),
("true", True),
("YES", True),
("on", True),
("y", True),
("0", False),
("false", False),
("no", False),
("", False),
(None, False),
],
)
def test_parse_truthy(value: str | None, expected: bool) -> None:
assert _parse_truthy(value) is expected
def test_resolve_kimi_binary_default(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("HARNESS_KIMI_PATH", raising=False)
assert _resolve_kimi_binary() == "kimi"
def test_resolve_kimi_binary_explicit_override(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("HARNESS_KIMI_PATH", "/opt/bin/kimi")
assert _resolve_kimi_binary() == "/opt/bin/kimi"
def test_latest_user_text_string_message() -> None:
messages = [
{"role": "system", "content": "be helpful"},
{"role": "user", "content": "hello"},
]
assert _latest_user_text(messages) == "hello"
def test_latest_user_text_picks_most_recent_user() -> None:
messages = [
{"role": "user", "content": "first"},
{"role": "assistant", "content": "ok"},
{"role": "user", "content": "second"},
]
assert _latest_user_text(messages) == "second"
def test_latest_user_text_concats_text_blocks() -> None:
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "hello "},
{"type": "input_text", "text": "world"},
],
}
]
assert _latest_user_text(messages) == "hello world"
def test_latest_user_text_drops_image_blocks_with_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
import logging
caplog.set_level(logging.WARNING, logger="omnigent.inner.kimi_executor")
messages = [
{
"role": "user",
"content": [
{"type": "input_image", "image_url": "data:image/png;base64,..."},
{"type": "text", "text": "what's in this image?"},
],
}
]
assert _latest_user_text(messages) == "what's in this image?"
assert any("dropped 1 non-text content block" in rec.message for rec in caplog.records)
def test_latest_user_text_returns_empty_when_no_user_message() -> None:
assert _latest_user_text([{"role": "assistant", "content": "hi"}]) == ""
def test_resolve_skills_dirs_valid() -> None:
payload = json.dumps(["/x/skills", "/y/skills"])
assert _resolve_skills_dirs(payload) == ["/x/skills", "/y/skills"]
def test_resolve_skills_dirs_unset() -> None:
assert _resolve_skills_dirs(None) == []
assert _resolve_skills_dirs("") == []
assert _resolve_skills_dirs(" ") == []
def test_resolve_skills_dirs_invalid_json() -> None:
assert _resolve_skills_dirs("{not-json") == []
def test_resolve_skills_dirs_wrong_shape() -> None:
assert _resolve_skills_dirs(json.dumps("scalar")) == []
assert _resolve_skills_dirs(json.dumps([1, 2])) == []
def test_session_resume_regex_captures_session_id() -> None:
line = "To resume this session: kimi -r session_1fac96e7-5223-4021-9bf4-6413bedf38ee"
m = _SESSION_RESUME_RE.search(line)
assert m is not None
assert m.group(1) == "session_1fac96e7-5223-4021-9bf4-6413bedf38ee"
def test_session_resume_regex_no_match() -> None:
assert _SESSION_RESUME_RE.search("nothing to see here") is None
# ---------------------------------------------------------------------------
# Argv builder
# ---------------------------------------------------------------------------
def test_build_argv_minimal() -> None:
ex = KimiExecutor(binary_path="kimi")
argv = ex._build_argv(prompt_text="hi")
assert argv[0] == "kimi"
assert argv[1:3] == ["--output-format", "stream-json"]
# ``-p`` always lands at the tail (it consumes a single argument).
assert argv[-2:] == ["-p", "hi"]
# No --print, --yolo, --afk, --thinking, --work-dir on the upstream binary.
for flag in ("--print", "--yolo", "--afk", "--thinking", "--no-thinking", "--work-dir"):
assert flag not in argv
def test_build_argv_threads_model() -> None:
ex = KimiExecutor(binary_path="kimi", model="kimi-k2-turbo")
argv = ex._build_argv(prompt_text="hi")
assert "-m" in argv
assert argv[argv.index("-m") + 1] == "kimi-k2-turbo"
def test_build_argv_plan_flag() -> None:
ex = KimiExecutor(binary_path="kimi", plan=True)
argv = ex._build_argv(prompt_text="hi")
assert "--plan" in argv
def test_build_argv_session_resume() -> None:
ex = KimiExecutor(binary_path="kimi")
ex._session_id = "session_deadbeef-1234-5678-9abc-def012345678"
argv = ex._build_argv(prompt_text="next")
assert "-S" in argv
assert argv[argv.index("-S") + 1] == "session_deadbeef-1234-5678-9abc-def012345678"
def test_build_argv_continue_last_when_no_session_id() -> None:
ex = KimiExecutor(binary_path="kimi", continue_last_session=True)
argv = ex._build_argv(prompt_text="next")
assert "-C" in argv
assert "-S" not in argv
def test_build_argv_explicit_session_id_wins_over_continue() -> None:
"""``-S <id>`` and ``-C`` are mutually exclusive; the explicit id wins."""
ex = KimiExecutor(binary_path="kimi", continue_last_session=True)
ex._session_id = "session_abc"
argv = ex._build_argv(prompt_text="next")
assert "-S" in argv
assert "-C" not in argv
def test_build_argv_skills_dirs_repeats_flag() -> None:
ex = KimiExecutor(binary_path="kimi", skills_dirs=["/a/skills", "/b/skills"])
argv = ex._build_argv(prompt_text="hi")
assert argv.count("--skills-dir") == 2
skills_positions = [i for i, v in enumerate(argv) if v == "--skills-dir"]
assert argv[skills_positions[0] + 1] == "/a/skills"
assert argv[skills_positions[1] + 1] == "/b/skills"
# ---------------------------------------------------------------------------
# Translate event
# ---------------------------------------------------------------------------
def test_translate_event_assistant_text_as_string() -> None:
"""Upstream emits ``content`` as a plain string; emit one TextChunk."""
ex = KimiExecutor(binary_path="kimi")
events = ex._translate_event({"role": "assistant", "content": "Hi there!"})
assert len(events) == 1
assert isinstance(events[0], TextChunk)
assert events[0].text == "Hi there!"
def test_translate_event_assistant_empty_string_yields_no_events() -> None:
ex = KimiExecutor(binary_path="kimi")
assert ex._translate_event({"role": "assistant", "content": ""}) == []
def test_translate_event_tool_call() -> None:
ex = KimiExecutor(binary_path="kimi")
events = ex._translate_event(
{
"role": "assistant",
"tool_calls": [
{
"type": "function",
"id": "tool_abc",
"function": {
"name": "Bash",
"arguments": '{"command": "ls -la"}',
},
}
],
}
)
assert len(events) == 1
assert isinstance(events[0], ToolCallRequest)
assert events[0].name == "Bash"
assert events[0].args == {"command": "ls -la"}
assert events[0].metadata == {"call_id": "tool_abc"}
def test_translate_event_tool_result() -> None:
ex = KimiExecutor(binary_path="kimi")
events = ex._translate_event(
{
"role": "tool",
"content": "total 0\n",
"tool_call_id": "tool_abc",
}
)
assert len(events) == 1
assert isinstance(events[0], ToolCallComplete)
assert events[0].result == "total 0\n"
assert events[0].metadata == {"call_id": "tool_abc"}
def test_translate_event_meta_captures_session_id() -> None:
"""``role:"meta"`` + ``type:"session.resume_hint"`` updates the executor."""
ex = KimiExecutor(binary_path="kimi")
events = ex._translate_event(
{
"role": "meta",
"type": "session.resume_hint",
"session_id": "session_abc123",
"command": "kimi -r session_abc123",
}
)
assert events == [] # meta events yield no Omnigent-visible events
assert ex._session_id == "session_abc123"
def test_translate_event_ignores_unknown_role() -> None:
ex = KimiExecutor(binary_path="kimi")
assert ex._translate_event({"role": "system", "content": "x"}) == []
# ---------------------------------------------------------------------------
# Capability flags
# ---------------------------------------------------------------------------
def test_kimi_executor_capabilities() -> None:
ex = KimiExecutor(binary_path="kimi")
assert ex.handles_tools_internally() is True
assert ex.supports_streaming() is True
assert ex.supports_tool_calling() is True
# ---------------------------------------------------------------------------
# run_turn end-to-end with stubbed subprocess
# ---------------------------------------------------------------------------
class _FakeStdout:
"""Async-iterable stdout that yields the prepared JSONL lines."""
def __init__(self, lines: list[str]) -> None:
self._lines = [line.encode("utf-8") + b"\n" for line in lines]
def __aiter__(self) -> _FakeStdout:
return self
async def __anext__(self) -> bytes:
if not self._lines:
raise StopAsyncIteration
return self._lines.pop(0)
class _FakeStderr:
"""Reader returning a single buffered stderr blob then EOF."""
def __init__(self, blob: bytes) -> None:
self._blob = blob
self._done = False
async def read(self, _n: int) -> bytes:
if self._done:
return b""
self._done = True
return self._blob
class _FakeProcess:
"""asyncio.subprocess.Process double the tests inject in place of a real spawn."""
def __init__(self, stdout_lines: list[str], stderr_blob: bytes, returncode: int = 0) -> None:
self.stdout = _FakeStdout(stdout_lines)
self.stderr = _FakeStderr(stderr_blob)
self._returncode = returncode
@property
def returncode(self) -> int | None:
return self._returncode
async def wait(self) -> int:
return self._returncode
def terminate(self) -> None: # pragma: no cover — happy path doesn't terminate
pass
def kill(self) -> None: # pragma: no cover
pass
async def _collect(ex: KimiExecutor, messages: list[dict[str, Any]]) -> list[Any]:
out: list[Any] = []
async for evt in ex.run_turn(messages=messages, tools=[], system_prompt=""):
out.append(evt)
return out
def test_run_turn_streams_text_and_emits_turn_complete(monkeypatch: pytest.MonkeyPatch) -> None:
"""End-to-end: assistant text + meta resume_hint → TextChunk + session id captured."""
stdout_lines = [
json.dumps({"role": "assistant", "content": "Hi there!"}),
json.dumps(
{
"role": "meta",
"type": "session.resume_hint",
"session_id": "session_abc12345-6789",
"command": "kimi -r session_abc12345-6789",
}
),
]
fake = _FakeProcess(stdout_lines, b"", returncode=0)
captured_argv: list[str] = []
async def _fake_spawn(*args: Any, **_kwargs: Any) -> _FakeProcess:
captured_argv.extend(args)
return fake
monkeypatch.setattr(kimi_executor, "_create_subprocess_exec", _fake_spawn)
monkeypatch.setattr(kimi_executor.shutil, "which", lambda _binary: "/usr/local/bin/kimi")
ex = KimiExecutor(binary_path="kimi", model="kimi-k2-turbo")
events = asyncio.run(_collect(ex, [{"role": "user", "content": "hi"}]))
text_chunks = [e for e in events if isinstance(e, TextChunk)]
turn_completes = [e for e in events if isinstance(e, TurnComplete)]
errors = [e for e in events if isinstance(e, ExecutorError)]
assert errors == []
assert [c.text for c in text_chunks] == ["Hi there!"]
assert turn_completes and turn_completes[0].response == "Hi there!"
assert ex._session_id == "session_abc12345-6789"
assert captured_argv[0] == "kimi"
# No --print on upstream — make sure we don't reintroduce it.
assert "--print" not in captured_argv
assert "--output-format" in captured_argv
assert "stream-json" in captured_argv
def test_run_turn_uses_session_resume_on_second_turn(monkeypatch: pytest.MonkeyPatch) -> None:
"""After the first turn captures a session id, the next spawn passes -S."""
fake_first = _FakeProcess(
[
json.dumps({"role": "assistant", "content": "first"}),
json.dumps(
{"role": "meta", "type": "session.resume_hint", "session_id": "session_aaaaa"}
),
],
b"",
returncode=0,
)
fake_second = _FakeProcess(
[
json.dumps({"role": "assistant", "content": "second"}),
json.dumps(
{"role": "meta", "type": "session.resume_hint", "session_id": "session_aaaaa"}
),
],
b"",
returncode=0,
)
second_argv: list[str] = []
calls = {"count": 0}
async def _fake_spawn(*args: Any, **_kwargs: Any) -> _FakeProcess:
calls["count"] += 1
if calls["count"] == 1:
return fake_first
second_argv.extend(args)
return fake_second
monkeypatch.setattr(kimi_executor, "_create_subprocess_exec", _fake_spawn)
monkeypatch.setattr(kimi_executor.shutil, "which", lambda _binary: "/usr/local/bin/kimi")
ex = KimiExecutor(binary_path="kimi")
asyncio.run(_collect(ex, [{"role": "user", "content": "first"}]))
asyncio.run(_collect(ex, [{"role": "user", "content": "next"}]))
assert "-S" in second_argv
idx = second_argv.index("-S")
assert second_argv[idx + 1] == "session_aaaaa"
def test_run_turn_falls_back_to_stderr_regex_for_session_id(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If the meta JSON event is absent, the stderr footer regex picks up the id."""
fake = _FakeProcess(
[json.dumps({"role": "assistant", "content": "hi"})],
b"To resume this session: kimi -r session_fallback-1234\n",
returncode=0,
)
async def _fake_spawn(*_args: Any, **_kwargs: Any) -> _FakeProcess:
return fake
monkeypatch.setattr(kimi_executor, "_create_subprocess_exec", _fake_spawn)
monkeypatch.setattr(kimi_executor.shutil, "which", lambda _binary: "/usr/local/bin/kimi")
ex = KimiExecutor(binary_path="kimi")
asyncio.run(_collect(ex, [{"role": "user", "content": "hi"}]))
assert ex._session_id == "session_fallback-1234"
def test_run_turn_emits_error_when_kimi_binary_missing(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(kimi_executor.shutil, "which", lambda _binary: None)
monkeypatch.setattr(kimi_executor.Path, "exists", lambda _self: False)
ex = KimiExecutor(binary_path="kimi")
events = asyncio.run(_collect(ex, [{"role": "user", "content": "hi"}]))
errors = [e for e in events if isinstance(e, ExecutorError)]
assert errors and "not found on PATH" in errors[0].message
assert errors[0].retryable is False
def test_run_turn_with_empty_user_text_emits_turn_complete_none(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(kimi_executor.shutil, "which", lambda _binary: "/usr/local/bin/kimi")
async def _never_called(*_args: Any, **_kwargs: Any) -> Any:
raise AssertionError("subprocess must not be spawned when prompt is empty")
monkeypatch.setattr(kimi_executor, "_create_subprocess_exec", _never_called)
ex = KimiExecutor(binary_path="kimi")
events = asyncio.run(_collect(ex, [{"role": "assistant", "content": "no user msg"}]))
assert len(events) == 1
assert isinstance(events[0], TurnComplete)
assert events[0].response is None
def test_run_turn_nonzero_exit_yields_executor_error(monkeypatch: pytest.MonkeyPatch) -> None:
fake = _FakeProcess([], b"boom\n", returncode=2)
async def _fake_spawn(*_args: Any, **_kwargs: Any) -> _FakeProcess:
return fake
monkeypatch.setattr(kimi_executor, "_create_subprocess_exec", _fake_spawn)
monkeypatch.setattr(kimi_executor.shutil, "which", lambda _binary: "/usr/local/bin/kimi")
ex = KimiExecutor(binary_path="kimi")
events = asyncio.run(_collect(ex, [{"role": "user", "content": "hi"}]))
errors = [e for e in events if isinstance(e, ExecutorError)]
assert errors and "exited with code 2" in errors[0].message
def test_run_turn_warns_once_when_tools_declared(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Tools on the spec are silently dropped (no MCP bridge on upstream kimi
yet) we should warn exactly once per session.
"""
import logging
caplog.set_level(logging.WARNING, logger="omnigent.inner.kimi_executor")
def _make_fake() -> _FakeProcess:
return _FakeProcess(
[
json.dumps({"role": "assistant", "content": "ok"}),
json.dumps(
{"role": "meta", "type": "session.resume_hint", "session_id": "session_x"}
),
],
b"",
returncode=0,
)
fakes = [_make_fake(), _make_fake()]
async def _fake_spawn(*_args: Any, **_kwargs: Any) -> _FakeProcess:
return fakes.pop(0)
monkeypatch.setattr(kimi_executor, "_create_subprocess_exec", _fake_spawn)
monkeypatch.setattr(kimi_executor.shutil, "which", lambda _binary: "/usr/local/bin/kimi")
ex = KimiExecutor(binary_path="kimi")
tools = [{"name": "my_tool", "description": "x", "parameters": {}}]
async def _two_turns() -> None:
async for _ in ex.run_turn(
messages=[{"role": "user", "content": "hi"}], tools=tools, system_prompt=""
):
pass
async for _ in ex.run_turn(
messages=[{"role": "user", "content": "again"}], tools=tools, system_prompt=""
):
pass
asyncio.run(_two_turns())
warnings = [rec for rec in caplog.records if "tool-injection bridge" in rec.message]
assert len(warnings) == 1, "should warn exactly once across both turns"
+209
View File
@@ -0,0 +1,209 @@
"""Unit tests for the kimi-native (terminal-injection) harness.
Covers the executor's text extraction + capability flags, the tmux bridge's pure
helpers (paste-payload encoding, bridge dir, spawn env, tmux.json round-trip),
and harness registration. The live tmux injection is exercised by the e2e gate,
not here, so these need no tmux or kimi binary.
Unlike cursor-native, kimi-native has NO MCP plumbing (upstream kimi has no
per-spawn MCP config), so the MCP-config tests have no analogue here.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from omnigent import kimi_native_bridge
from omnigent.inner.kimi_native_executor import (
KimiNativeExecutor,
_content_to_text,
_latest_user_text,
)
from omnigent.kimi_native_bridge import (
APPROVE_KEY,
BRIDGE_DIR_ENV_VAR,
DENY_KEY,
_paste_payload_bytes,
bridge_dir_for_session_id,
build_kimi_native_spawn_env,
inject_approval_keystroke,
read_tmux_info,
write_tmux_target,
)
class TestContentExtraction:
def test_string_content(self, tmp_path: Path) -> None:
assert _content_to_text("hello", tmp_path) == "hello"
def test_input_text_blocks(self, tmp_path: Path) -> None:
content = [
{"type": "input_text", "text": "one"},
{"type": "text", "text": "two"},
# invalid data URI -> materialize_attachment returns None -> no line
{"type": "input_image", "image_url": "data:..."},
]
assert _content_to_text(content, tmp_path) == "one\n\ntwo"
def test_real_image_attachment_materialized(self, tmp_path: Path) -> None:
# a tiny valid base64 PNG data URI should be written to disk + referenced
png = (
"data:image/png;base64,"
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
out = _content_to_text([{"type": "input_image", "image_url": png}], tmp_path)
assert out.startswith("[Attached: ")
assert str(tmp_path) in out
def test_empty_and_none(self, tmp_path: Path) -> None:
assert _content_to_text(None, tmp_path) == ""
assert _content_to_text([], tmp_path) == ""
def test_latest_user_text(self, tmp_path: Path) -> None:
messages = [
{"role": "user", "content": "first"},
{"role": "assistant", "content": "ok"},
{"role": "user", "content": "second"},
]
assert _latest_user_text(messages, tmp_path) == "second"
assert _latest_user_text([{"role": "assistant", "content": "x"}], tmp_path) == ""
class TestExecutorCapabilities:
def test_capability_flags(self, tmp_path: Path) -> None:
ex = KimiNativeExecutor(bridge_dir=tmp_path)
# Output is shown by the embedded terminal, not streamed by the executor.
assert ex.supports_streaming() is False
# Web-UI messages can be injected mid-turn (steering).
assert ex.supports_live_message_queue() is True
class TestPastePayload:
def test_newlines_become_cr(self) -> None:
assert _paste_payload_bytes("a\nb") == b"a\rb"
assert _paste_payload_bytes("a\r\nb") == b"a\rb"
assert _paste_payload_bytes("a\rb") == b"a\rb"
def test_tab_kept_other_control_dropped(self) -> None:
# tab kept (0x09), ESC (0x1b) and BEL (0x07) dropped.
assert _paste_payload_bytes("a\tb\x1b\x07c") == b"a\tbc"
def test_unicode_passthrough(self) -> None:
assert _paste_payload_bytes("café") == "café".encode()
class TestBridge:
def test_bridge_dir_is_deterministic_and_session_scoped(self) -> None:
a1 = bridge_dir_for_session_id("conv_a")
a2 = bridge_dir_for_session_id("conv_a")
b = bridge_dir_for_session_id("conv_b")
assert a1 == a2
assert a1 != b
assert "kimi-native" in str(a1)
def test_spawn_env_carries_bridge_dir(self) -> None:
env = build_kimi_native_spawn_env("conv_xyz")
assert env[BRIDGE_DIR_ENV_VAR] == str(bridge_dir_for_session_id("conv_xyz"))
# Only the bridge dir is emitted (no MCP / active-session guard env).
assert list(env) == [BRIDGE_DIR_ENV_VAR]
def test_tmux_target_round_trip(self, tmp_path: Path) -> None:
write_tmux_target(tmp_path, socket_path=Path("/tmp/x/tmux.sock"), tmux_target="main")
info = read_tmux_info(tmp_path)
assert info == {"socket_path": "/tmp/x/tmux.sock", "tmux_target": "main"}
def test_read_tmux_info_missing(self, tmp_path: Path) -> None:
assert read_tmux_info(tmp_path) is None
class TestApprovalKeystroke:
"""`inject_approval_keystroke` types the option digit + Enter, guarded by
the permission-menu marker so a stray verdict can't leak a keystroke."""
def _stub_tmux(
self, monkeypatch: pytest.MonkeyPatch, *, pane: str, alive: bool = True
) -> list[tuple[str, ...]]:
sent: list[tuple[str, ...]] = []
monkeypatch.setattr(
kimi_native_bridge,
"_wait_for_tmux_info",
lambda bridge_dir, *, timeout_s: {"socket_path": "/s", "tmux_target": "main"},
)
monkeypatch.setattr(kimi_native_bridge, "_session_alive", lambda s, t: alive)
monkeypatch.setattr(kimi_native_bridge, "_capture_pane", lambda s, t: pane)
monkeypatch.setattr(
kimi_native_bridge,
"_run_tmux",
lambda socket_path, *args: sent.append(args),
)
return sent
def test_injects_digit_and_enter_when_menu_present(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
sent = self._stub_tmux(monkeypatch, pane="▶ 1. Approve once\n 3. Reject")
assert inject_approval_keystroke(tmp_path, key=APPROVE_KEY) is True
assert sent == [
("send-keys", "-t", "main", APPROVE_KEY),
("send-keys", "-t", "main", "Enter"),
]
def test_deny_key_selects_reject(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
sent = self._stub_tmux(monkeypatch, pane="▶ 1. Approve once\n 3. Reject")
assert inject_approval_keystroke(tmp_path, key=DENY_KEY) is True
assert sent[0] == ("send-keys", "-t", "main", DENY_KEY)
def test_skips_when_menu_absent(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Prompt already answered in the terminal → marker gone → no keystroke.
sent = self._stub_tmux(monkeypatch, pane="● Hello! How can I help?")
assert inject_approval_keystroke(tmp_path, key=APPROVE_KEY) is False
assert sent == []
def test_skips_when_tui_exited(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
sent = self._stub_tmux(monkeypatch, pane="▶ 1. Approve once", alive=False)
assert inject_approval_keystroke(tmp_path, key=APPROVE_KEY) is False
assert sent == []
class TestRegistration:
def test_harness_is_registered(self) -> None:
from omnigent.runtime.harnesses import _HARNESS_MODULES
assert _HARNESS_MODULES["kimi-native"] == "omnigent.inner.kimi_native_harness"
def test_harness_is_allowlisted(self) -> None:
from omnigent.spec._omnigent_compat import OMNIGENT_HARNESSES
assert "kimi-native" in OMNIGENT_HARNESSES
def test_kimi_native_is_terminal_native(self) -> None:
# kimi-native launches the kimi TUI in an omnigent terminal (like
# claude/codex/cursor-native), so the runner must treat it as a native
# terminal harness.
from omnigent.harness_aliases import is_native_harness
assert is_native_harness("kimi-native") is True
assert is_native_harness("native-kimi") is True
def test_native_coding_agent_record(self) -> None:
from omnigent.native_coding_agents import native_coding_agent_for_harness
agent = native_coding_agent_for_harness("kimi-native")
assert agent is not None
assert agent.terminal_name == "kimi"
assert agent.display_name == "Kimi"
def test_distinct_from_headless_kimi_harness(self) -> None:
# The bare ``kimi`` harness is the headless SDK path; ``kimi-native`` is
# the TUI path. They must resolve to different harness modules.
from omnigent.runtime.harnesses import _HARNESS_MODULES
assert _HARNESS_MODULES["kimi"] != _HARNESS_MODULES["kimi-native"]
+47
View File
@@ -32,6 +32,53 @@ def test_install_spec_and_command(key: str, binary: str, package: str) -> None:
assert hi.harness_install_command(key) == ["npm", "install", "-g", package]
def test_kimi_install_spec_is_login_only_no_npm() -> None:
"""Kimi ships via a curl installer (no npm package) and authenticates
through its own ``kimi login`` (OAuth or Moonshot API key), so it carries
an ``install_hint`` instead of a ``package`` and intentionally has no
``status_args`` (no exit-code "am I logged in?" probe to read).
"""
spec = hi.harness_install_spec(hi.KIMI_KEY)
assert spec is not None
assert spec.binary == "kimi"
assert spec.package is None
assert spec.install_hint is not None and "code.kimi.com" in spec.install_hint
assert spec.login_args == ("login",)
assert spec.logout_args == ("logout",)
assert spec.status_args is None
def test_kimi_required_cli_returns_install_spec() -> None:
"""The kimi harness is binary-gated: it cannot launch without ``kimi`` on
PATH, so the sub-agent dispatch preflight must surface the install spec."""
spec = hi.required_cli_for_harness("kimi")
assert spec is not None
assert spec.binary == "kimi"
def test_kimi_only_upstream_binary_satisfies_readiness(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Only ``kimi`` (the upstream MoonshotAI/Kimi-Code binary) counts as
installed. The legacy pypi ``kimi-cli`` package is intentionally NOT
accepted its command-line surface is incompatible with what the
executor drives, so falsely reading it as configured would crash at
the first turn."""
monkeypatch.setattr(
hi.shutil,
"which",
lambda name: "/Users/x/.local/bin/kimi-cli" if name == "kimi-cli" else None,
)
assert hi.harness_cli_installed(hi.KIMI_KEY) is False
monkeypatch.setattr(
hi.shutil,
"which",
lambda name: "/Users/x/.kimi-code/bin/kimi" if name == "kimi" else None,
)
assert hi.harness_cli_installed(hi.KIMI_KEY) is True
def test_cursor_install_spec_is_login_only_no_npm() -> None:
"""Cursor ships via a curl installer (no npm package) and authenticates
through its own CLI login, so it carries an ``install_hint`` + status JSON
@@ -142,6 +142,12 @@ def test_configured_harness_map_covers_all_spellings(
"antigravity",
"agy",
"google-antigravity",
# Kimi Code CLI + alias.
"kimi",
"kimi-code",
# Native Kimi (``omnigent kimi``) — gates on the kimi CLI.
"kimi-native",
"native-kimi",
# Qwen harnesses
"qwen",
"qwen-code",
@@ -184,6 +190,7 @@ def test_configured_harness_map_gates_only_cli_harnesses(
"codex-native",
"native-codex",
"pi",
"kimi",
"cursor-native",
"native-cursor",
"qwen",
@@ -206,6 +213,25 @@ def test_configured_harness_map_all_true_with_clis(
assert all(result.values())
def test_kimi_readiness_keys_off_binary(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Kimi is configured iff the ``kimi`` binary is on PATH.
Kimi authenticates against Moonshot AI's backend via ``kimi login`` (OAuth
or a Moonshot API key), which the daemon cannot inspect so readiness
keys off binary presence, and the alias ``kimi-code`` resolves to the
same verdict via canonicalization.
"""
_no_clis_installed(monkeypatch)
assert harness_is_configured("kimi") is False
assert harness_is_configured("kimi-code") is False
_all_clis_installed(monkeypatch)
assert harness_is_configured("kimi") is True
assert harness_is_configured("kimi-code") is True
def test_cursor_readiness_keys_off_api_key(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
+66
View File
@@ -28,6 +28,7 @@ import yaml as _yaml
from omnigent.runtime.workflow import (
_build_claude_sdk_spawn_env,
_build_codex_spawn_env,
_build_kimi_spawn_env,
_build_openai_agents_sdk_spawn_env,
_build_pi_spawn_env,
_build_qwen_spawn_env,
@@ -969,3 +970,68 @@ def test_codex_undismissed_config_provider_routes_via_detection(
env = _build_codex_spawn_env(spec, workdir=None)
assert env["HARNESS_CODEX_MODEL_PROVIDER"] == "Databricks"
# ── Kimi Code CLI spawn-env ────────────────────────────────────────────────
def test_kimi_spawn_env_threads_spec_model_only(config_home: Path) -> None:
"""The kimi builder only emits ``HARNESS_KIMI_MODEL`` (when set) and
``HARNESS_KIMI_CWD`` (when workdir given). Upstream kimi has no per-spawn
provider override, so no HARNESS_KIMI_GATEWAY_* / _DATABRICKS_PROFILE
env vars are emitted provider routing lives in ``~/.kimi/config.toml``."""
_write_config(config_home, {"providers": {}})
spec = _make_spec(harness="kimi", model="kimi-k2-turbo")
env = _build_kimi_spawn_env(spec, workdir=None)
assert env == {"HARNESS_KIMI_MODEL": "kimi-k2-turbo"}
def test_kimi_workdir_threads_through_as_cwd(config_home: Path, tmp_path: Path) -> None:
"""``workdir`` lands in ``HARNESS_KIMI_CWD`` so kimi's subprocess runs in
the bundle dir (upstream kimi has no ``--work-dir`` flag, so the executor
threads this as ``cwd=`` on the subprocess)."""
_write_config(config_home, {"providers": {}})
spec = _make_spec(harness="kimi")
env = _build_kimi_spawn_env(spec, workdir=tmp_path)
assert env["HARNESS_KIMI_CWD"] == str(tmp_path)
def test_kimi_no_provider_emits_no_gateway_vars(config_home: Path) -> None:
"""With no provider configured and no spec auth, kimi uses its own
``kimi login`` credentials no HARNESS_KIMI_GATEWAY_* leaks in.
A regression here would either steal an ambient OPENAI_API_KEY (mis-billing)
or point at a stale URL the user never configured. Upstream kimi reads its
provider config from ``~/.kimi/config.toml``; Omnigent never injects."""
_write_config(config_home, {"providers": {}})
spec = _make_spec(harness="kimi")
env = _build_kimi_spawn_env(spec, workdir=None)
assert "HARNESS_KIMI_GATEWAY_BASE_URL" not in env
assert "HARNESS_KIMI_GATEWAY_API_KEY" not in env
assert "HARNESS_KIMI_GATEWAY_PROVIDER" not in env
assert "HARNESS_KIMI_DATABRICKS_PROFILE" not in env
def test_kimi_ignores_global_default_provider(config_home: Path) -> None:
"""An openai default provider does NOT inject creds into the kimi env.
Counterpart to the other harnesses: their spawn-env builders adopt the
global default. For kimi we DO NOT upstream has no per-spawn provider
override flag, so silently injecting a key the executor can't pass to the
subprocess would be misleading (and would mis-bill the user against an
OpenAI key when their ``~/.kimi/config.toml`` actually points at
Moonshot). The builder emits no gateway vars regardless of what's
configured."""
_write_config(config_home, _openai_default_config())
spec = _make_spec(harness="kimi")
env = _build_kimi_spawn_env(spec, workdir=None)
assert "HARNESS_KIMI_GATEWAY_BASE_URL" not in env
assert "HARNESS_KIMI_GATEWAY_API_KEY" not in env
@@ -0,0 +1,34 @@
"""Tests for the kimi-native bridge hook-config helpers."""
from __future__ import annotations
from pathlib import Path
from omnigent.kimi_native_bridge import (
read_active_session_id,
read_hook_config,
write_hook_config,
)
def test_write_then_read_hook_config_round_trips(tmp_path: Path) -> None:
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
write_hook_config(
bridge_dir,
server_url="http://127.0.0.1:8787",
headers={"Authorization": "Bearer tok"},
session_id="conv_xyz",
)
config = read_hook_config(bridge_dir)
assert config["ap_server_url"] == "http://127.0.0.1:8787"
assert config["ap_auth_headers"] == {"Authorization": "Bearer tok"}
assert config["session_id"] == "conv_xyz"
assert read_active_session_id(bridge_dir) == "conv_xyz"
def test_read_hook_config_absent_is_empty(tmp_path: Path) -> None:
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
assert read_hook_config(bridge_dir) == {}
assert read_active_session_id(bridge_dir) is None
+88
View File
@@ -0,0 +1,88 @@
"""Tests for the per-session KIMI_CODE_HOME builder."""
from __future__ import annotations
from pathlib import Path
import pytest
import tomllib
from omnigent.kimi_native_credentials import (
KIMI_CODE_HOME_ENV_VAR,
build_kimi_session_home,
render_kimi_hooks_toml,
)
def _fake_user_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Point ``resolve_user_kimi_home`` at a populated fake global home."""
user_home = tmp_path / "user-kimi"
user_home.mkdir()
(user_home / "config.toml").write_text(
'default_model = "kimi-code/x"\n[providers."managed"]\ntype = "kimi"\n', encoding="utf-8"
)
(user_home / "oauth").mkdir()
(user_home / "oauth" / "token").write_text("secret", encoding="utf-8")
monkeypatch.setenv(KIMI_CODE_HOME_ENV_VAR, str(user_home))
return user_home
def test_render_hooks_toml_is_valid_and_complete() -> None:
toml = render_kimi_hooks_toml(bridge_dir=Path("/tmp/b r"), python_executable="/py")
parsed = tomllib.loads(toml)
events = {h["event"] for h in parsed["hooks"]}
assert events == {"PreToolUse", "PermissionRequest"}
for hook in parsed["hooks"]:
assert "omnigent.kimi_native_hook" in hook["command"]
assert "/tmp/b r" in hook["command"] # space-bearing path round-trips
def test_build_session_home_preserves_user_config_and_appends_hooks(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_fake_user_home(tmp_path, monkeypatch)
session_home = tmp_path / "session-home"
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
env = build_kimi_session_home(session_home, bridge_dir=bridge_dir)
assert env == {KIMI_CODE_HOME_ENV_VAR: str(session_home)}
parsed = tomllib.loads((session_home / "config.toml").read_text(encoding="utf-8"))
# User config preserved …
assert parsed["default_model"] == "kimi-code/x"
assert "managed" in parsed["providers"]
# … and the Omnigent hooks appended.
assert {h["event"] for h in parsed["hooks"]} == {"PreToolUse", "PermissionRequest"}
def test_build_session_home_symlinks_auth_but_not_config(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_fake_user_home(tmp_path, monkeypatch)
session_home = tmp_path / "session-home"
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
build_kimi_session_home(session_home, bridge_dir=bridge_dir)
# oauth is symlinked through to the user's tokens (auth keeps working) …
oauth_link = session_home / "oauth"
assert oauth_link.is_symlink()
assert (oauth_link / "token").read_text(encoding="utf-8") == "secret"
# … but config.toml is a real file (we own its content), not a symlink.
assert not (session_home / "config.toml").is_symlink()
def test_build_session_home_without_user_home_writes_hooks_only(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv(KIMI_CODE_HOME_ENV_VAR, str(tmp_path / "does-not-exist"))
session_home = tmp_path / "session-home"
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
build_kimi_session_home(session_home, bridge_dir=bridge_dir)
parsed = tomllib.loads((session_home / "config.toml").read_text(encoding="utf-8"))
assert {h["event"] for h in parsed["hooks"]} == {"PreToolUse", "PermissionRequest"}
+158
View File
@@ -0,0 +1,158 @@
"""Unit tests for the kimi-native transcript forwarder.
Covers the pure parsing/discovery helpers against kimi's real ``wire.jsonl``
event schema (turn.prompt + content.part), the line-offset state round-trip,
and workspace/recency-based session discovery. The live POST loop is exercised
by the e2e gate, not here.
"""
from __future__ import annotations
import json
from pathlib import Path
from omnigent.kimi_native_forwarder import (
_discover_wire,
_ForwardState,
_read_new_items,
_read_state,
_row_to_item,
_write_state,
clear_kimi_bridge_state,
)
class TestRowToItem:
def test_turn_prompt_is_user(self) -> None:
row = {
"type": "turn.prompt",
"input": [{"type": "text", "text": "what is in this repo?"}],
"origin": {"kind": "user"},
}
item = _row_to_item(4, row)
assert item is not None
assert item.role == "user"
assert item.text == "what is in this repo?"
assert item.response_id == "kimi:turn:4"
def test_content_part_text_is_assistant(self) -> None:
row = {
"type": "context.append_loop_event",
"event": {
"type": "content.part",
"uuid": "67ce67f7",
"part": {"type": "text", "text": "This is **Omnigent**."},
},
}
item = _row_to_item(9, row)
assert item is not None
assert item.role == "assistant"
assert item.text == "This is **Omnigent**."
assert item.response_id == "kimi:67ce67f7"
def test_think_part_is_skipped(self) -> None:
row = {
"type": "context.append_loop_event",
"event": {"type": "content.part", "part": {"type": "think", "think": "reasoning"}},
}
assert _row_to_item(5, row) is None
def test_tool_call_and_metadata_skipped(self) -> None:
for row in (
{"type": "context.append_loop_event", "event": {"type": "tool.call", "name": "Read"}},
{"type": "metadata", "protocol_version": 1},
{"type": "usage.record", "usage": {}},
{"type": "context.append_message", "message": {"role": "user", "content": []}},
):
assert _row_to_item(0, row) is None
def test_non_user_turn_prompt_skipped(self) -> None:
row = {"type": "turn.prompt", "input": [{"type": "text", "text": "x"}],
"origin": {"kind": "system"}}
assert _row_to_item(0, row) is None
class TestReadNewItems:
def _wire(self, tmp_path: Path) -> Path:
def _part(uuid: str, part_type: str, text: str) -> dict[str, object]:
return {
"type": "context.append_loop_event",
"event": {"type": "content.part", "uuid": uuid,
"part": {"type": part_type, "text": text}},
}
rows = [
{"type": "metadata", "protocol_version": 1},
{"type": "turn.prompt", "input": [{"type": "text", "text": "hi"}],
"origin": {"kind": "user"}},
_part("u1", "think", ""),
_part("u2", "text", "hello!"),
]
p = tmp_path / "wire.jsonl"
p.write_text("\n".join(json.dumps(r) for r in rows) + "\n", encoding="utf-8")
return p
def test_parses_user_and_assistant_only(self, tmp_path: Path) -> None:
items = _read_new_items(self._wire(tmp_path), 0)
assert [(i.role, i.text) for i in items] == [("user", "hi"), ("assistant", "hello!")]
def test_offset_skips_already_seen(self, tmp_path: Path) -> None:
wire = self._wire(tmp_path)
# last_line past the user prompt (line 1) → only the assistant text (line 3).
items = _read_new_items(wire, 2)
assert [(i.role, i.text) for i in items] == [("assistant", "hello!")]
assert items[0].line_no == 3
def test_missing_file_is_empty(self, tmp_path: Path) -> None:
assert _read_new_items(tmp_path / "nope.jsonl", 0) == []
class TestState:
def test_round_trip_and_clear(self, tmp_path: Path) -> None:
assert _read_state(tmp_path) is None
_write_state(tmp_path, _ForwardState(wire_path="/x/wire.jsonl", last_line=7))
loaded = _read_state(tmp_path)
assert loaded is not None
assert loaded.wire_path == "/x/wire.jsonl"
assert loaded.last_line == 7
clear_kimi_bridge_state(tmp_path)
assert _read_state(tmp_path) is None
class TestDiscoverWire:
def _make_session(
self, home: Path, session_dir_name: str, work_dir: str, *, mtime: float
) -> Path:
wire = home / "sessions" / "wd_x" / session_dir_name / "agents" / "main" / "wire.jsonl"
wire.parent.mkdir(parents=True, exist_ok=True)
wire.write_text("{}\n", encoding="utf-8")
import os
os.utime(wire, (mtime, mtime))
# session_index keys on the session dir (…/<wd_…>/<session_…>).
idx = home / "session_index.jsonl"
index_row = {"sessionDir": str(wire.parent.parent.parent), "workDir": work_dir}
with idx.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(index_row) + "\n")
return wire
def test_picks_newest_matching_workspace(self, tmp_path: Path) -> None:
home = tmp_path / "kimi-code-home"
home.mkdir()
self._make_session(home, "session_old", "/ws", mtime=1000.0)
newest = self._make_session(home, "session_new", "/ws", mtime=2000.0)
self._make_session(home, "session_other", "/different", mtime=3000.0)
found = _discover_wire(home, "/ws", launch_epoch_ms=0)
assert found == newest
def test_none_before_any_session(self, tmp_path: Path) -> None:
home = tmp_path / "kimi-code-home"
home.mkdir()
assert _discover_wire(home, "/ws", launch_epoch_ms=0) is None
def test_ignores_sessions_before_launch(self, tmp_path: Path) -> None:
home = tmp_path / "kimi-code-home"
home.mkdir()
self._make_session(home, "session_stale", "/ws", mtime=1000.0)
# launch far in the future (ms) → the 1000s-mtime session is below the floor.
assert _discover_wire(home, "/ws", launch_epoch_ms=9_000_000_000_000) is None
+231
View File
@@ -0,0 +1,231 @@
"""Tests for the kimi-native tool-policy hook commands."""
from __future__ import annotations
import io
import json
import sys
from pathlib import Path
import httpx
import pytest
from omnigent import kimi_native_hook
from omnigent.kimi_native_bridge import APPROVE_KEY, DENY_KEY, write_hook_config
from omnigent.native_policy_hook import _EVAL_UNAVAILABLE_REASON
def _governed_bridge(tmp_path: Path, *, server: str = "http://127.0.0.1:8787") -> Path:
"""Make a bridge dir with a hook_config so the session reads as governed."""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
write_hook_config(
bridge_dir,
server_url=server,
headers={"Authorization": "Bearer t"},
session_id="conv_abc",
)
return bridge_dir
def _feed_stdin(monkeypatch: pytest.MonkeyPatch, payload: dict[str, object]) -> None:
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
def test_evaluate_policy_deny_emits_kimi_decision(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""A DENY verdict becomes kimi's ``permissionDecision: deny`` + reason."""
bridge_dir = _governed_bridge(tmp_path)
_feed_stdin(
monkeypatch,
{
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {"command": "rm -rf /"},
},
)
monkeypatch.setattr(
kimi_native_hook,
"post_evaluate_with_retry",
lambda *a, **k: httpx.Response(
200,
json={"result": "POLICY_ACTION_DENY", "reason": "blocked by policy"},
request=httpx.Request("POST", "http://x"),
),
)
exit_code = kimi_native_hook.main(["evaluate-policy", "--bridge-dir", str(bridge_dir)])
assert exit_code == 0
out = json.loads(capsys.readouterr().out)
assert out["hookSpecificOutput"]["permissionDecision"] == "deny"
assert out["hookSpecificOutput"]["permissionDecisionReason"] == "blocked by policy"
def test_evaluate_policy_allow_emits_nothing(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""ALLOW (engine default) emits no output so kimi's own prompt still runs."""
bridge_dir = _governed_bridge(tmp_path)
_feed_stdin(
monkeypatch,
{"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {}},
)
monkeypatch.setattr(
kimi_native_hook,
"post_evaluate_with_retry",
lambda *a, **k: httpx.Response(
200, json={"result": "POLICY_ACTION_ALLOW"}, request=httpx.Request("POST", "http://x")
),
)
exit_code = kimi_native_hook.main(["evaluate-policy", "--bridge-dir", str(bridge_dir)])
assert exit_code == 0
assert capsys.readouterr().out == ""
def test_evaluate_policy_ungoverned_session_no_opinion(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""No hook_config (no session/server) → exit 0, no output, no POST."""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
_feed_stdin(monkeypatch, {"hook_event_name": "PreToolUse", "tool_name": "Bash"})
def _boom(*_a: object, **_k: object) -> object:
raise AssertionError("must not POST for an ungoverned session")
monkeypatch.setattr(kimi_native_hook, "post_evaluate_with_retry", _boom)
exit_code = kimi_native_hook.main(["evaluate-policy", "--bridge-dir", str(bridge_dir)])
assert exit_code == 0
assert capsys.readouterr().out == ""
def test_evaluate_policy_fails_closed_when_unreachable(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""A governed PreToolUse with no usable verdict fails CLOSED (deny)."""
bridge_dir = _governed_bridge(tmp_path)
_feed_stdin(monkeypatch, {"hook_event_name": "PreToolUse", "tool_name": "Bash"})
monkeypatch.setattr(kimi_native_hook, "post_evaluate_with_retry", lambda *a, **k: None)
exit_code = kimi_native_hook.main(["evaluate-policy", "--bridge-dir", str(bridge_dir)])
assert exit_code == 0
out = json.loads(capsys.readouterr().out)
assert out["hookSpecificOutput"]["permissionDecision"] == "deny"
assert out["hookSpecificOutput"]["permissionDecisionReason"] == _EVAL_UNAVAILABLE_REASON
def _capture_injection(monkeypatch: pytest.MonkeyPatch) -> list[str]:
"""Patch ``inject_approval_keystroke`` to record the option keys it gets."""
keys: list[str] = []
def _fake_inject(bridge_dir: Path, *, key: str, timeout_s: float = 0.0) -> bool:
del bridge_dir, timeout_s
keys.append(key)
return True
monkeypatch.setattr(kimi_native_hook, "inject_approval_keystroke", _fake_inject)
return keys
@pytest.mark.parametrize(
("verdict", "expected_key"),
[("allow", APPROVE_KEY), ("deny", DENY_KEY)],
)
def test_permission_request_injects_keystroke_for_verdict(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
verdict: str,
expected_key: str,
) -> None:
"""A web Approve/Deny verdict is typed into kimi's prompt as the option digit."""
bridge_dir = _governed_bridge(tmp_path)
_feed_stdin(
monkeypatch,
{"hook_event_name": "PermissionRequest", "tool_name": "Bash", "tool_call_id": "tc_1"},
)
posted: list[dict[str, object]] = []
monkeypatch.setattr(
kimi_native_hook,
"_request_web_approval",
lambda url, headers, body: posted.append({"url": url, "body": body}) or verdict,
)
keys = _capture_injection(monkeypatch)
exit_code = kimi_native_hook.main(["permission-request", "--bridge-dir", str(bridge_dir)])
assert exit_code == 0
# Routed to the shared elicitation endpoint with the gated tool.
assert posted[0]["url"].endswith("/v1/sessions/conv_abc/hooks/permission-request")
assert posted[0]["body"]["tool_name"] == "Bash"
assert keys == [expected_key]
def test_permission_request_no_verdict_injects_nothing(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""No web verdict (timeout/unreachable/answered in terminal) → no keystroke."""
bridge_dir = _governed_bridge(tmp_path)
_feed_stdin(monkeypatch, {"hook_event_name": "PermissionRequest", "tool_name": "Bash"})
monkeypatch.setattr(kimi_native_hook, "_request_web_approval", lambda *a, **k: None)
keys = _capture_injection(monkeypatch)
assert kimi_native_hook.main(["permission-request", "--bridge-dir", str(bridge_dir)]) == 0
assert keys == []
def test_permission_request_ungoverned_no_request(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""No hook_config → no approval request and no keystroke (never raises)."""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
_feed_stdin(monkeypatch, {"hook_event_name": "PermissionRequest", "tool_name": "Bash"})
def _boom(*_a: object, **_k: object) -> str | None:
raise AssertionError("ungoverned session must not request approval")
monkeypatch.setattr(kimi_native_hook, "_request_web_approval", _boom)
keys = _capture_injection(monkeypatch)
assert kimi_native_hook.main(["permission-request", "--bridge-dir", str(bridge_dir)]) == 0
assert keys == []
@pytest.mark.parametrize(
("response", "expected"),
[
({"hookSpecificOutput": {"decision": {"behavior": "allow"}}}, "allow"),
({"hookSpecificOutput": {"decision": {"behavior": "deny"}}}, "deny"),
({"hookSpecificOutput": {"permissionDecision": "allow"}}, "allow"),
({"hookSpecificOutput": {"permissionDecision": "deny"}}, "deny"),
({"hookSpecificOutput": {"decision": {"behavior": "allow_always"}}}, "allow"),
({"hookSpecificOutput": {"decision": {"behavior": "reject"}}}, "deny"),
({}, None),
({"hookSpecificOutput": {}}, None),
({"hookSpecificOutput": {"decision": {"behavior": "huh"}}}, None),
("not a dict", None),
],
)
def test_verdict_from_response(response: object, expected: str | None) -> None:
assert kimi_native_hook._verdict_from_response(response) == expected
def test_unknown_subcommand_returns_2(capsys: pytest.CaptureFixture[str]) -> None:
assert kimi_native_hook.main(["bogus", "--bridge-dir", "/tmp/x"]) == 2
+14 -1
View File
@@ -33,10 +33,23 @@ def test_native_pi_alias_resolves_like_canonical() -> None:
def test_canonical_native_harnesses_resolve() -> None:
"""The canonical native spellings all resolve to their agents."""
for harness in ("claude-native", "codex-native", "pi-native"):
for harness in ("claude-native", "codex-native", "pi-native", "cursor-native", "kimi-native"):
assert native_coding_agent_for_harness(harness) is not None
def test_native_kimi_alias_resolves_like_canonical() -> None:
"""``native-kimi`` resolves to the same native agent as ``kimi-native``.
Mirrors the ``native-pi`` fold: ``canonicalize_harness`` maps the reversed
spelling to the canonical id so a forked/switched kimi-native agent keeps
its terminal-first presentation labels.
"""
agent = native_coding_agent_for_harness("native-kimi")
assert agent is not None
assert agent is native_coding_agent_for_harness("kimi-native")
assert agent.terminal_name == "kimi"
def test_unknown_harness_returns_none() -> None:
"""A non-native harness stays unresolved (no terminal presentation)."""
assert native_coding_agent_for_harness("claude-sdk") is None