playmaker-cli 0.10.0__tar.gz → 0.11.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/CHANGELOG.md +12 -0
  2. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/PKG-INFO +15 -8
  3. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/README.md +12 -5
  4. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/pyproject.toml +4 -2
  5. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/skills/playmaker-coach/references/agent-gotchas.md +16 -0
  6. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/skills/playmaker-coach/references/lanes.md +5 -2
  7. playmaker_cli-0.11.0/src/playmaker/agents/kimi.py +459 -0
  8. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/cli.py +1 -1
  9. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/registry.py +2 -0
  10. playmaker_cli-0.11.0/tests/fixtures/kimi_wire.jsonl +5 -0
  11. playmaker_cli-0.11.0/tests/test_kimi.py +333 -0
  12. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/.gitignore +0 -0
  13. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/LICENSE +0 -0
  14. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/skills/playmaker-coach/SKILL.md +0 -0
  15. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/skills/playmaker-coach/references/commands.md +0 -0
  16. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/skills/playmaker-coach/references/prompt-templates.md +0 -0
  17. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/skills/playmaker-coach/references/quotas.md +0 -0
  18. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/skills/playmaker-coach/references/review-board.md +0 -0
  19. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/skills/playmaker-coach/scripts/review-board.sh +0 -0
  20. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/__init__.py +0 -0
  21. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/__main__.py +0 -0
  22. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/agents/__init__.py +0 -0
  23. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/agents/agy.py +0 -0
  24. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/agents/base.py +0 -0
  25. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/agents/claude.py +0 -0
  26. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/agents/codex.py +0 -0
  27. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/agents/gemini.py +0 -0
  28. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/agents/opencode.py +0 -0
  29. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/config.py +0 -0
  30. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/notify.py +0 -0
  31. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/quotas.py +0 -0
  32. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/state.py +0 -0
  33. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/watcher.py +0 -0
  34. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/__init__.py +0 -0
  35. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_agy.py +0 -0
  36. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_batch.py +0 -0
  37. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_binary.py +0 -0
  38. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_claude.py +0 -0
  39. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_codex.py +0 -0
  40. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_no_changes.py +0 -0
  41. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_opencode.py +0 -0
  42. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_permissions.py +0 -0
  43. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_quotas_antigravity.py +0 -0
  44. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_quotas_claude.py +0 -0
  45. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_quotas_codex.py +0 -0
  46. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_quotas_freshness.py +0 -0
  47. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_quotas_zai.py +0 -0
  48. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_registry.py +0 -0
  49. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_skill.py +0 -0
  50. {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_state.py +0 -0
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.11.0] - 2026-09-04
9
+
10
+ ### Added
11
+
12
+ - **`kimi` lane — the Kimi Code CLI as a first-class agent.**
13
+ `playmaker dispatch kimi -m kimi-code/k3-256k` runs `kimi -p …
14
+ --output-format stream-json`, tracks the session, resumes with `-S`, and
15
+ reads the per-cwd `wire.jsonl` for `summary`. K3 is a senior-tier model on a
16
+ subscription nobody else on the machine draws from, and routing it through
17
+ opencode would have needed a second credential; the native CLI reuses its
18
+ own OAuth login.
19
+
8
20
  ## [0.10.0] - 2026-09-04
9
21
 
10
22
  ### Added
@@ -1,14 +1,14 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: playmaker-cli
3
- Version: 0.10.0
4
- Summary: Playing-coach CLI for orchestrating Claude Code, Codex, Antigravity and opencode sub-agents in parallel.
3
+ Version: 0.11.0
4
+ Summary: Playing-coach CLI for orchestrating Claude Code, Codex, Antigravity, Kimi Code and opencode sub-agents in parallel.
5
5
  Project-URL: Homepage, https://github.com/vladsafedev/playmaker
6
6
  Project-URL: Repository, https://github.com/vladsafedev/playmaker
7
7
  Project-URL: Issues, https://github.com/vladsafedev/playmaker/issues
8
8
  Author-email: Vladislav Shulyugin <vladislav.shulyugin@gmail.com>
9
9
  License-Expression: MIT
10
10
  License-File: LICENSE
11
- Keywords: agents,ai,antigravity,claude,claude-code,cli,codex,gemini,glm,opencode,orchestration,subagents
11
+ Keywords: agents,ai,antigravity,claude,claude-code,cli,codex,gemini,glm,kimi,kimi-code,opencode,orchestration,subagents
12
12
  Classifier: Development Status :: 3 - Alpha
13
13
  Classifier: Environment :: Console
14
14
  Classifier: Intended Audience :: Developers
@@ -32,7 +32,7 @@ Description-Content-Type: text/markdown
32
32
  [![Python](https://img.shields.io/pypi/pyversions/playmaker-cli.svg?cacheSeconds=3600)](https://pypi.org/project/playmaker-cli/)
33
33
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
34
34
 
35
- **Run Claude Code, Codex, Antigravity and opencode as parallel sub-agents from one terminal — and spend separate quotas instead of one.**
35
+ **Run Claude Code, Codex, Antigravity, Kimi Code and opencode as parallel sub-agents from one terminal — and spend separate quotas instead of one.**
36
36
 
37
37
  You stay in your Claude Code session doing the part only you can do. `playmaker`
38
38
  fans the rest out to other agent CLIs as detached processes, tracks them,
@@ -74,10 +74,11 @@ in one serial session:
74
74
  1. **Wall-clock speed.** A task that decomposes into 3–5 independent
75
75
  work-streams (schema, backend, frontend, tests, docs) finishes 2–4× faster
76
76
  when each stream runs as its own parallel agent.
77
- 2. **Provider arbitrage.** Codex, Antigravity and opencode quotas are entirely
78
- separate pools from your Anthropic plan. Every slice you hand them is
79
- capacity your main session never spends — and Antigravity's roster includes
80
- Claude Sonnet/Opus, so even Claude-quality work can run on Google's pool.
77
+ 2. **Provider arbitrage.** Codex, Antigravity, Kimi Code and opencode quotas
78
+ are entirely separate pools from your Anthropic plan. Every slice you hand
79
+ them is capacity your main session never spends — and Antigravity's roster
80
+ includes Claude Sonnet/Opus, so even Claude-quality work can run on Google's
81
+ pool. Kimi Code brings its own subscription with the senior-tier K3.
81
82
  `opencode` widens this the most: one CLI fronting ~75 providers, from a z.ai
82
83
  GLM coding plan to models running locally on your own machine.
83
84
  3. **Bucket arbitrage inside one plan.** Headless `claude -p` draws on the same
@@ -146,6 +147,10 @@ The other agents differ, because their CLIs do:
146
147
  { "permission": { "edit": "allow", "bash": "allow", "webfetch": "deny" } }
147
148
  ```
148
149
 
150
+ - **kimi** has no permission flags at all in headless mode — `kimi -p` refuses
151
+ `--auto`, `--yolo` and `--plan`, and already runs with auto-approval, so
152
+ there is no read-only mode below the prompt.
153
+
149
154
  - **gemini** (legacy) runs with `--yolo`.
150
155
 
151
156
  ## Install
@@ -183,6 +188,7 @@ playmaker agents # which agent CLIs are reachable
183
188
  | **Codex CLI** | `npm i -g @openai/codex` | the model roster depends on your plan; omit `--model` to use the account default |
184
189
  | **Antigravity (`agy`)** | bundled with [Antigravity](https://antigravity.google) | `--model claude-opus-4-6-thinking` — the roster moves, so read it from `agy models` |
185
190
  | **opencode** | `brew install sst/tap/opencode` (or see [opencode.ai](https://opencode.ai)) | `--model provider/model`, e.g. `zai-coding-plan/glm-5.2`; roster from `opencode models`, providers from `opencode auth login` |
191
+ | **Kimi Code CLI** | `npm i -g @moonshot-ai/kimi-code` | needs Node ≥ 22.19 (a wrapper that pins a newer Node is fine — point `[agents.kimi] binary` at it); `--model kimi-code/k3-256k` — 256k window at half the quota cost of `k3`; log in once with `kimi login --region global`; model ids from `~/.kimi-code/config.toml` |
186
192
  | **Gemini CLI** (legacy) | `npm i -g @google/gemini-cli` | still supported, superseded by `agy` |
187
193
 
188
194
  At least one is required; `playmaker agents` tells you which it can see.
@@ -317,6 +323,7 @@ and locates the session file the tool writes locally. Empirically:
317
323
  | Codex | `~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-<ts>-<thread_id>.jsonl` |
318
324
  | Antigravity | `~/.gemini/antigravity-cli/brain/<conversation-id>/.system_generated/logs/transcript_full.jsonl` |
319
325
  | opencode | SQLite — `~/.local/share/opencode/opencode.db` (`session` / `message` / `part`); playmaker keeps a pointer at `~/.playmaker/opencode/<id>.session` |
326
+ | kimi | `~/.kimi-code/sessions/wd_<cwd-basename>_<hash>/session_<id>/agents/main/wire.jsonl` (per-cwd; `KIMI_CODE_HOME` overrides the root) |
320
327
  | Gemini | `~/.gemini/tmp/<cwd-basename>/chats/session-<ts>-<short_id>.{json,jsonl}` |
321
328
 
322
329
  `thread` and `summary` normalize all of them into the same turn list, so every
@@ -5,7 +5,7 @@
5
5
  [![Python](https://img.shields.io/pypi/pyversions/playmaker-cli.svg?cacheSeconds=3600)](https://pypi.org/project/playmaker-cli/)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
7
 
8
- **Run Claude Code, Codex, Antigravity and opencode as parallel sub-agents from one terminal — and spend separate quotas instead of one.**
8
+ **Run Claude Code, Codex, Antigravity, Kimi Code and opencode as parallel sub-agents from one terminal — and spend separate quotas instead of one.**
9
9
 
10
10
  You stay in your Claude Code session doing the part only you can do. `playmaker`
11
11
  fans the rest out to other agent CLIs as detached processes, tracks them,
@@ -47,10 +47,11 @@ in one serial session:
47
47
  1. **Wall-clock speed.** A task that decomposes into 3–5 independent
48
48
  work-streams (schema, backend, frontend, tests, docs) finishes 2–4× faster
49
49
  when each stream runs as its own parallel agent.
50
- 2. **Provider arbitrage.** Codex, Antigravity and opencode quotas are entirely
51
- separate pools from your Anthropic plan. Every slice you hand them is
52
- capacity your main session never spends — and Antigravity's roster includes
53
- Claude Sonnet/Opus, so even Claude-quality work can run on Google's pool.
50
+ 2. **Provider arbitrage.** Codex, Antigravity, Kimi Code and opencode quotas
51
+ are entirely separate pools from your Anthropic plan. Every slice you hand
52
+ them is capacity your main session never spends — and Antigravity's roster
53
+ includes Claude Sonnet/Opus, so even Claude-quality work can run on Google's
54
+ pool. Kimi Code brings its own subscription with the senior-tier K3.
54
55
  `opencode` widens this the most: one CLI fronting ~75 providers, from a z.ai
55
56
  GLM coding plan to models running locally on your own machine.
56
57
  3. **Bucket arbitrage inside one plan.** Headless `claude -p` draws on the same
@@ -119,6 +120,10 @@ The other agents differ, because their CLIs do:
119
120
  { "permission": { "edit": "allow", "bash": "allow", "webfetch": "deny" } }
120
121
  ```
121
122
 
123
+ - **kimi** has no permission flags at all in headless mode — `kimi -p` refuses
124
+ `--auto`, `--yolo` and `--plan`, and already runs with auto-approval, so
125
+ there is no read-only mode below the prompt.
126
+
122
127
  - **gemini** (legacy) runs with `--yolo`.
123
128
 
124
129
  ## Install
@@ -156,6 +161,7 @@ playmaker agents # which agent CLIs are reachable
156
161
  | **Codex CLI** | `npm i -g @openai/codex` | the model roster depends on your plan; omit `--model` to use the account default |
157
162
  | **Antigravity (`agy`)** | bundled with [Antigravity](https://antigravity.google) | `--model claude-opus-4-6-thinking` — the roster moves, so read it from `agy models` |
158
163
  | **opencode** | `brew install sst/tap/opencode` (or see [opencode.ai](https://opencode.ai)) | `--model provider/model`, e.g. `zai-coding-plan/glm-5.2`; roster from `opencode models`, providers from `opencode auth login` |
164
+ | **Kimi Code CLI** | `npm i -g @moonshot-ai/kimi-code` | needs Node ≥ 22.19 (a wrapper that pins a newer Node is fine — point `[agents.kimi] binary` at it); `--model kimi-code/k3-256k` — 256k window at half the quota cost of `k3`; log in once with `kimi login --region global`; model ids from `~/.kimi-code/config.toml` |
159
165
  | **Gemini CLI** (legacy) | `npm i -g @google/gemini-cli` | still supported, superseded by `agy` |
160
166
 
161
167
  At least one is required; `playmaker agents` tells you which it can see.
@@ -290,6 +296,7 @@ and locates the session file the tool writes locally. Empirically:
290
296
  | Codex | `~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-<ts>-<thread_id>.jsonl` |
291
297
  | Antigravity | `~/.gemini/antigravity-cli/brain/<conversation-id>/.system_generated/logs/transcript_full.jsonl` |
292
298
  | opencode | SQLite — `~/.local/share/opencode/opencode.db` (`session` / `message` / `part`); playmaker keeps a pointer at `~/.playmaker/opencode/<id>.session` |
299
+ | kimi | `~/.kimi-code/sessions/wd_<cwd-basename>_<hash>/session_<id>/agents/main/wire.jsonl` (per-cwd; `KIMI_CODE_HOME` overrides the root) |
293
300
  | Gemini | `~/.gemini/tmp/<cwd-basename>/chats/session-<ts>-<short_id>.{json,jsonl}` |
294
301
 
295
302
  `thread` and `summary` normalize all of them into the same turn list, so every
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "playmaker-cli"
3
- version = "0.10.0"
4
- description = "Playing-coach CLI for orchestrating Claude Code, Codex, Antigravity and opencode sub-agents in parallel."
3
+ version = "0.11.0"
4
+ description = "Playing-coach CLI for orchestrating Claude Code, Codex, Antigravity, Kimi Code and opencode sub-agents in parallel."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
7
7
  license = "MIT"
@@ -19,6 +19,8 @@ keywords = [
19
19
  "codex",
20
20
  "antigravity",
21
21
  "gemini",
22
+ "kimi",
23
+ "kimi-code",
22
24
  "opencode",
23
25
  "glm",
24
26
  "subagents",
@@ -65,6 +65,22 @@ line from `agy models` / `opencode models` rather than typing it.
65
65
  - Neither trap applies to **review** dispatches, which write nothing — which makes opencode a
66
66
  perfectly good reviewer even where it is a shaky implementer.
67
67
 
68
+ ## kimi (Kimi Code CLI)
69
+
70
+ - There is **no read-only mode below the prompt**: `-p` refuses `--auto`, `--yolo` and `--plan`
71
+ (exit 1) and already runs with auto-approval. Only dispatch work you would run unattended anyway.
72
+ - The session id arrives **only in the trailing `session.resume_hint` line**, so `playmaker list`
73
+ shows the agent session late — do not conclude a dispatch failed just because the id has not
74
+ appeared yet.
75
+ - Sessions are **per-cwd**: `kimi session list` from another directory shows nothing. Track the
76
+ session through playmaker, not through the CLI.
77
+ - The stream carries **no token/cost fields** — there is nothing to budget against mid-run.
78
+ - Exit codes: **exit 1 is non-retryable** (auth, quota, unknown model — "is not configured in
79
+ config.toml"); **exit 75 is retryable**. Fix the cause on 1, re-dispatch on 75.
80
+ - K3 is **slow on real tickets** — never `--sync` a big WP; dispatch detached and poll.
81
+ - Needs **Node ≥ 22.19** — hence the wrapper binary; point `[agents.kimi] binary` at it.
82
+ - **Always pass `-m kimi-code/k3-256k`** — the CLI default is the weaker K2.7 `kimi-for-coding`.
83
+
68
84
  ## Worktrees
69
85
 
70
86
  Parallel WPs that touch the same files collide. Give each its own git worktree and dispatch with
@@ -18,8 +18,9 @@ pays. Four lanes:
18
18
  (`haiku` for trivial mechanical work). playmaker runs it in `acceptEdits`: it writes freely
19
19
  inside `--cwd` and is refused outside it, so keep every path in the prompt inside `--cwd`.
20
20
 
21
- 4. **External dispatch — `codex` / `agy` / `opencode`.** Each on its own subscription or plan — the
22
- home for write-heavy parallel implementation that can leave the Anthropic subscription.
21
+ 4. **External dispatch — `codex` / `agy` / `opencode` / `kimi`.** Each on its own subscription or
22
+ plan — the home for write-heavy parallel implementation that can leave the Anthropic
23
+ subscription.
23
24
  - **`agy` (Antigravity)** carries more than Google models: alongside Gemini Flash and Pro tiers it
24
25
  serves **Claude Sonnet/Opus (Thinking)** and a GPT-OSS tier. Its Claude runs on *Google's*
25
26
  pool and spends none of the Anthropic bucket — but that roster has trailed Anthropic's own
@@ -29,6 +30,8 @@ pays. Four lanes:
29
30
  - **`opencode`** is the widest lane: one CLI over ~75 providers addressed as `provider/model` — a
30
31
  GLM coding plan, or a model running locally on this machine, which spends no subscription quota
31
32
  at all.
33
+ - **`kimi`** runs the Kimi Code CLI on its own subscription: senior tier (K3), native login, no
34
+ opencode.
32
35
 
33
36
  **Never write an agy or opencode model name from memory** — run `agy models` / `opencode models` and
34
37
  copy a line. Both rosters and their spelling move with releases, and playmaker validates `--model`
@@ -0,0 +1,459 @@
1
+ """Kimi Code CLI handler.
2
+
3
+ Empirically (kimi-code 0.41.0):
4
+ - oneshot: `kimi -m <model> -p "<prompt>" --output-format stream-json`; resume
5
+ uses `kimi -S <session_id> -p ...` in the same working directory
6
+ - prompt mode auto-approves tool calls already; it refuses `--auto`, `--yolo`,
7
+ and `--plan`, and it has no read-only flag
8
+ - stream-json is JSONL. The final assistant text is in `role: assistant`
9
+ `content`; the session id arrives only in a trailing
10
+ `meta/session.resume_hint` event
11
+ - exit 1 is non-retryable (auth, quota, or an unknown model); exit 75 is
12
+ retryable (rate limit or 5xx). This handler does not retry; callers may
13
+ re-dispatch after an exit-75 failure
14
+ - sessions live under `$KIMI_CODE_HOME` (or `~/.kimi-code`):
15
+ `sessions/wd_*/session_<uuid>/agents/main/wire.jsonl`, with `state.json`
16
+ alongside the `agents/` directory
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import shutil
24
+ import subprocess
25
+ import threading
26
+ import time
27
+ from datetime import UTC, datetime
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from playmaker.agents.base import DispatchResult, SessionStartedCallback, Turn
32
+ from playmaker.config import agent_binary, agent_setting
33
+
34
+
35
+ def kimi_code_home() -> Path:
36
+ """Kimi's session root, respecting its documented environment override."""
37
+ configured = os.environ.get("KIMI_CODE_HOME")
38
+ return Path(configured).expanduser() if configured else Path("~/.kimi-code").expanduser()
39
+
40
+
41
+ class KimiHandler:
42
+ name = "kimi"
43
+
44
+ def is_available(self) -> bool:
45
+ return shutil.which(agent_binary("kimi")) is not None
46
+
47
+ def dispatch(
48
+ self,
49
+ prompt: str,
50
+ cwd: Path,
51
+ files: list[Path] | None = None,
52
+ on_session_started: SessionStartedCallback | None = None,
53
+ model: str | None = None,
54
+ ) -> DispatchResult:
55
+ return self._run(
56
+ prompt,
57
+ cwd,
58
+ files or [],
59
+ on_session_started=on_session_started,
60
+ model=model,
61
+ session_id=None,
62
+ )
63
+
64
+ def resume(
65
+ self,
66
+ prompt: str,
67
+ cwd: Path,
68
+ agent_session_id: str,
69
+ files: list[Path] | None = None,
70
+ on_session_started: SessionStartedCallback | None = None,
71
+ model: str | None = None,
72
+ ) -> DispatchResult:
73
+ return self._run(
74
+ prompt,
75
+ cwd,
76
+ files or [],
77
+ on_session_started=on_session_started,
78
+ model=model,
79
+ session_id=agent_session_id,
80
+ )
81
+
82
+ def _run(
83
+ self,
84
+ prompt: str,
85
+ cwd: Path,
86
+ files: list[Path],
87
+ *,
88
+ on_session_started: SessionStartedCallback | None,
89
+ model: str | None,
90
+ session_id: str | None,
91
+ ) -> DispatchResult:
92
+ effective_model = model or agent_setting("kimi", "model")
93
+ cmd = [agent_binary("kimi")]
94
+ if session_id:
95
+ cmd += ["-S", session_id]
96
+ if effective_model:
97
+ cmd += ["-m", str(effective_model)]
98
+ cmd += ["-p", self._build_prompt(prompt, files), "--output-format", "stream-json"]
99
+
100
+ t0 = time.monotonic()
101
+ proc = subprocess.Popen(
102
+ cmd,
103
+ cwd=str(cwd),
104
+ stdout=subprocess.PIPE,
105
+ stderr=subprocess.PIPE,
106
+ text=True,
107
+ bufsize=1,
108
+ )
109
+
110
+ agent_session_id: str | None = None
111
+ assistant_contents: list[str] = []
112
+ first_lines: list[str] = []
113
+ stderr_parts: list[str] = []
114
+ stderr = proc.stderr
115
+
116
+ def drain_stderr() -> None:
117
+ if stderr is not None:
118
+ stderr_parts.append(stderr.read())
119
+
120
+ stderr_thread = threading.Thread(target=drain_stderr, daemon=True)
121
+ stderr_thread.start()
122
+
123
+ def remember_session_id(candidate: object) -> None:
124
+ nonlocal agent_session_id
125
+ if agent_session_id is not None or not isinstance(candidate, str) or not candidate:
126
+ return
127
+ agent_session_id = candidate
128
+ if on_session_started is not None:
129
+ try:
130
+ on_session_started(candidate)
131
+ except Exception:
132
+ pass
133
+
134
+ assert proc.stdout is not None
135
+ for raw in proc.stdout:
136
+ if len(first_lines) < 3:
137
+ first_lines.append(raw)
138
+ line = raw.strip()
139
+ if not line:
140
+ continue
141
+ try:
142
+ event = json.loads(line)
143
+ except json.JSONDecodeError:
144
+ continue
145
+ if not isinstance(event, dict):
146
+ continue
147
+
148
+ if event.get("role") == "assistant":
149
+ content = event.get("content")
150
+ if isinstance(content, str) and content:
151
+ assistant_contents.append(content)
152
+ elif event.get("role") == "meta" and event.get("type") == "session.resume_hint":
153
+ remember_session_id(event.get("session_id"))
154
+
155
+ proc.wait()
156
+ stderr_thread.join()
157
+ stderr_text = "".join(stderr_parts)
158
+ duration = time.monotonic() - t0
159
+
160
+ if proc.returncode != 0:
161
+ detail = stderr_text.strip() or "".join(first_lines)[:500] or "(no error output)"
162
+ raise RuntimeError(f"kimi failed (exit {proc.returncode}): {detail}")
163
+
164
+ if agent_session_id is None:
165
+ remember_session_id(self._latest_session_id(cwd))
166
+ if agent_session_id is None:
167
+ raise RuntimeError(
168
+ "kimi stream-json missing session.resume_hint; first lines:\n"
169
+ f"{''.join(first_lines)[:500]}"
170
+ )
171
+
172
+ session_file = self.find_session_file(agent_session_id, cwd)
173
+ initial_output = next(
174
+ (content for content in reversed(assistant_contents) if content.strip()), ""
175
+ ) or self._last_assistant_text(session_file)
176
+ return DispatchResult(
177
+ agent_session_id=agent_session_id,
178
+ cwd=str(cwd),
179
+ session_file=session_file,
180
+ initial_output=initial_output,
181
+ cost_usd=None,
182
+ duration_seconds=duration,
183
+ exit_code=proc.returncode,
184
+ )
185
+
186
+ def _latest_session_id(self, cwd: Path) -> str | None:
187
+ """Ask Kimi's per-working-directory index for its most recent session."""
188
+ try:
189
+ proc = subprocess.run(
190
+ [agent_binary("kimi"), "session", "list", "--json"],
191
+ cwd=str(cwd),
192
+ capture_output=True,
193
+ text=True,
194
+ )
195
+ except (OSError, subprocess.SubprocessError):
196
+ return None
197
+ if proc.returncode != 0:
198
+ return None
199
+
200
+ entries = self._session_list_entries(proc.stdout)
201
+ # `kimi session list --help` says entries are most recently updated
202
+ # first. Sort timestamped listings too, so an unordered response does
203
+ # not bind a dispatch to an older session.
204
+ entries.sort(key=self._session_entry_timestamp, reverse=True)
205
+ expected_cwd = self._resolved_cwd(cwd)
206
+ for entry in entries:
207
+ if not isinstance(entry, dict):
208
+ continue
209
+ listed_cwd = entry.get("cwd") or entry.get("workDir")
210
+ if isinstance(listed_cwd, str) and self._resolved_cwd(Path(listed_cwd)) != expected_cwd:
211
+ continue
212
+ for key in ("id", "session_id", "sessionId"):
213
+ candidate = entry.get(key)
214
+ if isinstance(candidate, str) and candidate:
215
+ return candidate
216
+ return None
217
+
218
+ @staticmethod
219
+ def _session_entry_timestamp(entry: object) -> tuple[int, float]:
220
+ if not isinstance(entry, dict):
221
+ return (0, 0)
222
+ for key in ("updatedAt", "lastUsedAt", "updated_at", "last_used_at"):
223
+ raw = entry.get(key)
224
+ if isinstance(raw, (int, float)):
225
+ return (1, float(raw))
226
+ if isinstance(raw, str):
227
+ try:
228
+ return (1, float(raw))
229
+ except ValueError:
230
+ try:
231
+ return (1, datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp())
232
+ except (OSError, OverflowError, ValueError):
233
+ continue
234
+ return (0, 0)
235
+
236
+ @staticmethod
237
+ def _session_list_entries(raw: str) -> list[object]:
238
+ """Accept the array and object envelopes used by Kimi session listings."""
239
+ try:
240
+ decoded = json.loads(raw)
241
+ except json.JSONDecodeError:
242
+ decoded = None
243
+ if isinstance(decoded, list):
244
+ return decoded
245
+ if isinstance(decoded, dict):
246
+ for key in ("sessions", "items", "data"):
247
+ value = decoded.get(key)
248
+ if isinstance(value, list):
249
+ return value
250
+ return [decoded]
251
+
252
+ entries: list[object] = []
253
+ for line in raw.splitlines():
254
+ try:
255
+ entry = json.loads(line)
256
+ except json.JSONDecodeError:
257
+ continue
258
+ if isinstance(entry, dict):
259
+ entries.append(entry)
260
+ return entries
261
+
262
+ def find_session_file(self, agent_session_id: str, cwd: Path) -> Path | None:
263
+ sessions_root = kimi_code_home() / "sessions"
264
+ if not sessions_root.is_dir():
265
+ return None
266
+ matches = list(sessions_root.glob(f"wd_*/{agent_session_id}/agents/main/wire.jsonl"))
267
+ matches.sort(key=self._mtime, reverse=True)
268
+ expected_cwd = self._resolved_cwd(cwd)
269
+ unverified_matches: list[Path] = []
270
+ saw_state_file = False
271
+ for path in matches:
272
+ state_path = path.parents[2] / "state.json"
273
+ if not state_path.exists():
274
+ unverified_matches.append(path)
275
+ continue
276
+ saw_state_file = True
277
+ try:
278
+ state = json.loads(state_path.read_text(encoding="utf-8"))
279
+ except (OSError, json.JSONDecodeError):
280
+ continue
281
+ if not isinstance(state, dict) or state.get("id") != agent_session_id:
282
+ continue
283
+ state_cwd = state.get("cwd")
284
+ if isinstance(state_cwd, str) and self._resolved_cwd(Path(state_cwd)) == expected_cwd:
285
+ return path
286
+ if saw_state_file:
287
+ return None
288
+ workspace_prefix = f"wd_{expected_cwd.name}_"
289
+ for path in unverified_matches:
290
+ if path.parents[3].name.startswith(workspace_prefix):
291
+ return path
292
+ return None
293
+
294
+ def parse_session_file(self, path: Path) -> list[Turn]:
295
+ """Normalize Kimi's append-only `wire.jsonl` without runtime noise."""
296
+ turns: list[Turn] = []
297
+ assistants: dict[str, dict[str, Any]] = {}
298
+ call_turns: dict[str, str] = {}
299
+ if not path.exists():
300
+ return turns
301
+
302
+ def flush(turn_id: str) -> None:
303
+ assistant = assistants.pop(turn_id, None)
304
+ if assistant is None:
305
+ return
306
+ content = "\n".join(assistant["content"])
307
+ if content or assistant["tool_calls"] or assistant["tool_results"]:
308
+ turns.append(
309
+ Turn(
310
+ role="assistant",
311
+ content=content,
312
+ tool_calls=assistant["tool_calls"],
313
+ tool_results=assistant["tool_results"],
314
+ timestamp=assistant["timestamp"],
315
+ )
316
+ )
317
+
318
+ try:
319
+ lines = path.read_text(encoding="utf-8").splitlines()
320
+ except OSError:
321
+ return turns
322
+ for raw in lines:
323
+ try:
324
+ event = json.loads(raw)
325
+ except json.JSONDecodeError:
326
+ continue
327
+ if not isinstance(event, dict):
328
+ continue
329
+ event_type = event.get("type")
330
+ if event_type == "turn.prompt":
331
+ for turn_id in list(assistants):
332
+ flush(turn_id)
333
+ content = self._text_blocks(event.get("input"))
334
+ turns.append(Turn(role="user", content=content, timestamp=self._timestamp(event)))
335
+ continue
336
+ if event_type == "turn.ended":
337
+ turn_id = str(event.get("turnId", ""))
338
+ if turn_id:
339
+ flush(turn_id)
340
+ continue
341
+ if event_type != "context.append_loop_event":
342
+ continue
343
+
344
+ loop_event = event.get("event")
345
+ if not isinstance(loop_event, dict):
346
+ continue
347
+ loop_type = loop_event.get("type")
348
+ tool_id = loop_event.get("toolCallId")
349
+ turn_id = str(loop_event.get("turnId", ""))
350
+ if loop_type == "tool.result" and not turn_id and isinstance(tool_id, str):
351
+ turn_id = call_turns.get(tool_id, "")
352
+ if not turn_id:
353
+ continue
354
+ assistant = assistants.setdefault(
355
+ turn_id,
356
+ {
357
+ "content": [],
358
+ "tool_calls": [],
359
+ "tool_results": [],
360
+ "timestamp": self._timestamp(event),
361
+ },
362
+ )
363
+ if loop_type == "content.part":
364
+ part = loop_event.get("part")
365
+ if isinstance(part, dict) and part.get("type") == "text":
366
+ text = part.get("text")
367
+ if isinstance(text, str) and text:
368
+ assistant["content"].append(text)
369
+ elif loop_type == "tool.call":
370
+ name = loop_event.get("name")
371
+ if isinstance(tool_id, str) and isinstance(name, str):
372
+ call_turns[tool_id] = turn_id
373
+ assistant["tool_calls"].append(
374
+ {
375
+ "id": tool_id,
376
+ "name": name,
377
+ "input": self._tool_input(loop_event.get("args")),
378
+ }
379
+ )
380
+ elif loop_type == "tool.result":
381
+ if isinstance(tool_id, str):
382
+ assistant["tool_results"].append(
383
+ {
384
+ "tool_use_id": tool_id,
385
+ "content": self._tool_result(loop_event.get("result")),
386
+ }
387
+ )
388
+
389
+ for turn_id in list(assistants):
390
+ flush(turn_id)
391
+ return turns
392
+
393
+ def _last_assistant_text(self, session_file: Path | None) -> str:
394
+ if session_file is None:
395
+ return ""
396
+ for turn in reversed(self.parse_session_file(session_file)):
397
+ if turn.role == "assistant" and turn.content.strip():
398
+ return turn.content
399
+ return ""
400
+
401
+ @staticmethod
402
+ def _build_prompt(prompt: str, files: list[Path]) -> str:
403
+ if not files:
404
+ return prompt
405
+ refs = "\n".join(f"@{path}" for path in files)
406
+ return f"{prompt}\n\n{refs}"
407
+
408
+ @staticmethod
409
+ def _resolved_cwd(cwd: Path) -> Path:
410
+ return cwd.expanduser().resolve()
411
+
412
+ @staticmethod
413
+ def _mtime(path: Path) -> float:
414
+ try:
415
+ return path.stat().st_mtime
416
+ except OSError:
417
+ return 0
418
+
419
+ @staticmethod
420
+ def _timestamp(event: dict[str, Any]) -> datetime | None:
421
+ raw = event.get("time")
422
+ if not isinstance(raw, (int, float)):
423
+ return None
424
+ return datetime.fromtimestamp(raw / 1000, tz=UTC)
425
+
426
+ @staticmethod
427
+ def _text_blocks(raw: object) -> str:
428
+ if isinstance(raw, str):
429
+ return raw
430
+ if not isinstance(raw, list):
431
+ return ""
432
+ texts: list[str] = []
433
+ for block in raw:
434
+ if isinstance(block, str):
435
+ texts.append(block)
436
+ elif isinstance(block, dict) and isinstance(block.get("text"), str):
437
+ texts.append(block["text"])
438
+ return "\n".join(texts)
439
+
440
+ @staticmethod
441
+ def _tool_input(raw: object) -> object:
442
+ if not isinstance(raw, str):
443
+ return raw
444
+ try:
445
+ return json.loads(raw)
446
+ except json.JSONDecodeError:
447
+ return raw
448
+
449
+ @staticmethod
450
+ def _tool_result(raw: object) -> str:
451
+ if isinstance(raw, str):
452
+ return raw
453
+ if isinstance(raw, dict):
454
+ for key in ("output", "content"):
455
+ value = raw.get(key)
456
+ if isinstance(value, str):
457
+ return value
458
+ return json.dumps(raw, ensure_ascii=False)
459
+ return "" if raw is None else str(raw)
@@ -129,7 +129,7 @@ def skill_install(
129
129
 
130
130
  @app.command()
131
131
  def dispatch(
132
- agent: str = typer.Argument(..., help="agent name (claude|codex|agy|gemini|opencode)"),
132
+ agent: str = typer.Argument(..., help="agent name (claude|codex|agy|gemini|kimi|opencode)"),
133
133
  prompt: str = typer.Option(..., "--prompt", "-p", help="initial prompt"),
134
134
  cwd: Path = typer.Option(
135
135
  Path.cwd(),
@@ -9,6 +9,7 @@ from playmaker.agents.base import AgentHandler
9
9
  from playmaker.agents.claude import ClaudeHandler
10
10
  from playmaker.agents.codex import CodexHandler
11
11
  from playmaker.agents.gemini import GeminiHandler
12
+ from playmaker.agents.kimi import KimiHandler
12
13
  from playmaker.agents.opencode import OpencodeHandler
13
14
  from playmaker.state import AGENTS_DIR
14
15
 
@@ -17,6 +18,7 @@ _HANDLERS: dict[str, AgentHandler] = {
17
18
  "codex": CodexHandler(),
18
19
  "agy": AgyHandler(),
19
20
  "gemini": GeminiHandler(),
21
+ "kimi": KimiHandler(),
20
22
  "opencode": OpencodeHandler(),
21
23
  }
22
24
 
@@ -0,0 +1,5 @@
1
+ {"type":"turn.prompt","agentId":"main","input":[{"type":"text","text":"Create a file named hello.txt in the current directory containing the word OK, then reply DONE."}],"origin":{"kind":"user"},"promptId":"msg_01M1PEQAVTGFKDBPKVZN81AQ3H","time":1788533779323}
2
+ {"type":"context.append_loop_event","agentId":"main","event":{"type":"tool.call","uuid":"5f6ff6da-1a1b-40ae-b083-19f79d71855e","turnId":"0","step":1,"stepUuid":"0d92aa4d-e201-44a7-82d2-69e088dbcc3d","toolCallId":"tool_b9dutdcTH3OmrVAuOyVX7lsg","name":"Write","args":{"path":"hello.txt","content":"OK"}},"time":1788533785040}
3
+ {"type":"context.append_loop_event","agentId":"main","event":{"type":"tool.result","parentUuid":"5f6ff6da-1a1b-40ae-b083-19f79d71855e","toolCallId":"tool_b9dutdcTH3OmrVAuOyVX7lsg","result":{"output":"Wrote 2 bytes to hello.txt"}},"time":1788533785046}
4
+ {"type":"context.append_loop_event","agentId":"main","event":{"type":"content.part","uuid":"a5e7ecd6-5067-4ff3-8d07-2cc468d6a0f5","turnId":"0","step":2,"stepUuid":"bbc61589-3b47-43a6-a129-e2d3380f0761","part":{"type":"text","text":"DONE"}},"time":1788533787715}
5
+ {"type":"turn.ended","agentId":"main","turnId":0,"reason":"completed","durationMs":8392,"time":1788533787716}
@@ -0,0 +1,333 @@
1
+ """Kimi handler: prompt-mode stream dispatch and wire transcript parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ import threading
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
14
+
15
+ import playmaker.config as config
16
+ from playmaker.agents.kimi import KimiHandler
17
+ from playmaker.registry import all_handlers, get_handler
18
+
19
+ SESSION = "session_35f81bbc-3038-4bd3-96f1-ababeb5a86cb"
20
+ NEWER_SESSION = "session_4c38d6a2-0579-4ff3-8d07-2cc468d6a0f5"
21
+ FIXTURE = Path(__file__).parent / "fixtures" / "kimi_wire.jsonl"
22
+
23
+
24
+ class _FakeStream:
25
+ def __init__(self, lines: list[str]) -> None:
26
+ self._lines = lines
27
+
28
+ def __iter__(self):
29
+ return iter(self._lines)
30
+
31
+ def read(self) -> str:
32
+ return "".join(self._lines)
33
+
34
+
35
+ class _FakePopen:
36
+ """Stand-in for `kimi -p ... --output-format stream-json`."""
37
+
38
+ def __init__(self, lines: list[str], *, stderr: str = "", returncode: int = 0) -> None:
39
+ self._lines = lines
40
+ self._stderr = stderr
41
+ self.returncode = returncode
42
+ self.cmd: list[str] = []
43
+ self.kwargs: dict = {}
44
+ self.stdout = None
45
+ self.stderr = None
46
+
47
+ def __call__(self, cmd, **kwargs):
48
+ self.cmd = cmd
49
+ self.kwargs = kwargs
50
+ self.stdout = _FakeStream(self._lines)
51
+ self.stderr = _FakeStream([self._stderr])
52
+ return self
53
+
54
+ def wait(self) -> int:
55
+ return self.returncode
56
+
57
+
58
+ class _ConcurrentStderrPopen:
59
+ """Requires stderr draining before its stdout stream can make progress."""
60
+
61
+ def __init__(self, lines: list[str], stderr: str) -> None:
62
+ self._lines = lines
63
+ self._stderr = stderr
64
+ self.returncode = 1
65
+ self.stderr_started = threading.Event()
66
+ self.stdout = None
67
+ self.stderr = None
68
+
69
+ def __call__(self, cmd, **kwargs):
70
+ class _Stderr(_FakeStream):
71
+ def read(stream_self) -> str:
72
+ self.stderr_started.set()
73
+ return super().read()
74
+
75
+ class _Stdout(_FakeStream):
76
+ def __iter__(stream_self):
77
+ assert self.stderr_started.wait(timeout=1), "stderr was not drained concurrently"
78
+ return super().__iter__()
79
+
80
+ self.stdout = _Stdout(self._lines)
81
+ self.stderr = _Stderr([self._stderr])
82
+ return self
83
+
84
+ def wait(self) -> int:
85
+ return self.returncode
86
+
87
+
88
+ def _events(*objects: dict) -> list[str]:
89
+ return [json.dumps(obj) + "\n" for obj in objects]
90
+
91
+
92
+ def _assistant(content: str | None = None, tool_calls: list[dict] | None = None) -> dict:
93
+ event: dict = {"role": "assistant"}
94
+ if content is not None:
95
+ event["content"] = content
96
+ if tool_calls is not None:
97
+ event["tool_calls"] = tool_calls
98
+ return event
99
+
100
+
101
+ def _resume_hint() -> dict:
102
+ return {"role": "meta", "type": "session.resume_hint", "session_id": SESSION}
103
+
104
+
105
+ @pytest.fixture
106
+ def configured(monkeypatch):
107
+ def _set(cfg: dict) -> None:
108
+ monkeypatch.setattr(config, "load_config", lambda: cfg)
109
+
110
+ return _set
111
+
112
+
113
+ @pytest.fixture(autouse=True)
114
+ def isolated_kimi_home(monkeypatch, tmp_path) -> None:
115
+ monkeypatch.setenv("KIMI_CODE_HOME", str(tmp_path / "kimi-code"))
116
+
117
+
118
+ def _install(monkeypatch, fake: _FakePopen) -> None:
119
+ monkeypatch.setattr("playmaker.agents.kimi.subprocess.Popen", fake)
120
+
121
+
122
+ def test_dispatch_parses_stream_and_announces_the_late_session_id(monkeypatch, tmp_path) -> None:
123
+ fake = _FakePopen(
124
+ _events(
125
+ {"role": "meta", "type": "system.version", "version": "0.41.0"},
126
+ _assistant("Writing hello.txt."),
127
+ _assistant(
128
+ tool_calls=[
129
+ {
130
+ "type": "function",
131
+ "id": "tool_1",
132
+ "function": {"name": "Write", "arguments": '{"path":"hello.txt"}'},
133
+ }
134
+ ]
135
+ ),
136
+ {"role": "tool", "tool_call_id": "tool_1", "content": "Wrote 2 bytes"},
137
+ _assistant("DONE"),
138
+ _resume_hint(),
139
+ )
140
+ )
141
+ _install(monkeypatch, fake)
142
+ seen: list[str] = []
143
+
144
+ result = KimiHandler().dispatch(prompt="do it", cwd=tmp_path, on_session_started=seen.append)
145
+
146
+ assert result.agent_session_id == SESSION
147
+ assert result.initial_output == "DONE"
148
+ assert seen == [SESSION]
149
+ assert fake.kwargs["cwd"] == str(tmp_path)
150
+
151
+
152
+ def test_dispatch_uses_the_last_nonempty_assistant_content(monkeypatch, tmp_path) -> None:
153
+ fake = _FakePopen(
154
+ _events(_assistant("first"), _assistant(" "), _assistant("DONE"), _resume_hint())
155
+ )
156
+ _install(monkeypatch, fake)
157
+
158
+ result = KimiHandler().dispatch(prompt="p", cwd=tmp_path)
159
+
160
+ assert result.initial_output == "DONE"
161
+
162
+
163
+ def test_missing_resume_hint_uses_the_per_cwd_session_listing(monkeypatch, tmp_path) -> None:
164
+ _install(monkeypatch, _FakePopen(_events(_assistant("DONE"))))
165
+ monkeypatch.setattr(KimiHandler, "_latest_session_id", lambda self, cwd: SESSION)
166
+ seen: list[str] = []
167
+
168
+ result = KimiHandler().dispatch(prompt="p", cwd=tmp_path, on_session_started=seen.append)
169
+
170
+ assert result.agent_session_id == SESSION
171
+ assert seen == [SESSION]
172
+
173
+
174
+ def test_session_listing_uses_kimis_work_dir_field(monkeypatch, tmp_path) -> None:
175
+ class _Completed:
176
+ returncode = 0
177
+ stdout = json.dumps([{"id": SESSION, "workDir": str(tmp_path)}])
178
+
179
+ monkeypatch.setattr(
180
+ "playmaker.agents.kimi.subprocess.run", lambda *args, **kwargs: _Completed()
181
+ )
182
+
183
+ assert KimiHandler()._latest_session_id(tmp_path) == SESSION
184
+
185
+
186
+ def test_session_listing_sorts_matching_entries_by_updated_at(monkeypatch, tmp_path) -> None:
187
+ class _Completed:
188
+ returncode = 0
189
+ stdout = json.dumps(
190
+ [
191
+ {"id": SESSION, "workDir": str(tmp_path), "updatedAt": 100},
192
+ {"id": NEWER_SESSION, "workDir": str(tmp_path), "updatedAt": 200},
193
+ ]
194
+ )
195
+
196
+ monkeypatch.setattr(
197
+ "playmaker.agents.kimi.subprocess.run", lambda *args, **kwargs: _Completed()
198
+ )
199
+
200
+ assert KimiHandler()._latest_session_id(tmp_path) == NEWER_SESSION
201
+
202
+
203
+ def test_nonzero_exit_surfaces_stderr(monkeypatch, tmp_path) -> None:
204
+ fake = _FakePopen(
205
+ _events({"role": "meta", "type": "system.version", "version": "0.41.0"}),
206
+ stderr='error: failed to run prompt: Model "kimi-code/nope" is not configured',
207
+ returncode=1,
208
+ )
209
+ _install(monkeypatch, fake)
210
+
211
+ with pytest.raises(RuntimeError, match="kimi-code/nope"):
212
+ KimiHandler().dispatch(prompt="p", cwd=tmp_path)
213
+
214
+
215
+ def test_large_stderr_is_drained_while_stdout_is_streamed(monkeypatch, tmp_path) -> None:
216
+ fake = _ConcurrentStderrPopen(
217
+ _events({"role": "meta", "type": "system.version", "version": "0.41.0"}),
218
+ "x" * 70_000 + " stderr detail",
219
+ )
220
+ _install(monkeypatch, fake)
221
+
222
+ with pytest.raises(RuntimeError) as exc_info:
223
+ KimiHandler().dispatch(prompt="p", cwd=tmp_path)
224
+
225
+ assert "stderr detail" in str(exc_info.value)
226
+ assert fake.stderr_started.is_set()
227
+
228
+
229
+ def test_explicit_model_wins_and_prompt_mode_never_passes_approval_flags(
230
+ monkeypatch, tmp_path, configured
231
+ ) -> None:
232
+ configured({"agents": {"kimi": {"model": "kimi-code/configured"}}})
233
+ fake = _FakePopen(_events(_assistant("DONE"), _resume_hint()))
234
+ _install(monkeypatch, fake)
235
+
236
+ KimiHandler().dispatch(prompt="p", cwd=tmp_path, model="kimi-code/explicit")
237
+
238
+ assert fake.cmd[fake.cmd.index("-m") + 1] == "kimi-code/explicit"
239
+ assert fake.cmd[fake.cmd.index("-p") + 1] == "p"
240
+ assert fake.cmd[fake.cmd.index("--output-format") + 1] == "stream-json"
241
+ assert not {"--auto", "--yolo", "--plan"} & set(fake.cmd)
242
+
243
+
244
+ def test_configured_model_is_used_when_no_model_is_passed(
245
+ monkeypatch, tmp_path, configured
246
+ ) -> None:
247
+ configured({"agents": {"kimi": {"model": "kimi-code/configured"}}})
248
+ fake = _FakePopen(_events(_assistant("DONE"), _resume_hint()))
249
+ _install(monkeypatch, fake)
250
+
251
+ KimiHandler().dispatch(prompt="p", cwd=tmp_path)
252
+
253
+ assert fake.cmd[fake.cmd.index("-m") + 1] == "kimi-code/configured"
254
+
255
+
256
+ def test_no_model_flag_when_neither_dispatch_nor_config_supplies_one(
257
+ monkeypatch, tmp_path, configured
258
+ ) -> None:
259
+ configured({})
260
+ fake = _FakePopen(_events(_assistant("DONE"), _resume_hint()))
261
+ _install(monkeypatch, fake)
262
+
263
+ KimiHandler().dispatch(prompt="p", cwd=tmp_path)
264
+
265
+ assert "-m" not in fake.cmd
266
+
267
+
268
+ def test_resume_uses_dash_s_and_keeps_the_session_id(monkeypatch, tmp_path) -> None:
269
+ fake = _FakePopen(_events(_assistant("DONE2"), _resume_hint()))
270
+ _install(monkeypatch, fake)
271
+
272
+ result = KimiHandler().resume(prompt="more", cwd=tmp_path, agent_session_id=SESSION)
273
+
274
+ assert fake.cmd[fake.cmd.index("-S") + 1] == SESSION
275
+ assert result.agent_session_id == SESSION
276
+
277
+
278
+ def test_find_session_file_verifies_the_state_id_and_cwd(tmp_path) -> None:
279
+ cwd = tmp_path / "workspace"
280
+ cwd.mkdir()
281
+ home = Path(os.environ["KIMI_CODE_HOME"])
282
+ session_dir = home / "sessions" / "wd_workspace_0123456789ab" / SESSION
283
+ wire = session_dir / "agents" / "main" / "wire.jsonl"
284
+ wire.parent.mkdir(parents=True)
285
+ wire.write_text("", encoding="utf-8")
286
+ (session_dir / "state.json").write_text(
287
+ json.dumps({"id": SESSION, "cwd": str(cwd.resolve())}), encoding="utf-8"
288
+ )
289
+
290
+ assert KimiHandler().find_session_file(SESSION, cwd) == wire
291
+
292
+
293
+ def test_find_session_file_without_state_uses_the_workspace_directory_name(tmp_path) -> None:
294
+ cwd = tmp_path / "workspace"
295
+ cwd.mkdir()
296
+ home = Path(os.environ["KIMI_CODE_HOME"])
297
+
298
+ def make_wire(workspace_name: str) -> Path:
299
+ wire = home / "sessions" / workspace_name / SESSION / "agents" / "main" / "wire.jsonl"
300
+ wire.parent.mkdir(parents=True)
301
+ wire.write_text("", encoding="utf-8")
302
+ return wire
303
+
304
+ matching_wire = make_wire("wd_workspace_0123456789ab")
305
+ make_wire("wd_other_abcdef012345")
306
+
307
+ handler = KimiHandler()
308
+ assert handler.find_session_file(SESSION, cwd) == matching_wire
309
+ assert handler.find_session_file(SESSION, tmp_path / "unrelated") is None
310
+
311
+
312
+ def test_parse_session_file_maps_user_text_tools_and_assistant_text() -> None:
313
+ turns = KimiHandler().parse_session_file(FIXTURE)
314
+
315
+ assert [turn.role for turn in turns] == ["user", "assistant"]
316
+ assert turns[0].content.startswith("Create a file named hello.txt")
317
+ assert turns[0].timestamp is not None
318
+ assert turns[1].content == "DONE"
319
+ assert turns[1].tool_calls == [
320
+ {
321
+ "id": "tool_b9dutdcTH3OmrVAuOyVX7lsg",
322
+ "name": "Write",
323
+ "input": {"path": "hello.txt", "content": "OK"},
324
+ }
325
+ ]
326
+ assert turns[1].tool_results == [
327
+ {"tool_use_id": "tool_b9dutdcTH3OmrVAuOyVX7lsg", "content": "Wrote 2 bytes to hello.txt"}
328
+ ]
329
+
330
+
331
+ def test_registry_exposes_kimi() -> None:
332
+ assert get_handler("kimi").name == "kimi"
333
+ assert "kimi" in all_handlers()
File without changes