* 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.
Resolves 15 conflicts and reconciles main's storage V2 redesign,
DirectTerminal hooks split, opencode shared cache, and PR refactors
with the branch's Windows platform adapter.
Test suite is fully green on Windows after this merge. Changes:
Mechanical/portable fixes:
- Path-separator-agnostic regex matchers in spawn.test.ts and
update-check.test.ts (Windows uses backslashes).
- Fixed broken char-class regex in script-runner.test.ts path escape.
- Bash matcher accepts both POSIX (`bash`) and Windows (`bash.exe`).
- USERPROFILE override added alongside HOME in filesystem-browse-api
test (node's os.homedir() reads USERPROFILE on Windows, not HOME).
- Outside-HOME absolute path in browse test is now platform-aware
(C:\Windows on win32, /etc on POSIX) so realpathSync() resolves.
- Added missing enrichSessionIssue import in serialize.test.ts.
- agent-cursor execFileSync expectation loosened to objectContaining.
Windows-only test skips (with explanatory comments):
- migration-storage-v2.test.ts: 3 describe blocks skipped — they
migrate FROM the legacy hash-dir layout that only ever shipped on
Linux/macOS in V1. Future Windows migration coverage would need a
Windows-shaped fixture rewrite.
- migration-codex-restore.integration.test.ts: same legacy-layout
reason.
- bun-tmp-janitor.test.ts: startBunTmpJanitor() is a no-op on win32
(no opencode Windows binary, kernel disallows unlinking mapped
files).
- start.test.ts \"full stop\" test: now goes through killProcessTree()
which calls `taskkill /T /F` on win32, not process.kill.
- script-runner.test.ts POSIX-fixture tests: skip on win32.
- filesystem-browse symlink test: skipped on win32 (symlinkSync
requires admin or Developer Mode).
Windows fs-slowness adjustments:
- agent-report.test.ts: bumped per-test timeout to 30s for the
audit-trail test (260 atomic-write cycles are slow on Windows due
to AV scanning of every rename).
- lifecycle-manager.test.ts: replaced fixed 25ms wait with a
poll-until-called pattern (deadline 2000ms, 10ms intervals) to
remove a flake under full-suite load on Windows.
Pre-existing main test bugs (skipped, NOT introduced by this merge):
- api-routes.test.ts: 2 tests assert old async (dashboard, scm, pr,
opts) signature of enrichSessionPR, but commit a8bc7469 on main
simplified it to a synchronous, single-arg metadata read. Skipped
with explanatory comment; should be filed as separate main issue.
- page.test.tsx: \"renders inline missing-session state\" references
an undefined TestErrorBoundary symbol (commit 0538e07b on main
removed the class but missed these usages). Page now renders 404
inline rather than throwing, so the test would need a different
assertion strategy. Skipped; should be filed as separate main
issue.
Smoke-tested on Windows:
- pnpm build clean, pnpm test green, pnpm typecheck clean.
- ao --version, ao doctor (16 PASS / 1 expected WARN / 0 FAIL),
ao update --check, ao doctor --help — all working. Bash
auto-detect resolved to Git Bash and ran ao-doctor.sh via
spawn() with windowsHide:true.
Follow-ups (additive, not blocking):
- Re-add main's 3 Linux port-scan unit tests in start.test.ts as
POSIX-only tests (code path is intact in start.ts; only unit-test
coverage is missing post-merge).
- Add Windows runRepoScript unit tests in a separate
script-runner-windows.test.ts (branch's vi.mock-heavy tests were
incompatible with main's real-fs tests).
* feat: add config schema support for agent-orchestrator.yaml (#1370)
Expose a committed JSON Schema and inject the canonical $schema URL into generated and updated configs so editors can autocomplete and validate AO config files.
* fix schema injection edge cases and shared constant
* fix: address config schema review feedback (#1370)
* feat(core): auto-terminate sessions on PR merge (#1309)
When a session's PR was detected as merged, the session transitioned
to status "merged" but its tmux runtime, worktree, and metadata were
never cleaned up — leaving zombie tmux sessions and stale entries in
`ao status` / `ao session ls`. Users worked around this with an
external watchdog. Close the loop in AO itself.
Changes:
- `kill()` gains an optional `reason` and returns `KillResult`
(`cleaned` / `alreadyTerminated`), with short-circuit paths so
repeated calls on archived sessions are safe no-ops instead of
throwing `SessionNotFoundError`.
- New `LifecycleConfig` (`autoCleanupOnMerge: true` default,
`mergeCleanupIdleGraceMs: 5 min`) so operators can opt out when
they need merged worktrees preserved for inspection.
- `lifecycle-manager` runs `maybeAutoCleanupOnMerge` at the end of
each `checkSession`. Reactions and notifications observe the live
session first; cleanup runs last. If the agent is still `active` /
`waiting_input` / `blocked`, cleanup is deferred and retried on
the next poll until the agent idles or the grace window elapses
(prevents killing an agent mid-task).
- New `CanonicalSessionReason` / `CanonicalRuntimeReason` variants
(`pr_merged`, `auto_cleanup`) so observability distinguishes
automated teardown from manual kills.
Scope is deliberately narrow to `merged`: `done` / `errored` often
need the worktree preserved for debugging; `killed` would self-recurse.
Follows Codex review feedback (conditional pass): scope narrowed,
reactions-before-cleanup ordering, idleness safety gate, real
idempotency guards, config opt-in.
6 new unit tests cover: idle agent cleanup, active agent deferral,
grace-window force-cleanup, config opt-out, terminated/killed no
self-recursion, kill() failure retry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(core): clean up lifecycle config access per review
Address review comment on PR #1311. The `config.lifecycle` field is
typed optional but always populated by Zod — the old guard chain
(`if (lifecycleConfig && lifecycleConfig.autoCleanupOnMerge === false)`)
obscured that duality. Destructure with defaults at the call site so
the contract is visible in one place, and document why the field stays
optional (hand-constructed test configs) on the interface.
Matches the existing `power?: PowerConfig` pattern — keeps churn to
zero across 60 test config literals while removing the ambiguous guard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(core): surface auto-cleanup-on-merge in config, docs, and UI
Followup to DX audit on #1311. The lifecycle cleanup behavior was
operational but invisible — config key only in TS types, no changeset
for downstream consumers, missing observability spec, and a dashboard
summary that actively contradicted the new default-on behavior.
- agent-orchestrator.yaml.example: add commented `lifecycle:` block
with both keys so operators discover the knob in the primary
config reference.
- .changeset/auto-cleanup-on-merge.md: minor bump for @aoagents/ao-core
with migration note (default-on, opt-out via config).
- docs/observability.md: document the three new lifecycle_poll
operations (merge_cleanup.completed / deferred / failed) so
dashboard/alert authors have a spec.
- packages/web/src/lib/serialize.ts: replace stale summary
"PR merged; worker is still available for a keep-or-kill decision"
with "PR merged; worker session will be cleaned up automatically".
The old copy is wrong under default-on auto-cleanup.
Deferred to follow-up issues:
- Health surface degradation on repeated cleanup failure (the
operator-facing gap Codex flagged — failures emit a metric but
don't downgrade /api/observability health).
- Dashboard "cleaning up in Nm" indicator for the deferred state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(core,web): address PR review feedback for auto-cleanup on merge
- serialize.ts: only claim "will be cleaned up automatically" when
mergedPendingCleanupSince marker is present; otherwise show neutral
"PR merged". Avoids lying when autoCleanupOnMerge is opted out.
- lifecycle-manager.ts: use ACTIVITY_STATE constants instead of
hardcoded strings, matching the existing SESSION_STATUS.MERGED usage.
- config.ts: keep mergeCleanupIdleGraceMs=0 as a valid escape hatch
(immediate cleanup), but reject 1..9999 with a units-mistake error
so users typing `5` (intending seconds) get a clear message.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add idle sleep prevention on macOS using `caffeinate -i -w <pid>` to keep
the Mac awake while AO is running, enabling remote dashboard access (e.g.,
via Tailscale) without the machine going to sleep.
Changes:
- Add `preventIdleSleep()` helper in packages/cli/src/lib/prevent-sleep.ts
- Add `power.preventIdleSleep` config option (defaults to true on macOS)
- Wire sleep prevention into `ao start` command
- Add tests for the helper function and config validation
- Document the feature in README and example config
Note: Lid-close sleep is enforced by macOS hardware and cannot be prevented
by userspace assertions. Use clamshell mode for that use case.
Closes#1072
Co-Authored-By: Claude <noreply@anthropic.com>
- Use consistent regex for OPENCLAW_HOOKS_TOKEN detection and replacement
in shell profile (prevents silent no-ops for non-exported lines)
- Broaden token detection regex to match lines with/without export prefix
and leading whitespace
- Fix misleading --non-interactive help text (token is auto-generated)
- Fix doctor.ts catch block to say "Notifier checks failed" not "load config"
- Fix 204 mock in Discord notifier test (ok: true, not ok: false)
- Fix weak no-duplicate assertion in setup.test.ts (actually count list items)
- Add discord to notifier options comment in config-instruction.ts
- URL-encode threadId in Discord webhook URL construction
- Add aoCwd to required[] in openclaw.plugin.json configSchema
- Add HTTPS recommendation comment to agent-orchestrator.yaml.example
- Add rimraf for cross-platform clean script in notifier-discord
- Rename "Recommended Settings" to "Required: Disable Conflicting Built-in Skills"
with explicit warning in docs
- Add /ao setup post-setup reminder to manually disable coding-agent skill
- Fix misleading README non-interactive example wording
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(core): add scm webhook contract
Defines a provider-agnostic SCM webhook contract in core types and
config so SCM plugins can verify and normalize inbound webhook events
without reshaping project config later.
* feat(scm): trigger lifecycle checks from github webhooks
Adds GitHub webhook verification and event parsing, exposes a web
webhook endpoint, and routes matching PR/branch events through the
existing lifecycle manager so CI and review reactions update immediately.
* fix(scm): verify github signatures with raw webhook bytes
Preserves the original webhook bytes alongside the decoded payload so
GitHub HMAC verification uses the exact request body while the route
continues to drive lifecycle checks through the existing manager.
* fix(web): wire scm webhook route into main branch services
Restores the main-branch service and route-test wiring while keeping the
new webhook route coverage and scoped lifecycle helper in place.
* fix(webhooks): use singleton lifecycle manager and fail closed on scm API errors
Reuses the existing services lifecycle manager for webhook-triggered checks
so reactions and state transitions don't replay from a fresh instance, and
restores fail-closed behavior for GitHub review comment fetch failures.
* fix(webhooks): tighten project matching and restore scm compatibility methods
Prevents repository-less webhook events from matching all projects, restores
GitHub SCM PR utility methods and CI status rollup fallback, and adds tests
covering the compatibility paths and safer project matching behavior.
* fix(webhooks): pre-check content length and continue on parse errors
Adds an early content-length guard against configured maxBodyBytes before
reading the body and changes candidate parse failures to fail-forward so
one malformed payload path does not abort other valid candidate handling.
* fix(scm-github): parse review-comment timestamps from comment payload
Use comment.updated_at/created_at for pull_request_review_comment webhook
timestamps so normalized events retain temporal data for comment events.
* fix(webhooks): apply early size guard only when all candidates are bounded
Uses the broadest candidate limit for pre-read content-length checks and
skips early rejection when any matching project has no configured limit,
while retaining per-candidate verification limits.
* fix(webhooks): normalize repo matching and skip terminal sessions
Match webhook repository names case-insensitively against configured project
repos and avoid lifecycle checks for terminal sessions when resolving
webhook-affected sessions.
* fix(webhooks): fail forward when lifecycle checks throw
* fix(scm-github): parse push webhook branch and sha
* refactor(scm-github): dedupe cli exec helper wrappers
* fix(scm-github): tighten exec helper type and comment timestamps
* fix(scm-github): prefer head_commit timestamp for push events
* fix(webhooks): tighten repository parsing and helper visibility
* fix(scm-github): ignore non-head refs for push branch
* chore: trigger bugbot rerun
* feat(scm-gitlab): add webhook verification and event parsing
* fix(webhooks): share parser utils and handle check_run branch
* fix(webhooks): ignore gitlab tag refs in ci branch mapping
* fix(scm-gitlab): harden token and tag ref handling
* chore(scm-gitlab): update webhook helpers around bugbot threads
* feat: make terminal server ports configurable to fix multi-dashboard EADDRINUSE
When multiple ao dashboards run simultaneously (e.g., ao on port 3000,
integrator on port 3002), both try to start terminal WebSocket servers
on hardcoded ports 3001/3003, causing EADDRINUSE. Add terminalPort and
directTerminalPort to config schema so each instance can use unique ports.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: use optional() instead of default() for terminal port schema
Zod .default() always fills in the value, making config.terminalPort
never undefined and the env var fallback in buildDashboardEnv dead code.
Switch to .optional() so the priority chain works correctly:
config value > TERMINAL_PORT env var > hardcoded default.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: move terminal server defaults from 3001/3003 to 14800/14801
The 3000-3009 range is the most contested in dev tooling (Next.js
auto-increments, BrowserSync, Grafana, Rails, Express all default to
3000+). Port 14800-14899 has zero IANA registrations, zero known dev
tool conflicts, and is safely below OS ephemeral ranges.
Updated all hardcoded fallbacks, .env.local.example, docker-compose
port mappings, and documentation.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: auto-detect available terminal ports for zero-config multi-dashboard
When no terminal ports are configured (no config, no env vars),
buildDashboardEnv now probes for an available port pair starting at
14800. The second `ao start` automatically gets 14802/14803 (or the
next free pair), eliminating EADDRINUSE without any user configuration.
Port detection scans in steps of 2 to keep the pair consecutive.
Explicit config/env values bypass auto-detection entirely.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: update onboarding test to use new default terminal port (14801)
The onboarding integration test had port 3003 hardcoded for the
WebSocket health check. Updated to read from DIRECT_TERMINAL_PORT
env var with 14801 as the default, matching the new port defaults.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: implement layered prompt system for agent sessions
Replace hardcoded spawn prompts with a 3-layer composition system:
- Layer 1: BASE_AGENT_PROMPT constant with session lifecycle, git workflow, PR handling
- Layer 2: Config-derived context (project, repo, tracker, issue details via generatePrompt())
- Layer 3: User-customizable rules via agentRules (inline) and agentRulesFile (path)
The session-manager path fetches issue context from the tracker plugin and passes
the composed prompt via AgentLaunchConfig.prompt. The CLI spawn path delivers it
via tmux send-keys to keep agents interactive for follow-up messages.
Returns null when nothing to compose (no issue, no rules), preserving backward
compatibility for bare launches.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: use tmuxSendKeys for multi-line prompt delivery in CLI spawn
The buildPrompt() output contains newlines which tmux send-keys -l treats
as Enter keypresses, splitting the prompt into separate submissions. Use
the core tmuxSendKeys() helper which handles multi-line text via
load-buffer/paste-buffer.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: update spawn test to verify tmuxSendKeys usage
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Phase 0 complete. Establishes:
- pnpm workspace with 18 packages (core, cli, web, 15 plugins)
- Complete type definitions in packages/core/src/types.ts defining
all 8 plugin slot interfaces (Runtime, Agent, Workspace, Tracker,
SCM, Notifier, Terminal) + core service interfaces
- YAML config loader with Zod validation and sensible defaults
- Plugin registry with built-in discovery
- CLAUDE.md with conventions for spawned agents
All agents can now branch from main and implement their assigned
packages against the interfaces defined in types.ts.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>