186 lines
6.6 KiB
Go
186 lines
6.6 KiB
Go
// Package kimi implements the Kimi CLI (Moonshot AI) agent adapter: launching
|
|
// new non-interactive sessions and resuming sessions when a native Kimi session
|
|
// id is known.
|
|
//
|
|
// Kimi CLI (binary "kimi") is Moonshot AI's terminal-native agentic coding
|
|
// agent. A new task is run non-interactively with `kimi -p <prompt>`, which
|
|
// streams the assistant output to stdout without opening the TUI. Sessions are
|
|
// resumed by id with `kimi --session <id>`.
|
|
//
|
|
// Kimi exposes no native lifecycle/hook system and is not documented as
|
|
// Claude Code hook-compatible, so this is a Tier C adapter: hook installation
|
|
// and SessionInfo are intentionally no-ops, and activity is left to the
|
|
// lifecycle reaper. There is also no documented system-prompt flag, so AO's
|
|
// system prompt is not injected. Both should be upgraded if/when Kimi adds the
|
|
// corresponding CLI surface.
|
|
package kimi
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase"
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
|
)
|
|
|
|
const adapterID = "kimi"
|
|
|
|
// Plugin is the Kimi CLI agent adapter. It is safe for concurrent use; the
|
|
// binary path is resolved once and cached under binaryMu.
|
|
type Plugin struct {
|
|
agentbase.Base
|
|
binaryMu sync.Mutex
|
|
resolvedBinary string
|
|
}
|
|
|
|
// New returns a ready-to-register Kimi adapter.
|
|
func New() *Plugin {
|
|
return &Plugin{}
|
|
}
|
|
|
|
var _ adapters.Adapter = (*Plugin)(nil)
|
|
var _ ports.Agent = (*Plugin)(nil)
|
|
|
|
// Manifest returns the adapter's static self-description.
|
|
func (p *Plugin) Manifest() adapters.Manifest {
|
|
return adapters.Manifest{
|
|
ID: adapterID,
|
|
Name: "Kimi",
|
|
Description: "Run Kimi CLI (Moonshot AI) worker sessions.",
|
|
Version: "0.0.1",
|
|
Capabilities: []adapters.Capability{
|
|
adapters.CapabilityAgent,
|
|
},
|
|
}
|
|
}
|
|
|
|
// GetLaunchCommand builds the argv to start a new Kimi session:
|
|
//
|
|
// kimi -p <prompt> (non-interactive, default)
|
|
// kimi [--yolo|--auto] (interactive, no prompt)
|
|
//
|
|
// When a prompt is supplied, it is delivered via `-p` (in command), which runs
|
|
// a single prompt without opening the TUI. Per Kimi docs, `--prompt` cannot be
|
|
// combined with `--yolo`, `--auto`, or `--plan` -- non-interactive mode already
|
|
// uses the `auto` permission policy by default, so approval flags would be
|
|
// rejected at startup. They are only emitted on the (interactive) path with no
|
|
// prompt. Kimi has no documented system-prompt flag, so cfg.SystemPrompt /
|
|
// cfg.SystemPromptFile are not injected.
|
|
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
|
|
binary, err := p.kimiBinary(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cmd = []string{binary}
|
|
|
|
if cfg.Prompt != "" {
|
|
cmd = append(cmd, "-p", cfg.Prompt)
|
|
return cmd, nil
|
|
}
|
|
|
|
appendApprovalFlags(&cmd, cfg.Permissions)
|
|
return cmd, nil
|
|
}
|
|
|
|
// GetRestoreCommand rebuilds the argv that continues an existing Kimi session
|
|
// when a native Kimi session id is known:
|
|
//
|
|
// kimi --session <agentSessionId>
|
|
//
|
|
// ok is false when no native session id has been captured, so callers fall back
|
|
// to fresh launch behavior. Per Kimi docs, `--yolo` and `--auto` cannot be
|
|
// combined with `--session` (or `--continue`) -- resumed sessions inherit the
|
|
// approval settings of the original session -- so cfg.Permissions is
|
|
// intentionally ignored here. Kimi has no lifecycle hook for AO to capture the
|
|
// native session id from yet, so in practice this returns ok=false today.
|
|
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, false, err
|
|
}
|
|
agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID])
|
|
if agentSessionID == "" {
|
|
return nil, false, nil
|
|
}
|
|
|
|
binary, err := p.kimiBinary(ctx)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
cmd = []string{binary, "--session", agentSessionID}
|
|
return cmd, true, nil
|
|
}
|
|
|
|
// appendApprovalFlags maps AO's permission modes onto Kimi's approval flags
|
|
// for interactive launches. Per Kimi docs these flags cannot be combined with
|
|
// `--prompt`, `--session`, or `--continue`, so callers on those paths must
|
|
// skip this mapping.
|
|
//
|
|
// - Default: no flag, deferring to the user's Kimi config/default behavior.
|
|
// - AcceptEdits / Auto: `--auto` (auto permission mode; approvals handled
|
|
// automatically).
|
|
// - BypassPermissions: `-y` (yolo; auto-approve regular tool calls including
|
|
// file writes and shell execution).
|
|
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
|
switch normalizePermissionMode(permissions) {
|
|
case ports.PermissionModeDefault:
|
|
// No flag: defer to the user's Kimi config/default behavior.
|
|
case ports.PermissionModeAcceptEdits, ports.PermissionModeAuto:
|
|
*cmd = append(*cmd, "--auto")
|
|
case ports.PermissionModeBypassPermissions:
|
|
*cmd = append(*cmd, "-y")
|
|
}
|
|
}
|
|
|
|
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
|
|
switch mode {
|
|
case ports.PermissionModeDefault,
|
|
ports.PermissionModeAcceptEdits,
|
|
ports.PermissionModeAuto,
|
|
ports.PermissionModeBypassPermissions:
|
|
return mode
|
|
default:
|
|
return ports.PermissionModeDefault
|
|
}
|
|
}
|
|
|
|
var kimiBinarySpec = binaryutil.BinarySpec{
|
|
Label: "kimi",
|
|
Names: []string{"kimi"},
|
|
WinNames: []string{"kimi.cmd", "kimi.exe", "kimi"},
|
|
UnixPaths: []string{"/usr/local/bin/kimi", "/opt/homebrew/bin/kimi"},
|
|
UnixHomePaths: [][]string{{".local", "bin", "kimi"}, {".cargo", "bin", "kimi"}},
|
|
WinPaths: []binaryutil.WinPath{
|
|
{Base: binaryutil.WinAppData, Parts: []string{"npm", "kimi.cmd"}},
|
|
{Base: binaryutil.WinAppData, Parts: []string{"npm", "kimi.exe"}},
|
|
{Base: binaryutil.WinHome, Parts: []string{".local", "bin", "kimi.exe"}},
|
|
},
|
|
}
|
|
|
|
// ResolveKimiBinary finds the `kimi` binary, searching PATH then common install
|
|
// locations (the uv tool/curl installer drops it in ~/.local/bin, plus Homebrew
|
|
// and ~/.cargo/bin). It returns "kimi" as a last resort so callers get the
|
|
// shell's normal command-not-found behavior if Kimi is absent.
|
|
func ResolveKimiBinary(ctx context.Context) (string, error) {
|
|
return binaryutil.ResolveBinary(ctx, kimiBinarySpec)
|
|
}
|
|
|
|
func (p *Plugin) kimiBinary(ctx context.Context) (string, error) {
|
|
p.binaryMu.Lock()
|
|
defer p.binaryMu.Unlock()
|
|
|
|
if p.resolvedBinary != "" {
|
|
return p.resolvedBinary, nil
|
|
}
|
|
|
|
binary, err := ResolveKimiBinary(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
p.resolvedBinary = binary
|
|
return binary, nil
|
|
}
|