447 lines
17 KiB
TypeScript
447 lines
17 KiB
TypeScript
import {
|
|
DEFAULT_READY_THRESHOLD_MS,
|
|
DEFAULT_ACTIVE_WINDOW_MS,
|
|
shellEscape,
|
|
normalizeAgentPermissionMode,
|
|
buildAgentPath,
|
|
setupPathWrapperWorkspace,
|
|
readLastActivityEntry,
|
|
checkActivityLogState,
|
|
getActivityFallbackState,
|
|
recordTerminalActivity,
|
|
PREFERRED_GH_PATH,
|
|
type Agent,
|
|
type AgentSessionInfo,
|
|
type AgentLaunchConfig,
|
|
type AgentPermissionInput,
|
|
type ActivityDetection,
|
|
type ActivityState,
|
|
type PluginModule,
|
|
type ProjectConfig,
|
|
type RuntimeHandle,
|
|
type Session,
|
|
type WorkspaceHooksConfig,
|
|
} from "@aoagents/ao-core";
|
|
import { execFile, execFileSync } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { createReadStream } from "node:fs";
|
|
import { createInterface } from "node:readline";
|
|
import { join } from "node:path";
|
|
import {
|
|
captureKimiBaseline,
|
|
findKimiSessionMatch,
|
|
isKimiSessionFile,
|
|
_resetSessionMatchCache,
|
|
} from "./session-discovery.js";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
/** Max chars we keep from a wire.jsonl user-input summary. */
|
|
const SUMMARY_MAX_CHARS = 120;
|
|
/** Max bytes of wire.jsonl we read looking for the first TurnBegin. */
|
|
const SUMMARY_SCAN_BYTE_LIMIT = 1_000_000;
|
|
|
|
/**
|
|
* Extract the first user prompt from a session's wire.jsonl as a fallback
|
|
* summary. Stops after the first TurnBegin or after reading ~1 MB (whichever
|
|
* comes first) so we never slurp huge session logs.
|
|
*/
|
|
async function extractKimiSummary(sessionDir: string): Promise<string | null> {
|
|
const wirePath = join(sessionDir, "wire.jsonl");
|
|
// Sandbox check: refuse to follow a symlink (or open a socket / FIFO) at
|
|
// wire.jsonl. The sessionDir was already verified, but its children could
|
|
// still be planted as symlinks pointing at /etc/passwd or /dev/zero.
|
|
if (!(await isKimiSessionFile(wirePath))) return null;
|
|
let summary: string | null = null;
|
|
try {
|
|
const rl = createInterface({
|
|
input: createReadStream(wirePath, { encoding: "utf-8" }),
|
|
crlfDelay: Infinity,
|
|
});
|
|
let bytes = 0;
|
|
for await (const line of rl) {
|
|
bytes += line.length;
|
|
if (bytes > SUMMARY_SCAN_BYTE_LIMIT) break;
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
try {
|
|
const parsed: unknown = JSON.parse(trimmed);
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
|
|
const entry = parsed as Record<string, unknown>;
|
|
const message = entry["message"];
|
|
if (!message || typeof message !== "object" || Array.isArray(message)) continue;
|
|
const msg = message as Record<string, unknown>;
|
|
if (msg["type"] !== "TurnBegin") continue;
|
|
const payload = msg["payload"];
|
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) continue;
|
|
const userInput = (payload as Record<string, unknown>)["user_input"];
|
|
if (typeof userInput === "string" && userInput.length > 0) {
|
|
summary =
|
|
userInput.length > SUMMARY_MAX_CHARS
|
|
? userInput.slice(0, SUMMARY_MAX_CHARS) + "..."
|
|
: userInput;
|
|
break;
|
|
}
|
|
} catch {
|
|
// Skip malformed line
|
|
}
|
|
}
|
|
rl.close();
|
|
} catch {
|
|
return null;
|
|
}
|
|
return summary;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Plugin Manifest
|
|
// =============================================================================
|
|
|
|
export const manifest = {
|
|
name: "kimicode",
|
|
slot: "agent" as const,
|
|
description: "Agent plugin: Kimi Code CLI (MoonshotAI)",
|
|
version: "0.1.0",
|
|
displayName: "Kimi Code",
|
|
};
|
|
|
|
// =============================================================================
|
|
// Agent Implementation
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Append approval flags — kimi uses `--yolo` (aka `-y`, `--yes`, `--auto-approve`).
|
|
* Suggest/ask modes have no dedicated flag; kimi prompts inline by default.
|
|
*/
|
|
function appendApprovalFlags(
|
|
parts: string[],
|
|
permissions: AgentPermissionInput | undefined,
|
|
): void {
|
|
const mode = normalizeAgentPermissionMode(permissions);
|
|
if (mode === "permissionless" || mode === "auto-edit") {
|
|
parts.push("--yolo");
|
|
}
|
|
}
|
|
|
|
function createKimicodeAgent(): Agent {
|
|
return {
|
|
name: "kimicode",
|
|
processName: "kimi",
|
|
|
|
getLaunchCommand(config: AgentLaunchConfig): string {
|
|
const parts: string[] = ["kimi"];
|
|
|
|
// Explicit --work-dir prevents shell-rc / tmux-hook cwd drift from
|
|
// making our md5(cwd) hash diverge from kimi's.
|
|
//
|
|
// Prefer config.workspacePath (per-session worktree) over
|
|
// projectConfig.path (the original repo root). When the workspace
|
|
// plugin is "worktree", these differ — passing projectConfig.path
|
|
// would either (a) make kimi write to the project root, breaking
|
|
// worktree isolation, or (b) cause md5(cwd) to diverge from
|
|
// session.workspacePath, so getActivityState/getSessionInfo never
|
|
// find this session's bucket. Falls back to projectConfig.path
|
|
// for clone-mode workspaces or older callers that don't plumb it.
|
|
const workDir = config.workspacePath ?? config.projectConfig.path;
|
|
if (workDir) {
|
|
parts.push("--work-dir", shellEscape(workDir));
|
|
}
|
|
|
|
appendApprovalFlags(parts, config.permissions);
|
|
|
|
if (config.model) {
|
|
parts.push("--model", shellEscape(config.model));
|
|
}
|
|
|
|
// Route AO-level subagent selection to kimi's `--agent NAME`
|
|
// (built-in agents: default, okabe, or custom via --agent-file).
|
|
if (config.subagent) {
|
|
parts.push("--agent", shellEscape(config.subagent));
|
|
}
|
|
|
|
// Kimi does not have a documented system-prompt flag for ad-hoc injection;
|
|
// --agent-file is the closest, but requires a dedicated file on disk.
|
|
// Prefer passing systemPromptFile directly when the caller asked for a
|
|
// file-backed system prompt.
|
|
if (config.systemPromptFile) {
|
|
parts.push("--agent-file", shellEscape(config.systemPromptFile));
|
|
}
|
|
|
|
// kimi's `-p`/`--prompt` is just the prompt string (alias of `--command`).
|
|
// It does NOT switch to print/exit mode — that's the separate `--print`
|
|
// flag, which we never set. Inline delivery is reliable and avoids the
|
|
// post-launch sendMessage() delay.
|
|
if (config.prompt) {
|
|
parts.push("--prompt", shellEscape(config.prompt));
|
|
}
|
|
|
|
return parts.join(" ");
|
|
},
|
|
|
|
getEnvironment(config: AgentLaunchConfig): Record<string, string> {
|
|
const env: Record<string, string> = {};
|
|
env["AO_SESSION_ID"] = config.sessionId;
|
|
if (config.issueId) {
|
|
env["AO_ISSUE_ID"] = config.issueId;
|
|
}
|
|
|
|
// Prepend ~/.ao/bin so gh/git wrappers intercept PR/commit commands.
|
|
env["PATH"] = buildAgentPath(process.env["PATH"]);
|
|
env["GH_PATH"] = PREFERRED_GH_PATH;
|
|
|
|
return env;
|
|
},
|
|
|
|
detectActivity(terminalOutput: string): ActivityState {
|
|
if (!terminalOutput.trim()) return "idle";
|
|
|
|
const lines = terminalOutput.trim().split("\n");
|
|
const lastLine = lines[lines.length - 1]?.trim() ?? "";
|
|
const tail = lines.slice(-6).join("\n");
|
|
|
|
// Order matters: waiting_input → blocked → idle → active. Actionable
|
|
// states must be checked BEFORE the idle-prompt check, otherwise a
|
|
// confirmation prompt that re-renders `kimi>` on the last line would
|
|
// get classified as idle and the session would sit forever looking
|
|
// quiet. Matches agent-codex / agent-aider ordering.
|
|
|
|
// 1. waiting_input — approval / confirmation prompts. Line-anchored
|
|
// where practical to avoid matching narration like "I approve of
|
|
// this approach".
|
|
if (/\(y\)es.*\(n\)o/i.test(tail)) return "waiting_input";
|
|
if (/\[y\/n\]\s*[?:]?\s*$/im.test(tail)) return "waiting_input";
|
|
if (/^\s*approve\??\s*$/im.test(tail)) return "waiting_input";
|
|
if (/\bapproval required\b/i.test(tail)) return "waiting_input";
|
|
if (/^\s*do you want to (proceed|continue)\?\s*$/im.test(tail)) return "waiting_input";
|
|
if (/^\s*allow .+\?\s*$/im.test(tail)) return "waiting_input";
|
|
|
|
// 2. blocked — hard errors surfaced to the terminal. Line-anchored to
|
|
// skip narration ("Earlier I failed to connect, then retried").
|
|
if (/^\s*error:/im.test(tail)) return "blocked";
|
|
if (/^\s*(?:error:\s*)?failed to (connect|authenticate|load)\b/im.test(tail)) return "blocked";
|
|
|
|
// 3. idle — only when nothing actionable is visible and the tail is a
|
|
// bare prompt. Generic shell/REPL prompt…
|
|
if (/^[>$#]\s*$/.test(lastLine)) return "idle";
|
|
// …or kimi's interactive prompt.
|
|
if (/^kimi[>:]?\s*$/i.test(lastLine)) return "idle";
|
|
|
|
// 4. active — anything else with content is ongoing work.
|
|
return "active";
|
|
},
|
|
|
|
async getActivityState(
|
|
session: Session,
|
|
readyThresholdMs?: number,
|
|
): Promise<ActivityDetection | null> {
|
|
const threshold = readyThresholdMs ?? DEFAULT_READY_THRESHOLD_MS;
|
|
const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold);
|
|
|
|
// 1. Process check — always first.
|
|
const exitedAt = new Date();
|
|
if (!session.runtimeHandle) return { state: "exited", timestamp: exitedAt };
|
|
const running = await this.isProcessRunning(session.runtimeHandle);
|
|
if (!running) return { state: "exited", timestamp: exitedAt };
|
|
|
|
if (!session.workspacePath) return null;
|
|
|
|
// 2. Actionable states (waiting_input / blocked) sourced from the AO
|
|
// activity JSONL written by recordActivity. Kimi's native JSONL format
|
|
// is not publicly documented, so terminal-derived state is our only
|
|
// reliable source for approval/error detection.
|
|
const activityResult = await readLastActivityEntry(session.workspacePath);
|
|
const activityState = checkActivityLogState(activityResult);
|
|
if (activityState) return activityState;
|
|
|
|
// 3. Native signal — mtime of the freshest live file (context.jsonl /
|
|
// wire.jsonl) inside ~/.kimi/sessions/<md5(cwd)>/<uuid>/. The match
|
|
// already captured the mtime during the scan, so no re-stat here.
|
|
const match = await findKimiSessionMatch(session);
|
|
if (match) {
|
|
const ageMs = Math.max(0, Date.now() - match.mtime.getTime());
|
|
if (ageMs <= activeWindowMs) return { state: "active", timestamp: match.mtime };
|
|
if (ageMs <= threshold) return { state: "ready", timestamp: match.mtime };
|
|
return { state: "idle", timestamp: match.mtime };
|
|
}
|
|
|
|
// 4. JSONL entry fallback (MANDATORY) — uses the last AO activity entry
|
|
// with age-based decay when the native signal is unavailable.
|
|
const fallback = getActivityFallbackState(activityResult, activeWindowMs, threshold);
|
|
if (fallback) return fallback;
|
|
|
|
// 5. No data available.
|
|
return null;
|
|
},
|
|
|
|
async recordActivity(session: Session, terminalOutput: string): Promise<void> {
|
|
if (!session.workspacePath) return;
|
|
await recordTerminalActivity(session.workspacePath, terminalOutput, (output) =>
|
|
this.detectActivity(output),
|
|
);
|
|
},
|
|
|
|
async isProcessRunning(handle: RuntimeHandle): Promise<boolean> {
|
|
try {
|
|
if (handle.runtimeName === "tmux" && handle.id) {
|
|
const { stdout: ttyOut } = await execFileAsync(
|
|
"tmux",
|
|
["list-panes", "-t", handle.id, "-F", "#{pane_tty}"],
|
|
{ timeout: 30_000 },
|
|
);
|
|
const ttys = ttyOut
|
|
.trim()
|
|
.split("\n")
|
|
.map((t) => t.trim())
|
|
.filter(Boolean);
|
|
if (ttys.length === 0) return false;
|
|
|
|
const { stdout: psOut } = await execFileAsync("ps", ["-eo", "pid,tty,args"], {
|
|
timeout: 30_000,
|
|
});
|
|
const ttySet = new Set(ttys.map((t) => t.replace(/^\/dev\//, "")));
|
|
// Only consider argv[0] — this is the executable being run, not
|
|
// arbitrary filenames (e.g. `cat kimi.log`) that happen to contain
|
|
// "kimi". We accept:
|
|
// - argv[0] basename == "kimi" or ".kimi" (dot-prefixed shim)
|
|
// - argv[0] is a python/uv invocation followed by "kimi" as the
|
|
// next token (e.g. `uv run kimi ...`, `python -m kimi ...`).
|
|
const argv0Re = /(?:^|\/)\.?kimi$/;
|
|
const viaRunnerRe = /(?:^|\/)(?:uv|python3?|node)$/;
|
|
for (const line of psOut.split("\n")) {
|
|
const cols = line.trimStart().split(/\s+/);
|
|
if (cols.length < 3 || !ttySet.has(cols[1] ?? "")) continue;
|
|
const argv = cols.slice(2);
|
|
const head = argv[0] ?? "";
|
|
if (argv0Re.test(head)) return true;
|
|
if (!viaRunnerRe.test(head)) continue;
|
|
// Skip runner-internal flags (`uv run`, `python -m`) and check the
|
|
// next positional argument.
|
|
for (let i = 1; i < argv.length; i++) {
|
|
const tok = argv[i];
|
|
if (!tok || tok.startsWith("-")) continue;
|
|
if (tok === "run" || tok === "tool" || tok === "-m") continue;
|
|
if (argv0Re.test(tok)) return true;
|
|
break;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const rawPid = handle.data["pid"];
|
|
const pid = typeof rawPid === "number" ? rawPid : Number(rawPid);
|
|
if (Number.isFinite(pid) && pid > 0) {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (err: unknown) {
|
|
if (err instanceof Error && "code" in err && err.code === "EPERM") {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
|
|
async getSessionInfo(session: Session): Promise<AgentSessionInfo | null> {
|
|
if (!session.workspacePath) return null;
|
|
|
|
const match = await findKimiSessionMatch(session);
|
|
if (!match) return null;
|
|
|
|
// Best-effort summary: first user input from wire.jsonl. Kimi does not
|
|
// store a title, model, or cost breakdown on disk.
|
|
const summary = await extractKimiSummary(match.dir);
|
|
|
|
return {
|
|
summary,
|
|
summaryIsFallback: true,
|
|
agentSessionId: match.sessionId,
|
|
};
|
|
},
|
|
|
|
async getRestoreCommand(session: Session, project: ProjectConfig): Promise<string | null> {
|
|
if (!session.workspacePath) return null;
|
|
|
|
const match = await findKimiSessionMatch(session);
|
|
if (!match) return null;
|
|
|
|
const configuredModel =
|
|
typeof project.agentConfig?.model === "string" ? project.agentConfig.model : undefined;
|
|
|
|
const parts: string[] = ["kimi", "--resume", shellEscape(match.sessionId)];
|
|
appendApprovalFlags(parts, project.agentConfig?.permissions);
|
|
if (configuredModel) {
|
|
parts.push("--model", shellEscape(configuredModel));
|
|
}
|
|
return parts.join(" ");
|
|
},
|
|
|
|
async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise<void> {
|
|
await setupPathWrapperWorkspace(workspacePath);
|
|
},
|
|
|
|
// Snapshot pre-existing UUIDs BEFORE kimi launches. Capturing in
|
|
// postLaunchSetup races against kimi's own startup writes — kimi may
|
|
// create its UUID directory before postLaunchSetup runs, in which case
|
|
// the freshly-created UUID lands in `preExistingUuids` and gets filtered
|
|
// out forever. Discovery would then return null permanently.
|
|
//
|
|
// No-op on restore — captureKimiBaseline only writes the file when it
|
|
// doesn't already exist, so the original "what was here before AO
|
|
// started" partition stays stable across the session lifetime.
|
|
async preLaunchSetup(workspacePath: string): Promise<void> {
|
|
await captureKimiBaseline(workspacePath);
|
|
},
|
|
|
|
async postLaunchSetup(session: Session): Promise<void> {
|
|
if (!session.workspacePath) return;
|
|
await setupPathWrapperWorkspace(session.workspacePath);
|
|
},
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// Plugin Export
|
|
// =============================================================================
|
|
|
|
export function create(): Agent {
|
|
return createKimicodeAgent();
|
|
}
|
|
|
|
export { _resetSessionMatchCache };
|
|
|
|
/** Vendor strings that positively identify MoonshotAI's kimi-cli. Plain "kimi"
|
|
* alone is not enough — it matches unrelated binaries (e.g. a keyboard input
|
|
* manager). `kimi info` on real kimi-cli prints "kimi-cli version: ..." which
|
|
* is a distinct identifier. */
|
|
const KIMI_VENDOR_RE = /kimi[-_](?:cli|code)|moonshot/i;
|
|
/** Keep `kimi info` output capture bounded. Real kimi-cli prints ~80 bytes,
|
|
* but a future release adding plugin lists / telemetry banners could push
|
|
* this higher. 64 KB is well above anything realistic while still guarding
|
|
* against a hostile binary flooding stdout. */
|
|
const DETECT_BUFFER_BYTES = 65_536;
|
|
|
|
export function detect(): boolean {
|
|
try {
|
|
// Use `kimi info` as the authoritative check — `kimi --version` prints
|
|
// just "kimi, version X.Y.Z" which is too generic to distinguish the
|
|
// MoonshotAI tool from any other binary named "kimi".
|
|
const infoOut = execFileSync("kimi", ["info"], {
|
|
encoding: "utf-8",
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
timeout: 10_000,
|
|
maxBuffer: DETECT_BUFFER_BYTES,
|
|
});
|
|
return KIMI_VENDOR_RE.test(infoOut);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export default { manifest, create, detect } satisfies PluginModule<Agent>;
|