Skip to content

Commit d310470

Browse files
authored
Merge pull request #16 from beyond5959/dev
feat(agents): add Kimi CLI provider
2 parents 00dfcbf + 194a973 commit d310470

File tree

27 files changed

+1745
-493
lines changed

27 files changed

+1745
-493
lines changed

PROGRESS.md

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,35 @@
44

55
Code Agent Hub Server is a Go service that exposes HTTP/JSON APIs and SSE streaming for multi-client, multi-thread agent turns.
66
The system targets ACP-compatible agent providers, lazily starts per-thread agents, persists interaction history in SQLite, and bridges runtime permission requests back to clients.
7-
Current built-in providers are `codex`, `claude`, `opencode`, `gemini`, and `qwen`.
7+
Current built-in providers are `codex`, `claude`, `opencode`, `gemini`, `kimi`, and `qwen`.
88
This file is the source of milestone progress, validation commands, and next actions.
99

1010
## Current Milestone
1111

1212
- `Post-M8` ACP multi-agent readiness and maintenance.
1313

14-
## Latest Update (2026-03-08)
15-
16-
- release pipeline compatibility fix for GoReleaser v2:
17-
- removed unsupported CLI argument `--skip=dirty` from `.github/workflows/release.yml` (`release --clean` only).
18-
- keeps release workflow behavior unchanged while restoring successful tagged release builds.
14+
## Latest Update (2026-03-09)
15+
16+
- Kimi CLI ACP integration completed:
17+
- implemented `internal/agents/kimi` with one-turn ACP stdio lifecycle and fail-closed permission handling.
18+
- wired `kimi` into startup preflight, `/v1/agents`, thread allowlist, turn factory, model discovery, and startup config-catalog refresh.
19+
- added dual startup syntax fallback for current upstream docs drift: try `kimi acp`, then `kimi --acp` if ACP initialize closes immediately.
20+
- added fake-process provider tests, fallback coverage, and server/httpapi allowlist coverage.
21+
- fixed Kimi thread model switching: `POST /v1/threads/{threadId}/config-options` with `configId=model` now selects the target model via Kimi process startup `--model`, instead of assuming ACP `session/set_config_option(model)` is implemented.
22+
- Kimi stream/config discovery paths now also pass the selected model through both process startup args and `session/new` hints for compatibility.
23+
- Shared agent config/state refactor completed:
24+
- extracted common built-in agent fields `Dir`, `ModelID`, and `ConfigOverrides` into shared `internal/agents/agentutil.Config`.
25+
- extracted shared thread-safe mutable agent state into `internal/agents/agentutil.State`.
26+
- migrated `gemini`, `opencode`, `qwen`, `kimi`, `codex`, and `claude` to reuse the shared state helper instead of keeping duplicated per-provider copies of model/config override logic.
27+
- kept protocol/runtime behavior provider-specific; only constructor validation and common mutable state handling were unified.
28+
- Web UI Kimi icon completed:
29+
- downloaded the provided Kimi PNG asset into `internal/webui/web/public/kimi-icon.png`.
30+
- wired `kimi` avatar rendering in the Web UI to use the new asset with the existing `--contain` image treatment.
31+
- fixed the remaining New Thread modal agent-card icon map so Kimi now renders consistently there as well.
32+
- removed the forced white background from all `--contain` agent icons in message/thread views and from the modal's Kimi/OpenCode icon markup.
1933
- validation:
20-
- pass: `go test ./...` (in this repository)
34+
- pass: `cd internal/webui/web && npm run build`
35+
- pass: `go test ./...`
2136

2237
## Status
2338

@@ -99,7 +114,7 @@ This file is the source of milestone progress, validation commands, and next act
99114
- updated docs and tests to reflect absolute-cwd policy.
100115
- `Post-M8` docs framing update completed:
101116
- adjusted README/SPEC/API/ARCHITECTURE wording to emphasize ACP-compatible multi-agent goal.
102-
- kept current-state note explicit: built-in providers are `codex`, `claude`, `opencode`, `gemini`, and `qwen`.
117+
- kept current-state note explicit: built-in providers are `codex`, `claude`, `opencode`, `gemini`, `kimi`, and `qwen`.
103118
- simplified README startup path to `agent-hub-server` with explicit `agent-hub-server --help` guidance.
104119
- `Post-M8` startup log UX simplification completed:
105120
- replaced startup JSON line with multi-line human-readable stderr summary (QR code + port and URL hint).
@@ -412,10 +427,11 @@ This file is the source of milestone progress, validation commands, and next act
412427
- successful thread option update closes cached per-thread provider, so next turn re-initializes with new model config.
413428
- wired runtime model discovery into all providers:
414429
- `codex`/`claude`: ACP embedded `session/new.configOptions`.
415-
- `gemini`/`opencode`/`qwen`: ACP `session/new.models.availableModels`.
430+
- `gemini`/`kimi`/`opencode`/`qwen`: ACP `session/new.models.availableModels`.
416431
- wired `agentOptions.modelId` forwarding into all providers:
417432
- embedded `codex`/`claude` now pass `model` in ACP `session/new`.
418433
- `gemini` now passes `model` in `session/new` and `session/prompt`.
434+
- `kimi` passes `model` in `session/prompt`, and uses `model` + `modelId` during config/model discovery handshakes.
419435
- `opencode` passes `modelId` in `session/prompt`; `qwen` passes `model` in `session/prompt`.
420436
- Web UI updates:
421437
- new-thread modal model selector removed (create flow keeps agent/cwd/title/advanced JSON only).
@@ -432,7 +448,7 @@ This file is the source of milestone progress, validation commands, and next act
432448
- `POST` now applies model changes through ACP `session/set_config_option` (no separate apply endpoint/action).
433449
- provider-side config option support added across all built-in agents:
434450
- embedded: `codex`, `claude` (in-session `session/set_config_option` on cached runtime).
435-
- stdio: `opencode`, `qwen`, `gemini` (ACP handshake + `session/set_config_option` apply path, then persist selected model for next turns).
451+
- stdio: `opencode`, `qwen`, `gemini`, `kimi` (ACP handshake + `session/set_config_option` apply path, then persist selected model for next turns).
436452
- Web UI changes:
437453
- removed thread header `Apply` button.
438454
- model dropdown now applies immediately on selection.

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
1111
## What is Ngent?
1212

13-
Ngent acts as a bridge between **ACP-compatible agents** (like Claude Code, Codex, Gemini CLI) and **web clients**:
13+
Ngent acts as a bridge between **ACP-compatible agents** (like Claude Code, Codex, Gemini CLI, Kimi CLI) and **web clients**:
1414

1515
```
1616
┌─────────────┐ HTTP/WebSocket ┌─────────┐ JSON-RPC (ACP) ┌──────────────┐
@@ -21,13 +21,13 @@ Ngent acts as a bridge between **ACP-compatible agents** (like Claude Code, Code
2121

2222
### How it Works
2323

24-
1. **ACP Protocol**: Agents like Claude Code and Codex expose their capabilities through the Agent Client Protocol (ACP) — a JSON-RPC protocol over stdio
24+
1. **ACP Protocol**: Agents like Claude Code, Codex, and Kimi CLI expose their capabilities through the Agent Client Protocol (ACP) — a JSON-RPC protocol over stdio
2525
2. **Ngent Bridge**: Ngent spawns these CLI agents as child processes and translates their ACP protocol into HTTP/JSON APIs
2626
3. **Web Interface**: Provides a built-in Web UI and REST API for creating conversations, sending prompts, and managing permissions
2727

2828
### Features
2929

30-
- 🔌 **Multi-Agent Support**: Works with any ACP-compatible agent (Codex, Claude Code, Gemini, Qwen, OpenCode)
30+
- 🔌 **Multi-Agent Support**: Works with any ACP-compatible agent (Codex, Claude Code, Gemini, Kimi, Qwen, OpenCode)
3131
- 🌐 **Web API**: HTTP/JSON endpoints with Server-Sent Events (SSE) for streaming responses
3232
- 🖥️ **Built-in UI**: No separate frontend deployment needed — the web UI is embedded in the binary
3333
- 🔒 **Permission Control**: Fine-grained approval system for agent file/system operations
@@ -42,6 +42,7 @@ Ngent acts as a bridge between **ACP-compatible agents** (like Claude Code, Code
4242
| Codex ||
4343
| Claude Code ||
4444
| Gemini CLI ||
45+
| Kimi CLI ||
4546
| Qwen Code ||
4647
| OpenCode ||
4748

cmd/ngent/main.go

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
claudeagent "github.com/beyond5959/ngent/internal/agents/claude"
2525
codexagent "github.com/beyond5959/ngent/internal/agents/codex"
2626
geminiagent "github.com/beyond5959/ngent/internal/agents/gemini"
27+
kimiagent "github.com/beyond5959/ngent/internal/agents/kimi"
2728
opencodeagent "github.com/beyond5959/ngent/internal/agents/opencode"
2829
qwenagent "github.com/beyond5959/ngent/internal/agents/qwen"
2930
"github.com/beyond5959/ngent/internal/httpapi"
@@ -58,6 +59,7 @@ func main() {
5859
codexPreflightErr := codexagent.Preflight(codexRuntimeConfig)
5960
opencodePreflightErr := opencodeagent.Preflight()
6061
geminiPreflightErr := geminiagent.Preflight()
62+
kimiPreflightErr := kimiagent.Preflight()
6163
qwenPreflightErr := qwenagent.Preflight()
6264
claudePreflightErr := claudeagent.Preflight()
6365

@@ -85,6 +87,7 @@ func main() {
8587
codexAvailable := codexPreflightErr == nil
8688
opencodeAvailable := opencodePreflightErr == nil
8789
geminiAvailable := geminiPreflightErr == nil
90+
kimiAvailable := kimiPreflightErr == nil
8891
qwenAvailable := qwenPreflightErr == nil
8992
claudeAvailable := claudePreflightErr == nil
9093
if codexPreflightErr != nil {
@@ -96,13 +99,16 @@ func main() {
9699
if geminiPreflightErr != nil {
97100
logger.Warn("startup.gemini_unavailable", "error", geminiPreflightErr.Error())
98101
}
102+
if kimiPreflightErr != nil {
103+
logger.Warn("startup.kimi_unavailable", "error", kimiPreflightErr.Error())
104+
}
99105
if qwenPreflightErr != nil {
100106
logger.Warn("startup.qwen_unavailable", "error", qwenPreflightErr.Error())
101107
}
102108
if claudePreflightErr != nil {
103109
logger.Warn("startup.claude_unavailable", "error", claudePreflightErr.Error())
104110
}
105-
agents := supportedAgents(codexAvailable, opencodeAvailable, geminiAvailable, qwenAvailable, claudeAvailable)
111+
agents := supportedAgents(codexAvailable, opencodeAvailable, geminiAvailable, kimiAvailable, qwenAvailable, claudeAvailable)
106112

107113
listenAddr, port, err := validateListenAddr(*listenAddrFlag, *allowPublic)
108114
if err != nil {
@@ -136,7 +142,7 @@ func main() {
136142
handler := httpapi.New(httpapi.Config{
137143
AuthToken: *authToken,
138144
Agents: agents,
139-
AllowedAgentIDs: []string{"codex", "opencode", "gemini", "qwen", "claude"},
145+
AllowedAgentIDs: []string{"codex", "opencode", "gemini", "kimi", "qwen", "claude"},
140146
AllowedRoots: allowedRoots,
141147
Store: store,
142148
TurnController: turnController,
@@ -164,6 +170,12 @@ func main() {
164170
ModelID: modelID,
165171
ConfigOverrides: configOverrides,
166172
})
173+
case "kimi":
174+
return kimiagent.New(kimiagent.Config{
175+
Dir: thread.CWD,
176+
ModelID: modelID,
177+
ConfigOverrides: configOverrides,
178+
})
167179
case "qwen":
168180
return qwenagent.New(qwenagent.Config{
169181
Dir: thread.CWD,
@@ -205,6 +217,11 @@ func main() {
205217
return nil, geminiPreflightErr
206218
}
207219
return geminiagent.DiscoverModels(ctx, geminiagent.Config{Dir: modelDiscoveryDir})
220+
case "kimi":
221+
if kimiPreflightErr != nil {
222+
return nil, kimiPreflightErr
223+
}
224+
return kimiagent.DiscoverModels(ctx, kimiagent.Config{Dir: modelDiscoveryDir})
208225
case "qwen":
209226
if qwenPreflightErr != nil {
210227
return nil, qwenPreflightErr
@@ -236,6 +253,7 @@ func main() {
236253
codexPreflightErr,
237254
opencodePreflightErr,
238255
geminiPreflightErr,
256+
kimiPreflightErr,
239257
qwenPreflightErr,
240258
claudePreflightErr,
241259
))
@@ -315,6 +333,7 @@ func buildAgentConfigCatalogRefresher(
315333
codexPreflightErr error,
316334
opencodePreflightErr error,
317335
geminiPreflightErr error,
336+
kimiPreflightErr error,
318337
qwenPreflightErr error,
319338
claudePreflightErr error,
320339
) *agentConfigCatalogRefresher {
@@ -328,7 +347,7 @@ func buildAgentConfigCatalogRefresher(
328347
return &agentConfigCatalogRefresher{
329348
store: store,
330349
logger: logger,
331-
agentIDs: []string{"codex", "claude", "gemini", "qwen", "opencode"},
350+
agentIDs: []string{"codex", "claude", "gemini", "kimi", "qwen", "opencode"},
332351
fetchConfigOptions: func(ctx context.Context, agentID, modelID string) ([]agentimpl.ConfigOption, error) {
333352
switch agentID {
334353
case "codex":
@@ -370,6 +389,18 @@ func buildAgentConfigCatalogRefresher(
370389
return nil, err
371390
}
372391
return queryAgentConfigOptions(ctx, client)
392+
case "kimi":
393+
if kimiPreflightErr != nil {
394+
return nil, kimiPreflightErr
395+
}
396+
client, err := kimiagent.New(kimiagent.Config{
397+
Dir: modelDiscoveryDir,
398+
ModelID: modelID,
399+
})
400+
if err != nil {
401+
return nil, err
402+
}
403+
return queryAgentConfigOptions(ctx, client)
373404
case "qwen":
374405
if qwenPreflightErr != nil {
375406
return nil, qwenPreflightErr
@@ -426,6 +457,11 @@ func buildAgentConfigCatalogRefresher(
426457
return nil, geminiPreflightErr
427458
}
428459
return geminiagent.DiscoverModels(ctx, geminiagent.Config{Dir: modelDiscoveryDir})
460+
case "kimi":
461+
if kimiPreflightErr != nil {
462+
return nil, kimiPreflightErr
463+
}
464+
return kimiagent.DiscoverModels(ctx, kimiagent.Config{Dir: modelDiscoveryDir})
429465
case "qwen":
430466
if qwenPreflightErr != nil {
431467
return nil, qwenPreflightErr
@@ -668,7 +704,7 @@ func extractConfigOverrides(agentOptionsJSON string) map[string]string {
668704
return normalized
669705
}
670706

671-
func supportedAgents(codexAvailable, opencodeAvailable, geminiAvailable, qwenAvailable, claudeAvailable bool) []httpapi.AgentInfo {
707+
func supportedAgents(codexAvailable, opencodeAvailable, geminiAvailable, kimiAvailable, qwenAvailable, claudeAvailable bool) []httpapi.AgentInfo {
672708
codexStatus := "unavailable"
673709
if codexAvailable {
674710
codexStatus = "available"
@@ -681,6 +717,10 @@ func supportedAgents(codexAvailable, opencodeAvailable, geminiAvailable, qwenAva
681717
if geminiAvailable {
682718
geminiStatus = "available"
683719
}
720+
kimiStatus := "unavailable"
721+
if kimiAvailable {
722+
kimiStatus = "available"
723+
}
684724
qwenStatus := "unavailable"
685725
if qwenAvailable {
686726
qwenStatus = "available"
@@ -694,6 +734,7 @@ func supportedAgents(codexAvailable, opencodeAvailable, geminiAvailable, qwenAva
694734
{ID: "codex", Name: "Codex", Status: codexStatus},
695735
{ID: "claude", Name: "Claude Code", Status: claudeStatus},
696736
{ID: "gemini", Name: "Gemini CLI", Status: geminiStatus},
737+
{ID: "kimi", Name: "Kimi CLI", Status: kimiStatus},
697738
{ID: "qwen", Name: "Qwen Code", Status: qwenStatus},
698739
{ID: "opencode", Name: "OpenCode", Status: opencodeStatus},
699740
}

cmd/ngent/main_test.go

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ func TestAgentConfigCatalogRefresherPartialKeepsExistingRows(t *testing.T) {
263263
}
264264

265265
func TestSupportedAgentsCodexStatus(t *testing.T) {
266-
agentsUnavailable := supportedAgents(false, false, false, false, false)
266+
agentsUnavailable := supportedAgents(false, false, false, false, false, false)
267267
if len(agentsUnavailable) == 0 {
268268
t.Fatalf("supportedAgents returned empty list")
269269
}
@@ -279,23 +279,29 @@ func TestSupportedAgentsCodexStatus(t *testing.T) {
279279
if agentsUnavailable[1].Status != "unavailable" {
280280
t.Fatalf("claude unavailable status = %q, want %q", agentsUnavailable[1].Status, "unavailable")
281281
}
282-
if got, want := len(agentsUnavailable), 5; got != want {
282+
if got, want := len(agentsUnavailable), 6; got != want {
283283
t.Fatalf("len(agentsUnavailable) = %d, want %d", got, want)
284284
}
285-
if agentsUnavailable[3].ID != "qwen" {
286-
t.Fatalf("agents[3].ID = %q, want %q", agentsUnavailable[3].ID, "qwen")
285+
if agentsUnavailable[3].ID != "kimi" {
286+
t.Fatalf("agents[3].ID = %q, want %q", agentsUnavailable[3].ID, "kimi")
287287
}
288288
if agentsUnavailable[3].Status != "unavailable" {
289-
t.Fatalf("qwen unavailable status = %q, want %q", agentsUnavailable[3].Status, "unavailable")
289+
t.Fatalf("kimi unavailable status = %q, want %q", agentsUnavailable[3].Status, "unavailable")
290290
}
291-
if agentsUnavailable[4].ID != "opencode" {
292-
t.Fatalf("agents[4].ID = %q, want %q", agentsUnavailable[4].ID, "opencode")
291+
if agentsUnavailable[4].ID != "qwen" {
292+
t.Fatalf("agents[4].ID = %q, want %q", agentsUnavailable[4].ID, "qwen")
293293
}
294294
if agentsUnavailable[4].Status != "unavailable" {
295-
t.Fatalf("opencode unavailable status = %q, want %q", agentsUnavailable[4].Status, "unavailable")
295+
t.Fatalf("qwen unavailable status = %q, want %q", agentsUnavailable[4].Status, "unavailable")
296+
}
297+
if agentsUnavailable[5].ID != "opencode" {
298+
t.Fatalf("agents[5].ID = %q, want %q", agentsUnavailable[5].ID, "opencode")
299+
}
300+
if agentsUnavailable[5].Status != "unavailable" {
301+
t.Fatalf("opencode unavailable status = %q, want %q", agentsUnavailable[5].Status, "unavailable")
296302
}
297303

298-
agentsAvailable := supportedAgents(true, true, true, true, true)
304+
agentsAvailable := supportedAgents(true, true, true, true, true, true)
299305
if agentsAvailable[0].Status != "available" {
300306
t.Fatalf("codex available status = %q, want %q", agentsAvailable[0].Status, "available")
301307
}
@@ -305,20 +311,26 @@ func TestSupportedAgentsCodexStatus(t *testing.T) {
305311
if agentsAvailable[1].Status != "available" {
306312
t.Fatalf("claude available status = %q, want %q", agentsAvailable[1].Status, "available")
307313
}
308-
if got, want := len(agentsAvailable), 5; got != want {
314+
if got, want := len(agentsAvailable), 6; got != want {
309315
t.Fatalf("len(agentsAvailable) = %d, want %d", got, want)
310316
}
311-
if agentsAvailable[3].ID != "qwen" {
312-
t.Fatalf("agents[3].ID = %q, want %q", agentsAvailable[3].ID, "qwen")
317+
if agentsAvailable[3].ID != "kimi" {
318+
t.Fatalf("agents[3].ID = %q, want %q", agentsAvailable[3].ID, "kimi")
313319
}
314320
if agentsAvailable[3].Status != "available" {
315-
t.Fatalf("qwen available status = %q, want %q", agentsAvailable[3].Status, "available")
321+
t.Fatalf("kimi available status = %q, want %q", agentsAvailable[3].Status, "available")
316322
}
317-
if agentsAvailable[4].ID != "opencode" {
318-
t.Fatalf("agents[4].ID = %q, want %q", agentsAvailable[4].ID, "opencode")
323+
if agentsAvailable[4].ID != "qwen" {
324+
t.Fatalf("agents[4].ID = %q, want %q", agentsAvailable[4].ID, "qwen")
319325
}
320326
if agentsAvailable[4].Status != "available" {
321-
t.Fatalf("opencode available status = %q, want %q", agentsAvailable[4].Status, "available")
327+
t.Fatalf("qwen available status = %q, want %q", agentsAvailable[4].Status, "available")
328+
}
329+
if agentsAvailable[5].ID != "opencode" {
330+
t.Fatalf("agents[5].ID = %q, want %q", agentsAvailable[5].ID, "opencode")
331+
}
332+
if agentsAvailable[5].Status != "available" {
333+
t.Fatalf("opencode available status = %q, want %q", agentsAvailable[5].Status, "available")
322334
}
323335
}
324336

docs/ACCEPTANCE.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,19 @@ This checklist defines executable acceptance checks for requirements 1-16.
144144
- `E2E_QWEN=1 go test ./internal/agents/qwen -run TestQwenE2ESmoke -v -timeout 120s` (pass, real prompt returns `PONG`)
145145
- `go test ./cmd/ngent ./internal/httpapi -count=1` (pass)
146146

147+
## Requirement 16A: Kimi CLI Agent
148+
149+
- Operation: verify kimi provider is listed and can complete a turn over ACP.
150+
- Expected:
151+
- `GET /v1/agents` includes `{"id":"kimi","name":"Kimi CLI","status":"available"}` when `kimi` is in PATH.
152+
- thread creation accepts `agent=kimi`.
153+
- turn streaming emits `message_delta` and finishes with `turn_completed` (or explicit upstream error envelope).
154+
- provider tolerates current upstream ACP startup variants `kimi acp` and `kimi --acp`.
155+
- Verification commands:
156+
- `go test ./internal/agents/kimi -count=1`
157+
- `E2E_KIMI=1 go test ./internal/agents/kimi -run TestKimiE2ESmoke -v -timeout 120s`
158+
- `go test ./cmd/ngent ./internal/httpapi -count=1`
159+
147160
## Requirement 17: Thread Delete Lifecycle
148161

149162
- Operation: delete an existing thread from API/UI, verify ownership behavior, conflict behavior, and provider cleanup.

0 commit comments

Comments
 (0)