Compare commits

...

129 commits

Author SHA1 Message Date
Luyu Cheng
7de218a909
fix(kimi-web): resume paused goals (#1693)
Some checks are pending
Release / Release (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (3) (push) Waiting to run
CI / test (4) (push) Waiting to run
CI / test (5) (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / build (push) Waiting to run
CI / test (1) (push) Waiting to run
CI / test (2) (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
* fix(kimi-web): resume paused goals

* fix(agent-core-v2): defer paused goal continuation
2026-07-15 00:00:29 +08:00
Luyu Cheng
2bf009fe27
fix(agent-core-v2): reject subagent goals (#1697)
* fix(agent-core-v2): reject subagent goals

* fix(kap-server): guard reflected subagent goals
2026-07-14 23:30:30 +08:00
qer
de493aeec9
fix(web): use upward chevron for dock card expand buttons (#1715)
* fix(web): use upward chevron for plan card expand button

* fix(web): use upward chevron for question card expand button
2026-07-14 23:06:15 +08:00
Luyu Cheng
722694adf9
fix(agent-core-v2): enforce goal deadlines (#1698) 2026-07-14 23:00:05 +08:00
qer
20b69724aa
fix(web): make code block copy work over plain HTTP (#1714) 2026-07-14 22:31:36 +08:00
Luyu Cheng
5c0f17cfcf
fix(agent-core-v2): persist active goal time (#1695)
* fix(agent-core-v2): persist active goal time

* fix(agent-core-v2): migrate active goal anchors

* fix(agent-core-v2): migrate envelope-less goal logs

* fix(agent-core-v2): advance migrated goal checkpoints

* fix(agent-core-v2): align crash recovery with wire domain
2026-07-14 22:29:36 +08:00
Luyu Cheng
e53cd79957
fix(agent-core-v2): align goal turn budgets (#1692)
* fix(agent-core-v2): align goal turn budgets

* fix(agent-core-v2): preserve blocked goal outcomes

* fix(agent-core-v2): stop resumed goals at turn budget
2026-07-14 22:11:28 +08:00
Haozhe
78ca44b4d1
feat(kimi-code): keep print-mode goal runs alive until the goal settles (#1712)
* feat(kimi-code): keep print-mode goal runs alive until the goal settles

- applyPrintBackgroundPolicy waits for goal continuation turns via a goalActive hook before applying the exit/drain/steer mode, bounded by the wait ceiling
- createPrintTurnEndings skips the timeout when the remaining budget is not finite
- update the changeset to cover the goal-run lifecycle

* fix(kimi-code): wake the print goal wait periodically so settled goals exit promptly
2026-07-14 21:09:09 +08:00
Luyu Cheng
b781e8cbcf
fix(agent-core-v2): preserve goal continuations (#1696)
* fix(agent-core-v2): preserve goal continuations

* fix(agent-core-v2): cancel preserved continuation turns
2026-07-14 20:46:18 +08:00
Luyu Cheng
3107f963a5
fix(agent-core-v2): isolate replaced goals (#1700)
* fix(agent-core-v2): isolate replaced goals

* fix(agent-core-v2): tighten stale goal guards

* fix(agent-core-v2): preserve replacement goal budgets

* fix(agent-core-v2): continue replacement goals

* fix(agent-core-v2): adopt resumed goal turns

* fix(agent-core-v2): guard stale goal budgets

* fix(agent-core-v2): keep stale outcomes nonterminal

* fix(agent-core-v2): preserve same-batch goal outcomes

* fix(agent-core-v2): gate stale replacement outcomes

* fix(agent-core-v2): preserve replacement goal mutations

* fix(agent-core-v2): revalidate delayed goal creation
2026-07-14 20:32:05 +08:00
_Kerman
26d499bca7
refactor(agent-core-v2): consolidate wire services (#1680) 2026-07-14 19:51:29 +08:00
qer
9eff230f97
fix(web): show errors for failed actions and add daemon request/operation logging (#1711)
Some checks are pending
CI / test (5) (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / build (push) Waiting to run
CI / test (1) (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
CI / typecheck (push) Waiting to run
CI / lint (push) Waiting to run
CI / test (2) (push) Waiting to run
CI / test (3) (push) Waiting to run
CI / test (4) (push) Waiting to run
* fix(web): show errors for failed actions and add daemon request/operation logging

* fix(web): dedupe identical operation-failure toasts to avoid flooding

* Revert "fix(web): dedupe identical operation-failure toasts to avoid flooding"

This reverts commit 54fcfdcc97a034d7236d61cbb3664292a8966c64.
2026-07-14 19:17:28 +08:00
qer
ddfdfb0b09
fix(server): synthesize session status only from the main agent's turn (#1708)
* fix(server): synthesize session status only from the main agent's turn

A sub-agent runs its own turn on the shared session channel, and the
broadcaster synthesized event.session.status_changed from every agent's
turn.started/turn.ended. A foreground sub-agent finishing mid-turn thus
emitted a bogus idle transition that clients read as 'turn finished'
(notifications, sounds, unread dots, queued-message drain), while the
real main-agent turn end was swallowed by dedup.

Gate the status synthesis on MAIN_AGENT_ID, matching the existing
main-only rule in InFlightTurnTracker.

* fix(server): emit idle when a sub-agent turn ends as the last active agent

Defensive follow-up to the main-only status synthesis: ensureState seeds
lastStatus from ISessionActivity, which counts any agent's turn, so a
session first activated while only a sub-agent was active would stay
running for subscribers until the next main turn. A sub-agent's own
lease and loop state are already released when its turn.ended is
published, so a session-wide idle activity read cannot be a
mid-main-turn foreground sub-agent — emit idle in that case only.
Dedup keeps it a no-op everywhere else.

* revert: drop the defensive idle emit for sub-agent-only turn endings

This reverts 7918b45fe. The stale-seed scenario it guarded is
effectively unreachable in production: sessions are activated at
creation / first prompt and stay active for the process lifetime, and
persisted detached tasks are reconciled as lost on restart, never
resumed. The guard also raised a new behavioral question of its own
(failed/cancelled sub-agent endings would emit a success-style idle).
Keep the PR to the minimal main-only status synthesis; the theoretical
corner self-corrects on the next REST status pull or main turn.
2026-07-14 18:35:49 +08:00
Haozhe
38a2363a00
fix: cross-version session compatibility and real message times in snapshot history (#1704)
* fix(server): report CLI version as server_version

- kap-server: accept opts.version in startServer, reported as
  server_version (/meta, OpenAPI, session exports, lock registry,
  default User-Agent); defaults to its own package version
- kimi-code: pass the CLI product version when starting the server
- add boot test covering /api/v1/meta, lock file, and User-Agent

* fix(agent-core-v2): make new sessions resumable by older v1 builds

- add wireRecord.seal() to write the metadata envelope at agent creation,
  so fresh logs satisfy v1 replay's first-record-must-be-metadata invariant
- seal the wire log before any op dispatch in AgentLifecycleService.create;
  no-op when the log already has records (resume / forked copies)
- seed empty agents/custom maps in session metadata so v1 Session.resume()
  can index agents['main'] on a v2-created state.json
- add seal unit tests and lifecycle sealing tests

* fix(kap-server): show real message send times in snapshot history

- read per-record times stamped on wire.jsonl via reduceContextTranscript
  and cache them alongside the transcript messages
- prefer each record's real dispatch time for created_at; records without a
  stamp fall back to session.createdAt + index, clamped so the page stays
  strictly increasing (mirrors MessageLegacyService.list)
- add tests for fallback, clamping, and the page-offset index mapping

* fix(agent-core-v2): backfill missing agents/custom maps in existing session metadata

- load() now heals pre-fix v2 state.json documents that never gained the
  agents/custom maps, persisting the backfill so one open on a new build
  leaves the session resumable by released v1 builds (Session.resume()
  indexes agents['main'] unconditionally)
- updatedAt is deliberately untouched so the format heal does not reorder
  session listings

* feat(agent-core-v2): make subagent timeout configurable, default 2h

- add [subagent] config section with timeout_ms and KIMI_SUBAGENT_TIMEOUT_MS env override
- resolve Agent and AgentSwarm per-run timeouts through resolveSubagentTimeoutMs
- update tool descriptions and tests to reflect the 2-hour default

* feat(agent-core-v2): align print-mode background policy with v1

- add printBackgroundMode/printMaxTurns to the task config section with resolvePrintBackgroundMode (keepAliveOnExit fallback)
- apply exit/drain/steer policy to kimi -p on the experimental engine, buffering turn.ended events and failing the run when a steered turn fails
- register the subagent config section from the package entry

* fix(agent-core-v2): strict equality in metadata heal, comments in file headers only

- replace == null with === undefined in the session-metadata heal to
  satisfy eqeqeq (oxlint --type-aware in CI)
- move the seal() rationale into the wireRecord contract header and the
  agents/custom invariant into the sessionMetadata header per the
  tree's header-only comment convention
2026-07-14 18:35:02 +08:00
7Sageer
ac216163b9
fix: emit turn_id on turn_started/ended/interrupted telemetry (#1668)
* fix: emit turn_id on turn_started/ended/interrupted telemetry

The turn lifecycle telemetry events (turn_started, turn_ended,
turn_interrupted) never carried the turn id, while tool_call and
tool_call_dedup_detected did. Any analysis correlating a turn's start,
end, or interruption back to its tool calls had nothing to join on.

Add turn_id (already in scope) to all three track() calls, matching the
existing key/value convention used by tool_call_dedup_detected.

Update the strict turn_started/turn_interrupted assertion to cover it.

* fix(agent-core-v2): emit turn_id on turn_started/ended/interrupted telemetry

Port the v1 fix to agent-core-v2: turn lifecycle telemetry events
(turn_started, turn_ended, turn_interrupted) carried no turn id while
tool_call did, leaving nothing to correlate a turn's start, end, or
interruption back to its tool calls.

Add turn_id to the three event interfaces, the telemetry registry
property docs, and the three track2() calls in AgentLoopService,
matching the existing ToolCallEvent key convention. Extend the
turn telemetry assertions in loop.test.ts to cover it.

* fix: emit turn_id on tool_call telemetry

* docs(agent-core-v2): clarify turn_id is a per-agent index in the telemetry registry
2026-07-14 18:31:43 +08:00
Haozhe
8490c3e36b
feat(agent-core-v2): record compaction droppedCount in wire traces and rename select_tools capability (#1707)
* feat(agent-core-v2): record droppedCount on full-compaction llm.request wire ops

- add per-attempt droppedCount to the llm.request source logFields so
  replays can see how much history each retry round blinded
- cover compaction/loop request events in the full-compaction test

* refactor(agent-core-v2): rename select_tools capability to dynamically_loaded_tools

- rename the ModelCapability bit and catalog field across capability.ts,
  catalog.ts, modelResolverService and the toolSelect gate
- update toolSelect flag description wording to match
- update all affected tests and the test harness capabilityNames helper

* chore: add changesets for v2 wire trace and capability rename
2026-07-14 18:01:11 +08:00
Haozhe
9e6d53b025
fix(workspace): re-read the catalog on every v2 registry operation (#1706)
- drop the in-memory write cache in the v2 registry: every list/get/
  mutation is a fresh read-modify-write of workspaces.json under the op
  mutex, so v1 writers (touchWorkspaceRegistry) are never clobbered by a
  stale snapshot; the session-index merge stays once per process behind
  a flag (Codex P1)
- move the shared workspaces.json helper from services/workspace (the
  upper facade) to session/store, so the rpc runtime no longer imports
  back into services/ (Codex P2)
2026-07-14 17:52:03 +08:00
liruifengv
6b0a116326
docs(changelog): sync 0.24.1 from apps/kimi-code/CHANGELOG.md (#1703) 2026-07-14 17:47:24 +08:00
Haozhe
07c3632415
fix(workspace): sync workspace catalog across v1/v2 and soft-delete (#1701)
- register the cwd in workspaces.json when a v1 (TUI/SDK) session is
  created, via a shared workspaceRegistryFile read/write/touch module
- merge session_index.jsonl into the catalog once at v2 startup
  (awaited during kap-server boot), adding only paths absent from
  workspaces.json
- make v2 workspace delete a soft delete through the v1-compatible
  deleted_workspace_ids field: the session-index merge never resurrects
  tombstoned ids, while an explicit createOrTouch clears the tombstone
2026-07-14 17:34:35 +08:00
github-actions[bot]
1d2249bfa4
ci: release packages (#1682)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-14 17:03:41 +08:00
qer
ab22a2adf0
fix(web): show just the thinking level name in the model pill (#1689)
Drop the "thinking:" / "思考:" prefix from the effort suffix and
capitalize the level via effortLabel, matching the segment labels.
2026-07-14 17:00:09 +08:00
qer
ed420c99fd
test(agent-core-v2): add lifecycle create stub to sessionLegacy status test (#1691)
SessionLegacyService.status() now resolves the main agent through
ensureMainAgent() -> IAgentLifecycleService.create() (since d158e0a7a),
but the scenario's hand-rolled lifecycle stub only implemented
whenReady, so the test errored with 'create is not a function' before
any assertion ran. Return the existing main agent from create, matching
the real service's create-or-get contract for explicit ids.
2026-07-14 16:56:11 +08:00
Haozhe
94c0ef89d2
fix(agent-core): re-register builtin tools when provider resolves late (#1688)
When the model provider becomes resolvable only after the agent starts
(async OAuth / managed free-tokens registration), the builtin tool table
stayed empty: initializeBuiltinTools was gated on hasProvider at fixed
checkpoints and none fired. loopTools now self-heals on read once a
provider resolves, so tool calls no longer fail with "Tool not found".
2026-07-14 16:37:17 +08:00
Kai
d158e0a7ac
fix: resolve and synchronize thinking effort (#1625)
* fix: preserve provider thinking effort values

* fix: resolve Kimi thinking effort fallbacks

* fix: use resolved Kimi effort in v2 requests

* fix: align Kimi effort resolution paths

* fix: synchronize forced Kimi effort state

* fix: synchronize forced Kimi effort in v2 state

* fix: tolerate unresolved models in v2 status
2026-07-14 16:37:03 +08:00
liruifengv
268fd41734
docs(changelog): sync 0.24.0 from apps/kimi-code/CHANGELOG.md (#1683)
* docs(changelog): sync 0.24.0 from apps/kimi-code/CHANGELOG.md

* chore: update changelog

---------

Co-authored-by: qer <wbxl2000@outlook.com>
2026-07-14 16:23:07 +08:00
Kai
e417ee7c2c
fix(kosong): restore Kimi preserved-thinking sessions (#1684)
* fix: preserve empty reasoning across providers

* fix(kosong): restore Kimi preserved-thinking sessions
2026-07-14 16:17:48 +08:00
Luyu Cheng
ec1c9748c8
fix(agent-core-v2): preserve goal completion summaries and error messages (#1678)
* fix(agent-core-v2): preserve goal completion summaries

* fix(agent-core-v2): preserve loop error messages

* fix(agent-core-v2): keep goal cancellation responsive
2026-07-14 15:13:47 +08:00
qer
0f64b4dcc4
fix(web): submit thinking level verbatim and drop the hardcoded default (#1673)
* fix(web): submit thinking level verbatim and drop the hardcoded default

Align kimi-web's thinking-level handling with the TUI:

- Submit the stored level as-is on every prompt path (prompt, steer,
  skill activation, BTW side chat) instead of coercing it onto the
  target model's declared efforts.
- No stored preference (undefined) instead of a hardcoded 'high'
  default: prompts omit the thinking override and the daemon resolves
  the config/model default, same as an unset [thinking] in the TUI.
- Model switcher pre-selects the target model's own default level when
  switching models; re-selecting the current model keeps the level.
- Display the effective level (stored value, else the model default)
  in the composer, mobile sheet, and /status panel.

* chore(web): remove the dead dev:stub script

The stub daemon (dev/stub-daemon.mjs) no longer exists, so the
dev:stub npm script and its docs references were dead weight.

* fix(web): pin the model default thinking level and persist picks globally

- With no stored preference, loadModels() pins the active model's
  catalog default_effort as a concrete in-memory value, so what the
  UI shows, what prompts submit, and what the session runs always
  agree. localStorage stays reserved for levels the user picked.
- setThinking and model switches now also write the daemon-wide
  [thinking] config (same mapping as the TUI's thinkingEffortToConfig),
  so sessions created by other clients inherit the pick.
2026-07-14 15:04:28 +08:00
github-actions[bot]
2383137f3e
ci: release packages (#1583)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-14 15:01:30 +08:00
Haozhe
003e583d86
fix(agent-core-v2): project support_efforts and default_effort in the model catalog (#1677)
Align toProtocolModel with the v1 projection and the protocol wire schema by applying effectiveModelConfig and emitting support_efforts / default_effort, so clients of the v2 /models endpoint see thinking-effort levels again.
2026-07-14 14:53:50 +08:00
Haozhe
206c89574f
test(kap-server): use agent lifecycle get in blocked goal rig (#1679)
Replace the nonexistent getHandle call introduced by the blocked-goal continuation tests with the public get API and the shared MAIN_AGENT_ID constant, restoring typecheck and both continuation tests.
2026-07-14 14:47:51 +08:00
Kai
d1820ff0f8
fix: preserve empty reasoning across providers (#1676) 2026-07-14 14:30:56 +08:00
Chen, Yicun
88629bac3a
fix(web): allow image sources in CSP (#1672)
Co-authored-by: chenyicun <chenyicun@msh.team>
2026-07-14 14:15:22 +08:00
_Kerman
3215129860
refactor(agent-core-v2): split agent lifecycle into existence, subagent, and session MCP domains (#1624) 2026-07-14 14:13:41 +08:00
Luyu Cheng
28e9dd4d62
fix: continue agent after resuming blocked goals (#1627)
* fix(agent-core-v2): serialize agent startup

* style(agent-core-v2): align startup comments

* fix(agent-core-v2): resume blocked goals

* fix(agent-core-v2): continue blocked Web goals

* fix(agent-core-v2): keep exhausted goals blocked on resume

---------

Co-authored-by: qer <wbxl2000@outlook.com>
2026-07-14 13:59:12 +08:00
Luyu Cheng
490303db16
fix(web): refine goal mode controls (#1669)
* fix(web): refine goal mode controls

* fix(web): hide goal progress without budget

* fix(web): use design system for goal cancellation

* docs: document web goal controls

* fix(web): remove collapsed goal actions from tab order
2026-07-14 13:34:37 +08:00
liruifengv
d780b2334d
feat(agent-core-v2): make /init runs cancellable (#1667)
The init run spawns a coder subagent through agentLifecycle.run with an
AbortController that was a local of generateAgentsMd, so nothing outside
the service could cancel it: Ctrl+C in clients only cancels the main
agent's loop turn, which does not exist during /init.

ISessionInitService gains cancelInit(): the service keeps the in-flight
run's AbortController (cleared in finally), and cancelInit aborts it with
a user-cancellation reason. Cancellations propagate unwrapped from
generateAgentsMd (UserCancellationError / AbortError, never
SESSION_INIT_FAILED, no subagent.failed event) so callers can distinguish
"aborted" from "failed".
2026-07-14 13:31:45 +08:00
qer
5eb62178b3
feat(web): add session diagnostic export (#1646)
* feat(web): add session diagnostic export

* fix(web): bound session export resources

* fix(web): make session export atomic

* feat(web): add session export entry to session menus with item icons
2026-07-14 12:48:28 +08:00
Kai
32a89c3643
fix(agent-core): prevent oversized media from poisoning sessions (#1657) 2026-07-14 12:46:45 +08:00
_Kerman
f02f7241af
refactor(agent-core-v2): default all DI services to eager instantiation (#1666) 2026-07-14 12:37:57 +08:00
7Sageer
1294a0e1ad
fix(agent-core-v2): keep invalidated OAuth flows observable during login (#1663)
* fix(agent-core-v2): keep invalidated OAuth flows observable during login

invalidateFlows() deleted flows outright when the provider configuration
changed, including terminal ones. That voided the TERMINAL_RETENTION_MS
window and, worse, left the status guards in handleSuccess /
finalizeAuthentication blind when the invalidation landed in the
microtask gap after toolkit.login resolved: setTerminal then wrote
'authenticated' onto an orphaned state the client could never observe,
so the login hung after a successful browser authorization.

Transition affected pending flows to a visible 'cancelled' terminal
status instead, mirroring abortExisting().

Also normalize the KIMI_CODE_BASE_URL env override at the source in
kimiCodeBaseUrl(): provision persists it verbatim while the model
refresh rewrites it normalized, and the deep-equal diff between the two
shapes fired a spurious providers-changed event mid-login.

* style(agent-core-v2): drop non-header comments from the oauth login fix

* chore: cover agent-core-v2 in the oauth login fix changeset
2026-07-14 12:16:28 +08:00
_Kerman
e7374d015c
test(agent-core-v2): deflake full compaction retry-count tests (#1665) 2026-07-14 12:01:19 +08:00
liruifengv
4be5b0c288
feat(server): allow X-Kimi-Client-* headers and reflect request headers in CORS (#1661)
* feat(server): allow X-Kimi-Client-* headers in CORS for cross-origin desktop renderer

* feat(server): reflect request headers in CORS preflight

For allowed origins, echo the preflight's Access-Control-Request-Headers back in Access-Control-Allow-Headers (falling back to CORS_ALLOW_HEADERS for non-preflight responses). Newly added client request headers (e.g. X-Request-Id) no longer require a matching server-side allowlist change, unblocking desktop app://renderer cross-origin requests that were failing preflight on headers absent from the fixed list.
2026-07-14 11:51:43 +08:00
liruifengv
bd5e6bddb4
chore: remove stale changeset for removed server package (#1662)
Some checks are pending
CI / typecheck (push) Waiting to run
CI / build (push) Waiting to run
CI / test (1) (push) Waiting to run
CI / test (2) (push) Waiting to run
CI / test (3) (push) Waiting to run
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
CI / test (4) (push) Waiting to run
CI / test (5) (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
2026-07-14 11:36:01 +08:00
Kai
7c889f3a96
fix(agent-core): mark auto-approved plan exits as not user-reviewed (#1638)
* fix(agent-core): mark auto-approved plan exits as not user-reviewed

In auto permission mode ExitPlanMode is approved without user
involvement, but its result read "## Approved Plan:" exactly like a
genuine user approval and the transcript showed a green "Approved"
chip. The model treated that as a signal to start executing, even
when the user had asked it to stop after planning.

- Emit an "auto-approved, not user-reviewed" result with a note that
  execution follows the user's original instructions (v1 + v2).
- Extend the auto-mode reminder: plan approvals are automatic and are
  not a user signal to proceed (v1 + v2).
- Render an "Auto-approved" warning-toned chip in the TUI, keeping
  backward compatibility with the old result marker.
- Document the Auto mode plan-exit behavior in interaction guides.

* fix(agent-core): only mark plan exits as auto-approved in auto permission mode

Address review feedback on the auto-approved plan exit change:

- Branch the direct-execution output on the permission mode in both
  engines: only auto mode yields the "auto-approved, not
  user-reviewed" result and telemetry outcome. In manual / yolo modes
  the direct path means a configured or session allow/ask rule let the
  call through — an explicit user decision that keeps the user-approved
  output, the Approved transcript chip, and the approved outcome.
- Move the v2 rationale out of the method body into the file header,
  per the agent-core-v2 comment convention.
- Drop the absolute "only Auto mode" wording from the interaction
  guides (en/zh).
2026-07-14 11:30:47 +08:00
Haozhe
0e1c51cb0e
style(agent-core-v2): remove non-header comments (#1656)
Enforce the package comment convention that comments live only in the
top-of-file block: strip mid-file doc, prose, and trailing comments
while preserving header blocks and eslint/oxlint/@ts-* suppression
comments. No code tokens are changed.
2026-07-14 10:50:22 +08:00
Haozhe
e49b3b8777
fix(agent-core-v2): harden wire restore and background task lifecycle (#1635)
* fix(agent-core-v2): fix wire migration rewrite race and reject unversioned logs (8 files)

- await the migration rewrite in wireRecordService.restore() so restore only resolves once the migrated log is durable and rewrite failures surface
- serialize AppendLogStore.rewrite() behind the log flush so appends arriving during the whole-file replace land after the new content
- reject wire logs missing the metadata envelope (missingWireMetadataError) in restore, session fork, and agent create instead of fabricating a current-version envelope over legacy records

* fix(agent-core-v2): stop background tasks on session close (10 files)

- add IAgentTaskService.stopAllOnExit: suppress terminal notifications, then SIGTERM → grace → SIGKILL for every active task (v1 stopBackgroundTasksOnExit parity, gated by keepAliveOnExit)
- call stopAllOnExit from agentLifecycle.remove before scope disposal, and abort live tasks in AgentTaskService.dispose as a last resort for disposal paths that bypass the graceful close
- honor [task] killGracePeriodMs in the stop grace window and bind the v1 KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT env override for keepAliveOnExit

* fix(agent-core-v2): root task persistence at the owning agent's scope

- persist task records under <sessionScope>/agents/<agentId>/tasks/ (v1's
  per-agent layout) so tasks written by older versions are found on resume
- stop one agent's restore from loading, marking lost, or re-notifying
  another agent's tasks
- add a per-agent isolation test covering loadFromDisk + reconcile

* fix(agent-core-v2): address task lifecycle review findings

* fix(agent-core-v2): preserve queued appends across rewrites

* test(agent-core-v2): cover rewrite cutover flush

* fix(agent-core-v2): surface append durability failures

* fix(agent-core-v2): retire released append log buffers

* fix(agent-core-v2): preserve session-level task records

* fix(agent-core-v2): harden append log cutover recovery

* fix(agent-core-v2): suppress only background exit notifications

---------

Co-authored-by: qer <wbxl2000@outlook.com>
2026-07-14 10:02:24 +08:00
Haozhe
8027fe291b
fix(agent-core-v2): caller MCP servers and skill dirs parity (#1636)
* feat(agent-core-v2): support caller-supplied MCP servers on session create

- add mcpServers to CreateSessionOptions, forwarded to the session's first
  ensureMcpReady call so caller-supplied servers ride on the initial load
- ensureMcpReady accepts callerServers, honored only by the call that starts
  the initial load; later calls just await the in-flight single-flight load
- merge precedence mirrors v1 (rpc/core-impl.ts): file config <
  caller-supplied < plugin servers

* feat(agent-core-v2): file tools reach skill roots; add skillDirs seed

- add extendWorkspaceWithSkillRoots to path-access; Read/Write/Edit/Grep/
  Glob and media tools merge skill-catalog roots into the workspace per
  call (v1 merged once at construction), so skill dirs outside the cwd
  stay reachable and late-loading roots are picked up
- add skillCatalogRuntimeOptionsSeed; wire --skillsDir into the v2 print
  CLI and skillDirs into kap-server startServer (v1 SDK skillDirs parity)

* chore: add changesets for v2 caller MCP servers and skill dirs fixes
2026-07-14 10:02:15 +08:00
Haozhe
0e0a6e9a51
feat(agent-core-v2): support caller-supplied MCP servers on session create (#1637)
* feat(agent-core-v2): support caller-supplied MCP servers on session create

- add mcpServers to CreateSessionOptions, forwarded to the session's first
  ensureMcpReady call so caller-supplied servers ride on the initial load
- ensureMcpReady accepts callerServers, honored only by the call that starts
  the initial load; later calls just await the in-flight single-flight load
- merge precedence mirrors v1 (rpc/core-impl.ts): file config <
  caller-supplied < plugin servers

* chore: add changeset for v2 caller MCP servers
2026-07-14 10:02:07 +08:00
Haozhe
5d1f9049ca
fix(agent-core-v2): repair messages API history after resume (#1633)
Some checks are pending
CI / typecheck (push) Waiting to run
CI / build (push) Waiting to run
CI / test (1) (push) Waiting to run
CI / test (2) (push) Waiting to run
CI / test (3) (push) Waiting to run
CI / test (4) (push) Waiting to run
CI / test (5) (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
- rehydrate restored `blobref:` media URLs to inline `data:` URIs from the
  agent's blob store, matching the shape of live emissions
- pass tool results carrying media (e.g. ReadMediaFile) through as raw
  content parts instead of flattening them to empty text
- use wire record times for `created_at`, nudged to stay strictly
  increasing, instead of a synthesized session-start offset
- carry per-entry record times through `ContextTranscript` and the
  live-tail merge
2026-07-14 00:05:49 +08:00
Haozhe
2d874fbd73
fix(agent-core-v2): surface provider auth errors and align agent copy with v1 (#1631)
* fix(agent-core-v2): surface provider rejection on post-refresh 401

- map a 401 that survives forced token refresh to PROVIDER_AUTH_ERROR
  carrying the provider's sanitized message, instead of a misleading
  AUTH_LOGIN_REQUIRED re-login prompt
- propagate provider auth errors through full compaction instead of
  wrapping them as compaction failure
- export sanitizeStatusErrorMessage for reuse

* fix(agent-core-v2): align repeat-reminder and AskUserQuestion copy with v1

- rewrite repeated tool call reminders to redirect instead of prohibit (port of v1 #1518)
- simplify makeReminderText2 to (repeatCount), no longer echoing tool name and args
- treat dismissed AskUserQuestion as no answer, not the recommended pick (port of v1 #1550)
- update toolDedupe test assertions and regenerate affected wire snapshots

* chore: add changeset for v2 reminder and question copy alignment

* fix(kap-server): drain in-flight heartbeat writes before unlinking the instance file

- count writes that passed the released check and await them in release()
  so their atomic rename cannot recreate the file after unlink
- harden the heartbeat test: 1ms cadence plus a post-release re-check
  catches the recreate-after-unlink race (verified failing on old code)

* docs(agent-core-v2): move toProviderAuthError rationale to the file header

Address Codex review: agent-core-v2 keeps comments in the top-of-file
header only, never beside functions
2026-07-13 23:52:22 +08:00
Haozhe
96b83281b2
fix(agent-core-v2): make managed OAuth login env-aware (#1634)
Resolve the login environment via resolveKimiCodeLoginAuth so
KIMI_CODE_BASE_URL / KIMI_CODE_OAUTH_HOST steer the credential slot
login writes to the same way they steer runtime token reads — fixes
"login succeeds but every call 401s" against non-default environments.
The provisioned provider entry now records the login environment and
credential slot explicitly, and logout deletes from the runtime
(env-aware) slot.
2026-07-13 23:49:40 +08:00
Haozhe
a4aae87cd9
fix(agent-core-v2): fall back to protocol default base URL when unconfigured (#1632)
- drop the "missing a base URL" rejection in ModelResolverService so a
  structured provider without base_url resolves to undefined and the wire
  provider applies its protocol default endpoint, matching v1
- relax baseUrl to optional across Model, ModelImplInit, and
  ProtocolAdapterConfig; guard the anthropic /v1 strip against undefined
2026-07-13 23:33:44 +08:00
Haozhe
0303b82c3e
fix: align v1 protocol handshake and tool_result media passthrough (#1630)
* fix(protocol): make server_hello heartbeat_ms optional

kap-server dropped the server-initiated WS heartbeat and no longer emits
heartbeat_ms in server_hello, but the published v1 schema still required
it, so spec-compliant clients rejected the handshake before subscribing.

- mark heartbeat_ms optional in serverHelloPayloadSchema (advisory only)
- add a ws-control test for a server_hello without heartbeat_ms
- align kimi-web WireServerHello and the server-e2e handshake assertion

* fix(agent-core-v2): pass media parts through tool_result projection

- keep raw kosong content-part array for tool results carrying
  image/video/audio parts instead of flattening to text
- restore ReadMediaFile media rendering after session reload/resume

* chore: add changeset for optional server_hello heartbeat_ms

* docs(agent-core-v2): move tool_result media rationale to module header

Per the agent-core-v2 comment conventions, comments live solely in the
top-of-file block — move the media-passthrough rationale out of the
buildProtocolContent JSDoc into the module header.
2026-07-13 23:13:48 +08:00
Haozhe
0527ca2267
fix(agent-core-v2): carry session state across fork and ignite toolDedupe (#1629)
* fix(agent-core-v2): ignite toolDedupe plugin on agent creation

- ignite IAgentToolDedupeService in AgentLifecycleService before external
  hooks so it sits ahead of permission on onBeforeExecuteTool
- mirror the ignition in the test harness AgentTestContext
- add lifecycle test asserting the dedupe hooks are registered

* fix(agent-core-v2): carry session files and cron tasks over on fork

- copy blobs, plans, background-task output, and media originals into the
  forked session dir (excluding state.json, wire logs, and logs/)
- duplicate the source session's cron tasks with fresh ids retagged to the
  target session via the shared CRON_SESSION_TAG
- roll back the half-fork on failure: drop the materialized handle from the
  live registry and delete the target session dir
2026-07-13 22:53:35 +08:00
Haozhe
1c85f94472
feat(agent-core-v2): gate image formats and add media recovery resends (#1626)
* feat(kap-server): drop WS heartbeat; never terminate idle connections

- remove ping interval / pong timeout / socket.terminate() from both the
  v1 (WsConnectionV1) and v2 (WsConnection) WebSocket connections
- v2 protocol: drop pong from the client message schema, remove PingMessage
  and ReadyMessage.heartbeatMs; the ready frame is now bare { type: 'ready' }
- v1 protocol: remove buildPing/PingFrame and ServerHelloPayload.heartbeat_ms;
  server_hello no longer advertises a heartbeat
- drop pingIntervalMs/pongTimeoutMs options from registerWs/registerWsV1

* feat(agent-core-v2): gate image formats and add media-stripped resend

- add image-format-policy as the single source of truth for the
  provider-accepted image MIME set (PNG/JPEG/GIF/WebP), with MIME
  normalization, data-URL parsing, byte sniffing, and refusal notices
- enforce the format gate at every ingestion point: ReadMediaFile refuses
  unsupported formats with per-OS conversion guidance, MCP tool results and
  prompt step requests drop them for a text notice, and kap-server prompt
  routes gate inline, uploaded, and remote-URL images on the sniffed bytes
- resend once with every media part replaced by a text marker when the
  provider rejects an image's format, and keep later steps of the same turn
  on the media-stripped projection (v1 parity)
- commit the WebP decoder wasm as a base64 module for the bundled CLI, with
  a regenerate script

* feat(agent-core-v2): add media-degraded 413 resend and WebP re-encoding

- resend once with the media-degraded projection after an HTTP 413
  body-size rejection: all but the two most recent media parts are replaced
  by text markers, and later steps of the turn stay degraded (stripped
  still wins over degraded)
- generalize stripAllMediaParts into degradeOlderMediaParts; the
  media-stripped resend is now the keepRecent=0 case
- re-encode non-animated WebP through the wasm decoder and the PNG/JPEG
  ladder instead of passing oversized WebP through (animated WebP still
  passes through whole)
- stop lossless PNG rescaling at a 1000px floor and switch to the JPEG
  ladder below it, so small byte budgets stay readable
- add the @jsquash/webp dependency

* feat(agent-core-v2): add flag-gated fault injection for recovery testing

- add the faultInjection domain: a one-shot arm/take latch that raises a
  deterministic provider failure (HTTP 413 body-size or image-format 400)
  before the provider is contacted, so the media-degraded / media-stripped
  recovery resends can be exercised end-to-end against a real provider
- gate arming behind the new fault-injection experimental flag
  (KIMI_CODE_EXPERIMENTAL_FAULT_INJECTION), off by default
- expose IAgentPromptService and IFaultInjectionService over the kap-server
  /api/v2 RPC channel
- add a klient example that drives the REST ingestion gate and both
  recovery resends against a live server
2026-07-13 22:11:46 +08:00
qer
e91a616f21
fix(web): dedupe optimistic user message against snapshot resync (#1620)
* fix(web): dedupe optimistic user message against snapshot resync

* fix(web): match snapshot messages by identity
2026-07-13 21:54:08 +08:00
Luyu Cheng
3c0e368cbd
fix(agent-core-v2): serialize agent startup (#1614)
* fix(agent-core-v2): serialize agent startup
* style(agent-core-v2): align startup comments
2026-07-13 21:44:49 +08:00
Haozhe
4ec2e7fab1
feat(server): default to kap-server and remove the v1 server package (#1617)
* feat(server): default to kap-server and remove the v1 server package

- kimi server run / kimi web now boot kap-server (agent-core-v2 engine)
  unconditionally; the KIMI_CODE_EXPERIMENTAL_FLAG gate on the server
  path is gone (the kimi -p print-mode gate stays)
- move the OS service manager (svc: launchd/systemd/schtasks) from
  packages/server into packages/kap-server and export it there
- repoint the CLI server subcommands, tests, and dev scripts at
  kap-server; relabel the web dev backend presets default/multi
- delete packages/server and update workspace bookkeeping (flake.nix,
  pnpm-lock.yaml, changeset ignore docs, AGENTS.md, agent-core-dev skill)

* test(server-e2e): remove scenarios that depend on v1 debug endpoints

Scenarios 04-stateless-controls, 10-prompt-queue-steer and
12-send-and-cancel assert through the /api/v1/debug/prompts/*
introspection routes, which only the deleted v1 server mounted —
kap-server's --debug-endpoints is a documented no-op, so these
scenarios can only 404 now. The vitest e2e files using the same
surface already skip when it is absent.
2026-07-13 21:43:45 +08:00
Luyu Cheng
32cbd0cf61
fix(web): let workspace picker fit its content (#1611)
* fix(web): size workspace picker from full content

* chore: add web workspace picker changeset

* refactor(web): use intrinsic workspace picker sizing

* fix(web): cap workspace picker to conversation pane
2026-07-13 21:39:16 +08:00
Luyu Cheng
09c05346a8
test(server): stabilize approval teardown (#1621) 2026-07-13 21:35:17 +08:00
7Sageer
b2daa405f0
fix(agent-core-v2): support services.moonshot_search config for WebSearch (#1613)
* fix(agent-core-v2): support services.moonshot_search config for WebSearch

The v2 engine only resolved the WebSearch backend from the managed Kimi OAuth provider, so an environment that configures web search purely via [services.moonshot_search] (api key, no login) got no provider and the WebSearch tool stayed hidden. Register the services config section and resolve the provider config-first, mirroring v1 precedence.

* fix(agent-core-v2): preserve custom services config
2026-07-13 21:18:40 +08:00
liruifengv
83e175399f
feat: auto-background timed-out foreground bash commands (#1591)
* feat: auto-background timed-out foreground bash commands

* fix: discourage blocking TaskOutput waits on background tasks

* test: avoid unsafe string conversion in bash timeout test

* fix: align bash timeout description with auto-background opt-out

* feat(agent-core-v2): auto-background timed-out bash commands

- detach timed-out foreground Bash tasks and re-arm the background deadline
- align TaskOutput guidance with non-blocking background task handling
- add klient SEA end-to-end coverage for the v2 server

* fix(klient): clean up lint errors in auto-background e2e example

---------

Co-authored-by: haozhe.yang <yanghaozhe@moonshot.ai>
2026-07-13 21:08:21 +08:00
_Kerman
ae1bf43c7b
ci: split test job into 5 parallel vitest shards (#1618) 2026-07-13 21:04:56 +08:00
liruifengv
098623ed9f
chore(web): drop the /help, /model, /provider, and /permission slash commands (#1615)
* chore(web): drop the /help, /model, /provider, and /permission slash commands

* chore: drop the changeset
2026-07-13 20:52:19 +08:00
qer
e223549a79
fix(web): make mid-turn delta offsets step-relative (#1609)
* fix(web): make mid-turn delta offsets step-relative

Reset in-flight text and client stream alignment at step boundaries so
resync seeds only the current step instead of duplicating prior steps.

* fix(web): dedupe resync-seeded messages by normalized content

The exact-JSON content signature missed duplicates when the two copies
differed by thinking signature, tool progress, part boundaries, or the
tool set (finished parallel tools leave running_tools). Reduce content
to concatenated stream text plus sorted tool-call ids and treat a
covered subset as the duplicate, merging the seed's tool progress into
the existing cards before dropping it.
2026-07-13 20:47:56 +08:00
qer
f338fcdac4
fix(web): restore swarm member list after page refresh (#1589)
* fix(web): restore swarm member list after page refresh

* chore: add changeset for swarm roster refresh fix
2026-07-13 20:40:52 +08:00
liruifengv
3598793b7d
fix(agent-core-v2): append sessions to shared index so v1 TUI can resume web-created sessions (#1612)
* fix(agent-core-v2): append sessions to legacy index so v1 TUI can resume web-created sessions

* chore: remove changeset

* refactor: rename appendLegacySessionIndexEntry to appendSessionIndexEntry

* fix(agent-core-v2): address review feedback on session index append

- Await appendLogStore.flush() so the shared index is durable before create/fork returns
- Append fork entries only after wire copy and agent replay complete
- Move method-level rationale into the file header per package comment conventions
2026-07-13 20:30:55 +08:00
liruifengv
2da45fc419
fix(web): restore the goal card after a page refresh (#1606)
* fix(web): restore the goal card after a page refresh

* fix(web): assert goal endpoint URL without stringification

* fix(web): skip goal recovery write when a live goal event wins the race

* fix(web): track goal events with a per-session version so clears win the recovery race
2026-07-13 19:49:58 +08:00
_Kerman
22ec37b2c0
fix(agent-core-v2): skip deterministic provider validation retries (#1603)
Some checks are pending
CI / typecheck (push) Waiting to run
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
2026-07-13 18:40:25 +08:00
_Kerman
09c1c32960
fix(agent-core-v2): align compaction injections and goal tools with v1 (#1602) 2026-07-13 18:30:12 +08:00
Haozhe
8a4ee05951
fix(v2): detect MSYS2 bash from native toolchain git exec paths (#1590)
- recognize ucrt64/clang64/clangarm64 prefixes in git --exec-path
  alongside legacy mingw32/mingw64 when locating bash on Windows
- port the three MSYS2 regression tests from kaos to the v2 probe
2026-07-13 18:03:10 +08:00
_Kerman
dc309a7dfb
fix(web): keep context usage live on the v2 engine (#1601) 2026-07-13 18:00:58 +08:00
_Kerman
4feca6b073
fix(agent-core-v2): align rate-limit retries with v1 (#1598) 2026-07-13 17:50:23 +08:00
7Sageer
d601847f22
fix: send the product User-Agent on provider registry and catalog fetches (#1597)
* fix: send the product User-Agent on provider registry and catalog fetches

Registry (api.json) and models.dev catalog fetches only carried the
runtime default User-Agent while every other outbound request sends
kimi-code-cli/<version>. Thread an optional userAgent through
fetchCustomRegistry / fetchCatalog and the shared refresh host, pass
the product UA from the CLI, TUI, and both daemons, and seed a default
product UA in kap-server that hosts can override via opts.seeds.

* refactor: use options for registry fetches
2026-07-13 17:45:34 +08:00
_Kerman
5502b90b42
test: cut slow suite runtimes and isolate experimental flag from host env (#1595) 2026-07-13 17:45:22 +08:00
_Kerman
01dd63207d
refactor(agent-core-v2): consolidate the tool domain into src/tool (#1599) 2026-07-13 17:19:37 +08:00
_Kerman
63300ddf0e
refactor(agent-core-v2): derive wire-record scope from agent scope context (#1596) 2026-07-13 16:46:22 +08:00
_Kerman
bc8eb1e417
chore: drop #/ import array fallbacks and custom resolution plugins (#1594) 2026-07-13 16:37:35 +08:00
liruifengv
20615902c2
fix(tui): deliver pasted media in /skill args and Ctrl-S steer (#1588)
* fix(tui): deliver pasted media in /skill and plugin command args via file tags

Skill and plugin command args are a plain-text RPC channel, so pasted image/video placeholders never reached the model. Rewrite matched placeholders into <image|video path="..."> tags pointing at cache-dir copies (the same convention pasted videos already use), and gate them on model media capabilities like the normal prompt path.

* fix(tui): deliver pasted media when steering with Ctrl-S

Ctrl-S steer dropped media: queued messages were reduced to their text and the editor draft never went through placeholder extraction, so session.steer only ever received placeholder strings. Carry the queued items' extracted parts, extract the editor draft on the spot (with the same capability gate as a normal submit), and merge everything into a part list when any item has media.

* fix(tui): avoid empty text separator when steering consecutive media

The '\n\n' item separator could land as a standalone whitespace-only text part between two media parts, which normalizePromptInput rejects — failing the steer after the queue and editor draft were already cleared. Only append the separator onto a trailing text part so it always merges.

* fix(tui): keep blank-line separator between media and text steer items

The separator was only appended onto trailing text parts, so a media item followed by a plain-text steer item lost the historical '\n\n' separation. Always append it — it merges into the adjacent text part — except between two touching media parts, where a standalone whitespace-only text part would be rejected by normalizePromptInput.

* fix(tui): use escape-proof media references in /skill args

Skill args are XML-escaped on render (renderSkillAttributes + expandSkillParameters), which mangled the <image|video path> tags into escaped text. Add a 'plain' reference style (Attached image file: <path> (open it with ReadMediaFile)) with no tag/attribute boundary characters and use it for the skill channel; plugin command args expand verbatim and keep the tag form.

* fix(tui): strip XML boundary chars from plain-style video cache names

Video cache names carry the original label, which permits <>&"; in the plain (/skill) reference style those chars get XML-escaped in args and the reference no longer matches the file on disk. Strip them from the cache name in plain mode; the tag channel is unchanged (its attribute escaping already handles them).

* fix(tui): surface media materialization failures as TUI errors

The cache copy inside media placeholder rewriting can throw (unwritable cache dir, vanished video source) before any RPC .catch is installed, escaping the fire-and-forget slash-command dispatcher as an unhandled rejection. Catch it in sendSkillActivation, activatePluginCommand, and the Ctrl-S draft extraction, show an error, and leave queue/draft/session state untouched.
2026-07-13 16:16:44 +08:00
_Kerman
2185237c2f
refactor(v2): bind op definitions to models and require zod payload schemas (#1593) 2026-07-13 16:16:02 +08:00
qer
924d5c9141
feat(web): dev backend switcher and engine badge for dual-engine debugging (#1592)
Add the plumbing to debug kimi-web against the v1 (server) and v2
(kap-server) engines side by side:

- root dev:v1 / dev:v2 scripts; v2 boots kap-server with the
  multi_server flag on a fixed port so both engines can run at once
- dev-proxy backend switcher: GET/POST /__kimi-dev/backend repoints
  the /api/v1 proxy at runtime (HTTP + WS) without a Vite restart
- dev-only sidebar backend pill reading /meta's backend field, with a
  one-click switcher menu; Settings shows the backend engine too
- re-fetch /meta on every WS (re)connect so the badge stays truthful
  across backend restarts and switches
- strip the browser Origin header on proxied requests: v1's WS upgrade
  path rejected the Vite-origin vs server-Host mismatch with 403
2026-07-13 15:22:02 +08:00
wszqkzqk
83370f17ef
fix(kaos): detect bash when git comes from a native MSYS2 toolchain (#1580)
git --exec-path of a native MSYS2 git returns a path under the
modern toolchain prefixes (ucrt64, clang64, clangarm64), but the root
inference scan only covered the legacy mingw32/mingw64 prefixes, so
detection fell through to KaosShellNotFoundError on hosts without
Git for Windows even though MSYS2 bash exists at <root>\usr\bin.

Extend the segment scan to all current MSYS2 prefixes so the MSYS2
root is inferred correctly. mingw32/mingw64 stay for Git for Windows
compatibility.
2026-07-13 13:45:04 +08:00
qer
49a8c84a49
feat(web): cap markdown table column width at 700px (#1587)
* feat(web): cap markdown table column width at 700px

* fix(web): clamp table column width through the cell content box
2026-07-13 13:35:07 +08:00
Haozhe
ceb158dc54
feat(v2): land agent-core-v2 engine and kap-server behind experimental flag (#1441)
Some checks are pending
CI / typecheck (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / build (push) Waiting to run
CI / test (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
* fix: adapt grep tool to agent-core-v2

* fix(agent-core-v2): enrich PATH from the user's login shell at startup

- port probeLoginShellPath/mergeLoginShellPath/applyLoginShellPath into
  _base/execEnv/loginShellPath.ts as a pure helper (no DI)
- export execFileText from environmentProbe for reuse by the probe
- run applyLoginShellPathFromNode concurrently with the host probe in
  HostEnvironmentService, mirroring kaos LocalKaos.create()

Aligns agent-core-v2 with kaos 021786f5 so the Bash tool finds
user-installed tools (e.g. Homebrew's gh) when kimi-code is launched
from a GUI or non-login shell.

* fix(agent-core-v2): prefer persisted cwd on resume

* feat(agent-core-v2): support structured response formats

* fix: restore v2 grep telemetry and tests

* fix: preserve v2 compaction boundary

* fix(agent-core-v2): align agent and swarm tool behavior

* feat(ws-v1): add per-agent event subscription filter

- protocol: add optional agent_filter to client_hello and subscribe
- kap-server: carry per-subscription agent allowlists through the broadcaster
  and connection, narrowing live fan-out and replay to selected agents while
  keeping a single global sequence and bypassing the filter for global events
- agent-core-v2: degrade MiniDbQueryStore to a no-op read model when the
  query-store lock is held by another process instead of crashing the host

* feat(web): prefix skill slash commands with skill: to distinguish them from built-in commands (#1492)

* ci: release packages (#1468)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* docs(changelog): sync 0.23.2 from apps/kimi-code/CHANGELOG.md (#1496)

* chore: add changeset for agent swarm parity

* fix: align v2 compaction prompt

* fix: recover v2 compaction from plain 413

* fix: report v2 compaction retry telemetry

* fix: align MCP discovery and output with v1

* fix: align v2 compaction auth guards

* fix: align v2 grep behavior with v1

* chore: remove agent swarm changeset

* fix(agent-core-v2): restore task resume parity

* feat(agent-core-v2): record llm request traces

* fix(agent-core-v2): align AskUserQuestion tool chain with v1

- translate wire ids back to question text / option labels when resolving
  a question over REST, joining multi-select labels with ', '
- enforce unique question texts / option labels and non-empty strings at
  both the schema and the execution path
- cancel pending questions on turn abort or background task stop by
  dismissing the parked entry (resolves null, v1 broker semantics)
- restore the unsupported-client fallback and dismissed-error handling
- pass empty header / option description through verbatim and align the
  model-facing tool description byte-for-byte with v1
- drop the synthetic expires_at field from the question wire shape

* fix(agent-core-v2): preserve compaction hook session

* test(agent-core-v2): cover concurrent agent background limit

* fix(agent-core): report EXIF-rotated image dimensions and raise edge cap to 3000px (#1460)

* fix(agent-core): report EXIF-rotated image dimensions and raise edge cap to 3000px

Image compression now reports original dimensions in the decoded
(EXIF-rotated) space, matching the coordinate system of the sent image
and of ReadMediaFile region readback; previously portrait JPEGs
(orientation 5-8) got swapped width/height in captions. The longest-edge
downscale cap rises from 2000px to 3000px, and the default jimp resize
path is documented as the anti-aliased area-average one so it is not
accidentally switched to a point-sampled interpolation mode.

* test: shrink oversized image fixtures to fit CI timeouts

The 3600x3600 fixtures introduced for the 3000px edge cap nearly doubled
the pixel area jimp has to decode and deflate, pushing the slowest
compression tests past the 5s vitest timeout on CI runners. 3600x1800
keeps every fixture over the cap while restoring roughly the workload of
the old 2600x2600 fixtures that CI handled comfortably.

* test: pin anti-aliased downscale quality with executable guards

A 1px checkerboard probe pins the compressor to full-coverage averaging
at integer and fractional ratios, with jimp's point-sampled BILINEAR
mode kept as the executable aliasing counter-example (it collapses the
50%-gray pattern to solid black at 4:1). Also guards the other classic
downscale bugs: transparent-pixel color bleed, mean-brightness drift,
iterative recompression degradation, and zero-size collapse on extreme
aspect ratios.

* fix(agent-core): report decoded EXIF-rotated dimensions in ReadMediaFile notes

The media note derived its original-dimensions line from the header
sniff, which reports pre-rotation values for EXIF orientation 5-8
JPEGs. The sent image and region readback both live in the decoded
(rotated) space, so portrait photos got axis-swapped coordinate
guidance. Once a decode has happened — compression or crop — its
dimensions now overwrite the sniffed ones.

* fix(agent-core): improve handling of EXIF orientation in image dimensions and metadata

* fix(agent-core): sniff EXIF orientation and step budget fallback through 2000px

Two follow-ups to the EXIF and 3000px-cap changes:

sniffImageDimensions now reads the JPEG EXIF Orientation tag (pure
header parse, both byte orders) and reports display-space dimensions
for orientations 5-8. Passthrough images — never decoded — previously
kept the pre-rotation header size in compression results and media
read notes, disagreeing with the decoded space that region readback
uses.

encodeWithinBudget steps the over-budget fallback through 2000px
before the 1000px last resort. Raising the cap to 3000px had left a
regression window: an image whose 2000px encode fits the byte budget
was sent at 1000px where the old 2000px cap used to send it at
2000px.

* fix(kimi-code): record pasted image dimensions in display space

The TUI paste path recorded attachment and original dimensions from its
raw header parser, which ignores EXIF orientation. For a portrait JPEG
the submit-time caption then contradicted the sent image's aspect and
region readback coordinates were axis-swapped. Dimensions now come from
the compression result, which reports display space on both the
compressed and passthrough paths; parseImageMeta remains only the
format/mime gate.

* feat(agent-core): add image compression and crop telemetry

Every image ingestion path now reports an image_compress event —
outcome (compressed / passthrough fast, guard, unsupported, unhelpful,
error), input/output formats, byte and pixel sizes, EXIF transposition,
and duration — and region readback reports an image_crop event with a
failure classification and the region's share of the original area.

Wiring is per call site via a new CompressImageOptions.telemetry
option, so the outcome split and timing are measured inside the
compressor while each caller only names its source: ReadMediaFile
(tool construction, like GrepTool), MCP tool results (McpOutputOptions),
server prompt ingestion (ICoreProcessService now exposes the host
telemetry client), ACP prompts (session track adapter), and TUI paste
(host.track adapter). Properties are numeric/enum only — never paths
or content — and a throwing client can never affect the compression
result.

* fix(agent-core): run the full JPEG quality ladder at fallback sizes

The fallback rescales encoded only at quality 20, so a JPEG whose
ladder failed at the fitted size collapsed straight to the lowest
quality even when the smaller size left budget headroom for a higher
rung (the realistic window is the 1000px step, where the 4x pixel
drop pays for q80/q60). Each fallback edge now walks the same
q80-to-q20 ladder as the fitted size.

* test: shrink heavy JPEG fixtures and add explicit timeouts

The fallback-ladder test runs ~11 pure-JS JPEG encodes and the EXIF
paste test decodes, rotates, and re-encodes a 6.5MP frame; both sat at
the edge of the 5s vitest timeout on CI runners. Narrower fixtures cut
the pixel area (the ladder test keeps its width above 2000px so the
full fallback chain still runs) and explicit 15s timeouts absorb runner
variance.

* fix(server): scope prompt image compression telemetry to the session

The prompt-ingestion image_compress events were emitted with the bare
host telemetry client, while every agent-side source inherits a
session-scoped client — so prompt_inline/prompt_file events could not
be correlated with their session. The route now wraps the client with
withTelemetryContext({ sessionId }) like rpc/core-impl does for
session telemetry.

* chore(changeset): consolidate image compression changesets

One entry covering the cap raise and the EXIF dimension fix, listed
for both the CLI and the SDK so the SDK changelog's compression
description (previously pinned at 2000px) stays accurate.

* fix: count goal creation turn (#1477)

* feat(kosong): support structured response formats (#1397)

* fix: clarify goal blocked audit guidance (#1481)

* feat(agent-core): discard loaded tool schemas on compaction (#1471)

Align progressive tool disclosure with the discard-on-compaction model:
compaction no longer rebuilds loaded dynamic tool schemas. The boundary
announcement re-lists every loadable name, the model re-selects what it
still needs, and a from-memory call to a no-longer-loaded tool is
rejected by preflight with select guidance.

This removes the keep-all rebuild and its half-trigger budget heuristics
entirely: the post-compaction floor is back to users + summary, which is
structurally outside the auto-compaction trigger band, and the guard
baseline degenerates to summary + reinjected reminders. Every downstream
mechanism already treated the empty loaded set as its consistent base
state (ledger scan, pending clear at the compaction boundary, deferred
extras, preflight wording), so this is a strict simplification.

Co-authored-by: fengchenchen <fengchenchen@moonshot.ai>

* fix(kimi-code): exit 1 when a headless (-p) turn fails (#1483)

Headless (`kimi -p`) failures could exit with code 0 when the event loop
drained during the shutdown cleanup (e.g. telemetry's unref'd retry backoff
when the network is blocked), because the rejection never reached the
process.exit(1) call. Set the failure exit code before any await in both
the run-prompt catch and the main catch, and keep the cleanup timeout ref'd
so the loop stays alive long enough for the rejection to propagate.

* feat(plugins): add Vercel plugin to marketplace (#1489)

* feat(web): support Enter key to confirm archive and other dialogs (#1490)

* feat(web): redesign cron reminder as a message bubble (#1480)

* feat(web): redesign cron reminder as a message bubble

Restyle the cron trigger notice as a right-aligned user-style message bubble that shows the scheduled prompt in full (wrapping across lines), with a small meta row beneath it for the schedule, status, job id and run time. Extract a shared MessageTime component used by both user messages and the cron reminder so the timestamp format and click-to-expand behavior stay consistent, and give the CronCreate/CronList/CronDelete tools distinct calendar icons.

* refactor(web): render cron reminders only as standalone turns

Remove the embedded cron block path from the web transcript projector so cron reminder fires always render through the standalone right-aligned bubble path.

* chore(web): simplify cron redesign changeset

* fix(web): composer model switch also updates global default model (#1491)

* fix(web): composer model switch also updates global default model

The composer model switcher still switches the active session's model via
POST /sessions/{id}/profile (awaited, so the model pill reflects the result),
and additionally fires POST /api/v1/config with { default_model } as a
fire-and-forget side effect so new sessions inherit the chosen default. The
config request is skipped when the model already matches the current default.

* fix(web): route ModelPicker overlay selection through the default-model update

The overlay opened from the composer's "More models" row (and /model) is a
continuation of the same switch flow, so its selection now also bumps the
global default model instead of only switching the active session.

* fix(web): only persist the default model after a confirmed session switch

setModel now returns whether the switch was accepted (true for the draft
path), so the composer flow no longer writes a stale or invalid model alias
into the global config when the session-level switch failed and rolled back.

* feat(web): prefix skill slash commands with skill: to distinguish them from built-in commands (#1492)

* ci: release packages (#1468)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* fix(agent-core-v2): restore native append for Write append mode

- add IHostFileSystem.appendText backed by fs.appendFile (O_APPEND)
- route WriteTool append through it instead of read-then-rewrite, so
  existing content is never read, truncated, or clobbered by concurrent
  writers and a crash mid-append can only lose the new bytes
- update typed host-fs test fakes and WriteTool append assertions

* fix(agent-core-v2): align task tool prompts with v1

* fix(agent-core-v2): align compaction empty retry

* fix(agent-core-v2): restore web search source site and citation reminders

- surface source site: add WebSearchResult.siteName, map site_name in the
  Moonshot provider, and render the Site: line in tool output
- restore the per-search inline citation reminder alongside the results
- align web-search.md with v1: source-site/result-summary guidance and the
  static citation reminder

* fix(agent-core-v2): route task timeouts through SIGTERM grace + SIGKILL

- add terminateWithGrace shared by stop, timeoutMs, detachTimeoutMs, and
  track deadlines: cancel/SIGTERM -> 5s grace -> forceStop (SIGKILL)
- coerce a post-abort self-settled `killed` to `timed_out` so a deadline
  stays reported as timed_out, matching v1 settlementForOutcome
- add manager tests for SIGTERM-ignored escalation, graceful-exit within
  the grace window, and detachTimeout teardown

* fix(agent-core-v2): restore fs.grep streaming early-kill and symlink reporting

- stream `rg --json` in fs.grep and SIGKILL once max_total_matches/max_files is reached, restoring v1 early-stop instead of buffering the whole output
- report symlinks as kind 'symlink' in fs search/list/stat via lstat and never descend into symlinked directories
- remove the unused os grepSearch helper (dead, non-streaming, bypassed ISessionProcessRunner)

* test(agent-core-v2): align truncated compaction retry

* fix(agent-core-v2): align MCP tool results with v1

* fix(agent-core-v2): align blocked compaction failures

* fix(agent-core-v2): align manual compaction tool projection

* fix(agent-core-v2): preserve tail in windowed compaction

* fix(agent-core-v2): read todos from wire model, sanitize replay

- make SessionTodoService a stateless facade over the main agent's TodoModel:
  getTodos reads wire.getModel(TodoModel) live, setTodos only dispatches a
  todo.set op, and onDidChange is bridged from wire.subscribe(TodoModel); the
  in-memory list copy is gone so the live and post-replay views cannot drift
- sanitize todo.set payloads in apply via readTodoItems, so replayed or
  hand-written records cannot poison the model or downstream renders
- update todo tests to a sanitizing/notifying/replaying wire stub and cover
  malformed todo.set replay and main-absent reads
- record the main-agent-wire persistence debt for the ISessionWireService move

* fix(agent-core-v2): port v1 Bash tool output cap, saved-output reference, and background gating (#1503)

* fix: align v2 task observable behavior

* fix: v2 full compaction

* fix(agent-core-v2): align task wait timeout behavior

* chore: webSearch & FetchUrl Sync #1260

* fix: align background agent guidance

* fix(agent-core-v2): align full compaction with v1

* fix(agent-core-v2): align v1 wire records

* fix(agent-core-v2): remember observed compaction context window

* fix: align Agent / AgentSwarm

* fix(agent-core-v2): port v1 parity fixes for hooks, anthropic, thinking config and add-dir (#1504)

* fix(agent-core-v2): hide console window when running hooks on Windows

Port the v1 hooks runner fix: extract buildHookSpawnOptions and pass
windowsHide:true so hook child processes no longer flash a console
window on Windows, mirroring the node-local process host defaults.
Includes the same regression tests as v1.

* fix(agent-core-v2): port anthropic max_tokens ceiling and override fixes

Port two v1 kosong fixes to the v2 anthropic provider:

- Fall back to the nearest lower catalogued minor when resolving the
  Claude output ceiling, and catalogue Opus 4.8's documented 128k cap,
  so an uncatalogued minor no longer drops to the family baseline.
- Treat an explicit defaultMaxTokens as the final max_tokens value
  instead of clamping it to the built-in ceiling.

Mirrors the v1 regression tests in a new anthropic-max-tokens test file.

* refactor(agent-core-v2): converge thinking config to enabled/effort

Port the v1 thinking-config overhaul (#1132's config side) to v2:

- ThinkingConfigSchema becomes { enabled, effort, keep }; the mode enum,
  the separate defaultThinking section, and the KIMI_MODEL_THINKING_MODE /
  KIMI_MODEL_DEFAULT_THINKING env bindings are removed.
- The effort resolver drops the mode/defaultThinking branches and no
  longer normalizes a requested 'on' to a concrete effort in core; 'on'
  is taken verbatim and normalization stays at the UI boundary.
- OAuth login/refresh and catalog refresh now persist the thinking.enabled
  value computed by the shared oauth apply/restore logic instead of
  dropping it and writing the removed default_thinking key, so
  [thinking] enabled = false actually disables thinking and the login
  default survives on disk.

Mirrors the v1 resolver regression tests and adds a persistence
regression for the refresh path.

* docs(agent-core-v2): fix stale loop-event comments after wire parity

The v1.4 wire-parity alignment switched the v2 live loop to stream turns
as context.append_loop_event records, but three comments still described
the old world (restore-only Op, "v2 never emits loop events"). Update
them to match the actual write path: non-loop appends use append_message,
the loop persists loop events byte-compatible with v1, and the fold runs
both at live dispatch time and on replay.

* fix(agent-core-v2): load workspace additional dirs on session create and resume

The /add-dir command persisted remembered dirs to .kimi-code/local.toml,
but session materialization never read them back and offered no caller
additionalDirs entry point — a remembered dir silently stopped applying
to new, resumed, and forked sessions.

Mirror v1's createSession/resumeSession: merge the project-local
local.toml dirs with caller-supplied additionalDirs (relative paths
resolve against workDir) and seed the session workspace context in
materializeSession, so create/resume/fork all pick them up. A broken
local.toml fails the create loudly with CONFIG_INVALID, same as v1.
Tests mirror v1's runtime coverage for the load/merge/dedupe/resume/fork
scenarios.

* fix(kimi-code): forward create-session additional dirs from the v2 harness

The in-process v2 print-mode harness dropped the SDK CreateSessionOptions
additionalDirs when calling ISessionLifecycleService.create, so --add-dir
never reached the v2 resolver. Pass it through.

* fix(agent-core-v2): align full compaction observability

* fix(agent-core-v2): remove compact hook trigger state

* feat(agent-core-v2): enhance agent lifecycle with context size tracking and concurrency checks

* fix(agent-core-v2): align media reads with v1 note channel and EXIF handling (#1505)

* fix(agent-core-v2): align media reads with v1 note channel and EXIF handling

Port two agent-core changes into agent-core-v2:

- Move the ReadMediaFile media summary from an inline <system> text part
  onto the tool result's note side channel, so raw <system> markup never
  renders in UIs (matching the MCP output path).
- Report image dimensions in the decoded EXIF-rotated space: the header
  sniff now reads the JPEG Orientation tag, and once a decode happened
  (compression or crop) its dimensions overwrite the sniffed ones, so
  portrait photos no longer get axis-swapped coordinate guidance.
- Raise the longest-edge downscale cap from 2000px to 3000px, step the
  over-budget fallback through 2000px before the 1000px last resort, and
  run the full JPEG quality ladder at fallback sizes.
- Report image_compress / image_crop telemetry for media reads (source
  read_media), with EXIF transposition and crop failure classification.

The tool description also regains the downsampling recovery guidance
(region / full_resolution readback) that the v2 copy predated.

* fix(agent-core-v2): align v1 wire records

* fix(agent-core-v2): hide compression captions and register media tools in production

Port the remaining v1 media gaps into agent-core-v2:

- Reroute inline image-compression captions out of user messages: the
  prompt service splits them at the append chokepoint (prompt and steer
  flush) and delivers them through the built-in system-reminder
  injection (origin {kind: 'injection', variant: 'image_compression'}),
  which every UI hides. Session titles/lastPrompt strip the caption the
  same way. The model still receives the full note.
- Register ReadMediaFile in production: media tools cannot use the
  module-level contribution table (capabilities are unknown until a
  model binds), so a new Eager agent-scope registrar re-runs
  registerMediaTools on every agent.status.updated where the model
  alias or its media capabilities changed, rebinding the video uploader
  and dropping the tool when the model loses media input.

* fix(agent-core-v2): port v1 parity fixes for hooks, anthropic, thinking config and add-dir (#1504)

* fix(agent-core-v2): hide console window when running hooks on Windows

Port the v1 hooks runner fix: extract buildHookSpawnOptions and pass
windowsHide:true so hook child processes no longer flash a console
window on Windows, mirroring the node-local process host defaults.
Includes the same regression tests as v1.

* fix(agent-core-v2): port anthropic max_tokens ceiling and override fixes

Port two v1 kosong fixes to the v2 anthropic provider:

- Fall back to the nearest lower catalogued minor when resolving the
  Claude output ceiling, and catalogue Opus 4.8's documented 128k cap,
  so an uncatalogued minor no longer drops to the family baseline.
- Treat an explicit defaultMaxTokens as the final max_tokens value
  instead of clamping it to the built-in ceiling.

Mirrors the v1 regression tests in a new anthropic-max-tokens test file.

* refactor(agent-core-v2): converge thinking config to enabled/effort

Port the v1 thinking-config overhaul (#1132's config side) to v2:

- ThinkingConfigSchema becomes { enabled, effort, keep }; the mode enum,
  the separate defaultThinking section, and the KIMI_MODEL_THINKING_MODE /
  KIMI_MODEL_DEFAULT_THINKING env bindings are removed.
- The effort resolver drops the mode/defaultThinking branches and no
  longer normalizes a requested 'on' to a concrete effort in core; 'on'
  is taken verbatim and normalization stays at the UI boundary.
- OAuth login/refresh and catalog refresh now persist the thinking.enabled
  value computed by the shared oauth apply/restore logic instead of
  dropping it and writing the removed default_thinking key, so
  [thinking] enabled = false actually disables thinking and the login
  default survives on disk.

Mirrors the v1 resolver regression tests and adds a persistence
regression for the refresh path.

* docs(agent-core-v2): fix stale loop-event comments after wire parity

The v1.4 wire-parity alignment switched the v2 live loop to stream turns
as context.append_loop_event records, but three comments still described
the old world (restore-only Op, "v2 never emits loop events"). Update
them to match the actual write path: non-loop appends use append_message,
the loop persists loop events byte-compatible with v1, and the fold runs
both at live dispatch time and on replay.

* fix(agent-core-v2): load workspace additional dirs on session create and resume

The /add-dir command persisted remembered dirs to .kimi-code/local.toml,
but session materialization never read them back and offered no caller
additionalDirs entry point — a remembered dir silently stopped applying
to new, resumed, and forked sessions.

Mirror v1's createSession/resumeSession: merge the project-local
local.toml dirs with caller-supplied additionalDirs (relative paths
resolve against workDir) and seed the session workspace context in
materializeSession, so create/resume/fork all pick them up. A broken
local.toml fails the create loudly with CONFIG_INVALID, same as v1.
Tests mirror v1's runtime coverage for the load/merge/dedupe/resume/fork
scenarios.

* fix(kimi-code): forward create-session additional dirs from the v2 harness

The in-process v2 print-mode harness dropped the SDK CreateSessionOptions
additionalDirs when calling ISessionLifecycleService.create, so --add-dir
never reached the v2 resolver. Pass it through.

* fix(agent-core-v2): report video_upload telemetry for media reads

Port the v1 video-upload telemetry wrapper into createVideoUploader:
every upload emits a video_upload event with outcome (success/error),
byte size, mime type, duration, and the caller's static props (model
alias, protocol tags), and a throwing telemetry client never affects
the upload outcome. The media-tools registrar supplies the sink and
props from the bound model.

Also restores two v1 rationale comments in ReadMediaFile (original-size
reporting and the full_resolution hard refusal) that were dropped
during the earlier port.

---------

Co-authored-by: 7Sageer <7sageer@djwcb.cn>
Co-authored-by: liruifengv <liruifeng1024@gmail.com>

* refactor(agent-core-v2): remove the microCompaction domain

- delete the microCompaction domain (service, wire model/op, config section,
  experimental flag) and its dedicated tests
- stop truncating old tool results in the context projector and drop the
  projector's now-unused instantiation dependency
- remove the domain from the layer map, package exports, and the DI x Scope
  dependency diagram
- retarget the flag-registry test and skill examples at a neutral flag

* fix(contextProjector): surface projection repairs via log warning

- add ProjectionAnomaly + onAnomaly sink through the project / projectStrict
  passes (reorder, synthesize, orphan / duplicate drop, leading drop, merge,
  blank-text drop) so the pure projection reports every wire-repair it applies
- AgentContextProjectorService injects ILogService and emits a single
  signature-deduped 'repaired the request to keep it wire-valid' warning,
  excluding trailing-tail synthesis, matching agent-core parity
- cover the trace and its dedup in the projector tests

* fix(agent-core-v2): fix cron killswitch, lost deliveries, id clashes

- killswitch: read KIMI_DISABLE_CRON live by re-applying the ConfigService
  env overlay on every get(); CronCreate reads it via ISessionCronService
  instead of a value frozen at tool registration
- delivery: resolve fire delivery on promptService.steer().launched so a
  rejected launch retains one-shot tasks for retry instead of deleting
  them; tick() is now async and awaits delivery before advancing cursors
- ids: switch cron task ids to ULIDs (from 32-bit hex) so two sessions
  sharing a workspace cannot overwrite each other's persisted task;
  CronDelete and persistence accept both ULID and legacy 8-hex ids
- display: CronCreate reports nextFireAt through the service so it honors
  KIMI_CRON_NO_JITTER and matches the scheduler and CronList
- migration: adopt shape-valid tasks with no sessionId tag on
  loadFromStore and stamp the tag back to disk
- persistence: create cron directories 0700 and files 0600 via
  FileStorageService dirMode/fileMode

Gate SessionCronService startup on config.ready and resolve clocks after
ready so config is never read before it is loaded; start() is now async.

* feat(fs-watch): add workspace fs watch with v1-compatible WS delivery

- os layer: add IHostFsWatchService over chokidar (raw create/modify/delete, .git ignored)
- session layer: add ISessionFsWatchService, a workspace-confined, debounced, .gitignore-aware FsChangeEvent feed
- kap-server: add FsWatchBridge pushing event.fs.changed over /api/v1/ws (watch_fs_add/remove, volatile, per-connection filter), byte-compatible with v1
- tests: os/session unit tests and kap-server fs-watch e2e

* refactor(agent-core-v2): run external hooks through IHostProcessService

- inject IHostProcessService into ExternalHooksRunnerService and thread it
  through runMatchedHooks to runHook instead of spawning node:child_process
- route hook termination through the service's cross-platform process-tree kill
- settle on the exit code plus drained stdout/stderr so fast-exiting hooks
  keep their trailing output
- hide the child console window on Windows via the service default
- update externalHooks tests for the new dependency

* fix(agent-core-v2): strict-decode Edit reads, align with v1

- read the Edit target with errors:'strict' so a non-UTF-8 file fails the
  edit instead of being silently rewritten as U+FFFD (matches v1 kaos)
- declare readWriteFile access since Edit reads before it writes, matching v1
- render edit.md directly instead of through renderPrompt: it has no template
  vars, and raw avoids treating literal {{ }} as a template
- restore the replace_all usage example in edit.md (v1 #1102)
- add a regression test asserting a non-UTF-8 file fails the edit and keeps
  its bytes untouched

* fix(agent-core-v2): dedupe AgentMeta legacy field declarations

* refactor(agent-core-v2): persist wire records natively in the v1 vocabulary

Remove the persist-time v1 rewrite layer (serializeV1WireRecord): ops now
write v1-shaped records directly, live-only state is declared persist:false
on the op instead of being stripped at write time, and the swarm-exit
reminder pop replays from the swarm_mode.exit record via a cross-model
reducer. Fixes resumed sessions losing the todo list, drifting turn
counters after retries, and removed reminders reappearing on resume.

* refactor(agent-core-v2): move ReadTool status block to note side channel

- ReadTool.finishReadResult now returns rendered lines as `output` only and
  rides the `<system>` status block on the model-only `note` side channel
- drop the finishOutput helper that concatenated content and status
- update read.test.ts expectations to assert `note` separately from `output`

* refactor(agent-core-v2): split blob service helpers, rewrite tests

- extract rewriteMediaUrls and blobref parse/format helpers to dedupe URL rewriting
- move the byte-bounded LRU cache into a module-private ByteLruCache with focused unit tests, dropping the protected maxCacheSize test seam
- rewrite blob service tests against the contract on in-memory storage, removing cache-internals cases

* fix: make release-e2e scenarios pass under agent-core-v2

Three independent fixes for release-e2e failures that only appeared with the experimental v2 engine (KIMI_CODE_EXPERIMENTAL_FLAG):

- agent-core-v2: register the KIMI_MODEL env overlay statically so it takes effect even when ModelService is not instantiated (the DI layer does not auto-instantiate Eager services). Fixes wire-llm-request-trace.

- cli: omit the leading system.version meta line in stream-json prompt mode so the role sequence stays clean. Fixes stream-json-cron.

- agent-core-v2: honor --skills-dir via a new explicit skill source seeded from the host. Fixes interactive-skills-dir.

Cherry-picked from 2a7232737 (v2-migration), excluding the node-sdk V2Host change (not applicable on this branch).

* refactor(agent-core-v2): drop replay-only wire ops

Remove the three replay-only Ops that were kept for pre-alignment / 1.5 sessions, now that v2 persists natively in the v1 vocabulary:

- turn.launch (replaced by turn.prompt)

- todo.set (replaced by tools.update_store with key 'todo')

- context.splice (replaced by context.append_message / append_loop_event)

Also drop the dead code that handled them (transcript reducer, task-origin extraction, blob dehydration, harness helpers) and migrate the affected tests to the v1 record types. The live write path already emitted only v1 records, so wire.jsonl output is unchanged.

* Revert "fix: make release-e2e scenarios pass under agent-core-v2"

This reverts commit ec9dae72ab.

* fix(agent-core-v2): use a fresh TextDecoder per append-log read

The module-level TextDecoder is stateful in stream mode: it buffers a
trailing incomplete multi-byte sequence until the next decode. Sharing it
across reads let leftover state from an earlier read that returned early
(e.g. ensureWireMetadata bailing on the leading metadata record) leak into
the next read and prepend a U+FFFD to its first line, corrupting the
metadata envelope and breaking session fork with "corrupted line 1".

Give each read its own TextDecoder so decoder state never leaks between
reads.

* fix(agent-core-v2): register KIMI_MODEL env overlay statically

The KIMI_MODEL_* effective overlay was registered by ModelService on construction, but the DI layer does not auto-instantiate Eager services, so the overlay never took effect when nothing resolved IModelService. This broke the release-e2e wire-llm-request-trace scenario, where KIMI_MODEL_NAME must synthesize the env model and its thinking capability.

Move registration to module load via a new configOverlayContributions collector, drained by ConfigRegistry on construction — mirroring the existing configSectionContributions pattern. ModelService no longer depends on IConfigRegistry.

* docs(agent-core-v2): clarify live-only op semantics

* fix(agent-core-v2): preserve oversized tool results

* chore: remove full compaction complete data type

* fix(agent-core-v2): align foreground output cap

* fix(agent-core-v2): gate skill prompt injection

* chore(skills): bundle review and test lenses into kc-review

- add agent-core-review umbrella skill with slop and test sub-skills
- move write-tests rules into agent-core-review/test and drop the standalone skill

* feat(v2): auto-mint session ids and harden print-mode background drain

- make CreateSessionOptions.sessionId optional; SessionLifecycleService.create and fork now mint `session_<lowercase-uuid>` via a shared createSessionId helper, so edge layers stop minting their own ids (drop randomUUID in the v2 harness, ulid in kap-server)
- rework V2Session.waitForBackgroundTasksOnPrint to re-enumerate each round, suppress terminal notifications while waiting, and bound the drain by [task].print_wait_ceiling_s (default 1h) instead of a hardcoded 30s cap, so kimi -p can run long tasks to completion without being steered into a new turn
- add v2-session unit tests; seed session/agent/bootstrap context in the tool-dedupe harness for the real executor

* fix(agent-core-v2): refresh system prompt after compaction

* docs(agent-core-review): limit kc-review skill to agent-core-v2

Clarify that the kc-review lenses apply only to packages/agent-core-v2
(the DI x Scope engine), not to the legacy packages/agent-core or other
packages.

* fix(agent-core-v2): align model-facing prompts

* fix(agent-core): report EXIF-rotated image dimensions and raise edge cap to 3000px (#1460)

* fix(agent-core): report EXIF-rotated image dimensions and raise edge cap to 3000px

Image compression now reports original dimensions in the decoded
(EXIF-rotated) space, matching the coordinate system of the sent image
and of ReadMediaFile region readback; previously portrait JPEGs
(orientation 5-8) got swapped width/height in captions. The longest-edge
downscale cap rises from 2000px to 3000px, and the default jimp resize
path is documented as the anti-aliased area-average one so it is not
accidentally switched to a point-sampled interpolation mode.

* test: shrink oversized image fixtures to fit CI timeouts

The 3600x3600 fixtures introduced for the 3000px edge cap nearly doubled
the pixel area jimp has to decode and deflate, pushing the slowest
compression tests past the 5s vitest timeout on CI runners. 3600x1800
keeps every fixture over the cap while restoring roughly the workload of
the old 2600x2600 fixtures that CI handled comfortably.

* test: pin anti-aliased downscale quality with executable guards

A 1px checkerboard probe pins the compressor to full-coverage averaging
at integer and fractional ratios, with jimp's point-sampled BILINEAR
mode kept as the executable aliasing counter-example (it collapses the
50%-gray pattern to solid black at 4:1). Also guards the other classic
downscale bugs: transparent-pixel color bleed, mean-brightness drift,
iterative recompression degradation, and zero-size collapse on extreme
aspect ratios.

* fix(agent-core): report decoded EXIF-rotated dimensions in ReadMediaFile notes

The media note derived its original-dimensions line from the header
sniff, which reports pre-rotation values for EXIF orientation 5-8
JPEGs. The sent image and region readback both live in the decoded
(rotated) space, so portrait photos got axis-swapped coordinate
guidance. Once a decode has happened — compression or crop — its
dimensions now overwrite the sniffed ones.

* fix(agent-core): improve handling of EXIF orientation in image dimensions and metadata

* fix(agent-core): sniff EXIF orientation and step budget fallback through 2000px

Two follow-ups to the EXIF and 3000px-cap changes:

sniffImageDimensions now reads the JPEG EXIF Orientation tag (pure
header parse, both byte orders) and reports display-space dimensions
for orientations 5-8. Passthrough images — never decoded — previously
kept the pre-rotation header size in compression results and media
read notes, disagreeing with the decoded space that region readback
uses.

encodeWithinBudget steps the over-budget fallback through 2000px
before the 1000px last resort. Raising the cap to 3000px had left a
regression window: an image whose 2000px encode fits the byte budget
was sent at 1000px where the old 2000px cap used to send it at
2000px.

* fix(kimi-code): record pasted image dimensions in display space

The TUI paste path recorded attachment and original dimensions from its
raw header parser, which ignores EXIF orientation. For a portrait JPEG
the submit-time caption then contradicted the sent image's aspect and
region readback coordinates were axis-swapped. Dimensions now come from
the compression result, which reports display space on both the
compressed and passthrough paths; parseImageMeta remains only the
format/mime gate.

* feat(agent-core): add image compression and crop telemetry

Every image ingestion path now reports an image_compress event —
outcome (compressed / passthrough fast, guard, unsupported, unhelpful,
error), input/output formats, byte and pixel sizes, EXIF transposition,
and duration — and region readback reports an image_crop event with a
failure classification and the region's share of the original area.

Wiring is per call site via a new CompressImageOptions.telemetry
option, so the outcome split and timing are measured inside the
compressor while each caller only names its source: ReadMediaFile
(tool construction, like GrepTool), MCP tool results (McpOutputOptions),
server prompt ingestion (ICoreProcessService now exposes the host
telemetry client), ACP prompts (session track adapter), and TUI paste
(host.track adapter). Properties are numeric/enum only — never paths
or content — and a throwing client can never affect the compression
result.

* fix(agent-core): run the full JPEG quality ladder at fallback sizes

The fallback rescales encoded only at quality 20, so a JPEG whose
ladder failed at the fitted size collapsed straight to the lowest
quality even when the smaller size left budget headroom for a higher
rung (the realistic window is the 1000px step, where the 4x pixel
drop pays for q80/q60). Each fallback edge now walks the same
q80-to-q20 ladder as the fitted size.

* test: shrink heavy JPEG fixtures and add explicit timeouts

The fallback-ladder test runs ~11 pure-JS JPEG encodes and the EXIF
paste test decodes, rotates, and re-encodes a 6.5MP frame; both sat at
the edge of the 5s vitest timeout on CI runners. Narrower fixtures cut
the pixel area (the ladder test keeps its width above 2000px so the
full fallback chain still runs) and explicit 15s timeouts absorb runner
variance.

* fix(server): scope prompt image compression telemetry to the session

The prompt-ingestion image_compress events were emitted with the bare
host telemetry client, while every agent-side source inherits a
session-scoped client — so prompt_inline/prompt_file events could not
be correlated with their session. The route now wraps the client with
withTelemetryContext({ sessionId }) like rpc/core-impl does for
session telemetry.

* chore(changeset): consolidate image compression changesets

One entry covering the cap raise and the EXIF dimension fix, listed
for both the CLI and the SDK so the SDK changelog's compression
description (previously pinned at 2000px) stays accurate.

* fix: count goal creation turn (#1477)

* feat(kosong): support structured response formats (#1397)

* fix: clarify goal blocked audit guidance (#1481)

* feat(agent-core): discard loaded tool schemas on compaction (#1471)

Align progressive tool disclosure with the discard-on-compaction model:
compaction no longer rebuilds loaded dynamic tool schemas. The boundary
announcement re-lists every loadable name, the model re-selects what it
still needs, and a from-memory call to a no-longer-loaded tool is
rejected by preflight with select guidance.

This removes the keep-all rebuild and its half-trigger budget heuristics
entirely: the post-compaction floor is back to users + summary, which is
structurally outside the auto-compaction trigger band, and the guard
baseline degenerates to summary + reinjected reminders. Every downstream
mechanism already treated the empty loaded set as its consistent base
state (ledger scan, pending clear at the compaction boundary, deferred
extras, preflight wording), so this is a strict simplification.

Co-authored-by: fengchenchen <fengchenchen@moonshot.ai>

* fix(kimi-code): exit 1 when a headless (-p) turn fails (#1483)

Headless (`kimi -p`) failures could exit with code 0 when the event loop
drained during the shutdown cleanup (e.g. telemetry's unref'd retry backoff
when the network is blocked), because the rejection never reached the
process.exit(1) call. Set the failure exit code before any await in both
the run-prompt catch and the main catch, and keep the cleanup timeout ref'd
so the loop stays alive long enough for the rejection to propagate.

* feat(plugins): add Vercel plugin to marketplace (#1489)

* feat(web): support Enter key to confirm archive and other dialogs (#1490)

* feat(web): redesign cron reminder as a message bubble (#1480)

* feat(web): redesign cron reminder as a message bubble

Restyle the cron trigger notice as a right-aligned user-style message bubble that shows the scheduled prompt in full (wrapping across lines), with a small meta row beneath it for the schedule, status, job id and run time. Extract a shared MessageTime component used by both user messages and the cron reminder so the timestamp format and click-to-expand behavior stay consistent, and give the CronCreate/CronList/CronDelete tools distinct calendar icons.

* refactor(web): render cron reminders only as standalone turns

Remove the embedded cron block path from the web transcript projector so cron reminder fires always render through the standalone right-aligned bubble path.

* chore(web): simplify cron redesign changeset

* fix(web): composer model switch also updates global default model (#1491)

* fix(web): composer model switch also updates global default model

The composer model switcher still switches the active session's model via
POST /sessions/{id}/profile (awaited, so the model pill reflects the result),
and additionally fires POST /api/v1/config with { default_model } as a
fire-and-forget side effect so new sessions inherit the chosen default. The
config request is skipped when the model already matches the current default.

* fix(web): route ModelPicker overlay selection through the default-model update

The overlay opened from the composer's "More models" row (and /model) is a
continuation of the same switch flow, so its selection now also bumps the
global default model instead of only switching the active session.

* fix(web): only persist the default model after a confirmed session switch

setModel now returns whether the switch was accepted (true for the draft
path), so the composer flow no longer writes a stale or invalid model alias
into the global config when the session-level switch failed and rolled back.

* feat(web): prefix skill slash commands with skill: to distinguish them from built-in commands (#1492)

* ci: release packages (#1468)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* fix: surface provider auth error for unavailable models (#1506)

* fix: surface provider auth error for unavailable models

When an OAuth-managed model returns 401 after a forced token refresh, the token is valid but the provider rejected it for that model (the account lacks access). Emit provider.auth_error carrying the provider's message instead of auth.login_required with a misleading "OAuth login expired. Send /login" prompt.

* fix(agent-core): preserve provider auth errors through compaction

Treat provider.auth_error like auth.login_required in the compaction path so an auth rejection during compaction surfaces the provider's message instead of being wrapped as a generic compaction failure.

* ci: release packages (#1507)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* docs(changelog): sync 0.23.3 and shorten OAuth error entry (#1509)

* feat(kimi-web): add status-aware browser notifications (#1479)

* feat(kimi-web): add approval notification storage key and i18n copy

* feat(kimi-web): add approval notification helpers and tests

* feat(kimi-web): wire approval notifications and guard completion alerts

* fix(kimi-web): extract shouldNotifyCompletion helper and add tests

* feat(kimi-web): add approval notification settings toggle

* chore(kimi-web): add changeset and tidy notification module comment

- Align approval notification tag with spec (kimi-approval-${approvalId})

- Update module header to describe all three notification kinds

* fix(kimi-web): make notifications fire reliably

- Key completion notification tags by turn (sid + promptId) and question
  tags by request id, so a stale notification left in the notification
  center no longer swallows every follow-up alert in the same session
- Suppress notifications only while the window is actually focused, not
  merely visible (document.hasFocus() on top of visibilityState)
- Play the attention sound when a tool needs approval, matching the
  completion and question sounds

* chore(kimi-web): simplify changeset

* fix(agent-core-v2): serialize concurrent model catalog refreshes

Port v1 #1207's _refreshChain so a scheduled refresh and a manual one (or two overlapping manual ones) never race on reading/patching the persisted config.

Applied to both refresh entry points: ModelCatalogService.refreshProviderModels (scheduler + all/single-provider) and OAuthService.refreshOAuthProviderModels (OAuth-only, a separate service in v2).

* fix(agent-core-v2): dedupe workspace registry entries by root

Port v1 #1221: collapse registered workspaces that share a root in list(), preferring the entry whose id matches the current canonical encodeWorkDirKey, so a legacy workspaces.json (v1-compatible) does not render the same folder twice through GET /workspaces.

* fix(agent-core-v2): apply KIMI_CODE_CUSTOM_HEADERS and host identity headers

Port v1's provider-manager outbound header logic to agent-core-v2 so
`KIMI_CODE_CUSTOM_HEADERS` and host identity headers are applied to
outbound LLM requests, closing the migration gap from #1186:

- env `KIMI_CODE_CUSTOM_HEADERS` is the lowest-precedence header layer;
- host identity headers (User-Agent + X-Msh-*) are sent for Kimi
  providers, only the User-Agent for every other provider — a Kimi
  provider routed through the Anthropic protocol still gets the full
  set, matching v1;
- provider `customHeaders` always win on conflict.

Host headers are seeded by the CLI via `createKimiDefaultHeaders` and a
new `IHostRequestHeaders` App-scope token (defaulting to empty), so the
model resolver can layer them without the host threading them through
every call site.

* chore(agent-core-review): rename skill from kc-review to agent-core-review

* feat(kap-server): surface originating stack trace on error envelopes

- add optional `stack` field to errEnvelope and the envelope schema/interface; omitted when undefined so the wire shape stays byte-identical for callers without a stack
- thread `err.stack` through route error mappers plus the global and transport error handlers
- preserve `details` on `session.undo_unavailable` while adding its stack
- update tests to assert stacks are surfaced, reversing the prior no-leak contract

* fix: align v2 media and task compaction handling

* feat(agent-core-v2): sync shell mode and skill config parity (#1514)

* feat(agent-core-v2): record shell command context

- add ShellCommandOrigin and compaction handoff disposition
- extract IAgentShellCommandService from AgentRPCService
- keep AgentRPCService as a thin shell:run facade

* fix(agent-core-v2): align skill priority and sync docs

- restore project > user > plugin > builtin skill precedence
- sync Skill tool description and parameter docs from v1
- update write-goal and custom-theme builtin skill copy

* fix(agent-core-v2): restore undo and thinking telemetry

- track conversation_undo after undoHistory
- emit thinking_toggle with enabled/effort/from payload
- add coverage for both telemetry events

* feat(agent-core-v2): add skill directory config

- add extraSkillDirs and mergeAllAvailableSkills config sections
- introduce extra skill source and shared source priorities
- align kap-server workspace skill preview with session catalog

* feat(agent-core-v2): support explicit skill dirs

- add ISkillCatalogRuntimeOptions for SDK-style explicit skill dirs
- suppress default user/project discovery when explicitDirs are set
- resolve explicit dirs per session workDir via explicitFileSkillSource

* fix(agent-core-v2): fix configured skill dir resolution

- expand ~ using OS home for configured skill dirs
- honor explicitDirs in kap-server workspace skill preview

* fix(agent-core-v2): await config ready before skill discovery

- wait for config.ready before reading extraSkillDirs
- wait for config.ready before reading mergeAllAvailableSkills
- cover extra skill dir loading behind config readiness

* fix(agent-core-v2): keep skill config live after changes

- await config.ready in kap-server workspace skill preview
- reload user and workspace skill sources when mergeAllAvailableSkills changes

* fix(agent-core-v2): align goal budget handling

* fix(agent-core-v2): forbid model goal pauses

* fix(agent-core-v2): cap detached process output

* fix(kimi-code): drain v2 print subagents before exit

* fix: restore kap-server video upload compatibility

* fix(agent-core-v2): charge only output tokens against goal token budgets

Goal parity gap G2: v1 charges only per-step output tokens against a
goal's tokenBudget, while v2 summed all four usage buckets (cache read,
cache creation, other input, output), exhausting budgets orders of
magnitude faster under prompt caching and skewing persisted tokensUsed
counters. Align goal token accounting to output-only and drop the
unused tokenUsageTotal helper.

* fix: align server-v2 media file handling

* feat(agent-core-v2): allow coder profile to use MCP tools

* refactor(agent-core): introduce activity kernel and migrate turn lane

- add `activity` domain: `IAgentActivityService` (Agent turn lane machine),
  `ISessionActivityKernel` (Session admission, PR1 placeholder), and the
  `ActivityLease` that owns the turn `AbortSignal`
- turnService launches and cancels through the kernel lease; `Turn` now
  exposes `signal` instead of `abortController`
- agentLifecycle.remove drives `beginDisposal`/`settled` and waits for the
  in-flight turn to drain before releasing the agent scope
- add `activity.*` error codes; deprecate `turn.agent_busy` in favor of
  `activity.agent_busy`

* refactor(sessionLegacy): remove fork/compact/abort/archive pass-throughs

These four legacy session actions were thin delegations to the native v2
services (ISessionLifecycleService.fork/archive,
IAgentFullCompactionService.begin, IAgentRPCService.cancel) with no v1-only
projection to centralize. Drop them from ISessionLegacyService and call the
native services directly from the kap-server sessions route. updateProfile,
createChild, listChildren, undo and status stay in the adapter since they
carry real v1 adaptation logic.

* refactor(cli): run print-mode v2 on native agent-core-v2 services

- add native v2 print runner (v2/run-v2-print.ts) that consumes agent-core-v2
  DI services and awaits Turn.result directly
- extract shared print-mode rendering into prompt-render.ts for v1 and v2
- remove the V2PromptHarness/V2Session shim and v2->v1 event translation
- decouple initializeCliTelemetry from PromptHarness (homeDir/auth/track)
- add IAgentPromptLegacyService.submitAndSettle for authoritative completion

* refactor(session): serve v1 undo and children via native v2 services

- make IAgentPromptService.undo throw session.undo_unavailable with a structured
  reason; move the precheck into contextMemory
- add ISessionLifecycleService.createChild (fork + child markers) and
  ISessionIndex.list({ childOf })
- slim ISessionLegacyService to updateProfile/status (drop createChild,
  listChildren, undo)
- rewire kap-server session routes to the native services and map
  SESSION_UNDO_UNAVAILABLE

* refactor(cli): drop v1 sdk and telemetry deps from v2 print

- run-v2-print: use core ITelemetryService + CloudAppender instead of
  kimi-telemetry; remove kimi-code-sdk import (auth via IOAuthToolkit,
  config path from bootstrap, hook result via structural type)
- prompt-render: replace SDK HookResultEvent with a structural type so
  the shared renderer does not depend on the v1 SDK event shape
- telemetry: revert initializeCliTelemetry to its original signature now
  that v2 no longer calls it; keep v1 callers and assertions untouched
- update run-prompt and v2-run-print tests for the new wiring

* feat(activity): add session lane machine and agent snapshot projector

- implement SessionActivityKernel lane machine (restoring→active⇄quiescing→closing→disposed) with admission table, atomic quiesce+drain, beginClosing/settled, markActive
- start AgentActivityService lane at initializing; add markReady driven by agentLifecycle.create after bootstrap
- project LaneModel + EventBus facts into structured AgentActivitySnapshot (ActivityModel / setActivitySnapshot Op) with pending-approval and active-tool-call sets; emit agent.activity.updated
- add IAgentTurnService.launchWithLease; goal continuation acquires the lane before appending its prompt
- resolve pending interactions on turn.ended to avoid stranded awaiting_approval
- fullCompaction registers a background activity and checks the activity lane
- extract contextMemory publishSplice / isFullyUndoable / recoverFoldedLength helpers
- kap-server: map activity snapshot into legacy status and sessionEventBroadcaster

* fix(agent-core-v2): truncate over-long goal completion criteria

Goal parity gap G11: v1 silently truncates a goal's completionCriterion
to 4000 characters (the objective cap) before persisting, so an
over-long criterion never fails creation and cannot bloat every goal
reminder and record. v2 only trimmed whitespace and persisted arbitrary
lengths verbatim. Cap the normalized criterion at
MAX_GOAL_COMPLETION_CRITERION_LENGTH to match v1.

* fix(agent-core-v2): add goal error catalog info metadata

Align the GoalErrors domain with V1 by attaching the info block for the
seven goal.* error codes (title, retryable, public, action hints) so
errorInfo() surfaces them. Entries copied verbatim from the V1 error
catalog.

Gap: G43

* chore(nix): update pnpm deps hash

* fix(agent-core-v2): retain queued steers when a turn ends cancelled or failed

Align the prompt layer with V1's steer-buffer semantics: buffered steer
input now survives a turn that ends cancelled or failed and is flushed
into the next launched turn by the existing beforeStep hook, instead of
being silently dropped. The turn-result observation in the prompt
service existed only to perform that discard, so it is removed along
with the now-trivial launch wrapper; explicit clear() still discards
the queue.

Gap: G24

* fix(agent-core-v2): remove ask-user background mode

* fix(kap-server): align archived session restore

* chore(lint): fix type-aware lint errors

* chore(agent-core-v2): drop stray doResume debug log

* fix(agent-core-v2): defer prompts and steers while a full compaction is in flight

Align with V1's compaction gating: input arriving while a full compaction
holds the context (and no turn is active) used to launch a turn
immediately, appending assistant output that forced the in-flight
compaction to cancel. The prompt service now buffers such input and
replays it from a new onDidFinishCompaction hook that the compaction
worker runs in a finally, so the buffer drains on completion,
cancellation, and failure alike — the first deferred item launches a
turn and the rest join the steer queue.

The compaction service is resolved lazily instead of constructor-
injected: materializing it during prompt-service construction reorders
loop-hook registration and moves the full-compaction beforeStep hook
ahead of the hooks that let a freshly launched prompt land in context
before the auto-compaction check snapshots history.

Gap: G23

* fix(agent-core-v2): re-inject the goal reminder after full compaction

Align with V1: after a compaction rewrites the context, re-arm the
per-turn context injectors and run them before the compaction is marked
complete, so the first post-compaction request — including a replayed
deferred prompt's — already carries the goal reminder the summary
folded away. The injector service exposes injectAfterCompaction, which
re-arms the new-turn flag and injects immediately; the compaction
worker calls it after the system-prompt refresh and raises the
post-compaction token floor to include the re-injected reminders (the
pre-injection floor stays as the fallback when reinjection throws), so
the nothing-new-since-compaction guard does not re-trigger against a
shape that cannot shrink.

The injector is resolved lazily from the compaction service to keep
loop-hook registration order untouched across the dependency cascade.

Matches V1 verbatim including the existing quirk where an idle manual
compact yields a second reminder copy on the next turn's per-turn
injection; the parity test pins that behavior.

Gap: G14

* test(agent-core-v2): cover goal pause classification for provider errors

Port the missing end-to-end coverage: goal-driven turn failures pause
the goal with the exact per-class reason strings — provider rate limit,
provider connection error, provider authentication error, provider
safety policy block, and model configuration error (including the
forced 'LLM not set' substitution). Failures are driven through a real
turn with a throwing generate stub so the raw-error classification
feeding the pause reason is exercised, not just the mapper.

No source changes: the existing classification already matches the
reference strings verbatim.

Gap: G35

* feat: add progressive tool disclosure

* feat(cli): gate print-mode v2 behind KIMI_MODEL_EXPERIMENT_FLAG

- add KIMI_PRINT_V2_ENV / isPrintV2Enabled so `kimi -p` routes to the
  native agent-core-v2 runner through its own switch
- keep `kimi server run` server-v2 routing on isKimiV2Enabled
  (KIMI_CODE_EXPERIMENTAL_FLAG), decoupling the two
- update print-mode tests and comments to reference the new switch

* feat(cli): add KIMI_MODEL_OUTPUT_FORMAT for print-mode default

- resolve the effective `-p` format via resolveOutputFormat: the
  --output-format flag wins, then KIMI_MODEL_OUTPUT_FORMAT (prompt mode
  only), then text
- ignore the env outside prompt mode and reject invalid values eagerly
  through the friendly validation path
- apply the resolver on both the v1 and v2 print runners

* fix(agent-core-v2): count the goal-creating turn as the first goal turn

Goal parity gap G5: when the model creates or resumes a goal mid-turn,
v1 counts that ordinary turn as goal turn 1 at turn end (with a budget
re-check before the continuation driver takes over) and charges its
remaining step output tokens against the token budget. v2 only flagged
turns whose goal was already active at launch, leaving turnsUsed and
tokensUsed off by one turn in the model-initiated flow. Adopt the live
turn as a goal starter turn on activation: charge its post-creation
step output, count it once at turn end via incrementTurn, and block
instead of launching a continuation when that count exhausts the turn
budget.

* fix(agent-core-v2): remove model-initiated paused status from UpdateGoal

Goal parity gap G6: v1 reserves pausing for the user and runtime — its
UpdateGoal tool only accepts active/complete/blocked and rejects other
statuses with an invalid-status error. v2 still carried a leftover
'paused' enum option, a model pauseGoal branch, and matching tool
description wording from before v1 removed them. Drop the paused
option, port v1's runtime invalid-status guard, and align the tool
description with v1's.

* fix(agent-core-v2): deliver goal outcome prompts through the UpdateGoal tool result

Goal parity gap G7: when the model completes or blocks a goal, v1
returns the outcome prompt (stats plus final-message instructions) as
the UpdateGoal tool result with stopTurn, keys the one-shot final-
message continuation on that terminal tool result, and guards it with
the per-turn step budget so a capped turn ends 'completed' instead of
dying on max steps. v2 still used a pre-change leftover channel: terse
tool outputs plus goal_completion_summary / goal_blocked_reason system
reminders and a last-message-reminder continuation with no step-budget
check. Return the outcome prompts as tool output, drop the reminder
appends and their detection, key the continuation on the terminal
UpdateGoal result observed via the tool executor hook, and mirror v1's
hasStepBudgetRemaining guard. Also closes audit gaps G17 (max-steps
death) and G27 (actor-conditional reminders).

* fix(agent-core-v2): fail UpdateGoal as a tool error when no goal matches

Goal parity gap G8 (with user modification): v1 returns friendly
success-flagged no-op outputs when UpdateGoal targets a missing or
non-active goal, while v2 either let GOAL_NOT_FOUND escape from
resumeGoal or reported false success with stopTurn for complete and
blocked on a non-active goal. Per the user's decision these cases now
return error-flagged tool results in the same shape as the Edit tool's
old-string-not-found failure - v1's message texts ('Goal not resumed:
no current goal.', 'Goal not completed: no active goal.', 'Goal not
blocked: no active goal.') with isError and no stopTurn, so the model
sees a non-fatal failure and the turn continues normally.

* fix(agent-core-v2): settle active goals when the continuation relaunch fails

Goal parity gap P-B: the turn-ended subscriber that relaunches goal
continuation turns discarded every rejection, so a failed launch (for
example losing a race to a queued prompt) stranded the goal in status
active with nothing driving it. Keep the event-driven per-turn
continuation model but settle deterministically on failure: any
rejection out of the turn-ended handling now pauses the active goal as
actor system with reason 'Paused after goal continuation failure:
<message>', emitting the normal goal.updated event; the settle itself
never throws into the event bus. The busy-skip needs no settle: the
turn service clears its active turn before publishing turn.ended, so
the other live turn's own end reliably re-runs the relaunch check.

* fix(agent-core-v2): restore the fork-cleared goal system reminder

Goal parity gap G12: after a session fork, v1 tells the model the fork
has no current goal so it ignores stale active-goal reminders copied
from the source session; v2 cleared the goal silently through the
forked wire op and dropped the reminder. Track the fork boundary in a
derived (never persisted) wire model folded on both dispatch and
replay: a forked record that clears a copied goal marks the reminder
pending, and the post-replay pass appends v1's verbatim reminder text
with origin goal_fork_cleared exactly once - the appended reminder
record acknowledges the pending flag on later replays, so resumes never
duplicate it. Forks of sessions without a goal append nothing. The
forkGoal op itself stays pure.

* fix(agent-core-v2): preserve turn result details (#1531)

* feat(agent-core-v2): align defaultProvider fallback and clear-on-delete

- ModelResolverService falls back to the top-level defaultProvider config
  when a model pins neither providerId/provider nor an inline baseUrl
  (v1 parity).
- ProviderService.delete clears defaultProvider when removing the provider
  it points at, replacing v1's scattered call-site cleanups.
- defaultProvider rides as an unregistered top-level scalar config section,
  mirroring defaultModel (no schema, generic snake/camel passthrough).

* feat(agent-core-v2): synthesize legacy prompt lifecycle events

- emit prompt.completed/aborted/steered on the per-agent IEventBus so the v1-compatible WS edge can forward them (v2 core only emits turn.ended)
- bridge per-agent turn.ended into SessionInteractionService.cancelPendingForTurn via AgentLifecycleService (bus is Agent-scoped, no direct injection)
- extract agent create() helpers: assertCanCreate, buildAgentScopeExtras, igniteEagerServices, bindBootstrap
- finish assistant writer on PromptTranscriptWriter.flushAssistant
- ungate live session status idle->running->idle e2e test (v2 backend pulls real status)

* chore(agent-core-v2): align cron and skill prompts with agent-core

Drop the KIMI_CRON_NO_JITTER / KIMI_CRON_NO_STALE notes from the
CronCreate and CronList descriptions and the MAX_SKILL_QUERY_DEPTH
sentence from the Skill description, matching agent-core (v1) where
these were trimmed from the model-facing text in #1102. Code behavior
is unchanged; the env bypasses and the depth cap still exist in both
implementations. ULID/8-hex wording is left as-is for now.

* chore(agent-core-v2): drop legacy 8-hex mention from cron prompts

CronDelete and CronList descriptions now describe the task id as a ULID
only, removing the "(or legacy 8-hex)" qualifier from the model-facing
text. Code and tests are unchanged.

* chore(agent-core-v2): reorganize tests to mirror src layout

Move every file under test/ so its path mirrors the corresponding file
under src/ one-to-one (agent/, session/, app/, os/, persistence/,
_base/, activity/, wire/), and rename test files to match the basename
of the source file they cover. Test-only infrastructure with no src
counterpart (harness, snapshot, lint, dep-graph, tools/fixtures) stays
at the top level.

Rewrite imports after the move: src references use the #/ alias, while
test-to-test and cross-package relative imports are recomputed for the
new locations. No behavior changes.

* feat(config): add default permission/plan mode and yolo alias

- register `defaultPermissionMode` and `defaultPlanMode` config sections
- apply `defaultPermissionMode` when creating the main agent
- enter plan mode on fresh sessions when `defaultPlanMode` is true
- fold `yolo: true` into `default_permission_mode` on kap-server config write, derive `yolo` on read (yolo stays wire sugar, never a persisted domain)

* feat(agent-core-v2): add background mode to AskUserQuestion

Align with v1: the model can pass `background: true` to get a task_id
immediately while the question waits in the background for the user's
answer; completion is delivered to the agent automatically through the
task service's terminal notification.

- New QuestionBackgroundTask (AgentTask kind 'question') that runs the
  question request on a detached task and settles completed/failed/killed.
- AskUserQuestionTool gains the optional `background` schema field, the
  description suffix, an IAgentTaskService dependency, and a background
  execution branch whose output block matches v1 verbatim.
- Tests: harness injects a task-service stub, two legacy 'no background'
  assertions are flipped to the v1-aligned behavior, and three background
  cases (immediate task_id, settle completed, abort killed) are added.

* fix(agent-core-v2): synthesize default baseUrl for env-model provider

Align with v1: when KIMI_MODEL_NAME is set without KIMI_MODEL_BASE_URL,
the reserved __kimi_env__ provider now gets a per-type default baseUrl
(kimi -> api.moonshot.ai/v1, openai -> api.openai.com/v1; anthropic is
left unset so the SDK picks its default). Previously v2 left baseUrl
empty and later threw "missing a base URL" for the openai env-model
path, regressing v1's out-of-the-box behavior. An explicit
KIMI_MODEL_BASE_URL still wins.

* fix(agent-core-v2): restore UpdateGoal completion/blocked prompts and no-goal fallbacks

Align with v1: completing or blocking a goal now returns the dynamic
summary/blocked-reason prompt (buildGoalCompletionSummaryPrompt /
buildGoalBlockedReasonPrompt, already present in outcome-prompts.ts but
unused) instead of the static "Goal marked complete/blocked." text, and
all three statuses report the "no active/current goal" fallback when
there is nothing to transition.

* feat(agent-core-v2): add [image] config section for image compression

- add media-owned `image` config section (`max_edge_px`, `read_byte_budget`)
  with `KIMI_IMAGE_MAX_EDGE_PX` / `KIMI_IMAGE_READ_BYTE_BUDGET` env bindings
  (env > config.toml > default)
- add Agent-scope `ImageConfigBridge` that pushes the env-resolved section
  into the image-compress resolver seam on load and on change, so all call
  sites honor config without per-call wiring
- resolve `maxEdge` / read-byte-budget defaults in image-compress via the new
  seam; keep the support module config-agnostic
- apply the read-image byte budget in ReadMediaFile's default downscale path
  (previously fell back to the 3.75 MB provider ceiling)

* fix(agent-core-v2): align image compression defaults with v1 (#1508)

Port v1 #1508 into v2: lower the longest-edge downscale cap back to
2000px (v2 was stuck on the 3000px it had ported from an earlier v1
change) and make it overridable, add the 256 KB read-image byte budget
used by ReadMediaFile, and widen the over-budget fallback ladder to
[2000, 1000, 768, 512, 384, 256].

- MAX_IMAGE_EDGE_PX 3000 -> 2000, with KIMI_IMAGE_MAX_EDGE_PX env and a
  config-pushed value resolved via resolveMaxImageEdgePx.
- READ_IMAGE_BYTE_BUDGET=256KB with KIMI_IMAGE_READ_BYTE_BUDGET env and
  resolveReadImageByteBudget; ReadMediaFile's default compress path now
  uses it (region / full_resolution still honor IMAGE_BYTE_BUDGET).
- Test expectations that hard-coded 3000px / 1500px updated to 2000 /
  1000 to match the v1 behavior.

The [image] config.toml section is intentionally not added: the env
vars already cover the override path, and wiring a config section plus
a runtime push would add v2-specific scaffolding beyond v1.

* fix(agent-core-v2): restore HEIC/HEIF conversion guidance in ReadMediaFile

Align with v1: a HEIC/HEIF read is now refused up front with an
os-specific conversion command (sips / heif-convert / ImageMagick) so the
unsupported format never reaches the provider (which would reject the
whole session once it lands in history). The two guidance builders are
ported verbatim from v1, and the check sits after the image-capability
guard (using IHostEnvironment.osKind).

Also give the existing EXIF-rotation test a longer timeout: it does
heavy jimp encode/decode and was flaking around the default 5s boundary.

* feat(agent-core-v2): wire startBtw into the agent RPC API

Align with v1: expose `startBtw` on AgentAPI and delegate it to the
existing ISessionBtwService (already implemented and DI-registered as
SessionBtwService), so the /btw slash command can fork a side-question
child agent once the server runs on v2.

* chore(agent-core-v2): tidy misplaced and throwaway test files

- Remove resume-debug.test.ts: a one-off diagnosis script with a
  hardcoded local path, not a real test.
- Move streamTiming.test.ts to app/model/modelImpl.test.ts: it only
  exercises buildStreamTiming in app/model/modelImpl.ts.
- Rename wire/store.test.ts to wire/wireServiceImpl.test.ts to match the
  module it covers (there is no store.ts in src).

* fix(agent-core-v2): restore JSON Schema format validation in tool-args

Align with v1: replace the hand-rolled subset validator with v1's Ajv-
based implementation (draft-07/2019/2020 + ajv-formats), so tool-call
argument validation once again honors the JSON Schema `format` keyword
(and the full keyword set), not just the previously hard-coded subset.

- args-validator.ts is now byte-identical to v1 (93 lines, replacing the
  289-line hand-rolled subset).
- Adds ajv@^8.18.0 and ajv-formats@^3.0.1 (same versions as v1) plus the
  pnpm-lock.yaml update.
- The two call sites (compileToolArgsValidator -> validateToolArgs) keep
  working unchanged; a small test locks in format / required /
  additionalProperties / subset behavior.

* fix(agent-core-v2): restore JSON Schema format validation in tool-args

Align with v1: replace the hand-rolled subset validator with v1's Ajv-
based implementation (draft-07/2019/2020 + ajv-formats), so tool-call
argument validation once again honors the JSON Schema `format` keyword
(and the full keyword set), not just the previously hard-coded subset.

- args-validator.ts is now byte-identical to v1 (93 lines, replacing the
  289-line hand-rolled subset).
- Adds ajv@^8.18.0 and ajv-formats@^3.0.1 (same versions as v1) plus the
  pnpm-lock.yaml update.
- The two call sites (compileToolArgsValidator -> validateToolArgs) keep
  working unchanged; a small test locks in format / required /
  additionalProperties / subset behavior.

* chore(agent-core-v2): drop legacy 8-hex mention from CronDelete/CronList tool source

Match the prompt change in 5cc8e520f: the CronDelete parameter
description and invalid-id error now say "ULID" only (not
"ULID or legacy 8-hex"), and the CronList id doc comment likewise.
The validation regex is unchanged so loading any legacy 8-hex tasks
from disk still works.

* chore(agent-core-v2): remove resume-roundtrip test

It round-tripped restore over a hardcoded local dataset
(kimi-code-mini-bench/.vitest-results) that is not in the repo, so it
vacuously passed everywhere else. Removed at the original author's
request.

* test(agent-core-v2): raise timeout for slow image-compress invariant test

The fuzz-style invariant test now drives the full v1 fallback ladder
([2000, 1000, 768, 512, 384, 256]) for over-budget inputs, which takes
longer than the default 5s boundary in this environment. Give it 30s.

* chore(agent-core-v2): rename ambiguous test files

- agent/task/manager.test.ts -> taskManager.test.ts (no manager.ts in
  agent/task; disambiguate from taskService.test.ts).
- app/cron/persist.test.ts -> cronTaskPersistenceService.test.ts (mirrors
  the module it covers; agent/task/persist.test.ts mirrors
  agent/task/persist.ts, so it is left as-is).
- agent/contextMemory/message.test.ts -> message-history.test.ts (its
  describe is 'message history (IAgentContextMemoryService)'; the dir
  already names the domain).

* fix(agent-core-v2): preserve usage for aborted steps

* fix(agent-core-v2): resume-safe session reads and in-memory transcript

- sessionLifecycle: get/list no longer return a session whose cold resume is
  still in flight, so callers never observe a half-initialized handle; resume
  remains the way to await a fully restored handle
- messageLegacy: reduce the transcript from the main agent's in-memory wire
  journal instead of re-reading wire.jsonl; AgentWireRecordService now keeps
  the journal current with live dispatch so cold and live sessions both read a
  consistent, full transcript

* chore(agent-core-v2): drop stale micro-compaction references in comments

micro-compaction only exists in legacy agent-core; v2 has no such mechanism. Update two comments that still cited it:

- fullCompactionService: the real reason not to project here is that llmRequester already projects once.

- swarmService: context.spliced consumers no longer include micro-compaction bookkeeping.

* fix(agent-core-v2): report skill discovery diagnostics

* test(agent-core-v2): align plan mode parity fixtures

* fix: distinguish task output timeout from cancellation

* chore: fix test name

* fix(agent-core-v2): flush the wire persist queue before the record log

Since d5e1d76fc every wire append rides the async persist queue whenever
a blob service is registered, but AgentWireRecordService.flush() only
awaited the log store. Callers - including the session-close path -
could complete a flush while records were still in flight on the queue,
and record-order assertions in tests raced it. Await the wire service
flush, which drains the persist queue, before flushing the log.

* test(agent-core-v2): align the goal reminder boundary test with the continuation driver

The per-turn-boundary reminder test predates the goal continuation
driver and never passed: its second explicit prompt raced the
auto-launched continuation turn, which correctly holds the turn lane.
Treat the continuation turn as the second boundary and end it
deterministically by completing the goal through UpdateGoal, keeping
the once-per-boundary (never per-step) assertion.

* fix(agent-core-v2): stop goal turns gracefully when a hard budget is exhausted

Previously a goal whose hard budget was reached mid-turn was only
flipped to blocked while the turn kept running unbounded: the loop
continues unconditionally after a tool-calls step, and steer flushes or
Stop hooks could extend the turn indefinitely past the budget.

Now, when the over-budget step requested tool calls, a goal_budget_stop
system reminder is appended after the tool results telling the model the
goal is blocked (resumable via /goal resume), to stop immediately, that
further tool calls will be rejected, and to write a brief final status
message. The model gets exactly one grace step, during which tool calls
are answered with a soft rejection instead of executing. After the grace
step - or when the over-budget step had no pending tool calls - a new
AfterStepContext.stopTurn flag ends the turn, honored by the loop with
precedence over tool_calls and hook-set continue so nothing can extend
past the stop. The grace grant also respects maxStepsPerTurn so it can
never turn a budget stop into a max-steps turn failure.

Turn launch is gated as well: a prompt arriving while the active goal is
already over budget (e.g. after resuming an exhausted goal) blocks the
goal before the turn is marked goal-driven, so turnsUsed no longer
drifts, no spurious goal_continued telemetry fires, and the prompt runs
as an ordinary turn with the blocked-goal note injected.

This deliberately diverges from agent-core v1, which hard-stops with
zero grace and answers a prompt on an exhausted goal with a synthetic
model-less turn: budget overshoot is now bounded at one closing step in
exchange for consumed tool results and a user-facing wrap-up message.

* fix(agent-loop): stop looping on bare tool_calls signal

- remap tool_calls finishReason to 'other' when the provider emitted no tool call structure
- prevents re-issuing the model call until maxSteps on a bare tool_calls signal
- add loop test covering the v1 'unknown' turn-lifecycle behavior

* fix(kap-server): emit legacy background.task.* alias on /api/v1 ws

The v2 engine emits background-task lifecycle as `task.started` /
`task.terminated`, but v1 consumers (kimi-code TUI / `kimi -p`, node-sdk)
only handle `background.task.*` and silently dropped every task event when
talking to server-v2, while kimi-web handles the native spelling and has the
legacy one registered as known-but-unhandled.

Fan the legacy spelling out next to the native event in
SessionEventBroadcaster, reusing the same volatility so replay, journal and
the per-agent filter stay coherent between the two. kimi-web keeps the native
event and ignores the alias; the native /api/v2 stream is left unchanged.

Add a SessionEventBroadcaster test asserting both spellings are emitted.

* feat(agent-core-v2): port /init command from v1

- add Session-scope ISessionInitService that spawns the coder subagent,
  mirrors the run onto the main agent, reloads AGENTS.md and appends an
  init-variant system reminder, then flushes records
- add SESSION_INIT_FAILED error code and register the sessionInit domain in
  the layer map, package index and DI dependency graph
- drop the stale "flat (no subdirectories)" rule from the agent-core-dev skill

* feat(api-v2): add klient SDK and reflection-based channel registry

- replace per-method actionMap (resource:action) with a channel registry: each Service registers once by decorator id and all methods are invoked by reflection
- routes move from /api/v2/:sa to /api/v2/:service/:method across HTTP routes and the WS protocol
- add @moonshot-ai/klient: typed core/session/agent client over the HTTP channel that reuses agent-core-v2 service interfaces
- register klient in flake.nix and pnpm-lock; refresh kap-server tests, e2e, and the apiSurface snapshot

* refactor(agent-core-v2): drive turns by draining a StepRequest queue

- add StepRequest / StepRequestQueue: the loop drains one batch per step,
  folding mergeable requests (steers) into the driver's step; a step that
  ran tools enqueues a ContinuationStepRequest, a plain message enqueues
  none, so the turn completes when the queue empties
- replace AfterStepContext.continue with explicit enqueue; a failed step is
  retried by head-inserting its driver request
- move prompt's private steer queue onto the loop via PromptStepRequest /
  SteerStepRequest / RetryStepRequest, which materialize their context
  messages at pop time (image-compression captions reroute to reminders
  on materialization)
- goal and externalHooks orchestrate continuations by enqueueing requests
  instead of setting ctx.continue; a request's message only lands when the
  loop pops it, so skipped or aborted launches leave no orphan messages
- remove the now-unused CancellationError
- delete the reworked turn tests (turn.test.ts, turn-ready.test.ts) and the
  cron test suites

* refactor(agent-core-v2): translate provider errors at the model boundary

- add translateProviderError in app/protocol/errors and apply it once in
  ModelImpl.request: raw provider failures become coded KimiErrors with the
  raw error preserved as cause and HTTP fields in details; abort shapes pass
  through untouched
- move provider-error mapping out of _base/errors/serialize so _base no
  longer imports llmProtocol; toErrorPayload/fromErrorPayload now round-trip
  cause chains recursively, capped at depth 8
- move context.overflow from LoopErrors to ProtocolErrors (wire code
  unchanged); move LoopError and the max-steps helpers into loop/loop.ts
- consolidate isAbortError into _base/utils/abort, dropping the duplicates in
  retry, cloudTransport, and the question/subagent task tools
- add unwrapErrorCause; classify retryability and HTTP status on the
  unwrapped cause in llmRequester and full-compaction
- align task-notification tests with enqueue-only delivery: drain the loop
  queue with one turn and assert on task.notified instead of prompt steer
- protocol: add recursive cause to KimiErrorPayload with a lazy zod schema

* docs(agent-core-dev): add commit-align workflow, drop DI dependency map

- add commit-align.md subskill: triage one main-branch commit against v2
  (aligned / partial / missing / not-applicable) and link it from SKILL.md
- delete docs/di-scope-domains.puml and the rendered svg, and drop the
  keep-the-map-in-sync requirement from verify.md, align.md, commit-align.md,
  and packages/agent-core-v2/AGENTS.md

* refactor(agent-core-v2): move turn lifecycle into agent loop

- make loop admission, cancellation, and completion own turn execution
- extract step retry into a loop error recovery service
- preserve failed-step context and expose retry delay events

* refactor(agent-core-v2): centralize loop turn scheduling

- add queued turn and step lifecycle handles with explicit admission modes
- move continuation and retry scheduling behind the loop service
- consolidate legacy prompt scheduling into the prompt domain
- align kap-server routes and tests with the new loop contract

* feat(klient): add WebSocket transport for calls and event streams

- add WsSocket: persistent /api/v2/ws transport with hello handshake,
  heartbeat answers, per-call timeouts, and auto-reconnect that
  re-subscribes active listens; bearer token rides the
  kimi-code.bearer.<token> subprotocol for browser compatibility
- add WsKlient / WsChannel exposing core/session/agent scopes and
  listen(event, handler) over the shared socket
- add Klient#ws() lazy singleton with WebSocketImpl injection
- bind global fetch in HttpChannel to avoid "Illegal invocation" in browsers

* refactor(agent-core-v2): unify hook names to on{Will,Did}Xxx convention

- loop: beforeStep -> onWillBeginStep, afterStep -> onDidFinishStep
- toolExecutor: onWillExecuteTool -> onBeforeExecuteTool (ToolWillExecuteContext -> ToolBeforeExecuteContext)
- prompt: onWillSubmitPrompt -> onBeforeSubmitPrompt
- permissionMode: onChanged -> onDidChangeMode
- wireRecord: onRestoredRecord -> onDidRestoreRecord, onResumeEnded -> onDidFinishResume
- terminal: onData -> onProcessData, onExit -> onProcessExit

* feat(kap-server): synchronously refresh all providers before listing models

- GET /api/v1/models now awaits refreshProviderModels({ scope: 'all' })
  before returning the model list, so the response always reflects the
  latest provider model metadata
- refresh failures are logged and swallowed, falling back to the
  persisted catalog instead of failing the request

* refactor(agent-core-v2): convert one-way notification hooks to Events

Replace fire-and-forget OrderedHookSlot hooks with Emitter/Event-based
notifications for consumers that only observe, never intercept:

- usage: hooks.onDidRecord -> onDidRecord event
- permissionMode: hooks.onDidChangeMode -> onDidChangeMode event
- fullCompaction: hooks.onDidFinishCompaction -> onDidFinishCompaction event
- wireRecord: hooks.onDidFinishResume -> onDidFinishResume event
- agentLifecycle: hooks.onDidStopAgentTask -> onDidStopAgentTask event,
  announced via new notifyAgentTaskStopped() called by mirrorAgentRun

Interception-capable slots (onWillStartAgentTask, onWillCompact,
onDidRestoreRecord) stay as ordered hooks.

* fix(agent-core-v2): route FetchURL through the Moonshot fetch service when logged in

When the managed Kimi provider has an oauth ref, WebFetchService builds a MoonshotFetchURLProvider (bearer token + host identity headers) with the local fetcher as fallback, re-reading login state on every call; logged-out setups keep the local fetcher.

* fix(agent-core-v2): forward host identity headers with WebSearch requests

WebSearchProviderService now passes the host's IHostRequestHeaders (User-Agent + X-Msh-* device identity) as default headers to the Moonshot search provider, mirroring v1's kimiRequestHeaders.

* fix(cli): seed host identity headers into the experimental v2 server

The v2 boot path (kimi server run with the experimental flag) now seeds the CLI's Kimi identity headers (User-Agent + X-Msh-* device identity) into the engine through kap-server's seeds option, so outbound model, WebSearch, and FetchURL requests carry the same identity as direct CLI runs. kap-server's own package version is 0.0.0, so the identity has to come from the CLI.

* feat: validate workspace roots and auto-launch task notification turns

- task: idle terminal notifications now use activeOrNewTurn admission,
  launching their own turn instead of waiting for the next user prompt
  (matches v1 turn.steer)
- workspaceRegistry: createOrTouch rejects missing or non-directory roots
  with fs.path_not_found, so a phantom cwd never reaches session creation
- kap-server: map FS_PATH_NOT_FOUND to protocol error 40409 on the session
  and RPC surfaces
- server-e2e: migrate the v2 smoke test from the local ServerClient to the
  typed Klient
- misc: switch loop/prompt clear() iteration to .slice(), align terminal
  event handler names, and tidy klient examples

* fix(kap-server): emit idle/aborted session status on turn end

The v1 WS broadcaster only re-emitted event.session.status_changed(running)
on turn.started and never emitted the idle/aborted transition on turn.ended.
kimi-web treats that event as the single source of session status (its
turn.ended projector deliberately does not synthesize idle), so a session
stuck at 'running' after the turn finished — most visibly for background
tasks, where ISessionActivity keeps reporting non-idle while the detached
task lives and even a REST pull never corrected it.

Re-emit event.session.status_changed after turn.ended on the same dispatch
queue, mapping reason cancelled/failed/blocked to aborted and otherwise
idle (previous_status 'running'), matching v1's _computeStatus. Update the
broadcaster tests and harden the wsV1Resync test helper so non-matching
frames no longer strand a waiter's timeout.

* feat(ws): add Service event streaming with waitUntil handshake

- listen messages accept a service name; kap-server resolves the Service
  via resolveService and subscribes through its onUpperCase member
- add listen_result acknowledgement and per-listen error reporting
  (onDidListenError) so failed subscriptions surface to the client
- support onWill-style events: payload carries eventId/signal/waitUntil,
  the client replies with event_result and the server can event_cancel
- klient proxy maps onUpperCase members to channel.listen; WsChannel
  shares one remote subscription across first/last listeners
- channel.call now forwards the complete argument array

* feat(agent-core-v2): add typed telemetry event registry

- register business telemetry events with compile-time property contracts
- redact sensitive values and reject invalid cloud properties
- centralize cloud appender construction and version context

* feat(kap-server): add channel introspection endpoint

- add describeChannels() to channelRegistry: scope derived from the scoped
  DI registry, public methods/getters enumerated from the prototype chain
  (framework plumbing and events excluded)
- export ChannelDescriptor / ChannelMethodDescriptor from the contract
- serve GET /api/v2/channels so clients (kimi-inspect) can render a
  dynamic service browser without handwritten method lists
- cover with rpc test, e2e channel registry test, and API surface snapshot

* feat(kap-server): expose declared parameter names in channel descriptors

- add `params` field to ChannelMethodDescriptor, parsed from function source
- extract declared parameter list via Function#toString with paren-depth tracking
- cover param introspection in rpc and server-e2e channel registry tests

* fix: adapt v2 print runner and tests to enqueue prompt API

- run-v2-print: drive turns via IAgentPromptService.enqueue() and
  handle.launched; detect hook-blocked prompts via handle.completion;
  read the LoopRunResult type discriminator in formatNativeTurnFailure
- bootstrap stubs: add clientVersion required by IBootstrapService
- v2-run-print test: mock enqueue, stub IBootstrapService for
  createCloudAppender, add track2 to the telemetry stub
- node-sdk test: cover prompt.completed/aborted/steered in the
  exhaustive event switch

* feat(agent-core-v2): introduce graded error taxonomy for os, storage, and wire layers

- add `os.fs.*` codes with `HostFsError` and the `toHostFsError` boundary translator
- add `os.process.*`, `storage.*`, and `wire.*` domains with coded error classes
- register new codes in the protocol `KimiErrorCode` union
- translate raw OS and parse failures into domain codes across services, persistence backends, and kap-server transport
- rename `KimiError` to `Error2` in the v2 base errors
- remove obsolete kimi-csdk init example

* test(agent-core-v2): wait for MCP connectAll instead of a fixed tick

The MCP initial-connect assertion used a single setTimeout(0) tick, but
connectAll is gated on Promise.all([resolveSessionMcpConfig(...),
enabledMcpServers()]); the session-config side walks the real filesystem
(project-root search + mcp.json reads), which does not settle within one
macrotask under CI load. Use vi.waitFor so the test is robust on CI.

* fix(minidb): publish WAL value pointers only after the frame is durable

In valueMode 'disk' the write path installed a disk ValueLoc using the
predicted WAL offset before the frame's bytes were flushed (appendLoc
returns the offset synchronously; the writev lands on a later tick). Under
load a concurrent compaction snapshot could read a pointer past the WAL end
and fail with a short read. Apply the record as an in-memory ref first and
only publish the disk pointer after appended.done resolves, guarded against
WAL rotation and stale record seqs.

* fix(kap-server): report missing agents as agent.not_found

- resolveScope now throws Error2 for missing session/agent instead of
  returning undefined, distinguishing agent.not_found from session.not_found
- map AGENT_NOT_FOUND onto the session-not-found protocol envelope for v1
  parity

* refactor(cli): gate print-mode v2 on KIMI_CODE_EXPERIMENTAL_FLAG

- remove KIMI_PRINT_V2_ENV / isPrintV2Enabled and the dedicated
  KIMI_CODE_EXPERIMENT_FLAG switch
- route `kimi -p` to the agent-core-v2 runner via isKimiV2Enabled,
  the same master switch that gates server-v2

* fix(agent-core-v2): map hostFs/storage error codes at server boundaries

- unwrap the HostFsError cause before matching EISDIR in FileEditService,
  restoring the "is not a file" edit output broken by the error taxonomy
- map os.fs.* codes to the closest v1 wire codes in the kap-server fs
  route and /api/v2 transport instead of collapsing to INTERNAL_ERROR
- map storage.io_failed / storage.locked to PERSISTENCE_FAILURE in the
  /api/v2 transport

* fix(agent-core-v2): serialize config writes and reloads

A User-target set/replace mutates raw/rawSnake, awaits persist(), then
rebuilds effective, while a reload() replaces all three wholesale from disk.
Without serialization a reload whose file read resolves inside a write's
persist window (before the atomic rename lands) restores the stale pre-write
state, so the write's post-persist rebuild drops the just-written domain from
effective. This surfaced as POST /config responses missing the field they had
just written when the startup model-catalog refresh's reload() raced the
write. Run User-target writes and reloads through a promise chain so they can
no longer interleave.

* feat(agent-core-v2): align telemetry with the v1 wire format

- rename tool_call_dedupe_detected to tool_call_dedup_detected
- emit turn_ended on every turn end; add mode/provider/protocol tags
  and interrupt_reason to turn_started/turn_interrupted
- enrich api_error with alias, protocol tags, and input_tokens
- tag tool_call with dup_type via an executor-side map (avoids the
  executor/dedupe DI cycle)
- rename compaction usage fields to input_tokens/output_tokens
- add context_projection_repaired, session_started, and
  session_load_failed events

* feat(agent-core-v2): add lifecycle transition machine

- add guarded synchronous and asynchronous state transitions
- support commit, rollback, cleanup, and compensation actions
- cover transition conflicts, action ordering, and failure aggregation

* fix(agent-core-v2): harden plugin load, install, and update check paths

- Degrade plugin consumption reads to empty when installed.json fails to
  load, surface plugin.load_failed with a repair hint on management calls,
  and recover after an explicit reload; serialize the initial load and
  mutations so concurrent first callers share one load.
- Clean up zip temp dirs on every failure path, report the original
  source in zip/github manifest errors, and roll back to the previous
  managed copy when an install or persist fails.
- Restore managed Kimi endpoint env injection for stdio plugin MCP
  servers.
- Check plugin updates concurrently with per-repo failure isolation and
  10s timeouts, track branch installs by commit SHA, and stop false
  update reports for tag/SHA pins.
- Throw plugin.not_found from getPluginInfo and the manager's
  not-installed paths.
- Count plugin skills through the real skill discovery path.
- Re-sync context injection positions after silent wire replay so cold
  resumes do not duplicate injections, and fire plugin session-start
  reminders only when the plugin skill source finishes refreshing.

* fix(agent-core-v2): use the v2 coded error type for plugins

* fix(cli): align print session with background completion API

* fix(kap-server): stabilize catalog and session status updates

- keep model catalog reads free of provider refresh side effects
- broadcast deduplicated session lifecycle and interaction statuses
- cover catalog loops and global session status fan-out

* test: stabilize CI integration cleanup

---------

Co-authored-by: _Kerman <kermanx@qq.com>
Co-authored-by: 7Sageer <7sageer@djwcb.cn>
Co-authored-by: qer <wbxl2000@outlook.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Kaiyi <me@kaiyi.cool>
Co-authored-by: Luyu Cheng <2239547+chengluyu@users.noreply.github.com>
Co-authored-by: STAR-QUAKE <99738745+starquakee@users.noreply.github.com>
Co-authored-by: fengchenchen <fengchenchen@moonshot.ai>
Co-authored-by: liruifengv <liruifeng1024@gmail.com>
2026-07-12 21:44:04 +08:00
qer
f3dd006ab4
docs: update subagent timeout docs for configurable timeout_ms (#1582) 2026-07-12 21:09:17 +08:00
qer
febe030244
docs(changelog): sync 0.23.6 from apps/kimi-code/CHANGELOG.md (#1581) 2026-07-12 21:06:40 +08:00
github-actions[bot]
b5c236d00f
ci: release packages (#1546)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-12 20:28:38 +08:00
qer
47cd5f14c5
chore: downgrade print-goal-cron-turns changeset to patch (#1578) 2026-07-12 20:25:24 +08:00
qer
6fc1deb453
fix(web): wide markdown tables scroll internally and break out on desktop (#1577)
* fix(web): scroll wide markdown tables inside their own wrapper

* feat(web): break wide markdown tables out of the reading column

* fix(web): sample the full TOC rail for wide-table occlusion

* refactor(web): use rect overlap for the TOC occlusion check
2026-07-12 20:08:57 +08:00
qer
b1942bd571
fix(web): keep the connecting splash and retry the first-load auth check (#1574)
* fix(web): retry the first-load auth check behind the connecting splash

* fix(web): fall back instead of retrying deterministic 4xx auth-check failures

* fix(web): show the connection error on the splash while retrying

* fix(web): keep the first quick auth-check failure silent on the splash

* chore: remove accidentally committed dist-web symlink

* fix(web): hold onboarding until first load settles; drop unsupported 4xx auth fallback
2026-07-12 19:54:21 +08:00
qer
3a7aad653f
fix(web): finish local prompt state from session snapshot after a reconnect (#1572) 2026-07-12 19:01:30 +08:00
qer
9d96b538bf
fix(web): scroll wide markdown tables inside their own wrapper (#1575) 2026-07-12 18:52:26 +08:00
qer
5a208cb041
fix(web): auto-enable default thinking effort when switching to an effort-capable model (#1475)
Some checks are pending
CI / typecheck (push) Waiting to run
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
* fix(web): auto-enable default thinking effort when switching to an effort-capable model

* chore: add changeset

* fix(web): preserve thinking off when reselecting current model
2026-07-12 18:44:39 +08:00
qer
f901b9e1da
fix(web): persist server access token across tabs and browser restarts (#1567)
* fix(web): persist server access token across tabs and browser restarts

The web UI kept the server bearer credential in sessionStorage, which is
tab-scoped and cleared on tab close, so users had to re-enter the token
for every new tab and after every mobile tab eviction. Mirror it to
localStorage instead: the token already lives on disk at
<KIMI_CODE_HOME>/server.token and rides in the launch URL fragment, so
browser-profile persistence does not materially widen exposure for this
local tool. Existing sessionStorage copies are migrated on first boot,
and a 401 (e.g. after `kimi server rotate-token`) still clears the
stored credential.

* fix(web): avoid clearing a fresh shared token from a stale tab

localStorage is shared across tabs, so the unconditional removal let a
tab holding a stale in-memory credential erase a newer token another
tab had just persisted (e.g. after `kimi server rotate-token` one tab
stores the fresh fragment token, then an older tab's delayed 401 wiped
it). Only clear the persisted copy when it still matches the rejected
credential this tab was using.

* test(web): fix type-aware lint findings in server-auth tests

Drop return-await on the dynamic import, brace the void arrow in the
toThrow assertion, and remove a redundant String() conversion — CI runs
oxlint --type-aware, which flags these where the plain local run does
not.

* fix(web): expire persisted server credentials after 7 days

* fix(web): clear legacy session token even when localStorage is blocked

setCredential ran persistCredential and the legacy sessionStorage
cleanup in one try, so a localStorage.setItem failure (private mode,
quota) skipped the cleanup: a stale session-scoped credential left
behind was re-migrated on the next reload and 401'd into another token
prompt. Split the session cleanup into its own best-effort block.
2026-07-12 18:36:55 +08:00
qer
1d3dba5683
fix(web): match current model by id in model picker dropdown (#1565)
Model names and display names can collide across providers, so the
composer dropdown's checkmark matched by name and lit up every
same-named entry. Resolve the current model through its unique id
everywhere (dropdown check, thinking controls, status resolution).
2026-07-12 14:47:47 +08:00
qer
d2c2c33f3e
feat(web): type absolute paths directly in the workspace picker (#1556)
* feat(web): type absolute paths directly in the workspace picker

The add-workspace dialog's fuzzy-search box now doubles as an absolute
path entry: input starting with "/" or "~" is validated live (missing
path / missing parent get specific errors plus prefix-matched
candidates), and a valid path live-follows the folder browser so the
existing "Open this folder" button submits it. Enter accepts the first
candidate or opens a valid path; Esc clears the box. The collapsed
paste-path section at the bottom is removed, and the degraded
(no-browse) mode reuses the same box with format-only validation.

* fix(web): recognize Windows absolute paths and gate Open button in path mode

Two review fixes on the workspace picker's path entry:

- PATH_LIKE now also matches Windows drive (C:\x, C:/x) and UNC
  (\\srv\x) forms, matching node:path.isAbsolute on the daemon side.
  Without this, Windows users in degraded (no-browse) mode had no way to
  submit a path at all. Parent-dir splitting and trailing-separator
  trimming are now separator-aware (drive roots are preserved).
- "Open this folder" is disabled while path mode has no validated target.
  Previously, after typing a valid prefix and then an invalid path, the
  button still submitted the stale followed prefix.

* fix(web): submit the typed lexical root when adding a workspace in path mode

fs:browse canonicalizes via realpath, but workspace/session ids are based
on the lexical root. For a symlinked cwd (/tmp/project -> /private/tmp/
project on macOS), live-follow stored the resolved target in currentPath
and "Open this folder" emitted it, so sessions under the typed cwd would
not group under the workspace. Keep the typed normalized path for the add
action and use the browse result only to populate the visible browser.

* fix(web): handle workspace path input edge cases
2026-07-12 14:35:25 +08:00
Haozhe
cc03816ee0
feat(oauth): parse support_efforts/default_effort in custom registry import (#1564)
* feat(oauth): parse support_efforts/default_effort in custom registry import

- parse support_efforts / default_effort from api.json model entries
- map them onto model aliases as supportEfforts / defaultEffort
- treat both fields as upstream-owned in CUSTOM_REGISTRY_MODEL_FIELDS so
  refreshes sync and stale values are dropped
2026-07-12 13:27:50 +08:00
qer
c98238699c
fix(web): avoid repeated session list scans in sidebar computeds (#1563) 2026-07-12 11:51:55 +08:00
Haozhe
faefad0e29
feat(agent-core): configurable subagent timeout with 2h default (#1562)
* feat(agent-core): make subagent timeout configurable and raise default to 2h

- add `[subagent] timeout_ms` config (env `KIMI_SUBAGENT_TIMEOUT_MS` overrides) to replace the hardcoded 30-minute cap for Agent / AgentSwarm subagents
- raise the default subagent timeout from 30 minutes to 2 hours
- thread the value through tool construction so foreground and background subagents use it, with the timeout message reflecting the effective value
2026-07-12 11:48:06 +08:00
qer
264525eb51
fix(web): prevent chat scroll yank while browsing history (#1553)
* fix(web): preserve scroll position while loading older messages

* fix(web): prevent chat scroll yank while browsing history

* fix(web): stop stale auto-follow writes

* fix(web): anchor long tool histories

* fix(web): stabilize scroll anchor cancellation
2026-07-12 11:35:58 +08:00
Haozhe
c6e02daf42
feat(background): add print_background_mode steer for multi-turn -p runs (#1497)
* feat(background): add print_background_mode with steer for multi-turn -p runs

- add `[background].print_background_mode` (`exit`/`drain`/`steer`) and
  `print_max_turns`; when unset, falls back to `keep_alive_on_exit = true`
  mapping to `drain`, preserving existing behavior
- core: `Session.handlePrintMainTurnCompleted()` returns `finish`/`continue`;
  in `steer` mode the run stays alive so a background-task completion
  `turn.steer`s the main agent into a new turn (matching background
  subagents), bounded by `print_wait_ceiling_s` and `print_max_turns`
- cli: print driver follows every main turn instead of only the first and
  defers `finish()` until the run quiesces or a limit is hit
- plumb `handlePrintMainTurnCompleted` through node-sdk RPC; docs + tests
2026-07-12 10:47:35 +08:00
Haozhe
2f97917bb5
feat(cli): keep kimi -p running while a goal is active or cron tasks are pending (#1555)
Some checks are pending
CI / typecheck (push) Waiting to run
CI / lint (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
A kimi -p run settled the moment the main agent's turn ended (end_turn),
so a goal created mid-run was cancelled during cleanup and a scheduled
cron task never fired in the same run.

- runPromptTurn now re-evaluates completion when the main agent goes idle
  and stays alive while a goal is still active (the goal driver runs the
  continuation turns) or while cron tasks with a future fire remain (their
  fire steers a fresh turn). A ref'd handle keeps the event loop alive
  during the wait since the cron scheduler tick is unref'd.
- a terminal goal.updated (e.g. the driver blocking a goal on a hard
  budget, which emits no further turn.ended) also re-evaluates so the run
  cannot hang.
- add getCronTasks RPC and Session.getCronTasks() so the print flow can
  enumerate pending cron tasks.
2026-07-11 21:16:27 +08:00
qer
37bb4b870e
fix(web): keep ReadMediaFile media rendering after session resume (#1552)
Tool-role messages reached the snapshot/messages REST projection with
their content flattened to text, dropping image/video/audio parts, so a
ReadMediaFile result rendered as an image while streaming but fell back
to a generic tool card after a reload. Pass the raw content parts
through when a tool result carries media, matching the live tool.result
event shape the web client already parses.
2026-07-11 21:11:29 +08:00
qer
f17a6ecb52
fix(agent-core): treat dismissed AskUserQuestion as no answer, not recommended pick (#1550)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
Release / Release (push) Waiting to run
2026-07-11 16:01:13 +08:00
qer
19c5aa64eb
fix: update the WebBridge install link in the /plugins panel (#1547)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
Release / Desktop release artifact (push) Blocked by required conditions
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
* fix: update the WebBridge install link in the /plugins panel

* fix: drop the zh-cn segment from the WebBridge link
2026-07-10 23:33:34 +08:00
qer
c2f27b2a22
docs(changelog): sync 0.23.5 from apps/kimi-code/CHANGELOG.md (#1545)
* docs(changelog): sync 0.23.5 from apps/kimi-code/CHANGELOG.md

* chore: update config model doc

* docs: update config-files example with new models and services

---------

Co-authored-by: liruifengv <liruifeng1024@gmail.com>
2026-07-10 22:24:19 +08:00
STAR-QUAKE
7bd29ab011
refactor(kosong): rename select_tools capability to dynamically_loaded_tools (#1488)
* refactor(kosong): rename select_tools capability to dynamically_loaded_tools

Rename the `ModelCapability` bit from `select_tools` to `dynamically_loaded_tools` everywhere it is declared, detected, catalogued, and forwarded: kosong `ModelCapability`/catalog, agent-core capability resolution and the `toolSelectEnabled` gate, the SDK catalog-to-alias mapping, and the built-in catalog pruner's keep list.

The old `select_tools` spelling is removed outright rather than kept as an alias — no catalogued model or shipped configuration used the capability, so there is nothing to migrate. Client-side vocabulary (the `select_tools` builtin tool and the `tool-select` experimental flag) is intentionally untouched.

* chore: shorten changeset description

---------

Co-authored-by: fengchenchen <fengchenchen@moonshot.ai>
2026-07-10 22:12:54 +08:00
github-actions[bot]
352a449240
ci: release packages (#1533)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-10 21:44:24 +08:00
qer
f80b2eaf04
fix(kimi-web): single-source session status to stop duplicate turn-end notifications (#1542)
The web client received two sessionStatusChanged events per turn
transition: one projected client-side from the raw turn.started/turn.ended
stream, one mapped from the daemon's event.session.status_changed. After
the tag scheme in #1479 keyed the completion notification by prompt id,
the second (redundant) idle event lost the cached prompt id and fell back
to a Date.now() tag, so every turn end popped a second "Turn finished"
notification and replayed the completion sound.

Stop projecting sessionStatusChanged from the raw turn stream (turn.started,
turn.ended, and the in-flight snapshot seed). The daemon's
event.session.status_changed is the single source of status transitions:
it is computed from live daemon state (covering awaiting-approval /
awaiting-question / aborted), carries the authoritative previousStatus and
currentPromptId, and is deduped per real transition server-side. The turn
stream keeps its content responsibilities (message finalization, usage,
duration); seedInFlight keeps seeding the partially-streamed message while
status comes from the snapshot's authoritative session record.
2026-07-10 21:18:08 +08:00
Kai
db61c9e2dd
fix: refuse unsupported image formats instead of poisoning sessions (#1536)
* fix: refuse unsupported image formats instead of poisoning sessions

Images in formats providers reject (AVIF, HEIC, BMP, TIFF, ICO) used to
pass through to the API, and the resulting HTTP 400 repeated on every
later turn because the image_url stayed in the session history.

Add a single format policy (accepted set: PNG/JPEG/GIF/WebP) enforced at
every ingestion point: ReadMediaFile refuses with a per-OS conversion
command; MCP tool results, REST uploads, and ACP prompts replace the
image with a text notice; and turn.prompt/steer gates as the last-funnel
backstop so the SDK/RPC path cannot poison a session either. Accepted
MIME aliases (image/jpg, case/whitespace) are forwarded in canonical
form, and data URLs carrying MIME parameters can no longer slip past the
gate. Remote image URLs pass through (no bytes to inspect).

* fix: canonicalize accepted data URLs with MIME parameters

The format gate compared only the MIME token when deciding whether to
rebuild a data URL, so an accepted image carrying MIME parameters
(`data:image/jpeg;charset=utf-8;base64,...`) was forwarded with its
original header. The Anthropic provider splits the data URL and
exact-matches the full header against its whitelist, so the part still
poisoned the session. Rebuild to the byte-exact canonical URL whenever
the original differs, covering aliases, case/whitespace, and parameters
with one comparison.

Addresses review feedback on PR #1536.

* fix: parse data URLs case-insensitively in the image format gate

An uppercase `;BASE64,` marker is legal (RFC 2045 encoding names are
case-insensitive), but the parser required a lowercase match and
returned null, so the gate treated the URL as remote and forwarded it:
an unsupported image could still land in the session history, and the
Anthropic provider's lowercase-only split then threw on every turn.
Match the scheme and marker case-insensitively; the canonical rebuild
emits the lowercase form.

Addresses review feedback on PR #1536.

* fix: harden image format handling against mislabeled and legacy images

Two more ways an unsupported image could reach the provider are closed:

- Bytes, not labels, decide the format. A data-URL image whose declared
  MIME disagrees with its magic bytes (e.g. AVIF bytes an image search
  tool labels image/png) is now gated on the sniffed format at every entry
  point (MCP results, ACP, SDK/RPC prompt, REST inline and file uploads),
  so a mislabel cannot slip past the gate.
- A poisoned image already in the session history no longer kills the
  session: a server image-format 400 (or kosong's client-side image
  rejection) now retries once with every media part replaced by a text
  marker, mirroring the 413 media-degraded recovery. The recovery also
  fires during compaction, and the transient-retry fallback no longer
  burns the retry budget on image-format errors before the dedicated
  recovery can run.

* fix: reject remote image URLs ending in an unsupported extension

Remote image URLs (MCP resource_link, REST `kind: 'url'`) carry no bytes
to sniff, so a link ending in `.avif` (or `.heic`, `.bmp`, `.tiff`,
`.ico`) would pass through and be fetched server-side — and rejected.
Reject such URLs by their path extension instead (query/fragment
ignored, case-insensitive); extensionless or accepted-extension URLs
still pass through to the provider and the 400 recovery.

* fix: tighten image format handling for parameterized MIMEs and recovery scope

Address two review findings on PR #1536:

- A declared media type with parameters (e.g. image/jpeg; charset=utf-8)
  is no longer misread as unsupported: normalizeImageMime now strips
  parameters, matching the data-URL parser, so an accepted image with
  parameters is forwarded instead of dropped.
- The image-format recovery predicate is narrowed to specific
  format/data rejection phrases, so a 400 about image count, size, or
  image-input support no longer triggers a media-stripped resend that
  would let the model answer blind to the user's images.

* fix

* fix: scope image format recovery to images and flag remote SVG URLs

- The media_type/mime_type recovery match now requires the message to
  mention an image, so a video/audio media_type rejection surfaces
  instead of triggering a blind media-stripped resend.
- unsupportedImageMimeFromUrl flags .svg URLs as image/svg+xml without
  touching the shared suffix map (SVG stays text for the file tools),
  so remote SVG images get the intended notice instead of a provider
  rejection.

Addresses review feedback on PR #1536.

* fix: reject remote MCP images by their declared MIME type

An MCP resource_link with an extensionless or signed URL gives the
extension gate nothing to work with, and convertMCPContentBlock was
discarding the declared mimeType — an honestly-declared AVIF/HEIC link
from an image search tool still became an image_url and poisoned the
session. Reject on the declared MIME when the server provides one:
unsupported declarations become a text notice that keeps the URL so the
model can fetch and convert it; accepted declarations pass through as
before.

Addresses review feedback on PR #1536.

* fix: keep image format recovery image-specific and preserve dropped URLs in notices

- Drop the bare `media` alternative from the image-format recovery
  patterns so audio/video media rejections ("unsupported media type",
  "invalid media type") can never be misclassified as image errors and
  blindly media-stripped; every pattern now mentions "image" literally.
- Remote image URLs rejected by their extension now keep the URL in the
  replacement notice (gateImageFormatParts and the REST url path), so the
  model can still fetch and convert the image — matching the declared-MIME
  resource_link path.

Addresses review feedback on PR #1536.

* fix: drop malformed data URLs at ingestion instead of letting them poison the session

A `data:` URL that fails to parse (missing `;base64,` separator, empty
MIME, …) was treated like a remote URL and passed through the format
gate; the provider then rejects it on every turn, and the read-side
media-stripped recovery keeps paying that round-trip until compaction.
Detect unparseable `data:` URLs in gateImageFormatParts and replace them
with a (truncated) notice at ingestion, covering the MCP/ACP/SDK/turn
paths that share the gate.

Addresses review feedback on PR #1536.
2026-07-10 19:36:00 +08:00
qer
04041eb998
fix(web): hide injected system asides in user message bubbles (#1535)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
* fix(web): hide injected system asides in user message bubbles

* fix(web): preserve literal <system> tags in user prompts

* chore: fold duplicate web changeset into caption-hiding entry
2026-07-10 17:02:08 +08:00
Haozhe
9f66ec416c
feat: harden LLM API fault tolerance against 429 and overload (#1530)
* feat(retry): harden LLM API fault tolerance against 429/overload

- retry more transient errors: 408/409/429/5xx/529, an embedded upstream
  status_code=429 in OpenAI Responses stream errors, and unclassified
  provider errors as a last-resort fallback
- honor server Retry-After (parsed into APIStatusError.retryAfterMs by the
  OpenAI and Anthropic providers); chatWithRetry prefers it over its backoff
- align app-level backoff with claude-code (500ms base, 32s cap, factor 2,
  up to 25% jitter) so high-attempt configs ride out multi-minute overload
- emit a turn.step.retrying meta line in -p --output-format stream-json
2026-07-10 15:47:12 +08:00
qer
0ad568436a
docs(changelog): sync 0.23.4 from apps/kimi-code/CHANGELOG.md (#1528)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
2026-07-10 00:48:47 +08:00
qer
5a90d9d7f1
chore(agents): place Polish before Bug Fixes in changelog sections (#1527) 2026-07-10 00:08:25 +08:00
qer
7ce3c2dc31
fix(ci): pin npm to 11.x in release workflow (#1526) 2026-07-10 00:08:20 +08:00
github-actions[bot]
9f9324cdab
ci: release packages (#1513)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-09 23:45:47 +08:00
qer
ec8dc3456c
fix(web): stop sending prompts into a busy turn on the web UI (#1522)
* fix(web): prevent duplicate first prompts and keep goal drives from looking idle

- Guard startSessionAndSendPrompt with a per-workspace reentry lock so a
  double-click / repeated Enter during draft-session creation cannot fire
  two concurrent first prompts into the same new session.
- Track goal.active in the agent event projector so turn.ended between
  goal-driven continuation turns keeps the session 'running' instead of
  projecting a false 'idle' that drains the local queue into a still-busy
  core (turn.agent_busy).
- Show a 'starting conversation…' loading state on the empty-session
  landing while the first prompt is being created and submitted.
- Persist the resolved model in startSessionAndActivateSkill so the first
  skill turn on a fresh session does not fail with 'Model not set'.

* chore: add changeset for web first-prompt fixes

* fix(web): close remaining first-prompt and goal-settle gaps

- Pass the starting guard through the dock composer: draft-session
  creation selects the new session before submit, which swaps the empty
  composer for the dock; disabling both composers closes the last path
  to a concurrent first POST. Also take the workspace lock in
  startSessionAndActivateSkill / startSessionAndOpenSideChat.
- Emit the owed idle when a goal settles (blocked/paused/completed) in
  the inter-turn gap after a turn.ended was projected as 'running', so
  sending state, in-flight flags and queued prompts flush instead of
  the session staying 'running' forever.

* style(web): fix eqeqeq lint error in first-prompt guard

* fix(web): clear owed idle when a new goal turn starts

The idle debt from a 'running' projection survived turn.started, so an
UpdateGoal('complete'|'blocked') landing mid-turn in the NEXT goal turn
synthesized an early idle. onSessionIdle could then drain queued prompts
into a core that was mid-turn again, re-opening the turn.agent_busy race
for multi-turn goals. Clear the debt on turn.started: from that point the
turn's own turn.ended carries the idle with goalActive already false.

* fix(web): make first-prompt starting state workspace-id-agnostic

isStartingFirstPrompt now reads from the lock set directly (size > 0)
instead of the current activeWorkspaceId. createDraftSession can swap
activeWorkspaceId to a registered id mid-flight; a workspace-keyed read
would then return false while the first prompt is still in the create/
select/submit window, re-enabling the composer and reopening the
duplicate first-submit race.

* revert(web): drop goal-aware idle projection from agentEventProjector

The goalActive / idleOwed shadow state machine grew through multiple
review rounds and still leaves edge cases (snapshot-seeded turns, mid-
turn goal updates). Roll it back to the simple 'turn.ended projects idle'
behavior. Goal-driven sessions can once again race a queued prompt into a
busy core; this is accepted as a known limitation to be resolved properly
in a follow-up that has the core emit an authoritative idle signal.

* chore: align changeset with actual fix scope

* test(web): update profile-patch expectation for model field
2026-07-09 23:37:10 +08:00
Kai
046b6c4175
fix(agent-core): scope [image] config limits to the owning core (#1521)
* fix(agent-core): scope [image] config limits to the owning core

* fix(agent-core): thread harness [image] max_edge_px to TUI paste and ACP ingestion

* chore(changeset): simplify entry to user-facing wording
2026-07-09 20:55:52 +08:00
qer
a3548035a8
feat(tui): add Kimi WebBridge install entry to /plugins panel (#1494)
* feat(tui): add Kimi WebBridge install entry to /plugins panel

Surface a hardcoded Kimi WebBridge entry at the top of the Official tab in the /plugins panel. Selecting it opens the WebBridge install page in the user's browser instead of going through the plugin install flow, since WebBridge is a browser extension plus local daemon rather than an installable plugin package.

* fix(tui): restrict WebBridge open-url shortcut to the pinned row

Match the hardcoded pinned WebBridge entry by object reference instead of by id. A curated or custom marketplace entry on the Third-party tab can legitimately reuse the kimi-webbridge id; routing by id hijacked Enter on those rows and opened the WebBridge page instead of installing. The Official tab still dedupes a same-id official catalog entry so the pinned row is not duplicated.

* fix(tui): label WebBridge plugins row as "open in browser"

The previous "webpage" status did not make it clear that selecting this row opens an external page rather than installing in-app. "open in browser" states the action directly and contrasts with the install label on regular plugin rows.

* test(tui): navigate past pinned WebBridge row in marketplace install tests

Two message-flow tests pressed Enter on the Official tab assuming index 0 was the Kimi Datasource entry. The hardcoded Kimi WebBridge row now leads that tab, so move down one row before installing.
2026-07-09 19:51:53 +08:00
liruifengv
170ae44205
style(web): polish the session sidebar (#1519)
* feat(web): use sidebar fold/unfold icons for sidebar toggle

* feat(web): move settings entry to a sidebar footer row

* feat(web): fully collapse sidebar with animated width transition

* feat(web): redesign sidebar colors, spacing and macos desktop chrome

* feat(desktop): center traffic lights on the 48px header row

* fix(web): restore webkit thin scrollbars and unify sidebar icon sizes

* feat(web): add Kbd keycap component and justify sidebar search shortcut

* style(web): rework sidebar palette and pin a resident sidebar toggle

* fix(desktop): sync window appearance with web UI theme so dimmed traffic lights stay visible

* feat(web): adopt Kimi design icons in the sidebar via a local icon collection

* style(web): mute workspace group title color in the sidebar

* style(web): refine sidebar typography, unify shortcut keycaps, float workspace row actions

* style(web): cap sidebar draggable width at 480px

* style(web): derive sidebar row height from type and padding, float the kebab

* chore: add changeset for sidebar UI polish

* fix(nix): update pnpmDeps hash

* style(web): put the sidebar collapse button inside the header on non-mac

* fix(nix): update pnpmDeps hash
2026-07-09 19:39:20 +08:00
Kai
1bf2c9afee
feat: keep image-heavy sessions within provider request-size limits (#1508)
* feat(kosong): classify HTTP 413 request-body-too-large as a dedicated error type

* feat(agent-core): lower default image downscale cap to 2000px and make it configurable

* feat(agent-core): strip media to text markers and retry when the compaction request is too large

* feat(agent-core): cap model-initiated image reads with a configurable byte budget

* feat(agent-core): resend with degraded media when the provider rejects the request body as too large

* test(agent-core): add explicit timeouts to encode-heavy image budget tests

* feat: add WebP decoding support with wasm integration

- Introduced a new WebP decoding module using @jsquash/webp's wasm decoder.
- Implemented functions to decode WebP images and check for animated WebP formats.
- Updated image compression tests to include scenarios for WebP handling, including encoding and decoding.
- Enhanced error handling for API request size limits to accommodate various error messages.
- Updated pnpm lockfile to include new dependencies for WebP encoding and decoding.

* chore(changeset): consolidate this PR's entries into one

* fix(nix): update pnpmDeps hash for merged lockfile

* feat(agent-core): refuse HEIC/HEIF reads with platform-matched conversion guidance
2026-07-09 18:05:14 +08:00
liruifengv
b91099ed7a
feat: display Extra Usage fuel pack balance in /usage and /status (#1501)
* feat(oauth): parse boosterWallet extra usage from /usages

* feat(oauth): expose extraUsage on AuthManagedUsageResult

* feat(kimi-code): render Extra Usage section in /usage panel

* fix(kimi-code): address Task 3 review feedback for extra usage section

* feat(kimi-code): render Extra Usage section in /status panel

* feat(kimi-code): wire extraUsage into /usage and /status commands

* chore(extra-usage): address final review findings for fuel pack feature

- Update changeset to cover both kimi-code and kimi-code-sdk packages

- Add parser clamp tests and toolkit null-case test

- Replace 'as never' casts in usage-panel tests

- Wrap long import line in status-panel

* chore: temporarily log /usages raw response for debugging

* fix(oauth): accept BOOSTER balance type and drop debug log

* fix(oauth): drop reset hint from Extra Usage and revert periodEnd parsing

* fix(oauth): treat missing amountLeft as zero extra usage and drop debug log

* revert: keep missing amountLeft defaulting to 0 (fully used)

* feat(extra-usage): show monthly cap usage bar and balance in /usage and /status

* fix(extra-usage): show balance and unlimited marker when no monthly cap

* fix(extra-usage): show monthly used with unlimited marker and balance

* fix(extra-usage): label Used and use English Unlimited

* feat(extra-usage): render balance, monthly used and monthly limit as labeled rows

* fix(extra-usage): move Balance row to the bottom

* fix(extra-usage): format currency values with two decimals for column alignment

* fix(extra-usage): right-align currency values so numbers line up

* fix(extra-usage): align currency symbol and decimal point in usage rows
2026-07-09 17:44:59 +08:00
Kai
fe9479d89a
fix: rewrite repeated tool call reminders to redirect instead of prohibit (#1518)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
The r1/r2/r3 reminders injected into repeated tool results led with
prohibition verdicts and, in r2, echoed the repeated tool name and full
arguments back into the context, reinforcing the very pattern they were
meant to break. Rewrite them to state the situation factually and hand
the model a concrete next action: an expectation-setting sentence for
the next call (r1), a forced decision menu of falsify / ask-user /
conclude (r2), and a final hand-off summary without further tool calls
(r3). Detection, thresholds (3/5/8/12), force-stop, and telemetry are
unchanged.
2026-07-09 17:06:19 +08:00
Luyu Cheng
173bdfdab1
fix: resume sessions with missing workdir (#1517) 2026-07-09 16:46:57 +08:00
Luyu Cheng
9fb19154ac
fix: keep prompt goals running until terminal (#1516)
* fix: keep prompt goals running until terminal

* fix: reject invalid prompt goal commands

* fix: ignore stale prompt goal status checks
2026-07-09 15:54:50 +08:00
Luyu Cheng
ad30a1c632
style(web): polish web UI typography and controls (#1502)
* style(web): polish sidebar and tool typography

- Use UI font and medium weight for sidebar and composer controls

- Add reusable shortcut and tool output blocks

- Cap long tool output at 50 lines with a scrollbar

- Update sidebar show-more copy and muted styling

* style(web): refine workspace picker sizing

* style(web): align composer mode menus

* style(web): tune list and question typography

* style(web): reuse shortcut keys in approvals

* style(web): size workspace picker from content

* feat(web): localize chat status labels

* style(web): refine composer toolbar controls

* style(web): use complete Inter variable font

* style(web): tune sidebar workspace typography

* style(web): polish composer and workspace picker

* style(web): refine markdown and thinking typography

* chore: add web UI polish changeset

* fix(web): pin Inter package for Nix build

* style(web): polish goal tool calls

* style: polish goal mode display

* fix: layer latest message pill below menus

* style: align goal strip content
2026-07-09 14:55:58 +08:00
liruifengv
735922c291
feat(kimi-web): add status-aware browser notifications (#1479)
* feat(kimi-web): add approval notification storage key and i18n copy

* feat(kimi-web): add approval notification helpers and tests

* feat(kimi-web): wire approval notifications and guard completion alerts

* fix(kimi-web): extract shouldNotifyCompletion helper and add tests

* feat(kimi-web): add approval notification settings toggle

* chore(kimi-web): add changeset and tidy notification module comment

- Align approval notification tag with spec (kimi-approval-${approvalId})

- Update module header to describe all three notification kinds

* fix(kimi-web): make notifications fire reliably

- Key completion notification tags by turn (sid + promptId) and question
  tags by request id, so a stale notification left in the notification
  center no longer swallows every follow-up alert in the same session
- Suppress notifications only while the window is actually focused, not
  merely visible (document.hasFocus() on top of visibilityState)
- Play the attention sound when a tool needs approval, matching the
  completion and question sounds

* chore(kimi-web): simplify changeset
2026-07-09 12:30:15 +08:00
liruifengv
b89fc1a4fb
docs(changelog): sync 0.23.3 and shorten OAuth error entry (#1509)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
2026-07-08 23:33:29 +08:00
1773 changed files with 242124 additions and 31291 deletions

View file

@ -0,0 +1,70 @@
---
name: agent-core-dev
description: Use when developing in packages/agent-core-v2 (the DI × Scope agent engine) — adding or modifying a domain Service, choosing a LifecycleScope, wiring DI dependencies, splitting a domain across scopes, owning or migrating a config section, gating behavior behind an experimental flag, raising coded errors, working on the permission system, writing DI/Scope tests, porting business logic from agent-core (v1) to v2, triaging a main-branch commit against v2, or exposing a v2 domain over server-v2 while keeping the /api/v1 wire contract compatible with released clients. Self-contained guide organized by development stage (orient → design → implement → test → verify) plus align workflows for v1→v2 migration, main-branch commit triage, and server-v2 wire exposure; each file carries the rules, examples, and red lines for its step.
---
# agent-core-dev
> Develop `packages/agent-core-v2` by lifecycle stage. This skill is **self-contained**: every rule, recipe, and red line lives in the stage files below — it does not delegate to `packages/agent-core-v2/docs/`.
`agent-core-v2` is the new agent engine built on the **DI × Scope** architecture (a port of `packages/agent-core`). Everything resolves through the container: a service declares an **identity**, its **dependencies**, and a **lifetime**; the container decides construction, singleton-per-scope, ordering, and disposal. The stage files restate the rules in imperative form so you can work without reading the source docs.
## Lifecycle at a glance
```text
Orient → Design → Implement → Test → Verify
│ │ │ │ │
│ │ │ │ └─ lint:domain · typecheck · test · dep graph · red lines
│ │ │ └─ test.md
│ │ └─ implement.md (+ errors.md · flags.md · permission.md)
│ └─ design.md
└─ orient.md
```
Stages are ordered but not strictly linear: a test failure (stage 4) that reveals a wrong scope sends you back to design (stage 2); a `CyclicDependencyError` sends you to `design.md` §dependency-direction and `implement.md` §cycles.
## Workflows
End-to-end procedures that span the stages. Reach for these before reading the stage files individually.
- [Align (port `agent-core` → `agent-core-v2`)](align.md): split a v1 class into semantic units, fix each unit's domain / scope / Service / dependencies, then migrate the logic and tests. Use when the task is "move feature X from v1 to v2" or "port `IXxxService` to v2".
- [Commit align (triage a `main` commit against v2)](commit-align.md): given one `main` commit hash + a short note, find the v1 logic it changed, check whether v2 already has the corresponding implementation, bucket it (aligned / partial / missing / not-applicable), and recommend a minimal fix. Use in the `kimi-code-v2`-catching-up-to-`main` phase, for one commit at a time; escalate to [align.md](align.md) if the gap is a whole domain.
- [Server align (expose `agent-core-v2` over `server-v2`)](server-align.md): wire a v2 domain into `packages/kap-server` over `/api/v2` (native) and `/api/v1` (v1-compatible mirror), keep the wire schema byte-compatible with the established v1 contract by sharing the `@moonshot-ai/protocol` schema, and isolate v1-only behavior in a `<domain>Legacy` edge adapter instead of distorting the native v2 Service. Use when the task is "expose the new v2 Service on the server", "add a route to the `/api/v1` surface", or "keep server-v2 wire-compatible with released v1 clients".
## Stages
- [Stage 1 — Orient](orient.md): the DI black box (identity / dependencies / lifetime), the four `LifecycleScope` tiers and visibility, and the file-header comment convention. Read before touching business code.
- [Stage 2 — Design a service](design.md): pick a scope, split a domain across scopes, choose a calling style (direct call vs event vs hook), and direct dependencies. Decide *where things live and who knows whom* before coding.
- Topic: [Domain boundaries vs Scope](domain-boundaries.md) — keep `session` / `agent` / `turn` from becoming god objects; data-ownership test and their split conclusions.
- Topic: [Persistence layering](persistence.md) — the three-layer `Store → Storage → backend` model, naming Stores by access pattern, and which layer business code should depend on.
- Topic: [Edge exposure — `resource:action` + WS events](edge-exposure.md) — which Services are exposed over `/api/v2` (per-scope action map) and which events stream over WS; what to wrap in a facade.
- [Stage 3 — Implement](implement.md): the standard Service recipe and the DI building blocks — interface + identity, constructor injection, scoped registration, `Disposable`, eager vs delayed, `invokeFunction`, `createInstance`, child scopes, and the cycle-refactor playbook.
- Topic: [Service authoring](service-authoring.md) — file layout, naming, contract vs impl contents, interface style, constructor/field conventions, events, multi-Service domains, comment rules.
- Topic: [Config](config.md) — the section-registry model, App vs Session split, owning a config section, the TOML format, and the env overlay.
- Topic: [Errors](errors.md) — co-located `XxxError`, the central code registry, wire serialization, boundary translation.
- Topic: [Flags](flags.md) — `registerFlagDefinition`, `IFlagService.enabled(id)`, the `[experimental]` config section, resolution precedence.
- Topic: [Permission](permission.md) — composable chain-of-responsibility kernel, policy registry + composer, `modes`/`agentTypes` metadata, `resolveExecution`/`accesses`.
- Topic: [Telemetry](telemetry.md) — emitting events via `ITelemetryService`, context propagation, and appender destinations (`ConsoleAppender` / `CloudAppender`).
- [Stage 4 — Test](test.md): resolve the system under test by interface, pick `TestInstantiationService` vs `createScopedTestHost`, shared stubs, service groups, teardown.
- [Stage 5 — Verify & submit](verify.md): `lint:domain`, `typecheck`, `test`, and the pre-submit checklist.
## How to use this skill
Jump to the stage you are in and read that one file; each is self-contained and ends with its own red lines. Skim the global red lines below before submitting — they catch most mistakes across every stage. The repo's source of truth remains the code in `packages/agent-core-v2/src/`; this skill codifies the same rules so you do not have to re-derive them.
## Global red lines
Invariants that hold across every stage. Each is expanded in the stage file noted.
1. No `new` on a class whose constructor carries `@IService` deps — inject with `@IX` or `accessor.get(IX)`. (implement.md)
2. `@IX` decorates constructor parameters only; parameter order depends on construction (static-first for `createInstance`, `@IX`-first for scoped services). (service-authoring.md)
3. Both interface and impl carry `_serviceBrand`; the `createDecorator` name is globally unique. (implement.md)
4. Parent scope never depends on child scope — short-lived may inject long-lived, never the reverse. (orient.md)
5. No cyclic dependencies — refactor (extract a third Service / use an event / re-scope); do not break the cycle with `Delayed`. (design.md, implement.md)
6. `ServicesAccessor` is valid only during `invokeFunction` — never stash it for async use. (implement.md)
7. Scope follows state identity — no `Map<sessionId, …>` at `App` to fake per-session state. (design.md)
8. Foundational layers never know upstream ones; business code never depends on the edge layer (`gateway`/`rpc`). (design.md)
9. Throw coded errors; register codes centrally; branch on `code` across the wire, never `instanceof`. (errors.md)
10. Gate unreleased behavior behind a flag contributed via `registerFlagDefinition` and resolved through `IFlagService.enabled(id)`; no ad-hoc env toggles. (flags.md)
11. Tests resolve the SUT by interface; shared stubs live under `test/`, never `src/`. (test.md)
12. Config is the preference registry: only preferences that are persistable, schema'd, and user/operator-facing go in `IConfigService`. Domain-specific config (including env-only operational toggles) goes through `registerSection` + `envOverlay`. Facts → `IBootstrapService` (kept domain-agnostic — never add cron/flags/model state); session state → Session scope; constants → code. Business domains never call `IBootstrapService.getEnv()` directly. (config.md)

View file

@ -0,0 +1,235 @@
# Subskill — Align (port `agent-core``agent-core-v2`)
Port business logic from `packages/agent-core` (v1) into `packages/agent-core-v2` (v2) by **splitting semantics, then fixing the domain, scope, Service, and dependency relationships**, and finally migrating the logic and tests.
Use this when the task is "move feature X from v1 to v2", "port `IXxxService` to v2", or "align a v1 domain with the v2 architecture". It complements the stage files: orient / design / implement / test explain the *target* architecture; this file explains how to get there *from v1*.
## The one-paragraph mental model
v1 is a **VSCode-style singleton container**: services self-register with `registerSingleton`, resolve as singleton-per-container, and have no explicit lifetime tier — so a single `ISessionService` / `IToolService` tends to accumulate global, per-session, and per-agent state in one class. v2 is a **DI × Scope tree**: every service binds to one of `App` / `Session` / `Agent`, and a domain with state at several lifetimes is split into several Services. Porting is therefore **not** a file copy — it is "find each lifetime of state hiding in the v1 class, give each its own v2 Service at the right scope, then re-wire the dependencies".
## v1 → v2 at a glance
| Concern | v1 (`agent-core`) | v2 (`agent-core-v2`) |
|---|---|---|
| Registration | `registerSingleton(IX, X, InstantiationType.Delayed)` | `registerScopedService(LifecycleScope.X, IX, X, InstantiationType.Delayed, 'domain')` |
| DI import | `from '../../di'` | `from '#/_base/di/scope'` / `'#/_base/di/instantiation'` / `'#/_base/di/extensions'` / `'#/_base/di/lifecycle'` |
| Lifetime | implicit singleton-per-container | explicit `LifecycleScope` (App/Session/Agent) — see orient.md |
| Domain granularity | coarse (`session`, `tool`, `loop`) | fine, split by scope + responsibility |
| Test import | `from '@moonshot-ai/agent-core/di/test'` | `from '#/_base/di/test'` |
| Resolve SUT in tests | `ix.createInstance(Impl)` (common) | `ix.get(IX)` by interface — see test.md |
| Scope tests | none | `createScopedTestHost` — see test.md |
| Errors | `from '../../errors'` (central `KimiError`, `ErrorCodes`) | `from '#/_base/errors'` + domain co-located `XxxError` — see errors.md |
| Flags | `flags/` (process-global `FlagResolver`) | `flag/` (App-scope `IFlagService`) — see flags.md |
| Permission | `agent/permission/` (hardcoded chain) | `permission*` (registry + composer) — see permission.md |
## The align workflow
```text
Read v1 → Semantic split → Map domain → Assign scope → Shape Services
→ Direct dependencies → Port logic → Port tests → Verify
```
Each step below states the goal and the concrete action, then points to the stage file that goes deeper. Do them in order; a later step often sends you back to an earlier one (a scope that does not fit means the semantic split was wrong).
### 1. Read v1
**Goal:** build an accurate inventory of what the v1 code actually owns. Read the v1 *source*, not v1 docs.
Actions:
- Locate the v1 entry: contract (`<domain>/<domain>.ts`) + impl (`<domain>/<domain>Service.ts`), plus any helpers under the same folder.
- Inventory three things from the impl:
- **State** — every field / `Map` / cache the class holds. For each, note its *identity* (global? keyed by `sessionId`? by `agentId`?).
- **Behavior** — every public method; group them by which state they touch.
- **Dependencies** — every `@IFoo` constructor injection and every cross-domain relative import (`from '../<other>/...'`).
- Note the v1 registration line (`registerSingleton(...)`) and any `services.set(IX, ...)` overrides at bootstrap (these reveal runtime static args or prebuilt instances the port must preserve).
Do not start splitting yet — an accurate inventory prevents the common mistake of porting the class shape instead of the semantics.
### 2. Semantic split
**Goal:** break one v1 class into independent semantic units, each owning state at exactly one lifetime. This is the heart of the port.
Method — for each piece of state from the inventory, ask:
1. **What is it keyed by?** nothing → a global unit; `sessionId` → a per-session unit; `agentId` → a per-agent unit.
2. **When should it die?** with the process / the session / the agent. State that must outlive its neighbors is a different unit.
3. **Which methods touch only this state?** they travel with the unit.
Worked example — v1 `ISessionService` (one class, ~600 lines) holds:
- a global index of all sessions → **global** unit → v2 `sessionStore` (`ISessionStore`, App);
- this session's metadata → **per-session** unit → v2 `sessionMetaStore` (`ISessionMetaStore`, Session);
- this session's activity / status → **per-session** unit → v2 `sessionActivity`;
- this session's context projection → **per-session** unit → v2 `sessionContext`;
- child-agent lifecycle driven by a session → **per-session** unit → v2 `agentLifecycle`; create/close/archive/fork of the session itself → **global** unit → v2 `sessionLifecycle` (App).
A v1 class that maps cleanly to one v1 decorator often becomes **three to five** v2 Services. That is expected and correct — do not try to keep the v1 class shape.
Red lines:
- If two pieces of state have different identities, they belong in different units — do not keep them together "because v1 did".
- Do not split by method count or file aesthetics; split by state identity (design.md §3).
- If a unit has no mutable state (pure behavior), defer its scope decision to step 4 (it is pulled down by its shortest-lived dependency).
### 3. Map to v2 domain
**Goal:** assign each semantic unit to a v2 domain — an existing one if it fits, a new one only if none does.
Actions:
- Search v2 `src/` for an existing domain that owns the same responsibility. Prefer joining an existing domain over creating a new one.
- If creating a domain, name it after the responsibility (camelCase folder, e.g. `sessionActivity`), not after the v1 file.
- Keep a domain's public surface to one contract file (`<domain>.ts`) plus its impl(s).
Reference mapping (a **starting point**, not gospel — verify against the current v2 `src/`, which is the source of truth):
| v1 location | v2 domain(s) |
|---|---|
| `services/session/`, `session/` | `session`, `sessionStore`, `sessionMetaStore`, `sessionActivity`, `sessionContext`, `agentLifecycle` |
| `services/tool/`, `tools/`, `agent/tool/` | `toolRegistry`, `toolStore`, `toolExecutor`, `tooldedup`, `userTool` |
| `loop/`, `agent/` (turn loop) | `loop`, `llmRequester`, `llmRequestLog`, `turn` |
| `agent/context/`, `agent/compaction/` | `contextMemory`, `contextProjector`, `contextSize`, `fullCompaction`, `dynamicInjector` |
| `agent/permission/` | `permission`, `permissionMode`, `permissionPolicy`, `permissionRules`, `approval`, `externalHooks` |
| `agent/goal/`, `agent/plan/`, `agent/swarm/`, `agent/cron/`, `agent/background/` | `goal`, `plan`, `swarm`, `cron`, `background`, `subagentHost` |
| `services/config/`, `agent/config/` | `config` |
| `services/event/`, `base/common/event` | `event`, `eventBus` |
| `services/logger/`, `logging/` | `log` |
| `services/fileStore/` | `filestore`, `blobStore` |
| `services/fs/`, `services/workspace/` | `fs`, `workspace` |
| `services/auth/`, `services/oauth/` | `auth` |
| `services/environment/` | `environment` |
| `services/terminal/` | `terminal` |
| `services/question/`, `services/approval/` | `question`, `approval` |
| `services/prompt/`, `agent/injection/` | `prompt`, `dynamicInjector` |
| `services/mcp/`, `mcp/` | `mcp` |
| `plugin/`, `profile/`, `skill/` | `plugin`, `profile`, `skill` |
| `rpc/`, `services/coreProcess/` | `rpc`, `gateway` |
| `di/` | `_base/di` |
| `errors/`, `errors.ts` | `_base/errors` + co-located domain errors |
| `flags/` | `flag` |
| `telemetry.ts` | `telemetry` |
| `agent/records/` | (records split) — verify in v2 `src/` |
When the table says "verify", or when v1 and v2 have diverged, **read the v2 `src/` tree and decide from the code** — do not invent a mapping.
### 4. Assign scope
For each semantic unit, fix its `LifecycleScope` from the identity you found in step 2. Follow design.md §2 verbatim:
- global → `App`; per `sessionId``Session`; per `agentId``Agent`.
- Stateless unit → default to `App`, pulled down only by a shorter-lived dependency.
- Self-check: "when this scope is disposed, should this state disappear with it?"
This is the decision v1 never had to make — get it right before writing any v2 code, because the scope is fixed at registration and changing it later ripples through every consumer.
### 5. Shape Services
Decide the Service shape per unit, following design.md §3:
- A unit that owns **one instance's** state → a single per-instance Service (`ISessionXxx` / `IAgentXxx`).
- A unit that owns a **global view plus per-instance** state → split into an `App` registry/factory (`XxxStore` / `XxxRegistry` / `XxxCatalog`) **and** a per-instance Service. The `App` half creates or locates the per-instance half.
- Do not pre-split a unit that has state at only one lifetime.
Most consumers inject the per-instance Service; inject the `App` factory only for genuine cross-instance management.
### 6. Direct dependencies
Re-wire the dependencies you inventoried in step 1, now across the new v2 Services. Follow design.md §4§5:
- **Calling style** — need a result / I orchestrate → direct call (`@IX` injection); stating a fact → event; ordered participation that may veto → hook.
- **Scope direction** — a Service may inject only its own scope or an ancestor. If an `App` Service needs something from a `Session` Service, the dependency is backwards: re-scope or invert into an event.
- **Domain direction** — foundational layers must not know upstream ones. A cycle means a v1 relative import is now pointing the wrong way; extract a third Service or invert the notification into an event.
- **Durable facts** — state changes that must be recorded / replayed / projected across agents go on the wire (`wireRecord`), not a direct call alone.
Run `lint:domain` (verify.md) as soon as the dependencies compile — it catches direction violations early.
### 7. Port the business logic
Move the behavior into the shaped v2 Services, applying the mechanical conversions below. Follow implement.md for the recipe.
**Registration:**
```ts
// v1
import { InstantiationType, registerSingleton } from '../../di';
registerSingleton(IXxxService, XxxService, InstantiationType.Delayed);
// v2
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
registerScopedService(LifecycleScope.Session, IXxxService, XxxService, InstantiationType.Delayed, 'xxx');
```
**Imports:**
```ts
// v1
import { createDecorator, Disposable, IInstantiationService } from '../../di';
import { KimiError, ErrorCodes } from '../../errors';
// v2
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import { Disposable } from '#/_base/di/lifecycle';
import { IInstantiationService } from '#/_base/di/instantiation';
import { KimiError, type ErrorCode } from '#/_base/errors';
```
**Constructor injection** — unchanged in shape (`@IX` on constructor params, service params after static params). Verify each dependency is resolvable from the new scope (step 6).
**Errors** — move any shared error into a co-located `XxxError extends KimiError` with a registered `code` (errors.md). Do not keep throwing v1's central error codes from a v2 domain.
**Flags** — replace any `FlagResolver` / env check with `IFlagService.enabled(id)`; contribute new flags from the owning domain's `flag.ts` via `registerFlagDefinition` (flags.md).
**Events** — v1's `Emitter` / `Event` from `base/common/event` maps to v2's `event` / `eventBus` domains. Read existing v2 usage in neighboring domains and match it; do not import v1's `Emitter`.
**Runtime static args / prebuilt instances** — if v1 bootstrap did `services.set(IX, new SyncDescriptor(C, [bag]))` or set a prebuilt instance, preserve that behavior at the v2 composition root (the scope that owns the Service). Do not silently drop it.
Red lines:
- Do not copy a v1 file and "fix imports". Re-split first (steps 26); a straight copy carries v1's implicit-singleton assumptions into v2 and creates the `Map<sessionId, …>`-at-`App` anti-pattern.
- Do not leave v1 relative imports (`from '../x/...'`) in v2 — use the `#/...` alias and respect the domain layers.
- Do not preserve a v1 behavior just because it exists; if the split reveals it was a workaround for the missing scope tree, drop it.
### 8. Port the tests
Convert v1 tests to the v2 harness, following test.md:
```ts
// v1
import { TestInstantiationService } from '@moonshot-ai/agent-core/di/test';
const svc = ix.createInstance(XxxService, 'static-arg');
// v2
import { createServices } from '#/_base/di/test';
// in additionalServices:
reg.define(IXxxService, XxxService);
// in the test body:
const svc = ix.get(IXxxService);
```
- Resolve the SUT by interface (`ix.get(IX)`), never `new` a `@IService`-carrying impl, and prefer `ix.get(IX)` over `ix.createInstance(Impl)`.
- Move shared stubs into `test/<domain>/stubs.ts`; import by relative path, never `#/...`.
- If the port introduced scope-layer behavior, add a `createScopedTestHost` test that asserts resolution from the correct scope (with `_clearScopedRegistryForTests()` + explicit re-registration in `beforeEach`).
- Keep v1's behavioral assertions where they still describe observable behavior; delete assertions that only checked v1's internal class shape.
## Migration checklist
Before submitting a port:
- [ ] Every piece of v1 state landed in a v2 Service whose scope matches its identity (no `Map<sessionId, …>` at `App`).
- [ ] Each v1 dependency now points in the right scope and domain direction; `lint:domain` passes.
- [ ] Registrations use `registerScopedService` with an explicit scope and domain name; no `registerSingleton` remains.
- [ ] Imports use the `#/...` alias; no v1 relative (`../../di`, `../../errors`) imports remain.
- [ ] Errors are co-located coded errors; flags go through `IFlagService`.
- [ ] Tests resolve the SUT by interface; scope behavior is asserted via `createScopedTestHost`; teardown goes through one `DisposableStore`.
- [ ] v1 bootstrap overrides (`services.set(...)`) are preserved at the v2 composition root.
## Red lines (this subskill)
- Porting is semantic splitting, not file copying — never preserve a v1 class shape in v2.
- Decide scope from state identity before writing v2 code; the scope is fixed at registration.
- Verify the domain mapping against current v2 `src/`; the table here is a starting point, not authority.
- One Service owns state at exactly one lifetime; split global-view + per-instance into registry + per-instance.
- A dependency cycle introduced by the port means a v1 import is now backwards — refactor, do not route around it with `Delayed`.

View file

@ -0,0 +1,155 @@
# Topic — Close vs Dispose
How to shut down a scoped service in `agent-core-v2`: when `dispose()` is enough, when to add an async `close()`, and where cancellation / abort belongs. Read this before putting business shutdown logic into a `Disposable`.
## The one-sentence rule
> **`close()` is async business shutdown; `dispose()` is synchronous resource cleanup.**
`close()` finishes a domain's work: stop in-flight operations, apply shutdown policy, flush persistence, release async resources. `dispose()` releases object resources: event subscriptions, timers, hook registrations, and child disposables.
## Why they must stay separate
`IDisposable.dispose()` is synchronous:
```ts
export interface IDisposable {
dispose(): void;
}
```
The container calls it during scope teardown. Disposal order is deterministic (orient.md): child scopes first, then reverse construction order within a scope. Nothing awaits a Promise returned from `dispose()`.
Business shutdown is usually async. It may need to:
- stop in-flight tasks and wait for settlement;
- decide policy (`kill` vs `keepAliveOnExit` vs `markLost`);
- flush write queues and persistence;
- emit final records / events / telemetry;
- close sockets, child processes, or external clients.
If that logic lives in `dispose()`, it becomes fire-and-forget: the scope keeps tearing down, dependencies may be disposed immediately afterward, and the async continuation can run against a half-dead object graph.
## What `close()` owns
Add `close(): Promise<void>` when a service owns async shutdown work:
```ts
export interface IXxxService {
readonly _serviceBrand: undefined;
close(reason?: string): Promise<void>;
}
```
A good `close()`:
- is idempotent — repeated calls return the same Promise or no-op;
- is called by lifecycle code **before** `scope.dispose()`;
- rejects new work after it starts;
- applies shutdown policy explicitly;
- awaits the work it starts;
- leaves `dispose()` with only synchronous cleanup.
Sketch:
```ts
class XxxService extends Disposable implements IXxxService {
declare readonly _serviceBrand: undefined;
private closed = false;
async close(reason = 'scope closed'): Promise<void> {
if (this.closed) return;
this.closed = true;
await this.stopInFlightWork(reason);
await this.flushPersistence();
}
override dispose(): void {
this.closed = true;
// synchronous cleanup only: clear timers, remove listeners, release handles.
super.dispose();
}
}
```
`flush()` is different from `close()`: `flush()` persists buffered state while the service stays open; `close()` is terminal.
## What `dispose()` owns
`dispose()` releases resources owned by the object instance:
```ts
class WSBroadcastService extends Disposable implements IWSBroadcastService {
declare readonly _serviceBrand: undefined;
constructor(@IEventService event: IEventService) {
super();
this._register(event.subscribe(() => { /* … */ }));
}
}
```
Use `dispose()` to:
- `_register(...)` event subscriptions and hook registrations;
- clear timers;
- remove signal listeners;
- dispose child `IDisposable`s;
- detach from synchronous handles.
`dispose()` must be idempotent and should avoid throwing. If `close()` was already called, `dispose()` should be a no-op for business work and only clean resources.
## Where abort / cancellation belongs
Cancellation is not the same thing as graceful shutdown.
For an operation-scoped object, a cancellation trigger can be disposed:
```ts
const tokenSource = new CancellationTokenSource();
store.add(toDisposable(() => tokenSource.cancel()));
```
This is fine when the contract is **fire-and-forget cancel**: the operation observes the token and settles asynchronously; disposal does not wait for completion.
For a manager/service that owns many tasks and their state, do not use `dispose()` as the graceful abort path. Expose `stop()` / `stopAll()` / `close()` and let lifecycle code await the one it needs.
Background-specific rule: a `background`-style service may use `AbortController` internally to propagate cancellation to process / agent / question tasks, but manager shutdown belongs in `close()` or explicit `stopAll()`. `dispose()` may best-effort abort controllers only as a safety net; it must not be the mechanism that decides terminal status, persistence, or notifications.
## Decision tree
```text
What does the service own?
├─ only event subscriptions / timers / disposable handles?
│ └─ extend Disposable; no close() needed.
├─ async work, in-flight tasks, persistence buffers, sockets, child processes?
│ └─ add close(): Promise<void>; call it before scope.dispose().
├─ a single operation that callers may cancel?
│ └─ expose an AbortSignal / CancellationToken or a fire-and-forget cancel handle.
└─ both async shutdown and disposable resources?
└─ close() for business shutdown; dispose() for resource cleanup.
```
## VSCode parallel
VSCode uses the same split:
- `src/vs/base/common/lifecycle.ts``IDisposable.dispose(): void` for synchronous cleanup.
- `src/vs/base/parts/storage/common/storage.ts``close(): Promise<void>` flushes and closes the database.
- `src/vs/base/common/cancellation.ts``CancellationTokenSource.dispose(true)` / `cancelOnDispose()` cancels operation-scoped work without awaiting it.
The lesson is not "never cancel in dispose". It is: **disposal may trigger cancellation for a scoped operation, but service shutdown policy stays in an explicit async close path.**
## Red lines (this topic)
- Do not put business shutdown in `dispose()``dispose()` is synchronous and is not awaited.
- Do not `await` inside `dispose()`.
- Do not rely on `dispose()` to flush persistence, emit final events, wait for tasks, or send notifications.
- Add `close(): Promise<void>` for async shutdown and call it before `scope.dispose()`.
- Keep `close()` and `dispose()` idempotent; `dispose()` after `close()` must be safe.
- Use disposal as a cancellation trigger only for operation-scoped work, not as a manager/service shutdown policy.

View file

@ -0,0 +1,78 @@
# Subskill — Commit align (triage a `main` commit against v2)
Context: you are on the `kimi-code-v2` branch, in the phase of catching it up to **new commits that landed on `main`**. Those commits change `packages/agent-core` (v1); the job is to decide, for one commit at a time, whether v2 (`packages/agent-core-v2`) already has the corresponding logic — and if not, what the minimal fix is.
Use this when the user hands you **one commit hash plus a short description** ("look at `<commit>` — it fixed the steering race"). It is the small, per-commit sibling of [align.md](align.md): `align.md` ports a whole v1 domain into v2; this file triages a single `main` commit and says *port / adapt / skip*. If the triage reveals a whole missing domain, stop and switch to [align.md](align.md).
## The one-paragraph mental model
A `main` commit edits v1's singleton-container code. The same behavior in v2 lives behind a scoped Service, so a commit lands in one of four buckets: **already-aligned** (v2 has it, possibly by construction), **partial** (v2 has a nearby version whose semantics drift), **missing** (v2 has nothing), or **not-applicable** (the v2 architecture removed the very problem the commit fixes). Your output is a bucket assignment plus evidence, then a fix sized to that bucket — never a blind port of the diff.
## The workflow
```text
Read the commit + the user's note → Locate the v1 logic → Map to a v2 domain
→ Check v2 for a corresponding implementation → Bucket it → Recommend a fix → Verify
```
### 1. Read the commit and the note
**Goal:** know exactly what changed in v1 and *why*. The user's one-liner gives the intent; the diff gives the facts.
Actions:
- Inspect the change scoped to v1: `git show <commit> -- packages/agent-core` (and `--stat` first to see the blast radius).
- From the diff, list: touched files, changed functions/methods, and the observable behavior delta (before → after).
- Reconcile with the user's note: is this a bugfix, a semantic correction, new behavior, or a refactor? The *why* decides whether v2 even needs the change.
Do not skim the user's sentence and guess — the diff is the spec for what "aligned" means here.
### 2. Locate the v1 logic
Pin the change to a v1 place: the contract (`<domain>/<domain>.ts`) + impl (`<domain>/<domain>Service.ts`), or the helper/handler the commit touched. Note which state it reads/writes and which other v1 services it calls — this is the same inventory as [align.md](align.md) §1, scoped to the commit's footprint.
### 3. Map to a v2 domain
Use the v1 → v2 domain table in [align.md](align.md) §3 as a starting point, then **verify against the current `packages/agent-core-v2/src/` tree** — it is the source of truth. Identify the candidate v2 Service(s) that would own this behavior, and their `LifecycleScope`.
### 4. Check v2 and assign a bucket
Search the candidate domain in v2 (Grep the method name, the state field, the error code). For each piece of the commit's behavior delta, decide:
- **Already-aligned** — v2 produces the same observable result (sometimes for free, because the v2 design never had the bug). Cite the v2 file:line.
- **Partial** — v2 has a near miss: same method, different guard/ordering/error; or the state lives at a different scope. Name the exact drift.
- **Missing** — no v2 Service owns this behavior. Confirm it is a single-Service gap, not a whole-domain gap (latter → [align.md](align.md)).
- **Not-applicable** — the v2 architecture removed the condition the commit fixes (e.g. the scope tree already serializes what v1 patched with a lock). Explain why, so a reviewer trusts the skip.
Every claim needs a citation (`path:line`) on both sides; "I couldn't find it" is a finding only after you name where you looked.
### 5. Recommend a fix (sized to the bucket)
- **Already-aligned** — say so and stop; reference the v2 location. No code change.
- **Partial** — propose the smallest edit that closes the drift: which Service, which method, which guard. Stay inside v2 rules — scope/domain direction, no `Map<sessionId, …>` at `App` (see [align.md](align.md) §6§7 red lines).
- **Missing** — sketch the port at commit granularity: target domain + scope, the Service/method to add or extend, the dependency direction, and which [align.md](align.md) §7 conversions apply (registration, `#/…` imports, co-located coded error, `IFlagService` for any gate). If it needs a new scope or a wire change, flag it.
- **Not-applicable** — recommend no v2 change, but call out any test worth adding so the gap stays closed.
Keep the recommendation to the commit's footprint. If it keeps growing, that is the signal to hand off to [align.md](align.md) for a full domain port.
### 6. Verify
Point at the checks that cover the fix, per [verify.md](verify.md): `lint:domain`, `typecheck`, and the relevant `test`. Note the expected outcome rather than asserting you ran it if you did not.
## Output shape
When triaging, answer in this order so the user can act on it directly:
1. **Commit + intent** — one line restating what the commit changed and why (from the note + diff).
2. **v1 location** — file(s) and the behavior delta.
3. **v2 status** — one of the four buckets, with `path:line` evidence on both sides.
4. **Recommendation** — the concrete fix (or the justified skip), scoped to the commit; name the target Service / scope / dependency direction.
5. **Verify** — which checks should pass, and whether to escalate to [align.md](align.md).
## Red lines (this subskill)
- Read the diff and the note before judging v2; never infer "aligned" from the description alone.
- Do not copy a v1 diff into v2. Decide the bucket first; a bugfix commit often maps to **not-applicable** because the v2 design already removed the defect.
- Cite `path:line` on both sides. A recommendation without evidence is a guess.
- Stay in the commit's footprint. Growing scope means "switch to [align.md](align.md)", not "keep porting here".
- Do not break v2 invariants to chase v1 parity — scope direction, domain direction, and no `Map<sessionId, …>` at `App` still hold ([align.md](align.md) red lines).

View file

@ -0,0 +1,269 @@
# Topic — Config
How the `config` domain works and how a domain owns its configuration section. Covers the section-registry model, the App vs Session split, the TOML on-disk format, and the recipe for adding or migrating a config section.
The `config` domain is a thin registry + loader: it does **not** know the shape of any individual section. Each domain owns the schema (and, where needed, the TOML transform) for the config it consumes, registers the section into `IConfigRegistry`, and reads it through `IConfigService`. There is no whole-config object passed around.
## What belongs in Config
`IConfigService` is the **preference registry**: it holds values a user or
operator *chooses*, each with a schema and a default, that *can* be persisted to
`config.toml`. It is not a grab-bag for every value a domain needs. Before
registering a section, classify the value along three axes — **decision-maker**,
**preference vs fact**, **mutability / persistence**:
| Type | Decision-maker | Preference/Fact | Persisted? | Examples | Home |
|---|---|---|---|---|---|
| User preference | user | preference | ✅ config.toml | model, theme, log level | **Config** |
| Operational override | operator/deployer | preference | ❌ env / flag | `KIMI_MODEL_*`, `KIMI_LOG_*` | **Config** (env overlay) |
| Per-run intent | invoker | preference | ❌ ephemeral | CLI `--model`, `--config` | **Config** (Memory layer) |
| Host fact | host | fact | ❌ | platform, CI, proxy, home dir | **Bootstrap** |
| Derived convention | code | fact (derived) | ❌ | `configPath`, `logsDir` | **Bootstrap / code** |
| Session runtime state | session/agent | state | ✅ session meta | active model, plan mode | **Session scope** |
| Tuning constant | developer | preference | ❌ compile-time | retry backoffs, buffer sizes | **code** |
A value belongs in Config **iff** it satisfies all of:
1. **Preference** — a choice among valid values, not an observed fact.
2. **Persistable** — it *can* be written to `config.toml`, even when a given
value arrives via env or CLI.
3. **Schema + default** — registerable as a section with validation.
4. **User- or operator-facing** — meaningful to set as a preference.
If it fails any rule, it is not Config:
- **Fact** (CI, platform, proxy, `HOME`) → a structured fact on
`IBootstrapService` (the L1 startup snapshot), not Config.
- **Derived convention** (`configPath`, `logsDir`) → `IBootstrapService` / code.
- **Session runtime state** (active model, plan mode) → a Session-scoped
service in the owning domain (e.g. `IProfileService`), not `config`.
- **Tuning constant** (retry config, buffer sizes) → domain code; promote to
Config only when it becomes user-tunable.
**`IBootstrapService` is domain-agnostic.** It holds only generic facts shared by
all domains — the env bag, resolved paths, and host facts (`platform`, `arch`,
`cwd`, `osHomeDir`, `isCI`, …). It must **never** hold state tied to a specific
upper domain (no `cron`, no `flags`, no feature-specific fields): that couples
the foundational layer to an upstream one.
Any value that belongs to a specific domain — including env-only operational
toggles (`KIMI_CRON_*`, `KIMI_CODE_EXPERIMENTAL_*`), model parameters, or feature
flags — goes through **Config registration**: the owning domain registers a
section with a declarative `envBindings` map (and a `stripEnv` when the value must
not be persisted) and reads it via `config.get(...)`. Each config value declares
an optional env binding (`{ field: 'ENV_VAR' }`, with optional `parse`/`default`);
IConfig resolves each field by `env > config.toml > default` automatically. This
keeps every domain's config in one registry and keeps Bootstrap free of upstream
knowledge.
Operational env overrides and per-run intent live *inside* Config as layers over
the same persistable key: `model` can be set in `config.toml`, via `KIMI_MODEL_*`,
or via CLI `--model`. They are not separate abstractions — see "Reads vs writes"
and "Layered resolution" below.
Env access is encapsulated: business domains read `config.get(...)` or structured
`IBootstrapService` facts; only the `config` domain reads the raw env bag (from
`IBootstrapService`) to build its overlays. Business domains must not call
`IBootstrapService.getEnv()` directly.
## Layered resolution
`IConfigService` resolves a key by precedence across layers, lowest to highest:
```text
Default registered defaultValue (and code constants promoted to a section)
User config.toml (persisted user preferences)
Operational env overlay (e.g. KIMI_MODEL_*, KIMI_CODE_EXPERIMENTAL_*)
Memory per-run intent (CLI flags); never persisted; highest
```
`set(domain, patch, target?)` writes the `User` layer (persisted) by default;
pass `ConfigTarget.Memory` for a per-run override that is never written to disk.
`inspect(domain)` reports the value at each layer.
## Layout
- `src/config/config.ts``IConfigRegistry` / `IConfigService` tokens, `ConfigSection`, `ConfigEffectiveOverlay`, event types.
- `src/config/configService.ts``ConfigRegistry` + `ConfigService` impl; self-registers at App scope.
- `src/config/toml.ts` — generic snake_case ↔ camelCase machinery plus the registry-aware `transformTomlData` / `applySectionToToml` entry points. Per-domain normalization lives in the section owner's `configSection.ts` (registered as `fromToml` / `toToml`); this module stays free of any other domain's semantics.
- `src/profile/thinking.ts` (owner domain, not `config`) — the `resolveThinkingEffort` helper; uses the authoritative `ThinkingConfig` from `configSection.ts`.
- `src/config/configPure.ts``isPlainObject`, `deepMerge`, `omitUndefined`, `describeUnknownError`.
A domain that owns a section keeps the schema in its own `configSection.ts` (e.g. `src/flag/flag.ts` for `experimental`, `src/profile/configSection.ts` for `thinking`, `src/loop/configSection.ts` for `loopControl`). A cross-section env overlay (e.g. the `KIMI_MODEL_*` synthesis) lives in the owning domain too (`src/provider/envOverlay.ts`) and is registered via `IConfigRegistry.registerEffectiveOverlay`.
## Scope
- `IConfigRegistry` / `IConfigService`**App** scope, process-global. One registry of sections; one loader reading `~/.kimi-code/config.toml` (path from `IBootstrapService.configPath`).
All config reads go through `IConfigService` (global config). Per-session runtime state (active model, thinking level, etc.) lives in the owning Session-scoped service (e.g. `IProfileService`), not in `config`.
## The section-registry model
A config section is identified by a camelCase domain key (`'providers'`, `'thinking'`, `'loopControl'`). Each section has:
- `schema?: ConfigSchema<T>` — zod schema used to validate the value (absent ⇒ passthrough).
- `defaultValue?: T` — filled when the file has no value for the domain.
- `merge?: ConfigMerge<T>` — how `set(domain, patch)` combines base + patch (default `deepMerge`).
- `fromToml?: ConfigFromToml` — read-path transform (snake_case file value → in-memory shape). Defaults to a plain key-casing pass; owners register one when the on-disk shape needs custom normalization (record key preservation, nested object conversion, array entries, key renames, reshapes).
- `toToml?: ConfigToToml` — write-path transform (in-memory value → snake_case file value). Defaults to a plain camelCase→snake_case key mapping.
Ownership rules:
- **One owner per section.** `registerSection` throws if a domain is registered twice.
- **The domain that consumes a config owns its schema.** This is what keeps `config` (L2) from importing higher domains: `config` must not import `externalHooks` / `permissionRules` / `provider` / `kosong` / etc. for a section's schema. If a schema needs a domain's types, the schema lives in that domain.
- **Demand-driven.** Do not register sections for config that no domain reads yet; a section appears (with its schema in the owning domain) only when a consumer appears.
## Env bindings
A section can declare how its fields are read from environment variables, so the
value resolves through `config.get(...)` rather than ad-hoc `process.env` reads.
Declare the bindings with `envBindings(schema, { … })` — the field names are
type-checked against the schema (no magic strings), and nested schemas recurse:
```ts
registerSection('thinking', ThinkingConfigSchema, {
env: envBindings(ThinkingConfigSchema, {
effort: 'KIMI_MODEL_THINKING_EFFORT',
}),
});
// nested / record section — outer key is a runtime constant, inner fields are
// checked against the value schema:
registerSection('providers', ProvidersSectionSchema, {
env: envBindings(ProvidersSectionSchema, {
[ENV_MODEL_PROVIDER_KEY]: envBindings(ProviderConfigSchema, {
apiKey: 'KIMI_MODEL_API_KEY',
type: 'KIMI_MODEL_PROVIDER_TYPE',
baseUrl:'KIMI_MODEL_BASE_URL',
}),
}),
stripEnv: stripProvidersEnv,
});
```
Each field is an `EnvBinding` — a string (env var name) or
`{ env, parse?, default? }`. IConfig resolves every field by
`env > config.toml > default`, sets it on the effective value, and validates the
section. Empty nested entries (no field resolved) are omitted, so a synthetic
entry like `__kimi_env__` only appears when at least one of its env vars is set.
`stripEnv(value, rawSnake?)` removes env-derived fields before `set`/`replace`
persists, so env overrides never leak into `config.toml`.
Business domains read `config.get('section')`; they never read env directly, and
never write their own env-merge logic.
## Add a config section (recipe)
1. Define the schema in the owning domain, e.g. `src/<domain>/configSection.ts`:
```ts
export const MY_SECTION = 'mySection';
export const MySectionSchema = z.object({ /* ... */ });
export type MySection = z.infer<typeof MySectionSchema>;
```
2. In the domain's service constructor, inject `IConfigRegistry` and register:
```ts
constructor(@IConfigRegistry registry: IConfigRegistry) {
registry.registerSection(MY_SECTION, MySectionSchema, { defaultValue: {} });
}
```
Pick a service whose scope matches when the config is first needed. Registering from an Agent-scope service is fine — see "Late registration".
3. Read it anywhere via `IConfigService`:
```ts
constructor(@IConfigService private readonly config: IConfigService) {}
// ...
const value = this.config.get<MySection>(MY_SECTION);
```
4. React to edits by subscribing `IConfigService.onDidChange` and filtering on `e.domain === MY_SECTION` (see `FlagService`).
5. Write it only through `IConfigService.set(domain, patch)` (merge) or `.replace(domain, value)` (wholesale). Never write `config.toml` directly.
## Reads vs writes
Data flow is one-way by default — reading config never touches the file:
```text
config.toml ──load──▶ IConfigService.effective ──get──▶ services read
▲ │
└──────── IConfigService.set/replace ◀──── only on explicit writes
```
- **Read path** (startup, every service): `config.toml` is loaded into `IConfigService` once; services read via `get()`. This path **never writes the file**.
- **Write path** (rare): `config.toml` is rewritten only when something explicitly calls `IConfigService.set/replace`. The only production writers today are provider CRUD (`ProviderService.set/delete`, e.g. provisioning a provider after OAuth login).
**Runtime service state is not config.** Mutating a service at runtime does **not** rewrite `config.toml`:
- `ProfileService.configure(...)` / `update(...)` / `setModel(...)` / `setThinking(...)` only change **in-memory** fields and append to the session **wireRecord** (for replay). They never call `IConfigService.set`.
- Switching model or thinking level mid-session is session runtime state, not a config edit — the user's `config.toml` is left untouched.
So `configure(...)` never overwrites the local file. Treat `config.toml` as the user's static config; runtime overrides live in memory and the session record.
## Late registration
`ConfigService` loads in its constructor (first `get(IConfigService)`). Domain services that register sections may be constructed later (especially Agent-scope services). To keep validation and defaults correct:
- `IConfigRegistry` emits `onDidRegisterSection` whenever a section is registered.
- `ConfigService` subscribes and, on registration, re-validates the already-loaded raw value for that domain, applies the default if the raw value is absent, re-runs the env overlay, and fires `onDidChange` if the effective value changed.
- Before a section is registered, `get(domain)` returns the raw (transformed, unvalidated) value; consumers that need validated values should read after the owning service is constructed, or react to `onDidChange`.
This means registration order is never a correctness concern — you do not need an eager bootstrap.
## TOML on-disk format
`config.toml` stores keys in **snake_case**; in-memory values are **camelCase**. `ConfigService` converts both ways by dispatching to each section's registered transform:
- **Read**: `transformTomlData(fileData, registry)` maps each top-level key to a domain and applies that domain's `fromToml` hook (or a plain key-casing pass when none is registered). Owner domains register their own normalization — e.g. provider `oauth`/`env`/`customHeaders`, permission `deny/allow/ask``rules`, `loop_control.max_steps_per_run``maxStepsPerTurn`, `experimental` keys preserved verbatim. When a section registers after the initial load, `ConfigService` re-applies its `fromToml` against the preserved snake_case raw value (see "Late registration"), so registration order is never a correctness concern.
- **Write**: `applySectionToToml(rawSnake, domain, value, registry)` applies the domain's `toToml` hook (or a plain camelCase→snake_case mapping) into a raw clone of the file, preserving unknown top-level keys and unknown sub-fields (lossless round-trip).
`ConfigService` keeps three views:
- `rawSnake` — snake_case clone of the file; the write base, never carries the env overlay.
- `raw` — camelCase, env-free; the read/set/replace base.
- `effective` — validated `raw` plus the env overlay; what `get()` returns.
### `KIMI_MODEL_*` env overlay
When `KIMI_MODEL_NAME` is set, the `provider` domain's `kimiModelEnvOverlay` (`src/provider/envOverlay.ts`) injects a reserved model alias (`__kimi_env_model__`) into `effective`, points `defaultModel` at it, and merges the request `modelOverrides`; the reserved provider (`__kimi_env__`) comes from the `providers` section env bindings. The overlay is registered via `IConfigRegistry.registerEffectiveOverlay` and applied **only to `effective`**, never to `rawSnake`, so it is never persisted. Its `strip` (plus the providers section `stripEnv`) is the final guard so a caller that read `effective` (with the overlay) cannot write the reserved entries or the shell API key back to disk. `config` itself only runs registered overlays — it does not know the `KIMI_MODEL_*` semantics.
## Owner-owned sections
`config` holds no monolithic config schema and no whole-config object. Every section is owned by the domain that consumes it: the schema (and any `fromToml` / `toToml` normalization and `stripEnv`) lives in that domain's `configSection.ts`, and the domain registers it via `IConfigRegistry.registerSection`. Cross-section env behavior (e.g. `KIMI_MODEL_*`) lives in an owner-registered `ConfigEffectiveOverlay`. To add a section, follow "Add a config section" above in the owning domain — never add schema or normalization to `config` itself.
## Ownership map (current)
| Section | Owner | Layer | Status |
|---|---|---|---|
| `providers` | `provider` | L2 | owner-owned (`IProviderService` CRUD) |
| `experimental` | `flag` | L3 | owner-owned |
| `thinking` | `profile` | L4 | owner-owned |
| `loopControl` | `loop` | L4 | owner-owned (read by `loop` + `profile`) |
| `McpServerConfig` (type) | `mcp` | L5 | owner-owned (type only; not a registered section) |
| `session` | `config` | L2 | in config |
| `models` / `defaultModel` / `defaultProvider` | `kosong` | L1 | owner-owned (read by `ProviderManager`) |
| `hooks` | `externalHooks` | L4 | owner-owned |
| `permission` | `permissionRules` | L3 | owner-owned |
| `background` | `background` | L5 | owner-owned |
`config` must not import from any of these owner domains; that is the whole reason the schemas, TOML normalization, and env overlays live with their owners.
## Layering & scope
- `config` is **L2**. Domains that own sections import `config` (for `IConfigRegistry` / `IConfigService`) and must be at L2 or higher; lower layers need an entry in `ALLOWED_EXCEPTIONS` (e.g. `kosong>config`, `kosong>provider`).
- Cross-domain type sharing for a config type may need an exception too (e.g. `plugin>mcp` for `McpServerConfig`). Prefer importing the type from the owning domain over re-declaring it.
- `IConfigRegistry` / `IConfigService` are **App**. Agent scope services may inject App services via ancestor lookup.
- `config` never imports a higher domain and holds no section schemas of its own; if a section needs a type from another domain, that schema lives in that domain.
## Red lines (this topic)
- One owner per section; `registerSection` throws on duplicate domains.
- `config` (L2) never imports a higher domain — keep section schemas in the owning domain.
- Config is the **preference registry**: register only values that are preferences, persistable, schema'd, and user/operator-facing. Facts → `IBootstrapService`; session state → Session scope; constants → code.
- Business domains read `config.get(...)` or structured `IBootstrapService` facts; never call `IBootstrapService.getEnv()` directly — only `config` reads the raw env bag to build overlays.
- Keep `IBootstrapService` domain-agnostic: never add state tied to a specific upper domain (cron, flags, model params, …). Domain-specific config goes through `registerSection` + `envBindings`, read via `config.get(...)`.
- Do not pass a whole config bag via options; read each section through `IConfigService`. There is no `KimiConfig` object — config is a registry of owner-owned sections.
- `config.toml` is snake_case on disk, camelCase in memory — never write camelCase keys to disk, and never write to `config.toml` except through `IConfigService.set/replace`.
- Reading config / calling `configure(...)` / switching model at runtime must not rewrite `config.toml`; runtime state lives in memory and the session wireRecord, not the file.
- Never persist env overlays (`__kimi_env__` / `__kimi_env_model__` / shell API key / experimental env); overlays live only in `effective` / `Memory`.
- Registering from an Agent-scope service is fine — the late-registration mechanism keeps validation correct; do not add an eager bootstrap.

View file

@ -0,0 +1,285 @@
# Stage 2 — Design a service
Decide *where things live and who knows whom* before writing code. Every rule here derives from two questions:
1. **What is the identity of the state it owns?** → decides the **Scope**.
2. **Who owns the decision, and who needs the result?** → decides the **calling style** and **dependency direction**.
## 1. What a Service is
A Service = a bundle of **state** + a set of **behaviors**, bound to a **lifetime**.
- **Behavior** is almost free — the same logic runs anywhere, so it does not by itself decide a scope.
- **State** pins a Service to a scope. State has an **identity** (what it is keyed by) and a **lifetime** (when it is born, when it dies).
- **Dependencies / calling style** answer a different question: who controls whom, and who knows whom.
## 2. Choosing a scope
> Scope = the identity + lifetime of the owned state.
| Scope | State identity (keyed by) | Lifetime |
|---|---|---|
| `App` | none (single global instance) | the process |
| `Session` | `sessionId` | one session |
| `Agent` | `agentId` | one agent |
### Decision tree
**Q1. Does it own mutable state?**
- No (pure behavior) → jump to Q3.
- Yes → Q2.
**Q2. What is the identity of that state?**
- one global instance → **`App`**
- one per session → **`Session`**
- one per agent → **`Agent`**
- a mix (a global registry *and* per-instance state) → **split it** (see §3).
**Q3 (stateless). What is the shortest-lived dependency it must inject?**
A stateless Service is pulled *down* by its shortest-lived dependency: if it injects an `Agent`-scoped Service, it cannot be `App`. Among the scopes that still satisfy every dependency, **default to the longest-lived one** (usually `App`) to maximize reuse. Push it down only when it must inject a shorter-lived Service, or when you want to limit its visibility.
### The core anti-pattern (a litmus test)
> **Do not store per-session state in a `Map<sessionId, …>` inside an `App` Service.**
This is the tell-tale sign of "should have been `Session`-scoped but was parked at `App`". Consequences: nobody cleans the entry up when the session ends (leak); every consumer threads `sessionId` around (loss of type safety); it cannot inject `Session`/`Agent`-scoped collaborators.
### One-sentence self-check
> "When this scope is disposed, should this state disappear with it?"
>
> - Yes → the scope is right.
> - It must outlive the scope → too short; move up one tier.
> - It should be one-per-unit but is shared → too long; move down one tier.
## Scope is not a domain
Scope answers **lifetime and visibility**. Domain answers **responsibility and data ownership**. A Service registered at `Session` or `Agent` scope is not automatically part of the `session` or `agent` domain, and an entity Service must not be named `I{Scope}EntityService` just because its data is scoped that way.
Use the data-ownership test and the `session` / `agent` / `turn` split conclusions in [domain-boundaries.md](domain-boundaries.md) before naming a Service or adding `I{Domain}EntityService`.
## 3. Multi-Scope splitting
> One Service owns state at exactly one identity / lifetime. If a domain owns state at several lifetimes, split it along those boundaries — one Service per lifetime.
The standard split is "global registry / factory" + "per-instance":
| Tier | Role | Naming tends to |
|---|---|---|
| `App` | global registry / catalog / factory — knows "all of them" and how to create one | `XxxStore` / `XxxRegistry` / `XxxCatalog` |
| `Session` / `Agent` | one instance — only the state of "this one" | `XxxService` / `ISessionXxx` / `IAgentXxx` |
Canonical splits in the codebase:
- **`records`** — `ISessionStore` (`App`) + `ISessionMetaStore` (`Session`) + `IAgentRecords` (`Agent`).
- **`config`** — `IConfigRegistry` / `IConfigService` (`App`).
- **`kosong`** — `IProtocolHandlerRegistry` (`App`) + `IProviderManager` (`Session`). Generation is driven by `ILLMRequester` (`Agent`) in the `llmRequester` domain.
- **`tool`** — `IToolDefinitionRegistry` (`App`) + `IToolService` (`Agent`).
Split when the domain genuinely has both a global view and per-instance state. Do **not** split when state lives at only one lifetime (e.g. purely `App` like `log`; purely `Agent` like `prompt`). Do not pre-split for symmetry.
After the split, the `App` Service usually plays the **factory**; most consumers inject the **per-instance** Service. Inject the `App` factory only when you genuinely need cross-instance management.
## 4. Choosing a calling style
Three mechanisms answer three different questions:
| Mechanism | Nature | Coupling | Returns a value? | Consumers |
|---|---|---|---|---|
| **Direct call** | command: A tells B to do | A → B | yes | one (known) |
| **Event** | fact: A announces "X happened" | both depend only on the bus | no | zero / one / many (unknown) |
| **Hook** (`onWill` / `onDid`, `OrderedHookSlot`) | participation: observers step into an operation, in order | both depend only on the bus | can observe / veto | many, but ordered |
### Decision tree
**Q1. Does A need a return value from B?** → Yes: **direct call**. Events cannot return a value (request/reply over events is an anti-pattern).
**Q2. Is B's reaction part of A's responsibility, or B's own concern?**
- A's responsibility *includes* B's behavior (A orchestrates B) → **direct call**. E.g. `session` drives `agentLifecycle`; `loop` drives `llmRequester` / `toolExecutor`.
- B's reaction is B's own concern, A merely states a fact → **event**. E.g. `flag` reacts to `config.onDidChange`.
**Q3. How many consumers?**
- exactly one, known → **direct call**.
- zero / one / many, producer should not know → **event**.
**Q4. Would a direct A→B call create a cycle or violate scope direction?** → A *consequence check*, not a primary reason. Decide by Q1Q3 first; do not turn a genuine direct call into an event just to break a cycle.
**Q5. Is this fact part of the durable record / replay / cross-agent projection?** → Yes: **emit it on the wire** (`wireRecord`). State changes that must be recorded, replayed, or synchronized across agents are projected onto the wire, not handled by a direct call alone (`permission.set_mode`, `goal.create/update/clear`, `plan_mode.enter/exit`). The wire is the *durable record*, not the live notification channel.
### One-sentence rule
> "I am telling you to do this, and I may need the result" → **direct call.**
> "I am announcing that something happened; react if you care" → **event.**
> "I am announcing something, and you may step in, in order, possibly to veto" → **hook.**
### As extension points (open-closed)
The three mechanisms above are also where a domain accepts new behavior without being edited. When adding a scenario would otherwise require changing this domain's `if/else`, expose the right extension point instead:
| Need | Extension point | Typical scope |
|---|---|---|
| Register a new implementation / definition | a **registry / catalog** the domain queries | `App` |
| React to a fact the domain announces | an **event** on the bus | the announcing scope |
| Step into an operation in order / veto | a **hook** (`onWill`/`onDid`, `OrderedHookSlot`) | the owning scope |
| Swap a backend (File ↔ DB ↔ S3) | a **Store / Storage token** at the byte layer (see persistence.md) | `App` (composition root) |
Closed-for-modification means: the domain's own file is not where new scenarios branch. If a new scenario forces an edit here, an extension point is missing or misplaced.
## 5. Dependency direction
Two layers are involved:
- **Scope direction**: short-lived → long-lived, **enforced by the container** (see orient.md).
- **Domain direction**: which domain may depend on which — **a matter of judgment**, not enforced by the container.
> **A depends on B iff A needs B's data or behavior to do its own job.**
Add one anti-rot heuristic to keep the graph from collapsing into a clique:
> **Do not let a more foundational / more-reused Service come to know a more specific / more-upstream one.**
Once a foundational component knows about an upstream scenario, it can no longer be reused by other scenarios and will almost always create a cycle.
### The natural layers of this repo
`agent-core-v2` is stratified into eight dependency layers, **L0L7** (the `Ln` number in file headers — see orient.md for the full table and the representative domains). A domain at layer `L` may import only domains at layer `<= L`; lower layers never reach upward. `lint:domain` enforces this from the `DOMAIN_LAYER` map in `scripts/check-domain-layers.mjs`.
The tiers, from lowest to highest:
- **L0 — base infrastructure** (`_base`, errors, wire types).
- **L1 — bridges & low-level capabilities** (logging, telemetry, event bus, environment, storage).
- **L2 — data & cross-cutting capabilities** (records, config, providers, auth, workspace registry).
- **L3 — registries & capabilities** (tools, permissions, flags, skills, plugins).
- **L4 — agent behaviour** (turn, loop, prompt, profile, context, goal, plan, swarm).
- **L5 — async lifecycle** (background, MCP, cron, sub-agent tools).
- **L6 — coordination** (session, agent/session lifecycle, interactions, terminal).
- **L7 — boundary / edge** (`gateway`, `rpc`, approval/question, the `*Legacy` v1 adapters).
Red lines:
- The **L0/L1 substrate** never imports a higher business layer.
- Business logic never depends on the **L7 edge** layer — business code should not know REST / WebSocket exist.
- A cycle means knowledge was placed backwards: extract a third, more foundational Service, or invert the "notification" half into an event.
> Capability → orchestrator (e.g. `prompt → turn`) is allowed and present in this repo; the real red line is *inverted reuse* — a foundational / lower Service depending on a specific / upper one.
> When a Service is meant to be reached over the wire (`/api/v2`, WS), see [edge-exposure.md](edge-exposure.md) for the per-scope `resource:action` map, which Services may be exposed directly vs wrapped in a facade, and how events stream.
## 6. New-Service checklist
1. **What does it remember, and what is the state's identity?** → pick the scope (§2).
2. **What is the shortest-lived dependency it must inject?** → the scope cannot be longer than that.
3. **Does it own state at both a global and a per-instance lifetime?** → if yes, split Multi-Scope (§3).
4. **For each collaborator: am I commanding it, notifying it, or letting it participate?** → pick the calling style (§4).
5. **Does each dependency arrow make a more foundational thing know a more specific thing?** → if yes, invert it (§5).
## 7. Render the placement tree
After the checklist, render the result as a plaintext tree — the deliverable reviewers read. Keep it in the design doc or PR description.
```text
domain: `<name>` (owning scope: <Scope>)
├─ serves (who uses me) tag = HOW they reach me
│ ├─ (inject) <ConsumerDomain> @<Scope><what they use me for>
│ └─ (accessor) <ConsumerDomain> @<Scope><what they use me for>
├─ exposes (interfaces I provide, by scope)
│ ├─ App : <IXxxRegistry><role>
│ ├─ Session : <ISessionXxx><role>
│ └─ Agent : <IAgentXxx><role>
└─ depends (what I inject) tag = calling style
└─ <DepDomain> @<Scope> direct/event/hook — <what for>
```
Conventions:
- List **only real interfaces**; write `—` for a scope with no exposed interface. Most domains are single-scope — do not invent symmetry.
- On `depends`, tag each arrow with its calling style: `direct`, `event`, or `hook`.
- On `serves`, tag each consumer with its **access mechanism**, grouped `inject` first then `accessor`:
- `inject` — a descendant or peer scope DI-injects me. Resolved by the container; lifetime-safe.
- `accessor` — an ancestor or edge scope borrows me through `IScopeHandle.accessor.get(...)`. Valid only while this scope lives; never cache the result; must run before the child scope is disposed. See the cross-scope borrow diagram below.
- An empty `(inject)` group with a non-empty `(accessor)` group is a signal: the interface is currently an edge / lifecycle command surface — check it is not leaking internals.
- A consumer is upstream of you. If you cannot name one business consumer, the domain may be dead or mis-scoped.
### Cross-scope borrow diagram
When a domain has `accessor` consumers, draw the reverse-direction borrow next to the tree so it is never mistaken for injection:
```text
App scope
<AncestorService> ──holds──► IScopeHandle(<id>)
│ accessor.get(<IMyService>)
│ └── resolve runs inside the child scope
<Child> scope (<id>)
<MyService> ← the interface lives here
```
Read it as:
- `──holds──►` = the ancestor owns a handle to the child scope (it stores the key, not the service). DI allows this.
- `accessor.get(...)` = a **runtime borrow**, not a dependency edge. It must cross an `IScopeHandle`, run on demand, never be cached, and finish before the child scope is disposed.
Worked example — `sessionLifecycle`:
```text
domain: `sessionLifecycle` (owning scope: App)
├─ serves (who uses me)
│ ├─ (inject) — (none yet)
│ └─ (accessor)
│ ├─ sessionLegacy @App(edge) — v1-compatible create/fork/archive/…
│ └─ gateway / rpc @App(edge) — native v2 session lifecycle actions
├─ exposes (interfaces I provide, by scope)
│ ├─ App : ISessionLifecycleService — owns the live session scope tree
│ ├─ Session : — — (per-session state lives in sessionMetadata / agentLifecycle / …)
│ └─ Agent : — — (per-agent state lives in agentLifecycle)
└─ depends (what I inject)
├─ bootstrap @App direct — addresses session storage
├─ hostEnvironment @App direct — gates scope creation on the probe
├─ sessionIndex @App direct — persisted read model for cold resumes
├─ storage @App direct — atomic docs + append logs
├─ workspaceRegistry @App direct — resolves a session's workspace
└─ event @App direct — broadcasts session-level facts (e.g. archived)
```
Cross-scope borrow for `sessionLifecycle`:
```text
App scope
SessionLifecycleService ──holds──┐
GatewayService ───────────holds──┼──► IScopeHandle(sessionId)
│ accessor.get(ISessionMetadata) …
│ └── resolve runs inside the Session scope
Session scope (sessionId)
sessionMetadata / agentLifecycle / … ← per-session services live here
```
How the three lenses shaped it:
- **Scope (§2)** → the live registry of session scopes is process-wide, so it is App-scoped; per-session data stays in Session-scoped services, reached through the handle's `accessor`.
- **Dependency direction (§5)**`sessionLifecycle` is consumed by the edge via `accessor` borrows; it never imports the edge. Every downward arrow lands on a peer or a more foundational Service.
- **Extension points (§4)** → new per-session behavior plugs into the Session-scoped services (`sessionMetadata`, `agentLifecycle`, `sessionActivity`); new transports stay at the edge. Neither edits `sessionLifecycle`.
For a multi-scope split, the `exposes` block fills more than one scope — see the `records` pattern in §3.
## Red lines (this stage)
- Scope is not a domain; ownership follows write authority and invariants, not read consumption.
- Do not create `I{Scope}EntityService` bundles (`IAgentEntityService`, `ISessionEntityService`) that re-merge multiple domains.
- No `Map<sessionId, …>` at `App` to fake per-session state.
- Scope follows state identity; stateless Services are pulled down by their shortest-lived dependency, otherwise default to `App`.
- Do not pre-split a domain that has state at only one lifetime.
- Need a result / I orchestrate → direct call; stating a fact → event; ordered participation / may veto → hook.
- Foundational layers never know upstream ones; business code never depends on the edge layer.
- A cycle means knowledge is placed backwards — refactor, do not route around it.
- Render the placement tree with real interfaces only — never pad an empty scope for symmetry.
- Tag `serves` consumers with `inject` / `accessor`; an empty `inject` group is a signal to check the interface is not leaking internals.
- An `accessor` consumer is a runtime borrow across a scope boundary, not DI injection — never cache the result and finish before the child scope disposes.
- A `serves` list with no business consumer (or only edge consumers) signals a dead or leaking interface.

View file

@ -0,0 +1,203 @@
# Topic — Domain boundaries vs Scope
How to keep `agent-core-v2` from recreating a god object after splitting one. Read this before naming a Service, adding an `I{Domain}EntityService`, or deciding whether data belongs to `session`, `agent`, or `turn`.
## The one-sentence rule
> **Scope is a lifetime and visibility boundary; a domain is a responsibility and data-ownership boundary.**
A Service registered at `LifecycleScope.Session` or `LifecycleScope.Agent` is **not automatically in the `session` or `agent` domain**. Scope says when an instance is born, when it dies, and who can see it. Domain says which business responsibility it owns and which data it is allowed to mutate.
## Definitions
| Term | Meaning |
|---|---|
| **Scope** | Lifetime / visibility tier. Current code registers Services at `App`, `Session`, or `Agent`. |
| **Domain** | A cohesive business responsibility with its own model, invariants, and write authority. |
| **Entity** | Data with identity and lifecycle, usually suitable for `get/list/create/update/delete` semantics. |
| **Aggregate** | A consistency boundary: the owner that enforces invariants over a cluster of data. |
| **Read model / projection** | Derived data built for queries; it may be shaped like a domain, but it is not the write authority. |
| **Runtime state** | Ephemeral data that dies with its scope; it should not be forced into an entity store. |
## The data-ownership test
Do not ask "does Session / Agent / Turn use this data?". Most data is used by several of them. Ask these instead:
1. **What is the data's identity?** `sessionId`, `agentId`, `turnId`, `taskId`, `workspaceId`, `providerName`, or something else?
2. **Who is the only writer?** The writer is usually the owner. Readers and projectors are not owners.
3. **Who enforces the invariants?** The domain that decides valid transitions owns the model.
4. **What is the authoritative source?** Atomic document, append-log / event stream, blob, query projection, config, or runtime memory?
5. **Can it be named without `Session` / `Agent` / `Turn`?** If yes, it probably deserves its own domain.
Examples:
- `PermissionRules` are Agent-scoped, but `permission` owns rule changes and evaluation.
- `BackgroundTask` is spawned by an Agent, but `background` owns task state and output.
- `ContextMessage` is consumed by the Agent loop, but `contextMemory` / `wireRecord` owns history and replay.
- `SessionMeta` is about a Session, but it is owned by `sessionMetadata`, not by a broad `session` data bag.
## Persistence models are not all entity CRUD
Before introducing `I{Domain}EntityService`, classify the persistence model:
| Persistence model | Use when | Examples |
|---|---|---|
| **Atomic document** | One typed document per key | `SessionMeta`, `config.toml` |
| **Append-log / event-sourced** | The authoritative record is "what happened" | `wireRecord`, `contextMemory`, `goal`, `plan`, `permission` transitions |
| **Blob / key-value** | Large or content-addressed bytes | media offload, blob store |
| **Indexed query / read model** | Derived, queryable view | `sessionIndex`, future `IQueryStore` projections |
| **Registry / catalog** | Global or scoped known items | `workspaceRegistry`, `toolRegistry` |
| **Ephemeral runtime state** | No durable entity | active turn handle, pending interactions, terminal handles |
See [persistence.md](persistence.md) for the `Store → Storage → backend` rules. A domain EntityService is a business facade over those stores; it is not a replacement for the store layer.
## Naming consequence
Do not name Services after a scope or a god-object-shaped concept:
- ❌ `IAgentEntityService`
- ❌ `IAgentDataService`
- ❌ `ISessionEntityService`
- ❌ `ITurnEntityService` that bundles context, tools, permissions, and telemetry
Name Services after the real owning domain:
- ✅ `ISessionMetadata`
- ✅ `ISessionIndex`
- ✅ `IAgentLifecycleService`
- ✅ `ITurnService`
- ✅ `IBackgroundTaskEntityService`
- ✅ `ICronTaskEntityService`
- ✅ `IPermissionRulesService`
`Session` and `Agent` are valid scope names. They are usually **not** good data-owner names.
## Split conclusion — `session`
`session` is both a Scope and a narrow Domain. Keep the Domain small.
The `session` domain owns only Session-level identity, metadata, lifecycle commands, and Session-level read views:
| Concern | Owner | Notes |
|---|---|---|
| `sessionId`, `workspaceId`, `sessionDir`, `metaScope` | `sessionContext` | Seeded facts; no IO |
| `SessionMeta` | `sessionMetadata` | Durable atomic document; entity-like |
| Open session scope registry | `sessionLifecycle` | App-scope live handles; not the persisted entity table |
| Session commands such as `archive()` | `session` | Orchestrates metadata, agent teardown, and events |
| Persisted session list / get / count | `sessionIndex` | Backend-neutral read model |
| Running / idle / awaiting status | `sessionActivity` | Derived from interactions and active turns; owns no state |
`session` must not reabsorb these:
| Data | Real owner |
|---|---|
| Agent instances / handles | `agentLifecycle` |
| Turns | `turn` |
| Context messages | `contextMemory` / `wireRecord` |
| Tool state | `toolStore` / `tool` |
| Permission rules / mode | `permission` |
| Profile / model | `profile` |
| Goal / Plan | `goal` / `plan` |
| Background tasks | `background` |
| Cron tasks | `cron` |
| Pending approvals / questions | `interaction` / `approval` / `question` |
| Workspace | `workspaceRegistry` |
| Provider / config | `provider` / `config` |
Entity-service conclusion for `session`:
- ✅ `ISessionMetadata` is already an entity-document Service.
- ✅ `ISessionIndex` is a query/read-model Service.
- ❌ Do not create a broad `ISessionEntityService` that owns agents, turns, records, interactions, logs, workspace, and config.
## Split conclusion — `agent`
`agent` is primarily a Scope and composition boundary, not a large data Domain.
Strictly, the `agent` domain owns only Agent-instance concerns:
| Concern | Owner | Notes |
|---|---|---|
| Agent instance identity / handle | `agentLifecycle` | Owns live Agent scope handles |
| Agent creation / removal | `agentLifecycle` | Lifecycle, not a data bag |
| Parent / child relationship | `session` / `agentLifecycle` depending on current code | Do not duplicate it into a new Agent data service |
| Active turn reference | `turn` | Turn is its own domain even though it is Agent-scoped |
Many Agent-scoped Services are **not** in the `agent` domain:
| Data / capability | Real owner | Persistence model |
|---|---|---|
| Wire records | `wireRecord` | Append-log |
| Context messages | `contextMemory` | Event-sourced through `wireRecord` |
| Profile / model config | `profile` | Config + wire records |
| Tool definitions / registry | `toolRegistry` | Runtime registry |
| Tool mutable state | `toolStore` | Wire records |
| Permission mode / rules | `permissionMode` / `permissionRules` | Wire records + config |
| Goal | `goal` | Wire records |
| Plan | `plan` | Wire records + plan file |
| Skill activation | `skill` | Wire records |
| Background tasks | `background` | Task records / output logs, candidate for entity service |
| Cron tasks | `cron` | Task records, candidate for entity service |
Entity-service conclusion for `agent`:
- ✅ Keep `IAgentLifecycleService` for Agent instance lifecycle.
- ✅ If a persisted Agent identity registry is ever needed, name it after that narrow concern, e.g. `IAgentInstanceRegistry`.
- ❌ Do not create `IAgentEntityService` or `IAgentDataService` that bundles profile, records, tools, permission, goal, plan, background, cron, and turn.
## Split conclusion — `turn`
`turn` is a Domain, but it is **not** currently a separate `LifecycleScope` in code; `ITurnService` is registered at `Agent` scope.
`turn` owns one execution round's runtime state and turn-level facts:
| Concern | Owner | Notes |
|---|---|---|
| Active `Turn` handle | `turn` | `id`, `abortController`, `ready`, `result` |
| Turn id allocation | `turn` | Restored from `turn.prompt` records and `context.append_loop_event` turn ids |
| Turn lifecycle hooks | `turn` | `onLaunched`, `onEnded`, `beforeStep`, `afterStep` |
| `turn.started` / `turn.ended` live events | `turn` | Live event stream |
`turn` must not own these:
| Data / capability | Real owner |
|---|---|
| Prompt and context messages | `contextMemory` |
| Append-only record log mechanics | `wireRecord` |
| Step loop | `loop` |
| Tool execution | `toolExecutor` / `tool` |
| Permission decisions | `permission` |
| External hook policy | `externalHooks` |
| Telemetry pipeline | `telemetry` |
| Event transport | `eventSink` |
Entity-service conclusion for `turn`:
- ✅ Keep `ITurnService` as a runtime orchestrator.
- ✅ Add a Turn read model / projection only if history queries are needed.
- ❌ Do not create `ITurnEntityService` with `create/update/delete/list` over a turn table as the authoritative model.
## Migration recipe
When moving data out of a v1 god object or reviewing a proposed EntityService:
1. **Name the data without using `Session`, `Agent`, or `Turn`.** If you cannot, the domain is probably unclear.
2. **Find the writer.** The exclusive writer is the likely owner.
3. **Find the invariant.** The Service that rejects invalid transitions owns the model.
4. **Classify the persistence model.** Atomic document, append-log, blob, query projection, registry, or runtime-only.
5. **Pick the Service shape.**
- Entity document / record → `I{Domain}EntityService` or domain-specific CRUD Service.
- Event-sourced → behavior Service + `wireRecord` record types + optional projection.
- Derived query → read-model Service, not a write authority.
- Runtime-only → scoped Service with no entity store.
6. **Choose the Scope by state identity.** Scope follows what the state is keyed by; it does not decide the domain name.
7. **Render the placement tree** from [design.md §7](design.md#7-render-the-placement-tree).
## Red lines (this topic)
- Scope is not a domain. `Session` / `Agent` scopes do not make data `session` / `agent` owned.
- Ownership follows write authority and invariants, not read consumption.
- Do not create `I{Scope}EntityService` bundles (`IAgentEntityService`, `ISessionEntityService`, `ITurnEntityService`) that re-merge multiple domains.
- Event-sourced domains keep behavior Services and append-log records; do not replace them with arbitrary CRUD.
- Read models may be shaped like a domain, but they are projections, not write authorities.
- A dependency is not ownership. A Service may inject another domain without owning that domain's data.

View file

@ -0,0 +1,181 @@
# Edge exposure — `resource:action` + WS events
How a domain's Services become the wire surface (`/api/v2`) and WebSocket events. This is a **design-time** decision: which Services are exposed, under what public `resource:action` name, and which events stream.
The transport (`/api/v2` over HTTP + WS) lives in the **edge** layer (`gateway`/`rpc`/`transport`). It borrows business Services by interface; business code never imports it.
## 1. The edge model
Three scopes, three URL shapes, one dispatcher:
```text
GET|POST /api/v2/:sa Core
GET|POST /api/v2/session/:session_id/:sa Session
GET|POST /api/v2/session/:session_id/agent/:agent_id/:sa Agent
```
`:sa` is a single path segment of the form `<resource>:<action>` (e.g.
`sessions:list`, `session:read`, `profile:getModel`).
- `:resource` is a **public** name (`sessions`, `session`, `profile`), never an internal domain token (`ISessionMetadata`).
- `:action` is the method. `GET` for reads, `POST` for writes.
- Body = the method's single argument (JSON), omitted for no-arg.
- Response = the project envelope `{ code, msg, data, request_id, details? }`.
- The dispatcher resolves the **scope** from the URL, the **Service** from an `actionMap`, calls the method, wraps the result.
```ts
// actionMap — the allowlist; hides internal domain names.
const actionMap = {
core: { 'sessions:list': { service: ISessionIndex, method: 'list' }, ... },
session: { 'session:read': { service: ISessionMetadata, method: 'read' }, ... },
agent: { 'profile:getModel': { service: IProfileService, method: 'getModel' }, ... },
};
```
The `actionMap` is the single allowlist: only mapped `resource:action` pairs are callable; unknown → `40001`.
## 2. What may be exposed directly
A Service method is directly exposable iff **all** hold:
1. Args are JSON-serializable (no live objects, `AbortSignal`, callbacks, resumer fns).
2. Return is JSON-serializable data or `void` (no `IScopeHandle`, `Turn`, `IProcess`, `AsyncIterable`, `IDisposable`, `Event`).
3. Errors are `KimiError` (coded).
4. It is a command/query, not a factory, stream, byte-store, or sink.
If any fail → wrap in a **facade** (a Service that takes ids, returns data, throws `KimiError`) and expose the facade. The repo already ships a wire-shaped facade in `rpc/core-api.ts` (`CoreAPI` / `SessionAPI` / `AgentAPI`) behind `IAgentRPCService` / `ISessionRPCService` — prefer building the HTTP edge on top of it rather than re-deriving a new one.
## 3. Per-scope `resource:action` map
Read = `GET`, write = `POST`. `sid` = `session_id`, `aid` = `agent_id`.
### Core (`/api/v2/:resource:action`)
| resource | action | Service.method | verb |
|---|---|---|---|
| `sessions` | `list` | ISessionIndex.list | GET |
| `sessions` | `get` | ISessionIndex.get | GET |
| `sessions` | `countActive` | ISessionIndex.countActive | GET |
| `workspaces` | `list` | IWorkspaceRegistry.list | GET |
| `workspaces` | `get` | IWorkspaceRegistry.get | GET |
| `workspaces` | `createOrTouch` | IWorkspaceRegistry.createOrTouch | POST |
| `workspaces` | `update` | IWorkspaceRegistry.update | POST |
| `workspaces` | `delete` | IWorkspaceRegistry.delete | POST |
| `config` | `get` / `getAll` / `inspect` | IConfigService.* | GET |
| `config` | `set` / `replace` / `reload` | IConfigService.* | POST |
| `providers` | `list` / `get` | IProviderService.* | GET |
| `providers` | `set` / `delete` | IProviderService.* | POST |
| `oauth` | `startLogin` / `cancelLogin` / `logout` | IOAuthService.* | POST |
| `oauth` | `getFlow` / `status` | IOAuthService.* | GET |
| `auth` | `summarize` | IAuthSummaryService.summarize | GET |
| `auth` | `ensureReady` | IAuthSummaryService.ensureReady | POST |
| `flags` | `snapshot` / `enabled` / `explain` / `explainAll` | IFlagService.* | GET |
| `fs` | `browse` / `home` | IHostFolderBrowser.* | GET |
| `meta` | `getEnv` / `detect` | IBootstrapService.* | GET |
### Session (`/api/v2/session/:sid/:resource:action`)
| resource | action | Service.method | verb |
|---|---|---|---|
| `session` | `read` | ISessionMetadata.read | GET |
| `session` | `update` | ISessionMetadata.update | POST |
| `session` | `setTitle` | ISessionMetadata.setTitle | POST |
| `session` | `setArchived` | ISessionMetadata.setArchived | POST |
| `session` | `status` | ISessionActivity.status | GET |
| `session` | `isIdle` | ISessionActivity.isIdle | GET |
| `session` | `archive` | ISessionLifecycleService.archive | POST |
| `approvals` | `listPending` | IApprovalService.listPending | GET |
| `approvals` | `decide` | IApprovalService.decide | POST |
| `questions` | `listPending` | IQuestionService.listPending | GET |
| `questions` | `answer` | IQuestionService.answer | POST |
| `interactions` | `listPending` | IInteractionService.listPending | GET |
| `interactions` | `respond` | IInteractionService.respond | POST |
| `workspace` | `setWorkDir` / `addAdditionalDir` / `removeAdditionalDir` / `resolve` | IWorkspaceContext.* | GET/POST |
### Agent (`/api/v2/session/:sid/agent/:aid/:resource:action`)
| resource | action | Service.method | verb |
|---|---|---|---|
| `goal` | `get` | IGoalService.getGoal | GET |
| `goal` | `create` / `pause` / `resume` / `cancel` | IGoalService.* | POST |
| `plan` | `status` | IPlanService.status | GET |
| `plan` | `enter` / `exit` / `cancel` / `clear` | IPlanService.* | POST |
| `tasks` | `list` / `get` / `readOutput` | IBackgroundService.* | GET |
| `tasks` | `stop` / `detach` | IBackgroundService.* | POST |
| `usage` | `status` | IUsageService.status | GET |
| `context` | `status` | IAgentContextSizeService.get | GET |
| `swarm` | `isActive` | ISwarmService.isActive | GET |
| `swarm` | `enter` / `exit` | ISwarmService.* | POST |
| `permission` | `getMode` | IPermissionModeService.mode | GET |
| `permission` | `setMode` | IPermissionModeService.setMode | POST |
| `permissionRules` | `list` | IPermissionRulesService.rules | GET |
| `permissionRules` | `addRules` | IPermissionRulesService.addRules | POST |
| `profile` | `get` / `getModel` / `getSystemPrompt` / `getActiveToolNames` | IProfileService.* | GET |
| `profile` | `setModel` / `setThinking` | IProfileService.* | POST |
| `messages` | `list` | IContextMemory.get | GET |
| `messages` | `splice` | IContextMemory.splice | POST |
| `toolStore` | `get` / `data` | IToolStoreService.* | GET |
| `toolStore` | `set` | IToolStoreService.set | POST |
| `mcp` | `list` | IMcpService.list | GET |
| `mcp` | `reconnect` | IMcpService.reconnect | POST |
| `tools` | `list` | IToolRegistry.list | GET |
## 4. Facade-needed (wrap before exposing)
These fail §2 and must be wrapped in a facade that takes ids and returns data:
| Service | Why not direct | Facade shape |
|---|---|---|
| ISessionLifecycleService | returns `IScopeHandle` | `sessions.create` / `fork` / `close` / `archive` → wire Session |
| IAgentPromptService / IAgentTurnService | returns `Turn` handle | `prompts.submit` / `steer` / `abort` / `undo` |
| ILLMRequester | `AsyncIterable` stream | stream over WS, not RPC |
| ISubagentHost | `SubagentHandle` | `subagents.spawn` / `resume` → info |
| IProcessRunner | `IProcess` streams | terminal (separate WS protocol) |
| Storage / Store (IFileSystemStorageService / IAppendLogStore / IAtomicDocumentStore / IBlobStore) | bytes / streams | not for RPC |
| IAgentFileSystem | `withCwd` handle | `fs.read` / `write` → text/bytes |
| IExternalHooksService | server-side outbound | not exposed |
| IWireRecord | write-ahead log | internal |
## 5. WS events
A single WebSocket endpoint multiplexes RPC `call`s and event `listen`s over a JSON protocol (the lean counterpart of VSCode's `IMessagePassingProtocol`, carrying the same safety features — see §6):
```text
WS /api/v2/ws
```
Client → server: `hello` (auth), `call` (scope + `resource:action` + arg), `cancel`, `listen` (scope + event), `unlisten`, `pong`.
Server → client: `ready`, `result`, `error`, `event`, `ping`.
`call` reuses the same dispatcher as the HTTP routes (scope + `actionMap`). `listen` subscribes to an `Event<T>` source and forwards each emission as an `event` message, keyed by the client-chosen `id`.
The `eventMap` binds a public event name to the scope's `Event` source (analogous to the `actionMap`):
| Scope | event | Source |
|---|---|---|
| Core | `events` | `IEventService.subscribe` (process-wide `DomainEvent` bus) |
| Agent | `events` | `IEventSink.on` (per-agent `AgentEvent` stream) |
Session-level `onDidChange` sources (metadata / interactions) carry no payload today, so they are not exposed until there is a concrete consumer.
Safety / reliability (carried over from `packages/server/src/ws/connection.ts` and VSCode's `ChannelServer`):
- request ids + active-request table — `cancel` / `unlisten` disposes them;
- heartbeat — `ping` every 30s, `pong` timeout 10s → `terminate`;
- schema validation — invalid frames are dropped, not fatal;
- graceful close — dispose listeners, cancel pending, reject in-flight calls;
- no stack traces over the wire;
- non-serializable event payloads are dropped, never fatal.
Cursor / replay / resync for events is a future addition (a separate `call` before `listen`); the raw stream is the foundation.
## 6. Red lines (edge exposure)
- Never expose an internal domain token (`ISessionMetadata`) as a URL segment — use a public `resource` name + `action`.
- Never expose a method that returns a handle / stream / bytes / disposable — wrap in a facade.
- Never expose a method that takes a live object / `AbortSignal` / callback / resumer fn — wrap in a facade.
- Session / Agent Services are reached by `accessor.get` with the id from the URL — never cache the result; finish before the scope disposes.
- The `actionMap` is the allowlist — only mapped `resource:action` pairs are callable; unknown → `40001`.
- Events stream over WS (`listen`), never RPC (`call`).
- Business code never imports the edge (`gateway` / `rpc` / `transport`) — the edge borrows business Services by interface.
- Read = `GET`, write = `POST`; do not overload `POST` for reads when caching / browser-friendliness matters.

View file

@ -0,0 +1,40 @@
# Topic — Errors
Error infrastructure for agent-core-v2: base classes, the per-domain code contract, wire serialization, and the conventions domains follow when raising errors. The package-level reference is `packages/agent-core-v2/docs/errors.md`; this topic summarizes the hot-path rules.
Base classes and serialization are **centralized** in `_base/errors`; error **codes** are **decentralized** — each domain owns an `errors.ts` that self-registers its codes and metadata, and the `src/errors.ts` facade aggregates them into the unified `ErrorCodes` const.
## Where things live
- `src/_base/errors/errors.ts`: base classes — `Error2`, `ExpectedError`, `ErrorNoTelemetry`, `BugIndicatingError`, `NotImplementedError`, plus `isError2` and `unwrapErrorCause`.
- `src/_base/errors/codes.ts`: the `ErrorDomain` contract, the `ErrorCode` type (aliased to the protocol's `KimiErrorCode`), the registry (`registerErrorDomain` / `errorInfo` / `isErrorCode`), and `CoreErrors` (`internal`, `not_implemented`).
- `src/_base/errors/serialize.ts`: `ErrorPayload`, `isCodedError`, `toErrorPayload`, `fromErrorPayload`. Wire-facing names (`KimiErrorPayload`, `toKimiErrorPayload`) mirror the protocol and are kept as-is.
- `src/_base/errors/unexpectedError.ts`: `onUnexpectedError` / `setUnexpectedErrorHandler` (global handler).
- `src/<domain>/errors.ts`: the domain's `XxxErrors` descriptor (codes + retryable list + per-code info overrides), self-registered on import.
- `src/errors.ts`: the **facade** — imports every domain's `errors.ts`, builds `ErrorCodes`, re-exports the primitives. Throw sites import from here.
## Conventions (hard rules)
- **Throw a coded error, not a bare string.** `throw new Error2(ErrorCodes.X, …)`. Bare `new Error` only for unreachable guards; `BugIndicatingError` for caller bugs; `NotImplementedError('feature')` for stubs.
- **Define codes in the owning domain**, in `<domain>/errors.ts` as an `XxxErrors` descriptor (`satisfies ErrorDomain` + `registerErrorDomain`), then wire it into the facade. Never add domain codes to `_base/errors`.
- **One `code` per failure mode.** Codes read `domain.reason`. The valid code strings are fixed by the protocol (`KimiErrorCode` in `packages/protocol/src/events.ts`): **add new codes to the protocol first**. Renaming/removing a code is a major.
- **Translate foreign errors at the boundary.** Provider/HTTP, fs, MCP errors are re-thrown as the owning domain's coded error. `_base/errors` never imports a business domain.
- **Translation is idempotent and cause-preserving.** Translators (`toHostFsError`, `toStorageIoError`) pass through an already-translated error and always keep the original as `cause`.
- **`details` is structured and JSON-serializable; `message` is a short human sentence.** Paths/errnos/scope/key go into `details`, not the message.
- **Cancellation passes through untranslated** (`UserCancellationError` from `_base/utils/abort`) — apply only at boundaries that can actually see cancellation; do not sprinkle the check everywhere.
- **Classify wrapped errors via `unwrapErrorCause`** — errno/status predicates test the unwrapped cause, not the coded wrapper.
- **Branch on `code`, never `instanceof`, across the wire.** In-process, `instanceof Error2` / `isCodedError` are fine.
## Reference tiers
- `os.fs``HostFsError` via `toHostFsError` (`os/interface/hostFsErrors.ts`): errno → `os.fs.*`, details `{ path, op, errno?, syscall? }`.
- `os.process``HostProcessError`: `spawn_failed` / `kill_failed`, raw error as `cause`.
- `storage``StorageError` (`persistence/interface/storage.ts`): `not_found` / `decode_failed` / `corrupted` / `io_failed` (retryable) / `locked` (retryable). ENOENT keeps absence semantics, never an error. A locked query store throws `storage.locked`; consumers catch it explicitly and fall back — no silent no-op degradation.
- `wire``WireError` (`wire/errors.ts`): `DuplicateOpError`, `CycleError`, and `wire.unknown_record` (replay skips unknown records, reports via `onUnexpectedError`, returns `{ unknownRecords }`).
## Red lines (this topic)
- Throw a coded error with a `code`, not a bare string (except unreachable guards / `BugIndicatingError` / `NotImplementedError`).
- Codes live in the owning domain's `errors.ts` and self-register; new codes land in the protocol first.
- Translate foreign errors at the owning domain's boundary, idempotently, with `cause` and structured `details`; `_base/errors` never imports a business domain.
- Branch on `code` across the wire, never `instanceof`.

View file

@ -0,0 +1,108 @@
# Topic — Flags
Experimental feature-flag gating for agent-core-v2 — an App-scope `IFlagService` resolver plus a writable `IFlagRegistry` catalog that domains contribute their flags to, backed by the `[experimental]` config section.
Gate not-yet-public features behind `IFlagService.enabled(id)`, per the repository hard rule that unreleased behavior must be flag-gated. v1 was a process-global `FlagResolver` singleton over a central `FLAG_DEFINITIONS` array; v2 is a scoped DI service whose flag definitions are registered **decentrally** by each owning domain — there is no central catalog to edit.
## Layout
- `src/flag/flagRegistry.ts``IFlagRegistry` token + `FlagDefinitionInput` / `FlagId` / `FlagSurface` types + `registerFlagDefinition` / `getContributedFlags` (import-time contribution queue).
- `src/flag/flagRegistryService.ts``FlagRegistryService` impl; in-memory catalog seeded from import-time contributions; App scope.
- `src/flag/flag.ts``IFlagService` token + resolver types (`ExperimentalFlagMap`, `ExperimentalFlagConfig`, `ExperimentalFlagSource`, `ExperimentalFeatureState`) + `ExperimentalConfigSchema` / `ExperimentalConfig` (zod).
- `src/flag/flagService.ts``FlagService` impl + `MASTER_ENV` (`KIMI_CODE_EXPERIMENTAL_FLAG`) + `EXPERIMENTAL_SECTION` (`experimental`); reads definitions from `IFlagRegistry`; self-registers at App scope.
- `src/flag/index.ts`**removed (no barrel)**; `src/index.ts` imports the `flag` leafs precisely instead (e.g. `import './flag/flagService'`).
- `src/<domain>/flag.ts` — each domain that owns a flag declares it here and calls `registerFlagDefinition` at the module top level (e.g. `src/multiServer/flag.ts`). The directory already names the domain, so the file is just `flag.ts`.
## Public surface
- `IFlagService` (DI token, App scope): `enabled(id)`, `explain(id)`, `snapshot()`, `enabledIds()`, `explainAll()`, `setConfigOverrides(overrides)`, `registry`.
- `IFlagRegistry` (DI token, App scope): `register(definition)`, `get(id)`, `list()` — writable catalog. `register` is the **runtime** path (tests, dynamic registration); `IFlagService.registry` exposes the same instance for hosts/UI to enumerate flags without resolving them.
- `registerFlagDefinition(definition)` — the **import-time** path. Domains call this from their `flag.ts` top level; contributions are queued and drained by `FlagRegistryService` when it is instantiated.
- `FlagService` / `FlagRegistryService`: exported for tests and hosts that construct them directly.
## Resolution precedence
Highest wins; env is read live on every call (nothing cached):
1. Master env `KIMI_CODE_EXPERIMENTAL_FLAG` truthy → every flag on.
2. Per-feature `def.env` (e.g. `KIMI_CODE_EXPERIMENTAL_MY_FEATURE`) → forces on/off.
3. `[experimental]` config section per-flag override.
4. Registry `default`.
`explain(id)` returns the winning `source` (`master-env` | `env` | `config` | `default`) plus the effective `configValue`. `explain(id)` returns `undefined` (and `enabled(id)` returns `false`) for an id that no domain has registered.
## Config integration
- `FlagService` registers the `[experimental]` section into `IConfigRegistry` at construction (`registerSection('experimental', ExperimentalConfigSchema)`) and reads overrides from `IConfigService`.
- It subscribes `IConfigService.onDidChange` and refreshes overrides whenever the `experimental` domain changes, so config edits apply live.
- `IConfigRegistry.registerSection` throws if a domain is registered twice — `experimental` is owned exclusively by `FlagService`.
- `setConfigOverrides(overrides)` is an imperative escape hatch for tests and hosts without an `IConfigService`; hosts on `IConfigService` should set the `[experimental]` section instead.
Config shape:
```toml
[experimental]
my_feature = false
```
Keys are intentionally loose (`z.record(z.string(), z.boolean())`), so obsolete flags stay inert config.
## Add a flag
Declare the definition in the owning domain's `flag.ts` and call `registerFlagDefinition` at the module top level. There is no central catalog to edit.
`src/<domain>/flag.ts`:
```ts
import { type FlagDefinitionInput, registerFlagDefinition } from '#/flag';
export const myFeatureFlag: FlagDefinitionInput = {
id: 'my_feature',
title: 'My feature',
description: '...',
env: 'KIMI_CODE_EXPERIMENTAL_MY_FEATURE',
default: false,
surface: 'both',
};
registerFlagDefinition(myFeatureFlag);
```
Then ensure the package entry `src/index.ts` imports the flag leaf precisely so the top-level call runs at import time — there is no `src/<domain>/index.ts` barrel:
```ts
// src/index.ts
import './<domain>/flag';
```
`src/index.ts` imports every domain's leaf files precisely (one line per leaf), so the contribution runs during bootstrap, before any scope is created — and therefore before any consumer resolves `IFlagService`.
- `env` must start with `KIMI_CODE_EXPERIMENTAL_`, be unique, and not equal `KIMI_CODE_EXPERIMENTAL_FLAG`.
- `id` must not be `flag`. A duplicate `id` throws when `FlagRegistryService` drains the contributions.
- `FlagId` is `string`, not a literal union: with no central catalog there is nothing to derive it from, so `enabled()` has no compile-time typo-checking. Cover gated behavior with tests instead.
- `surface`: `core` | `tui` | `both` (documentation/grouping only; not used in resolution).
## Consume a flag
Inject `IFlagService` and gate on it. It is resolvable from any scope (App ancestor):
```ts
constructor(@IFlagService private readonly flags: IFlagService) {}
// ...
if (!this.flags.enabled('my_feature')) return;
```
## Layering & scope
- Domain `flag` is registered at **L3**. It imports only `config` (L2) downward.
- It cannot live in `_base` (L0): registering/reading the config section requires importing `config`, and L0 must not import L2.
- Scope: `IFlagRegistry` and `IFlagService` are both `App`. Env + config are process-global inputs, so there is no per-session/agent state. Flag definitions are contributed at **import time** (top-level `registerFlagDefinition` calls), so they are queued before any scope is created and drained when `FlagRegistryService` is first instantiated — before `IFlagService` is first resolved.
- Tests build `FlagService` + `FlagRegistryService` directly with a real `ConfigRegistry`/`ConfigService` and an injected env map, then `register` the flags they exercise.
## Red lines (this topic)
- Gate unreleased behavior behind a registered flag; no ad-hoc env toggles.
- Contribute each flag from the **owning domain's** `flag.ts` (`src/<domain>/flag.ts`) via a top-level `registerFlagDefinition` call; there is no central catalog to edit. The directory names the domain, so the file is just `flag.ts`.
- `env` must start with `KIMI_CODE_EXPERIMENTAL_`, be unique, and not equal `KIMI_CODE_EXPERIMENTAL_FLAG`; `id` must not be `flag`.
- `FlagId` is `string` (decentralized registration) — do not reintroduce a central `FLAG_DEFINITIONS` array or a derived literal union.
- `flag` lives at L3 and `App` scope — never in `_base`, never per-session.

View file

@ -0,0 +1,258 @@
# Stage 3 — Implement
Write the contract leaf, implementation leaf (with its registration), and the package-entry lines that load them. Each section below introduces one DI building block as you need it. Source lives in `src/_base/di/`.
## Standard recipe for a new `IXxxService`
1. **Contract leaf**`src/<domain>/<domain>.ts`: interface (with `_serviceBrand`) + `createDecorator` identity.
2. **Impl leaf**`src/<domain>/<domain>Service.ts`: class with `@IX` constructor deps; top-level `registerScopedService(scope, IX, Impl, type, '<domain>')`.
3. **Entry**`src/index.ts`: load each leaf precisely — `export * from './<domain>/<domain>';` for the contract and `import './<domain>/<domain>Service';` for the impl (importing the impl runs the registration). **No `src/<domain>/index.ts` barrel.**
4. **Tests** — see test.md.
There is **no central wiring file**: bindings live in each domain's impl file and are collected through import side effects.
## §1 Interface + identity (a global service, no deps)
```ts
// greet/greet.ts
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface IGreeter {
readonly _serviceBrand: undefined; // type marker: tells DI "this is a service"
hello(): string;
}
export const IGreeter: ServiceIdentifier<IGreeter> = createDecorator<IGreeter>('greeter');
```
`createDecorator(name)` produces a `ServiceIdentifier` that is three things at once: a runtime key, a parameter decorator, and a compile-time carrier of the `IGreeter` type.
> **The identity name is globally unique.** `createDecorator` caches by `name`; two domains using the same string collide and share one identity.
```ts
// greet/greetService.ts
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IGreeter } from './greet';
export class Greeter implements IGreeter {
declare readonly _serviceBrand: undefined; // mirrors the interface marker
hello(): string { return 'hi'; }
}
registerScopedService(
LifecycleScope.App, // lifetime: process-wide
IGreeter, // identity
Greeter, // implementation
InstantiationType.Eager, // when to construct: immediately
'greet', // domain name (for diagnostics)
);
```
The scope a class binds to is an **intrinsic property of the class**, decided at the registration point, not the call site.
The impl's top-level `registerScopedService` runs as soon as the module is imported. There is no `greet/index.ts` barrel — instead, add the leafs to the package entry `src/index.ts`, one line per leaf:
```ts
// src/index.ts
export * from './greet/greet';
import './greet/greetService'; // this import runs registerScopedService
```
Anyone can now `accessor.get(IGreeter)` the single global instance.
## §2 Constructor injection (your service uses others)
```ts
export class SessionMetadata extends Disposable implements ISessionMetadata {
declare readonly _serviceBrand: undefined;
constructor(
@ISessionContext private readonly ctx: ISessionContext,
@IAtomicDocumentStore private readonly store: IAtomicDocumentStore,
@ILogService private readonly log: ILogService,
) {
super();
}
}
```
`@ISessionContext` records "parameter 0 needs `ISessionContext`" on the class metadata; the container fills it when constructing.
Three inviolable constraints:
1. **Do not `new` a class with `@IService` deps**`new` bypasses registration, scope, and the singleton cache. Inject with `@IX` or `accessor.get(IX)`.
2. **`@IX` decorates constructor parameters only.** Decorating a field/method throws at runtime.
3. **Parameter order depends on how the object is built** — for `createInstance` non-singletons, static params come first (see §7); for scoped services, `@IX` params are conventionally first and any static params need defaults. See service-authoring.md §constructor-conventions.
Consumers resolve by interface and never import the impl class:
```ts
const meta = accessor.get(ISessionMetadata); // type is ISessionMetadata
```
> If you need "a config" rather than "a service", model it as a service (e.g. `IConfigService`) and inject it. If you need a per-turn, parameterized, non-singleton object, see §7.
## §3 Scoped registration (not global)
Swap the `scope` argument to bind to a different tier:
```ts
registerScopedService(LifecycleScope.Session, ISessionMetadata, SessionMetadata, InstantiationType.Delayed, 'sessionMetadata');
```
Remember the visibility rule from orient.md: a service may inject services from its own scope or any ancestor; never from a descendant.
## §4 Releasing resources (`Disposable`)
For a service that subscribes to events, starts timers, or holds handles:
```ts
import { Disposable } from '#/_base/di/lifecycle';
export class WSBroadcastService extends Disposable implements IWSBroadcastService {
declare readonly _serviceBrand: undefined;
constructor(@IEventService event: IEventService) {
super();
this._register(event.subscribe(() => { /* … */ })); // collect child resources
}
}
```
- Extend `Disposable`, collect any `IDisposable` with `this._register(d)` (event subscriptions, `toDisposable(fn)`, etc.).
- The container calls `dispose()` automatically when the service is torn down; child resources release in turn.
- Disposal order is deterministic (orient.md): child scopes first, then reverse construction order within a scope.
## §5 Eager vs delayed instantiation
```ts
// Eager: constructed when the scope is created
registerScopedService(LifecycleScope.App, ILogService, LogService, InstantiationType.Eager, 'log');
// Delayed: constructed on first get
registerScopedService(LifecycleScope.App, IScopeRegistry, ScopeRegistry, InstantiationType.Delayed, 'gateway');
```
A `Delayed` service returns a **Proxy** that constructs the real instance on first property access. Listeners registered on its `onDid…` / `onWill…` events before construction are not lost — the container records them and replays the subscriptions once the instance exists.
> Rule of thumb: `Eager` for dependency-free, frequently-used, or "early side effect" services (e.g. `ILogService`); default to `Delayed` otherwise.
## §6 Using a service inside a plain function (`invokeFunction`)
When you do not want a new class and just need a service once, or when you expose a `ServicesAccessor` to the outside:
```ts
const accessor: ServicesAccessor = {
get: <T>(id: ServiceIdentifier<T>): T => instantiation.invokeFunction((a) => a.get(id)),
};
```
`invokeFunction(fn)` hands `fn` a `ServicesAccessor` valid **only during that call**.
> **The accessor is valid only during the invocation.** Calling `accessor.get()` after `invokeFunction` returns throws `"service accessor is only valid during the invocation"`. Do not stash it for async use — inject the service in the constructor (§2) if you need it long-term.
## §7 Creating a non-singleton object with deps (`createInstance`)
For a per-turn executor that also has `@IService` deps:
```ts
class TurnRunner {
constructor(
private readonly input: string, // static param: passed by caller
private readonly turn: number, // static param: passed by caller
@ILogService private readonly log: ILogService, // service param: injected by container
) {}
}
const runner = instantiation.createInstance(TurnRunner, 'hello', 1);
```
Static params come first (you pass them), service params follow (the container fills them), then `Reflect.construct` builds the instance. This object is **not** placed in any scope's singleton cache — every call is a fresh instance.
> This is why service params must follow static params **for `createInstance`**: the container sorts by the parameter positions recorded via `@IX`. `_serviceBrand` lets the compiler tell the two kinds apart. Scoped services built by `registerScopedService` follow a different convention (`@IX` params first, optional static params after) — see service-authoring.md §constructor-conventions.
## §8 Spawning a child scope / child container
For a service that "starts a new session / agent" and needs a child scope, inject `IInstantiationService` itself (every container binds itself as `IInstantiationService`):
```ts
export class ScopeRegistry implements IScopeRegistry {
declare readonly _serviceBrand: undefined;
constructor(@IInstantiationService private readonly instantiation: IInstantiationService) {}
createSession(opts: CreateSessionOptions): Promise<IScopeHandle> {
const collection = new ServiceCollection();
for (const entry of getScopedServiceDescriptors(LifecycleScope.Session)) {
collection.set(entry.id, entry.descriptor); // collect Session-tier descriptors
}
const child = this.instantiation.createChild(collection); // spawn child container
const accessor: ServicesAccessor = {
get: <T>(id: ServiceIdentifier<T>): T => child.invokeFunction((a) => a.get(id)),
};
const handle: IScopeHandle = { id: opts.sessionId, kind: LifecycleScope.Session, accessor };
this.sessions.set(opts.sessionId, handle);
return Promise.resolve(handle);
}
}
```
Key points:
- `getScopedServiceDescriptors(scope)` returns every descriptor registered at that tier; load them into a `ServiceCollection`.
- `instantiation.createChild(collection)` builds a child container whose parent pointer is the current container — so the child resolves upward to `App` services (the visibility rule).
- Expose the child to the outside by wrapping it in a `ServicesAccessor` via `invokeFunction` (§6).
> Higher-level code usually calls `Scope.createChild(kind, id)` (it does the "filter descriptors + build child" for you). Drop to the manual `ServiceCollection` form only when you need explicit control.
## §9 Cyclic dependencies (forbidden — refactor)
Business rule: **no cyclic dependencies.** The container rejects them; the correct response is to refactor, not to make it run.
### The container rejects synchronous cycles
If A needs B while being created and B needs A while being created, the container throws `CyclicDependencyError` with a `path` like `['A', 'B', 'A']`. Self-cycles (A depends on itself) are also rejected. This is a protection mechanism telling you the two services' responsibilities are mis-drawn.
### Why cycles are disallowed
- Scope layering makes normal dependencies a DAG (Agent → Session → App, resolving upward); a cycle is almost always a design smell.
- "Making the cycle happen to work" turns construction order into an implicit contract — hard to debug.
v2's stance: **the dependency graph must be acyclic.**
### How to refactor (in priority order)
1. **Extract a third service C.** Move the part A and B both need into C; let A and B both depend on C instead of each other. The most common fix.
2. **Decouple with an event.** If A only needs to know about a change in B, have B emit via `IEventService` and A subscribe, rather than A holding a reference to B.
3. **Re-partition scope.** One of them may belong at a different tier — moving it makes the cycle disappear.
### Delayed as a cycle-breaker (legacy escape hatch — forbidden)
A legacy mechanism lets a `Delayed` edge turn a "soft cycle" into a non-synchronous Proxy. **Do not use it to bypass cyclic dependencies** — it exists for historical compatibility, not to paper over your design. On `CyclicDependencyError`, refactor per the above.
## Interface cheat sheet
| Interface | Section | Role |
|---|---|---|
| `createDecorator<T>(name)``ServiceIdentifier<T>` | §1 | identity (runtime key + compile-time type + param decorator) |
| `@IService` | §2, §7 | declare a dependency on a constructor param |
| `registerScopedService(scope, id, ctor, type, domain)` | §1, §3, §5 | bind an impl to a lifetime tier |
| `ServicesAccessor.get(IX)` | §2, §6 | resolve an instance by interface |
| `IInstantiationService.invokeFunction(fn, …)` | §6, §8 | obtain a temporary accessor inside a function |
| `IInstantiationService.createInstance(ctor, …args)` | §7 | build a non-singleton object with deps injected |
| `IInstantiationService.createChild(collection)` | §8 | spawn a child container |
| `getScopedServiceDescriptors(scope)` | §8 | retrieve all descriptors registered at a tier |
| `Disposable` / `DisposableStore` / `IDisposable` | §4 | resource management and disposal |
| `Scope` / `LifecycleScope` | §3, §8 | the lifetime tree |
| `SyncDescriptor` | (tests / low-level) | package a constructor + static args into a pending descriptor |
> Legacy export (not used in v2, just recognize it): `refineServiceDecorator` is a VS Code leftover DI helper. v2 src/test has zero references; always use `registerScopedService`.
## Red lines (this stage)
- No `new` on a class whose constructor carries `@IService` deps — inject or `accessor.get(IX)`.
- `@IX` decorates constructor params only; parameter order depends on construction (static-first for `createInstance`, `@IX`-first for scoped services — see service-authoring.md).
- Both interface and impl carry `_serviceBrand`; the `createDecorator` name is globally unique.
- `ServicesAccessor` is valid only during `invokeFunction` — never stash it for async use.
- No cyclic dependencies — refactor (extract / event / re-scope); do not break the cycle with `Delayed`.

View file

@ -0,0 +1,111 @@
# Stage 1 — Orient
Understand the DI × Scope black box and the file conventions before touching business code.
## The DI black box
When writing business code you declare three things; the container handles the rest (when to construct, whether it is the same instance, ordering, disposal):
- **Who am I** — an identity that is both a runtime key and a compile-time type.
- **Whom do I need** — the dependencies that provide my capabilities.
- **How long do I live** — which lifetime tier I belong to.
Classes talk only to interfaces and never care how an implementation is constructed.
## The three `LifecycleScope` tiers
Lifetimes form a tree, from longest to shortest:
```text
App (0) process-wide, single global instance
└── Session (1) one session
└── Agent (2) one agent
```
```ts
export enum LifecycleScope {
App = 0,
Session = 1,
Agent = 2,
}
```
- A larger number = shorter life = closer to a leaf.
- "Singleton" means **one per scope**: `ILogService` is global once; each `Session` scope has its own `ISessionMetadata`.
- `kind` strictly increases along the parent→child direction.
### Visibility rule
A child scope sees its ancestors; a parent never sees its children. Resolution walks *up* the tree:
- ✅ An `Agent` service injects a `Session` or `App` service (found upward).
- ❌ An `App` service injects a `Session` service (the parent does not look down, and the child may not exist yet).
> **Short-lived may inject long-lived; never the reverse.** The tree structure enforces this — it is not a matter of discipline.
### Disposal order
Deterministic: **child scopes die first; within one scope, instances dispose in reverse construction order** (last constructed, first disposed). Business code declares which tier it lives in and never disposes by hand.
## The `(Ln)` layer number in headers
The `Ln` in a file-header identity line is the domain's **dependency layer** (L0L7), **not** its `LifecycleScope`. They are easy to confuse because both are small integers, but they answer different questions:
- `LifecycleScope` (App=0 / Session=1 / Agent=2) — **lifetime & visibility** (this stage).
- Dependency layer `Ln` (L0L7) — **who may import whom**: a domain at layer `L` may import only domains at layer `<= L`. Enforced by `lint:domain` from the authoritative `DOMAIN_LAYER` map in `scripts/check-domain-layers.mjs`.
So a Session-scoped service is not "L1" — e.g. `session` is Session-scoped but lives at **L6**. When you write the header, read the number from the layer map, not from the scope.
| Layer | Role | Representative domains |
|---|---|---|
| L0 | base infrastructure | `_base`, `errors`, `llmProtocol` |
| L1 | bridges & low-level capabilities | `log`, `telemetry`, `event`, `environment`, `bootstrap`, `storage` |
| L2 | data & cross-cutting capabilities | `records`, `wireRecord`, `config`, `provider`, `auth`, `workspaceRegistry` |
| L3 | registries & capabilities | `tool`, `toolRegistry`, `permission*`, `flag`, `skill`, `plugin` |
| L4 | agent behaviour | `turn`, `loop`, `prompt`, `profile`, `contextMemory`, `goal`, `plan`, `swarm` |
| L5 | async lifecycle | `background`, `mcp`, `cron`, `agentTool` |
| L6 | coordination | `session`, `agentLifecycle`, `sessionMetadata`, `interaction`, `terminal` |
| L7 | boundary / edge | `gateway`, `rpc`, `approval`, `question`, `*Legacy` |
## File-header comment convention
`packages/agent-core-v2/AGENTS.md` mandates a header-only comment style:
- **Header only.** Comments live solely in the top-of-file `/** */` block — never beside functions, methods, or statements. The code is the source of truth for *how*; the header states *what the module exposes and the responsibility it owns*.
- **Identity line first.** Start with `` `<domain>` domain (Ln) — <one-line role>. `` Keep an existing `(cross-cutting)` label as-is. Write the role as a responsibility ("drives the turn lifecycle"), not a symbol list.
- **Scope is in the filename.** `session*.ts` = Session, `agent*.ts` = Agent, no prefix = App (see service-authoring.md). State the same scope in the header so the two never drift.
- **Interface files** (`<name>.ts`) state the public contract + scope: which `IXxx` they define and what it is for.
- **Impl files** (`<name>Service.ts`) add collaborators + scope: list every imported cross-domain collaborator as a role ("persists records through `records`"); read scope from `registerScopedService(LifecycleScope.X, …)`.
- **Contribution files** (`<targetDomain>.ts` / `<what>.contrib.ts`) state what they register into the target domain (e.g. "registers the `log` config section into `config`").
- **Pure-function / `.types` / `.errors` files** state the responsibility only — they own no scoped state, so no scope line.
Impl file example (`sessionMetadataService.ts`):
```ts
/**
* `sessionMetadata` domain (L6) — `ISessionMetadata` implementation.
*
* Persists the session metadata document (`state.json`) through the `storage`
* access-pattern store (`IAtomicDocumentStore`), rooted at the `metaScope`
* namespace from `sessionContext`. Loads the existing document on
* construction (creating it on first run), and logs through `log`. Bound at
* Session scope.
*/
```
Contribution file example (`config.ts` inside `log/`):
```ts
/**
* `log` domain — registers the `log` config section into `config`.
*
* Owns the `log` section schema and its env overlay; imported for the
* registration side effect. Bound at App scope.
*/
```
## Red lines (this stage)
- Import via the `#/...` alias (mapped to `src/`); never reach into another domain's internals by relative path.
- Short-lived may inject long-lived; never the reverse.
- File-header comments describe role and scope only; never narrate implementation beside statements.

View file

@ -0,0 +1,206 @@
# Topic — Permission
The target design for the agent-core permission system. Read this when touching `permission`, `permissionMode`, `permissionRules`, or when adding a new permission dimension.
> **The permission system should be a composable, registrable chain of responsibility (a microkernel).** The kernel only runs the chain in order, first hit wins; concrete permission dimensions (policies) are contributed by their owning Domain Services through a registry; tools only declare standardized resource access (`accesses`) in `resolveExecution`, and generic dimensions consume that metadata.
>
> **Do not introduce Casbin** — the hard part here is *decision behavior* (continuations, side effects, RPC, state machines), not "match + scalar decision".
## 1. Problem definition
The permission system answers one question: **for each tool call, in the current agent and current mode — allow / deny / ask the user?** Three traits shape the architecture:
1. **Decisions carry behavior.** Returning `ask` is not an enum value — it is a workflow with an RPC round-trip, hooks, telemetry, state writes, and a continuation; returning `deny` may be the result of running an external hook.
2. **Heterogeneous policies.** Some check a tool-name set, some count same-batch `AgentSwarm` calls, some run a hook, some inspect the plan state machine — no uniform `(sub, obj, act)` shape.
3. **Multi-agent × multi-mode × external extension.** Different agents / modes need different permissions, and outsiders (org admins, plugins) must contribute rules or behavior in a decoupled way.
## 2. Current state (v1) at a glance
Code lives in `packages/agent-core/src/agent/permission/`.
- **Architecture: ordered chain of responsibility, first hit wins.** `PermissionManager` holds `PermissionPolicy[]`; evaluation iterates in order, the first non-`undefined` result wins.
- **`PermissionPolicyResult` is a behavior bundle, not a scalar:** `approve` (with `executionMetadata`), `deny` (with `message`), or `ask` (with `resolveApproval` / `resolveError` continuations).
- **11 dimensions, 19 policies**, hardcoded in `policies/index.ts#createPermissionDecisionPolicies()`. Order is a high-to-low safety cascade: external force → structural deny → state-machine deny → static deny → mode allow → session-memory allow → static ask → static allow → flow allow → sensitive-path ask → default allow → fallback ask.
- **Resource-access declaration:** tools declare accessed resources in `resolveExecution(input)` via `accesses` (`ToolAccesses`, currently `file` and `all`); generic dimensions read `context.execution.accesses`.
### v1 pain points the target design fixes
1. The chain is hardcoded — outsiders cannot contribute.
2. `mode` is an `if` inside each policy (`YoloModeApprove` / `AutoModeApprove` self-guard).
3. No per-agent chain entry point (only scattered `agent.type === 'sub'` checks).
4. No external extension point beyond the single `PreToolUse` hook slot.
## 3. Why not Casbin
- **`policy_effect` is unusable** — composition here is a fixed, intentionally hardcoded safety cascade; the real complexity lives in each policy's `evaluate` behavior, which a Casbin expression cannot absorb. Externally tunable safety knobs are already exposed via `mode` + allow/deny/ask rules.
- **Flexible priority is unusable** — there is no plugin injection point, no multi-subject/RBAC, and a fixed subject (agent/user), so priority collisions do not arise. Casbin's `(sub, obj, act)`, `g()`, and domains would idle.
- **Fundamental mismatch: decisions are not scalars.** `enforce()` maps a request to an effect; agent-core decisions are behavior bundles (continuations, side effects, synthesized results). Even if Casbin computed `ask`, the surrounding behavior would still need to be rewritten — Casbin would degrade to an enum generator.
- **When Casbin becomes worth it:** when the hard part is matching semantics itself — role inheritance, domain isolation, ABAC expressions, policies loaded from a DB. Not before.
## 4. Design-pattern placement
Permission orchestration is a layered combination, not a single pattern:
| Layer | Pattern | Role |
|---|---|---|
| Runtime decision | **Chain of Responsibility** | multiple candidates in order; first hit wins, rest short-circuit |
| Single handler | **Strategy** | each policy is an interchangeable "permission adjudication" algorithm |
| Assembly / external extension | **Plugin / Microkernel** | minimal kernel + explicit extension points + pluggable policies |
| Landing support | **Registry + Factory** | collect plugins; assemble the chain per `(agent, mode)` on demand |
Casbin = single Strategy + data-driven. This design = multiple Strategies + chain-of-responsibility composition. Behavior-heavy systems must choose the latter — behavior cannot be flattened into data rows.
## 5. Target design
### 5.1 Core principles
1. **The chain encodes "permission dimensions", not "tools".** Adding a tool does not lengthen the chain; only adding a dimension adds a node.
2. **Two contribution paths:** high-frequency trivial specifics go through the **data path** (rules); low-frequency new dimensions with behavior go through the **code path** (policies).
3. **Domain self-registration:** a domain that owns a dimension (plan/goal/swarm) registers its policy in DI, mirroring v2's existing "domain self-registers tools".
4. **Tools declare resources; generic dimensions consume them:** bash/write/read only declare `accesses`; file/security dimensions judge centrally.
### 5.2 Core abstractions
```ts
type Phase =
| 'guard' | 'user-deny' | 'mode' | 'session'
| 'user-ask' | 'default' | 'fallback';
interface PermissionPolicyEntry {
name: string;
phase: Phase;
modes?: PermissionMode[]; // declare which modes this applies in (no more in-evaluate if)
agentTypes?: AgentType[];
factory: (accessor: ServicesAccessor) => PermissionPolicy;
}
// App scope — collects every domain's registration
interface IPermissionPolicyRegistry {
register(entry: PermissionPolicyEntry): IDisposable;
list(): readonly PermissionPolicyEntry[];
}
```
`PermissionPolicyService` (Agent scope) changes from a hardcoded list to "assemble by `(agent, mode)`":
```ts
this.policies = registry.list()
.filter(e => !e.modes || e.modes.includes(mode))
.filter(e => !e.agentTypes || e.agentTypes.includes(agentType))
.sort(byPhaseThenRegistrationOrder)
.map(e => e.factory(accessor));
```
Key points:
- `modes` / `agentTypes` are **declarations** — they lift the `if (mode !== 'yolo') return` out of `YoloModeApprove` into metadata.
- `factory`, not `instance`: a node may depend on agent-scoped services (mode, rules) and must be instantiated in the Agent scope — symmetric to `IToolDefinitionRegistry` (App) storing factories and `IToolService` (Agent) instantiating tools.
- **Different `(agent, mode)` produce differently-shaped chains** — under yolo the ask/fallback phases are physically filtered out.
### 5.3 Two contribution paths
| What is being added | Path | Chain length |
|---|---|---|
| New tool, new org rule, new user preference ("deny `Bash(curl *)`") | **Data path**: add a `PermissionRule` to an existing node | unchanged |
| New cross-cutting behavior (custom approval UI, audit log, new mode) | **Code path**: register a new policy node | +1 |
Most growth goes through the data path — node count is bounded by "kinds of behavior"; rule count grows with specifics (rule matching is a cheap Set/glob).
### 5.4 Domain self-registration
Mirrors v2's "domain registers tools in its constructor". `PlanService` self-registers its dimensions:
```ts
// src/plan/planService.ts
constructor(@IPermissionPolicyRegistry registry: IPermissionPolicyRegistry) {
registry.register({ name: 'plan-mode-guard-deny', phase: 'guard',
factory: a => new PlanModeGuardDenyPolicy(a.get(IPlanService)) });
registry.register({ name: 'plan-mode-tool-approve', phase: 'mode',
factory: a => new PlanModeToolApprovePolicy(a.get(IPlanService)) });
registry.register({ name: 'exit-plan-mode-review-ask', phase: 'user-ask',
factory: a => new ExitPlanModeReviewAskPolicy(a.get(IPlanService), a.get(IPermissionModeService)) });
}
```
A complex domain may register a single **composite** node externally and run a small internal chain, hiding its internal order from the global chain.
### 5.5 Tools declare resources at runtime (`resolveExecution` / `accesses`)
In `resolveExecution(input)`, before execution, declare accessed resources with the `ToolAccesses.*` builders:
```ts
resolveExecution(args: WriteInput): ToolExecution {
const path = resolvePathAccessPath(args.path, { kaos, workspace, operation: 'write' });
return {
accesses: ToolAccesses.writeFile(path), // declares: write this file
approvalRule: literalRulePattern(this.name, path),
matchesRule: (ruleArgs) => matchesPathRuleSubject(ruleArgs, path, ...),
execute: () => this.execution(args, path),
};
}
```
Current resource types:
```ts
type ToolResourceAccess =
| { kind: 'file'; operation: 'read'|'write'|'readwrite'|'search'; path: string; recursive?: boolean }
| { kind: 'all' }; // non-enumerable side effects (pessimistic, globally exclusive)
```
Two complementary channels:
- **Enumerable resources** (write/read/edit/grep/glob) → use `accesses`; generic file dimensions cover them automatically.
- **Non-enumerable resources** (bash running arbitrary commands) → do not declare `accesses`; use the `matchesRule` DSL (e.g. `Bash(rm *)` globs by command string).
**kaos's role:** kaos is the execution-environment abstraction (fs/process/pathClass) used by the file dimension for path normalization and judgment — it is **not** the permission-dimension abstraction itself. Permission semantics live one layer above kaos, at "file access".
**v2 evolution:** extend the `ToolResourceAccess` union so non-file resources can be declared structurally:
```ts
type ToolResourceAccess =
| { kind: 'file'; operation: FileOp; path: string; recursive?: boolean }
| { kind: 'network'; operation: 'connect'; host: string }
| { kind: 'shell'; command: string }
| { kind: 'datastore'; operation: 'read'|'write'; table: string }
| { kind: 'all' };
```
Each new resource kind can pair with a generic dimension that consumes it; tools always only **declare**.
### 5.6 Dimension ownership
| Dimension | Owner (who registers) | Type |
|---|---|---|
| external hook veto | `externalHooks` domain | generic |
| tool-batch exclusivity | `swarm` domain | domain-specific (ships with the AgentSwarm tool) |
| runtime-mode posture | `permissionMode` domain | generic |
| plan-mode constraints | `plan` domain | domain-specific |
| goal-start approval | `goal` domain | domain-specific |
| static config rules | `permissionRules` domain | generic (data path) |
| session approval memory | `permissionRules` domain | generic |
| sensitive / special paths | generic "file-access/security" dimension | generic (consumes `accesses`) |
| tool intrinsic risk | core permission | generic (consumes tool declarations) |
| workspace write trust | generic "file-access/security" dimension | generic (consumes `accesses`) |
| fallback | core permission | generic |
Pattern: **specific dimensions ship with their owning domain + tool; generic dimensions register centrally and apply across tools via the declared `accesses`.**
## 6. Evolution path
Incremental, not big-bang:
1. **Registry + Composer (zero behavior change).** Replace the 19 hardcoded `new`s in v2 `PermissionPolicyService` with reads from `IPermissionPolicyRegistry`; register existing policies as-is. Immediately gain multi-agent/mode selectable chains and an external registration entry.
2. **Declarative modes.** Lift the mode guards in `YoloModeApprove` / `AutoModeApprove` into `modes` metadata.
3. **Sink domain dimensions.** Move registration of plan/goal/swarm policies into their owning domain service constructors.
4. **(On demand) extend resource types.** When non-file resources (network/DB/shell) need structural dimensions, extend the `ToolResourceAccess` union.
5. **(On demand) swap the matching kernel for Casbin.** Only when external rules genuinely need RBAC/ABAC semantics, swap the data-path rule-matching kernel for Casbin. Not before.
## Red lines (this topic)
- Do not introduce Casbin — decisions are behavior bundles, not scalar effects.
- The chain encodes dimensions, not tools: a new tool must not lengthen the chain.
- New specifics go through the data path (rules); only new behavior goes through the code path (a policy node).
- A domain that owns a dimension self-registers its policy in DI; do not centralize domain policies in core.
- Tools only declare `accesses`; generic dimensions consume them. kaos is the execution environment, not the permission abstraction.
- Use `factory` (Agent-scope instantiation), not `instance`, for registered policies.

View file

@ -0,0 +1,204 @@
# Topic — Persistence layering
How business code persists data in `agent-core-v2`: the three-layer model (`Store → Storage → backend`), the naming rules for each layer, and how to decide which layer a domain should depend on. Read this before adding any persistence to a domain.
A domain `I{Domain}EntityService` is a business facade over these layers, not a replacement for them. Before naming or bundling EntityServices by `session` / `agent` / `turn`, read [domain-boundaries.md](domain-boundaries.md).
## The three-layer model
Persistence is split into three layers, each hiding one kind of change:
```text
Business Service
│ inject
┌────────────────────────────────────────┐
│ Store (semantic layer) │ ← access-pattern facade
│ IAppendLogStore / IAtomicDocumentStore│ append-log / atomic-doc / blob
└────────────────────────────────────────┘
│ inject
┌────────────────────────────────────────┐
│ Storage (byte layer) │ ← byte primitives
│ IFileSystemStorageService │ read/write/append/list/delete
└────────────────────────────────────────┘
│ implements
┌────────────────────────────────────────┐
│ Backend (deployment-specific) │ ← File / Postgres / Redis / S3
│ FileStorageService / PostgresStorage │
└────────────────────────────────────────┘
│ uses
┌────────────────────────────────────────┐
│ Platform primitives │ ← hostFs / dbClient / redisClient
└────────────────────────────────────────┘
```
Each layer hides exactly one concern:
| Layer | Hides | Business code sees |
|---|---|---|
| **Store** | how an access pattern works (append-log reads, atomic-doc serialization) | "append this record" / "save this document" |
| **Storage** | byte primitives (atomic write, ordered append, prefix list) | `read/write/append/list/delete` over `(scope, key)` |
| **Backend** | deployment environment (file vs DB vs Redis vs S3) | nothing — chosen at the composition root |
## The one-sentence rule
> **Business code expresses *what* to store or fetch, never *how* to store it.**
If business code contains any "how to persist" detail, it has punched through the layer it should depend on:
| Business code contains | It has punched through | Depend on instead |
|---|---|---|
| `INSERT INTO …` / `SELECT …` | Storage + backend | a Store |
| file paths / `rename` / `fsync` | Storage | Storage or a Store |
| `JSON.parse` / `JSON.stringify` | Store (serialization) | `IAtomicDocumentStore` |
| append offsets / sequential cursors | Store (log semantics) | `IAppendLogStore` |
| `hash(data)` used as a key | Store (blob semantics) | `IBlobStore` |
| `pathe.join / relative / basename` on `homeDir` etc. | Bootstrap (path layout) | `IBootstrapService.scope(...)` / scope contexts |
| only `read/write/list/delete` on bytes | nothing — this is the byte layer | `IFileSystemStorageService` directly ✅ |
## Where scopes come from — `IBootstrapService` and scope contexts
Business code **never assembles scope strings from paths**. Scope strings come from three places:
1. **`IBootstrapService.scope(name)`** — well-known top-level scopes (`'config' | 'sessions' | 'blobs' | 'store' | 'logs' | 'cache' | 'credentials'`). App-scope, deployment-agnostic contract.
2. **`ISessionContext.scope(subKey?)`** — persistence scope rooted at the current session; `scope('agents/main')` etc.
3. **`IAgentScopeContext.scope(subKey?)`** — persistence scope rooted at the current agent; `scope('cron')`, `scope('blobs')` etc.
The bootstrap layer decides how each semantic scope maps to concrete addressing. In the file deployment, `FileBootstrapService` reads a `ResolvedEnvironment` (the paths bag) and returns homeDir-relative scopes; a server deployment could bind a different `IBootstrapService` implementation that maps `'sessions'` to a DB table without any business change.
```ts
// ❌ Wrong — path arithmetic on homeDir/sessionDir leaks the file layout
const scope = relative(bootstrap.homeDir, join(session.sessionDir, 'agents', agentId, 'cron'));
// ✅ Right — the agent already knows its own scope root
const scope = agentCtx.scope('cron');
```
Absolute paths (`sessionDir`, `agentHomedir`) are still available on `IBootstrapService` for the very small number of legacy APIs that expose on-disk paths (session log rotation, background task tail file). Prefer scope strings; ask before adding a new absolute-path caller.
## Which layer to depend on — decision tree
```text
Need to persist
├─ read-whole / write-whole, JSON-serializable?
│ └─ IAtomicDocumentStore
├─ append-only writes / sequential reads, independent records?
│ └─ IAppendLogStore
├─ large object, addressed by content hash?
│ └─ IBlobStore
├─ custom byte layout (index / cache / binary) that read/write/list cover?
│ └─ IFileSystemStorageService directly
├─ new, reusable access semantics (multi-field query / time-range / graph)?
│ └─ add a new Store; business depends on the Store
└─ business-specific, trivial, one or two lines?
└─ IFileSystemStorageService directly; if it grows, extract a private Store
```
## Naming — Store by access pattern, not by business
A Store abstracts an **access pattern**, not a business data type. Name it after the pattern so its reusability is obvious from the name.
| Access pattern | Store name | Backend examples |
|---|---|---|
| append-log (append / sequential read) | `IAppendLogStore` | `FileAppendLogStore` / `PostgresAppendLogStore` |
| atomic-document (read/write whole) | `IAtomicDocumentStore` | `FileDocumentStore` / `RedisDocumentStore` |
| blob (hash-addressed large object) | `IBlobStore` | `FileBlobStore` / `S3BlobStore` |
**Do not name a generic Store after a business concept.** `IRecordStore` / `IConfigStore` make a reusable access pattern look like a private store for one feature. Any domain that needs an append-log uses `IAppendLogStore`; any domain that needs an atomic document uses `IAtomicDocumentStore`.
**Exception — business-specific Stores are named after the business.** When a Store captures one domain's unique query semantics (not a generic access pattern), name it after the domain:
```text
ISessionIndex query / enumerate sessions by workspace ← business-specific
```
Test: is the Store's semantics a *generic access pattern* (append-log / atomic-doc / blob) or *one domain's unique query*? Generic → name by pattern; unique → name by domain.
## Storage — a filesystem-specific byte layer
The byte layer is a single `IFileSystemStorageService` interface (read / readStream / write / append / list / delete / watch / flush / close). As the name says, it is **filesystem-specific**: it exposes the two irreducible durable primitives a local filesystem implements optimally — atomic whole-value replacement (`write`, via tmp + rename) and ordered durable extension (`append`, via `open('a')`). The node-fs Store backends (`AppendLogStore`, `JsonAtomicDocumentStore`, `BlobStoreService`) are built on it.
```ts
export interface IFileSystemStorageService {
read(scope: string, key: string): Promise<Uint8Array | undefined>;
readStream(scope: string, key: string): AsyncIterable<Uint8Array>;
write(scope: string, key: string, data: Uint8Array, options?: { atomic?: boolean }): Promise<void>;
append(scope: string, key: string, data: Uint8Array, options?: { durable?: boolean }): Promise<void>;
list(scope: string, prefix?: string): Promise<readonly string[]>;
delete(scope: string, key: string): Promise<void>;
watch?(scope: string, key: string): Event<void>;
flush(): Promise<void>;
close(): Promise<void>;
}
```
Two backends implement it today, both bound at the composition root:
```ts
// Production — local filesystem rooted at homeDir
collection.set(IFileSystemStorageService, new FileStorageService(homeDir));
// Tests — in-memory backend seeded by the test harness
collection.set(IFileSystemStorageService, new InMemoryStorageService());
```
**Non-filesystem backends (Postgres, S3, Redis) do not implement this interface.** Atomic-rename and byte-append have no native equivalent in those stores, so they implement the **Store** interfaces directly via their own clients instead:
```ts
// Server profile — append-logs on Postgres, atomic documents on Redis.
// Each Store is backed by a native client; IFileSystemStorageService is not involved.
collection.set(IAppendLogStore, new PostgresAppendLogStore(db, 'records'));
collection.set(IAtomicDocumentStore, new RedisDocumentStore(redis, 'config'));
```
Use the `scope` parameter to express **business namespace** within a backend. Do not overload `scope` to route backends — bind a different Store implementation at the composition root instead.
## Store `acquire(scope, key)` — flush-on-dispose handle
Stores that buffer writes expose an `acquire(scope, key)` handle so a business can flush them on disposal:
```ts
export interface IAppendLogStore {
// …
/**
* Acquire a disposable handle for `(scope, key)`. Register it with your
* `Disposable` (via `this._register(...)`); when you are disposed, pending
* appends for that log are flushed. The shared store itself is not disposed.
*/
acquire(scope: string, key: string): IDisposable;
}
```
`IAppendLogStore.acquire` flushes the log's pending appends on dispose — it exists because `append` is fire-and-forget. `IAtomicDocumentStore.acquire` is a no-op today (atomic documents are durable on write) and exists for interface symmetry. Businesses that do not need flush-on-dispose simply do not call `acquire`.
## When the byte layer does not apply
`IFileSystemStorageService` covers only the local-filesystem byte primitives. It is not a universal storage abstraction:
- **Non-filesystem backends** (Postgres / S3 / Redis) implement the **Store** interfaces directly via native clients — they never implement `IFileSystemStorageService`.
- **Blobs** are a Store-level interface (`IBlobStore`) with their own backends; the node-fs `BlobStoreService` sits on `IFileSystemStorageService`, but an `S3BlobStore` would not.
- **A backend has a fast primitive the Store interface cannot express** (e.g. Postgres `COPY`) → as an exception, extend that backend's Store implementation directly. This is an exception, not the default.
## Platform primitives are deployment-coupled, not core abstractions
`hostFs` (local filesystem) is a **platform primitive** used only by local backends (`FileStorageService`, `LocalFileSystemBackend`, `LocalSkillCatalog`, `HostFolderBrowser`). It is **not** a core abstraction and must not appear in L2/L3 dependency graphs. A server deployment swaps those backends for DB / S3 implementations and never registers `hostFs`.
## Red lines (this topic)
- Business code never contains "how to persist" details (serialization / paths / SQL / append offsets) — if it does, drop a layer.
- Business code never assembles scope strings from paths (`pathe.join / relative / basename` on `homeDir` / `sessionDir` / …). Use `IBootstrapService.scope(name)` for well-known scopes, `ISessionContext.scope(subKey?)` for session-rooted scopes, and `IAgentScopeContext.scope(subKey?)` for agent-rooted scopes.
- Name generic Stores by access pattern (`IAppendLogStore` / `IAtomicDocumentStore` / `IBlobStore`), never by business concept (`IRecordStore` / `IConfigStore`).
- Business-specific Stores (unique query semantics) are named after the domain (`ISessionIndex`).
- `IFileSystemStorageService` is the filesystem byte-layer interface; non-filesystem backends implement the **Store** interfaces directly. Route backends by binding a different Store implementation at the composition root, not by overloading `scope`.
- `hostFs` is a local-only platform primitive; L2/L3 domains must not import `node:fs` or `hostFs` directly.
- Only the file-backed bootstrap (`FileBootstrapService`) and file backends import `pathe`; business domains do not.
- Do not create a pass-through `Store` that only forwards `read/write` — a Store must hide a real access-pattern concern, or it is noise; use `IFileSystemStorageService` directly instead.

View file

@ -0,0 +1,250 @@
# Subskill — Server align (expose `agent-core-v2` over `server-v2`)
Wire a v2 domain into `packages/kap-server`, and — when the endpoint is part of the established `/api/v1` wire contract — keep the wire shape **byte-for-byte compatible** with what released v1 clients expect. This is the server-side counterpart of [align.md](align.md): `align.md` ports v1 *business logic* into v2; this file exposes the v2 result over HTTP / WS, reusing the v1 wire contract where it already exists.
Use this when the task is "expose the new v2 Service on the server", "add a `/sessions/:sid/...` route to the `/api/v1` surface", or "keep server-v2 speaking the same `/api/v1` contract released clients rely on".
## The one-paragraph mental model
`server-v2` serves **two HTTP surfaces** off the same `agent-core-v2` scope tree:
- **`/api/v2/:sa`** — the native v2 RPC surface, driven by the `actionMap` allowlist (`packages/kap-server/src/transport/actionMap.ts`). One `resource:action` segment maps to one `Service.method`. New v2-native capabilities land here. See [edge-exposure.md](edge-exposure.md).
- **`/api/v1/...`** — the v1-compatible surface, hand-written routes in `packages/kap-server/src/routes/*.ts` that **implement the established v1 wire contract path-for-path and schema-for-schema**, mounted by `registerApiV1Routes.ts`. This surface IS the v1 contract now (the legacy v1 server is gone); it exists so existing v1 clients keep working against server-v2 unchanged.
The two surfaces can point at **different Services** for the same feature. v2's native `IAgentPromptService` serves `/api/v2`; a v1-shaped `IAgentPromptService` serves `/api/v1`. Keeping them separate is what lets v2's domain design stay clean while the wire stays compatible.
## Decision: which surface?
```text
Is the endpoint part of the established /api/v1 wire contract (protocol schema
+ released-client expectation)?
├─ YES → /api/v1 mirror route (this file, §schema-fidelity + §legacy-service).
│ Reuse the protocol schema; add a LegacyService if v2 semantics diverge.
└─ NO → /api/v2 native action (edge-exposure.md).
Add to actionMap, wrapping in a facade if the method fails §2 there.
```
A feature often needs **both**: the v1 mirror so old clients keep working, and the v2 action so new clients get the cleaner shape. Do them as two routes / two action-map entries over the same scope tree.
## The server-align workflow
```text
Pick surface → Read the v1 route (if any) → Reuse / add the protocol schema
→ Choose native Service vs LegacyService → Wire the route / actionMap entry
→ Map errors → Test against the v1 wire shape → Verify
```
### 1. Pick the surface
Apply the decision above. For a v1-matched endpoint, the **spec** is the protocol schema plus the existing mirror routes:
- `packages/protocol/src/rest/<resource>.ts` — the wire schema you must match.
- `packages/kap-server/src/routes/<resource>.ts` — the file you are writing (create it if missing); sibling route files show the conventions.
The protocol schema is the source of truth. Do not re-derive the wire shape from memory or from the v2 domain model.
### 2. Reuse (or add) the protocol schema
The wire schema lives in **`@moonshot-ai/protocol`** under `packages/protocol/src/rest/<resource>.ts` (e.g. `promptSubmissionSchema`, `promptListResponseSchema`, `configResponseSchema`). Every `/api/v1` route in `packages/kap-server` imports from it — that single import is what guarantees the server speaks the same shape released clients expect.
Actions:
- **Schema already in protocol** → import it in the server-v2 route and use it in `defineRoute` (`body`, `success.data`, error `dataSchema` / `detailsSchema`). Do **not** re-declare the schema inline in server-v2.
- **Schema missing in protocol** → add it to `packages/protocol/src/rest/<resource>.ts` first, with a `rest-<resource>.test.ts`, then consume it from the route. The protocol package is the source of truth; server-v2 never owns a v1 wire schema locally.
- **Schema exists but only v1 uses it** → move/keep it in protocol and import it into server-v2; do not fork a copy.
#### Schema-fidelity rule (the hard rule)
For a `/api/v1` endpoint, the request and response schemas **must be the established protocol schema** (or a strict superset):
- ✅ **Adding** an optional field is allowed (`field: z.string().optional()`). Old clients ignore it; new clients may send it.
- ❌ **Renaming** a field, **changing** its type, **tightening** its validation, or **changing its meaning** is a wire break — do not do it in a mirror route. If the v2 domain genuinely needs a different shape, that shape belongs on `/api/v2`, not on the `/api/v1` mirror.
- ❌ Re-declaring the schema inline in server-v2 (even if it "looks identical") is forbidden — it drifts. One schema, one home: `packages/protocol`.
Self-check: "would a released v1 client get a byte-identical envelope from `packages/kap-server` for this request?" If you cannot answer yes from the shared schema, the route is wrong.
### 3. Choose native Service vs LegacyService
Resolve the v2 Service that will back the route. Two cases:
**Case A — the v2 native Service already matches the v1 contract.** Use it directly. Most data/command Services (`IConfigService`, `IWorkspaceRegistry`, `IApprovalService`, `IQuestionService`, `IFileStore`, …) land here: the route is a thin adapter that resolves the scope, calls the method, and wraps the result. Examples: `routes/config.ts`, `routes/messages.ts`, `routes/questions.ts`, `routes/files.ts`.
**Case B — the v1 contract needs behavior that would distort the v2 domain.** Introduce a **`*LegacyService`** — an L7 edge adapter that implements the v1 contract **on top of** the v2 native Service, leaving the native Service untouched. The v2 native Service keeps serving `/api/v2`; the LegacyService serves `/api/v1`.
Reach for a LegacyService when **any** hold:
- The v1 endpoint carries state the v2 domain deliberately dropped (e.g. a FIFO queue, a `prompt_id`, idempotent `abort`/`steer`, auto-start-next).
- The v1 method returns a handle/stream that v2 wraps differently, and the v1 clients expect the old envelope shape.
- Matching v1 would force a `Map<sessionId, …>`-at-`App` anti-pattern or a scope/domain-direction violation into the native Service (see [align.md](align.md) red lines).
- The native Service's error set / return type would have to grow v1-only branches.
Do **not** put v1 quirks into the native v2 Service "to keep the route simple". That is the conflict this rule exists to prevent: the native Service serves the v2 architecture; the LegacyService serves the wire contract.
#### LegacyService recipe
A LegacyService is a normal v2 Service (service-authoring.md) with one extra convention: its contract is shaped by the **protocol** types, not by the v2 domain model.
```text
packages/agent-core-v2/src/<domain>Legacy/
├── <domain>Legacy.ts ← contract: protocol-typed interface + decorator
├── <domain>LegacyService.ts ← impl: delegates to the native v2 Service(s)
└── errors.ts ← v1-compatible error codes (KimiError codes)
```
Skeleton (matches `prompt/`):
```ts
// prompt.ts — contract shaped by @moonshot-ai/protocol
import type { PromptSubmitResult, PromptSubmission } from '@moonshot-ai/protocol';
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface IAgentPromptService {
readonly _serviceBrand: undefined;
submit(body: PromptSubmission): Promise<PromptSubmitResult>;
// ...the rest of the v1 contract, typed by protocol
}
export const IAgentPromptService: ServiceIdentifier<IAgentPromptService> =
createDecorator<IAgentPromptService>('agentPromptLegacyService');
```
```ts
// promptService.ts — impl delegates to the native v2 Service
constructor(@IAgentPromptService private readonly prompt: IAgentPromptService /*, ... */) {}
// submit() builds v2-native input, calls the native Service, projects the result
// back into the protocol PromptSubmitResult.
registerScopedService(
LifecycleScope.Agent, // scope = the lifetime of the legacy state
IAgentPromptService,
AgentPromptLegacyService,
InstantiationType.Delayed,
'prompt',
);
```
Conventions:
- **Name** the domain `<domain>Legacy` and the interface with the scope prefix, `I<Scope><Domain>LegacyService` (e.g. `prompt` / `IAgentPromptService`), per service-authoring.md.
- **Header comment** must say it is an `L7 edge adapter` and name both the v1 contract it implements and the native v2 Service it leaves untouched (see `prompt.ts`).
- **Scope** = the lifetime of the *legacy* state it holds (the `prompt` queue is per-agent → `LifecycleScope.Agent`). Apply [orient.md](orient.md) / [design.md](design.md) normally — a LegacyService is not exempt from scope rules.
- **Delegate, do not duplicate** business logic. The LegacyService translates the v1 contract into native-Service calls and translates results back; the real work stays in the native Service.
- **Contract types come from `@moonshot-ai/protocol`**, so the interface cannot drift from the wire shape.
### 4. Wire the route / actionMap entry
**For `/api/v1` (mirror):** add a route file under `packages/kap-server/src/routes/<resource>.ts` using `defineRoute`, then register it in `registerApiV1Routes.ts`. Resolve the scope from the URL (`session_id` → Session scope, agent → Agent scope via `IAgentLifecycleService.getHandle`), then `accessor.get(IX)` the native or Legacy Service. Match the established verbs, paths (`:sid` / `{session_id}`), and `parseActionSuffix` actions (`:steer`, `:abort`) exactly — sibling routes under `packages/kap-server/src/routes/` are the reference.
```ts
const route = defineRoute(
{
method: 'POST',
path: '/sessions/{session_id}/prompts',
body: promptSubmissionSchema, // ← from @moonshot-ai/protocol
params: sessionIdParamSchema,
success: { data: promptSubmitResultSchema }, // ← from @moonshot-ai/protocol
errors: {
[ErrorCode.SESSION_NOT_FOUND]: {},
[ErrorCode.SESSION_BUSY]: {},
[ErrorCode.PROMPT_ALREADY_COMPLETED]: { dataSchema: z.object({ aborted: z.literal(false) }) },
},
operationId: 'submitPrompt',
tags: ['prompts'],
},
async (req, reply) => {
try {
const result = await resolveLegacy(core, req.params.session_id).submit(req.body);
reply.send(okEnvelope(result, req.id));
} catch (error) {
sendMappedError(reply, req.id, error);
}
},
);
app.post(route.path, route.options, route.handler);
```
**For `/api/v2` (native):** add a `resource:action` entry to `actionMap` ([edge-exposure.md](edge-exposure.md) §3). If the method fails the direct-exposure rules (returns a handle / stream / bytes, takes a live object), wrap it in a wire-shaped facade first (`IAgentRPCService` / `ISessionRPCService`) and map to the facade — as `prompts:*` does via `IAgentRPCService`.
### 5. Map errors
The route translates domain `KimiError` codes into protocol `ErrorCode` numbers. Two registries must stay in sync:
- **Domain code** — register in `agent-core-v2/src/errors.ts` (`ErrorCodes`) and throw from the Service (errors.md). Co-located domain errors go in `<domain>Legacy/errors.ts` (e.g. `prompt.not_found`, `session.busy`).
- **Wire code** — register the matching number in `packages/protocol/src/error-codes.ts` and reference it in the route's `errors` map and `sendMappedError`.
```ts
function sendMappedError(reply, requestId, err) {
if (isKimiError(err)) {
switch (err.code) {
case 'session.not_found':
case 'agent.not_found':
return reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId));
case 'prompt.not_found':
return reply.send(errEnvelope(ErrorCode.PROMPT_NOT_FOUND, err.message, requestId));
// ...
}
}
return reply.send(errEnvelope(ErrorCode.INTERNAL_ERROR, String(err), requestId));
}
```
Match the v1 route's status codes and idempotent-conflict envelopes (e.g. `prompt.already_completed``40903` with `{ data: { aborted: false } }`). The error envelope is part of the wire contract — it is covered by the same schema-fidelity rule.
### 6. Test against the v1 wire shape
Add a `packages/kap-server/test/<resource>.test.ts` that boots the server and hits the route. Assert on the **envelope + protocol shape**, not on the v2 domain internals:
- success envelope `{ code: 0, data: <protocol shape>, request_id }`;
- each declared error envelope `{ code: <ErrorCode>, msg, data, request_id }`;
- the fields v1 clients read are present with the same names/types.
Where the route mirrors v1, the test is the regression guard for the schema-fidelity rule: if someone drifts the protocol schema or the projection, this test breaks.
### 7. Verify
- `pnpm -C packages/kap-server test` — server routes green.
- `pnpm -C packages/protocol test` — schema tests green (incl. any new `rest-*.test.ts`).
- `pnpm -C packages/agent-core-v2 test` — native + Legacy Service tests green.
- `pnpm -C packages/agent-core-v2 run lint:domain` — a LegacyService is still inside the domain layers (edge adapter, L7); it must not pull business code into the edge or invert scope direction.
- `pnpm -C packages/server-e2e ...` when a v1 parity scenario exists.
## Worked example — porting v1 `/sessions/:sid/prompts`
This is the reference alignment (commits `feat(server-v2): port v1 /sessions/:sid/prompts routes`, `feat(server-v2): return turn ids for prompt actions`). It shows all three decisions at once.
**The mismatch.** v1 `IPromptService` is a per-agent *scheduler*: it owns a FIFO queue, assigns `prompt_id`s, supports `steer`/`abort`, and auto-starts the next queued prompt when a turn settles. v2's native `IAgentPromptService` is a *turn driver*: a submission *is* a turn, there is no queue and no `prompt_id`. Forcing the queue into the v2 native Service would distort the v2 domain.
**The split.**
- `/api/v2` keeps the native shape — `prompts:submit` / `steer` / `undo` / `clear` / `cancel` map to `IAgentRPCService` (a wire facade over the v2 turn driver) in `actionMap`. The native `IAgentPromptService` is untouched.
- `/api/v1` gets an `AgentPromptLegacyService` (`prompt/`, `LifecycleScope.Agent`) that re-implements the v1 scheduler — queue, `prompt_id`, steer/abort, auto-start-next — **on top of** the native `IAgentPromptService`. The `/api/v1` routes consume the LegacyService.
**The schema.** Both surfaces import `promptSubmissionSchema` / `promptSubmitResultSchema` / `promptListResponseSchema` / `promptSteerRequestSchema` / `promptSteerResultSchema` / `promptAbortResponseSchema` from `@moonshot-ai/protocol`. The `/api/v1` and `/api/v2` routes are therefore compatible with released clients by construction; the LegacyService projects v2 turn results back into those protocol shapes.
**The errors.** v1 codes (`prompt.not_found`, `session.busy`, `prompt.already_completed`) are registered in `agent-core-v2` (`prompt/errors.ts`) and in `packages/protocol` (`error-codes.ts`), then mapped in the route's `sendMappedError` — including the idempotent `prompt.already_completed``40903 { data: { aborted: false } }`.
**The lesson.** When the v1 contract and the v2 domain disagree, add an adapter (LegacyService) at the edge; do not let the wire contract leak into the native domain. The two surfaces share the protocol schema but not the Service.
## Migration checklist
Before submitting a server-align change:
- [ ] Surface chosen deliberately: `/api/v1` mirror for a v1-matched endpoint, `/api/v2` for a new native capability (both if needed).
- [ ] For a `/api/v1` mirror, the route matches the established v1 contract (protocol schema + sibling routes) path-for-path, verb-for-verb, action-for-action.
- [ ] Request and response schemas come from `@moonshot-ai/protocol` (`packages/protocol/src/rest/<resource>.ts`); no inline re-declaration in server-v2.
- [ ] Existing schema fields are unchanged in name, type, and semantics; only optional fields added (if any).
- [ ] Native v2 Service left clean; v1-only behavior isolated in a `<domain>Legacy` / `I<Domain>LegacyService` edge adapter when the semantics diverge.
- [ ] LegacyService registered with the correct `LifecycleScope` and a header comment naming it an L7 edge adapter + the native Service it preserves.
- [ ] Domain error codes registered in `agent-core-v2`; wire codes registered in `packages/protocol`; route maps them in `sendMappedError`, matching v1's status codes and idempotent envelopes.
- [ ] Route resolves the scope from the URL by `accessor.get(IX)`; no cached scope; finishes before disposal.
- [ ] Tests assert the wire envelope + protocol shape; schema tests in `packages/protocol` added/updated.
- [ ] `lint:domain` passes; the LegacyService did not invert scope or domain direction.
## Red lines (this subskill)
- One wire schema, one home: `packages/protocol`. Never re-declare a v1 wire schema inline in server-v2.
- A `/api/v1` mirror route must keep every existing schema field's name, type, and semantics; only optional additions are allowed. A different shape belongs on `/api/v2`, not on the mirror.
- Do not distort the native v2 Service to satisfy a v1 quirk — add a `<domain>Legacy` edge adapter instead. The native Service serves the v2 architecture; the LegacyService serves the wire contract.
- A LegacyService is still a v2 Service: it follows scope, domain-direction, and DI rules. "Edge adapter" describes its role, not an exemption.
- The protocol schema (`packages/protocol/src/rest/<resource>.ts`) plus the existing mirror routes are the spec for a `/api/v1` route — match them; do not re-derive the wire shape from the v2 domain model or from memory.
- Register every new error code in **both** `agent-core-v2` and `packages/protocol`; an unmapped code is a wire break.
- Events stream over WS (`listen`), never over the REST mirror; do not invent REST polling for something v1 pushed as an event.

View file

@ -0,0 +1,341 @@
# Topic — Service authoring
How to write a Service in `packages/agent-core-v2`: file layout, naming, what goes in the contract vs the impl, interface style, constructor / field conventions, events, multi-Service domains, and the comment rules. This is the day-to-day reference for stage 3 (implement.md covers the DI *mechanics*; this file covers the *authoring details*).
## File layout
One folder per domain, **camelCase**: `session/`, `sessionActivity/`, `contextMemory/`, `toolDedup/`. Inside, six kinds of files:
```text
<domain>/
├── <name>.ts ← interface file: exactly one IXxx + its createDecorator + the types it owns
├── <name>Service.ts ← impl file: exactly one class + exactly one registerScopedService(...)
├── <concern>.ts ← pure function(s): no Service suffix, no class, no registration
├── <targetDomain>.ts ← contribution file (common): registers into another domain's extension point
├── <what>.contrib.ts ← contribution file (uncommon / ad-hoc)
└── <domain>.types.ts ← shared types that no single interface owns
```
- **Strictly one service per file.** An interface file holds exactly one injectable interface and exactly one `createDecorator(...)`; an impl file holds exactly one service implementation class and exactly one `registerScopedService(...)`. No exceptions for "tightly-coupled" groups: even same-scope collaborators each get their own `<name>.ts` + `<name>Service.ts` pair.
- **Scope is in the filename.** `session*.ts` = Session, `agent*.ts` = Agent, no scope prefix = App (see [Naming](#naming)). The header comment restates the same scope.
- A domain therefore has as many impl files as it has services (e.g. `logService.ts` for the App `ILogService`, `sessionLogService.ts` for the Session `ISessionLogService`). See [Multi-Service domains](#multi-service-domains).
The package entry `src/index.ts` imports and `export *`s every domain's leaf files precisely (one line per leaf), so importing the package still runs every `registerScopedService(...)` side effect — exactly as the old per-domain barrels did.
## Naming
### Interfaces and classes
| Artifact | Rule | Example |
|---|---|---|
| Interface | `I` + scope prefix + PascalCase domain + role suffix. Scope prefix: `Session` / `Agent` / none (= App). Role suffix is usually `Service`. | `ISessionLogService`, `IAgentLoopService`, `ILogService` (App) |
| Class | the interface name minus the leading `I`, plus `Service` if it does not already end in `Service`; `implements` the interface | `SessionLogService implements ISessionLogService`, `AppendLogStoreService implements IAppendLogStore` |
| Decorator string | lowerCamelCase of the interface name minus the leading `I`; **globally unique and stable** (it surfaces in `CyclicDependencyError.path` and "no service registered" errors) | `createDecorator<ISessionLogService>('sessionLogService')` |
| Model / non-service types | PascalCase, no `I` prefix | `SessionMeta`, `LogEntry`, `ConfigSection` |
The scope prefix makes a service's lifetime readable from its name. App services carry **no** prefix (App is the default, longest-lived tier); Session and Agent services always carry `Session` / `Agent`. The prefix applies to the interface, the class, and therefore the file names.
> Do **not** use the scope prefix to re-merge domains by lifetime. `IAgentEntityService`, `IAgentDataService`, and `ISessionEntityService` are still banned — the prefix marks lifetime, the rest of the name must still be the real owning domain (`IBackgroundTaskEntityService`, `ISessionMetadata`, `IPermissionRulesService`). See [domain-boundaries.md](domain-boundaries.md).
### File names
File names derive from the interface / class names so that scope and role are visible in the tree:
| File kind | Rule | Example (interface → file) |
|---|---|---|
| Interface file | interface name minus leading `I`, minus trailing `Service` if present; acronym-aware lowerCamelCase | `ISessionLogService``sessionLog.ts`; `IAppendLogStore``appendLogStore.ts`; `ILogService``log.ts` |
| Impl file | the class name; acronym-aware lowerCamelCase | `SessionLogService``sessionLogService.ts`; `AppendLogStoreService``appendLogStoreService.ts` |
| Pure-function file | the function / concern name; no `Service` suffix | `formatLogEntry.ts`, `levelEnabled.ts` |
| Contribution file (common) | the **target** domain name | `config.ts` (registers a config section), `tool.ts`, `flag.ts` |
| Contribution file (uncommon) | `<what>.contrib.ts` | `slackWebhook.contrib.ts` |
| Shared-types file | `<domain>.types.ts` | `log.types.ts` |
| Errors file | `<name>.errors.ts` | `appendLogStore.errors.ts` |
Acronym-aware lowerCamelCase lowercases a leading acronym as a group: `ILLMRequester``llmRequester.ts`, `IWSGateway``wsGateway.ts`, `IOAuthToolkit``oauthToolkit.ts`, `IAgentRPCService``agentRpcService.ts`.
Because the impl class always ends in `Service` and the interface file never does, the two files of one service never collide — even for `Store` / `Registry` / `Resolver` interfaces (`IAppendLogStore``appendLogStore.ts` + `appendLogStoreService.ts`).
## The contract file (`<domain>.ts`)
Holds the public surface of the domain. A typical contract:
```ts
/**
* `greet` domain (Ln) — one-line role.
*
* Defines the `Greeting` model and the `IGreeter` used by … Bound at … scope.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface Greeting { // model — no _serviceBrand
readonly message: string;
}
export interface IGreeter { // injectable service — carries _serviceBrand
readonly _serviceBrand: undefined;
hello(): Greeting;
}
export const IGreeter: ServiceIdentifier<IGreeter> =
createDecorator<IGreeter>('greeter');
```
What belongs here:
- **Model types** (`type` / `interface`) the domain exposes — `SessionMeta`, `LogEntry`, `ConfigSection`.
- **Service interface(s)** — the contract consumers depend on.
- **Decorator(s)** — one `createDecorator` per injectable service.
- **Helper types and pure functions** tightly bound to the contract — e.g. option bags, `satisfies`-checked seeds, predicate functions like `levelEnabled`.
### Which interfaces carry `_serviceBrand`
Only interfaces used as a **DI token** carry `readonly _serviceBrand: undefined`. Everything else does not:
- ✅ Service interface resolved via `@IX` / `accessor.get(IX)` → carries `_serviceBrand`.
- ❌ Base interface extended by a service (e.g. `ILogger` extended by `ILogService`) → no `_serviceBrand`.
- ❌ Plain model / data interface (`LogEntry`, `SessionMeta`) → no `_serviceBrand`.
```ts
export interface ILogger { // base interface — no brand
info(message: string): void;
}
export interface ILogService extends ILogger { // DI token — branded
readonly _serviceBrand: undefined;
setLevel(level: LogLevel): void;
}
```
## Interface style
- **Sync methods** return a concrete type; **async methods** return `Promise<T>`. Do not wrap a sync return in `Promise`.
- **Readonly fields** for immutable exposed state: `readonly ready: Promise<void>`, `readonly modelAlias: string | undefined`.
- **Optional members** with `?`: `flush?(): Promise<void>`, `close?(): Promise<void>`.
- **Generics** where the caller supplies the shape: `get<T = unknown>(domain: string): T`.
- **Extend** a base interface to share method groups: `interface ILogService extends ILogger`.
- **Events** as `readonly onDid…` / `onWill…` properties typed `Event<T>` — see [Events](#events).
```ts
export interface IConfigService {
readonly _serviceBrand: undefined;
readonly ready: Promise<void>;
readonly onDidChange: Event<ConfigChangedEvent>;
get<T = unknown>(domain: string): T;
set(domain: string, patch: unknown): Promise<void>;
reload(): Promise<void>;
}
```
## The impl file (`<domain>Service.ts`)
Holds the concrete class(es) and the top-level registration. A typical impl:
```ts
/**
* `greet` domain (Ln) — `IGreeter` implementation.
*
* … collaborators as roles ("logs through `log`") … Bound at App scope.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { ILogService } from '#/log';
import { type Greeting, IGreeter } from './greet';
export class Greeter implements IGreeter {
declare readonly _serviceBrand: undefined;
constructor(@ILogService private readonly log: ILogService) {}
hello(): Greeting {
this.log.info('hello');
return { message: 'hi' };
}
}
registerScopedService(LifecycleScope.App, IGreeter, Greeter, InstantiationType.Eager, 'greet');
```
What belongs here:
- **Imports**`InstantiationType` from `'#/_base/di/extensions'`; `LifecycleScope` + `registerScopedService` from `'#/_base/di/scope'`; collaborators via the `#/<domain>` alias; the contract's types + decorator via a relative `./<domain>` import.
- **Class**`XxxService implements IXxxService`, with `declare readonly _serviceBrand: undefined`.
- **Helper classes / functions** used only by this impl (e.g. a built-in writer, an `extractError` helper) — co-located in the same file.
- **Top-level `registerScopedService(...)`** — one per Service the file owns; importing the impl file runs the registration.
## Constructor conventions
- Declare every dependency with `@IX` on a constructor parameter.
- Use `private readonly` (or `protected readonly`) to store a used dependency as a field.
- For an injected dependency the class does **not** directly use (e.g. passed through, or only needed to force construction order), drop the visibility modifier and prefix with `_`: `@IEventService _event: IEventService`.
- Service parameters and static parameters may both appear; the ordering rule depends on how the object is created — see below.
### Parameter order: scoped service vs `createInstance`
- **`registerScopedService` services** — the container injects only the `@IX` parameters; any static parameters must have defaults and are left at their default when the container builds the instance. Order is therefore not enforced by the container, but the common style is **`@IX` parameters first, optional static parameters after**:
```ts
constructor(
@ILogWriterService protected readonly writer: ILogWriterService,
private readonly bound: LogContext = {},
level: LogLevel = 'info',
) {}
```
- **`createInstance` objects** (non-singletons built with `instantiation.createInstance(Ctor, …staticArgs)`) — static parameters **must come first**, service parameters after, because the caller passes the static prefix positionally:
```ts
constructor(
private readonly input: string, // static — passed by caller
@ILogService private readonly log: ILogService, // service — injected
) {}
```
### Factory methods
A scoped Service may expose a factory method that returns a **new** instance of itself (or a related class) with extra context bound — e.g. `ILogger.child(ctx)` returns `new LogService(this.writer, { …this.bound, …ctx }, this._level)`. This is not a DI violation: it is an explicit factory, not a request for the container to build a Service. Do not use it to circumvent scope or singleton semantics.
## Fields and state
- `private readonly` for fields set once at construction (injected deps, derived config).
- `private _name` (underscore prefix) for mutable private state: `private _level: LogLevel`.
- `readonly` public fields only for immutable exposed state; prefer a getter (`get level()`) when the value can change.
- Keep state minimal — a Service owns only the state that matches its scope's identity (design.md §2). Anything else belongs in a different Service.
## Events
v2 has two distinct event mechanisms. Pick by audience:
### `Event<T>` / `Emitter` — typed property on a Service
Use when a Service exposes a typed event its consumers subscribe to. Lives in `'#/_base/event'`.
```ts
// contract
import type { Event } from '#/_base/event';
export interface IConfigService {
readonly onDidChange: Event<ConfigChangedEvent>;
}
// impl
import { Emitter, type Event } from '#/_base/event';
export class ConfigService extends Disposable implements IConfigService {
private readonly _onDidChange = this._register(new Emitter<ConfigChangedEvent>());
readonly onDidChange: Event<ConfigChangedEvent> = this._onDidChange.event;
private notify(changed: ConfigChangedEvent): void {
this._onDidChange.fire(changed);
}
}
```
Conventions:
- Back the public `Event<T>` with a private `Emitter<T>`, registered with `this._register(...)` so it disposes with the Service.
- Naming: `onDid…` for "happened" (past tense, after the fact); `onWill…` for "about to happen" (may allow `waitUntil` participation / veto — see `AsyncEmitter` / `IWaitUntil` in `'#/_base/event'`).
- The Delayed-instantiation Proxy preserves early `onDid…` / `onWill…` subscriptions (implement.md §5).
### `IEventService` — global pub-sub bus
Use to broadcast protocol events across domains. Lives in `'#/event'`.
```ts
export interface IEventService {
readonly _serviceBrand: undefined;
publish(event: ProtocolEvent): void;
subscribe(handler: (event: ProtocolEvent) => void): IDisposable;
}
```
Inject `@IEventService` and `publish(...)`; `subscribe(...)` returns an `IDisposable` to register with `this._register(...)`. This is the bus for "a fact happened, react if you care" (design.md §4) — not for typed per-Service events.
## Multi-Service domains
A domain may define several Services. Each Service gets its own pair of files regardless of scope or coupling:
- **One pair per Service**`<name>.ts` for the contract + `<name>Service.ts` for the implementation.
- **Different scopes** → the scope prefix in the Service name makes this obvious (`logService.ts` for App `ILogService`, `sessionLogService.ts` for Session `ISessionLogService`).
- **Same interface, multiple role tokens** (e.g. `IAtomicDocumentStore` and `IAtomicTomlDocumentStore` share one interface type but are distinct DI tokens) → each token is its own Service identity and must be registered and resolved independently.
There is no `index.ts` barrel: consumers import each contract/impl from its precise leaf path (e.g. `import { ILogService } from '#/log/log'`), never the domain directory.
## No barrel — the package entry loads leafs precisely
A domain has **no `index.ts` barrel**. Its files are the contract leaf (`<name>.ts`) and the impl leaf (`<name>Service.ts`), and consumers import the precise file — never the directory:
```ts
import { IGreeter, type Greeting } from '#/greet/greet';
```
Self-registration is unchanged: `greetService.ts` keeps its top-level `registerScopedService(...)`. The package entry `src/index.ts` loads the domain's leafs precisely — `export *` for the contract, a side-effect `import` for the impl — one line per leaf:
```ts
// src/index.ts
export * from './greet/greet';
import './greet/greetService';
```
Importing the package therefore fires every `register*` side effect, exactly as the old per-domain barrels did. When you add a new domain, write the contract + impl leafs (with their top-level `register*`), then add the leaf path(s) to `src/index.ts`. **Do not create an `index.ts`.**
- Load the impl file too — its top-level `registerScopedService(...)` only runs when the module is imported.
- `export *` helper modules only if they are part of the domain's public surface.
- Each leaf's file-header comment still names the domain, scope, and (for impls) the `register*` binding it owns.
## Comments
- **File-header comment is mandatory** and the only place comments live (orient.md). State the identity line, the role, collaborators (impls), and scope.
- **Methods and fields carry no comments by default.** Well-named identifiers and types say *what*; the code is the source of truth for *how*.
- Write an inline comment only when the *why* is non-obvious (a hidden constraint, a subtle invariant, a workaround). One short line.
- For unimplemented stubs, throw `NotImplementedError('feature')` rather than `throw new Error('TODO: …')` (errors.md).
## Complete minimal example
```ts
// greet/greet.ts
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface Greeting { readonly message: string; }
export interface IGreeter {
readonly _serviceBrand: undefined;
hello(): Greeting;
}
export const IGreeter: ServiceIdentifier<IGreeter> = createDecorator<IGreeter>('greeter');
```
```ts
// greet/greetService.ts
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { type Greeting, IGreeter } from './greet';
export class Greeter implements IGreeter {
declare readonly _serviceBrand: undefined;
hello(): Greeting { return { message: 'hi' }; }
}
registerScopedService(LifecycleScope.App, IGreeter, Greeter, InstantiationType.Eager, 'greet');
```
```ts
// src/index.ts
export * from './greet/greet';
import './greet/greetService';
```
## Red lines (this topic)
- One folder per domain, camelCase; one service per file pair: contract `<name>.ts` + impl `<name>Service.ts`; **no `index.ts` barrel**`src/index.ts` loads each leaf file precisely.
- Exactly one injectable interface and one `createDecorator(...)` per contract file.
- Exactly one service implementation class and one `registerScopedService(...)` per impl file.
- `IXxxService` / `XxxService` naming; decorator string is lowerCamelCase, globally unique, and stable.
- Name Services by owning domain, never by scope (`IAgentEntityService`, `ISessionEntityService`).
- `_serviceBrand` only on interfaces used as a DI token — never on base interfaces or plain models.
- Sync methods return concrete types, async return `Promise<T>`; do not `Promise`-wrap sync work.
- `createInstance` objects put static parameters before service parameters; scoped services put `@IX` parameters first (static params need defaults).
- Never `new` a `@IService`-carrying Service — except inside an explicit factory method, which is not a DI request.
- Events: typed per-Service event → `Event<T>`/`Emitter` from `'#/_base/event'`; cross-domain broadcast → `IEventService` from `'#/event'`.
- `src/index.ts` must import/export every leaf file (including the impl) so each `register*` side effect runs.
- File-header comment only; methods/fields carry no comments by default; stubs throw `NotImplementedError`.

View file

@ -0,0 +1,95 @@
# Topic — Telemetry
Telemetry infrastructure for agent-core-v2: how business services emit events, how context propagates, and how events reach a destination through appenders.
Telemetry is a **layer-1 root** domain (alongside `log`): pure `App` scope, stateless, no business-domain dependencies. It is a thin facade — enrichment, batching, and transport belong to the appenders, not to this layer.
## Where things live
- `src/app/telemetry/telemetry.ts`: contract — `ITelemetryService` (facade), `ITelemetryAppender` (destination), `TelemetryProperties`, `nullTelemetryAppender`, and `TelemetryServiceOptions`.
- `src/app/telemetry/events.ts`: event registry — `telemetryEventDefinitions` pairs every business event's property type with review metadata (owner / purpose / per-property comment); the single source of truth for `track2`.
- `src/app/telemetry/telemetryService.ts`: `TelemetryService` impl + `registerScopedService(LifecycleScope.App, …)`.
- `src/app/telemetry/consoleAppender.ts`: `ConsoleAppender` — echoes events to a log function (dev / debug).
- `src/app/telemetry/cloudAppender.ts`: `CloudAppender` — sanitizes + PII-cleans properties, batches + enriches + posts to the telemetry endpoint.
- `src/app/telemetry/cloudTransport.ts`: `CloudTransport` — HTTP transport behind `CloudAppender`.
- `src/app/telemetry/privacy.ts`: outbound PII redaction (`cleanTelemetryProperties`) — URLs, emails, tokens, and absolute file paths become `<REDACTED: ...>` labels; `node_modules/` tails are kept.
## Emitting events (business services)
Inject `ITelemetryService` and call `track2` with a registered event:
```ts
import { ITelemetryService } from '#/app/telemetry/telemetry';
constructor(@ITelemetryService private readonly telemetry: ITelemetryService) {}
this.telemetry.track2('cron_fired', { task_id: taskId, coalesced_count: 0, stale: false, buffered: false, recurring: true });
```
`track2` is checked against the registry in `events.ts` at compile time: the event name must be a key of `telemetryEventDefinitions`, and the properties must match the registered interface exactly (extra or missing keys are compile errors). **New events must be registered first** — add a properties interface and a `defineTelemetryEvent<P>({ owner, comment, properties })` entry documenting every property. Naming: snake_case for events and properties, unit suffixes (`_ms` / `_count` / `_bytes`), no user content or file paths; `test/app/telemetry/events.test.ts` enforces the conventions. The low-level `track` remains for appender plumbing and tests only.
`TelemetryService.track` merges the bound context into the properties and fans the event out to every registered appender. A single throwing appender is isolated via `onUnexpectedError` and never blocks the rest.
### Context (sessionId / agentId / turnId)
The service carries a bound context (`sessionId` / `agentId` / `turnId`) that is merged into every event. Bind it at construction or derive a scoped view:
```ts
const child = telemetry.withContext({ agentId: 'main', turnId: 't1' });
child.track2('tool_call', { turn_id: 1, tool_call_id: 'c1', tool_name: 'bash', outcome: 'success', duration_ms: 12 }); // carries sessionId + agentId + turnId
```
`withContext(patch)` returns a new service sharing the same appenders; per-call properties override bound context on key collision. `setContext(patch)` mutates the bound context in place and propagates to appenders that implement `setContext`.
## Appenders (destinations)
An appender is the destination an event is fanned out to. It is **not a DI Service** — it is a plain object implementing `ITelemetryAppender`, held by `TelemetryService`.
```ts
export interface ITelemetryAppender {
track(event: string, properties?: TelemetryProperties): void;
withContext?(patch: TelemetryContextPatch): ITelemetryAppender;
setContext?(patch: TelemetryContextPatch): void;
flush?(): Promise<void> | void;
shutdown?(): Promise<void> | void;
}
```
Built-in appenders:
- `ConsoleAppender``[telemetry] <event> <json>` to a log function (default `console.log`); options `prefix` / `pretty` / `log`.
- `CloudAppender` — batches events, enriches with common context (`app_name` / `version` / `platform` / …), and posts to `https://telemetry-logs.kimi.com/v1/event` through `CloudTransport` (Bearer auth, retry, on-disk fallback). Options: `homeDir` / `deviceId` / `sessionId?` / `appName` / `version` / `uiMode?` / `model?` / `getAccessToken?` / `endpoint?` / `flushThreshold?` / `flushIntervalMs?`.
### Registering appenders (bootstrap)
Appenders are added after the App scope exists, by resolving the service and calling `addAppender`:
```ts
const app = createAppScope();
const telemetry = app.accessor.get(ITelemetryService);
telemetry.addAppender(new ConsoleAppender({ prefix: '[dev]' })); // dev echo
telemetry.addAppender(new CloudAppender({ // production
homeDir, deviceId, sessionId,
appName: 'kimi-code', version, uiMode: 'shell', model,
getAccessToken: () => auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME),
}));
```
`addAppender` returns an `IDisposable` that removes the appender when disposed. `setAppender(appender)` resets to a single appender (mainly for tests). `removeAppender(appender)` drops one.
> There is no production bootstrap wired yet — `TelemetryService` defaults to `[nullTelemetryAppender]`, so `track(...)` is a no-op until `addAppender` is called at startup.
## Lifecycle
- `setEnabled(false)` drops `track` (service-level switch); `setEnabled(true)` resumes. `flush` / `shutdown` are unaffected by the switch.
- `flush()` / `shutdown()` fan out to all appenders concurrently; a single rejecting appender is swallowed. Await `shutdown()` before process exit so buffered events (e.g. in `CloudAppender`) are sent.
## Red lines (this topic)
- Business services depend only on `ITelemetryService` — never import an appender class.
- Telemetry is layer-1 root: do not inject any business-domain service into it, and do not move it off `App`.
- Appenders are plain `ITelemetryAppender` objects, not DI Services — register them with `addAppender`, never via `registerScopedService`.
- `track` is fire-and-forget and must not throw; appender `track` must be synchronous — buffer and send asynchronously via `flush` / `shutdown`.
- Await `telemetry.shutdown()` before process exit when a buffering appender is registered.
- Keep event names stable; register every business event in `events.ts` and emit via `track2` — properties must be JSON-serializable primitives (non-primitives are dropped with a warning by `CloudAppender`).

View file

@ -0,0 +1,262 @@
# Stage 4 — Test
Exercise the **same path production uses**: a service is reached by its interface through the container, its `@IService` dependencies are resolved from the container, and — where the scope layer matters — through the scope tree. Tests that `new` a service and paper over its constructor with hand-rolled objects bypass that path and let the `registerScopedService(IX → Impl)` binding rot untested.
`@IService` parameter decorators run under vitest (the build uses `experimentalDecorators`), so fixtures declare dependencies exactly like production code. There is **no** `param()` helper, no manual `(Id as …)(Ctor, '', 0)`, and no capturing `accessor` inside a constructor to synchronously `.get()` a peer.
## The one rule
**Resolve the system under test by its interface, through the container. Never call `new` on a production service whose constructor carries `@IService` dependencies.**
```ts
// ✅ resolve by interface — the IX → Sut binding is exercised
ix.set(IMessageService, new SyncDescriptor(MessageService));
const svc = ix.get(IMessageService);
// ❌ construct the implementation directly — the registration is never run
const svc = new MessageService(stubContext);
```
Resolving by interface is what makes `registerScopedService(ISut, Sut, …)` part of the test. Constructing the class directly (or via `ix.createInstance(Sut)`) tests the class in isolation but leaves the binding, the scope layer, and the delayed/eager flag unverified.
Pure functions, value objects, and services with **no** `@IService` dependencies may be constructed directly.
The only other exception is a test that genuinely needs **two independent instances** of the same service with different dependencies (e.g. constructing two `TurnService`s with different `ILoopRunner`s). A singleton-per-container resolution cannot produce both, so `ix.createInstance(Impl)` is acceptable there — annotate it with a comment explaining why.
## Two harnesses
Pick the harness by *whether the scope layer is part of what you are testing*.
| Under test | Harness | Resolve the SUT with |
|---|---|---|
| A single service's behavior (unit) | `TestInstantiationService` (flat) | `ix.get(ISut)` after `ix.set(ISut, new SyncDescriptor(Sut))` |
| Cross-scope wiring, or which layer a service lives in | `createScopedTestHost` (scope tree) | `host.<scope>.accessor.get(ISut)` |
### Unit harness — `TestInstantiationService`
Default for domain service unit tests. It is an `InstantiationService` that also implements `ServicesAccessor` (so you can `ix.get(...)` directly) and owns sinon (so `dispose()` restores stubs).
```ts
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DisposableStore } from '#/_base/di/lifecycle';
import { createServices } from '#/_base/di/test';
import type { TestInstantiationService } from '#/_base/di/test';
import { registerRecordsServices } from '../records/stubs';
describe('XxxService', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
beforeEach(() => {
disposables = new DisposableStore();
ix = createServices(disposables, {
base: [registerRecordsServices],
additionalServices: (reg) => {
reg.define(IContextService, ContextService); // 1. real collaborator, by interface
reg.define(IXxxService, XxxService); // 2. system under test, by interface
},
});
});
afterEach(() => disposables.dispose());
it('does the thing', () => {
const svc = ix.get(IXxxService); // 3. resolve by interface
expect(svc.thing()).toBe('…');
});
});
```
`createServices` builds the container from domain **service groups** plus per-test overrides (see Service groups). Reach for `ix.stub(...)` / `ix.set(...)` directly only inside an `it` when a single test needs to swap a registration:
- whole service, partial object: `ix.stub(IId, { method() { return … } })`;
- single method: `ix.stub(IId, 'method', value)` returns a sinon stub; `ix.spy(IId, 'method')` returns a spy;
- a prebuilt instance or descriptor: `ix.set(IId, instance)` / `ix.set(IId, new SyncDescriptor(Impl))`;
- when a collaborator's behavior must vary per test, model it as a `Test*Service` subclass whose methods read suite-scoped `let` variables rather than rebuilding the container each test.
### Scope harness — `createScopedTestHost`
Reach for this only when *which layer a service lives in* is itself the thing being asserted, or when the SUT reads from parent/child scopes. It builds the real `Scope` tree and resolves through it.
```ts
import { beforeEach, describe, expect, it } from 'vitest';
import { InstantiationType } from '#/_base/di/extensions';
import {
LifecycleScope,
_clearScopedRegistryForTests,
registerScopedService,
} from '#/_base/di/scope';
import { createScopedTestHost, stubPair } from '#/_base/di/test';
describe('XxxService (scoped)', () => {
beforeEach(() => {
_clearScopedRegistryForTests();
registerScopedService(
LifecycleScope.Agent,
IXxxService,
XxxService,
InstantiationType.Delayed,
'xxx',
);
});
it('resolves from the Agent scope with ancestor deps injected', () => {
const host = createScopedTestHost([stubPair(ILogService, stubLog())]);
const agent = host.child(LifecycleScope.Agent, 'main');
const svc = agent.accessor.get(IXxxService); // by interface
expect(svc.thing()).toBe('…');
host.dispose();
});
});
```
Always `_clearScopedRegistryForTests()` and re-register explicitly in `beforeEach`. Do not rely on a production module's top-level `registerScopedService(...)` side effect: import order then becomes part of the test, and another suite's `_clearScopedRegistryForTests()` can wipe it.
## Register the SUT by interface
Whichever harness you use, the SUT is registered under its interface (`ix.set(IX, new SyncDescriptor(Impl))` or `registerScopedService(scope, IX, Impl, …)`) and resolved by that interface. This is non-negotiable: it is the only thing that keeps the production registration honest.
A test that does `ix.createInstance(Impl)` is testing the class, not the service. Convert those (see Migration).
## Shared stubs
Hand-rolled stubs (`noopLog`, `noneEvent`, `unusedRecords`, …) must not be copied between test files. Each domain that owns a frequently-stubbed interface exports a stub from a `stubs.ts` **in the `test/` tree**, never from `src/`:
```text
test/log/stubs.ts → stubLog() / stubLogger()
test/turn/stubs.ts → stubTurn()
test/records/stubs.ts → stubAgentRecords()
test/environment/stubs.ts → stubEnvironment()
```
All test support lives under `test/` so test-only code stays out of the production source tree. Because `tsdown` builds from `src/index.ts`, anything under `test/` is unreachable from the entry and is never bundled into `dist/`.
Conventions:
- export a **factory** (`stubXxx()`), not a shared singleton, so tests cannot leak state through a stub;
- name it `stub<Interface>` — e.g. `stubAgentRecords`;
- the stub satisfies the full interface so the compiler, not a cast, guarantees it stays in sync;
- import it with a **relative path**`./stubs` from the same domain's tests, `../<domain>/stubs` from another domain. Never import stubs from `#/…` (that alias is for production `src/`) and never import one test file from another;
- a `stubs.ts` may import its domain's production types via `#/<domain>/…`.
If a stub is needed by two test files, it belongs in that domain's `test/<domain>/stubs.ts`.
## Service groups
Most unit tests stub the same handful of collaborators (`ILogService`, `IAgentRecords`, `IConfigService`, `ITelemetryService`, …). Rather than repeat `ix.stub(...)` lines in every `beforeEach`, each domain exports a `register*Services` function from its `stubs.ts` that registers the default test doubles for that domain:
```ts
// test/log/stubs.ts
export function registerLogServices(reg: ServiceRegistration): void {
reg.defineInstance(ILogService, stubLog());
}
```
`createServices(disposables, { base, additionalServices })` composes them:
- `base` — an ordered list of service groups. Each group's registrations are deduped (first writer wins), so groups supply safe defaults without clobbering each other.
- `additionalServices` — applied after `base`. Registrations here **overwrite** any base default, so a test can swap a stub for a spy, register the system under test, or supply a one-off collaborator.
```ts
ix = createServices(disposables, {
base: [registerLogServices, registerConfigServices, registerRecordsServices],
additionalServices: (reg) => {
reg.definePartialInstance(IAgentKaos, {}); // one-off collaborator
reg.define(IAgentRecords, spyRecords); // override a base default
reg.define(IXxxService, XxxService); // system under test
},
});
```
`ServiceRegistration` offers three verbs:
- `define(id, Ctor)` — lazy `SyncDescriptor`; the service is instantiated on first resolve. Use for real collaborators and the system under test.
- `defineInstance(id, instance)` — a fully-built instance (a fake such as `stubLog()`, or `new ConfigRegistry()`).
- `definePartialInstance(id, { ... })` — a partial mock; only the supplied members are provided. Use for collaborators the test does not exercise.
Conventions:
- a group registers the domain's services **as dependencies** (a fake, or a `{}` partial when no fake exists yet). When a service is the system under test, the test registers the real implementation via `additionalServices` and does not rely on the group's default for it;
- keep groups small and domain-local. A service that is almost always the system under test, or that every consumer configures differently, should not have a group — register it inline via `additionalServices`;
- import groups with a **relative path** (`../<domain>/stubs`), never from `#/…`.
`createServices` defaults to `strict: false` (missing dependencies warn rather than throw), matching `new TestInstantiationService()`. Pass `strict: true` to surface unregistered `@IService` dependencies.
## Declaring dependencies
Always use `@IService` constructor decorators — in fixtures and in production services alike.
```ts
// ✅
class Consumer {
constructor(@IGreeter private readonly greeter: IGreeter) {}
}
// ❌ no param() helper, no inline cast
class Consumer {
constructor(private readonly greeter: IGreeter) {}
}
param(IGreeter, Consumer, 0);
```
Because the decorator runs when the class is defined, the `createDecorator` identifier must be initialized **before** the class that uses it. Declare the identifier, then the class:
```ts
const IDep = createDecorator<IDep>('dep');
class Consumer {
constructor(@IDep private readonly dep: IDep) {}
}
```
For two services that depend on each other (a cycle), declare both identifiers first, then both classes, so neither class references an uninitialized binding.
Declare fixtures at module top, interface + decorator + implementation co-located, and keep `_serviceBrand` on the interface when it represents a real service — `GetLeadingNonServiceArgs` relies on the brand to tell service parameters apart from static ones. Pure throwaway fixtures may omit `_serviceBrand`.
## Lifecycle / teardown
One `DisposableStore` per suite. Add the **container** and any event subscriptions to it; dispose in `afterEach`.
```ts
beforeEach(() => { disposables = new DisposableStore(); /* … */ });
afterEach(() => disposables.dispose());
```
Do **not** add the system-under-test itself to the store. `TestInstantiationService` disposes every service it creates when the container is disposed, so `ix.get(IX)` instances are cleaned up automatically via `disposables.add(ix)`. Wrapping the SUT in `disposables.add(...)` would double-dispose it. For the same reason, do not call `svc.dispose()` at the end of a test unless you are asserting something about disposal itself.
Scope-host tests call `host.dispose()` in `afterEach` (or at the end of the `it`). Route teardown through the store so ordering is deterministic and nothing leaks when a test fails mid-way.
## Assertions and naming
- One behavior per `it`; describe observable behavior (`child shadows parent registration`), not implementation (`calls _getOrCreateServiceInstance`).
- For cycles, assert `CyclicDependencyError` and its `path` array (e.g. `['A', 'B', 'A']`), not merely `toThrow`.
- For disposal order, capture events in an array and assert the sequence (`['C', 'B', 'A']` — children before parents).
## Migrating existing tests
Most legacy tests build the SUT with `ix.createInstance(Impl)`. Converting one is mechanical:
1. import the interface (`IX`) and the descriptor;
2. register the SUT by interface — `reg.define(IX, Impl)` inside `additionalServices` (or `ix.set(IX, new SyncDescriptor(Impl))`);
3. replace `ix.createInstance(Impl)` with `ix.get(IX)`;
4. drop the `disposables.add(...)` wrapper around the SUT and any trailing `svc.dispose()` — the container disposes it;
5. replace any hand-rolled collaborator object with the domain's shared stub or service group (or add one to `test/<domain>/stubs.ts` if it does not exist);
6. delete now-unused imports.
Before / after:
```ts
// before
const svc = ix.createInstance(MessageService);
// after — registration in beforeEach additionalServices
reg.define(IMessageService, MessageService);
// after — resolution in the test body
const svc = ix.get(IMessageService);
```
## Red lines (this stage)
- Resolve the SUT by interface — never `new` a production service with `@IService` deps; prefer `ix.get(IX)` over `ix.createInstance(Impl)`.
- Shared stubs live in `test/<domain>/stubs.ts` (never `src/`); import by relative path, never `#/...`.
- Scope tests call `_clearScopedRegistryForTests()` and re-register explicitly in `beforeEach`; do not rely on production import-order side effects.
- One `DisposableStore` per suite; add the container, dispose in `afterEach`; do not add the SUT itself.
- Declare fixture dependencies with `@IService`; initialize `createDecorator` identifiers before the classes that use them.

View file

@ -0,0 +1,32 @@
# Stage 5 — Verify & submit
Run the guards and re-scan the red lines before submitting.
## Commands
Run from the package (or with `--filter @moonshot-ai/agent-core-v2`):
- `pnpm --filter @moonshot-ai/agent-core-v2 lint:domain` — domain-layer / dependency-direction guard (`scripts/check-domain-layers.mjs`). Catches a domain importing a layer it must not.
- `pnpm --filter @moonshot-ai/agent-core-v2 typecheck``tsc -p tsconfig.json --noEmit`.
- `pnpm --filter @moonshot-ai/agent-core-v2 test``vitest run`.
## Changesets (when the change ships through the CLI)
If the change is user-facing and ships through the CLI, generate a changeset with the repository's `gen-changesets` skill (root `AGENTS.md` workflow). `agent-core-v2` is an internal package; if its change enters the CLI bundle, the changeset lists `@moonshot-ai/kimi-code` and describes the real change — do not present an internal-only change as a user-facing feature. Never write a `major` bump without explicit user confirmation.
## Pre-submit checklist
Walk the stages you touched and confirm:
- **Design** — scope follows state identity; no `Map<sessionId, …>` at `App`; dependency arrows do not make a foundational layer know an upstream one; no cycle was routed around.
- **Implement** — no `new` on `@IService`-carrying classes; `@IX` on constructor params only (service params after static params); interface + impl carry `_serviceBrand`; decorator names unique; coded errors only; flags for unreleased behavior.
- **Test** — SUT resolved by interface; stubs under `test/`; scope tests re-register after `_clearScopedRegistryForTests()`; teardown through one `DisposableStore`.
- **Files** — header comments describe role + scope only; registration runs from the impl file's top level; the new domain is exported from `src/index.ts`.
Then re-read the [global red lines](SKILL.md#global-red-lines) once — they catch most cross-stage mistakes in a single scan.
## Red lines (this stage)
- Do not skip `lint:domain` — it is the only automated check for the dependency-direction rules.
- Do not list internal packages in a changeset when the change enters the CLI bundle — list `@moonshot-ai/kimi-code` and describe the real change.
- Never write a `major` changeset without explicit user confirmation.

View file

@ -0,0 +1,21 @@
---
name: agent-core-review
description: Use ONLY for code review and test write/review guidance in `packages/agent-core-v2` (the DI × Scope agent engine). Does NOT apply to the legacy `packages/agent-core` or to any other package — for those, do not load this skill. Groups the review and testing lenses used for agent-core-v2 — `slop` (single-level-of-abstraction / layered error-handling review, invoked only on explicit request) and `test` (contract-driven per-test rules for both authoring and reviewing tests). Apply the sub-skill that matches the task; do not apply `slop` unprompted.
has-sub-skill: true
---
# kc-review
> **Scope: `packages/agent-core-v2` only.** These lenses are calibrated for the v2 engine (DI × Scope). Do not apply them to the legacy `packages/agent-core` or to other packages.
A bundle of the lenses used when reviewing or testing `packages/agent-core-v2`. Each sub-skill is self-contained; invoke the one that matches the task.
## Sub-skills
- **`slop/`** — Single Level of Abstraction & layered error handling. A *review dimension*: a function should read as a straight-line description of its own layer, with errors handled above or below. The agent reports detections and measurements, not severity grades. **Invoke only when the user explicitly asks for this lens** — do not apply it unprompted to general reviews or refactors.
- **`test/`** — Per-test rules behind "test the contract / responsibility, not the implementation," serving two modes. **Write mode:** author a test — one behavior per `it`, drive through the public surface, stub only the true external boundary, control time/config via documented knobs, keep tests clear, isolated, and refactor-resilient (CCCR). **Review mode:** audit existing tests against the same rules and report findings with `file:line`. Use when writing, modifying, or reviewing tests, or when asked how to write a good single test.
## Routing
- Reviewing code structure / abstraction layers / where error handling belongs → `slop` (only on explicit request).
- Writing or modifying tests, reviewing test quality, or advising on a single test → `test`.

View file

@ -0,0 +1,133 @@
---
name: slop
description: Invoke only when the user explicitly asks to review code through the "single level of abstraction / layered error handling" lens — a function does only its own layer's business logic while errors are handled above or below. The agent reports detections, raw-count measurements, and move directions. Apply only when the user explicitly requests this lens.
---
# Single Level of Abstraction & Layered Error Handling
North star: **a function should read as a straight-line description of what its own layer does. Anything that is not that — input validation, error handling, error-to-response translation, logging, retries, low-level mechanics — belongs to a layer above or below, not inline.**
This is a review dimension, not a hard rule. See "Exemption checklist" at the end.
## Scope of this skill — detect and measure
The agent applying this lens is a **sensor**. Its one job is to report *whether* a function mixes levels and *by how much*; deciding *how serious* it is belongs downstream. Severity labels (`Block` / `Request changes` / `Nit`) compress a continuous quantity into an uncalibrated three-point scale and are the main source of review-to-review variance, so they are produced downstream — by a deterministic rubric, anchored examples, or a human — from the facts the agent reports.
The agent's output is exactly these four things:
- **Detection (yes/no):** does this statement / block / function violate a rule of the lens?
- **Measurement (raw factual counts only):** mechanically countable quantities — body size, control-flow keywords, named syntactic shapes (see "Quantify"). Anything that first requires classifying a line (core/foreign, happy/error, high/low level) is recorded under detection, not here.
- **Direction (where it moves):** for each foreign concern, the destination layer — push **down** into a value / parser / infra helper, or push **up** into the edge handler.
- **Exemption flags:** which items, if any, hit the exemption checklist — recorded, not weighed.
Severity grades, merge/block verdicts, and "is splitting worth it" calls live downstream, derived from the four items above.
## When to use
Apply this lens only when the user asks for it explicitly (for example "用单一抽象层次审视一下", "check whether this function does too much", "errors should be handled above/below, right?"). Leave general reviews and refactors to other lenses unless the user names this one.
## The principle
One function, one level of abstraction, one responsibility. Three mutually reinforcing rules:
1. **Single Level of Abstraction (SLAP).** Every statement inside a function sits at the same conceptual level. High-level intent ("reserve inventory, charge payment, create the order") must not be interleaved with low-level mechanics (building headers, escaping strings, opening sockets, parsing bytes). If some lines read as "what" and others as "how", they belong in different functions.
2. **Error handling is its own concern (Clean Code).** A function either does the work or handles the error — not both. Business logic describes the happy path and *signals* failure (throw or return a result); the catch, mapping, logging, and recovery live in a dedicated handler, usually one layer up. Prefer exceptions / result types over threaded check-and-return ladders that interrupt the main flow.
3. **Separation of concerns by layer.** Each layer owns exactly one kind of knowledge: low-level code knows formats and protocols; mid-level code knows business rules; edge code knows the outside world (HTTP / CLI / UI). A function that knows two of these at once is leaking a layer.
The combined test: **could you explain this function to someone without using the word "and"?** If the explanation is "it reserves stock AND validates the email format AND maps the error to a status code AND logs to metrics", it is doing more than its layer's job.
Concerns that usually do **not** belong in a business function:
- Format / range / null validation that a lower value or parser could guarantee once.
- Mapping domain failures to an external protocol (status code, exit code, UI message) — that is the edge layer's job.
- Catch-and-swallow, retry loops, backoff, timeout, circuit breaking around a single call — infrastructure, push down.
- Cross-cutting telemetry / log / metric noise woven through every step — extract or push to a wrapper.
- Check-and-return ladders that occupy more space than the business core — replace with signal + a handler above.
## Methodology — fixing a function that violates it
Work top-down. Never start by shuffling lines.
1. **Name the level.** In one sentence, write what this function is for at its own layer. If you cannot, the function has no clear level — split before polishing.
2. **Classify every statement.** Tag each line or block as: **core** (this layer's business), **down** (a detail a lower abstraction should own), **up** (a concern an upper / edge layer should own), or **cross-cutting** (log / metric / retry). Unlabeled lines are where the mess hides — do not "just leave them".
3. **Decide down vs. up for each foreign item.**
- Push **down** when it is a guarantee a lower building block can provide: a value that can only be constructed valid, a parser that returns a typed result, an infra helper that already retries. The business function then assumes validity and stays clean.
- Push **up** when it is about translating or reacting to failure for the outside world: status codes, messages, exit codes, aggregation of many errors. The edge layer catches once and maps; business code just signals.
- Rule of thumb: if removing it would change what the business rule says, it is core and stays; if removing it only changes how a failure is reported or a detail is computed, it moves.
4. **Extract, do not interleave.** Pull each foreign concern into its own named function or layer. Keep the original function as a readable sequence of same-level calls. For error handling specifically, separate the work body from the recovery body into distinct functions so neither clutters the other.
5. **Signal, do not handle, in the middle.** Mid-layer business functions throw / return and let the right layer react. Do not catch-and-log-and-continue in business code unless continuing is itself the business rule.
6. **Re-read for level.** After the moves, every remaining line should be explainable at the same altitude. If not, repeat from step 1.
Keep the change minimal: move the smallest thing that restores the level. Do not invent abstractions, frameworks, or generic "handler" machinery beyond what the function actually needs. Three straight-line, same-level calls beat a premature pipeline.
## Review method — applying the lens to a diff
Read each changed or touched function and, for each check, record only: **the hit (yes/no) plus evidence (`file:line`)**, and — where the check points at a construct — a raw factual count from "Quantify".
1. **Altitude check.** Are all lines at the same level of abstraction? Record each place where a "what" line is immediately followed by a "how" block (or vice versa) inside the same function, with `file:line`.
2. **Happy-path check.** Can you read the business intent top to bottom without stepping through error branches? Record whether error handling sits inline between business steps (yes/no + `file:line`), supported by raw counts from "Quantify" (e.g. number of `catch` clauses, `continue` statements).
3. **Ownership check.** For each validation, catch, mapping, log, retry: is this layer the rightful owner, or is it borrowed from above / below? Record each borrowed item with `file:line` and its destination (down / up), using the rules from the methodology.
4. **Layer-leak check.** Does a business function mention an external protocol (status code, exit code, UI text, wire field)? Does an edge function contain a business rule? Record each leak candidate with `file:line` and whether it names an *external* protocol or an *internal* domain shape.
5. **Explanation test.** Describe the function in one sentence with no "and". Record whether "and" was needed; if so, list the proposed split as candidate moves (down / up).
### Quantify — report only raw factual counts
Report only quantities that can be counted **mechanically from the text**. Anything that first requires classifying a line (core vs foreign, happy-path vs error-handling, high-level vs low-level) is recorded under detection (the five checks above) as evidence, not as a number here.
Report, per function:
- **Body size** — lines and/or statements of the function body; state the basis (e.g. "statements, excluding lone braces").
- **Control-flow keywords (raw counts)**`if`, `continue`, early `return`, `throw`, `try` / `catch` / `finally`, `await`, loops (`for` / `while` / `.forEach`).
- **Named syntactic shapes a check points at** — when a check cites a construct, count it verbatim and name the exact token: e.g. number of object literals, string literals, `.trim()` calls, `.length` reads, `origin.` property reads, spread `[...x]` operations.
- **Recovery presence (raw)** — number of `catch` clauses, and number of log / metric calls inside them.
Quantities that embed a prior classification — out-of-level vs core counts, guard-to-core ratios, happy-path vs error-handling volume, "repeated boundary checks a lower layer could guarantee once", "low-level literals in a high-level flow" — are captured as evidence under the relevant check (`file:line` + the verbatim tokens). A downstream rubric derives any ratio from those raw facts.
### Red flags
Record each as evidence (yes/no + `file:line`); these are candidates, not verdicts:
- A body that is mostly check-and-return / check-and-throw ladders around a thin core.
- A recovery block that logs, maps, and returns inline, sitting next to business steps.
- A function that both computes a value and decides how that value's failure is shown to the user.
- Low-level literals (byte offsets, header strings, format codes) inside a high-level workflow.
- A name that needs "And" / "Or" / "With" to be honest, or a name so vague ("handle", "process", "do") that it hides multiple levels.
- Catch-and-swallow that hides a failure the caller needed to see.
- Defensive null / format checks repeated at every call site instead of guaranteed once at the boundary.
### Severity grading belongs downstream
The agent's facts (detections, raw counts, directions, exemptions) feed a downstream grade; the agent reports those facts and stops there. Grades compress a continuous quantity into an uncalibrated three-point scale and are exactly where identical evidence gets labeled differently across runs. Grading happens above the agent:
- A **deterministic rubric** — a versioned threshold table over the raw counts from "Quantify"; or
- **Anchored examples** — the reviewer judges relative to repo-known reference functions rather than against an absolute adjective like "materially"; or
- A **human**, for items that land near a threshold boundary.
If a downstream consumer still asks the agent for a grade, the agent returns the underlying facts and the threshold band it would fall under, with `confidence: low` on boundary cases; the grade itself is produced downstream.
### How to report findings
Report **evidence + direction**. Lead with the location and the level, then the proposed move. Prefer "this block is one level lower than the rest of the function (`file:line`) — move it **down** into X" over "this is ugly" or "this is a request-changes". The destination layer (down into a value / parser / infra helper, or up into the edge handler) is the actionable output and the deliverable. Attach the "Quantify" numbers and any exemption flags to each finding.
## Exemption checklist
This is a lens, not a law. For each foreign concern, check whether any exemption below applies and **record the hit (yes/no) plus the reason**. The agent records exemptions as facts; a recorded exemption is then used downstream to cap the grade (e.g. to `Nit`) deterministically.
- **Tiny function:** the function is small enough that splitting would add indirection with no reader benefit.
- **Foreign concern is the single job:** the "foreign" concern is in fact the function's one purpose — a dedicated error mapper, a validator, an infra wrapper, or an index-bookkeeping helper whose low-level arithmetic *is* its level.
- **Atomicity / correctness / performance:** the steps genuinely must stay together (e.g. a re-check after an `await` to guard state that may have changed).
- **Edge-translator role:** an edge / handler function whose job is to translate an external event into internal indices; naming the wire fields is its job.
Keep a split that would make the code harder to read as a recorded candidate for downstream review. When the evidence lands on an exemption boundary, record both sides and set `confidence: low`.
## Output contract
Return, per function, items 15 only:
1. **Level statement** — one sentence: what the function is for at its own layer.
2. **Per-check results** — for each of the five review checks: `hit: yes/no`, evidence `file:line`, and (only where the check points at a construct) a raw factual count.
3. **Measurements** — the raw factual counts from "Quantify".
4. **Exemptions** — checklist hits (yes/no + reason).
5. **Proposed moves** — for each foreign concern: `file:line` → destination (down into X / up into Y). This is the actionable deliverable.
Severity grades, block/merge verdicts, and "worth splitting" calls live downstream, derived from items 14. When a consumer asks for a label, hand back items 14 and the threshold band, with `confidence: low` on boundary cases.

View file

@ -0,0 +1,115 @@
---
name: test
description: Use when writing or reviewing tests, or when asked how to write a good single test. Encodes the per-test rules behind the "test the contract / responsibility, not the implementation" principle — name and structure one behavior per `it`, drive through the public surface, stub only true external boundaries, control time and config via documented knobs, and keep tests clear, isolated, and refactor-resilient. The same rules drive both authoring (write mode) and auditing existing tests (review mode).
---
# Tests — write & review
Per-test rules that operationalize one principle: **test the contract / responsibility, not the implementation**. This is the how-to for a single `it`, and the lens for reviewing one.
## Two modes, one rule set
- **Write mode** — authoring a test. Apply the rules below to produce it.
- **Review mode** — auditing an existing test or test diff. Apply the same rules as a checklist; report each violation with `file:line`, the rule it breaks, and the fix. See "Review mode" near the end.
The rules are identical in both modes — only the posture changes (produce vs. audit).
## Test contract, not implementation
- Drive the system through its **public control plane** and assert on **observable effects** (returned values, persisted state, emitted events, injected messages), never on source details.
- Resolve collaborators through their contract — the interface plus its identifier — not the module that binds a concrete implementation.
- Do not reach into private fields or add backdoors "for testing". If you feel the need, the seam is wrong — fix the design, not the test.
## One behavior per `it`
Each `it` covers exactly one responsibility / scenario. If the name needs "and", split it.
```ts
it('returns 401 when the caller is unauthorized', ...);
it('does not double-fire when the same tick repeats', ...);
```
## Name and structure
- `describe('<slice> (<responsibilities>)'` — name the **responsibility**, not the class.
- An `it(...)` reads as a sentence, but it must still encode three things — the **behavior / method**, the **state or condition**, and the **expected outcome**: `it('<behavior> when <condition>, <outcome>')`. A name like `does X when Y` with no result is too vague to fail usefully.
- Use spaces, not the Java-style `method_state_outcome` underscores — that convention exists only because Java test methods cannot contain spaces. A string-named test reads fine as a sentence.
- Good: `it('returns 401 when the caller is unauthorized')` · `it('advances the cursor and does not double-fire on a repeat tick')`
- Bad: `it('works')` · `it('handles auth correctly')` — no condition, no outcome
- Arrange / Act / Assert. A short `// Given` `// When` `// Then` is fine when it aids reading; do not paste it mechanically on trivial tests.
## Build a small rig
When several tests share setup, write a factory (`rig()`, `createHost()`, whatever fits the codebase) that returns the **smallest surface the test needs**. Tests reach into the rig; they do not rebuild the world each time. Keep the rig dumb: wiring only, no assertions.
## Stub only the real external boundary
Default to real collaborators wired the way production wires them. Stub the **minimum seam** that is genuinely external:
- A remote / model / service boundary — spy on the contract method (the interface), and capture what the system sends across it. Do not stand up the real external thing.
- Network / other-process boundaries — stub at the boundary, not the internals.
- Time, timers, jitter — use the documented control knobs the system exposes (env, an injected clock, a manual tick). Do **not** use fake timers or real `setTimeout` to drive time.
- Env / config knobs are usually snapshotted at bootstrap — set them **before** building the system under test, and restore them in `afterEach`.
## Keep tests DAMP and keep cause next to effect
- DAMP over DRY: use **literal expected values** in assertions; do not compute the expectation with the same logic as the code under test.
- Keep the key preconditions inside the `it` (or its rig), where the reader can see cause next to effect. Reserve `beforeEach` for cross-cutting plumbing (env snapshot, cleanup), not for hiding the scenario's setup.
```ts
// Good — the expected value is a literal the reader can check.
expect(discount).toBe(15);
// Bad — re-derives the expectation; mirrors the implementation.
expect(discount).toBe(price * rate);
```
## Assert only what is relevant
Assert the effect that proves the contract. Use matchers / partial-object matching to ignore incidental fields. Do not assert internal counters, call orders, or shapes the user cannot rely on.
## Isolate and clean up (no flakes)
Every test must be hermetic and order-independent. In `afterEach`:
- restore every mock / spy
- restore every env var you touched (snapshot in `beforeEach`)
- dispose the host / container and reset its reference
No dependence on wall-clock time, run order, or leftover on-disk state — give each scenario its own isolated identity / workspace when state persists.
## Quality bar: CCCR
Before finishing, check each test against:
- **Clarity** — a stranger can tell what broke from the failure message alone.
- **Completeness** — covers the responsibility's success, error, and boundary paths.
- **Conciseness** — no duplicate or speculative cases; one scenario per `it`.
- **Resilience** — survives an internal refactor with no test change (because it asserts contract, not implementation).
## Per-file scenario header
Start each test file with a short header comment: the **scenario**, the **responsibilities** asserted, the **wiring** (which collaborators are real vs. the single stubbed boundary), and how to run it.
## Review mode — auditing existing tests
Apply the rules above as a checklist against each test in scope (a file, a diff, or a named `it`). For every hit, report `file:line` + the rule it breaks + the fix; do not rewrite unless asked. Lead with the contract question: *what observable behavior does this test prove, and would it survive a refactor?*
Check, in order:
1. **Contract, not implementation** — asserts observable effects, not private fields, call order, or internal shapes the user cannot rely on.
2. **One behavior per `it`** — the name carries behavior + condition + outcome; "and" in the name means a split is owed.
3. **Boundary discipline** — only the true external seam is stubbed; time is driven by documented knobs, not fake timers / real `setTimeout`.
4. **DAMP expectations** — expected values are literals, not re-derived by the code under test's logic.
5. **Isolation** — mocks / spies / env / host restored in `afterEach`; no wall-clock, run-order, or leftover on-disk dependence.
6. **CCCR read-through** — Clarity, Completeness (success / error / boundary), Conciseness, Resilience.
Report findings as evidence + fix, e.g. "`foo.test.ts:42` asserts on `service.internalMap` (contract) — assert the returned value instead." If a test passes the lens, say so briefly; silence on a rule means it held.
## Quick checklist (write & review)
- Resolved through the contract; no concrete-impl import
- One behavior per `it`; name carries behavior + condition + outcome; AAA
- Stubbed only the true external seam; time via knobs, not fake timers
- Literal expectations; relevant assertions only
- Mocks / env / host restored in `afterEach`; hermetic, no flakes
- CCCR read-through done

View file

@ -148,8 +148,8 @@ The docs changelog uses five section types:
| English section | Chinese section | Meaning | | English section | Chinese section | Meaning |
|---|---|---| |---|---|---|
| `### Features` | `### 新功能` | New user-facing functionality, such as a new command, flag, mode, or capability that did not exist before | | `### Features` | `### 新功能` | New user-facing functionality, such as a new command, flag, mode, or capability that did not exist before |
| `### Bug Fixes` | `### 修复` | Fixes for behavior that was broken |
| `### Polish` | `### 优化` | User-visible improvements to existing functionality, including UX adjustments, behavior tweaks, and performance improvements that are not fixes or new capabilities | | `### Polish` | `### 优化` | User-visible improvements to existing functionality, including UX adjustments, behavior tweaks, and performance improvements that are not fixes or new capabilities |
| `### Bug Fixes` | `### 修复` | Fixes for behavior that was broken |
| `### Refactors` | `### 重构` | Internal changes with no user-visible behavior change, including build, CI, tests, dependency cleanup, and internal renames | | `### Refactors` | `### 重构` | Internal changes with no user-visible behavior change, including build, CI, tests, dependency cleanup, and internal renames |
| `### Other` | `### 其他` | Anything that does not fit above, such as CDN/endpoint swaps and docs-related artifacts | | `### Other` | `### 其他` | Anything that does not fit above, such as CDN/endpoint swaps and docs-related artifacts |
@ -176,7 +176,7 @@ Keyword hints:
Within each version, section order is: Within each version, section order is:
```text ```text
Features → Bug Fixes → Polish → Refactors → Other Features → Polish → Bug Fixes → Refactors → Other
``` ```
Omit empty sections. Within each section, order entries by reader value, not upstream order: Omit empty sections. Within each section, order entries by reader value, not upstream order:

View file

@ -24,7 +24,6 @@ All other workspace packages are private internal packages, are not published to
- `@moonshot-ai/kosong` - `@moonshot-ai/kosong`
- `@moonshot-ai/migration-legacy` - `@moonshot-ai/migration-legacy`
- `@moonshot-ai/protocol` - `@moonshot-ai/protocol`
- `@moonshot-ai/server`
- `@moonshot-ai/server-e2e` - `@moonshot-ai/server-e2e`
- `@moonshot-ai/vis` - `@moonshot-ai/vis`
- `@moonshot-ai/vis-server` - `@moonshot-ai/vis-server`

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Align the print-mode run lifecycle across engines: `print_background_mode` and `print_max_turns` now take effect for `kimi -p` on the experimental engine, with the same exit / drain / steer semantics and defaults as the default engine, and `kimi -p "/goal ..."` now stays alive until the goal reaches a terminal state instead of exiting after the first turn.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Align the subagent timeout across engines: a fixed 2-hour default, overridable with `[subagent] timeout_ms` in config.toml or the KIMI_SUBAGENT_TIMEOUT_MS environment variable.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Add the number of messages dropped during compaction retries to the session wire log's LLM request traces.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Rename the dynamic tool loading model capability from `select_tools` to `dynamically_loaded_tools`, matching the model catalog vocabulary; the `select_tools` tool and the `tool-select` flag are unchanged.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Enforce goal wall-clock budgets while model or tool work is still running.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Preserve active goal elapsed time across crash recovery.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Allow goals to use every configured turn before the turn budget stops further work.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix sessions created by newer builds failing to open in older CLI builds on the same machine; new sessions are written in a compatible layout, and existing sessions are healed on first open.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix sub-agent completions being signaled as session turn completions, which fired premature completion notifications, sounds, and unread markers while the main turn was still running.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
web: Fix code block copy buttons when the web UI is served over plain HTTP.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
web: Use an upward chevron for the expand button on minimized plan review and question cards so the icon matches the direction the cards open.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
web: Resume paused goals when you select Resume.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Prevent late activity from replaced goals from changing or consuming the budget of replacement goals.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Preserve final status messages when automatic goal continuations reach a budget or report a blocker.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Reject subagent goal requests consistently instead of starting goals they cannot finish.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Log failed requests, WebSocket auth rejections, shutdowns, and key operations (abort, cancel, approvals, config changes) in the web UI server so daemon problems can be diagnosed from its logs.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix `kimi server` reporting the internal server package version instead of the CLI version in its metadata; the web UI settings now show the CLI version.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
web: Show each message's actual send time in chat history after reloading a session, instead of the session creation time.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
web: Surface server error details when actions such as stopping a session, archiving, or toggling modes fail, instead of failing silently, and log every operation failure to the console and the exported web log.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Keep the workspace catalog complete and durable: creating a session registers its directory as a workspace, the server backfills missing workspaces from session history at startup, and a removed workspace no longer reappears after a restart.

View file

@ -30,6 +30,10 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -42,7 +46,7 @@ jobs:
cache: pnpm cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm run test - run: pnpm run test --shard=${{ matrix.shard }}/5
# pi-tui's suite runs on node:test (not vitest), so the root `pnpm run test` # pi-tui's suite runs on node:test (not vitest), so the root `pnpm run test`
# does not execute it; it needs its own job. # does not execute it; it needs its own job.

View file

@ -37,7 +37,7 @@ jobs:
registry-url: "https://registry.npmjs.org" registry-url: "https://registry.npmjs.org"
- name: Upgrade npm for Trusted Publishing - name: Upgrade npm for Trusted Publishing
run: npm install -g npm@latest run: npm install -g npm@11
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile

4
.gitignore vendored
View file

@ -4,9 +4,12 @@ dist-web/
dist-single/ dist-single/
dist-native/ dist-native/
.tmp-api-extractor/ .tmp-api-extractor/
.contract-types-tmp/
.local/
coverage/ coverage/
*.tsbuildinfo *.tsbuildinfo
.vitest-results/ .vitest-results/
.vite/
.DS_Store .DS_Store
.playwright-mcp/ .playwright-mcp/
.claude .claude
@ -16,6 +19,7 @@ plugins/cdn/
.worktrees/ .worktrees/
.kimi-code/local.toml .kimi-code/local.toml
.kimi-sandbox/ .kimi-sandbox/
.vscode/
Dockerfile Dockerfile
docker-compose.yml docker-compose.yml

View file

@ -15,7 +15,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo
## Project Map ## Project Map
- `apps/kimi-code`: the CLI / TUI application. It consumes core capabilities through `@moonshot-ai/kimi-code-sdk` and must not depend directly on `@moonshot-ai/agent-core`. When writing or modifying its terminal UI, use the `write-tui` skill (`.agents/skills/write-tui/SKILL.md`). - `apps/kimi-code`: the CLI / TUI application. It consumes core capabilities through `@moonshot-ai/kimi-code-sdk` and must not depend directly on `@moonshot-ai/agent-core`. When writing or modifying its terminal UI, use the `write-tui` skill (`.agents/skills/write-tui/SKILL.md`).
- `apps/kimi-web`: the browser web UI, a peer to the TUI. Vue 3 + Vite + vue-i18n; talks to the server over REST + WebSocket under `/api/v1`. It must not depend on `@moonshot-ai/agent-core` (wire types are re-implemented locally). See `apps/kimi-web/AGENTS.md`. - `apps/kimi-web`: the browser web UI, a peer to the TUI. Vue 3 + Vite + vue-i18n; talks to the server over REST + WebSocket under `/api/v1`. It must not depend on `@moonshot-ai/agent-core` (wire types are re-implemented locally). Debug against the two engines via the root `pnpm dev:v1` / `pnpm dev:v2` backend scripts — the dev Sidebar shows the active backend and switches it at runtime. See `apps/kimi-web/AGENTS.md`.
- `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays. - `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays.
- `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, the in-process DI service layer (`src/services/`), and other core capabilities. - `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, the in-process DI service layer (`src/services/`), and other core capabilities.
- `packages/node-sdk`: the public TypeScript SDK and harness. - `packages/node-sdk`: the public TypeScript SDK and harness.
@ -23,7 +23,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo
- `packages/kaos`: the execution environment and file/process abstractions. - `packages/kaos`: the execution environment and file/process abstractions.
- `packages/oauth`: Kimi OAuth and managed auth utilities. - `packages/oauth`: Kimi OAuth and managed auth utilities.
- `packages/telemetry`: shared client-side telemetry infrastructure. - `packages/telemetry`: shared client-side telemetry infrastructure.
- `packages/server`: the Kimi Code server. Hosts `agent-core` sessions and exposes them over REST + WebSocket (`/api/v1`); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. See `packages/server/AGENTS.md`. - `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2`). Exposes sessions over REST + WebSocket (`/api/v1` and the native `/api/v2` RPC surface); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`.
- `packages/server-e2e`: live e2e tests and scenarios against a running server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`). See `packages/server-e2e/AGENTS.md`. - `packages/server-e2e`: live e2e tests and scenarios against a running server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`). See `packages/server-e2e/AGENTS.md`.
## Environment Requirements ## Environment Requirements

231
GOAL.md Normal file
View file

@ -0,0 +1,231 @@
# Goal 功能拆分
本文把 agent-core 中 goal mode 的能力拆成三部分:
1. 核心工作流:没有它就不能运行 goal。
2. 统计 / token 数限制:让 goal 可度量、可限额、可审计。
3. 用户交互相关:让用户可以安全启动、理解、控制和恢复 goal。
## 1. 核心工作流
核心工作流是 goal mode 的运行骨架。它负责创建结构化目标、维护状态机、把普通 turn 串成自治多轮执行,并让模型用机器可读状态结束或停放目标。
### 目标状态
同一个 main agent 同时最多只有一个当前 goal。goal 不是普通聊天文本,而是 runtime 持有的结构化状态,至少包含目标、可选完成标准、当前状态、停止原因和运行统计。
状态分为四类:
- `active`:正在被 goal driver 推进。只有这个状态会自动运行下一轮。
- `paused`暂停但保留目标。通常来自用户暂停、中断、进程恢复后降级、provider 或 runtime 错误。可以恢复。
- `blocked`目标遇到真实阻塞但保留目标。通常来自模型判断需要外部输入、目标无法按当前表述完成、预算达到、prompt hook 阻止。可以恢复。
- `complete`瞬时完成状态。runtime 发出完成事件后立即清除 goal不长期持久化。
没有 `cancelled` 状态。取消就是清除 goal并提醒模型忽略之前关于该目标的 active reminder。
### 创建和替换
创建 goal 时runtime 需要校验目标不能为空、不能过长。已有 active、paused 或 blocked goal 时,默认拒绝创建新 goal防止静默覆盖。只有用户或调用方明确要求替换时才先清除旧 goal再创建新 goal。
新 goal 创建后进入 `active`,写入持久记录,并发出 goal 更新事件。
### 多轮驱动
goal driver 的职责是把一个 active goal 推进成连续的普通 turn
- turn 开始时如果 goal 已经是 `active`,进入 goal driver。
- 普通 turn 中如果模型创建了 goal或把 paused/blocked goal 恢复成 active当前 turn 结束后 goal driver 接管继续执行。
- driver 每次只运行一个普通 turn。
- 每个 turn 结束后读取 goal 状态。
- goal 仍是 `active`runtime 自动追加 continuation prompt 并启动下一轮。
- goal 变成 `paused``blocked` 或被清除时driver 停止。
模型如果不调用状态更新工具,且 goal 仍是 activeruntime 会继续下一轮。模型不能只靠自然语言说“完成了”来结束 goal必须给出结构化状态信号。
### Goal 注入
每个 goal turn 的边界runtime 会把当前 goal 状态注入上下文。注入内容包括:
- 当前正在 goal mode。
- 目标和完成标准是什么。
- 目标文本是用户提供的数据,不能覆盖 system/developer 指令、工具 schema、权限规则或 host 控制。
- 当前状态和进度。
- 模型应该做简短自审,然后推进一个连贯工作切片。
- 简单、已完成、不可能、不安全、矛盾的目标,应在同一轮内直接标记 complete 或 blocked。
- 只有全部要求完成、验证通过、没有下一步有用动作时,才能标记 complete。
- 外部条件或用户输入阻塞时,应标记 blocked。
- 不要只做了计划、总结、第一版或部分结果就标记 complete。
goal 注入只在 turn / continuation 边界做,不在每个 model step 都做,避免上下文重复膨胀,也有利于 prompt cache。
paused 和 blocked goal 的注入更轻:
- paused提醒模型目标存在但当前不应自治推进除非用户明确要求继续。
- blocked提醒模型目标被阻塞且当前不自治推进除非用户要求处理或恢复。
### Continuation prompt
当 goal 仍是 activeruntime 会追加一个系统触发输入,含义相当于“继续朝当前 active goal 工作”。它不只是简单续跑,还要求模型每轮重新判断:
- 是否已经完成。
- 是否遇到真实阻塞。
- 是否应该只推进一个合理切片后继续下一轮。
- 是否应该避免发散或启动无关工作。
- 除非真实阻塞,否则不要向用户要输入。
### 完成、阻塞和暂停
模型通过结构化状态更新控制 goal 生命周期:
- `complete`目标已满足runtime 发出完成事件并清除 goal。
- `blocked`遇到真实阻塞runtime 保留 goal 并停止自治推进。
- `paused`:暂时放下 goalruntime 保留 goal 并停止自治推进。
- `active`:恢复 paused 或 blocked goal。
状态更新工具的输入应保持窄,只表达机器状态。完成总结或阻塞原因由模型随后给用户说明。
当模型标记 complete 后runtime 应再给模型一次收尾机会,生成简短最终回复,说明 goal 已完成、主要做了什么、跑了什么验证。
当模型标记 blocked 后runtime 应再给模型一次收尾机会,说明具体阻塞、需要什么输入或变化才能继续。
如果当前 turn 已经没有 step 预算,不应为了收尾总结强行再跑一步,避免把“没法写总结”变成 turn 失败。
### 错误停车
goal mode 把技术运行失败视为可恢复停车:
- 用户中断当前 turngoal 变 paused。
- provider rate limitgoal 变 paused。
- provider 连接错误、认证错误、API 错误goal 变 paused。
- 模型配置错误goal 变 paused。
- runtime 异常goal 变 paused。
- provider safety filtergoal 变 paused。
业务、规则或外部条件阻塞则变 blocked
- prompt hook 阻止目标。
- 模型判断无法继续。
- 预算达到。
- 需要用户或外部系统提供新条件。
### 持久化和恢复
goal 的创建、更新、完成、阻塞、清除应写入可恢复记录。session 恢复时runtime 用记录重建 goal。
恢复时如果发现 goal 原来是 active不应自动继续跑而是降级为 paused。因为旧进程中的 active turn 不可能还活着,自动继续会造成重启后偷偷消耗资源。
paused 和 blocked 原样保留。complete 理论上不长期存在,因为完成后会清除。
fork session 时不继承源 session 的 goal并提醒模型不要继续源 session 的旧目标。
## 2. 统计 / token 数限制
这一部分让 goal 可度量、可限额、可审计。没有它goal 仍然可以运行,但不可控。
### 运行统计
goal 统计包括:
- continuation turn 数。
- token 数。
- active wall-clock 时间。
统计只在 goal 是 `active` 时增长。paused 和 blocked 期间不继续计数。
turn 统计在每个 goal turn 准备运行时增加,因此模型在某一轮里标记 complete 时,这一轮也计入最终统计。
token 统计在 model step 结束后累计。没有 active goal 时,不记入 goal。token 统计应以静默更新为主,不应每一步都刷 UI。
时间统计只计算 active pursuit 时间。进入 active 时开启计时区间,离开 active 时折算进累计时间pause/resume 会形成新的 active 区间。
### 预算
goal 预算包括:
- turn budget。
- token budget。
- wall-clock budget。
默认没有预算。只有用户明确给出硬限制时才设置,例如“最多 20 轮”“不超过 500k token”“30 分钟内”。模糊表达如“尽快”“别花太久”不能设置预算,模型也不能自行发明预算。
时间预算需要合理范围。过短或过长应拒绝。turn 和 token 预算应规范化为正整数。
### 预算硬停
预算检查应发生在 goal turn 开始前和结束后。token budget 还应在 model step 后触发停止,避免超额后继续下一步。
一旦达到预算runtime 应直接把 goal 标记为 blocked原因是配置预算已达到。这个 blocked 仍可恢复,但如果预算不变,恢复后可能立刻再次 blocked。
### 预算引导和最终统计
当预算未接近时,模型提示应鼓励稳定推进。当任一预算达到 75% 以上时,提示应转为收敛,避免启动新的可选工作。
complete 和 blocked 的最终回复提示应包含 worked turns、elapsed time、tokens used 等统计信息。UI 事件也应带当前 snapshot 和变化类型。
telemetry 可以记录 goal 创建、预算设置、continuation、状态变化、清除等事件但不应包含目标文本、停止原因等敏感内容。
## 3. 用户交互相关
这一部分让用户可以安全启动、理解、控制和恢复 goal。没有它runtime 仍可能运行,但交互体验和安全边界不足。
### 生命周期控制
用户可以直接控制 goal
- 创建。
- 查看。
- 暂停。
- 恢复。
- 取消。
这些操作可以不经过模型 turn。pause 把 active goal 变 pausedresume 把 paused 或 blocked goal 变 activecancel 直接清除当前 goal。
resume 会清除旧停止原因表示开始新的尝试。paused/blocked goal 不会因为用户发普通消息就自动继续。
### 模型发起 goal 的确认
模型可以代表用户创建 goal但只有在用户明确要求启动 goal、自治工作或宿主 goal-intake 提示要求时才应该这样做。普通请求不能被模型擅自升级成 goal。
模型发起 CreateGoal 时,非 auto 权限模式下应触发用户确认。确认菜单允许用户选择本次 goal 的运行权限模式。用户拒绝则 goal 不创建。
`GetGoal``SetGoalBudget``UpdateGoal` 只改 goal runtime 状态,默认可以更容易批准。真正写文件、跑 shell、访问敏感路径等仍走普通权限系统。
### 暂停、阻塞和取消后的提示
paused goal 的上下文提示应说明目标存在但当前不应继续做,除非用户明确要求继续。
blocked goal 的上下文提示应说明目标被阻塞且当前不自治推进,可以在用户要求时帮助解阻,否则正常处理当前请求。
cancel 后应追加提醒,让模型忽略旧 goal 的 active reminder避免旧上下文诱导模型继续已经取消的目标。
### 完成和阻塞的用户回复
complete 后goal 被清除,模型应给用户一条简短完成总结,说明完成了什么、做了什么验证。
blocked 后goal 保留,模型应给用户一条简短阻塞说明,说明具体阻塞和继续所需输入、权限、外部条件或变更。
### Tool 暴露和隔离
goal 工具只给 main agent。subagent 不应直接创建、恢复、结束主 goal。
没有 goal 时,模型不应看到 `UpdateGoal``SetGoalBudget`。有 goal 时才暴露这些控制工具。
goal ID 不应暴露给模型,因为它只是 runtime/UI 内部标识,没有用户语义。
### 辅助写 goal
`write-goal` 类能力用于帮助用户把粗糙意图整理成适合 goal mode 的完成契约。好的 goal 应明确:
- end state什么条件必须变成真。
- proof用什么可观察证据证明完成。
- boundaries工作范围和禁止触碰的内容。
- loop如何迭代推进。
- stop rule什么情况下停止并报告而不是强行继续。
预算是 opt-in不应默认加入也不应把 turn cap 写进目标文本。
### UI 和会话语义
goal 创建、暂停、恢复、阻塞、完成、清除都应发出 goal updated 事件。lifecycle 变化和 completion 变化应区分。completion 是一次终局事件,然后 snapshot 变 null。blocked/paused 保留 snapshotUI 可以继续展示可恢复 goal。
session 恢复时active goal 会变 paused避免重启后自动继续。fork session 时不继承 goal并提醒模型不要继续源 session 的目标。

View file

@ -1,10 +1,218 @@
# @moonshot-ai/kimi-code # @moonshot-ai/kimi-code
## 0.24.1
### Patch Changes
- [#1678](https://github.com/MoonshotAI/kimi-code/pull/1678) [`ec1c974`](https://github.com/MoonshotAI/kimi-code/commit/ec1c9748c816d152bf06af2456e82ac35786bba9) Thanks [@chengluyu](https://github.com/chengluyu)! - Preserve goal completion summaries and show untyped LLM errors without an internal error-code prefix in step interruption events.
- [#1688](https://github.com/MoonshotAI/kimi-code/pull/1688) [`94c0ef8`](https://github.com/MoonshotAI/kimi-code/commit/94c0ef89d29ea8532be02828201328fa1281273c) Thanks [@sailist](https://github.com/sailist)! - Fix built-in tools being unavailable when the model provider becomes ready after the session starts.
- [#1684](https://github.com/MoonshotAI/kimi-code/pull/1684) [`e417ee7`](https://github.com/MoonshotAI/kimi-code/commit/e417ee7c2c282f00113dc0e4f4514ca5018b76c9) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix Kimi sessions getting stuck when preserved-thinking history contains an empty reasoning step.
- [#1673](https://github.com/MoonshotAI/kimi-code/pull/1673) [`0f64b4d`](https://github.com/MoonshotAI/kimi-code/commit/0f64b4dcc4f2d295d0039b176d96d8003cb49991) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Align thinking-level handling with the CLI: submit the selected level verbatim instead of silently downgrading it, pin the model's catalog default when nothing was chosen, pre-select the target model's default on model switches, and persist explicit picks as the daemon-wide default so new sessions inherit them.
- [#1689](https://github.com/MoonshotAI/kimi-code/pull/1689) [`ab22a2a`](https://github.com/MoonshotAI/kimi-code/commit/ab22a2adf0ca17cbb94f1abdab334ebc58814e8d) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Show just the level name (e.g. Max) in the model pill instead of "thinking: max".
- [#1625](https://github.com/MoonshotAI/kimi-code/pull/1625) [`d158e0a`](https://github.com/MoonshotAI/kimi-code/commit/d158e0a7ac4e432046d56787263dd2dbac40285e) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix Thinking effort routing so non-Kimi providers preserve configured values for upstream validation, while Kimi models validate runtime selections, fall back safely during model resolution, and synchronize the effective effort back to clients.
## 0.24.0
### Minor Changes
- [#1441](https://github.com/MoonshotAI/kimi-code/pull/1441) [`ceb158d`](https://github.com/MoonshotAI/kimi-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Add v2 session export support for packaging diagnostic zip archives.
- [#1591](https://github.com/MoonshotAI/kimi-code/pull/1591) [`83e1753`](https://github.com/MoonshotAI/kimi-code/commit/83e175399f4dc3dfc3bb478543ff5897a24dfa3d) Thanks [@liruifengv](https://github.com/liruifengv)! - Move foreground Bash commands that hit their timeout to the background instead of killing them, so long-running commands survive the timeout and report back on completion. Set `bash_auto_background_on_timeout = false` under `[background]` in config.toml to restore the kill-on-timeout behavior.
- [#1617](https://github.com/MoonshotAI/kimi-code/pull/1617) [`4ec2e7f`](https://github.com/MoonshotAI/kimi-code/commit/4ec2e7fab14ab89cddf77821082c3ff4911f737b) Thanks [@sailist](https://github.com/sailist)! - Run the local server (`kimi server run` / `kimi web`) on the agent-core-v2 engine by default — the `KIMI_CODE_EXPERIMENTAL_FLAG` opt-in is no longer needed, and the legacy v1 server package has been removed.
- [#1441](https://github.com/MoonshotAI/kimi-code/pull/1441) [`ceb158d`](https://github.com/MoonshotAI/kimi-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Port progressive tool disclosure to the new agent engine: MCP tool schemas stay out of the top-level tool list, and the model loads them by name on demand through the announcements plus the select_tools tool, keeping the prompt cache stable. Off by default; set KIMI_CODE_EXPERIMENTAL_TOOL_SELECT=1 to enable.
- [#1646](https://github.com/MoonshotAI/kimi-code/pull/1646) [`5eb6217`](https://github.com/MoonshotAI/kimi-code/commit/5eb62178b3b67d8659788bdf91132469f6588653) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Add session diagnostic export to download a session and bounded metadata-only troubleshooting logs as a ZIP. Run `/export` or pick Export session from a session's more menu. Web downloads are limited to 64 MiB.
### Patch Changes
- [#1638](https://github.com/MoonshotAI/kimi-code/pull/1638) [`7c889f3`](https://github.com/MoonshotAI/kimi-code/commit/7c889f3a960482cc9382203bda55d972b6fb6acd) Thanks [@RealKai42](https://github.com/RealKai42)! - In auto permission mode, plan exits are now marked as auto-approved (not user-reviewed) in both the tool result and the transcript, so the agent no longer treats automatic plan approval as a user signal to start executing.
- [#1598](https://github.com/MoonshotAI/kimi-code/pull/1598) [`4feca6b`](https://github.com/MoonshotAI/kimi-code/commit/4feca6b0738ee0120ab8bea04604b8f467a72e48) Thanks [@kermanx](https://github.com/kermanx)! - web: Recover transient subagent rate limits without surfacing them as session errors.
- [#1635](https://github.com/MoonshotAI/kimi-code/pull/1635) [`e49b3b8`](https://github.com/MoonshotAI/kimi-code/commit/e49b3b877750ba5ca0ea80e154549d5b53455575) Thanks [@sailist](https://github.com/sailist)! - Request task-owned work to stop on session close, honoring `background.keep_alive_on_exit` for independent processes and `background.kill_grace_period_ms` before attempting force-stop.
- [#1629](https://github.com/MoonshotAI/kimi-code/pull/1629) [`0527ca2`](https://github.com/MoonshotAI/kimi-code/commit/0527ca2267f8cf355d0c158953f3dbfc0c9692ac) Thanks [@sailist](https://github.com/sailist)! - Fix session fork losing everything except the conversation log: forked sessions now carry over media attachments, plan files, background task output, and cron tasks, and a failed fork no longer leaves a broken half-copy behind.
- [#1627](https://github.com/MoonshotAI/kimi-code/pull/1627) [`28e9dd4`](https://github.com/MoonshotAI/kimi-code/commit/28e9dd4d627f01143b715976cb071e7d16cd2001) Thanks [@chengluyu](https://github.com/chengluyu)! - web: Continue blocked goals after the user resumes them from the goal controls.
- [#1631](https://github.com/MoonshotAI/kimi-code/pull/1631) [`2d874fb`](https://github.com/MoonshotAI/kimi-code/commit/2d874fbd73eb511e4ef4c8d4c88bd47e429580b2) Thanks [@sailist](https://github.com/sailist)! - Fix a race where a heartbeat write in flight during server shutdown could recreate the instance file right after it was removed.
- [#1663](https://github.com/MoonshotAI/kimi-code/pull/1663) [`1294a0e`](https://github.com/MoonshotAI/kimi-code/commit/1294a0e1ad739151573163505f9c58afb2d543e4) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix OAuth login hanging after browser authorization when the provider configuration changes during sign-in.
- [#1657](https://github.com/MoonshotAI/kimi-code/pull/1657) [`32a89c3`](https://github.com/MoonshotAI/kimi-code/commit/32a89c36432f9aea452a697734102a7956e42e92) Thanks [@RealKai42](https://github.com/RealKai42)! - Prevent oversized image reads from poisoning sessions and recover existing request-too-large failures by removing unsafe media from provider requests.
- [#1635](https://github.com/MoonshotAI/kimi-code/pull/1635) [`e49b3b8`](https://github.com/MoonshotAI/kimi-code/commit/e49b3b877750ba5ca0ea80e154549d5b53455575) Thanks [@sailist](https://github.com/sailist)! - Store background task records per agent again, so tasks written by older versions are found on resume and one agent's restore no longer marks another agent's tasks as lost.
- [#1632](https://github.com/MoonshotAI/kimi-code/pull/1632) [`a4aae87`](https://github.com/MoonshotAI/kimi-code/commit/a4aae87cd9a240d3567601ed1a9aefaab540b075) Thanks [@sailist](https://github.com/sailist)! - Fix providers without a configured base_url being rejected: anthropic/openai and other protocol providers now fall back to their official default endpoints again, as before.
- [#1588](https://github.com/MoonshotAI/kimi-code/pull/1588) [`2061590`](https://github.com/MoonshotAI/kimi-code/commit/20615902c2c3776d17c6c334cedec1c8723222b1) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix pasted media being dropped from /skill and plugin command arguments.
- [#1588](https://github.com/MoonshotAI/kimi-code/pull/1588) [`2061590`](https://github.com/MoonshotAI/kimi-code/commit/20615902c2c3776d17c6c334cedec1c8723222b1) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix pasted images being dropped when steering with Ctrl-S.
- [#1629](https://github.com/MoonshotAI/kimi-code/pull/1629) [`0527ca2`](https://github.com/MoonshotAI/kimi-code/commit/0527ca2267f8cf355d0c158953f3dbfc0c9692ac) Thanks [@sailist](https://github.com/sailist)! - Fix the v2 engine never activating tool-call deduplication: identical tool calls issued in the same step no longer execute multiple times, and repeated identical calls across steps receive escalating reminders again.
- [#1441](https://github.com/MoonshotAI/kimi-code/pull/1441) [`ceb158d`](https://github.com/MoonshotAI/kimi-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Fix a race in the experimental v2 config service that could drop a just-written setting from the config response.
- [#1614](https://github.com/MoonshotAI/kimi-code/pull/1614) [`3c0e368`](https://github.com/MoonshotAI/kimi-code/commit/3c0e368cbdfebff9632cffca3b18365615a146b8) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix a server crash when the first goal-mode prompt is submitted while the v2 agent is still starting.
- [#1631](https://github.com/MoonshotAI/kimi-code/pull/1631) [`2d874fb`](https://github.com/MoonshotAI/kimi-code/commit/2d874fbd73eb511e4ef4c8d4c88bd47e429580b2) Thanks [@sailist](https://github.com/sailist)! - Surface the provider's actual rejection message instead of a misleading re-login prompt when an OAuth-managed model keeps returning 401 after a token refresh.
- [#1631](https://github.com/MoonshotAI/kimi-code/pull/1631) [`2d874fb`](https://github.com/MoonshotAI/kimi-code/commit/2d874fbd73eb511e4ef4c8d4c88bd47e429580b2) Thanks [@sailist](https://github.com/sailist)! - Rewrite repeated-tool-call reminders to redirect the agent toward a different action instead of prohibiting the call, and treat a dismissed question prompt as no answer rather than the recommended option.
- [#1636](https://github.com/MoonshotAI/kimi-code/pull/1636) [`8027fe2`](https://github.com/MoonshotAI/kimi-code/commit/8027fe291b03fbfce6dc60aa06f8699ad0976ec5) Thanks [@sailist](https://github.com/sailist)! - Make file tools able to reach skill directories outside the working directory in the v2 engine (experimental), and honor --skillsDir in v2 print mode and the server's skillDirs option.
- [#1630](https://github.com/MoonshotAI/kimi-code/pull/1630) [`0303b82`](https://github.com/MoonshotAI/kimi-code/commit/0303b82c3e691836163ecf906febfb6324c81d74) Thanks [@sailist](https://github.com/sailist)! - Fix ReadMediaFile results losing their image rendering after a session reload or resume on the v2 server backend.
- [#1441](https://github.com/MoonshotAI/kimi-code/pull/1441) [`ceb158d`](https://github.com/MoonshotAI/kimi-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Fix a storage race in the experimental v2 engine that could fail value reads when writes overlap with compaction.
- [#1601](https://github.com/MoonshotAI/kimi-code/pull/1601) [`dc309a7`](https://github.com/MoonshotAI/kimi-code/commit/dc309a7dfb38b6ef885b8ae80be51b49f8486207) Thanks [@kermanx](https://github.com/kermanx)! - web: Fix the context usage indicator dropping to 0 when a session is reopened or the session list reloads (e.g. after a sidebar search) — the cached live usage is now kept instead of the session record's all-zero placeholder.
- [#1620](https://github.com/MoonshotAI/kimi-code/pull/1620) [`e91a616`](https://github.com/MoonshotAI/kimi-code/commit/e91a616f2196ab9ffc69b3fcc0f2015398d86bd4) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix duplicate user message bubbles after a session snapshot resync.
- [#1672](https://github.com/MoonshotAI/kimi-code/pull/1672) [`88629ba`](https://github.com/MoonshotAI/kimi-code/commit/88629bac3add2a8a17ae8288ee4edbdc9313d55a) Thanks [@yicun](https://github.com/yicun)! - web: Fix uploaded and persisted images failing to display on non-loopback server connections.
- [#1609](https://github.com/MoonshotAI/kimi-code/pull/1609) [`e223549`](https://github.com/MoonshotAI/kimi-code/commit/e223549a79c80e442850947c0cf60d58b2d18667) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix a running multi-step turn rendering a duplicated wall of text after the page reconnects or refreshes mid-turn.
- [#1611](https://github.com/MoonshotAI/kimi-code/pull/1611) [`32cbd0c`](https://github.com/MoonshotAI/kimi-code/commit/32cbd0cf6109f4f3f124e6e4ee7c4c87fa344247) Thanks [@chengluyu](https://github.com/chengluyu)! - web: Fix the workspace picker menu sizing too narrowly for its content.
- [#1635](https://github.com/MoonshotAI/kimi-code/pull/1635) [`e49b3b8`](https://github.com/MoonshotAI/kimi-code/commit/e49b3b877750ba5ca0ea80e154549d5b53455575) Thanks [@sailist](https://github.com/sailist)! - Fix possible record loss when resuming sessions whose wire log needs migration, and reject session logs missing the version envelope instead of silently misreading them.
- [#1441](https://github.com/MoonshotAI/kimi-code/pull/1441) [`ceb158d`](https://github.com/MoonshotAI/kimi-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Fix MCP tools being unavailable on the first turn after session startup.
- [#1580](https://github.com/MoonshotAI/kimi-code/pull/1580) [`83370f1`](https://github.com/MoonshotAI/kimi-code/commit/83370f17ef38770561a421e3b3a15f6244219aa5) Thanks [@wszqkzqk](https://github.com/wszqkzqk)! - Fix bash auto-detection on Windows failing when git comes from a native MSYS2 toolchain (ucrt64/clang64/clangarm64).
- [#1676](https://github.com/MoonshotAI/kimi-code/pull/1676) [`d1820ff`](https://github.com/MoonshotAI/kimi-code/commit/d1820ff0f853689e84b3e9d4c482532c481eb9bd) Thanks [@RealKai42](https://github.com/RealKai42)! - Preserve empty model reasoning blocks across providers so multi-step tool calls can continue.
- [#1669](https://github.com/MoonshotAI/kimi-code/pull/1669) [`490303d`](https://github.com/MoonshotAI/kimi-code/commit/490303db16ed374eae20572e4c6f9880db911547) Thanks [@chengluyu](https://github.com/chengluyu)! - web: Refine goal mode controls with animated strip interactions, budget-aware progress, and design-system cancellation confirmation.
- [#1597](https://github.com/MoonshotAI/kimi-code/pull/1597) [`d601847`](https://github.com/MoonshotAI/kimi-code/commit/d601847f22366b041d949d7c9f7857471be8970c) Thanks [@7Sageer](https://github.com/7Sageer)! - Send the kimi-code-cli User-Agent on provider registry (api.json) and model catalog fetches, so registries can identify the client version.
- [#1441](https://github.com/MoonshotAI/kimi-code/pull/1441) [`ceb158d`](https://github.com/MoonshotAI/kimi-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Fix approval and question prompts not appearing in real time for web clients connected to the v2 server; they previously only showed up after a page refresh.
- [#1441](https://github.com/MoonshotAI/kimi-code/pull/1441) [`ceb158d`](https://github.com/MoonshotAI/kimi-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Log a warning when a skill fails to parse instead of silently dropping it, and fix the skill catalog so scanned skill roots and policy-skipped skills are actually reported.
- [#1589](https://github.com/MoonshotAI/kimi-code/pull/1589) [`f338fcd`](https://github.com/MoonshotAI/kimi-code/commit/f338fcdac4fa8d4235c44310953e5d512f6549fb) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the AgentSwarm member list disappearing after a page refresh while subagents are still running.
- [#1591](https://github.com/MoonshotAI/kimi-code/pull/1591) [`83e1753`](https://github.com/MoonshotAI/kimi-code/commit/83e175399f4dc3dfc3bb478543ff5897a24dfa3d) Thanks [@liruifengv](https://github.com/liruifengv)! - Optimize the TaskOutput tool prompts to discourage blocking waits on background tasks.
- [#1441](https://github.com/MoonshotAI/kimi-code/pull/1441) [`ceb158d`](https://github.com/MoonshotAI/kimi-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Enforce a typed registry for v2 engine telemetry events and redact URLs, tokens, and file paths from outgoing telemetry properties.
- [#1624](https://github.com/MoonshotAI/kimi-code/pull/1624) [`3215129`](https://github.com/MoonshotAI/kimi-code/commit/321512986099037acb4b2677d4455db316d27b50) Thanks [@kermanx](https://github.com/kermanx)! - Fix the experimental v2 engine crashing when the first prompt is sent right after a new conversation is created (for example sending /goal on the web's new-conversation page): agent creation now joins the in-flight bootstrap instead of failing, and the v2 agent lifecycle is split into focused existence, sub-agent, and session MCP domains.
- [#1637](https://github.com/MoonshotAI/kimi-code/pull/1637) [`0e0a6e9`](https://github.com/MoonshotAI/kimi-code/commit/0e0a6e9a5170c28c5e6809c1b2cf6d6f8904de73) Thanks [@sailist](https://github.com/sailist)! - Support caller-supplied MCP server configs on session create in the v2 engine (experimental), merged over the file config and under plugin servers.
- [#1626](https://github.com/MoonshotAI/kimi-code/pull/1626) [`1c85f94`](https://github.com/MoonshotAI/kimi-code/commit/1c85f94472ead2746ad6860ec0e09f4384dd95ec) Thanks [@sailist](https://github.com/sailist)! - v2 engine: expose the prompt scheduler over /api/v2 for native clients, and add an experimental fault-injection service (KIMI_CODE_EXPERIMENTAL_FAULT_INJECTION) that arms a one-shot provider failure so the media-degraded / media-stripped recovery resends can be exercised end-to-end.
- [#1441](https://github.com/MoonshotAI/kimi-code/pull/1441) [`ceb158d`](https://github.com/MoonshotAI/kimi-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Introduce a graded error taxonomy for the v2 engine's filesystem, storage, and wire layers, translating raw OS and parse failures into specific error codes instead of generic internal errors.
- [#1626](https://github.com/MoonshotAI/kimi-code/pull/1626) [`1c85f94`](https://github.com/MoonshotAI/kimi-code/commit/1c85f94472ead2746ad6860ec0e09f4384dd95ec) Thanks [@sailist](https://github.com/sailist)! - v2 engine: block unsupported image formats (AVIF, HEIC, BMP, TIFF, ICO) at every ingestion point so they can no longer poison session history, and auto-recover provider image-format rejections with a media-stripped resend.
- [#1626](https://github.com/MoonshotAI/kimi-code/pull/1626) [`1c85f94`](https://github.com/MoonshotAI/kimi-code/commit/1c85f94472ead2746ad6860ec0e09f4384dd95ec) Thanks [@sailist](https://github.com/sailist)! - v2 engine: recover image-heavy sessions from provider request-size rejections (HTTP 413) by resending with older media degraded to text markers, re-encode oversized WebP images instead of passing them through, and keep downscaled PNGs readable by switching to JPEG below 1000px.
- [#1613](https://github.com/MoonshotAI/kimi-code/pull/1613) [`b2daa40`](https://github.com/MoonshotAI/kimi-code/commit/b2daa405f075cb6847c0a313809b1bcac750b611) Thanks [@7Sageer](https://github.com/7Sageer)! - Support the `services.moonshot_search` api-key config for WebSearch in the v2 engine, matching v1: the tool is now available without an OAuth login, and explicit config takes precedence over the OAuth-derived provider.
- [#1590](https://github.com/MoonshotAI/kimi-code/pull/1590) [`8a4ee05`](https://github.com/MoonshotAI/kimi-code/commit/8a4ee05951ebe4f804fd1fb0989aaf44b3b7a3ed) Thanks [@sailist](https://github.com/sailist)! - Fix bash auto-detection on Windows in the experimental v2 engine when git comes from a native MSYS2 toolchain (ucrt64/clang64/clangarm64).
- [#1441](https://github.com/MoonshotAI/kimi-code/pull/1441) [`ceb158d`](https://github.com/MoonshotAI/kimi-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Send the CLI identity headers (User-Agent and device identity) with outbound requests from the experimental v2 server, matching direct CLI runs.
- [#1593](https://github.com/MoonshotAI/kimi-code/pull/1593) [`2185237`](https://github.com/MoonshotAI/kimi-code/commit/2185237c2f5c5fb3cc6b44c01ac158c6e2b81fe6) Thanks [@kermanx](https://github.com/kermanx)! - Declare v2 engine wire op payloads with required zod schemas and derive their types from the schemas, with ops declared on their models and every op type registered for replay classification.
- [#1441](https://github.com/MoonshotAI/kimi-code/pull/1441) [`ceb158d`](https://github.com/MoonshotAI/kimi-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Keep sessions from the new agent engine compatible with existing transcript replay.
- [#1592](https://github.com/MoonshotAI/kimi-code/pull/1592) [`924d5c9`](https://github.com/MoonshotAI/kimi-code/commit/924d5c914143d178020c2dddc56906ce15088680) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Show the connected backend engine (v1 / v2) in Settings, and add a dev-mode backend pill next to the sidebar brand that can switch the dev proxy between the two engines at runtime.
- [#1606](https://github.com/MoonshotAI/kimi-code/pull/1606) [`2da45fc`](https://github.com/MoonshotAI/kimi-code/commit/2da45fc419cf5285a9353df8690bba444037ffe4) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Fix the goal card disappearing after a page refresh while a session goal is active.
- [#1587](https://github.com/MoonshotAI/kimi-code/pull/1587) [`49a8c84`](https://github.com/MoonshotAI/kimi-code/commit/49a8c84a493610c2b2cc2c7da0a8ec0261d876db) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Let wide Markdown tables in the desktop chat grow up to 1040px with each column capped at 700px so long cell content wraps, temporarily hiding the conversation outline while a table passes under it; anything wider still scrolls inside the table.
## 0.23.6
### Patch Changes
- [#1550](https://github.com/MoonshotAI/kimi-code/pull/1550) [`f17a6ec`](https://github.com/MoonshotAI/kimi-code/commit/f17a6ecb52907ffabf67a26de65df89572ac515a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Treat a dismissed question prompt as the user choosing not to answer, instead of implicitly selecting the recommended option.
- [#1488](https://github.com/MoonshotAI/kimi-code/pull/1488) [`7bd29ab`](https://github.com/MoonshotAI/kimi-code/commit/7bd29ab0117a1c15691404f411fd67f511bbb897) Thanks [@starquakee](https://github.com/starquakee)! - Rename the dynamic tool loading model capability from `select_tools` to `dynamically_loaded_tools`.
- [#1497](https://github.com/MoonshotAI/kimi-code/pull/1497) [`c6e02da`](https://github.com/MoonshotAI/kimi-code/commit/c6e02daf421b47e8451e60ed4d7b3847a895d00b) Thanks [@sailist](https://github.com/sailist)! - Add a print-mode background policy that lets `kimi -p` stay alive across background-task completions so the main agent can be steered into follow-up turns. Set `[background].print_background_mode = "steer"` to enable it.
- [#1555](https://github.com/MoonshotAI/kimi-code/pull/1555) [`2f97917`](https://github.com/MoonshotAI/kimi-code/commit/2f97917bb5edc8bdb9837724e57a88f5c0e1f2bd) Thanks [@sailist](https://github.com/sailist)! - Keep `kimi -p` runs alive after a turn ends while a goal is still active or a cron task is pending, so goal continuations and cron fires run their turns instead of being cut off when the main turn finishes.
- [#1564](https://github.com/MoonshotAI/kimi-code/pull/1564) [`cc03816`](https://github.com/MoonshotAI/kimi-code/commit/cc03816ee0a89b272c1ab87ca43ed246833f0453) Thanks [@sailist](https://github.com/sailist)! - Recognize the support_efforts and default_effort fields when importing a custom registry, so thinking effort levels are available for those models.
- [#1562](https://github.com/MoonshotAI/kimi-code/pull/1562) [`faefad0`](https://github.com/MoonshotAI/kimi-code/commit/faefad0e290ceacb89851baa42043c8685b08dc9) Thanks [@sailist](https://github.com/sailist)! - Add a `subagent.timeout_ms` config option to control how long a single subagent may run before timing out, and raise the default from 30 minutes to 2 hours. Set `[subagent] timeout_ms` in config.toml (or the `KIMI_SUBAGENT_TIMEOUT_MS` env var) to adjust it.
- [#1572](https://github.com/MoonshotAI/kimi-code/pull/1572) [`3a7aad6`](https://github.com/MoonshotAI/kimi-code/commit/3a7aad653f1226ce4e2c7103318471f65154c406) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix sessions getting stuck in a sending state after a reconnect, so the working spinner stops and the next message sends normally once a turn finishes while the connection is down.
- [#1574](https://github.com/MoonshotAI/kimi-code/pull/1574) [`b1942bd`](https://github.com/MoonshotAI/kimi-code/commit/b1942bd5718c46991ba5021b4ae96dbf2458617c) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the first visit after starting or updating the web UI bouncing to the login page when the initial auth check fails; the connecting screen now stays up, shows the connection error, and retries until the server answers.
- [#1553](https://github.com/MoonshotAI/kimi-code/pull/1553) [`264525e`](https://github.com/MoonshotAI/kimi-code/commit/264525eb51f87409a8961bcf3f5f0271ab767c49) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the chat view jumping downward while scrolling through conversation history.
- [#1565](https://github.com/MoonshotAI/kimi-code/pull/1565) [`1d3dba5`](https://github.com/MoonshotAI/kimi-code/commit/1d3dba56832b69628f3bb22ce240f38a08f0af3a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the model dropdown showing checkmarks on same-named models from other providers; the current model is now matched by its unique model id.
- [#1567](https://github.com/MoonshotAI/kimi-code/pull/1567) [`f901b9e`](https://github.com/MoonshotAI/kimi-code/commit/f901b9e1da9a9575b5d47dba40babe2ccd035180) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Keep the server access token for up to 7 days across tab close and browser restarts, instead of asking for it again with every new tab.
- [#1552](https://github.com/MoonshotAI/kimi-code/pull/1552) [`37bb4b8`](https://github.com/MoonshotAI/kimi-code/commit/37bb4b870edf6a5458dda755a5b4a432c32df2a7) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix ReadMediaFile results rendering as plain tool cards instead of images after resuming or reloading a session.
- [#1563](https://github.com/MoonshotAI/kimi-code/pull/1563) [`c982386`](https://github.com/MoonshotAI/kimi-code/commit/c98238699c1ae51a2237969b43282373fc0c0e89) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix sidebar lag with many sessions by removing repeated session list scans during rendering.
- [#1475](https://github.com/MoonshotAI/kimi-code/pull/1475) [`5a208cb`](https://github.com/MoonshotAI/kimi-code/commit/5a208cb041530e320f343a46e231bf3c109e30c9) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Auto-enable the default thinking effort when switching to a model that supports effort levels in the web UI.
- [#1577](https://github.com/MoonshotAI/kimi-code/pull/1577) [`6fc1deb`](https://github.com/MoonshotAI/kimi-code/commit/6fc1deb45312574a9e97ffaf6d7ced530d38910d) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Let wide Markdown tables in the desktop chat grow beyond the reading column up to 1040px, temporarily hiding the conversation outline while a table passes under it; anything wider still scrolls inside the table.
- [#1575](https://github.com/MoonshotAI/kimi-code/pull/1575) [`9d96b53`](https://github.com/MoonshotAI/kimi-code/commit/9d96b538bf4311fdf07aa262a1b4141f7bdd83ed) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Let wide Markdown tables scroll horizontally inside the table instead of being squeezed into the reading column.
- [#1556](https://github.com/MoonshotAI/kimi-code/pull/1556) [`d2c2c33`](https://github.com/MoonshotAI/kimi-code/commit/d2c2c33f3e89c7c9ed06aa7c2376b88b6107e41d) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Add workspaces by typing an absolute path directly in the workspace picker's search box, with live validation and completion suggestions.
- [#1547](https://github.com/MoonshotAI/kimi-code/pull/1547) [`19c5aa6`](https://github.com/MoonshotAI/kimi-code/commit/19c5aa64ebef86925ad58074ebcac6a5a7a8ff8d) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Update the WebBridge install page link opened from the /plugins panel.
## 0.23.5
### Patch Changes
- [#1542](https://github.com/MoonshotAI/kimi-code/pull/1542) [`f80b2ea`](https://github.com/MoonshotAI/kimi-code/commit/f80b2eaf04925ce920f693fc8d4d81cb00e825d7) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the "Turn finished" desktop notification and completion sound firing twice per turn.
- [#1535](https://github.com/MoonshotAI/kimi-code/pull/1535) [`04041eb`](https://github.com/MoonshotAI/kimi-code/commit/04041eb998b6798898fa5df97f7587b3aa119b27) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Hide the internal image-compression note so it no longer renders as user message text.
- [#1536](https://github.com/MoonshotAI/kimi-code/pull/1536) [`db61c9e`](https://github.com/MoonshotAI/kimi-code/commit/db61c9e2dddcb6c35b019ef7f374385248ece881) Thanks [@RealKai42](https://github.com/RealKai42)! - Stop unsupported image formats (AVIF, BMP, TIFF, ICO, …) from breaking sessions at every entry point — including remote image URLs and images mislabeled by a tool — and recover an already-stuck session by dropping the offending image and retrying, so one such image can no longer make every later request fail.
- [#1530](https://github.com/MoonshotAI/kimi-code/pull/1530) [`9f66ec4`](https://github.com/MoonshotAI/kimi-code/commit/9f66ec416cc658842dc1414b79b6447d1b4cc7f9) Thanks [@sailist](https://github.com/sailist)! - Retry provider 429, overload, and other transient errors more reliably, honoring the server Retry-After delay, and surface retries in `-p --output-format stream-json`.
## 0.23.4
### Patch Changes
- [#1501](https://github.com/MoonshotAI/kimi-code/pull/1501) [`b91099e`](https://github.com/MoonshotAI/kimi-code/commit/b91099ed7a2590d1afa4d6e3675671da52b7661c) Thanks [@liruifengv](https://github.com/liruifengv)! - Display Extra Usage (fuel pack) balance in `/usage` and `/status` commands.
- [#1517](https://github.com/MoonshotAI/kimi-code/pull/1517) [`173bdfd`](https://github.com/MoonshotAI/kimi-code/commit/173bdfdab1f484ed79927aeaac7dc8116d3fd346) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix resuming sessions whose original working directory no longer exists.
- [#1516](https://github.com/MoonshotAI/kimi-code/pull/1516) [`9fb1915`](https://github.com/MoonshotAI/kimi-code/commit/9fb19154accf6b6f7abfbf7a9820ccda517bc87e) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix prompt-mode goals so they run until completion and report invalid goal commands before sending prompts.
- [#1508](https://github.com/MoonshotAI/kimi-code/pull/1508) [`1bf2c9a`](https://github.com/MoonshotAI/kimi-code/commit/1bf2c9afee4643fbf6755f0b92fd60aa14240501) Thanks [@RealKai42](https://github.com/RealKai42)! - Keep image-heavy sessions within provider request-size limits: model-read images now honor a 256 KB per-image budget and a 2000px downscale cap (configurable via `[image]` in config.toml or `KIMI_IMAGE_*` env vars), oversized WebP is compressed as well, HEIC/HEIF reads are refused with a platform-matched conversion command instead of poisoning the session, and a request-too-large rejection (HTTP 413) now recovers automatically — the request and /compact both retry with older media replaced by text markers instead of failing the session.
- [#1519](https://github.com/MoonshotAI/kimi-code/pull/1519) [`170ae44`](https://github.com/MoonshotAI/kimi-code/commit/170ae4420526b6592d696cd597d1693dbd1a660b) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Polish the session sidebar layout, colors, icons, and typography.
- [#1521](https://github.com/MoonshotAI/kimi-code/pull/1521) [`046b6c4`](https://github.com/MoonshotAI/kimi-code/commit/046b6c417581792933732c7ffe154e120c96171d) Thanks [@RealKai42](https://github.com/RealKai42)! - The `[image]` limits in config.toml now also apply to pasted images (CLI paste and ACP prompts), and each core now uses its own settings, so reloading one client's config no longer changes another client's image compression.
- [#1494](https://github.com/MoonshotAI/kimi-code/pull/1494) [`a354803`](https://github.com/MoonshotAI/kimi-code/commit/a3548035a8b6d25df9a11daab37a21daee1ef73f) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Add a Kimi WebBridge entry to the Official tab of the /plugins panel that opens the WebBridge install page in your browser.
- [#1479](https://github.com/MoonshotAI/kimi-code/pull/1479) [`735922c`](https://github.com/MoonshotAI/kimi-code/commit/735922c291ec3d32d60da6af053f75e1c6179f92) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Add notifications when a tool needs approval, and improve notification reliability.
- [#1522](https://github.com/MoonshotAI/kimi-code/pull/1522) [`ec8dc34`](https://github.com/MoonshotAI/kimi-code/commit/ec8dc3456c1696a5eba6c37b6e26ef99837c35e2) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix an occasional "another turn is active" error when sending the first message of a new conversation, and show a starting state while it is being sent.
- [#1502](https://github.com/MoonshotAI/kimi-code/pull/1502) [`ad30a1c`](https://github.com/MoonshotAI/kimi-code/commit/ad30a1c6328327729221f9f5fc700b621dfef779) Thanks [@chengluyu](https://github.com/chengluyu)! - web: Polish the chat UI with Inter typography, localized labels, and tighter sidebar, composer, and menu styling.
## 0.23.3 ## 0.23.3
### Patch Changes ### Patch Changes
- [#1506](https://github.com/MoonshotAI/kimi-code/pull/1506) [`e83511a`](https://github.com/MoonshotAI/kimi-code/commit/e83511a7118652a67676bbcfd41148907ad7b8de) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix a misleading "OAuth login expired" message shown when a model is not available for the current account; the CLI now shows the provider's actual error. - [#1506](https://github.com/MoonshotAI/kimi-code/pull/1506) [`e83511a`](https://github.com/MoonshotAI/kimi-code/commit/e83511a7118652a67676bbcfd41148907ad7b8de) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix a misleading "OAuth login expired" message shown when a model is not available for the current account.
## 0.23.2 ## 0.23.2

View file

@ -1,6 +1,6 @@
{ {
"name": "@moonshot-ai/kimi-code", "name": "@moonshot-ai/kimi-code",
"version": "0.23.3", "version": "0.24.1",
"description": "The Starting Point for Next-Gen Agents", "description": "The Starting Point for Next-Gen Agents",
"license": "MIT", "license": "MIT",
"author": "Moonshot AI", "author": "Moonshot AI",
@ -36,13 +36,14 @@
"type": "module", "type": "module",
"imports": { "imports": {
"#/tui/theme": "./src/tui/theme/index.ts", "#/tui/theme": "./src/tui/theme/index.ts",
"#/tui/commands": "./src/tui/commands/index.ts",
"#/cli/sub/server": "./src/cli/sub/server/index.ts", "#/cli/sub/server": "./src/cli/sub/server/index.ts",
"#/cli/sub/server/*": "./src/cli/sub/server/*.ts", "#/cli/sub/server/*": "./src/cli/sub/server/*.ts",
"#/*": [ "#/generated/vis-web-asset": [
"./src/*.ts", "./src/generated/vis-web-asset.ts",
"./src/*/index.ts", "./src/generated/vis-web-asset.d.ts"
"./src/*.d.ts" ],
] "#/*": "./src/*.ts"
}, },
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
@ -63,6 +64,8 @@
"dev": "node scripts/dev.mjs", "dev": "node scripts/dev.mjs",
"dev:cli-only": "tsx --import ../../build/register-raw-text-loader.mjs ./src/main.ts", "dev:cli-only": "tsx --import ../../build/register-raw-text-loader.mjs ./src/main.ts",
"dev:server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground", "dev:server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground",
"dev:kap-server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground",
"dev:kap-server:multi": "KIMI_CODE_EXPERIMENTAL_MULTI_SERVER=1 tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground",
"dev:server:restart": "node scripts/dev-server-restart.mjs", "dev:server:restart": "node scripts/dev-server-restart.mjs",
"dev:plugin-marketplace": "node scripts/dev-plugin-marketplace-server.mjs", "dev:plugin-marketplace": "node scripts/dev-plugin-marketplace-server.mjs",
"build:plugin-marketplace": "node scripts/build-plugin-marketplace-cdn.mjs", "build:plugin-marketplace": "node scripts/build-plugin-marketplace-cdn.mjs",
@ -80,13 +83,14 @@
}, },
"devDependencies": { "devDependencies": {
"@moonshot-ai/acp-adapter": "workspace:^", "@moonshot-ai/acp-adapter": "workspace:^",
"@moonshot-ai/agent-core-v2": "workspace:^",
"@moonshot-ai/kap-server": "workspace:^",
"@moonshot-ai/kimi-code-oauth": "workspace:^", "@moonshot-ai/kimi-code-oauth": "workspace:^",
"@moonshot-ai/kimi-code-sdk": "workspace:^", "@moonshot-ai/kimi-code-sdk": "workspace:^",
"@moonshot-ai/kimi-telemetry": "workspace:^", "@moonshot-ai/kimi-telemetry": "workspace:^",
"@moonshot-ai/kimi-web": "workspace:^", "@moonshot-ai/kimi-web": "workspace:^",
"@moonshot-ai/migration-legacy": "workspace:^", "@moonshot-ai/migration-legacy": "workspace:^",
"@moonshot-ai/pi-tui": "workspace:^", "@moonshot-ai/pi-tui": "workspace:^",
"@moonshot-ai/server": "workspace:^",
"@moonshot-ai/vis-server": "workspace:^", "@moonshot-ai/vis-server": "workspace:^",
"@moonshot-ai/vis-web": "workspace:*", "@moonshot-ai/vis-web": "workspace:*",
"@types/semver": "^7.7.0", "@types/semver": "^7.7.0",

View file

@ -46,7 +46,7 @@ function executableLines() {
} }
for (const line of executableLines()) { for (const line of executableLines()) {
for (const match of line.matchAll(/\brequire\(\s*["']([^"']+)["']\s*\)/g)) { for (const match of line.matchAll(/(?<![.\w])require\(\s*["']([^"']+)["']\s*\)/g)) {
const specifier = match[1]; const specifier = match[1];
if (specifier.startsWith('.') || specifier.startsWith('/')) { if (specifier.startsWith('.') || specifier.startsWith('/')) {
if (optionalRelativeRuntimeRequires.has(specifier)) continue; if (optionalRelativeRuntimeRequires.has(specifier)) continue;

View file

@ -24,6 +24,10 @@ const KEEP_MODEL = new Set([
"reasoning", "reasoning",
"interleaved", "interleaved",
"modalities", "modalities",
// Message-level tool declarations capability — kosong's
// catalogModelToCapability reads it; stripping it here would silently
// disable tool-select for catalog-imported aliases.
"dynamically_loaded_tools",
]); ]);
function resolveOutputFile(args) { function resolveOutputFile(args) {

View file

@ -0,0 +1,29 @@
/**
* Experimental agent-core-v2 engine gate for `kimi -p` (print mode).
*
* When the master switch `KIMI_CODE_EXPERIMENTAL_FLAG` is truthy, print mode
* routes to the native agent-core-v2 runner instead of the default v1
* harness (see `run-prompt.ts`). Read directly from the env (matching
* `cli/update/rollout.ts`) because the CLI must not depend on the core flag
* registry. Unset / any non-truthy value keeps the v1 harness.
*
* Note: `kimi server run` always boots kap-server (the agent-core-v2 engine
* server) it no longer consults this switch.
*/
export const KIMI_V2_ENV = 'KIMI_CODE_EXPERIMENTAL_FLAG';
const TRUTHY_VALUES = new Set(['1', 'true', 'yes', 'on']);
function isTruthyEnv(
key: string,
env: Readonly<Record<string, string | undefined>>,
): boolean {
return TRUTHY_VALUES.has((env[key] ?? '').trim().toLowerCase());
}
export function isKimiV2Enabled(
env: Readonly<Record<string, string | undefined>> = process.env,
): boolean {
return isTruthyEnv(KIMI_V2_ENV, env);
}

View file

@ -46,13 +46,18 @@ const GOAL_PREFIX = /^\/goal(\s|$)/;
* Parses a headless prompt into a goal-create request, or `undefined` when the * Parses a headless prompt into a goal-create request, or `undefined` when the
* prompt is not a `/goal` create command (so the caller runs it as a normal * prompt is not a `/goal` create command (so the caller runs it as a normal
* prompt). Non-create goal subcommands are not supported headless and fall * prompt). Non-create goal subcommands are not supported headless and fall
* through to normal prompt handling. * through to normal prompt handling. Malformed create commands throw instead of
* falling through, so validation errors are reported before anything is sent to
* the model.
*/ */
export function parseHeadlessGoalCreate(prompt: string): HeadlessGoalCreate | undefined { export function parseHeadlessGoalCreate(prompt: string): HeadlessGoalCreate | undefined {
const trimmed = prompt.trim(); const trimmed = prompt.trim();
if (!GOAL_PREFIX.test(trimmed)) return undefined; if (!GOAL_PREFIX.test(trimmed)) return undefined;
const args = trimmed.replace(/^\/goal/, '').trim(); const args = trimmed.replace(/^\/goal/, '').trim();
const parsed = parseGoalCommand(args); const parsed = parseGoalCommand(args);
if (parsed.kind === 'error') {
throw new Error(parsed.message);
}
if (parsed.kind !== 'create') return undefined; if (parsed.kind !== 'create') return undefined;
return { objective: parsed.objective, replace: parsed.replace }; return { objective: parsed.objective, replace: parsed.replace };
} }

View file

@ -1,6 +1,39 @@
export type UIMode = 'shell' | 'print'; export type UIMode = 'shell' | 'print';
export type PromptOutputFormat = 'text' | 'stream-json'; export type PromptOutputFormat = 'text' | 'stream-json';
/** Environment variable that sets the default `-p` output format (flag wins). */
export const OUTPUT_FORMAT_ENV = 'KIMI_MODEL_OUTPUT_FORMAT';
const OUTPUT_FORMATS = ['text', 'stream-json'] as const;
function isOutputFormat(value: string): value is PromptOutputFormat {
return (OUTPUT_FORMATS as readonly string[]).includes(value);
}
/**
* Resolve the effective `-p` output format.
*
* Precedence: explicit `--output-format` flag `KIMI_MODEL_OUTPUT_FORMAT` env
* (prompt mode only) `text`. The env var is ignored outside prompt mode so an
* ambient value never affects interactive `kimi`. An invalid env value fails
* fast via `OptionConflictError`.
*/
export function resolveOutputFormat(
opts: Pick<CLIOptions, 'prompt' | 'outputFormat'>,
env: Readonly<Record<string, string | undefined>> = process.env,
): PromptOutputFormat {
if (opts.outputFormat !== undefined) return opts.outputFormat;
if (opts.prompt === undefined) return 'text';
const raw = (env[OUTPUT_FORMAT_ENV] ?? '').trim();
if (raw.length === 0) return 'text';
if (!isOutputFormat(raw)) {
throw new OptionConflictError(
`Invalid ${OUTPUT_FORMAT_ENV} value "${raw}". Expected one of: text, stream-json.`,
);
}
return raw;
}
export interface CLIOptions { export interface CLIOptions {
session: string | undefined; session: string | undefined;
continue: boolean; continue: boolean;
@ -26,7 +59,10 @@ export class OptionConflictError extends Error {
} }
} }
export function validateOptions(opts: CLIOptions): ValidatedOptions { export function validateOptions(
opts: CLIOptions,
env: Readonly<Record<string, string | undefined>> = process.env,
): ValidatedOptions {
const prompt = opts.prompt; const prompt = opts.prompt;
const promptMode = prompt !== undefined; const promptMode = prompt !== undefined;
if (promptMode && prompt.trim().length === 0) { if (promptMode && prompt.trim().length === 0) {
@ -56,5 +92,8 @@ export function validateOptions(opts: CLIOptions): ValidatedOptions {
if (opts.yolo && opts.auto) { if (opts.yolo && opts.auto) {
throw new OptionConflictError('Cannot combine --yolo with --auto.'); throw new OptionConflictError('Cannot combine --yolo with --auto.');
} }
// Validate `KIMI_MODEL_OUTPUT_FORMAT` eagerly in prompt mode so a typo fails
// fast through the friendly `error:` path instead of mid-run.
if (promptMode) resolveOutputFormat(opts, env);
return { options: opts, uiMode: promptMode ? 'print' : 'shell' }; return { options: opts, uiMode: promptMode ? 'print' : 'shell' };
} }

View file

@ -0,0 +1,409 @@
/**
* Output rendering for `kimi -p` (print mode) shared by the v1 driver
* (`run-prompt.ts`) and the native v2 runner (`v2/run-v2-print.ts`).
*
* Both engines feed the same writer classes: v1 via the SDK `Event` stream, v2
* via the main agent's native `IEventBus` (whose `DomainEvent` payloads are
* already v1-protocol-shaped). Keeping the writers here lets v2 reuse them
* without re-implementing rendering, while v1's `runPromptTurn` keeps its own
* event-filtering / completion flow intact.
*/
import type { PromptOutputFormat } from './options';
/**
* Structural hook-result shape the renderer reads. Both the v1 SDK
* `HookResultEvent` and the v2 native `hook.result` `DomainEvent` satisfy it,
* so the renderer stays engine-agnostic without depending on either event
* definition.
*/
interface HookResultEventLike {
readonly hookEvent: string;
readonly content: string;
readonly blocked?: boolean;
}
/**
* Structural retry shape the renderer reads. Mirrors the v1 SDK
* `turn.step.retrying` event fields the stream-json meta line surfaces. Only
* the v1 driver forwards retries to `writeRetrying`; the v2 runner currently
* just discards the failed attempt's partial output and stays silent.
*/
interface RetryingEventLike {
readonly failedAttempt: number;
readonly nextAttempt: number;
readonly maxAttempts: number;
readonly delayMs: number;
readonly errorName: string;
readonly errorMessage: string;
readonly statusCode?: number;
}
export interface PromptOutput {
readonly columns?: number | undefined;
write(chunk: string): boolean;
}
const PROMPT_BLOCK_BULLET = '• ';
const PROMPT_BLOCK_INDENT = ' ';
export interface PromptTurnWriter {
writeAssistantDelta(delta: string): void;
writeHookResult(event: HookResultEventLike): void;
writeThinkingDelta(delta: string): void;
writeToolCall(toolCallId: string, name: string, args: unknown): void;
writeToolCallDelta(
toolCallId: string,
name: string | undefined,
argumentsPart: string | undefined,
): void;
writeToolResult(toolCallId: string, output: unknown): void;
writeRetrying(event: RetryingEventLike): void;
flushAssistant(): void;
discardAssistant(): void;
finish(): void;
}
interface PromptJsonToolCall {
type: 'function';
id: string;
function: {
name: string;
arguments: string;
};
}
interface PromptJsonAssistantMessage {
role: 'assistant';
content?: string;
tool_calls?: PromptJsonToolCall[];
}
interface PromptJsonToolMessage {
role: 'tool';
tool_call_id: string;
content: string;
}
interface PromptJsonRetryMetaMessage {
role: 'meta';
type: 'turn.step.retrying';
failed_attempt: number;
next_attempt: number;
max_attempts: number;
delay_ms: number;
error_name: string;
error_message: string;
status_code?: number;
}
export class PromptTranscriptWriter implements PromptTurnWriter {
private readonly assistantWriter: PromptBlockWriter;
private readonly thinkingWriter: PromptBlockWriter;
constructor(stdout: PromptOutput, stderr: PromptOutput) {
this.assistantWriter = new PromptBlockWriter(stdout);
this.thinkingWriter = new PromptBlockWriter(stderr);
}
writeAssistantDelta(delta: string): void {
this.thinkingWriter.finish();
this.assistantWriter.write(delta);
}
writeHookResult(event: HookResultEventLike): void {
this.thinkingWriter.finish();
this.assistantWriter.finish();
this.assistantWriter.write(formatHookResultPlain(event));
this.assistantWriter.finish();
}
writeThinkingDelta(delta: string): void {
this.thinkingWriter.write(delta);
}
writeToolCall(): void {}
writeToolCallDelta(): void {}
writeToolResult(): void {}
// Text `-p` keeps retries silent: only the failed attempt's partial assistant
// text is discarded (handled by the caller). No human-readable retry line is
// emitted, matching the prior behavior.
writeRetrying(): void {}
flushAssistant(): void {
this.assistantWriter.finish();
}
discardAssistant(): void {}
finish(): void {
this.thinkingWriter.finish();
this.assistantWriter.finish();
}
}
export class PromptJsonWriter implements PromptTurnWriter {
private assistantText = '';
private readonly toolCalls: PromptJsonToolCall[] = [];
constructor(private readonly stdout: PromptOutput) {}
writeAssistantDelta(delta: string): void {
this.assistantText += delta;
}
writeHookResult(event: HookResultEventLike): void {
this.flushAssistant();
this.writeJsonLine({
role: 'assistant',
content: formatHookResultPlain(event),
});
}
writeThinkingDelta(): void {}
writeToolCall(toolCallId: string, name: string, args: unknown): void {
const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId);
if (existing !== undefined) {
existing.function.name = name;
existing.function.arguments = stringifyJsonValue(args);
return;
}
this.toolCalls.push({
type: 'function',
id: toolCallId,
function: {
name,
arguments: stringifyJsonValue(args),
},
});
}
writeToolCallDelta(
toolCallId: string,
name: string | undefined,
argumentsPart: string | undefined,
): void {
const toolCall = this.findOrCreateToolCall(toolCallId, name ?? '');
if (name !== undefined) {
toolCall.function.name = name;
}
if (argumentsPart !== undefined) {
toolCall.function.arguments += argumentsPart;
}
}
writeToolResult(toolCallId: string, output: unknown): void {
this.flushAssistant();
this.writeJsonLine({
role: 'tool',
tool_call_id: toolCallId,
content: stringifyToolOutput(output),
});
}
writeRetrying(event: RetryingEventLike): void {
// Emit a machine-readable meta line so stream-json consumers can observe
// provider retries. The failed attempt's partial assistant text was already
// discarded by the caller, so no half-formed assistant message leaks.
const message: PromptJsonRetryMetaMessage = {
role: 'meta',
type: 'turn.step.retrying',
failed_attempt: event.failedAttempt,
next_attempt: event.nextAttempt,
max_attempts: event.maxAttempts,
delay_ms: event.delayMs,
error_name: event.errorName,
error_message: event.errorMessage,
status_code: event.statusCode,
};
this.writeJsonLine(message);
}
flushAssistant(): void {
if (this.assistantText.length === 0 && this.toolCalls.length === 0) return;
const message: PromptJsonAssistantMessage = {
role: 'assistant',
content: this.assistantText.length > 0 ? this.assistantText : undefined,
tool_calls: this.toolCalls.length > 0 ? [...this.toolCalls] : undefined,
};
this.writeJsonLine(message);
this.discardAssistant();
}
discardAssistant(): void {
this.assistantText = '';
this.toolCalls.length = 0;
}
finish(): void {
this.flushAssistant();
}
private findOrCreateToolCall(toolCallId: string, name: string): PromptJsonToolCall {
const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId);
if (existing !== undefined) return existing;
const toolCall: PromptJsonToolCall = {
type: 'function',
id: toolCallId,
function: {
name,
arguments: '',
},
};
this.toolCalls.push(toolCall);
return toolCall;
}
private writeJsonLine(
message: PromptJsonAssistantMessage | PromptJsonToolMessage | PromptJsonRetryMetaMessage,
): void {
this.stdout.write(`${JSON.stringify(message)}\n`);
}
}
class PromptBlockWriter {
private started = false;
private atLineStart = false;
private lineWidth = 0;
private readonly wrapWidth: number | undefined;
constructor(private readonly output: PromptOutput) {
this.wrapWidth =
typeof output.columns === 'number' && output.columns > PROMPT_BLOCK_INDENT.length + 1
? output.columns
: undefined;
}
write(chunk: string): void {
if (chunk.length === 0) return;
let rendered = this.start();
for (const char of chunk) {
if (this.atLineStart && char !== '\n') {
rendered += PROMPT_BLOCK_INDENT;
this.atLineStart = false;
this.lineWidth = PROMPT_BLOCK_INDENT.length;
}
const charWidth = visibleCharWidth(char);
if (
this.wrapWidth !== undefined &&
!this.atLineStart &&
char !== '\n' &&
this.lineWidth + charWidth > this.wrapWidth
) {
rendered += `\n${PROMPT_BLOCK_INDENT}`;
this.lineWidth = PROMPT_BLOCK_INDENT.length;
}
rendered += char;
if (char === '\n') {
this.atLineStart = true;
this.lineWidth = 0;
} else {
this.lineWidth += charWidth;
}
}
this.output.write(rendered);
}
finish(): void {
if (!this.started) return;
this.output.write(this.atLineStart ? '\n' : '\n\n');
this.started = false;
this.atLineStart = false;
this.lineWidth = 0;
}
private start(): string {
if (this.started) return '';
this.started = true;
this.atLineStart = false;
this.lineWidth = PROMPT_BLOCK_BULLET.length;
return PROMPT_BLOCK_BULLET;
}
}
function visibleCharWidth(char: string): number {
return char === '\t' ? 4 : 1;
}
function formatHookResultPlain(event: HookResultEventLike): string {
return `${formatHookResultTitle(event)}\n\n${formatHookResultBody(event)}`;
}
function formatHookResultTitle(event: HookResultEventLike): string {
return `${event.hookEvent} hook${event.blocked === true ? ' blocked' : ''}`;
}
function formatHookResultBody(event: HookResultEventLike): string {
const content = event.content.trim();
return content.length === 0 ? '(empty)' : content;
}
function stringifyJsonValue(value: unknown): string {
if (typeof value === 'string') return value;
const json = JSON.stringify(value);
return json ?? '';
}
function stringifyToolOutput(output: unknown): string {
if (typeof output === 'string') return output;
const json = JSON.stringify(output);
return json ?? String(output);
}
interface PromptJsonResumeMetaMessage {
role: 'meta';
type: 'session.resume_hint';
session_id: string;
command: string;
content: string;
}
interface PromptJsonVersionMetaMessage {
role: 'meta';
type: 'system.version';
version: string;
}
export function writeExperimentalVersion(
version: string,
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
stderr: PromptOutput,
): void {
if (outputFormat === 'stream-json') {
const message: PromptJsonVersionMetaMessage = {
role: 'meta',
type: 'system.version',
version,
};
stdout.write(`${JSON.stringify(message)}\n`);
return;
}
stderr.write(`kimi version ${version}\n`);
}
export function writeResumeHint(
sessionId: string,
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
stderr: PromptOutput,
): void {
const command = `kimi -r ${sessionId}`;
const content = `To resume this session: ${command}`;
if (outputFormat === 'stream-json') {
const message: PromptJsonResumeMetaMessage = {
role: 'meta',
type: 'session.resume_hint',
session_id: sessionId,
command,
content,
};
stdout.write(`${JSON.stringify(message)}\n`);
return;
}
stderr.write(`${content}\n`);
}

View file

@ -0,0 +1,66 @@
/**
* Minimal harness/session surface consumed by `kimi -p` (print mode).
*
* `run-prompt.ts` only needs a small subset of the SDK `KimiHarness` / `Session`
* API. Coding the print-mode driver against these narrow interfaces instead of
* the concrete SDK classes lets the same driver run on either the v1 engine
* (`createKimiHarness`, the default) or the experimental agent-core-v2 engine
* (`createPromptHarnessV2`, gated by `KIMI_CODE_EXPERIMENTAL_FLAG`). Both the
* v1 `KimiHarness` / `Session` and the v2 harness structurally satisfy these
* interfaces, so no adapter wrappers are needed on the v1 path.
*/
import type {
ApprovalHandler,
ConfigDiagnostics,
CreateGoalInput,
CreateSessionOptions,
Event,
GetCronTasksResult,
GoalSnapshot,
GoalToolResult,
KimiAuthFacade,
KimiConfig,
ListSessionsOptions,
PermissionMode,
PromptInput,
QuestionHandler,
ResumeSessionInput,
SessionStatus,
SessionSummary,
TelemetryProperties,
Unsubscribe,
} from '@moonshot-ai/kimi-code-sdk';
export interface PromptHarness {
readonly homeDir: string;
readonly auth: KimiAuthFacade;
track(event: string, properties?: TelemetryProperties): void;
ensureConfigFile(): Promise<void>;
getConfig(): Promise<Pick<KimiConfig, 'defaultModel' | 'telemetry'>>;
getConfigDiagnostics(): Promise<ConfigDiagnostics>;
listSessions(options: ListSessionsOptions): Promise<readonly SessionSummary[]>;
createSession(options: CreateSessionOptions): Promise<PromptSession>;
resumeSession(input: ResumeSessionInput): Promise<PromptSession>;
close(): Promise<void>;
}
export interface PromptSession {
readonly id: string;
readonly workDir: string;
getStatus(): Promise<SessionStatus>;
setModel(model: string): Promise<void>;
setPermission(mode: PermissionMode): Promise<void>;
setApprovalHandler(handler: ApprovalHandler | undefined): void;
setQuestionHandler(handler: QuestionHandler | undefined): void;
onEvent(listener: (event: Event) => void): Unsubscribe;
prompt(input: string | PromptInput): Promise<void>;
waitForBackgroundTasksOnPrint(): Promise<void>;
handlePrintMainTurnCompleted?(): Promise<'finish' | 'continue'>;
createGoal(input: CreateGoalInput): Promise<GoalSnapshot>;
getGoal(): Promise<GoalToolResult>;
getCronTasks(): Promise<GetCronTasksResult>;
}

View file

@ -11,9 +11,6 @@ import {
log, log,
type Event, type Event,
type GoalSnapshot, type GoalSnapshot,
type HookResultEvent,
type KimiHarness,
type Session,
type SessionStatus, type SessionStatus,
type TelemetryClient, type TelemetryClient,
} from '@moonshot-ai/kimi-code-sdk'; } from '@moonshot-ai/kimi-code-sdk';
@ -21,6 +18,8 @@ import { resolve } from 'pathe';
import { CLI_SHUTDOWN_TIMEOUT_MS, PROMPT_CLEANUP_TIMEOUT_MS } from '#/constant/app'; import { CLI_SHUTDOWN_TIMEOUT_MS, PROMPT_CLEANUP_TIMEOUT_MS } from '#/constant/app';
import { isKimiV2Enabled } from './experimental-v2';
import { resolveOutputFormat } from './options';
import type { CLIOptions, PromptOutputFormat } from './options'; import type { CLIOptions, PromptOutputFormat } from './options';
import { import {
formatGoalSummaryText, formatGoalSummaryText,
@ -29,6 +28,8 @@ import {
parseHeadlessGoalCreate, parseHeadlessGoalCreate,
type HeadlessGoalCreate, type HeadlessGoalCreate,
} from './goal-prompt'; } from './goal-prompt';
import type { PromptHarness, PromptSession } from './prompt-session';
import { PromptJsonWriter, PromptTranscriptWriter, writeResumeHint } from './prompt-render';
import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry'; import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry';
import { createKimiCodeHostIdentity } from './version'; import { createKimiCodeHostIdentity } from './version';
@ -50,7 +51,7 @@ import { createKimiCodeHostIdentity } from './version';
* alive until it fires, then gives the rejection a chance to surface. A wedged * alive until it fires, then gives the rejection a chance to surface. A wedged
* cleanup is still bounded by `timeoutMs`, so this can't hang the run forever. * cleanup is still bounded by `timeoutMs`, so this can't hang the run forever.
*/ */
async function raceWithTimeout(promise: Promise<void>, timeoutMs: number): Promise<void> { export async function raceWithTimeout(promise: Promise<void>, timeoutMs: number): Promise<void> {
let timedOut = false; let timedOut = false;
let timer: ReturnType<typeof setTimeout> | undefined; let timer: ReturnType<typeof setTimeout> | undefined;
// Attach the catch eagerly (synchronously) so `promise` is always consumed and // Attach the catch eagerly (synchronously) so `promise` is always consumed and
@ -78,13 +79,13 @@ interface PromptOutput {
write(chunk: string): boolean; write(chunk: string): boolean;
} }
interface PromptRunIO { export interface PromptRunIO {
readonly stdout?: PromptOutput; readonly stdout?: PromptOutput;
readonly stderr?: PromptOutput; readonly stderr?: PromptOutput;
readonly process?: PromptProcess; readonly process?: PromptProcess;
} }
interface PromptProcess { export interface PromptProcess {
once(signal: NodeJS.Signals, listener: () => Promise<void>): unknown; once(signal: NodeJS.Signals, listener: () => Promise<void>): unknown;
off(signal: NodeJS.Signals, listener: () => Promise<void>): unknown; off(signal: NodeJS.Signals, listener: () => Promise<void>): unknown;
exit(code?: number): never | void; exit(code?: number): never | void;
@ -92,18 +93,27 @@ interface PromptProcess {
const PROMPT_UI_MODE = 'print'; const PROMPT_UI_MODE = 'print';
const PROMPT_MAIN_AGENT_ID = 'main'; const PROMPT_MAIN_AGENT_ID = 'main';
const PROMPT_BLOCK_BULLET = '• ';
const PROMPT_BLOCK_INDENT = ' ';
export async function runPrompt( export async function runPrompt(
opts: CLIOptions, opts: CLIOptions,
version: string, version: string,
io: PromptRunIO = {}, io: PromptRunIO = {},
): Promise<void> { ): Promise<void> {
if (isKimiV2Enabled()) {
// The experimental agent-core-v2 engine runs on its own native DI service
// runtime (see v2/run-v2-print.ts); it does not share the v1 PromptHarness
// path below. Loaded lazily so the v2 module graph stays off the default
// (v1) path.
const { runV2Print } = await import('./v2/run-v2-print');
await runV2Print(opts, version, io);
return;
}
const startedAt = Date.now(); const startedAt = Date.now();
const stdout = io.stdout ?? process.stdout; const stdout = io.stdout ?? process.stdout;
const stderr = io.stderr ?? process.stderr; const stderr = io.stderr ?? process.stderr;
const promptProcess = io.process ?? process; const promptProcess = io.process ?? process;
const outputFormat = resolveOutputFormat(opts);
const workDir = process.cwd(); const workDir = process.cwd();
const telemetryBootstrap = createCliTelemetryBootstrap(); const telemetryBootstrap = createCliTelemetryBootstrap();
const telemetryClient: TelemetryClient = { const telemetryClient: TelemetryClient = {
@ -111,7 +121,7 @@ export async function runPrompt(
withContext: withTelemetryContext, withContext: withTelemetryContext,
setContext: setTelemetryContext, setContext: setTelemetryContext,
}; };
const harness = createKimiHarness({ const harness = await createPromptHarness({
homeDir: telemetryBootstrap.homeDir, homeDir: telemetryBootstrap.homeDir,
identity: createKimiCodeHostIdentity(version), identity: createKimiCodeHostIdentity(version),
uiMode: PROMPT_UI_MODE, uiMode: PROMPT_UI_MODE,
@ -186,7 +196,6 @@ export async function runPrompt(
}); });
setCrashPhase('runtime'); setCrashPhase('runtime');
const outputFormat = opts.outputFormat ?? 'text';
// Headless goal mode: `kimi -p "/goal <objective>"`. The goal driver keeps // Headless goal mode: `kimi -p "/goal <objective>"`. The goal driver keeps
// the turn-run alive across continuation turns, so the normal prompt-turn // the turn-run alive across continuation turns, so the normal prompt-turn
// waiter blocks until the goal is terminal; we then emit a summary and set a // waiter blocks until the goal is terminal; we then emit a summary and set a
@ -195,7 +204,13 @@ export async function runPrompt(
if (goalCreate !== undefined) { if (goalCreate !== undefined) {
await runHeadlessGoal(session, goalCreate, goalModel, outputFormat, stdout, stderr); await runHeadlessGoal(session, goalCreate, goalModel, outputFormat, stdout, stderr);
} else { } else {
await runPromptTurn(session, opts.prompt!, outputFormat, stdout, stderr); await runPromptTurn(
session as PrintTurnSession,
opts.prompt!,
outputFormat,
stdout,
stderr,
);
} }
writeResumeHint(session.id, outputFormat, stdout, stderr); writeResumeHint(session.id, outputFormat, stdout, stderr);
@ -207,8 +222,16 @@ export async function runPrompt(
} }
} }
async function createPromptHarness(
options: Parameters<typeof createKimiHarness>[0],
): Promise<PromptHarness> {
// The v2 engine is dispatched earlier in `runPrompt` (see the
// `isKimiV2Enabled()` branch) and never reaches here; this is the v1 path.
return createKimiHarness(options);
}
async function runHeadlessGoal( async function runHeadlessGoal(
session: Session, session: PromptSession,
goal: HeadlessGoalCreate, goal: HeadlessGoalCreate,
model: string | undefined, model: string | undefined,
outputFormat: PromptOutputFormat, outputFormat: PromptOutputFormat,
@ -234,7 +257,13 @@ async function runHeadlessGoal(
try { try {
// The objective is sent as the normal prompt; goal continuation keeps the // The objective is sent as the normal prompt; goal continuation keeps the
// turn alive until a terminal state is reached. // turn alive until a terminal state is reached.
await runPromptTurn(session, goal.objective, outputFormat, stdout, stderr); await runPromptTurn(
session as PrintTurnSession,
goal.objective,
outputFormat,
stdout,
stderr,
);
} finally { } finally {
unsubscribeGoalEvents(); unsubscribeGoalEvents();
const snapshot = completedSnapshot ?? (await session.getGoal()).goal; const snapshot = completedSnapshot ?? (await session.getGoal()).goal;
@ -252,7 +281,7 @@ async function runHeadlessGoal(
} }
interface ResolvedPromptSession { interface ResolvedPromptSession {
readonly session: Session; readonly session: PromptSession;
readonly resumed: boolean; readonly resumed: boolean;
readonly restorePermission: () => Promise<void>; readonly restorePermission: () => Promise<void>;
readonly telemetryModel?: string; readonly telemetryModel?: string;
@ -260,7 +289,7 @@ interface ResolvedPromptSession {
} }
async function resolvePromptSession( async function resolvePromptSession(
harness: KimiHarness, harness: PromptHarness,
opts: CLIOptions, opts: CLIOptions,
workDir: string, workDir: string,
defaultModel: string | undefined, defaultModel: string | undefined,
@ -355,7 +384,7 @@ async function resolvePromptSession(
} }
async function forcePromptPermission( async function forcePromptPermission(
session: Session, session: PromptSession,
previousPermission: SessionStatus['permission'], previousPermission: SessionStatus['permission'],
setRestorePermission: (restorePermission: () => Promise<void>) => void, setRestorePermission: (restorePermission: () => Promise<void>) => void,
): Promise<() => Promise<void>> { ): Promise<() => Promise<void>> {
@ -374,7 +403,7 @@ async function forcePromptPermission(
return restorePermission; return restorePermission;
} }
function requireConfiguredModel(...models: readonly (string | undefined)[]): string { export function requireConfiguredModel(...models: readonly (string | undefined)[]): string {
const model = configuredModel(...models); const model = configuredModel(...models);
if (model === undefined) { if (model === undefined) {
throw new Error( throw new Error(
@ -384,16 +413,16 @@ function requireConfiguredModel(...models: readonly (string | undefined)[]): str
return model; return model;
} }
function configuredModel(...models: readonly (string | undefined)[]): string | undefined { export function configuredModel(...models: readonly (string | undefined)[]): string | undefined {
return models.find((model) => model !== undefined && model.trim().length > 0); return models.find((model) => model !== undefined && model.trim().length > 0);
} }
function installHeadlessHandlers(session: Session): void { function installHeadlessHandlers(session: PromptSession): void {
session.setApprovalHandler(() => ({ decision: 'approved' })); session.setApprovalHandler(() => ({ decision: 'approved' }));
session.setQuestionHandler(() => null); session.setQuestionHandler(() => null);
} }
function installPromptTerminationCleanup( export function installPromptTerminationCleanup(
promptProcess: PromptProcess, promptProcess: PromptProcess,
cleanup: () => Promise<void>, cleanup: () => Promise<void>,
): () => void { ): () => void {
@ -420,14 +449,17 @@ function installPromptTerminationCleanup(
}; };
} }
function signalExitCode(signal: NodeJS.Signals): number { export function signalExitCode(signal: NodeJS.Signals): number {
if (signal === 'SIGINT') return 130; if (signal === 'SIGINT') return 130;
if (signal === 'SIGHUP') return 129; if (signal === 'SIGHUP') return 129;
return 143; return 143;
} }
type PrintTurnSession = PromptSession &
Required<Pick<PromptSession, 'handlePrintMainTurnCompleted'>>;
function runPromptTurn( function runPromptTurn(
session: Session, session: PrintTurnSession,
prompt: string, prompt: string,
outputFormat: PromptOutputFormat, outputFormat: PromptOutputFormat,
stdout: PromptOutput, stdout: PromptOutput,
@ -441,11 +473,28 @@ function runPromptTurn(
: new PromptTranscriptWriter(stdout, stderr); : new PromptTranscriptWriter(stdout, stderr);
let settled = false; let settled = false;
let unsubscribe: (() => void) | undefined; let unsubscribe: (() => void) | undefined;
// A `kimi -p` run is not done just because the model ended a turn: an active
// goal drives continuation turns on its own, and a scheduled cron task fires
// later from an idle session — both trigger new turns after `end_turn`. While
// either is pending, something must keep the event loop alive: the cron
// scheduler's tick is deliberately unref'd, so without a ref'd handle the
// process would drain and exit before the next turn is ever triggered. This
// no-op interval is that handle; finish() always clears it.
let keepAliveTimer: NodeJS.Timeout | undefined;
const holdEventLoop = (): void => {
keepAliveTimer ??= setInterval(() => {}, 60_000);
};
const releaseEventLoop = (): void => {
if (keepAliveTimer === undefined) return;
clearInterval(keepAliveTimer);
keepAliveTimer = undefined;
};
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const finish = (error?: Error): void => { const finish = (error?: Error): void => {
if (settled) return; if (settled) return;
settled = true; settled = true;
releaseEventLoop();
unsubscribe?.(); unsubscribe?.();
outputWriter.finish(); outputWriter.finish();
if (error !== undefined) { if (error !== undefined) {
@ -455,6 +504,36 @@ function runPromptTurn(
resolve(); resolve();
}; };
// Re-evaluates whether the run can settle now that the main agent is idle.
// The run outlives a completed turn while a goal is still active (the goal
// driver launches the next continuation turn itself) or while cron tasks
// with a future fire remain (their fire steers a fresh turn when idle).
// Called on turn.ended and on a terminal goal.updated — the latter covers
// the driver blocking a goal on a hard budget, which emits no further
// turn.ended. Only when neither is pending do we drain background tasks
// and settle.
const evaluateRunCompletion = async (): Promise<void> => {
try {
const { goal } = await session.getGoal();
if (settled || activeTurnId !== undefined) return;
if (goal?.status === 'active') {
holdEventLoop();
return;
}
const { tasks } = await session.getCronTasks();
if (settled || activeTurnId !== undefined) return;
// A task whose expression has no future fire can never trigger a
// turn; don't hold the run open for it.
if (tasks.some((task) => task.nextFireAt !== null)) {
holdEventLoop();
return;
}
await finishCompletedTurn();
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)));
}
};
unsubscribe = session.onEvent((event) => { unsubscribe = session.onEvent((event) => {
if (event.type === 'error') { if (event.type === 'error') {
if (event.agentId !== PROMPT_MAIN_AGENT_ID) { if (event.agentId !== PROMPT_MAIN_AGENT_ID) {
@ -463,7 +542,7 @@ function runPromptTurn(
finish(new Error(`${event.code}: ${event.message}`)); finish(new Error(`${event.code}: ${event.message}`));
return; return;
} }
if (event.type === 'turn.started' && activeTurnId === undefined) { if (event.type === 'turn.started') {
if (event.agentId !== PROMPT_MAIN_AGENT_ID) { if (event.agentId !== PROMPT_MAIN_AGENT_ID) {
return; return;
} }
@ -471,6 +550,16 @@ function runPromptTurn(
activeAgentId = event.agentId; activeAgentId = event.agentId;
return; return;
} }
if (
event.type === 'goal.updated' &&
event.agentId === PROMPT_MAIN_AGENT_ID &&
activeTurnId === undefined &&
event.snapshot !== null &&
event.snapshot.status !== 'active'
) {
void evaluateRunCompletion();
return;
}
if ( if (
activeTurnId === undefined || activeTurnId === undefined ||
activeAgentId === undefined || activeAgentId === undefined ||
@ -487,6 +576,7 @@ function runPromptTurn(
return; return;
case 'turn.step.retrying': case 'turn.step.retrying':
outputWriter.discardAssistant(); outputWriter.discardAssistant();
outputWriter.writeRetrying(event);
return; return;
case 'assistant.delta': case 'assistant.delta':
outputWriter.writeAssistantDelta(event.delta); outputWriter.writeAssistantDelta(event.delta);
@ -515,19 +605,10 @@ function runPromptTurn(
return; return;
case 'turn.ended': case 'turn.ended':
if (event.reason === 'completed') { if (event.reason === 'completed') {
void (async () => { outputWriter.flushAssistant();
// Flush the buffered assistant message before draining background activeTurnId = undefined;
// tasks: in stream-json mode the final message is only emitted by activeAgentId = undefined;
// finish(), so a long background wait would otherwise withhold the void evaluateRunCompletion();
// main turn's result until the drain settles.
outputWriter.flushAssistant();
try {
await session.waitForBackgroundTasksOnPrint();
} catch (error) {
log.warn('waitForBackgroundTasksOnPrint failed', { error });
}
finish();
})();
return; return;
} }
finish(new Error(formatTurnEndedFailure(event))); finish(new Error(formatTurnEndedFailure(event)));
@ -550,7 +631,6 @@ function runPromptTurn(
case 'subagent.started': case 'subagent.started':
case 'subagent.suspended': case 'subagent.suspended':
case 'tool.list.updated': case 'tool.list.updated':
case 'turn.started':
case 'turn.step.completed': case 'turn.step.completed':
case 'warning': case 'warning':
return; return;
@ -560,314 +640,41 @@ function runPromptTurn(
session.prompt(prompt).catch((error: unknown) => { session.prompt(prompt).catch((error: unknown) => {
finish(error instanceof Error ? error : new Error(String(error))); finish(error instanceof Error ? error : new Error(String(error)));
}); });
async function finishCompletedTurn(): Promise<void> {
// Flush the buffered assistant message before the end-of-turn policy
// runs: in stream-json mode the final message is only emitted by
// finish(), so a long drain/steer wait would otherwise withhold the main
// turn's result until the run exits.
outputWriter.flushAssistant();
try {
const action = await session.handlePrintMainTurnCompleted();
if (action === 'continue') {
// Stay alive: a still-pending background task will, on completion,
// steer the main agent into a new turn whose events we keep mapping.
// Do not finish yet.
holdEventLoop();
return;
}
} catch (error) {
log.warn('handlePrintMainTurnCompleted failed', { error });
}
finish();
}
}); });
} }
interface PromptTurnWriter {
writeAssistantDelta(delta: string): void;
writeHookResult(event: HookResultEvent): void;
writeThinkingDelta(delta: string): void;
writeToolCall(toolCallId: string, name: string, args: unknown): void;
writeToolCallDelta(
toolCallId: string,
name: string | undefined,
argumentsPart: string | undefined,
): void;
writeToolResult(toolCallId: string, output: unknown): void;
flushAssistant(): void;
discardAssistant(): void;
finish(): void;
}
class PromptTranscriptWriter implements PromptTurnWriter {
private readonly assistantWriter: PromptBlockWriter;
private readonly thinkingWriter: PromptBlockWriter;
constructor(stdout: PromptOutput, stderr: PromptOutput) {
this.assistantWriter = new PromptBlockWriter(stdout);
this.thinkingWriter = new PromptBlockWriter(stderr);
}
writeAssistantDelta(delta: string): void {
this.thinkingWriter.finish();
this.assistantWriter.write(delta);
}
writeHookResult(event: HookResultEvent): void {
this.thinkingWriter.finish();
this.assistantWriter.finish();
this.assistantWriter.write(formatHookResultPlain(event));
this.assistantWriter.finish();
}
writeThinkingDelta(delta: string): void {
this.thinkingWriter.write(delta);
}
writeToolCall(): void {}
writeToolCallDelta(): void {}
writeToolResult(): void {}
flushAssistant(): void {}
discardAssistant(): void {}
finish(): void {
this.thinkingWriter.finish();
this.assistantWriter.finish();
}
}
interface PromptJsonToolCall {
type: 'function';
id: string;
function: {
name: string;
arguments: string;
};
}
interface PromptJsonAssistantMessage {
role: 'assistant';
content?: string;
tool_calls?: PromptJsonToolCall[];
}
interface PromptJsonToolMessage {
role: 'tool';
tool_call_id: string;
content: string;
}
interface PromptJsonResumeMetaMessage {
role: 'meta';
type: 'session.resume_hint';
session_id: string;
command: string;
content: string;
}
function writeResumeHint(
sessionId: string,
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
stderr: PromptOutput,
): void {
const command = `kimi -r ${sessionId}`;
const content = `To resume this session: ${command}`;
if (outputFormat === 'stream-json') {
const message: PromptJsonResumeMetaMessage = {
role: 'meta',
type: 'session.resume_hint',
session_id: sessionId,
command,
content,
};
stdout.write(`${JSON.stringify(message)}\n`);
return;
}
stderr.write(`${content}\n`);
}
class PromptJsonWriter implements PromptTurnWriter {
private assistantText = '';
private readonly toolCalls: PromptJsonToolCall[] = [];
constructor(private readonly stdout: PromptOutput) {}
writeAssistantDelta(delta: string): void {
this.assistantText += delta;
}
writeHookResult(event: HookResultEvent): void {
this.flushAssistant();
this.writeJsonLine({
role: 'assistant',
content: formatHookResultPlain(event),
});
}
writeThinkingDelta(): void {}
writeToolCall(toolCallId: string, name: string, args: unknown): void {
const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId);
if (existing !== undefined) {
existing.function.name = name;
existing.function.arguments = stringifyJsonValue(args);
return;
}
this.toolCalls.push({
type: 'function',
id: toolCallId,
function: {
name,
arguments: stringifyJsonValue(args),
},
});
}
writeToolCallDelta(
toolCallId: string,
name: string | undefined,
argumentsPart: string | undefined,
): void {
const toolCall = this.findOrCreateToolCall(toolCallId, name ?? '');
if (name !== undefined) {
toolCall.function.name = name;
}
if (argumentsPart !== undefined) {
toolCall.function.arguments += argumentsPart;
}
}
writeToolResult(toolCallId: string, output: unknown): void {
this.flushAssistant();
this.writeJsonLine({
role: 'tool',
tool_call_id: toolCallId,
content: stringifyToolOutput(output),
});
}
flushAssistant(): void {
if (this.assistantText.length === 0 && this.toolCalls.length === 0) return;
const message: PromptJsonAssistantMessage = {
role: 'assistant',
content: this.assistantText.length > 0 ? this.assistantText : undefined,
tool_calls: this.toolCalls.length > 0 ? [...this.toolCalls] : undefined,
};
this.writeJsonLine(message);
this.discardAssistant();
}
discardAssistant(): void {
this.assistantText = '';
this.toolCalls.length = 0;
}
finish(): void {
this.flushAssistant();
}
private findOrCreateToolCall(toolCallId: string, name: string): PromptJsonToolCall {
const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId);
if (existing !== undefined) return existing;
const toolCall: PromptJsonToolCall = {
type: 'function',
id: toolCallId,
function: {
name,
arguments: '',
},
};
this.toolCalls.push(toolCall);
return toolCall;
}
private writeJsonLine(message: PromptJsonAssistantMessage | PromptJsonToolMessage): void {
this.stdout.write(`${JSON.stringify(message)}\n`);
}
}
class PromptBlockWriter {
private started = false;
private atLineStart = false;
private lineWidth = 0;
private readonly wrapWidth: number | undefined;
constructor(private readonly output: PromptOutput) {
this.wrapWidth =
typeof output.columns === 'number' && output.columns > PROMPT_BLOCK_INDENT.length + 1
? output.columns
: undefined;
}
write(chunk: string): void {
if (chunk.length === 0) return;
let rendered = this.start();
for (const char of chunk) {
if (this.atLineStart && char !== '\n') {
rendered += PROMPT_BLOCK_INDENT;
this.atLineStart = false;
this.lineWidth = PROMPT_BLOCK_INDENT.length;
}
const charWidth = visibleCharWidth(char);
if (
this.wrapWidth !== undefined &&
!this.atLineStart &&
char !== '\n' &&
this.lineWidth + charWidth > this.wrapWidth
) {
rendered += `\n${PROMPT_BLOCK_INDENT}`;
this.lineWidth = PROMPT_BLOCK_INDENT.length;
}
rendered += char;
if (char === '\n') {
this.atLineStart = true;
this.lineWidth = 0;
} else {
this.lineWidth += charWidth;
}
}
this.output.write(rendered);
}
finish(): void {
if (!this.started) return;
this.output.write(this.atLineStart ? '\n' : '\n\n');
this.started = false;
this.atLineStart = false;
this.lineWidth = 0;
}
private start(): string {
if (this.started) return '';
this.started = true;
this.atLineStart = false;
this.lineWidth = PROMPT_BLOCK_BULLET.length;
return PROMPT_BLOCK_BULLET;
}
}
function visibleCharWidth(char: string): number {
return char === '\t' ? 4 : 1;
}
function formatHookResultPlain(event: HookResultEvent): string {
return `${formatHookResultTitle(event)}\n\n${formatHookResultBody(event)}`;
}
function formatHookResultTitle(event: HookResultEvent): string {
return `${event.hookEvent} hook${event.blocked === true ? ' blocked' : ''}`;
}
function formatHookResultBody(event: HookResultEvent): string {
const content = event.content.trim();
return content.length === 0 ? '(empty)' : content;
}
function stringifyJsonValue(value: unknown): string {
if (typeof value === 'string') return value;
const json = JSON.stringify(value);
return json ?? '';
}
function stringifyToolOutput(output: unknown): string {
if (typeof output === 'string') return output;
const json = JSON.stringify(output);
return json ?? String(output);
}
function hasTurnId(event: Event): event is Event & { readonly turnId: number } { function hasTurnId(event: Event): event is Event & { readonly turnId: number } {
return 'turnId' in event; return 'turnId' in event;
} }
function formatTurnEndedFailure(event: Extract<Event, { type: 'turn.ended' }>): string { function formatTurnEndedFailure(event: Extract<Event, { type: 'turn.ended' }>): string {
if (event.error !== undefined) return `${event.error.code}: ${event.error.message}`; if (event.error?.code === 'provider.filtered') {
if (event.reason === 'filtered') {
return 'Provider safety policy blocked the response.'; return 'Provider safety policy blocked the response.';
} }
if (event.error !== undefined) return `${event.error.code}: ${event.error.message}`;
if (event.reason === 'blocked') {
return 'Prompt hook blocked the request.';
}
return `Prompt turn ended with reason: ${event.reason}`; return `Prompt turn ended with reason: ${event.reason}`;
} }

View file

@ -35,7 +35,7 @@ import {
} from '@moonshot-ai/kimi-code-sdk'; } from '@moonshot-ai/kimi-code-sdk';
import type { Command } from 'commander'; import type { Command } from 'commander';
import { createKimiCodeHostIdentity } from '#/cli/version'; import { createKimiCodeHostIdentity, createKimiCodeUserAgent } from '#/cli/version';
interface WritableLike { interface WritableLike {
write(chunk: string): boolean; write(chunk: string): boolean;
@ -99,7 +99,7 @@ export async function handleProviderAdd(
let entries: Awaited<ReturnType<typeof fetchCustomRegistry>>; let entries: Awaited<ReturnType<typeof fetchCustomRegistry>>;
try { try {
entries = await fetchCustomRegistry(source); entries = await fetchCustomRegistry(source, { userAgent: createKimiCodeUserAgent() });
} catch (error) { } catch (error) {
const suffix = error instanceof CustomRegistryApiError ? ` (HTTP ${String(error.status)})` : ''; const suffix = error instanceof CustomRegistryApiError ? ` (HTTP ${String(error.status)})` : '';
deps.stderr.write(`Failed to fetch registry${suffix}: ${errorMessage(error)}\n`); deps.stderr.write(`Failed to fetch registry${suffix}: ${errorMessage(error)}\n`);
@ -398,7 +398,7 @@ export async function handleCatalogAdd(
async function loadCatalogOrExit(deps: ProviderDeps, url: string): Promise<Catalog> { async function loadCatalogOrExit(deps: ProviderDeps, url: string): Promise<Catalog> {
try { try {
return await fetchCatalog(url); return await fetchCatalog(url, { userAgent: createKimiCodeUserAgent() });
} catch (error) { } catch (error) {
const suffix = error instanceof CatalogFetchError ? ` (HTTP ${String(error.status)})` : ''; const suffix = error instanceof CatalogFetchError ? ` (HTTP ${String(error.status)})` : '';
deps.stderr.write(`Failed to fetch catalog from ${url}${suffix}: ${errorMessage(error)}\n`); deps.stderr.write(`Failed to fetch catalog from ${url}${suffix}: ${errorMessage(error)}\n`);

View file

@ -22,7 +22,7 @@ import { createRequire } from 'node:module';
import { createServer } from 'node:net'; import { createServer } from 'node:net';
import { dirname, isAbsolute, join, resolve } from 'node:path'; import { dirname, isAbsolute, join, resolve } from 'node:path';
import { DEFAULT_LOCK_DIR, getLiveLock, type LockContents } from '@moonshot-ai/server'; import { DEFAULT_LOCK_DIR, getLiveLock, type LockContents } from '@moonshot-ai/kap-server';
import { import {
DEFAULT_SERVER_HOST, DEFAULT_SERVER_HOST,
@ -179,7 +179,7 @@ function detectSea(): boolean {
/** /**
* Absolute path to the CLI entry that should be re-execed to run the daemon. * Absolute path to the CLI entry that should be re-execed to run the daemon.
* Mirrors `resolveSupervisorProgram` in `packages/server/src/svc/program.ts`: * Mirrors `resolveSupervisorProgram` in `packages/kap-server/src/svc/program.ts`:
* when the CLI is a compiled single binary, `argv[1]` is the invoked command * when the CLI is a compiled single binary, `argv[1]` is the invoked command
* name (e.g. `kimi`) or the first user argument never a script path so we * name (e.g. `kimi`) or the first user argument never a script path so we
* must re-exec `process.execPath` itself. * must re-exec `process.execPath` itself.

View file

@ -6,7 +6,7 @@
* The OS service-manager subcommands (`install/uninstall/start/stop/restart/ * The OS service-manager subcommands (`install/uninstall/start/stop/restart/
* status`) are temporarily NOT registered — see the commented * status`) are temporarily NOT registered — see the commented
* `addLifecycleCommands(server)` below. Their implementation is preserved in * `addLifecycleCommands(server)` below. Their implementation is preserved in
* `./lifecycle.ts` + `packages/server/src/svc/*` for later re-exposure. * `./lifecycle.ts` + `packages/kap-server/src/svc/*` for later re-exposure.
* *
* The top-level `kimi web` alias is registered separately via * The top-level `kimi web` alias is registered separately via
* `registerWebAliasCommand` so it stays at the program root. * `registerWebAliasCommand` so it stays at the program root.
@ -39,7 +39,7 @@ export function registerServerCommand(program: Command): void {
// OS service-manager commands (`install/uninstall/start/stop/restart/status`) // OS service-manager commands (`install/uninstall/start/stop/restart/status`)
// are temporarily hidden — the product now favors the on-demand background // are temporarily hidden — the product now favors the on-demand background
// daemon (`kimi web`) over service-ization. The implementation still lives in // daemon (`kimi web`) over service-ization. The implementation still lives in
// `./lifecycle.ts` + `packages/server/src/svc/*`; re-import // `./lifecycle.ts` + `packages/kap-server/src/svc/*`; re-import
// `addLifecycleCommands` and call it here to re-expose. // `addLifecycleCommands` and call it here to re-expose.
// addLifecycleCommands(server); // addLifecycleCommands(server);

View file

@ -16,7 +16,7 @@
import type { Command } from 'commander'; import type { Command } from 'commander';
import { getLiveLock, type LockContents } from '@moonshot-ai/server'; import { getLiveLock, type LockContents } from '@moonshot-ai/kap-server';
import { getDataDir } from '#/utils/paths'; import { getDataDir } from '#/utils/paths';

View file

@ -1,8 +1,8 @@
/** /**
* `kimi server install/uninstall/start/stop/restart/status`. * `kimi server install/uninstall/start/stop/restart/status`.
* *
* Phase 2 lands the CLI shape; the lifecycle calls into the platform service * The lifecycle calls into the platform service manager from
* manager from `@moonshot-ai/server`, which is filled in by Phase 3+. * `@moonshot-ai/kap-server` (`src/svc/*`).
* *
* The Commander wiring here mirrors `addGatewayServiceCommands` from * The Commander wiring here mirrors `addGatewayServiceCommands` from
* `../openclaw/src/cli/daemon-cli/register-service-commands.ts:58`. * `../openclaw/src/cli/daemon-cli/register-service-commands.ts:58`.
@ -17,7 +17,7 @@ import {
type InstallArgs, type InstallArgs,
type ServiceManager, type ServiceManager,
type ServiceStatus, type ServiceStatus,
} from '@moonshot-ai/server'; } from '@moonshot-ai/kap-server';
import { openUrl as defaultOpenUrl } from '#/utils/open-url'; import { openUrl as defaultOpenUrl } from '#/utils/open-url';

View file

@ -9,7 +9,7 @@
import chalk from 'chalk'; import chalk from 'chalk';
import type { Command } from 'commander'; import type { Command } from 'commander';
import { getLiveLock } from '@moonshot-ai/server'; import { getLiveLock } from '@moonshot-ai/kap-server';
import { getDataDir } from '#/utils/paths'; import { getDataDir } from '#/utils/paths';

View file

@ -6,7 +6,7 @@
* auth check, so rotation takes effect without a restart. * auth check, so rotation takes effect without a restart.
*/ */
import { getLiveLock, rotateServerToken } from '@moonshot-ai/server'; import { getLiveLock, rotateServerToken } from '@moonshot-ai/kap-server';
import chalk from 'chalk'; import chalk from 'chalk';
import type { Command } from 'commander'; import type { Command } from 'commander';

View file

@ -13,8 +13,9 @@
import { join } from 'node:path'; import { join } from 'node:path';
import { hostRequestHeadersSeed } from '@moonshot-ai/agent-core-v2';
import { createServerLogger, startServer, type ServerLogger } from '@moonshot-ai/kap-server';
import { shutdownTelemetry, track } from '@moonshot-ai/kimi-telemetry'; import { shutdownTelemetry, track } from '@moonshot-ai/kimi-telemetry';
import { startServer, type RunningServer } from '@moonshot-ai/server';
import chalk from 'chalk'; import chalk from 'chalk';
import { Option, type Command } from 'commander'; import { Option, type Command } from 'commander';
@ -25,7 +26,11 @@ import { openUrl as defaultOpenUrl } from '#/utils/open-url';
import { getDataDir } from '#/utils/paths'; import { getDataDir } from '#/utils/paths';
import { initializeServerTelemetry } from '../../telemetry'; import { initializeServerTelemetry } from '../../telemetry';
import { createKimiCodeHostIdentity, getHostPackageRoot, getVersion } from '../../version'; import {
buildKimiDefaultHeaders,
getHostPackageRoot,
getVersion,
} from '../../version';
import { import {
accessUrlLines, accessUrlLines,
buildOpenableUrl, buildOpenableUrl,
@ -48,6 +53,17 @@ import {
const WEB_ASSETS_DIR = 'dist-web'; const WEB_ASSETS_DIR = 'dist-web';
/**
* Minimal surface `runServerInProcess` needs from the server. kap-server's
* `RunningServer` is adapted to it (it returns `{ host, port, close }`
* instead of `{ address, logger, close }`).
*/
interface RoutedServer {
readonly address: string;
readonly logger: ServerLogger;
close(): Promise<void>;
}
export interface RunCliOptions extends ServerCliOptions { export interface RunCliOptions extends ServerCliOptions {
open?: boolean; open?: boolean;
/** Run the server in-process instead of spawning a background daemon. */ /** Run the server in-process instead of spawning a background daemon. */
@ -340,9 +356,11 @@ async function runServerInProcess(
onReady?: (origin: string) => void, onReady?: (origin: string) => void,
): Promise<never> { ): Promise<never> {
const version = getVersion(); const version = getVersion();
const telemetry = initializeServerTelemetry({ version }); // Registers the telemetry provider for `track` / `shutdownTelemetry`; the
// client itself is not passed into kap-server.
initializeServerTelemetry({ version });
let running: RunningServer | undefined; let running: RoutedServer | undefined;
let stopping = false; let stopping = false;
// Idle auto-shutdown is only for the on-demand personal daemon. It is skipped // Idle auto-shutdown is only for the on-demand personal daemon. It is skipped
@ -375,30 +393,53 @@ async function runServerInProcess(
process.exit(0); process.exit(0);
} }
running = await startServer({ // kap-server (the DI × Scope engine server) is the only server flavor. Its
// `startServer` returns `{ host, port, close }` rather than `{ address,
// logger, close }`, so adapt it to the `RoutedServer` surface the rest of
// this runner consumes.
const logger = createServerLogger({ level: options.logLevel });
const v2 = await startServer({
host: options.host, host: options.host,
port: options.port, port: options.port,
// Report the CLI's product version as `server_version` (/meta, web UI)
// rather than kap-server's private package version.
version,
logLevel: options.logLevel, logLevel: options.logLevel,
logger,
debugEndpoints: options.debugEndpoints, debugEndpoints: options.debugEndpoints,
insecureNoTls: options.insecureNoTls, insecureNoTls: options.insecureNoTls,
allowRemoteShutdown: options.allowRemoteShutdown, allowRemoteShutdown: options.allowRemoteShutdown,
allowRemoteTerminals: options.allowRemoteTerminals, allowRemoteTerminals: options.allowRemoteTerminals,
dangerousBypassAuth: options.dangerousBypassAuth,
allowedHosts: options.allowedHosts, allowedHosts: options.allowedHosts,
disableAuth: options.dangerousBypassAuth,
// Seed the CLI's Kimi identity headers so the engine's outbound
// requests (model, WebSearch, FetchURL) carry the same User-Agent +
// X-Msh-* identity as direct CLI runs.
seeds: hostRequestHeadersSeed(buildKimiDefaultHeaders(version)),
webAssetsDir: serverWebAssetsDir(), webAssetsDir: serverWebAssetsDir(),
coreProcessOptions: {
identity: createKimiCodeHostIdentity(version),
telemetry,
},
wsGatewayOptions: {
telemetry,
onConnectionCountChange: idle
? (size) => {
idle.onConnectionCountChange(size);
}
: undefined,
},
}); });
// The connection registry exposes no count-change hook, so forward
// add/remove to the daemon's idle-shutdown handler (a no-op when `idle`
// is undefined, e.g. foreground or --keep-alive).
if (idle !== undefined) {
const registry = v2.connectionRegistry;
const add = registry.add.bind(registry);
const remove = registry.remove.bind(registry);
registry.add = (conn) => {
add(conn);
idle.onConnectionCountChange(registry.size());
};
registry.remove = (connId) => {
remove(connId);
idle.onConnectionCountChange(registry.size());
};
}
logger.info('serving the REST/WS API and the bundled web UI');
running = {
address: `http://${v2.host}:${v2.port}`,
logger,
close: () => v2.close(),
};
track('server_started', { daemon: mode.daemon }); track('server_started', { daemon: mode.daemon });

View file

@ -8,7 +8,7 @@
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import type { ServerLogLevel } from '@moonshot-ai/server'; import type { ServerLogLevel } from '@moonshot-ai/kap-server';
export const LOCAL_SERVER_HOST = '127.0.0.1'; export const LOCAL_SERVER_HOST = '127.0.0.1';
export const DEFAULT_LAN_HOST = '0.0.0.0'; export const DEFAULT_LAN_HOST = '0.0.0.0';

View file

@ -5,9 +5,10 @@ import {
resolveConfigPath, resolveConfigPath,
resolveKimiHome, resolveKimiHome,
type KimiConfig, type KimiConfig,
type KimiHarness,
type TelemetryClient, type TelemetryClient,
} from '@moonshot-ai/kimi-code-sdk'; } from '@moonshot-ai/kimi-code-sdk';
import type { PromptHarness } from './prompt-session';
import { import {
initializeTelemetry, initializeTelemetry,
setTelemetryContext, setTelemetryContext,
@ -26,7 +27,7 @@ export interface CliTelemetryBootstrap {
} }
export interface InitializeCliTelemetryOptions { export interface InitializeCliTelemetryOptions {
readonly harness: KimiHarness; readonly harness: PromptHarness;
readonly bootstrap: CliTelemetryBootstrap; readonly bootstrap: CliTelemetryBootstrap;
readonly config: Pick<KimiConfig, 'defaultModel' | 'telemetry'>; readonly config: Pick<KimiConfig, 'defaultModel' | 'telemetry'>;
readonly version: string; readonly version: string;

View file

@ -0,0 +1,722 @@
/**
* Native v2 `kimi -p` (print mode) runner.
*
* Unlike the v1 path (and the former `V2PromptHarness` / `V2Session` shim), this
* runner talks to agent-core-v2's native DI services directly no
* `PromptHarness`, no SDK-shaped session, no v2v1 event translation. It:
* - `bootstrap()`s the app scope,
* - creates / resumes a session and its main agent via native services,
* - subscribes to the main agent's per-agent `IEventBus` and renders the
* native `DomainEvent` stream (payloads are already v1-protocol-shaped),
* - drives a turn through `IAgentPromptService.enqueue()` and awaits
* `Turn.result` for authoritative completion,
* - applies the print-mode background policy (config-driven, v1-aligned:
* `exit` / `drain` / `steer`) before exiting.
*
* Selected by `runPrompt` when `KIMI_CODE_EXPERIMENTAL_FLAG` is set.
*/
import {
IAgentGoalService,
IAgentLifecycleService,
IAgentPermissionModeService,
IAgentProfileService,
IAgentPromptService,
IAgentTaskService,
IAuthSummaryService,
IConfigService,
IEventBus,
IOAuthToolkit,
ISessionIndex,
ISessionLifecycleService,
ITelemetryService,
bootstrap,
createCloudAppender,
ensureMainAgent,
hostRequestHeadersSeed,
logSeed,
resolveAgentTaskConfig,
resolveKimiHome,
resolveLoggingConfig,
resolvePrintBackgroundMode,
skillCatalogRuntimeOptionsSeed,
type DomainEvent,
type IAgentScopeHandle,
type ISessionScopeHandle,
type LoopRunResult,
type PrintBackgroundMode,
type Scope,
} from '@moonshot-ai/agent-core-v2';
import { createKimiDefaultHeaders, createKimiDeviceId } from '@moonshot-ai/kimi-code-oauth';
import { resolve } from 'pathe';
import {
CLI_SHUTDOWN_TIMEOUT_MS,
CLI_USER_AGENT_PRODUCT,
PROMPT_CLEANUP_TIMEOUT_MS,
} from '#/constant/app';
import {
formatGoalSummaryText,
goalExitCode,
goalSummaryJson,
parseHeadlessGoalCreate,
type HeadlessGoalCreate,
} from '../goal-prompt';
import {
type PromptRunIO,
configuredModel,
installPromptTerminationCleanup,
raceWithTimeout,
requireConfiguredModel,
} from '../run-prompt';
import { createKimiCodeHostIdentity } from '../version';
import { resolveOutputFormat } from '../options';
import type { CLIOptions, PromptOutputFormat } from '../options';
import {
type PromptOutput,
PromptJsonWriter,
type PromptTurnWriter,
PromptTranscriptWriter,
writeExperimentalVersion,
writeResumeHint,
} from '../prompt-render';
const PROMPT_UI_MODE = 'print';
const DEFAULT_PRINT_WAIT_CEILING_S = 3600;
const DEFAULT_PRINT_MAX_TURNS = 50;
/** Re-check `goalActive` at least this often while waiting for goal turns. */
const GOAL_WAIT_POLL_MS = 250;
export async function runV2Print(
opts: CLIOptions,
version: string,
io: PromptRunIO = {},
): Promise<void> {
const startedAt = Date.now();
const stdout = io.stdout ?? process.stdout;
const stderr = io.stderr ?? process.stderr;
const promptProcess = io.process ?? process;
const outputFormat = resolveOutputFormat(opts);
const workDir = process.cwd();
writeExperimentalVersion(version, outputFormat, stdout, stderr);
const homeDir = resolveKimiHome();
let firstLaunch = false;
const deviceId = createKimiDeviceId(homeDir, {
onFirstLaunch: () => {
firstLaunch = true;
},
});
const logging = resolveLoggingConfig({ homeDir, env: process.env });
const identity = createKimiCodeHostIdentity(version);
const hostHeaders = createKimiDefaultHeaders({ homeDir, ...identity });
const { app } = bootstrap({ homeDir, clientVersion: version }, [
...logSeed(logging),
...hostRequestHeadersSeed(hostHeaders),
// `--skillsDir` (v1 print parity): explicit skill dirs replace default
// user / project discovery for this process.
...skillCatalogRuntimeOptionsSeed(opts.skillsDirs),
]);
const auth = app.accessor.get(IOAuthToolkit);
const configService = app.accessor.get(IConfigService);
await configService.ready;
const defaultModel = configService.get<string>('defaultModel') ?? undefined;
let telemetryEnabled = true;
try {
telemetryEnabled = configService.get('telemetry') !== false;
} catch {
telemetryEnabled = true;
}
for (const diagnostic of configService.diagnostics()) {
if (diagnostic.severity === 'warning') {
stderr.write(`Warning: ${diagnostic.message}\n`);
}
}
let restorePermission = async (): Promise<void> => {};
let removeTerminationCleanup: (() => void) | undefined;
let cleanupPromise: Promise<void> | undefined;
let telemetryService: ITelemetryService | undefined;
const cleanup = async (): Promise<void> => {
const pending = (cleanupPromise ??= (async () => {
removeTerminationCleanup?.();
try {
await restorePermission();
} finally {
if (telemetryService !== undefined) {
await raceWithTimeout(telemetryService.shutdown(), CLI_SHUTDOWN_TIMEOUT_MS);
}
app.dispose();
}
})());
await raceWithTimeout(pending, PROMPT_CLEANUP_TIMEOUT_MS);
};
removeTerminationCleanup = installPromptTerminationCleanup(promptProcess, cleanup);
try {
const resolved = await resolveNativeSession(app, opts, workDir, defaultModel, stderr);
restorePermission = resolved.restorePermission;
telemetryService = app.accessor.get(ITelemetryService);
if (telemetryEnabled) {
telemetryService.setAppender(
createCloudAppender(app.accessor, {
deviceId,
appName: CLI_USER_AGENT_PRODUCT,
uiMode: PROMPT_UI_MODE,
model: resolved.telemetryModel,
getAccessToken: async () => (await auth.getCachedAccessToken()) ?? null,
}),
);
}
telemetryService.setContext({ sessionId: resolved.session.id });
if (firstLaunch) {
telemetryService.track2('first_launch');
}
const goalCreate = parseHeadlessGoalCreate(opts.prompt!);
if (goalCreate !== undefined) {
await runNativeGoal(
app,
resolved.session,
resolved.agent,
goalCreate,
resolved.goalModel,
outputFormat,
stdout,
stderr,
);
} else {
await runNativeTurn(
app,
resolved.session,
resolved.agent,
opts.prompt!,
outputFormat,
stdout,
stderr,
);
}
writeResumeHint(resolved.session.id, outputFormat, stdout, stderr);
telemetryService.withContext({ sessionId: resolved.session.id }).track2('exit', {
duration_ms: Date.now() - startedAt,
});
} finally {
await cleanup();
}
}
interface ResolvedNativeSession {
readonly session: ISessionScopeHandle;
readonly agent: IAgentScopeHandle;
readonly restorePermission: () => Promise<void>;
readonly telemetryModel: string | undefined;
readonly goalModel: string | undefined;
}
async function resolveNativeSession(
app: Scope,
opts: CLIOptions,
workDir: string,
defaultModel: string | undefined,
stderr: PromptOutput,
): Promise<ResolvedNativeSession> {
const lifecycle = app.accessor.get(ISessionLifecycleService);
const index = app.accessor.get(ISessionIndex);
const resumeById = async (id: string): Promise<ISessionScopeHandle> => {
const session = await lifecycle.resume(id);
if (session === undefined) {
throw new Error(`Session "${id}" not found.`);
}
return session;
};
const forceAuto = (
agent: IAgentScopeHandle,
): { readonly restorePermission: () => Promise<void> } => {
const permissionMode = agent.accessor.get(IAgentPermissionModeService);
const previous = permissionMode.mode;
permissionMode.setMode('auto');
return {
restorePermission: async () => {
permissionMode.setMode(previous);
},
};
};
if (opts.session !== undefined) {
const page = await index.list({});
const target = page.items.find((summary) => summary.id === opts.session);
if (target === undefined) {
throw new Error(`Session "${opts.session}" not found.`);
}
if (target.cwd !== undefined && resolve(target.cwd) !== resolve(workDir)) {
stderr.write(
`Session "${opts.session}" was created under a different directory.\n` +
` cd "${target.cwd}" && kimi -r ${opts.session}\n\n`,
);
throw new Error(`Session "${opts.session}" was created under a different directory.`);
}
const session = await resumeById(opts.session);
const agent = await ensureMainAgent(session);
const profile = agent.accessor.get(IAgentProfileService);
if (opts.model !== undefined) {
await profile.setModel(opts.model);
}
const currentModel = profile.getModel();
const { restorePermission } = forceAuto(agent);
return {
session,
agent,
restorePermission,
telemetryModel: configuredModel(opts.model, currentModel, defaultModel),
goalModel: configuredModel(opts.model, currentModel),
};
}
if (opts.continue) {
const page = await index.list({});
const previous = page.items.find((summary) => summary.cwd === workDir);
if (previous !== undefined) {
const session = await resumeById(previous.id);
const agent = await ensureMainAgent(session);
const profile = agent.accessor.get(IAgentProfileService);
if (opts.model !== undefined) {
await profile.setModel(opts.model);
}
const currentModel = profile.getModel();
const { restorePermission } = forceAuto(agent);
return {
session,
agent,
restorePermission,
telemetryModel: configuredModel(opts.model, currentModel, defaultModel),
goalModel: configuredModel(opts.model, currentModel),
};
}
stderr.write(`No sessions to continue under "${workDir}"; starting a fresh session.\n`);
}
const model = requireConfiguredModel(opts.model, defaultModel);
const session = await lifecycle.create({
workDir,
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
});
const agent = await ensureMainAgent(session);
await agent.accessor.get(IAgentProfileService).setModel(model);
agent.accessor.get(IAgentPermissionModeService).setMode('auto');
return {
session,
agent,
restorePermission: async () => {},
telemetryModel: model,
goalModel: model,
};
}
async function runNativeTurn(
app: Scope,
session: ISessionScopeHandle,
agent: IAgentScopeHandle,
prompt: string,
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
stderr: PromptOutput,
): Promise<void> {
const writer: PromptTurnWriter =
outputFormat === 'stream-json'
? new PromptJsonWriter(stdout)
: new PromptTranscriptWriter(stdout, stderr);
await agent.accessor.get(IAuthSummaryService).ensureReady();
const turnEndings = createPrintTurnEndings();
const subscription = agent.accessor.get(IEventBus).subscribe((event: DomainEvent) => {
dispatchNativeEvent(writer, event, stderr);
// Arm the turn-endings collector before `turn.result` settles so a
// background-task completion that steers a new turn right after the main
// turn ends cannot have its `turn.ended` slip past the policy loop.
if (event.type === 'turn.ended') turnEndings.push(event);
});
try {
const handle = await agent.accessor.get(IAgentPromptService).enqueue({
message: {
role: 'user',
content: [{ type: 'text', text: prompt }],
toolCalls: [],
origin: { kind: 'user' },
},
});
const turn = await handle.launched;
if (turn === undefined) {
// A prompt blocked by an onBeforeSubmitPrompt hook never launches a turn.
writer.finish();
const completion = await handle.completion;
throw new Error(
completion.state === 'blocked'
? 'Prompt hook blocked the request.'
: 'Prompt turn could not be started',
);
}
const result = await turn.result;
// Turn settled, but `-p` is not done until the print-mode background
// policy says so (config-driven: exit / drain / steer). Flush the buffered
// assistant message first so a long drain/steer wait does not withhold the
// final message.
writer.flushAssistant();
if (result.type === 'completed') {
const configService = app.accessor.get(IConfigService);
const taskConfig = resolveAgentTaskConfig(configService);
const goalService = agent.accessor.get(IAgentGoalService);
try {
await applyPrintBackgroundPolicy({
mode: resolvePrintBackgroundMode(configService),
ceilingS: taskConfig?.printWaitCeilingS ?? DEFAULT_PRINT_WAIT_CEILING_S,
maxTurns: taskConfig?.printMaxTurns ?? DEFAULT_PRINT_MAX_TURNS,
countPending: () => countPendingBackgroundTasks(session),
drain: () => drainBackgroundTasks(session, taskConfig?.printWaitCeilingS),
turnEndings,
skipTurnId: turn.id,
warn: (message) => stderr.write(`Warning: ${message}\n`),
now: () => Date.now(),
goalActive: () => goalService.getGoal().goal?.status === 'active',
});
} catch (error) {
// A steered turn that fails fails the run (v1 parity). Anything else
// is best-effort: a wedged background task must not fail the (already
// completed) main turn.
if (error instanceof PrintSteeredTurnFailedError) {
writer.finish();
throw error;
}
stderr.write(
`Warning: print background policy failed: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
}
writer.finish();
return;
}
writer.finish();
throw new Error(formatNativeTurnFailure(result));
} catch (error) {
writer.finish();
throw error instanceof Error ? error : new Error(String(error));
} finally {
subscription.dispose();
}
}
async function runNativeGoal(
app: Scope,
session: ISessionScopeHandle,
agent: IAgentScopeHandle,
goal: HeadlessGoalCreate,
model: string | undefined,
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
stderr: PromptOutput,
): Promise<void> {
requireConfiguredModel(model);
const goalService = agent.accessor.get(IAgentGoalService);
await goalService.createGoal({
objective: goal.objective,
replace: goal.replace,
});
let completedSnapshot: { readonly status: string } | null = null;
const subscription = agent.accessor.get(IEventBus).subscribe((event: DomainEvent) => {
if (
event.type === 'goal.updated' &&
event.change?.kind === 'completion' &&
event.snapshot !== null
) {
completedSnapshot = event.snapshot;
}
});
try {
await runNativeTurn(app, session, agent, goal.objective, outputFormat, stdout, stderr);
} finally {
subscription.dispose();
const snapshot = completedSnapshot ?? goalService.getGoal().goal;
if (outputFormat === 'stream-json') {
stdout.write(`${JSON.stringify(goalSummaryJson(snapshot))}\n`);
} else {
stderr.write(`${formatGoalSummaryText(snapshot)}\n`);
}
if (snapshot !== null && snapshot.status !== 'complete') {
process.exitCode = goalExitCode(snapshot.status);
}
}
}
function dispatchNativeEvent(
writer: PromptTurnWriter,
event: DomainEvent,
stderr: PromptOutput,
): void {
switch (event.type) {
case 'turn.step.started':
case 'turn.step.interrupted':
writer.flushAssistant();
return;
case 'turn.step.retrying':
writer.discardAssistant();
return;
case 'assistant.delta':
writer.writeAssistantDelta(event.delta);
return;
case 'hook.result':
writer.writeHookResult(event);
return;
case 'thinking.delta':
writer.writeThinkingDelta(event.delta);
return;
case 'tool.call.started':
writer.writeToolCall(event.toolCallId, event.name, event.args);
return;
case 'tool.call.delta':
writer.writeToolCallDelta(event.toolCallId, event.name, event.argumentsPart);
return;
case 'tool.result':
writer.writeToolResult(event.toolCallId, event.output);
return;
case 'tool.progress':
if (event.update.text !== undefined && event.update.text.length > 0) {
stderr.write(event.update.text.endsWith('\n') ? event.update.text : `${event.update.text}\n`);
}
return;
}
}
export type PrintTurnEnding = Extract<DomainEvent, { type: 'turn.ended' }>;
/**
* Source of `turn.ended` events for the print steer loop. `next` resolves with
* the next ending (skipping `skipTurnId`, the main turn's own buffered
* ending), or `null` when `remainingMs` elapses first.
*/
export interface PrintTurnEndings {
next(remainingMs: number, skipTurnId: number): Promise<PrintTurnEnding | null>;
}
/**
* Buffered `turn.ended` collector fed from the agent event bus. Events that
* arrive while no one is waiting are queued, so endings that fire between the
* main turn settling and the policy loop starting are not missed.
*/
export function createPrintTurnEndings(): PrintTurnEndings & {
push: (event: PrintTurnEnding) => void;
} {
const buffer: PrintTurnEnding[] = [];
let waiter: ((ending: PrintTurnEnding | null) => void) | undefined;
return {
push: (event) => {
const resolve = waiter;
if (resolve !== undefined) {
waiter = undefined;
resolve(event);
return;
}
buffer.push(event);
},
next: async (remainingMs, skipTurnId) => {
const deadlineAt = Date.now() + remainingMs;
const waitOnce = (ms: number): Promise<PrintTurnEnding | null> =>
new Promise((resolve) => {
let settled = false;
const settle = (value: PrintTurnEnding | null): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
waiter = undefined;
// oxlint-disable-next-line promise/no-multiple-resolved -- `settled` guards the single resolve; the rule cannot see it
resolve(value);
};
const timer = Number.isFinite(ms)
? setTimeout(() => {
settle(null);
}, ms)
: undefined;
waiter = settle;
});
for (;;) {
while (buffer.length > 0) {
const ending = buffer.shift()!;
if (ending.turnId !== skipTurnId) return ending;
}
const ms = deadlineAt - Date.now();
if (ms <= 0) return null;
const ending = await waitOnce(ms);
if (ending === null) return null;
if (ending.turnId !== skipTurnId) return ending;
// The skipped turn's own ending: keep waiting within the same budget.
}
},
};
}
/** A background-task completion steered a new main turn that did not complete. */
export class PrintSteeredTurnFailedError extends Error {}
export interface PrintBackgroundPolicyInput {
readonly mode: PrintBackgroundMode;
readonly ceilingS: number;
readonly maxTurns: number;
readonly countPending: () => number;
readonly drain: () => Promise<void>;
readonly turnEndings: PrintTurnEndings;
readonly skipTurnId: number;
readonly warn: (message: string) => void;
readonly now: () => number;
/**
* Reports whether an agent goal is still `active`. v2 drives goal
* continuation as new turns (v1 keeps a single turn alive), so a `-p` goal
* run must stay alive until the goal leaves `active`, independent of the
* background policy.
*/
readonly goalActive?: () => boolean;
}
/**
* Apply the print-mode (`kimi -p`) background-task policy after the main turn
* completes. Mirrors v1's `Session.handlePrintMainTurnCompleted`:
* - goal : while a goal is `active`, keep waiting for its continuation
* turns (bounded by `ceilingS` as a safety net), regardless of
* the background mode; the goal summary drives the exit code.
* - 'exit' : return immediately (default).
* - 'drain' : suppress + drain background tasks, then return.
* - 'steer' : while background tasks are still pending, stay alive so task
* completions steer new main turns; return once quiescent, or
* when the wall-clock ceiling (`ceilingS`) or the turn cap
* (`maxTurns`) is reached. A steered turn that does not complete
* fails the run.
*/
export async function applyPrintBackgroundPolicy(
input: PrintBackgroundPolicyInput,
): Promise<void> {
if (input.goalActive !== undefined) {
const goalDeadline = input.now() + input.ceilingS * 1000;
while (input.goalActive()) {
// Also wake on a short poll: a goal can leave `active` without any
// further turn.ended (budget block at a turn boundary, or a pause after
// a continuation-launch failure), which would otherwise hang the run
// until the ceiling.
const ended = await input.turnEndings.next(
Math.min(goalDeadline - input.now(), GOAL_WAIT_POLL_MS),
input.skipTurnId,
);
if (ended === null && input.now() >= goalDeadline) {
input.warn(`print goal wait ceiling reached (${input.ceilingS}s), finishing`);
return;
}
// A continuation turn that does not complete pauses/blocks the goal, so
// the loop condition exits on the next check.
}
}
if (input.mode === 'exit') return;
if (input.mode === 'drain') {
await input.drain();
return;
}
// 'steer'
const deadline = input.now() + input.ceilingS * 1000;
let turns = 0;
for (;;) {
turns += 1;
if (input.now() >= deadline) {
input.warn(`print steer ceiling reached (${input.ceilingS}s), finishing`);
return;
}
if (turns > input.maxTurns) {
input.warn(`print steer max turns reached (${input.maxTurns}), finishing`);
return;
}
if (input.countPending() === 0) return;
const ended = await input.turnEndings.next(deadline - input.now(), input.skipTurnId);
if (ended === null) return;
if (ended.reason !== 'completed') {
throw new PrintSteeredTurnFailedError(formatTurnEndingFailure(ended));
}
}
}
function formatTurnEndingFailure(ending: PrintTurnEnding): string {
if (ending.error?.code === 'provider.filtered') {
return 'Provider safety policy blocked the response.';
}
if (ending.error !== undefined) return `${ending.error.code}: ${ending.error.message}`;
if (ending.reason === 'blocked') {
return 'Prompt hook blocked the request.';
}
return `Prompt turn ended with reason: ${ending.reason}`;
}
function countPendingBackgroundTasks(session: ISessionScopeHandle): number {
let count = 0;
for (const handle of session.accessor.get(IAgentLifecycleService).list()) {
count += handle.accessor.get(IAgentTaskService).list(true).length;
}
return count;
}
async function drainBackgroundTasks(
session: ISessionScopeHandle,
ceilingS: number | undefined,
): Promise<void> {
const ceilingMs =
typeof ceilingS === 'number' && Number.isFinite(ceilingS) && ceilingS > 0
? ceilingS * 1000
: DEFAULT_PRINT_WAIT_CEILING_S * 1000;
const deadline = Date.now() + ceilingMs;
const seen = new Set<string>();
const allWaiters: Promise<unknown>[] = [];
while (Date.now() < deadline) {
const batch: Promise<unknown>[] = [];
const suppressions: Promise<void>[] = [];
let activeCount = 0;
for (const handle of session.accessor.get(IAgentLifecycleService).list()) {
const taskService = handle.accessor.get(IAgentTaskService);
for (const task of taskService.list(true)) {
activeCount++;
if (seen.has(task.taskId)) continue;
seen.add(task.taskId);
suppressions.push(taskService.suppressTerminalNotification(task.taskId));
const remaining = Math.max(1, deadline - Date.now());
const waiter = taskService.wait(task.taskId, remaining);
batch.push(waiter);
allWaiters.push(waiter);
}
}
if (suppressions.length > 0) await Promise.all(suppressions);
if (activeCount === 0 || batch.length === 0) break;
await Promise.all(batch);
}
if (allWaiters.length > 0) await Promise.all(allWaiters);
}
function formatNativeTurnFailure(result: LoopRunResult): string {
if (result.type === 'failed') {
const error = result.error as { readonly code?: string; readonly message?: string } | undefined;
if (error?.code === 'provider.filtered') {
return 'Provider safety policy blocked the response.';
}
if (error?.code !== undefined) {
return `${error.code}: ${error.message ?? ''}`.trimEnd();
}
if (result.error instanceof Error) {
return result.error.message;
}
}
return `Prompt turn ended with reason: ${result.type}`;
}

View file

@ -7,7 +7,7 @@
import { existsSync, readFileSync } from 'node:fs'; import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
import { createKimiDefaultHeaders, type KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth'; import { createKimiDefaultHeaders, createKimiUserAgent, type KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth';
import { CLI_USER_AGENT_PRODUCT } from '#/constant/app'; import { CLI_USER_AGENT_PRODUCT } from '#/constant/app';
@ -55,6 +55,14 @@ export function createKimiCodeHostIdentity(version = getVersion()): KimiHostIden
}; };
} }
/**
* Product User-Agent (`kimi-code-cli/<version>`) for ad-hoc outbound fetches
* that don't go through the provider pipeline (registry / catalog imports).
*/
export function createKimiCodeUserAgent(version = getVersion()): string {
return createKimiUserAgent(createKimiCodeHostIdentity(version));
}
export function buildKimiDefaultHeaders(version: string): Record<string, string> { export function buildKimiDefaultHeaders(version: string): Record<string, string> {
return createKimiDefaultHeaders({ return createKimiDefaultHeaders({
homeDir: getDataDir(), homeDir: getDataDir(),

View file

@ -403,7 +403,8 @@ async function performModelSwitch(
const modelChanged = alias !== prevModel; const modelChanged = alias !== prevModel;
const effortChanged = effort !== prevEffort; const effortChanged = effort !== prevEffort;
const runtimeChanged = modelChanged || effortChanged; const runtimeChanged = modelChanged || effortChanged;
const displayName = modelDisplayName(alias, host.state.appState.availableModels[alias]); let effectiveAlias = alias;
let effectiveEffort = effort;
const session = host.session; const session = host.session;
try { try {
@ -416,6 +417,9 @@ async function performModelSwitch(
if (effort !== prevEffort) { if (effort !== prevEffort) {
await session.setThinking(effort); await session.setThinking(effort);
} }
const status = await session.getStatus();
effectiveAlias = status.model ?? alias;
effectiveEffort = status.thinkingEffort;
} }
} catch (error) { } catch (error) {
const msg = formatErrorMessage(error); const msg = formatErrorMessage(error);
@ -423,15 +427,25 @@ async function performModelSwitch(
return; return;
} }
host.setAppState({ model: alias, thinkingEffort: effort }); if (session === undefined) {
effectiveAlias = host.state.appState.model;
effectiveEffort = host.state.appState.thinkingEffort;
}
const effectiveModelChanged = effectiveAlias !== prevModel;
const effectiveEffortChanged = effectiveEffort !== prevEffort;
const displayName = modelDisplayName(
effectiveAlias,
host.state.appState.availableModels[effectiveAlias],
);
host.setAppState({ model: effectiveAlias, thinkingEffort: effectiveEffort });
if (session === undefined && runtimeChanged) { if (session === undefined && runtimeChanged) {
if (alias !== prevModel) { if (effectiveModelChanged) {
host.track('model_switch', { model: alias }); host.track('model_switch', { model: effectiveAlias });
} }
if (effort !== prevEffort) { if (effectiveEffortChanged) {
host.track('thinking_toggle', { host.track('thinking_toggle', {
enabled: effort !== 'off', enabled: effectiveEffort !== 'off',
effort, effort: effectiveEffort,
from: prevEffort, from: prevEffort,
}); });
} }
@ -440,7 +454,7 @@ async function performModelSwitch(
let persisted = false; let persisted = false;
if (persist) { if (persist) {
try { try {
persisted = await persistModelSelection(host, alias, effort); persisted = await persistModelSelection(host, effectiveAlias, effectiveEffort);
} catch (error) { } catch (error) {
const msg = formatErrorMessage(error); const msg = formatErrorMessage(error);
host.showError(`Switched to ${displayName}, but failed to save default: ${msg}`); host.showError(`Switched to ${displayName}, but failed to save default: ${msg}`);
@ -449,18 +463,18 @@ async function performModelSwitch(
} }
let status: string; let status: string;
if (modelChanged) { if (effectiveModelChanged) {
status = persist status = persist
? `Switched to ${displayName} with thinking ${effort}.` ? `Switched to ${displayName} with thinking ${effectiveEffort}.`
: `Switched to ${displayName} with thinking ${effort} for this session only.`; : `Switched to ${displayName} with thinking ${effectiveEffort} for this session only.`;
} else if (effortChanged) { } else if (effectiveEffortChanged) {
status = persist status = persist
? `Thinking set to ${effort}.` ? `Thinking set to ${effectiveEffort}.`
: `Thinking set to ${effort} for this session only.`; : `Thinking set to ${effectiveEffort} for this session only.`;
} else if (persist && persisted) { } else if (persist && persisted) {
status = `Saved ${displayName} with thinking ${effort} as default.`; status = `Saved ${displayName} with thinking ${effectiveEffort} as default.`;
} else { } else {
status = `Already using ${displayName} with thinking ${effort}.`; status = `Already using ${displayName} with thinking ${effectiveEffort}.`;
} }
host.showStatus(status, 'success'); host.showStatus(status, 'success');
} }

View file

@ -213,5 +213,5 @@ async function loadManagedUsageReport(host: SlashCommandHost): Promise<ManagedUs
if (res.kind === 'error') { if (res.kind === 'error') {
return { error: res.message }; return { error: res.message };
} }
return { usage: { summary: res.summary, limits: res.limits } }; return { usage: { summary: res.summary, limits: res.limits, extraUsage: res.extraUsage } };
} }

View file

@ -22,6 +22,7 @@ import { UsagePanelComponent } from '../components/messages/usage-panel';
import { formatErrorMessage } from '../utils/event-payload'; import { formatErrorMessage } from '../utils/event-payload';
import { formatPluginSourceLabel, isOfficialPluginSource } from '../utils/plugin-source-label'; import { formatPluginSourceLabel, isOfficialPluginSource } from '../utils/plugin-source-label';
import { loadPluginMarketplace } from '#/utils/plugin-marketplace'; import { loadPluginMarketplace } from '#/utils/plugin-marketplace';
import { openUrl } from '#/utils/open-url';
import type { SlashCommandHost } from './dispatch'; import type { SlashCommandHost } from './dispatch';
interface ShowPluginsPickerOptions { interface ShowPluginsPickerOptions {
@ -411,6 +412,12 @@ async function handlePluginsPanelSelection(
isOfficialPluginSource(selection.source), isOfficialPluginSource(selection.source),
); );
return; return;
case 'open-url':
host.restoreEditor();
openUrl(selection.url);
host.showStatus(`Opening the ${selection.label} page in your browser…`, 'success');
host.showStatus(`If it did not open, visit ${selection.url}`);
return;
} }
} }

View file

@ -16,6 +16,7 @@ import {
type ThinkingEffort, type ThinkingEffort,
} from '@moonshot-ai/kimi-code-sdk'; } from '@moonshot-ai/kimi-code-sdk';
import { createKimiCodeUserAgent } from '#/cli/version';
import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; import { ChoicePickerComponent } from '../components/dialogs/choice-picker';
import { import {
CustomRegistryImportDialogComponent, CustomRegistryImportDialogComponent,
@ -160,7 +161,10 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise<void> {
const spinner = host.showLoginProgressSpinner(`Fetching catalog from ${DEFAULT_CATALOG_URL}`); const spinner = host.showLoginProgressSpinner(`Fetching catalog from ${DEFAULT_CATALOG_URL}`);
let catalog: Catalog | undefined; let catalog: Catalog | undefined;
try { try {
catalog = await fetchCatalog(DEFAULT_CATALOG_URL, controller.signal); catalog = await fetchCatalog(DEFAULT_CATALOG_URL, {
signal: controller.signal,
userAgent: createKimiCodeUserAgent(),
});
spinner.stop({ ok: true, label: 'Catalog loaded.' }); spinner.stop({ ok: true, label: 'Catalog loaded.' });
} catch (error) { } catch (error) {
if (controller.signal.aborted) { if (controller.signal.aborted) {
@ -276,7 +280,7 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise
let entries: Awaited<ReturnType<typeof fetchCustomRegistry>>; let entries: Awaited<ReturnType<typeof fetchCustomRegistry>>;
try { try {
entries = await fetchCustomRegistry(source); entries = await fetchCustomRegistry(source, { userAgent: createKimiCodeUserAgent() });
} catch (error) { } catch (error) {
host.showError(`Failed to import registry: ${formatErrorMessage(error)}`); host.showError(`Failed to import registry: ${formatErrorMessage(error)}`);
return false; return false;

View file

@ -28,6 +28,27 @@ const INSTALL_TRUST_EXIT = 'exit';
const INSTALL_TRUST_TRUST = 'trust'; const INSTALL_TRUST_TRUST = 'trust';
const ELLIPSIS = '…'; const ELLIPSIS = '…';
// Hardcoded Web Bridge promotion: a built-in entry that always leads the
// Official tab, even when the marketplace catalog is unavailable. Selecting it
// opens the install page in the browser rather than installing from a source,
// because Web Bridge is a browser extension + daemon, not a plugin package.
const WEB_BRIDGE_URL = 'https://www.kimi.com/features/webbridge#local-agent';
const WEB_BRIDGE_ENTRY: PluginMarketplaceEntry = {
id: 'kimi-webbridge',
displayName: 'Kimi WebBridge',
source: WEB_BRIDGE_URL,
tier: 'official',
homepage: WEB_BRIDGE_URL,
description: 'Control your real browser from Kimi Code — navigate, click, type, and screenshot',
};
// Only the hardcoded pinned row should open the WebBridge install page. Match
// by reference (not id) so a catalog entry on another tab that happens to
// reuse the same id still installs normally instead of being hijacked.
function isPinnedWebBridgeEntry(entry: PluginMarketplaceEntry): boolean {
return entry === WEB_BRIDGE_ENTRY;
}
interface PluginsOverviewItem { interface PluginsOverviewItem {
readonly value: string; readonly value: string;
readonly kind: 'plugin' | 'action'; readonly kind: 'plugin' | 'action';
@ -304,7 +325,8 @@ export type PluginsPanelSelection =
| { readonly kind: 'details'; readonly id: string } | { readonly kind: 'details'; readonly id: string }
| { readonly kind: 'reload' } | { readonly kind: 'reload' }
| { readonly kind: 'install'; readonly entry: PluginMarketplaceEntry } | { readonly kind: 'install'; readonly entry: PluginMarketplaceEntry }
| { readonly kind: 'install-source'; readonly source: string }; | { readonly kind: 'install-source'; readonly source: string }
| { readonly kind: 'open-url'; readonly url: string; readonly label: string };
export interface PluginsPanelOptions { export interface PluginsPanelOptions {
readonly installed: readonly PluginSummary[]; readonly installed: readonly PluginSummary[];
@ -402,7 +424,19 @@ export class PluginsPanelComponent extends Container implements Focusable {
} }
private get officialEntries(): readonly PluginMarketplaceEntry[] { private get officialEntries(): readonly PluginMarketplaceEntry[] {
return this.marketplaceEntries.filter((entry) => entry.tier === 'official'); // The hardcoded Web Bridge entry always leads the Official tab, even when
// the catalog is loading or unreachable. Dedupe by id so a catalog that
// also lists it does not render a second row.
return [WEB_BRIDGE_ENTRY, ...this.officialCatalogEntries];
}
private get officialCatalogEntries(): readonly PluginMarketplaceEntry[] {
// Dedupe by id (not reference): if the official catalog also lists
// kimi-webbridge, the pinned row already represents it, so suppress the
// catalog copy to avoid a duplicate row on the Official tab.
return this.marketplaceEntries.filter(
(entry) => entry.tier === 'official' && entry.id !== WEB_BRIDGE_ENTRY.id,
);
} }
private get thirdPartyEntries(): readonly PluginMarketplaceEntry[] { private get thirdPartyEntries(): readonly PluginMarketplaceEntry[] {
@ -516,6 +550,10 @@ export class PluginsPanelComponent extends Container implements Focusable {
if (matchesKey(data, Key.enter)) { if (matchesKey(data, Key.enter)) {
const entry = entries[this.selectedIndex]; const entry = entries[this.selectedIndex];
if (entry === undefined) return; if (entry === undefined) return;
if (isPinnedWebBridgeEntry(entry)) {
this.opts.onSelect({ kind: 'open-url', url: WEB_BRIDGE_URL, label: entry.displayName });
return;
}
this.opts.onSelect({ kind: 'install', entry }); this.opts.onSelect({ kind: 'install', entry });
} }
} }
@ -622,6 +660,7 @@ export class PluginsPanelComponent extends Container implements Focusable {
lines: string[], lines: string[],
width: number, width: number,
entries: readonly PluginMarketplaceEntry[], entries: readonly PluginMarketplaceEntry[],
indexOffset = 0,
): void { ): void {
const colors = currentTheme.palette; const colors = currentTheme.palette;
if (this.market.status === 'loading' || this.market.status === 'idle') { if (this.market.status === 'loading' || this.market.status === 'idle') {
@ -637,7 +676,7 @@ export class PluginsPanelComponent extends Container implements Focusable {
lines.push(chalk.hex(colors.textMuted)(' No plugins found.')); lines.push(chalk.hex(colors.textMuted)(' No plugins found.'));
} else { } else {
for (let i = 0; i < entries.length; i++) { for (let i = 0; i < entries.length; i++) {
lines.push(...this.renderMarketplaceRow(entries[i]!, i, width)); lines.push(...this.renderMarketplaceRow(entries[i]!, i + indexOffset, width));
} }
} }
const installedCount = entries.filter((e) => this.opts.installedIds.has(e.id)).length; const installedCount = entries.filter((e) => this.opts.installedIds.has(e.id)).length;
@ -649,7 +688,11 @@ export class PluginsPanelComponent extends Container implements Focusable {
} }
private renderOfficial(lines: string[], width: number): void { private renderOfficial(lines: string[], width: number): void {
this.renderMarketplaceTab(lines, width, this.officialEntries); // Web Bridge is pinned above the catalog and stays visible while the
// catalog loads or errors, since it's built into the TUI rather than
// fetched. Catalog rows shift down by one index to match.
lines.push(...this.renderMarketplaceRow(WEB_BRIDGE_ENTRY, 0, width));
this.renderMarketplaceTab(lines, width, this.officialCatalogEntries, 1);
} }
private renderThirdParty(lines: string[], width: number): void { private renderThirdParty(lines: string[], width: number): void {
@ -662,7 +705,9 @@ export class PluginsPanelComponent extends Container implements Focusable {
const pointer = selected ? SELECT_POINTER : ' '; const pointer = selected ? SELECT_POINTER : ' ';
const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text);
const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `);
const status = marketplaceEntryStatus(entry, this.installedVersions); const status = isPinnedWebBridgeEntry(entry)
? 'open in browser'
: marketplaceEntryStatus(entry, this.installedVersions);
const line = const line =
prefix + labelStyle(entry.displayName) + ' ' + marketplaceStatusStyle(status, colors)(status); prefix + labelStyle(entry.displayName) + ' ' + marketplaceStatusStyle(status, colors)(status);
const descWidth = Math.max(1, width - 4); const descWidth = Math.max(1, width - 4);

View file

@ -22,7 +22,11 @@ import {
safeUsageRatio, safeUsageRatio,
} from '#/utils/usage/usage-format'; } from '#/utils/usage/usage-format';
import { buildManagedUsageReportLines, type ManagedUsageReport } from './usage-panel'; import {
buildExtraUsageSection,
buildManagedUsageReportLines,
type ManagedUsageReport,
} from './usage-panel';
interface FieldRow { interface FieldRow {
readonly label: string; readonly label: string;
@ -145,5 +149,16 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] {
lines.push(...managedSection); lines.push(...managedSection);
} }
const extraSection = buildExtraUsageSection(
options.managedUsage?.extraUsage,
accent,
value,
muted,
);
if (extraSection.length > 0) {
lines.push('');
lines.push(...extraSection);
}
return lines; return lines;
} }

View file

@ -42,6 +42,7 @@ const MAX_SUB_TOOL_CALLS_SHOWN = 4;
// cannot wrap the header onto a second row and break the card's stable height. // cannot wrap the header onto a second row and break the card's stable height.
const MAX_SUBAGENT_DESCRIPTION_LENGTH = 60; const MAX_SUBAGENT_DESCRIPTION_LENGTH = 60;
const APPROVED_PLAN_MARKER = '## Approved Plan:'; const APPROVED_PLAN_MARKER = '## Approved Plan:';
const AUTO_APPROVED_PLAN_MARKER = '## Plan (auto-approved, not user-reviewed):';
const STREAMING_PROGRESS_INTERVAL_MS = 1000; const STREAMING_PROGRESS_INTERVAL_MS = 1000;
const PROGRESS_URL_RE = /https?:\/\/\S+/g; const PROGRESS_URL_RE = /https?:\/\/\S+/g;
const ABORTED_MARK = '⊘'; const ABORTED_MARK = '⊘';
@ -171,13 +172,16 @@ function formatElapsed(seconds: number): string {
} }
function extractApprovedPlan(output: string): string { function extractApprovedPlan(output: string): string {
const markerIndex = output.indexOf(APPROVED_PLAN_MARKER); const marker = output.includes(AUTO_APPROVED_PLAN_MARKER)
? AUTO_APPROVED_PLAN_MARKER
: APPROVED_PLAN_MARKER;
const markerIndex = output.indexOf(marker);
if (markerIndex < 0) return ''; if (markerIndex < 0) return '';
return output.slice(markerIndex + APPROVED_PLAN_MARKER.length).trim(); return output.slice(markerIndex + marker.length).trim();
} }
interface ExitPlanModeOutcome { interface ExitPlanModeOutcome {
readonly kind: 'approved' | 'rejected'; readonly kind: 'approved' | 'auto_approved' | 'rejected';
readonly chosen?: string; readonly chosen?: string;
readonly feedback?: string; readonly feedback?: string;
readonly path?: string; readonly path?: string;
@ -193,11 +197,17 @@ const PLAN_SAVED_TO_RE = /\nPlan saved to: ([^\n]+)\n/;
/** /**
* Parses the ExitPlanMode result content string to recover the approval outcome * Parses the ExitPlanMode result content string to recover the approval outcome
* and optional plan path. Core-side templates live in * and optional plan path. Core-side templates live in
* `packages/agent-core/src/tools/builtin/planning/exit-plan-mode.ts`: * `packages/agent-core-v2/src/agent/plan/tools/exit-plan-mode.ts` (auto-approved
* path) and `.../permissionPolicy/policies/exit-plan-mode-review-ask.ts`
* (user-reviewed path):
* - Approved output starts with 'Exited plan mode.' and selected options * - Approved output starts with 'Exited plan mode.' and selected options
* are reported as 'Selected approach: <label>'. Older outputs may start * are reported as 'Selected approach: <label>'. Older outputs may start
* with 'User approved option "<label>".' Plan-file mode may include * with 'User approved option "<label>".' Plan-file mode may include
* 'Plan saved to: <path>'. * 'Plan saved to: <path>'.
* - Auto-approved output (auto permission mode skips the review ask) also
* starts with 'Exited plan mode.' but marks the plan body with
* '## Plan (auto-approved, not user-reviewed):' instead of
* '## Approved Plan:' the user never saw or approved the plan.
* - Rejected output starts with 'Plan rejected by user.' or older * - Rejected output starts with 'Plan rejected by user.' or older
* 'User rejected the plan.'; feedback uses 'User rejected the plan. * 'User rejected the plan.'; feedback uses 'User rejected the plan.
* Feedback:\n\n<text>'. * Feedback:\n\n<text>'.
@ -217,6 +227,11 @@ function interpretExitPlanModeOutcome(output: string): ExitPlanModeOutcome {
} }
const pathMatch = PLAN_SAVED_TO_RE.exec(output); const pathMatch = PLAN_SAVED_TO_RE.exec(output);
const path = pathMatch?.[1]?.trim(); const path = pathMatch?.[1]?.trim();
if (output.includes(AUTO_APPROVED_PLAN_MARKER)) {
return path !== undefined && path.length > 0
? { kind: 'auto_approved', path }
: { kind: 'auto_approved' };
}
const optionMatch = SELECTED_APPROACH_RE.exec(output) ?? APPROVED_OPTION_RE.exec(output); const optionMatch = SELECTED_APPROACH_RE.exec(output) ?? APPROVED_OPTION_RE.exec(output);
if (optionMatch !== null) { if (optionMatch !== null) {
return path !== undefined && path.length > 0 return path !== undefined && path.length > 0
@ -232,7 +247,8 @@ function isExitPlanModeOutcomeOutput(output: string): boolean {
output.startsWith(PLAN_REJECT_PREFIX) || output.startsWith(PLAN_REJECT_PREFIX) ||
output.startsWith('Exited plan mode.') || output.startsWith('Exited plan mode.') ||
APPROVED_OPTION_RE.test(output) || APPROVED_OPTION_RE.test(output) ||
output.includes(APPROVED_PLAN_MARKER) output.includes(APPROVED_PLAN_MARKER) ||
output.includes(AUTO_APPROVED_PLAN_MARKER)
); );
} }
@ -1426,6 +1442,11 @@ export class ToolCallComponent extends Container {
: 'Approved'; : 'Approved';
return `${label}${currentTheme.fg('success', ` · ${chipText}`)}`; return `${label}${currentTheme.fg('success', ` · ${chipText}`)}`;
} }
if (outcome.kind === 'auto_approved') {
// Auto permission mode let the plan through without user review —
// a warning-toned chip keeps "the user approved this" out of the UI.
return `${label}${currentTheme.fg('warning', ' · Auto-approved')}`;
}
return label; return label;
} }

View file

@ -30,9 +30,19 @@ export interface ManagedUsageRow {
readonly resetHint?: string; readonly resetHint?: string;
} }
export interface BoosterWalletInfo {
readonly balanceCents: number;
readonly totalCents: number;
readonly monthlyChargeLimitEnabled: boolean;
readonly monthlyChargeLimitCents: number;
readonly monthlyUsedCents: number;
readonly currency: string;
}
export interface ManagedUsageReport { export interface ManagedUsageReport {
readonly summary: ManagedUsageRow | null; readonly summary: ManagedUsageRow | null;
readonly limits: readonly ManagedUsageRow[]; readonly limits: readonly ManagedUsageRow[];
readonly extraUsage?: BoosterWalletInfo | null;
} }
export interface UsageReportOptions { export interface UsageReportOptions {
@ -121,8 +131,7 @@ function buildManagedUsageSection(
r.limit > 0 ? Math.max(0, Math.min(r.used / r.limit, 1)) : 0; r.limit > 0 ? Math.max(0, Math.min(r.used / r.limit, 1)) : 0;
const labelWidth = Math.max(10, ...rows.map((r) => r.label.length)); const labelWidth = Math.max(10, ...rows.map((r) => r.label.length));
const pctWidth = Math.max(...rows.map((r) => `${Math.round(usedRatio(r) * 100)}% used`.length)); const pctWidth = Math.max(...rows.map((r) => `${Math.round(usedRatio(r) * 100)}% used`.length));
const severityColor = (sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' =>
sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success';
const out: string[] = [accent('Plan usage')]; const out: string[] = [accent('Plan usage')];
for (const row of rows) { for (const row of rows) {
const ratioUsed = usedRatio(row); const ratioUsed = usedRatio(row);
@ -136,6 +145,91 @@ function buildManagedUsageSection(
return out; return out;
} }
function severityColor(sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' {
return sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success';
}
function currencySymbol(currency: string): string {
switch (currency.toUpperCase()) {
case 'CNY':
return '¥';
case 'USD':
return '$';
default:
return '';
}
}
interface CurrencyParts {
readonly symbol: string;
readonly number: string;
}
function formatCurrencyParts(cents: number, currency: string): CurrencyParts {
const symbol = currencySymbol(currency);
const main = cents / 100;
const formatted = main.toFixed(2);
return symbol.length > 0
? { symbol, number: formatted }
: { symbol: '', number: `${formatted} ${currency}` };
}
export function buildExtraUsageSection(
extraUsage: BoosterWalletInfo | undefined | null,
accent: Colorize,
value: Colorize,
muted: Colorize,
): string[] {
if (extraUsage === undefined || extraUsage === null) return [];
const hasMonthlyLimit =
extraUsage.monthlyChargeLimitEnabled && extraUsage.monthlyChargeLimitCents > 0;
const balance = formatCurrencyParts(extraUsage.balanceCents, extraUsage.currency);
const used = formatCurrencyParts(extraUsage.monthlyUsedCents, extraUsage.currency);
const rows: Array<{ label: string; symbol: string; number: string }> = [];
let barLine: string | null = null;
if (hasMonthlyLimit) {
const ratio = Math.max(
0,
Math.min(extraUsage.monthlyUsedCents / extraUsage.monthlyChargeLimitCents, 1),
);
const bar = renderProgressBar(ratio, 20);
barLine = ` ${currentTheme.fg(severityColor(ratioSeverity(ratio)), bar)}`;
const limit = formatCurrencyParts(extraUsage.monthlyChargeLimitCents, extraUsage.currency);
rows.push({ label: 'Used this month', ...used });
rows.push({ label: 'Monthly limit', ...limit });
rows.push({ label: 'Balance', ...balance });
} else {
rows.push({ label: 'Used this month', ...used });
rows.push({ label: 'Monthly limit', symbol: '', number: 'Unlimited' });
rows.push({ label: 'Balance', ...balance });
}
// `Used this month` is the longest label; size the column to the widest label
// so the currency symbol starts in the same column on every row.
const labelWidth = Math.max(...rows.map((r) => r.label.length));
// Right-align the numeric part of currency rows against each other so the
// decimal points line up (e.g. `¥ 50.00` / `¥200.00`). Text-only rows such as
// `Unlimited` carry no currency symbol, so they must not widen the numeric
// column — otherwise money values get padded with stray spaces.
const numberWidth = Math.max(
0,
...rows.filter((r) => r.symbol.length > 0).map((r) => visibleWidth(r.number)),
);
const row = (label: string, symbol: string, number: string): string => {
const cell = symbol.length > 0 ? symbol + number.padStart(numberWidth, ' ') : number;
return ` ${muted(label.padEnd(labelWidth, ' '))} ${value(cell)}`;
};
const lines: string[] = [accent('Extra Usage')];
if (barLine !== null) lines.push(barLine);
for (const r of rows) lines.push(row(r.label, r.symbol, r.number));
return lines;
}
export function buildManagedUsageReportLines(options: ManagedUsageReportLineOptions): string[] { export function buildManagedUsageReportLines(options: ManagedUsageReportLineOptions): string[] {
const accent = (text: string) => currentTheme.boldFg('primary', text); const accent = (text: string) => currentTheme.boldFg('primary', text);
const value = (text: string) => currentTheme.fg('text', text); const value = (text: string) => currentTheme.fg('text', text);
@ -157,8 +251,6 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] {
const value = (text: string) => currentTheme.fg('text', text); const value = (text: string) => currentTheme.fg('text', text);
const muted = (text: string) => currentTheme.fg('textDim', text); const muted = (text: string) => currentTheme.fg('textDim', text);
const errorStyle = (text: string) => currentTheme.fg('error', text); const errorStyle = (text: string) => currentTheme.fg('error', text);
const severityColor = (sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' =>
sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success';
const lines: string[] = [ const lines: string[] = [
accent('Session usage'), accent('Session usage'),
@ -197,6 +289,17 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] {
lines.push(...managedSection); lines.push(...managedSection);
} }
const extraSection = buildExtraUsageSection(
options.managedUsage?.extraUsage,
accent,
value,
muted,
);
if (extraSection.length > 0) {
lines.push('');
lines.push(...extraSection);
}
return lines; return lines;
} }

View file

@ -1,4 +1,7 @@
import type { CreateSessionOptions, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; import type { CreateSessionOptions, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk';
import { createKimiCodeUserAgent } from '#/cli/version';
import type { SkillListSession } from '../commands'; import type { SkillListSession } from '../commands';
import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/kimi-tui'; import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/kimi-tui';
@ -173,6 +176,7 @@ export class AuthFlowController {
const tokenProvider = host.harness.auth.resolveOAuthTokenProvider(providerName, oauthRef); const tokenProvider = host.harness.auth.resolveOAuthTokenProvider(providerName, oauthRef);
return tokenProvider.getAccessToken(); return tokenProvider.getAccessToken();
}, },
userAgent: createKimiCodeUserAgent(),
}, },
{ scope }, { scope },
); );

View file

@ -196,14 +196,17 @@ export class BtwPanelController {
} }
function formatBtwTurnEnd(event: TurnEndedEvent): string { function formatBtwTurnEnd(event: TurnEndedEvent): string {
if (event.error !== undefined) {
return `[${event.error.code}] ${event.error.message}`;
}
if (event.reason === 'cancelled') { if (event.reason === 'cancelled') {
return 'Interrupted by user'; return 'Interrupted by user';
} }
if (event.reason === 'filtered') { if (event.error?.code === 'provider.filtered') {
return 'Provider safety policy blocked the response.'; return 'Provider safety policy blocked the response.';
} }
if (event.error !== undefined) {
return `[${event.error.code}] ${event.error.message}`;
}
if (event.reason === 'blocked') {
return 'Prompt hook blocked the request.';
}
return `BTW turn ended with reason: ${event.reason}`; return `BTW turn ended with reason: ${event.reason}`;
} }

View file

@ -1,4 +1,4 @@
import type { Session } from '@moonshot-ai/kimi-code-sdk'; import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk';
import { compressImageForModel, persistOriginalImage, sessionMediaOriginalsDir } from '@moonshot-ai/kimi-code-sdk'; import { compressImageForModel, persistOriginalImage, sessionMediaOriginalsDir } from '@moonshot-ai/kimi-code-sdk';
import { ClipboardMediaError, readClipboardMedia } from '#/utils/clipboard/clipboard-image'; import { ClipboardMediaError, readClipboardMedia } from '#/utils/clipboard/clipboard-image';
@ -15,7 +15,8 @@ import {
} from '../constant/kimi-tui'; } from '../constant/kimi-tui';
import { formatErrorMessage } from '../utils/event-payload'; import { formatErrorMessage } from '../utils/event-payload';
import type { ImageAttachmentStore } from '../utils/image-attachment-store'; import type { ImageAttachmentStore } from '../utils/image-attachment-store';
import type { PendingExit, QueuedMessage } from '../types'; import { extractMediaAttachments } from '../utils/image-placeholder';
import type { PendingExit, QueuedMessage, SteerInputItem } from '../types';
import type { TUIState } from '../tui-state'; import type { TUIState } from '../tui-state';
import type { BtwPanelController } from './btw-panel'; import type { BtwPanelController } from './btw-panel';
@ -23,10 +24,21 @@ export interface EditorKeyboardHost {
state: TUIState; state: TUIState;
session: Session | undefined; session: Session | undefined;
cancelInFlight: (() => void) | undefined; cancelInFlight: (() => void) | undefined;
/**
* The host's harness (KimiTUI always has one). Its `imageLimits` drives
* paste-time image compression; hosts without one fall back to the
* env/built-in default.
*/
harness?: KimiHarness | undefined;
handleUserInput(text: string): void; handleUserInput(text: string): void;
readonly btwPanelController: BtwPanelController; readonly btwPanelController: BtwPanelController;
steerMessage(session: Session, input: string[]): void; steerMessage(session: Session, input: readonly SteerInputItem[]): void;
validateMediaCapabilities(extraction: {
hasMedia: boolean;
imageAttachmentIds: readonly number[];
videoAttachmentIds: readonly number[];
}): boolean;
recallLastQueued(): QueuedMessage | undefined; recallLastQueued(): QueuedMessage | undefined;
showError(msg: string): void; showError(msg: string): void;
track(event: string, props?: Record<string, unknown>): void; track(event: string, props?: Record<string, unknown>): void;
@ -244,22 +256,53 @@ export class EditorKeyboardController {
// after the current task instead of being injected into the turn as text. // after the current task instead of being injected into the turn as text.
const queued = host.state.queuedMessages; const queued = host.state.queuedMessages;
const steerable = queued.filter((m) => m.mode !== 'bash'); const steerable = queued.filter((m) => m.mode !== 'bash');
host.state.queuedMessages = queued.filter((m) => m.mode === 'bash');
const parts: string[] = []; const items: SteerInputItem[] = [];
for (const m of steerable) { for (const m of steerable) {
const trimmed = m.text.trim(); const trimmed = m.text.trim();
if (trimmed.length > 0) parts.push(trimmed); if (trimmed.length > 0) {
// Queued items carry the parts extracted when they were submitted
// (and were already capability-validated then).
items.push({ text: trimmed, parts: m.parts, imageAttachmentIds: m.imageAttachmentIds });
}
}
let editorExtraction: ReturnType<typeof extractMediaAttachments> | undefined;
if (!editorIsBash && text.length > 0) {
try {
editorExtraction = extractMediaAttachments(text, this.imageStore);
} catch (error) {
// Cache copy failed (e.g. the pasted video's source vanished) —
// leave the queue and the editor draft untouched.
host.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`);
return;
}
items.push({
text,
parts: editorExtraction.hasMedia ? editorExtraction.parts : undefined,
imageAttachmentIds:
editorExtraction.imageAttachmentIds.length > 0
? editorExtraction.imageAttachmentIds
: undefined,
});
} }
if (!editorIsBash && text.length > 0) parts.push(text);
if (parts.length > 0) { if (items.length > 0) {
// The editor draft is fresh input: gate it on the model's media
// capabilities before splicing the queue, so a rejection leaves the
// queue and the draft untouched.
if (
editorExtraction !== undefined &&
!host.validateMediaCapabilities(editorExtraction)
) {
return;
}
host.state.queuedMessages = queued.filter((m) => m.mode === 'bash');
if (!editorIsBash) editor.setText(''); if (!editorIsBash) editor.setText('');
const session = host.session; const session = host.session;
if (host.state.appState.model.trim().length === 0 || session === undefined) { if (host.state.appState.model.trim().length === 0 || session === undefined) {
host.showError(LLM_NOT_SET_MESSAGE); host.showError(LLM_NOT_SET_MESSAGE);
} else { } else {
host.steerMessage(session, parts); host.steerMessage(session, items);
} }
} }
host.updateQueueDisplay(); host.updateQueueDisplay();
@ -407,7 +450,11 @@ export class EditorKeyboardController {
// session's media-originals dir when known, else the temp-dir fallback) // session's media-originals dir when known, else the temp-dir fallback)
// and recorded on the attachment, so submit-time expansion can announce // and recorded on the attachment, so submit-time expansion can announce
// the compression and point the model at the full-fidelity copy. // the compression and point the model at the full-fidelity copy.
// The edge cap comes from the host harness's [image] config (resolved per
// paste so a config reload applies immediately); hosts without a harness
// use the env/built-in default.
const compressed = await compressImageForModel(media.bytes, meta.mime, { const compressed = await compressImageForModel(media.bytes, meta.mime, {
maxEdge: this.host.harness?.imageLimits?.maxEdgePx(),
telemetry: { telemetry: {
client: { client: {
track: (event, properties) => track: (event, properties) =>

View file

@ -333,9 +333,12 @@ export class SessionEventHandler {
if (event.reason === 'cancelled') { if (event.reason === 'cancelled') {
this.markActiveAgentSwarmsCancelled(); this.markActiveAgentSwarmsCancelled();
} }
if (event.reason === 'filtered') { if (event.reason === 'failed' && event.error?.code === 'provider.filtered') {
this.host.showStatus('Turn stopped: provider safety policy blocked the response.', 'error'); this.host.showStatus('Turn stopped: provider safety policy blocked the response.', 'error');
} }
if (event.reason === 'blocked') {
this.host.showStatus('Turn stopped: prompt hook blocked the request.', 'error');
}
const todos = this.host.state.todoPanel.getTodos(); const todos = this.host.state.todoPanel.getTodos();
if (todos.length > 0 && todos.every((t) => t.status === 'done')) { if (todos.length > 0 && todos.every((t) => t.status === 'done')) {
this.host.streamingUI.setTodoList([]); this.host.streamingUI.setTodoList([]);
@ -426,7 +429,11 @@ export class SessionEventHandler {
if (reason === 'error') return; if (reason === 'error') return;
if (reason === 'aborted' || reason === undefined || reason === '') { if (reason === 'aborted' || reason === undefined || reason === '') {
this.markActiveAgentSwarmsCancelled(); this.markActiveAgentSwarmsCancelled();
this.host.showStatus('Interrupted by user', 'error'); if (event.message === undefined || event.message === '') {
this.host.showStatus('Interrupted by user', 'error');
} else {
this.host.showError(event.message);
}
return; return;
} }
this.host.showError( this.host.showError(
@ -608,6 +615,7 @@ export class SessionEventHandler {
patch.permissionMode = event.permission; patch.permissionMode = event.permission;
} }
if (event.model !== undefined) patch.model = event.model; if (event.model !== undefined) patch.model = event.model;
if (event.thinkingEffort !== undefined) patch.thinkingEffort = event.thinkingEffort;
if (Object.keys(patch).length > 0) this.host.setAppState(patch); if (Object.keys(patch).length > 0) this.host.setAppState(patch);
if (event.swarmMode === false) { if (event.swarmMode === false) {
this.host.state.swarmModeEntry = undefined; this.host.state.swarmModeEntry = undefined;

View file

@ -123,6 +123,7 @@ import {
type LivePaneState, type LivePaneState,
type LoginProgressSpinnerHandle, type LoginProgressSpinnerHandle,
type QueuedMessage, type QueuedMessage,
type SteerInputItem,
type TranscriptEntry, type TranscriptEntry,
type TUIStartupOptions, type TUIStartupOptions,
type TUIStartupState, type TUIStartupState,
@ -132,7 +133,7 @@ import { isDeadTerminalError } from './utils/dead-terminal';
import { formatErrorMessage } from './utils/event-payload'; import { formatErrorMessage } from './utils/event-payload';
import { pickForegroundTasks } from './utils/foreground-task'; import { pickForegroundTasks } from './utils/foreground-task';
import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store';
import { extractMediaAttachments } from './utils/image-placeholder'; import { extractMediaAttachments, rewriteMediaPlaceholders } from './utils/image-placeholder';
import { hasPatchChanges } from './utils/object-patch'; import { hasPatchChanges } from './utils/object-patch';
import { sessionRowsForPicker } from './utils/session-picker-rows'; import { sessionRowsForPicker } from './utils/session-picker-rows';
import { formatBashOutputForDisplay } from './utils/shell-output'; import { formatBashOutputForDisplay } from './utils/shell-output';
@ -238,6 +239,50 @@ interface SendMessageOptions {
readonly hasMedia?: boolean; readonly hasMedia?: boolean;
} }
/**
* Flatten steer items into the payload `session.steer` expects: the
* historical `'\n\n'`-joined string when nothing carries media, or a
* merged part list when any item has extracted media parts (queued image
* messages, or the editor draft after placeholder extraction).
*
* Items are separated by the historical `'\n\n'`, which merges into the
* adjacent text part. The one exception is two touching media parts: a
* standalone `{type:'text',text:'\n\n'}` between them would be rejected
* by `normalizePromptInput` as an empty text part, so the separator is
* dropped there (media parts are self-delimiting anyway).
*/
function combineSteerInput(items: readonly SteerInputItem[]): string | PromptPart[] {
const hasMedia = items.some((item) => item.parts !== undefined && item.parts.length > 0);
if (!hasMedia) return items.map((item) => item.text).join('\n\n');
const parts: PromptPart[] = [];
for (const item of items) {
const startsWithMedia =
item.parts !== undefined && item.parts.length > 0 && item.parts[0]?.type !== 'text';
const lastIsMedia = parts.length > 0 && parts.at(-1)?.type !== 'text';
if (parts.length > 0 && !(lastIsMedia && startsWithMedia)) {
appendSteerText(parts, '\n\n');
}
if (item.parts !== undefined && item.parts.length > 0) {
for (const part of item.parts) {
if (part.type === 'text') appendSteerText(parts, part.text);
else parts.push(part);
}
} else {
appendSteerText(parts, item.text);
}
}
return parts;
}
function appendSteerText(parts: PromptPart[], text: string): void {
const last = parts.at(-1);
if (last?.type === 'text') {
parts[parts.length - 1] = { type: 'text', text: last.text + text };
return;
}
parts.push({ type: 'text', text });
}
/** How long the one-shot "moved to background" footer hint stays visible. */ /** How long the one-shot "moved to background" footer hint stays visible. */
const DETACH_HINT_DISPLAY_MS = 4_000; const DETACH_HINT_DISPLAY_MS = 4_000;
@ -1095,9 +1140,11 @@ export class KimiTUI {
this.state.ui.requestRender(); this.state.ui.requestRender();
} }
private validateMediaCapabilities( validateMediaCapabilities(extraction: {
extraction: ReturnType<typeof extractMediaAttachments>, hasMedia: boolean;
): boolean { imageAttachmentIds: readonly number[];
videoAttachmentIds: readonly number[];
}): boolean {
if (!extraction.hasMedia) return true; if (!extraction.hasMedia) return true;
if ( if (
extraction.imageAttachmentIds.length > 0 && extraction.imageAttachmentIds.length > 0 &&
@ -1243,8 +1290,22 @@ export class KimiTUI {
} }
sendSkillActivation(session: Session, skillName: string, skillArgs: string): void { sendSkillActivation(session: Session, skillName: string, skillArgs: string): void {
// Args are a plain-text channel, so pasted media can't ride along as
// inline parts. Skill args are XML-escaped on render (renderSkillAttributes
// + expandSkillParameters), so rewrite placeholders into escape-proof
// plain-text file references the model can open with ReadMediaFile.
let rewrite: ReturnType<typeof rewriteMediaPlaceholders>;
try {
rewrite = rewriteMediaPlaceholders(skillArgs, this.imageStore, 'plain');
} catch (error) {
// Cache copy failed (unwritable cache dir, vanished video source…);
// nothing has been dispatched yet, so just report and keep the input.
this.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`);
return;
}
if (!this.validateMediaCapabilities(rewrite)) return;
this.beginSessionRequest(); this.beginSessionRequest();
void session.activateSkill(skillName, skillArgs).catch((error: unknown) => { void session.activateSkill(skillName, rewrite.text).catch((error: unknown) => {
const message = formatErrorMessage(error); const message = formatErrorMessage(error);
this.failSessionRequest(`Skill "${skillName}" failed: ${message}`); this.failSessionRequest(`Skill "${skillName}" failed: ${message}`);
}); });
@ -1256,11 +1317,24 @@ export class KimiTUI {
commandName: string, commandName: string,
args: string, args: string,
): void { ): void {
// Plugin command args are expanded verbatim (no XML escaping), so the
// standard <image|video path> tag convention works — see
// sendSkillActivation for the escaped-channel variant.
let rewrite: ReturnType<typeof rewriteMediaPlaceholders>;
try {
rewrite = rewriteMediaPlaceholders(args, this.imageStore, 'tag');
} catch (error) {
this.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`);
return;
}
if (!this.validateMediaCapabilities(rewrite)) return;
this.beginSessionRequest(); this.beginSessionRequest();
void session.activatePluginCommand(pluginId, commandName, args).catch((error: unknown) => { void session
const message = formatErrorMessage(error); .activatePluginCommand(pluginId, commandName, rewrite.text)
this.failSessionRequest(`Command "${pluginId}:${commandName}" failed: ${message}`); .catch((error: unknown) => {
}); const message = formatErrorMessage(error);
this.failSessionRequest(`Command "${pluginId}:${commandName}" failed: ${message}`);
});
} }
private sendMessage(session: Session, input: string, options?: SendMessageOptions): void { private sendMessage(session: Session, input: string, options?: SendMessageOptions): void {
@ -1275,31 +1349,35 @@ export class KimiTUI {
this.sendMessageInternal(session, input, options); this.sendMessageInternal(session, input, options);
} }
steerMessage(session: Session, input: string[]): void { steerMessage(session: Session, input: readonly SteerInputItem[]): void {
if (this.deferUserMessages || this.state.appState.isCompacting) { if (this.deferUserMessages || this.state.appState.isCompacting) {
for (const part of input) { for (const item of input) {
this.enqueueMessage(part); this.enqueueMessage(item.text, item);
} }
return; return;
} }
if (this.state.appState.streamingPhase === 'idle') { if (this.state.appState.streamingPhase === 'idle') {
for (const part of input) { for (const item of input) {
this.sendMessageInternal(session, part); this.sendMessageInternal(session, item.text, item);
} }
return; return;
} }
for (const part of input) { for (const item of input) {
this.appendTranscriptEntry({ this.appendTranscriptEntry({
id: nextTranscriptId(), id: nextTranscriptId(),
kind: 'user', kind: 'user',
turnId: this.streamingUI.getTurnContext().turnId, turnId: this.streamingUI.getTurnContext().turnId,
renderMode: 'plain', renderMode: 'plain',
content: part, content: item.text,
imageAttachmentIds:
item.imageAttachmentIds !== undefined && item.imageAttachmentIds.length > 0
? item.imageAttachmentIds
: undefined,
}); });
} }
void session.steer(input.join('\n\n')).catch((error: unknown) => { void session.steer(combineSteerInput(input)).catch((error: unknown) => {
const message = formatErrorMessage(error); const message = formatErrorMessage(error);
this.showError(`Failed to steer: ${message}`); this.showError(`Failed to steer: ${message}`);
}); });

View file

@ -202,7 +202,7 @@ function describeApproval(display: ToolInputDisplay, action: string): string {
return `search: ${display.query ?? ''}`.trim(); return `search: ${display.query ?? ''}`.trim();
case 'todo_list': case 'todo_list':
return `update todo list (${String(display.items?.length ?? 0)} items)`; return `update todo list (${String(display.items?.length ?? 0)} items)`;
case 'background_task': case 'task':
return `${display.status ?? 'background'} task ${display.task_id ?? ''}: ${ return `${display.status ?? 'background'} task ${display.task_id ?? ''}: ${
display.description ?? '' display.description ?? ''
}`.trim(); }`.trim();
@ -334,7 +334,7 @@ function adaptDisplay(display: ToolInputDisplay): DisplayBlock[] {
return []; return [];
case 'todo_list': case 'todo_list':
return []; return [];
case 'background_task': case 'task':
return []; return [];
default: default:
return []; return [];

View file

@ -206,6 +206,18 @@ export interface QueuedMessage {
readonly mode?: 'prompt' | 'bash'; readonly mode?: 'prompt' | 'bash';
} }
/**
* One unit of Ctrl-S steer input: a queued message or the editor draft,
* with the media parts extracted at submit/paste time so images and video
* tags survive the steer path (which accepts full prompt parts, not just
* text).
*/
export interface SteerInputItem {
readonly text: string;
readonly parts?: readonly PromptPart[];
readonly imageAttachmentIds?: readonly number[];
}
export const INITIAL_LIVE_PANE: LivePaneState = { export const INITIAL_LIVE_PANE: LivePaneState = {
mode: 'idle', mode: 'idle',
pendingApproval: null, pendingApproval: null,

View file

@ -1,7 +1,4 @@
import { import { isKimiError } from '@moonshot-ai/kimi-code-sdk';
isKimiError,
type KimiErrorPayload,
} from '@moonshot-ai/kimi-code-sdk';
import { import {
STREAMING_ARGS_FIELD_RE, STREAMING_ARGS_FIELD_RE,
@ -103,9 +100,13 @@ export function formatErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
} }
export function formatErrorPayload( interface ErrorPayloadLike {
error: Pick<KimiErrorPayload, 'code' | 'message' | 'details'>, readonly code: string;
): string { readonly message: string;
readonly details?: Record<string, unknown>;
}
export function formatErrorPayload(error: ErrorPayloadLike): string {
const filteredMessage = formatProviderFilteredMessage(error.details); const filteredMessage = formatProviderFilteredMessage(error.details);
if (filteredMessage !== undefined) return `[${error.code}] ${filteredMessage}`; if (filteredMessage !== undefined) return `[${error.code}] ${filteredMessage}`;
return `[${error.code}] ${error.message}`; return `[${error.code}] ${error.message}`;

View file

@ -18,7 +18,7 @@
*/ */
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { copyFileSync, mkdirSync } from 'node:fs'; import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import type { PromptPart } from '@moonshot-ai/kimi-code-sdk'; import type { PromptPart } from '@moonshot-ai/kimi-code-sdk';
@ -101,6 +101,78 @@ export function extractMediaAttachments(
}; };
} }
export interface MediaTagRewriteResult {
/** Input text with resolved placeholders replaced by media references. */
text: string;
hasMedia: boolean;
imageAttachmentIds: number[];
videoAttachmentIds: number[];
}
/**
* How a resolved placeholder is rendered into command args:
* - `'tag'`: the `<image|video path="…"></…>` convention, for channels
* that pass args through verbatim (plugin commands).
* - `'plain'`: a plain-text file reference with no XML tag/attribute
* boundary characters, for channels that XML-escape args (`/skill`
* args are escaped by both `renderSkillAttributes` and
* `expandSkillParameters`, which would mangle the tag form).
*/
export type MediaReferenceStyle = 'tag' | 'plain';
/**
* Rewrite media placeholders in slash-command args (`/skill:foo …`,
* plugin commands) into references pointing at cache-dir copies. Command
* args are a plain-text channel unlike `extractMediaAttachments`, which
* inlines image parts for the prompt endpoint so the model reaches the
* media through `ReadMediaFile` instead, the same way it already handles
* pasted videos.
*
* Surrounding text is preserved verbatim (args are user content, not
* LLM parts), and unresolved placeholders stay literal.
*/
export function rewriteMediaPlaceholders(
text: string,
store: ImageAttachmentStore,
style: MediaReferenceStyle = 'tag',
): MediaTagRewriteResult {
const imageAttachmentIds: number[] = [];
const videoAttachmentIds: number[] = [];
let cursor = 0;
let out = '';
PLACEHOLDER_REGEX.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = PLACEHOLDER_REGEX.exec(text)) !== null) {
const [literal, kind, idStr] = match;
if (kind !== 'image' && kind !== 'video') continue;
if (idStr === undefined) continue;
const id = Number.parseInt(idStr, 10);
const attachment = store.get(id);
if (attachment === undefined) continue; // stale / user-typed — leave as text
if (attachment.kind !== kind) continue;
out += text.slice(cursor, match.index);
if (attachment.kind === 'video') {
const path = materializeVideoToCache(attachment, style === 'plain');
out += style === 'plain' ? formatMediaReference('video', path) : formatMediaTag('video', path);
videoAttachmentIds.push(id);
} else {
const path = materializeImageToCache(attachment);
out += style === 'plain' ? formatMediaReference('image', path) : formatMediaTag('image', path);
imageAttachmentIds.push(id);
}
cursor = match.index + literal.length;
}
const hasMedia = imageAttachmentIds.length + videoAttachmentIds.length > 0;
return {
text: hasMedia ? out + text.slice(cursor) : text,
hasMedia,
imageAttachmentIds,
videoAttachmentIds,
};
}
function pushText(parts: PromptPart[], segment: string): void { function pushText(parts: PromptPart[], segment: string): void {
if (segment.length === 0) return; if (segment.length === 0) return;
// Keep whitespace-only segments only when they sit between non-empty // Keep whitespace-only segments only when they sit between non-empty
@ -123,14 +195,38 @@ function imagePartForAttachment(att: ImageAttachment): PromptPart {
}; };
} }
function materializeVideoToCache(att: VideoAttachment): string { function materializeVideoToCache(att: VideoAttachment, escapeProofName = false): string {
const cacheDir = getCacheDir(); const cacheDir = getCacheDir();
mkdirSync(cacheDir, { recursive: true }); mkdirSync(cacheDir, { recursive: true });
const target = join(cacheDir, `${randomUUID()}-${att.label}`); // The label permits XML boundary chars (`<>&"`); plain references go
// through skill-arg escaping, where they would no longer match the file
// on disk, so strip them from the cache name in that mode.
const label = escapeProofName ? att.label.replaceAll(/[<>&"]/g, '_') : att.label;
const target = join(cacheDir, `${randomUUID()}-${label}`);
copyFileSync(att.sourcePath, target); copyFileSync(att.sourcePath, target);
return target; return target;
} }
const IMAGE_MIME_EXTENSION: Readonly<Record<string, string>> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/gif': 'gif',
'image/webp': 'webp',
'image/bmp': 'bmp',
'image/tiff': 'tif',
};
function materializeImageToCache(att: ImageAttachment): string {
const cacheDir = getCacheDir();
mkdirSync(cacheDir, { recursive: true });
// ReadMediaFile sniffs the real format from the bytes, so the extension
// only needs to be a reasonable hint.
const ext = IMAGE_MIME_EXTENSION[att.mime.trim().toLowerCase()] ?? 'img';
const target = join(cacheDir, `${randomUUID()}.${ext}`);
writeFileSync(target, att.bytes);
return target;
}
function captionForCompressedImage(att: ImageAttachment): string { function captionForCompressedImage(att: ImageAttachment): string {
const original = att.original; const original = att.original;
if (original === undefined) return ''; if (original === undefined) return '';
@ -155,6 +251,16 @@ function formatMediaTag(tag: 'image' | 'video', path: string): string {
return `<${tag} path="${escapeAttribute(path)}"></${tag}>`; return `<${tag} path="${escapeAttribute(path)}"></${tag}>`;
} }
/**
* Plain-text media reference for channels that XML-escape args (`/skill`).
* Free of `& < > "` (UUID image names; boundary chars stripped from video
* cache names see materializeVideoToCache) so it survives
* `escapeXml`/`escapeXmlTags` untouched.
*/
function formatMediaReference(kind: 'image' | 'video', path: string): string {
return `Attached ${kind} file: ${path} (open it with ReadMediaFile)`;
}
function escapeAttribute(value: string): string { function escapeAttribute(value: string): string {
return value return value
.replaceAll('&', '&amp;') .replaceAll('&', '&amp;')

View file

@ -17,6 +17,8 @@ export interface RefreshProviderHost {
removeProvider(providerId: string): Promise<KimiConfig>; removeProvider(providerId: string): Promise<KimiConfig>;
setConfig(patch: KimiConfigPatch): Promise<KimiConfig>; setConfig(patch: KimiConfigPatch): Promise<KimiConfig>;
resolveOAuthToken(providerName: string, oauthRef?: OAuthRef): Promise<string>; resolveOAuthToken(providerName: string, oauthRef?: OAuthRef): Promise<string>;
/** Product User-Agent sent on custom-registry (api.json) fetches. */
readonly userAgent?: string;
} }
export type { ProviderChange, RefreshProviderOptions, RefreshProviderScope, RefreshResult }; export type { ProviderChange, RefreshProviderOptions, RefreshProviderScope, RefreshResult };
@ -37,6 +39,7 @@ export async function refreshAllProviderModels(
setConfig: (patch) => host.setConfig(patch as unknown as KimiConfigPatch), setConfig: (patch) => host.setConfig(patch as unknown as KimiConfigPatch),
resolveOAuthToken: (providerName, oauthRef) => resolveOAuthToken: (providerName, oauthRef) =>
host.resolveOAuthToken(providerName, oauthRef as unknown as OAuthRef), host.resolveOAuthToken(providerName, oauthRef as unknown as OAuthRef),
userAgent: host.userAgent,
}, },
options, options,
); );

View file

@ -46,6 +46,12 @@ describe('parseHeadlessGoalCreate', () => {
expect(parseHeadlessGoalCreate('/goal status')).toBeUndefined(); expect(parseHeadlessGoalCreate('/goal status')).toBeUndefined();
expect(parseHeadlessGoalCreate('/goal pause')).toBeUndefined(); expect(parseHeadlessGoalCreate('/goal pause')).toBeUndefined();
}); });
it('rejects malformed goal create prompts instead of falling through', () => {
expect(() => parseHeadlessGoalCreate(`/goal ${'x'.repeat(4001)}`)).toThrow(
'Goal objective is too long',
);
});
}); });
describe('goal summary', () => { describe('goal summary', () => {
@ -86,6 +92,7 @@ const mocks = vi.hoisted(() => {
getStatus: vi.fn(async () => ({ permission: 'auto', model: 'k2' })), getStatus: vi.fn(async () => ({ permission: 'auto', model: 'k2' })),
createGoal: vi.fn(async () => snapshot({ status: 'active' })), createGoal: vi.fn(async () => snapshot({ status: 'active' })),
getGoal: vi.fn(async () => ({ goal: snapshot({ status: 'complete' }) })), getGoal: vi.fn(async () => ({ goal: snapshot({ status: 'complete' }) })),
getCronTasks: vi.fn(async () => ({ tasks: [] })),
onEvent: vi.fn((handler: (event: any) => void) => { onEvent: vi.fn((handler: (event: any) => void) => {
eventHandlers.add(handler); eventHandlers.add(handler);
return () => eventHandlers.delete(handler); return () => eventHandlers.delete(handler);
@ -97,6 +104,7 @@ const mocks = vi.hoisted(() => {
handler(mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); handler(mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' }));
} }
}), }),
waitForBackgroundTasksOnPrint: vi.fn(async () => {}),
}; };
return { return {
session, session,
@ -160,15 +168,24 @@ describe('runPrompt headless goal mode', () => {
let savedExitCode: typeof process.exitCode; let savedExitCode: typeof process.exitCode;
beforeEach(() => { beforeEach(() => {
// Pin the experimental engine flag off so runPrompt stays on the v1 path
// this suite mocks, regardless of the host environment (matches
// run-prompt.test.ts). With the flag on, runPrompt dispatches to the
// native v2 runner, which ignores these mocks and hangs the test.
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '');
savedExitCode = process.exitCode; savedExitCode = process.exitCode;
mocks.experimentalFeatures = [{ id: 'micro_compaction', enabled: true }]; mocks.experimentalFeatures = [{ id: 'micro_compaction', enabled: true }];
mocks.sessions = []; mocks.sessions = [];
mocks.session.createGoal.mockClear(); mocks.session.createGoal.mockClear();
mocks.session.prompt.mockClear();
mocks.session.waitForBackgroundTasksOnPrint.mockClear();
mocks.session.getStatus.mockResolvedValue({ permission: 'auto', model: 'k2' } as never); mocks.session.getStatus.mockResolvedValue({ permission: 'auto', model: 'k2' } as never);
mocks.session.getGoal.mockResolvedValue({ goal: snapshot({ status: 'complete' }) } as never); mocks.session.getGoal.mockResolvedValue({ goal: snapshot({ status: 'complete' }) } as never);
mocks.session.getCronTasks.mockResolvedValue({ tasks: [] } as never);
}); });
afterEach(() => { afterEach(() => {
vi.unstubAllEnvs();
process.exitCode = savedExitCode; process.exitCode = savedExitCode;
}); });
@ -243,6 +260,113 @@ describe('runPrompt headless goal mode', () => {
expect(mocks.session.prompt).toHaveBeenCalledWith('Ship feature X'); expect(mocks.session.prompt).toHaveBeenCalledWith('Ship feature X');
}); });
it('keeps listening across continuation turns until the goal is terminal', async () => {
const active = snapshot({ status: 'active', turnsUsed: 1, tokensUsed: 80 });
const completed = snapshot({ status: 'complete', turnsUsed: 2, tokensUsed: 160 });
mocks.session.getGoal.mockResolvedValueOnce({ goal: active } as never);
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {
handler(mocks.mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }));
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 1, delta: '1' }));
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' }));
}
await Promise.resolve();
for (const handler of mocks.eventHandlers) {
handler(
mocks.mainEvent({
type: 'turn.started',
turnId: 2,
origin: { kind: 'system_trigger', name: 'goal_continuation' },
}),
);
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 2, delta: '2' }));
handler(
mocks.mainEvent({
type: 'goal.updated',
snapshot: completed,
change: { kind: 'completion', status: 'complete' },
}),
);
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 2, reason: 'completed' }));
}
});
const stdout = writer();
const stderr = writer();
await runPrompt(opts(), 'test', {
stdout,
stderr,
process: { once: () => {}, off: () => {}, exit: () => undefined as never },
});
expect(stdout.text()).toBe('• 1\n\n• 2\n\n');
expect(stderr.text()).toContain('Goal [complete]');
expect(stderr.text()).toContain('turns: 2');
});
it('ignores stale goal checks once a continuation turn has started', async () => {
const completed = snapshot({ status: 'complete', turnsUsed: 2, tokensUsed: 160 });
let resolveFirstGoal: ((value: { goal: null }) => void) | undefined;
const firstGoal = new Promise<{ goal: null }>((resolve) => {
resolveFirstGoal = resolve;
});
mocks.session.getGoal
.mockImplementationOnce(() => firstGoal as never)
.mockResolvedValue({ goal: null } as never);
mocks.session.prompt.mockImplementationOnce(async () => {
const emit = (event: Record<string, unknown>) => {
for (const handler of [...mocks.eventHandlers]) {
handler(mocks.mainEvent(event));
}
};
emit({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } });
emit({ type: 'assistant.delta', turnId: 1, delta: '1' });
emit({ type: 'turn.ended', turnId: 1, reason: 'completed' });
emit({
type: 'turn.started',
turnId: 2,
origin: { kind: 'system_trigger', name: 'goal_continuation' },
});
emit({ type: 'assistant.delta', turnId: 2, delta: '2' });
emit({
type: 'goal.updated',
snapshot: completed,
change: { kind: 'completion', status: 'complete' },
});
resolveFirstGoal?.({ goal: null });
await Promise.resolve();
emit({ type: 'assistant.delta', turnId: 2, delta: ' tail' });
emit({ type: 'turn.ended', turnId: 2, reason: 'completed' });
});
const stdout = writer();
const stderr = writer();
await runPrompt(opts(), 'test', {
stdout,
stderr,
process: { once: () => {}, off: () => {}, exit: () => undefined as never },
});
expect(stdout.text()).toBe('• 1\n\n• 2 tail\n\n');
expect(stderr.text()).toContain('Goal [complete]');
});
it('does not send an invalid goal create prompt as a normal prompt', async () => {
const stdout = writer();
const stderr = writer();
await expect(
runPrompt(opts({ prompt: `/goal ${'x'.repeat(4001)}` }), 'test', {
stdout,
stderr,
process: { once: () => {}, off: () => {}, exit: () => undefined as never },
}),
).rejects.toThrow('Goal objective is too long');
expect(mocks.session.createGoal).not.toHaveBeenCalled();
expect(mocks.session.prompt).not.toHaveBeenCalled();
});
it('validates the resumed session model before creating a headless goal', async () => { it('validates the resumed session model before creating a headless goal', async () => {
mocks.sessions = [{ id: 'ses_goal', workDir: process.cwd() }]; mocks.sessions = [{ id: 'ses_goal', workDir: process.cwd() }];
mocks.session.getStatus.mockResolvedValueOnce({ permission: 'auto', model: '' } as never); mocks.session.getStatus.mockResolvedValueOnce({ permission: 'auto', model: '' } as never);

View file

@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { createProgram } from '#/cli/commands'; import { createProgram } from '#/cli/commands';
import type { CLIOptions } from '#/cli/options'; import type { CLIOptions } from '#/cli/options';
import { OptionConflictError, validateOptions } from '#/cli/options'; import { OptionConflictError, OUTPUT_FORMAT_ENV, resolveOutputFormat, validateOptions } from '#/cli/options';
function parse(argv: string[]): CLIOptions { function parse(argv: string[]): CLIOptions {
let captured: CLIOptions | undefined; let captured: CLIOptions | undefined;
@ -303,6 +303,84 @@ describe('CLI options parsing', () => {
}); });
}); });
describe('KIMI_MODEL_OUTPUT_FORMAT', () => {
it('defaults to text when unset in prompt mode', () => {
expect(resolveOutputFormat({ prompt: 'run this', outputFormat: undefined }, {})).toBe('text');
});
it('uses stream-json from the env in prompt mode', () => {
expect(
resolveOutputFormat(
{ prompt: 'run this', outputFormat: undefined },
{ [OUTPUT_FORMAT_ENV]: 'stream-json' },
),
).toBe('stream-json');
});
it('uses text from the env in prompt mode', () => {
expect(
resolveOutputFormat(
{ prompt: 'run this', outputFormat: undefined },
{ [OUTPUT_FORMAT_ENV]: 'text' },
),
).toBe('text');
});
it('trims surrounding whitespace from the env value', () => {
expect(
resolveOutputFormat(
{ prompt: 'run this', outputFormat: undefined },
{ [OUTPUT_FORMAT_ENV]: ' stream-json ' },
),
).toBe('stream-json');
});
it('lets the --output-format flag override the env', () => {
expect(
resolveOutputFormat(
{ prompt: 'run this', outputFormat: 'text' },
{ [OUTPUT_FORMAT_ENV]: 'stream-json' },
),
).toBe('text');
});
it('ignores the env outside prompt mode', () => {
expect(
resolveOutputFormat(
{ prompt: undefined, outputFormat: undefined },
{ [OUTPUT_FORMAT_ENV]: 'stream-json' },
),
).toBe('text');
});
it('rejects an invalid env value', () => {
expect(() =>
resolveOutputFormat(
{ prompt: 'run this', outputFormat: undefined },
{ [OUTPUT_FORMAT_ENV]: 'json' },
),
).toThrow(OptionConflictError);
expect(() =>
resolveOutputFormat(
{ prompt: 'run this', outputFormat: undefined },
{ [OUTPUT_FORMAT_ENV]: 'json' },
),
).toThrow('Invalid KIMI_MODEL_OUTPUT_FORMAT value "json"');
});
it('fails validation fast for an invalid env value in prompt mode', () => {
const opts = parse(['-p', 'run this']);
expect(() => validateOptions(opts, { [OUTPUT_FORMAT_ENV]: 'json' })).toThrow(
OptionConflictError,
);
});
it('does not validate the env outside prompt mode', () => {
const opts = parse([]);
expect(() => validateOptions(opts, { [OUTPUT_FORMAT_ENV]: 'json' })).not.toThrow();
});
});
describe('--skills-dir', () => { describe('--skills-dir', () => {
it('collects repeated skill directories', () => { it('collects repeated skill directories', () => {
expect(parse(['--skills-dir', '/one', '--skills-dir=/two']).skillsDirs).toEqual([ expect(parse(['--skills-dir', '/one', '--skills-dir=/two']).skillsDirs).toEqual([

View file

@ -1,5 +1,5 @@
import type { createKimiDeviceId as createKimiDeviceIdFn } from '@moonshot-ai/kimi-code-oauth'; import type { createKimiDeviceId as createKimiDeviceIdFn } from '@moonshot-ai/kimi-code-oauth';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { runPrompt } from '#/cli/run-prompt'; import { runPrompt } from '#/cli/run-prompt';
import { PROMPT_CLEANUP_TIMEOUT_MS } from '#/constant/app'; import { PROMPT_CLEANUP_TIMEOUT_MS } from '#/constant/app';
@ -40,6 +40,9 @@ const mocks = vi.hoisted(() => {
} }
}), }),
waitForBackgroundTasksOnPrint: vi.fn(async () => {}), waitForBackgroundTasksOnPrint: vi.fn(async () => {}),
getGoal: vi.fn(async () => ({ goal: null })),
getCronTasks: vi.fn(async () => ({ tasks: [] })),
handlePrintMainTurnCompleted: vi.fn(async (): Promise<'finish' | 'continue'> => 'finish'),
}; };
return { return {
@ -64,6 +67,42 @@ const mocks = vi.hoisted(() => {
harnessClose: vi.fn(), harnessClose: vi.fn(),
harnessTrack: vi.fn(), harnessTrack: vi.fn(),
harnessGetCachedAccessToken: vi.fn(), harnessGetCachedAccessToken: vi.fn(),
runV2Print: vi.fn(
async (
opts: { readonly outputFormat?: string },
version: string,
io?: {
readonly stdout?: { write(chunk: string): boolean };
readonly stderr?: { write(chunk: string): boolean };
},
) => {
// Mirror the native runner's output protocol so the version-banner
// assertions stay meaningful: version first, then the assistant
// message, then the resume hint — in the active output format.
const stdout = io?.stdout ?? process.stdout;
const stderr = io?.stderr ?? process.stderr;
const outputFormat = opts?.outputFormat ?? 'text';
if (outputFormat === 'stream-json') {
stdout.write(
`${JSON.stringify({ role: 'meta', type: 'system.version', version })}\n`,
);
stdout.write(`${JSON.stringify({ role: 'assistant', content: 'hello world' })}\n`);
stdout.write(
`${JSON.stringify({
role: 'meta',
type: 'session.resume_hint',
session_id: 'ses_prompt',
command: 'kimi -r ses_prompt',
content: 'To resume this session: kimi -r ses_prompt',
})}\n`,
);
return;
}
stderr.write(`kimi version ${version}\n`);
stdout.write('• hello world\n\n');
stderr.write('To resume this session: kimi -r ses_prompt\n');
},
),
initializeTelemetry: vi.fn(), initializeTelemetry: vi.fn(),
setCrashPhase: vi.fn(), setCrashPhase: vi.fn(),
shutdownTelemetry: vi.fn(), shutdownTelemetry: vi.fn(),
@ -126,6 +165,14 @@ vi.mock('@moonshot-ai/kimi-telemetry', () => ({
withTelemetryContext: mocks.withTelemetryContext, withTelemetryContext: mocks.withTelemetryContext,
})); }));
// The experimental v2 engine is loaded via a dynamic import from run-prompt.ts
// when KIMI_CODE_EXPERIMENTAL_FLAG is set. Mock the native v2 runner so tests
// that flip that flag can exercise the dispatch without pulling in the real
// agent-core-v2 graph.
vi.mock('../../src/cli/v2/run-v2-print', () => ({
runV2Print: mocks.runV2Print,
}));
function opts(overrides: Partial<Parameters<typeof runPrompt>[0]> = {}) { function opts(overrides: Partial<Parameters<typeof runPrompt>[0]> = {}) {
return { return {
session: undefined, session: undefined,
@ -185,8 +232,17 @@ async function waitForAssertion(assertion: () => void): Promise<void> {
} }
describe('runPrompt', () => { describe('runPrompt', () => {
beforeEach(() => {
// Pin the experimental engine flag off so the default v1 path is
// deterministic regardless of the host environment. Tests that exercise the
// experimental path opt back in explicitly with `vi.stubEnv(..., '1')`.
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '');
vi.stubEnv('KIMI_MODEL_OUTPUT_FORMAT', '');
});
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.unstubAllEnvs();
mocks.eventHandlers.clear(); mocks.eventHandlers.clear();
mocks.createKimiDeviceId.mockImplementation(() => 'device-1'); mocks.createKimiDeviceId.mockImplementation(() => 'device-1');
mocks.resolveKimiHome.mockImplementation( mocks.resolveKimiHome.mockImplementation(
@ -666,6 +722,59 @@ describe('runPrompt', () => {
); );
}); });
it('emits a stream-json meta line on retry and discards the failed attempt output', async () => {
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {
handler(mocks.mainEvent({ type: 'turn.started', turnId: 10, origin: { kind: 'user' } }));
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 10, delta: 'partial attempt' }));
handler(
mocks.mainEvent({
type: 'turn.step.retrying',
turnId: 10,
step: 1,
stepId: 'step-uuid',
failedAttempt: 1,
nextAttempt: 2,
maxAttempts: 3,
delayMs: 300,
errorName: 'APIProviderRateLimitError',
errorMessage: 'llmproxy/openai/responses/resp_abc.json status_code=429',
statusCode: 429,
}),
);
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 10, delta: 'final answer' }));
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 10, reason: 'completed' }));
}
});
const stdout = writer();
const stderr = writer();
await runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { stdout, stderr });
const retryMeta = JSON.stringify({
role: 'meta',
type: 'turn.step.retrying',
failed_attempt: 1,
next_attempt: 2,
max_attempts: 3,
delay_ms: 300,
error_name: 'APIProviderRateLimitError',
error_message: 'llmproxy/openai/responses/resp_abc.json status_code=429',
status_code: 429,
});
expect(stdout.text()).toBe(
[
retryMeta,
'{"role":"assistant","content":"final answer"}',
'{"role":"meta","type":"session.resume_hint","session_id":"ses_prompt","command":"kimi -r ses_prompt","content":"To resume this session: kimi -r ses_prompt"}',
'',
].join('\n'),
);
// The failed attempt's partial text must not leak as an assistant line.
expect(stdout.text()).not.toContain('partial attempt');
expect(stderr.text()).toBe('');
});
it('flushes stream-json assistant output before waiting for background tasks', async () => { it('flushes stream-json assistant output before waiting for background tasks', async () => {
let releaseWait: () => void = () => {}; let releaseWait: () => void = () => {};
const waitGate = new Promise<void>((resolve) => { const waitGate = new Promise<void>((resolve) => {
@ -697,6 +806,56 @@ describe('runPrompt', () => {
await runPromise; await runPromise;
}); });
it('follows a background-steered second main turn before finishing in steer mode', async () => {
// First end-of-turn: stay alive (a background task is still pending).
// Second end-of-turn: finish.
mocks.session.handlePrintMainTurnCompleted
.mockResolvedValueOnce('continue')
.mockResolvedValueOnce('finish');
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {
handler(mocks.mainEvent({ type: 'turn.started', turnId: 10, origin: { kind: 'user' } }));
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 10, delta: 'first' }));
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 10, reason: 'completed' }));
}
});
const stdout = writer();
const stderr = writer();
const runPromise = runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', {
stdout,
stderr,
});
// The first turn's assistant message must be flushed and the end-of-turn
// policy consulted, while the run stays alive (action === 'continue').
await waitForAssertion(() => {
expect(mocks.session.handlePrintMainTurnCompleted).toHaveBeenCalledTimes(1);
expect(stdout.text()).toContain('{"role":"assistant","content":"first"}');
});
// Simulate a background-task completion steering the main agent into a new
// turn (the runtime does this via turn.steer; here we drive the events
// directly to verify the driver follows and finishes only after it).
for (const handler of mocks.eventHandlers) {
handler(
mocks.mainEvent({
type: 'turn.started',
turnId: 11,
origin: { kind: 'background_task' },
}),
);
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 11, delta: 'second' }));
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 11, reason: 'completed' }));
}
await runPromise;
expect(mocks.session.handlePrintMainTurnCompleted).toHaveBeenCalledTimes(2);
expect(stdout.text()).toContain('{"role":"assistant","content":"second"}');
});
it('resumes a concrete session without a configured default model', async () => { it('resumes a concrete session without a configured default model', async () => {
mocks.harnessGetConfig.mockResolvedValueOnce({ providers: {}, telemetry: true }); mocks.harnessGetConfig.mockResolvedValueOnce({ providers: {}, telemetry: true });
mocks.session.getStatus.mockResolvedValueOnce({ permission: 'manual', model: 'saved-model' }); mocks.session.getStatus.mockResolvedValueOnce({ permission: 'manual', model: 'saved-model' });
@ -988,7 +1147,13 @@ describe('runPrompt', () => {
mocks.mainEvent({ mocks.mainEvent({
type: 'turn.ended', type: 'turn.ended',
turnId: 2, turnId: 2,
reason: 'filtered', reason: 'failed',
error: {
code: 'provider.filtered',
message: 'Provider safety policy blocked the response.',
name: 'ProviderFilteredError',
retryable: false,
},
}), }),
); );
} }
@ -1024,4 +1189,213 @@ describe('runPrompt', () => {
const handler = mocks.session.setQuestionHandler.mock.calls[0]![0] as () => unknown; const handler = mocks.session.setQuestionHandler.mock.calls[0]![0] as () => unknown;
expect(handler()).toBeNull(); expect(handler()).toBeNull();
}); });
it('emits the version first in text mode when the experimental flag is enabled', async () => {
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '1');
const stdout = writer();
const stderr = writer();
await runPrompt(opts(), '1.2.3-test', { stdout, stderr });
// The experimental engine is selected and the version banner is the very
// first write, ahead of any assistant output or the resume hint.
expect(mocks.runV2Print).toHaveBeenCalled();
expect(mocks.kimiHarnessConstructor).not.toHaveBeenCalled();
expect(stderr.write).toHaveBeenNthCalledWith(1, 'kimi version 1.2.3-test\n');
expect(stderr.text().startsWith('kimi version 1.2.3-test\n')).toBe(true);
expect(stdout.text()).toBe('• hello world\n\n');
});
it('emits the version first in stream-json mode when the experimental flag is enabled', async () => {
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '1');
const stdout = writer();
const stderr = writer();
await runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', {
stdout,
stderr,
});
expect(mocks.runV2Print).toHaveBeenCalled();
expect(mocks.kimiHarnessConstructor).not.toHaveBeenCalled();
const lines = stdout.text().split('\n');
expect(lines[0]).toBe(
'{"role":"meta","type":"system.version","version":"1.2.3-test"}',
);
expect(stderr.text()).toBe('');
});
it('does not emit the version when the experimental flag is disabled', async () => {
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0');
const stdout = writer();
const stderr = writer();
await runPrompt(opts(), '1.2.3-test', { stdout, stderr });
expect(mocks.runV2Print).not.toHaveBeenCalled();
expect(mocks.kimiHarnessConstructor).toHaveBeenCalled();
expect(stderr.text()).not.toContain('kimi version');
});
it('does not settle on end_turn while a goal is still active', async () => {
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {
handler(mocks.mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }));
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 1, delta: 'created a goal' }));
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' }));
}
});
// First evaluation (after turn 1) sees an active goal; the continuation
// turn's evaluation sees the goal gone (completed → record cleared).
mocks.session.getGoal.mockResolvedValueOnce({ goal: { status: 'active' } } as never);
const stdout = writer();
const stderr = writer();
let settled = false;
const run = runPrompt(opts(), '1.2.3-test', { stdout, stderr }).then(() => {
settled = true;
});
await waitForAssertion(() => {
expect(mocks.session.getGoal).toHaveBeenCalledTimes(1);
});
expect(settled).toBe(false);
// The goal driver launches the continuation turn on its own; the run
// streams it and settles only once no goal is active anymore.
for (const handler of mocks.eventHandlers) {
handler(
mocks.mainEvent({
type: 'turn.started',
turnId: 2,
origin: { kind: 'system_trigger' },
}),
);
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 2, delta: 'goal work' }));
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 2, reason: 'completed' }));
}
await run;
expect(settled).toBe(true);
expect(stdout.text()).toContain('goal work');
});
it('settles when the goal reaches a terminal state between turns with no trailing turn.ended', async () => {
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {
handler(mocks.mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }));
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 1, delta: 'working' }));
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' }));
}
});
// Turn 1's evaluation sees the goal still active; the terminal
// goal.updated (e.g. the driver blocked it on a hard budget) arrives with
// no further turn.ended and must settle the run itself.
mocks.session.getGoal
.mockResolvedValueOnce({ goal: { status: 'active' } } as never)
.mockResolvedValue({ goal: { status: 'blocked' } } as never);
const stdout = writer();
const stderr = writer();
let settled = false;
const run = runPrompt(opts(), '1.2.3-test', { stdout, stderr }).then(() => {
settled = true;
});
await waitForAssertion(() => {
expect(mocks.session.getGoal).toHaveBeenCalledTimes(1);
});
expect(settled).toBe(false);
for (const handler of mocks.eventHandlers) {
handler(
mocks.mainEvent({
type: 'goal.updated',
snapshot: { status: 'blocked' },
change: { kind: 'blocked' },
}),
);
}
await run;
expect(settled).toBe(true);
});
it('does not settle on end_turn while a cron task is pending, then lets the fire drive a turn', async () => {
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {
handler(mocks.mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }));
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 1, delta: 'scheduled a reminder' }));
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' }));
}
});
// Turn 1 leaves a pending one-shot cron task; its fire steers turn 2, and
// by turn 2's evaluation the task has fired and been removed.
mocks.session.getCronTasks
.mockResolvedValueOnce({
tasks: [
{
id: '3f9a1c2e',
cron: '*/5 * * * *',
recurring: false,
createdAt: 1,
lastFiredAt: undefined,
nextFireAt: Date.now() + 60_000,
},
],
} as never)
.mockResolvedValue({ tasks: [] } as never);
const stdout = writer();
const stderr = writer();
let settled = false;
const run = runPrompt(opts(), '1.2.3-test', { stdout, stderr }).then(() => {
settled = true;
});
await waitForAssertion(() => {
expect(mocks.session.getCronTasks).toHaveBeenCalledTimes(1);
});
expect(settled).toBe(false);
// The cron fire steers a fresh turn; the run streams it and settles once
// no pending tasks remain.
for (const handler of mocks.eventHandlers) {
handler(
mocks.mainEvent({
type: 'turn.started',
turnId: 2,
origin: { kind: 'cron_job' },
}),
);
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 2, delta: 'cron ran' }));
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 2, reason: 'completed' }));
}
await run;
expect(settled).toBe(true);
expect(stdout.text()).toContain('cron ran');
});
it('does not wait for cron tasks whose expression has no future fire', async () => {
mocks.session.getCronTasks.mockResolvedValue({
tasks: [
{
id: '3f9a1c2e',
cron: '0 0 31 2 *',
recurring: true,
createdAt: 1,
lastFiredAt: undefined,
nextFireAt: null,
},
],
} as never);
const stdout = writer();
const stderr = writer();
await runPrompt(opts(), '1.2.3-test', { stdout, stderr });
expect(stdout.text()).toBe('• hello world\n\n');
expect(mocks.harnessClose).toHaveBeenCalled();
});
}); });

View file

@ -0,0 +1,288 @@
import { describe, expect, it, vi } from 'vitest';
import {
applyPrintBackgroundPolicy,
createPrintTurnEndings,
PrintSteeredTurnFailedError,
type PrintTurnEnding,
type PrintTurnEndings,
} from '#/cli/v2/run-v2-print';
function ending(
turnId: number,
reason: PrintTurnEnding['reason'] = 'completed',
): PrintTurnEnding {
return { type: 'turn.ended', turnId, reason };
}
interface ScriptedEntry {
readonly event: PrintTurnEnding;
/** Side effect applied when this entry is consumed (e.g. mutate pending). */
readonly apply?: () => void;
}
/**
* Scripted `PrintTurnEndings`: replays queued endings (honouring `skipTurnId`),
* then resolves `null` once the script is exhausted (the wait "timed out").
*/
function scriptedTurnEndings(entries: ScriptedEntry[]): PrintTurnEndings {
const queue = [...entries];
return {
next: async (_remainingMs: number, skipTurnId: number) => {
while (queue.length > 0) {
const entry = queue.shift()!;
if (entry.event.turnId === skipTurnId) continue;
entry.apply?.();
return entry.event;
}
return null;
},
};
}
describe('applyPrintBackgroundPolicy', () => {
it('exit returns immediately without draining or waiting', async () => {
const drain = vi.fn(async () => {});
const countPending = vi.fn(() => 1);
await applyPrintBackgroundPolicy({
mode: 'exit',
ceilingS: 60,
maxTurns: 50,
countPending,
drain,
turnEndings: scriptedTurnEndings([]),
skipTurnId: 1,
warn: () => {},
now: () => Date.now(),
});
expect(drain).not.toHaveBeenCalled();
expect(countPending).not.toHaveBeenCalled();
});
it('drain drains once and returns', async () => {
const drain = vi.fn(async () => {});
await applyPrintBackgroundPolicy({
mode: 'drain',
ceilingS: 60,
maxTurns: 50,
countPending: () => 1,
drain,
turnEndings: scriptedTurnEndings([]),
skipTurnId: 1,
warn: () => {},
now: () => Date.now(),
});
expect(drain).toHaveBeenCalledTimes(1);
});
it('steer returns once background tasks are quiescent', async () => {
let pending = 1;
const warn = vi.fn();
await applyPrintBackgroundPolicy({
mode: 'steer',
ceilingS: 60,
maxTurns: 50,
countPending: () => pending,
drain: async () => {},
turnEndings: scriptedTurnEndings([
// The main turn's own buffered ending is skipped.
{ event: ending(1) },
// A background task completed and steered a new turn; it finished and
// no tasks remain.
{ event: ending(2), apply: () => { pending = 0; } },
]),
skipTurnId: 1,
warn,
now: () => Date.now(),
});
expect(warn).not.toHaveBeenCalled();
});
it('steer finishes with a warning when max turns is reached', async () => {
const warn = vi.fn();
await applyPrintBackgroundPolicy({
mode: 'steer',
ceilingS: 60,
maxTurns: 2,
countPending: () => 1,
drain: async () => {},
turnEndings: scriptedTurnEndings([{ event: ending(2) }, { event: ending(3) }]),
skipTurnId: 1,
warn,
now: () => Date.now(),
});
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0]?.[0]).toContain('max turns');
});
it('steer finishes with a warning when the ceiling is reached', async () => {
let now = 0;
const warn = vi.fn();
await applyPrintBackgroundPolicy({
mode: 'steer',
ceilingS: 10,
maxTurns: 50,
countPending: () => 1,
drain: async () => {},
turnEndings: scriptedTurnEndings([
{ event: ending(2), apply: () => { now = 10_001; } },
]),
skipTurnId: 1,
warn,
now: () => now,
});
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0]?.[0]).toContain('ceiling');
});
it('steer returns when the wait times out with tasks still pending', async () => {
const warn = vi.fn();
await applyPrintBackgroundPolicy({
mode: 'steer',
ceilingS: 60,
maxTurns: 50,
countPending: () => 1,
drain: async () => {},
// Empty script: no further turn ends before the deadline.
turnEndings: scriptedTurnEndings([]),
skipTurnId: 1,
warn,
now: () => Date.now(),
});
expect(warn).not.toHaveBeenCalled();
});
it('steer throws when a steered turn does not complete', async () => {
await expect(
applyPrintBackgroundPolicy({
mode: 'steer',
ceilingS: 60,
maxTurns: 50,
countPending: () => 1,
drain: async () => {},
turnEndings: scriptedTurnEndings([
{
event: {
type: 'turn.ended',
turnId: 2,
reason: 'failed',
error: { code: 'provider.overloaded', message: 'try later' },
} as PrintTurnEnding,
},
]),
skipTurnId: 1,
warn: () => {},
now: () => Date.now(),
}),
).rejects.toThrow(PrintSteeredTurnFailedError);
});
it('waits for goal continuation turns before applying the mode', async () => {
let active = true;
let consumed = 0;
const drain = vi.fn(async () => {});
await applyPrintBackgroundPolicy({
mode: 'drain',
ceilingS: 60,
maxTurns: 50,
countPending: () => 0,
drain,
turnEndings: scriptedTurnEndings([
{ event: ending(2), apply: () => { consumed += 1; } },
{
event: ending(3),
apply: () => {
consumed += 1;
active = false;
},
},
]),
skipTurnId: 1,
warn: () => {},
now: () => Date.now(),
goalActive: () => active,
});
// Both continuation turns ended before the mode ('drain') ran.
expect(consumed).toBe(2);
expect(drain).toHaveBeenCalledTimes(1);
});
it('warns and returns when the goal wait hits the ceiling', async () => {
let now = 0;
const warn = vi.fn();
await applyPrintBackgroundPolicy({
mode: 'exit',
ceilingS: 10,
maxTurns: 50,
countPending: () => 0,
drain: async () => {},
// No continuation turn ever ends; the poll interval elapses each time.
turnEndings: {
next: async () => {
now = 10_001;
return null;
},
},
skipTurnId: 1,
warn,
now: () => now,
goalActive: () => true,
});
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0]?.[0]).toContain('goal wait ceiling');
});
it('exits the goal wait promptly when the goal settles without a turn ending', async () => {
let active = true;
const warn = vi.fn();
await applyPrintBackgroundPolicy({
mode: 'exit',
ceilingS: 3600,
maxTurns: 50,
countPending: () => 0,
drain: async () => {},
// Poll interval elapses; the goal settles (paused/blocked) mid-wait
// without producing a turn.ended.
turnEndings: {
next: async () => {
active = false;
return null;
},
},
skipTurnId: 1,
warn,
now: () => Date.now(),
goalActive: () => active,
});
expect(warn).not.toHaveBeenCalled();
});
});
describe('createPrintTurnEndings', () => {
it('buffers events pushed before next() and skips the given turn id', async () => {
const endings = createPrintTurnEndings();
endings.push(ending(1));
endings.push(ending(2));
await expect(endings.next(1000, 1)).resolves.toMatchObject({ turnId: 2 });
});
it('delivers a pushed event to a pending next()', async () => {
const endings = createPrintTurnEndings();
const pending = endings.next(1000, 1);
endings.push(ending(3));
await expect(pending).resolves.toMatchObject({ turnId: 3 });
});
it('resolves null when the remaining time elapses', async () => {
const endings = createPrintTurnEndings();
await expect(endings.next(5, 1)).resolves.toBeNull();
});
it('keeps waiting when only the skipped turn ends', async () => {
const endings = createPrintTurnEndings();
const pending = endings.next(1000, 1);
endings.push(ending(1));
endings.push(ending(4));
await expect(pending).resolves.toMatchObject({ turnId: 4 });
});
});

View file

@ -101,7 +101,7 @@ describe('`kimi server` lifecycle exits with ESERVICE_UNSUPPORTED on unsupported
// The remaining platforms fall through to the stub that throws // The remaining platforms fall through to the stub that throws
// `ServiceUnsupportedError` — pin that contract so a future addition // `ServiceUnsupportedError` — pin that contract so a future addition
// (freebsd, etc.) needs a deliberate decision instead of silently working. // (freebsd, etc.) needs a deliberate decision instead of silently working.
const { resolveServiceManager, ServiceUnsupportedError } = await import('@moonshot-ai/server'); const { resolveServiceManager, ServiceUnsupportedError } = await import('@moonshot-ai/kap-server');
const mgr = resolveServiceManager('freebsd'); const mgr = resolveServiceManager('freebsd');
await expect( await expect(
mgr.install({ host: '127.0.0.1', port: 58627, logLevel: 'info' }), mgr.install({ host: '127.0.0.1', port: 58627, logLevel: 'info' }),
@ -112,7 +112,7 @@ describe('`kimi server` lifecycle exits with ESERVICE_UNSUPPORTED on unsupported
describe('`kimi server` lifecycle handles unavailable service managers', () => { describe('`kimi server` lifecycle handles unavailable service managers', () => {
it('prints a friendly JSON error and exits 2', async () => { it('prints a friendly JSON error and exits 2', async () => {
const { ServiceUnavailableError } = await import('@moonshot-ai/server'); const { ServiceUnavailableError } = await import('@moonshot-ai/kap-server');
const program = new Command('kimi').exitOverride(); const program = new Command('kimi').exitOverride();
const server = program.command('server'); const server = program.command('server');
let stdout = ''; let stdout = '';

View file

@ -227,6 +227,10 @@ async function flushBackgroundInstall(): Promise<void> {
describe('runUpdatePreflight', () => { describe('runUpdatePreflight', () => {
beforeEach(() => { beforeEach(() => {
// Pin the experimental flag off so rollout gating is deterministic
// regardless of the host environment (the flag bypasses batch holds).
// Tests that exercise the bypass opt back in with `vi.stubEnv(..., '1')`.
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '');
mocks.readUpdateInstallState.mockResolvedValue(emptyUpdateInstallState()); mocks.readUpdateInstallState.mockResolvedValue(emptyUpdateInstallState());
mocks.writeUpdateInstallState.mockResolvedValue(undefined); mocks.writeUpdateInstallState.mockResolvedValue(undefined);
mocks.loadTuiConfig.mockResolvedValue(tuiConfig()); mocks.loadTuiConfig.mockResolvedValue(tuiConfig());

View file

@ -0,0 +1,277 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
IAgentGoalService,
IAgentLifecycleService,
IAgentPermissionModeService,
IAgentProfileService,
IAgentPromptService,
IAgentTaskService,
IAuthSummaryService,
IBootstrapService,
IConfigService,
IEventBus,
IFileSystemStorageService,
IOAuthToolkit,
ISessionIndex,
ISessionLifecycleService,
ISkillCatalogRuntimeOptions,
ITelemetryService,
type DomainEvent,
type ScopeSeed,
} from '@moonshot-ai/agent-core-v2';
import { runV2Print } from '../../src/cli/v2/run-v2-print';
const mocks = vi.hoisted(() => ({
bootstrap: vi.fn(),
ensureMainAgent: vi.fn(),
createKimiDefaultHeaders: vi.fn(() => ({})),
resolveKimiHome: vi.fn((homeDir?: string) => homeDir ?? '/tmp/kimi-code-test-home'),
createKimiDeviceId: vi.fn(() => 'device-1'),
}));
vi.mock('@moonshot-ai/agent-core-v2', async (importOriginal) => {
const actual = await importOriginal<typeof import('@moonshot-ai/agent-core-v2')>();
return {
...actual,
bootstrap: mocks.bootstrap,
ensureMainAgent: mocks.ensureMainAgent,
};
});
vi.mock('@moonshot-ai/kimi-code-oauth', async () => {
const actual = await vi.importActual<typeof import('@moonshot-ai/kimi-code-oauth')>(
'@moonshot-ai/kimi-code-oauth',
);
return {
...actual,
createKimiDefaultHeaders: mocks.createKimiDefaultHeaders,
createKimiDeviceId: mocks.createKimiDeviceId,
};
});
vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => {
const actual = await importOriginal<typeof import('@moonshot-ai/kimi-code-sdk')>();
return {
...actual,
resolveKimiHome: mocks.resolveKimiHome,
};
});
vi.mock('@moonshot-ai/kimi-telemetry', () => ({
initializeTelemetry: vi.fn(),
setCrashPhase: vi.fn(),
shutdownTelemetry: vi.fn(),
track: vi.fn(),
setTelemetryContext: vi.fn(),
withTelemetryContext: vi.fn(() => ({ track: vi.fn() })),
}));
interface FakeScope {
readonly id: string;
readonly accessor: { readonly get: (token: unknown) => unknown };
readonly dispose: ReturnType<typeof vi.fn>;
}
function fakeScope(id: string, services: Map<unknown, unknown>): FakeScope {
return {
id,
accessor: {
get: (token: unknown) => {
if (!services.has(token)) throw new Error(`unexpected service request: ${String(token)}`);
return services.get(token);
},
},
dispose: vi.fn(),
};
}
function writer() {
let text = '';
return {
write: vi.fn((chunk: string) => {
text += chunk;
return true;
}),
text: () => text,
};
}
function opts(overrides: Record<string, unknown> = {}) {
return {
session: undefined,
continue: false,
yolo: false,
auto: false,
plan: false,
model: undefined,
outputFormat: undefined,
prompt: 'say hello',
skillsDirs: [],
addDirs: [],
...overrides,
} as const;
}
function makeFakeHarness() {
// Native event listeners registered on the main agent's IEventBus; the turn
// emits a streaming assistant delta before completing.
const eventListeners = new Set<(event: DomainEvent) => void>();
const agentServices = new Map<unknown, unknown>([
[IAgentProfileService, { setModel: vi.fn(async () => ({ model: 'k2' })), getModel: () => 'k2' }],
[IAgentPermissionModeService, { mode: 'auto', setMode: vi.fn() }],
[IAuthSummaryService, { ensureReady: vi.fn(async () => {}) }],
[
IEventBus,
{
subscribe: vi.fn((handler: (event: DomainEvent) => void) => {
eventListeners.add(handler);
return { dispose: () => eventListeners.delete(handler) };
}),
},
],
[
IAgentPromptService,
{
enqueue: vi.fn(async () => {
// Emit a native assistant delta on the main agent bus, then complete.
for (const listener of [...eventListeners]) {
listener({ type: 'assistant.delta', turnId: 1, delta: 'hello world' } as DomainEvent);
}
return {
launched: Promise.resolve({
id: 1,
result: Promise.resolve({ type: 'completed' }),
}),
};
}),
},
],
[IAgentTaskService, { list: vi.fn(() => []) }],
[IAgentGoalService, { createGoal: vi.fn(), getGoal: vi.fn() }],
]);
const agent = fakeScope('main', agentServices);
const sessionServices = new Map<unknown, unknown>([
// drain enumerates agents; empty → no background work to wait on.
[IAgentLifecycleService, { list: vi.fn(() => []) }],
]);
const session = fakeScope('ses_v2', sessionServices);
const appServices = new Map<unknown, unknown>([
[
IConfigService,
{
ready: Promise.resolve(),
get: vi.fn((section: string) => (section === 'defaultModel' ? 'k2' : undefined)),
diagnostics: vi.fn(() => []),
},
],
[
ISessionLifecycleService,
{
create: vi.fn(async () => session),
resume: vi.fn(async () => session),
},
],
[ISessionIndex, { list: vi.fn(async () => ({ items: [] })) }],
[
IBootstrapService,
{
platform: 'linux',
arch: 'x64',
clientVersion: '1.2.3-test',
getEnv: () => undefined,
},
],
[IOAuthToolkit, { getCachedAccessToken: vi.fn(async () => undefined) }],
[IFileSystemStorageService, {}],
[
ITelemetryService,
(() => {
const svc = {
setAppender: vi.fn(),
setContext: vi.fn(),
track: vi.fn(),
track2: vi.fn(),
shutdown: vi.fn(async () => {}),
withContext: vi.fn(() => svc),
};
return svc;
})(),
],
]);
const app = fakeScope('app', appServices);
return { app, agent, session, agentServices };
}
describe('runV2Print', () => {
beforeEach(() => {
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '1');
vi.stubEnv('KIMI_MODEL_OUTPUT_FORMAT', '');
});
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
});
it('submits a prompt, renders native events, awaits completion, and drains', async () => {
const stdout = writer();
const stderr = writer();
const { app, agent, agentServices } = makeFakeHarness();
mocks.bootstrap.mockReturnValue({ app });
mocks.ensureMainAgent.mockResolvedValue(agent);
await runV2Print(opts() as never, '1.2.3-test', { stdout, stderr });
const promptService = agentServices.get(IAgentPromptService) as { enqueue: ReturnType<typeof vi.fn> };
expect(promptService.enqueue).toHaveBeenCalledWith({
message: {
role: 'user',
content: [{ type: 'text', text: 'say hello' }],
toolCalls: [],
origin: { kind: 'user' },
},
});
// Version banner is first, then the rendered assistant output.
expect(stderr.write).toHaveBeenNthCalledWith(1, 'kimi version 1.2.3-test\n');
expect(stdout.text()).toContain('hello world');
expect(app.dispose).toHaveBeenCalled();
});
it('seeds explicit skill dirs from --skillsDir into bootstrap', async () => {
const stdout = writer();
const stderr = writer();
const { app, agent } = makeFakeHarness();
mocks.bootstrap.mockReturnValue({ app });
mocks.ensureMainAgent.mockResolvedValue(agent);
await runV2Print(opts({ skillsDirs: ['/skills'] }) as never, '1.2.3-test', {
stdout,
stderr,
});
const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed;
const seeded = seeds.find(([id]) => id === ISkillCatalogRuntimeOptions);
expect(seeded?.[1]).toMatchObject({ explicitDirs: ['/skills'] });
});
it('leaves the skill runtime options unseeded when --skillsDir is empty', async () => {
const stdout = writer();
const stderr = writer();
const { app, agent } = makeFakeHarness();
mocks.bootstrap.mockReturnValue({ app });
mocks.ensureMainAgent.mockResolvedValue(agent);
await runV2Print(opts() as never, '1.2.3-test', { stdout, stderr });
const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed;
expect(seeds.some(([id]) => id === ISkillCatalogRuntimeOptions)).toBe(false);
});
});

View file

@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest';
import { import {
buildKimiDefaultHeaders, buildKimiDefaultHeaders,
createKimiCodeUserAgent,
getHostPackageJsonPath, getHostPackageJsonPath,
getHostPackageRoot, getHostPackageRoot,
getVersion, getVersion,
@ -25,4 +26,8 @@ describe('cli version helpers', () => {
expect(headers['User-Agent']).toBe('kimi-code-cli/1.2.3'); expect(headers['User-Agent']).toBe('kimi-code-cli/1.2.3');
}); });
it('builds the product user-agent for ad-hoc fetches', () => {
expect(createKimiCodeUserAgent('1.2.3')).toBe('kimi-code-cli/1.2.3');
});
}); });

View file

@ -289,6 +289,87 @@ describe('plugins selector dialogs', () => {
expect(out).toContain('0 installed · 1 available'); expect(out).toContain('0 installed · 1 available');
}); });
it('renders the hardcoded Web Bridge entry on the Official tab while loading', () => {
const { panel } = makePanel({ initialTab: 'official' });
// The catalog is still loading, but the built-in Web Bridge entry is shown
// immediately because it is baked into the TUI, not fetched.
const out = strip(renderRaw(panel));
expect(out).toContain('Kimi WebBridge open in browser');
expect(out).toContain('Loading marketplace');
});
it('keeps the Web Bridge entry visible when the Official catalog errors', () => {
const { panel } = makePanel({ initialTab: 'official' });
panel.setMarketplaceError('fetch failed');
const out = strip(renderRaw(panel));
expect(out).toContain('Kimi WebBridge open in browser');
expect(out).toContain('Marketplace unavailable: fetch failed');
});
it('opens the Web Bridge webpage on Enter instead of installing', () => {
const { panel, onSelect } = makePanel({ initialTab: 'official' });
panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json');
// Web Bridge is pinned at index 0, so Enter selects it directly.
panel.handleInput('\r');
expect(onSelect).toHaveBeenCalledWith({
kind: 'open-url',
url: 'https://www.kimi.com/features/webbridge#local-agent',
label: 'Kimi WebBridge',
});
});
it('installs a catalog official entry after navigating past Web Bridge', () => {
const { panel, onSelect } = makePanel({ initialTab: 'official' });
panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json');
panel.handleInput('\u001B[B'); // ↓ → kimi-datasource
panel.handleInput('\r');
expect(onSelect).toHaveBeenCalledWith({
kind: 'install',
entry: expect.objectContaining({ id: 'kimi-datasource' }),
});
});
it('does not duplicate Web Bridge when the catalog also lists it', () => {
const entries = [
{
id: 'kimi-webbridge',
tier: 'official' as const,
displayName: 'Kimi WebBridge',
source: 'https://x/w.zip',
},
...officialEntries,
];
const { panel } = makePanel({ initialTab: 'official' });
panel.setMarketplace(entries, '/tmp/marketplace.json');
const out = strip(renderRaw(panel));
// The label should appear exactly once — the hardcoded row wins, the
// catalog copy is filtered out.
expect(out.split('Kimi WebBridge').length - 1).toBe(1);
});
it('installs a Third-party entry whose id matches the pinned WebBridge', () => {
// A curated/custom marketplace entry can legitimately reuse the
// kimi-webbridge id; on the Third-party tab it must install normally, not
// open the WebBridge page (that shortcut is reserved for the pinned row).
const entries = [
{
id: 'kimi-webbridge',
tier: 'curated' as const,
displayName: 'Kimi WebBridge',
source: 'https://x/w.zip',
},
];
const { panel, onSelect } = makePanel({ initialTab: 'third-party' });
panel.setMarketplace(entries, '/tmp/marketplace.json');
const out = strip(renderRaw(panel));
expect(out).toContain('Kimi WebBridge install');
panel.handleInput('\r');
expect(onSelect).toHaveBeenCalledWith({
kind: 'install',
entry: expect.objectContaining({ id: 'kimi-webbridge', source: 'https://x/w.zip' }),
});
});
it('installs the selected Third-party entry on Enter', () => { it('installs the selected Third-party entry on Enter', () => {
const { panel, onSelect } = makePanel({ installed: [superpowers], initialTab: 'third-party' }); const { panel, onSelect } = makePanel({ installed: [superpowers], initialTab: 'third-party' });
panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json');

View file

@ -68,6 +68,44 @@ describe('status panel report lines', () => {
expect(output).not.toContain('Runtime'); expect(output).not.toContain('Runtime');
}); });
it('formats extra usage section in status report', () => {
const lines = buildStatusReportLines({
version: '1.2.3',
model: 'k2',
workDir: '/tmp/project',
sessionId: 'ses-1',
sessionTitle: null,
thinkingEffort: 'off',
permissionMode: 'manual',
planMode: false,
contextUsage: 0,
contextTokens: 0,
maxContextTokens: 0,
availableModels: {},
managedUsage: {
summary: null,
limits: [],
extraUsage: {
balanceCents: 15000,
totalCents: 20000,
monthlyChargeLimitEnabled: true,
monthlyChargeLimitCents: 20000,
monthlyUsedCents: 5000,
currency: 'USD',
},
},
}).map(strip);
const output = lines.join('\n');
expect(output).toContain('Extra Usage');
expect(output).toContain('Balance');
expect(output).toContain('150.00');
expect(output).toContain('Used this month');
expect(output).toContain('50.00');
expect(output).toContain('Monthly limit');
expect(output).toContain('200.00');
});
it('falls back to app state and shows status load errors as warnings', () => { it('falls back to app state and shows status load errors as warnings', () => {
const lines = buildStatusReportLines({ const lines = buildStatusReportLines({
version: '1.2.3', version: '1.2.3',

View file

@ -570,6 +570,34 @@ describe('ToolCallComponent', () => {
expect(header).toContain('Current plan · Approved: Pragmatic refactor'); expect(header).toContain('Current plan · Approved: Pragmatic refactor');
}); });
it('header chips Auto-approved when ExitPlanMode was auto-approved without user review', () => {
const component = new ToolCallComponent(
{
id: 'call_exit_auto',
name: 'ExitPlanMode',
args: {},
},
{
tool_call_id: 'call_exit_auto',
output:
'Exited plan mode. Plan mode deactivated. All tools are now available.\n' +
'Note: this plan was auto-approved without user review — the user has NOT explicitly approved it.\n' +
'Plan saved to: /tmp/plan.md\n\n' +
'## Plan (auto-approved, not user-reviewed):\n# Auto Plan\n\n1. Do the thing.',
is_error: false,
},
);
const out = strip(component.render(100).join('\n'));
const header = out.split('\n')[1] ?? '';
expect(header).toMatch(/Current plan · Auto-approved\s*$/);
// The plan body renders from the auto-approved marker; the engine-side
// note above the marker must not leak into the rendered plan box.
expect(out).toContain('Auto Plan');
expect(out).toContain('1. Do the thing.');
expect(out).not.toContain('Note: this plan was auto-approved');
});
it('renders Rejected in the plan box title and keeps revise feedback visible', () => { it('renders Rejected in the plan box title and keeps revise feedback visible', () => {
const component = new ToolCallComponent( const component = new ToolCallComponent(
{ {

Some files were not shown because too many files have changed in this diff Show more