* feat(plugin): add kimicode agent plugin
Add @aoagents/ao-plugin-agent-kimicode implementing the Agent interface
for MoonshotAI's Kimi Code CLI. Follows the AO activity JSONL + PATH
wrapper pattern established by agent-aider/opencode, with a native-ish
signal sourced from ~/.kimi/<session>/ mtimes when present.
- Full Agent interface: getLaunchCommand (--yolo, --model, --agent-file),
getEnvironment (AO_SESSION_ID + ~/.ao/bin PATH + GH_PATH), detectActivity,
getActivityState (5-step cascade with mandatory JSONL entry fallback),
isProcessRunning (tmux TTY + PID signal-0, matches `.kimi`/`uv run kimi`),
getSessionInfo (state.json parsing), getRestoreCommand (--resume <id>
with --continue fallback), setupWorkspaceHooks, postLaunchSetup,
recordActivity, detect().
- Post-launch prompt delivery — kimi's `-p` implicitly enables --print and
exits, which would break interactive supervised sessions.
- 58 unit tests covering all 7 mandatory getActivityState cases plus
manifest, launch, env, prompt classification, process detection,
session info extraction, restore command, and detect().
- Register in cli/src/lib/plugins.ts, detect-agent.ts, plugin-registry.json,
cli package deps, and update user-facing docs / yaml examples.
Closes#1384
* fix(plugin): register kimicode in core BUILTIN_PLUGINS and web services
The CLI-side registration in packages/cli/src/lib/plugins.ts only covers
`getAgentByName` callers. Code paths that go through the shared plugin
registry (session-manager, doctor, plugin, verify CLI commands, and the
web dashboard's services singleton) use `createPluginRegistry()` +
`loadBuiltins()` / explicit `register()`, which bypass the CLI map.
Without this wiring:
- `pnpm ao doctor` / `ao plugin` / `ao verify` wouldn't see kimicode
- Web dashboard would fail to render sessions with `agent: kimicode`
because the webpack-bundled services.ts couldn't resolve the plugin
Add kimicode to:
- packages/core/src/plugin-registry.ts BUILTIN_PLUGINS
- packages/web/package.json dependencies
- packages/web/src/lib/services.ts static imports + register call
Caught while comparing against #1395 (kimi-2-6-code plugin), which added
the same registry entry.
* fix(plugin-kimicode): address review feedback
Critical (from @harshitsinghbhandari, verified against kimi-cli source):
- Remove `promptDelivery: "post-launch"` — `-p`/`--prompt` is just a prompt
string alias (also `--command`/`-c`), NOT a mode switch. The non-interactive
flag is `--print`, which we never set. Inline delivery via `--prompt` is
reliable and avoids the post-launch sendMessage() delay.
- Drop unchecked `as string` casts in getRestoreCommand in favor of typeof
guards + `?? undefined` so null model values don't silently leak.
Medium (performance):
- Add 30s per-workspace cache to findKimiSessionMatch (mirrors codex's
SESSION_FILE_CACHE_TTL_MS) so the ~/.kimi/ scan doesn't run 12×/min per
active session. Cache keyed by workspacePath; cleared via the new
`_resetSessionMatchCache` test-only export between test cases.
Minor (correctness):
- Collapse findKimiSessionDir + readKimiSessionState into one
findKimiSessionMatch that returns {dir, state} from a single state.json
read. Previously the file was parsed twice per getSessionInfo /
getRestoreCommand call.
- Wire config.subagent → `kimi --agent <name>` (default / okabe / custom).
- Tighten detectActivity patterns so "I approve of this approach" and
"Earlier I failed to connect" no longer falsely trigger waiting_input /
blocked. Regexes are now line-anchored with `^`/`$` + `\b` word boundaries.
Tests: 58 → 71 (all green). New cases cover:
- Native-signal ready/idle decay (previously only active was tested)
- Cascade ordering: JSONL waiting_input wins over a matching native signal
- Malformed state.json in both getSessionInfo and getRestoreCommand
- `work_dir` alias accepted in addition to `cwd`
- project.agentConfig.model preferred over state.json's recorded model
- False-positive narration guards for both regex tightenings
* refactor(plugin-kimicode): clean up after second-round review
All changes are non-behavioral perf/style cleanups flagged during my second
review pass — no user-visible changes.
- Consolidate double JSON.parse in findKimiSessionMatchUncached: the previous
pass parsed each candidate state.json once to extract cwd and a second time
to extract session_id/model/title. Replaced both helpers with a single
`parseKimiState(raw)` that returns all four fields in one traversal.
- Carry state.json's mtime through KimiSessionMatch so getKimiLiveSignalMtime
(renamed from getKimiSessionMtime) doesn't re-stat state.json — the winner's
mtime was already captured during the scan. Live-signal probe is now limited
to context.jsonl + wire.jsonl (the per-turn files) and runs them in parallel
via Promise.all instead of sequential awaits.
- Fold state.json mtime and the live-signal mtime into a single "freshest"
timestamp in getActivityState so a recently-written context.jsonl wins even
when state.json is stale.
- Tighten appendApprovalFlags signature: `string | undefined` → proper
`AgentPermissionInput | undefined` so typos at call sites fail at compile
time.
- Stricter detect(): don't trust every binary named `kimi` — verify the
--version output mentions kimi/kimi-cli/kimi-code, and fall back to
`kimi info` for builds that print a bare version number. Rejects unrelated
tools that happen to install a `kimi` binary.
Tests: 71 → 75. New coverage:
- detect() accepts kimi-cli vendor strings
- detect() falls back to `kimi info` when --version is ambiguous
- detect() rejects an unrelated `kimi` binary
- Native signal picks the fresher of state.json vs context.jsonl mtimes
* fix(plugin-kimicode): correct session layout discovered via smoke test
Installing kimi-cli 1.38.0 locally (\`uv tool install kimi-cli\`) and running
it once revealed the plugin's session-discovery logic was built on wrong
assumptions about the on-disk layout.
Observed layout (kimi-cli 1.38.0):
~/.kimi/sessions/<md5(cwd)>/<session-uuid>/
context.jsonl — conversation history
wire.jsonl — turn events (TurnBegin/TurnEnd with user_input payload)
Differences from my original assumptions:
- Sessions are nested under \`sessions/\` (not direct subdirectories of
\`~/.kimi/\`).
- The workspace is identified by an MD5 hash of the absolute path, not by
a \`cwd\` field stored in a state file.
- There is no \`state.json\`. No \`title\`, \`model\`, or \`cost\` is persisted.
- The session ID is the UUID directory name and is accepted as-is by
\`kimi --resume <uuid>\`.
- The old \`--continue\` fallback is unnecessary — if we found the directory,
we always know its UUID.
Fixes:
- \`findKimiSessionMatch\` now computes \`md5(workspacePath)\` with node:crypto
and lists \`~/.kimi/sessions/<hash>/\` directly. No more full-tree scan of
\`~/.kimi/\`, no more \`readFile\` of a fictional \`state.json\`.
- \`getKimiLiveSignalMtime\` keeps the parallel \`Promise.all\` stat of
context.jsonl + wire.jsonl (the only files that exist).
- \`getSessionInfo\` streams the first \`TurnBegin\` out of wire.jsonl as a
best-effort summary, with a 1 MB byte ceiling. agentSessionId is the UUID.
- \`getRestoreCommand\` drops the \`--continue\` fallback branch — a found dir
always has a usable UUID.
Verified end-to-end against the real kimi-cli 1.38 binary on this machine:
- \`detect()\` → true
- \`getLaunchCommand\` output parses cleanly when run with \`--help\`
- \`getSessionInfo\` extracts the actual first user prompt ("say hello")
- \`getRestoreCommand\` produces the same UUID kimi itself prints as the
resume hint: \`kimi -r 6ec34626-aedf-4659-a061-c5fbfa4cf166\`
Tests remain at 75 green. Coverage is now against real on-disk layouts
using temp directories with MD5-hashed bucket names — no mock-structure
drift from reality.
* fix(plugin-kimicode): address follow-up review issues
Follow-up to the issues filed as a review comment on the PR.
[MED] detect() too loose (\bkimi\b matches unrelated binaries)
The old regex accepted plain "kimi" alone because the (?:cli|code)?
suffix was optional — any binary whose output contains "kimi" passed.
Real kimi-cli's --version prints just "kimi, version X.Y.Z" (no suffix),
so --version alone can't distinguish it from, say, a hypothetical
keyboard-input-manager named kimi. Switch to `kimi info` exclusively;
real kimi-cli prints "kimi-cli version: ..." which is a distinct vendor
string. Regex now requires "kimi-cli" / "kimi-code" / "moonshot"
literally. Added maxBuffer cap (4 KB) so a hostile binary can't flood
detect() with MB-scale output.
[MED] --work-dir not passed — investigated, not actionable in this PR
AgentLaunchConfig doesn't expose session.workspacePath — only
projectConfig.path (the project root), which would actively break
discovery if passed. Runtime cwd handling is load-bearing. Left a
comment explaining the constraint and pointing at the core-types
change needed to fix it properly.
[LOW] Empty-bucket race returned transient null
During session creation kimi mkdirs the UUID directory before writing
context.jsonl / wire.jsonl. getKimiLiveSignalMtime returned null in
that window and findKimiSessionMatch returned null, flickering the
dashboard to "no signal". Fall back to the UUID directory's own mtime
when live files are absent.
[LOW] isProcessRunning matched "kimi" anywhere in ps args
Old regex /(?:^|\/)\.?kimi(?:\s|$)|(?:\s|^)kimi(?:\s|$)/ matched
`cat kimi.log`, `vim ~/.kimi/config.toml`, etc. Anchor to argv[0]
instead — only the executable itself, or a python/uv/node runner
followed by `kimi` as the first positional argument, counts.
[NIT] Symlink normalization
kimi's process reads cwd via os.getcwd(), which returns the realpath on
Linux. If AO hands us a symlinked workspacePath, our MD5(symlink) won't
match kimi's MD5(realpath). realpath-resolve with a best-effort fallback
to the raw string (preserves behavior when the path doesn't exist yet).
Tests: 75 → 80. New coverage:
- detect() vendor-string matrix: kimi-cli / kimi-code / moonshot accepted,
unrelated "kimi keyboard input manager" rejected
- isProcessRunning rejects `cat kimi.log` / `vim ~/.kimi/config.toml`
- isProcessRunning accepts `python -m kimi`
- Native signal falls back to UUID-dir mtime during the empty-bucket race
- Symlinked workspace path matches the realpath-hashed bucket
Verified end-to-end against real kimi-cli 1.38.0:
- detect() → true (via `kimi info` vendor match)
- getSessionInfo → correct summary + UUID
- getRestoreCommand → matches kimi's own resume hint
* fix(plugin-kimicode): address inline review from illegalcall
Addresses all 10 inline comments on PR #1390.
Load-bearing fixes:
[#6 line 327] detectActivity ordering was wrong
The old code checked the idle prompt (`^kimi>\s*$`) before approval/error
patterns. Real kimi UI re-renders `kimi>` on the last line when asking for
a confirmation, so \`(Y)es/(N)o\\nkimi>\` was misclassified as idle and the
session would sit forever looking quiet while actually blocked on input.
Reordered to: waiting_input → blocked → idle → active. Matches codex/aider.
[#2,#4,#8 lines 128,154,493] No stable AO↔Kimi session binding
Discovery was pure (path-hash + recency). If the user ran kimi manually in
the same repo, or two AO sessions shared a workspace hash, AO would attach
to the wrong UUID — summary / activity / --resume target all corrupted.
Now:
- \`session.metadata.kimiSessionId\` pins a specific UUID when set; no
fallback to recency when the pin misses (fails closed, no silent drift).
- Unpinned lookups filter UUIDs by \`liveMtime >= session.createdAt - 60s\`
so stray dirs from prior AO sessions don't attach.
- findKimiSessionMatch now takes the whole Session (not just workspacePath)
so createdAt + metadata are available.
[#3 line 141] Any recent subdir was treated as a real session
Stray temp dirs and crash leftovers would match on mtime, producing
\`kimi --resume <garbage>\` and bogus active states. Now require
context.jsonl OR wire.jsonl to exist before trusting a dir. The race
fallback (empty UUID dir → dir mtime) is removed — the JSONL activity
fallback in getActivityState covers the startup window instead.
[#5 line 191] Symlink follow outside ~/.kimi/sessions/
\`stat()\` / \`createReadStream()\` followed symlinks without rebinding, so
a bucket entry that's a symlink to \`/dev/zero\` or \`/etc/passwd\` would
hang forever or leak data. Added \`isInsideKimiSessions(path)\` that realpaths
the candidate and rejects anything outside the sessions root. Every
bucket entry is checked before use.
Smaller cleanups:
[#1 line 89] Cache: 30s negative TTL + unbounded growth
Negative results now cached 2s so a session appearing mid-poll is picked
up on the next cycle. Expired entries evicted on read. Cache capped at
256 entries with oldest-expiry pruning. Key changed to (workspacePath,
pinnedUuid) so two AO sessions in the same bucket can't poison each
other's cache entry.
[#7 line 440] Duplicate argv0Re regex — use the const.
[#9 line 532] maxBuffer: 4096 → 65536. Future \`kimi info\` releases that add
plugin listings or telemetry banners won't silently break detect() with
swallowed ENOBUFS.
[#10 test line 650] macOS test breakage: /var/folders is a symlink to
/private/var/folders, so fakeHome under tmpdir() is a symlink path, while
the plugin realpaths before hashing. Wrap the mkdtempSync in realpathSync
so tests agree with the plugin on the canonical path. Linux CI masked this.
Tests: 80 → 86. New coverage:
- detectActivity classifies confirmation-then-prompt-rerender as waiting_input
- detectActivity classifies error-then-prompt-rerender as blocked
- createdAt floor filter (ignores UUIDs from before the AO session)
- Pinned kimiSessionId wins over recency
- Pinned UUID missing returns null (no silent fallback)
- Negative cache TTL ~2s (session appearing mid-poll picked up next cycle)
- Empty UUID dir without live files is rejected (no stray-dir attach)
Verified end-to-end against real kimi-cli 1.38.0: detect() true,
getSessionInfo extracts correct summary + UUID, getRestoreCommand matches
kimi's own resume hint.
* fix(plugin-kimicode): use kimi.json for workspace mapping and add --work-dir
Read ~/.kimi/kimi.json work_dirs[] as the authoritative workspace-to-session
mapping. When last_session_id is populated, prefer it over the directory-mtime
recency heuristic — kimi itself wrote it. Falls back gracefully to the existing
MD5 hash scan when kimi.json is absent or last_session_id is null.
Add --work-dir to getLaunchCommand using projectConfig.path to establish an
explicit cwd contract, preventing shell-rc / tmux-hook drift from causing the
MD5(cwd) hash to diverge from kimi's session bucket.
* fix(plugin-kimicode): plumb workspacePath into AgentLaunchConfig
The kimicode plugin's --work-dir was passing projectConfig.path, which
breaks worktree-mode workspaces. In worktree mode, projectConfig.path is
the original repo root while session.workspacePath is the per-session
checkout — they differ. Either kimi would write to the project root
(breaking worktree isolation) or md5(projectConfig.path) would diverge
from md5(session.workspacePath), so getActivityState/getSessionInfo would
never find this session's bucket.
Fix:
- Add optional `workspacePath` field to AgentLaunchConfig.
- Plumb it through all 3 launch call sites in session-manager.ts.
- kimicode getLaunchCommand uses config.workspacePath, falling back to
config.projectConfig.path when undefined.
- Tests for the divergent-paths case.
Public-interface change: AgentLaunchConfig grows one optional field.
Invariants preserved:
- Agent.getLaunchCommand signature unchanged — still takes one
AgentLaunchConfig.
- Existing plugins (claude-code, aider, codex, opencode) compile and run
unchanged; the new field is optional and they ignore it.
- Clone-mode workspaces (where workspacePath === projectConfig.path)
produce the same launch command as before.
- Fallback to projectConfig.path keeps callers that don't pass the new
field working — no flag day required.
* fix(plugin-kimicode): capture baseline pre-launch to close startup race
captureKimiBaseline() previously ran in postLaunchSetup, which races
against kimi's own startup writes. If kimi created its UUID directory
before postLaunchSetup ran, that UUID landed in `preExistingUuids` and
was filtered out forever — so `findKimiSessionMatch` returned null
permanently for that session.
Fix:
- Add optional `preLaunchSetup(workspacePath)` to the Agent interface,
invoked from session-manager AFTER the workspace exists but BEFORE
`runtime.create()` spawns the agent.
- Move captureKimiBaseline from postLaunchSetup to preLaunchSetup in
the kimicode plugin.
- Test asserts the new UUID is attached even when written immediately
after preLaunchSetup runs (i.e. in the race window).
Public-interface change: Agent.preLaunchSetup is optional. Existing
plugins (claude-code, aider, codex, opencode) compile and behave
unchanged. Only kimicode opts in.
Invariants preserved:
- Workspace exists before preLaunchSetup runs (called after the
worktree/clone is created, never before).
- Failures in preLaunchSetup propagate just like other launch-path
failures — the existing try/catch covers it.
- captureKimiBaseline is still write-once (returns early if the
baseline file already exists), so restore preserves the original
partition.
* fix(plugin-kimicode): persist UUID pin to disk instead of dead metadata
The session.metadata.kimiSessionId branch was treated as the highest-
priority signal but nothing ever populated it. That left the entire
"AO↔kimi UUID binding" mechanism dead — discovery fell through to the
recency heuristic on every call, so a manual `kimi` run in the same
workspace, a sibling AO session sharing a bucket, or any drift in
kimi's directory layout could attach the wrong session.
Fix:
- Remove the dead session.metadata.kimiSessionId branch from
findKimiSessionMatchUncached and the cache key.
- Add a workspace-local pin file (.ao/kimi-session-id.json). Once
findKimiSessionMatchUncached identifies a winner via the recency
heuristic (or via kimi.json's last_session_id soft-pin), it writes
the UUID to the pin file. Subsequent calls read the pin file as the
highest-priority signal and skip the heuristic entirely — locking
in the AO↔kimi binding for the rest of the session lifetime.
- Cache key simplified to workspacePath alone since the pin is now
persistent and cannot drift between calls.
- Tests cover: pin wins over recency, first match writes the pin,
pin holds when a newer non-pinned UUID appears later.
Mechanism mirrors the existing .ao/kimi-baseline.json pattern (also
file-based, write-once, lives in the workspace).
* refactor(plugin-kimicode): extract session-discovery into its own module
index.ts had grown to 880 lines after the pin-file fix landed. The
discovery layer (kimi.json parsing, baseline capture, pin file, hash
bucket scan, cache) is one cohesive responsibility — pulling it out
keeps both files under the 500-line mark and makes the precedence
rules legible.
- New file: session-discovery.ts. Opens with a decision-table comment
documenting the precedence (pin file → kimi.json soft-pin → recency
heuristic) so future readers see the rule before the code.
- Public surface: captureKimiBaseline, findKimiSessionMatch,
KimiSessionMatch, kimiShareDir, _resetSessionMatchCache.
- index.ts re-exports _resetSessionMatchCache so the existing test
imports keep working.
- No behavioral change — all 98 tests pass unchanged.
* test(plugin-kimicode): worktree-mode end-to-end discovery test
Adds a test where workspacePath (per-session worktree) and
projectConfig.path (repo root) are different paths. Asserts that
discovery hashes workspacePath — not projectConfig.path — for the
kimi bucket lookup. Previously this scenario was untested; the bug
fixed in 9fcc1d9 (--work-dir using projectConfig.path) would have
been caught by this test.
Combined with the earlier --work-dir tests in 9fcc1d9, the worktree
divergent-paths case is now exercised at both the launch site
(getLaunchCommand) and the discovery site (getRestoreCommand) end
to end.
* fix(plugin-kimicode): sandbox-check live-signal files against symlinks
Addresses illegalcall's review comment (id 3127022353): the existing
isInsideKimiSessions check verified the session DIRECTORY but not its
children. A symlinked context.jsonl, wire.jsonl, or wire.jsonl pointing
at /etc/passwd, /dev/zero, or a FIFO would be silently followed by
stat() / createReadStream() — leaking reads, hanging on devices, or
escaping the kimi-sessions sandbox.
Fix:
- New isKimiSessionFile(path) helper using lstat + isFile() — rejects
symlinks, sockets, FIFOs, block/char devices. lstat (not stat) so we
see the symlink itself before the kernel resolves it.
- getKimiLiveSignalMtime swapped to lstat-based check; non-regular
files contribute no mtime.
- extractKimiSummary refuses to open wire.jsonl when it isn't a
regular file.
- Tests cover both paths: getActivityState rejects a session whose
live-signal files are symlinked outside the bucket; getSessionInfo
returns null summary when wire.jsonl is symlinked even if context.jsonl
is real.
* fix(plugin-kimicode): apply baseline + createdAt filters to kimi.json soft-pin
The kimi.json soft-pin used to record a candidate UUID before the baseline
and createdAt filters were applied, so a stale last_session_id pointing at
a pre-AO UUID (manual `kimi` run, kimi.json lag) would be captured into
.ao/kimi-session-id.json and route every later getActivityState /
getSessionInfo / getRestoreCommand call at the wrong conversation, with
no self-healing path.
Move the baseline + createdAt floor checks above the soft-pin branch so
the soft-pin candidate goes through the same gates as the recency contest.
Add two regression tests:
- soft-pin pointing at a baseline UUID is rejected and the AO pin file
records the legitimate AO-spawned UUID instead
- soft-pin pointing at a UUID older than session.createdAt - 60s is
rejected by the createdAt floor
Both tests fail on the prior code and pass after the fix.