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.
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/CHANGELOG.md +12 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/PKG-INFO +15 -8
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/README.md +12 -5
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/pyproject.toml +4 -2
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/skills/playmaker-coach/references/agent-gotchas.md +16 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/skills/playmaker-coach/references/lanes.md +5 -2
- playmaker_cli-0.11.0/src/playmaker/agents/kimi.py +459 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/cli.py +1 -1
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/registry.py +2 -0
- playmaker_cli-0.11.0/tests/fixtures/kimi_wire.jsonl +5 -0
- playmaker_cli-0.11.0/tests/test_kimi.py +333 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/.gitignore +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/LICENSE +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/skills/playmaker-coach/SKILL.md +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/skills/playmaker-coach/references/commands.md +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/skills/playmaker-coach/references/prompt-templates.md +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/skills/playmaker-coach/references/quotas.md +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/skills/playmaker-coach/references/review-board.md +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/skills/playmaker-coach/scripts/review-board.sh +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/__init__.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/__main__.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/agents/__init__.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/agents/agy.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/agents/base.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/agents/claude.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/agents/codex.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/agents/gemini.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/agents/opencode.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/config.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/notify.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/quotas.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/state.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/src/playmaker/watcher.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/__init__.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_agy.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_batch.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_binary.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_claude.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_codex.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_no_changes.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_opencode.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_permissions.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_quotas_antigravity.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_quotas_claude.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_quotas_codex.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_quotas_freshness.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_quotas_zai.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_registry.py +0 -0
- {playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/tests/test_skill.py +0 -0
- {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.
|
|
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
|
[](https://pypi.org/project/playmaker-cli/)
|
|
33
33
|
[](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
|
|
78
|
-
separate pools from your Anthropic plan. Every slice you hand
|
|
79
|
-
capacity your main session never spends — and Antigravity's roster
|
|
80
|
-
Claude Sonnet/Opus, so even Claude-quality work can run on Google's
|
|
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
|
[](https://pypi.org/project/playmaker-cli/)
|
|
6
6
|
[](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
|
|
51
|
-
separate pools from your Anthropic plan. Every slice you hand
|
|
52
|
-
capacity your main session never spends — and Antigravity's roster
|
|
53
|
-
Claude Sonnet/Opus, so even Claude-quality work can run on Google's
|
|
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.
|
|
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",
|
{playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/skills/playmaker-coach/references/agent-gotchas.md
RENAMED
|
@@ -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
|
|
22
|
-
home for write-heavy parallel implementation that can leave the Anthropic
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/skills/playmaker-coach/references/prompt-templates.md
RENAMED
|
File without changes
|
|
File without changes
|
{playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/skills/playmaker-coach/references/review-board.md
RENAMED
|
File without changes
|
{playmaker_cli-0.10.0 → playmaker_cli-0.11.0}/skills/playmaker-coach/scripts/review-board.sh
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|