Compare commits

..

520 commits

Author SHA1 Message Date
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
github-actions[bot]
93c0b7bb78
ci: release packages (#1507)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-08 23:18:00 +08:00
7Sageer
e83511a711
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.
2026-07-08 21:16:44 +08:00
qer
2394d013bc
docs(changelog): sync 0.23.2 from apps/kimi-code/CHANGELOG.md (#1496) 2026-07-08 17:02:00 +08:00
github-actions[bot]
67b2147d8e
ci: release packages (#1468)
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
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-08 15:37:29 +08:00
qer
b0809ddac8
feat(web): prefix skill slash commands with skill: to distinguish them from built-in commands (#1492) 2026-07-08 15:03:30 +08:00
qer
0cc9831a2f
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.
2026-07-08 14:53:29 +08:00
liruifengv
2ad0120c2a
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
2026-07-08 13:42:22 +08:00
qer
b30a45efec
feat(web): support Enter key to confirm archive and other dialogs (#1490) 2026-07-08 13:24:44 +08:00
qer
2206d21327
feat(plugins): add Vercel plugin to marketplace (#1489) 2026-07-08 12:22:28 +08:00
qer
f30781bb27
fix(kimi-code): exit 1 when a headless (-p) turn fails (#1483)
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
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.
2026-07-07 23:38:20 +08:00
STAR-QUAKE
9b76e5bff6
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>
2026-07-07 23:06:29 +08:00
Luyu Cheng
131700097a
fix: clarify goal blocked audit guidance (#1481) 2026-07-07 22:29:40 +08:00
_Kerman
6c9abe8cf7
feat(kosong): support structured response formats (#1397) 2026-07-07 22:02:29 +08:00
Luyu Cheng
150206a6f7
fix: count goal creation turn (#1477) 2026-07-07 21:51:29 +08:00
Kai
474ce289dd
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.
2026-07-07 21:38:43 +08:00
qer
11c6a37ce0
fix(web): dismiss stale connection error toast after ws reconnect (#1474) 2026-07-07 20:47:56 +08:00
7Sageer
2065ae4615
fix(telemetry): gate unsafe numeric telemetry values (#1478)
- Drop numeric telemetry properties whose absolute value exceeds Number.MAX_SAFE_INTEGER before enqueue, and reject them again during payload assembly as a backstop.

- Omit constrained_memory_bytes when process.constrainedMemory() is not a safe non-negative integer so Linux no-cgroup sentinels do not overflow int64 parsing downstream.

- Add telemetry tests for unsafe numeric properties and constrained_memory_bytes filtering.
2026-07-07 20:32:02 +08:00
Luyu Cheng
d1a964fba9
fix: forbid model-driven goal pauses (#1476) 2026-07-07 20:02:43 +08:00
7Sageer
ebd25a4a55
chore(telemetry): sample system metrics every 5 minutes (#1472)
Raise SystemMetricsCollector's default sampling interval from 30s to 5min to reduce the frequency of system_metrics telemetry events.
2026-07-07 18:33:24 +08:00
liruifengv
ee385456d0
refactor(web): migrate icons to unplugin-icons (#1467)
* refactor(web): migrate icons to unplugin-icons

Replace the hand-written gen-icon-data.mjs + @iconify/utils runtime
rendering with unplugin-icons build-time imports. The public API
(<Icon name>, iconSvg, IconName, SIZE_PX, NAME_TO_REMIX) is unchanged;
127+ call sites are untouched.

- add unplugin-icons@^23.0.0 (devDep) + Vite Icons() plugin (compiler: vue3)
- rewrite src/lib/icons.ts: static ~icons/ri/* imports (component + ?raw)
  for 56 distinct Remix icons across 59 IconName entries
- Icon.vue renders <component :is> with unknown-name fallback
- append ICON_GROUPS export for DesignSystemView catalog
- DesignSystemView: v-for catalog, remove legacy-script references
- delete gen-icon-data.mjs, gen-icon-catalog.mjs, icon-data.ts,
  gen:icons script
- remove @iconify/vue and @iconify/utils from dependencies; move
  @iconify-json/ri to devDependencies
- drop Icon.vue from check-style ICON_EXEMPT (no hand-written <svg>)

* refactor(web): drop unused NAME_TO_REMIX icon mapping

NAME_TO_REMIX was a Record<IconName, string> table introduced to map
internal icon names to their ri: ids. After the unplugin-icons migration
it has no production consumers — only icons.test.ts imported it (for two
drift tests) and DesignSystemView mentioned it in descriptive copy. The
ICONS table already conveys the same ri: id via each entry's paired
component + ?raw imports (e.g. RiFolderOpenLine / RawFolderOpenLine).

- remove NAME_TO_REMIX const from src/lib/icons.ts (-63 lines)
- remove NAME_TO_REMIX import + describe block from icons.test.ts
- update DesignSystemView §02 copy: describe the import-pair idiom and
  stop claiming ICON_GROUPS is sourced from NAME_TO_REMIX

* chore: add changeset for web icon migration

* chore(nix): bump pnpmDeps hash for unplugin-icons

Adding unplugin-icons changed pnpm-lock.yaml, so the fixed-output
pnpmDeps derivation hash is stale. Update to the hash reported by the
Nix Build CI run.
2026-07-07 16:44:56 +08:00
qer
2a44ae075a
chore: update 0.23.1 changelog (#1469)
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-07 16:22:40 +08:00
liruifengv
063bce2a2f
fix(agent-core): hide console window when running hooks on Windows (#1466)
Pass windowsHide:true when spawning the hook process so a visible console
no longer flashes and steals focus on Windows. The Bash-tool path was
already hardened (KAOS buildLocalSpawnOptions); the hook runner missed the
flag even though its own taskkill helper already set it.

Extract the spawn options into a pure builder and add a regression test
asserting windowsHide, mirroring the existing KAOS spawn-options test.

Relates to #1298.
2026-07-07 15:55:47 +08:00
github-actions[bot]
08b3c3fc53
ci: release packages (#1444)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-07 15:36:15 +08:00
Luyu Cheng
e9ef9399d0
fix(agent-core): harden goal-mode budget and outcome flow (#1456) 2026-07-07 15:23:57 +08:00
liruifengv
bfdbce593f
feat(kosong): honor explicit anthropic max output override (#1465)
* feat(kosong): honor explicit anthropic max output override

Add claude-opus-4-8 output ceiling and treat explicit max_output_size/KIMI_MODEL_MAX_OUTPUT_SIZE as the final Anthropic max_tokens value. Sync configuration and environment variable docs.

* feat(kosong): drop claude-opus-4-8 ceiling

Revert the newly added claude-opus-4-8 default output ceiling while keeping explicit max_output_size overrides for Anthropic.
2026-07-07 15:23:42 +08:00
Luyu Cheng
03e78ae190
fix(kosong): fall back to nearest lower catalogued minor for max tokens (#1463)
* fix(kosong): fall back to nearest lower catalogued minor for Claude max tokens

An uncatalogued Claude minor version (e.g. claude-opus-4-8) previously
dropped straight to the family/major baseline ceiling, so Opus 4.8
resolved to 32k max output tokens instead of the 128k it supports.
Walk the minor version down to the nearest catalogued entry before
using the family baseline, so a newer minor inherits its predecessor's
documented ceiling.

* fix(kosong): catalogue Opus 4.8's documented 128k output ceiling

Known models should resolve from explicit table entries; the
nearest-lower-minor fallback now only covers minors that are not yet
catalogued. Fable 5 already has an explicit entry.
2026-07-07 15:11:12 +08:00
qer
928726ace1
chore: correct wire-request-trace changeset (#1464)
PR #1448 records a per-request trace in wire.jsonl for debugging, with
no user-facing behavior change. Downgrade the CLI bump from minor to
patch, drop the unpublished private internal packages (agent-core,
kosong), and trim the entry to a single honest sentence.
2026-07-07 14:59:19 +08:00
qer
16dc940834
fix(web): recover from stale background websocket (#1451) 2026-07-07 14:48:22 +08:00
qer
809a88cb34
fix(web): run new-session slash actions (skills, /goal, /btw) from the empty composer (#1445)
* fix(web): activate slash skills on the new-session screen

The composer now lists workspace skills before a session exists, but
activating one from the empty-session composer silently did nothing:
activateSkill() short-circuits when there is no active session id, so
the command was cleared with no turn started.

Mirror the first-prompt path: when a slash skill is activated with no
active session (but a workspace is active), create the session first and
activate on the new session id. Extract the shared session-creation
block out of startSessionAndSendPrompt into createDraftSession and add a
startSessionAndActivateSkill counterpart; activateSkill now also accepts
an explicit session id so a concurrent session switch can't redirect it.

Unrecognized '/cmd' text still falls through to a plain-text message as
before.

* fix(web): persist draft modes when a new session is opened via skill

The skill-activate request carries only `args`, so for a skill launched
from the new-session composer createDraftSession's local plan/swarm maps
never reached the daemon, and the first skill turn ran at default modes
while the UI showed them enabled.

Persist the draft plan/swarm modes to the new session's profile inside
createDraftSession (by the new session id), and teach persistSessionProfile
to take an explicit session id so a concurrent session switch during the
snapshot load can't write the patch to the wrong session. Plain prompts
already send planMode/swarmMode on the prompt request itself, so this is
redundant but harmless on the first-prompt path. Goal mode is a one-shot
flag consumed per send, not a profile field, so there is nothing to
persist for it.

Add coverage that the profile is persisted before activation when draft
plan/swarm modes are enabled.

* fix(web): await draft-mode profile POST before activating a skill

The previous fix wrote the draft plan/swarm modes to the new session's
profile but fire-and-forget: persistSessionProfile returned immediately
after starting POST /profile, so startSessionAndActivateSkill sent
:activate without waiting. Since skill activation carries only args, the
daemon could process :activate (and start the turn) before
applyAgentState from /profile finished, running the first skill turn at
default modes while the UI showed them enabled.

Make persistSessionProfile return the update promise and await it inside
createDraftSession, so any origin that follows (the skill activation, or
a plain prompt) only starts after the profile is applied. Existing
callers still fire-and-forget via `void persistSessionProfile(...)`.

Test coverage now blocks activation behind a deferred profile POST and
asserts it is issued only after the profile resolves.

* refactor(web): persist draft modes on the skill path only

Move the awaited plan/swarm profile write out of createDraftSession and
into startSessionAndActivateSkill. The create path now only builds the
session and mirrors draft modes into the per-session maps, matching the
old first-prompt behavior: plain prompts already send planMode/swarmMode
on the prompt request itself, so a /profile write there was redundant.

The profile write stays on the one path that needs it: skill activation
carries only args, so the draft modes must be stored on the new session
and applied before :activate is sent.

* fix(web): persist draft permission and thinking for new-session skill

/auto, /yolo, and /thinking on the new-session composer only update
rawState (there is no session to persist to yet). A plain first prompt
still honors them because submitPromptInternal sends permissionMode and
thinking on the request, but skill activation carries only args, so the
first skill turn was running at daemon defaults.

Include permissionMode and thinking in the awaited profile patch alongside
planMode/swarmMode before activating, so the skill turn matches the
controls the UI shows.

* fix(web): start /goal from the new-session composer

createGoal() short-circuited when there was no active session, so
`/goal <objective>` from the empty-session composer silently cleared and
ran nothing — the same bug class as the slash-skill activation fixed
earlier in this PR.

Mirror startSessionAndSendPrompt: when no session exists but a workspace
is active, create one first then target it with the goal profile update
and the objective prompt. Send via submitPromptInternal with the explicit
sid so a concurrent session switch during creation can't redirect it.
Plain prompts already carry their own permissionMode / thinking / plan /
swarm, so no profile fallback is needed here.

* fix(web): use active-workspace fallback for empty-composer /goal

On a fresh-booted empty workspace, load() never writes
rawState.activeWorkspaceId (no most-recent session to anchor it). The
UI still has a usable workspace via the client-wide activeWorkspaceId
computed, which falls back to the first sidebar-visible workspace — but
createGoal read the raw value directly and silently no-op'd when it was
null.

Normal first prompts and skill activations didn't hit this because
App.vue passes the computed activeWorkspaceId in. Make createGoal use
the same fallback so a first-session `/goal <objective>` works in empty
workspaces too.

* fix(web): start /btw from the new-session composer

openSideChat() reads rawState.activeSessionId directly, so `/btw
[<question>]` from the empty-session composer silently no-oped — it
still set detailTarget to 'btw', leaving the side chat panel open but
empty.

Add startSessionAndOpenSideChat(workspaceId, prompt?) that creates the
parent session first, then calls a new sideChat.openSideChatOn(sid,
prompt) which targets the explicit parent session id (race-safe against
a concurrent session switch, like the skill activation case). Route
through it from the empty-composer branch in openSideChatTab.

Side-chat prompts now also carry model / thinking / permissionMode /
plan / swarm (via a shared sendSideChatPromptOn), so a BTW first turn
matches the UI even when the parent /profile is still in flight. Unlike
skill activation, this means the BTW path needs no profile fallback.

Tests: startSessionAndOpenSideChat creates a session then opens BTW on
the new id, works without an initial question, and is a no-op for an
unknown workspace; sendSideChatPromptOn carries the runtime controls
on the submitted prompt.

* fix(web): preserve send queue when creating goals

Switching createGoal from sendPrompt to submitPromptInternal avoided the
activeSessionId race during the empty-composer create window, but it also
bypassed sendPrompt's queue guard: when a goal is created against an
already-active session that is running another turn, the prompt posted
immediately instead of being locally queued.

Restore the guard for the overwhelmingly common case (the goal still
targets the active session): route through sendPrompt when activeSessionId
still matches the resolved sid, which enqueues when the session is
running or a prompt is already in flight. Only fall back to the explicit-
session submitPromptInternal(sid) when activeSessionId moved during the
create window, so a concurrent session switch can't redirect the goal
prompt. The newly-created session is idle+not-in-flight in that branch,
so the explicit submit does not race another turn.

Add a regression test: createGoal against a running existing session
enqueues instead of submits.

* fix(web): coerce thinking for skill/BTW and handle goal creation failures

Three follow-ups from review:

- createGoal now wraps createDraftSession in a try/catch: App.vue invokes
  it fire-and-forget, so a rejection from session creation previously
  leaked as an unhandled rejection with no operation failure surfaced.
  Mirrors the skill / BTW / first-prompt paths that already wrap it.

- startSessionAndActivateSkill coerces the draft thinking level against
  the new session's model before persisting the profile (via
  coercePromptThinking), matching what the first-prompt path submits.
  A value carried over from another/default model (e.g. 'max' from an
  effort model) would otherwise be persisted verbatim and the first
  skill turn would run at a level the UI wouldn't send for this model.

- sendSideChatPromptOn coerces thinking against the parent session's
  model the same way normal prompts do (coerceThinkingForModel against
  the model catalog). Model catalog threaded in via a new 'models' dep
  on useSideChat. Same reasoning: stale rawState.thinking must not be
  submitted raw into a BTW first turn.

* fix(web): clear staged goal mode when submitting explicit /goal

When the empty composer has goal mode staged (e.g. the user runs bare
`/goal`, then `/goal <objective>`) and an explicit objective is then
submitted, createDraftSession copies draftModes.goalMode into
goalModeBySession[sid]. createGoal then updateSession(goalObjective) for
the explicit goal, and sendPrompt(trimmed) re-enters submitPromptInternal
which sees goalModeBySession[sid] still set and POSTs a second
goalObjective. The daemon rejects that as an existing goal, the catch in
submitPromptInternal rolls back the optimistic message, and the user's
objective prompt never lands.

Clear the staged goalModeBySession[sid] flag right after the explicit
update, since `/goal <objective>` has exactly the same effect as the
flag's consumption.
2026-07-07 14:18:40 +08:00
Kai
65d30177ad
feat(agent-core): record llm request trace in wire.jsonl (#1448)
* feat(agent-core): record llm request trace in wire.jsonl

Add three observability record types so every request sent to the model
can be reconstructed from the wire log at the logical-request level:

- llm.tools_snapshot: content-addressed snapshot of the top-level tools
  table as sent (post deferred-strip), written once per unique table
- llm.request: one record per outbound request (retries, strict resends,
  and compaction rounds included) carrying the effective request params
  and hash links to the system prompt and tools snapshot
- mcp.tools_discovered: the server's verbatim tools/list result plus the
  agent's gating (allow-list, collisions), deduplicated by content hash

Observability records never feed state rebuild; replay only restores the
write-dedup cursors. The records/types.ts contract now documents the two
record classes explicitly (persisted is not the same as replayed).

Recording happens at the single Agent.generate choke point. The
LLMRequestLogFields side channel gains kind/projection/maxTokens/
droppedCount, chatWithRetry preserves caller-set fields, and compaction
tags its requests. The vis wire view renders the new record kinds.

* fix(agent-core): record the provider-clamped completion cap in the request trace

The llm.request trace recorded the client-requested budget cap, but
chat-completions providers tighten the actual wire value inside
withMaxCompletionTokens (remaining-context sizing, transport ceilings,
model-default resolution) — with the default budget the clamp is active
on nearly every non-empty-context request, so the recorded value did not
match what was sent.

Providers now expose the effective cap they computed as a readonly
maxCompletionTokens field on the clone, and the recorder reads it from
the effective provider at the Agent.generate choke point. This replaces
the side-channel recomputation, which is removed along with the
appliedCompletionBudgetCap helper.

* fix(agent-core): park pre-replay MCP discovery records and hash the collision outcome

Two wire-hygiene fixes for the mcp.tools_discovered trace:

Parking: the real Session ordering connects MCP servers concurrently with
agent construction, so ToolManager can observe a connected server before
agent.resume() has replayed the wire. Recording at that point bypassed
the restored dedup cursor (duplicating a 1-50KB record on every resume)
and appended a stray metadata record ahead of replay. AgentRecords now
exposes a one-shot opened latch — set when replay completes (after the
migration rewrite flushes) or when the first live record is logged — and
ToolManager parks discoveries until then, re-running the dedup check at
drain time. A frozen range-limited replay never opens; those agents are
transient previews.

Collision hashing: the dedup hash now covers the collision outcome, not
just the raw list and allow-list. Collisions depend on which other
servers hold a sanitized qualified name at registration time, so a
server can re-register with identical tools but a flipped outcome; that
gating change must produce a new record instead of being suppressed.

* fix(agent-core): skip the request trace for pre-flight-aborted calls

Mirror kosong generate()'s pre-flight abort check at the Agent.generate
choke point: a call whose signal is already aborted never reaches the
wire (generate throws before dispatching), so it must not leave an
llm.request/llm.tools_snapshot trace or a diagnostic log line claiming a
request was sent. Recording stays before dispatch for every call that
passes the gate, preserving the crash-safety of the trace.

* chore(agent-core): remove a leftover adaptive-thinking override hook

The adaptiveThinkingOverride option was a temporary local hook explicitly
marked for removal before commit. Nothing passes it, so resolution falls
back to the alias-level adaptiveThinking value in all cases; drop the
option and the dead indirection.

* fix(kosong): derive the exposed completion cap from generation kwargs

maxCompletionTokens was a field stored only by withMaxCompletionTokens,
so caps that reach the wire through other paths were invisible to the
request trace: with completion budgeting disabled via env, Anthropic
still sends the constructor-resolved max_tokens (required by the
Messages API), and constructor-level kwargs like OpenAILegacyOptions
maxTokens were likewise unreported.

Replace the stored field with a getter derived from each provider's
generation kwargs — the single source the request body reads — covering
constructor defaults, direct withGenerationKwargs configuration, and
budget application in one place. Kimi mirrors its request-time legacy
max_tokens alias normalization; openai-legacy reuses the same
normalizeGenerationKwargs the request path uses.

* feat(agent-core): add thinkingKeep passthrough for Kimi providers and update tests
2026-07-07 14:09:19 +08:00
qer
9b18dc46cf
fix(kimi-web): shrink collapsed thinking block on mobile (#1461) 2026-07-07 14:04:00 +08:00
liruifengv
8d58d6ad4c
chore(dev): open monorepo root when running make dev (#1458)
* chore(dev): open monorepo root when running make dev

The dev runner spawned the tsx child process with cwd pinned to
apps/kimi-code, so the TUI always opened apps/kimi-code as its working
directory. Switch the child cwd to the monorepo root and pass the
tsconfig, raw-text loader, and entry point as absolute paths so their
resolution does not depend on cwd.

This only affects the local dev runner; the published CLI is unchanged.

* fix(dev): pass --import preload as a file URL

Node resolves `--import` preloads as ESM specifiers. On Windows, the
absolute path produced by path.resolve() looks like a `c:` URL scheme
and crashes the dev CLI with ERR_UNSUPPORTED_ESM_URL_SCHEME before
src/main.ts loads. Convert the preload to a file:// URL via
pathToFileURL so it resolves on every platform.

Addresses codex review on PR #1458.
2026-07-07 13:37:40 +08:00
qer
166e404a92
chore: downgrade #1432 changeset to patch (#1453) 2026-07-07 13:03:11 +08:00
liruifengv
260a80793a
fix(cli): respect --skills-dir in interactive mode (#1457)
The interactive shell entry point dropped opts.skillsDirs when building the harness, so --skills-dir only took effect in prompt mode. Forward it so the flag works in the TUI as documented.
2026-07-07 13:02:57 +08:00
liruifengv
6916240ae3
docs(changelog): remove contributor thanks credit from docs and update skills (#1454) 2026-07-07 12:49:27 +08:00
Haozhe
244ec077f9
fix(agent-core): remove print-mode subagent drain deadline (#1452)
- drop the session-wide absolute drain deadline that gated print-mode turn holds
- hold the turn until background subagents reach a terminal state, bounded by each subagent's own timeout
- fixes late or long-running subagents being abandoned (results suppressed) in long `kimi -p` runs
2026-07-07 12:37:14 +08:00
liruifengv
7a65e0d1c0
feat(tui): update permission mode copy and reorder /auto /yolo (#1450) 2026-07-07 12:26:25 +08:00
Kai
743f66e547
refactor: move tool-result metadata into a structured note side channel (#1437)
* fix: stop rendering <system> notes from tool results in the terminal and web UIs

Tool results carry <system> blocks as side-channel notes for the model (ReadMediaFile summaries, Read status, MCP image captions, error/empty sentinels). Keep them in history for the model, but strip them at every core-to-UI boundary so they no longer render as plain text. vis is intentionally left untouched to preserve the model's-eye view for debugging.

* fix: keep error/empty status text visible when stripping tool-result <system> tags

Unwrap the tool error/empty sentinels (<system>ERROR: ...</system>, <system>Tool output is empty.</system>) instead of deleting them: keep the human-readable text and drop only the tags. Otherwise a failed or empty tool result rendered as a blank output, indistinguishable from a rendering bug. The model still reads the wrapped form in history.

* refactor: move tool-result metadata into a structured note side channel

Tool-produced model-facing metadata (ReadMediaFile summaries, Read status
lines, MCP image-compression captions) was baked into tool output as
<system> text, so every UI had to strip it back out and three copies of
the model-view normalization had silently drifted apart.

- ExecutableToolResult gains `note`: content rendered to the model but
  never to UIs; records and history now store the raw output plus the
  structured isError/note fields
- the model view is rendered exactly once at the LLM projection boundary
  by renderToolResultForModel; the transcript and vis hand-copies are
  deleted (vis now calls the same function for its model view, fixing
  their drifted empty-output checks)
- ReadMediaFile / Read / MCP captions write `note`; tool outputs stay
  pure data, and text-only results keep a single text part (note joined
  with a newline) so provider tool content stays a plain string
- all UI-side <system> stripping is removed; failed tools show their own
  error text with the structured isError flag
- wire protocol 1.4 -> 1.5 migrates existing records' tool-produced
  <system> blocks into `note` on resume

* fix: provider-neutral wording, no wire migration, direct optional fields

- "The attached image was downsampled" replaces directional wording that
  depended on provider serialization order (inline media vs flatten-and-
  re-attach)
- drop the 1.4 -> 1.5 wire migration: legacy records replay verbatim, so
  the model view of old sessions stays byte-identical to what the model
  originally saw and UIs show the legacy <system> text as-is; this also
  removes the risk of the migration misclassifying user data that quotes
  tool metadata, and the additive note field needs no version bump
- pass optional result fields as undefined instead of conditional spreads
  (repo convention)

* fix: enforce the note contract at the trust boundary; narrow the TUI system-tag guard

- normalizeToolResult now keeps a note only when it is a non-empty string:
  tools and finalize hooks are arbitrary JS, and a malformed note (null,
  number, object) would previously persist into the record and crash every
  subsequent LLM projection of the session. Everything downstream now
  trusts note to be string | undefined.
- the TUI tool body suppression matches the full <system-reminder> tag
  instead of any <system prefix: reminder piggy-backing stays hidden,
  while real output that merely starts with a literal <system> tag (file
  contents, MCP text) stays visible, covered through the real
  ToolCallComponent path.

* fix: return MCP compression captions as data instead of extracting them from text

compressImageContentParts now returns { parts, captions } — captions come
back from the compressor as structured data and are never inserted into
the parts, so the MCP pipeline no longer pattern-matches text to move
them into the note side channel. Tool output that merely quotes a
caption (a doc, a log, a test fixture) stays verbatim in the output.
Also corrects the stale claim that prompt ingestion uses this helper
(it compresses per image while constructing the part).

* docs: correct the image-compression re-export comment; export CompressedContentParts

The package-root comment still described compressImageContentParts as the
input-stage helper every ingestion site calls; prompt ingestion compresses
per image with compressBase64ForModel / compressImageForModel, and the MCP
pipeline is the walker's only caller. Also export the walker's
CompressedContentParts return type so public-API consumers can name it.

* feat: wrap tool status sentinels in <system> so the model can tell harness verdicts from tool output

The error/empty status text is model-only after the note refactor (UIs
render the raw output and style failures via the structured isError
flag), so the earlier plain-text wording served no remaining audience.
Wrapping the statuses in <system> gives every piece of system-generated
text inside a tool result the same marker:

- failed calls get '<system>ERROR: Tool execution failed.</system>'
  unconditionally — the ERROR:-prefix guard is removed, so the harness
  verdict can no longer be confused with tool output that happens to
  start with error-like text
- empty outputs render as '<system>Tool output is empty.</system>'; the
  plain placeholder the loop layer bakes into records is still
  recognized and upgraded at projection time

* style: collapse an internal helper docstring per the services subtree convention
2026-07-07 11:40:27 +08:00
qer
4aeb33637f
docs(changelog): sync 0.23.0 from apps/kimi-code/CHANGELOG.md (#1446)
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-06 23:59:08 +08:00
Kai
25a655cf88
feat(agent-core): enable Preserved Thinking by default on the Anthropic provider (#1432)
* feat(agent-core): enable Preserved Thinking by default on the Anthropic provider

Default thinking.keep to "all" for the Anthropic provider (Claude and Kimi in Anthropic-compatible mode) while Thinking is on, via a context_management clear_thinking_20251015 edit, mirroring the Kimi default. Reuses [thinking] keep and KIMI_MODEL_THINKING_KEEP (env > config > default "all"); off-values disable it.

* feat(kosong): route Anthropic Preserved Thinking through the beta Messages API

Force the beta endpoint (client.beta.messages.create) when thinking.keep is enabled, since clear_thinking_20251015 is only honored there. Also prepend clear_thinking to any existing context-management edits (for example clear_tool_uses) instead of replacing them, keeping it first as Anthropic requires when combining edits.

* docs: clarify Anthropic beta endpoint and compaction keep behavior

Note in code comments and bilingual docs that enabling Anthropic Preserved Thinking routes requests to the beta Messages API (client.beta.messages.create), with keep=off as the escape hatch back to the standard endpoint. Correct the resolveThinkingKeep comment to reflect that compaction shares ConfigState.provider and intentionally carries the same keep.

* test(kosong): cover Anthropic beta endpoint (streaming and forced betaApi)

Add a streaming beta-endpoint capture and a test that withThinkingKeep forces the beta endpoint even when constructed with betaApi: false, pinning down the documented behavior.
2026-07-06 23:45:17 +08:00
github-actions[bot]
379bc57ef0
ci: release packages (#1378)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-06 23:18:32 +08:00
qer
c4c2fef162
chore(skills): merge similar changelog entries and credit external contributors (#1442) 2026-07-06 23:18:09 +08:00
qer
903e8ed93a
fix(web): clarify archive session confirmation copy (#1428)
* fix(web): clarify archive session confirmation copy

* fix(web): drop delete mention from archive confirmation copy

* feat(web): add archived-sessions restore entry to mobile settings

Mobile used a separate settings bottom-sheet with no archived-sessions
restore surface, so the archive confirmation pointing users to Settings
to restore was untrue on mobile. Add an Archived sessions sub-view to
the mobile settings sheet with search, sort and restore, mirroring the
desktop Settings tab.

* fix(web): refresh mobile archived list and use Input primitive

Refresh the archived-sessions list each time the mobile sub-view opens so
sessions archived from the mobile switcher show up without remounting the
sheet, and replace the hand-rolled search input plus inline svg with the
shared Input primitive.

* fix(web): use Button primitive for mobile archived restore

Render the mobile Archived sessions restore action with the shared Button
primitive instead of a hand-rolled button and scoped CSS, matching the
desktop archived list and the app's design-system rule.
2026-07-06 22:48:01 +08:00
liruifengv
2374bc41c3
feat(kimi-web): render cron fire notices in the web chat (#1426)
* feat(kimi-web): render cron fire notices as in-transcript cards

Show scheduled-reminder fires as distinct notice cards in the web chat,
both live and after a reload, instead of hiding them. Each card carries
a humanized schedule, a dimmed job id, and a collapsible prompt body.

* fix(kimi-web): isolate cron notice prompt id and route prefixed events

- Give synthesized cron messages a fresh promptId so a fire mid-turn
  cannot be reconciled into the optimistic user echo and hide it.
- Add cron.fired to KNOWN_AGENT_CORE_TYPES so event.-prefixed frames
  reach the projector and render live too.

* fix(kimi-web): truncate long one-line cron prompts when collapsed

Slice the first line to the collapse limit with an ellipsis when a
single-line prompt exceeds it, so the collapsed state actually truncates
and the expand toggle is not a no-op for common one-line reminders.

* fix(kimi-web): keep cron notices from reconciling into optimistic user echoes

Skip optimistic-echo reconciliation for user messages whose origin is
cron_job or cron_missed: their prompt text can coincide with a still-
optimistic user message, and the loose content match would otherwise
replace the user's turn with the cron notice instead of appending.

* fix(kimi-web): keep in-turn cron injections from breaking tool results

A cron injection steered into an active turn lands inside that turn's
message sequence, between a tool use and its result. Treating it as a
hard user-turn boundary flushed the pending assistant group, so the next
tool result had no group to fold into and the tool rendered without
output. Embed such in-turn cron notices as a block inside the assistant
group instead, and only render a cron at a turn boundary as its own turn.

* fix(kimi-web): only embed cron notices while a tool is in flight

Embedding whenever a group was pending was too broad: on REST snapshots
without prompt ids the whole transcript shares one group, so an idle cron
fire merged into the previous assistant answer and its own reply kept
going in that group. Embed only while the group has a running tool (a
cron sandwiched between a tool use and its result); flush to its own
turn otherwise.

* fix(kimi-web): omit synthetic prompt id on cron notices

The synthesized cron message carried a cron_pr_ promptId that the web
client caches into promptIdBySession for Stop/abort. Because it is not a
real daemon prompt id, it clobbered the active promptId, so Stop first
aborted a nonexistent prompt and only recovered via the error fallback.
Omit the promptId; the reducer already skips optimistic-echo
reconciliation for cron-origin messages, so it is not needed for de-dup.
2026-07-06 22:25:19 +08:00
liruifengv
ac5b5e4cbf
fix(kimi-web): keep tool components from jumping on expand or collapse (#1433)
* fix(kimi-web): keep tool components from jumping on expand or collapse

Also show the scroll-to-bottom button whenever scrolled up, and render a
fallback icon for tools without a dedicated glyph.

* fix(kimi-web): preserve bottom follow during content-only resizes

Late-loading media can grow after scrollKey has run; keep chasing the
bottom on content growth, and only suppress follow during the pinned
expand/collapse window.
2026-07-06 22:19:02 +08:00
qer
d86fa38e11
fix(kimi-web): disable text hyphenation and code ligatures (#1438)
Set hyphens: none (with the -webkit- prefix) on body so chat and markdown text never gain a hyphen glyph at a line break; components still control where lines wrap via word-break/overflow-wrap.

Disable the ligatures that coding fonts enable by default (liga/calt/ss01) on native code elements so code renders literally, e.g. != stays as two characters.
2026-07-06 22:12:43 +08:00
qer
0824e7b668
chore: ignore and remove throwaway scratch files (#1439)
Agent working notes (HANDOVER/handoff) and one-off UI prototype HTML files were committed by mistake. Remove the already-tracked ones, add .gitignore patterns for these classes of files and a .tmp/ scratch dir, and document the rule plus a pre-commit self-check in AGENTS.md so future mistakes are caught mechanically.

No publishable package is affected, so this PR needs no changeset.
2026-07-06 22:12:14 +08:00
qer
c5e3e80041
feat(web): render AgentSwarm as an inline tool card (#1425)
* feat(web): render AgentSwarm as an inline tool card

Replace the bottom SwarmCard footer and the messagesToTurns live-skip
with one dedicated inline tool card for AgentSwarm. The card shows a
phase overview plus a per-subagent accordion: live progress while it
runs, parsed aggregated result once it completes (and after a refresh
that has already dropped the live tasks).

Refresh and resync keep member identity metadata (swarmIndex,
parentToolCallId, subagentType, runInBackground) stable across skeleton
task replacement in the reducer, and the .content-wrap flex layout is
hardened against the overflow compression that previously displaced the
footer.

* fix(web): handle swarm review feedback

- SwarmTool: when AgentSwarm fails before producing a structured
  agent_swarm_result (e.g. argument validation), render the raw tool
  output instead of the "waiting for subagents" placeholder so the
  failure cause is visible.
- resolveSwarmMembers: source live members from the AppTask store keyed
  by parentToolCallId rather than buildSwarmGroups, which filters out
  single-member groups. A resume-only AgentSwarm now streams its live
  progress before the final result arrives. The badge counter still
  relies on buildSwarmGroups's filter.

* fix(web): carry streamed subagent text into swarm rows

Swarm subagents that stream normal assistant output accumulate it on
AppTask.text (text-kind taskProgress), not outputLines. The new live
member map was dropping `text`, so a still-composing subagent rendered
an empty / stale row until the structured result arrived.

- Add `text` to SwarmMember and thread it through buildSwarmGroups and
  swarmMembersByToolCall.
- SwarmTool: prefer member.text for both the row activity preview and
  the expanded body; fall back to outputLines / summary.
- Tests cover text propagation through both helpers.

* fix(web): merge swarm result rows and fall back to raw output

Address the two latest swarm review comments:

- Rows: when a parsed agent_swarm_result coexists with live AppTasks
  (which the detail panel also depends on), the inline card previously
  only rendered the live members. Interrupted swarms can carry
  state="not_started" / outcome="aborted" result entries for items that
  never spawned a task; those rows were dropped until a refresh cleared
  the live tasks. Extract the row model into buildSwarmCardRows and
  merge result-only aborted/not-started rows with the live member rows.
- Fallback: when the tool is no longer running but produced no
  structured result (argument validation, parser miss, or legacy
  legacy transcript), render the raw tool output instead of
  "Waiting for subagents…" so the final text / failure cause is
  visible to the user.

* fix(web): parse swarm result subagent bodies defensively

Producer writes subagent body unescaped, so a subagent that analyzes or
emits an AgentSwarm snippet can include a literal "</subagent>" inside
its body. The non-greedy regex treated that as the row close and truncated
the body in the result-only path (post-refresh where the AppTask store is
gone).

Rewrite the parser to scan opening tags, then resolve each row's body as
everything up to the last "</subagent>" before the next row's opening tag
(or document end), preserving embedded close-tag strings. Add tests for a
literal "</subagent>" within a single body and across sibling rows.

* fix(web): only count top-level subagent result tags

A subagent body that contains a literal `<subagent ...>` tag — for
example emitting an AgentSwarm/XML snippet — was being pre-collected as
another result row, splitting the real body and producing duplicate /
bogus subagents after refresh where the AppTask store is gone.

Rewrite parseSubagents with a depth-tracking tokenizer: scan every
`<subagent ...>` / `</subagent>` token in order, push a real row frame
only at the outermost level, and treat openings / closings while nested
inside another body as body text. Drop the now-inaccurate "literal
</subagent> without matching open" regression tests; replace with tests
that verify a balanced nested snippet stays inside the parent body and
does not register as a separate row.
2026-07-06 22:05:05 +08:00
qer
a5fbcb75b4
fix(kimi-web): keep composer toolbar usable on narrow windows (#1436)
The desktop composer toolbar renders every control on one row and relies on
overflow:hidden to fit, so between the mobile breakpoint and a wide window it
clipped its own content. Shed secondary ink below 980px (the context readout
moves into the ring tooltip, the model name truncates earlier, permission is
capped) and keep the context ring visible on phones too, instead of sending it
to the settings sheet.
2026-07-06 21:54:38 +08:00
7Sageer
10922fc70f
feat(telemetry): add system metrics collection (#1435)
* feat(telemetry): add system metrics collection

Add periodic CPU and memory telemetry sampling with warmup capture, lifecycle cleanup, and tests.

* fix(telemetry): attach prompt session to system metrics
2026-07-06 21:54:12 +08:00
qer
578f7d334c
fix(web): reconcile session from snapshot on reopen (#1409)
* fix(web): reconcile session from snapshot on reopen

* fix(web): discard stale snapshot when a newer prompt races reopen

* fix(web): harden reopen snapshot against first-open and optimistic-send races

* fix(web): keep evicted reopens subscribed when a snapshot races

* fix(web): let resync snapshots bypass the reopen staleness guard

* fix(web): force-apply the snapshot after an undo

* fix(web): gate session reopen on durable seq instead of updatedAt

* refactor(web): always rebuild reopened sessions from a snapshot

* fix(web): sharpen reopen snapshot discard and skip rebuilds mid-stream

* refactor(web): unconditionally apply session snapshots, drop the staleness guard

* fix(web): preserve loaded older messages when reopen snapshots apply
2026-07-06 21:30:25 +08:00
qer
4aacddc432
fix(kimi-web): clarify desktop notification title and icon (#1434) 2026-07-06 21:18:45 +08:00
7Sageer
dd9077595d
chore(agent-core): classify turn_interrupted telemetry cause (#1431)
Add an `interrupt_reason` field to the `turn_interrupted` telemetry event so the data can tell a deliberate user cancel (`user_cancelled`) apart from a programmatic abort (`aborted`), max-steps exhaustion (`max_steps`), an error (`error`), or a hook-filtered turn (`filtered`).

The user-cancel signal comes from the existing UserCancellationError carried as the abort signal's reason, reused here without changing any loop control or external protocol semantics.
2026-07-06 20:52:40 +08:00
Haozhe
6c0ce09414
feat(server): support restoring and listing archived sessions (#1073)
* feat(server): support restoring and listing archived sessions

- add a `:restore` session action that clears the archived flag in state.json and returns the restored session
- add an `archived_only` list query param, mutually exclusive with `include_archive`, that post-filters to archived sessions
- keep the implementation in the server layer as a temporary measure until agent-core exposes restore natively

* fix(server): paginate archived-only sessions before response

* feat(web): add archived sessions page in Settings

Browse, search, filter by workspace, sort, and restore archived sessions
from a new Archived tab in Settings, backed by the server archived_only
list and :restore action.

* fix(web): keep archived Load more visible when a page filters to empty

When a search or workspace filter empties the loaded archived page, the
Load more button was hidden inside the non-empty branch, so users could
not fetch older pages to find a match. Move the button out so it stays
available whenever more archived pages exist.

* fix(server): preserve after_id bound while draining archived pages

Draining an archived_only request that starts from after_id would switch
to before_id and cross the pivot, reintroducing the pivot and older
sessions. Take a single filtered page for after_id instead of draining
past the lower bound.

* fix(server): drain archived_only within the after_id bound

An archived_only request starting from after_id now keeps paging toward
older sessions until it reaches the pivot, instead of treating the first
page as exhaustive. The loop stops as soon as it encounters the pivot
session itself, so it never reintroduces the pivot or anything older.

* feat(web): drain all archived pages for global search and sort

When the user searches, sorts, or changes the workspace filter in the
Archived settings page, fetch every remaining archived page first so the
client-side filter and sort run over the full set rather than only the
pages loaded so far.

* refactor(web): load all archived sessions upfront instead of paginating

Fetch every archived session once when the Archived settings tab opens and
drop frontend pagination entirely. Search, sort and workspace filter now run
over the full set, removing the empty-page and cursor bookkeeping that
previously caused bugs.

---------

Co-authored-by: qer <wbxl2000@outlook.com>
2026-07-06 20:09:45 +08:00
liruifengv
fa6d198b01
fix(kimi-web): fix input caret and todo strikethrough (#1423)
* fix(kimi-web): keep composer caret visible when input is empty

The composer textarea coloured its empty state with `--faint`, and with no caret-color set the caret inherited that faint colour and nearly vanished until the first character was typed. Pin the caret to --color-text so it stays readable regardless of the placeholder state.

* fix(kimi-web): use currentColor for done todo strikethrough

TodoCard pinned the done-state strikethrough to --color-line-strong, which is lighter than the faint text in light theme and darker in dark theme, so the line looked washed-out and broken against the text. Drop the override so the line inherits the text colour, matching TasksPane and the design-system examples.

* fix(kimi-web): do not reserve width for hidden workspace row actions

The workspace header's more/add buttons were hidden with opacity: 0, which kept them in the flex layout and permanently reserved ~60px on the right, so the workspace name (flex:1) truncated long before the row was full. Hide them with display: none and restore display: inline-flex on hover/focus/open, so the name fills the row and only truncates once the buttons actually appear.

The same actions remain reachable via the right-click menu and the section kebab, so removing them from the tab order when hidden is acceptable.

* fix(kimi-web): keep workspace header row height stable on hover

Lock .gh-top to the sm IconButton height (26px) so revealing the hover actions no longer grows the row and nudges the path line and the groups below it. Follow-up to the display: none change: that freed the horizontal space but let the row height collapse when the buttons were hidden.

* chore: add changeset for kimi-web UI fixes

* fix(kimi-web): restore keyboard access to workspace actions

Revert the display: none change (and the min-height that accompanied it) for the workspace row's hover actions, going back to opacity-hidden buttons.

The display: none approach removed the buttons from the tab order, and the 'create in workspace' add button has no keyboard-accessible alternative in the right-click or section menus, so keyboard users lost that action. Restoring opacity keeps the buttons focusable again, at the cost of the workspace name truncating a little earlier to reserve their space.

Addresses the P2 review on PR #1423.
2026-07-06 18:34:06 +08:00
迷渡
e95fc83cc2
fix: honor web font size setting (#1394)
* fix: honor web font size setting

* fix: scale web font-size dependents

---------

Co-authored-by: liruifengv <liruifeng1024@gmail.com>
2026-07-06 17:29:23 +08:00
liruifengv
1de028612c
fix(tui): keep Edit preview height stable as results land (#1421) 2026-07-06 16:57:52 +08:00
liruifengv
5ea3ec489e
fix(tui): stabilize Bash card height and distinguish command from output (#1419)
Keep the command preview in the body after the result lands so a multi-line command with short output no longer collapses the card. Render the command in textDim with a shellMode $ and the result one shade dimmer in textMuted, and simplify the header to "Running a command" / "Ran a command".
2026-07-06 16:50:07 +08:00
Kai
c12f30951f
feat(agent-core): feed AskUserQuestion answers back as question text and option labels (#1414)
* feat(agent-core): feed AskUserQuestion answers back as question text and option labels

The flattened answers record the model receives was keyed by synthesized
ids (q_0 / opt_0_1), forcing a cross-message positional lookup against the
original tool call to understand what the user picked — both unreadable in
transcripts and a real model-misreads-the-choice badcase.

- toAgentCoreResponse now takes the original broker request and translates
  wire ids back to question text (keys) and option labels (values);
  unknown ids are kept verbatim, missing request falls back to raw ids
- wire protocol unchanged: clients still answer with option ids; the
  resolve route reads the pending request before settling it
- question texts must be unique per call and option labels unique per
  question, enforced in the tool execution path (AJV cannot express the
  zod refine) and mirrored on the exported schemas
- web transcript card resolves both the new label form and legacy id
  transcripts; TUI and ACP paths already produced the text form

* fix(agent-core): align multi-select answer join across clients and harden question schema

- Join multi-select labels with ', ' in the server translator, matching
  what the TUI reverse-RPC path already emits, so the model sees one
  format regardless of which client answered
- Trim segments in the web transcript resolver before label matching:
  TUI-answered multi-select transcripts (', '-joined) previously lost
  their highlight to a spurious leading-space Other row
- Move the question-text/legacy-q_<i> answer lookup out of the SFC into
  askUserToolParse as answerFor(), per that module's testability intent
- Require non-empty question text and option labels (.min(1)) so empty
  strings are rejected by AJV at the tool boundary instead of failing
  deeper in the protocol layer

* fix(agent-core): resolve option ids only within the answered question

The translator's option-id lookup was a single flat map across all
questions, so a stale or malformed response pairing one question with
another question's option id (q_1 + opt_0_0) was silently translated
into a label that was never offered for that question. Scope the lookup
to the answered question's own options; cross-question and unknown ids
now both pass through verbatim, staying diagnosable.
2026-07-06 16:37:54 +08:00
Kai
f7b557732b
chore: symlink CLAUDE.md to AGENTS.md for compatibility (#1420)
Some agent tooling looks for a CLAUDE.md at the repo root. Add a
symlink to the canonical AGENTS.md so both filename conventions
resolve to the same instructions, avoiding any duplicated content.
2026-07-06 16:21:52 +08:00
STAR-QUAKE
f0896a53b0
feat(agent-core): progressive tool disclosure via select_tools (#1369)
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
* feat(agent-core): progressive tool disclosure via select_tools

Keep MCP tool schemas out of the immutable top-level tools[] and let the
model load them on demand, preserving the provider prompt cache:

- kosong: Message.tools (append-only load primitive, serialized as Kimi
  messages[].tools with type:function wrapping and no content),
  Tool.deferred (stripped once in generate() so loaded tools stay
  executable without re-entering the top level), select_tools capability
  bit (UNKNOWN/catalog default false).
- select_tools builtin: load-by-exact-name, three-branch semantics
  settled per name (Loaded / Already available / Unknown), schemas read
  from the live registry, injection-origin schema messages survive undo.
- ToolsDiffInjector: <tools_added>/<tools_removed> announcements at turn
  boundaries and post-compaction, folded from history (undo/compaction/
  resume self-heal), appended only when the loadable set changes.
- Loaded-tools ledger = history scan + defer-window pending set (cleared
  on /clear); loop re-reads the executable table per step so a selected
  tool dispatches on the next step of the same turn; preflight
  distinguishes not-loaded from loaded-but-disconnected.
- Cross-cuts: projection strips protocol context for non-select_tools
  models (lossless mid-session model switch both ways), compaction
  filters it from the summarizer input and rebuilds loaded schemas
  keep-all after folding, token estimation counts message.tools, request
  logging reflects the post-strip wire tools.
- Three-condition gate: capability.select_tools x capability.tool_use x
  tool-select experimental flag (KIMI_CODE_EXPERIMENTAL_TOOL_SELECT).
  Any gate closed reproduces the inline request byte-for-byte; all
  current models keep the capability off, so behavior is unchanged
  until a supporting model is catalogued. The SDK catalog-to-alias
  mapping forwards the capability so catalog-driven setups can enable it.

* feat(kosong): skip tool-declaration-only messages in non-Kimi providers

Message-level tool declarations (messages[].tools) are a Kimi wire
feature. The other providers' explicit field construction already keeps
the tools field off the wire, but the content-free leftover message
would be rejected (OpenAI: system message without content) or serialize
as a garbage <system></system> turn (Anthropic/Google system-to-user
wrapping). Skip such messages entirely via a shared predicate; a message
that also carries content only loses the tools field, as before.

Unreachable in kimi-code (the projection gate strips dynamic-tool
context for models without the select_tools capability before any
provider sees it) — defense-in-depth for direct kosong consumers.

* fix(agent-core): survive runtime flag flips and align tool table with post-compaction state

Two fixes from PR review:

- Register select_tools unconditionally and gate only its exposure in
  loopTools. The tool-select flag can flip at runtime (config reload
  calls setConfigOverrides on the live resolver) without
  initializeBuiltinTools re-running; previously the disclosure shape
  activated while the tool itself was unregistered, cutting the session
  off from MCP entirely until a model/cwd change rebuilt the builtins.
  A profile listing the name explicitly still never surfaces it in
  inline mode, and execution guards the flip race defensively.

- Resolve the per-step tool table AFTER beforeStep, next to
  buildMessages. beforeStep can run full compaction, which trims loaded
  schemas and rewrites the ledger; a table captured before it could
  still dispatch a tool whose schema the model no longer has. The
  executable table and the request messages now always reflect the same
  state, so a trimmed tool is rejected with select guidance instead of
  executed.

* fix(agent-core): drop unused Tool import in dynamic-tools

* fix(agent-core): baseline compaction guard after post-compaction reinjection

The reinjected reminders (loadable-tools manifest, goal) are re-appended
after every compaction, but the nothing-new-since-compaction baseline was
captured before injectAfterCompaction. With a large manifest the guard
could re-trigger auto-compaction against a floor that cannot shrink.
Raise the baseline to the true post-compaction floor once reinjection
completes; the earlier capture stays as a fallback when reinjection
throws.

---------

Co-authored-by: fengchenchen <fengchenchen@moonshot.ai>
2026-07-06 15:51:08 +08:00
Kai
79b360c96a
feat(thinking): enable Preserved Thinking by default for kimi models (#1417)
Default `thinking.keep` to "all" when Thinking is on so prior `reasoning_content` is kept across turns. Add `[thinking] keep` to config.toml and keep `KIMI_MODEL_THINKING_KEEP` as an override (env > config > default); off-values disable it.
2026-07-06 15:27:08 +08:00
liruifengv
8c7e6da582
chore(changelog): enable thanks credit in changelog (#1418) 2026-07-06 15:26:03 +08:00
liruifengv
913d042208
fix(tui): keep input anchored after slash command menu closes (#1413)
* fix(tui): keep input anchored after slash command menu closes

Force a full re-render when the slash command menu closes, but only when the session content already overflows one screen; skipped under tmux. Detect the close edge from a render frame so asynchronous closes (Backspace deleting the leading slash) are covered too. Apply the same overflow/tmux gating when restoring the editor from selector panels.

* fix(tui): measure overflow against restored editor tree

Address Codex review: the overflow probe ran before the editor container was swapped back, so it counted the tall replacement panel and forced a full clear/home even when the restored content fit on one screen, yanking the editor to the top. Measure after the editor is mounted instead.

* fix(tui): redraw when content exactly fills one screen

Address Codex review: the guard skipped the forced redraw when the post-close layout is exactly one screen, leaving the editor shifted up because the differential renderer keeps the old viewport offset after a shrink. An exact fill is safe to clear (no blank tail), so redraw when content fills or overflows the viewport, and cover it with a test.
2026-07-06 15:18:27 +08:00
liruifengv
353c0885e5
chore: remove unused kimi-migration-legacy package (#1415)
This package only ever contained a package.json with no sources, dependencies, or scripts, and nothing in the repo imports it (the CLI uses @moonshot-ai/migration-legacy instead). Remove the directory and drop its entries from flake.nix and the changeset config, then refresh the lockfile.
2026-07-06 15:09:27 +08:00
liruifengv
fc259abdb4
fix(tui): complete @ file mentions across additional workspace roots with fd (#1408)
* fix(tui): complete @ file mentions across additional workspace roots with fd

When additional workspace directories are added via /add-dir, @ file completion fell back to a readdir-based scanner capped at 2000 entries, so deeply nested files in large projects never appeared. Route @ completion through fd across every root instead, keeping the query pushed down to fd and deduplicating by absolute path. The readdir fallback remains for when fd is unavailable.

* fix(tui): preserve per-root full-path fallback for @ mentions

Address review feedback: decide the scoped-versus-full-path fallback per root instead of globally. When one root has the scoped directory but another does not, the latter still runs a whole-tree --full-path search with the original query, so a match that only exists under that root is not hidden just because a sibling root happens to contain the prefix directory.

* fix(tui): fall back to filesystem when fd binary is not executable

Address review: when fdPath is non-null but the binary cannot be spawned (managed fd removed or lost execute permission), @ completion returned null because pi-tui swallows the spawn error into an empty result, so the catch never ran. Probe fd with accessSync(X_OK) before delegating and use the filesystem fallback when it is not executable, while still returning null for genuine no-match results.

* fix(tui): trust bare fd command names when probing executability

Address review: when fd is discovered on the system PATH, detectSystemFdPath returns the bare name (fd/fdfind). accessSync checked that literal string relative to cwd and never searched PATH, so a valid system fd was treated as unavailable and @ completion fell back to the capped scanner. Trust bare names (spawn resolves them via PATH) and only probe absolute/relative paths, which is how the managed fd is referenced and which can go stale.

* chore: remove accidentally committed plan files

* test(pi-tui): stabilize paste-burst test by freezing the clock

The paste-burst heuristic uses an 8ms inter-character interval that a slow or busy CI runner can exceed between synchronous handleInput calls, which resets the burst and lets Enter submit. Freeze Date so the synchronous keystrokes always register as one burst, making the assertion deterministic.

* chore: ignore top-level plan directory
2026-07-06 15:02:02 +08:00
qer
e6e6dd53ce
fix(web): stop replaying the bubble entrance animation on session open (#1411) 2026-07-06 13:58:44 +08:00
liruifengv
1c817df1e5
fix(tui): include surrounding context in edit approval preview (#1410) 2026-07-06 13:50:23 +08:00
liruifengv
b9258ee07d
feat(tui): show compaction summary with Ctrl-O (#1346)
* feat(tui): show compaction summary with Ctrl-O

* docs: document compaction summary toggle

* fix(tui): preserve compaction summary expansion state

* fix(tui): preserve compaction summary expansion across replay and theme changes
2026-07-06 13:32:37 +08:00
qer
ce41f4b58d
fix(web): keep Sidebar single-root so collapse hides it (#1406)
A top-level Teleport in the sidebar template made the component multi-root, so v-show could not apply display:none and the collapsed sidebar stayed mounted at the rail width, squeezing the conversation. Move the teleport inside the aside so the sidebar is single-root again.
2026-07-06 12:39:58 +08:00
迷渡
4c43935e31
fix: show Windows session search shortcut (#1393) 2026-07-06 12:08:08 +08:00
qer
c5c6282f44
feat(web): render AskUserQuestion result as an option list (#1391)
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
* feat(web): render AskUserQuestion result as an option list

Parse the AskUserQuestion tool result and echo the full option list, highlighting the chosen option(s) and dimming the rest, instead of dumping raw JSON. Handles single/multi select, free-text Other answers, and the dismissed state. Answers are zipped back to the input questions by index since the input carries no ids.

* fix(web): drop the stray indent in the tool-call card body

The expanded body of every tool-call card carried a hard-coded 36px left indent (a magic number meant to align with the header name). Drop it so expanded content aligns with the header's 11px padding instead. The design-system preview page mirrored the same rule and is updated to match.

* fix(web): align markdown diff block with the design system

Restyle the local ```diff renderer in chat Markdown to match the ~/diff panel: code text keeps the normal ink colour, the +/- sign carries the add/del colour, and rows use a soft background with an inset accent bar instead of dyeing the text green/red. The header and copy button now match the standard code-block chrome (height, hover, focus ring).

* fix(web): match the markdown diff block chrome to code blocks

Make the local markdown diff renderer's shell identical to a regular code block: swap the text copy button for the same icon button, inherit the header's text-xs sizing, and keep the container / header / code-area padding, background, radius and shadow in lockstep with .code-block-container and .code-block-header. The diff-specific row tinting (soft background, inset accent bar, coloured sign) is kept since that is what makes it a diff.

* fix(web): fall back to raw output for non-answer AskUserQuestion results

Background launches and error cases return plain-text tool output (task_id/status lines, or a failure reason), not the { answers } JSON the card expects. The card used to render an empty, fully-unselected option list in those cases, hiding the task id or error. Now the card only renders the option list when the output parses as the answer payload; otherwise it shows the raw output. Addresses review feedback (P2).

* fix(web): localize AskUserQuestion result labels

The card hard-coded user-visible strings (Dismissed, answer/answers, Answered, the (+N more) summary), so Chinese-locale transcripts mixed in English. Move them into the en/zh tools locale files and read them via t(). Addresses review feedback (P2).
2026-07-06 02:36:31 +08:00
Haozhe
4963c9016f
feat(skills): list workspace skills without a session (#1392)
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
- add GET /api/v1/workspaces/{workspace_id}/skills backed by a listWorkspaceSkills core RPC and ISkillService.listForWorkDir, reusing the session skill-loading path so results match a new session
- web: populate the composer slash menu from workspace skills before a session exists, then fall back to session skills once one is active
2026-07-05 18:05:15 +08:00
Haozhe
083d0caf05
fix(session): rebuild index on boot to find missing sessions (#1390)
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
* feat(session): rebuild session index on boot and self-describe workDir

- persist workDir into state.json so session dirs are self-describing and
  summaries do not depend on the index's one-way-hashed workDir
- relax readSessionIndex so a stale or non-absolute index workDir no longer
  drops an otherwise valid entry
- serialize in-process index appends to avoid torn jsonl lines
- add SessionStore.reindex() and run it once at server boot so the
  scan-free request path can find sessions whose index line is missing
  or stale

* chore: add changeset for session index rebuild

* fix(session): repair index entries with a stale workDir during reindex
2026-07-05 14:06:33 +08:00
Haozhe
ebdffc7df7
fix(gemini): fix Gemini tool calling and thought-signature round-trip (#1389)
- send tool declarations, system prompt, and sampling/thinking settings in the camelCase shape the Google SDK forwards so tool calls reach the model
- thread tool-call extras (thought signatures) through the loop tool-call event into context so Gemini 3 can resume a tool turn
- update and add tests for the corrected request shape and signature round-trip
2026-07-05 14:00:09 +08:00
qer
be7c9916b0
fix(web): refill attachments when editing a queued or undone message (#1357)
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): refill attachments when editing a queued or undone message

Queued prompts that carry images/video are no longer remove-only: clicking one loads its text and attachments back into the composer. Undo ("edit & resend") now restores the message's attachments too, not just its text. useAttachmentUpload gains loadAttachments, which reuses the existing fileIds (no re-upload) and fetches authenticated blob URLs for protected getFileUrl previews so the refilled thumbnails don't 401.

* fix(web): forward attachment refills through ChatDock

When the normal chat dock is mounted (non-empty conversation), bindChatDock receives the ChatDock instance, but ChatDock only exposed loadForEdit/focus — so the new loadAttachmentsForEdit fell back to a no-op and editing a queued media prompt or undoing a media message still dropped the attachments. ChatDock now forwards loadAttachmentsForEdit to the underlying Composer.

* fix(web): replace composer attachments when refilling edits

loadForEdit(text) overwrites the composer text, but loadAttachments appended to any unsent draft attachments, so a later submit sent the stale draft files together with the edited message's files. Make loadAttachments replace the current session's attachments (revoking their object URLs) so an edit/undo replaces the whole composer, mirroring loadForEdit.

* fix(web): clear stale attachments on text-only edits

When the composer already has attachments loaded (for example after editing a queued media prompt) and the user then edits a text-only queued prompt or undoes a text-only message, loadComposerForEdit skipped loadAttachmentsForEdit (the only path that clears the strip) because the new attachment list was empty/undefined, so the next submit would send the stale media with the new text. Always call loadAttachmentsForEdit(attachments ?? []) so text-only edits replace the strip with an empty set.

* fix(web): make fileId-less refilled media resendable

When editing a user turn whose media was base64-inlined by the server (no fileId), loadAttachments used to add a non-uploading chip with no fileId, which handleSubmit silently drops on resend (and an image-only edit would not submit at all). Re-upload the data URL to obtain a fileId so the attachment is actually resendable; when re-upload is unavailable, skip the chip instead of showing a misleading ready attachment. Also factor a patchAttachment helper.

* chore: prefix web changeset entry

The gen-changesets rules require web-app changelog entries to start with 'web: ' so the synced release notes classify web UI changes correctly.

* fix(web): don't dequeue a prompt when the composer is hidden

When a queued media item is clicked while the dock is showing a pending question or approval, ChatDock has no nested Composer (only rendered in its v-else), so loadComposerForEdit no-ops — but handleEditQueued still dequeued the item, losing it. Make ChatDock.loadForEdit report whether the nested composer is present, have loadComposerForEdit return success, and only dequeue when the load actually succeeds.

* fix(web): preserve URL-backed media when refilling composer

When an undone turn contains media with source.kind === 'url' (a URL but no fileId), loadAttachments used to fall through and drop it. Re-upload the URL (data: or http(s):) to obtain a fileId so URL-backed media is preserved on edit/resend; if the URL can't be fetched (CORS / non-2xx) the chip is dropped instead of shown as a misleading ready attachment.
2026-07-04 23:32:32 +08:00
liruifengv
fbb9dc3772
docs(changelog): fix tui revert wording in 0.22.3 (#1376)
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-04 19:57:03 +08:00
liruifengv
d0f2994536
docs(changelog): sync 0.22.3 from apps/kimi-code/CHANGELOG.md (#1375) 2026-07-04 19:47:55 +08:00
github-actions[bot]
dfcd6c8ed5
ci: release packages (#1355)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-04 19:17:47 +08:00
liruifengv
7d40d55ac3
chore: downgrade unreleased changesets to patch (#1374) 2026-07-04 19:15:14 +08:00
Haozhe
d111c02ea0
fix(agent-core): cap background shell output to match foreground (#1372)
Background (detached) shell commands were exempt from the 16 MiB output
ceiling, so a runaway background command could fill the disk or crash
the process. Apply the same cap to background shell commands and stop
feeding the disk write chain once it trips. Scope the ceiling to
process tasks so subagent and user-question results, which are appended
once and must be persisted, are left untouched.
2026-07-04 18:48:04 +08:00
Haozhe
5394feaabb
feat: hold print-mode turn until background subagents drain (#1371)
* feat: hold print-mode turn until background subagents drain

In `kimi -p` (print mode), when the main agent ends a turn while background
subagents (`kind === 'agent'`) are still running, hold the turn open and
idle-wait until they finish, flushing their completions into the turn so the
model can react before the run exits.

Previously, the main agent could end its turn after launching background
subagents; the print flow then drained them with their completion
notifications suppressed, so the main agent never saw the results and the run
exited with the work abandoned (e.g. no nomination). This was the root cause
of the swarm-alpha-mining eval failures.

The hold is gated on a new `drainAgentTasksOnStop` session option (set by the
print flow), only affects `kind === 'agent'` background tasks, and is bounded
by `background.printWaitCeilingS`. Backfill / fan-out is handled by
re-enumerating active tasks. Other background task kinds and non-print modes
are unaffected.
2026-07-04 18:44:44 +08:00
Haozhe
e715b1648c
docs(server): document --dangerous-bypass-auth and --keep-alive flags (#1373)
Add the two kimi server run flags introduced in #1368 to the CLI reference (en + zh), including a danger callout for the auth-bypass flag, and add the changeset so the next release notes the feature.
2026-07-04 18:33:22 +08:00
Haozhe
7db88b6f0c
feat(server): add --dangerous-bypass-auth and --keep-alive flags (#1368)
* feat(server): add --dangerous-bypass-auth and --keep-alive flags

- --dangerous-bypass-auth disables bearer-token auth on every REST and
  WebSocket route and advertises it via /api/v1/meta so the web UI skips
  the token prompt; the startup banner drops the token and shows a red
  danger notice
- --keep-alive keeps the daemon running instead of idle-killing after 60s;
  implied by --host / --allowed-host and always on in --foreground mode

* fix(server): address review feedback on bypass-auth

- keep the token and skip the bypass notice when a daemon is reused, since
  the requested --dangerous-bypass-auth flag is not applied to the
  already-running server
- clear the cached dangerous_bypass_auth web state on HTTP 401 so a stale
  bypass value cannot hide the token prompt after the server restarts
  without the flag
2026-07-04 18:03:50 +08:00
liruifengv
23daf0f3c1
revert(pi-tui): restore upstream differential rendering behavior (#1367)
* fix(pi-tui): make the viewport anchor follow above-viewport content shifts

The anchor pins a buffer row index, but an above-viewport length change
shifts the content living at every index below it. The pinned window
then suddenly showed content further along (visible upward creep), and
the rows that slid above the window top were lost: never committed to
scrollback, which holds older bytes at those indices. During streaming,
every above-viewport net shrink (a finished agent row collapsing, a
merged step, a spinner line disappearing) permanently swallowed that
many rows, and the blank area under the input box kept growing.

doRender now scores two hypotheses for such frames — window stayed put
vs window content shifted by the length delta — and when the shift
explains the frame strictly better, moves the anchor with the content:
a pure shift re-anchors with no painting at all (the screen already
shows exactly that content), and a shift with local in-window changes
(spinner/timer rows) repaints the window at the shifted anchor.
Commit order stays continuous, so the exactly-once scrollback invariant
is preserved: no loss, no duplication.

Covered by e2e case07 (above-viewport shift), which fails on the
previous revision; the stale-content unit test now asserts the
follow-the-content window instead of the old swallowed-row behavior.

* revert(pi-tui): restore upstream differential rendering behavior

The fork's viewport/scrollback rendering patches (clamping the diff to
the visible viewport, viewport re-anchoring on collapse, the pinned
anchor with commit-on-advance, cursor visibility guarding, and the
content-shift anchor follow) accumulated interacting edge cases faster
than they could be stabilized: blank screens, duplicated scrollback
spans, vanished rows, and a growing blank area under the input box.

Revert src/tui.ts to the upstream 0.80.2 differential rendering
behavior: a change above the viewport triggers a destructive full
redraw again. Verified line-by-line against the upstream source — the
only remaining divergences are the TypeScript strict-mode syntax
adaptations and the narrow-terminal fixes (Container width clamping
and overwide-line truncation replacing the upstream crash-and-throw),
which are kept.

Also remove the rendering-bug e2e ledger and the shrink test suite
that specified the reverted behavior, restore the pre-fork rendering
tests (the transient-content test asserts the upstream full-redraw
behavior again), and drop the e2e glob from the test script. Editor
input-history and paste-burst changes are untouched.

Known trade-off, accepted for now: the original scroll-position yank
during streaming (destructive redraws emitting ESC[3J) returns; the
rendering rework will restart from this clean baseline.

* chore: downgrade the web thinking-effort changeset to patch
2026-07-04 17:57:43 +08:00
qer
26b90225d2
feat(web): support multi-level thinking effort selection (#1344)
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
* feat(web): support multi-level thinking effort selection

Surface each model's declared reasoning efforts (support_efforts /
default_effort) in the web model picker as a segmented control,
replacing the on/off toggle. ThinkingLevel is now an open string, the
model catalog carries the effort metadata to the web app, and the
mobile settings sheet and /thinking command cycle through the
available levels.

* fix(web): preserve persisted thinking level before models load

coerceThinkingForModel returned 'on' for any non-'off' level when the
active model was still undefined (catalog not loaded yet), which
rewrote a persisted/default effort like 'high' to 'on' and silently
dropped the model's declared effort on later prompts. Keep the
requested level as-is until loadModels() re-runs coercion with the
real model.

Addresses Codex P1 review on modelThinking.ts.

* fix(web): coerce stale thinking level against the active model

When selecting another session, the persisted thinking level can be a
boolean 'on'/'off' carried over from a previous model. Deriving the
pill suffix and active segment from that raw value could show
"thinking: off" on an always-on model or hide the concrete effort
behind a bare "thinking" tag. Coerce the level against the current
model before deriving display state.

Addresses Codex P2 review on Composer.vue.

* fix(web): submit coerced thinking level for the prompt's target model

The send paths (submitPromptInternal / steerPrompt) previously submitted
rawState.thinking verbatim, so a value carried over from another session
(e.g. 'max' from an effort model) was sent to a model that doesn't
declare it, even though the composer already showed the coerced default.
Coerce the level against the target model before submitting so the first
turn runs with the level the UI displays.

Addresses Codex P2 review on Composer.vue.

* fix(web): coerce stale thinking in /thinking and mobile settings

Two more surfaces derived their active state from the raw persisted
level, which is stale when the saved level came from a different model:

- /thinking slash command: indexing the raw value (e.g. 'on' from a
  boolean model) into an effort model's segments returned -1 and jumped
  to 'off' instead of advancing from the model's default effort.
- Mobile settings sheet: clamping the raw prop fell back to the first
  segment, showing/selecting 'off' or the first effort while the
  composer and prompt submission coerce to the model default.

Coerce the level against the active model in both places, matching the
composer and send-path behavior.

Addresses Codex P2 reviews on App.vue and MobileSettingsSheet.vue.
2026-07-04 00:57:35 +08:00
qer
ec758c747a
fix(web): make uploaded videos play in the chat (#1343)
* feat(media): materialize video uploads to cache and reference by path

- copy TUI video placeholders into the shared cache instead of
  inlining the original source path
- emit <video path="..."> tags so ReadMediaFile / the provider's
  VideoUploader owns upload behavior
- apply the same cache materialization to server prompt video
  submissions, matching the TUI flow
- update TUI unit tests and server e2e test to assert cache-path
  behavior

* fix(web): make uploaded videos play in the chat

Render the server's <video path> tag as a real video and reconcile the echoed user message so the bubble no longer shows raw markup or a duplicate. Serve file downloads with byte-range support and fetch video bytes with the bearer credential into a blob URL, since browsers cannot authorize a <video> src on their own. Also let users click an uploaded image to open it in the preview panel.

* fix(web): use authenticated source for uploaded image previews

openMediaPreview stored the raw getFileUrl as sourceUrl, and FilePreview renders it with a native <img> that sends no Authorization header, so the enlarge action 401'd for uploaded images. When the media carries a fileId, fetch the bytes through the authenticated API client and preview a blob URL instead, revoking it when the preview is replaced or closed.

* fix(web): ignore stale authenticated media fetches

AuthMedia fetches the file bytes asynchronously; when the component is reused with a new fileId before a prior fetch resolves (e.g. queued thumbnails keyed by index), the older response could still create a blob URL and show the previous file. Add a per-request sequence guard (and an unmount guard) so a stale response is discarded and its blob URL revoked instead of being applied.

* fix(web): gate media path tags on file-store id shape

Treating any standalone <video path="..."> text as an uploaded daemon file and stripping the basename into getFileUrl is only valid for server cache files named after the file-store id (f_…). TUI/ReadMediaFile tags use arbitrary cache names like <uuid>-<label>, and older transcripts may point at paths like /tmp/foo.mp4; those produced a broken /files/<basename> request. Only extract a fileId when the basename matches the file-store id shape, otherwise leave the raw tag as text.

* fix(web): invalidate pending media preview on close

Closing an uploaded-image preview before getFileBlob() resolved left previewRequestSeq untouched, so the fetch callback still passed its seq check, created a blob URL, then skipped attaching it because previewFile was already null — leaking up to the file size until another preview opened. Bump previewRequestSeq on close so the in-flight callback bails before creating the blob URL.

* fix(web): defer authenticated media fetch until near viewport

AuthMedia fetched the full image/video into a Blob on mount whenever a fileId was present, bypassing native loading="lazy" and preload="metadata". Opening a session with several historical large video uploads started many full downloads and held all blobs in memory even if the user never scrolled to or played them. Use an IntersectionObserver to defer the fetch until the element nears the viewport.

* fix(web): revoke preview blob when leaving the file panel

Switching to another detail panel only flips detailTarget and never calls closeFilePreview, so an in-flight getFileBlob could still create a blob URL after the file panel hid, and an already-shown blob URL was held until the next file preview. Check detailTarget before creating the blob URL, and reset/revoke the preview when detailTarget leaves 'file'.

---------

Co-authored-by: haozhe.yang <yanghaozhe@moonshot.ai>
2026-07-04 00:36:00 +08:00
liruifengv
36bb506a8f
docs(changelog): sync 0.22.2 from apps/kimi-code/CHANGELOG.md (#1354)
* docs(changelog): sync 0.22.2 from apps/kimi-code/CHANGELOG.md

* docs(agents): require web: prefix for web UI changelog entries
2026-07-04 00:18:02 +08:00
github-actions[bot]
9f4079106c
ci: release packages (#1334)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-03 23:51:39 +08:00
liruifengv
68ad686211
fix(pi-tui): stop scrollback duplication from viewport rewinds (#1353)
* fix(pi-tui): stop scrollback duplication from viewport rewinds

Rewinding the viewport anchor repaints rows that the terminal
scrollback already holds, and the next scroll commits them again —
every rewind duplicated its span. Two paths triggered it during
streaming oscillation (content shrinking then growing back): the
shrink re-anchor rewound immediately, and the clamped differential
path then painted the shifted content through the screen.

Rework the shrink/shift handling around a shared in-place viewport
repaint that never scrolls, with the anchor treated as the scrollback
high-water mark that must never move backward:

- Partial shrinks keep the anchor pinned: the content bottom hovers
  above a bounded blank gap that the next growth naturally fills. No
  rewind means duplication is impossible by construction.
- Only a collapse past the viewport top (compaction, clears) rewinds,
  as nothing sensible could be shown otherwise; the content has
  changed so drastically there that the repainted span is not
  recognizable as a duplicate.
- Above-viewport length changes repaint the visible window in place
  instead of painting through: nothing scrolls, scrollback keeps the
  stale old version, and the anchor only advances when the content
  outgrows the pinned window.
- Deleted-tail changes within a pinned viewport repaint at the pinned
  anchor instead of falling back to a destructive full render.

Pure appends keep flowing through the screen into scrollback, and
equal-length above-viewport changes keep the bounded clamped diff.

* fix(pi-tui): commit skipped rows on anchor advance and guard cursor visibility

Address two review findings on the pinned-anchor rendering:

- An anchor advance (growth past the pinned viewport combined with an
  above-viewport change) repainted the screen in place without
  scrolling, so the rows between the old and new anchor were never
  committed to scrollback and vanished. repaintViewport now paints from
  the old anchor and lets the paint loop scroll the skipped rows out,
  committing each exactly once with fresh content.

- positionHardwareCursor recorded hardwareCursorRow on a logical row
  outside the visible window when the cursor marker sat above a pinned
  viewport (tall editor after a deep shrink), desyncing every later
  differential move. It now hides the cursor and keeps the bookkeeping
  on the real cursor row when the marker is not visible.

Also add an e2e rendering-bug ledger (packages/pi-tui/e2e): one
xterm-emulated repro per production rendering bug, asserting the
renderer invariants (monotonic anchor, exactly-once commit, cursor
bookkeeping sync). The two findings above are case05/case06.

* ci: run the pi-tui node:test suite in a dedicated job

pi-tui's tests (unit + e2e) run on node:test, which the root vitest
run silently skips, so CI never executed them. Add a test-pi-tui job
that runs the package's test script.
2026-07-03 23:49:04 +08:00
liruifengv
e9db9cafcf
feat: record model response id in wire logs (#1349)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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
* feat: record model response id in wire logs

* chore: include sdk in response id changeset

* chore: include agent-core in response id changeset
2026-07-03 17:15:04 +08:00
Kai
02da587795
feat(cli): wait for background subagents before exiting kimi -p (#1347)
* feat(agent-core): guide the model away from repeating denied or failed tool calls

- system.md: add a diagnose-before-retrying paragraph next to the existing
  permission-denial guidance, covering failed tool calls
- permission: when the user rejects an approval on the main agent, tell the
  model not to re-attempt the exact same call (sub agents already had an
  equivalent hint)

* fix(agent-core): close abandoned tool exchanges and dedupe duplicate tool_use ids

A turn that dies between a recorded tool.call and its paired tool.result
(e.g. a transcript write failure mid-batch) used to leave
pendingToolResultIds open forever: every later message was stranded in
deferredMessages and user input was silently swallowed.

- runOneTurn now defensively closes any dangling tool calls when a turn
  ends (completed, cancelled, or failed), synthesizing an error result
  that names the cause, with a warn log and a tool_exchange_abandoned
  telemetry event
- the projector drops assistant tool calls whose id already appeared
  earlier (first occurrence wins): a duplicate id is wire-invalid on
  strict providers and not repairable by the strict resend; reported via
  the existing projection-repair log and telemetry
- resume-side closePendingToolResults now logs what it closes (warn for
  a mid-history gap, info for the routine trailing interruption)

* chore: add changesets for tool exchange fixes

* fix(agent-core): scope duplicate tool_use id dedup to the strict resend

Unconditional dedup regressed providers that emit per-response counter
ids (e.g. call_0 in every step) and accept their own duplicates: later
tool exchanges silently vanished from the projected history, and a
duplicate call's own recorded result was left dangling.

- the dedupe pass is now opt-in via dedupeDuplicateToolCalls and enabled
  only in strictMessages, so the normal projection keeps the history the
  provider produced
- the pass also drops every tool result after the first for an id, so no
  dangling tool message survives; when the kept call has no result of
  its own, the surviving one is reattached by the adjacency repair
- kosong now classifies the Anthropic "tool_use ids must be unique" 400
  as a recoverable request-structure error so it triggers the strict
  resend

* feat(cli): wait for background subagents before exiting kimi -p

When `background.keep_alive_on_exit` is enabled, `kimi -p` now waits for
all background subagents to reach a terminal state before exiting, bounded
by `background.print_wait_ceiling_s` (default 3600s). This lets concurrent
background subagents run to completion in single-turn runs instead of being
torn down when the main agent's turn ends.
2026-07-03 16:56:16 +08:00
liruifengv
3ed22e35a4
feat: redesign the subagent card (#1345)
Stable height across running, done, failed and backgrounded states: all share the same header + one-line tool summary + two-row content window, so the card no longer shrinks when a run finishes. Add a braille spinner in the header while active, collapse sub-tool calls into a one-line summary, and have the two-row window follow the live stream (tool output, text, or thinking) instead of showing thinking and text side by side. Mute the window tones so a brief text or tool-output segment no longer flashes white against dim thinking.
2026-07-03 16:54:21 +08:00
Kai
175b95f3af
fix(agent-core): route image-compression captions through hidden system reminders (#1348)
* fix(agent-core): route image-compression captions through hidden system reminders

Prompt ingestion (server upload/base64 route, TUI paste, ACP) annotates a
compressed image with an inline <system> caption inside the user's own
message. That raw markup rendered verbatim in every user-visible history
projection (TUI session replay, web UI) and leaked into session titles.

Split the caption out at the appendUserMessage chokepoint and deliver it
through the built-in system-reminder injection (origin
{kind: 'injection', variant: 'image_compression'}), which every UI already
hides. The model still receives the full note; ingestion sites and the wire
protocol are unchanged. Session titles/lastPrompt strip the caption the same
way. Tool-result captions (MCP) keep the established <system> convention.

Covered by unit tests plus an end-to-end smoke suite that drives
rpc.prompt/steer through the real turn pipeline and asserts the provider
wire request, stored history, replay records, and resume parity.

* chore: tighten changeset wording per gen-changesets conventions
2026-07-03 16:21:10 +08:00
Kai
021786f5a2
fix(kaos): enrich PATH from the user's login shell at startup (#1339)
* feat(agent-core): strengthen the language-matching rule in the default system prompt

* chore: refine changeset wording

* fix(kaos): enrich PATH from the user's login shell at startup

When kimi-code is launched from a context that skipped the user's shell
profile (GUI launchers, non-login parent shells), process.env.PATH misses
entries like /opt/homebrew/bin, so commands spawned by the Bash tool
cannot find user-installed tools such as gh.

LocalKaos.create() now probes the user's login shell once
($SHELL -l -c env, 5s timeout, memoised) and appends the missing PATH
entries to process.env.PATH. Existing entries keep their order and
priority; probe failures silently leave PATH untouched. Windows is
skipped: the problem is specific to POSIX login-shell profiles.

* fix(kaos): fall back to the account login shell when $SHELL is unset

launchd/daemon launches can leave $SHELL unset or blank — the very
contexts whose PATH is impoverished — so the login-shell PATH probe
would give up exactly where it matters most. Resolve the shell from the
OS user database (os.userInfo().shell) before giving up; lookups that
throw (uid without a database entry) or yield nologin shells degrade
silently as before.

* fix(kaos): preserve empty PATH components when merging login-shell PATH

POSIX command lookup treats an empty PATH component (leading colon,
trailing colon, or double colon) as the current directory. The merge
previously filtered those out of the current PATH and rewrote the value
even when nothing was appended, silently dropping cwd lookup for users
who rely on it.

Keep the current PATH string verbatim as the prefix, append only the
missing login-shell entries, and skip the env write entirely when the
login shell contributes nothing — an unset PATH stays unset, a set PATH
is never rewritten. Empty login-shell components are still never
imported.

* fix(kaos): only import absolute login-shell PATH entries

A `.` or relative component in the login-shell PATH is cwd-dependent
lookup with another spelling, and LocalKaos runs commands from arbitrary
workspace directories — importing one would let a command name resolve
from an untrusted project cwd. Tighten the merge's skip condition from
"empty" to "not absolute", which subsumes the empty-component check.

* fix(kaos): invoke the login-shell probe's env by absolute path

A bare `env` inside `$SHELL -l -c` resolves through the inherited PATH
from the workspace cwd. If that PATH carries a cwd-dependent component
(which the merge deliberately preserves), a repo-planted `env` binary
would run automatically at session startup and could feed the probe an
arbitrary PATH. /usr/bin/env is guaranteed on mainstream POSIX systems
and also bypasses profile function shadowing.
2026-07-03 15:20:38 +08:00
Kai
e2fe62a5ef
fix(agent-core): harden tool_use/tool_result exchange integrity (#1340)
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / build (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
* feat(agent-core): guide the model away from repeating denied or failed tool calls

- system.md: add a diagnose-before-retrying paragraph next to the existing
  permission-denial guidance, covering failed tool calls
- permission: when the user rejects an approval on the main agent, tell the
  model not to re-attempt the exact same call (sub agents already had an
  equivalent hint)

* fix(agent-core): close abandoned tool exchanges and dedupe duplicate tool_use ids

A turn that dies between a recorded tool.call and its paired tool.result
(e.g. a transcript write failure mid-batch) used to leave
pendingToolResultIds open forever: every later message was stranded in
deferredMessages and user input was silently swallowed.

- runOneTurn now defensively closes any dangling tool calls when a turn
  ends (completed, cancelled, or failed), synthesizing an error result
  that names the cause, with a warn log and a tool_exchange_abandoned
  telemetry event
- the projector drops assistant tool calls whose id already appeared
  earlier (first occurrence wins): a duplicate id is wire-invalid on
  strict providers and not repairable by the strict resend; reported via
  the existing projection-repair log and telemetry
- resume-side closePendingToolResults now logs what it closes (warn for
  a mid-history gap, info for the routine trailing interruption)

* chore: add changesets for tool exchange fixes

* fix(agent-core): scope duplicate tool_use id dedup to the strict resend

Unconditional dedup regressed providers that emit per-response counter
ids (e.g. call_0 in every step) and accept their own duplicates: later
tool exchanges silently vanished from the projected history, and a
duplicate call's own recorded result was left dangling.

- the dedupe pass is now opt-in via dedupeDuplicateToolCalls and enabled
  only in strictMessages, so the normal projection keeps the history the
  provider produced
- the pass also drops every tool result after the first for an id, so no
  dangling tool message survives; when the kept call has no result of
  its own, the surviving one is reattached by the adjacency repair
- kosong now classifies the Anthropic "tool_use ids must be unique" 400
  as a recoverable request-structure error so it triggers the strict
  resend
2026-07-03 14:26:57 +08:00
liruifengv
9091627257
fix(tui): avoid submitting rapid multi-line paste bursts (#1305)
* fix(tui): avoid submitting rapid multi-line paste bursts

* chore(tui): remove paste burst settings picker

* fix(tui): reset paste burst on DEL backspace
2026-07-03 14:21:02 +08:00
qer
01b65bdddc
feat(web): open design-system easter egg at /design-system route (#1328)
* feat(web): open design-system easter egg at /design-system route

Replace the long-press-logo iframe overlay, which loaded a separately maintained static design-system.html, with a real /design-system route. The new view aliases to the product design tokens, so the design system is maintained in one place. Adds vue-router for the route, removes the duplicate static HTML copies, and exempts the showcase view from the style scanner.

* fix(web): lazy-load the design-system view

Address Codex review: load the 2.4k-line showcase via defineAsyncComponent so it is code-split and fetched only when /design-system is visited, instead of bloating the initial bundle for every load.

* chore(nix): update pnpmDeps hash for vue-router

Adding vue-router changed pnpm-lock.yaml, which invalidated the fetchPnpmDeps hash. Set it to the value reported by the nix build.

* fix(web): return to app root when closing the design system

In-page nav anchors push hash history entries, so router.back() only stepped through them and required multiple clicks to leave. Navigate to / directly; the client lives above the route so session state is preserved.

* feat(web): let /design-system bypass the auth gate

Render the design-system route ahead of the auth/server gates and skip the /login rewrite for it, so a direct deep link shows the showcase even before the app is OAuth-ready. The view is read-only and holds no user data, matching the old static page behavior.

* fix(web): make the design-system root scrollable

The route renders inside .app-shell (height:100dvh; overflow:hidden), so a position:fixed root could be clipped. Make .ds-page a flex item that fills the shell and scrolls internally, so later sections and hash navigation remain reachable.

* fix(web): preserve /design-system during initial session load

On load the app auto-selects the first session and rewrites the URL to /sessions/<id>, which clobbered a deep-linked /design-system. Skip the session URL write while on the design-system route so refreshing keeps the route.

* fix(web): restore the prior session URL when leaving the design system

Record the URL on entry to /design-system and navigate back to it on close, so a /sessions/<id> URL is preserved instead of falling back to /. This also keeps the earlier fix that sidesteps in-page hash anchors.

* fix(web): capture the real browser URL before opening the design system

Session URLs are rewritten via the native history API, so vue-router's from.fullPath can be stale ('/' after a session is selected). Read window.location on entry instead, falling back to / for a direct deep link.

* fix(web): sync the active session URL when leaving the design system

After a direct deep link to /design-system the app auto-selects a session but the address stays '/'. On close, fall back to the active session's canonical URL so the address bar matches the displayed session.

* fix(web): capture the design-system return path at logo entry

Capture window.location in the logo long-press handler instead of a navigation guard. The guard fired on browser Back/Forward too and overwrote the return path with the design-system URL itself; capturing only at the explicit entry action avoids that.

* fix(web): replace the design-system route when closing

Use router.replace instead of push for the captured return URL, so closing does not append a second app URL after /design-system and the browser Back button returns to the page before the easter egg.

* revert(web): drop the design-system auth-gate bypass

Keep /design-system behind the auth gate so it has a single in-app entry (logo long-press). This removes the deep-link machinery (auth exemption, active-session fallback) that drove most of the URL/session edge cases, while keeping the lazy-loaded route, the flex scroll fix, the logo-entry return-path capture, and replace-on-close.

* refactor(web): rebuild the design-system easter egg as an in-app overlay

The easter egg is a hidden, read-only spec viewer opened by long-pressing the logo; it does not need to be a URL route. Replace the vue-router approach with an overlay: Sidebar opens a lazy-loaded DesignSystemView in a body-teleported full-screen overlay, dismissed by the Back button or Escape. This removes vue-router, the route, the auth-gate exemption, the session-URL guards, and the return-path machinery — none of which the feature needs.

* chore(nix): restore pnpmDeps hash after removing vue-router
2026-07-03 13:42:27 +08:00
Kai
84d8d5b063
feat(agent-core): make compaction notes capture a forward plan, not just the next step (#1342)
Compaction runs at the point of maximum context for the task, and the next
turn resumes with less. So the handoff note now records the plan for the
remaining work — upcoming steps, settled decisions, and foreseeable obstacles,
plus any work that can be pre-committed — instead of only the immediate next
command. Update the affected compaction snapshots and one hardcoded
input-token assertion (the instruction is ~163 tokens longer).
2026-07-03 13:38:09 +08:00
Kai
276407d2a4
feat(agent-core): strengthen the language-matching rule in the default system prompt (#1338)
* feat(agent-core): strengthen the language-matching rule in the default system prompt

* chore: refine changeset wording
2026-07-03 11:58:02 +08:00
wenhua020201-arch
4498ea9ee3
docs: use kimi-for-coding in model overrides example; drop dangling experimental refs (#1337)
* docs: use kimi-for-coding in model overrides example

kimi-for-coding is the stable public model ID users actually configure;
kimi-k2 is the underlying model name and shouldn't appear in the config
example. Demonstrate overrides with max_context_size and display_name.

* docs: drop dangling references to commented-out experimental section

The `## experimental` section was commented out when micro_compaction
was removed, but the top-level fields table and the intro sentence still
linked to the now-dead #experimental anchor. Remove those references.
2026-07-03 11:41:03 +08:00
liruifengv
4c1d0a1633
fix: hide the background updater console window on Windows (#1336)
A detached Windows child gets its own console window. With the shell: true introduced for the CVE-2024-27980 fix, a passive background auto-update could flash a command window even though stdio is ignored. Set windowsHide on the detached background child so the silent updater stays silent. The foreground `kimi upgrade` path is interactive (stdio: inherit, non-detached) and reuses the parent console, so it is left unchanged.
2026-07-03 11:32:31 +08:00
liruifengv
93f16c32d7
fix: run package-manager upgrades through a shell on Windows (#1332)
On Windows, npm/pnpm/yarn are .cmd shims. Since Node's CVE-2024-27980 fix, spawning a .cmd/.bat without a shell throws EINVAL, which broke `kimi upgrade` and background auto-install on Windows. Pass shell: true on win32 so the install runs through the shell.
2026-07-03 11:03:46 +08:00
liruifengv
ceeebc1f85
docs(changelog): sync 0.22.1 from apps/kimi-code/CHANGELOG.md (#1325)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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-02 22:20:57 +08:00
github-actions[bot]
508384502d
ci: release packages (#1291)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-02 21:56:42 +08:00
qer
5441ad1838
feat(web): collapse loaded sessions back to the first page (#1322)
* feat(web): collapse loaded sessions back to the first page

The workspace session list's load-more control was one-way: once expanded, the only way to hide the extra sessions was to collapse the whole group. Add a Show less / Show all toggle so an expanded list can be collapsed back to its first page and re-expanded without losing the loaded data.

Restyle the control as a session-row-shaped pill whose label aligns with the session titles, per design-system section 07, and mirror the behavior in the mobile switcher.

* fix(web): preserve first-page capacity for sparse workspaces

The collapse target was seeded with the exact number of sessions loaded on first paint, which is 0 for an empty workspace and below a full page for a sparse one. Newly created sessions are prepended without bumping that count, so a workspace that was empty on load would hide its first new session behind a Show all control, and a sparse one would hide an older row on each new session even when it had never paged.

Floor the collapse target at one full page so the first-page capacity is preserved.

* fix(web): keep the active session visible in a collapsed group

A collapsed workspace only rendered its first page, so an older session selected from outside the pagination flow — Cmd/Ctrl-K search (loadAllSessions) or a URL deep link (fetchSessionIntoList) — was marked active but had no visible row in the sidebar until the user manually clicked Show all.

Include the active session in the collapsed view (appended in newest-first order) on both the desktop sidebar and the mobile switcher, so selection and search never navigate to a hidden row.
2026-07-02 21:51:28 +08:00
qer
444e6b15f0
fix(web): cap live session subscriptions to reduce lag with many sessions (#1320)
* fix(web): cap live session subscriptions to reduce lag with many sessions

Every opened session stayed subscribed to its WebSocket event stream across reconnects, so opening hundreds of sessions turned background events into a constant reducer and sidebar recompute storm. Keep only the four most-recently-opened sessions subscribed; evicted sessions resume from their tracked cursor on re-open.

* fix(web): reset cursor for sessions evicted from the subscription cap

Some session events (status_changed, meta_updated, ...) are broadcast to every connection and still advance lastSeqBySession for an unsubscribed session. If an evicted session emits per-session durable events and then a global event, the cursor jumps past the missed events, so resuming from it later would skip them and leave the reopened session stale. Track evicted sessions and reset their cursor on the next re-subscribe so the daemon replays or snapshots what was missed.

* fix(web): rebuild evicted sessions from a snapshot on re-open

Two fixes for the subscription cap:

- Re-opening a session that was evicted now rebuilds it from a snapshot instead of resuming from seq 0. Replaying from zero made the projector regenerate assistant/tool message ids, which duplicated the already-loaded transcript; resuming from the kept cursor could skip per-session events that arrived while unsubscribed.

- Eviction now skips the active session wherever it sits in the list, instead of breaking when it lands at the tail. First-time opens retain only after an awaited snapshot, so rapid clicks can complete out of order and leave the active session at the tail, which previously let the list grow past the cap.

* fix(web): keep stale cursor marker until snapshot succeeds

Re-opening an evicted session deleted the stale-cursor marker before the snapshot ran. If the snapshot failed transiently, the marker was gone and a later re-open would fall back to subscribeToSessionEvents, resuming from a cursor that may have skipped per-session events while evicted. Read the marker instead of deleting it, and let syncSessionFromSnapshot clear it once the snapshot succeeds.
2026-07-02 21:48:47 +08:00
liruifengv
3ff401261f
chore: refine changeset wording (#1323)
Simplify the image compression and compaction handoff changesets, and set the image compression bump to patch.
2026-07-02 21:18:32 +08:00
liruifengv
b40bb71399
fix(pi-tui): repaint viewport in place when content collapses above it (#1315)
* fix(pi-tui): repaint viewport in place when content collapses above it

When content shrinks past the viewport top while a line above the
viewport also changes, the clamped differential path left the render
loop empty, cleared deleted lines past the screen bottom, and desynced
the cursor anchor — leaving the viewport blank with the input box gone
until a full redraw.

Repaint the visible viewport in place for that case (no ESC[3J, so the
scrollback and the user's scroll position are preserved), and clamp
deleted-line clearing to the screen bottom so it can never scroll
untracked.

* fix(kimi-code): clear the screen fully on session reset

The collapse repaint in pi-tui intentionally preserves scrollback, so
/new, /clear, and session switches no longer got a clean screen as a
side effect of the destructive full redraw — the previous session's
text stayed above the welcome banner.

Session resets want a pristine screen, so force a destructive full
render explicitly instead of relying on the renderer's shrink
behavior.

* fix(pi-tui): delete kitty images straddling the viewport top on collapse repaint

A multi-row kitty image can start above prevViewportTop while its
reserved rows are still visible. The collapse repaint's image-delete
range started at prevViewportTop and missed the image line carrying the
id, leaving a stale overlay that also dropped out of
previousKittyImageIds tracking. Widen the range to include such a
straddling block.

* chore: shorten session reset changeset wording

* fix(pi-tui): re-anchor the viewport whenever content shrinks below the screen bottom

previousViewportTop only ever grows during normal rendering, so after a
shrink the content bottom could hover above the screen bottom, leaving
dead rows that nothing repaints. Upstream masked this by frequently
doing destructive full redraws, which re-anchored as a side effect; the
fork removed those redraws without replacing the re-anchoring.

Generalize the collapse repaint into a re-anchor check at the top of
the differential path: whenever prevViewportTop exceeds
max(0, newLines - height), repaint the visible viewport in place with
the tail of the new content. The input area snaps back to the screen
bottom, scrollback and the user's scroll position stay intact (no
ESC[3J), and the previous collapse branch becomes a defensive
destructive fallback.

Update the three renderer tests that encoded the old behavior
(destructive redraw on shrink, viewport hover after clamped shrink) to
assert the re-anchored behavior instead.

* fix(kimi-code): full repaint on ctrl+o expansion toggle

Expanding tool output shifts content above the viewport; the clamped
differential render paints the shifted content through the screen,
stacking a duplicate copy below the stale one in scrollback on every
toggle. The toggle is a deliberate user action (like /clear), so do a
destructive full render instead: scrollback holds exactly one copy and
the expanded output stays readable by scrolling up.

* chore: simplify user-facing changeset wording
2026-07-02 20:52:37 +08:00
qer
e8ab7ca786
fix(web): stop session row from shifting on hover (#1319)
The session row swapped the relative time for the kebab on hover using display:none, which dropped the time from layout and appended the kebab at the end. Because the time is variable-width (2h / 5m / just now) and the kebab is a fixed 26px, the right region reflowed: status badges shifted and the title's truncation changed, causing visible jitter.

Place the time and kebab in one inline-grid cell (grid-area:1/1) and swap them via visibility instead, so the slot width stays max(time width, 26px) across hover. The badges and title no longer reflow. Documented in the design system (section 07 Session row) as a trailing action slot rule.
2026-07-02 20:40:47 +08:00
qer
5322c63889
fix(web): trim redundant and incorrect tooltips (#1316)
* fix(web): trim redundant and incorrect tooltips

Drop hover tooltips that only restated a button's accessible label, and remove the permission-pill tooltip that described cycling modes while the control actually opens a dropdown. Keep tooltips that reveal truncated text or explain status.

* fix(web): remove remaining ChatHeader tooltips

Drop the session-title, git-branch, and open-PR tooltips in ChatHeader so the header has no hover tooltips. Broaden the changeset wording to cover the wider trim.

* fix(web): keep tooltips from getting stuck on unmount

Tooltip attached its mouseleave listener to the slotted trigger element once, so if that element was removed (e.g. a v-if toggled while hovering a tool-call path) the open bubble never received mouseleave and stranded on screen. Re-sync the listener to the live slotted element via a MutationObserver and close the tooltip whenever the trigger changes.

* chore: add changeset for stuck-tooltip fix

* fix(web): restore session title tooltip in ChatHeader

The session title is truncated with an ellipsis when it exceeds the header width, so the tooltip was the only way to read the full name from the header. Restoring it keeps the truncation-revealing hint while the redundant git/branch/open-PR tooltips stay removed.
2026-07-02 19:53:01 +08:00
Kai
78a058acd2
chore(agent-core): remove experimental micro compaction (#1317)
* chore(agent-core): remove experimental micro compaction

* fix(docs): drop micro compaction row from env-vars table
2026-07-02 19:50:51 +08:00
liruifengv
b40649b2ae
chore(tui): remove shortcut newline telemetry (#1311)
The prompt editor no longer needs custom newline interception: the underlying editor handles Shift+Enter and Ctrl+J natively. Drop the interception along with the shortcut_newline telemetry hook.
2026-07-02 19:38:04 +08:00
Kai
4dd926b0ac
fix(agent-core): recover sessions bricked by orphan tool results (#1308)
* fix(agent-core): recover sessions bricked by orphan tool results

A stray `tool` message with no preceding assistant `tool_calls` permanently
bricked a session on OpenAI-compatible providers: every turn re-sent the same
malformed history and got a 400, and switching model/provider did not help.

Two independent gaps caused this:

- kosong did not recognize the OpenAI / DeepSeek / vLLM / Qwen phrasings of the
  tool-exchange structural 400 (`role 'tool' must be a response to a preceding
  message with 'tool_calls'` and the mirror `assistant message with 'tool_calls'
  must be followed by tool messages`), so the post-400 strict-resend fallback
  that drops the orphan never fired.

- The legacy-restore compaction path kept a verbatim tail
  `history.slice(compactedCount)`; when the cut landed inside a tool exchange the
  tail began with an orphan tool result whose assistant was summarized away. The
  normal projection does not repair a leading orphan, so the malformed history
  was baked in and re-sent every turn.

Recognize the additional phrasings so the strict resend un-bricks any session,
and trim leading tool results from the legacy-restore tail so the orphan is
never persisted in the first place.

* fix(agent-core): drop orphan tool results at the projection boundary

Rework the legacy-restore half of the fix based on review feedback: mutating
`_history` at restore time desyncs every consumer that models the history from
the wire records — the transcript reducer's fold length would overcount and
make MessageService skip unflushed live-tail messages.

Keep the restored history faithful to the wire records instead, and drop a
`tool` result whose call is nowhere in the history at the projection boundary,
on every request-building projection: the normal wire (`messages`), the
post-400 strict resend (`strictMessages`), and the compaction summarizer. An
orphan is wire-invalid on strict providers and useless to the model either
way, so it never reaches a provider — no longer relying on recognizing the
provider's 400 phrasing to recover. Fragment projections (e.g. token-estimating
a history slice) leave results untouched, since a matching call may
legitimately sit outside the slice.
2026-07-02 19:29:05 +08:00
Kai
329846c569
feat(agent-core): keep head and tail of user messages during compaction (#1313)
* feat(agent-core): keep head and tail of user messages during compaction

Compaction used to keep only the most recent 20k tokens of real user
input, so the original task statement was the first thing to vanish in
long sessions. Now, when the user-message pool fits the 20k budget it is
still kept whole; when it overflows, the oldest 2k tokens and the most
recent 18k are kept instead, with an elision marker between the two
segments telling the model what was omitted and that the summary covers
it. The summary prefix and the default system prompt describe the new
shape as well.

The new `keptHeadUserMessageCount` record field keeps restore and the
wire-transcript folded length consistent: records without it (written by
older versions) restore with the original tail-only selection that
produced them, and the vis model-mode projection mirrors the same
head/marker/tail rebuild.

* style(agent-core): drop redundant spread over slice in head selection
2026-07-02 19:24:37 +08:00
qer
d1275557f9
docs(web): translate design system to English and add anti-slop guidance (#1302)
* docs(web): add anti-slop design guidance inspired by taste-skill

Codify one icon family (Remix) with no hand-rolled SVG in §02. Expand the banned AI-tell list in §01 (AI-purple/blue glow, infinite-loop micro-animations). Add button and form contrast requirements to §08. Add a 'declare design intent (Design Read) first' callout.

* docs(web): resolve merge conflict with main

Reset design-system.html to latest main (which includes the merged #1300 and #1301) and re-apply the taste-skill design guidance on top, so the branch merges cleanly.

* docs(web): translate design system to English

Translate the entire design-system.html from Chinese to English, preserving all HTML structure, CSS, code blocks, SVGs, the scroll-spy script, and token values. The design system is now a single English document.
2026-07-02 19:16:04 +08:00
Kai
0fc0ae380b
feat(agent-core): announce image compression and keep originals readable (#1304)
* feat(agent-core): announce image compression and keep originals readable

Every image ingestion point (ReadMediaFile, MCP tool results, clipboard
paste, REST upload/inline base64, ACP) now places a <system> caption next
to a compressed image stating the original vs. delivered dimensions, byte
size, and format, so downsampling is never silent to the model.

Originals stay readable: file uploads point at the stored blob, and
in-memory images are persisted into the session's media-originals dir
(content-addressed, size-capped, removed with the session; temp-dir
fallback when no session is known).

ReadMediaFile gains region (crop in original-image pixel coordinates,
delivered at full fidelity) and full_resolution (skip downscaling, with
an explicit error over the per-image byte limit), so the model can zoom
into fine detail instead of silently degrading on large images.

* fix(agent-core): exempt compression captions from the MCP text budget

The caption announcing an image's compression was inserted before the
shared 100K text budget was applied, so a chatty MCP result (page text +
screenshot) consumed the budget first and the caption was evicted — or
sliced mid-string into an unclosed <system> fragment — while the
downsampled image survived, silently reintroducing the exact degradation
the caption exists to report, and orphaning the persisted original.

Split the size-limit pass in two and reorder the pipeline: the text
budget now runs on the tool's own text BEFORE compression inserts
captions (exempt by construction), and the per-part binary cap still
runs after compression so compressible screenshots are kept.

* fix(agent-core): harden crop error reporting and document readback semantics

- cropImageForModel rejects non-finite region coordinates with a clean
  message instead of surfacing the codec's internal validation dump
- the full_resolution and cropped-region over-budget errors now include
  exact byte counts alongside the rounded sizes, so a file a hair over
  budget no longer reads "is 3.8 MB, over the 3.8 MB limit"
- read-media.md notes that re-reading a file without region or
  full_resolution reproduces the same downsampled image
2026-07-02 19:07:56 +08:00
liruifengv
77eb3a9fe4
feat(tui): include shell commands in input history (#1295)
* feat(tui): include shell commands in input history

Shell commands entered through the `!` prompt are now saved to input history. Recalling one restores bash mode, and in bash mode Up only cycles through previous shell commands while a normal prompt browses all history.

* docs(interaction): document shell command recall in input history

Note that shell commands are now saved to input history and can be recalled in Shell mode, in both the English and Chinese interaction guides.

* feat(pi-tui): add setHistoryFilter and onRecall to editor history

Add two first-class hooks to the editor's history navigation: setHistoryFilter to limit which entries Up/Down visit, and onRecall to decorate a recalled entry before it is shown. Draft restore, direction-aware cursor placement, and undo behavior are unchanged.

* refactor(tui): use pi-tui history filter for shell command recall

Replace the CustomEditor navigateHistory shadow with pi-tui's setHistoryFilter + onRecall hooks, wired in the editor-keyboard controller. This keeps pi-tui's draft-restore and direction-aware cursor behavior intact (the shadow dropped both) and moves the shell/prompt filtering and mode-restore logic into the business layer.

* feat(pi-tui): save and restore host state with the history draft

Add onHistoryDraftSave/onHistoryDraftRestore hooks so hosts can stash their own state when entering history browsing and restore it when the user navigates back to the draft. The saved host state is discarded when browsing ends any other way (typing, submit), mirroring the editor draft lifecycle.

* fix(tui): restore input mode when returning to the history draft

Wire pi-tui's history draft save/restore hooks to the editor input mode. Without this, recalling a shell entry and then pressing Down back to an empty draft left the editor in bash mode, so the next typed message was submitted as a shell command.

* fix(pi-tui): capture host draft state before running the history filter

Fire onHistoryDraftSave before the history filter runs when entering browse, so the host's filter can read the browse-entry mode rather than a mode that changes as entries are recalled. The captured state is still only committed once a matching entry is found.

* fix(tui): lock history filter to the browse-entry mode

Lock the history filter to the input mode captured when entering browse. Previously the filter read inputMode live, so after recalling a shell entry (which flips to bash mode) a second Up would only show shell commands.
2026-07-02 17:59:26 +08:00
liruifengv
2639786ce5
fix(pi-tui): prevent crashes on very narrow terminals (#1303)
* docs: add pi-tui narrow-width fix plan

* fix(pi-tui): stop wordWrapLine infinite recursion on wide graphemes at width 1

* docs: extend pi-tui narrow-width plan with emoji grapheme regression coverage

* fix(pi-tui): clamp container render width to a minimum of 1

* fix(pi-tui): truncate overwide rendered lines instead of throwing

* perf(pi-tui): fast-path overwide line detection and enlarge width cache

* test(pi-tui): assert exact truncated viewport in overwide line test

* docs: record review amendments in pi-tui narrow-width plan

* test(pi-tui): add editor narrow-width regression tests

* docs: record task 4 review amendments in pi-tui narrow-width plan

* fix(pi-tui): guard blank-line padding against negative widths

* docs: record task 5 review amendments in pi-tui narrow-width plan

* docs(pi-tui): document local divergences from upstream

* chore: add changeset for pi-tui narrow width fixes

* docs(pi-tui): point Text guard test to its actual test file

* test(pi-tui): translate narrow-width test comments to English

* docs: remove internal pi-tui narrow-width plan

* docs(pi-tui): translate AGENTS.md to English
2026-07-02 17:14:11 +08:00
qer
c3653a1c50
refactor(web): show an up arrow on the composer send button (#1301)
* refactor(web): show an up arrow on the composer send button

* docs(web): regenerate design-system catalog with the send up-arrow
2026-07-02 15:56:48 +08:00
qer
7fa18c9e45
docs(web): sync design system with the Remix icon switch (#1300)
* docs(web): sync design system with the Remix icon switch

Update the §02 icon guidance to describe Remix Icon (fill, 24x24, registry-sourced) and drop the stale 'line-icon' wording. Convert the §03 component-gallery demo icons from hand-drawn stroke SVGs to Remix fill icons. Clarify that the workspace-group add button reveals on hover or keyboard focus for accessibility. Sync public/design-system.html with design/.

* docs(web): show composer send button as an up arrow
2026-07-02 15:34:33 +08:00
Kai
021de5433b
feat(agent-core): align model-facing prompts with actual tool behavior (#1296)
* feat(agent-core): align model-facing prompts with actual tool behavior

A hunk-by-hunk accuracy pass over every model-visible prompt surface
(system.md, tool .md descriptions, zod describes, profile role prompts,
and injected reminder strings), with each claim verified against the
implementation and, where possible, empirically (ripgrep semantics).

Fix descriptions that drifted from the code:
- Grep `glob` matches against each file's absolute path, so
  `src/**/*.ts` silently matches nothing — document the working forms
- Glob `path` accepts relative paths; results are files-only
- FetchURL no longer promises a content-type-to-mode mapping the
  default provider does not honor
- cron: a pinned-date 5-field expression repeats yearly unless
  `recurring: false`; drop a bench-only env knob from cron-list
- skill `args` expansion covers $NAME/$1/$ARGUMENTS and the trailing
  ARGUMENTS: line; goal reminder no longer cites a nonexistent
  developer-message channel

Disclose enforced-but-silent behavior:
- cron fires deliver only while the session is idle; expressions with
  no fire within 5 years are rejected at create time
- VCS metadata directories are always excluded from Glob/Grep, even
  with include_ignored; sensitive-file guard exemptions
  (.env.example/.env.sample/.env.template, public SSH keys)
- large images may be downsampled while the <system> block reports
  original dimensions; subagent summaries under the length floor are
  sent back for expansion; background-disabled Agent calls are
  rejected before launch; AGENTS.md beyond ~32 KB triggers a
  performance warning (surfaced in the /init prompt)

Resolve cross-surface contradictions:
- AskUserQuestion background describe/envelope no longer teach polling
- AgentSwarm subagent_type documents that resume keeps original types
- bash.md scopes &&-chaining to dependent commands and steers
  independent read-only commands to parallel calls
- the shared system prompt no longer names tools that read-only
  subagent profiles lack

Add missing guidance:
- denied/rejected tool calls mean the user declined that action —
  adjust, don't retry or route around (root agent)
- plan subagent now knows it is read-only; coder subagent knows its
  final message is the entire handoff; explore subagent knows web
  tools are in scope
- gh CLI routing for GitHub-hosted work; FetchURL login-wall note;
  a dual-use content-safety boundary; scope discipline,
  surrounding-idiom, and dependency-verification norms; file:line
  citation convention; progress notes on long multi-phase tasks

* fix(agent-core): let the model fetch a background answer after the completion notice

In sessions with background persistence (any agent with a homedir), a
background question's answer is flushed to output.log and the completion
notification carries an <output-file> pointer, not the answer text. The
previous envelope wording ("use TaskOutput only to re-read the answer if
you missed the notification") gated the normal post-completion fetch
behind a missed-notification condition, so a model could acknowledge the
notice and continue without ever reading the user's answer.

Reword the envelope to state that the completion notice may carry a
pointer and to direct the model to read that file (or call TaskOutput
once) for the answer, while still forbidding polling before the user
responds. Align the background param describe the same way ("notified
automatically" rather than "the answer arrives", polling scoped to the
pending window).

* fix
2026-07-02 14:51:30 +08:00
qer
0b4c05e394
fix(kimi-desktop): make the sidebar header buttons clickable (#1289)
On macOS the sidebar header has a hidden title bar, so the whole header doubles as the window-drag region (matching the chat header). The collapse / settings buttons sit inside it and were being captured by the drag, so they would not click. Mark the buttons and the logo as no-drag inside the drag-region header — the same no-drag-inside-drag pattern ChatHeader.vue already uses — so they receive clicks normally.
2026-07-02 14:39:52 +08:00
qer
6a469b3e07
refactor(web): replace hand-written icons with Remix Icon (#1293)
* refactor(web): replace hand-written icons with Remix Icon

Generate a tree-shaken Remix Icon subset at build time via @iconify/utils + @iconify-json/ri, keeping the <Icon>/iconSvg() API.

Add a chat-new icon for the new-chat buttons and reveal the workspace 'new chat in group' button on hover. Unify the message copy and undo buttons (matching hover style and tooltip, drop the undo hover label, align sizes). Switch the mobile switcher kebab to the horizontal dots icon and tweak sidebar search colors. Regenerate the design-system icon catalog.

* fix(web): address PR review feedback

Restore accessible names (aria-label) on the message copy and undo buttons. Keep the workspace add button reachable for keyboard users by revealing it on header focus-within. Update the nix pnpmDeps hash for the newly added icon dependencies.

* fix(web): address follow-up review feedback

Keep the workspace add/more buttons focusable without hover by revealing them via opacity instead of display:none, so keyboard and non-hover users can reach the control.

Drop explicit .ts extensions in icon imports to satisfy oxlint, and read the design-system icon catalog directly from the generated icon data.
2026-07-02 14:20:17 +08:00
Kai
93ec6cb652
fix(kosong): recognize OpenAI-compatible tool_call_id 400 as a recoverable tool-exchange error (#1292)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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
Moonshot / Kimi (OpenAI-compatible) rejects a history whose tool message
references a tool_call_id with no matching tool_calls entry in the preceding
assistant message as `400 tool_call_id  is not found`. The
TOOL_EXCHANGE_ADJACENCY_MESSAGE_PATTERNS only covered Anthropic's
tool_use/tool_result phrasing, so isRecoverableRequestStructureError returned
false, the strict-resend fallback in executeLoopStep never fired, and the
session stayed permanently stuck re-sending the same rejected history every
turn (observed in the field after a manual compaction busted the prompt cache
and forced full revalidation of a latently misordered prefix).

Add the tool_call_id-anchored pattern so the whole recovery chain — strict
projection (adjacency repair, orphan-result drop, synthetic results) plus the
one-shot resend — now also covers the default provider. Covered by classifier
unit tests and an e2e resend-and-recover case.
2026-07-02 13:52:44 +08:00
Kai
ea55911062
feat(agent-core): sharpen the compaction handoff prompt (#1283)
* feat(agent-core): sharpen the compaction handoff prompt

Refine the first-person handoff note the model writes at compaction so it
preserves what actually gets dropped instead of what already survives:

- Lead with the intent of the latest request, not a verbatim re-transcription
  (the recent user messages are already kept verbatim beside the summary);
  name which request governs when several are in play.
- Carry forward tool results — the concrete values, key lines, schemas — not
  just the commands that produced them.
- Keep settled decisions separate from still-open questions, and name the
  context the next turn must go and re-check.
- Write in the conversation's language, keep the note proportional to the task,
  and don't re-transcribe the auto-attached TODO list.

Also correct the system prompt's description of the post-compaction shape: the
recent user messages come first, followed by a first-person summary (not a
rigidly sectioned report), and a newer kept message supersedes the summary.

Update the affected inline snapshots and the compaction request token count.

* fix(agent-core): preserve an oversized latest request in the handoff note

selectRecentUserMessages truncates a kept user message to the size cap,
keeping only its prefix, so when the latest request itself exceeds the cap
only its head survives verbatim beside the summary. Telling the summary
"don't re-transcribe, it survives verbatim" then permanently dropped the
tail — often the actual ask. Keep the intent-not-transcription guidance,
but require preserving the at-risk parts of a long current request.
2026-07-02 13:44:29 +08:00
Kai
c434b4c3e6
fix(agent-core): cap foreground shell output to prevent OOM crash (#1285)
* fix(agent-core): cap foreground shell output to prevent OOM crash

A foreground command that streams a very large or unbounded amount of output (e.g. `b3sum --length 18446744073709551615`) grew the live-output buffer until Node aborted with a JavaScript heap out-of-memory error (exit 134). The per-command output is now capped at 16 MiB: on breach the command is gracefully terminated (SIGTERM -> grace -> SIGKILL) and the result carries a message pointing at redirecting large output to a file. The per-task output ring buffer is also made O(1) per chunk (was O(n^2)), which previously starved the event loop and the foreground timeout. Background/detached tasks are exempt.

* fix(agent-core): stop buffering output after the foreground cap trips

After the 16 MiB foreground ceiling tripped, appendOutput still enqueued every subsequent chunk into the per-task disk write chain during the SIGTERM grace window. A producer that ignores SIGTERM could keep that chain — and the chunk strings each pending write retains — growing until SIGKILL, re-introducing the same out-of-memory risk the cap prevents. Once the cap has tripped, keep only the bounded in-memory ring buffer and stop feeding the disk write chain. Interrupt/timeout capture behaviour is unchanged (they do not set outputLimitTripped).
2026-07-02 13:42:39 +08:00
qer
3ea84a56e4
fix(web): prevent horizontal scrollbar in session search dialog (#1290)
The search sessions dialog used a column flex layout where the title and snippet had white-space: nowrap but no min-width: 0, so the flex items refused to shrink below their text width and overflowed, surfacing a horizontal scrollbar on the dialog body. Add min-width: 0 so they shrink and ellipsize instead.
2026-07-02 12:24:46 +08:00
liruifengv
a4d7a4ff2a
docs(changelog): sync 0.22.0 from apps/kimi-code/CHANGELOG.md (#1288) 2026-07-02 11:34:17 +08:00
github-actions[bot]
ba7f18b3fb
ci: release packages (#1268)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-02 11:12:20 +08:00
qer
bbda90af84
fix(web): hide conversation outline when it cannot expand (#1278)
Some checks are pending
Release / Publish native release assets (push) Blocked by required conditions
CI / build (push) Waiting to run
CI / test (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
* fix(web): hide conversation outline when it cannot expand

* fix(web): re-measure conversation outline when it becomes visible

When ConversationToc mounts while hidden (sessionLoading, mobile, or before a second user turn), navRef is null and the ResizeObserver is never set up, so fits stays true and the outline shows even in narrow layouts. Re-initialize the measurement whenever the nav is rendered.
2026-07-02 00:44:11 +08:00
Kai
074bb9ba13
fix(kosong): retry a dropped provider stream (terminated) on the Anthropic path (#1274)
A raw undici `terminated` error — an SSE/HTTP response body cut mid-flight,
common on long streaming responses — fell through convertAnthropicError to a
generic base ChatProviderError, which isRetryableGenerateError treats as fatal,
so it was never retried. Route raw non-SDK errors through the shared
classifyBaseApiError heuristic (already used by the OpenAI path) so a dropped
stream is classified as a retryable APIConnectionError and retried instead of
failing the turn.
2026-07-01 22:48:57 +08:00
liruifengv
a5db546d77
feat: add KIMI_MODEL_THINKING_EFFORT to force a thinking effort (#1275)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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
* feat: add KIMI_MODEL_THINKING_EFFORT to force a thinking effort

Send thinking effort only when the model declares it in support_efforts, and add the KIMI_MODEL_THINKING_EFFORT environment variable as an escape hatch to force a specific effort regardless of declared support.

* test: align thinking effort expectations with support_efforts gating

Update the kimi adapter e2e and compaction tests that asserted the previous pass-through behavior on models without support_efforts.
2026-07-01 21:20:27 +08:00
liruifengv
54703d9457
fix(tui): restore terminal state on crash and release leaked resources (#1272)
* fix(tui): restore terminal state on crash and release leaked resources

Restore raw mode, cursor, and flow control on uncaughtException, unhandledRejection, and SIGTERM cleanup failure; reclaim pasted image bytes when transcript entries are trimmed; and stop feedback, activity, transcript, footer, and editor timers during shutdown.

* fix(tui): keep crash handlers installed and attach stty to the tty

Keep uncaughtException / unhandledRejection handlers installed for the whole interactive session; removing them right after start() resolved left runtime crashes uncaught. Run stty with stdin inherited from the TTY, since stty fails when stdin is /dev/null.
2026-07-01 21:14:19 +08:00
qer
b905dd4910
feat(web): redesign web UI and add design system (#1258)
* feat: redesign web ui & add design system

* feat(web): add motion to redesigned UI and add changesets

Animate toast enter/leave, dialog open, and workspace-list and tool-row expand/collapse instead of snapping, and add the changesets covering the web redesign.

* fix(web): remove undo message exit animation

* fix(web): route agent tools to AgentTool and focus dialog on open

- toolRegistry matched the raw 'agent' name, but normalizeToolName folds
  agent/subagent into 'task', so agent calls fell through to GenericTool
  and lost the inline Open button for the subagent detail panel.
- Dialog's focus watcher only fired on change; callers that mount with
  open already true (Login, Settings, ...) never moved focus into the
  modal. Run it immediately so initial focus and restore-on-close work.

* feat(web): add logo long-press design-system easter egg

Hold the sidebar logo for 3 seconds to open a dialog showing the design system page. Also trim and rebalance the redesign changesets.

* fix(web): silence Sidebar v-show warning by making it single-root

Nest the design-system Teleport inside the sidebar <aside> so the component has a single element root. App applies v-show to Sidebar, which needs an element root to attach to; the fragment root logged a "non-element root node" warning on every reactive update and the collapse did not take effect.

* fix(web): thinking toggle, tool-group i18n, agent detail button

- Show the default-thinking switch as on whenever thinking is effectively enabled (enabled !== false), matching the core resolver.

- Route the grouped tool-call header and status through vue-i18n.

- Hide the subagent "Open detail" button when no matching task exists (e.g. a completed foreground subagent after a refresh).

* fix(web): use strict equality in agent detail button guard

oxlint eqeqeq flagged the loose != null check; resolveAgentTaskId returns string | undefined, so compare with !== undefined.

* chore(web): remove stray design mockups and screenshots

Remove the design exploration mockups, screenshots, prompts, and notes that were accidentally committed under apps/kimi-web/design. Keep design-system.html, which the sidebar logo easter egg still references.

* fix(web): keep sessions on continuation failure, treat absent thinking as on

- Keep sessions already loaded from earlier pages when a continuation page fetch fails, instead of replacing the workspace with an empty page.

- Treat an absent thinking config as enabled in the settings toggle, matching the core resolver (thinking is on unless explicitly disabled).

* feat(web): open design-system easter egg on 10 logo clicks

Replace the 3-second long-press trigger with 10 consecutive clicks on the Kimi mark; the count resets after a short idle. The long-press was unreliable because pointerleave cancelled the timer on any drift.

* fix(web): treat cancelled swarm members as finished

phaseForTask now lets a terminal status (completed/failed/cancelled) override a stale subagentPhase, so a cancelled swarm member no longer stays live and suppresses the finished AgentSwarm card.

* feat(web): use 1s long-press for design-system easter egg

Switch the logo easter egg back to a long-press, shortened to 1 second, and make it reliable this time: use pointer capture plus touch-action:none so a slight drift no longer cancels the hold.

* fix(web): use plain Spinner for activity notices

ActivityNotice renders for non-chat loading states (e.g. compaction), so it must use Spinner per the design-system rule that reserves MoonSpinner for the chat first-response state.

* docs(web): add a11y guidance to design system

* docs(web): drop stale design README link

* fix(web): restore model search focus and define panel header weight

- Bind the model picker's search Input to searchRef so useDialogFocus moves focus into it on open instead of the dialog's close button.

- Use the defined --weight-semibold token for panel header titles (--weight-bold is not declared, so the shorthand was invalid).

* fix(web): let any open dialog own Escape over the side panel

Track open design-system Dialog instances in a shared count and include it in App.vue's anyOverlayOpen, so a dialog whose open state lives outside App.vue (such as the sidebar session search) captures Escape before the background side panel closes.

* fix(web): base dock-work flag on filtered dock task lists

Foreground subagents are excluded from the dock task lists, so a session whose only task is a foreground subagent no longer renders an empty workbar above the composer.

* fix(web): create subagent task before forwarding text deltas

A client that subscribes from a snapshot after subagent.spawned already fired never received the lifecycle taskCreated; the reducer only applies taskProgress to existing tasks, so assistant text deltas were dropped and the live subagent detail stayed blank. Emit taskCreated (via patchSubagent) before the text progress, mirroring the tool-progress path.

* fix(web): keep plan, swarm, and goal mode toggles per session

Plan, swarm, and goal modes were stored as global scalars on the web client and a single localStorage key each, so they leaked across sessions. Bind them to the active session via per-session maps, persist per session, and apply server status/events to the originating session so background sessions keep independent state.

* fix(web): keep subagent detail reachable for synthesized tasks

When the web client subscribes after a subagent already spawned, the synthesized subagent task has no parentToolCallId, so the Agent tool's Open-detail button was hidden and the panel would not open. Fall back to the single unmapped subagent task when resolving the detail target in both the button visibility and the panel open paths.

* fix(web): keep session kebab menu from being clipped

Teleport the SessionRow kebab menu to body and anchor it with fixed positioning so the collapsing group-sessions list's overflow:hidden no longer clips the dropdown.

* fix(web): apply staged modes to the created session by id

When starting the first prompt, apply the staged plan/swarm/goal modes to the just-created session's per-session maps by id instead of via the activeSessionId-based setters, so a session switch during the selectSession await can't drop the modes for this session or pollute another.

* refactor(web): unify confirmation dialogs into a single modal

Replace the inconsistent confirmation patterns (native confirm(),
two-step menu arming, hand-rolled inline strips, bare buttons) with one
modal ConfirmDialog driven by a global useConfirmDialog() composable,
and consolidate the duplicated confirm/cancel i18n keys.

* feat(web): inline message queue with separate stop button

- Send button always sends/enqueues; interrupt moves to a separate red Stop
  button shown only while running, so the two can no longer be confused.
- Queued prompts now render inline at the tail of the transcript (after the
  running turn) instead of behind the dock panel: click to edit, remove, drag
  the grip to reorder, with image thumbnails and a "next up" marker.
- Remove the dock queue panel and the QueuePane component; Steer stays on
  Ctrl/Cmd+S.

* chore: add changeset for web queue UX

* feat(web): prompt reliability, sidebar menu, and composer/markdown polish

- Fix spurious errors when question/approval/task actions were already complete
- Add loading feedback to question and approval prompts; block double-clicks
- Make the question "Other" option selectable by row click and let Enter advance/submit
- Consolidate workspace section actions into an overflow menu
- Tighten markdown prose line-height and block spacing
- Recall input history only when the caret is at text start
2026-07-01 20:47:12 +08:00
liruifengv
7859b0afe8
feat(kimi-code): vendor @moonshot-ai/pi-tui (#1254)
* chore(kimi-code): upgrade pi-tui to 0.78.1 and adapt native helpers

Bump @earendil-works/pi-tui from ^0.74.0 to ^0.78.1. pi-tui 0.75.5 replaced its koffi-based Windows VT input with a bundled native helper, and 0.76.0 added a darwin native helper for Terminal.app Shift+Enter.

- SEA build: teach native-deps to collect pi-tui's per-target .node files, drop the koffi registry, and add a native-file-only collect mode so only package.json + the target .node ship (28 -> 2 files).

- Redirect pi-tui's absolute-path native require() into the native-asset cache through the Module._load hook, and extend the native smoke test to actually load the helper.

- npm package: ship pi-tui's native/ directory so macOS Terminal.app Shift+Enter and Windows Shift+Tab keep working for npm installs.

* chore: add changeset for pi-tui upgrade

* chore: vendor @earendil-works/pi-tui 0.80.2

Fork the upstream pi-tui source into packages/pi-tui for local modification. Pristine snapshot of @earendil-works/pi-tui@0.80.2; the apps/kimi-code dependency on ^0.78.1 from npm is left unchanged.

* feat(kimi-code): integrate vendored @moonshot-ai/pi-tui

Replace the npm @earendil-works/pi-tui dependency with the vendored @moonshot-ai/pi-tui workspace package so the fork can be modified locally.

- Point apps/kimi-code imports and native-deps at @moonshot-ai/pi-tui.

- Make pi-tui source-first (exports -> src, publishConfig.exports -> dist, mirroring node-sdk) and strict-clean: bracket access for process.env / named capture groups, an override modifier, and non-null assertions for noUncheckedIndexedAccess.

- Bump the root tsconfig target to ES2024 and enable allowImportingTsExtensions (needed for pi-tui's /v regex and .ts imports, which node --test requires).

- Add packages/pi-tui to flake.nix workspaces and exclude the vendored source from oxlint.

* fix(pi-tui): export package.json for native asset resolution

The SEA native-asset collector resolves the package root via
require.resolve('@moonshot-ai/pi-tui/package.json'). The vendored
package's exports field only exposed ".", which blocked the
"./package.json" subpath and broke build:native:sea with
ERR_PACKAGE_PATH_NOT_EXPORTED.

* chore(kimi-code): sync pi-tui native prebuilds at build time

Copy packages/pi-tui/native prebuilds into apps/kimi-code/native
during build instead of tracking a manual copy in git. Only the
.node prebuilds are copied (not the C sources); the directory is
now a build artifact covered by .gitignore.

* fix(pi-tui): avoid destructive full redraw during streaming

When the first changed line is above the viewport, the differential
renderer fell back to fullRender(true), which clears scrollback and
yanks the user's viewport. On Windows Terminal this jumps to the
absolute top (microsoft/Terminal#20370).

Clamp the diff to the visible viewport when content length is
unchanged (spinner tick / markdown reflow above the viewport), so
streaming no longer triggers a full redraw in those cases. Length
changes still fall back to fullRender to reset the viewport.

* fix(kimi-code): update pi-tui imports in files merged from main

Two files added on main (effort-selector, plugin-command) still
imported the old @earendil-works/pi-tui package name; point them at
the vendored @moonshot-ai/pi-tui.

* chore(nix): update pnpmDeps hash after lockfile regen

* fix(pi-tui): clamp above-viewport diff even when content shrinks

Previously, when the first changed line was above the viewport and
content length changed (e.g. spinner removed at end of streaming), the
renderer fell back to fullRender(true), which clears scrollback and
yanks the viewport to the absolute top on Windows Terminal.

Always clamp the diff to the visible viewport instead, preserving the
user's scroll position. Stale bytes remain in scrollback but are not
visible.

* fix(kimi-code): keep activity placeholder to avoid streaming shrink

When streaming ends, removing the activity spinner shrank the content
by two rows, which (combined with transient→final code highlighting
above the viewport) triggered a destructive full redraw. Keep a one-row
placeholder in the activity pane when idle so the content does not
fully shrink.

* chore: refine streaming scroll changeset wording

* chore: remove obsolete pi-tui native helpers changeset

* chore: add pi-tui changesets and document the pi-tui changelog rule

Add changesets for the fork integration, package manifest export, and
viewport clamp fix so the vendored pi-tui keeps its own changelog.

Update the gen-changesets skill to treat @moonshot-ai/pi-tui as a
special internal package that lists itself instead of the CLI, with a
separate CLI changeset only when the change is user-visible there.

* chore(nix): update pnpmDeps hash after merging main

* fix(changeset): drop private kimi-code-docs from google-genai changeset
2026-07-01 20:23:35 +08:00
liruifengv
3cb7936cf5
chore(telemetry): track conversation undo, shell mode, and effort changes (#1271)
* chore(telemetry): track conversation undo, shell mode, and effort changes

- conversation_undo: fires when an undo reverts history (double-Esc selector, /undo, /undo N)
- shell_command: fires when a ! shell command runs in the TUI
- thinking_toggle: now fires on any effort change (not just on/off flips) and carries { enabled, effort, from }; align the no-session TUI path to the same payload

* chore(telemetry): track conversation_undo in core undoHistory

Tracking it in the TUI only covered the TUI; web/acp/REST undos reach
core.rpc.undoHistory directly and were missed. Emit the event from the
agent undoHistory RPC after the undo succeeds so every host is covered,
and drop the now-redundant TUI track.

Addresses review feedback on #1271.
2026-07-01 20:04:31 +08:00
liruifengv
c070fbedde
feat: add model alias overrides (#1262)
* feat: add model alias overrides

Preserve user model overrides across provider catalog refreshes and resolve effective model metadata for runtime, TUI, protocol, and ACP consumers.

* fix: apply model display name overrides

Show overridden model display names in the footer, welcome panel, status output, and model switch confirmations.

* fix: pass through kimi effort when undeclared

Keep support_efforts authoritative when declared, but pass requested Kimi thinking effort through when the model does not declare support_efforts.

* fix: honor model overrides in effort commands

Use effective model metadata for /effort choices and for always_thinking clamping when resolving thinking effort.
2026-07-01 19:57:13 +08:00
qer
0e279bfd7e
chore(kimi-desktop): rename installers to kcd-beta-alpha-crazy-internal (#1267)
* chore(kimi-desktop): rename installers to kcd-beta-alpha-crazy-internal

New artifact name: kcd-beta-alpha-crazy-internal-v50-<arch>-<MMDD>.<ext>,
where MMDD is the build date in UTC+8. This makes leaked or forwarded
installers harder to mistake for an official release, and the date makes
each build easy to identify.

* chore(kimi-desktop): uppercase KCD in installer name
2026-07-01 19:50:11 +08:00
Kai
ace7901066
feat(agent-core): compress oversized images before sending to the model (#1243)
* feat(agent-core): compress oversized images before sending to the model

Downsample images to a 2000px longest-edge and per-image byte budget at the
single prompt-ingestion chokepoint (the prompt/steer RPC) and on tool results
(ReadMediaFile, MCP), so every client transport — CLI, web, desktop, ACP, SDK —
is covered uniformly inside the core. PNG screenshots stay lossless and only
degrade to JPEG when the byte budget cannot otherwise be met. Best-effort: the
original image is sent unchanged if compression fails.

* fix(agent-core): serialize prompt/steer RPCs to avoid a turn-claim race

The prompt/steer RPC handlers await image compression before turn.launch()
synchronously claims the active turn, so two overlapping calls could both
compress first — letting the faster-to-compress one win the turn and strand the
other on agent_busy. Run these two RPCs through a per-agent serialization chain
so they claim in submit order; cancel and the other RPCs stay immediate.

* fix: update flake.nix pnpmDeps hash for the jimp dependency

Adding jimp to the workspace changed pnpm-lock.yaml, so the pnpmDeps
fixed-output hash was stale and the nix build failed. Update it to the value
the CI nix build reported.

* fix(agent-core): guard image compression against decompression bombs

A tiny-byte, huge-dimension image (e.g. a solid 30000x30000 PNG) would be fully
decoded into a multi-gigabyte bitmap by Jimp before any resize — an OOM vector
the byte budget never catches. Skip compression when the sniffed pixel count
exceeds MAX_DECODE_PIXELS (~100 MP), before the decode; oversized images pass
through uncompressed as they did before compression existed.

* fix(agent-core): cap decode byte size before compressing images

Compression runs before downstream size caps (e.g. the 10MB MCP per-part
limit), so a huge or invalid base64 image from an MCP tool was Buffer.from-
decoded — and handed to Jimp — just to be dropped afterward. Add a
MAX_DECODE_BYTES ceiling (64MB, overridable) checked before the base64 decode
and before Jimp, the byte-side complement to the pixel-count guard; oversized
payloads pass through uncompressed.

* refactor(agent-core): compress images at ingestion, not on the turn RPC

Move image compression off the prompt/steer RPC path and back to each ingestion
site (CLI paste, server upload resolution, ACP conversion; ReadMediaFile and MCP
already compressed at their producers). Compressing on the RPC control path put
an async step before the synchronous turn-claim, which spawned a series of
races: prompt/steer interleaving, and — with a cancel arriving mid-compression —
an ineffective abort that let a cancelled prompt launch anyway.

Treating compression as a pure input-stage transform (done while the content
part is built, before it ever enters the agent loop) removes those races
structurally: rpc.prompt/steer are plain synchronous handlers again, and the
serialization/cancel-window machinery is gone. Records stay compressed, resume
stays consistent, and coverage degrades gracefully (a new client that skips
compression just sends a larger image, as before this feature).

* fix: compress inline base64 prompts and honor ACP cancels mid-compression

Two contained ingestion-site follow-ups:

- server: resolvePromptMediaFiles now also compresses images submitted as an
  inline `{ kind: 'base64' }` source, not just uploaded files, so the REST
  inline-base64 path gets the same downsampling.
- acp-adapter: AcpSession tracks a pending-abort flag while prompt() awaits
  image compression (before any turn exists). A session/cancel in that window
  flips it, so the prompt returns `cancelled` instead of launching a turn the
  client already stopped.

* fix(acp-adapter): cover all concurrent pre-turn prompts on cancel

The pending-abort marker was a single session field, so with two
`session/prompt` requests compressing large inline images at once the later
one overwrote it and a `session/cancel` could mark only one — the other
launched after the client had cancelled. Track a token per in-flight prompt in
a set and flip them all on cancel so every pre-turn prompt is covered.

* chore(node-sdk): declare jimp as a devDependency

The SDK re-exports the image compressor, whose lazy `import('jimp')` (inside
the bundled agent-core code) is inlined into the published dist. jimp was
resolved only transitively via agent-core, so declare it as an explicit build
input here — matching the CLI — to make the bundling reliable rather than
phantom. It stays a devDependency: jimp is bundled, not a runtime dependency.
2026-07-01 19:36:48 +08:00
Kai
bf35f63c5d
fix(provider): honor base_url for google-genai and vertexai providers (#1269)
* fix(provider): honor base_url for google-genai and vertexai providers

The google-genai and vertexai provider types silently ignored a configured
base_url and always hit generativelanguage.googleapis.com (e.g. a Gemini-
compatible proxy URL + key could not be used). Plumb the endpoint through to
the @google/genai SDK via httpOptions.baseUrl:

- kosong: add baseUrl to GoogleGenAIOptions and inject it into the client's
  httpOptions alongside the existing headers (the SDK merges headers and
  overrides the base host).
- agent-core: forward provider.baseUrl in the google-genai and vertexai
  branches, with GOOGLE_GEMINI_BASE_URL / GOOGLE_VERTEX_BASE_URL env
  fallback. vertexai keeps deriving location from an aiplatform host.
- docs: document base_url for both providers, noting the host root only
  must be given because the SDK appends the API version itself.

Covered by unit tests asserting the URL reaches the kosong config and the
SDK client's httpOptions.

* fix(provider): use the effective base_url for vertex location detection

The vertexai branch forwarded the endpoint from config `base_url` OR the
GOOGLE_VERTEX_BASE_URL env fallback, but service-account detection
(`hasVertexAIServiceEnv` / `vertexAILocation`) still derived the region from
`provider.baseUrl` only. Supplying the regional endpoint via the env fallback
(with a project but no explicit GOOGLE_CLOUD_LOCATION) therefore left location
undefined and silently downgraded Vertex ADC to API-key Gemini routing.

Resolve the effective base URL once and use it for both forwarding and location
derivation, so the env fallback behaves exactly like `base_url`. Add a
changeset for the kosong + agent-core patch release.
2026-07-01 19:33:35 +08:00
Kai
ffe41ac752
fix(changeset): drop private kimi-code-docs from web-search changeset to unblock release (#1270)
The web-search-query-only changeset mixed @moonshot-ai/agent-core (published)
with kimi-code-docs, a private package without a version field that changesets
treats as ignored. Changesets rejects any changeset containing both ignored and
non-ignored packages, which broke the Release workflow's version step. Drop the
private docs entry; it never publishes and needs no changelog.
2026-07-01 19:28:20 +08:00
Kai
e47ca10267
feat(agent-core): slim WebSearch to query-only; fetch page content via FetchURL (#1260)
* feat(agent-core): slim WebSearch to query-only; fetch page content via FetchURL

The coding search backend no longer honors `limit`/`enable_page_crawling` and
always returns full page content, which was token-heavy and frequently
truncated. Realign the web tools around a search+fetch split:

- WebSearch request sends only `text_query`; the tool exposes only `query`.
- Search results drop inline page content and add the source site; full page
  content is now fetched on demand via FetchURL.
- Add a citation reminder to both WebSearch and FetchURL results (in the
  FetchURL front note so it survives body truncation).
- Update tool descriptions and reference docs accordingly.

* fix(agent-core): satisfy no-base-to-string lint and drop redundant | undefined

- Assert the exact serialized WebSearch request body instead of String()-coercing
  a BodyInit, fixing the type-aware no-base-to-string lint error.
- Drop redundant `| undefined` from WebSearchResult optional fields per AGENTS.md.
- Add changeset.

* chore(changeset): bump agent-core to minor for WebSearch input-contract change

Removing the `limit`/`include_content` tool inputs tightens a closed
(`additionalProperties: false`) schema, so previously-valid args are now
rejected — an incompatible change for a released package. Bump minor rather
than patch.
2026-07-01 18:46:15 +08:00
liruifengv
8cfb1657ad
fix(tui): reduce default transcript window to keep long sessions responsive (#1265)
Lower KIMI_CODE_TUI_MAX_TURNS default from 50 to 15 and KIMI_CODE_TUI_HYSTERESIS from 10 to 5 so the TUI keeps fewer transcript turns in long sessions.
2026-07-01 18:41:27 +08:00
liruifengv
003733c751
fix(tui): hide redundant Off option for always-on effort models (#1264)
Always-on models that expose multiple effort levels already render only the effort segments in the /model switcher, so the trailing "Off (Unsupported)" label was non-selectable clutter. Drop it for those models while keeping it for legacy boolean always-on models.
2026-07-01 18:33:56 +08:00
liruifengv
62999caca3
docs(changelog): sync 0.21.1 from apps/kimi-code/CHANGELOG.md (#1259) 2026-07-01 15:15:12 +08:00
github-actions[bot]
70f01ab01b
ci: release packages (#1257)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-01 14:43:39 +08:00
liruifengv
0cc02ac67d
fix(tui): keep waiting spinner during encrypted reasoning streams (#1256)
Empty (encrypted/redacted) thinking deltas no longer switch out of waiting mode, which previously stopped the moon spinner while no thinking component was ever created, leaving a blank spinner-less gap until the first real text/tool token.
2026-07-01 14:39:35 +08:00
wenhua020201-arch
ef61f4369b
docs: document plugin slash commands (#1253) 2026-07-01 13:49:43 +08:00
qer
170b29aa90
feat(vis): support importing debug zips via drag and drop (#1251)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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
* feat(vis): support importing debug zips via drag and drop

Add a window-level drop target so a /export-debug-zip bundle can be
imported by dragging it anywhere into the vis UI, alongside the existing
file-picker button. A full-screen overlay gives feedback during the drag
and while the upload is in flight, and non-zip files are rejected with a
hint.

* fix(vis): gate drop handler to file drags

Match the other drag handlers by checking dataTransfer.types before
calling preventDefault, so non-file drops (selected text or a URL into
the search input) keep their native behavior instead of being swallowed
by the window-level listener.
2026-07-01 12:02:08 +08:00
liruifengv
c2fd9f0494
docs(changelog): sync 0.21.0 from apps/kimi-code/CHANGELOG.md (#1250) 2026-07-01 11:26:44 +08:00
github-actions[bot]
f2c7ec75d3
ci: release packages (#1224)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-01 10:51:54 +08:00
qer
4d936d98b8
fix(kimi-desktop): revert sidebar header padding to 80px (#1242)
Some checks are pending
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (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
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
100px left too much empty space next to the traffic lights; 80px clears
them without the extra gap.
2026-07-01 02:25:41 +08:00
Simon He
7f05f589e7
feat(web): add Mermaid diagram rendering and off-thread KaTeX/Mermaid workers (#1226)
* feat(web): add Mermaid diagram rendering and off-thread KaTeX/Mermaid workers

Enable Mermaid diagram support in the web chat via markstream-vue's enableMermaid(). Set up Web Workers for both KaTeX rendering and Mermaid parsing using markstream-vue's pre-built workers (katexRenderer.worker, mermaidParser.worker), keeping heavy computation off the main thread during live streaming.

* fix(web): skip Mermaid SVG subtrees in file link and markdown link rewriters

processFileLinks() uses a TreeWalker that scans all text nodes in mdRef.
Mermaid diagrams render as inline SVG, and diagram labels containing
file-path-like strings (e.g. src/App.vue) would be replaced with HTML
<button> elements inside SVG <text> nodes, corrupting the rendered diagram.

processMarkdownLinks() similarly queries a[href] inside SVGs; while
isLocalLink() mostly filters these out, explicitly skipping SVG subtrees
is safer.

Add svg to the closest() exclusion in processFileLinks(), and skip
links inside svg in processMarkdownLinks().

* fix(web): satisfy worker import lint

* chore: align mermaid dependency versions

* chore: change mermaid workers changeset to patch

---------

Co-authored-by: qer <wbxl2000@outlook.com>
2026-07-01 02:17:34 +08:00
Kai
8ac337a2b2
fix(agent-core): harden strict-provider wire compliance so malformed history can't brick a session (#1241)
* feat(agent-core): rework compaction to keep only user prompts and summary

* refactor(agent-core): rewrite compaction summary as first-person handoff

Rework the full-compaction summary to read as the agent's own continuing
notes instead of a third-party report:

- compaction-instruction.md: free-form first-person continuation that
  preserves exact commands, paths and outcomes, states the precise next
  action, and flags claimed-but-unverified work rather than trusting it.
- compaction-summary-prefix.md: skeptical "your own working notes"
  framing; drop the collaborative third-party prefix.
- system.md: add compaction-awareness guidance so the model continues
  naturally from a summary and re-checks any reported "done".
- Rename the compaction helpers module to handoff.ts.

Update tests and regenerate snapshots for the new prompt text, and fill
in contextSummary in the restored-compaction replay expectations.

* fix(agent-core): count image/audio/video parts in token estimation

estimateTokensForContentPart returned 0 for image_url/audio_url/video_url,
so auto-compaction triggers, the overflow-shrink budget, the kept-user
budget, and the reported context size all went blind to media — a
media-heavy session could overflow the model window while the estimate
reported a near-empty context. Media parts now carry a fixed estimate
(MEDIA_TOKEN_ESTIMATE), and the content-part switch is exhaustive so a new
ContentPart kind must declare its estimate rather than silently count as
zero.

* feat(agent-core): re-surface active background tasks after compaction

Folding the live context to [recent user prompts, summary] drops the
messages that started background tasks and their status updates, so the
model could forget a task is still running and spawn a duplicate.
injectAfterCompaction now appends a system-reminder listing active
background tasks (with guidance to use TaskOutput/TaskList/TaskStop
instead of re-spawning). It runs only post-compaction and carries an
injection origin, so the next compaction drops and rebuilds it rather
than stacking copies; the all-user-role post-compaction shape is
preserved (no tool-pairing reintroduced).

* test(agent-core): add compaction scenario guards and risk probes

Adds compaction-scenarios.test.ts driving the real Agent/ContextMemory/
FullCompaction machinery:

- A guard test locking in that repeated compaction folds the prior summary
  into the new one instead of stacking two summaries.
- Seven `it.fails` probes that executably reproduce known, currently-accepted
  edge-case defects so the suite stays green while documenting each one
  precisely; any of them will flip red (forcing removal of `.fails`) the day
  the behavior is fixed. They cover: assistant/tool appended during an
  in-flight summarizer call being dropped; unbounded shrink on empty
  summaries; the fixed 20k kept-user budget overflowing a small model window;
  a tool result orphaned when compaction starts mid-exchange; legacy
  compaction records dropping their verbatim tail on replay; micro-compaction
  clearing recent tool results in an overflow-shrunk suffix; and media being
  discarded when the oldest kept user message is truncated.

* fix(agent-core): repair tool_use/tool_result adjacency in projected context

A tool call and its result can end up non-adjacent in history — a
background-task notification or flushed steer lands between them, or an
interrupted/nested step delays the result — which strict providers reject
with HTTP 400. The projector now moves each tool_use's result up to
immediately follow it (projection-time only; the stored history is
untouched), and full compaction projects its summarizer input with a
synthetic result for any still-open call so the summary request stays
well-formed. Micro-compaction only surfaced this latent ordering by busting
the prompt cache, so it now defaults off.

Includes projector adjacency regression tests, a context-level integration
test, and a compaction synthesize-missing guard; the prior "keeps an
unresolved tool exchange out of the compaction prompt" test is updated to
the now-well-formed (synthetic-result) behavior.

* fix(agent-core): preserve the verbatim tail when restoring legacy compactions

A pre-rework `context.apply_compaction` record used
`[summary, ...history.slice(compactedCount)]` semantics and kept a verbatim
recent tail, but it has no `keptUserMessageCount`. The reworked applyCompaction
re-folded such records into the all-user shape, dropping the recent
assistant/tool tail — so resuming a session compacted by an older version
silently lost its most recent context.

On restore of such a record (gated on records.restoring, no keptUserMessageCount,
and compactedCount < history length) reproduce the old shape instead. The
forward/live path is unchanged; the projector's tool-adjacency repair keeps the
restored tail well-formed, and compaction only runs at clean step boundaries so
the tail has no open exchange. The legacy-tail probe now passes as a regression
guard via the real restore path.

* fix(agent-core): align legacy compaction foldedLength with live restore

The transcript reducer re-derived foldedLength for pre-rework
context.apply_compaction records (no keptUserMessageCount) using the new
kept-user+summary rule, but ContextMemory's restore now reproduces the legacy
[summary, ...history.slice(compactedCount)] shape for those records. The two
diverged for legacy sessions, so MessageService's foldedLength-vs-live-history
comparison could mis-handle GET /messages (miss or misorder recent output).

The reducer now mirrors the live legacy fold: when compactedCount is below the
pre-compaction length it computes 1 + (length - compactedCount); otherwise it
falls back to the kept-user derivation. The MessageService transcript test's
fixture is corrected to a new-format record, matching its all-user live mock.

* fix(kosong): merge a follow-up user turn into the preceding tool_results

The Anthropic message merge keyed on isToolResultOnly(last) ===
isToolResultOnly(converted), which left a tool_result-only user turn
followed by a plain-text user turn unmerged. After tool-exchange repair
this shape (assistant tool_use -> tool_result -> injected notification)
produces two adjacent user messages, which strict Anthropic-compatible
backends reject with HTTP 400.

Switch to the asymmetric predicate isToolResultOnly(last) ||
!isToolResultOnly(converted): a tool-result-only running message absorbs
whatever user turn follows (parallel tool_results or a trailing text),
yielding a valid [tool_result, ..., text] message; a plain-text running
message still only absorbs plain text. [tool_result, text] is valid for
both native Anthropic (which concatenates anyway) and strict backends.

* test(agent-core): pin micro-compaction flag in the shrunk-suffix probe

The 'does not clear recent tool results when projecting a shrunk suffix'
probe is an it.fails that only documents a real defect while
micro-compaction is active. It inherited the ambient
KIMI_CODE_EXPERIMENTAL master switch, so its pass/fail flipped with the
runner: green locally (master switch on) but a hard failure in CI, where
the flag defaults off and MicroCompaction.compact() is a no-op that
leaves the tool result intact.

Enable KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION explicitly for this probe
so it deterministically exercises the micro-compaction path regardless of
the environment.

* fix(agent-core): harden full compaction against in-flight races, unbounded shrink, and media loss

Three compaction-path fixes surfaced by review, each flipping its
documenting it.fails probe to a passing it:

- Append race (CMP-02): after the summarizer returns, the post-summary
  history check only compared the compacted prefix. A live step appending
  to the tail while a manual/SDK compaction was in flight slipped through —
  an appended assistant/tool turn is neither summarized (the summary covers
  only the snapshot) nor kept (the rebuild keeps user input), so it
  vanished. Now cancel when the appended tail contains a non-user message;
  an appended user message is still kept (rebuild picks it up), preserving
  the existing 'keeps messages appended while compacting an unchanged
  prefix' behavior.

- Unbounded empty/truncated shrink: an empty or truncated summary dropped
  the oldest message and reset retryCount, so a model that kept returning
  empty could issue ~one request per history entry. Bound the shrink
  attempts by MAX_COMPACTION_RETRY_ATTEMPTS, mirroring the overflow-shrink
  counter.

- Media dropped on truncation (CMP-07): truncating the oldest kept user
  message replaced its whole content with one text block, discarding any
  image/audio/video. Keep the non-text parts and spend the remaining budget
  (maxTokens minus their cost) on truncated text.

* fix(vis): mirror legacy compaction tail in the model-mode projector

For a pre-rework context.apply_compaction record (no keptUserMessageCount),
agent-core's ContextMemory restore and the transcript reducer keep the old
[summary, ...history.slice(compactedCount)] tail — a verbatim recent tail
including assistant/tool. The vis model-mode projector always applied the
new kept-user selection, so opening an older compacted session in model
mode hid the assistant/tool tail the resumed agent still holds (and
surfaced a pre-compaction user message the agent dropped).

Branch on a missing keptUserMessageCount with compactedCount < history
length and reproduce the legacy shape, matching the agent-core restore.

* fix(agent-core): cancel compaction on any droppable user-role tail

The in-flight append guard cancelled only when the tail grew with a
non-user role. A user-role message that compaction would still drop — a
background-task notification, hook/cron reminder, or shell-command output —
slipped through: appended after the summary snapshot (so absent from the
summary) and dropped by the all-user rebuild (which keeps only real user
input), vanishing silently.

Key the guard on the same predicate applyCompaction uses (!isRealUserInput)
so it cancels whenever the appended tail holds anything compaction would
drop. A real user message is still kept, so a live user turn racing a
manual/SDK compaction continues to complete.

* fix(agent-core): exclude pre-clear prompts from legacy folded length

The transcript reducer's legacy fallback (records predating
keptUserMessageCount, compacted with no verbatim tail) re-derived the
kept-user count from the whole transcript, including messages before the
last context.clear. Live ContextMemory rebuilds _history from post-clear
messages only, so counting pre-clear prompts overstated foldedLength;
MessageService then saw context.history.length <= foldedLength and skipped
appending unflushed live tail messages, dropping recent output from the
messages endpoint for old sessions compacted after a clear.

Derive only from entries at or after clearFloor to match the live context.

* fix(agent-core): drop media when truncating the oldest kept prompt

Revert the media-preserving truncation: keeping non-text parts on the
truncated boundary message overshot the kept-user budget when the media
alone exceeded it, and reordered interleaved text/media parts. Both codex
(no media-aware truncation) and Claude Code (strips media at compaction)
decline to preserve media on a truncated message, since media cannot be
partially truncated and keeping it whole breaks the budget.

truncateUserMessage now keeps only the truncated text. Recent messages
that fit the budget are still kept verbatim with their media; only the
oldest, partially-overflowing boundary message loses its attachments.

* fix(agent-core): make manual compaction and turns mutually exclusive

A manual/SDK compaction could start while a turn was streaming, or a new
turn could launch while a compaction was in flight. Either way the turn
mutates the shared context (streaming content into an existing assistant
message, or appending new messages) during the summarizer await, and that
output is neither summarized nor preserved by the all-user rebuild —
silent loss that object-identity checks can't detect (the streamed message
is mutated in place).

Guard both directions so the agent does one of {turn, compaction} at a
time: begin() refuses a manual compaction while a turn is active, and
launch() refuses a new turn while a compaction is in progress. Auto
compaction is exempt — it runs from within the turn at a step boundary,
which blocks the turn for its duration.

* chore(changeset): consolidate compaction changesets into one

* chore(agent-core): drop external-product references from compaction comments

* test(agent-core): add Anthropic wire-compliance smoke tests for compaction

Drive real compaction output and the compaction summarizer projection
through the real Anthropic provider conversion and assert the wire request
is well-formed: strict user/assistant alternation and every tool_use
answered by an adjacent tool_result. Locks in the cross-layer guarantee
(projector merge + Anthropic consecutive-user merge + adjacency repair +
synthesizeMissing) that compacted sessions stay valid for strict
Anthropic-compatible backends.

* fix(agent-core): defer and replay inputs during manual compaction instead of rejecting

Manual/SDK compaction runs outside a turn, so the earlier guard rejected
prompts/steers that arrived while it held the context. That broke three
things: a REST/web prompt got stuck 'running' (no terminal turn event), a
background-task/cron steer was silently lost (null was read as 'buffered'
but nothing was), and a follow-up prompt could land in the window after
isCompacting cleared but before reminders were reinjected.

Reuse the existing defer-and-replay model instead of rejecting:

- steer() and launch() buffer into steerBuffer while a compaction is in
  progress (returning null = buffered), mirroring how an active turn defers
  input.
- FullCompaction.compactionWorker keeps isCompacting true through
  refreshSystemPrompt + injectAfterCompaction (moving markCompleted and the
  completed event after reinjection), then replays the buffer via
  TurnFlow.onCompactionFinished — on success, on an A1 prefix/tail cancel,
  and on failure/abort.
- onCompactionFinished flushes into an active turn if one exists, else
  launches a fresh turn from the deferred input.

No PromptService change: a deferred prompt's eventual turn.started lets it
associate the pending prompt and clear it on turn.ended.

* feat(kosong): detect tool_use/tool_result adjacency errors

Add isToolExchangeAdjacencyError to classify the strict-provider 400 raised
when an assistant tool_use is not correctly paired with its tool_result
(missing, stray, or non-adjacent), excluding context-overflow 400s. Lets the
agent loop recognize the error and resend a wire-compliant request instead of
leaving the session stuck.

* fix(agent-core): close mid-history orphan tool calls and resend wire-compliant after a strict 400

Strict providers (Anthropic) reject a request whose assistant tool_use is not
answered by an adjacent tool_result, and the same malformed history is re-sent
every turn, permanently bricking the session.

- Projector now closes a mid-history tool call whose result is missing entirely
  (a later turn proves it is not in-flight) with a synthetic result; the
  trailing in-flight call is still left untouched.
- Add a strict projection (synthesize every open call, drop stray results) and,
  on a tool_use/tool_result adjacency 400, resend the request once with it.
- Report every projection repair (reorder / synthesize / drop) via log and
  telemetry, deduped by signature, so a silently-mangled history leaves a trace.
  Trailing-tail synthesis (expected under compaction) is not flagged.

* fix(kosong): merge consecutive user turns for strict providers

Gemini/Vertex require strictly alternating user/model turns and reject
consecutive user turns with HTTP 400. They arise after compaction (kept
prompts + user-role summary + injected reminders) and when a turn is
steered in right after a tool result. Anthropic already merged them
inline; the Google converter did not, so post-compaction requests failed.

Extract the asymmetric merge into a shared mergeConsecutiveUserMessages
helper applied at each strict provider's conversion boundary: refactor
Anthropic to use it (behavior unchanged) and apply it at the Google
converter's exit. A conformance suite drives every strict provider with
the post-compaction shape and a steer-after-tool-result shape, asserting
no consecutive same-role turns reach the wire, so a new strict provider
cannot silently omit the merge.

The provider-agnostic projector stays structure-preserving: lenient
providers (OpenAI/Kimi) keep distinct turns for clearer message
boundaries; only strict providers normalize, where the requirement lives.

* feat(kosong): recognize the broader structural request-rejection family

Add isRecoverableRequestStructureError, covering the strict-provider 400s that
stem from a malformed message array re-sent every turn: tool_use/tool_result
pairing, empty/whitespace-only text blocks, a non-user first message, and
non-alternating roles. Context-overflow 400s are excluded (handled by
compaction). Lets the loop trigger one strict, wire-compliant resend for the
whole family rather than only tool-pairing errors.

* fix(agent-core): sanitize whitespace and strict-resend structural 400s, with diagnostics

- Drop empty AND whitespace-only text blocks in projection (Anthropic rejects
  whitespace-only with "text content blocks must contain non-whitespace text",
  which otherwise sticks a session); treat whitespace-only tool output as empty.
- Broaden the post-400 strict resend to the whole structural family and add two
  strict-only passes to the strict projection: drop leading non-user messages
  (first message must be user) and merge consecutive assistant turns.
- Log + telemetry for every wire repair the projector applies (reorder,
  synthesize, drop orphan, drop leading, merge assistants, drop whitespace),
  deduped by signature; log the strict resend outcome (recovered or still
  rejected) so a stuck session always leaves a trace.

* fix(agent-core): normalize empty-equivalent tool result arrays to the empty placeholder

A tool result whose ContentPart[] output has no sendable content (an empty array,
or only empty/whitespace-only text blocks) was returned verbatim, so projection
stripped the blank blocks, left the tool message empty, and threw on every send —
bricking the session locally. String outputs were already normalized; do the same
for arrays. A non-text part or any non-whitespace text still keeps the real
output.

* chore(changeset): simplify the wire-compliance changeset
2026-07-01 02:16:19 +08:00
qer
882cf355a9
fix(web): persist workspace rename and hide provider manager (#1234)
* fix(web): persist workspace rename via the daemon update API

* fix(web): hide the unshipped provider manager

* fix(web): fall back to a local override for derived workspace renames

* fix(web): preserve workspace name override across registration
2026-07-01 02:03:19 +08:00
Kai
86e0c9201e
feat(agent-core): rework compaction to keep only user prompts and summary (#1214)
* feat(agent-core): rework compaction to keep only user prompts and summary

* refactor(agent-core): rewrite compaction summary as first-person handoff

Rework the full-compaction summary to read as the agent's own continuing
notes instead of a third-party report:

- compaction-instruction.md: free-form first-person continuation that
  preserves exact commands, paths and outcomes, states the precise next
  action, and flags claimed-but-unverified work rather than trusting it.
- compaction-summary-prefix.md: skeptical "your own working notes"
  framing; drop the collaborative third-party prefix.
- system.md: add compaction-awareness guidance so the model continues
  naturally from a summary and re-checks any reported "done".
- Rename the compaction helpers module to handoff.ts.

Update tests and regenerate snapshots for the new prompt text, and fill
in contextSummary in the restored-compaction replay expectations.

* fix(agent-core): count image/audio/video parts in token estimation

estimateTokensForContentPart returned 0 for image_url/audio_url/video_url,
so auto-compaction triggers, the overflow-shrink budget, the kept-user
budget, and the reported context size all went blind to media — a
media-heavy session could overflow the model window while the estimate
reported a near-empty context. Media parts now carry a fixed estimate
(MEDIA_TOKEN_ESTIMATE), and the content-part switch is exhaustive so a new
ContentPart kind must declare its estimate rather than silently count as
zero.

* feat(agent-core): re-surface active background tasks after compaction

Folding the live context to [recent user prompts, summary] drops the
messages that started background tasks and their status updates, so the
model could forget a task is still running and spawn a duplicate.
injectAfterCompaction now appends a system-reminder listing active
background tasks (with guidance to use TaskOutput/TaskList/TaskStop
instead of re-spawning). It runs only post-compaction and carries an
injection origin, so the next compaction drops and rebuilds it rather
than stacking copies; the all-user-role post-compaction shape is
preserved (no tool-pairing reintroduced).

* test(agent-core): add compaction scenario guards and risk probes

Adds compaction-scenarios.test.ts driving the real Agent/ContextMemory/
FullCompaction machinery:

- A guard test locking in that repeated compaction folds the prior summary
  into the new one instead of stacking two summaries.
- Seven `it.fails` probes that executably reproduce known, currently-accepted
  edge-case defects so the suite stays green while documenting each one
  precisely; any of them will flip red (forcing removal of `.fails`) the day
  the behavior is fixed. They cover: assistant/tool appended during an
  in-flight summarizer call being dropped; unbounded shrink on empty
  summaries; the fixed 20k kept-user budget overflowing a small model window;
  a tool result orphaned when compaction starts mid-exchange; legacy
  compaction records dropping their verbatim tail on replay; micro-compaction
  clearing recent tool results in an overflow-shrunk suffix; and media being
  discarded when the oldest kept user message is truncated.

* fix(agent-core): repair tool_use/tool_result adjacency in projected context

A tool call and its result can end up non-adjacent in history — a
background-task notification or flushed steer lands between them, or an
interrupted/nested step delays the result — which strict providers reject
with HTTP 400. The projector now moves each tool_use's result up to
immediately follow it (projection-time only; the stored history is
untouched), and full compaction projects its summarizer input with a
synthetic result for any still-open call so the summary request stays
well-formed. Micro-compaction only surfaced this latent ordering by busting
the prompt cache, so it now defaults off.

Includes projector adjacency regression tests, a context-level integration
test, and a compaction synthesize-missing guard; the prior "keeps an
unresolved tool exchange out of the compaction prompt" test is updated to
the now-well-formed (synthetic-result) behavior.

* fix(agent-core): preserve the verbatim tail when restoring legacy compactions

A pre-rework `context.apply_compaction` record used
`[summary, ...history.slice(compactedCount)]` semantics and kept a verbatim
recent tail, but it has no `keptUserMessageCount`. The reworked applyCompaction
re-folded such records into the all-user shape, dropping the recent
assistant/tool tail — so resuming a session compacted by an older version
silently lost its most recent context.

On restore of such a record (gated on records.restoring, no keptUserMessageCount,
and compactedCount < history length) reproduce the old shape instead. The
forward/live path is unchanged; the projector's tool-adjacency repair keeps the
restored tail well-formed, and compaction only runs at clean step boundaries so
the tail has no open exchange. The legacy-tail probe now passes as a regression
guard via the real restore path.

* fix(agent-core): align legacy compaction foldedLength with live restore

The transcript reducer re-derived foldedLength for pre-rework
context.apply_compaction records (no keptUserMessageCount) using the new
kept-user+summary rule, but ContextMemory's restore now reproduces the legacy
[summary, ...history.slice(compactedCount)] shape for those records. The two
diverged for legacy sessions, so MessageService's foldedLength-vs-live-history
comparison could mis-handle GET /messages (miss or misorder recent output).

The reducer now mirrors the live legacy fold: when compactedCount is below the
pre-compaction length it computes 1 + (length - compactedCount); otherwise it
falls back to the kept-user derivation. The MessageService transcript test's
fixture is corrected to a new-format record, matching its all-user live mock.

* fix(kosong): merge a follow-up user turn into the preceding tool_results

The Anthropic message merge keyed on isToolResultOnly(last) ===
isToolResultOnly(converted), which left a tool_result-only user turn
followed by a plain-text user turn unmerged. After tool-exchange repair
this shape (assistant tool_use -> tool_result -> injected notification)
produces two adjacent user messages, which strict Anthropic-compatible
backends reject with HTTP 400.

Switch to the asymmetric predicate isToolResultOnly(last) ||
!isToolResultOnly(converted): a tool-result-only running message absorbs
whatever user turn follows (parallel tool_results or a trailing text),
yielding a valid [tool_result, ..., text] message; a plain-text running
message still only absorbs plain text. [tool_result, text] is valid for
both native Anthropic (which concatenates anyway) and strict backends.

* test(agent-core): pin micro-compaction flag in the shrunk-suffix probe

The 'does not clear recent tool results when projecting a shrunk suffix'
probe is an it.fails that only documents a real defect while
micro-compaction is active. It inherited the ambient
KIMI_CODE_EXPERIMENTAL master switch, so its pass/fail flipped with the
runner: green locally (master switch on) but a hard failure in CI, where
the flag defaults off and MicroCompaction.compact() is a no-op that
leaves the tool result intact.

Enable KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION explicitly for this probe
so it deterministically exercises the micro-compaction path regardless of
the environment.

* fix(agent-core): harden full compaction against in-flight races, unbounded shrink, and media loss

Three compaction-path fixes surfaced by review, each flipping its
documenting it.fails probe to a passing it:

- Append race (CMP-02): after the summarizer returns, the post-summary
  history check only compared the compacted prefix. A live step appending
  to the tail while a manual/SDK compaction was in flight slipped through —
  an appended assistant/tool turn is neither summarized (the summary covers
  only the snapshot) nor kept (the rebuild keeps user input), so it
  vanished. Now cancel when the appended tail contains a non-user message;
  an appended user message is still kept (rebuild picks it up), preserving
  the existing 'keeps messages appended while compacting an unchanged
  prefix' behavior.

- Unbounded empty/truncated shrink: an empty or truncated summary dropped
  the oldest message and reset retryCount, so a model that kept returning
  empty could issue ~one request per history entry. Bound the shrink
  attempts by MAX_COMPACTION_RETRY_ATTEMPTS, mirroring the overflow-shrink
  counter.

- Media dropped on truncation (CMP-07): truncating the oldest kept user
  message replaced its whole content with one text block, discarding any
  image/audio/video. Keep the non-text parts and spend the remaining budget
  (maxTokens minus their cost) on truncated text.

* fix(vis): mirror legacy compaction tail in the model-mode projector

For a pre-rework context.apply_compaction record (no keptUserMessageCount),
agent-core's ContextMemory restore and the transcript reducer keep the old
[summary, ...history.slice(compactedCount)] tail — a verbatim recent tail
including assistant/tool. The vis model-mode projector always applied the
new kept-user selection, so opening an older compacted session in model
mode hid the assistant/tool tail the resumed agent still holds (and
surfaced a pre-compaction user message the agent dropped).

Branch on a missing keptUserMessageCount with compactedCount < history
length and reproduce the legacy shape, matching the agent-core restore.

* fix(agent-core): cancel compaction on any droppable user-role tail

The in-flight append guard cancelled only when the tail grew with a
non-user role. A user-role message that compaction would still drop — a
background-task notification, hook/cron reminder, or shell-command output —
slipped through: appended after the summary snapshot (so absent from the
summary) and dropped by the all-user rebuild (which keeps only real user
input), vanishing silently.

Key the guard on the same predicate applyCompaction uses (!isRealUserInput)
so it cancels whenever the appended tail holds anything compaction would
drop. A real user message is still kept, so a live user turn racing a
manual/SDK compaction continues to complete.

* fix(agent-core): exclude pre-clear prompts from legacy folded length

The transcript reducer's legacy fallback (records predating
keptUserMessageCount, compacted with no verbatim tail) re-derived the
kept-user count from the whole transcript, including messages before the
last context.clear. Live ContextMemory rebuilds _history from post-clear
messages only, so counting pre-clear prompts overstated foldedLength;
MessageService then saw context.history.length <= foldedLength and skipped
appending unflushed live tail messages, dropping recent output from the
messages endpoint for old sessions compacted after a clear.

Derive only from entries at or after clearFloor to match the live context.

* fix(agent-core): drop media when truncating the oldest kept prompt

Revert the media-preserving truncation: keeping non-text parts on the
truncated boundary message overshot the kept-user budget when the media
alone exceeded it, and reordered interleaved text/media parts. Both codex
(no media-aware truncation) and Claude Code (strips media at compaction)
decline to preserve media on a truncated message, since media cannot be
partially truncated and keeping it whole breaks the budget.

truncateUserMessage now keeps only the truncated text. Recent messages
that fit the budget are still kept verbatim with their media; only the
oldest, partially-overflowing boundary message loses its attachments.

* fix(agent-core): make manual compaction and turns mutually exclusive

A manual/SDK compaction could start while a turn was streaming, or a new
turn could launch while a compaction was in flight. Either way the turn
mutates the shared context (streaming content into an existing assistant
message, or appending new messages) during the summarizer await, and that
output is neither summarized nor preserved by the all-user rebuild —
silent loss that object-identity checks can't detect (the streamed message
is mutated in place).

Guard both directions so the agent does one of {turn, compaction} at a
time: begin() refuses a manual compaction while a turn is active, and
launch() refuses a new turn while a compaction is in progress. Auto
compaction is exempt — it runs from within the turn at a step boundary,
which blocks the turn for its duration.

* chore(changeset): consolidate compaction changesets into one

* chore(agent-core): drop external-product references from compaction comments

* test(agent-core): add Anthropic wire-compliance smoke tests for compaction

Drive real compaction output and the compaction summarizer projection
through the real Anthropic provider conversion and assert the wire request
is well-formed: strict user/assistant alternation and every tool_use
answered by an adjacent tool_result. Locks in the cross-layer guarantee
(projector merge + Anthropic consecutive-user merge + adjacency repair +
synthesizeMissing) that compacted sessions stay valid for strict
Anthropic-compatible backends.

* fix(agent-core): defer and replay inputs during manual compaction instead of rejecting

Manual/SDK compaction runs outside a turn, so the earlier guard rejected
prompts/steers that arrived while it held the context. That broke three
things: a REST/web prompt got stuck 'running' (no terminal turn event), a
background-task/cron steer was silently lost (null was read as 'buffered'
but nothing was), and a follow-up prompt could land in the window after
isCompacting cleared but before reminders were reinjected.

Reuse the existing defer-and-replay model instead of rejecting:

- steer() and launch() buffer into steerBuffer while a compaction is in
  progress (returning null = buffered), mirroring how an active turn defers
  input.
- FullCompaction.compactionWorker keeps isCompacting true through
  refreshSystemPrompt + injectAfterCompaction (moving markCompleted and the
  completed event after reinjection), then replays the buffer via
  TurnFlow.onCompactionFinished — on success, on an A1 prefix/tail cancel,
  and on failure/abort.
- onCompactionFinished flushes into an active turn if one exists, else
  launches a fresh turn from the deferred input.

No PromptService change: a deferred prompt's eventual turn.started lets it
associate the pending prompt and clear it on turn.ended.

* fix(kosong): merge consecutive user turns for strict providers

Gemini/Vertex require strictly alternating user/model turns and reject
consecutive user turns with HTTP 400. They arise after compaction (kept
prompts + user-role summary + injected reminders) and when a turn is
steered in right after a tool result. Anthropic already merged them
inline; the Google converter did not, so post-compaction requests failed.

Extract the asymmetric merge into a shared mergeConsecutiveUserMessages
helper applied at each strict provider's conversion boundary: refactor
Anthropic to use it (behavior unchanged) and apply it at the Google
converter's exit. A conformance suite drives every strict provider with
the post-compaction shape and a steer-after-tool-result shape, asserting
no consecutive same-role turns reach the wire, so a new strict provider
cannot silently omit the merge.

The provider-agnostic projector stays structure-preserving: lenient
providers (OpenAI/Kimi) keep distinct turns for clearer message
boundaries; only strict providers normalize, where the requirement lives.
2026-07-01 01:17:30 +08:00
qer
f12c83cfb9
fix(kimi-desktop): clear traffic lights and make the top draggable (#1237)
- Increase the sidebar header's left padding (100px) on macOS so the floating
  traffic lights no longer overlap the Kimi Code brand.
- Make the conversation header a window-drag region on macOS (interactive
  controls opt out with no-drag), so the whole top of the window can be dragged
  without adding a separate titlebar strip.
2026-06-30 23:28:34 +08:00
qer
bfe8e6ace3
fix(web): surface error when adding a workspace by path fails (#1236)
* fix(web): surface error when adding a workspace by path fails

Previously, addWorkspaceByPath swallowed any error from the daemon and created a local-only workspace that could not host sessions and vanished on reload, so an invalid absolute path appeared to succeed but never took effect. Now the error is reported to the user and no fake workspace is added.

* fix(web): preserve pending prompt when adding workspace fails

addWorkspaceByPath now returns a boolean success signal. handleAddWorkspace only clears pendingWorkspaceSubmit and sends the prompt on success, so a daemon-rejected path no longer drops the user's pending submission; they can retry with a valid path.

* fix(web): keep workspace picker open when add fails

Move closing the add-workspace dialog until after a successful add. On a daemon-rejected path the picker now stays open with the user's input intact, so they can correct the path and retry; the pending submission is consumed by a successful retry or dropped only when the user explicitly closes the picker.

* fix(web): show add-workspace errors inline in the picker

When the daemon rejects a path, the global warning toast rendered behind the picker's backdrop (z-index 60 vs 200) and could auto-dismiss unseen. Surface the failure as an inline error inside the dialog instead, so it is visible and persists until the user retries or closes. addWorkspaceByPath now returns a boolean and leaves surfacing to the caller; the picker shows a localized inline message.
2026-06-30 22:40:53 +08:00
liruifengv
108299be3c
refactor!: overhaul thinking config and effort resolution (#1132)
* feat: support multi-level thinking effort switching

- kimi provider: emit thinking.effort in the new wire format; keep reasoning_effort mirrored during the transition
- model catalog: thread support_efforts / default_effort from oauth through to /models
- config schema: add supportEfforts / defaultEffort on model aliases
- TUI: multi-segment thinking control in /model, new /effort command, footer effort display
- switch status uses displayName and distinguishes model vs effort-only changes

* docs: add thinking effort design plans

- thinking-effort-switching.md: implemented multi-level effort switching
- thinking-model-overhaul.md: follow-up refactor plan for the thinking state model

* docs: collapse thinking overhaul plan into a single PR

* refactor!: overhaul thinking config and effort resolution

Replace default_thinking and thinking.mode with a single [thinking] enabled/effort table. ThinkingEffort is now an open string ('off' | 'on' | model-declared effort); effort levels come from each model's support_efforts instead of a fixed enum.

Centralize default and always_thinking clamp logic in resolveThinkingEffort/defaultThinkingEffortFor, and honor an explicitly configured effort when an always_thinking model is forced back on.

TUI keeps a single thinkingEffort field instead of the boolean + level pair; 'on' is normalized to the model default at the UI boundary.

BREAKING CHANGE: default_thinking and thinking.mode are removed from config; migrate to [thinking] enabled/effort.

* refactor: rename residual thinking level wording to effort

Rename comments, error messages, parameter names, the SetThinkingPayload wire field (level -> effort), and TUI local variables so the thinking effort naming is consistent throughout. No behavior change.

* refactor: rename remaining camelCase thinking level identifiers to effort

Rename liveLevel/prevLevel/levelChanged/commitLevel/effectiveLevel to liveEffort/prevEffort/effortChanged/commitEffort/effectiveEffort in the TUI model picker and config commands.

* refactor: eliminate remaining thinking level wording in comments and tests

Rename levelLabel -> effortLabel, EffortSelectorOptions.levels -> efforts, and 'effort level(s)' / 'default level' / 'requested level' wording in comments, error messages, slash-command description, and test titles to effort. Also restore the withThinking(effort) parameter rename in the Kimi provider that was accidentally reverted.

* fix: address codex review feedback on thinking effort handling

- OpenAI thinkingEffortToReasoningEffort and Anthropic clampEffort now normalize 'on' / unrecognized efforts instead of throwing, so boolean non-Kimi models no longer crash on session start.

- ACP resolveCurrentThinkingEnabled treats a non-empty thinking.effort as enabled, matching agent-core's resolveThinkingEffort.

- REST promptThinkingSchema accepts any non-empty effort string so model-declared efforts are not rejected at the API boundary.

* test: align kimi e2e expectations with supportEfforts-gated reasoning_effort

The kimi provider now sends reasoning_effort only when the model declares support_efforts; boolean models (no support_efforts) send only thinking.type. Update the kimi e2e tests to drop the stale reasoning_effort expectation for the boolean test model.

* test: cover [thinking] effort parsing in config.test

Add effort = "high" to the documented [thinking] table in the config parse test and assert config.thinking.effort is resolved, so the new [thinking] effort field has direct parse coverage.

* docs: add thinking test coverage gap analysis

Capture the explore agent's test coverage review for the thinking overhaul PR, including P1/P2 gaps and the two open design questions, for follow-up test additions.

* feat(oauth): parse nested think_efforts from /models response

The /models endpoint now returns effort levels under a nested think_efforts object ({ support, valid_efforts, default_effort }). Parse it preferentially in both managed-kimi-code and open-platform model parsing, falling back to the legacy flat support_efforts / default_effort fields for older servers.

* refactor(oauth): only read nested think_efforts; gate on support=true

Drop the legacy flat support_efforts / default_effort fallback. The think_efforts object is now the single source, and its support flag gates the whole object — when support is not true, valid_efforts and default_effort are ignored entirely.

* chore: remove unused parseStringArray import in open-platform

* docs: finalize thinking effort release notes

Downgrade the changeset to minor with an English summary, drop the version-specific 'added in 1.0.0' info block, and present the deprecated config fields as a table (field / deprecated in 0.21.0 / description).

* refactor: drop temporary refresh toggles and kimi reasoning_effort mirror

Remove the always-true REFRESH_MODELS_ON_PICKER_OPEN / REFRESH_PROVIDER_MODELS_ON_STARTUP toggles and their stale re-enable TODOs, and stop sending reasoning_effort from the kimi provider (thinking.effort is the only wire field now).

* fix(tui): avoid persisting "on" as thinking effort

* fix: preserve persisted thinking effort across login and provider setup

* fix(tui): show actual thinking effort in /status and footer

* test(tui): align message-flow expectations with effort persistence and /status display

* fix(vis): rename thinkingLevel to thinkingEffort in config.update analysis
2026-06-30 22:34:13 +08:00
qer
e207f52f55
ci(kimi-desktop): strip Developer ID prefix from CSC_NAME (#1235)
* ci(kimi-desktop): strip Developer ID prefix from CSC_NAME

electron-builder rejects the 'Developer ID Application: ' prefix in CSC_NAME;
the keychain setup exports the full certificate name, so strip the prefix
before passing it to electron-builder.

* ci(kimi-desktop): add homepage and maintainer for linux .deb

electron-builder's .deb target requires a project homepage and a package
maintainer; set both so the Linux build succeeds.
2026-06-30 22:19:35 +08:00
Kai
020992c286
fix(cli): force-exit headless runs so a lingering handle can't wedge kimi -p (#1233)
* fix(cli): force-exit headless runs so a lingering handle can't wedge kimi -p

Print mode (`kimi -p`) never calls process.exit(); it relies on the Node
event loop draining once the run completes. A stray ref'd handle left over
from the run — a lingering socket, an un-cleared timer, or a child whose
pipes stay open — keeps the loop alive, so a finished run hangs until an
external timeout kills it instead of exiting.

- Arm an unref'd force-exit fallback after a headless run completes. A
  healthy run drains and exits before it fires (behaviour unchanged); it
  only force-exits a run whose loop is already wedged.
- Bound prompt cleanup with a timeout so a wedged shutdown step (a
  SessionEnd hook, MCP shutdown, or a connection blackholed by a
  restrictive firewall) can't keep a completed run alive forever.

Add unit tests for the force-exit guard and for cleanup staying bounded
when harness.close() hangs.

* refactor(cli): move headless force-exit to the entrypoint, keep handleMainCommand pure

handleMainCommand is a reusable, exported, unit-tested handler — scheduling a
deferred process.exit inside it could terminate a test runner or an embedding
host once the spied exit was restored. Make it pure: it now returns a
MainCommandOutcome, and the process entrypoint (the program action, which
already owns the error-path process.exit) arms the unref'd force-exit fallback
only for a completed headless run.

* fix(cli): drain stdio before the headless force-exit to avoid truncating output

The unref'd force-exit fallback fired after a fixed grace even when the event
loop was alive only because buffered stdout/stderr had not finished flushing to
a slow or piped consumer (the prompt writers ignore write() backpressure). That
could truncate the assistant output or resume hint of an otherwise-successful
`kimi -p` run — a regression versus the previous natural-drain exit.

finalizeHeadlessRun now flushes stdio first (bounded by HEADLESS_STDIO_DRAIN_TIMEOUT_MS)
and only then arms the force-exit. Draining first also means a leaked handle is
the only thing that can still hold the loop, so the backstop stays precise and
bounded.

* fix(cli): only swallow cleanup rejections abandoned by the timeout

The cleanup timeout guard caught every rejection, so a cleanup step that failed
fast (e.g. restoring a resumed session's permission, or harness.close hitting a
persistence error) was silently swallowed: runPrompt resolved as success and the
session could be left in `auto` with no error surfaced.

A timeout should bound how long we wait, not change the outcome. raceWithTimeout
now propagates a rejection that settles before the timeout, and only swallows the
abandoned promise's late rejection once the timeout has won the race.
2026-06-30 22:13:17 +08:00
qer
210cedb3bf
chore: add KCD client for test (#1230)
* feat(kimi-desktop): add Electron desktop client wrapping kimi-web

New apps/kimi-desktop — a thin Electron shell + process manager around
the existing web UI. It reuses kimi-code's shared daemon: it runs the
bundled SEA's `server run` (the same ensureDaemon reuse-or-spawn flow as
`kimi web`), reads ~/.kimi-code/server/lock for the real origin, and
loads the SEA-served kimi-web same-origin. The daemon is left running on
quit so the CLI / browser / TUI keep sharing it.

- main process: ensure-server (run SEA, read lock, confirm healthz),
  sea-path (dev vs packaged), window + native menu + window-state +
  loading/error screens
- packaging: electron-builder config; before-pack stages the
  matching-platform SEA into <resources>/bin/<target>
- CI: desktop-build workflow builds unsigned mac/win/linux installers,
  each runner building its own SEA
- workspace wiring: register in flake.nix, allow electron postinstall
  (onlyBuiltDependencies), root dev:desktop + typecheck entries

v1 is unsigned, default icon, no auto-update.

* feat(kimi-desktop): sign + notarize macOS builds

Unsigned macOS builds are blocked by Gatekeeper ("app is damaged") once
transferred to another Mac. Add Developer ID signing + Apple notarization,
mirroring the TUI native build:

- build/entitlements.mac.plist: hardened-runtime entitlements (allow-jit,
  disable-library-validation for koffi/clipboard, etc.) applied to the app
  and — via entitlementsInherit — the nested SEA backend
- electron-builder.config.cjs (replaces .yml): hardenedRuntime + entitlements;
  signing and notarization are env-driven (CSC_* + KIMI_DESKTOP_NOTARIZE +
  APPLE_API_* ), so the same config builds unsigned locally or signed+notarized
- desktop-build CI: sign-macos input reuses the existing macos-keychain-setup
  action + APPLE_* secrets, notarizes via the notary API key
- README: document signing, the Developer-ID requirement, and the
  "don't rename the .app" gotcha

Verified locally that electron-builder signs both the app and the nested SEA
with hardened runtime + the entitlements, and the signed app still launches and
serves the web UI. Notarization itself needs a Developer ID cert (CI / a machine
that has one).

* feat(kimi-desktop): rename product to Kimi Code Desktop

productName / window title / menu label / error-screen text all use
"Kimi Code Desktop" so the bundle name matches its executable (a
mismatch from manual renaming is itself reported as "damaged").

* ci(kimi-desktop): build and attach desktop installers in the release pipeline

Make desktop-build.yml reusable (workflow_call) and invoke it from the
release workflow, mirroring the native-build pipeline, so each release
also attaches signed+notarized macOS, Windows and Linux desktop
installers to the GitHub Release.

* feat(kimi-desktop): brand the desktop as an internal testing build

- Add an inline 'internal testing build' tag next to the Kimi Code brand
  in the sidebar header, shown only inside the desktop app.
- Use a hidden native title bar on macOS with the traffic lights folded
  into the sidebar header, and pin the window title to the product name.
- Ship the Kimi app icon for macOS and Linux builds.

Desktop detection is runtime (a query hint from the Electron shell,
persisted in sessionStorage) so the branding appears even when the
window is served by an already-running shared daemon.

* docs(kimi-desktop): update v1 scope now that the app icon ships

* feat(kimi-desktop): add the Kimi app icon for Windows builds

* chore(nix): update pnpmDeps hash after lockfile refresh

* ci(kimi-desktop): build desktop on release but do not attach to GitHub Release

The desktop build is an internal-testing artifact (branded as such), so
keep it as a CI artifact for internal download instead of publishing it
to the public GitHub Release.

* chore(kimi-desktop): mark installers as internal pre-release builds

Rename the packaged artifacts to KCD-Internal-<version>-<arch>.<ext> and
bump the version to the 0.1.1-internal.0 pre-release, so a leaked or
forwarded installer file is not mistaken for an official public release.

* feat(kimi-desktop): strengthen the internal-build tag wording

Change the sidebar tag to 'Internal testing · do not distribute' /
'内部测试 · 禁止外传' so the no-distribution intent is explicit.

* feat(kimi-desktop): tweak internal-build tag to '仅供内部测试'

* fix(kimi-desktop): pass the server token to the web UI on launch

Read the daemon's persistent bearer token from <KIMI_CODE_HOME>/server.token
and carry it in the URL fragment (#token=), matching how 'kimi web' opens
the Web UI. Without this, a fresh launch (no saved credential) boots the
web UI without a token, hits 401, and falls into the manual token dialog
even though the desktop started the daemon itself.

Addresses review feedback on the desktop URL.
2026-06-30 21:46:51 +08:00
Haozhe
ceb27f5e44
feat(server): add GUI store API mirroring localStorage (#1231)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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 / Publish native release assets (push) Blocked by required conditions
Release / Release (push) Waiting to run
* feat(server): add GUI store API mirroring localStorage

- add /api/v1/gui/store/* endpoints (getItem/setItem/removeItem/clear/length) mirroring the browser localStorage interface
- add IGuiStoreService persisting opaque string values to ~/.kimi-code/gui.toml via smol-toml with atomic writes and an in-process write lock
- wire protocol schema, service, routes, and DI registration; add e2e tests and update the API surface snapshot

* chore: add changeset for gui store api

* fix(server): harden GUI store key handling and file permissions

- use a null-prototype record and an own-property check so keys that exist on Object.prototype (toString, constructor, __proto__) behave like ordinary keys
- write gui.toml with 0600 permissions so unsent drafts and input history stay private to the owning user
2026-06-30 21:04:59 +08:00
qer
aa6b0d065e
feat(web): always show the usage-data opt-out toggle in settings (#1232)
The telemetry toggle was hidden unless the config explicitly set a value, and its on/off mapping treated the default (unset) state as off even though telemetry is enabled by default. Show it always, treat unset/true as on, and rename it with a description and a restart note.
2026-06-30 20:49:35 +08:00
qer
5cb80ce879
feat: support plugin slash commands (#1204)
* feat(agent-core): support plugin slash commands

* feat(node-sdk): expose listPluginCommands

* feat(kimi-code): register and dispatch plugin slash commands

* chore: add changeset for plugin commands

* feat(agent-core): activate plugin commands server-side

* feat(node-sdk): add activatePluginCommand

* feat(kimi-code): render plugin command activations compactly

* feat(agent-core): recurse plugin command directories and preserve namespace

* fix(kimi-code): parse nested plugin command names

* fix(agent-core): update prompt metadata for plugin command turns

* fix(kimi-code): replay plugin command turns as command cards

* fix: treat plugin-command origins as real user prompts in undo

* fix(kimi-code): guard model-empty and clear plugin command render ids

* fix: propagate plugin_command to web and vis turn projectors

* fix(kimi-code): refresh plugin commands through auth flow

* fix(kimi-web): render plugin command cards in chat pane

* fix(kimi-web): render plugin command card in desktop chat view

* fix(kimi-code): treat slash-activation cards as transcript turn boundaries

* fix(kimi-code): count slash-activation entries when trimming transcript turns

* fix(kimi-code): preserve plugin command args in undo selector
2026-06-30 19:38:01 +08:00
Kai
42e37eb898
feat(timing): split TTFT into api-server and client portions (#1228)
* feat(timing): split TTFT into api-server and client portions

Time-to-first-token previously lumped in-process request building
(message serialization, param assembly) together with network + server
latency, making it impossible to tell whether a slow turn was the client
or the API server.

Add an `onRequestSent` hook to kosong's GenerateOptions, fired by every
provider immediately before it dispatches the network call. The window
from request start to dispatch is attributed to the client; the window
from dispatch to the first streamed token is attributed to the API
server. The split flows through the step.end / turn.step.completed
events (and therefore wire.jsonl) and is surfaced in three places:

- KIMI_CODE_DEBUG=1: `TTFT: 2.5s (api 2.4s + client 100ms)`
- session log: new `llm response` line with the timing breakdown
- vis: firstToken/api + firstToken/client rows and timeline label

The split is omitted (total only) when a provider does not report the
boundary, preserving backward compatibility.

* feat(timing): split the decode window into server vs client time

Time-to-first-token now reports a client/server split, but the slow part
of a long turn is the decode window (inter-token streaming), which was
still a single opaque number. Profiling long sessions showed decode
throughput halving over a session's lifetime independent of context
size, which the synchronous per-chunk stream pipeline can cause: kosong
awaits the host callback for every streamed part, so a loaded main
thread throttles how fast tokens are pulled off the wire.

Account for this directly in the stream loop: the time awaiting the next
part (server + network) versus the time spent processing each part
in-process (deep copy, host callback, part merge). The split is reported
through onStreamEnd and flows through the step.end / turn.step.completed
events (and wire.jsonl) into the same three surfaces as the TTFT split:

- KIMI_CODE_DEBUG=1: `TPS: 40.0 tok/s (200 tokens in 5.0s; server 4.6s + client 400ms)`
- session log: serverDecodeMs / clientConsumeMs on the `llm response` line
- vis: streamDuration/server + streamDuration/client rows and timeline label

A large, growing client share confirms host-side throttling; a dominant
server share points at the server/connection. The per-chunk accounting is
wrapped in try/finally so it stays correct across `continue` and aborts,
and is omitted when the stream reports nothing.
2026-06-30 19:15:02 +08:00
liruifengv
659062d11c
fix(tui): enable file path completion for / in shell mode (#1225)
Typing `/\' in shell mode (`!\') now triggers file path completion instead of the slash command menu, for both a bare leading `/\' and inline paths like `ls /\'. Hidden entries are skipped to match `/add-dir\', and accepting a completion no longer produces a double leading slash.
2026-06-30 17:54:24 +08:00
qer
a3f9cec8a9
fix(web): deduplicate workspaces shown in the sidebar (#1221)
Collapse registered workspaces that share a root in the daemon registry (preferring the canonical id) and in the web sidebar merge, so the same folder no longer renders as two identical, synchronously-selected entries.
2026-06-30 17:25:24 +08:00
liruifengv
ec51324230
feat(tui): open undo selector on double-Esc (#1220)
* feat(tui): open undo selector on double-Esc

Pressing Esc twice while idle now opens the undo selector, equivalent to running /undo with no arguments. Esc during streaming, compaction, or with a popup open keeps its cancel/close behavior and does not arm the double-press.

* fix(tui): disarm double-Esc undo on any intervening key

A pending double-Esc was only cleared by text changes, so a sequence like Esc, Ctrl-C, Esc within the window still opened the undo selector. Fire an onNonEscapeInput hook for every non-Escape key and clear the pending state there, so the shortcut only triggers for two consecutive Escape presses.
2026-06-30 15:42:27 +08:00
liruifengv
80e6888e34
fix(tui): open @ file mentions inside slash command arguments (#1223)
Typing @ in the middle of a slash command argument (for example `/goal Fix the @checkout docs`) was swallowed by the slash-argument completion guard before the @ mention branch ran, so the file list never opened. Run the @ mention branch ahead of the slash guards so file mentions take priority; plain slash-argument editing is still suppressed as before.
2026-06-30 15:35:19 +08:00
liruifengv
7f61488a88
docs(changelog): sync 0.20.3 from apps/kimi-code/CHANGELOG.md (#1216) 2026-06-30 14:50:31 +08:00
Kai
525fb146d6
feat(vis): full session debugging — tasks/cron, execution timeline, retries & tool progress (#1210)
* feat(vis): surface background tasks and cron jobs

The visualizer read every wire/state/blob artifact a session persists but
ignored the two on-demand families agent-core also writes under the session
directory: background tasks (tasks/<id>.json + output.log) and cron jobs
(cron/<id>.json). Neither is reconstructable from the wire, so there was no
way to inspect what a session spawned in the background or scheduled.

Server:
- task-store / cron-store read-only readers mirroring agent-core's on-disk
  layout, id-validation guard, and legacy snake_case task normalization
- GET /:id/tasks, /:id/tasks/:taskId/output (byte-window paged via an exact
  nextOffset cursor), and /:id/cron routes
- re-export the public background-task types from agent-core; mirror the
  non-exported CronTask shape with a fixture-backed drift test

Web:
- Tasks tab: process/agent/question kinds with status, timing, kind-specific
  fields, raw JSON, and a progressively paged output.log viewer
- Cron tab: expression, prompt, recurring/one-shot, created/last-fired
- count badges on both tabs

Tests: +20 (lib + route), all 113 vis-server tests green; web typecheck and
build clean.

* feat(agent-core): persist step retries and tool progress summary

Two transient signals were only ever emitted as live-only loop events, so
nothing survived in the agent record for post-hoc analysis:

- step retries: chatWithRetry gains an onRetry callback; turn-step collects
  the recovered attempts and attaches them to step.end as an optional
  `retries` array (previously only the live `step.retrying` event).
- tool progress: tool-call distills a tool's sparse status/percent updates
  into a bounded `progress` summary (updateCount / lastStatus / maxPercent)
  on tool.result. Streamed stdout/stderr is excluded — it would bloat the
  wire and is already reflected in the result output.

Both are additive optional fields, so the wire protocol version is unchanged
and existing records keep loading. New public types: LoopStepRetryRecord,
LoopToolProgressSummary.

* feat(vis): add execution-analysis timeline and surface retries/progress

Turn the debugger from a flat record viewer into an analysis tool.

New Timeline tab: folds the wire into turns → steps → tool calls (client-side,
no extra round-trip) and derives the metrics the raw list hides — per-turn /
per-step / per-tool duration, per-turn token cost, a context-window fill
sparkline with cache-hit rate, a tool usage table, idle-gap detection, and a
config-change timeline.

Inline elsewhere:
- Wire rows show tool.call → tool.result elapsed time; tool.result detail
  shows truncation, output size, retries, and the progress summary.
- Issues drawer gains tool-error, truncation, filtered, max_tokens, and
  retried categories.
- Tasks tab links agent-kind tasks to the subagent's wire.

Wires up vitest for the web package and adds analysis/issues unit tests.

* feat(vis): import debug zips with a logs view and imported-session filtering

A `/export-debug-zip` bundle is just `manifest.json` plus a flattened session
directory, which vis already knows how to read. Importing one therefore lights
up every existing tab for a session that lives on someone else's machine.

Server:
- zip-import: yauzl extraction with zip-slip path guards and entry-count /
  uncompressed-size caps for untrusted uploads.
- import-store: extract a bundle into <home>/imported/<imp_…>/, validate it
  has a main wire, and record an import-meta.json sidecar.
- session-store resolves imp_-prefixed ids against imported/, so wire /
  context / tasks / cron / blobs / logs all work on imported sessions; agent
  homedirs are re-derived locally (the bundle holds foreign absolute paths).
- POST /api/imports (raw zip body) and GET /api/sessions/:id/logs (structured
  log lines — also available for local sessions).

Web:
- session rail: import button + all/local/imported filter + imported badge.
- new Logs tab: virtualized, level filter, search, session/global toggle.
- manifest card atop the State tab for imported sessions.

SessionSummary/SessionDetail gain `imported` + `importMeta`. Tests cover
extraction, the zip-slip guard, list merge, reading an imported wire through
the existing route, and log parsing.

* fix(vis): read tasks/cron from agent homedirs and stop persisting tool status text

Addresses review feedback on the debug-tooling changes:

- Background tasks and cron jobs are persisted under each agent's homedir
  (<session>/agents/<id>/tasks and /cron), not the session root. The Tasks and
  Cron tabs read the session root, so they showed nothing for normal sessions.
  Both routes now aggregate across detail.agents homedirs; task entries carry
  the owning agentId. The route-test fixtures were writing to the wrong
  (session-root) location too — corrected to the real agents/main layout so
  they actually exercise the path.

- tool.result progress no longer keeps free-form status text, only updateCount
  and maxPercent. A tool's status string can contain sensitive data (e.g. an
  MCP OAuth authorization URL) that must not leak into persisted wire files or
  exported debug bundles.

* fix(vis): stop the main content area from overflowing horizontally

The <main> flex child lacked min-w-0, so it defaulted to min-width:auto and
refused to shrink below its content's intrinsic width. Tabs that lay out in
normal flow with flex-wrap rows (the Timeline tab) then got unbounded width,
never wrapped, and blew the layout out to thousands of pixels wide. Adding
min-w-0 lets the column shrink to the available width so its content wraps,
truncates, or scrolls within its own container.

* fix(vis): resolve local global log path and imported-state agent fallback

- Logs tab: for non-imported sessions the shared global log lives at
  <KIMI_CODE_HOME>/logs/kimi-code.log, not under the session dir (that path is
  only used inside exported bundles). The route now reads the home path for
  local sessions, so the global-log toggle works for them.
- Imported detail: a bundle's state.json is best-effort and may omit the
  agents map. When the inventory is empty, fall back to discovering agents
  from disk so routes that require an agent (wire/context) still resolve main.

* fix(vis): harden imported manifest and task parsing against corrupt input

An imported debug zip is untrusted, so a syntactically valid but type-corrupt
file could crash whole views:

- manifest.json: a non-string field (e.g. workspaceDir: 123) flowed into
  SessionSummary.workDir, where the session rail calls .split('/') and crashed
  the entire list. readManifest/readImportMeta now sanitize declared string
  fields, keeping only strings.
- task JSON: a record that passed the shape guard but held a non-string legacy
  field (e.g. stop_reason: 5) threw in normalization, failing GET /tasks with a
  500 and hiding all of a session's tasks. optionalNonEmptyString now tolerates
  non-strings, and listBackgroundTasks skips any record that still fails to
  normalize — honouring the reader's documented silently-skips contract.

* fix(vis): discover rotated logs; keep tool progress on thrown failures

- Logs tab: the diagnostic log can rotate (kimi-code.log.1, .2, …) and an
  exported bundle may contain only the archives. The route now discovers the
  active file plus its rotated siblings and concatenates them oldest-first, so
  a rotated-away log still surfaces (covered by node-sdk's rotated-export case).
- agent-core: a tool that reported sparse progress and then threw lost its
  progress summary, because the catch path built the error tool.result without
  it. Thread progressSummary through that path too, matching the success and
  malformed-return paths.

* fix(vis): skip type-corrupt agent entries in imported state

readImportedDetail's empty-inventory fallback never ran when a bundle's
state.json had a non-empty but type-corrupt agents map (e.g.
`{ "agents": { "main": null } }`): inventoryAgents dereferenced the null entry
and threw, so readSessionDetail returned 500 instead of recovering main from
the on-disk agents/main/wire.jsonl. inventoryAgents now skips non-object
entries, letting the disk-discovery fallback take over.

* fix(vis): reset timeline agent on session change; preserve context on zero-usage steps

- Timeline tab kept the previously-selected agent id across session navigation,
  so a subagent selection would 404 against the next session. Reset it to main
  on sessionId change, mirroring WireTab/ContextTab.
- A zero-usage step.end (e.g. a content-filtered response) reset the
  context-window fill to 0, pushing a false drop into the Timeline chart and the
  Context tab. agent-core's ContextMemory keeps the prior count in that case;
  the analysis lib and the context projector now do the same.

* revert: drop agent-core retries/tool-progress persistence

These were the only changes in this branch that touched agent-core. They
persisted two previously live-only signals (step retries, tool progress) to
the wire purely so the visualizer could display them — marginal features that
did not justify modifying the core loop or extending the wire surface.

Reverts the agent-core loop/type/export changes (restored to main, keeping
#1209) and its changeset, and removes the vis-side rendering and types that
consumed step.end.retries / tool.result.progress. The rest of vis is unchanged
and reads only data agent-core already persists.
2026-06-30 13:40:37 +08:00
github-actions[bot]
b41f108584
ci: release packages (#1197)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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 / Native release artifact (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-30 12:37:57 +08:00
Haozhe
14d9e98903
feat(server): auto-refresh provider model catalog and push change events (#1207)
* feat(server): auto-refresh provider models and push change events

- add scheduled provider-model refresh in the daemon (configurable
  interval + refresh-on-start) plus manual endpoints:
  POST /providers:refresh and POST /providers/{id}:refresh
- publish global event.model_catalog.changed when a refresh changes
  the catalog so connected clients can resync
- extract the refresh orchestrator into @moonshot-ai/kimi-code-oauth so
  the CLI and server share managed/open-platform/custom-registry logic
- wire the web daemon client to the new refresh endpoints

* chore: add changeset for provider model auto-refresh

* fix(web): reload model and provider caches on catalog change events

When the daemon's scheduled refresh changes the catalog, the pushed
event.model_catalog.changed only advanced the websocket sequence, leaving
the web composer's model/provider refs stale until an unrelated reload.
Reload both caches when the event arrives.

* test(sdk): cover event.model_catalog.changed in event exhaustiveness
2026-06-30 12:29:10 +08:00
qer
636ccc40f1
fix(web): fix mobile Safari composer toolbar overlap and focus zoom (#1212)
* fix(web): keep composer visible above the mobile Safari toolbar and keyboard

* fix(web): scope the mobile Safari composer fix to the toolbar case

* fix(web): prevent page zoom when focusing the mobile composer
2026-06-30 12:15:14 +08:00
Kai
063538744f
refactor(agent-core): align malformed tool args with schema validation (#1209)
Some checks are pending
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
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 / Native release artifact (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
* feat(agent-core): repair malformed tool args JSON

Attempt jsonrepair when tool call arguments fail JSON.parse, then continue schema validation. Return malformed JSON errors with a concise expected schema hint so the model can retry with corrected arguments.

* chore(nix): update pnpm deps hash

Update the fixed-output pnpmDeps hash after adding jsonrepair so the Nix build can fetch dependencies.

* refactor(agent-core): drop tool args JSON repair

Stop repairing malformed tool call arguments. Fall back to an empty object on JSON parse failure and let schema validation produce the retry error, while preserving valid arguments for unknown tools in the transcript.

* chore: add changeset for tool args validation
2026-06-29 23:24:03 +08:00
7Sageer
53e79e4bae
chore(telemetry): add ripgrep fallback telemetry (#1203)
Some checks are pending
CI / lint (push) Waiting to run
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-windows (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 / Native release artifact (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
2026-06-29 18:30:09 +08:00
7Sageer
c82dcf9cd8
refactor(agent-core): use ripgrep for Glob tool (#1068)
* refactor(agent-core): use ripgrep for Glob tool

Glob now shares Grep's ripgrep subprocess plumbing: it respects .gitignore by default, supports brace patterns natively, adds an include_ignored option, and returns only files.

* fix(glob): address review findings on ripgrep migration

- Run rg with cwd pinned to the search root so glob patterns containing
  a slash (e.g. src/**/*.ts) match under an absolute search root.
- Keep include_dirs as a deprecated, ignored parameter so older calls
  are not rejected by parameter validation.
- Surface stdout truncation and drop half-written trailing paths when
  the rg output buffer is capped.
- Document that a bare pattern (e.g. *.ts) matches recursively, and sync
  user docs, the explore profile prompt, and the TUI summary to the new
  files-only / gitignore behavior.
- Add real-ripgrep integration tests covering sort order, recursion,
  brace patterns, and the absolute-search-root case.

* fix(glob): keep partial results on traversal errors

---------

Co-authored-by: hynor <hynor@users.noreply.github.com>
Co-authored-by: Kai <me@kaiyi.cool>
2026-06-29 17:40:58 +08:00
7Sageer
10ffb7d9f9
chore(telemetry): normalize telemetry property keys to snake_case (#1196)
- Rename camelCase telemetry keys to snake_case on compaction_finished, compaction_failed, micro_compaction_finished, and the tool error event (tokens_before, tokens_after, compacted_count, retry_count, thinking_level, error_type, input_tokens/output_tokens, and the micro compaction config/effect keys).

- Emit a fixed client-attribution key set (client_id/name/version/ui_mode, null when absent) from both session_started producers (core-impl and kimi-harness) so they share a stable schema.

- Drop the duplicate current/latest keys on update_prompted and the redundant ui_mode on server_started.

- Additive fields: login.method=oauth and question_answered.answered.

Telemetry-only change; no changeset.
2026-06-29 17:40:50 +08:00
Haozhe
3e98e709b3
docs(changelog): sync 0.20.2 from apps/kimi-code/CHANGELOG.md (#1198) 2026-06-29 16:51:23 +08:00
liruifengv
0df1812502
fix: render provider HTML error messages instead of blank lines (#1191)
When a provider returns an HTML error page (e.g. nginx 413 Request Entity Too Large), the error message carried CRLF line endings and raw HTML. The trailing carriage returns made the TUI render the error line as blank. Extract the page title for the wire message and strip carriage returns before rendering.
2026-06-29 15:49:48 +08:00
github-actions[bot]
52e2719d17
ci: release packages (#1154)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-29 14:49:34 +08:00
Haozhe
c8d1d76de5
test(agent-core): align turn telemetry expectations with provider_type (#1194)
Update the turn_started/turn_ended and api_error telemetry assertions to
expect provider_type, matching the telemetry property rename in #1184.
2026-06-29 14:44:26 +08:00
Kai
821847cb4b
feat(managed-kimi-code): route anthropic protocol via beta api (#1186)
* feat(managed-kimi-code): route anthropic protocol via beta api

- kosong: add betaApi option to use client.beta.messages.create
- agent-core: thread alias betaApi into the anthropic provider config
- oauth: route managed models on the anthropic protocol through the beta Messages API

* feat(providers): add KIMI_CODE_CUSTOM_HEADERS support

- Add KIMI_CODE_CUSTOM_HEADERS env var for custom outbound LLM headers
- Send User-Agent to non-Kimi providers
- Forward Kimi identity headers to model catalog fetches
- Support defaultHeaders in Google GenAI provider

* feat(agent-core): add protocol attrs to turn and api error telemetry

- Add type/protocol/alias to api_error for per-protocol error attribution

- Add turn_ended event with reason/duration/mode/type/protocol

- Add type/protocol to turn_interrupted

* chore(oauth): remove hardcoded internal dev endpoint from shared OAuth base URLs

---------

Co-authored-by: haozhe.yang <yanghaozhe@moonshot.ai>
2026-06-29 14:24:01 +08:00
liruifengv
04b3492e74
fix(tui): keep working tips out of the agent swarm progress line (#1189)
* fix(tui): keep working tips out of the agent swarm progress line

The activity loader is shared between the activity pane and the agent swarm progress status line. Tips were written into the loader, so they leaked into the swarm progress line and got squeezed against the bar. Keep the inline spinner text used by the swarm progress line free of tips, while the loader's own row in the activity pane still shows them.

* test(tui): stop moon loader timers in tests

MoonLoader starts a real setInterval in its constructor. Stop every loader created in the tests via afterEach so no live timer leaks past the file.
2026-06-29 14:21:16 +08:00
Haozhe
c99bd1c10e
chore(changeset): downgrade web-completion-sound changeset to patch (#1190) 2026-06-29 14:03:29 +08:00
liruifengv
db5fbc53c0
fix(tui): stop full-screen redraw on input and slash panel toggle (#1188)
Remove the global clear-on-shrink setting so typing in the input box and opening or closing the slash panel no longer trigger a full-screen redraw.
2026-06-29 13:51:31 +08:00
liruifengv
97f9263c6f
fix(tui): clear debug timing status on undo (#1187) 2026-06-29 13:31:08 +08:00
7Sageer
49e93893a6
refactor: standardize telemetry property names (#1184)
Align outlier telemetry events with the conventions already used by
tool_call, api_error, permission_approval_result, and the plan events:

- duration / latency_ms / duration_s -> duration_ms
- success boolean -> outcome enum ('success' | 'error')
- bare type -> provider_type

The exit event switches from seconds to milliseconds to match the
duration_ms convention, so its numeric scale changes accordingly.
2026-06-29 12:34:38 +08:00
qer
1dab2c2268
feat(web): preserve side panel and scope input history per session (#1181)
Some checks are pending
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (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
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
* feat(web): preserve open side panel across session switches

* feat(web): scope composer input history to current session

* fix(web): suppress side panel open animation on session switch

* fix(web): preserve per-session scroll position on session switch

* chore: add changeset for per-session scroll position

* fix(web): preserve follow-bottom state per session

* fix(web): scope composer attachments to their session

* fix(web): restore saved scroll position on session switch
2026-06-29 01:52:08 +08:00
qer
fc3d69dbdc
feat(web): add completion sound and question notifications (#1179)
* feat(web): play a sound when a turn completes

Synthesize a short chime when a session finishes a turn. Opt-in via Settings -> Notifications (off by default); the audio context is unlocked on the first user gesture so it also plays while the tab is backgrounded.

* feat(web): notify and play a sound when a question needs an answer

Reuse the existing notification/sound toggles so they also fire when the agent asks a question (the awaiting-answer state). Generalize the Settings labels to cover both cases.

* fix(web): don't queue the chime on a suspended audio context

A suspended AudioContext has a frozen clock, so tones scheduled on it would play stale when the context later resumes (e.g. on the next click). Only schedule the chime when the context is actually running; if it is still suspended, try to unlock it for next time and skip this one.

* fix(web): gate question notifications behind explicit opt-in

Question notifications surface question text, so they must not fire for users who only opted into turn-completion alerts (which default on). Split question notifications into their own persisted preference that defaults off, with a separate Settings toggle. Completion notifications keep their existing default-on behavior.

* fix(web): show the question text in question notifications

Lead with the actionable question text in the desktop notification body, keeping the short header as context (e.g. 'Storage: Which database?'). Previously the header alone was shown, so users had to open the tab to learn what was being asked.
2026-06-28 21:02:00 +08:00
qer
c63edd5bf6
refactor(web): unify new-session creation behind the first message (#1167)
Some checks are pending
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
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
Remove the NewSessionDialog path so every new-session entry in the web UI enters the onboarding composer, creating the session only when the first message is sent. This removes the last web flow that produced an empty session.
2026-06-28 17:01:26 +08:00
qer
dfcfdfd9dd
feat(web): hide empty sessions from the session list (#1166)
* chore(web): remove the /sessions slash command

* feat(web): hide empty sessions from the session list

Add an optional exclude_empty parameter to the session list API; the web client passes it so unused "New Session" entries are hidden by default, with pagination and has_more computed on the filtered set.

* fix(protocol): add exclude_empty to the session list query schema

Keep the shared protocol schema in sync with the server route so clients using the protocol type see the new parameter.

* fix(protocol): keep exclude_empty off the child session list schema

listSessionChildrenQuerySchema aliased the main list schema, so it inherited exclude_empty even though the /sessions/{id}/children route does not filter by it. Split it so generated clients are not misled.
2026-06-28 13:38:46 +08:00
Haozhe
cf558cd742
feat(managed-kimi-code): support Anthropic-compatible protocol (#1170)
* fix(agent-core): recover from context overflow 413

- track provider-observed effective context limit after overflow
- compact with the reduced limit before retrying the turn
- treat large plain 413 responses as recoverable context overflow
- add CLI patch changeset

* feat(managed-kimi-code): support Anthropic-compatible protocol

- switch managed provider to anthropic when models declare anthropic protocol
- add base64 video content blocks to the kosong anthropic provider
- downgrade unsupported media parts to text placeholders by capability
- pass prompt cache key as Anthropic metadata.user_id for session affinity

* feat(agent-core): add protocol/type to request and video upload telemetry

- turn_started now carries `type` (configured provider wire type) and
  `protocol` (effective transport, i.e. alias.protocol ?? provider.type)
- new video_upload event reports mime type, size, latency and
  success/failure, plus type/protocol/model context
- ResolvedRuntimeProvider gains `type` and `protocol` fields
2026-06-28 13:04:03 +08:00
qer
f3b15322da
fix(web): replace the web composer attach plus icon with an image icon (#1165)
Some checks are pending
CI / lint (push) Waiting to run
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-windows (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
2026-06-28 02:18:11 +08:00
qer
ff6e8bbd7c
fix(web): clear composer draft synchronously on send (#1163)
When the first message of an empty session is submitted, the optimistic
user turn unmounts the empty-session composer before the post-flush text
watcher can persist the cleared draft. The docked composer then mounts
and reloads the stale text from localStorage.

Clear the persisted draft synchronously in the submit / steer / slash
command paths instead of relying on the text watcher, so the next mount
always starts empty.
2026-06-28 01:55:04 +08:00
qer
b0708464f4
feat(kimi-web): rework ask-user-question card as step-by-step wizard (#1162)
* chore(web): show five sessions per workspace

* feat(kimi-web): rework ask-user-question card as step-by-step wizard
2026-06-28 01:24:38 +08:00
qer
d968642384
chore(web): show five sessions per workspace (#1161) 2026-06-28 01:07:51 +08:00
qer
23a553bb91
feat(web): focus composer on /new and /clear, keep viewport scalable (#1159) 2026-06-28 00:48:19 +08:00
qer
794db55538
fix(agent-core): cap compaction output at 128k by default (#1156) 2026-06-27 23:24:57 +08:00
qer
54baf5d07f
chore(deps): upgrade web markdown renderer dependencies (#1155)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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 / Native release artifact (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
* chore(deps): upgrade web markdown renderer dependencies

- katex: ^0.16.22 -> ^0.17.0

- markstream-vue: 1.0.3 -> ^1.0.4

- shiki: ^4.2.0 -> ^4.3.0

* chore(deps): update pnpm deps hash in flake.nix

Required after upgrading katex, markstream-vue, and shiki.
2026-06-27 18:18:04 +08:00
Qkunio
d02b5c4984
fix(agent-core): use max output size for compaction budget (#1129)
* fix(agent-core): use max output size for compaction budget

* chore: add changeset for compaction max output size fix

* docs: rephrase compaction changeset

---------

Co-authored-by: qkunio <qkunio@163.com>
Co-authored-by: qer <wbxl2000@outlook.com>
2026-06-27 16:40:31 +08:00
liruifengv
278984dee0
ci: disable windows test job (#1144)
Some checks are pending
CI / typecheck (push) Waiting to run
CI / build (push) Waiting to run
CI / test (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 / Native release artifact (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
2026-06-26 21:47:44 +08:00
qer
7eca38aa52
docs: sync 0.20.1 changelog and document plugin hooks (#1142) 2026-06-26 20:06:46 +08:00
github-actions[bot]
da63403207
ci: release packages (#1124)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-26 19:17:49 +08:00
qer
76c643bcb6
feat: cap completion tokens to remaining context window for chat-completions (#1131)
* feat: cap completion tokens to remaining context window for chat-completions

* test: cover dynamic completion budget for kimi and openai-legacy

* fix: leave compaction budget uncapped to avoid one-token summaries

* fix: add hookCount to plugins-selector test mocks
2026-06-26 19:12:04 +08:00
qer
f8a638d7e3
chore: downgrade plugin hooks changeset to patch (#1137) 2026-06-26 18:49:42 +08:00
qer
bf51fb7a10
fix(server): skip Unix-only permission check on Windows for server token (#1135) 2026-06-26 18:38:24 +08:00
liruifengv
b0b2aee8c5
perf(tui): keep long conversations responsive (#1119)
* perf(tui): cache rendered message lines across frames

Cache render(width) output in the transcript container and message components, returning cached lines when content, theme, and width are unchanged. Removes the per-frame full-transcript re-render that caused the TUI to lag as history grew.

* perf(tui): bound transcript with sliding window and step merging

Keep the TUI responsive as conversations grow by bounding the live
transcript:

- Sliding window: keep only the most recent 50 turns in the component
  tree; older turns are destroyed (entry + component).
- Step merging: within each turn, keep only the most recent 30
  thinking / tool steps rendered; older ones collapse into a summary.
- Expand (Ctrl+O) only reaches the most recent 3 turns.

All thresholds are overridable via KIMI_CODE_TUI_* env vars; 0
disables the corresponding feature.

* chore: add changeset for tui transcript window

* chore(tui): remove KIMI_TUI_PERF render timing log
2026-06-26 18:32:32 +08:00
qer
f1c8175f9c
fix(tui): carry server token in /web and print it on exit (#1133)
* fix(tui): show the server token when handing off via /web

The /web slash command opened the session deep link without the bearer token, so the web UI was not authenticated and the token was never shown, unlike the kimi web subcommand. Resolve the persistent server token, append it as the #token= fragment so the browser signs in on load, and show it in green below the status line so it can be copied before the terminal exits.

* test(plugins-selector): add required hookCount to fixtures

PluginSummary/PluginInfo gained a required hookCount field, so the app's typecheck failed on fixtures that did not provide it. Add hookCount: 0 to the test summaries (none of these fixtures declare hooks).
2026-06-26 18:04:51 +08:00
liruifengv
e5eaeb4634
ci: skip server e2e tests on windows (#1126) 2026-06-26 18:01:22 +08:00
qer
81ba48f455
feat(web): auto-grow composer and add expandable editing mode (#1121)
* feat(web): auto-grow composer and add expandable editing mode

- Grow the chat textarea with its content up to a 1/4-viewport cap.
- Add an expand toggle above the send button for a taller editor; in that
  mode Enter inserts a newline and Cmd/Ctrl+Enter or the button sends,
  then the editor collapses back.

* fix(web): reset expanded composer state on session change

The composer instance is reused across sessions (not keyed by session id), so the expanded preference leaked into the next session's draft, leaving it stuck in the tall editor with Enter inserting newlines. Collapse back when the active session changes.

* fix(web): match expand-toggle threshold to theme resting height

The modern/kimi global theme overrides the composer min-height to 40px (the scoped default is 56px), so a hard-coded 56px threshold kept the expand toggle hidden until a third line under the default theme. Read the computed min-height from the element instead.

* fix(web): recompute expand-toggle visibility after collapsing

While expanded the computed min-height is 70vh, so a multi-line draft measured there sets isGrown=false. Collapsing did not recompute it, hiding the toggle even though the collapsed draft was still multi-line. Recompute growth after every toggle via a shared helper. The expanded state itself is unchanged and stays at 70vh until toggled or sent.

* fix(web): collapse expanded editor on slash-command submit

Known slash commands return early from handleSubmit, above the post-send collapse, so sending an expanded /goal, /btw, /compact, or skill command left an empty 70vh editor. Collapse in the slash-command path too.

* fix(web): refocus textarea after toggling expand

Clicking the expand toggle leaves focus on the button, so subsequent keystrokes do not reach the textarea and Enter would activate the button again instead of inserting a newline. Return focus to the textarea after toggling.

* fix(web): refit textarea when collapsing after image-only sends

When the expanded editor collapses on an image-only send, the text is already empty so the draft watcher never re-runs autosize; the textarea kept the inline height measured at 70vh and the collapsed cap left an oversized empty box. Route all send/steer collapses through a helper that re-runs autosize after the 70vh min-height is removed.

---------

Co-authored-by: liruifengv <liruifeng1024@gmail.com>
2026-06-26 17:21:51 +08:00
Haozhe
0886bff2bc
feat(server): add --allowed-host flag for DNS-rebinding allowlist (#1128)
- add `kimi server run --allowed-host <host...>` (repeatable or
  comma-separated; leading dot matches a domain suffix) and thread it
  through daemon spawn into startServer
- merge CLI allowed hosts with KIMI_CODE_ALLOWED_HOSTS for both the HTTP
  and WebSocket Host checks
- include the rejected host and allow guidance in the 403 error message
2026-06-26 17:06:01 +08:00
qer
184acf5db5
feat: support hooks in plugins (#1127)
* feat(agent-core): support per-hook cwd and env in HookEngine

* feat(agent-core): support hooks in plugin manifest and aggregate via PluginManager

* feat(agent-core): merge plugin hooks into session hook engine

* chore: add changeset for plugin hooks
2026-06-26 17:02:14 +08:00
7Sageer
28d358b526
chore: downgrade feedback changeset to patch (#1130) 2026-06-26 16:57:00 +08:00
Kai
9c9716125e
feat: Harden the default system prompt and built-in tool descriptions (#1102)
* feat(agent-core): strengthen default system prompt

Add high-confidence, prompt-only guardrails to the default agent system prompt:

- Personality/candor: extend the HELPFUL/CONCISE/ACCURATE line with CANDID, and
  require plainly stating what could not be run, reproduced, or verified.
- Reminders: avoid cheerleading; voice evidence-based disagreement; deliver
  complete code with no placeholders; update now-stale comments/docstrings after
  a change; re-check the user's latest request before finalizing a reply.
- Context Management: explain automatic compaction — continue from the summary,
  re-establish transient state with tools, do not restart from scratch.
- Output formatting: replies render as Markdown in the terminal; keep lists flat;
  no emojis unless the user uses them first.
- Project Information: frame injected AGENTS.md as project context, not a
  privileged instruction channel that can override system rules.

Prompt text only; no code or template-variable changes.

* feat(agent-core): hoist key working rules into the system prompt

Lift a few high-leverage rules from individual tool descriptions up into the
default system prompt, so they shape default behavior before any specific tool
is in play (kept terse and integrated, not bolted on):

- Planning: for multi-step or multi-file work, maintain a `TodoList` (one item
  in_progress, mark done as it finishes) and prefer `EnterPlanMode` first when
  the approach isn't settled.
- Default to making progress, not asking: once the goal is clear and sanctioned,
  carry it through and work blockers yourself; ask only when the answer would
  change the next step. Explicitly does not override stopping to discuss an
  unclear goal or waiting for go-ahead before writing code.
- Tool routing: prefer dedicated tools (Read/Glob/Grep/Write/Edit) over raw
  shell when one fits; keep Bash for genuine shell work.
- Definition of done: verify with the checks that cover the change before
  marking it complete, independent of whether a TodoList is in use.
- Delegation: explore subagents also keep intermediate file contents out of your
  own context — you get a conclusion back, not a pile of dumps.

Prompt text only; no code or template-variable changes.

* fix: clarify guidelines for file pattern matching and tool usage in explore.yaml and system.md

* fix(agent-core): hide the Skills section from agents without the Skill tool

Subagents (coder/explore/plan) inherit the root system prompt but lack the Skill tool, yet KIMI_SKILLS was rendered unconditionally — leaking the full skill listing into agents that cannot invoke any skill. Gate KIMI_SKILLS on the profile's tool set and wrap the '# Skills' section in {% if KIMI_SKILLS %} so it disappears for those profiles.

Also note in the Working Directory section that Bash enforces none of the workspace/secret-file guards, so the model must hold that discipline itself.

Tests: assert the Skills section renders for the root agent and is absent for Skill-less subagents; update the prompt-rendering fixtures for the new gating.

* fix(agent-core): gate Agent, background-task, and TodoList guidance by tool availability

Subagents (coder/explore/plan) inherit the root system prompt but lack the Agent, TaskList, and TodoList tools, so they were shown usage guidance for tools they cannot call. Derive HAS_AGENT/HAS_TASKLIST/HAS_TODOLIST from each profile's tool set and gate those sections with inline {% if %}, so they render only for agents that hold the tool.

Root rendering is byte-identical (the inline tags collapse to the original text when the flag is set). The cross-tool secret-file guard stays shared, since explore/plan still hold Read/Grep/Glob.

Tests: assert the gated guidance is present for the root agent and absent for explore/plan, while the shared secret-file guard remains.

* refactor(agent-core): move Agent-delegation and Glob-anchor guidance into the tool descriptions

The Agent-delegation paragraph in the system prompt duplicated mechanics already documented on the Agent tool itself (new-vs-resume, zero-context briefing, foreground default / run_in_background threshold), so remove it. HAS_AGENT still gates the explore-delegation bullet, which carries the 'when to delegate' nudge the tool description deliberately omits.

Move the proactive 'anchor the pattern up front' guidance into the Glob tool description (it previously only described the reactive 'refine after hitting the cap' path) and drop the now-redundant Glob bullet from the system prompt.

Tests: drop the assertions tied to the removed Agent paragraph; HAS_AGENT gating stays covered via the explore bullet.

* test(agent-core): add guidance for blast-radius and concrete examples in agent profiles

* docs: update descriptions for skill-tool and fetch-url; enhance web-search citation instructions

* feat(agent-core): disclose enforced constraints in tool descriptions; fix GetGoal field doc

Surface runtime-enforced behavior in the Agent / AgentSwarm / AskUserQuestion / Goal
tool descriptions so the model learns the rules from the tool, not from a failed call:

- Agent: resuming excludes subagent_type (setting both is rejected)
- AgentSwarm: at least 2 items unless resuming, prompt_template required and must
  contain {{item}}, distinct resulting prompts; plus Agent-vs-AgentSwarm fan-out note
- AskUserQuestion: result is {answers}; an empty answers with a dismissal note means
  the user declined — fall back to best judgment instead of re-asking
- CreateGoal: creating fails when a goal already exists (use replace)
- SetGoalBudget: state the hard 1s-24h time-budget band
- UpdateGoal: do not mark blocked merely because work is hard/slow/incomplete
- GetGoal: drop the advertised self-report / evaluator-verdict fields — GoalSnapshot
  never held them, so the tool never returned them

Each change is covered by a description assertion.

* fix(agent-core): soften AskUserQuestion answers-keying wording to match the code

The answers object is passed through from the host/RPC layer (QuestionAnswers is
Record<string, string | true>); this code does not key it by question text. Describe
what the keys identify instead of asserting a guarantee the code does not provide.

* feat(agent-core): tighten Bash/Grep/Write/Edit tool descriptions

- Bash: prefer the cwd argument (or absolute paths) over a cd from an earlier
  call, since each call runs in a fresh shell
- Grep: note that files_with_matches is ordered most-recently-modified first
- Write: do not create documentation/README files unless the user asks
- Edit: frame replace_all with its rename-across-file use-case

Each change is covered by a description assertion.

* feat(agent-core): refine plan-mode/todo/cron tool descriptions

- ExitPlanMode: describe what a good plan contains (specific, verifiable steps
  grounded in the codebase, not vague filler)
- TodoList: stop calling it useful 'in Plan mode' — plan-mode planning goes to
  the plan file; TodoList tracks execution progress
- CronCreate: warn that a one-shot whose pinned day/month already passed this
  year is rejected; document the 50-task session cap and the 8 KiB prompt cap
- CronCreate: drop the bench-only KIMI_CRON_NO_STALE / KIMI_CRON_NO_JITTER env
  knobs from the model-facing description (CI-only; the model never sets them)

Each change is covered by a description assertion.

* refactor(agent-core): dedupe ExitPlanMode options docs into the param schema; trim EnterPlanMode workflow

- ExitPlanMode: the options field mechanics (label format, recommended, count,
  single-option=plain-approval, reserved labels) now live only in the options
  param describe; the tool description routes to it and keeps the yolo/manual UI
  behavior it uniquely documents. The options consistency test now enforces a
  single source of truth (describe) plus the schema-consistency guard, instead
  of requiring the same facts in both surfaces.
- EnterPlanMode: trim the duplicated 'What Happens in Plan Mode' steps to a
  pointer (the full workflow is injected unconditionally once plan mode is
  active), keeping the explore-subagent recommendation.

* fix(agent-core): correct prompt/code inaccuracies found in the final audit

Every item below was re-verified against the live code:

- Skill: drop the never-fired recursion-depth cap (production never seeds depth);
  keep the <kimi-skill-loaded> 'already loaded, don't re-invoke' guard
- TaskOutput: terminal_reason can also be `failed`, not just timed_out/stopped
- Grep: count_matches emits per-file `path:count`, with the total reported separately
- Plan mode: the reminder names TaskStop/CronCreate/CronDelete as blocked (they are
  hard-denied by plan-mode-guard-deny)
- Bash: the failure trailer is non-zero-exit-specific; timeout/interrupt differ
- CreateGoal: replace also covers a blocked goal, not just active/paused
- UpdateGoal: it also injects the completion/blocked outcome prompt, so it does more
  than 'only record the status'
- FetchURL: state the universal http/https contract instead of provider-internal SSRF
  and 10 MiB limits (the primary Moonshot fetcher enforces neither)
- TodoList: query mode triggers on omitting `todos`, not on zero args
- TaskList: command/PID/exit code are shell-task fields only
- CronCreate: the returned fields include `cron`
- SetGoalBudget: turn/token budgets are rounded up to >= 1, not rejected below 1

Each change is covered by a description/param assertion; plan.test.ts snapshots
refreshed for the longer plan-mode reminder.

* fix(agent-core): gate prompt tool guidance on runtime availability, not declared profile tools

The HAS_* / Skills gating computed flags from the profile's declared tools, but
Agent/AgentSwarm only register when a subagentHost exists (ToolManager
.initializeBuiltinTools). A runtime built without a subagentHost (e.g. direct SDK
construction) therefore rendered the explore-delegation guidance for an Agent
tool the model could not call.

SystemPromptContext now carries an optional availableTools; buildTemplateVars
gates on it when present and falls back to the declared tools otherwise. useProfile
passes the profile tools minus Agent/AgentSwarm when no subagentHost is wired, so
the render reflects what the model can actually call. The normal session path
(subagentHost always defaulted) is unchanged.

* fix(agent-core): exempt the plan-mode plan file from the Write *.md ban

Plan mode writes its plan to plans/<id>.md (plan/index.ts) and the reminder tells
the model to create it with Write when missing, which contradicted Write's blanket
'do not create *.md unless asked' guard. Carve the plan file out of the ban.

* fix(agent-core): scope plan-mode prompt guidance and the Write *.md ban to runtime reality

- Gate the TodoList bullet's "enter plan mode via EnterPlanMode" suggestion
  on a new HAS_ENTERPLANMODE flag. A custom profile that keeps TodoList but
  drops EnterPlanMode no longer steers the model toward a tool it cannot call;
  the default profile render is unchanged.
- Reframe the Write *.md prohibition around intent (unsolicited docs) instead
  of a blanket extension ban, so artifacts a task or project instruction
  requires — the plan-mode plan file, a repo-mandated changeset — are no
  longer contradicted by the tool's own rules.

* refactor(agent-core): move tool-coupled guidance into tool descriptions

The default system prompt carried tool-usage guidance behind {% if HAS_* %}
gates that re-derived, in prose, the availability the tool schema already
encodes — and the same guidance was duplicated in each tool's own
description. Drop the four gated blocks (background Bash, Agent/explore,
TodoList, EnterPlanMode) and the compaction TaskList/TodoList bullets; the
tool descriptions, shipped only when the tool is registered, already carry
the same instructions, so subagents and tool-trimmed profiles are no longer
pointed at tools they lack.

Fold the two genuinely unique lines into the tool descriptions: bash.md
gains "return control after starting a background task", agent.md gains the
context-hygiene reason to delegate. Collapse the compaction bullets into one
tool-agnostic sentence. Remove the now-unused availableTools / HAS_* render
machinery.

* fix(skill-tool): clarify no-reinvoke guard and argument handling in tool description

* feat(fetch-url): indicate content retrieval mode in output for better model context

* fix(agent-core): correct goal-budget rounding and task-output failure docs

set-goal-budget.md said turn/token budgets are "rounded up", but the code uses
Math.round — say "rounded to the nearest whole number" instead. task-output.md
implied every failed task carries terminal_reason/stop_reason, but a plain
non-zero command exit carries only status plus exit_code; describe that exit_code
path and reserve terminal_reason for non-exit endings (timeout, explicit stop,
or an internal error with no exit code).

* fix(agent-core): scope free-work guidance by role and steer one-shots near-term

The blast-radius paragraph told every profile that local work — including
editing files — may be done freely, but the read-only explore/plan subagents
render it too; scope it to "work your role permits" so it no longer undercuts
their read-only constraints.

The one-shot cron guidance leaned on a year-boundary heuristic ("avoid a
day/month already passed this year") that misfires across Dec 31 to Jan 1 and
duplicated a limit the code already enforces. Replace it with a plain near-term
nudge and leave the hard future-window guard in code.

* fix(enter-plan-mode): clarify availability of Agent tool in plan mode description

* fix(agent-core): surface Grep count_matches total and pagination in output

count_matches put the aggregate "Found N occurrences" summary and the
"Results truncated... use offset=N to see more" notice on the result's
message field, which normalizeToolResult drops before the result reaches the
model. The model saw only the path:count lines and could miss the total and,
worse, the pagination cue — so it would not know to page through truncated
counts. Append both to output after the path:count lines, the same way the
content and files_with_matches modes already inline their notices.

* fix(grep): reorder count summary and results in output for clarity

* chore(changeset): consolidate prompt-hardening changesets into one

Squash the five per-change changesets for this PR into a single concise
entry; they all bump @moonshot-ai/kimi-code (patch) for the same
system-prompt and tool-description hardening work.

* fix(agent-core): stop the agent from blocking on background tasks

Both the Agent and Bash background-launch messages invited the model to "peek
at progress" via TaskOutput, and the foreground-vs-background guidance had been
thinned to a single parameter hint. Together that led the model to launch a
background subagent and then immediately wait on it through TaskOutput —
defeating the point of background execution.

Make both launch messages take the same anti-wait stance the user-detach path
already uses (do NOT wait, poll, or call TaskOutput on it), restore
foreground-by-default guidance in the Agent background description (run in the
background only when you have other work and do not need the result to proceed),
and add a TaskOutput backstop against using it to sit and wait. Also fold the
fix into the consolidated changeset.
2026-06-26 16:56:40 +08:00
Kai
e9a3b7c83a
feat(cli): add update alias for upgrade command (#1125)
Register a `kimi update` alias for the existing `kimi upgrade` command via commander's .alias(), so both forms run the same upgrade flow. Document the alias in the command reference and add a routing test.
2026-06-26 16:48:45 +08:00
7Sageer
e736349a7c
feat(feedback): support attaching logs and codebase (#1120)
* feat(feedback): support attaching logs and codebase

Add an attachment picker to /feedback (none / logs / logs + codebase).
Codebase uploads scan the working directory with sensitive files excluded
and are sent through a new multipart upload API on the oauth/node-sdk layers.

* fix(feedback): fall back to logs when codebase scan fails

* tiny fix

* fix(feedback): make diagnostic uploads partial-safe

* refactor(feedback): reuse harness session export and normalize upload url types

* docs(slash-commands): note optional feedback attachments

* refactor(feedback): reorganize feedback upload modules

Move the attachment orchestration out of tui/commands/info.ts into a
dedicated feedback/feedback-attachments.ts, and split the former
codebase-upload/attach.ts into a generic multipart uploader
(feedback/upload.ts) and an archive lifecycle module
(feedback/archive.ts). Both session and codebase archives now flow
through a single upload lifecycle, which also removes the temp-dir
leak that occurred when codebase packaging failed.

Rename FeedbackCodebaseArchive to FeedbackArchive and the
codebase-upload/ directory to codebase/ so module boundaries match
their actual responsibilities (scan + package only).
2026-06-26 16:15:08 +08:00
liruifengv
820d77ab4c
feat(tui): show hidden todo status breakdown in collapsed panel (#1122) 2026-06-26 15:50:58 +08:00
liruifengv
36cbdb29c0
ci: bump windows test timeout to 30s (#1123) 2026-06-26 15:35:56 +08:00
liruifengv
b51e13538d
ci: run unit tests on windows (#1037)
* ci: run unit tests on windows

* fix(migration-legacy): align workdir bucket key with agent-core

computeWorkdirBucket used a local node:path-based resolve that yields backslash-separated paths on Windows, while agent-core's encodeWorkDirKey uses pathe (forward slashes on every platform). The SHA-256 inputs diverged, so migrated sessions were written to a bucket that the session picker never reads, making them invisible on Windows.

Alias computeWorkdirBucket to encodeWorkDirKey so both sides stay byte-identical, drop the local slugify copy, and update the workdir-bucket test reference accordingly.

* test(acp-adapter): expect platform-native separators in e2e-fs path

The e2e-fs test asserted the fs/readTextFile wire path as the raw POSIX targetPath, but AcpKaos.toClientPath converts '/' to '\' when the inner LocalKaos reports pathClass 'win32' (Windows). On Windows the wire path became '\Users\test\x.ts' and the assertion failed.

Mirror toClientPath in the test: expect backslash separators on win32 and the raw path otherwise. Implementation is unchanged.

* test(sdk): normalize workDir and skillDir paths in session tests

SessionStore.create/list and the skill loader normalize paths through pathe (forward slashes). The SDK tests compared the resulting workDir and skill loaded-dir against raw mkdtemp / node:path strings, which use backslashes on Windows (and node:fs realpath also returns backslashes for the skill dir), failing three toMatchObject assertions.

Build the expected paths with agent-core's normalizeWorkDir so they match the internal pathe representation on every platform. The skill dir keeps its realpath() (the loader realpaths the root) and only normalizes separators.

* test(skill): normalize realpath to forward slashes in scanner tests

resolveSkillRoots normalizes every root.path through fs.realpath followed by replacing backslashes with forward slashes (scanner.ts). The scanner tests compared root.path against node:fs realpath directly, which returns backslashes on Windows, so twenty assertions failed (toEqual / toContain / toHaveLength) even though the resolved paths were identical.

Wrap realpath at the top of the test file to mirror the implementation's normalization, so every comparison uses the same forward-slash form on every platform.

* test: skip Unix-only permission tests on Windows

The Unix file-permission assertions (mode bits like 0o600 / 0o700 and chmod 000 making a path unreadable) have no equivalent on Windows, which uses ACLs; fs.chmod there can only toggle the read-only bit. These six tests failed on Windows with mismatched mode values or a missing 40411.

Skip them on win32 via it.skipIf(process.platform === 'win32'): oauth FileTokenStorage (0600 file, 0700 dir), agent-core BackgroundTaskPersistence (0700 tasks dir), agent-core createPerIdJsonStore (0700 subdir), migration-legacy atomicWrite (0600 file), and server fs:browse (chmod 000 -> 40411).

* test(tui): make platform-sensitive assertions cross-platform

The TUI implementations are already platform-aware (pathe-style paths, pathToFileURL, quoteShellArg cmd/POSIX quoting, Alt+V on Windows for paste expansion), but the tests hard-coded POSIX expectations and failed on Windows.

Align the assertions with the implementation's platform behavior: footer-goal-badge matches the '[goal' badge prefix instead of /goal/ (toolbar tips contain '/goal'); tool-call expects backslash relative paths on win32; plan-box builds the file:// URL via pathToFileURL; custom-editor sends Alt+V on win32 for paste expansion; file-mention-provider normalizes the expected description to forward slashes; kimi-tui-startup builds the resume command with quoteShellArg; kimi-tui-message-flow builds the expected install path with resolve().

* test: align path assertions with pathe on Windows

Several test suites asserted paths produced by node:path/node:os/node:fs against values that agent-core, node-sdk and kaos normalize through pathe (forward slashes). On Windows the two forms diverge (backslashes vs forward slashes), failing about 19 assertions.

Mirror the implementation's normalization in the assertions via a local toPosix helper (or agent-core's normalizeWorkDir), so expected paths use forward slashes on every platform: kaos LocalKaos, node-sdk export/list/resume/config/transport sessions, cli FileMentionProvider, and agent-core skill-session.

* test(native): build path expectations with node:path.resolve

paths.mjs builds every path with node:path.resolve, which yields backslash-separated absolute paths on Windows. The path-helpers tests asserted against template strings that mixed the backslash appRoot with forward-slash segments, so Object.is failed on Windows even though the strings looked identical.

Build the expectations with the same resolve(appRoot, ...) helper so the separators match on every platform.

* fix: make Windows CI tests pass across all packages

Fix the remaining Windows CI failures so the Windows test job can go green. The changes fall into a few categories:

- Path separators: agent-core/node-sdk/kaos normalize paths via pathe (forward slashes); align test expectations and a couple of implementations (native cache base, workspace registry) with that.

- Platform-only services: skip launchd/systemd manager suites on win32 (Windows uses schtasks).

- Process/signal lifecycle: skip or relax tests that rely on POSIX signals / SIGTERM semantics that Windows does not support.

- Hook shell syntax: rewrite hook test commands from POSIX shell (single quotes, semicolons, stderr redirects, if/then/fi) to node -e / .cjs files that run under cmd.exe.

- CRLF: make Bash tool description stripping tolerate CRLF line endings.

- Misc: realpath short-name divergence, port-retry timing, telemetry spawn, fs-watch timing, snapshot path normalization, etc.

* fix: remove unused basename import in workspaceRegistryService

Fix lint error (no-unused-vars): basename from node:path is no longer used after switching to posixBasename from pathe.

* fix: align resume harness pathClass and wait for banner state on Windows

Two more Windows CI fixes:

- createResumeNoSideEffectKaos now reports pathClass 'win32' on Windows so tool descriptions (e.g. Glob's Windows note) match the live agent in expectResumeMatches, fixing usage/description deep-equal drift.

- kimi-tui-startup once-banner test now waits for writeBannerDisplayState to land before asserting, since the atomic write can lag behind the render on Windows.

* fix: resolve remaining Windows unit test failures

Make the new Windows CI job green across agent-core, kaos, node-sdk and server:

- Align the resume harness kaos pathClass with the live agent so platform-conditional tool descriptions (Glob's Windows note) match in expectResumeMatches instead of drifting on win32.
- Rewrite hook commands in agent-core tests as cross-platform node one-liners; single-quote echo, >&2 and ';' do not work under cmd.exe.
- Add .gitattributes enforcing LF so raw-imported templates (e.g. the compaction instruction) produce byte-identical token counts on Windows and POSIX.
- Terminate the full process tree on Windows in both the hook runner and kaos (taskkill /T /F) so grandchildren cannot outlive their parent and keep the cwd locked.
- Normalize workDir path separators in two kimi-sdk session tests to match the stored canonical form.
- Avoid cmd.exe arg-quoting pitfalls in the kaos cmd.exe test, and run the Windows process-tree kill test from a script file with the pid path passed via argv.
- Give the first fs-git e2e test more time on Windows and retry the temp-dir cleanup; skip the fs-watch overflow-burst assertion on Windows where fs-event coalescing prevents the single-window spike.

* ci: retrigger checks

* fix: resolve remaining Windows failures after merging main

- Terminate the spawned git/gh process tree on Windows in FsGitService (taskkill /T /F on timeout) so a timed-out 'gh pr view' cannot leave a grandchild holding the workspace cwd, which made the fs-git e2e cleanup fail with EPERM.

- Give the fs:git_status e2e suite a longer timeout on Windows and retry the temp-dir cleanup longer to ride out the slower child-process teardown.

- Make the third-party plugin install trust test assert the resolved install path via node:path so it matches the Windows-resolved path (D:\tmp\...) as well as the POSIX one.

* fix: align workspace registry roots and harden fs-git cleanup on Windows

- workspace-registry test: compare normalized (forward-slash) roots, since the registry and session index both store workDir via pathe.resolve (forward slashes on every platform). realpath() yields backslashes on Windows and diverged from the stored root.

- fs-git e2e: bump the temp-dir cleanup retries and the afterEach timeout, since Windows child-process teardown after server.close() is asynchronous and can keep the workspace cwd locked for several seconds.

* test: stub openUrl in kimi-tui-message-flow feedback tests

The /feedback command falls back to openUrl(FEEDBACK_ISSUE_URL) when submission fails, which spawned a real browser window on every test run. Mock #/utils/open-url (matching the existing login/message-replay/server test convention) so the suite never opens a browser.

* test: harden fs-git e2e cleanup against Windows cwd locks

On Windows, git/gh child processes and the session core process can outlive server.close() and keep the temp workspace as their cwd, so rmSync fails with EPERM even after a long retry. Add rmSyncRobust that retries and, if the cwd is still locked, swallows EPERM/EBUSY on Windows — the OS reclaims the temp dir and a cleanup hiccup must not fail an otherwise-passing test.

* test: harden server e2e cleanup against async teardown races

server.close() does not fully await the server's asynchronous teardown, so on a loaded CI runner the temp home/workspace dirs can still be held or written to when the afterEach rmSync runs, failing with EPERM (Windows) or ENOTEMPTY (Linux). Use a rmSyncRobust helper (retry + swallow EPERM/EBUSY/ENOTEMPTY) in the fs-git and question e2e cleanup. Also fix a leftover `throw err` (renamed to `throw error`) that broke the typecheck.
2026-06-26 11:56:41 +08:00
qer
174101278d
chore: update changelog skill (#1115) 2026-06-26 03:32:09 +08:00
qer
258d248020
doc: 0.20.0 changelog (#1114) 2026-06-26 03:30:59 +08:00
github-actions[bot]
5f36e763ca
ci: release packages (#1061)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-26 02:29:53 +08:00
qer
6194d3fad3
perf(web): reuse sessions reference to stop sidebar re-render on streaming deltas (#1113) 2026-06-26 02:24:31 +08:00
qer
6a97d0bf43
feat(web): add a copy button to user messages (#1112)
* feat(web): add a copy button to user messages

Mirror the assistant per-message copy button on user turns, copying the message text with the same checkmark feedback.

* fix(web): place user message copy button with undo and time

Move the copy button out of the line-number role row into the modern bubble meta row, grouped with the undo and time buttons, with matching styling and tooltip.
2026-06-26 01:01:56 +08:00
qer
d554f9ac87
feat(web): show full accumulated subagent progress (#1109)
* feat(web): show full accumulated subagent progress

The subagent detail panel only showed the most recent 40 progress lines because the reducer truncated the accumulated output. Keep the full history so the panel reflects the entire process as it grows.

* fix(web): preserve subagent progress across task updates

The projector emits a taskCreated (without reducer-owned outputLines) right before every taskProgress, and the taskCreated branch replaced the task object outright, resetting outputLines to empty on each progress event. So the panel still only showed the latest chunk. Preserve the accumulated outputLines when replacing a task, and update the test to mirror the real taskCreated-before-taskProgress path.

* feat(web): clean up subagent progress text

Drop the noisy 'Started a step' line and summarize tool calls with a concise target (path / command / pattern) instead of the full JSON args, so the subagent progress panel shows what the subagent is actually doing.

* fix(web): strip numeric index from subagent tool result names

Some subagents name tool calls with a trailing index (e.g. Read_0, Bash_4), which surfaced in tool.result progress lines since the label did not resolve. Strip the index before resolving the label.

* fix(web): bound non-subagent task output and subagent progress text

Restore a tail cap for background bash/tool task output (which can grow without bound) while keeping subagent progress in full, and cap individual subagent tool.progress chunks so a single huge output cannot dominate the panel.

* feat(web): group and fold subagent progress output

Drop the noisy 'Finished' lines, group tool output under its call, and fold long output (first 5 + last 2 lines, expandable) so the subagent progress panel shows the call rhythm at a glance.
2026-06-26 00:22:12 +08:00
qer
18f7c34a07
feat(web): show edit diffs inline in tool call cards (#1103)
* feat(web): show edit diffs inline in tool call cards

Render a line-by-line diff inside Edit/Write tool cards in the web chat, reusing the existing diff-line style from the changes panel. The diff is built client-side from the tool input, so no protocol or server changes are needed; the header chip now shows real +/- counts.

* fix(web): extend diff line backgrounds through horizontal scroll

Diff rows were only as wide as the viewport, so the add/del background stopped where long lines overflowed and the area revealed by horizontal scroll had no color. Size each row to its content so the background paints the full line.

* fix(web): make all diff rows fill the longest line width

Size the diff container to the longest line and have every row fill it, so add/del backgrounds form one continuous band across the whole horizontal scroll instead of stopping at each line's own length.

* fix(web): resolve oxlint errors in diff line builder

Use Array.at(-1) and Array.from instead of index access and new Array(length), which oxlint flags as errors.

* fix(web): show tool output for failed edit/write calls

When an Edit/Write call fails, render the tool output (the failure reason) instead of the requested diff, so error cards explain why no change was applied.

* fix(web): fall back to tool output for replace_all and append edits

A single-pair diff misrepresents replace_all edits (which change many occurrences) and from-empty diffs misrepresent append writes (which extend an existing file). Skip the synthetic diff in those cases and render the truthful tool output instead.

* feat(web): open edit diffs in the right-side detail panel

Move the Edit/Write diff out of the inline tool-card body into the shared right-side detail layer, opened by clicking the card (like the subagent detail panel). The panel shows the line diff when it faithfully represents the operation, otherwise the tool output (replace_all, append, errors), so failed calls explain why no change was applied.

* feat(web): toggle detail panels closed when their trigger is clicked again

Clicking the same Edit/Write card, file link, or git-status area a second time now collapses the right-side detail panel, matching the existing thinking / compaction / subagent toggle behavior.

* fix(web): cap client-side edit diff to avoid freezing on large inputs

The line-level LCS allocates an (oldLines+1) x (newLines+1) matrix and runs eagerly when an Edit/Write card renders. For large edits (e.g. a 5k x 5k block, ~25M cells) this can freeze or exhaust the chat. Cap the matrix at 1M cells and fall back to the raw tool output beyond that.

* fix(web): keep the edit diff panel in sync with live tool state

Store only the tool id and re-derive the diff panel payload from the live tool call in the session turns, so a panel opened while the tool is still running reflects later status/output changes (e.g. an eventual error shows the failure output instead of the stale attempted diff).

* fix(web): do not render a synthetic diff for Write calls

Write only reports the new content, so the client cannot tell a new file from an overwrite of an existing one. A from-empty diff showed overwrites as all additions and no deletions, which is misleading. Fall back to the tool output for every Write (append already did).

* fix(web): also cap diff output rows, not just the LCS matrix

The matrix-size cap alone let through asymmetric edits (e.g. one line replaced by hundreds of thousands) whose small matrix still produced a huge row array that the chip computed on render. Cap each side's line count too, so the diff output is bounded.

* fix(web): keep edit/write cards expandable when the diff panel is unwired

Clicking an Edit/Write card opens the right-side diff panel, but the nested ChatPane in the side chat does not wire that event, so the click became a no-op and the card could no longer expand to show its output. Gate the panel behavior behind a toolDiffPanel prop (enabled only in the main conversation); elsewhere the cards expand inline as before.

* fix(web): close the tool diff panel when its target disappears

When a session resync removes the tool call an open diff panel is showing, toolDiffTarget becomes null but detailTarget stayed 'toolDiff', leaving an empty aside that Escape would not close. Gate sidePanelVisible on toolDiffVisible so the panel collapses once its target is gone.
2026-06-25 23:18:14 +08:00
qer
fe667d7c2e
fix(reload): re-inject plugin session-start reminder after /reload (#1086)
* fix(reload): re-inject plugin session-start reminder after /reload

Reload reloaded plugins and resumed the session, but the model kept seeing the stale plugin session-start reminder from before the reload, so plugin skill changes only took effect in a fresh session.

Append a fresh plugin_session_start reminder to the main agent after reload, gated on a new forcePluginSessionStartReminder flag that only the explicit /reload command sets, so config and experiment toggles that reuse the reload RPC do not spam the transcript.

* fix(reload): keep reload result fresh and neutralize stale plugin reminder

Append the plugin session-start reminder before constructing ResumeSessionResult so SDK callers reading getResumeState() see the refreshed plugin context instead of a pre-reload snapshot.

When a plugin with a prior plugin_session_start reminder is disabled or removed, append a neutralizing reminder so the model does not keep following stale plugin instructions.

* fix(reload): neutralize stale plugin reminder after compaction

A full compaction folds the discrete plugin_session_start reminder into a compaction_summary, so the origin-only scan no longer detects it. Also treat a compaction_summary in history as a signal to neutralize, so disabling or removing a plugin after compaction still emits a superseding 'no active plugin session starts' reminder.

* fix(reload): thread plugin reminder option through KimiHarness.reloadSession

KimiHarness.reloadSession is a public SDK entry point; forward forcePluginSessionStartReminder to both the active-session and RPC reload paths so SDK callers using the harness can opt into the refreshed plugin reminder too.
2026-06-25 23:16:55 +08:00
Haozhe
46808e1cff
perf(prompt): filter session lookup by sessionId in _requireSession (#1107)
- pass sessionId to listSessions instead of fetching all sessions
- update test mock to mirror the store's sessionId filter
2026-06-25 22:53:28 +08:00
Haozhe
bf9b01e0d8
feat(server): make --host opt-in for LAN binding (#1105)
* feat(server): bind `kimi server` to 0.0.0.0 by default

- change DEFAULT_SERVER_HOST to 0.0.0.0 and default --insecure-no-tls on
  so a bare `kimi server run` clears the non-loopback TLS gate
- add LOCAL_SERVER_HOST (127.0.0.1) for connectable URLs when bound to the
  wildcard, used by lockConnectHost and the service status URL
- pin the OS-supervised install to --host 127.0.0.1 so the always-on
  service stays loopback-only

* feat(server): make --host opt-in for LAN binding

- default kimi server run/web binds 127.0.0.1 when --host is omitted
- treat bare --host as 0.0.0.0 while keeping --host <host> explicit
- update server help/banner text and host binding tests
2026-06-25 22:13:13 +08:00
liruifengv
2db5fc20ec
feat: add shell mode (!) to the CLI (#1079)
* feat: add shell mode (`!`) to the CLI

Add shell mode, letting users run shell commands directly from the prompt
with `!`. Output streams live into the transcript, supports backgrounding
(ctrl+b), cancellation (Esc / Ctrl+C), input queuing while running, and
enters the conversation context with resume support.

* feat(kimi-code): show shell mode label on editor border and add tip

Render a "! shell mode" label on the top-left of the editor border while the editor is in `!` bash mode, so the active mode is visible at a glance. Also add a rotating toolbar tip (`! to run a shell command`) to surface the feature.

* feat(kimi-code): refine shell mode queue, history, and display

- Keep `!` commands out of input history so they never resurface as bare text stripped of their `!`.

- Make `!` commands non-steerable: Ctrl-S skips them (they stay queued to run after the current task) and the steer hint is only shown when something is actually steerable.

- Render queued `!` commands with a `$` prompt and the shell-mode hue so they read as commands, not as text to send to the model.

- Echo executed shell commands with a `$` prompt instead of `!`.

* fix(kimi-code): sanitize shell output and harden rendering

Captured shell command output can contain terminal control sequences (colours, cursor moves, alternate-screen switches, OSC hyperlinks, carriage-return spinners, bells). pi-tui's Text passes strings straight to the terminal, so any unhandled sequence was executed by the terminal and fought with pi-tui's own cursor control, producing the blank-screen-plus-leftover-characters mess after running commands like pnpm dev or a nested TUI.

- Sanitize CSI (incl. private modes), OSC, single-char ESC and C0 control chars (keeping newline and tab) in both the finished/resume view (previously unsanitized) and the running tail.

- Make the sanitize, format, and ShellRunComponent render paths never-throw, and cap the live running buffer, so a misbehaving command cannot crash the TUI.

- Dispose transcript children on clear so ShellRunComponent's timer is released on /clear or session switch.

* fix(kimi-code): render shell command echo with $ instead of sparkles

The shell command echo is a 'user' transcript entry, so UserMessageComponent prefixed it with the USER_MESSAGE_BULLET (sparkles), producing 'sparkles $ command'.

Add an optional bullet override to UserMessageComponent / TranscriptEntry and set it to an empty string for the shell echo (both live and resume), so the '$ command' content sits at the leading column where the sparkles marker used to be. Normal user messages keep the sparkles bullet.

* fix(kimi-code): enter shell mode when pasting a !-prefixed command

The bash-mode trigger only handled the single ! keystroke, so a pasted !cmd was inserted as literal text in prompt mode and submitted as a normal message.

After pi-tui inserts pasted content, detect an empty-prompt buffer that now starts with !, switch to bash mode, and strip the leading ! so the buffer holds only the command, matching the typed ! path.

* fix(kimi-code): restore shell mode when recalling a queued command

recallLastQueued() dropped the queued item's mode, and the Up-arrow recall only restored the text. A queued ! command (queued while another command runs, which resets the editor to prompt mode) therefore came back as a normal prompt and was submitted as a message instead of a shell command.

Return the full QueuedMessage from recallLastQueued() and restore editor.inputMode (plus the onInputModeChange sync) from the recalled item's mode.

* feat(kimi-code): use violet as the shell mode color

Replace the claude-code-style magenta/rose shellMode token with a violet that is distinct from plan-mode blue, the user role amber, success green, error red, and the teal accent.

Custom themes that omit the token fall back to this new default via the base+overrides merge, so existing custom themes keep working unchanged.

* chore: refine the shell mode changeset

* docs: document shell mode

Add a Shell mode section to the interaction guide and list the ! and Ctrl+B shortcuts in the keyboard reference, in both English and Chinese.

* test(protocol): include shell events in volatile classification check

shell.output and shell.started were added as volatile event types for shell mode; update the snapshot test's volatile-type list and count accordingly.

* fix(agent-core): surface shell command failure reason with no output

When a ! shell command fails without producing stdout/stderr (non-zero exit with no output, timeout, spawn failure), the failure reason lived only in the tool result's output and the TUI showed '(no output)'. Fold it into stderr so the live view and replay show what went wrong.

* fix(kimi-code): decode CSI-u ! to enter shell mode

In terminals with the Kitty keyboard protocol (VSCode integrated terminal, Kitty), pressing ! arrives as a CSI-u sequence, so the raw normalized === '!' comparison never matched and shell mode could not be entered by typing !. Decode with printableChar before comparing, matching every other printable-key check in the TUI.

* fix(kimi-code): do not steer while a shell command is running

Ctrl-S steers queued input into the running turn, but a shell command is not an agent turn, so steering during streamingPhase === 'shell' would launch a turn before the command output is recorded. Keep Ctrl-S a no-op during shell runs; queued messages stay queued.

* fix(agent-core): escape bash tag delimiters in shell output

Shell command output is arbitrary text; if it contains a bash tag delimiter such as </bash-stdout>, the recorded pseudo-XML wrapper breaks and replay extracts the wrong slice. Escape the content when wrapping it in agent-core and unescape when extracting during replay, so output survives round-trip intact.

* docs: document the shellMode theme token

The shellMode color token was added to the palette but not propagated to its mirrors. Add it to the custom-theme docs token table, the theme JSON schema, and the custom-theme skill token list.

* feat(agent-core): reset background task deadline on detach

Add a resettable deadline timer to BackgroundManager and let tasks register a detach timeout; when a foreground task is moved to the background, its deadline resets to the background default counted from the detach moment.

Wire this into shell mode so ! commands run with a 3-minute foreground timeout and get 10 minutes once detached to the background, instead of staying bounded by the original 60-second foreground deadline.

* feat(agent-core): lower shell mode foreground timeout to 2 minutes
2026-06-25 21:24:53 +08:00
qer
3ea6ac278d
feat(web): render plan review card with plan body and approach choices (#1101)
* feat(web): render plan review card with plan body and approach choices

The ExitPlanMode plan_review approval in the web UI now renders the plan body as Markdown with one button per approach option, plus Revise and Reject-and-Exit, with the selected label threaded back to the server.

The approval header keeps APPROVAL REQUIRED and the minimize control on the title row and shows the plan path on a second line, and the plan body uses up to half the viewport height.

The ExitPlanMode tool card also gains a link to the plan file, currently hidden behind a flag until the server can read files outside the workspace.

* fix(web): hide misleading shortcut numbers on plan review actions

When a plan review has approach options, the option buttons already own [1]/[2]/[3]. Revise and Reject-and-Exit advertised the same numbers even though those keys approve an option, so hide their shortcut labels whenever options are present.
2026-06-25 19:25:23 +08:00
Haozhe
77412b89fa
fix(server): import bcryptjs via default export for ESM dev runner (#1104)
- bcryptjs is a CommonJS package whose index.js re-exports via
  `module.exports = require("./dist/bcrypt.js")`
- Node's cjs-module-lexer cannot detect named exports through that
  require() indirection, so `import { compare, hash } from 'bcryptjs'`
  threw "Named export 'compare' not found" under the tsx dev runner
  (make dev), even though vitest and the esbuild bundle handled it fine
- switch to the default import and destructure, which works across tsx,
  vitest and the bundled binary
2026-06-25 19:21:17 +08:00
liruifengv
f059649ce8
feat(agent-core): suggest update-config command in max-steps error (#1099) 2026-06-25 18:19:12 +08:00
Haozhe
60dfb68a2d
feat(server): add bearer-token auth and safe host exposure (#1006)
* test(server): add API surface snapshot guardrail

Boot startServer on port 0 and snapshot the documented v1 route table derived from /openapi.json paths, plus the reachability of doc/meta endpoints (/healthz, /openapi.json, /asyncapi.json, /). Gives later auth/--host phases an intentional diff when routes change. M0 makes no production behavior change.

* test(server): add e2e server harness with token support

Add test/helpers/serverHarness.ts: boot() wraps startServer with an isolated lock + home dir and returns a handle (server, address, baseUrl, wsUrl, token, close) plus authedFetch/authedWs that carry Authorization: Bearer <token> (and the kimi-code.bearer.<token> WS subprotocol). serviceOverrides is the generic DI seam later phases use to inject a fixed-token auth service; IAuthTokenService is not referenced yet. closeAll() tears down every booted server and socket. M0 makes no production behavior change; typecheck-only gate.

* feat(server): add privateFiles 0600 atomic write/read utility

* feat(server): add per-start tokenStore

* feat(server): add env-based bcrypt password hash utility

* feat(server): add IAuthTokenService DI seam

* feat(server): add global onRequest auth hook with bypass + redaction

* fix(server): stop reflecting Host header in /asyncapi.json

* feat(server): add WS bearer subprotocol constant and parser

* feat(server): enforce bearer token auth on WS upgrade

* feat(server): add Host header allowlist middleware

* feat(server): add Origin/CORS middleware

* feat(server): wire Host/Origin checks into HTTP and WS

* feat(server): wire token auth, Host/Origin, and WS auth into start.ts

* fix(server): create lock file with 0600 permissions

* fix(server): suppress debug routes on non-loopback binds

* feat(kimi-code): read server token and send Authorization on CLI calls

* feat(kimi-code): inject server token into /web URL fragment

* feat(server): add bindClassify for loopback/lan/public classification

* feat(kimi-code): register --host flag and pass it through the daemon

* feat(server): require password and TLS opt-out on non-loopback binds

* feat(server): rate-limit repeated auth failures on non-loopback binds

* feat(server): disable shutdown and terminals on public binds by default

* feat(server): add security response headers on non-loopback binds

* test(server): cover LAN/public host-exposure hardening end to end

* docs(server): add deployment security and threat-model guide

* changeset: minor kimi-code for server auth and host exposure

* feat(kimi-web): add server bearer-token auth support

* fix: repair CI for server auth and host exposure

- Replace native @node-rs/bcrypt with pure-JS bcryptjs so the ESM CLI
  bundle and the SEA native bundle both build without native-addon
  require issues (node-rs/bcrypt broke the ESM smoke and the SEA
  check-bundle allowlist).
- Remove dead cleanup references (stopSpinner, authLogoBlinkTimer) in
  apps/kimi-web App.vue that failed vue-tsc.
- Fix lint: drop empty spread fallbacks in the e2e auth-header merge,
  void the intentionally-async WS upgrade listener, add missing
  assertions to satisfy jest/expect-expect, and convert a ternary
  statement to if/else.
- Send the bearer token in the snapshot perf/smoke tests so they pass
  under the new global auth hook.
- Refresh the pnpmDeps hash in flake.nix for the updated lockfile.

* feat(server): persist bearer token and add rotate-token command

- persist the server bearer token in <home>/server.token (0600) and reuse it across restarts instead of per-start server-<pid>.token
- add `kimi server rotate-token` to regenerate the token; the token store reloads on mtime/inode change so rotation applies without restart
- print the token and Vite-style Local/Network URLs in the startup banner
- allow non-loopback binds with bearer-token-only auth (password now optional) and update SECURITY.md
- surface daemon boot failures immediately with the exit reason and log tail instead of waiting for the spawn timeout

* feat(server): print full token URLs and re-print links after rotate

- Drop the ready-panel border so token URLs print in full for copying; keep the Kimi sprite beside the title.
- Re-print Local/Network access links after `server rotate-token` (host/port from the lock).
- Extract shared access-URL helpers into access-urls.ts.
- Unify link and token colors between the banner and rotate-token.

* feat(server): dim URL #token= fragment and de-highlight token

- Render the `#token=…` fragment in a dim gray so the host/port stands out in the banner and rotate-token links.
- De-highlight the standalone token; set it off with surrounding whitespace instead of color.
- Add splitTokenFragment helper.

* refactor(cli): polish server ready banner and rotate-token output

- move version onto the ready banner title line; drop the separate
  Ready:/Version: rows and the startup-time metric
- reorder rotate-token output so the new token sits between the
  invalidation note and the access links
- update server CLI tests for the new layout

* feat(server): warn on reuse and refine ready banner

- Warn when `server run` reuses an already-running daemon (its options are not applied) and show the running server's actual URLs.
- Show a `Network: off  use --host 0.0.0.0 to enable` hint on loopback binds.
- Move the version onto the title line and drop the startup-time metric.

* fix(web): relabel auth dialog to token and cover full page

- Relabel the server auth dialog from "password" to "token"; the server accepts the bearer token, with the password only as a fallback.
- Make the auth dialog overlay fully opaque so it covers the whole page instead of revealing the login page underneath.

* fix: resolve CI failures on web auth PR

- Replace chalk.yellow named color with chalk.hex(darkColors.warning)
  in the server reuse notice to satisfy the chalk named color guard.
- Update pnpmDeps hash in flake.nix to match the regenerated
  pnpm-lock.yaml so the Nix build succeeds.
- Retry rmSync in ws-broadcast e2e teardown to ride out EBUSY /
  ENOTEMPTY races while the server flushes files after close().

* test(server): update API surface snapshot for warnings route

The feat/web-auth branch adds GET /api/v1/sessions/{session_id}/warnings
(packages/server/src/routes/sessions.ts), so the API surface guardrail
snapshot needs to record the new documented v1 route.
2026-06-25 17:57:56 +08:00
qer
d6e524682d
perf(web): page session list per workspace on first load (#1084)
* perf(web): page session list per workspace on first load

Load only the first page of sessions per workspace instead of draining every session up front, so the initial request count scales with the number of workspaces rather than the total number of sessions. The per-workspace "show more" button now fetches the next page on demand, and searching lazily loads the full list so results stay complete.

To keep per-workspace paging working for sessions created with cwd only, the workspace registry now also surfaces directories that have sessions but were never explicitly registered.

* fix(web): trust server hasMore for session pagination

Stop deriving per-workspace hasMore from the workspace session_count. After a local archive/delete the count is stale (archiveSession only removes the local session), so loadedCount < total stayed true after the server returned its final page and re-fetched empty pages forever. The server's page.hasMore is authoritative for whether more pages exist, so use it directly and keep session_count only as a label total.

* fix(web): fall back to global session walk when no workspaces listed

When /workspaces is unavailable or empty on older or partially-failing daemons while /sessions still works, the per-workspace initial load produced no sessions and the sidebar rendered blank. Reuse the existing global walk as a fallback in that case so history still shows.

* fix(agent-core): keep workspace deletion durable

Record deleted workspace ids as tombstones in the registry and skip them during derived registration. Deleting a workspace only removes its registry entry (session buckets stay on disk by design), so without the tombstone the next derived-registration scan recreated the workspace, making deletion non-durable for any workspace with history. An explicit re-add clears the tombstone.

* fix(web): track session paging cursor per workspace

Compute the load-more cursor from the end of the last fetched page instead of the oldest loaded session. A deep-linked older session appended out of band would otherwise become the cursor, so the next page started after it and skipped every session between the first page and the deep link.

* fix(web): load more sessions in the mobile switcher

The mobile switcher still used the old local display-expansion logic, but each workspace now starts with only the first page of sessions. Wire its show-more button to loadMoreSessions (matching the desktop sidebar) so workspaces with more sessions can page beyond the first page on mobile.

* fix(agent-core): align derived workspace id with its session bucket

Session buckets are keyed by normalizeWorkDir (resolve, not realpath), so registering a derived workspace via createOrTouch (which realpaths) produced a different workspace id for a symlinked cwd, and per-workspace session lookups then read the wrong bucket and returned empty. Register derived workspaces with the resolved bucket key instead.

* fix(agent-core): skip archived-only buckets in derived registration

A bucket that only contains archived sessions would otherwise be registered as an empty workspace on a plain GET /workspaces, surfacing an empty sidebar group that the old session-derived fallback (which ignored archived sessions) kept hidden. Skip derived buckets with no active sessions.

* refactor(agent-core): derive workspaces from the session index on the fly

Stop persisting derived workspaces into the registry. Persisting made the registry a second data source that drifted from the session store (symlinked cwds, archived-only buckets, deleted/unmounted roots), each producing a bug. Instead compute derived workspaces fresh in list() from the session index and resolve their ids in resolveRoot() via the same index, so the session store stays the single source of truth. The deletion tombstone is kept so explicitly removed workspaces are not re-derived.

* fix(agent-core): tombstone derived workspaces on delete

After deriving workspaces from the session index, derived ids are valid list results but absent from the registry file, so delete() threw before writing the tombstone and DELETE on a derived workspace 404ed and reappeared on the next list(). Resolve the derived id and write the tombstone for it too.

* fix(web): use local session count once a workspace is fully loaded

mergedWorkspaces kept the server session_count as a floor even after a workspace had no more pages, so archiving the last session left the header showing 1 until a reload. Once a workspace is fully loaded (hasMore === false) the local count is exact, so prefer it; keep the server count as a floor only while pages remain.
2026-06-25 17:12:34 +08:00
Haozhe
8fc6aa5f68
fix(server): broadcast session metadata updates to all clients (#1081)
- deliver session.meta.updated to every connection instead of only
  session subscribers, so title changes sync across all clients
- emit session.meta.updated when a session is explicitly renamed
2026-06-25 16:07:25 +08:00
liruifengv
27ef516695
feat(agent-core): add config hint to max-steps-per-turn error (#1097)
* feat(agent-core): add config hint to max-steps-per-turn error

* test(agent-core): assert config hint in max-steps error
2026-06-25 15:49:44 +08:00
qer
0030f76c5c
feat(tui): confirm before installing third-party plugins (#1088)
* feat(tui): confirm before installing third-party plugins

* chore: add changeset for third-party plugin install confirmation

* docs: note third-party plugin install confirmation prompt

* fix: harden third-party plugin install confirmation
2026-06-25 13:48:23 +08:00
qer
f1fad7222c
fix: reduce streaming stutter in the web chat (#1085)
Some checks are pending
CI / test (push) Waiting to run
CI / lint (push) Waiting to run
CI / build (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(web): coalesce streaming token updates into one render per frame

* fix(server): disable Nagle on WebSocket socket for lower streaming latency

* fix(web): flush pending streaming deltas before re-subscribing

* fix(web): flush pending streaming deltas before forgetting a session
2026-06-25 13:06:38 +08:00
liruifengv
8ee5c0ff81
fix(kimi-code): avoid terminal focus flicker on Linux Wayland (#1094)
The focus-driven clipboard image hint spawned wl-paste/xclip on every
terminal focus event. On Wayland this perturbs seat focus and re-triggers
the focus event, creating a feedback loop that made the terminal window
repeatedly gain and lose focus and broke IME input.

Limit the probe to macOS and Windows, which use the in-process native
module and do not perturb focus. Image paste is unaffected.

Resolve #1090
2026-06-25 12:59:01 +08:00
qer
ea03f30e51
feat(web): render LaTeX math in chat via KaTeX (#1035)
* feat(web): render LaTeX math in chat via KaTeX

* fix(web): keep literal prose dollars out of KaTeX inline math

Enabling KaTeX turned plain prose with two dollar-prefixed tokens
(`Check $PATH before $HOME`, `costs $5 and $10`) into a single
inline formula, since markstream's $…$ tokenizer has no
"no whitespace inside the delimiters" rule.

Add a postTransformTokens guard that turns a single-$ inline span back
into literal text when its content starts or ends with whitespace. Real
inline math is written tight (`$E=mc^2$`, `$\frac{1}{2}$`), while
the prose false-positives always have whitespace inside the delimiters,
so this keeps inline/block math working while leaving prices, env vars,
and ranges as readable text. Code spans are already excluded by the
tokenizer, and running on the flat token stream also covers dollars
nested inside lists and blockquotes.

Addresses the Codex review comment on PR #1035.

* fix(web): reject compact currency ranges before rendering math

The literal-dollar guard only caught prose whose content had whitespace
inside the delimiters, so a compact range like `costs $5/$10` still
rendered `5/` as a formula and dropped the second dollar. (markstream's
own currency check rejects `-`/`~` ranges but not `/`.)

Extend the guard to also reject a single-$ span whose content is a
numeric amount with a trailing range connector (`/`, `-`, `~`,
en/em dash) -- a complete formula never ends in a dangling operator.
Scoped to digit-led content so symbolic math is left alone, and numeric
math that is not a range (`$5/2$`, `$5-2$`, `$0.5$`) still
renders. Added tests for the range cases and the non-range math.

Addresses the follow-up Codex review comment on PR #1035.

* fix(web): treat shell/path dollar pairs as literal text

Adjacent shell variables and PATH-like values (`Use \$HOME/bin:\$PATH`,
`\$PATH:\$HOME`) were still rendered as math, because the prose-dollar
guard only looked at the span's own content (whitespace inside the
delimiters, or a trailing numeric range connector) and never at what
touches the delimiters from the outside.

Replace the two bespoke heuristics with the two industry-standard rules,
now driven by the surrounding text tokens:

  - Pandoc (tex_math_dollars): no whitespace immediately inside the
    delimiters.
  - GitHub: each \$ must be bounded on its outer side by whitespace, a
    line boundary, or structural punctuation. A letter or digit there
    means a second prose token, so the span is literal text.

The GitHub outer-boundary rule subsumes the old numeric-range check (a
closing \$ in \$5/\$10 is followed by a digit) and also catches
shell/path cases Pandoc's inner rule misses. Normal math -- including
bare \$x\$, \$x^2\$., and (\$x^2\$) -- still renders. Added
tests for shell/path values and punctuation-wrapped math.

Addresses the third Codex review comment on PR #1035.

* fix(web): render math next to CJK punctuation and quotes

The outer-boundary guard only accepted ASCII punctuation, so a formula
followed by full-width punctuation or wrapped in typographic quotes was
misclassified as prose: `公式为 \$E=mc^2\$,其中` and `“\$x\$”`
showed raw dollars instead of rendering.

Invert the boundary check from an allow-list of ASCII punctuation to a
deny-list of ASCII letters/digits. A \$ glued to an ASCII letter/digit
still means a second prose token (\$PATH:\$HOME, \$5/\$10), but
whitespace, line boundaries, and every other character -- full-width
punctuation, CJK ideographs, curly quotes -- is now a valid math
boundary, which is the correct behavior for localized prose.

Addresses the fourth Codex review comment on PR #1035.

* fix(web): preserve later math after literal-dollar spans

A prose dollar in front of a real formula in the same inline run
(`costs $5 and formula $x$`, `Use \$HOME before $E=mc^2$`) exposed
the core limit of the token-level guard: markstream's tokenizer greedily
pairs the first literal \$ with the formula's opening \$ before any hook
runs, so converting that span back to text could only blank it -- the
later formula's opening \$ was already consumed and the formula rendered
as raw text.

Move the guard from postTransformTokens to a source-level preprocessor
that runs before tokenization. escapeProseDollars protects code spans,
fenced code blocks, and \$\$…\$\$ display math, then pairs single \$
delimiters using the Pandoc (tight delimiters) and GitHub-style
outer-boundary rules: any \$ without a valid partner is escaped as
\\\$, so the tokenizer leaves it literal while real formulas -- including
ones that come after a prose dollar -- still parse as math.

The component now preprocesses each markdown segment's text and the
postTransformTokens hook is gone. Rewrote the tests around the
string-in/string-out helper, including the prose-before-formula case,
code spans, fenced code, and block math.

Addresses the fifth Codex review comment on PR #1035.

* fix(web): protect indented code blocks before escaping dollars

The dollar-escaping preprocessor stashed fenced code blocks, inline code,
and display math, but not 4-space / tab indented code blocks. So a
snippet like `    echo \$HOME` had its dollar rewritten to `\\$HOME`,
and because Markdown renders backslashes literally inside code, the web
chat corrupted the code to show a stray backslash.

Add an indented-code regex and protect those lines too. Also make the
placeholder restore iterative, so nested protected regions (e.g. inline
code that looks like display math) restore correctly instead of leaving
a placeholder behind.

Addresses the sixth Codex review comment on PR #1035.

* fix(web): do not treat list-continuation lines as indented code

The indented-code regex protected every 4-space line, but inside a list
item a 4-space indent is a normal continuation paragraph, not a code
block (code under a list marker needs deeper indentation). So a message
like `- total\n    costs \$5 and \$10` had that nested line
stashed as "code", leaving its dollars un-escaped -- and the KaTeX
parser then rendered the price range as math.

Narrow the indented-code rule to a run of 4-space / tab lines that is
preceded by a blank line (or the start of the text). That still protects
real top-level indented code blocks and deeper-indented code inside
lists, while letting 4-space list-continuation lines get their dollars
escaped.

Addresses the seventh Codex review comment on PR #1035.

* refactor(web): render only $$…$$ display math, drop single-$ inline

Enable KaTeX for display math only: disable markstream's inline math rule
(`md.inline.ruler.disable('math')`) via customMarkdownIt, leaving the
`math_block` rule for $$…$$. Single $ now stays literal everywhere, so
prices, env vars, shell paths, and code are never mis-rendered as math --
with no escaping, no code detection, and no preprocessor.

This removes the escapeProseDollars normalization layer and all of its
code-protection machinery (the 8 review comments it attracted were
symptoms of trying to make a lax single-$ tokenizer behave). Display
$$…$$ math continues to render via KaTeX.

Changeset updated to describe display-math-only support.
2026-06-25 12:47:40 +08:00
qer
884b65a040
fix(web): coalesce snapshot reloads on resync (#1087)
Avoid concurrent session snapshot requests when resync_required fires repeatedly, while still allowing one queued rerun after the in-flight reload settles.
2026-06-25 11:49:05 +08:00
qer
3554f7e7d6
feat(plugins): source Superpowers from GitHub and show update badges (#1066)
Some checks are pending
CI / test (push) Waiting to run
CI / lint (push) Waiting to run
CI / build (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 / Native release artifact (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
* feat(plugins): source Superpowers from GitHub and show update badges

Source the Superpowers plugin from its GitHub release (v6.0.3) instead of a vendored copy, and drop the explicit version field.

Derive marketplace entry versions from GitHub source URLs when the version field is omitted, keeping the source URL the single source of truth.

Show update badges for installed plugins on the /plugins Installed tab.

* docs(plugins): document Installed tab update badges

* fix(plugins): stamp GitHub source version in CDN catalog

Older CLIs only read the explicit marketplace version and cannot derive it from a GitHub source URL. When publishing the CDN catalog, stamp the version derived from a pinned GitHub source so those clients still surface update badges.

The source plugins/marketplace.json keeps no explicit version; the version is derived at build time instead.

* feat(plugins): resolve latest version for bare GitHub sources at runtime

Point the Superpowers marketplace entry at the bare GitHub repo URL so it tracks the latest release instead of a pinned tag.

When a marketplace entry omits version and its source is a bare GitHub repo URL, resolve the latest release tag at load time (via the /releases/latest redirect) to fill the version for update detection.

Revert the build-time version stamping; it is no longer needed. Older CLIs that only read the explicit catalog version will no longer see update badges for Superpowers, since the catalog no longer carries one.

* feat(plugins): make Enter update and add I for details on Installed tab

On the Installed tab, Enter now installs the available update when one is present, and falls back to opening plugin details otherwise.

Add the I key to always open plugin details, so details remain reachable when Enter is occupied by an update. Update the installed hint, docs and changeset accordingly.

* feat(plugins): show installing state inside the plugins panel

Move the "Installing … from marketplace" notice from a transient status message into the plugins panel itself, so the user sees progress in the interactive card while an install or update is in flight.

* feat(plugins): highlight reload hint and add dev:cli:marketplace

Highlight "Run /new or /reload to apply plugin changes." in warning color after plugin install and remove, and make the two notices symmetric.

Add a root dev:cli:marketplace script that points the dev CLI at the production marketplace instead of the local dev server.

* fix(plugins): dedupe install success notice

Drop the redundant showNotice on marketplace installs so the success message is shown only once, symmetric with remove.

* fix(plugins): reset installing state on install failure

When a marketplace or Custom-tab install rejects, clear the installing state and return to the list so the user can retry, instead of leaving the panel stuck on the one-way "Installing…" view.
2026-06-24 21:58:13 +08:00
liruifengv
a86bb9757d
fix(kimi-code): show clipboard image paste hint only for newly copied images (#1072)
* fix(kimi-code): show clipboard image paste hint only once per image

The footer hint repeated on every terminal focus whenever an image remained in the clipboard, which became noisy. Replace the 30s time-based cooldown with a per-image gate: the hint shows once for a given image and stays quiet until the clipboard is observed empty and a new image appears.

* fix(kimi-code): suppress clipboard image hint for images present at startup

The footer hint fired during initialization whenever an image was already in the clipboard, treating it as new. The first clipboard observation after start now only establishes a baseline, so only images copied during the session trigger the hint.

* fix(kimi-code): show hint for first image copied after startup

* fix(kimi-code): make clipboard image probe non-blocking

The startup baseline probe in ClipboardImageHintController calls clipboardHasImage(), which on Linux/WSL ran wl-paste/xclip/powershell via spawnSync. The probe only reaches its first await after those synchronous calls, so a slow or wedged helper could freeze the TUI launch for up to the 1s-2s tool timeouts even when the user never focuses with an image.

Add an async runCommandAsync built on spawn with timeout-based kill, and route the Linux/WSL image detection through it so the event loop is never blocked. Keep the synchronous runCommand for the explicit paste-read path.

* chore: add changeset for non-blocking clipboard probe

* chore: simplify clipboard image hint changeset
2026-06-24 21:07:14 +08:00
liruifengv
3aaf1e5803
fix(kimi-code): bump native clipboard dependency to fix Linux startup crash (#1075)
* fix(kimi-code): bump native clipboard dependency to fix Linux startup crash

* chore(nix): update pnpmDeps hash
2026-06-24 21:07:02 +08:00
liruifengv
75ca3b2160
feat(tui): add Ctrl+U/Ctrl+D paging in the task output viewer (#1078)
PgUp/PgDn are often captured by terminal or tmux scrollback, so add Ctrl+U and Ctrl+D as full-page up and down alternatives, matching the existing PgUp/PgDn behavior.
2026-06-24 21:00:40 +08:00
liruifengv
500677ab8b
fix(tui): clear editor draft on Ctrl-C during compaction (#1076)
When compaction is in progress and the editor has a draft, Ctrl-C now clears the draft first instead of cancelling compaction, matching the streaming behavior. The clear-text logic is shared between the compaction and streaming branches.
2026-06-24 20:30:20 +08:00
Kai
0e227ba18a
fix(agent-core): surface git context failures for explore subagents (#1067)
* fix(agent-core): surface git context failures for explore subagents

collectGitContext collapsed every git failure (spawn error, non-zero exit, timeout) into null, so explore subagents silently lost git context with no signal. Now a definitive 'not a git repository' injects an explicit unavailable signal so the subagent does not waste turns probing git history, while other failures are logged and surface as an empty block. The block is all-or-nothing so a partial snapshot (e.g. a timed-out status making a dirty tree look clean) is never shown.

* fix(agent-core): use rev-parse for branch to support git < 2.22

`git branch --show-current` was added in Git 2.22 and fails (exit 129) on older Git even in a valid repository. Because the branch probe is fatal, this dropped the whole git-context block for older-Git users. Switch to `git rev-parse --abbrev-ref HEAD`, which is supported across Git versions, and filter the `HEAD` output produced in detached-HEAD state.

* fix(agent-core): show whatever git info is available in explore context

Git probes fail in perfectly normal states — no `origin` remote, no commits yet (unborn branch), detached HEAD, older Git — so a failed probe no longer aborts the whole collection. Each probe is now best-effort: failures are logged and their section is omitted, and the block is dropped only when nothing useful was collected. Branch is read via `symbolic-ref --short HEAD`, which works in unborn repositories and on older Git; it fails in detached-HEAD state, where the Branch section is just omitted.
2026-06-24 19:59:30 +08:00
liruifengv
b62b3a147f
feat(kimi-code): show cache read details in debug timing (#1074)
* feat(kimi-code): show cache read details in debug timing

* chore: remove changeset
2026-06-24 19:39:42 +08:00
Haozhe
ff177155ca
fix(web): stop auto-dismissing pending questions and approvals on a timeout (#1070)
* fix(web): stop dismissing questions after a 60 second timeout

The server's question broker auto-expired AskUserQuestion requests after 60s, which dismissed the question even when the user simply needed more time. Remove the timeout, and the now-unused expires_at field, so a question stays pending until the user answers or explicitly dismisses it.
2026-06-24 19:15:06 +08:00
liruifengv
d18aa1666a
perf(tui): reuse streaming markdown instances (#1069) 2026-06-24 16:08:39 +08:00
ForgottenR
bbd8a1a947
fix(cli): resolve spawn EFTYPE on Windows for kimi web and /web (#903)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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(cli): resolve spawn EFTYPE on Windows for kimi web and /web

* chore(changeset): clarify affected Windows installation methods

---------

Co-authored-by: qer <wbxl2000@outlook.com>
Co-authored-by: liruifengv <liruifeng1024@gmail.com>
2026-06-24 15:27:00 +08:00
_Kerman
ea6a4bfe6e
fix: preserve long tool output (#1062)
* fix: persist truncated foreground bash output

* fix: persist oversized tool results

* fix: link background task notifications to saved output

* fix: avoid lossy tool result budgeting

* fix

* fix: include fallback task output previews

* fix

* fix
2026-06-24 14:42:11 +08:00
7Sageer
4b837d6bfb
feat: auto-create missing parent directories when writing files (#1065)
The Write tool previously failed when a parent directory was missing, forcing a manual mkdir round trip. It now creates missing parents recursively before writing.
2026-06-24 14:05:27 +08:00
7Sageer
ee69e16dc8
fix: use session cwd for stdio MCP servers (#1057) 2026-06-24 13:40:56 +08:00
7Sageer
a752a5309b
fix(agent-core): mark truncated skill descriptions with an ellipsis (#1064)
The model-facing skill listing silently sliced long descriptions to 250 characters with no marker, so neither the user nor the model could tell a description was cut. Truncated entries now end with an ellipsis and the truncation walks whole grapheme clusters so it never splits a surrogate pair or combining sequence.
2026-06-24 13:20:48 +08:00
qer
5ef66ddfed
feat(tui): redesign /plugins as a tabbed panel (#1025)
* feat(tui): redesign /plugins as a tabbed panel

Split the /plugins manager into Installed / Official / Third-party /
Custom tabs. The Official and Third-party marketplace catalogs load
lazily, so /plugins opens instantly and keeps working offline, with
fetch failures shown inline instead of closing the panel. The tab strip
is shared with the /model provider tabs via the new renderTabStrip
helper.

* fix(tui): show untiered marketplace entries and update badges

Address Codex review feedback on the /plugins tab redesign:

- Untiered marketplace entries (no `tier` field) now appear on the
  Third-party tab instead of being invisible in both marketplace tabs.
- Installed plugins whose marketplace version is newer than the local
  version render an `update <local> → <latest>` badge again, and
  up-to-date plugins show `installed · v<version>` — restoring the
  update visibility the pre-redesign marketplace UI had.

* fix(tui): decode Space for installed-plugin toggle

In terminals that send printable keys via Kitty/CSI-u sequences (e.g. VS
Code's integrated terminal), the Space key arrives as a printable char
rather than a Key.space match, so the Installed-tab Space toggle silently
stopped working. Check both matchesKey(Key.space) and the decoded
printable char to match the MCP selector and other dialogs.

* fix(tui): open custom marketplaces on the Third-party tab

When `/plugins marketplace <source>` points at a custom catalog whose
entries omit `tier`, those entries are classified into the Third-party
tab. Opening on Official left the visible tab empty and Enter could not
install anything, unlike the old marketplace picker which showed all
entries from the supplied source. Open on Third-party when a custom
source is supplied; the default catalog still lands on Official.

* docs(plugins): drop open-url wording and hyphenate Shift-Tab

Address Codex review feedback:

- The marketplace Enter action is install/update only (open-url rows were
  removed), so say "install or update" instead of "open or install" and
  drop the leftover changeset sentence about setup URLs.
- Use `Shift-Tab` (hyphen) instead of `Shift+Tab` to match the docs
  typography convention.

* fix(tui): keep marketplace selection valid while loading

When the Official/Third-party catalog is still loading, `entries` is empty
and pressing ↓ computed `Math.min(-1, selectedIndex + 1)` = -1. The later
Enter then read `entries[-1]` and the first install silently did nothing.
Clamp the index to 0 while there are no entries.

* fix(tui): count tab separators in tab-strip fit check

renderTabStrip declared a strip to fit whenever the sum of tab cell widths
fit, but the returned string also inserts single spaces between tabs via
`segments.join(' ')`. At widths around 43-45 columns for a four-tab strip
this declared a fit while the joined line was wider, so the trailing tab
got truncated instead of showing the `<`/`>` scroll markers. Count the
inter-tab separators in both the full-fit check and the scrolling window
fit check.

* docs(plugins): fix Kimi Datasource redirect anchor

The datasource.md redirect pointed at ./plugins.html#kimi-datasource, but
plugins.md no longer has a `## Kimi Datasource` heading — it is now
`## Official Plugins`. Update the en/zh redirect targets and fallback
links to #official-plugins / #官方插件 so the link lands on an existing
anchor.

* docs(plugins): restore concise Kimi Datasource section

The `## Official Plugins` section had replaced the original
`## Kimi Datasource` section, leaving the datasource.md redirect pointing
at a missing anchor and the Datasource capabilities/usage unreachable.
Restore a concise `## Kimi Datasource` section (intro + OAuth login +
install steps + usage) in both en and zh so the #kimi-datasource anchor
is valid again and the content is reachable.

* docs(plugins): restore Installing-from-GitHub subheading

The tab-redesign rewrite had dropped the `### Installing from GitHub` /
`### 从 GitHub 安装` subheading and its lead sentence, leaving only the
four URL forms. Restore the heading and lead sentence in both en and zh.

* docs(plugins): expand Kimi Datasource and tidy marketplace docs

- Condense the Official / Third-party / Custom tab overview and trust-badge note

- Trim the custom marketplace JSON section to the minimal id + source shape

- Move and expand the Kimi Datasource section with install, usage, and coverage

* docs(plugins): fix heading style and drop Next steps section

- Use sentence case for the Datasource headings (How to use, What you can do)

- Rename the Datasource caveat heading to Billing and limitations / 计费与限制 to avoid a duplicate Notes / 注意事项 anchor

- Remove the Next steps section, which linked back to the on-page Datasource anchor

* fix(tui): repaint plugins panel from current theme palette

The /plugins panel and MCP selector captured a palette snapshot at construction. In auto theme mode, applyResolvedAutoTheme swaps currentTheme.palette and re-renders without remounting the open panel, so it kept stale colors until closed.

Read currentTheme.palette during render instead, drop the colors opt from both components and their call sites, and add a regression test that switches palettes on a mounted panel.

* fix(tui): repaint model tab strip from current theme palette

TabbedModelSelectorComponent cached a palette snapshot in opts and used it only for the tab strip. In auto theme mode the inner model list repaints from currentTheme but the strip kept the old colors until the dialog was closed.

Read currentTheme.palette on the render path instead, drop the colors opt and its three call sites, and add a regression test that switches palettes on a mounted selector and asserts the strip repaints. This removes the last palette snapshot among editor-replacement dialogs.
2026-06-24 13:12:28 +08:00
qer
51723bee1a
docs(changelog): sync 0.19.2 from apps/kimi-code/CHANGELOG.md (#1063) 2026-06-24 12:46:27 +08:00
Kai
66640380eb
feat: replace silent AGENTS.md truncation with a visible warning (#1040)
Oversized AGENTS.md files are no longer silently truncated. The full
content is injected, and a warning is shown in the TUI status bar and the
web UI when the combined AGENTS.md size exceeds the recommended 32 KB.

A generic session-warnings API backs this so future warning types can be
added without changing the API surface.
2026-06-24 12:26:17 +08:00
github-actions[bot]
0bcd9843c1
ci: release packages (#997)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-24 12:13:38 +08:00
qer
98d3e5b71d
feat(web): stabilize and drag-reorder workspaces in the sidebar (#1047)
* feat(web): stabilize and drag-reorder workspaces in the sidebar

* fix(web): preserve dragged workspace order after refresh

* fix(web): float session to top of its group on new message

* fix(web): align workspace drop order with insertion marker

* fix(web): allow dropping a workspace after the last item

* fix(web): use reordered workspaces for active fallback

* fix(web): honor drag order for next-workspace fallback on removal
2026-06-24 12:06:32 +08:00
qer
b93e9365b6
fix(web): stop auto-approving plan reviews and sensitive files in yolo mode (#1056)
The web app ran a client-side policy that auto-approved every approval request in auto/yolo mode, including plan reviews, sensitive file access, and other asks the daemon intentionally sends for user confirmation. The daemon already resolves auto/yolo server-side, so drop the client-side auto-approve and let those requests reach the approval UI.
2026-06-24 11:21:21 +08:00
qer
ac1882fe28
feat(web): persist collapsed workspace groups to localStorage (#1045) 2026-06-23 22:45:24 +08:00
_Kerman
c240bfab7d
fix(agent-core): realign mid-history interrupted tool calls on resume (#1027) 2026-06-23 22:39:19 +08:00
qer
9d197e0f67
fix(web): make clipboard copy work over plain HTTP (#1044)
* fix(web): make clipboard copy work over plain HTTP

The Clipboard API (navigator.clipboard) is only exposed in secure contexts. When the web UI is served over plain HTTP, every copy action threw synchronously and silently failed. Route all copy call sites through a helper that falls back to execCommand('copy') in insecure contexts, and surface success or failure feedback to the user.

* test: address review feedback and a flaky goal-badge test

- clipboard test: drop the jsdom environment and mock the small navigator/document surface in the default node environment, per the kimi-web "pure logic tests only" rule.

- footer-goal-badge test: assert the absence of the "[goal" badge instead of the bare "goal" substring, which could match a rotating working tip ("/goal ...") and fail depending on Date.now().
2026-06-23 22:21:10 +08:00
qer
27df39c7ed
fix(web): fall back to session abort for stale prompts (#1043)
* fix(web): fall back to session abort for stale prompts

* test(web): cover stale prompt abort fallback
2026-06-23 22:05:08 +08:00
qer
dc6b9ef02b
feat(web): show dev-mode indicator in sidebar (#1042)
Tint the sidebar logo yellow and append the connected backend host:port to the title when the page is served by the Vite dev server, so local development tabs are easy to tell apart. Inert in production.
2026-06-23 21:41:47 +08:00
liruifengv
be77d5da03
feat(kimi-code): show clipboard image paste hint in footer (#1028)
* feat(kimi-code): add lightweight clipboard image detection

* fix(clipboard): correct Linux X11 image detection and extract shared helpers

- Extract shared clipboard constants/helpers into clipboard-common.ts

- Fix Linux X11 branch calling macOS-only osascript

- Use native hasImage() on Linux X11, macOS, and Windows with fallbacks

- Compute xclip result once and reuse on Linux

- Add test coverage for unsupported MIME types, empty targets, failures, WSL, and native fallbacks

* fix(kimi-code): restore Wayland/WSL xclip fallback in clipboard image detection

* feat(kimi-code): add clipboard image hint controller

* fix(kimi-code): clipboard image hint focus race and cleanup

* fix(kimi-code): prevent clipboard image hint from clearing unrelated hints and stale reads

* fix(clipboard-image-hint): lifecycle issues and platform-dependent tests

* fix(kimi-code): invalidate pending clipboard hint read on stop

* feat(kimi-code): wire clipboard image hint controller into TUI

* style(kimi-code): wrap void expression in braces to fix lint warning

* style(kimi-code): prefer nullish coalescing in clipboard image detection

* chore: add changeset for clipboard image footer hint

* fix(kimi-code): let clipboard image hint observe non-consuming focus events

* fix(kimi-code): extend clipboard image hint display duration to 4 seconds

* docs: mention clipboard image footer hint in interaction guide

* chore: downgrade clipboard image hint changeset to patch

* Revert "docs: mention clipboard image footer hint in interaction guide"

This reverts commit 0fd50dcc9b.

* fix(cli): avoid treating copied Finder files as images on macOS

Filter file-like native clipboard formats in clipboardHasImage() before

calling native hasImage(), mirroring the guard already used by

readClipboardMedia(). This prevents copied Finder files from being

mis-detected as pasteable images because macOS exposes their

thumbnails as image data.

* fix(cli): align image detection with paste path on macOS and Windows

Remove osascript and PowerShell fallbacks from clipboardHasImage() on

macOS and Windows. The paste reader (readClipboardMedia()) only uses the

native clipboard module for images on those platforms, so detecting images

via methods the reader cannot consume produced misleading footer hints.

Linux fallbacks (wl-paste / xclip / PowerShell under WSL) remain because

they match the actual paste path.

* fix(tui): do not truncate inline image escape sequences

UserMessageComponent applied truncateToWidth() to every rendered line,

including the Kitty / iTerm2 inline image escape sequences produced by

ImageThumbnail. pi-tui treats the embedded base64 payload inside those

sequences as visible text, so truncation chopped the escape code and left

behind '0m...' garbage instead of the image.

Skip truncation for lines that contain an inline image protocol sequence;

the image already respects maxWidthCells via ImageThumbnail.

* fix(tui): clear stale rows when content shrinks

Enable pi-tui's setClearOnShrink so that when a tall inline image is

replaced by shorter content (e.g. after sending a message), the terminal

rows the image previously occupied are cleared. Without this, pi-tui's

differential renderer can leave behind artifacts such as duplicated input

boxes.

* chore: add changesets for inline image rendering fixes

* test(cli): stabilize pi-tui capability mocks in concurrent test runs

* test(cli): use setCapabilities instead of mocked getCapabilities
2026-06-23 20:49:01 +08:00
qer
866b91c8f5
refactor(web): group components into area subdirectories (#1036)
Move the 40 feature-specific components out of the flat components/ into
chat/, settings/, dialogs/, and mobile/ subdirectories, leaving 9 shared
layout components at the top level. Recompute every relative import (no
path alias in the web app), refresh the line-1 path comments, and update
the layout description in AGENTS.md.

No behavior change; typecheck / test / build / lint all pass.
2026-06-23 20:34:46 +08:00
liruifengv
b1e6b64319
feat(tui): show working tips behind composing spinner (#1033)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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
* refactor(tui): extract toolbar tip constants to tui/constant/tips.ts

* feat(tui): add WORKING_TIPS subset to tip constants

* feat(tui): allow MoonLoader to render a dim tip suffix

* feat(tui): pass optional tip through ActivityPaneComponent

* feat(tui): show working tip behind composing spinner

* feat(tui): hide working tip when spinner line does not fit

* chore: add changeset for working tips

* feat(tui): guard setAvailableWidth to skip unchanged widths

* feat(tui): show working tips on moon loader and compaction

* feat(tui): use singular 'Tip:' label for loading tips

* fix(tui): keep the same loading tip across waiting/thinking/tool/composing within one turn

* feat: show contextual working tips behind loading spinners

- Add pickRandomWorkingTip() for per-step tip selection

- Cache tips by loading kind (moon/composing) so continuous tool bursts keep the same tip

- Update tip inventory with /web, /plugins, /goal, /sessions, etc.

- Add unit tests for random tip selection

* docs: update working-tips changeset summary
2026-06-23 20:12:55 +08:00
qer
603a7679de
refactor(web): extract attachment upload into a composable (#1034)
Move the image/video attachment state, the file-picker / paste / drag-drop
handlers, the upload machinery, the preview lightbox, and the paste-listener
+ object-URL cleanup lifecycle out of Composer into useAttachmentUpload.

The composer keeps handleSubmit / handleSteer (which read the attachments to
build the payload) and the hasUpload toolbar flag; it consumes the returned
refs and handlers directly. The destructured names match the originals so the
template bindings are unchanged. handleSubmit / handleSteer now call the
composable's clearAfterSubmit() to revoke object URLs and drop the list.

Composer.vue: 1937 -> 1787 lines. Adds unit tests for useAttachmentUpload. No
behavior change.
2026-06-23 19:52:03 +08:00
qer
2bfd6860e4
refactor(web): extract composer text + draft persistence into a composable (#1031)
Move the composer's text ref, textarea ref, autosize helper, the per-session
draft load/save watchers, and the loadForEdit handle into useComposerDraft.
The returned text/textareaRef/autosize refs are passed straight through to the
history / slash / mention composables as their deps, so the rest of the
component is unchanged.

Composer.vue: 1987 -> 1937 lines. Adds unit tests for useComposerDraft. No
behavior change.
2026-06-23 19:14:50 +08:00
qer
a753b0535e
fix(web): upgrade markstream-vue to 1.0.3 to fix blank nested code blocks (#1032)
* fix(web): upgrade markstream-vue to 1.0.3 to fix blank nested code blocks

* fix(web): update flake pnpmDeps hash after markstream upgrade
2026-06-23 19:09:42 +08:00
qer
661c1fbe5b
refactor(web): extract @-mention menu into a composable (#1030)
Move the @-mention menu's open/items/active/loading state, the @token
detection, debounced search, and insertion logic out of Composer into
useMentionMenu. The composer keeps the keydown orchestration (it also
juggles the slash menu and history recall) and consumes the returned refs
directly; the destructured refs are aliased back to the original names so
the rest of the component is unchanged.

Move the FileItem view type into types.ts (mirroring the FileData move) so
the .ts composable can import it without hitting the type-aware lint rule
against importing types from .vue files; MentionMenu re-exports it for the
existing .vue consumers.

Composer.vue: 2035 -> 1987 lines. Adds unit tests for useMentionMenu. No
behavior change.
2026-06-23 18:38:50 +08:00
qer
318c964f07
refactor(web): extract slash-command menu into a composable (#1026)
Move the slash menu's open/items/active state, the filter logic, and item
selection out of Composer into useSlashMenu. The composable takes the text
ref, textarea ref, autosize, a skills getter, and the emit/history-push
callbacks as deps.

The composer keeps the keydown orchestration (arrow keys, Enter/Tab, Escape)
because it also juggles the mention menu and history recall; it consumes the
returned open/items/active refs directly and calls update/select. The
destructured refs are aliased back to the original names so the rest of the
component is unchanged.

Composer.vue: 2058 -> 2035 lines. Adds unit tests for useSlashMenu. No
behavior change.
2026-06-23 18:17:48 +08:00
qer
83384ee6d4
fix(web): persist input history so recall works after the first message (#1015)
* fix(web): persist input history so recall works after the first message

The composer has two mutually-exclusive instances: the empty-session
composer and the docked composer. The first message of a new session is
sent by the empty composer, which unmounts as soon as the first turn
appears; the docked composer then mounted with an empty in-memory history,
so ArrowUp did nothing until a second message was sent. The history was
also lost on every page reload.

Persist the history to localStorage as a single global list and re-read it
on mount. Global (not per-session) because a new session has no id until
after the first submit, so per-session keys would not line up across the
empty -> docked handoff. Caps the list at 200 entries.

Adds persistence-focused unit tests (surviving a remount, the 200-entry
cap, and a malformed stored value).

* fix(web): record slash commands in input history too

Move the history.push call ahead of the slash-command branch so that known
commands (with or without args, e.g. /goal <task> or /model) are recorded
and can be recalled with ArrowUp, instead of only plain messages. Steer
already pushed; only the submit slash path was missing it.

* fix(web): record menu-selected slash commands in history

Bare slash commands picked from the slash menu (e.g. /model, /login) go
through selectSlashCommand and emit directly, never reaching handleSubmit,
so they were not recorded even after the typed-slash fix. Push the command
name before emitting. acceptsInput commands are still recorded later by
handleSubmit together with their argument.
2026-06-23 18:07:00 +08:00
liruifengv
6d506380ce
fix(changeset): downgrade web panel resize changeset from minor to patch (#1021) 2026-06-23 17:23:31 +08:00
liruifengv
9c553e4bf7
feat(tui): add Alt+S to switch model for current session only (#1020)
In the /model picker, Enter still switches the model and saves it as the
default; Alt+S now switches only for the current session without writing
to the config file.
2026-06-23 17:14:43 +08:00
qer
fb780fce96
refactor(web): extract input-history recall into a composable (#1011)
* refactor(web): extract input-history recall into a composable

Move the shell-style up/down recall of previously sent messages out of
Composer into useInputHistory. The composable owns the history list, the
browsing cursor, and the textarea caret/selection work needed to apply a
recalled entry, taking the text ref, textarea ref, and autosize as deps.

The composer keeps the keydown orchestration (which also juggles the slash
and mention menus) and calls into the composable for push / recall / caret /
browsing state.

Composer.vue: 2104 -> 2050 lines. No behavior change.

* test(web): cover useInputHistory recall behavior

Add unit tests for the extracted input-history composable: push dedup and
empty-skip, walking backward/forward through entries, restoring the live
draft (empty and non-empty), empty-history no-op, resetBrowsing, and the
caretAtFirstLine gate.
2026-06-23 17:01:17 +08:00
liruifengv
fd16ffb80a
fix(tui): fix Tab key completion in the editor (#1012)
Stop plain Tab from opening the file completion list when the autocomplete menu is closed; Tab now only accepts the selected item while the menu is open.

After Tab-completing a slash command name, reopen the menu to show its subcommands instead of falling back to file completions.
2026-06-23 16:51:12 +08:00
qer
a2650f85d4
refactor(web): extract ConversationToc from ConversationPane (#1010)
Move the beta conversation outline (proportional bubbles, viewport
indicator, hover tooltip) into a dedicated ConversationToc component.
The child owns the nav markup, the tooltip hover state, and its own
visibility (mobile / session-loading / single-turn), while the metric
derivation and scroll-driven viewport/active-turn tracking stay in the
pane because they are coupled to the scroll container.

ConversationPane.vue: 1613 -> 1422 lines. No behavior change.
2026-06-23 15:51:49 +08:00
liruifengv
e47de610e4
feat(tui): add ctrl+t to expand the todo list (#1009)
* feat(tui): add ctrl+t to expand the todo list

Toggle between the truncated view and the full list; the shortcut only takes effect while the list actually overflows.

* docs(keyboard): document ctrl+t todo expand shortcut

* chore(changeset): mark todo expand shortcut as patch

* docs(agents): clarify minor vs patch in gen-changesets skill

* fix(tui): clear pending exit when toggling the todo list
2026-06-23 15:49:07 +08:00
liruifengv
d70c3a8c01
fix(tui): support expanding bash command while running (#1004)
Render the command in the in-flight Bash card body so it is visible while the command runs, and let Ctrl+O expand the full command before the result arrives.
2026-06-23 15:28:49 +08:00
qer
ea1b33b674
refactor(web): extract pure turn-rendering helpers from ChatPane (#1001)
* refactor(web): extract pure turn-rendering helpers from ChatPane

* chore: add changeset for chat pane helper extraction

* test(kimi-web): cover chat turn-rendering helpers

Pure-logic tests for the helpers extracted from ChatPane, focused on
assistantRenderBlocks (tool-stack grouping, interrupt/media break, single
tool) plus the formatting/boundary helpers. Doubles as a safety net
confirming the extraction preserved behavior.
2026-06-23 15:20:00 +08:00
qer
e15edfd017
fix: always expose the free-text Other option in question prompts (#1003)
The question adapter only set allow_other on the wire when the SDK item carried an otherLabel/otherDescription, but the AskUserQuestion tool never provides those fields. Web clients honor allow_other, so the free-text option silently disappeared in the web UI while the TUI (which renders it unconditionally) kept working. Set allow_other unconditionally to match the tool's 'users always have an Other option' contract.
2026-06-23 15:09:55 +08:00
7Sageer
b84704bff3
perf(kaos): optimize large file reads (#971) 2026-06-23 15:07:40 +08:00
liruifengv
6b68aa85e2
feat(cli): add -c as shorthand for --continue (#999)
The lowercase -c now maps to --continue, shown in help as the primary short flag. The uppercase -C still works as a hidden alias since commander does not allow two short flags on a single option.
2026-06-23 13:53:31 +08:00
qer
3e4793d611
refactor(web): extract WorkspaceGroup from Sidebar (#998)
* refactor(web): extract WorkspaceGroup from Sidebar

* chore: add changeset for sidebar workspace group extraction
2026-06-23 13:33:36 +08:00
qer
92c2cf0ef5
feat(web): remove sidebar and panel max-width limits (#985)
* feat(web): remove sidebar and panel max-width limits

Make the resize handle max width optional so the web sidebar and right-side detail/preview panel can be resized beyond their previous fixed maximums.

* fix(web): keep sidebar resize handle reachable on narrow windows

Cap the restored sidebar width at a viewport-aware maximum (viewport width minus the conversation pane minimum) so a width saved on a wide display cannot push the resize handle or collapse button off-screen on a narrower window. The cap updates on resize.

* fix(web): cap preview panel to viewport and share panel-width logic

Apply the same viewport-aware maximum to the right-side detail/preview panel and extract the viewport tracking and width clamping into a shared composable used by both panels.

* fix(web): keep resize caps reactive and reserve room for the preview

Make the resize handle read its max width reactively so a viewport-derived cap keeps working as the window grows after mount. Also have the sidebar reserve the preview panel's minimum width whenever the right-side panel is open, so the conversation column can never be squeezed to zero.

* fix(web): clamp sidebar content width and ignore stale preview target

Render the Sidebar content at the clamped width so controls stay reachable when the saved width exceeds the viewport cap. Also stop reserving space for a hidden right panel by keying the sidebar preview-open check off detailTarget instead of the stale previewTarget.

* fix(web): clamp drag start to current resize cap

When the saved width exceeds the current cap (after the window narrows or a side panel opens), start the drag from the clamped width so the handle responds immediately instead of first covering an invisible delta.

* fix(web): clear detailTarget when closing side chat via /btw

The bare /btw close path called client.closeSideChat() directly, which hid the panel but left detailTarget set to 'btw', so the sidebar kept reserving room for a hidden right panel. Route it through the detail-layer close which clears detailTarget.
2026-06-23 13:14:19 +08:00
liruifengv
87fb95850c
docs(changelog): sync 0.19.1 from apps/kimi-code/CHANGELOG.md (#995)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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
2026-06-23 12:10:06 +08:00
github-actions[bot]
7d2a23e7db
ci: release packages (#983)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-23 11:29:45 +08:00
liruifengv
7341fb4979
fix(acp): read workspace local config via local fs at session start (#992)
Read `.kimi-code/local.toml` through the persistence (local) kaos instead of
the ACP tool kaos during session bootstrap. The ACP reverse-RPC bridge needs
the session registered on the client, which is not true until `session/new`
returns, so reading through it failed with "unknown session" and blocked new
threads in ACP editors such as Zed.

Fixes #988
2026-06-23 11:19:20 +08:00
qer
da81858802
fix(web): clear all per-session state when archiving or removing a session (#984)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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(web): clear all per-session state when archiving or removing a session

* fix(web): clear queued and in-flight prompt state in session teardown

* fix(web): unsubscribe session before clearing its state in teardown

* fix(web): cancel pending WS subscriptions on unsubscribe
2026-06-23 01:29:23 +08:00
qer
8c6cade69e
refactor(web): consolidate storage and split root state and app shell into composables (#979)
* refactor(web): consolidate localStorage access and split appearance/notification modules

* refactor(web): extract task polling into useTaskPoller module

* refactor(web): split useKimiWebClient into workspace/sideChat/modelProvider modules

* refactor(web): extract App.vue composables for page title, auth, sidebar, detail panel and file preview

* refactor(web): funnel sessions and activeSessionId mutations through setters

* refactor(web): funnel messagesBySession mutations through setters

* refactor(web): move FileData type to types.ts to fix type-aware lint
2026-06-22 23:56:14 +08:00
qer
d4ae02d82e
fix(web): keep sidebar unread dots in sync across browser tabs (#978) 2026-06-22 23:36:58 +08:00
qer
0c689e1891
changelog: 0.19.0 (#980) 2026-06-22 22:05:58 +08:00
github-actions[bot]
b2d3ad0728
ci: release packages (#911)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-22 21:39:21 +08:00
qer
4731f02ccb
chore(skills): add pre-changelog skill (#902)
* chore(skills): add release-precheck skill

Add a project-scope agent skill that audits an open kimi-code release PR ("ci: release packages") before merge: it lists the changesets the release will publish, maps each to its source PR, scans the release window for merged PRs that forgot a changeset, and flags wrong bump levels, wrong packages, and non-compliant wording. Outputs the release PR link and a concise suggestion table.

* chore(skills): fix release-precheck package glob and base filter

* chore(skills): classify shipped apps/vis paths as needing changeset

* chore(skills): simplify release-precheck to principle-based audit

* chore(skills): scope release-precheck to changeset validation only

* chore(skills): surface possibly-missing changesets in release-precheck

* chore(skills): replace release-precheck with pre-changelog
2026-06-22 21:36:31 +08:00
liruifengv
e7dd13804d
feat(tui): detach foreground tasks to background with Ctrl+B (#976)
* feat(tui): detach foreground tasks to background with Ctrl+B

- Add Ctrl+B shortcut to detach all foreground Bash/subagent tasks at once

- Show "Press Ctrl+B to run in background" hint in tool cards (Agent immediately, Bash after 10s) and in the agent group panel

- Mark detached foreground subagents as ◐ backgrounded instead of ✓ Completed

- Filter foreground tasks out of the /tasks panel (they appear after detach)

- Steer the model away from blocking on TaskOutput after a detach

* fix(tui): address Codex review on Ctrl+B detach

- Preserve `◐ backgrounded` for detached subagents inside AgentGroupComponent by reusing getDerivedSubagentPhase in getSubagentSnapshot

- Distinguish detached-from-foreground subagents from started-in-background ones so the latter still read as `done`

- Let Ctrl+B fall through to readline backward-char at the idle prompt instead of always consuming the key

* fix(tui): address follow-up Codex review on detach hints

- Auto-clear the "No foreground task running." hint via showDetachHint so it doesn't stick on the footer

- Don't clobber a newer transient hint (e.g. exit confirmation) when the detach hint timer fires

- Add FooterComponent.getTransientHint()

* fix(tui): address Codex review on backgrounded gating and /tasks counts

- Only mark foreground-running subagent cards as backgrounded (skip done/backgrounded cards so background resumes don't mutate older rows)

- Count /tasks header from the filtered (background-only) task set so foreground-only sessions don't read misleading counts
2026-06-22 20:59:24 +08:00
qer
d521932c3e
fix(web): stop showing unread dots for cancelled or failed sessions (#977) 2026-06-22 20:43:05 +08:00
Haozhe
c5c1834725
feat(server): add fast disk-based snapshot reader (#975)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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
* feat(server): add fast disk-based snapshot reader

- add ISnapshotService that reads session state and wire log directly from disk for fast initial sync
- gate the reader behind KIMI_SNAPSHOT_READER (auto/legacy) with a KIMI_SNAPSHOT_TIMEOUT_MS ceiling, keeping the legacy assembly as a fallback
- version @moonshot-ai/server and other internal packages in changesets instead of ignoring them

* fix(server): resolve TS4111 error in snapshot perf test

Use bracket access for process.env['KIMI_SNAPSHOT_READER'] to satisfy
noPropertyAccessFromIndexSignature.
2026-06-22 19:47:31 +08:00
liruifengv
c0eeca2469
feat: add workspace add-dir support (#812)
* feat: add workspace add-dir support

Add multi-directory /add-dir management with session-only or project-remembered persistence, directory completion, confirmation UI, and runtime workspace/permission wiring.

* fix: honor --add-dir for resumed sessions

Pass CLI additional directories through shell and prompt resume paths, resolve caller-relative dirs against workDir, and add regression coverage.

* fix: keep additional dirs AGENTS.md out of default context

Load only user-level and cwd AGENTS.md by default, while preserving additional directory listings in the prompt context.

* feat: append /add-dir result as user message

Add a session appendUserMessage RPC and use it after /add-dir so the command result is recorded as a normal user message and surfaced in the transcript.

* docs: add add-dir research and follow-up todos

Document the add-dir / local-command-stdout research findings and the follow-up tasks for stdout wrapping, slash file completion, and hints.

* feat: wrap /add-dir output as local-command-stdout

Insert the /add-dir result as a user-role <local-command-stdout> record with an injection origin directly inside Session.addAdditionalDir. It enters the model context on the next turn but does not start a turn, and stays out of the live and resumed transcript; the transient status toast is kept for immediate feedback. --add-dir is unaffected since it bypasses addAdditionalDir.

Remove the now-unused appendUserMessage RPC and SDK method.

* feat: reopen /add-dir completion after accepting a directory

Generalize the slash-argument completion reopen so it fires whenever the text before the cursor ends with '/', not only when the literal '/' key is typed. After Tab-accepting a directory (or auto-applying a single-child dir), the next level's completion list reappears automatically, so repeated Tab keeps drilling down into subdirectories. '@' file mention is unaffected.

* feat: reopen file mention completion after accepting a directory

Extend the path completion reopen so it also fires for '@' file mentions. After Tab-accepting a directory in an '@' mention, the next level's completion list reappears automatically, matching the '/add-dir' continuous-Tab behavior.

* feat: show inline argument hints for slash commands

Render a dim ghost-text argument hint inside the input box after a slash command that takes arguments, replacing the popup-only hint that was easy to miss. The hint appears once the command is typed and disappears as soon as an argument is entered, and is truncated to fit the box width. Add argument hints for /compact, /swarm, /goal and /title; /add-dir already had one.

* test: remove stale additional-dirs AGENTS.md assertion

The subagent-host test still asserted that an additional directory's AGENTS.md content appears in the agent system prompt, but additional-dirs AGENTS.md has been intentionally excluded from the default context since an earlier commit (covered by context.test.ts). Drop the stale assertion.

* fix: resolve /add-dir paths against workdir and persist via kaos

Resolve user-supplied /add-dir paths against the current workdir instead of the project root, so launching from a subdirectory behaves like the CLI --add-dir flag. Also route the local.toml read/write through the kaos abstraction instead of host fs, so the remember path works for non-local sessions.

* fix: expand ~ in /add-dir paths before resolving

The /add-dir completer emits ~/... values, but the core treated ~/foo as a relative path because pathe isAbsolute('~/foo') is false, producing <workDir>/~/foo. Expand ~ and ~/ to the home directory (via kaos.gethome()) before resolving.

* chore: remove add-dir dev docs from the branch

These were working notes (research and follow-up todos) that don't belong in the PR.

* chore: clarify add-dir changeset for users

* docs: document /add-dir, --add-dir, and local.toml

* test: flush records before reading wire in add-dir runtime tests

FileSystemAgentRecordPersistence.append buffers records and flushes asynchronously, so readMainWire can read the wire before the local-command-stdout record lands. Flush the main agent's records explicitly in the two add-dir runtime tests to make them deterministic.
2026-06-22 19:42:13 +08:00
7Sageer
d434d8f0d8
refactor(agent-core): unify image extension sniff-failed detection (#974)
Merge the two duplicate image-extension guards in detectFileType into a single mode-independent rule: an image extension without confirming magic is not an image in any mode. The video extension fallback stays before the NUL check so video containers with no magic still win. No behavior change.

Add and align tests: Read rejects an image-extension file with non-image bytes as not readable instead of redirecting to ReadMediaFile; file-type asserts the sniff-failed image case in both media and text modes.
2026-06-22 19:31:48 +08:00
7Sageer
27300797f2
fix(agent-core): reject image extensions whose bytes fail to sniff (#970)
When ReadMediaFile reads a file whose bytes have no recognisable image magic (for example a `.png` that is actually plain text), detectFileType previously fell back to the extension MIME type and built a data URL whose bytes did not match the declared format, which the model API rejected.

Every image format the model accepts (PNG/JPEG/GIF/WebP) has a reliable magic signature, so a failed sniff means the bytes are not a supported image. Report such files as `unknown` so ReadMediaFile returns a clear error instead of a bad data URL. The extension fallback is kept for video containers with no magic signature (for example MPEG-PS `.mpg`); format acceptance beyond that is left to the provider.
2026-06-22 19:07:34 +08:00
7Sageer
4292ae9f9b
fix: surface provider content filter and preserve context tokens (#963)
* fix: surface provider content filter and preserve context tokens

* fix: complete filtered turn handling across surfaces

- context: accumulate token estimate for zero-usage steps to preserve
  the tokenCount / tokenCountCoveredMessageCount invariant
- turn/goal: pause the goal when a turn is blocked by safety policy
- subagent: surface a filtered child turn as a distinct error
- acp: map filtered to the native ACP refusal stop reason
- tui: show a filtered-specific message in the btw panel
- cli: drop the redundant content_filter suffix from the error message
- tests: cover filtered across cli, web, acp, and goal flows
2026-06-22 17:32:05 +08:00
qer
3b9938b4c3
refactor(web): consolidate localStorage access and split appearance/notification modules (#973) 2026-06-22 17:21:15 +08:00
Haozhe
b57fc905fe
fix(kaos): hide console window for spawned commands on Windows (#957)
- add windowsHide:true to spawn options so commands do not flash a console on Windows
- extract buildLocalSpawnOptions helper shared by exec and execWithEnv
- add regression coverage for the Windows console-window behavior
2026-06-22 15:34:18 +08:00
qer
42237392dd
fix(web): keep page title static instead of tracking session or workspace (#964) 2026-06-22 15:16:55 +08:00
qer
845de6bd51
test(kimi-web): keep only pure logic unit tests (#959)
* test(kimi-web): keep only pure logic unit tests

Remove jsdom/component Vitest coverage from apps/kimi-web, keep server-e2e as the e2e path, and add focused pure-logic Vitest coverage for diff parsing, file path links, tool summaries, turn grouping, and todo derivation.

* build(nix): update pnpm deps hash
2026-06-22 14:57:27 +08:00
qer
98905eb409
fix(web): show longer branch names in chat header (#958) 2026-06-22 14:28:18 +08:00
liruifengv
152bb69d86
fix: fix bundle (#956)
* fix: fix bundle

* test(kimi-code): remove obsolete pino-pretty test
2026-06-22 13:59:57 +08:00
7Sageer
3443a00a43
feat(agent-core): add thinkingLevel to compaction telemetry (#954)
Report the effective thinking effort (off/low/medium/high/xhigh/max) on
compaction_finished, compaction_failed, and micro_compaction_finished events.
The value matches the thinking level applied to the compaction provider, so we
can analyze how thinking mode affects compaction token usage, duration, and
failures.
2026-06-22 12:09:29 +08:00
_Kerman
ba64072559
feat: detach foreground tasks to background (#821)
Some checks failed
CI / build (push) Has been cancelled
CI / test (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / typecheck (push) Has been cancelled
Nix Build / Check flake.nix workspace sync (push) Has been cancelled
Release / Release (push) Has been cancelled
Release / Native release artifact (push) Has been cancelled
Nix Build / nix build .#kimi-code (push) Has been cancelled
Release / Deploy docs (push) Has been cancelled
Release / Publish native release assets (push) Has been cancelled
2026-06-20 21:24:22 +08:00
_Kerman
7644f1036c
fix: drop empty text blocks during projection (#910)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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
2026-06-19 23:33:38 +08:00
qer
710277a34b
docs(changelog): sync 0.18.0 from apps/kimi-code/CHANGELOG.md (#898)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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 / Native release artifact (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
2026-06-18 21:04:36 +08:00
github-actions[bot]
e6c2f51fa3
ci: release packages (#877)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-18 20:19:23 +08:00
qer
d7ec05686a
feat(web): add scroll-up lazy loading for older session messages and fix new-messages pill (#893)
* feat(web): add scroll-up lazy loading for older session messages

* fix(web): keep new-messages pill above the composer dock

* fix(web): observe top sentinel after DOM flush in ChatPane

* fix(web): avoid eager lazy-load on open and suppress pill during history prepend

* fix(web): gate scroll-key watcher and skip scroll restore on session switch

* fix(web): restore scroll position from stable top anchor when prepending history

* fix(web): preserve new-message pill during history lazy load

* fix(web): resolve lint regressions in lazy load tests

* fix(web): harden session lazy-load retry and scroll restore

* fix(web): treat same-turn history prepends as non-bottom updates
2026-06-18 20:12:21 +08:00
qer
495fe8c674
feat(web): add session search (#895)
* feat(web): add session search

Add a search box to the web sidebar that instantly filters all loaded
sessions by title and the last user prompt (case-insensitive).

Surface the last user prompt from the server: the daemon already
persisted it in session metadata, and it now flows through the session
schema into the REST response so the web client can match against it.

* fix(web): keep lastPrompt fresh on session.meta.updated

Address Codex review: the daemon emits session.meta.updated with
patch.lastPrompt whenever a new prompt is submitted, but the web
projector only forwarded the title. That left the cached session's
lastPrompt stale, so sidebar search by the latest prompt text failed
until a full reload. Forward lastPrompt through the projector and
reducer, and cover it with a pipeline test.

* refactor(web): avoid conditional spreads in meta patch

Address Codex review: per the root AGENTS.md, optional object properties
should be passed directly rather than via conditional spreads. Use nullish
coalescing so a field the event does not carry keeps its prior value.

* fix(web): stop Escape from aborting a run while search is focused

Address Codex review: ConversationPane registers a document-level keydown
that aborts the active prompt on Escape. Without handling it on the search
input, pressing Escape to dismiss the search would unexpectedly stop the
agent. Stop propagation and clear the query, matching the inline rename
inputs.

* fix(web): exclude hidden-workspace sessions from search

Address Codex review: removing a workspace only records its root in
hiddenWorkspaceRoots and leaves the sessions intact; the grouped sidebar
skips the hidden root, but sessionsForView (the search source) did not,
so a matching title or prompt could resurrect sessions from a removed
workspace. Filter sessionsForView by the visible workspace set so the
flat list matches what the grouped sidebar renders.
2026-06-18 19:23:23 +08:00
qer
de610deb5f
fix(web): drop workspace session count after archiving the last session (#896)
The daemon's workspace session_count counted archived session directories,
so a workspace still reported a non-zero count after its last session was
archived. The web sidebar then showed the workspace as non-empty, making it
look like the archive had failed (and a retry would error out).

Count only non-archived sessions in the workspace registry, and trust the
live local count in the web app once sessions have loaded.
2026-06-18 19:14:47 +08:00
Luyu Cheng
cde7ca51cc
feat(goal): support guided goal authoring (#839) 2026-06-18 18:48:23 +08:00
7Sageer
42d648655a
refactor(telemetry): merge duplicate session-start and goal events (#885)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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
* refactor(telemetry): merge duplicate session-start and goal events

* test: align telemetry tests with merged session-start events

- run-shell: assert sessionStartedProperties plumbing instead of the removed 'started' event, drop the now-redundant resumed-lifecycle test, and fix the startup_perf call-count assertion

- node-sdk: cover process-level and session-level sessionStartedProperties merging on session_started

* fix(telemetry): keep session_started canonical fields authoritative

Caller-supplied sessionStartedProperties were merged after the canonical fields (client_name, client_version, ui_mode, resumed), so a caller could silently override them via the public SDK options. Reorder so the harness-owned canonical fields always win, while session-level properties still override process-level ones for non-canonical keys.
2026-06-18 17:38:02 +08:00
qer
8ab9e96963
fix(web): paginate session list on load (#882)
* fix(web): list sessions per workspace with pagination

* fix(web): use global paginated session list instead of per-workspace
2026-06-18 17:21:50 +08:00
qer
23277a574c
feat(web): show server version in settings (#889)
* feat(web): show server version in settings

Display the server version reported by GET /api/v1/meta in the web
settings General tab, under a new Build section.

* feat(web): show server version in mobile settings too

Address Codex review: the mobile settings sheet renders instead of
SettingsDialog on mobile, so surface the server version there as well.
2026-06-18 17:06:52 +08:00
7Sageer
58898de020
feat(agent-core): cap AgentSwarm concurrency via env var (#888)
* feat(agent-core): cap AgentSwarm concurrency via env var

Add KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY to limit how many subagents run concurrently during the initial ramp, so large swarms do not trip provider rate limits as easily. Leave it unset to keep the previous uncapped ramp behavior.

* chore: drop ignored agent-core from changeset

* fix(agent-core): fail fast on invalid AgentSwarm concurrency cap
2026-06-18 16:45:42 +08:00
qer
7bc3d99933
fix(web): keep highlighted slash command visible in long menus (#881)
* fix(web): scroll active slash item into view and stabilize item keys

* fix(web): use function ref for slash menu items to preserve order

* chore(changeset): add changeset for web slash menu scroll polish
2026-06-18 14:46:38 +08:00
Haozhe
584d997530
feat(server): report host kimi-code CLI version in /meta (#879)
- prefer coreProcessOptions.identity.version in start.ts for /meta and the OpenAPI/AsyncAPI docs
- fall back to getServerVersion() when the server runs standalone without a host identity
- update meta.ts and version.ts docs to describe the new version source
- add e2e test verifying server_version reports the host identity version
2026-06-18 14:39:24 +08:00
qer
a74a6b7f6b
fix(web): improve slash menu and skill editing experience (#878)
* fix(web): keep slash skills editable

* fix(web): wrap long slash menu entries

* fix(web): rank exact slash matches before substrings
2026-06-18 14:30:29 +08:00
qer
d1dc2a3e77
feat(web): redesign OAuth login dialog layout (#867)
* feat(web): redesign OAuth login dialog layout

Lead with a single 'Authorize in browser' button that opens the verification link with the device code embedded; demote manual code entry to a secondary fallback; remove the duplicate open-browser and cancel controls and the redundant footer hints so the step order is clear.

Props/emits unchanged; no caller changes needed.

* fix(web): align login slash copy

* fix(web): keep login dialog open on backdrop click
2026-06-18 14:06:57 +08:00
qer
49183d8729
chore: suggest /reload alongside /new in plugin hints (#876) 2026-06-18 14:04:23 +08:00
wenhua020201-arch
90745abc29
docs(reference): fix kimi web to document background-daemon behavior (#871)
* docs(reference): 修正 kimi web 为后台守护进程运行

kimi web 实际复用 server run 的后台守护进程流程(web-alias.ts 中
defaultOpen 置为 true),原文档误写为前台运行。改为后台启动、命令返回,
并补充 --foreground 示例。

* docs(reference): 与文档站参考手册版本对齐 kimi web 说明
2026-06-18 11:16:48 +08:00
qer
15cc4ab256
docs(changelog): sync 0.17.1 from apps/kimi-code/CHANGELOG.md (#863)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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
* docs(changelog): sync 0.17.1 from apps/kimi-code/CHANGELOG.md

* chore
2026-06-18 00:11:02 +08:00
github-actions[bot]
55f865642f
ci: release packages (#856)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-17 23:50:24 +08:00
qer
bd0979578b
feat(web): group default model dropdown by provider in settings (#861)
* feat(web): group default model dropdown by provider in settings

* fix(web): prevent login dialog closing on backdrop click
2026-06-17 23:48:23 +08:00
Haozhe
0e2877bee3
fix(server): use execPath for daemon/supervisor re-exec in SEA (#860)
* fix(server): use execPath for daemon/supervisor re-exec in SEA

Detect SEA via node:sea and re-exec process.execPath instead of resolving argv[1] against cwd, which produced a bogus <cwd>/kimi and crashed the spawn with ENOENT for the native binary (kimi web).

Apply the same fix to both resolveDaemonProgram (kimi web daemon spawner) and resolveSupervisorProgram (launchd/systemd/schtasks), and handle the spawn error event so a launch failure is logged instead of crashing the parent with an unhandled error event.

* chore: add changeset for native server start fix

* fix(server): run background daemon from its log directory

Spawn the detached server child with cwd set to the server log directory instead of inheriting the caller's cwd, so the long-lived daemon does not pin the directory it was launched from (notably blocking its deletion on Windows).
2026-06-17 23:37:23 +08:00
qer
9468868f3d
docs(changelog): sync 0.17.0 from apps/kimi-code/CHANGELOG.md (#859) 2026-06-17 22:33:30 +08:00
github-actions[bot]
cca89064a5
ci: release packages (#826)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-17 21:57:55 +08:00
Haozhe
2c82a86813
feat(tui): add /web slash command to open session in Web UI (#854)
* chore(daemon): remove unused daemon package and stale references

- delete packages/daemon package.json
- drop daemon from flake.nix workspace paths/names and pnpm-lock.yaml
- remove dead daemon-e2e Dockerfile gitignore negation
- update stale daemon references in DiffView and PromptDispatchLogEntry comments

* feat(tui): add /web slash command to open session in Web UI

add /web command that starts the server daemon, opens the current session in the browser, and exits the terminal UI\nrequire a confirmation dialog (Enter on Continue, Esc to cancel) before executing\nregister the command in the built-in registry and dispatch\nadd tests for command registration and session URL building

* feat(tui): print opened web URL alongside resume hint on exit

thread the opened URL from /web through KimiTUI.exitOpenUrl to the onExit handler\nprint open <url> as a clickable hyperlink next to the kimi -r resume hint\nadd a test for the exit handler when an opened URL is set
2026-06-17 21:56:11 +08:00
qer
cb8f9d58da
fix(changeset): remove ignored packages from mixed changesets (#855) 2026-06-17 21:53:01 +08:00
qer
05fe7595ab
fix: improve web login and workspace startup (#853) 2026-06-17 21:39:51 +08:00
Haozhe
31f9024046
chore(daemon): remove unused daemon package and stale references (#852)
- delete packages/daemon package.json
- drop daemon from flake.nix workspace paths/names and pnpm-lock.yaml
- remove dead daemon-e2e Dockerfile gitignore negation
- update stale daemon references in DiffView and PromptDispatchLogEntry comments
2026-06-17 21:11:36 +08:00
Haozhe
9a8fea5c85
feat(web): introduce Kimi web app and daemon gateway (#625)
* docs(reports): collapse P3 plan into a single final-solution doc

Drop the per-step TDD/commit scaffolding; keep the substance as one final
approach per area (what it does, files to touch, key types/events/projection,
component responsibilities, verification, risks, sequencing).

* fix(kimi-web): normalize chat block spacing

Group consecutive tool cards structurally so chat block spacing is applied consistently without leaking card borders or shadows.

* feat(web): land P3 — goal / swarm / subagent + terminal + view split

Implements the locked P3 design end-to-end:
- subagent lifecycle projection (spawned→started→suspended→completed/failed) +
  inline Agent / AgentGroup cards; swarm progress card (multi-column) derived
  from swarmIndex; goal dock strip (expandable) from goal.updated; plan/goal/
  swarm activation badges in the composer status line.
- terminal as a view (xterm + WS terminal_* frames with since_seq replay) and a
  tab/view-dimension split (usePaneLayout tree + ViewGroup + SplitLayout, VSCode
  editor-group style), persisted to localStorage.
Adds swarm-groups / subagent-goal / agent-group-turns unit tests and stub-daemon
seeds. 98 tests pass; vue-tsc + oxlint clean; production build OK.

Accepted by review (see reports/web-p3-acceptance.md); no blocking issues.

* docs(reports): P3 landing acceptance review

Comprehensive acceptance of the P3 landing (f5a7f21c): per-area verdicts, the
terminal 'map' crash explained as a stale-stub test artifact, non-blocking
recommendations, and verification record (98 tests, vue-tsc/oxlint clean, prod
build, in-browser smoke). No serious issues found; no code changed per the
'only fix serious issues' instruction.

* fix(terminal): make node-pty load and spawn in packaged + pnpm-dev builds

Two distinct PTY failures:
- 'Failed to load native module: pty.node' (npx/published daemon): node-pty was
  transitively bundled via @moonshot-ai/services (alwaysBundle), inlining its JS
  while its native binary can't be bundled and wasn't shipped. Mark node-pty
  external in tsdown (neverBundle) and declare it as a runtime dependency of
  @moonshot-ai/kimi-code so npm/npx installs it with its prebuilt pty.node.
- 'posix_spawnp failed' (local pnpm dev): node-pty's prebuilds/*/spawn-helper
  loses its +x bit through pnpm's store extraction. Add a root postinstall
  (scripts/fix-node-pty-perms.mjs) that restores the executable bit; verified it
  fixes a reproducible spawn failure.

Also harden defaultShell() to fall back on an empty (not just unset) $SHELL.

Note: the SEA standalone binary still needs node-pty's pty.node + spawn-helper
wired into scripts/native/native-deps.mjs (not addressed here; npx path covers
the reported case).

* fix(web): use a real monospace font + tighter line height in the terminal

xterm's fontFamily takes a literal font string, so 'var(--mono)' never resolved
and the terminal fell back to courier with loose metrics — the wrong-looking
font and spacing. Pass the actual JetBrains Mono stack, await document.fonts
before xterm measures the cell (so the variable font isn't mismeasured), tighten
lineHeight 1.25 → 1.1, and pin letterSpacing 0.

* style(web): drop the staggered line-in animation on expanded tool-call output

Remove the per-line kimi-line-in stagger on `.box.open .bb > div` (modern/kimi
themes) and its keyframes — expanding a tool card no longer animates each output
line in.

* feat(web): move the tool-call summary into the card when expanded

Previously the command/summary always sat on the header. Now it shows on the
header only while collapsed; expanding hides it from the header and renders it
at the top of the card body (above the output) — so it appears exactly once and
the expanded header stays clean. Re-adds the .bb-summary style and a mount test.

* feat(web): show the full, un-truncated summary in the expanded tool card

The expanded body has room to wrap, so it shouldn't keep the header's '…'
clip. Add a `full` flag to toolSummary that skips the length clip and use it for
the .bb-summary; the collapsed header keeps the clipped form (CSS ellipsis still
guards overflow). Extends the mount test to cover full-vs-clipped.

* revert(web): keep the sending moon until the turn ends

Reverts 980ff9d4: dropping the moon the instant the first token streamed wasn't
wanted. Remove the assistantDelta/messageUpdated clear so sendingBySession is
again cleared only on turn end (onSessionIdle), restoring the prior behavior,
and delete the now-moot sending-moon test.

* style(web): bump composer textarea font-size to 14px

The composer input (.ph) under the modern/kimi themes was 13px while the
terminal-theme baseline is 14px. Unify on 14px so the textarea text matches
the rest of the composer.

* fix(web): dedupe the daemon echo of an image steer (no double user bubble)

Steering an image while a turn was running rendered TWO user bubbles and the
steer text looked like it never landed. Two causes:

1. The reducer matched the daemon's user-message echo to our optimistic copy by
   exact content equality. Image content serializes differently on each side
   (our {source:{kind:'file',fileId}} vs the daemon's resolved URL/base64), so
   the echo never matched and appended a duplicate. Match by prompt_id first
   (stamped on the optimistic message at submit), falling back to content.

2. Optimistic message ids were msg_opt_<Date.now()>. A queued send + a steer in
   the same millisecond collided on one id, so the prompt_id stamp landed on the
   wrong message. Use a monotonic counter for a unique id per optimistic message.

steerPrompt now also stamps the real prompt_id onto its optimistic echo, like
submitPromptInternal already did.

* fix(web): don't flash the chat pane when opening an empty session

Selecting a never-opened session set sessionLoading=true until its snapshot
arrived, so the chat pane (loading spinner) rendered for a beat before the
empty-composer. A session the daemon reports as empty (messageCount 0) has
nothing to load — keep sessionLoading false for it so the empty-composer shows
immediately. Non-empty sessions still show the loading state.

* fix(web): auto-scroll to the latest content after a mid-stream refresh

Refreshing while a turn was streaming left two things parked above the live
output:

- The thinking block's inner 5-line window stayed at its TOP. Its scroll watcher
  only re-pins when already at the bottom, but a refresh delivers the whole
  thinking text at once with scrollTop 0. Pin a streaming block to its latest
  line on mount.

- The transcript could stop short of the bottom: the first scroll runs before
  markdown highlighting/images lay out and grow the content. Re-pin on the next
  couple of frames (only while still following) so a refresh ends at the latest
  content.

* fix(web): stop subagent turns from fragmenting the parent transcript

A subagent runs under the parent session id and streams its own turn / step /
delta / tool frames over the SAME session channel, each tagged with the
subagent's agentId. The web projector folded them into the parent transcript,
which produced the reported bug: empty 'skeleton' assistant bubbles (a subagent
turn.step.started opened a parent assistant message the main agent never filled)
and fragmented snippets (subagent deltas appended to the parent).

Skip transcript-building frames whose agentId is a non-main subagent, mirroring
the server's InFlightTurnTracker (which already tracks only main-agent
activity). Subagent progress is unaffected — it flows through the
subagent.* -> task -> AgentCard path, which is intentionally not gated.

* feat(web): remove the floating todo/background-task overlay

The wide-screen float-stack pinned a todo card + running-tasks card to the
top-right of the chat. Drop the overlay entirely (and the now-unused
TasksCard.vue) — todos and background tasks live in their own ~/todo and ~/tasks
tabs, so the overlay was a redundant, transcript-covering duplicate.

* feat(web): show all background tasks in the tasks tab, scroll on overflow

The tasks tab capped the list at 5 rows and showed '… +N more', hiding the rest
even with plenty of room. Render every task and let the list scroll internally
once it overflows the pane, so nothing is silently dropped.

* feat(web): running spinner + unread blue dot left of the session title

The gutter slot left of each session title (which kept the title aligned under
the workspace name) now carries a status indicator instead of being an empty
spacer:
- a small SVG spinner (Kimi-blue arc) while the session is running, replacing
  the old absolutely-positioned pulse dot;
- an unread blue dot when a BACKGROUND session finished a turn the user hasn't
  opened yet. Tracked via unreadBySession (set on idle for a non-active session,
  cleared when the session is selected).

* feat(web): unify archive/remove wording + keep the confirm within the title

- Clarify the two list-removal actions: a session is 'Archive' (归档), a
  workspace is 'Remove workspace' (移除工作区) — the workspace menu used the bare
  'Delete', which read as the same action as the session archive.
- Keep the session row's archive-confirm strip aligned under the title: the
  leading gutter slot now persists in the confirm state, so the confirm row
  starts at the title's left boundary instead of spilling to the row edge.

* feat(web): new-conversation button + workspace picker on the empty composer

- Add a compose button in the sidebar header (top-left) that starts a new
  conversation in the active workspace. It wires up the previously-dead 'create'
  emit (handleCreateSession → openWorkspaceDraft).
- On the empty composer, add a workspace picker below the hint so a new
  conversation can be started in any workspace without leaving the screen
  (switching enters that workspace's draft via openWorkspaceDraft).

* feat(web): add a Fork entry to the session row menu

Forking already worked via the /fork command and the daemon's :fork route, but
had no discoverable affordance. Add a 'Fork session' item to each session row's
kebab menu; forkSession() now takes an optional session id so any row (not just
the active one) can be forked.

* feat(web): recall sent messages with ArrowUp/ArrowDown in the composer

Shell-style history: ArrowUp on the first line of the composer walks back
through previously sent messages; ArrowDown on the last line walks forward and
finally restores the live draft. Editing the text leaves history-browsing, and
the edge-line guards keep multi-line cursor movement intact. Submitting (or
steering) a message appends it to the history (consecutive duplicates skipped).

* feat(web): capture console.log/info/debug + reusable log export

The client trace only captured console.error/warn. Capture every console level
(log/info/debug too) when tracing is enabled, so the exported troubleshooting
log reflects the full front-end console. Extract the JSONL download into a
reusable downloadTraceLog() (the debug panel now calls it; a settings 'Export
log' action can reuse it).

* feat(web): extract settings into a dedicated Settings page

Settings used to live in the sidebar account popover (a cramped fixed dropdown
that mixed appearance, language, account and the daemon endpoint). Move them
into a dedicated SettingsDialog modal opened from the header gear:
- Appearance (theme / colour scheme / accent), Language
- Account (provider, add workspace, reopen onboarding, sign in/out)
- Advanced (daemon endpoint, Export log — reuses downloadTraceLog)

The sidebar popover and its anchoring/positioning code are removed; the gear now
just emits openSettings. A Notifications section is added next (T14).

* feat(web): browser notification when a turn completes (with a settings toggle)

When a session finishes a turn and the user isn't already watching it (page
hidden, or a different session is active), fire a browser system notification
titled with the session, clicking it focuses the window and opens the session.
Opt-in via a new Notifications toggle in the Settings page; enabling it requests
OS permission and the preference is persisted (stays off if the user blocks it).

* feat(web): modes selector (plan/goal/swarm) + fix swarm double-render

- The plan pill at the composer's bottom-left becomes a 'Modes' popover that
  groups Plan (a working client toggle) with Goal and Swarm. Each shows its
  activated state (plan on / goal active / swarm n/m), and goal/swarm focus
  their card in the chat when active. The menu is position:fixed so the composer
  input row can't paint over it.
- Fix the swarm 'two blocks' bug: a multi-member swarm rendered BOTH inline as an
  AgentGroup AND as its SwarmCard. messagesToTurns now skips the inline block for
  swarm members (same membership test as buildSwarmGroups), so the swarm shows
  once — its special card in the chat flow.

Note: starting a goal/swarm from the web needs a daemon REST endpoint (the goal
RPC isn't exposed over REST and the daemon doesn't interpret slash commands in
prompts); display + activation state are wired here.

* feat(web): add a chat context header (workspace/session, git, open, copy, PR)

A thin bar above the chat shows the workspace / session breadcrumb, the git
branch with ahead/behind + changed-file count, an 'open in editor' action
(daemon fs:open on the workspace root), and a 'copy all conversation' action
(reuses ChatPane.copyConversation). It also has a GitHub PR slot that renders
when PR data is available — the daemon doesn't expose PR status yet, so it's
wired but currently passed null. Hidden on mobile and for the empty composer.

* feat(web): default path + fuzzy recursive search in the add-workspace browser

- Open the folder browser at the path kimi-web is working in (the active
  workspace root, falling back to $HOME) instead of always at $HOME.
- The filter becomes an fzf-style search: typing runs a bounded, debounced
  RECURSIVE subsequence-fuzzy walk under the current folder (capped depth/dirs/
  results, cancellable) and lists matching directories by relative path. The
  result list keeps a fixed height, so the dialog never resizes while searching.
- Collapse the paste-an-absolute-path field behind a secondary 'enter a path'
  toggle (auto-expanded when the daemon can't browse).

* chore(web): remove the non-functional /undo slash command

/undo had no daemon endpoint — it only pushed an 'undo not implemented' warning,
so it was a dead menu entry. Remove it from the slash list, the command router,
and the client. The full slash-command review with deletion suggestions for the
remaining commands is in reports/web-goal2-fixes.md (T17).

* docs(reports): results report for the second web TODO sweep (19 items)

* test(web): provide browser storage in vitest under node 24

* docs(reports): add web goal2 acceptance notes

* feat: show shortPath over branch in sidebar workspace header

* feat(kimi-code-web): use rounded chat bubble icon for new session button

* feat(kimi-code-web): add workspace creation in empty composer and tidy settings dialog

* feat: add manual swarm and goal activation to web ui

- Extend protocol schemas with swarm_mode, goal_objective, goal_control

- Add stub diff-dispatch in PromptService for new runtime controls

- Wire swarm/goal state through useKimiWebClient and daemon events

- Add Swarm toggle and Goal create/pause/resume/cancel in Composer modes menu

- Update StatusPanel and MobileSettingsSheet with swarm indicator/toggle

- Add bilingual i18n strings and update fixtures/tests

* feat: remove copy-conversation button from view-tabs

- Drop showCopyConversation / copyConversationCopied props from TabBar and ViewGroup

- Remove the share-conversation button markup and styles from TabBar

- Clean up related i18n strings in en/zh sidebar locales

- Keep the existing ChatHeader copy-all button and internal copy state unchanged

* feat: reorder chat-header layout and simplify git status styling

- Move Copy all button next to the workspace/session title on the left

- Move git branch/status and Open-in-editor to the right

- Shorten editor button label via new openInEditorShort i18n key

- Render ahead/behind/changes as plain colored text without pills

- Update en/zh header locale files

* style: make chat-header action buttons borderless icon + text

- Remove border, background, border-radius, and padding from .ch-act

- Keep label collapse on narrow widths, drop obsolete padding override

* feat: move copy-all to kebab menu and add session actions in chat-header

* feat: redesign chat-header open button with open-in menu

* feat: align chat-header diff stats with git ++/-- red/green style

* style(web): thinner, fainter scrollbars across all components

* fix(web): re-pin chat to bottom when a turn finishes streaming

* fix(web): keep the working moon spinning after a refresh mid-stream

* fix(web): subagent card margins in bubble layout + expandable task/result detail

* feat(web): rebuild subagent cards from the transcript so they survive a refresh

* fix(web): stop code blocks getting stuck on the loading skeleton

markstream's CodeBlock shows a skeleton while !stream && loading, and its
loading prop defaults to true. We never set it, so every settled code block
waited on shiki to highlight before showing anything; a screenful of code
(long session / fast burst) overwhelms shiki and the skeletons get stuck,
leaving the whole page blank. Pin loading:false so blocks render their
plain-text fallback immediately and upgrade to highlighted when ready.

* fix(web): tick running task timers + make task rows expandable to view output

* fix(web): dedupe image-steer echo via a loose (text+image-count) match

The daemon's messageCreated echo can land before submitPrompt stamps the
prompt_id onto the optimistic copy, and an image serializes differently
(file ref vs resolved URL), so neither the prompt_id nor exact-content match
fired and the echo rendered as a SECOND user bubble. Add a loose fallback
matching on text + image-count so the echo reconciles regardless of order.

* fix(web): let ↑/↓ walk all the way through input history once browsing

Recalling a multi-line entry left the caret on its last line, and the
'ArrowUp only on the first line' gate then refused to recall further, so
history only ever went one step back. Once browsing (historyIndex set), walk
history directly regardless of caret line; typing still exits browsing.

* feat(web): minimize button on question/approval cards + stack option label/desc

- Add a minimize toggle so a blocking question/approval can collapse to a thin
  header bar instead of covering the chat; number-key shortcuts are gated while
  collapsed so an unseen option can't be picked.
- Stack each option's label above its description (was squeezed side-by-side
  into many thin lines when the description was long).
Note: there is no question/approval timeout in the codebase (ask-user waits
indefinitely), so the '10 minute' request is a no-op.

* feat(web): archive-confirm text matches title size; workspace remove always hides

- Bump the 'archive session?' confirm label to the session-title size (14px)
  so it lines up with the title instead of reading as a smaller note.
- 'Remove workspace' now always hides the sidebar entry, even when it still has
  sessions: record the root in a persisted hidden set so mergedWorkspaces stops
  re-deriving it from session cwds. History/sessions are untouched; re-adding
  the same path un-hides it.

* feat(web): persist unsent composer drafts per session in localStorage

The composer text is saved under a per-session key as you type and restored
when you switch back to that session or reload the page; sending/steering
clears it. New-session drafts use a '__new__' key.

* feat(web): implement undo + edit-and-resend the last user message

- Wire the daemon POST /sessions/{id}:undo endpoint: client.undo(count) reverts
  the last turn(s) and re-syncs the snapshot. Restore the /undo slash command.
- Add an 'edit & resend' button on the latest user message: it undoes the last
  exchange and refills the composer with that message's text for editing.

* fix(web): reflect the agent's plan mode in the composer toggle

The agent reports plan mode via agent.status.updated (e.g. it auto-entered plan
mode for a 'make a plan' prompt), but the projector only forwarded swarmMode, so
the composer's plan toggle never lit up. Carry planMode on sessionUsageUpdated,
sync it into state, and also read it from GET /status — mirroring swarmMode.

* fix(web): hide an empty {} argument from the tool-call title (kept in details)

An empty tool argument was rendered as a noisy '{}' in the collapsed tool-card
header. toolSummary now returns '' for empty args in header (non-full) mode while
the expanded body still shows it.

* feat(web): show file/media preview as a split pane (peer of chat/files)

On desktop, opening a preview from a chat link/media now splits the layout and
shows it as a 'preview' view at the chat/files level (a transient tab in that
group, closeable via the group's close button) instead of a separate right-side
panel — matching the split buttons. Mobile keeps the full-screen side panel; the
preview view isn't persisted across reloads.

* docs(reports): results report for the third web TODO sweep (16 items)

* chore(kimi-web): temporarily hide open-in-app header menu

* docs: design doc for temporarily disabling swarm and goal modes in web composer

* feat(web): gray out swarm and goal modes with not-supported label

* docs: design doc for composer queue bubble + expanded panel

* feat(kimi-web): move undo button out of bubble with new icon and confirm step

* fix: inherit split layout attributes

* fix(kimi-web): smooth moon spinner speed

* feat(web): wire swarm and goal controls to agent-core

- enable swarm toggle and goal input/pause/resume/cancel controls in Composer\n- add goal error codes and agent-core-to-protocol route mappings\n- wire enterSwarm/exitSwarm and create/pause/resume/cancelGoal RPCs in PromptService\n- bootstrap swarmMode from agent-core state and track it in the shadow\n- update tests for swarm/goal dispatch and session status serialization

* fix(tui): only show provider refresh status for added models

 Skip removed / metadata-only provider updates when reporting model list changes.\n\n add: test to enforce the behavior.

* feat(tasks): add background task command and output polling to web

- include command field on protocol/server tasks\n- support withOutput/outputBytes on task get endpoint\n- poll running task output and fetch final output in web client\n- show bash command and terminal output in TasksPane with copy buttons

* feat(web,server): wire open-in app menu to daemon endpoint

- add /fs:open-in endpoint and command builders for vscode, cursor, finder, iterm, terminal\n- expose installed open_in_apps via meta response\n- filter OpenInMenu by available apps and remove antigravity target\n- support optional line number when opening files in apps\n- add unit tests for open-in launch commands

* feat(kimi-web): expose git diff line stats in chat header

- add additions/deletions to fs:git_status protocol and daemon response\n- compute aggregate diff stats with git diff --numstat HEAD\n- render +N/-N counter and detached HEAD label in chat header\n- update tests and stub daemon fixtures

* fix(kimi-web): hide terminal tab temporarily

* feat(kimi-web): add UI font size setting

* fix(kimi-web): repin chat after tail layout settles

* feat(kimi-web): show live subagent progress

* fix(kimi-web): align plaintext colors in dark mode

* feat(kimi-web): stream running bash output

* fix(kimi-web): avoid shiki overload on large messages

* feat(kimi-web): preselect recommended questions

* feat(kimi-web): add conversation outline nav

* fix(kimi-web): tighten mobile layouts

* feat(kimi-web): animate undo removal

* feat(kimi-web): reorganize bottom dock

* fix(web): lengthen session row running spinner arc

* feat(kimi-web): turn goal mode into a toolbar toggle

Turn the Modes menu's 'Goal' row from a dedicated input form into a
switch that arms the main composer. When goal mode is on, the composer
placeholder prompts for an objective and the next submitted prompt
is sent as a goal via updateSession({ goalObjective }).

The armed state is surfaced in the composer toolbar the same way as
Plan and Swarm: the Modes pill shows a 'Goal' tag and the menu switch
is highlighted. An active (agent-driven) goal continues to expose
Pause / Resume controls in the menu.

Also includes minor bottom-dock spacing alignment changes to keep
goal chips, workbar, and composer visually consistent.

* feat(config): add config API endpoint with redaction support

add GET/POST /api/v1/config routes\nadd ConfigService and config protocol schemas\nredact api_key in config response\nadd web client bindings and config event handling\nadd e2e tests for config routes and ws-broadcast

* fix: keep web tool calls collapsed by default

* feat(kimi-web): split dock work panel into bash, subagent, and todos tabs

- Replace the single Background tasks tab with separate Bash and
  Subagent tabs in the bottom dock work panel.
- Add i18n labels for the new dock tabs in both en and zh.
- Filter task lists by kind and reuse TasksPane for each tab.
- Align left edges of Bash/Subagent/Todos tab bodies and match
  the dock panel width/background to the Goal card style.
- Hide TasksPane header title when rendered inside the dock to
  avoid duplicate headings.
- Make the dock tab header show only the current tab name instead
  of clickable buttons.
- Hide Goal card title summary on expand while keeping layout
  alignment for status/progress/chevron.
- Update unit tests for the three-chip dock behavior.
- Add changesets for the dock split and alignment fixes.

* feat(kimi-web): beta proportional conversation outline with viewport indicator and hover tooltip

* fix(kimi-web): avoid streaming markdown placeholders

* fix(kimi-web): hide legacy conversation outline when beta TOC is off

* chore(kimi-web): rename beta settings section to Experimental / 实验性

* fix(web): scale UI font size consistently

Apply the web font size preference across readable text using the shared UI font scale, keep fixed icon glyph sizes pinned, and raise the default font size to 15px.

* fix(web): remove task tabs from tab bar

* feat(web): refresh OAuth model metadata for always-thinking models

* fix(kimi-web): use 'Sub Agent' and 'Mode' in English labels

* fix(kimi-web): keep swarm subagents across background-task refreshes

REST /tasks lists only the main agent's background-task store and never
returns foreground swarm subagents (kind 'subagent'), which arrive purely
through the WS event stream. Both the 1s output poll and the session-load
task fetch rebuilt tasksBySession from that REST list, so a plain replace
dropped the subagents on every refresh and the next event re-added them —
flickering the swarm/subagent cards, their live "currently doing" line,
and the dock "running" count about once per second.

Add keepLiveSubagents() to carry WS-owned subagent tasks across the REST
refresh (REST stays authoritative for the background tasks it does return)
and use it at both rebuild sites.

* fix(kimi-web): hide completed swarm cards from conversation bottom stack

- Filter out swarm groups whose members are all completed/failed.
- Preserve markstream-vue .table-node styles without overriding its layout.
- Add unit tests for swarm stack visibility.

* feat(session): add session-level abort and expose current_prompt_id in snapshot

- add POST /sessions/{sid}:abort to cancel running turns without prompt_id\n- expose current_prompt_id in in-flight turn snapshot\n- wire session-level abort fallback in kimi-web stop button\n- add IPromptService.abortBySession and getCurrentPromptId\n- update protocol, server, services, and web tests

* fix(server): allow aborting queued prompts and add server-e2e send/cancel coverage

add server-e2e scenario (12-send-and-cancel) and vitest cases for send prompt / cancel prompt flows, including repeated ESC idempotency\nsupport aborting queued prompts in PromptService.abort and add abortSession helper to DaemonClient/HttpClient\nhandle SSE transport case in MCP server mapping and fix related typecheck issues\nadd changeset for @moonshot-ai/services and @moonshot-ai/kimi-code

* fix(kimi-web): prevent file preview scroll jump when opening a file at a line

* fix(kimi-web): show just now for sessions created less than a minute ago

* fix(kimi-web): keep sidebar logo intact and hide product name on narrow sidebars

* fix(kimi-web): preserve markdown code gutter

* feat(web): carry message createdAt into ChatTurn

* feat(web): add formatMessageTime utility

* feat(i18n): localize yesterday label for message timestamps

* feat(web): render timestamp below user query bubble

* fix(web): move user query timestamp outside the bubble

* fix(web): place timestamp on same line as undo action

* fix(web): swap timestamp and undo positions, reveal undo text on hover

* feat(web): make timestamp a button that toggles full date time

* feat(web): update sidebar branding and enlarge session tags; clean up changesets

- Replace "Kimi Code Web" + "BETA" with "Kimi Code" + version pill

- Enlarge session pending tags to match the title font size

- Ignore internal private packages in changeset config

- Remove stale changesets that only affected ignored packages

- Document web release flow in README

* style(web): equalize timestamp and undo button heights and alignment

* feat(web): make workspace names bolder to distinguish from session titles

* feat(kimi-web): rename themes to Explore/Native and remove accent selector

* style(web): nudge undo icon up by 1px

* style(web): align both meta actions to the right

* style(web): remove gap between undo and timestamp buttons

* style(web): nudge undo icon up by another 0.5px

* feat(web): tune workspace name font-weight to 500

* fix(kimi-web): center tag/question text and limit shell-cmd height in approval card

* style(kimi-web): remove icons from session pending tags

* feat(kimi-web): limit recent workspaces in empty-composer picker

* feat(kimi-web): rename sidebar new-workspace button to new-chat and adjust empty conversation title

* style(kimi-web): refine sidebar typography, spacing and font settings

* style(kimi-web): unify Composer typography with sidebar

- Use --ui-font-size for queue text and --ui-font-size-xs for queue labels/bubbles.

- Remove mono font-family from composer queue/bubble elements.

- Normalize perm/mode/model pill text color to --text.

- Set placeholder color to --muted.

* style(kimi-web): keep model pill color dimmed

Revert the model selector pill text color from --text back to --dim

so it stays visually secondary, matching the original design intent.

* style(kimi-web): adjust ChatHeader git status spacing and badge layout

* style(kimi-web): polish composer dock chip styles

* feat(kimi-web): refresh session list relative times on a 30s clock

* fix(kimi-web): decode base64 file content in the preview pane

* feat(kimi-web): show per-session answer/approve tags in the sidebar

* feat(kimi-web): pop the KAP debug panel out into a separate window

* feat(kimi-web): open subagent detail in the side panel; inline agent-live

* style(kimi-web): align DiffView focus outline with KMBlue

* feat(kimi-web): surface goal protocol errors; ignore global config-changed events

* feat(kimi-web): support video attachments in user messages end-to-end

* feat(kimi-web): add /swarm and /goal slash commands

* feat(kimi-web): add /btw side chat backed by child sessions

* style(kimi-web): pin user message bubble font size to 15px

* style(kimi-web): use Lucide PR icon and text-only git status in header

* fix(kimi-web): update undo tooltip copy and reduce hover delay

* fix(kimi-web): auto-scroll to bottom on send, session switch, and tab switch

- Scroll side-chat panel to bottom after sending and while streaming
- Reset scroll baseline on session switch to avoid stale lastScrollTop
- Reset scroll baseline when returning from files tab to chat
- Reset scroll baseline after user sends a message
- Include @moonshot-ai/kimi-code so CLI rebuilds bundle the updated web app

* fix(kimi-web): drop duplicate config-changed case shadowing the real handler

A stopgap no-op `case 'event.config.changed'` (added before the config
feature landed) ended up earlier in the switch than the real configChanged
mapper after merging origin/feat/web, silently swallowing config events.
Remove the no-op so the proper handler runs.

* style(kimi-web): unify PR badge with git status pills and drop changes count

* style(kimi-web): remove unused changes computed in ChatHeader

* fix: keep packaged web build in sync

Build kimi-web before copying packaged web assets and surface the build version plus short commit in the settings dialog.

* fix(web): support slash command input tails

* fix(web): scope composer dock to chat tab

* fix(web): route btw through side-channel agents

* feat(web): open changed files from git status

* feat(web): copy final assistant summary

* feat(web): add tabbed model picker

* fix(web): keep composer input height fixed

* fix(web): preview composer attachments

* feat(web): open workspace links in files tab

* fix(web): stabilize subagent progress

* docs(web): design tab split workflow

* feat(web): connect daemon config settings

* fix: resolve CI failures on feat/web

- add missing 'event.config.changed' case in exhaustive switch test\n- fix oxlint errors (unused import, string spread, unsafe stringification)\n- update protocol test fixtures for additions/deletions, open_in_apps, swarm_mode\n- fix services mcp transport switch exhaustiveness for sse\n- update nix pnpm deps hash

* fix(server): suppress debug logs by default

- route BridgeClientAPI and PromptService debug logs through ILogService instead of console.error\n- lower SessionClientsService debug logs from info to debug\n- add changeset for @moonshot-ai/services, @moonshot-ai/server and @moonshot-ai/kimi-code

* fix(kimi-web): remove model picker top blue bar and widen dialog

- Remove the inset blue box-shadow from the model picker header.

- Increase the default dialog width from 620px to 760px.

* fix(kimi-web): replace model picker checkmark with icon

- Swap the textual checkmark for a proper SVG check icon in ModelPicker,

  matching the icon used in the composer model dropdown.

* fix(kimi-web): hide the Open in app menu

- Remove OpenInMenu usage from ChatHeader and the prop/event plumbing

  through ConversationPane and App.

- Remove the now-obsolete test case in files-tab-no-git.test.ts.

* feat(kimi-web): scope composer dock to chat tab and polish BTW side chat

- Move the composer dock into the chat tab only so it no longer appears in
  split file, task, preview, or BTW panes.
- Render the BTW side chat as a split side pane scoped to the active session,
  and keep its messages out of the main conversation transcript.
- Remove the side-chat panel header, relabel the tab to Side chat / 侧边聊天,
  and use the shared moon spinner while waiting for the first token.
- Suppress the generic Started a step progress text for side-channel agents.

* feat(server): expose live session status via HTTP and WebSocket

- add status field to session status response schema and event.session.status_changed\n- compute session lifecycle status in SessionService from approvals, questions, prompts, and turns\n- broadcast event.session.status_changed globally to all WebSocket connections\n- re-export new event types from agent-core\n- add e2e and unit tests for status computation and broadcasting

* fix(kimi-web): label sidebar session removal as Archive

* feat(kimi-web): make tab/split chrome accessible

TabBar is now a real ARIA tablist: role=tab buttons with aria-selected,
roving tabindex, Left/Right/Home/End keyboard nav, focus-visible styling,
and aria-controls wired to each ViewGroup's tabpanel (role=tabpanel +
aria-labelledby).

ViewGroup split-right/split-down/close buttons get localized aria-labels
(no longer English title-only) and a 28x28 hit area.

* fix(kimi-web): move auth banner into layout flow

The onboarding/auth banner was position:fixed over the top of the
conversation column, covering the desktop ChatHeader and the mobile
top bar. Wrap the app grid in a flex-column shell and render the banner
as the shell's first in-flow child so it reserves its own height above
both the sidebar/header and the mobile top bar instead of overlapping
navigation.

* fix(kimi-web): enlarge and label header/sidebar icon buttons

Unify icon-button hit areas and keyboard affordances:
- ChatHeader kebab: 28x28 target, aria-label + aria-expanded/haspopup,
  focus-visible ring.
- Sidebar workspace kebab (.gh-more): 24x24 target, aria-label, stays
  visible on keyboard focus (it was hover-only), focus ring.
- Sidebar per-workspace add (.gh-add): aria-label + larger tap target.
- Focus rings on the settings button and new-chat/new-workspace buttons.

* feat(kimi-web): give overlay dialogs modal focus management

Add useDialogFocus(): records the opener, moves focus into the dialog on
open, and restores focus to the opener on close. Wire it plus
aria-modal="true" / tabindex="-1" into ModelPicker, LoginDialog and
ProviderManager (Escape-to-close was already present). ModelPicker keeps
focusing its search box on open. Covered by a model-picker focus test.

SettingsDialog is intentionally left for a follow-up to avoid colliding
with in-flight settings work.

* feat(kimi-web): consistent rules for the right-side detail layer

The transient detail panels (thinking, compaction summary, subagent
detail, mobile file/media preview) now share one set of rules:
- the aside is a labelled role=complementary region (aria-hidden when
  collapsed),
- Escape closes whichever panel is open — handled in App on the capture
  phase so it takes precedence over the conversation's "Esc interrupts a
  run" handler instead of firing both,
- every close button has an aria-label (not just a title) and a
  focus-visible ring; thinking/subagent close targets bumped to 28x28,
- FilePreview toolbar buttons get focus-visible rings too.

* fix(kimi-web): calm the diff line colors

Added/removed diff lines washed the entire row in green/red (12% tint +
fully coloured text), which competed with reading the code. Drop the
background to a faint 7% tint plus a left accent bar, color only the +/-
sign, and let the code text keep the normal ink color so the content —
not the color wash — is what stands out.

* docs(web): record tab/split convergence + UI audit follow-through

Implementation note for the tab/split work: the final three-layer view
model (persistent chat/files tabs, transient preview/btw tabs, right-side
detail layer), the transient-view routing rules, a per-task status table,
which audit suggestions were absorbed vs intentionally skipped, and why
the SettingsDialog focus item is deferred (concurrent in-flight rewrite).

* feat(kimi-web): add side-tab navigation to SettingsDialog

* feat(session): persist session archive state and add include_archive list filter

- Replace deleteSession with archiveSession RPC and REST endpoint\n- Persist archived flag in session state and filter archived sessions by default\n- Add optional include_archive query parameter to list archived sessions\n- Expose archived flag on session responses through protocol and web types\n- Rename web session delete events/handlers to archive

* feat(kimi-web): give SettingsDialog modal focus management

Completes the dialog-focus baseline (task 8): wire useDialogFocus +
aria-modal/tabindex into SettingsDialog now that the side-tab rewrite has
landed. Focus moves into the dialog on open and returns to the opener on
close; covered by a settings-dialog focus test.

* refactor(workspace): centralize registry into a single workspaces.json

- replace per-bucket workspace.json files with one workspaces.json registry\n- serialize registry reads/writes through an opQueue to avoid races\n- delete now removes only the registry entry, leaving the session bucket intact\n- update workspace e2e tests and scenario for the new storage model

* feat(workspace): add workspace lifecycle WS events

- publish event.workspace.created/updated/deleted from the registry service\n- broadcast them to every connection via the __global__ watermark\n- add protocol types/schemas and frontend mapping for real-time workspace sync\n- cover with protocol, broadcast, and exhaustive-switch tests

* feat(fs): add fs:mkdir action for creating directories

- add fsMkdir request/response schemas and FS_ALREADY_EXISTS (40919) error code
- implement IFsService.mkdir guarded by resolveSafePath, returning the created directory entry
- register the mkdir action in the fs route dispatcher with EEXIST/ENOENT mapping
- add protocol schema tests and server e2e coverage

* style(kimi-web): set fixed heights for all modal dialogs

* feat(kimi-web): add collapsible sidebar

* refactor(web): temporarily hide new workspace button in sidebar

* feat(kimi-web): add starred models support to model picker and composer dropdown

- Persist starred model ids in localStorage via useKimiWebClient.

- Pin starred models to the top of the All tab in ModelPicker.

- Show a Starred section in the Composer quick-switch dropdown, including models from other providers.

- Render a star glyph on each starred model row in both pickers.

- Add --star CSS variable with a brighter yellow across themes.

- Add tests for starred model ordering and Composer dropdown rendering.

* test(kimi-web): cover chat dock composer alignment

* feat(server): expose GitHub pull request in git status and web header

Add a pullRequest field to the session fs:git_status response, looked up via gh pr view with a 5s timeout, GH_NO_UPDATE_NOTIFIER/GH_PROMPT_DISABLED, and a 60s per-cwd cache.\nNormalize gh state to open/merged/closed and fail soft to null so git status never breaks.\nWire the web chat header PR badge to the active session.

* feat(kimi-web): surface 5-state session status with a separate busy flag

The session view-model collapsed every lifecycle state to running|idle,
so awaiting-input and aborted sessions were indistinguishable and the
spinner span while a session was actually waiting on the user.

Session now carries the real `status` (idle/running/awaitingApproval/
awaitingQuestion/aborted) plus a separate `busy` flag (running + a real
task in flight). SessionRow spins only when busy, shows awaiting tags
from status as a fallback for background sessions, and a distinct aborted
tag; SessionsDialog and MobileSwitcherSheet distinguish the states too.

(The producers in useKimiWebClient already landed via an earlier commit;
this adds the Session type, the UI, and labels so the tree type-checks.)

* fix(kimi-web): persist unread dots across a page reload

unreadBySession was pure in-memory state seeded empty on every load, with
no localStorage persistence and no server-side read cursor — so a browser
refresh dropped every sidebar unread dot. Persist the `true` entries to
localStorage (compact: only unread sessions are stored) and seed the map
from storage on init; opening a session clears the flag and the stored
entry. Covered by a reload test in the session-cache suite.

Note: also carries pre-existing empty-session-flash test edits that were
already part of this file's working state.

* style(kimi-web): redraw collapse/expand sidebar icons

Use an indent-style glyph: three lines with a directional chevron, mirrored between the collapse (left) and expand (right) states.

* feat: add server-hosted web UI and document its packages

- wire kimi-web into root and CI typecheck (vue-tsc) and refresh the Nix pnpm hash
- consolidate per-feature changesets into a single server-hosted-web-ui entry
- make agentEventProjector.shortJson resilient to stringify failures and adjust tests
- add AGENTS.md for kimi-web and server; add READMEs for server and services
- expand root AGENTS.md project map and clarify the flake.nix workspace-sync rule

* test(kimi-web): cover empty-session flash + draft-send paths

Tests and changeset for the empty-session-flash fix (the sessionsKnownEmpty
/ sessionLoading logic itself already landed in an earlier commit):
selecting a locally-created session shows the empty composer with no
loading flash, an existing session reported as empty still loads its
snapshot (messageCount is not trusted), and sending straight from the
draft composer does not flash the empty state.

* chore: ignore generated docs and reports

* style(apps/kimi-web): remove app shell top border

* fix(build): build kimi-web assets before native SEA build

The native SEA build embeds the Kimi web SPA from apps/kimi-code/dist-web (see scripts/native/02-sea-blob.mjs) and fails when that directory is missing. Build kimi-web and stage its assets via copy-web-assets.mjs before running build:native:sea.

- flake.nix: add the web build + asset copy to buildPhase so `nix build` works.
- _native-build.yml: add the same prep step before the SEA build so CI release / manual native bundles keep working.

Fixes "Kimi web build output was not found at .../dist-web" in the nix build and the CI native build stage.

* feat(web): hide context indicator on empty session composer

* feat(kimi-web): unify detail panel layout

* fix(web): improve dark toc tooltip contrast

* fix(web): keep markdown code blocks mounted

* fix(web): tighten dark color contrast

* fix(web): close dock cards on outside click

* feat(web): show session content summaries

* fix(web): report web client telemetry

* refactor(services): merge @moonshot-ai/services into agent-core

- move services/src/** into agent-core/src/services/** and delete the standalone @moonshot-ai/services package
- re-export service contracts/implementations from agent-core src/index.ts
- update all server, test, and kimi-web imports to @moonshot-ai/agent-core
- enable experimentalDecorators in tsconfig and adjust dev/build configs
- sync workspace registry (changeset config, flake.nix, pnpm-lock)

* refactor(server): remove Swagger UI and --swagger flag

- drop @fastify/swagger-ui and the dev-only --swagger option
- keep /openapi.json via @fastify/swagger (bundled in the SEA)
- simplify the native SEA build (no swagger-ui external or asset copy)
- update tests, docs, and lockfile

* feat(server): add on-demand daemon for kimi web with idle shutdown

- make kimi web non-blocking by spawning or reusing a single detached daemon per device (via ~/.kimi-code/server/lock), auto-picking a free port on conflict
- daemon self-exits after a 1-minute grace once the last web WebSocket client disconnects (new onConnectionCountChange hook + createIdleShutdownHandler)
- add getLiveLock helper for daemon discovery
- hide kimi server install/uninstall/start/stop/restart/status (service-ization) for now; implementation preserved for later re-exposure

* fix(web): improve mobile dialog layouts

* fix(ci): resolve lint, typecheck, and nix build failures

- add startBtw to IPromptService test mocks (agent-core, server)
- remove useless spread in PromptService session cleanup
- add assertion to concurrent-connection WS handshake test
- bind idle onConnectionCountChange callback in server run
- type getSessionSnapshot mock via vi.mocked in kimi-web test
- update pnpmDeps hash in flake.nix

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(web): distinguish native markdown links

* fix(vis-web): enable experimentalDecorators for typecheck

vis-web type-checks agent-core source (via source exports), whose services use legacy parameter decorators for DI. Without experimentalDecorators, tsc reports TS1206 "Decorators are not valid here" and the CI typecheck job fails.

* Revert "feat(web): show session content summaries"

This reverts commit 8de58eacbc.

* fix(web): align active toc-bubble highlight with bubble edges

* fix(web): hide conversation toc when chat pane is too narrow

* feat(server): add `kimi server ps` to list active clients

- add GET /api/v1/connections endpoint backed by IConnectionRegistry
- record connection metadata (connectedAt, remoteAddress, userAgent) on WsConnection
- add connection wire schema in @moonshot-ai/protocol
- add kimi server ps CLI command with table and --json output

* feat(kimi-web): add queue chip to chat dock workbar

* fix(agent-core): hide console window when spawning git/gh on Windows

On Windows, spawning a console-subsystem executable (gh.exe / git.exe)
from the background Kimi server creates a visible console window that
flashes on screen. Set windowsHide: true (a no-op on POSIX) to suppress it.

* feat(server): add startup and ws connection telemetry for kimi web

- bootstrap telemetry in `kimi web`/`kimi server run` via initializeServerTelemetry (ui_mode=web, honors telemetry=false)
- wire the real client into KimiCore so agent-core events carry the enriched context
- emit server_started after the server listens; flush telemetry on shutdown
- emit ws_connected/ws_disconnected from WSGateway via WSGatewayOptions.telemetry
- re-export loadRuntimeConfigSafe/resolveConfigPath from the SDK for host config reads

* feat(web): dynamic page title based on session or workspace

* fix(web): show recently active sessions at the top of the web session list

* feat(web): show running indicator in dynamic page title

* feat(kimi-web): show elapsed time for completed assistant turns

* fix(kimi-web): resolve TDZ error on App mount

* refactor(kimi-web): align turn duration and support multi-tab timing

* feat: display per-turn wall-clock duration in web chat

* feat(web): use animated spinner in page title while running

* feat(server): add kill command and background server run

- add `kimi server kill` to stop the running daemon (graceful API + forced PID kill)
- add `POST /api/v1/shutdown` so the server can terminate itself
- make `kimi server run` start in the background and print the ready banner
- route `kimi web` through the same path as `server run` so it prints the banner too

* fix(server): remove duplicate startBtw key in prompt e2e test

Resolves eslint no-dupe-keys and TS1117 errors that broke the lint and typecheck CI jobs.

* chore: bundle Inter font locally

* fix(nix): update pnpmDeps hash for new font dependency

The local Inter font dependency changed pnpm-lock.yaml, so refresh the fixed-output derivation hash to match what the nix builder computes. Resolves the nix build .#kimi-code CI failure.

* fix(server): restore web client telemetry and stabilize skills cleanup

Restore forwarding of x-kimi-client-* headers into session creation telemetry, which was dropped during the services-to-agent-core merge and left the new-session telemetry test with empty records.\n\nRetry the skills e2e sandbox cleanup to ride out ENOTEMPTY races when the core process flushes files into the sandboxed home after close().

* chore: remove trailing blank lines

* chore(changeset): remove consumed changeset files

These 20 changeset files were applied during the version bump and are no longer needed.

* chore: clean up web release changesets

* docs: clean up kimi web readme

* chore: scope package lint to cli release

* chore: remove temporary design docs and preview files from PR

Remove files that were not intended for submission:
- docs HTML research/design archives
- docs/superpowers specs added during design phase
- apps/kimi-web icon preview pages and one-off test script

* chore(kimi-web): remove dev stub daemon

The real server package is now available; the throwaway stub daemon
is no longer needed for development.

* test(server-e2e): accept aborted image prompt scenario

* chore: update flake.nix workspace paths and add new changesets

- Added new packages: daemon, server-e2e, and kimi-migration-legacy to the workspacePaths in flake.nix.
- Introduced new changeset for "@moonshot-ai/kimi-code-sdk" to add host-side config helpers.
- Removed outdated changesets related to server-hosted web UI and server web APIs.

* feat(server): daemonize by default and fall back to port +1

- kimi server run now spawns a background daemon by default; --foreground keeps the terminal attached
- default server port moves from 7878 to 58627 across CLI, web, e2e, and docs
- listenWithPortRetry retries on port + 1 when a third party holds the port (capped at 100)
- lock gains updatePort so status/kill/ps find the daemon on its real bound port

* fix(cli): resolve oxlint unbound-method errors in server run

---------

Signed-off-by: qer <wbxl2000@outlook.com>
Co-authored-by: qer <wbxl2000@outlook.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-17 20:53:46 +08:00
7Sageer
254f946a50
fix: skip debug TPS on short streams 2026-06-17 20:32:22 +08:00
7Sageer
53cab3683f
refactor(agent-core): rename micro compaction telemetry event to micro_compaction_finished (#846)
* feat(agent-core): record context length before and after micro compaction

* test(agent-core): assert context length fields in micro compaction telemetry

* chore: drop changeset for telemetry-only change

* refactor(agent-core): rename micro compaction telemetry event to micro_compaction_finished

* fix(agent-core): align micro compaction context telemetry

* chore: drop changeset for telemetry-only change

* refactor(agent-core): align micro compaction token telemetry fields
2026-06-17 20:31:55 +08:00
7Sageer
843a731097
fix: classify OAuth token refresh errors by cause (#838)
Map OAuth token-fetch failures to distinct public error codes instead of collapsing them all to auth.login_required:

- missing/revoked tokens or 401/403 from the refresh endpoint -> auth.login_required
- transport failures and 429/5xx after internal retries -> provider.connection_error
- anything else is rethrown as-is (surfaces as internal) rather than guessed

The refresh helper already retries internally, so the agent loop does not
re-retry these. Both the managed provider and the standalone SDK provider
share the same mapping.
2026-06-17 18:34:42 +08:00
Luyu Cheng
a71b2e3123
fix(agent-core): restore turn counter from loop events on resume (#833)
* fix(agent-core): restore turn counter from loop events on resume

On cold-start resume the turn counter was rebuilt by counting `turn.prompt`
records, but `restorePrompt()` set `activeTurn = 'resuming'` on the first
record and then early-returned for the rest, so only one historical turn was
counted regardless of how many actually ran. After resume the next prompt
reused turn ids already present in history, producing duplicate raw turnIds
across the session (confirmed in real `wire.jsonl` sessions: e.g. history
turns 0-4 followed by post-resume turns 1-5).

Derive the restored counter from the persisted loop events instead: every
turn that ran — prompted, goal continuation, or steer-launched — emits loop
events carrying its real turnId, even though only prompted turns write a
`turn.prompt` record. `observeRestoredTurnId()` raises the counter to the max
turnId seen during replay so resume continues from `max + 1`. It only ever
raises the counter, runs solely on the resume path, and adds O(1) work per
already-iterated record. This also self-heals already-damaged sessions, which
resume past the highest id ever recorded.

Add resume tests covering multiple prompted turns, goal-continuation turns
with no `turn.prompt` record, and repeated resume cycles.

* chore: add changeset for resume turn counter fix
2026-06-17 18:24:14 +08:00
_Kerman
65423c673b
fix: retry think-only compaction summaries with a smaller prefix (#836)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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 / Native release artifact (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
2026-06-17 15:17:15 +08:00
wenhua020201-arch
8ac274369c
docs: document the kimi vis command (#827)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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 / Native release artifact (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
2026-06-16 22:49:40 +08:00
7Sageer
66b4d658a0
fix: dispose process stdio after managed commands (#822)
* fix: dispose process stdio after managed commands

* refactor: share stream close detection
2026-06-16 22:09:11 +08:00
qer
cf6fadba41
docs(changelog): sync 0.16.0 changelog to docs site (#825)
* docs(changelog): sync 0.16.0 from apps/kimi-code/CHANGELOG.md

* docs(changelog): move banner display frequency entry to Polish
2026-06-16 22:04:33 +08:00
github-actions[bot]
efda4387d5
ci: release packages (#803)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-16 21:23:58 +08:00
_Kerman
90fc04b707
refactor: simplify LLM request logging (#823) 2026-06-16 21:13:31 +08:00
Kai
efdf8a1b2d
feat(vis): faithful wire.jsonl rendering + built-in kimi vis command (#788)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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 / 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 / Release (push) Waiting to run
* feat: polish vis

* feat: add 'kimi vis' command for session visualization

* fix(vis): drop metadata app_version/resumed removed upstream

#786 stopped recording resume version metadata, so those fields no
longer exist on the metadata wire record. The vis-into-typecheck wiring
caught the stale field reads after merging main; drop them from the
metadata headline.

* fix(vis): drop unnecessary return-await in startVisServer

oxlint typescript-eslint(return-await) flags returning an awaited
promise outside try/catch; return the promise directly.

* fix(vis): green CI — tolerate unbuilt embedded asset + bump nix pnpmDeps hash

- handleVis: wrap the embedded-SPA dynamic import in try/catch. The value
  module is generated at build time (prebuild); in contexts without a build
  (tests run pnpm test, not build) only the .d.ts type stub exists, so the
  runtime import throws. Tolerate it and fall back to filesystem serving.
- flake.nix: update the fetchPnpmDeps hash after adding the vis-web /
  vis-server / vite-plugin-singlefile dependencies.

* refactor(vis): drop redundant alwaysBundle in tsdown config

#775's single-entry build (codeSplitting: false) already bundles
everything not declared in dependencies/peerDependencies. hono /
@hono/node-server (transitive via vis-server) and @moonshot-ai/vis-server
(a devDependency) are all undeclared there, so they bundle by default —
the explicit alwaysBundle was redundant. Verified the emitted main.mjs is
still fully self-contained and 'kimi vis' serves.

* fix(vis): address review — context-token resets, IPv6 url, marker-safe indexing

- contextTokens now mirrors agent-core on lifecycle records: 0 on
  context.clear, tokensAfter on context.apply_compaction (was only
  updated from step.end.usage, leaving a stale live fill after a
  clear/compaction).
- start.ts brackets IPv6 hosts in the returned url (http://[::1]:port/);
  hostForUrl moved to config.ts and shared with the startup banner.
- compaction slice + micro-compaction blanking now index over real
  history entries only, so synthetic undo/clear UI markers no longer
  offset agent-core's compactedCount / cutoff.

* chore: add changeset for kimi vis command

* fix(vis): cross-platform single-file build + history-count micro clamp

- build-vis-asset.mjs sets VIS_SINGLEFILE via the spawn env and runs
  'vite build' directly (cross-platform), instead of the POSIX-inline-env
  'build:single' script that broke on Windows cmd; removed the now-unused
  build:single script. Fixes the win32 build path (the asset generator
  runs in the kimi-code prebuild + native bundle).
- context.undo now clamps the micro-compaction cutoff by history-entry
  count (excluding synthetic undo/clear markers) instead of messages.length,
  mirroring agent-core undo() -> microCompaction.reset(_history.length); a
  surviving marker no longer leaves the cutoff one too high and wrongly
  blanks a later-appended tool result.

* fix(vis): run the single-file build through a shell for Windows pnpm

The win32 native binary is built on Windows runners
(.github/workflows/_native-build.yml), which run this generator. pnpm's
launcher there is pnpm.cmd, which a bare argv exec can't resolve without
a shell. Use execSync with a single command string so the platform shell
(cmd on Windows) resolves the shim; a command string (not an args array)
avoids the args+shell deprecation. Args are static.

* fix(vis): show model-facing tool result content in the context view

agent-core normalizes tool results via toolResultOutputForModel before
they enter history (error -> '<system>ERROR: ...' prefix, empty ->
'<system>Tool output is empty.' sentinel). The projector was using the
raw ev.result.output, so the Context tab's model view showed content the
model never saw for failed/empty tool calls. Replicate that normalization
(the upstream helper is module-private) so the projected tool message
matches what the model received.
2026-06-16 16:54:14 +08:00
_Kerman
7b5b818815
fix: continue compaction while context remains blocked (#813) 2026-06-16 16:48:27 +08:00
_Kerman
3e6196e6b2
fix: build replay ranges from replay records (#805) 2026-06-16 15:20:23 +08:00
liruifengv
6f442bd8cd
feat(kimi-code): support banner display frequency (#809)
* feat(kimi-code): support banner display frequency

* chore: change banner changeset to patch
2026-06-16 14:58:58 +08:00
7Sageer
36e06152e4
docs: simplify anthropic auth changeset (#810) 2026-06-16 14:34:24 +08:00
7Sageer
d0d5821900
fix(kosong): isolate anthropic auth environment (#790)
* fix(kosong): isolate anthropic auth environment

* fix(kosong): close remaining anthropic env fallbacks

Pass explicit nulls into the Anthropic SDK for unused auth/base URL overrides, keep adapter-owned auth headers authoritative, and add regression coverage for Anthropic shell env leakage.

* fix(kosong): block anthropic custom header env leakage

* docs(kosong): explain anthropic env-isolation intent + migration

Spell out that the SDK is used as a transport to arbitrary endpoints, so disabling its shell-env auto-discovery is the fix: the authToken/baseURL/header nulls are load-bearing, not redundant. Also document the behavior change (shell ANTHROPIC_* no longer read; use provider config) in the changeset. No logic change.
2026-06-16 14:30:49 +08:00
7Sageer
b45672cdaa
fix(kaos): destroy buffered readable sources (#807) 2026-06-16 13:07:33 +08:00
liruifengv
ff332be6d3
fix(tui): add top border to queue pane to separate it from todo panel (#801) 2026-06-16 12:19:52 +08:00
7Sageer
299b9fcad4
fix(agent-core): suppress close-time background notifications (#804) 2026-06-16 11:34:44 +08:00
liruifengv
aa1896ca74
fix(kimi-code): reduce /btw panel max height to one-third of terminal (#802) 2026-06-16 11:12:20 +08:00
liruifengv
c48e823f61
docs(changelog): sync 0.15.0 release notes into docs site (#793)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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 / Native release artifact (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
* docs(changelog): sync 0.15.0 from apps/kimi-code/CHANGELOG.md

* docs(changelog): improve Chinese translation for 0.15.0 tool-call status entry

* docs(changelog): polish Chinese wording for 0.15.0 entries
2026-06-15 23:09:23 +08:00
github-actions[bot]
18aa21575b
ci: release packages (#746)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-15 22:50:45 +08:00
Luyu Cheng
4578f05f44
fix: surface skill directory in the loaded-skill context block (#785) 2026-06-15 22:29:00 +08:00
liruifengv
2746c71c47
feat: add all-sessions picker with name search (#779)
* feat: add all-sessions picker with name search

* fix: satisfy clipboard lint errors

* test: make clipboard fallback assertion portable

* fix: harden session picker interactions

* fix: harden session picker startup flow

Invalidate pending session picker scope changes after dismissal and exit startup picker after showing a cross-directory resume command.

* fix: show empty session scope hint

Render the all-sessions toggle hint in the sessions picker empty state so users can discover Ctrl+A when the current directory has no sessions.
2026-06-15 21:31:30 +08:00
Kai
1eb363f655
feat(agent-core): prompt same-language reasoning (#787) 2026-06-15 21:20:50 +08:00
liruifengv
e2a407ce31
fix: prevent tui width overflows (#783)
* fix: prevent tui width overflows

* fix: address tui review feedback
2026-06-15 21:11:01 +08:00
_Kerman
e10b25f9be
fix: stop recording resume version metadata (#786) 2026-06-15 21:04:53 +08:00
youngxhui
73be7ba17d
fix(kosong): repair mismatched schema types from Xcode 26.5 MCP (#343)
* fix(kosong): repair mismatched schema types from Xcode 26.5 MCP

Xcode 26.5 (17F42) mcpbridge generates contradictory JSON Schemas where
String-backed Swift enums carry type: 'object' alongside string enum values.
Moonshot rejects these as invalid. Detect and repair the mismatch in
normalizeKimiToolSchema, stripping irrelevant structure keys after the fix.

Closes #302

* fix(kosong): avoid dumping full tool schemas on schema-related 400 errors

* fix(kosong): redact enum and const values in schema repair diagnostics

* fix(kosong): keep schema repair quiet

---------

Co-authored-by: 7Sageer <7sageer@djwcb.cn>
2026-06-15 20:47:15 +08:00
_Kerman
a562ef54e5
refactor: decouple agent skill registry (#784) 2026-06-15 20:39:29 +08:00
liruifengv
3fa1b8ea7d
fix(kimi-code): bundle npm package into single entry (#775)
* fix(kimi-code): bundle npm package into single entry

Bundle runtime dependencies into the CLI npm entry file, prepare an npm publish directory without bundled dependencies, and keep clipboard support optional.

* fix(kimi-code): sync npm publish directory lockfile

Update pnpm-lock.yaml for apps/kimi-code publishConfig.directory so CI can install with --frozen-lockfile.

* fix(kimi-code): disable cli declaration output

Stop generating the TypeScript declaration sidecar so the npm package contains only the main.mjs CLI entry artifact.

* fix(kimi-code): move bundled deps to devDependencies

Remove the npm publish directory pre-processing path, keep runtime dependencies as dev-only build inputs for the single-file bundle, and add a manifest regression test.

* fix(kimi-code): make koffi optional

Keep koffi available as an optional native dependency without listing bundled runtime packages as published dependencies.

* fix(kimi-code): use explicit bundle allowlist

Avoid treating every devDependency as a runtime bundle input and list the bundled third-party dependencies explicitly.

* fix(kimi-code): remove manifest regression test

Keep the packaging change focused on the manifest and bundler configuration without an extra package-json regression test.

* fix(kimi-code): only externalize optional deps

Keep tsdown's never-bundle list limited to published optional native dependencies.

* Update tsdown.config.ts

* fix(kimi-code): simplify changeset

Keep the changeset summary focused on optimizing the npm packaging system.
2026-06-15 20:23:13 +08:00
Kai
8a92db6a0c
feat: polish system prompt context (#780)
* feat(agent-core): collapse hidden dirs in prompt

Keep hidden directory entries visible in the cwd snapshot while omitting their contents to reduce prompt noise, and document the tools models should use to inspect hidden paths.

* feat(agent-core): clarify prompt AGENTS context

Add a marker when AGENTS.md content is truncated by the prompt budget and move dynamic system prompt context after the static guidance.

* chore: add prompt refinement changesets

* fix: prompt brief tool-call status

Ask the model to emit one short same-language status sentence before non-trivial tool calls while keeping detailed reasoning out of the visible transcript. Updates snapshots for the longer default prompt.
2026-06-15 19:12:07 +08:00
_Kerman
c6a996756c
fix: close interrupted tool calls on resume (#768) 2026-06-15 19:02:17 +08:00
Kai
4516f62f6a
feat(agent-core): refine system prompt context (#777)
* feat(agent-core): collapse hidden dirs in prompt

Keep hidden directory entries visible in the cwd snapshot while omitting their contents to reduce prompt noise, and document the tools models should use to inspect hidden paths.

* feat(agent-core): clarify prompt AGENTS context

Add a marker when AGENTS.md content is truncated by the prompt budget and move dynamic system prompt context after the static guidance.

* chore: add prompt refinement changesets
2026-06-15 18:38:42 +08:00
Kai
d47e699015
fix: skip legacy max step limit during migration (#772)
* fix: skip legacy max step limit during migration

* fix: skip more obsolete legacy migration fields
2026-06-15 18:02:04 +08:00
7Sageer
ecd7a0afb6
refactor: resolve model capabilities via a static table lookup (#776)
Replace the per-provider getCapability instance method, and the throwaway
provider instantiation used only for capability probing, with a static
getModelCapability(wire, model) entry point in kosong. The same
capability-registry tables are consulted, so resolution stays behaviorally
identical; it no longer constructs a temporary provider or forges an API key.

Also drop the unused getContextSizeLimit interface method.
2026-06-15 17:58:08 +08:00
liruifengv
5306fd70c5
feat(update): roll out automatic updates in staged batches via CDN manifest (#691)
* feat(update): roll out automatic updates in staged batches via CDN manifest

* chore: remove changeset

* refactor(update): single-source the CDN latest file names

* refactor(update): reuse the CDN latest URL constants in update checks

* refactor(update): drop the test-only CDN base override

* fix: preserve first launch attribution

* fix: use refreshed rollout manifest for telemetry

* fix: abort hung cdn update checks

* fix: preserve update cache with bad manifest

* chore: remove review report from branch
2026-06-15 16:52:44 +08:00
liruifengv
a355f2af2f
fix: prioritize clearing editor before cancelling stream (#767)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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
2026-06-15 14:36:17 +08:00
liruifengv
e0c6508ed1
docs(changelog): sync 0.14.3 from apps/kimi-code/CHANGELOG.md (#769) 2026-06-15 14:31:50 +08:00
_Kerman
0ab72d7d19
test: update compaction usage snapshots (#770) 2026-06-15 13:50:06 +08:00
_Kerman
046856b740
fix: prefer media headers when reading media files (#765) 2026-06-15 12:09:05 +08:00
_Kerman
9cef896563
fix: clarify compaction summary output target (#766) 2026-06-15 12:06:56 +08:00
oocz
18f299fd0b
mcp suport sse (#744)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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 / Publish native release assets (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Co-authored-by: yuchengzhen <yuchengzhen@moonshot.cn>
2026-06-14 18:04:26 +08:00
github-actions[bot]
93928066dc
ci: release packages (#717)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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 / Publish native release assets (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-14 09:12:54 +08:00
Haozhe
f874251288
feat(kimi-code): refresh OAuth provider models before opening model picker (#713)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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
- call refreshOAuthProviderModels before /model picker opens
- add scoped refreshAllProviderModels with 'oauth' and 'all' scopes
- update tests for async picker rendering and OAuth-only refresh
2026-06-13 20:55:35 +08:00
liruifengv
1c65cbf6c3
docs(changelog): sync 0.14.2 from apps/kimi-code/CHANGELOG.md (#698)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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
2026-06-12 21:42:51 +08:00
github-actions[bot]
1cb49dba5b
ci: release packages (#678)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-12 17:43:06 +08:00
liruifengv
7ca9bdfed5
fix(tui): skip re-entering plan mode on resume and scope startup flags to startup (#692)
Resuming a session that was already in plan mode with --plan crashed with
"Already in plan mode": the resume path called setPlanMode(true)
unconditionally while session replay had already restored the active plan
state. Check the session status first and only enable plan mode when it is
not active yet, in both the resume startup path and the startup session
picker.

The /sessions picker shared the same onSelect callback, so startup flags
were also re-applied on every mid-session switch, overriding the picked
session's own persisted modes. Gate the flag application behind an
applyStartupModes option that only the startup picker enables, and surface
post-switch setup errors instead of leaving them as unhandled rejections.
2026-06-12 17:41:25 +08:00
Haozhe
d1ba14562b
feat(providers): sync custom registry providers on startup refresh (#675)
* feat(providers): sync custom registry providers on startup refresh

- group registry providers by URL and retry available API keys\n- automatically add new providers and remove disappeared ones\n- coalesce duplicate source URLs to avoid false config-change reports\n- clear defaultThinking when default model is removed\n- update docs and add tests for registry sync scenarios

* fix(tui): only show provider refresh status for added models

 Skip removed / metadata-only provider updates when reporting model list changes.\n\n add: test to enforce the behavior.
2026-06-12 17:08:40 +08:00
liruifengv
7f0dde2ece
fix(tui): gate terminal progress sequences behind OSC 9;4 support (#690)
iTerm2 interprets any OSC 9 payload as a desktop notification, so the
ConEmu-style 9;4 progress sequence (re-sent every second by the progress
keepalive) flooded users with notifications. Only emit progress on
terminals known to implement OSC 9;4: Windows Terminal, ConEmu, Ghostty,
and WezTerm.
2026-06-12 16:49:00 +08:00
liruifengv
8d251f8ab4
feat(config): tolerate invalid config.toml sections instead of failing startup (#689)
* feat(config): tolerate invalid config.toml sections instead of failing startup

Schema errors now drop only the offending sections (single entries for
providers/models) with a warning, so a typo no longer prevents startup or
drops the login state. TOML syntax errors still fail fast with the parse
location. Mid-run reloads keep the last good config when the file breaks.

Warnings surface via the new getConfigDiagnostics API: as a startup notice
in the TUI, on stderr in print mode, and in the status bar after /new.

Write paths stay strict so a broken file is never silently rewritten, and
now fail with a short actionable message instead of raw validation JSON;
the /provider TUI flow and the kimi provider CLI report these errors
instead of crashing on an unhandled rejection.

* fix(config): keep entry-keyed sections when one entry has multiple issues

A providers/models entry with several validation issues was deleted by the
first issue, and the remaining issues from the same safeParse pass then
escalated to deleting the entire section — one badly-typed custom provider
could drop every provider, including the managed OAuth login. Issues on
entry-keyed sections now only ever target the entry itself; once it is
gone, later issues are no-ops.
2026-06-12 15:56:13 +08:00
7Sageer
c1191f5794
test: redact internal endpoint fixtures (#688) 2026-06-12 15:05:26 +08:00
Haozhe
1b55185f84
fix(changeset): downgrade agent-core and kimi-code bumps from minor to patch (#687)
Update .changeset/qualify-sub-skill-names.md to use patch instead of minor for both packages.
2026-06-12 15:02:49 +08:00
liruifengv
ad239cb1c0
fix(cli,tui): allow --auto, --yolo, and --plan with resumed sessions (#683)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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(cli,tui): allow --auto, --yolo, and --plan with resumed sessions

* docs(cli): update flag conflict docs for resumed sessions

* fix(tui): apply startup permission/plan overrides after picker selection
2026-06-12 14:17:12 +08:00
_Kerman
dff9fd4e32
chore: use raw query imports for prompt sources (#682) 2026-06-12 11:47:44 +08:00
_Kerman
2f7218cba4
test: fix resume test assertions for compaction replay records (#681) 2026-06-12 11:32:30 +08:00
liruifengv
e900854be8
docs(changelog): sync 0.14.1 from apps/kimi-code/CHANGELOG.md (#680) 2026-06-12 11:27:49 +08:00
Haozhe
c39c62590d
feat(skill): qualify sub-skill names with parent prefix (#651)
* feat(skill): qualify sub-skill names with parent prefix

- qualify sub-skill names with parent prefix and set isSubSkill metadata\n- hide sub-skills from the model skill listing
- expose sub-skills as dotted slash commands in TUI
- update slash command docs for English and Chinese
- align built-in sub-skill local names with their directories
2026-06-12 11:12:52 +08:00
_Kerman
911e7c3fcf
fix: replay compaction records on resume (#617) 2026-06-12 11:10:52 +08:00
_Kerman
dcf30754d0
feat: stream shell tool output (#676) 2026-06-12 11:10:20 +08:00
github-actions[bot]
0a3e87f05a
ci: release packages (#629)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-12 10:54:44 +08:00
liruifengv
596cadd465
feat: support always-thinking models via supports_thinking_type (#662)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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
Map the /models three-state supports_thinking_type field ('only' /
'no' / 'both', taking precedence over the legacy supports_reasoning
boolean) onto the existing always_thinking capability:

- oauth: parse the field in both /models parsers; 'only' emits
  always_thinking alongside thinking, 'no' suppresses thinking even
  when supports_reasoning is set, absent falls back to the legacy
  boolean. Default thinking selection is forced on for 'only' (and
  off for 'no') models during login and provider refresh
- TUI: render the thinking control with a fixed On/Off layout — locked
  models show a greyed-out "Off (Unsupported)" segment, and
  non-thinking models mirror the style with "On (Unsupported)"
- agent-core: clamp thinkingLevel at the getter so a stale
  thinking-off config can never reach the request builder, status
  events, or subagent inheritance
- acp-adapter: derive alwaysThinking from capabilities, collapse the
  thinking select to a single locked "on" entry, and ignore off
  requests for locked models while re-emitting the snapshot
2026-06-11 23:16:02 +08:00
liruifengv
1e2e679693
feat: add tips banner below welcome panel on startup (#655)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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
* chore: ignore .worktrees directory

* feat: add tips banner below welcome panel on startup

- Fetch active/fallback tips from the configured CDN with a 3s timeout.

- Filter tips by semver, client version, and date range.

- Render the banner directly below the welcome panel on startup/resume.

- Support tag, multi-line text, subtext, automatic wrapping, and narrow-terminal safety.

* refactor(tui): use theme color methods in banner component

Replace raw chalk calls with currentTheme helpers: tag uses
boldFg('primary'), main text uses boldFg('textStrong'), and subtext
uses fg('textDim') without stacking the dim modifier on the already
dim shade. Strengthen tests to assert the exact themed ANSI output.
2026-06-11 22:20:31 +08:00
7Sageer
0927f79883
fix: cancel active turns during session shutdown (#661)
* fix: cancel active turns during session shutdown

* fix: preserve background agents during session close
2026-06-11 20:52:50 +08:00
7Sageer
0381329570
fix: send responses system prompts as instructions (#658) 2026-06-11 19:32:26 +08:00
_Kerman
588cdaa152
chore: remove pnpm catalog usage (#653) 2026-06-11 17:21:42 +08:00
_Kerman
ff80327344
fix: propagate kaos env overlays (#654) 2026-06-11 17:21:32 +08:00
_Kerman
a2c5e1be25
fix: add minor improvements for Mira (#649) 2026-06-11 15:50:33 +08:00
_Kerman
54302ad612
fix: scope interactive agent requests (#648) 2026-06-11 15:37:09 +08:00
liruifengv
a58b5b20bb
fix(skill): polish builtin skills KIMI_CODE_HOME resolution (#644)
- custom-theme.md, update-config.md, mcp-config.md now resolve KIMI_CODE_HOME first.

- custom-theme.md requires clarifying intent before creating or editing themes.

- custom-theme.md falls back to plain-text questions in auto mode.
2026-06-11 14:53:46 +08:00
_Kerman
1b58aa8cdf
fix: allow YOLO when starting swarm tasks (#645) 2026-06-11 14:34:35 +08:00
wenhua020201-arch
e37d7e5837
docs: note datasource latest version and manual update flow (#646)
* docs: note datasource latest version and manual update flow

* docs: align datasource version with marketplace manifest (3.2.0)

* docs: tighten datasource update wording
2026-06-11 14:31:40 +08:00
_Kerman
4e5043b03b
fix: require AgentSwarm to run alone (#643)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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
2026-06-11 13:58:35 +08:00
7Sageer
30459af6ab
fix: clean up background tasks on session exit (#641)
* fix: clean up background tasks on session exit

* Stop background tasks on session close

Change versioning for agent-core and kimi-code to patch.

Signed-off-by: 7Sageer <12210216@mail.sustech.edu.cn>

---------

Signed-off-by: 7Sageer <12210216@mail.sustech.edu.cn>
2026-06-11 11:35:34 +08:00
qer
71f5926d0e
feat(datasource): add yuandian_law legal data source + request-id trace (#611)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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
- Register the yuandian_law (元典法律数据库) data source for Chinese
  laws/regulations and judicial case search.
- Append a request-id / tool-call-id trace line to every tool result so
  failures can be correlated with backend logs.
- Fix the documented MCP tool names in SKILL.md (-data -> _data).
- Also includes the dev marketplace-server env isolation fix in dev.mjs.

Co-authored-by: qer <Anna_Knapprfr@mail.com>
2026-06-10 22:29:51 +08:00
7Sageer
d8cdebf3c0
fix: stop silently dropping unsupported multimodal content in kosong providers (#632)
Some checks are pending
CI / build (push) Waiting to run
CI / test (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: stop silently dropping unsupported media in provider conversions

* fix: keep non-standard audio/video parts off the chat completions wire
2026-06-10 21:39:03 +08:00
Cyning12
42a104a840
docs: align getting-started Node.js requirement with package engines (#622)
Fixes misleading npm install prerequisite (24.15.0) so docs match
apps/kimi-code package.json engines.node >=22.19.0.

Co-authored-by: cyning <cyning12@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: liruifengv <liruifeng1024@gmail.com>
2026-06-10 21:31:32 +08:00
liruifengv
296142544e
feat: alias search and two-line descriptions for slash command autocomplete (#631)
* feat: search slash command aliases in autocomplete

Slash-command name completion now matches aliases and shows them in the
label as `name (alias1, alias2)` when the match came from an alias.
Argument completion resolves aliases too. The intercepted completion
keeps the inner provider's conventions: argument hints stay in the
suggestion description, and primary-name matches outrank alias matches
on score ties.

* feat: wrap autocomplete descriptions onto up to two lines

Long slash command / skill descriptions used to be truncated to a single
line in the completion menu. CustomEditor now swaps the slash menu's list
for a WrappingSelectList that wraps descriptions to at most two lines,
ellipsizing past the second; non-slash completion keeps pi-tui's
single-line list.

Plain-text truncations also strip the trailing ANSI reset that pi-tui's
truncateToWidth appends — embedded inside the theme colouring it reset
the rest of the row, so a selected row with a truncated name rendered
its two description lines in different colours.

* chore: make changeset wording user-facing

* style: pass optional description directly instead of conditional spread

Follows the AGENTS.md convention for optional object properties.
2026-06-10 21:26:36 +08:00
liruifengv
fabc3b218f
docs: limit changelog TOC to version headings (#633) 2026-06-10 21:15:41 +08:00
liruifengv
89097aa02c
docs: sync changelog 0.13.1 & 0.14 (#620)
* docs(changelog): sync 0.13.1 from apps/kimi-code/CHANGELOG.md

* docs(changelog): sync 0.14.0 from apps/kimi-code/CHANGELOG.md
2026-06-10 20:54:26 +08:00
Haozhe
7ec738c4a1
fix(agent-core): suppress premature stream close on shell timeout or kill (#604)
- Add terminating-state guard to skip ERR_STREAM_PREMATURE_CLOSE when a process is killed or times out
- Ensure timeout/kill reason is reported instead of the internal stream error
- Add changeset for agent-core and kimi-code
2026-06-10 20:02:47 +08:00
Haozhe
0ee91066ea
fix(acp-adapter): convert Unix paths to Windows separators for ACP file RPC (#628)
- fix readTextFile and writeTextFile to use backslash separators on win32
- add changeset for acp-adapter and kimi-code
2026-06-10 20:00:54 +08:00
github-actions[bot]
ecc0496115
ci: release packages (#621)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-10 18:31:01 +08:00
7Sageer
856ec00290
fix: preserve tool result images in chat completions (#626) 2026-06-10 18:09:53 +08:00
qer
b253a82a7a
feat(agent-core): add Interrupt hook for user-interrupted turns (#607)
Fires an observation-only Interrupt event when a turn is aborted by the user (e.g. pressing Esc). Previously neither Stop nor StopFailure fired on interrupt, so external tooling that tracks status from hooks stayed stuck on a working state.
2026-06-10 15:35:25 +08:00
github-actions[bot]
3f9226f014
ci: release packages (#608)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-10 15:11:40 +08:00
liruifengv
1fbe0e4ee8
fix: truncate goal marker text to terminal width (#619) 2026-06-10 15:09:33 +08:00
_Kerman
b853823609
chore: undo selector as a patch (#618) 2026-06-10 14:44:51 +08:00
_Kerman
494554eac5
feat: add undo selector (#615) 2026-06-10 14:22:02 +08:00
Haozhe
4603d8ad6e
feat(protocol): extract shared protocol package from agent-core (#612)
* feat(protocol): extract shared protocol package from agent-core

- add `@moonshot-ai/protocol` package with REST/WS schemas, envelopes, error codes, event types, and display schemas\n- migrate agent-core `events.ts` and `display/schemas.ts` to re-export from protocol
- add centralized `onUnexpectedError` handler for safe emitter listener callbacks
- reject forkSession when source session has an active running turn
- add protocol schema tests and unexpectedError handler tests
2026-06-10 14:03:38 +08:00
7Sageer
95a124804e
chore: downgrade Fable 5 changeset to patch (#614) 2026-06-10 13:54:27 +08:00
7Sageer
b747c6a950
feat(kosong): support claude-fable-5 with adaptive thinking (#610)
* feat(kosong): support claude-fable-5 adaptive thinking

claude-fable-5 only accepts thinking: {type: "adaptive"} with
output_config.effort; the legacy enabled/budget_tokens config and an
explicit disabled config both return HTTP 400.

- Parse the fable family in Claude model ids (major-only version)
- Route fable >= 5 to adaptive thinking; allow xhigh effort
- Omit the thinking field entirely when thinking is off on fable
- Register the 128k output ceiling and thinking/vision capability

* update .changeset

Updated the configuration to use adaptive thinking for Claude Fable 5 support.

Signed-off-by: 7Sageer <12210216@mail.sustech.edu.cn>

---------

Signed-off-by: 7Sageer <12210216@mail.sustech.edu.cn>
2026-06-10 13:15:24 +08:00
liruifengv
32d7080837
fix(skill): clarify active skill prompts (#598)
* fix(skill): clarify active skill prompts

* fix(skill): preserve nested skill trigger
2026-06-10 12:46:23 +08:00
7Sageer
1580f35136
fix: align datasource plugin environment (#595)
* fix: align datasource plugin environment

* refactor: inject managed Kimi env into all stdio plugins

Pin the datasource credential-name test to the canonical
resolveKimiCodeOAuthKey so a digest drift in the standalone plugin
fails CI, and drop the hardcoded plugin-name special case so every
stdio plugin receives the active managed Kimi base URL / OAuth host
consistently (process.env and KIMI_CODE_HOME are already shared with
all plugins).
2026-06-10 12:42:27 +08:00
Luyu Cheng
2ebe38769f
feat(agent-core): strengthen Edit-over-Write preference in tool prompts (#540) 2026-06-10 12:22:25 +08:00
_Kerman
a1b419ab59
fix: stop asking for yolo external writes (#606) 2026-06-10 12:19:33 +08:00
liruifengv
99b3748dbe
docs(changelog): sync 0.12.1, 0.13.0 from apps/kimi-code/CHANGELOG.md (#605) 2026-06-10 11:09:20 +08:00
github-actions[bot]
25cf13ac97
ci: release packages (#588)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-10 10:47:32 +08:00
qer
40506f49d6
feat(tui): show available plugin updates in the marketplace (#593)
Installed plugins whose marketplace version is newer than the local
version now render an `update <local> → <latest>` badge and update in
place on Enter; up-to-date plugins show `installed · v<version>`.

Dev-server and CDN-build marketplace generation now stamp each entry's
version from the plugin manifest so the advertised "latest" stays accurate.
Adds a pure computeUpdateStatus() (semver, no spurious downgrades) with tests.

Co-authored-by: qer <Anna_Knapprfr@mail.com>
2026-06-09 21:28:36 +08:00
liruifengv
46f909d694
docs: add built-in skill commands section to slash-commands and skills pages (#596) 2026-06-09 20:39:33 +08:00
7Sageer
e48234af57
fix: avoid Windows command shim launches (#591) 2026-06-09 20:20:44 +08:00
liruifengv
f2863af267
fix: keep login fallback prompt visible (#594) 2026-06-09 20:06:07 +08:00
liruifengv
0abde8662a
fix(tui): clarify grouped subagent progress (#587) 2026-06-09 19:21:46 +08:00
liruifengv
f863127ab7
feat: custom color themes (#484)
* Refactor theme

* custom theme support

* docs: add custom themes guide

Document the custom theme file location, the color token reference, selecting a theme via /theme and tui.toml, and fallback behavior. Link it from the customization sidebar and the tui.toml theme field.

* feat(skill): add built-in custom-theme skill

Guides the model (or a manual /custom-theme run) to author a theme JSON in ~/.kimi-code/themes/: docs token reference, deliberate color choices, hex validation, and how to apply via /theme or /reload-tui. Note in the write-tui skill to keep the token set in sync across colors.ts, the schema, the docs, and this skill. Enrich the custom-theme changeset to cover all three usage paths.

* chore: remove theme research report

* fix(tui): resolve lint errors after main merge

Remove unused chalk/currentTheme/ResolvedTheme imports left by the theme refactor; break the theme <-> pi-tui-theme import cycle by dropping the markdown/editor theme getters from the Theme class (consumers call createMarkdownTheme directly); fix unused vars/params, a floating promise, and a redundant union type.

* fix(tui): address custom theme review feedback

- await applyTheme before refreshing terminal theme tracking, so
  switching to "auto" installs the watcher against the new state
- invalidate the transcript on automatic (terminal-driven) theme
  changes so already-rendered entries repaint
- rebuild UsagePanel bodies on invalidate (previously a no-op); /usage,
  /status, /mcp and /plugins now repaint on a theme switch
- repaint the compaction header on invalidate
- validate a custom theme before applying it from the /theme picker
- hide reserved dark/light/auto names from the custom theme list
- escape the theme name when writing tui.toml
- stop the custom theme loader writing warnings to the raw terminal
- remove a stray hello.ts

* refactor(tui): polish custom theme feature

- footer and todo-panel read the currentTheme singleton directly at
  render time instead of caching a palette copy; drop their setColors
  methods and the manual setColors calls on every theme change
- support "base": "dark" | "light" in custom theme files so a partial
  light theme inherits the light palette for unspecified tokens
- reconcile the docs and the custom-theme skill with the silent
  invalid-color fallback (no terminal warning)

* refactor(tui): live-repaint the agent swarm progress panel

Read the currentTheme palette through a getter instead of caching it at
construction time, so the swarm progress panel recolors on a theme switch
like the rest of the transcript. Drops the now-unused `colors` option.

* chore: remove plan.md

* docs: update custom theme guide

* docs: document custom theme skill command

* fix(skill): make custom theme user-triggered only
2026-06-09 18:55:15 +08:00
liruifengv
d85dc0b96a
feat: add Claude Codex import skill (#582) 2026-06-09 18:44:10 +08:00
liruifengv
7cb4a23e01
fix: truncate queued messages to a single line (#586) 2026-06-09 17:26:41 +08:00
github-actions[bot]
c79972c28c
ci: release packages (#585)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-09 17:02:42 +08:00
liruifengv
11bb62c12f
fix: allow obsolete experimental config entries (#584)
* fix: allow obsolete experimental config entries

* docs: remove config docs update from PR
2026-06-09 17:00:48 +08:00
7Sageer
aa3471f5d3
fix(kosong): pass through chat reasoning effort (#581) 2026-06-09 16:59:41 +08:00
liruifengv
75a894e9ad
docs(changelog): sync 0.12.0 from apps/kimi-code/CHANGELOG.md (#571) 2026-06-09 12:17:20 +08:00
github-actions[bot]
20f7aa337a
ci: release packages (#491)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-09 11:54:52 +08:00
qer
a175cd8f73
docs(guides): add Paseo ACP integration section (#542) 2026-06-09 11:53:25 +08:00
Luyu Cheng
41ebe9fb9f
fix: polish goal lifecycle messaging (#555) 2026-06-09 11:47:00 +08:00
liruifengv
d7407b0ecf
feat: release experimental features (#569)
* feat: release experimental features

* refactor: remove redundant goal runtime gate

* refactor: remove unused skill flag plumbing

* feat: keep micro compaction opt-out
2026-06-09 11:42:50 +08:00
_Kerman
db82e33a20
fix: restore goal resume state from agent records (#552) 2026-06-09 10:02:19 +08:00
qer
9ed6b8c350
docs: restore Kimi Datasource legacy pages (#557) 2026-06-08 22:33:18 +08:00
wenhua020201-arch
e5e9d28f7c
docs: merge Kimi Datasource into plugins page and add terminal tip (#551)
* docs: merge Kimi Datasource into plugins page and add terminal tip to getting started

- Merge datasource.md content into plugins.md as a dedicated section,
  placed between installation management and plugin manifest sections
- Replace verbose feature tables with scenario-driven use cases and a
  condensed coverage table
- Add /skill:kimi-datasource as an explicit invocation method alongside
  natural language; update /new references to /reload
- Promote GitHub URL formats and notes to named H3 subsections within
  installation management
- Add terminal recommendation tip (Kitty / Ghostty) in the Installation
  section of getting-started
- Remove standalone datasource.md sidebar entries from zh and en nav

* docs: remove stale datasource pages and add redirects to plugins

Delete zh/en datasource.md (content now merged into plugins.md) and
add VitePress redirects so existing bookmarks and search results for
/customization/datasource land on /customization/plugins instead.

* docs: restore datasource pages as forwarding stubs

Replace deleted files with minimal pages that link to the merged
section in plugins.md. VitePress redirects only fire in SSG builds;
dev-server visitors hitting the old URL would 404 without these stubs.

* docs: add dev-server redirect middleware for removed datasource pages

VitePress `redirects` config only fires during SSG build; the dev
server ignores it, causing 404s on the old /customization/datasource
URLs. Add a Vite `configureServer` middleware that handles the redirect
in dev mode, while the top-level `redirects` config continues to
generate meta-refresh HTML pages for the production build.

---------

Co-authored-by: qer <wbxl2000@outlook.com>
2026-06-08 22:13:54 +08:00
wenhua020201-arch
0e1665173d
docs: expand AGENTS.md with reader personas, format decisions, and checklist (#507)
* docs: expand AGENTS.md with reader personas, format decisions, and checklist

Add documentation writing principles that were missing from the existing guide:
- Readers: two audience types (technical vs non-technical) with writing targets
- Authoring workflow: think before rewriting (understand → structure → fill)
- Kimi platform rules: strict separation of api.kimi.com vs api.moonshot.cn URLs
- Typography: forbid four-colon callout syntax (::::), note no nesting
- Writing style: one-idea-per-paragraph, map-before-detail structure, clarify
  when to use lists vs prose (avoids confusion between 'no fragmentation'
  and 'parallel content needs formatting')
- Format decisions: explicit rules for ordered list / unordered list / table / prose
- Cross-references: when links are required, anchor precision, inline vs next-steps
- Page structure: standard template and banner placement rule
- Content completeness: default to keeping everything, omissions need stated reasons
- Checklist: format violation quick-reference and Kimi-specific consistency checks

All existing rules are preserved unchanged (0 lines removed).

* docs: address review comments on AGENTS.md

- Fix "next steps" rule contradiction: replace blanket ban with "no nav
  tip blocks"; `## Next steps` is allowed when related pages exist
- Fix checklist Base URL pointer: was pointing to locale index.md
  (which has no platform table); now links to Kimi platform rules above
- Fix callout syntax rule: `::::` is valid as an outer nested fence
  when correctly closed; ban only unclosed/mismatched fences
- Fix checklist code block rule: carry forward the existing exception
  that natural-language prompt examples may omit the language tag

---------

Co-authored-by: qer <wbxl2000@outlook.com>
2026-06-08 21:46:01 +08:00
liruifengv
3765a49163
feat: rework TUI file references (#547)
* feat: rework TUI file references

* fix: respect fd file reference filtering

* chore: adjust file reference changeset

* fix: harden fd helper downloads

* fix: avoid symlink recursion in file fallback
2026-06-08 20:48:56 +08:00
Haozhe
879a7eeb33
fix(acp): restore legacy permission compatibility and stabilize ACP (#395)
* feat(acp-adapter): support embedded resource prompts

- advertise embeddedContext support in ACP capabilities and docs
- convert file:// resource_link blocks into decoded paths with optional line ranges
- keep XML wrappers for non-file or unparseable resource_link URIs
- update adapter tests for the new resource link behavior

* feat(acp-adapter): add ACP built-in slash command routing and UNC path support

- add local execution for /compact, /status, /usage, /mcp, /tasks, /help in ACP sessions
- surface unknown slash commands as local errors instead of forwarding to model\n- export ACP_BUILTIN_SLASH_COMMANDS from acp-adapter for CLI reuse
- fix file:// URI conversion for Windows UNC paths
- rebuild agent builtin tools on session tool kaos rebind
2026-06-08 19:27:20 +08:00
liruifengv
5cff6d6027
fix: honor KIMI_CODE_HOME for global agent resources (#544) 2026-06-08 17:42:03 +08:00
MicroGrey
3787c3016a
fix(tui): support exit shortcuts in startup session picker (#473)
* fix(tui): support exit shortcuts in startup session picker

* fix(tui): clear picker exit confirmation on close

---------

Co-authored-by: liruifengv <liruifeng1024@gmail.com>
2026-06-08 17:02:16 +08:00
liruifengv
0c3d556778
chore: add changeset for PR #486 (#543) 2026-06-08 16:35:10 +08:00
liruifengv
2db1bd9675
fix(tui): subagent thinking text and tool output display (#541) 2026-06-08 16:19:15 +08:00
Davy
b47734ca0b
docs: add Homebrew installation (#531)
* docs: add Homebrew installation instructions

Add Homebrew as an installation option for macOS/Linux users in both
English and Chinese READMEs.

Closes #130

* feat(cli): detect Homebrew installs and use brew upgrade for updates

When kimi-code is installed via Homebrew, the update system now detects
the installation source and uses 'brew upgrade kimi-code' instead of
falling back to 'npm install -g'. This prevents duplicate installations
when Homebrew users receive update prompts.

* chore: add changeset for Homebrew update detection

* fix(cli): tighten Homebrew detection and disable auto-update

- Only match /cellar/ path segment (not /homebrew/) to avoid false
  positives on Apple Silicon where npm global installs live under
  /opt/homebrew/lib/node_modules/
- Disable background auto-update for Homebrew: brew upgrade may mutate
  dependents silently and the formula can lag behind CDN releases

* fix(cli): add homebrew to InstallSource Zod schema

Keeps the persistence schema in sync with the TypeScript type. Currently
harmless since Homebrew auto-install is disabled, but prevents a silent
state reset if it is ever enabled later.

---------

Co-authored-by: liruifengv <liruifeng1024@gmail.com>
2026-06-08 16:11:31 +08:00
liruifengv
b785e2698a
feat: show full plan cards (#536)
* feat: show full plan cards

* chore: patch plan card changeset
2026-06-08 15:35:49 +08:00
Qkunio
0fe13173f4
fix: handle windows session workdir separators (#486)
* fix: handle windows session workdir separators

* test: cover Windows session workdir normalization

---------

Co-authored-by: qkunio <qkunio@163.com>
Co-authored-by: liruifengv <liruifeng1024@gmail.com>
2026-06-08 15:10:30 +08:00
liruifengv
8d0c91faa1
fix(cli): wrap long approval shell commands (#537) 2026-06-08 15:06:25 +08:00
_Kerman
72c4b0adaa
feat: agent swarm (#424) 2026-06-08 14:26:56 +08:00
7Sageer
3b62b123e6
fix: detect Scoop Git Bash on Windows (#529)
* fix: detect Scoop Git Bash on Windows

* fix: harden Git Bash shim detection

* fix(kaos): preserve Git Bash PATH priority
2026-06-08 14:00:11 +08:00
caiji
9aba465fd8
fix(tui): keep the /mcp panel border intact on multi-line server errors (#521) 2026-06-08 13:50:00 +08:00
_Kerman
f09ec7bbb5
fix: remove auto-compaction turn limit (#506) 2026-06-06 23:16:34 +08:00
Kai
4d113949c8
feat: honor HTTP_PROXY/HTTPS_PROXY/NO_PROXY for all outbound traffic (#487)
* feat: honor HTTP_PROXY/HTTPS_PROXY/NO_PROXY for all outbound traffic

Install a global undici dispatcher at CLI startup so every in-process fetch
(LLM APIs, MCP HTTP, web tools, telemetry, sign-in, update checks) honors the
standard proxy variables, and propagate NODE_USE_ENV_PROXY to spawned stdio
MCP child processes. Loopback hosts always bypass the proxy; an invalid proxy
URL is reported and ignored rather than aborting startup.

* feat: support SOCKS proxies via ALL_PROXY

Recognize SOCKS proxies (socks5/socks5h/socks4/socks alias) from ALL_PROXY or a
socks-scheme HTTP(S)_PROXY, routing traffic through a custom undici connector
backed by the socks client (reusing undici's own TLS handling for https).
HTTP(S) proxies keep precedence; NO_PROXY and loopback are honored for the SOCKS
path too. Child stdio MCP node processes honor HTTP(S) proxies via
NODE_USE_ENV_PROXY; SOCKS applies to the main process only.

* fix: address proxy review comments (env masking, child NO_PROXY, nix hash)

- Resolve HTTP(S)_PROXY explicitly via the first non-blank casing so a blank
  lowercase var can no longer mask a populated uppercase one (the dispatcher
  installed but went direct), and coerce a SOCKS-scheme value sitting in an
  HTTP(S) var to '' so it is never handed to EnvHttpProxyAgent.
- Reconcile a child's NO_PROXY override across both casings using the first
  non-blank value run through resolveNoProxy, so a per-server config override
  is not shadowed by the injected lowercase value, keeps the loopback bypass,
  and passes '*' through verbatim.
- Update flake.nix pnpmDeps hash for the added socks/undici dependencies.

* fix(proxy): honor http ALL_PROXY, match port-qualified NO_PROXY, note child Node version

- Honor an http-scheme ALL_PROXY as the catch-all fallback for both http and
  https (scheme-specific HTTP(S)_PROXY still wins), so an ALL_PROXY-only setup
  no longer installs a no-op dispatcher and connects direct.
- Make the SOCKS-path NO_PROXY matcher port-aware: a `host:port` entry now
  matches only that port (with IPv6-safe parsing for `::1` / `[::1]:443`).
- Document that child stdio MCP proxying via NODE_USE_ENV_PROXY only applies on
  Node versions that support it (>= 22.21 / >= 24.5).

* fix(proxy): IPv6 + wildcard NO_PROXY and per-server child proxy edges

- Strip IPv6 brackets from a SOCKS proxy host (e.g. ALL_PROXY=socks5://[::1]:1080)
  so the socks client connects to the bare address.
- Add the bracketed [::1] to the loopback bypass: undici's EnvHttpProxyAgent
  only exempts IPv6 loopback when the NO_PROXY entry is bracketed (it mis-parses
  bare ::1). The SOCKS-path matcher normalizes brackets on both sides.
- Match *.domain wildcard (and host:port) NO_PROXY entries in the SOCKS matcher.
- Compute the child stdio proxy env from the MERGED env so a proxy declared only
  in a server's config.env also enables NODE_USE_ENV_PROXY.

* fix(proxy): synthesize HTTP(S)_PROXY from ALL_PROXY for child processes

proxyEnvForChild now hands spawned stdio MCP children the resolved
HTTP_PROXY/HTTPS_PROXY (in both casings), synthesizing them from an http-scheme
ALL_PROXY when no scheme-specific variable is set. Node's --use-env-proxy reads
HTTP_PROXY/HTTPS_PROXY (not ALL_PROXY), so an ALL_PROXY-only parent now proxies
the child consistently with the main process. Shared resolveHttpProxyUrls helper
is reused by createProxyDispatcher and proxyEnvForChild.

* chore(changeset): tighten proxy changeset wording
2026-06-06 01:44:54 +08:00
luren
d995928681
fix(cli): show migration failure reason (#210)
Co-authored-by: Kai <me@kaiyi.cool>
2026-06-06 01:41:03 +08:00
liruifengv
84afaf42fc
docs(changelog): sync 0.11.0 and 0.10.1 from apps/kimi-code/CHANGELOG.md (#488) 2026-06-05 18:51:16 +08:00
github-actions[bot]
696b46ed11
ci: release packages (#453)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-05 18:25:21 +08:00
Luyu Cheng
658e4653fc
fix(tui): refine goal queue workflows (#474)
* fix(tui): harden goal startup flow

* fix(tui): refine upcoming goal handling

* fix(tui): accent upcoming goal confirmation

* docs: update goal queue guidance

* docs: address goal queue review comments

* fix(tui): avoid goal autocomplete before text

* fix(tui): highlight goal queue subcommands

* fix(tui): address goal queue review nits

* Remove useless docs

---------

Signed-off-by: Luyu Cheng <2239547+chengluyu@users.noreply.github.com>
2026-06-05 18:02:39 +08:00
liruifengv
f61165aa9a
fix: use underscore sub_skill experimental flag id (#483)
* fix: use underscore sub_skill experimental flag id

* chore: keep changeset wording unchanged
2026-06-05 18:01:34 +08:00
liruifengv
f555c89de7
feat: prioritize built-in skill slash commands (#480) 2026-06-05 17:27:01 +08:00
7Sageer
2af19e29b9
fix: refresh provider model capabilities (#461)
* fix: refresh provider model capabilities

* refactor: build provider refresh target once and compare generically
2026-06-05 17:22:30 +08:00
_Kerman
420a387e21
chore: remove llm request token estimate (#479) 2026-06-05 17:19:46 +08:00
wenhua020201-arch
c984cbcb30
docs: improve goals page and related cross-references (zh/en) (#475)
* docs: fix code fence language and add cross-references (zh/en)

- slash-commands: add 'sh' language tag to /goal example code block
- config-files: link experimental section to env-vars#runtime-switches
- env-vars: link KIMI_CODE_EXPERIMENTAL_* rows to config-files#experimental

* docs: improve goals.md formatting and clarity (zh/en)

- Merge redundant opening paragraphs into one with concrete examples
- Use colons before code examples (not periods)
- Bold state names in lifecycle list (complete/paused/blocked)
- Replace prose keyboard shortcut description with table
- Remove draft product-background text from EN queue section

* docs: move goals after sessions in sidebar (zh/en)

Goal mode builds on multi-turn sessions, so it reads more naturally
after the sessions page in the guides navigation.
2026-06-05 16:19:17 +08:00
Haozhe
df4f2d6e86
feat(skills): add experimental sub-skill discovery (#468)
- Add sub-skill feature flag and recursive bundle discovery behind KIMI_CODE_EXPERIMENTAL_SUB_SKILL.
- Register builtin sub-skill review and consolidate helpers as inline hidden skills.
2026-06-05 16:10:41 +08:00
_Kerman
aa610e247d
feat: use fixed 30-minute subagent timeout (#470) 2026-06-05 15:59:10 +08:00
Kai
bbcf6170a4
docs: add ACP / IDE integration and refresh README key features (#472)
* docs: document ACP / IDE integration in README

为中英文 README 增加 ACP 支持说明:核心特性新增条目、新增
独立小节(含 Zed 配置示例),并在文档列表补充「在 IDE 中使用」链接。

* docs: refresh README key features

强调 TUI 端到端优化、补充视频输入用例,并新增「插件生态」条目;
中英文 README 保持一致。
2026-06-05 15:49:02 +08:00
Kai
93eb70a727
feat(env): migrate kimi-cli model request params and auto-update toggle (#458)
* feat(env): migrate kimi-cli model request params and auto-update toggle

Migrate still-relevant environment variables from kimi-cli:

- KIMI_MODEL_TEMPERATURE / KIMI_MODEL_TOP_P: sampling params applied
  globally to any kimi provider (not tied to KIMI_MODEL_NAME).
- KIMI_MODEL_THINKING_KEEP: Moonshot preserved-thinking passthrough
  (thinking.keep), injected only while Thinking is on.
- KIMI_CODE_NO_AUTO_UPDATE (legacy alias KIMI_CLI_NO_AUTO_UPDATE):
  fully disables the update preflight.

Wires env -> provider in Agent.get llm() via applyKimiEnvGenerationParams,
reusing kosong's existing GenerationKwargs / thinking.keep support.
KIMI_MODEL_MAX_TOKENS is intentionally untouched: it already flows through
the completion-budget path.

* fix(env): apply Kimi sampling params to compaction requests too

Sink KIMI_MODEL_TEMPERATURE / KIMI_MODEL_TOP_P into ConfigState.provider so
every request built from config.provider — main loop and full-history
compaction alike — carries them, matching kimi-cli where these live on the
shared create_llm provider. thinking.keep stays in Agent.llm because it
depends on the runtime thinking state (compaction runs thinking-off and
correctly skips it).

Splits applyKimiEnvGenerationParams into applyKimiEnvSamplingParams (applied
at provider construction) and applyKimiEnvThinkingKeep (applied in Agent.llm).

Addresses PR review feedback about compaction requests bypassing the wrapped
provider.
2026-06-05 14:54:24 +08:00
_Kerman
95dd0a1f3b
chore: clarify agent record resume contract (#465) 2026-06-05 14:51:58 +08:00
_Kerman
4f9977d4dc
fix: preserve thinking during compaction (#464) 2026-06-05 14:25:32 +08:00
7Sageer
1fe5d5549c
fix(kosong): clamp OpenAI chat xhigh effort by model (#457) 2026-06-05 14:01:50 +08:00
_Kerman
3a98713050
fix: show concise filtered response errors (#456) 2026-06-05 12:48:35 +08:00
Qkunio
f8940ebf26
docs(typo): fix zh changelog kimi acp link (#455)
Co-authored-by: qkunio <qkunio@163.com>
Co-authored-by: liruifengv <liruifeng1024@gmail.com>
2026-06-05 12:17:46 +08:00
Yifan Song
960a0e2885
fix(cli): show unknown command error for invalid subcommands (#442)
Co-authored-by: liruifengv <liruifeng1024@gmail.com>
2026-06-05 11:54:16 +08:00
Kai
52f75a42e5
docs(changelog): add release dates to version headings (#401)
Add the published release date to every version heading on the
user-facing changelog pages (English half-width, Chinese full-width),
covering 0.2.0 through 0.9.0. Dates come from each version's published
release tag. Also update the sync-changelog skill so future syncs carry
the date convention forward.
2026-06-05 11:37:11 +08:00
1845 changed files with 277248 additions and 18554 deletions

View file

@ -11,20 +11,23 @@ description: Use when generating changesets in the kimi-code repository, includi
All other `@moonshot-ai/*` packages are treated as internal packages, including `@moonshot-ai/kimi-code-sdk`, `agent-core`, `kosong`, `kaos`, `kimi-code-oauth`, `kimi-telemetry`, and `migration-legacy`.
`@moonshot-ai/pi-tui` is a special internal package: it is a private fork (`private: true`) that is never published, but it keeps its own changelog through changesets. It is an exception to Core Rule 4 — see the dedicated section below.
## Core Rules
1. **Inspect the actual changes first.** Use `git status` / `git diff --name-only` to identify which packages were actually changed.
2. **List packages that were actually changed.** Source code, build config, package metadata, and other changes that affect a package's output or behavior need a changeset entry for that package.
3. **Do not list unchanged internal packages.** For example, if `packages/node-sdk` was not changed, do not list `@moonshot-ai/kimi-code-sdk` just because another internal package changed. The SDK follows the same rule as other internal packages: list it only when it was actually changed.
4. **Internal package source changes that enter the CLI bundle must manually list the CLI.** `@moonshot-ai/kimi-code` inline-bundles `@moonshot-ai/*` source, but those internal packages are devDependencies from the CLI's perspective, so changesets will not automatically propagate bumps. If a change enters the CLI output, also list `@moonshot-ai/kimi-code`.
2. **List packages that changesets can release.** If a changed package is ignored in `.changeset/config.json`, do not put that ignored package in frontmatter together with a non-ignored package; changesets rejects mixed ignored/non-ignored frontmatter.
3. **Map ignored internal changes to the affected released package.** If an ignored internal package changes CLI output or behavior, list `@moonshot-ai/kimi-code` and describe the actual user-visible or release-artifact change in the changelog text.
4. **Internal package source changes that enter the CLI bundle must manually list the CLI.** `@moonshot-ai/kimi-code` inline-bundles `@moonshot-ai/*` source, but those internal packages are devDependencies from the CLI's perspective, so changesets will not automatically propagate bumps. If a change enters the CLI output, list `@moonshot-ai/kimi-code`.
- **Web app (`@moonshot-ai/kimi-web`) changes always enter the CLI bundle.** `@moonshot-ai/kimi-web` is ignored by changesets (see `.changeset/config.json`) and cannot be mixed with `@moonshot-ai/kimi-code` in one changeset frontmatter. Describe the web change in the changelog text, but list `@moonshot-ai/kimi-code` so the CLI release carries the bundled `dist-web` output.
5. **Docs-only and tests-only changes usually do not need a changeset.** README, internal docs, and `test/` changes that do not enter package output do not trigger a CLI bump.
6. `@moonshot-ai/vis` / `vis-server` / `vis-web` are ignored by changesets and should not be handled.
## Workflow
1. List the packages that were actually changed.
1. List the changed packages and check whether each one is ignored by `.changeset/config.json`.
2. Choose a bump level for each package.
3. If an internal package change enters the CLI bundle, add `@moonshot-ai/kimi-code`.
3. If an ignored internal package change enters the CLI bundle, put `@moonshot-ai/kimi-code` in frontmatter instead of mixing the ignored package into the same changeset.
4. Create a short kebab-case file under `.changeset/`.
5. Split unrelated changes into separate changesets; keep one logical change in one file.
@ -43,10 +46,12 @@ Format:
| Level | When to use |
|---|---|
| `patch` | Bug fixes; build/package fixes; internal refactors that do not change behavior; wording tweaks; small dependency upgrades |
| `minor` | New backwards-compatible features or capabilities |
| `patch` | Bug fixes; build/package fixes; internal refactors that do not change behavior; wording tweaks; small dependency upgrades; small improvements to existing features with limited user-facing impact (e.g. a new keyboard shortcut, a flag alias, a minor UX tweak) |
| `minor` | A substantial new user-facing feature, such as a new slash command, a new built-in tool, or a new mode |
| `major` | Breaking changes: incompatible config changes, renamed or removed commands/arguments, behavior semantics changes, and similar |
When in doubt between `patch` and `minor`: if the change improves an existing feature and the user-facing impact is small, choose `patch` even when the change is technically "new". Reserve `minor` for a substantial new capability that introduces something users could not do before.
### Major Rule
Never write `major` on your own.
@ -56,31 +61,72 @@ If you believe a change qualifies as major, stop first, explain why, and ask the
## Wording Rules
- Changelog entries **must be written in English**.
- **Keep it short — ideally a single sentence that states what was done.** Do not write a paragraph, do not pile on technical detail, and do not enumerate every sub-change.
- **Keep the whole entry concise.** Aim for one short sentence that states what was done; at most a short sentence plus a one-line usage hint. Do not write a paragraph, do not pile on technical detail, and do not enumerate every sub-change.
- **For new user-facing features, append a brief usage hint** so users know how to try it. Keep it to a single short line — a command name, a subcommand, a flag, or a one-line "how to use". Do not explain design rationale or list edge cases. Skip the hint for bug fixes, internal changes, and refactors.
- Slash command: `Add the /foo slash command to list active sessions. Run /foo to see them.`
- CLI subcommand: `Add the kimi web subcommand to open the web UI. Run kimi web to launch it.`
- Flag: `Add a --bar flag to skip confirmation prompts. Pass --bar to skip.`
- Too long: `Add the /foo command to list active sessions. It accepts an optional --all flag to include background sessions, supports filtering by name with /foo <name>, and writes the result to the transcript...`
- User-facing CLI wording should only be used when CLI users can perceive the change.
- Internal changes that do not affect CLI users can still share a changeset with the CLI, but the wording must describe the real change honestly and must not present it as a user-facing feature.
- Do not mention file names, class names, function names, PR numbers, or commit hashes.
- Do not include real internal endpoints, key names, account names, or service names. If an example is needed, use neutral placeholders such as `example.com`, `example.test`, or `YOUR_API_KEY`.
- Avoid vague words such as `refactor`, `optimize`, and `improve`. Describe the actual change, or use more specific wording.
## When You Are Unsure About a Change
Generate the changeset from what the diff clearly shows. If part of a change is unclear and you cannot confidently describe what it does for users, do not guess or pad the entry with vague wording.
1. Finish the changeset for the parts that are clear.
2. Then ask the user once, in a short list: name the specific change(s) you do not understand, and ask whether you may dig into the repository (read related source, tests, or call sites) to describe it more accurately.
3. Only read more code after the user agrees. If the user says no or does not reply, keep the concise wording you already have and do not invent detail.
## Common Examples
An internal package fixes a bug visible to CLI users:
```markdown
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
---
Fix occasional loss of tool call results in long conversations.
```
A new user-facing slash command (note the short usage hint):
```markdown
---
"@moonshot-ai/kimi-code": minor
---
Add the /foo slash command to list active sessions. Run /foo to see them.
```
A new CLI subcommand:
```markdown
---
"@moonshot-ai/kimi-code": minor
---
Add the kimi web subcommand to open the web UI. Run kimi web to launch it.
```
A new flag on an existing command:
```markdown
---
"@moonshot-ai/kimi-code": patch
---
Add a --bar flag to skip confirmation prompts. Pass --bar to skip.
```
An internal package has an internal-only change, but it enters the CLI bundle:
```markdown
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
---
@ -97,12 +143,83 @@ Only SDK source changed, and the CLI does not use it:
Clarify session status typing for internal SDK callers.
```
## Web app changes
`@moonshot-ai/kimi-web` is ignored by changesets and must **never** appear in a changeset frontmatter. Because the web app is bundled into the CLI release artifact, any web change that ships must list `@moonshot-ai/kimi-code` instead and describe the actual web-facing change in the text.
- Prefix the changelog entry text with `web: ` (for example `web: Fix the chat not scrolling to the bottom after sending a message.`) so the synced docs changelog can mark web UI entries. Apply this whenever the change is to the web project (`@moonshot-ai/kimi-web`).
- If a PR ships a web UI feature backed by server API changes that exist solely to power that feature, prefer a single `web:` entry describing what the web user gets. Do not add a separate server-API changeset unless the API has independent user value (a public endpoint that SDK or server consumers call directly). The docs changelog sync also deduplicates this pattern, but catching it here avoids duplicate changesets.
- Do not enumerate every micro-tweak; keep it to one sentence that captures what the web user gets.
Web-only fix:
```markdown
---
"@moonshot-ai/kimi-code": patch
---
web: Fix the chat not scrolling to the bottom after sending a message.
```
Web UI plus backing server APIs in the same PR (prefer a single `web:` entry; the API is plumbing):
```markdown
---
"@moonshot-ai/kimi-code": minor
---
web: Add the server-hosted web UI, including chat layout and session list behaviors.
```
Split into two changesets only when the API has independent user value on its own (for example, a public endpoint SDK consumers call directly). In that case add the web entry above plus a separate one such as `Add a public REST API to list archived sessions for SDK consumers.`
## `@moonshot-ai/pi-tui` changes
`@moonshot-ai/pi-tui` is a vendored fork that lives in `packages/pi-tui`. It is `private: true` and is never published, but it is **not** ignored by changesets: changesets versions it and writes `packages/pi-tui/CHANGELOG.md` so the fork keeps its own history. Because it is bundled into the CLI like other internal packages, it is an exception to Core Rule 4 — do **not** list `@moonshot-ai/kimi-code` for a change that only touches pi-tui.
- Changes that only affect pi-tui (build, package, strict-mode cleanup, renderer fixes): list `@moonshot-ai/pi-tui` only. No CLI changeset.
- If the same change is also user-visible in the CLI (for example a terminal rendering fix that CLI users can see), add a **separate** changeset that lists `@moonshot-ai/kimi-code` with CLI-focused wording, in addition to the pi-tui changeset. Do not mix both packages in one frontmatter — the two changelogs need different wording.
pi-tui-only change:
```markdown
---
"@moonshot-ai/pi-tui": patch
---
Export the package manifest so the bundled binary can locate its native assets.
```
pi-tui change that is also visible in the CLI (two separate changesets):
```markdown
---
"@moonshot-ai/pi-tui": patch
---
Clamp the differential render to the visible viewport so scrolling up during streaming no longer jumps to the top.
```
```markdown
---
"@moonshot-ai/kimi-code": patch
---
Fix the transcript jumping to the top when scrolling up through history during streaming output.
```
## Red Flags
- You are about to write `major` without asking the user.
- A new user-facing feature entry has no usage hint, or the hint runs to multiple lines and explains design rationale.
- You guessed wording for a change you do not understand instead of asking the user whether you may dig into the repo.
- Internal package source enters the CLI bundle, but `@moonshot-ai/kimi-code` is missing.
- A changeset frontmatter mixes ignored internal packages with non-ignored packages.
- `packages/node-sdk` was not changed, but `@moonshot-ai/kimi-code-sdk` was listed for "internal package sync".
- The changelog entry is in Chinese.
- The wording claims more than the diff actually did.
- The CLI wording mentions internal package names, class names, or PR numbers.
- The entry includes real internal identifiers instead of neutral placeholders.
- A change that only touches `@moonshot-ai/pi-tui` lists `@moonshot-ai/kimi-code` instead of `@moonshot-ai/pi-tui`, or mixes both packages in one frontmatter.
- A web app change entry is missing the `web: ` prefix.
- A server/API changeset exists only to back a web feature that a `web:` changeset already describes (use one `web:` entry instead, unless the API has independent user value).

View file

@ -0,0 +1,67 @@
---
name: pre-changelog
description: Use before merging a kimi-code release PR to preview the user-facing CLI changelog in Chinese. Reads the changelog that changesets pre-generated in the release PR, then reuses sync-changelog's strip / classify / translate logic to render a Chinese preview. Writes no files.
---
# Pre-Changelog
Preview the user-facing **Chinese** changelog of an open `kimi-code` release PR **before** it is merged. Read-only: this skill writes no files and commits nothing.
This skill reuses `sync-changelog`'s strip / classify / translate rules. Read `sync-changelog` first; only the data source (release PR diff instead of a published `CHANGELOG.md`) and the output (preview instead of docs files) differ.
## Workflow
### 1. Locate the release PR
```bash
gh pr list --state open --search "ci: release packages in:title" \
--json number,title,url,headRefName,baseRefName
```
Pick the one with `headRefName: changeset-release/main`; record `number`, `url` as `<RELEASE>`. If none is open, nothing to preview — stop.
### 2. Read the pre-generated CLI changelog block
changesets already pre-generates `apps/kimi-code/CHANGELOG.md` inside the release PR. Extract the new version block from the diff:
```bash
gh api repos/MoonshotAI/kimi-code/pulls/<RELEASE>/files \
--jq '.[] | select(.filename=="apps/kimi-code/CHANGELOG.md") | .patch'
```
Take the added lines (`+`) from the top `## <version>` down to (but not including) the next `## `. That is the version block to preview.
If the CLI changelog is not in the diff (for example an SDK-only release), stop and tell the user — there is no user-facing CLI changelog to preview.
### 3. Render the Chinese preview (reuse `sync-changelog`)
Process the version block exactly as `sync-changelog` does for the docs site, but only in memory:
- **Strip** (`sync-changelog` step 3): drop the H1, the `### Patch Changes` / `### Minor Changes` / `### Major Changes` subheadings, PR links, and commit-hash links; keep only each entry's body text. The `Thanks [@user](...)!` credit (including the multi-author form) must be removed every time. Within each entry, drop SDK-only and provider-internal sentences (SDK capability mapping / API exposure, provider wire-format mechanics, internal XML markers) and keep only the user-facing effect and required constraints.
- **Merge and deduplicate** (`sync-changelog` step 4): merge micro-tweaks to the same surface into one higher-level entry; when three or more fixes target the same UI area or the same class of problem, merge them into one higher-level fix entry (do not merge broad or genuinely distinct fixes); and drop a server/API entry that only backs a web feature already listed.
- **Classify** (`sync-changelog` step 4): bucket into Features / Bug Fixes / Polish / Refactors / Other; order within each section by reader value (in Polish, user-visible improvements before protocol/internal adjustments).
- **Translate** (`sync-changelog` step 6): translate entry bodies to Chinese; keep one sentence per entry with a parallel rhythm within a section; section headings become 新功能 / 修复 / 优化 / 重构 / 其他.
If an upstream entry is not in English, flag it and stop (changeset entries must be English).
### 4. Output
Print the preview directly. Use `<version>(预览)` as the heading because the version is not released yet. Write `无` for empty sections. Do not write any file.
```
发版 PR: <url>
## <version>(预览)
### 新功能
- ...
### 修复
- ...
```
## Rules
- Read-only. Never write `CHANGELOG.md`, docs files, or commit anything.
- Classification, ordering, and translation follow `sync-changelog` exactly — do not reword or reclassify beyond what it specifies.
- If the release PR has no CLI changelog diff, report it and stop.

View file

@ -1,6 +1,6 @@
---
name: sync-changelog
description: Use after a release succeeds, when maintainers need to sync apps/kimi-code/CHANGELOG.md into docs/en/release-notes/changelog.md and docs/zh/release-notes/changelog.md.
description: Use after a release succeeds, when maintainers need to sync apps/kimi-code/CHANGELOG.md into docs/en/release-notes/changelog.md and docs/zh/release-notes/changelog.md, then open a PR on a dedicated branch.
---
# Sync Changelog
@ -15,7 +15,7 @@ apps/kimi-code/CHANGELOG.md
This file is the **only upstream source** for the documentation-site changelog. Internal package changelogs such as `packages/*/CHANGELOG.md` do not go into the documentation site.
After the release flow finishes (Release PR merged → `Version Packages` completed → npm publish succeeded), maintainers manually run this skill to copy the new CLI changelog entries into the docs site and translate the English increment into Chinese.
After the release flow finishes (Release PR merged → `Version Packages` completed → npm publish succeeded), maintainers manually run this skill to copy the new CLI changelog entries into the docs site, translate the English increment into Chinese, wait for an optional human review, then commit on a dedicated branch and open a PR.
## When To Use
@ -41,39 +41,65 @@ Before editing, confirm:
- The released version exists on npm (`npm view @moonshot-ai/kimi-code versions --json`) or has a matching GitHub Release tag.
- The top of `apps/kimi-code/CHANGELOG.md` is that new version.
- The current branch is clean, or you are on a dedicated docs-sync branch.
If any condition is not true, stop and confirm with the user.
Do **not** edit or commit directly on `main`. All sync work happens on a dedicated branch created in step 1.
## Workflow
### 1. Find The Version Range
### 1. Prepare Branch
Start from an up-to-date default branch:
```bash
# Upstream versions
rg '^## ' apps/kimi-code/CHANGELOG.md | head -20
git fetch origin
git checkout main
git pull --ff-only origin main
```
# Latest version already synced into the English docs page
Before creating the branch, peek at the version range so the branch name matches the newest version being synced:
```bash
rg '^## ' apps/kimi-code/CHANGELOG.md | head -5
rg '^## ' docs/en/release-notes/changelog.md | head -5
```
Name the branch after the newest upstream version that is not yet in the English docs page:
```text
docs/changelog-sync-<newest-version>
```
Example: syncing `0.2.1` only → `docs/changelog-sync-0.2.1`.
```bash
git checkout -b docs/changelog-sync-<newest-version>
```
If the branch already exists locally or on the remote, stop and confirm with the user instead of reusing it.
### 2. Find The Version Range
Use the same version lists from step 1. Confirm:
- First sync: copy all upstream version blocks into the English page.
- Incremental sync: copy every upstream version block above the latest version already present in the English page.
Use upstream order: newest version first.
### 2. Strip Decorations And Extract Entry Text
### 3. Strip Decorations And Extract Entry Text
Upstream entries look like this:
```markdown
- [#317](https://github.com/...) [`2f51db4`](https://github.com/...) - Clean up lint warnings ...
- [#317](https://github.com/...) [`2f51db4`](https://github.com/...) Thanks [@user](https://github.com/...)! - Clean up lint warnings ...
```
Keep:
Changesets may add a `Thanks ...!` credit, but it must be removed every time. Keep:
- Version headings such as `## 0.2.0`.
- Only the body text of each entry, after the PR/hash decoration.
- Only the body text of each entry, after the PR/hash decoration and any `Thanks ...!` credit have been removed.
Remove:
@ -81,26 +107,49 @@ Remove:
- Changesets subheadings such as `### Patch Changes`, `### Minor Changes`, and `### Major Changes`.
- PR links such as `[#317](...)`.
- Commit hash links such as ``[`2f51db4`](...)``.
- The `Thanks [@user](...)!` credit, including the multi-author form `Thanks [@a](...), [@b](...)!`. Drop the whole `Thanks ...!` segment every time, regardless of whether the feature is enabled.
After stripping, each entry should be only:
After stripping, each entry is `- <body text>`.
Drop SDK-only and provider-internal detail. This changelog serves `@moonshot-ai/kimi-code` CLI and web users. Within an entry, keep only what CLI/web users can perceive, and remove sentences that document internals instead of user-visible behavior. Apply this on both the English and Chinese pages:
- Drop sentences about how the SDK maps a capability, builds model aliases, or exposes a flag through an API such as `getExperimentalFeatures()` — that belongs in the SDK changelog, not here.
- Drop provider / wire-format implementation mechanics (XML markers like `<tools_added>`, protocol field explanations, "the wire protocol is unchanged", cache-hit mechanics) unless they are the behavior a user perceives.
- Keep the user-facing effect and any constraints users must follow (for example "question texts must be unique").
Do not change facts or drop a real user-facing behavior — only trim the internal-only scaffolding. For over-long, internal-heavy entries, this trim applies on the English page too, not only in translation.
Web UI prefix: if the entry is a web UI change, prefix the body text with `web: ` so readers can tell it affects the web UI:
```markdown
- <body text>
- web: <body text>
```
An entry counts as a web UI change when its upstream commit touches `apps/kimi-web/`. Check with `git show --name-only <hash>` (the commit hash is the one stripped above). `gen-changesets` writes this prefix for web changes, so it is usually already present in upstream — preserve it when it is there, and add it when a web entry lacks it. When a commit touches both web and non-web code, use `web:` only if the user-facing change described by the entry is in the web UI. Keep the `web:` prefix on the Chinese page too — it is a scope marker, not translated text.
Upstream language rule: `gen-changesets` requires changelog entries to be English. If the upstream CLI changelog contains a non-English entry, stop and report it to the user. Do not silently rewrite it while syncing docs.
Public-text rule: do not copy real internal endpoints, key names, account names, or service names into docs changelogs. Replace examples with neutral placeholders such as `example.com`, `example.test`, or `YOUR_API_KEY` while preserving the user-visible meaning.
### 3. Classify Entries
### 4. Merge, Deduplicate, And Classify Entries
Before classifying, merge related entries and drop redundant ones from the user-facing changelog:
- **Merge micro-tweaks to the same surface.** Collapse several small tweaks to the same UI area or feature into one concise entry at the higher level. For example, "change the composer's default height" and "change the composer's default font" merge into "Polish the composer's default styling." Use the most specific common ancestor (composer, settings page, tool card, and so on). Classify the merged entry by its combined effect, and keep the `web:` prefix if the combined change is still web-facing.
- **Merge same-surface or same-kind fixes when you have three or more.** The `Bug Fixes` section tends to accumulate many narrow UI/polish fixes that read as noise when listed one by one. When three or more fixes target the same area (for example several tool cards in the TUI, or the web session/conversation surface) or the same class of problem (for example several "jumping/flickering/collapsing during streaming" fixes), merge them into one higher-level entry. Examples:
- "Fix the Bash tool card collapsing...", "Fix the Edit tool card jumping in height...", "Fix the Edit tool card flickering while its result streams in" → "Fix several TUI tool cards jumping, flickering, or collapsing in height when results stream in or end with short output."
- "Fix the collapsed sidebar not hiding...", "Stop the chat history from replaying its entrance animation...", "Fix tool components jumping the conversation when expanded/collapsed" → "web: Fix several layout and display glitches when switching sessions, including the collapsed sidebar not hiding, the chat history replaying its entrance animation, and tool components jumping the conversation."
- Keep `web:` if the merged fixes are all web-facing. Classify as `Bug Fixes`.
- **Do not over-merge.** Leave a fix standalone when it is broad, high-value, or genuinely distinct (for example model/provider tool-calling bugs, session-list corruption, file-completion gaps). Merging is for low-reader-value, similar-shape fixes that read as a wall of similar bullets.
- **Drop server/API plumbing covered by a web entry.** If one entry adds a web UI feature (for example, an Archived sessions page) and another entry only adds the server or REST/WebSocket endpoints that exist solely to power that web feature, keep the `web:` entry and drop the API entry. CLI and web users perceive the web page; the backing API is implementation detail with no independent user value on this changelog. Keep the API entry only when it has independent user value — a new public endpoint that SDK or server consumers call directly, or a capability usable outside the web feature. When unsure, keep both and let the reviewer decide.
The docs changelog uses five section types:
| English section | Chinese section | Meaning |
|---|---|---|
| `### 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 |
| `### 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 |
| `### Other` | `### 其他` | Anything that does not fit above, such as CDN/endpoint swaps and docs-related artifacts |
@ -114,6 +163,8 @@ Classification process:
Features vs. Polish: ask whether the entry introduces something the user could not do before. If yes (new command, flag, mode, viewer, or capability), use `Features`. If it only improves an existing surface (a UI panel that already existed, an existing prompt, an existing tool card, an existing payload pipeline), use `Polish`. Verbs like `Add` do not automatically mean `Features` — a small visual addition to an existing UI is still polish.
Default-behavior changes: changing the default value of an existing capability (for example flipping a feature on by default) is usually `Polish`, because the capability already existed. Use `Features` only when the new default materially changes the out-of-box experience for most users in a way they could not get before. When genuinely ambiguous, flag it and confirm with the reviewer rather than guessing.
Keyword hints:
- **Features**: `Add ... command/flag/option/mode/viewer`, `Introduce`, `Support`, `Allow`, `Enable`, `Implement`, `New ... command/flag/option`
@ -125,18 +176,19 @@ Keyword hints:
Within each version, section order is:
```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:
1. Put the most valuable, obvious, and larger changes first.
2. Prefer broad user-visible features, workflow-changing fixes, high-frequency bugs, and large cross-cutting improvements over small polish, narrow edge cases, and internal cleanup.
3. If entries have similar value, preserve upstream order.
3. Within `Polish`, put directly user-visible UX or performance improvements (something users can see or feel) before protocol or internal-behavior adjustments (something that makes the model or pipeline behave more reliably but is invisible to users).
4. If entries have similar value, preserve upstream order.
Do not reword or exaggerate entries just to make them look more important; only reorder existing entries.
### 4. Write The English Page
### 5. Write The English Page
Never change the English page header:
@ -148,10 +200,24 @@ This page documents the changes in each Kimi Code CLI release.
Insert new version blocks immediately after the header paragraph and before the previous latest version.
Every version heading must carry its release date in parentheses:
```text
## <version> (YYYY-MM-DD)
```
Take the date from the version's published GitHub Release tag, not from when you run the sync:
```bash
git log -1 --format=%cs "@moonshot-ai/kimi-code@<version>"
```
Use the half-width parenthesis form ` (YYYY-MM-DD)` on the English page. Never invent or guess a date; if the tag is missing, stop and confirm with the user.
Example:
```markdown
## 0.2.0
## 0.2.0 (2026-05-26)
### Bug Fixes
@ -163,7 +229,7 @@ Example:
- Update the native release workflow to use current GitHub artifact actions.
```
### 5. Translate The Increment Into Chinese
### 6. Translate The Increment Into Chinese
After updating the English page, translate only the newly added English content into `docs/zh/release-notes/changelog.md`.
@ -179,7 +245,7 @@ Chinese page requirements:
本页记录 Kimi Code CLI 每个版本的变更内容。
```
- Preserve version headings exactly, such as `## 0.2.0`.
- Preserve version headings including the release date, but use full-width parentheses on the Chinese page, such as `## 0.2.02026-05-26`. The date must match the English page; only the parenthesis style differs (half-width `()` in English, full-width `` in Chinese).
- Translate section headings exactly:
- `### Features``### 新功能`
- `### Bug Fixes``### 修复`
@ -191,7 +257,54 @@ Chinese page requirements:
- Translate only entry body text. Do not add entries that are not present in English.
- Follow `docs/AGENTS.md` for Chinese typography: full-width punctuation, spaces between Chinese and English, and the glossary.
### 6. Verify
#### Chinese wording style
Structural fidelity does not mean literal translation. The Chinese entries should read like a concise, idiomatic Chinese changelog. Keep the same facts as the English entry, but rephrase for natural Chinese prose.
Guidelines:
- **One entry, one sentence.** Avoid chaining multiple effects with commas or semicolons. If the English entry is long, split it into shorter sentences or keep only the most important effect.
- **Drop SDK-only and provider-internal detail.** Apply the trim from step 3 while translating: keep the user-facing effect and required constraints, drop SDK-mapping sentences, provider / wire-format mechanics, and internal XML markers. A long internal entry should collapse to one short Chinese sentence about what the user gets.
- **Prefer common changelog verbs**: 新增、支持、修复、优化、改进、调整.
- **Avoid indirect "through... make..." structures**. Do not write "通过 X使 Y"; prefer direct cause-effect or just state the result.
- Bad: `通过缓存已渲染消息行,使终端在长篇对话中保持响应。`
- Better: `缓存已渲染消息行,提升长对话下终端的响应速度。`
- **Be specific, not vague**. Prefer concrete actions over abstract quality words.
- Bad: `加固默认系统提示词和内置工具描述。`
- Better: `优化默认系统提示词与内置工具描述,避免 Agent 阻塞后台任务。`
- **Name concrete files or config keys when it helps clarity**.
- Bad: `插件现在可以在其清单中声明 hooks。`
- Better: `插件现支持在 kimi.plugin.json 中声明生命周期 hooks。`
- **Include required argument placeholders in CLI options**.
- Bad: `--allowed-host`
- Better: `--allowed-host <host>`
- **Keep usage hints to one short clause**.
- Bad: `传入 --allowed-host 以允许额外的 host。例如 ... (多句展开)`
- Better: `例如 kimi web --allowed-host example.com。`
- **Do not translate technical identifiers**: keep command names, flag names, file names, env vars, config keys, and the `web:` scope prefix as-is.
- **Keep parallel rhythm within a section.** When several entries fix similar web surfaces (layout, animation, sizing), phrase them with a consistent structure (for example 修复 <问题>,现 <行为>) so the section reads as a tidy list rather than a mix of shapes.
Example — translating a feature entry:
English source:
```markdown
- Add a --allowed-host flag to kimi web that lets extra Host header values pass the DNS-rebinding check, and include allow guidance in the 403 error message. Pass --allowed-host <host> to allow an extra host.
```
Before (literal, wordy):
```markdown
- 为 `kimi web` 新增 `--allowed-host` 标志,允许额外的 Host 请求头值通过 DNS 重绑定检查,并在 403 错误消息中包含允许指引。传入 `--allowed-host <host>` 以允许额外的 host。例如 `kimi web --allowed-host example.com`
```
After (concise, idiomatic):
```markdown
- `kimi web` 新增 `--allowed-host <host>` 选项,可将指定 Host 加入 DNS 重绑定白名单403 错误会提示如何通过 `--allowed-host``KIMI_CODE_ALLOWED_HOSTS` 放行,例如 `kimi web --allowed-host example.com`
```
### 7. Verify
Review:
@ -202,10 +315,12 @@ git diff docs/en/release-notes/changelog.md docs/zh/release-notes/changelog.md
Check:
- Versions and version counts match between English and Chinese.
- Every version heading carries its release date from the published tag, with half-width parentheses in English and full-width in Chinese.
- Each version has the same section set and order on both pages.
- Each section has the same number of entries on both pages.
- Within each section, the most valuable, obvious, and larger entries appear before smaller or narrower entries.
- PR links and commit hashes were stripped.
- No `Thanks ...!` credit remains (remove it every time).
- Real internal identifiers were replaced with neutral placeholders.
- There are no empty sections.
- Markdown indentation and blank lines are intact.
@ -216,7 +331,36 @@ Then run the docs build:
pnpm --filter docs run build
```
### 7. Commit
### 8. Human Review Checkpoint
After verification passes, **before committing**, ask the user whether they want to review the sync result. Use `AskQuestion` with options such as:
- **Review first** — show the diff and wait for the user to finish checking.
- **Skip review, commit and open PR** — proceed directly to steps 9 and 10.
If the user chooses review:
1. Show the uncommitted diff:
```bash
git diff docs/en/release-notes/changelog.md docs/zh/release-notes/changelog.md
```
2. Summarize synced versions, section counts, and anything that needed manual classification.
3. Tell the user to reply when they are done reviewing, or to ask for edits.
4. Do **not** commit, push, or open a PR until the user explicitly says review is complete, or asks to proceed.
If the user requests edits during review, make the changes, re-run verification from step 7, and return to this checkpoint.
### 9. Commit
Only run this step when the user skipped review or confirmed review is complete.
Stage only the changelog docs files:
```bash
git add docs/en/release-notes/changelog.md docs/zh/release-notes/changelog.md
```
Use a neutral docs-sync commit message:
@ -226,12 +370,66 @@ docs(changelog): sync <version range> from apps/kimi-code/CHANGELOG.md
Do **not** create a changeset for changelog docs sync. Docs sync does not enter the bundle.
### 10. Push And Open PR
Run immediately after step 9.
Push the branch:
```bash
git push -u origin HEAD
```
Create the PR with `gh pr create`. Title follows Conventional Commits:
```text
docs(changelog): sync <version range> from apps/kimi-code/CHANGELOG.md
```
Fill in `.github/pull_request_template.md`. For changelog sync PRs:
- **Related Issue**: write `N/A — post-release docs maintenance` (no issue required).
- **Problem**: the docs-site changelog is behind the published CLI release(s).
- **What changed**: list synced version(s), note English source + Chinese translation, and mention verification (`pnpm --filter docs run build`).
- **Checklist**: check CONTRIBUTING; explain no issue, no tests, no changeset, and that `gen-docs` is not needed because this is the dedicated changelog sync flow.
Example body:
```markdown
## Related Issue
N/A — post-release docs maintenance
## Problem
The docs-site changelog has not yet been synced for `<version range>` after the npm release.
## What changed
- Synced `<version range>` from `apps/kimi-code/CHANGELOG.md` into `docs/en/release-notes/changelog.md`
- Translated the new English increment into `docs/zh/release-notes/changelog.md`
- Verified with `pnpm --filter docs run build`
## Checklist
- [x] I have read the CONTRIBUTING document.
- [x] I have linked a related issue, or explained the problem above.
- [ ] I have added tests that prove my feature works. (N/A — docs-only sync)
- [x] Ran `gen-changesets` skill, or this PR needs no changeset. (No changeset — docs sync is out of bundle)
- [x] Ran `gen-docs` skill, or this PR needs no doc update. (This PR is the dedicated changelog sync)
```
Return the PR URL to the user when done.
## Rules
- The English docs changelog is the source of truth.
- Never edit upstream `apps/kimi-code/CHANGELOG.md`.
- Do not backfill unreleased `.changeset/*.md` drafts into the docs site.
- Prefix web UI entries with `web: ` (when the upstream commit touches `apps/kimi-web/`), and keep the prefix on both the English and Chinese pages.
- If upstream wording is wrong, leave upstream alone and fix it in a future changeset.
- Always sync on a `docs/changelog-sync-*` branch and open a PR; never push changelog docs sync directly to `main`.
- Wait for the human review checkpoint before committing, pushing, or opening a PR.
## Common Mistakes
@ -239,6 +437,10 @@ Do **not** create a changeset for changelog docs sync. Docs sync does not enter
|---|---|
| Adding entries directly to the English docs page without reading upstream | Use `apps/kimi-code/CHANGELOG.md` as the source |
| Copying PR links or commit hashes into docs | Strip them; keep only body text |
| Leaving the `Thanks ...!` credit in docs | Remove it every time, including the multi-author form |
| Leaving near-duplicate micro-tweaks as separate bullets | Merge small tweaks to the same surface into one higher-level entry (e.g. composer height + font → composer's default styling) |
| Listing many narrow fixes to the same surface as separate bullets | When three or more fixes target the same UI area or the same class of problem, merge them into one higher-level fix entry; keep genuinely distinct or high-value fixes standalone |
| Listing a server/API entry that only backs a web feature already listed | Drop the API entry and keep the `web:` entry, unless the API has independent user value |
| Rewording upstream English entries | Upstream is frozen; copy the body text unless the user explicitly asks otherwise |
| Leaving English text untranslated in the Chinese page | The Chinese page must be fully Chinese except preserved technical terms |
| Editing upstream changelog text | Do not edit upstream |
@ -253,7 +455,11 @@ Do **not** create a changeset for changelog docs sync. Docs sync does not enter
| Putting everything under Other for convenience | Classify what can be classified first |
| Translating tool names, command names, or config keys | Keep them as written |
| Creating a changeset for docs sync | Do not create one |
| Committing or pushing directly on `main` | Create `docs/changelog-sync-<version>`, commit there, then open a PR |
| Committing or opening a PR before the user skips review or confirms review is done | Wait at the human review checkpoint |
| Using curly quotes or half-width Chinese punctuation | Follow `docs/AGENTS.md` |
| Omitting the release date from a version heading, or guessing it | Add ` (YYYY-MM-DD)` (full-width `` in Chinese) taken from the published tag |
| Forgetting or translating the `web:` prefix on web UI entries | Prefix web UI entries (commit touches `apps/kimi-web/`) with `web: ` on both pages; keep the prefix as-is when translating |
## Stop Signals
@ -263,3 +469,5 @@ Do **not** create a changeset for changelog docs sync. Docs sync does not enter
- English and Chinese versions, entry counts, or section sets do not match.
- A section is empty.
- A Chinese term is uncertain and `docs/AGENTS.md` does not answer it.
- A `docs/changelog-sync-*` branch already exists for the same version and you cannot confirm whether it is stale.
- The user asked to review but has not yet confirmed review is complete.

View file

@ -68,6 +68,8 @@ Themes are managed centrally under `src/tui/theme/`:
- `bundle.ts` — packs `colors`, `styles`, `markdownTheme` into a `KimiTUIThemeBundle`.
- `index.ts` / `detect.ts` — theme type and auto/dark/light resolution.
> **Keep the color-token set in sync.** `ColorPalette` in `colors.ts` is the source of truth for color tokens. When you add, rename, or remove one, update its mirrors in the same change: the custom-theme JSON schema (`apps/kimi-code/src/tui/theme/theme-schema.json`), the token tables in the custom-theme docs (`docs/en/customization/themes.md` and `docs/zh/customization/themes.md`), and the token table in the `custom-theme` built-in skill (`packages/agent-core/src/skill/builtin/custom-theme.md`).
Apply / switch flow:
- UI entry: `ThemeSelectorComponent``handleThemeCommand``applyThemeChoice`.

View file

@ -15,11 +15,17 @@ Current publishable packages:
All other workspace packages are private internal packages, are not published to npm, and are excluded via `ignore` in `.changeset/config.json`:
- `@moonshot-ai/acp-adapter`
- `@moonshot-ai/agent-core`
- `@moonshot-ai/kaos`
- `@moonshot-ai/kimi-code-oauth`
- `@moonshot-ai/kimi-telemetry`
- `@moonshot-ai/kaos`
- `@moonshot-ai/kimi-web`
- `@moonshot-ai/kosong`
- `@moonshot-ai/migration-legacy`
- `@moonshot-ai/protocol`
- `@moonshot-ai/server`
- `@moonshot-ai/server-e2e`
- `@moonshot-ai/vis`
- `@moonshot-ai/vis-server`
- `@moonshot-ai/vis-web`
@ -39,6 +45,7 @@ Example scenarios:
| SDK behavior change affects CLI user experience | Add changesets to both `@moonshot-ai/kimi-code-sdk` and `@moonshot-ai/kimi-code` |
| Provider abstraction change affects SDK / CLI | Add changesets to the affected `@moonshot-ai/kimi-code-sdk` and/or `@moonshot-ai/kimi-code` |
| Test-only, internal refactor, docs, or private debug tooling changes | Usually no changeset needed |
| Bundled official plugin change under `plugins/` (e.g. `kimi-datasource`) | No changeset — the plugin is versioned via its own `kimi.plugin.json` / `plugins/marketplace.json` and shipped through the marketplace CDN, not the npm package |
## Prerequisite: NPM Trusted Publishing (OIDC)
@ -138,6 +145,7 @@ The root-level `pnpm run publish` first runs typecheck, lint, sherif, test, buil
## Notes
- Every PR that affects publishable-package behavior or public API should include a corresponding changeset.
- Changes under `plugins/` (the bundled official plugins such as `kimi-datasource`) do **not** need a changeset: each plugin carries its own version in `kimi.plugin.json` and `plugins/marketplace.json` and is distributed via the marketplace CDN, separately from the `@moonshot-ai/kimi-code` npm package.
- Changeset files must be committed to the repository — release PRs are only triggered after they're merged.
- Release PRs require human review and merge; they will not publish automatically.
- Do not add release changesets for private internal packages; only select `@moonshot-ai/kimi-code` and `@moonshot-ai/kimi-code-sdk`.

View file

@ -1,5 +1,5 @@
{
"changelog": ["@changesets/changelog-github", { "repo": "MoonshotAI/kimi-code", "disableThanks": true }],
"changelog": ["@changesets/changelog-github", { "repo": "MoonshotAI/kimi-code" }],
"commit": false,
"fixed": [],
"linked": [],
@ -7,6 +7,7 @@
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": [
"@moonshot-ai/server-e2e",
"@moonshot-ai/vis",
"@moonshot-ai/vis-server",
"@moonshot-ai/vis-web"

View file

@ -0,0 +1,7 @@
---
"@moonshot-ai/kosong": patch
"@moonshot-ai/kimi-code": patch
"@moonshot-ai/kimi-code-sdk": patch
---
Rename the dynamic tool loading model capability from `select_tools` to `dynamically_loaded_tools`.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Update the WebBridge install page link opened from the /plugins panel.

10
.gitattributes vendored Normal file
View file

@ -0,0 +1,10 @@
# Enforce LF line endings in the working tree on every platform so that
# raw-imported text (e.g. `*.md?raw` templates) is byte-identical on Windows
# and POSIX. Without this, Git for Windows' default `core.autocrlf=true`
# checks text files out as CRLF, which shifts token-count snapshots.
* text=auto eol=lf
# Binary assets — never normalize line endings.
*.gif binary
*.ico binary
*.png binary

View file

@ -85,6 +85,13 @@ jobs:
node apps/kimi-code/scripts/update-catalog.mjs --out "$CATALOG_FILE"
echo "KIMI_CODE_BUILT_IN_CATALOG_FILE=$CATALOG_FILE" >> "$GITHUB_ENV"
- name: Build Kimi web assets
# The SEA blob step embeds apps/kimi-code/dist-web; build the web app
# and stage its assets before producing the native executable.
run: |
pnpm --filter @moonshot-ai/kimi-web run build
node apps/kimi-code/scripts/copy-web-assets.mjs
- name: Build native executable (release profile, macOS signed)
if: runner.os == 'macOS' && inputs.sign-macos
run: pnpm --filter @moonshot-ai/kimi-code run build:native:release

View file

@ -44,6 +44,45 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: 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.
test-pi-tui:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @moonshot-ai/pi-tui test
test-windows:
runs-on: windows-latest
# Temporarily disabled while Windows tests are being stabilized.
if: false
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
# Windows runners are slower and run the whole suite (including
# in-process e2e tests) under more contention, so the default 5s test
# timeout causes flaky failures. Give it more headroom.
- run: pnpm run test -- --testTimeout=30000
lint:
runs-on: ubuntu-latest
@ -82,3 +121,9 @@ jobs:
echo "Typechecking ${config}"
pnpm dlx --package @typescript/native-preview@beta tsgo -p "${config}" --noEmit
done
- name: Typecheck kimi-web (vue-tsc)
run: pnpm --filter @moonshot-ai/kimi-web run typecheck
- name: Typecheck vis-server
run: pnpm --filter @moonshot-ai/vis-server run typecheck
- name: Typecheck vis-web
run: pnpm --filter @moonshot-ai/vis-web run typecheck

170
.github/workflows/desktop-build.yml vendored Normal file
View file

@ -0,0 +1,170 @@
name: desktop-build
# Builds the Kimi Desktop (Electron) installers for macOS, Windows and Linux.
# Each runner builds the matching-platform SEA backend first, then packages it
# with electron-builder.
#
# macOS is signed with a Developer ID certificate + notarized (so it opens on
# any Mac without the "app is damaged" Gatekeeper block) when `sign-macos` is
# true and the Apple secrets are configured. Windows/Linux ship unsigned in v1.
#
# Triggered two ways:
# - workflow_dispatch: manual ad-hoc builds from the Actions tab.
# - workflow_call: called by release.yml to attach installers to a release.
on:
workflow_dispatch:
inputs:
sign-macos:
description: 'Sign + notarize macOS (needs Apple secrets)'
required: false
type: boolean
default: true
retention-days:
description: 'Artifact retention in days'
required: false
type: number
default: 5
upload-artifact-prefix:
description: 'Prefix for uploaded artifact name'
required: false
type: string
default: 'kimi-desktop'
workflow_call:
inputs:
sign-macos:
description: 'Sign + notarize macOS (needs Apple secrets)'
required: false
type: boolean
default: false
retention-days:
description: 'Artifact retention in days'
required: false
type: number
default: 7
upload-artifact-prefix:
description: 'Prefix for uploaded artifact name'
required: false
type: string
default: 'kimi-desktop'
secrets:
APPLE_CERTIFICATE_P12:
required: false
APPLE_CERTIFICATE_PASSWORD:
required: false
APPLE_NOTARIZATION_KEY_P8:
required: false
APPLE_NOTARIZATION_KEY_ID:
required: false
APPLE_NOTARIZATION_ISSUER_ID:
required: false
permissions:
contents: read
jobs:
desktop:
name: Desktop installer (${{ matrix.target }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: macos-15
target: darwin-arm64
- os: macos-15-intel
target: darwin-x64
- os: windows-2025-vs2026
target: win32-x64
- os: ubuntu-24.04
target: linux-x64
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Kimi web assets
# The SEA blob embeds apps/kimi-code/dist-web; build the web app and
# stage its assets before producing the native executable.
# KIMI_WEB_DESKTOP=1 bakes the internal-build banner into the web bundle
# (see apps/kimi-web/src/components/InternalBuildBanner.vue); only the
# desktop sets this flag, so the CLI `kimi web` stays banner-free.
env:
KIMI_WEB_DESKTOP: '1'
run: |
pnpm --filter @moonshot-ai/kimi-web run build
node apps/kimi-code/scripts/copy-web-assets.mjs
- name: Build native executable (local profile)
# The Electron app signs the SEA itself (electron-builder, inside-out),
# so the native build stays unsigned here.
run: pnpm --filter @moonshot-ai/kimi-code run build:native:sea
- name: Setup macOS keychain (Developer ID)
if: runner.os == 'macOS' && inputs.sign-macos
uses: ./.github/actions/macos-keychain-setup
with:
certificate-p12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Prepare CSC_NAME for electron-builder (macOS)
if: runner.os == 'macOS' && inputs.sign-macos
shell: bash
run: |
# electron-builder rejects the "Developer ID Application: " prefix in
# CSC_NAME; strip it so the certificate matches by team name + ID.
name="${APPLE_SIGNING_IDENTITY}"
name="${name#Developer ID Application: }"
echo "CSC_NAME=$name" >> "$GITHUB_ENV"
- name: Prepare notarization API key (macOS)
if: runner.os == 'macOS' && inputs.sign-macos
shell: bash
env:
APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }}
run: |
set -euo pipefail
key_path="$RUNNER_TEMP/notary-AuthKey.p8"
printf '%s' "$APPLE_NOTARIZATION_KEY_P8" | base64 -d > "$key_path"
echo "APPLE_API_KEY=$key_path" >> "$GITHUB_ENV"
- name: Build & package desktop app
shell: bash
env:
# macOS signing is driven by env: when sign-macos, electron-builder
# signs with the keychain's Developer ID and notarizes via the notary
# API key; otherwise it builds unsigned.
CSC_IDENTITY_AUTO_DISCOVERY: ${{ (runner.os == 'macOS' && inputs.sign-macos) && 'true' || 'false' }}
CSC_KEYCHAIN: ${{ env.APPLE_KEYCHAIN_PATH }}
KIMI_DESKTOP_NOTARIZE: ${{ (runner.os == 'macOS' && inputs.sign-macos) && 'true' || 'false' }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
run: pnpm --filter @moonshot-ai/kimi-desktop run dist
- name: Cleanup macOS keychain
if: always() && runner.os == 'macOS' && inputs.sign-macos
uses: ./.github/actions/macos-keychain-cleanup
- name: Upload installers
uses: actions/upload-artifact@v7
with:
name: ${{ inputs.upload-artifact-prefix }}-${{ matrix.target }}
retention-days: ${{ inputs.retention-days }}
path: |
apps/kimi-desktop/dist-app/*.dmg
apps/kimi-desktop/dist-app/*.zip
apps/kimi-desktop/dist-app/*.exe
apps/kimi-desktop/dist-app/*.AppImage
apps/kimi-desktop/dist-app/*.deb
if-no-files-found: ignore

View file

@ -36,6 +36,9 @@ jobs:
- name: Build package dependencies
run: pnpm run build:packages
- name: Build Kimi web assets
run: pnpm --filter @moonshot-ai/kimi-web run build
- name: Generate Kimi Code built-in catalog
shell: bash
run: |

View file

@ -37,7 +37,7 @@ jobs:
registry-url: "https://registry.npmjs.org"
- name: Upgrade npm for Trusted Publishing
run: npm install -g npm@latest
run: npm install -g npm@11
- name: Install dependencies
run: pnpm install --frozen-lockfile
@ -97,6 +97,22 @@ jobs:
APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
desktop-artifacts:
name: Desktop release artifact
needs: release
if: needs.release.outputs.kimi_native_release == 'true'
uses: ./.github/workflows/desktop-build.yml
with:
upload-artifact-prefix: kimi-desktop
retention-days: 7
sign-macos: true
secrets:
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }}
APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
publish-native-assets:
name: Publish native release assets
needs:

27
.gitignore vendored
View file

@ -1,5 +1,7 @@
node_modules/
dist/
dist-web/
dist-single/
dist-native/
.tmp-api-extractor/
coverage/
@ -11,4 +13,27 @@ coverage/
.conductor
.kimi-stash-dir
plugins/cdn/
superpowers
.worktrees/
.kimi-code/local.toml
.kimi-sandbox/
Dockerfile
docker-compose.yml
.dockerignore
docs/superpowers/
reports/
.superpowers/
/plan/
# Agent scratch / throwaway files - do not commit
.tmp/
HANDOVER*.md
HANDOFF*.md
handoff.md
handover.md
*-designs.html
*-design.html
*-mockup.html
*-demo.html
*-demos.html

View file

@ -150,7 +150,7 @@
"node_modules/",
"apps/*/scripts/",
"docs/smoke-archive/",
"plugins/curated/superpowers/",
"packages/pi-tui/",
"*.generated.ts"
]
}

View file

@ -15,13 +15,16 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo
## 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-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/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, 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/kosong`: the LLM / provider abstraction layer.
- `packages/kaos`: the execution environment and file/process abstractions.
- `packages/oauth`: Kimi OAuth and managed auth utilities.
- `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/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
@ -32,9 +35,11 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo
## Monorepo Workspace Maintenance
- `pnpm-workspace.yaml` is the source of truth for workspace membership, but `flake.nix` also contains **hardcoded** `workspacePaths` and `workspaceNames` lists.
- **Whenever you add or remove a workspace package, you MUST update both `pnpm-workspace.yaml` and `flake.nix`.**
- **Whenever you add or remove a workspace package, you MUST update both `pnpm-workspace.yaml` and `flake.nix` — for every package, including leaf / test / e2e packages that nothing depends on.**
- `pnpm-workspace.yaml` uses globs (`packages/*`, `apps/*`), so most packages land there automatically; `flake.nix` is fully manual and is where omissions happen.
- Missing a path in `flake.nix`'s `workspacePaths` will silently drop files from the Nix build's `src` fileset.
- Missing a name in `flake.nix`'s `workspaceNames` will break `pnpmConfigHook` because dependencies for that workspace will not be fetched.
- The automated "Check flake.nix workspace sync" (`scripts/check-nix-workspace.mjs`) only validates the transitive dependency **closure of `@moonshot-ai/kimi-code`**. A leaf package outside that closure (e.g. an e2e package nobody imports) slips through even when it is missing from `flake.nix`. A green check is therefore NOT proof that `flake.nix` is fully in sync — keep it updated by hand on every add/remove, do not rely on the check to catch omissions.
## General Coding Rules
@ -72,3 +77,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo
- After finishing a task and before submitting a PR, you must run the `gen-changesets` skill (see `.agents/skills/gen-changesets/SKILL.md`) and generate a changeset under `.changeset/` according to its rules.
- When generating a changeset, **never** decide on a `major` bump on your own. When you judge a change to meet the major criteria (breaking changes, incompatible user configuration, renamed or removed commands/arguments, changed behavior semantics, etc.), you must stop and explain it to the user and ask for confirmation. **Only write `major` after the user has explicitly agreed.** Otherwise default to `minor` (and fall back to `patch` if `minor` is unclear). See the "Hard rule: confirm with the user before writing `major`" section in `.agents/skills/gen-changesets/SKILL.md` for details.
- Prefer importing via `import ... from '#/...'`, which serves the same purpose as `import ... from '@/...'`.
- Do not commit throwaway scratch or exploratory files. Never stage:
- Agent working notes or handoff/summary documents (e.g. `HANDOVER-*.md`, `HANDOFF-*.md`, `handoff.md`).
- Throwaway UI/UX prototypes or design mockups (e.g. `*-designs.html`, `*-mockup.html`, `*-demo(s).html`) at the repo root or under a `design/` folder. The only tracked `.html` files should be Vite `index.html` entrypoints.
Before committing or opening a PR, run `git status` and `git diff --staged --stat` and remove anything matching these patterns. Put scratch work under `.tmp/` (gitignored) instead of the repo root or the source tree.

1
CLAUDE.md Symbolic link
View file

@ -0,0 +1 @@
AGENTS.md

View file

@ -19,6 +19,12 @@ Install with the official script. No Node.js required.
curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash
```
- **Homebrew (macOS/Linux)**:
```sh
brew install kimi-code
```
- **Windows (PowerShell)**:
```powershell
@ -54,17 +60,41 @@ Take a look at this project and explain its main directories.
- **Single-binary distribution.** Install with one command: no Node.js setup, PATH gymnastics, or global module conflicts.
- **Blazing-fast startup.** The TUI is ready in milliseconds, so starting a session never feels heavy.
- **Purpose-built TUI.** A carefully tuned interface for long, focused agent sessions.
- **Video input.** Drop a screen recording or demo clip into the chat, and let the agent watch what is hard to describe in words.
- **Purpose-built TUI.** A carefully tuned interface, optimized end to end for long, focused agent sessions.
- **Video input.** Drop a screen recording or demo clip into the chat and let the agent watch what is hard to describe in words — turn a reference clip into a LUT, a long video into a short, a screen recording into working code, and more.
- **AI-native MCP configuration.** Add, edit, and authenticate Model Context Protocol servers conversationally with `/mcp-config`, without hand-editing JSON.
- **Rich plugin ecosystem.** Install skills, MCP servers, and data sources from the marketplace or any GitHub repo, with each install's trust level surfaced up front.
- **Subagents for focused, parallel work.** Dispatch built-in `coder`, `explore`, and `plan` subagents in isolated contexts while keeping the main conversation clean.
- **Lifecycle hooks.** Run local commands at key points to gate risky tool calls, audit decisions, trigger desktop notifications, or connect to your own automation.
- **Editor & IDE integration (ACP).** Drive a Kimi Code CLI session straight from Zed, JetBrains, or any [Agent Client Protocol](https://agentclientprotocol.com/) client with `kimi acp`.
## Use it in your editor (ACP)
Kimi Code CLI speaks the [Agent Client Protocol](https://agentclientprotocol.com/), so ACP-compatible editors and IDEs (Zed, JetBrains, …) can drive a session over stdio. Log in once, then point your editor at the `kimi acp` subcommand — no extra login needed.
For Zed, add this to `~/.config/zed/settings.json`:
```json
{
"agent_servers": {
"Kimi Code CLI": {
"type": "custom",
"command": "kimi",
"args": ["acp"],
"env": {}
}
}
}
```
Then open a new conversation in Zed's Agent panel. See [Using in IDEs](https://moonshotai.github.io/kimi-code/en/guides/ides) for JetBrains setup and troubleshooting, and the [`kimi acp` reference](https://moonshotai.github.io/kimi-code/en/reference/kimi-acp) for the full capability matrix.
## Docs
- [Getting Started](https://moonshotai.github.io/kimi-code/en/guides/getting-started)
- [Interaction and approvals](https://moonshotai.github.io/kimi-code/en/guides/interaction)
- [Sessions](https://moonshotai.github.io/kimi-code/en/guides/sessions)
- [Using in IDEs (ACP)](https://moonshotai.github.io/kimi-code/en/guides/ides)
- [Configuration](https://moonshotai.github.io/kimi-code/en/configuration/config-files)
- [Command reference](https://moonshotai.github.io/kimi-code/en/reference/kimi-command)

View file

@ -22,6 +22,12 @@ Kimi Code CLI 是一个运行在终端里的 AI 编程 agent可以帮你读
curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash
```
- **HomebrewmacOS / Linux**
```sh
brew install kimi-code
```
- **WindowsPowerShell**
```powershell
@ -57,18 +63,42 @@ kimi
- **二进制发行,零环境依赖** 一行命令安装,不需要预装 Node.js不用折腾 PATH也不会和全局模块冲突。
- **极速启动** TUI 在毫秒级就绪,开一个新会话没有任何心智负担。
- **精致的 TUI 体验** 为长时间、专注的 Agent 会话精心打磨的交互界面
- **视频也能输入** 屏幕录像、演示视频也能拖进对话。
- **精致的 TUI 体验** 端到端打磨的交互界面,专为长时间、专注的 Agent 会话优化
- **视频也能输入** 屏幕录像、演示视频拖进对话,让 Agent 看那些难以用文字描述的东西——把参考片段做成 LUT、把长视频剪成短视频、把录屏变成代码等等
- **AI-native 的 MCP 配置** 通过 `/mcp-config` 对话式添加、编辑、认证 MCP 服务器,无需手写 JSON。
- **丰富的插件生态** 从插件市场或任意 GitHub 仓库安装 skills、MCP 服务器和数据源,每次安装都会标明来源的信任级别。
- **子 Agent 聚焦并行工作** 内置 `coder``explore``plan` 子 Agent 在隔离上下文中处理子任务,主对话保持清爽。
- **生命周期 hooks** 在关键节点执行本地命令:拦截高风险工具调用、审计决策、发送桌面通知,或对接你自己的自动化脚本。
- **编辑器 / IDE 集成ACP**`kimi acp` 让 Zed、JetBrains 等任意 [Agent Client Protocol](https://agentclientprotocol.com/) 客户端直接驱动会话。
## 在编辑器里使用ACP
Kimi Code CLI 支持 [Agent Client Protocol](https://agentclientprotocol.com/)ACP 兼容的编辑器 / IDEZed、JetBrains……可以通过 stdio 直接驱动会话。登录一次后,把编辑器指向 `kimi acp` 子命令即可,无需重复登录。
以 Zed 为例,在 `~/.config/zed/settings.json` 中加入:
```json
{
"agent_servers": {
"Kimi Code CLI": {
"type": "custom",
"command": "kimi",
"args": ["acp"],
"env": {}
}
}
}
```
随后在 Zed 的 Agent 面板新建对话即可。JetBrains 配置与排障见[在 IDE 中使用](https://moonshotai.github.io/kimi-code/zh/guides/ides),完整能力矩阵见 [`kimi acp` 参考](https://moonshotai.github.io/kimi-code/zh/reference/kimi-acp)。
## 文档
- [快速上手](https://moonshotai.github.io/kimi-code/zh/guides/getting-started)
- [交互与审批](https://moonshotai.github.io/kimi-code/zh/guides/interaction)
- [会话](https://moonshotai.github.io/kimi-code/zh/guides/sessions)
- [在 IDE 中使用ACP](https://moonshotai.github.io/kimi-code/zh/guides/ides)
- [配置](https://moonshotai.github.io/kimi-code/zh/configuration/config-files)
- [命令参考](https://moonshotai.github.io/kimi-code/zh/reference/kimi-command)

View file

@ -1,2 +1,11 @@
# Copied from packages/kimi-core at build time
agents/
# Generated at build time by scripts/build-vis-asset.mjs.
# Only the ~150KB base64 VALUE file is ignored; the committed `.d.ts` stub
# next to it keeps `#/generated/vis-web-asset` type-resolvable on a fresh
# clone (before any build has produced the `.ts`).
src/generated/vis-web-asset.ts
# Copied from packages/pi-tui/native at build time by scripts/copy-native-assets.mjs
native/

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "@moonshot-ai/kimi-code",
"version": "0.10.1",
"version": "0.23.5",
"description": "The Starting Point for Next-Gen Agents",
"license": "MIT",
"author": "Moonshot AI",
@ -27,15 +27,21 @@
},
"files": [
"dist",
"dist-web",
"native",
"scripts/postinstall.mjs",
"scripts/postinstall",
"README.md"
],
"type": "module",
"imports": {
"#/tui/theme": "./src/tui/theme/index.ts",
"#/cli/sub/server": "./src/cli/sub/server/index.ts",
"#/cli/sub/server/*": "./src/cli/sub/server/*.ts",
"#/*": [
"./src/*.ts",
"./src/*/index.ts"
"./src/*/index.ts",
"./src/*.d.ts"
]
},
"publishConfig": {
@ -43,7 +49,8 @@
"provenance": true
},
"scripts": {
"build": "tsdown",
"build": "pnpm -C ../kimi-web run build && tsdown && node scripts/copy-native-assets.mjs && node scripts/copy-web-assets.mjs",
"prebuild": "node scripts/build-vis-asset.mjs",
"catalog:update": "node scripts/update-catalog.mjs --out dist/built-in-catalog.json",
"smoke": "node scripts/smoke.mjs",
"build:native:js": "node scripts/native/01-bundle.mjs",
@ -55,6 +62,8 @@
"test:native:smoke": "node scripts/native/smoke.mjs",
"dev": "node scripts/dev.mjs",
"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:restart": "node scripts/dev-server-restart.mjs",
"dev:plugin-marketplace": "node scripts/dev-plugin-marketplace-server.mjs",
"build:plugin-marketplace": "node scripts/build-plugin-marketplace-cdn.mjs",
"dev:prod": "node dist/main.mjs",
@ -65,27 +74,34 @@
"e2e:real": "pnpm -w run build:packages && KIMI_E2E_REAL=1 vitest run test/e2e/real-llm-smoke.e2e.test.ts",
"postinstall": "node scripts/postinstall.mjs"
},
"dependencies": {
"@earendil-works/pi-tui": "^0.74.0",
"@mariozechner/clipboard": "^0.3.2",
"chalk": "^5.4.1",
"cli-highlight": "^2.1.11",
"commander": "^13.1.0",
"semver": "^7.7.4",
"smol-toml": "^1.6.1",
"zod": "^4.3.6"
"optionalDependencies": {
"@mariozechner/clipboard": "^0.3.9",
"node-pty": "^1.1.0"
},
"devDependencies": {
"@moonshot-ai/acp-adapter": "workspace:^",
"@moonshot-ai/kimi-code-oauth": "workspace:^",
"@moonshot-ai/kimi-code-sdk": "workspace:^",
"@moonshot-ai/kimi-telemetry": "workspace:^",
"@moonshot-ai/kimi-web": "workspace:^",
"@moonshot-ai/migration-legacy": "workspace:^",
"@moonshot-ai/pi-tui": "workspace:^",
"@moonshot-ai/server": "workspace:^",
"@moonshot-ai/vis-server": "workspace:^",
"@moonshot-ai/vis-web": "workspace:*",
"@types/semver": "^7.7.0",
"@types/yazl": "^2.4.6",
"chalk": "^5.4.1",
"cli-highlight": "^2.1.11",
"commander": "^13.1.0",
"jimp": "^1.6.1",
"pathe": "^2.0.3",
"postject": "1.0.0-alpha.6",
"semver": "^7.7.4",
"smol-toml": "^1.6.1",
"tsx": "^4.21.0",
"yazl": "^3.3.1"
"yazl": "^3.3.1",
"zod": "^4.3.6"
},
"engines": {
"node": ">=22.19.0"

View file

@ -6,6 +6,8 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
import yazl from 'yazl';
import { readPluginManifestVersion } from './plugin-manifest-version.mjs';
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(SCRIPT_DIR, '../../..');
const DEFAULT_PLUGINS_ROOT = resolve(REPO_ROOT, 'plugins');
@ -46,7 +48,13 @@ export async function buildPluginMarketplaceCdn({ pluginsRoot, outDir }) {
continue;
}
const result = await materializeEntrySource(entry.source, pluginsRoot, outDir);
plugins.push({ ...entry, source: result.source });
let stamped = { ...entry, source: result.source };
if (isLocalRelativeSource(entry.source)) {
// Stamp the version from the plugin's real manifest so "latest" stays truthful.
const version = await readPluginManifestVersion(resolveInsideRoot(pluginsRoot, entry.source));
if (version !== undefined) stamped = { ...stamped, version };
}
plugins.push(stamped);
if (result.archive !== undefined) archives.push(result.archive);
}

View file

@ -0,0 +1,54 @@
// Builds the vis web single-file bundle, gzips it, and writes a generated
// TS module that embeds it as base64 so tsdown can later bundle it into
// dist/main.mjs (works identically for the npm package and the native SEA
// binary).
import { execSync } from 'node:child_process';
import { gzipSync } from 'node:zlib';
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, '..', '..', '..');
const visWeb = join(repoRoot, 'apps', 'vis', 'web');
const out = join(here, '..', 'src', 'generated', 'vis-web-asset.ts');
console.log('[build-vis-asset] building vis web single-file bundle…');
try {
// Run vite with VIS_SINGLEFILE set on the spawn so the build is
// cross-platform (Node sets the env, not a POSIX-only inline-env shell
// prefix). `pnpm --filter X exec` runs in X's package dir, so vite picks up
// vis-web's vite.config.ts, which gates the single-file output on
// `process.env.VIS_SINGLEFILE === '1'`.
// execSync runs through the platform shell, which is required on Windows:
// pnpm's launcher is `pnpm.cmd`, which a bare argv exec cannot resolve (no
// PATHEXT without a shell). The win32 native binary IS built on Windows
// runners (.github/workflows/_native-build.yml), which run this generator.
// A single command string (not an args array) avoids the args+shell
// deprecation; the command is static (no injection surface).
execSync('pnpm --filter @moonshot-ai/vis-web exec vite build', {
stdio: 'inherit',
cwd: repoRoot,
env: { ...process.env, VIS_SINGLEFILE: '1' },
});
} catch (err) {
throw new Error(
`[build-vis-asset] failed to run the vis-web single-file build via pnpm (is pnpm on PATH?): ${err instanceof Error ? err.message : String(err)}`,
);
}
const html = readFileSync(join(visWeb, 'dist-single', 'index.html'));
if (html.length < 1024 || !html.toString('utf8', 0, 256).toLowerCase().includes('<!doctype html')) {
throw new Error(
`[build-vis-asset] dist-single/index.html looks invalid (${html.length} bytes) — the web build may have failed`,
);
}
const b64 = gzipSync(html, { level: 9 }).toString('base64');
mkdirSync(dirname(out), { recursive: true });
writeFileSync(
out,
`// GENERATED by scripts/build-vis-asset.mjs — do not edit.\n` +
`export const VIS_WEB_GZIP_B64 = ${JSON.stringify(b64)};\n`,
);
console.log(`[build-vis-asset] wrote ${out} (${(b64.length / 1024).toFixed(0)} KB base64)`);

View file

@ -0,0 +1,38 @@
import { cp, mkdir, rm, stat } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = resolve(appRoot, '../..');
const source = resolve(repoRoot, 'packages/pi-tui/native');
const target = resolve(appRoot, 'native');
// pi-tui ships platform-specific native helpers only for darwin/win32;
// Linux has no native helper, so there is nothing to copy for it.
const PLATFORMS = ['darwin', 'win32'];
async function assertPrebuilds(platform) {
const dir = resolve(source, platform, 'prebuilds');
try {
const info = await stat(dir);
if (!info.isDirectory()) {
throw new Error('not a directory');
}
} catch {
throw new Error(
`pi-tui native prebuilds were not found at ${dir}. Build or restore packages/pi-tui first.`,
);
}
return dir;
}
await rm(target, { recursive: true, force: true });
await mkdir(target, { recursive: true });
for (const platform of PLATFORMS) {
const srcPrebuilds = await assertPrebuilds(platform);
const dstPrebuilds = resolve(target, platform, 'prebuilds');
await cp(srcPrebuilds, dstPrebuilds, { recursive: true });
}
console.log(`Copied pi-tui native prebuilds to ${target}`);

View file

@ -0,0 +1,27 @@
import { cp, rm, stat } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = resolve(appRoot, '../..');
const source = resolve(repoRoot, 'apps/kimi-web/dist');
const target = resolve(appRoot, 'dist-web');
async function assertBuiltWeb() {
try {
const info = await stat(resolve(source, 'index.html'));
if (!info.isFile()) {
throw new Error('index.html is not a file');
}
} catch {
throw new Error(
`Kimi web build output was not found at ${source}. Run \`pnpm --filter @moonshot-ai/kimi-web run build\` first.`,
);
}
}
await assertBuiltWeb();
await rm(target, { recursive: true, force: true });
await cp(source, target, { recursive: true });
console.log(`Copied Kimi web assets to ${target}`);

View file

@ -7,6 +7,8 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
import yazl from 'yazl';
import { readPluginManifestVersion } from './plugin-manifest-version.mjs';
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(SCRIPT_DIR, '../../..');
const DEFAULT_PLUGINS_ROOT = resolve(REPO_ROOT, 'plugins');
@ -109,7 +111,10 @@ async function rewriteMarketplaceJson(raw, pluginsRoot) {
if (!isLocalRelativeSource(entry.source)) return entry;
const sourcePath = resolveInsideRoot(pluginsRoot, entry.source);
if (!(await isDirectory(sourcePath))) return entry;
return { ...entry, source: withZipExtension(entry.source) };
// Stamp the version from the plugin's real manifest so "latest" stays truthful.
const version = await readPluginManifestVersion(sourcePath);
const withVersion = version !== undefined ? { ...entry, version } : entry;
return { ...withVersion, source: withZipExtension(withVersion.source) };
}),
);

View file

@ -0,0 +1,127 @@
#!/usr/bin/env node
// Press-Enter-to-restart wrapper for the local server. No file watcher.
//
// Spawns `tsx ./src/main.ts server run …extraArgs` once, then on each newline
// read from stdin SIGTERMs the child and respawns after it has cleanly exited.
// SIGTERM triggers the server's own `shutdown()` handler
// (apps/kimi-code/src/cli/sub/server/run.ts) which releases the port lock and
// closes WS conns before exit, so a fresh start can re-acquire 58627 without a
// stale-lock fight.
//
// CLI args after `--` (or any extras) are passed straight through, so:
// pnpm dev:server:restart -- --host 0.0.0.0 --port 58627 --log-level debug
// is equivalent to `pnpm dev:server` with that arg list, but with the restart
// loop on top.
import { spawn } from 'node:child_process';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const APP_ROOT = resolve(SCRIPT_DIR, '..');
const tsxBin = process.platform === 'win32' ? 'tsx.cmd' : 'tsx';
const cliArgs = process.argv.slice(2);
if (cliArgs[0] === '--') cliArgs.shift();
const tsxArgs = [
'--tsconfig',
'./tsconfig.dev.json',
'--import',
'../../build/register-raw-text-loader.mjs',
'./src/main.ts',
'server',
'run',
...cliArgs,
];
let child = null;
let restarting = false;
let shuttingDown = false;
let killTimer = null;
function start() {
console.error('[dev:server:restart] starting server…');
child = spawn(tsxBin, tsxArgs, {
cwd: APP_ROOT,
env: process.env,
// Server does not read stdin; keep ours free for the Enter trigger.
stdio: ['ignore', 'inherit', 'inherit'],
});
child.on('error', (err) => {
console.error(`[dev:server:restart] spawn error: ${err.message}`);
});
child.on('exit', (code, signal) => {
if (killTimer !== null) {
clearTimeout(killTimer);
killTimer = null;
}
const prev = child;
child = null;
if (shuttingDown) {
process.exit(code ?? 0);
return;
}
if (restarting) {
restarting = false;
start();
return;
}
// Server died on its own (port conflict, runtime error, etc.). Stay alive
// so the user can fix the issue and press Enter to retry.
const tag = signal !== null ? `signal=${signal}` : `code=${code}`;
console.error(
`[dev:server:restart] server exited (${tag}). Press Enter to restart, Ctrl+C to quit.`,
);
void prev; // silence unused warning
});
}
function restart() {
if (shuttingDown) return;
if (child === null) {
// Previous run already exited; just spin up a new one.
start();
return;
}
if (restarting) return; // debounce — multiple Enters during shutdown collapse
restarting = true;
console.error('[dev:server:restart] restarting…');
child.kill('SIGTERM');
// Safety net: if the child ignores SIGTERM, force-kill after 5s so the
// restart loop doesn't wedge.
killTimer = setTimeout(() => {
if (child !== null && child.exitCode === null && child.signalCode === null) {
console.error('[dev:server:restart] SIGTERM timed out, sending SIGKILL');
child.kill('SIGKILL');
}
}, 5000);
}
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
// Any newline (Enter on most terminals) triggers a restart. Empty Enter is
// the canonical signal; typing `r<Enter>` works too.
if (chunk.includes('\n') || chunk.includes('\r')) {
restart();
}
});
const onShutdownSignal = (signal) => {
if (shuttingDown) return;
shuttingDown = true;
if (child !== null) {
child.kill(signal);
// Give the server a moment to flush logs / release the lock.
setTimeout(() => process.exit(0), 1000).unref();
} else {
process.exit(0);
}
};
process.on('SIGINT', () => onShutdownSignal('SIGINT'));
process.on('SIGTERM', () => onShutdownSignal('SIGTERM'));
start();

View file

@ -1,31 +1,64 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { createRequire } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { startPluginMarketplaceServer } from './dev-plugin-marketplace-server.mjs';
const require = createRequire(import.meta.url);
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const APP_ROOT = resolve(SCRIPT_DIR, '..');
// Monorepo root. Used as the dev CLI's working directory so `make dev` opens
// the whole repo instead of just apps/kimi-code.
const REPO_ROOT = resolve(APP_ROOT, '../..');
// Runtime variable the CLI reads to locate the marketplace JSON.
const MARKETPLACE_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL';
// Opt-in for dev: point this run at an external marketplace instead of a local one.
const EXTERNAL_MARKETPLACE_ENV = 'KIMI_CODE_DEV_MARKETPLACE_URL';
let marketplaceServer;
const env = { ...process.env };
if (env[MARKETPLACE_ENV] === undefined || env[MARKETPLACE_ENV]?.trim().length === 0) {
const externalUrl = process.env[EXTERNAL_MARKETPLACE_ENV]?.trim();
if (externalUrl !== undefined && externalUrl.length > 0) {
// Explicitly asked to use an external marketplace; don't start a local server.
env[MARKETPLACE_ENV] = externalUrl;
console.error(`Using external plugin marketplace: ${externalUrl}`);
} else {
// Default: every `pnpm run dev:cli` runs its own isolated marketplace server on a
// random port, so multiple concurrent dev instances never collide. Overwrite any
// inherited MARKETPLACE_ENV so a stale URL from a dead instance can't break this run.
const inherited = process.env[MARKETPLACE_ENV]?.trim();
marketplaceServer = await startPluginMarketplaceServer();
env[MARKETPLACE_ENV] = marketplaceServer.marketplaceUrl;
console.error(`Plugin marketplace dev server: ${marketplaceServer.marketplaceUrl}`);
if (inherited !== undefined && inherited.length > 0 && inherited !== marketplaceServer.marketplaceUrl) {
console.error(
`(ignored inherited ${MARKETPLACE_ENV}=${inherited}; set ${EXTERNAL_MARKETPLACE_ENV} to use an external marketplace)`,
);
}
}
const tsxBin = process.platform === 'win32' ? 'tsx.cmd' : 'tsx';
const tsxCli = require.resolve('tsx/cli');
const cliArgs = process.argv.slice(2);
if (cliArgs[0] === '--') cliArgs.shift();
const child = spawn(
tsxBin,
['--import', '../../build/register-raw-text-loader.mjs', './src/main.ts', ...cliArgs],
process.execPath,
[
tsxCli,
// Use the dev tsconfig whose `include` covers packages/*/src, so tsx's
// esbuild transform sees `experimentalDecorators: true` for DI parameter
// decorators in agent-core. Mirrors `dev:server` in package.json.
'--tsconfig',
resolve(APP_ROOT, 'tsconfig.dev.json'),
'--import',
pathToFileURL(resolve(REPO_ROOT, 'build/register-raw-text-loader.mjs')).href,
resolve(APP_ROOT, 'src/main.ts'),
...cliArgs,
],
{
cwd: APP_ROOT,
cwd: REPO_ROOT,
env,
stdio: 'inherit',
},

View file

@ -6,8 +6,14 @@ import { run } from './exec.mjs';
const requireFromScript = createRequire(import.meta.url);
const tsdownCliPath = requireFromScript.resolve('tsdown/run');
const checkBundlePath = resolve(import.meta.dirname, 'check-bundle.mjs');
const buildVisAssetPath = resolve(import.meta.dirname, '..', 'build-vis-asset.mjs');
export async function runBundleStep() {
// Generate the embedded `kimi vis` web asset before bundling. The native
// tsdown run here never goes through the npm `prebuild` lifecycle, so the
// generated module must be produced explicitly first or the bundle would
// miss it (npm builds get it via the `prebuild` script).
await run(process.execPath, [buildVisAssetPath]);
await run(process.execPath, [tsdownCliPath, '--config', 'tsdown.native.config.ts']);
await run(process.execPath, [checkBundlePath]);
}

View file

@ -16,6 +16,7 @@ import {
nativeSeaConfigPath,
targetTriple,
} from './paths.mjs';
import { collectWebAssets, webAssetManifestKey } from './web-assets.mjs';
async function ensureBundleExists() {
try {
@ -31,13 +32,19 @@ async function writeSeaConfig(target) {
appRoot,
target,
});
const web = await collectWebAssets({ appRoot, target });
const manifestPath = resolve(nativeManifestDir(target), 'manifest.json');
const webManifestPath = resolve(nativeIntermediatesDir(), 'web-assets', target, 'manifest.json');
await mkdir(dirname(manifestPath), { recursive: true });
await mkdir(dirname(webManifestPath), { recursive: true });
await writeFile(manifestPath, manifestJson);
await writeFile(webManifestPath, web.manifestJson);
const seaAssets = {
[nativeAssetManifestKey(target)]: manifestPath,
[webAssetManifestKey(target)]: webManifestPath,
...assets,
...web.assets,
};
const config = {
main: nativeJsBundlePath(),
@ -55,6 +62,9 @@ async function writeSeaConfig(target) {
for (const line of nativeAssetSummary(manifest)) {
console.log(`- ${line}`);
}
console.log(
`Collected web assets for ${web.manifest.target}: ${web.manifest.files.length} files`,
);
}
export async function runSeaBlobStep() {

View file

@ -17,9 +17,7 @@ export const NATIVE_TARGETS = Object.freeze(
SUPPORTED_TARGETS.map((t) => {
const deps = resolveTargetDeps(t);
const clipboardTarget = deps.find((d) => d.id === 'clipboard-target')?.resolvedName;
const koffiNativeFile = deps.find((d) => d.id === 'koffi')?.nativeFileRelatives?.[0];
const koffiTriplet = koffiNativeFile?.match(/koffi\/([^/]+)\/koffi\.node$/)?.[1] ?? null;
return [t, { clipboardPackage: clipboardTarget, koffiTriplet }];
return [t, { clipboardPackage: clipboardTarget }];
}),
),
);
@ -161,16 +159,19 @@ async function collectPackageFiles({
packageName,
packageRoot,
includeNativeFiles,
includeEntryJs = true,
nativeFileRelatives = [],
}) {
const packageJsonPath = join(packageRoot, 'package.json');
const packageJson = await readJson(packageJsonPath);
const selected = new Set([packageJsonPath]);
const entry = resolvePackageEntry(packageRoot, packageJson);
if (entry !== null) {
selected.add(entry);
await addRuntimeDependencyFiles(packageRoot, entry, selected);
if (includeEntryJs) {
const entry = resolvePackageEntry(packageRoot, packageJson);
if (entry !== null) {
selected.add(entry);
await addRuntimeDependencyFiles(packageRoot, entry, selected);
}
}
for (const nativeFileRelative of nativeFileRelatives) {
@ -250,6 +251,7 @@ export async function collectNativeAssets({ appRoot, target }) {
packageName: dep.resolvedName,
packageRoot,
includeNativeFiles: dep.collect === 'native-files',
includeEntryJs: dep.collect !== 'native-file-only',
nativeFileRelatives: dep.nativeFileRelatives,
});
const result = await packageManifestEntries({

View file

@ -18,10 +18,12 @@ const optionalRuntimeRequires = new Set([
'canvas',
'chokidar',
'cpu-features',
'fast-json-stringify/lib/serializer',
'fast-json-stringify/lib/validator',
'utf-8-validate',
]);
const optionalRelativeRuntimeRequires = new Set(['./crypto/build/Release/sshcrypto.node']);
const handledNativeRuntimeRequires = new Set(['koffi']);
const handledNativeRuntimeRequires = new Set();
function isAllowedSpecifier(specifier) {
if (builtins.has(specifier) || specifier.startsWith('node:')) return true;

View file

@ -1,4 +1,5 @@
export const NATIVE_ASSET_MANIFEST_VERSION = 1;
export const WEB_ASSET_MANIFEST_VERSION = 1;
export function buildManifestKey(target) {
return `native/${target}/manifest.json`;
@ -11,3 +12,11 @@ export function isManifestVersionSupported(version) {
export function buildAssetKey(target, packageRoot, relativePath) {
return `native/${target}/${packageRoot}/${relativePath}`;
}
export function buildWebManifestKey(target) {
return `web/${target}/manifest.json`;
}
export function buildWebAssetKey(target, relativePath) {
return `web/${target}/dist-web/${relativePath}`;
}

View file

@ -27,13 +27,16 @@ const clipboardSubpackageByTarget = Object.freeze({
'win32-x64': '@mariozechner/clipboard-win32-x64-msvc',
});
const koffiTripletByTarget = Object.freeze({
'darwin-arm64': 'darwin_arm64',
'darwin-x64': 'darwin_x64',
'linux-arm64': 'linux_arm64',
'linux-x64': 'linux_x64',
'win32-arm64': 'win32_arm64',
'win32-x64': 'win32_x64',
// pi-tui ships platform-specific native helpers (no Linux build):
// - darwin: Shift-modifier detection for Terminal.app Shift+Enter
// - win32: enable ENABLE_VIRTUAL_TERMINAL_INPUT so Shift+Tab is distinguishable
const piTuiNativeFileByTarget = Object.freeze({
'darwin-arm64': ['native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node'],
'darwin-x64': ['native/darwin/prebuilds/darwin-x64/darwin-modifiers.node'],
'linux-arm64': [],
'linux-x64': [],
'win32-arm64': ['native/win32/prebuilds/win32-arm64/win32-console-mode.node'],
'win32-x64': ['native/win32/prebuilds/win32-x64/win32-console-mode.node'],
});
export function isSupportedTarget(target) {
@ -45,13 +48,15 @@ export function isSupportedTarget(target) {
* @property {string} id stable internal id used for parent refs
* @property {(target: string) => string} name
* npm package name (may depend on target)
* @property {'js-only'|'native-files'|'js-and-native-file'|'virtual'} collect
* @property {'js-only'|'native-files'|'js-and-native-file'|'native-file-only'|'virtual'} collect
* @property {string|null} parent
* id of another registered dep this nests under (for pnpm),
* or null for top-level (resolvable from app root)
* @property {(target: string) => string[]} [nativeFileRelatives]
* explicit list of .node files relative to package root
* (used by 'js-and-native-file'; native-files mode auto-scans *.node)
* (used by 'js-and-native-file' and 'native-file-only';
* native-files mode auto-scans *.node). 'native-file-only' collects
* package.json + these .node files but skips the package entry JS.
*/
/** @type {readonly NativeDepDescriptor[]} */
@ -70,18 +75,14 @@ export const nativeDeps = Object.freeze([
},
{
id: 'pi-tui',
name: () => '@earendil-works/pi-tui',
// pi-tui is bundled into main.cjs at build time — we don't collect it as
// a native dep, only register it so koffi can declare it as parent.
collect: 'virtual',
name: () => '@moonshot-ai/pi-tui',
// pi-tui's JS is bundled into main.cjs, so only the platform-specific
// native helper (.node under native/) ships alongside the binary — its
// dist/ JS is intentionally NOT collected (it stays in the bundle). This
// keeps the SEA native-asset payload small. Linux has no native helper.
collect: 'native-file-only',
parent: null,
},
{
id: 'koffi',
name: () => 'koffi',
collect: 'js-and-native-file',
parent: 'pi-tui',
nativeFileRelatives: (target) => [`build/koffi/${koffiTripletByTarget[target]}/koffi.node`],
nativeFileRelatives: (target) => piTuiNativeFileByTarget[target] ?? [],
},
]);

View file

@ -0,0 +1,118 @@
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { readdir, readFile, stat } from 'node:fs/promises';
import { join, relative, resolve } from 'node:path';
import {
WEB_ASSET_MANIFEST_VERSION,
buildWebAssetKey,
buildWebManifestKey,
} from './manifest.mjs';
export { WEB_ASSET_MANIFEST_VERSION };
const WEB_ASSETS_DIR = 'dist-web';
function toPosixPath(path) {
return path.split('\\').join('/');
}
function sha256(bytes) {
return createHash('sha256').update(bytes).digest('hex');
}
async function listFiles(root) {
const files = [];
async function walk(dir) {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
await walk(path);
continue;
}
if (entry.isFile()) {
files.push(path);
}
}
}
await walk(root);
return files;
}
async function assertBuiltAssetRoot({ assetRoot, requiredFile, message }) {
const requiredPath = join(assetRoot, requiredFile);
try {
const info = await stat(requiredPath);
if (!info.isFile()) {
throw new Error(`${requiredFile} is not a file`);
}
} catch {
throw new Error(message);
}
}
export function webAssetManifestKey(target) {
return buildWebManifestKey(target);
}
export function webAssetKey(target, relativePath) {
return buildWebAssetKey(target, relativePath);
}
async function collectAssetRoot({
appRoot,
target,
root,
requiredFile,
missingMessage,
assetKey,
}) {
const assetRoot = resolve(appRoot, ...root.split('/'));
await assertBuiltAssetRoot({ assetRoot, requiredFile, message: missingMessage });
const files = (await listFiles(assetRoot)).sort((a, b) => a.localeCompare(b));
const manifestFiles = [];
const assets = {};
for (const file of files) {
if (!existsSync(file)) continue;
const bytes = await readFile(file);
const relativePath = toPosixPath(relative(assetRoot, file));
const key = assetKey(target, relativePath);
manifestFiles.push({
assetKey: key,
relativePath,
sha256: sha256(bytes),
});
assets[key] = file;
}
const manifest = {
version: WEB_ASSET_MANIFEST_VERSION,
target,
root,
files: manifestFiles,
};
return {
manifest,
manifestJson: `${JSON.stringify(manifest, null, 2)}\n`,
assets,
};
}
export async function collectWebAssets({ appRoot, target }) {
const buildCommand =
'pnpm --filter @moonshot-ai/kimi-web run build && pnpm --filter @moonshot-ai/kimi-code run build';
return collectAssetRoot({
appRoot,
target,
root: WEB_ASSETS_DIR,
requiredFile: 'index.html',
missingMessage: `Kimi web build output was not found at ${resolve(appRoot, WEB_ASSETS_DIR)}. Run \`${buildCommand}\` before building native SEA assets. App root: ${appRoot}`,
assetKey: webAssetKey,
});
}

View file

@ -0,0 +1,38 @@
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
// Read a local plugin directory's declared version from its manifest, mirroring
// the plugin loader's precedence (packages/agent-core/src/plugin/manifest.ts):
// `kimi.plugin.json` is authoritative once it exists, and `.kimi-plugin/plugin.json`
// is only consulted when the root manifest is absent. Returns undefined when no
// manifest is present or the chosen manifest has no version — callers then leave
// the marketplace entry's existing version untouched.
export async function readPluginManifestVersion(pluginDir) {
for (const rel of ['kimi.plugin.json', '.kimi-plugin/plugin.json']) {
const raw = await readFileOrUndefined(resolve(pluginDir, rel));
if (raw === undefined) continue; // manifest absent — fall back to the next candidate
return versionFromManifest(raw); // the chosen manifest wins, even if it has no version
}
return undefined;
}
async function readFileOrUndefined(file) {
try {
return await readFile(file, 'utf8');
} catch {
return undefined;
}
}
function versionFromManifest(raw) {
try {
const parsed = JSON.parse(raw);
if (parsed !== null && typeof parsed === 'object' && typeof parsed.version === 'string') {
const trimmed = parsed.version.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
} catch {
return undefined;
}
return undefined;
}

View file

@ -7,6 +7,7 @@ import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const bundlePath = resolve(appRoot, 'dist', 'main.mjs');
const webIndexPath = resolve(appRoot, 'dist-web', 'index.html');
const packageJson = JSON.parse(await readFile(resolve(appRoot, 'package.json'), 'utf-8'));
const expectedVersion = packageJson.version;
@ -23,6 +24,14 @@ async function ensureBundleExists() {
}
}
async function ensureRuntimeAssetsExist() {
try {
await stat(webIndexPath);
} catch {
fail(`Runtime asset not found at ${webIndexPath}. Run \`pnpm build\` first.`);
}
}
async function runBundle(args) {
try {
const { stdout, stderr } = await execFileAsync(process.execPath, [bundlePath, ...args], {
@ -45,6 +54,7 @@ function assertIncludes(output, expected, command) {
}
await ensureBundleExists();
await ensureRuntimeAssetsExist();
const versionOutput = await runBundle(['--version']);
assertIncludes(versionOutput, expectedVersion, '--version');
@ -55,4 +65,7 @@ assertIncludes(helpOutput, 'Usage: kimi', '--help');
const exportHelpOutput = await runBundle(['export', '--help']);
assertIncludes(exportHelpOutput, 'Usage: kimi export', 'export --help');
const webHelpOutput = await runBundle(['web', '--help']);
assertIncludes(webHelpOutput, 'Usage: kimi web', 'web --help');
console.log(`Bundle smoke passed: ${bundlePath}`);

View file

@ -24,6 +24,10 @@ const KEEP_MODEL = new Set([
"reasoning",
"interleaved",
"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) {

View file

@ -1,8 +1,6 @@
import { Command, Option } from 'commander';
import { CLI_COMMAND_NAME } from '#/constant/app';
import { registerMigrateCommand } from '#/migration/index';
import { Command, Option } from 'commander';
import type { CLIOptions } from './options';
import { registerAcpCommand } from './sub/acp';
@ -10,6 +8,8 @@ import { registerDoctorCommand } from './sub/doctor';
import { registerExportCommand } from './sub/export';
import { registerLoginCommand } from './sub/login';
import { registerProviderCommand } from './sub/provider';
import { registerServerCommand } from './sub/server';
import { registerVisCommand } from './sub/vis';
export type MainCommandHandler = (opts: CLIOptions) => void;
export type MigrateCommandHandler = () => void;
@ -29,10 +29,8 @@ export function createProgram(
.allowUnknownOption(false)
.configureHelp({ helpWidth: 100 })
.helpOption('-h, --help', 'Show help.')
.addHelpText(
'after',
'\nDocumentation: https://moonshotai.github.io/kimi-code/\n'
);
.usage('[options] [command]')
.addHelpText('after', '\nDocumentation: https://moonshotai.github.io/kimi-code/\n');
program
.addOption(
@ -46,7 +44,8 @@ export function createProgram(
.hideHelp()
.argParser((val: string | boolean) => (val === true ? '' : (val as string))),
)
.option('-C, --continue', 'Continue the previous session for the working directory.', false)
.option('-c, --continue', 'Continue the previous session for the working directory.', false)
.addOption(new Option('-C').hideHelp().default(false))
.option('-y, --yolo', 'Automatically approve all actions.', false)
.option('--auto', 'Start in auto permission mode.', false)
.addOption(
@ -75,6 +74,14 @@ export function createProgram(
.argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value])
.default([]),
)
.addOption(
new Option(
'--add-dir <dir>',
'Add an additional workspace directory for this session. Can be repeated.',
)
.argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value])
.default([]),
)
.addOption(new Option('--yes').hideHelp().default(false))
.addOption(new Option('--auto-approve').hideHelp().default(false))
.option('--plan', 'Start in plan mode.', false);
@ -82,11 +89,14 @@ export function createProgram(
registerExportCommand(program);
registerProviderCommand(program);
registerAcpCommand(program);
registerServerCommand(program);
registerLoginCommand(program);
registerDoctorCommand(program);
registerVisCommand(program);
registerMigrateCommand(program, onMigrate);
program
.command('upgrade')
.alias('update')
.description('Upgrade Kimi Code to the latest version.')
.action(async () => {
await onUpgrade();
@ -101,7 +111,11 @@ export function createProgram(
onPluginNodeRunner(entry, args);
});
program.action(() => {
program.argument('[args...]').action((args: string[]) => {
if (args.length > 0) {
program.error(`unknown command '${args[0]}'. See '${CLI_COMMAND_NAME} --help'.`);
}
const raw = program.opts<Record<string, unknown>>();
const rawSession = raw['session'] ?? raw['resume'];
@ -111,7 +125,7 @@ export function createProgram(
const opts: CLIOptions = {
session: sessionValue,
continue: raw['continue'] as boolean,
continue: raw['continue'] === true || raw['C'] === true,
yolo: yoloValue,
auto: autoValue,
plan: raw['plan'] as boolean,
@ -119,6 +133,7 @@ export function createProgram(
outputFormat: raw['outputFormat'] as CLIOptions['outputFormat'],
prompt: raw['prompt'] as string | undefined,
skillsDirs: raw['skillsDir'] as string[],
addDirs: raw['addDir'] as string[],
};
onMain(opts);

View file

@ -46,17 +46,18 @@ const GOAL_PREFIX = /^\/goal(\s|$)/;
* 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). 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,
flagEnabled: boolean,
): HeadlessGoalCreate | undefined {
if (!flagEnabled) return undefined;
export function parseHeadlessGoalCreate(prompt: string): HeadlessGoalCreate | undefined {
const trimmed = prompt.trim();
if (!GOAL_PREFIX.test(trimmed)) return undefined;
const args = trimmed.replace(/^\/goal/, '').trim();
const parsed = parseGoalCommand(args);
if (parsed.kind === 'error') {
throw new Error(parsed.message);
}
if (parsed.kind !== 'create') return undefined;
return { objective: parsed.objective, replace: parsed.replace };
}

View file

@ -0,0 +1,96 @@
import type { Writable } from 'node:stream';
import { HEADLESS_FORCE_EXIT_GRACE_MS, HEADLESS_STDIO_DRAIN_TIMEOUT_MS } from '#/constant/app';
/** Minimal process surface needed to force a headless run to terminate. */
export interface ExitableProcess {
exit(code?: number): void;
}
/**
* Schedule a best-effort force-exit for a completed headless (`kimi -p`) run.
*
* Print mode does not call `process.exit()`; it relies on the Node event loop
* draining once the run is done. If a stray ref'd handle survives shutdown a
* lingering socket (e.g. a connection blackholed by a restrictive firewall, or
* an HTTP/2 session kept alive by PING), an un-cleared timer, or a child whose
* pipes stay open the loop never empties and the process hangs until an
* external timeout kills it.
*
* This arms an **unref'd** fallback timer: a healthy run drains and exits
* naturally before it fires (so behaviour is unchanged), and the timer itself
* never keeps the loop alive. It only force-exits a run whose loop is already
* wedged. The exit code is read lazily at fire time so callers may set
* `process.exitCode` after scheduling (e.g. a goal turn mapping its terminal
* status to a non-zero code).
*
* Returns the timer handle so callers/tests can `clearTimeout` it.
*/
export function scheduleHeadlessForceExit(
proc: ExitableProcess,
getExitCode: () => number,
graceMs: number = HEADLESS_FORCE_EXIT_GRACE_MS,
): NodeJS.Timeout {
const timer = setTimeout(() => {
proc.exit(getExitCode());
}, graceMs);
timer.unref?.();
return timer;
}
/** Resolve once a stream's currently-buffered writes have flushed to its sink. */
function flushStream(stream: Writable): Promise<void> {
return new Promise<void>((resolve) => {
try {
// An empty write's callback fires after all previously-queued writes have
// been flushed (writes are ordered), which is the documented way to know a
// stream's buffer has drained.
stream.write('', () => resolve());
} catch {
resolve();
}
});
}
/**
* Wait for buffered output on the given streams to flush, bounded by `timeoutMs`.
*
* A slow or piped consumer that hasn't read all of stdout/stderr yet leaves the
* pipe as a legitimate ref'd handle keeping the loop alive. Flushing before any
* force-exit prevents truncating output from an otherwise-successful run. The
* wait is bounded so a permanently-stuck consumer can't re-introduce the hang.
*/
export async function drainStdio(
streams: readonly Writable[],
timeoutMs: number = HEADLESS_STDIO_DRAIN_TIMEOUT_MS,
): Promise<void> {
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<void>((resolve) => {
timer = setTimeout(resolve, timeoutMs);
timer.unref?.();
});
try {
await Promise.race([Promise.all(streams.map(flushStream)).then(() => undefined), timeout]);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
/**
* Finalize a completed headless run: flush stdio, then arm the force-exit
* backstop.
*
* Draining first means in-flight legitimate output is fully written before the
* backstop can fire, and since drained stdio no longer holds the loop only a
* genuinely leaked handle can keep it alive afterwards, which is exactly what
* the backstop is for.
*/
export async function finalizeHeadlessRun(
proc: ExitableProcess,
streams: readonly Writable[],
getExitCode: () => number,
options: { drainTimeoutMs?: number; graceMs?: number } = {},
): Promise<void> {
await drainStdio(streams, options.drainTimeoutMs ?? HEADLESS_STDIO_DRAIN_TIMEOUT_MS);
scheduleHeadlessForceExit(proc, getExitCode, options.graceMs);
}

View file

@ -11,6 +11,7 @@ export interface CLIOptions {
outputFormat: PromptOutputFormat | undefined;
prompt: string | undefined;
skillsDirs: string[];
addDirs?: string[];
}
export interface ValidatedOptions {
@ -55,14 +56,5 @@ export function validateOptions(opts: CLIOptions): ValidatedOptions {
if (opts.yolo && opts.auto) {
throw new OptionConflictError('Cannot combine --yolo with --auto.');
}
if (!promptMode && (opts.continue || opts.session !== undefined) && opts.yolo) {
throw new OptionConflictError('Cannot combine --yolo with --continue or --session.');
}
if (!promptMode && (opts.continue || opts.session !== undefined) && opts.auto) {
throw new OptionConflictError('Cannot combine --auto with --continue or --session.');
}
if (!promptMode && (opts.continue || opts.session !== undefined) && opts.plan) {
throw new OptionConflictError('Cannot combine --plan with --continue or --session.');
}
return { options: opts, uiMode: promptMode ? 'print' : 'shell' };
}

View file

@ -17,9 +17,9 @@ import {
type SessionStatus,
type TelemetryClient,
} from '@moonshot-ai/kimi-code-sdk';
import { resolve } from 'pathe';
import { CLI_SHUTDOWN_TIMEOUT_MS } from '#/constant/app';
import { experimentalFeatureMap } from '#/utils/experimental-features';
import { CLI_SHUTDOWN_TIMEOUT_MS, PROMPT_CLEANUP_TIMEOUT_MS } from '#/constant/app';
import type { CLIOptions, PromptOutputFormat } from './options';
import {
@ -32,6 +32,47 @@ import {
import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry';
import { createKimiCodeHostIdentity } from './version';
/**
* Await `promise`, but stop waiting after `timeoutMs`.
*
* The timeout only bounds how long we WAIT it does not change the outcome:
* - if `promise` settles first, its result is propagated (a rejection throws),
* so a cleanup step that actually fails in time still surfaces;
* - if the timeout wins, we resolve (give up waiting) and swallow the abandoned
* promise's eventual late rejection so it can't surface as an unhandled
* rejection.
*
* Used to bound shutdown so a wedged cleanup step can't keep a completed
* headless run alive, without silently swallowing a cleanup that fails fast. The
* timer stays ref'd so a cleanup step that suspends on an unref'd handle (e.g.
* telemetry's retry backoff when the network is blocked) can't drain the event
* loop and exit 0 before the rejection propagates the timer keeps the loop
* 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.
*/
async function raceWithTimeout(promise: Promise<void>, timeoutMs: number): Promise<void> {
let timedOut = false;
let timer: ReturnType<typeof setTimeout> | undefined;
// Attach the catch eagerly (synchronously) so `promise` is always consumed and
// a late rejection can never become an unhandled rejection. Before the timeout
// wins, the handler rethrows so a real cleanup failure still propagates.
const guarded = promise.catch((error: unknown) => {
if (timedOut) return;
throw error;
});
const timedOutSignal = new Promise<void>((resolve) => {
timer = setTimeout(() => {
timedOut = true;
resolve();
}, timeoutMs);
});
try {
await Promise.race([guarded, timedOutSignal]);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
interface PromptOutput {
readonly columns?: number | undefined;
write(chunk: string): boolean;
@ -78,11 +119,12 @@ export async function runPrompt(
telemetry: telemetryClient,
onOAuthRefresh: (outcome) => {
if (outcome.success) {
track('oauth_refresh', { success: true });
track('oauth_refresh', { outcome: 'success' });
return;
}
track('oauth_refresh', { success: false, reason: outcome.reason });
track('oauth_refresh', { outcome: 'error', reason: outcome.reason });
},
sessionStartedProperties: { yolo: false, plan: false, afk: true },
});
log.info('kimi-code starting', {
version,
@ -95,7 +137,7 @@ export async function runPrompt(
let removeTerminationCleanup: (() => void) | undefined;
let cleanupPromise: Promise<void> | undefined;
const cleanupPromptRun = async (): Promise<void> => {
cleanupPromise ??= (async () => {
const pending = (cleanupPromise ??= (async () => {
removeTerminationCleanup?.();
setCrashPhase('shutdown');
try {
@ -104,15 +146,23 @@ export async function runPrompt(
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
await harness.close();
}
})();
await cleanupPromise;
})());
// Bound cleanup so a wedged shutdown step (e.g. a SessionEnd hook, MCP
// shutdown, or a connection blackholed by a restrictive firewall) cannot
// keep a completed headless run alive forever. The cleanup keeps running in
// the background if it overruns; the caller (`kimi -p`) force-exits shortly
// after, so any straggling work is torn down with the process.
await raceWithTimeout(pending, PROMPT_CLEANUP_TIMEOUT_MS);
};
removeTerminationCleanup = installPromptTerminationCleanup(promptProcess, cleanupPromptRun);
try {
await harness.ensureConfigFile();
const config = await harness.getConfig();
const { session, resumed, restorePermission, telemetryModel, goalModel } =
for (const warning of (await harness.getConfigDiagnostics()).warnings) {
stderr.write(`Warning: ${warning}\n`);
}
const { session, restorePermission, telemetryModel, goalModel } =
await resolvePromptSession(
harness,
opts,
@ -132,23 +182,16 @@ export async function runPrompt(
version,
uiMode: PROMPT_UI_MODE,
model: telemetryModel,
sessionId: session.id,
});
setCrashPhase('runtime');
withTelemetryContext({ sessionId: session.id }).track('started', {
resumed,
yolo: false,
plan: false,
afk: true,
});
const outputFormat = opts.outputFormat ?? 'text';
// Headless goal mode: `kimi -p "/goal <objective>"`. The goal driver keeps
// 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
// distinct exit code.
const flagMap = experimentalFeatureMap(await harness.getExperimentalFeatures());
const goalCreate = parseHeadlessGoalCreate(opts.prompt!, flagMap['goal_command'] === true);
const goalCreate = parseHeadlessGoalCreate(opts.prompt!);
if (goalCreate !== undefined) {
await runHeadlessGoal(session, goalCreate, goalModel, outputFormat, stdout, stderr);
} else {
@ -157,7 +200,7 @@ export async function runPrompt(
writeResumeHint(session.id, outputFormat, stdout, stderr);
withTelemetryContext({ sessionId: session.id }).track('exit', {
duration_s: (Date.now() - startedAt) / 1000,
duration_ms: Date.now() - startedAt,
});
} finally {
await cleanupPromptRun();
@ -181,6 +224,7 @@ async function runHeadlessGoal(
const unsubscribeGoalEvents = session.onEvent((event) => {
if (
event.type === 'goal.updated' &&
event.agentId === 'main' &&
event.change?.kind === 'completion' &&
event.snapshot !== null
) {
@ -190,7 +234,7 @@ async function runHeadlessGoal(
try {
// The objective is sent as the normal prompt; goal continuation keeps the
// turn alive until a terminal state is reached.
await runPromptTurn(session, goal.objective, outputFormat, stdout, stderr);
await runPromptTurn(session, goal.objective, outputFormat, stdout, stderr, true);
} finally {
unsubscribeGoalEvents();
const snapshot = completedSnapshot ?? (await session.getGoal()).goal;
@ -229,7 +273,7 @@ async function resolvePromptSession(
if (target === undefined) {
throw new Error(`Session "${opts.session}" not found.`);
}
if (target.workDir !== workDir) {
if (resolve(target.workDir) !== resolve(workDir)) {
stderr.write(
`${chalk.hex('#E8A838')(
`Session "${opts.session}" was created under a different directory.\n` +
@ -240,7 +284,10 @@ async function resolvePromptSession(
`Session "${opts.session}" was created under a different directory.`,
);
}
const session = await harness.resumeSession({ id: opts.session });
const session = await harness.resumeSession({
id: opts.session,
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
});
const status = await session.getStatus();
const restorePermission = await forcePromptPermission(
session,
@ -264,7 +311,10 @@ async function resolvePromptSession(
const sessions = await harness.listSessions({ workDir });
const previous = sessions[0];
if (previous !== undefined) {
const session = await harness.resumeSession({ id: previous.id });
const session = await harness.resumeSession({
id: previous.id,
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
});
const status = await session.getStatus();
const restorePermission = await forcePromptPermission(
session,
@ -287,7 +337,13 @@ async function resolvePromptSession(
}
const model = requireConfiguredModel(opts.model, defaultModel);
const session = await harness.createSession({ workDir, model, permission: 'auto' });
const session = await harness.createSession({
workDir,
model,
permission: 'auto',
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
drainAgentTasksOnStop: true,
});
installHeadlessHandlers(session);
return {
session,
@ -353,16 +409,21 @@ function installPromptTerminationCleanup(
};
const onSigint = () => exitAfterCleanup('SIGINT');
const onSigterm = () => exitAfterCleanup('SIGTERM');
const onSighup = () => exitAfterCleanup('SIGHUP');
promptProcess.once('SIGINT', onSigint);
promptProcess.once('SIGTERM', onSigterm);
promptProcess.once('SIGHUP', onSighup);
return () => {
promptProcess.off('SIGINT', onSigint);
promptProcess.off('SIGTERM', onSigterm);
promptProcess.off('SIGHUP', onSighup);
};
}
function signalExitCode(signal: NodeJS.Signals): number {
return signal === 'SIGINT' ? 130 : 143;
if (signal === 'SIGINT') return 130;
if (signal === 'SIGHUP') return 129;
return 143;
}
function runPromptTurn(
@ -371,9 +432,11 @@ function runPromptTurn(
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
stderr: PromptOutput,
waitForGoalTerminal = false,
): Promise<void> {
let activeTurnId: number | undefined;
let activeAgentId: string | undefined;
let latestStartedTurnId: number | undefined;
const outputWriter =
outputFormat === 'stream-json'
? new PromptJsonWriter(stdout)
@ -408,6 +471,18 @@ function runPromptTurn(
}
activeTurnId = event.turnId;
activeAgentId = event.agentId;
latestStartedTurnId = event.turnId;
return;
}
if (
waitForGoalTerminal &&
event.type === 'goal.updated' &&
event.agentId === PROMPT_MAIN_AGENT_ID &&
activeTurnId === undefined &&
event.snapshot !== null &&
event.snapshot.status !== 'active'
) {
void finishCompletedTurn();
return;
}
if (
@ -426,6 +501,7 @@ function runPromptTurn(
return;
case 'turn.step.retrying':
outputWriter.discardAssistant();
outputWriter.writeRetrying(event);
return;
case 'assistant.delta':
outputWriter.writeAssistantDelta(event.delta);
@ -454,7 +530,29 @@ function runPromptTurn(
return;
case 'turn.ended':
if (event.reason === 'completed') {
finish();
outputWriter.flushAssistant();
if (waitForGoalTerminal) {
const completedTurnId = event.turnId;
activeTurnId = undefined;
activeAgentId = undefined;
void (async () => {
try {
const { goal } = await session.getGoal();
if (
activeTurnId !== undefined ||
latestStartedTurnId !== completedTurnId
) {
return;
}
if (goal?.status === 'active') return;
await finishCompletedTurn();
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)));
}
})();
return;
}
void finishCompletedTurn();
return;
}
finish(new Error(formatTurnEndedFailure(event)));
@ -474,6 +572,8 @@ function runPromptTurn(
case 'subagent.completed':
case 'subagent.failed':
case 'subagent.spawned':
case 'subagent.started':
case 'subagent.suspended':
case 'tool.list.updated':
case 'turn.started':
case 'turn.step.completed':
@ -485,6 +585,20 @@ function runPromptTurn(
session.prompt(prompt).catch((error: unknown) => {
finish(error instanceof Error ? error : new Error(String(error)));
});
async function finishCompletedTurn(): Promise<void> {
// Flush the buffered assistant message before draining background tasks:
// in stream-json mode the final message is only emitted by finish(), so a
// long background wait would otherwise withhold the main turn's result
// until the drain settles.
outputWriter.flushAssistant();
try {
await session.waitForBackgroundTasksOnPrint();
} catch (error) {
log.warn('waitForBackgroundTasksOnPrint failed', { error });
}
finish();
}
});
}
@ -499,6 +613,7 @@ interface PromptTurnWriter {
argumentsPart: string | undefined,
): void;
writeToolResult(toolCallId: string, output: unknown): void;
writeRetrying(event: Extract<Event, { type: 'turn.step.retrying' }>): void;
flushAssistant(): void;
discardAssistant(): void;
finish(): void;
@ -535,7 +650,14 @@ class PromptTranscriptWriter implements PromptTurnWriter {
writeToolResult(): void {}
flushAssistant(): 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 {}
@ -574,6 +696,18 @@ interface PromptJsonResumeMetaMessage {
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;
}
function writeResumeHint(
sessionId: string,
outputFormat: PromptOutputFormat,
@ -672,6 +806,24 @@ class PromptJsonWriter implements PromptTurnWriter {
this.toolCalls.length = 0;
}
writeRetrying(event: Extract<Event, { type: 'turn.step.retrying' }>): 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);
}
finish(): void {
this.flushAssistant();
}
@ -691,7 +843,9 @@ class PromptJsonWriter implements PromptTurnWriter {
return toolCall;
}
private writeJsonLine(message: PromptJsonAssistantMessage | PromptJsonToolMessage): void {
private writeJsonLine(
message: PromptJsonAssistantMessage | PromptJsonToolMessage | PromptJsonRetryMetaMessage,
): void {
this.stdout.write(`${JSON.stringify(message)}\n`);
}
}
@ -791,5 +945,8 @@ function hasTurnId(event: Event): event is Event & { readonly turnId: number } {
function formatTurnEndedFailure(event: Extract<Event, { type: 'turn.ended' }>): string {
if (event.error !== undefined) return `${event.error.code}: ${event.error.message}`;
if (event.reason === 'filtered') {
return 'Provider safety policy blocked the response.';
}
return `Prompt turn ended with reason: ${event.reason}`;
}

View file

@ -1,7 +1,13 @@
import { execSync } from 'node:child_process';
import { execSync, spawnSync } from 'node:child_process';
import { homedir } from 'node:os';
import { join } from 'node:path';
import {
createKimiHarness,
log,
type KimiHarness,
type TelemetryClient,
} from '@moonshot-ai/kimi-code-sdk';
import {
setCrashPhase,
setTelemetryContext,
@ -9,12 +15,6 @@ import {
track,
withTelemetryContext,
} from '@moonshot-ai/kimi-telemetry';
import {
createKimiHarness,
log,
type KimiHarness,
type TelemetryClient,
} from '@moonshot-ai/kimi-code-sdk';
import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE } from '#/constant/app';
import { detectPendingMigration } from '#/migration/index';
@ -22,7 +22,10 @@ import type { TuiConfig } from '#/tui/config';
import { loadTuiConfig, TuiConfigParseError } from '#/tui/config';
import { CHROME_GUTTER } from '#/tui/constant/rendering';
import { KimiTUI } from '#/tui/index';
import { detectTerminalTheme } from '#/tui/theme/detect';
import { currentTheme, getColorPalette } from '#/tui/theme';
import { combineStartupNotice } from '#/tui/utils/startup';
import { toTerminalHyperlink } from '#/utils/terminal-hyperlink';
import { restoreTerminalModes } from '#/utils/terminal-restore';
import type { CLIOptions } from './options';
import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry';
@ -45,9 +48,9 @@ export async function runShell(
configWarning = error.message;
}
// Resolve `theme = "auto"` against the live terminal once, before pi-tui
// grabs stdin. Explicit `dark` / `light` skip detection.
const resolvedTheme = tuiConfig.theme === 'auto' ? await detectTerminalTheme() : tuiConfig.theme;
// Initialise the global Theme singleton before pi-tui grabs stdin.
const palette = await getColorPalette(tuiConfig.theme);
currentTheme.setPalette(palette);
const workDir = process.cwd();
const telemetryBootstrap = createCliTelemetryBootstrap();
@ -59,17 +62,19 @@ export async function runShell(
const harness = createKimiHarness({
homeDir: telemetryBootstrap.homeDir,
identity: createKimiCodeHostIdentity(version),
skillDirs: opts.skillsDirs,
telemetry: telemetryClient,
onOAuthRefresh: (outcome) => {
if (outcome.success) {
track('oauth_refresh', { success: true });
track('oauth_refresh', { outcome: 'success' });
return;
}
track('oauth_refresh', {
success: false,
outcome: 'error',
reason: outcome.reason,
});
},
sessionStartedProperties: { yolo: opts.yolo, auto: opts.auto, plan: opts.plan, afk: false },
});
log.info('kimi-code starting', {
version,
@ -91,14 +96,17 @@ export async function runShell(
return;
}
const config = await harness.getConfig();
for (const warning of (await harness.getConfigDiagnostics()).warnings) {
configWarning = combineStartupNotice(configWarning, warning);
}
const configMs = Date.now() - configStartedAt;
const tui = new KimiTUI(harness, {
cliOptions: opts,
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
tuiConfig,
version,
workDir,
startupNotice: configWarning,
resolvedTheme,
migrationPlan,
migrateOnly: runOptions.migrateOnly,
});
@ -112,7 +120,6 @@ export async function runShell(
});
setCrashPhase('runtime');
const resumed = opts.continue || opts.session !== undefined;
const trackLifecycleForSession = (
sessionId: string,
event: string,
@ -128,35 +135,85 @@ export async function runShell(
trackLifecycleForSession(tui.getCurrentSessionId(), event, properties);
};
let savedStty: string | undefined;
try {
// stty operates on the terminal behind stdin, so stdin must be the TTY —
// piping /dev/null (ignore) makes stty fail with "not a tty".
const saved = execSync('stty -g', {
encoding: 'utf8',
stdio: ['inherit', 'pipe', 'ignore'],
});
savedStty = typeof saved === 'string' ? saved.trim() : undefined;
execSync('stty -ixon', { stdio: ['inherit', 'ignore', 'ignore'] });
} catch {
/* ignore */
}
const restoreStty = (): void => {
if (savedStty === undefined) return;
const args = savedStty.split(/\s+/).filter((arg) => arg.length > 0);
if (args.length === 0) return;
spawnSync('stty', args, { stdio: ['inherit', 'ignore', 'ignore'] });
};
// If we crash without going through KimiTUI.stop(), the terminal is left in
// raw mode with a hidden cursor and XON/XOFF flow control disabled. Restore
// both before exiting so the user's shell is usable afterwards.
const emergencyExit = (exitCode: number): void => {
restoreTerminalModes();
restoreStty();
process.exit(exitCode);
};
const onUncaughtException = (error: unknown): void => {
try {
log.error('uncaughtException, restoring terminal and exiting', { error: String(error) });
} catch {
/* ignore */
}
emergencyExit(1);
};
const onUnhandledRejection = (reason: unknown): void => {
try {
log.error('unhandledRejection, restoring terminal and exiting', { reason: String(reason) });
} catch {
/* ignore */
}
emergencyExit(1);
};
process.on('uncaughtException', onUncaughtException);
process.on('unhandledRejection', onUnhandledRejection);
// Remove the crash handlers once the TUI exits cleanly so repeated runShell()
// calls in the same process (e.g. tests) don't accumulate process listeners.
const removeCrashHandlers = (): void => {
process.off('uncaughtException', onUncaughtException);
process.off('unhandledRejection', onUnhandledRejection);
};
tui.onExit = async (exitCode = 0) => {
const sessionId = tui.getCurrentSessionId();
const hasContent = tui.hasSessionContent();
setCrashPhase('shutdown');
trackLifecycle('exit', { duration_s: (Date.now() - startedAt) / 1000 });
trackLifecycle('exit', { duration_ms: Date.now() - startedAt });
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
const gutter = ' '.repeat(CHROME_GUTTER);
process.stdout.write(`${gutter}Bye!\n`);
const hints: string[] = [];
if (sessionId !== '' && hasContent) {
process.stderr.write(`\n${gutter}To resume this session: kimi -r ${sessionId}\n`);
hints.push(`${gutter}To resume this session: kimi -r ${sessionId}`);
}
if (tui.exitOpenUrl !== undefined) {
hints.push(`${gutter}open ${toTerminalHyperlink(tui.exitOpenUrl, tui.exitOpenUrl)}`);
}
if (hints.length > 0) {
process.stderr.write(`\n${hints.join('\n')}\n`);
}
removeCrashHandlers();
restoreStty();
process.exit(exitCode);
};
try {
execSync('stty -ixon', { stdio: 'ignore' });
} catch {
/* ignore */
}
try {
const initStartedAt = Date.now();
await tui.start();
const initMs = Date.now() - initStartedAt;
trackLifecycle('started', {
resumed,
yolo: opts.yolo,
auto: opts.auto,
plan: opts.plan,
afk: false,
});
const startupSessionId = tui.getCurrentSessionId();
const mcpMs = await tui.getStartupMcpMs();
trackLifecycleForSession(startupSessionId, 'startup_perf', {
@ -166,8 +223,9 @@ export async function runShell(
mcp_ms: mcpMs,
});
} catch (error) {
removeCrashHandlers();
setCrashPhase('shutdown');
trackLifecycle('exit', { duration_s: (Date.now() - startedAt) / 1000 });
trackLifecycle('exit', { duration_ms: Date.now() - startedAt });
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
await harness.close();
throw error;

View file

@ -21,11 +21,17 @@
import type { Command } from 'commander';
import { runAcpServer } from '@moonshot-ai/acp-adapter';
import { createKimiHarness } from '@moonshot-ai/kimi-code-sdk';
import {
ACP_BUILTIN_SLASH_COMMANDS,
runAcpServer,
type AvailableCommand,
type SlashCommandsSnapshot,
} from '@moonshot-ai/acp-adapter';
import { createKimiHarness, type Session, type SkillSummary } from '@moonshot-ai/kimi-code-sdk';
import { KIMI_CODE_HOME_ENV } from '#/constant/app';
import { createKimiCodeHostIdentity, getVersion } from '#/cli/version';
import { buildSkillSlashCommands } from '#/tui/commands/skills';
import { runLoginFlow } from './login-flow';
@ -66,9 +72,46 @@ export function registerAcpCommand(parent: Command): void {
// client can spawn it with `args:['login']` for the top-level
// `kimi login` subcommand — matches kimi-cli `acp/server.py:77-96`.
const legacyCommand = process.argv[1];
const builtinCommands: AvailableCommand[] = (ACP_BUILTIN_SLASH_COMMANDS as readonly AvailableCommand[]).map((cmd) => ({
name: cmd.name,
description: cmd.description,
input: cmd.input,
}));
// Skills are session-scoped (per-cwd config), so we defer the
// listSkills() call until the adapter hands us the just-created
// Session — mirrors opencode's per-directory snapshot. A
// listSkills() failure degrades to builtins-only so a broken
// skill source never blanks the palette.
const resolveSlashCommands = async (
session: Session,
): Promise<SlashCommandsSnapshot> => {
let skills: readonly SkillSummary[] = [];
try {
skills = await session.listSkills();
} catch {
skills = [];
}
// `buildSkillSlashCommands` already returns both views — the
// palette entries (advertised via `available_commands_update`)
// and the `commandName → skillName` map the adapter uses to
// intercept `/skill:<name>` inputs and route them to
// `Session.activateSkill`. Passing both through keeps the two
// surfaces in lockstep (palette ↔ interceptable set) without
// a second `listSkills()` round trip.
const built = buildSkillSlashCommands(skills);
const skillCommands = built.commands.map((cmd) => ({
name: cmd.name,
description: cmd.description,
}));
return {
commands: [...builtinCommands, ...skillCommands],
skillCommandMap: built.commandMap,
};
};
try {
await runAcpServer(harness, {
agentInfo: { name: 'Kimi Code CLI', version: getVersion() },
slashCommands: resolveSlashCommands,
...(terminalAuthEnv ? { terminalAuthEnv } : {}),
...(legacyCommand !== undefined && legacyCommand.length > 0
? { terminalAuthLegacyCommand: legacyCommand }

View file

@ -17,18 +17,17 @@ export async function runLoginFlow(): Promise<never> {
uiMode: 'cli',
});
const controller = new AbortController();
process.once('SIGINT', () => controller.abort());
process.once('SIGINT', () => {
controller.abort();
});
try {
const result = await harness.auth.login(undefined, {
signal: controller.signal,
onDeviceCode: (data) => {
const url = data.verificationUriComplete || data.verificationUri;
// Best-effort: try to open the user's default browser at the
// pre-baked URL (which already embeds the user code). Print the
// URL + code as a fallback for headless boxes / when openUrl
// silently fails (it `execFile`s `open`/`xdg-open`/`cmd start`
// with no error handling — see `utils/open-url.ts`).
openUrl(url);
// Print the manual fallback before attempting to open the user's
// browser so headless/browser-opener failures never hide the URL
// and code needed to complete login.
process.stderr.write(
[
'',
@ -43,15 +42,20 @@ export async function runLoginFlow(): Promise<never> {
.filter((line): line is string => line !== undefined)
.join('\n'),
);
try {
openUrl(url);
} catch {
// Best effort only: the manual fallback has already been printed.
}
},
});
process.stderr.write(`Logged in to ${result.providerName}.\n`);
process.exit(0);
} catch (err) {
} catch (error) {
if (controller.signal.aborted) {
process.stderr.write('Login cancelled.\n');
} else {
const message = err instanceof Error ? err.message : String(err);
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`Login failed: ${message}\n`);
}
process.exit(1);

View file

@ -7,8 +7,9 @@
*
* `add` writes the same `source = { kind: 'apiJson', url, apiKey }` blob the
* TUI does; the next launch's `refreshAllProviderModels`
* (apps/kimi-code/src/tui/utils/refresh-providers.ts) groups by `{url, apiKey}`
* and re-fetches the model list, so periodic refresh is automatic.
* (apps/kimi-code/src/tui/utils/refresh-providers.ts) groups by URL, retries
* available API-key candidates, and re-fetches the model list, so periodic
* refresh is automatic.
*/
import {
@ -339,7 +340,7 @@ export async function handleCatalogAdd(
// already-configured provider would lose the user's previously-set default
// even when `--default-model` is not supplied.
const previousDefaultModel = config.defaultModel;
const previousDefaultThinking = config.defaultThinking;
const previousThinking = config.thinking;
if (config.providers[providerId] !== undefined) {
config = await harness.removeProvider(providerId);
@ -347,7 +348,7 @@ export async function handleCatalogAdd(
const baseUrl = catalogBaseUrl(entry, wire);
// `applyCatalogProvider` always overwrites both `defaultModel` and
// `defaultThinking`. The values we pass here are temporary; we restore
// `[thinking]`. The values we pass here are temporary; we restore
// a consistent state in the post-apply block below.
applyCatalogProvider(config, {
providerId,
@ -372,18 +373,18 @@ export async function handleCatalogAdd(
config.defaultModel = stillResolves ? previousDefaultModel : undefined;
}
// Always restore `defaultThinking` from what was there before — including
// `undefined`. Persisting `false` when the user never set it would make
// `resolveThinkingLevel` (agent-core/src/agent/config/thinking.ts) treat
// it as an explicit "off" request and silently disable thinking, even
// for thinking-capable models.
config.defaultThinking = previousDefaultThinking;
// Always restore `[thinking]` from what was there before — including
// `undefined`. Persisting `enabled: false` when the user never set it would
// make `resolveThinkingEffort` (agent-core/src/agent/config/thinking.ts) treat
// it as an explicit "off" request and silently disable thinking, even for
// thinking-capable models.
config.thinking = previousThinking;
await harness.setConfig({
providers: config.providers,
models: config.models,
defaultModel: config.defaultModel,
defaultThinking: config.defaultThinking,
thinking: config.thinking,
});
const displayName = entry.name ?? providerId;
@ -410,13 +411,26 @@ export function registerProviderCommand(parent: Command, deps?: Partial<Provider
.command('provider')
.description('Manage LLM providers non-interactively.');
// Last-resort boundary: handlers report expected failures themselves, but
// anything that escapes (e.g. a config write rejected because config.toml
// is invalid) must end as a one-line error + exit 1, not an unhandled
// rejection dumping a stack trace.
const runAction = async (resolved: ProviderDeps, run: () => Promise<void>): Promise<void> => {
try {
await run();
} catch (error) {
resolved.stderr.write(`${errorMessage(error)}\n`);
resolved.exit(1);
}
};
provider
.command('add <url>')
.description('Import every provider listed in a custom registry (api.json).')
.option('--api-key <key>', 'Registry API key. Falls back to KIMI_REGISTRY_API_KEY.')
.action(async (url: string, options: { apiKey?: string }) => {
const resolved = resolveDeps(deps);
await handleProviderAdd(resolved, url, { apiKey: options.apiKey });
await runAction(resolved, () => handleProviderAdd(resolved, url, { apiKey: options.apiKey }));
});
provider
@ -424,7 +438,7 @@ export function registerProviderCommand(parent: Command, deps?: Partial<Provider
.description('Remove a provider and every model alias that referenced it.')
.action(async (providerId: string) => {
const resolved = resolveDeps(deps);
await handleProviderRemove(resolved, providerId);
await runAction(resolved, () => handleProviderRemove(resolved, providerId));
});
provider
@ -433,7 +447,7 @@ export function registerProviderCommand(parent: Command, deps?: Partial<Provider
.option('--json', 'Emit the raw providers/models config as JSON.', false)
.action(async (options: { json?: boolean }) => {
const resolved = resolveDeps(deps);
await handleProviderList(resolved, { json: options.json === true });
await runAction(resolved, () => handleProviderList(resolved, { json: options.json === true }));
});
const catalog = provider
@ -452,11 +466,13 @@ export function registerProviderCommand(parent: Command, deps?: Partial<Provider
options: { filter?: string; url?: string; json?: boolean },
) => {
const resolved = resolveDeps(deps);
await handleCatalogList(resolved, providerId, {
json: options.json === true,
...(options.filter === undefined ? {} : { filter: options.filter }),
...(options.url === undefined ? {} : { url: options.url }),
});
await runAction(resolved, () =>
handleCatalogList(resolved, providerId, {
json: options.json === true,
...(options.filter === undefined ? {} : { filter: options.filter }),
...(options.url === undefined ? {} : { url: options.url }),
}),
);
},
);
@ -472,11 +488,13 @@ export function registerProviderCommand(parent: Command, deps?: Partial<Provider
options: { apiKey?: string; defaultModel?: string; url?: string },
) => {
const resolved = resolveDeps(deps);
await handleCatalogAdd(resolved, providerId, {
...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }),
...(options.defaultModel === undefined ? {} : { defaultModel: options.defaultModel }),
...(options.url === undefined ? {} : { url: options.url }),
});
await runAction(resolved, () =>
handleCatalogAdd(resolved, providerId, {
...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }),
...(options.defaultModel === undefined ? {} : { defaultModel: options.defaultModel }),
...(options.url === undefined ? {} : { url: options.url }),
}),
);
},
);
}

View file

@ -0,0 +1,85 @@
/**
* Build the clickable/copyable access URLs for the running server.
*
* Shared by the `server run` ready banner and `server rotate-token` so both
* show the same Local/Network links. When a token is known it rides in the
* `#token=` fragment (never sent to the server, so never logged), letting a
* user open the link on another device and be authenticated automatically.
*/
import { formatHostForUrl, listNetworkAddresses, type NetworkAddress } from './networks';
/**
* Build a directly-openable server URL. When the token is known it is appended
* as `#token=<token>`; otherwise the bare origin (with a trailing slash) is
* returned.
*/
export function buildOpenableUrl(bareOrigin: string, token: string | undefined): string {
const base = bareOrigin.endsWith('/') ? bareOrigin.slice(0, -1) : bareOrigin;
return token === undefined ? `${base}/` : `${base}/#token=${token}`;
}
/**
* Split a full URL into the part before `#token=` and the `#token=…` fragment
* itself, so callers can render the fragment in a de-emphasized color. Returns
* `[fullUrl, '']` when there is no token fragment.
*/
export function splitTokenFragment(fullUrl: string): [string, string] {
const marker = '#token=';
const idx = fullUrl.indexOf(marker);
return idx < 0 ? [fullUrl, ''] : [fullUrl.slice(0, idx), fullUrl.slice(idx)];
}
export interface AccessUrlLine {
/** Fixed-width label including trailing padding, e.g. `"Local: "`. */
label: string;
/** Full URL, carrying `#token=` when a token is known. */
url: string;
}
function isWildcard(host: string): boolean {
return host === '' || host === '0.0.0.0' || host === '::';
}
/** True when `host` is a loopback address (this host only). */
export function isLoopbackHost(host: string): boolean {
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
}
function hostOrigin(host: string, port: number): string {
const family = host.includes(':') ? 'IPv6' : 'IPv4';
return `http://${formatHostForUrl(host, family)}:${port}`;
}
/**
* Compute the access-URL lines for a bind host/port.
*
* - wildcard (`0.0.0.0` / `::` / empty): a `Local:` line (localhost) plus one
* `Network:` line per non-loopback interface.
* - loopback: a single `Local:` line.
* - specific host: a single `URL:` line.
*/
export function accessUrlLines(
host: string,
port: number,
token: string | undefined,
networkAddresses?: NetworkAddress[],
): AccessUrlLine[] {
if (isWildcard(host)) {
const lines: AccessUrlLine[] = [
{ label: 'Local: ', url: buildOpenableUrl(`http://localhost:${port}`, token) },
];
const addrs = networkAddresses ?? listNetworkAddresses();
for (const addr of addrs) {
lines.push({
label: 'Network: ',
url: buildOpenableUrl(`http://${formatHostForUrl(addr.address, addr.family)}:${port}`, token),
});
}
return lines;
}
if (isLoopbackHost(host)) {
return [{ label: 'Local: ', url: buildOpenableUrl(hostOrigin(host, port), token) }];
}
return [{ label: 'URL: ', url: buildOpenableUrl(hostOrigin(host, port), token) }];
}

View file

@ -0,0 +1,412 @@
/**
* `kimi web` daemon orchestration parent (spawner) side.
*
* Ensures a single background server daemon exists for this device, then
* returns its origin so the caller can open the web UI. The flow:
*
* 1. Read `~/.kimi-code/server/lock`. If it names a *live* daemon, reuse it
* (wait for it to be healthy) never spawn a second one.
* 2. Otherwise pick a free port (preferred port when available, else an
* OS-assigned one) and spawn `kimi server run --daemon` as a detached
* child whose stdio is redirected to the server log.
* 3. Poll the lock until *some* live daemon (ours, or a concurrent racer's
* that won the lock) is healthy, then return its origin.
*
* The child side (`startServerDaemon`) lives in `./run.ts` next to the
* foreground runner so it can share the same bootstrap helpers.
*/
import { spawn, type ChildProcess } from 'node:child_process';
import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { createServer } from 'node:net';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import { DEFAULT_LOCK_DIR, getLiveLock, type LockContents } from '@moonshot-ai/server';
import {
DEFAULT_SERVER_HOST,
DEFAULT_SERVER_PORT,
LOCAL_SERVER_HOST,
isServerHealthy,
serverOrigin,
waitForServerHealthy,
} from './shared';
const SERVER_LOG_FILENAME = 'server.log';
/** How long to wait for an already-running daemon to answer `/healthz`. */
const REUSE_HEALTH_TIMEOUT_MS = 15_000;
/** How long to wait for a freshly-spawned daemon to come up. */
const SPAWN_TIMEOUT_MS = 20_000;
/** Poll cadence while waiting for the daemon to appear in the lock + healthz. */
const POLL_INTERVAL_MS = 200;
/** Default log level for a daemon spawned without an explicit `--log-level`. */
const DEFAULT_DAEMON_LOG_LEVEL = 'info';
export interface EnsureDaemonOptions {
/** Bind host for the spawned daemon (default `127.0.0.1`). */
host?: string;
/** Preferred port; on conflict a free port is chosen automatically. */
port?: number;
/** Pino log level for the spawned daemon (defaults to `info`). */
logLevel?: string;
/** Mount `/api/v1/debug/*` routes on the spawned daemon. */
debugEndpoints?: boolean;
/** Allow a non-loopback bind without a TLS-terminating reverse proxy. */
insecureNoTls?: boolean;
/** Keep `POST /api/v1/shutdown` enabled on a non-loopback bind. */
allowRemoteShutdown?: boolean;
/** Keep the PTY `/api/v1/terminals/*` routes enabled on a non-loopback bind. */
allowRemoteTerminals?: boolean;
/** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */
dangerousBypassAuth?: boolean;
/** Keep the daemon alive instead of idle-killing it (`--keep-alive`). */
keepAlive?: boolean;
/** Extra `Host` header values to allow through the DNS-rebinding check. */
allowedHosts?: readonly string[];
/** Idle-shutdown grace in ms for the spawned daemon (daemon mode only). */
idleGraceMs?: number;
}
export interface EnsureDaemonResult {
readonly origin: string;
/** True when an already-running daemon was reused (no new server started). */
readonly reused: boolean;
/** Bind host the running daemon is actually listening on (from the lock). */
readonly host: string;
/** Port the running daemon is actually listening on (from the lock). */
readonly port: number;
}
/** Path of the daemon log file (shared with the OS-service log location). */
export function daemonLogPath(): string {
return join(DEFAULT_LOCK_DIR, SERVER_LOG_FILENAME);
}
export function lockConnectHost(lock: LockContents): string {
const host = lock.host ?? LOCAL_SERVER_HOST;
return host === '0.0.0.0' ? LOCAL_SERVER_HOST : host;
}
/** True when `host:port` is currently free to bind (nothing listening). */
function canBind(host: string, port: number): Promise<boolean> {
return new Promise((resolvePromise) => {
const probe = createServer();
probe.once('error', () => resolvePromise(false));
probe.listen({ host, port }, () => {
probe.close(() => resolvePromise(true));
});
});
}
/** Ask the OS for an ephemeral free port on `host`. */
function getFreePort(host: string): Promise<number> {
return new Promise((resolvePromise, reject) => {
const probe = createServer();
probe.once('error', reject);
probe.listen({ host, port: 0 }, () => {
const address = probe.address();
if (address === null || typeof address === 'string') {
probe.close(() => reject(new Error('failed to allocate a free port')));
return;
}
const { port } = address;
probe.close(() => resolvePromise(port));
});
});
}
/**
* How many consecutive `preferred + n` ports to probe before giving up and
* asking the OS for any free port. Mirrors `PORT_RETRY_LIMIT` in the server's
* own bind retry so the spawner and the daemon agree on the policy.
*/
export const DAEMON_PORT_SCAN_LIMIT = 100;
/**
* Pick a port for a new daemon: prefer `preferred` when it is free, otherwise
* walk `preferred + 1`, `+ 2`, upward and take the first free one. Only when
* the whole scan window is saturated do we fall back to an OS-assigned free
* port.
*
* Reusing an already-live daemon is handled by `ensureDaemon` before this runs,
* so a busy port here is held by a third-party process bumping by one (rather
* than jumping to a random ephemeral port) keeps the URL predictable, matching
* the server's own "port busy ⇒ +1" bind retry.
*/
export async function resolveDaemonPort(
host: string = DEFAULT_SERVER_HOST,
preferred: number = DEFAULT_SERVER_PORT,
): Promise<number> {
for (
let candidate = preferred;
candidate < preferred + DAEMON_PORT_SCAN_LIMIT && candidate <= 65535;
candidate++
) {
if (await canBind(host, candidate)) return candidate;
}
return getFreePort(host);
}
interface NodeSeaModule {
isSea(): boolean;
}
const nodeRequire = createRequire(import.meta.url);
let cachedSea: NodeSeaModule | null | undefined;
function loadSeaModule(): NodeSeaModule | null {
if (cachedSea !== undefined) return cachedSea;
try {
cachedSea = nodeRequire('node:sea') as NodeSeaModule;
} catch {
cachedSea = null;
}
return cachedSea;
}
/** True when running as a compiled single-executable (SEA / native) binary. */
function detectSea(): boolean {
const sea = loadSeaModule();
if (sea === null) return false;
try {
return sea.isSea();
} catch {
return false;
}
}
/**
* Absolute path to the CLI entry that should be re-execed to run the daemon.
* Mirrors `resolveSupervisorProgram` in `packages/server/src/svc/program.ts`:
* 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
* must re-exec `process.execPath` itself.
*/
export function resolveDaemonProgram(
argv: readonly string[] = process.argv,
cwd: string = process.cwd(),
execPath: string = process.execPath,
isSea: boolean = detectSea(),
): string {
// In a SEA binary `argv[1]` is not a script path, so resolving it against
// `cwd` would produce a bogus path (e.g. `<cwd>/kimi`) and crash the spawn
// with ENOENT. Always re-exec the binary itself.
if (isSea) return execPath;
const candidate = argv[1] === 'server' ? execPath : (argv[1] ?? execPath);
return isAbsolute(candidate) ? candidate : resolve(cwd, candidate);
}
interface SpawnDaemonChildOptions {
host?: string;
port: number;
logLevel: string;
debugEndpoints?: boolean;
insecureNoTls?: boolean;
allowRemoteShutdown?: boolean;
allowRemoteTerminals?: boolean;
dangerousBypassAuth?: boolean;
keepAlive?: boolean;
allowedHosts?: readonly string[];
idleGraceMs?: number;
}
export function spawnDaemonChild(options: SpawnDaemonChildOptions): ChildProcess {
const program = resolveDaemonProgram();
const logPath = daemonLogPath();
const logDir = dirname(logPath);
mkdirSync(logDir, { recursive: true });
const args = [
'server',
'run',
'--daemon',
'--port',
String(options.port),
'--log-level',
options.logLevel,
];
if (options.host !== undefined) {
args.push('--host', options.host);
}
if (options.debugEndpoints === true) {
args.push('--debug-endpoints');
}
if (options.insecureNoTls === true) {
args.push('--insecure-no-tls');
}
if (options.allowRemoteShutdown === true) {
args.push('--allow-remote-shutdown');
}
if (options.allowRemoteTerminals === true) {
args.push('--allow-remote-terminals');
}
if (options.dangerousBypassAuth === true) {
args.push('--dangerous-bypass-auth');
}
if (options.keepAlive === true) {
args.push('--keep-alive');
}
if (options.idleGraceMs !== undefined) {
args.push('--idle-grace-ms', String(options.idleGraceMs));
}
if (options.allowedHosts !== undefined && options.allowedHosts.length > 0) {
args.push('--allowed-host', ...options.allowedHosts);
}
// On Windows `.mjs` files are not executable PE binaries, so we must run
// the script through the Node binary rather than spawning it directly. In
// SEA mode or when re-spawning from an already-running daemon, `program` is
// `process.execPath` itself, so no script argument is needed.
const execPath = process.execPath;
const spawnArgs = program === execPath ? args : [program, ...args];
const logFd = openSync(logPath, 'a');
try {
const child = spawn(execPath, spawnArgs, {
detached: true,
// Run from the server log directory instead of inheriting the caller's
// cwd, so the long-lived daemon does not pin the directory it was
// launched from (notably blocking its deletion on Windows).
cwd: logDir,
stdio: ['ignore', logFd, logFd],
});
child.once('error', (error) => {
// A spawn failure (e.g. ENOENT) surfaces asynchronously on the child,
// not as a thrown error. Without a listener Node would crash the parent
// with an unhandled 'error' event; record it instead and let the polling
// loop in `ensureDaemon` report the timeout.
try {
appendFileSync(logPath, `[spawner] failed to launch daemon: ${error.message}\n`);
} catch {
// Best-effort; the log directory may already be gone.
}
});
child.unref();
return child;
} finally {
// `spawn` dups the fd into the child; the parent must not keep it open.
closeSync(logFd);
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolvePromise) => {
setTimeout(resolvePromise, ms);
});
}
/**
* Ensure a daemon is running and return its origin. Non-blocking for the
* caller beyond the short health wait the server itself keeps running in a
* detached process after this returns.
*/
export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise<EnsureDaemonResult> {
const host = options.host ?? DEFAULT_SERVER_HOST;
const preferred = options.port ?? DEFAULT_SERVER_PORT;
const logLevel = options.logLevel ?? DEFAULT_DAEMON_LOG_LEVEL;
// 1. Reuse an already-live daemon if one holds the lock.
const existing = getLiveLock();
if (existing) {
const origin = serverOrigin(lockConnectHost(existing), existing.port);
if (await waitForServerHealthy(origin, REUSE_HEALTH_TIMEOUT_MS)) {
return {
origin,
reused: true,
host: existing.host ?? DEFAULT_SERVER_HOST,
port: existing.port,
};
}
// Live pid but not responding (wedged or mid-boot failure). Fall through
// and spawn: if it is truly wedged our child loses the lock race and we
// reconnect below; if it died, stale takeover lets our child claim it.
}
// 2. No reusable daemon — pick a free port and spawn one detached.
const port = await resolveDaemonPort(host, preferred);
const child = spawnDaemonChild({
host,
port,
logLevel,
debugEndpoints: options.debugEndpoints,
insecureNoTls: options.insecureNoTls,
allowRemoteShutdown: options.allowRemoteShutdown,
allowRemoteTerminals: options.allowRemoteTerminals,
dangerousBypassAuth: options.dangerousBypassAuth,
keepAlive: options.keepAlive,
allowedHosts: options.allowedHosts,
idleGraceMs: options.idleGraceMs,
});
// Watch for an early exit so a boot failure (e.g. the non-loopback TLS gate,
// a config error, or a lost lock race with no other daemon to fall back to)
// surfaces the real error immediately instead of waiting out the full spawn
// timeout. The exit code/signal plus a tail of the daemon log is what tells
// the operator *why* it failed.
let childExit: { code: number | null; signal: NodeJS.Signals | null } | undefined;
child.once('exit', (code, signal) => {
childExit = { code, signal };
});
child.once('error', () => {
// Spawn failure (ENOENT etc.) is already recorded in the log by
// spawnDaemonChild; treat it as an early exit here.
childExit = { code: -1, signal: null };
});
// 3. Wait until some live daemon (ours, or a racer that won the lock) is up.
const deadline = Date.now() + SPAWN_TIMEOUT_MS;
while (Date.now() < deadline) {
const live = getLiveLock();
if (live) {
const origin = serverOrigin(lockConnectHost(live), live.port);
if (await isServerHealthy(origin, 500)) {
return {
origin,
reused: false,
host: live.host ?? DEFAULT_SERVER_HOST,
port: live.port,
};
}
}
if (childExit !== undefined && !live) {
// Our child exited and no other live daemon holds the lock to fall back
// to — this is a real boot failure, not a lost race.
throw new Error(formatDaemonBootFailure(childExit, daemonLogPath()));
}
await sleep(POLL_INTERVAL_MS);
}
throw new Error(
`Kimi server daemon failed to start within ${String(SPAWN_TIMEOUT_MS)}ms.\n\n` +
formatLogTail(daemonLogPath()),
);
}
function formatDaemonBootFailure(
exit: { code: number | null; signal: NodeJS.Signals | null },
logPath: string,
): string {
const reason =
exit.signal === null
? `exited with code ${String(exit.code)}`
: `was terminated by signal ${exit.signal}`;
return `Kimi server daemon ${reason} during startup.\n\n${formatLogTail(logPath)}`;
}
function formatLogTail(logPath: string): string {
const tail = tailFile(logPath, 30);
if (tail.length === 0) {
return `Check the log for details: ${logPath}`;
}
return `Last log lines (${logPath}):\n${tail}`;
}
function tailFile(filePath: string, maxLines: number): string {
try {
const content = readFileSync(filePath, 'utf8');
const lines = content.split('\n').filter((line) => line.length > 0);
return lines.slice(-maxLines).join('\n');
} catch {
return '';
}
}

View file

@ -0,0 +1,49 @@
/**
* `kimi server` parent command. Mounts:
* - `server run` (background daemon by default; `--foreground` to attach; the
* detached daemon child runs the same command with `--daemon`)
*
* The OS service-manager subcommands (`install/uninstall/start/stop/restart/
* status`) are temporarily NOT registered — see the commented
* `addLifecycleCommands(server)` below. Their implementation is preserved in
* `./lifecycle.ts` + `packages/server/src/svc/*` for later re-exposure.
*
* The top-level `kimi web` alias is registered separately via
* `registerWebAliasCommand` so it stays at the program root.
*/
import type { Command } from 'commander';
import { registerPsCommand } from './ps';
import { registerKillCommand } from './kill';
import { buildRunCommand } from './run';
import { registerRotateTokenCommand } from './rotate-token';
import { registerWebAliasCommand } from './web-alias';
export function registerServerCommand(program: Command): void {
const server = program
.command('server')
.description('Run the local Kimi server (REST + WebSocket + web UI).');
buildRunCommand(
server.command('run').description('Start the Kimi server (background daemon; use --foreground to attach).'),
{ defaultOpen: false },
);
registerPsCommand(server);
registerKillCommand(server);
registerRotateTokenCommand(server);
// OS service-manager commands (`install/uninstall/start/stop/restart/status`)
// are temporarily hidden — the product now favors the on-demand background
// daemon (`kimi web`) over service-ization. The implementation still lives in
// `./lifecycle.ts` + `packages/server/src/svc/*`; re-import
// `addLifecycleCommands` and call it here to re-expose.
// addLifecycleCommands(server);
registerWebAliasCommand(program);
}
export { registerWebAliasCommand };

View file

@ -0,0 +1,167 @@
/**
* `kimi server kill` terminate the running server.
*
* Combines two independent mechanisms so the server dies even if one path
* fails:
*
* 1. API path `POST /api/v1/shutdown` for a graceful, in-process shutdown
* (best-effort; older builds or a wedged server may not answer).
* 2. PID path signal the pid recorded in the lock (SIGTERM wait
* SIGKILL). SIGKILL / TerminateProcess is the hard guarantee:
* it cannot be caught or ignored.
*
* The only honest failure mode is insufficient permissions (a process owned by
* another user), which surfaces as an error rather than a silent miss.
*/
import type { Command } from 'commander';
import { getLiveLock, type LockContents } from '@moonshot-ai/server';
import { getDataDir } from '#/utils/paths';
import { lockConnectHost } from './daemon';
import { authHeaders, serverOrigin, tryResolveServerToken } from './shared';
/** How long to wait for the graceful API shutdown request. */
const API_TIMEOUT_MS = 2000;
/** Grace period after SIGTERM before escalating to SIGKILL. */
const TERM_GRACE_MS = 3000;
/** Grace period after SIGKILL before giving up. */
const KILL_GRACE_MS = 2000;
/** Poll cadence while waiting for the pid to exit. */
const POLL_INTERVAL_MS = 100;
export interface KillCommandDeps {
getLiveLock(): LockContents | undefined;
requestShutdown(origin: string, token: string | undefined): Promise<void>;
/** Best-effort read of the persistent bearer token; undefined on miss. */
resolveToken(): string | undefined;
signalPid(pid: number, signal: NodeJS.Signals): boolean;
pidAlive(pid: number): boolean;
sleep(ms: number): Promise<void>;
stdout: Pick<NodeJS.WriteStream, 'write'>;
now(): number;
}
export function registerKillCommand(server: Command): void {
server
.command('kill')
.description('Stop the running Kimi server (graceful API + forced PID kill).')
.action(async () => {
try {
await handleKillCommand(DEFAULT_KILL_DEPS);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
}
});
}
export async function handleKillCommand(deps: KillCommandDeps): Promise<void> {
const lock = deps.getLiveLock();
if (!lock) {
deps.stdout.write('No running Kimi server.\n');
return;
}
const { pid } = lock;
const origin = serverOrigin(lockConnectHost(lock), lock.port);
// 1. API path — best-effort graceful shutdown. Ignore every outcome: the
// server may be an older build without the route, already wedged, or may
// drop the connection as it exits. The bearer token (M5.1) is best-effort
// too: if it can't be read the API call 401s and the PID path below still
// guarantees the kill.
const token = deps.resolveToken();
await deps.requestShutdown(origin, token).catch(() => {});
// 2. PID path — SIGTERM, wait, then SIGKILL.
deps.signalPid(pid, 'SIGTERM');
if (await waitForExit(pid, TERM_GRACE_MS, deps)) {
deps.stdout.write(`Kimi server (pid ${String(pid)}) stopped.\n`);
return;
}
deps.signalPid(pid, 'SIGKILL');
if (await waitForExit(pid, KILL_GRACE_MS, deps)) {
deps.stdout.write(`Kimi server (pid ${String(pid)}) killed.\n`);
return;
}
throw new Error(
`Failed to stop Kimi server (pid ${String(pid)}); insufficient permissions?`,
);
}
async function waitForExit(
pid: number,
timeoutMs: number,
deps: Pick<KillCommandDeps, 'pidAlive' | 'sleep' | 'now'>,
): Promise<boolean> {
const deadline = deps.now() + timeoutMs;
do {
if (!deps.pidAlive(pid)) return true;
await deps.sleep(POLL_INTERVAL_MS);
} while (deps.now() < deadline);
return !deps.pidAlive(pid);
}
/** `process.kill(pid, 0)` probe — true if the pid exists, false on ESRCH. */
export function pidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ESRCH') return false;
// EPERM = process exists but we can't signal it. Treat as alive.
return true;
}
}
/** Send `signal` to `pid`. Returns false if the signal could not be sent. */
export function signalPid(pid: number, signal: NodeJS.Signals): boolean {
try {
process.kill(pid, signal);
return true;
} catch {
return false;
}
}
/** POST the shutdown endpoint; resolves once the request completes or times out. */
export async function requestShutdownViaApi(
origin: string,
token: string | undefined,
): Promise<void> {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, API_TIMEOUT_MS);
try {
await fetch(`${origin}/api/v1/shutdown`, {
method: 'POST',
headers: token !== undefined ? authHeaders(token) : undefined,
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
}
const DEFAULT_KILL_DEPS: KillCommandDeps = {
getLiveLock,
requestShutdown: requestShutdownViaApi,
resolveToken: () => tryResolveServerToken(getDataDir()),
signalPid,
pidAlive,
sleep: (ms) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
}),
stdout: process.stdout,
now: () => Date.now(),
};

View file

@ -0,0 +1,254 @@
/**
* `kimi server install/uninstall/start/stop/restart/status`.
*
* Phase 2 lands the CLI shape; the lifecycle calls into the platform service
* manager from `@moonshot-ai/server`, which is filled in by Phase 3+.
*
* The Commander wiring here mirrors `addGatewayServiceCommands` from
* `../openclaw/src/cli/daemon-cli/register-service-commands.ts:58`.
*/
import type { Command } from 'commander';
import {
ServiceUnavailableError,
ServiceUnsupportedError,
resolveServiceManager,
type InstallArgs,
type ServiceManager,
type ServiceStatus,
} from '@moonshot-ai/server';
import { openUrl as defaultOpenUrl } from '#/utils/open-url';
import {
DEFAULT_LOG_LEVEL,
DEFAULT_SERVER_HOST,
DEFAULT_SERVER_PORT,
LOCAL_SERVER_HOST,
parseLogLevel,
parsePort,
serverOrigin,
VALID_LOG_LEVELS,
} from './shared';
export interface InstallCliOptions {
port?: string;
logLevel?: string;
force?: boolean;
open?: boolean;
json?: boolean;
}
export interface JsonCliOptions {
json?: boolean;
}
export interface LifecycleCommandDeps {
resolveManager(): ServiceManager;
openUrl(url: string): void;
stdout: Pick<NodeJS.WriteStream, 'write'>;
stderr: Pick<NodeJS.WriteStream, 'write'>;
}
const DEFAULT_DEPS: LifecycleCommandDeps = {
resolveManager: resolveServiceManager,
openUrl: defaultOpenUrl,
stdout: process.stdout,
stderr: process.stderr,
};
/** Mount install/uninstall/start/stop/restart/status under a parent command. */
export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps = DEFAULT_DEPS): void {
parent
.command('install')
.description('Install the Kimi server as an OS-managed service (launchd/systemd/schtasks).')
.option('--port <port>', `Bind port (default ${DEFAULT_SERVER_PORT})`, String(DEFAULT_SERVER_PORT))
.option(
'--log-level <level>',
`Log level: ${VALID_LOG_LEVELS.join('|')} (default ${DEFAULT_LOG_LEVEL})`,
DEFAULT_LOG_LEVEL,
)
.option('--force', 'Reinstall and overwrite if already installed', false)
.option('--no-open', 'Do not open the web UI after install.', true)
.option('--json', 'Output JSON', false)
.action(async (opts: InstallCliOptions) => {
await runLifecycle(deps, opts.json === true, async (mgr) => {
const args: InstallArgs = {
host: DEFAULT_SERVER_HOST,
port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT),
logLevel: parseLogLevel(opts.logLevel),
force: opts.force === true,
};
const result = await mgr.install(args);
const status = await readStatus(mgr);
const enriched = withStatusDetails({
ok: true,
action: 'install',
status: result.status,
plistPath: result.plistPath,
unitPath: result.unitPath,
taskName: result.taskName,
message: result.message,
}, status, args);
if (opts.json !== true && opts.open !== false && enriched.running === true && typeof enriched.url === 'string') {
deps.openUrl(enriched.url);
}
return enriched;
});
});
parent
.command('uninstall')
.description('Uninstall the Kimi server service.')
.option('--json', 'Output JSON', false)
.action(async (opts: JsonCliOptions) => {
await runLifecycle(deps, opts.json === true, async (mgr) => {
const result = await mgr.uninstall();
return { ok: result.ok, action: 'uninstall', message: result.message };
});
});
parent
.command('start')
.description('Start the Kimi server service.')
.option('--json', 'Output JSON', false)
.action(async (opts: JsonCliOptions) => {
await runLifecycle(deps, opts.json === true, async (mgr) => {
const result = await mgr.start();
const status = await readStatus(mgr);
return withStatusDetails({ ok: result.ok, action: 'start', message: result.message }, status);
});
});
parent
.command('stop')
.description('Stop the Kimi server service.')
.option('--json', 'Output JSON', false)
.action(async (opts: JsonCliOptions) => {
await runLifecycle(deps, opts.json === true, async (mgr) => {
const result = await mgr.stop();
return { ok: result.ok, action: 'stop', message: result.message };
});
});
parent
.command('restart')
.description('Restart the Kimi server service.')
.option('--json', 'Output JSON', false)
.action(async (opts: JsonCliOptions) => {
await runLifecycle(deps, opts.json === true, async (mgr) => {
const result = await mgr.restart();
const status = await readStatus(mgr);
return withStatusDetails({ ok: result.ok, action: 'restart', message: result.message }, status);
});
});
parent
.command('status')
.description('Show Kimi server service status and connectivity.')
.option('--json', 'Output JSON', false)
.action(async (opts: JsonCliOptions) => {
await runLifecycle(deps, opts.json === true, async (mgr) => {
const status: ServiceStatus = await mgr.status();
return withStatusDetails({ ok: true, action: 'status', ...status }, status);
});
});
}
async function runLifecycle(
deps: LifecycleCommandDeps,
json: boolean,
body: (mgr: ServiceManager) => Promise<Record<string, unknown>>,
): Promise<void> {
try {
const mgr = deps.resolveManager();
const result = await body(mgr);
if (json) {
deps.stdout.write(`${JSON.stringify(result)}\n`);
return;
}
deps.stdout.write(formatHuman(result));
} catch (error) {
if (error instanceof ServiceUnavailableError || error instanceof ServiceUnsupportedError) {
const payload = {
ok: false,
action: error instanceof ServiceUnavailableError ? 'unavailable' : 'unsupported',
platform: error.platform,
message: error.message,
};
if (json) {
deps.stdout.write(`${JSON.stringify(payload)}\n`);
} else {
deps.stderr.write(`${error.message}\n`);
}
process.exit(2);
return;
}
if (json) {
deps.stdout.write(
`${JSON.stringify({ ok: false, message: error instanceof Error ? error.message : String(error) })}\n`,
);
} else {
deps.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
}
process.exit(1);
}
}
function formatHuman(result: Record<string, unknown>): string {
const rawAction = result['action'];
const action = typeof rawAction === 'string' ? rawAction : 'action';
const rawMessage = result['message'];
const message = typeof rawMessage === 'string' ? `: ${rawMessage}` : '';
const lines = [`${action}${message}`];
const url = result['url'];
if (typeof url === 'string') lines.push(`URL: ${url}`);
const running = result['running'];
if (typeof running === 'boolean') lines.push(`Status: ${running ? 'running' : 'not running'}`);
const logPath = result['logPath'];
if (typeof logPath === 'string') lines.push(`Log: ${logPath}`);
const notes = result['notes'];
if (Array.isArray(notes)) {
for (const note of notes) {
if (typeof note === 'string' && note.length > 0) lines.push(`Note: ${note}`);
}
}
return `${lines.join('\n')}\n`;
}
async function readStatus(mgr: ServiceManager): Promise<ServiceStatus | undefined> {
try {
return await mgr.status();
} catch {
return undefined;
}
}
function withStatusDetails(
result: Record<string, unknown>,
status: ServiceStatus | undefined,
fallback?: { host: string; port: number },
): Record<string, unknown> & { url?: string; running?: boolean } {
const host = status?.host ?? fallback?.host;
const port = status?.port ?? fallback?.port;
const url = host !== undefined && port !== undefined ? formatServiceUrl(host, port) : undefined;
return {
...result,
url,
running: status?.running,
host,
port,
logPath: status?.logPath,
notes: status?.notes,
};
}
function formatServiceUrl(host: string, port: number): string {
return serverOrigin(host === '0.0.0.0' ? LOCAL_SERVER_HOST : host, port);
}

View file

@ -0,0 +1,84 @@
/**
* Enumerate this machine's non-loopback network interface addresses, used to
* print `Network: http://<addr>:<port>/` hints (à la Vite) when the server
* binds a wildcard host (`0.0.0.0` / `::`).
*/
import { networkInterfaces } from 'node:os';
export interface NetworkAddress {
/** Raw IP address (IPv4 or IPv6); IPv6 is NOT bracket-wrapped here. */
address: string;
family: 'IPv4' | 'IPv6';
}
/**
* List non-internal interface addresses, IPv4 first then IPv6, preserving
* interface order within each family.
*
* Like Vite, this lists the machine's own interface addresses LAN
* (192.168/10/172.16) plus any directly-assigned public address. It does not
* (and cannot, without an external service) discover a NAT-translated WAN IP,
* and we deliberately avoid any network call for a startup hint.
*/
export function listNetworkAddresses(): NetworkAddress[] {
const raw: NetworkAddress[] = [];
for (const entries of Object.values(networkInterfaces())) {
for (const info of entries ?? []) {
if (info.internal) {
continue;
}
if (info.family === 'IPv4') {
raw.push({ address: info.address, family: 'IPv4' });
} else if (info.family === 'IPv6') {
raw.push({ address: info.address, family: 'IPv6' });
}
}
}
return filterDisplayAddresses(raw);
}
/**
* Drop addresses that are not useful as a connect target and de-duplicate.
*
* IPv6 link-local (`fe80::/10`) is filtered out: it is only reachable with a
* zone id (e.g. `fe80::1%en0`), which our bare URL cannot carry, so showing it
* is pure noise and it is the bulk of what `os.networkInterfaces()` reports
* on a typical machine. Duplicates (the same address reported on more than one
* interface) are collapsed. The result is IPv4 first, then IPv6, preserving
* order within each family.
*/
export function filterDisplayAddresses(
addrs: readonly NetworkAddress[],
): NetworkAddress[] {
const seen = new Set<string>();
const kept: NetworkAddress[] = [];
for (const addr of addrs) {
if (addr.family === 'IPv6' && isLinkLocalV6(addr.address)) {
continue;
}
if (seen.has(addr.address)) {
continue;
}
seen.add(addr.address);
kept.push(addr);
}
return [
...kept.filter((a) => a.family === 'IPv4'),
...kept.filter((a) => a.family === 'IPv6'),
];
}
/** True for IPv6 link-local addresses (`fe80::/10`, i.e. `fe80::``febf::`). */
function isLinkLocalV6(address: string): boolean {
const first = Number.parseInt(address.split(':')[0] ?? '', 16);
return first >= 0xfe80 && first <= 0xfebf;
}
/**
* Format an address for use as a URL host: bracket-wrap IPv6 per RFC 3986,
* return IPv4 as-is.
*/
export function formatHostForUrl(address: string, family: NetworkAddress['family']): string {
return family === 'IPv6' ? `[${address}]` : address;
}

View file

@ -0,0 +1,148 @@
/**
* `kimi server ps` list clients currently connected to the running server.
*
* Talks to the running server over HTTP (`GET /api/v1/connections`) using the
* single-instance lock (`~/.kimi-code/server/lock`) to discover its origin
* the same way `kimi web` locates the daemon.
*/
import chalk from 'chalk';
import type { Command } from 'commander';
import { getLiveLock } from '@moonshot-ai/server';
import { getDataDir } from '#/utils/paths';
import { lockConnectHost } from './daemon';
import { authHeaders, isServerHealthy, resolveServerToken, serverOrigin } from './shared';
/** Wire shape of a single connection returned by `GET /api/v1/connections`. */
interface ConnectionInfo {
id: string;
connected_at: string;
remote_address: string | null;
user_agent: string | null;
has_client_hello: boolean;
subscriptions: string[];
}
interface ConnectionsEnvelope {
code: number;
msg: string;
data?: { connections?: ConnectionInfo[] };
}
const HEALTH_TIMEOUT_MS = 1500;
const FETCH_TIMEOUT_MS = 5000;
const USER_AGENT_MAX_WIDTH = 40;
export function registerPsCommand(server: Command): void {
server
.command('ps')
.description('List clients currently connected to the running Kimi server.')
.option('--json', 'Print the raw connection list as JSON.')
.action(async (opts: { json?: boolean }) => {
try {
await handlePsCommand(opts);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
}
});
}
async function handlePsCommand(opts: { json?: boolean }): Promise<void> {
const lock = getLiveLock();
if (!lock) {
throw new Error(
'No running Kimi server. Start one with `kimi server run` or `kimi web`.',
);
}
const origin = serverOrigin(lockConnectHost(lock), lock.port);
if (!(await isServerHealthy(origin, HEALTH_TIMEOUT_MS))) {
throw new Error(`Kimi server at ${origin} is not responding.`);
}
// The `/api/v1/connections` route is gated by bearer auth (M5.1). Read the
// persistent token; a clear error here means the server has never been
// started (no token file yet) or the token file was removed.
const token = resolveServerToken(getDataDir());
const connections = await fetchConnections(origin, token);
if (opts.json) {
process.stdout.write(`${JSON.stringify(connections, null, 2)}\n`);
return;
}
process.stdout.write(formatTable(connections));
}
async function fetchConnections(origin: string, token: string): Promise<ConnectionInfo[]> {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, FETCH_TIMEOUT_MS);
try {
const res = await fetch(`${origin}/api/v1/connections`, {
headers: authHeaders(token),
signal: controller.signal,
});
if (!res.ok) {
throw new Error(`Failed to list clients: HTTP ${String(res.status)} from ${origin}.`);
}
const body = (await res.json()) as ConnectionsEnvelope;
if (body.code !== 0) {
throw new Error(`Failed to list clients: ${body.msg}`);
}
return body.data?.connections ?? [];
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(`Timed out listing clients from ${origin}.`);
}
throw error;
} finally {
clearTimeout(timeout);
}
}
function formatTable(connections: ConnectionInfo[]): string {
if (connections.length === 0) {
return 'No active clients.\n';
}
const header = ['ID', 'CONNECTED', 'REMOTE', 'USER_AGENT', 'SESSIONS', 'HELLO'];
const rows = connections.map((c) => [
c.id,
formatAge(c.connected_at),
c.remote_address ?? '-',
truncate(c.user_agent ?? '-', USER_AGENT_MAX_WIDTH),
String(c.subscriptions.length),
c.has_client_hello ? 'yes' : 'no',
]);
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i]!.length)));
const formatRow = (cells: string[]): string =>
cells.map((cell, i) => cell + ' '.repeat(Math.max(0, widths[i]! - cell.length))).join(' ');
const lines = [chalk.bold(formatRow(header)), ...rows.map(formatRow)];
return `${lines.join('\n')}\n`;
}
function formatAge(iso: string): string {
const ms = Date.now() - Date.parse(iso);
if (!Number.isFinite(ms) || ms < 0) return '-';
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${String(seconds)}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${String(minutes)}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${String(hours)}h`;
const days = Math.floor(hours / 24);
return `${String(days)}d`;
}
function truncate(value: string, max: number): string {
if (value.length <= max) return value;
if (max <= 1) return value.slice(0, max);
return `${value.slice(0, max - 1)}`;
}

View file

@ -0,0 +1,57 @@
/**
* `kimi server rotate-token` generate a new persistent server token.
*
* Rewrites `<KIMI_CODE_HOME>/server.token` (0600, atomic). The previous token
* stops working immediately: a running server re-reads the file on its next
* auth check, so rotation takes effect without a restart.
*/
import { getLiveLock, rotateServerToken } from '@moonshot-ai/server';
import chalk from 'chalk';
import type { Command } from 'commander';
import { darkColors } from '#/tui/theme/colors';
import { getDataDir } from '#/utils/paths';
import { accessUrlLines, splitTokenFragment } from './access-urls';
import { DEFAULT_SERVER_HOST } from './shared';
export function registerRotateTokenCommand(server: Command): void {
server
.command('rotate-token')
.description(
'Generate a new persistent server token; the previous token stops working immediately.',
)
.action(async () => {
try {
const token = await rotateServerToken(getDataDir());
process.stdout.write(
'The previous token is now invalid. A running server picks up the new token automatically.\n',
);
// Token in the middle: indented and set off by blank lines (no color
// highlight), so it is easy to spot without dominating the output.
process.stdout.write(`\n ${chalk.bold('New server token:')} ${token}\n\n`);
// Re-print the access links with the new token so the user can
// reconnect immediately. When a server is running its bind host/port
// come from the lock; otherwise there is nothing to connect to yet.
const lock = getLiveLock();
if (lock !== undefined) {
const host = lock.host ?? DEFAULT_SERVER_HOST;
for (const { label, url: href } of accessUrlLines(host, lock.port, token)) {
// De-emphasize the `#token=…` fragment so the host/port stands out.
const [base, frag] = splitTokenFragment(href);
const rendered =
frag === ''
? chalk.hex(darkColors.accent)(base)
: chalk.hex(darkColors.accent)(base) + chalk.hex(darkColors.textDim)(frag);
process.stdout.write(` ${chalk.dim(label)}${rendered}\n`);
}
}
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
}
});
}

View file

@ -0,0 +1,559 @@
/**
* `kimi server run` starts the local server.
*
* By default this ensures a single background daemon is running (spawning a
* detached `kimi server run --daemon` child when needed) and returns once it is
* healthy. Pass `--foreground` to run the server in-process and keep this
* terminal attached until SIGINT/SIGTERM. OS-managed background operation
* (launchd / systemd / schtasks) lives in `kimi server install` + `kimi server start`.
*
* `kimi web` is an alias of this command with `--open` defaulted to `true`,
* registered in `./web-alias.ts`.
*/
import { join } from 'node:path';
import { shutdownTelemetry, track } from '@moonshot-ai/kimi-telemetry';
import { startServer, type RunningServer } from '@moonshot-ai/server';
import chalk from 'chalk';
import { Option, type Command } from 'commander';
import { CLI_SHUTDOWN_TIMEOUT_MS } from '#/constant/app';
import { getNativeWebAssetsDir } from '#/native/web-assets';
import { darkColors } from '#/tui/theme/colors';
import { openUrl as defaultOpenUrl } from '#/utils/open-url';
import { getDataDir } from '#/utils/paths';
import { initializeServerTelemetry } from '../../telemetry';
import { createKimiCodeHostIdentity, getHostPackageRoot, getVersion } from '../../version';
import {
accessUrlLines,
buildOpenableUrl,
isLoopbackHost,
splitTokenFragment,
} from './access-urls';
import { ensureDaemon, type EnsureDaemonResult } from './daemon';
import { type NetworkAddress } from './networks';
import {
DEFAULT_FOREGROUND_LOG_LEVEL,
DEFAULT_LAN_HOST,
DEFAULT_SERVER_HOST,
DEFAULT_SERVER_PORT,
parseServerOptions,
tryResolveServerToken,
VALID_LOG_LEVELS,
type ParsedServerOptions,
type ServerCliOptions,
} from './shared';
const WEB_ASSETS_DIR = 'dist-web';
export interface RunCliOptions extends ServerCliOptions {
open?: boolean;
/** Run the server in-process instead of spawning a background daemon. */
foreground?: boolean;
}
export interface StartForegroundHooks {
/** Fires once the server is listening, before the foreground runner blocks. */
onReady?: (origin: string) => void;
}
export interface RunCommandDeps {
startServerBackground(options: ParsedServerOptions): Promise<{
origin: string;
/** True when an already-running daemon was reused (no new server started). */
reused?: boolean;
/** Bind host the running daemon is actually listening on (from the lock). */
host?: string;
/** Port the running daemon is actually listening on (from the lock). */
port?: number;
}>;
/** Foreground runner; defaults to the real in-process runner when omitted. */
startServerForeground?: (
options: ParsedServerOptions,
hooks?: StartForegroundHooks,
) => Promise<never>;
openUrl(url: string): void;
/**
* Best-effort read of the server's persistent bearer token. When it returns
* a token, the ready banner prints it and the opened Web UI URL carries it in
* the `#token=` fragment (M5.5). Optional so callers/tests that don't supply
* it simply print/open the plain origin.
*/
resolveToken?: () => string | undefined;
/**
* Non-loopback interface addresses to display for a wildcard bind. Defaults
* to the machine's own interfaces (`listNetworkAddresses()`); inject a fixed
* list in tests for deterministic output.
*/
networkAddresses?: NetworkAddress[];
stdout: Pick<NodeJS.WriteStream, 'write'>;
stderr: Pick<NodeJS.WriteStream, 'write'>;
}
/**
* Build the Web UI URL, carrying the bearer token in the URL fragment.
*
* The token rides in `#token=<token>` a client-side fragment that is never
* sent to the server (so it never appears in server access logs) and is not
* logged by proxies. The Web UI reads it from `location.hash` after load.
*/
export function buildWebUrl(origin: string, token: string): string {
return buildOpenableUrl(origin, token);
}
/** Build the `run` subcommand, mounted under a parent (`server` or top-level). */
export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean }): Command {
return cmd
.option(
'--port <port>',
`Bind port (default ${DEFAULT_SERVER_PORT})`,
String(DEFAULT_SERVER_PORT),
)
.option(
'--host [host]',
`Bind host. Omit to bind ${DEFAULT_SERVER_HOST} (this machine only); pass --host to bind ${DEFAULT_LAN_HOST} (all interfaces), or --host <host> for a specific host. The bearer token is printed at startup.`,
)
.option(
'--allowed-host <host...>',
'Extra Host header value to allow through the DNS-rebinding check. Repeat or comma-separate; a leading dot matches a domain suffix (e.g. .example.com).',
)
.option(
'--keep-alive',
'Keep the server running instead of exiting after 60s with no connected clients. Implied automatically by --host / --allowed-host, and always on in --foreground mode.',
false,
)
.option(
'--insecure-no-tls',
'Allow a non-loopback bind without a TLS-terminating reverse proxy. Defaults to true; only relevant for non-loopback binds.',
true,
)
.option(
'--allow-remote-shutdown',
'On a non-loopback bind, keep POST /api/v1/shutdown enabled (default: route is disabled → 404).',
false,
)
.option(
'--allow-remote-terminals',
'On a non-loopback bind, keep the PTY /api/v1/terminals/* routes enabled (default: disabled → 404). Remote shell is high risk.',
false,
)
.option(
'--dangerous-bypass-auth',
'Disable bearer-token auth on every REST and WebSocket route, and advertise it via /api/v1/meta so the web UI connects without a token. Only use on a trusted network or behind your own authenticating proxy.',
false,
)
.option(
'--log-level <level>',
`Server log level: ${VALID_LOG_LEVELS.join('|')}. Omit to keep logs off.`,
)
.option(
'--debug-endpoints',
'Mount /api/v1/debug/* routes for test introspection. OFF by default; production callers leave this unset.',
false,
)
.option(
'--foreground',
'Run the server in the foreground and keep this terminal attached until SIGINT/SIGTERM (do not daemonize).',
false,
)
.option(
options.defaultOpen ? '--no-open' : '--open',
options.defaultOpen
? 'Do not open the web UI in the default browser.'
: 'Open the web UI in the default browser once the server is healthy.',
options.defaultOpen,
)
.addOption(
new Option('--daemon', 'Run as an idle-exiting background daemon (internal).').hideHelp(),
)
.addOption(
new Option(
'--idle-grace-ms <ms>',
'Idle-shutdown grace in ms (daemon mode, internal).',
).hideHelp(),
)
.action(async (opts: RunCliOptions) => {
try {
await handleRunCommand(opts);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
}
});
}
export async function handleRunCommand(
opts: RunCliOptions,
deps: RunCommandDeps = DEFAULT_RUN_COMMAND_DEPS,
): Promise<void> {
const parsed = parseServerOptions(opts);
if (parsed.daemon) {
await startServerDaemon(parsed);
return;
}
// Foreground is always keep-alive: a server attached to the operator's
// terminal must never idle-kill itself. Background daemons respect the
// derived `--keep-alive` flag.
const runOptions: ParsedServerOptions =
opts.foreground === true ? { ...parsed, keepAlive: true } : parsed;
// Resolve the persistent token once: it is printed in the ready banner and
// rides in the opened Web UI URL's `#token=` fragment (M5.5). Falls back to
// the plain origin / no token line when unavailable. When auth is bypassed,
// the token is meaningless and is intentionally NOT shown or carried in the
// opened URL.
const writeReady = (result: { origin: string; reused?: boolean; host?: string }): void => {
const { origin } = result;
const host = result.host ?? parsed.host;
// When a daemon is reused, this command's flags were NOT applied to the
// already-running server. Don't trust the requested `--dangerous-bypass-auth`
// for display/open: treat the server as token-protected so we never hide a
// token the user actually needs, nor claim bypass for a server that is
// authenticating. (Probing the running server's `/meta` would give its real
// mode; we conservatively assume non-bypass on reuse.)
const effectiveBypass = result.reused === true ? false : parsed.dangerousBypassAuth;
const token = effectiveBypass ? undefined : deps.resolveToken?.();
let output = '';
if (result.reused === true) {
// A daemon was already running, so this command's --host/--port/etc. did
// not start a new one. Say so loudly, then print the actual running
// server's URLs (using its real bind host, not the requested one).
output += formatReuseNotice(origin);
}
output +=
parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL
? formatReadyBanner(origin, host, {
token,
networkAddresses: deps.networkAddresses,
dangerousBypassAuth: effectiveBypass,
})
: formatReadyLine(origin, token, effectiveBypass);
deps.stdout.write(output);
if (opts.open === true) {
deps.openUrl(token !== undefined ? buildWebUrl(origin, token) : origin);
}
};
if (opts.foreground === true) {
const run = deps.startServerForeground ?? startServerForeground;
await run(runOptions, {
onReady: (origin) => {
writeReady({ origin, reused: false, host: parsed.host });
},
});
return;
}
const result = await deps.startServerBackground(runOptions);
writeReady(result);
}
function formatReuseNotice(origin: string): string {
return (
`${chalk.hex(darkColors.warning)('A server is already running')} at ${origin}` +
`the options from this command were not applied. ` +
`Run ${chalk.bold('kimi server kill')} first to bind a new host/port.\n`
);
}
function formatReadyLine(
origin: string,
token: string | undefined,
dangerousBypassAuth = false,
): string {
const notice = dangerousBypassAuth
? `${formatDangerNoticeLines().join('\n')}\n`
: '';
return `${notice}Kimi server: ${buildOpenableUrl(origin, token)}\n`;
}
/**
* Red, impossible-to-miss notice emitted when `--dangerous-bypass-auth`
* disables the bearer-token gate. Shared by the full ready banner and the
* compact one-line output so the warning always shows regardless of log level.
*/
function formatDangerNoticeLines(): string[] {
const danger = (text: string): string => chalk.hex(darkColors.error)(text);
const dangerBold = (text: string): string => chalk.bold.hex(darkColors.error)(text);
return [
` ${dangerBold('⚠ DANGER: authentication is DISABLED (--dangerous-bypass-auth).')}`,
` ${danger('Anyone who can reach this port gets full access. Only continue if you understand the risk.')}`,
` ${danger(`If you are unsure, run `)}${dangerBold('kimi server kill')}${danger(' now to stop this process.')}`,
];
}
/**
* `kimi server run` (non-daemon) ensures a background daemon is running
* (spawning a detached `kimi server run --daemon` child if needed), then
* returns its origin so the caller can print the ready banner and exit. The
* server keeps running in the background after this returns.
*/
export async function startServerBackground(
options: ParsedServerOptions,
): Promise<EnsureDaemonResult> {
return ensureDaemon({
host: options.host,
port: options.port,
logLevel: options.logLevel,
debugEndpoints: options.debugEndpoints,
insecureNoTls: options.insecureNoTls,
allowRemoteShutdown: options.allowRemoteShutdown,
allowRemoteTerminals: options.allowRemoteTerminals,
dangerousBypassAuth: options.dangerousBypassAuth,
keepAlive: options.keepAlive,
allowedHosts: options.allowedHosts,
idleGraceMs: options.idleGraceMs,
});
}
/**
* `kimi server run --daemon` runs the local server as a background daemon.
*
* Spawned as a detached child by {@link startServerBackground}. The process is
* expected to be detached (no controlling terminal) and self-terminates after
* the last web client disconnects and a grace period elapses. The grace timer
* is driven by the WS connection count reported through `wsGatewayOptions`.
* Resolves only via `process.exit`.
*/
export async function startServerDaemon(options: ParsedServerOptions): Promise<never> {
return runServerInProcess(options, { daemon: true });
}
/**
* `kimi server run --foreground` runs the local server in-process, attached
* to the current terminal. Resolves only via `process.exit` (SIGINT/SIGTERM).
*/
export async function startServerForeground(
options: ParsedServerOptions,
hooks: StartForegroundHooks = {},
): Promise<never> {
return runServerInProcess(options, { daemon: false }, hooks.onReady);
}
/**
* Start the server in the current process and block until shutdown. Shared by
* the detached daemon (`daemon: true`, with idle-exit) and the foreground
* runner (`daemon: false`). `onReady` fires once the server is listening.
*/
async function runServerInProcess(
options: ParsedServerOptions,
mode: { daemon: boolean },
onReady?: (origin: string) => void,
): Promise<never> {
const version = getVersion();
const telemetry = initializeServerTelemetry({ version });
let running: RunningServer | undefined;
let stopping = false;
// Idle auto-shutdown is only for the on-demand personal daemon. It is skipped
// in foreground mode (`mode.daemon` is false) and whenever `--keep-alive` is
// set — explicitly, or implied by `--host` / `--allowed-host`.
const idle =
mode.daemon && !options.keepAlive
? createIdleShutdownHandler({
graceMs: options.idleGraceMs,
onIdle: () => {
void shutdown('idle');
},
})
: undefined;
async function shutdown(reason: string): Promise<void> {
if (stopping) return;
stopping = true;
idle?.cancel();
running?.logger.info({ reason }, 'server shutting down');
try {
await running?.close();
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
} catch (error) {
running?.logger.error(
{ err: error instanceof Error ? error : new Error(String(error)) },
'server shutdown error',
);
}
process.exit(0);
}
running = await startServer({
host: options.host,
port: options.port,
logLevel: options.logLevel,
debugEndpoints: options.debugEndpoints,
insecureNoTls: options.insecureNoTls,
allowRemoteShutdown: options.allowRemoteShutdown,
allowRemoteTerminals: options.allowRemoteTerminals,
dangerousBypassAuth: options.dangerousBypassAuth,
allowedHosts: options.allowedHosts,
webAssetsDir: serverWebAssetsDir(),
coreProcessOptions: {
identity: createKimiCodeHostIdentity(version),
telemetry,
},
wsGatewayOptions: {
telemetry,
onConnectionCountChange: idle
? (size) => {
idle.onConnectionCountChange(size);
}
: undefined,
},
});
track('server_started', { daemon: mode.daemon });
process.once('SIGINT', () => {
void shutdown('SIGINT');
});
process.once('SIGTERM', () => {
void shutdown('SIGTERM');
});
const readyFields = mode.daemon
? options.keepAlive
? { address: running.address, idleShutdown: 'disabled' as const }
: { address: running.address, idleGraceMs: options.idleGraceMs }
: { address: running.address };
running.logger.info(readyFields, mode.daemon ? 'daemon ready' : 'server ready');
onReady?.(running.address);
return new Promise<never>(() => {
// Keeps the event loop alive; the process ends via shutdown()/process.exit.
});
}
/**
* Pure idle-shutdown state machine, exported for tests.
*
* Watches the live WS connection count and fires `onIdle` exactly once, after
* the count has dropped back to zero for `graceMs` ms *and* at least one
* client had connected since startup. A reconnect before the grace elapses
* cancels the pending exit. The initial "no clients yet" state never arms the
* timer (so a freshly-spawned daemon is not killed before anyone connects).
*/
export function createIdleShutdownHandler(opts: { graceMs: number; onIdle: () => void }): {
onConnectionCountChange(size: number): void;
cancel(): void;
} {
let timer: NodeJS.Timeout | undefined;
let seenClient = false;
const cancel = (): void => {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
};
return {
onConnectionCountChange(size: number): void {
if (size > 0) {
seenClient = true;
cancel();
return;
}
if (seenClient) {
cancel();
timer = setTimeout(opts.onIdle, opts.graceMs);
}
},
cancel,
};
}
function serverWebAssetsDir(): string {
return resolveServerWebAssetsDir();
}
export function resolveServerWebAssetsDir(
nativeWebAssetsDir: string | null = getNativeWebAssetsDir(),
): string {
return nativeWebAssetsDir ?? join(getHostPackageRoot(), WEB_ASSETS_DIR);
}
interface FormatReadyBannerOptions {
/** Persistent bearer token to print; omitted when unresolvable. */
token?: string;
/** Non-loopback interface addresses to list for a wildcard bind. */
networkAddresses?: NetworkAddress[];
/** When true, render a red danger notice (auth is disabled). */
dangerousBypassAuth?: boolean;
}
function formatReadyBanner(
origin: string,
host: string,
opts: FormatReadyBannerOptions = {},
): string {
const primary = (text: string): string => chalk.hex(darkColors.primary)(text);
const title = (text: string): string => chalk.bold.hex(darkColors.primary)(text);
const dim = (text: string): string => chalk.hex(darkColors.textDim)(text);
const muted = (text: string): string => chalk.hex(darkColors.textMuted)(text);
const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text);
const url = (text: string): string => chalk.hex(darkColors.accent)(text);
// Render the `#token=…` fragment in a de-emphasized gray so the host/port
// stands out while the full URL stays selectable for copying.
const urlWithDimToken = (href: string): string => {
const [base, frag] = splitTokenFragment(href);
return frag === '' ? url(base) : url(base) + dim(frag);
};
const port = Number(new URL(origin).port);
// Borderless header: the Kimi sprite (the little mascot with eyes) sits next
// to the title, keeping the brand without the enclosing box.
const logo = ['▐█▛█▛█▌', '▐█████▌'] as const;
const lines: string[] = [
'',
` ${primary(logo[0])} ${title('Kimi server ready')} ${dim(getVersion())}`,
` ${primary(logo[1])} ${dim('Local web UI is available from this machine.')}`,
'',
];
if (opts.dangerousBypassAuth === true) {
// Red, impossible-to-miss notice: the bearer-token gate is off, so anyone
// who can reach this port gets full session / filesystem / shell access.
lines.push(...formatDangerNoticeLines(), '');
}
// Access links.
for (const { label: text, url: href } of accessUrlLines(
host,
port,
opts.token,
opts.networkAddresses,
)) {
lines.push(` ${label(text)}${urlWithDimToken(href)}`);
}
// On a loopback bind there is no network URL; show how to enable one.
if (isLoopbackHost(host)) {
lines.push(` ${label('Network: ')}${muted('off')}${dim(' use --host to enable')}`);
}
if (opts.token !== undefined) {
// Set the token off with surrounding whitespace rather than color, so it is
// easy to spot without being highlighted.
lines.push('');
lines.push(` ${label('Token: ')}${opts.token}`);
lines.push('');
}
// Auxiliary controls last.
lines.push(` ${label('Logs: ')}${muted('off')}${dim(' use --log-level info to enable')}`);
lines.push(` ${label('Stop: ')}${muted('kimi server kill')}`);
lines.push('');
return lines.join('\n');
}
const DEFAULT_RUN_COMMAND_DEPS: RunCommandDeps = {
startServerBackground,
startServerForeground,
openUrl: defaultOpenUrl,
resolveToken: () => {
// Read the persistent `<homeDir>/server.token` written on first boot
// (M5.1). Best-effort: a missing/older server yields undefined and the
// caller opens the plain origin.
return tryResolveServerToken(getDataDir());
},
stdout: process.stdout,
stderr: process.stderr,
};

View file

@ -0,0 +1,279 @@
/**
* Shared helpers for `kimi server …` subcommands.
*
* Owns the default host/port, option parsers, and health/readiness probes that
* `run`, `web`, and `status` all use.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { ServerLogLevel } from '@moonshot-ai/server';
export const LOCAL_SERVER_HOST = '127.0.0.1';
export const DEFAULT_LAN_HOST = '0.0.0.0';
export const DEFAULT_SERVER_HOST = LOCAL_SERVER_HOST;
export const DEFAULT_SERVER_PORT = 58627;
export const DEFAULT_SERVER_ORIGIN = serverOrigin(DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT);
/** Filename (under KIMI_CODE_HOME) of the persistent server bearer token. */
export const SERVER_TOKEN_FILE = 'server.token';
export const DEFAULT_LOG_LEVEL: ServerLogLevel = 'info';
export const DEFAULT_FOREGROUND_LOG_LEVEL: ServerLogLevel = 'silent';
/**
* Default idle-shutdown grace for the background daemon: once the last web
* client disconnects, the daemon waits this long before exiting. Overridable
* via the internal `--idle-grace-ms` flag (used by tests).
*/
export const DEFAULT_IDLE_GRACE_MS = 60_000;
export const VALID_LOG_LEVELS: readonly ServerLogLevel[] = [
'fatal',
'error',
'warn',
'info',
'debug',
'trace',
'silent',
];
export interface ParsedServerOptions {
host: string;
port: number;
logLevel: ServerLogLevel;
debugEndpoints: boolean;
/** Allow a non-loopback bind without a TLS-terminating reverse proxy. */
insecureNoTls: boolean;
/** Allow `POST /api/v1/shutdown` on a non-loopback bind. */
allowRemoteShutdown: boolean;
/** Allow PTY `/api/v1/terminals/*` routes on a non-loopback bind. */
allowRemoteTerminals: boolean;
/** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */
dangerousBypassAuth: boolean;
/** Extra `Host` header values to allow through the DNS-rebinding check. */
allowedHosts: readonly string[];
/**
* Keep the server running instead of idle-killing it after 60s with no
* connected clients (`--keep-alive`). Also implied automatically by a
* non-default bind (`--host`) or a proxy/tunnel setup (`--allowed-host`),
* and always on in `--foreground` mode. Only the daemon mode consults this
* foreground never idle-kills regardless.
*/
keepAlive: boolean;
/** Internal: run as an idle-exiting background daemon instead of foreground. */
daemon: boolean;
/** Internal: idle-shutdown grace in ms (daemon mode only). */
idleGraceMs: number;
}
export interface ServerCliOptions {
host?: string | boolean;
port?: string;
logLevel?: string;
debugEndpoints?: boolean;
/** Allow a non-loopback bind without TLS (`--insecure-no-tls`). */
insecureNoTls?: boolean;
/** Allow remote shutdown on a non-loopback bind (`--allow-remote-shutdown`). */
allowRemoteShutdown?: boolean;
/** Allow remote terminals on a non-loopback bind (`--allow-remote-terminals`). */
allowRemoteTerminals?: boolean;
/** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */
dangerousBypassAuth?: boolean;
/** Extra `Host` header values to allow (`--allowed-host`). */
allowedHost?: string[];
/** Keep the server running instead of idle-killing it (`--keep-alive`). */
keepAlive?: boolean;
/** Internal flag set by the daemon spawner (`kimi web`). */
daemon?: boolean;
/** Internal flag set by the daemon spawner / tests. */
idleGraceMs?: string;
}
export function parseServerOptions(opts: ServerCliOptions): ParsedServerOptions {
const host = parseHost(opts.host);
const allowedHosts = parseAllowedHostArgs(opts.allowedHost);
// `--keep-alive` is explicit, but also implied by a non-default bind
// (`--host`) or a proxy/tunnel setup (`--allowed-host`). Foreground mode is
// forced keep-alive later in `handleRunCommand`.
const keepAlive =
opts.keepAlive === true || host !== DEFAULT_SERVER_HOST || allowedHosts.length > 0;
return {
host,
port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT),
logLevel: parseLogLevel(opts.logLevel ?? DEFAULT_FOREGROUND_LOG_LEVEL),
debugEndpoints: opts.debugEndpoints === true,
insecureNoTls: opts.insecureNoTls !== false,
allowRemoteShutdown: opts.allowRemoteShutdown === true,
allowRemoteTerminals: opts.allowRemoteTerminals === true,
dangerousBypassAuth: opts.dangerousBypassAuth === true,
allowedHosts,
keepAlive,
daemon: opts.daemon === true,
idleGraceMs: parseIdleGraceMs(opts.idleGraceMs),
};
}
export function parseAllowedHostArgs(raw: readonly string[] | undefined): string[] {
if (raw === undefined) return [];
return raw
.flatMap((entry) => entry.split(','))
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
function parseHost(raw: string | boolean | undefined): string {
if (raw === undefined || raw === false) return DEFAULT_SERVER_HOST;
if (raw === true || raw === '') return DEFAULT_LAN_HOST;
return raw;
}
function parseIdleGraceMs(raw: string | undefined): number {
if (raw === undefined) return DEFAULT_IDLE_GRACE_MS;
const n = Number.parseInt(raw, 10);
if (!Number.isFinite(n) || n < 0) {
throw new Error(`error: invalid --idle-grace-ms value: ${raw}`);
}
return n;
}
export function parsePort(raw: string | undefined, label: string, fallback: number): number {
if (raw === undefined) return fallback;
const n = Number.parseInt(raw, 10);
if (!Number.isFinite(n) || n < 0 || n > 65535) {
throw new Error(`error: invalid ${label} value: ${raw}`);
}
return n;
}
export function parseLogLevel(raw: string | undefined): ServerLogLevel {
if (raw === undefined) return DEFAULT_LOG_LEVEL;
if ((VALID_LOG_LEVELS as readonly string[]).includes(raw)) {
return raw as ServerLogLevel;
}
throw new Error(
`error: invalid --log-level value: ${raw} (allowed: ${VALID_LOG_LEVELS.join(', ')})`,
);
}
export function serverOrigin(host: string, port: number): string {
return `http://${host}:${port}`;
}
/** Strip `/api/v1` and trailing slashes so user-supplied origins are uniform. */
export function normalizeServerOrigin(value: string): string {
const url = new URL(value);
url.pathname = url.pathname.replace(/\/api\/v1\/?$/, '').replace(/\/$/, '');
url.search = '';
url.hash = '';
return url.toString().replace(/\/$/, '');
}
/** Single probe of `/api/v1/healthz`. Returns true if the response envelope reports `code: 0`. */
export async function isServerHealthy(origin: string, timeoutMs: number): Promise<boolean> {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, timeoutMs);
try {
const response = await fetch(`${origin}/api/v1/healthz`, {
signal: controller.signal,
});
if (!response.ok) return false;
const body = (await response.json()) as { code?: unknown };
return body.code === 0;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
/** Poll `/api/v1/healthz` until it reports healthy or `timeoutMs` elapses. */
export async function waitForServerHealthy(origin: string, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
do {
if (await isServerHealthy(origin, 500)) {
return true;
}
await new Promise((resolve) => {
setTimeout(resolve, 200);
});
} while (Date.now() < deadline);
return false;
}
/**
* Probe `/` and confirm the bundled web UI is being served.
*
* A different build that runs on the same port serves its own bundle opening
* a browser at that origin lands on stale code. Catching that here lets the
* caller surface a clear "stop the running server" message instead of silently
* handing the user the wrong UI.
*/
export async function ensureServerWebReady(origin: string): Promise<void> {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, 3000);
try {
const response = await fetch(`${origin}/`, {
headers: { accept: 'text/html' },
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const body = await response.text();
if (!body.includes('<div id="app"')) {
throw new Error('missing app root');
}
} catch (error) {
const reason = error instanceof Error ? ` (${error.message})` : '';
throw new Error(
`Server at ${origin} does not serve the Kimi web UI${reason}. Stop the existing server and rerun \`kimi server run\`.`,
{ cause: error },
);
} finally {
clearTimeout(timeout);
}
}
/**
* Read the persistent bearer token for the server.
*
* The server writes `<homeDir>/server.token` (0600) on first boot and reuses
* it across restarts (ROADMAP M5.1); CLI commands that hit a gated REST route
* read it back here and send it as `Authorization: Bearer <token>`. `homeDir`
* is the CLI's own KIMI_CODE_HOME resolution (`getDataDir()`).
*
* Throws a clear error when the file is missing/unreadable the usual cause
* is a server that has never been started (no token file yet), or an older
* build that predates token auth.
*/
export function resolveServerToken(homeDir: string): string {
const tokenPath = join(homeDir, SERVER_TOKEN_FILE);
try {
return readFileSync(tokenPath, 'utf8').trim();
} catch (error) {
throw new Error(
`unable to read server token at ${tokenPath}; has the server been started at least once?`,
{ cause: error },
);
}
}
/** Best-effort token read: returns `undefined` instead of throwing. */
export function tryResolveServerToken(homeDir: string): string | undefined {
try {
return resolveServerToken(homeDir);
} catch {
return undefined;
}
}
/** An `Authorization: Bearer <token>` header bag for `fetch`. */
export function authHeaders(token: string): { Authorization: string } {
return { Authorization: `Bearer ${token}` };
}

View file

@ -0,0 +1,22 @@
/**
* `kimi web` open the Kimi web UI.
*
* Shares the exact same code path as `kimi server run`: it is registered via
* the same `buildRunCommand` builder (and therefore the same `handleRunCommand`
* handler, the same background-daemon flow, and the same ready banner) with
* `defaultOpen` flipped to `true`. The only difference from `server run` is
* that `web` opens the browser by default.
*/
import type { Command } from 'commander';
import { buildRunCommand } from './run';
export function registerWebAliasCommand(program: Command): void {
buildRunCommand(
program
.command('web')
.description('Open the Kimi web UI (starts a background daemon if needed).'),
{ defaultOpen: true },
);
}

View file

@ -0,0 +1,158 @@
/**
* `kimi vis` sub-command.
*
* CLI glue only: resolves the kimi home, starts the in-process session
* visualizer server (auto-picking a free port by default), prints the URL,
* optionally opens the browser (with an optional session deep-link), then
* waits for Ctrl-C and shuts the server down. The visualizer server itself
* lives in `@moonshot-ai/vis-server`.
*/
import type { Command } from 'commander';
import { createCliTelemetryBootstrap } from '#/cli/telemetry';
import { openUrl } from '#/utils/open-url';
interface WritableLike {
write(chunk: string): boolean;
}
export interface StartedVisServer {
readonly port: number;
readonly host: string;
readonly url: string;
readonly close: () => Promise<void>;
}
export interface StartVisServerArgs {
readonly homeDir: string;
readonly port: number;
readonly host?: string;
readonly webAsset?: { gzipped: Uint8Array };
}
export interface VisDeps {
readonly getHomeDir: () => string;
readonly startVisServer: (opts: StartVisServerArgs) => Promise<StartedVisServer>;
readonly openUrl: (url: string) => Promise<void>;
readonly waitForShutdown: () => Promise<void>;
readonly stdout: WritableLike;
readonly stderr: WritableLike;
readonly exit: (code: number) => never;
}
export interface VisOptions {
readonly open: boolean;
readonly port?: number;
readonly host?: string;
readonly sessionId?: string;
}
export async function handleVis(deps: VisDeps, opts: VisOptions): Promise<void> {
const homeDir = deps.getHomeDir();
// Lazily load the embedded single-file SPA so normal `kimi` startup never
// pays for it. The module is generated at build time (prebuild). When running
// from source without a build — e.g. tests — the generated value module is
// absent and the dynamic import throws; in that case the server falls back to
// its own static `public/` directory.
let webAsset: { gzipped: Uint8Array } | undefined;
try {
const { VIS_WEB_GZIP_B64 } = await import('#/generated/vis-web-asset');
if (VIS_WEB_GZIP_B64.length > 0) {
webAsset = { gzipped: new Uint8Array(Buffer.from(VIS_WEB_GZIP_B64, 'base64')) };
}
} catch {
// Embedded asset not generated in this context — fall back to filesystem.
}
let server: StartedVisServer;
try {
server = await deps.startVisServer({
homeDir,
port: opts.port ?? 0,
...(opts.host === undefined ? {} : { host: opts.host }),
...(webAsset === undefined ? {} : { webAsset }),
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
deps.stderr.write(`Failed to start kimi vis: ${msg}\n`);
return deps.exit(1);
}
const target =
opts.sessionId === undefined
? server.url
: `${server.url}sessions/${encodeURIComponent(opts.sessionId)}`;
deps.stdout.write(`kimi vis is running at ${server.url}\n`);
deps.stdout.write('Press Ctrl-C to stop.\n');
if (opts.open) {
try {
await deps.openUrl(target);
} catch {
deps.stderr.write(`Could not open a browser; visit ${target} manually.\n`);
}
}
await deps.waitForShutdown();
await server.close();
}
export function registerVisCommand(parent: Command, overrides?: Partial<VisDeps>): void {
parent
.command('vis')
.description('Launch the session visualizer in your browser.')
.option('--port <number>', 'Port to bind. Default: auto-pick a free port.')
.option('--host <host>', 'Host to bind. Default: 127.0.0.1.')
.option('--no-open', 'Do not open the browser automatically.')
.argument('[sessionId]', 'Open directly to this session.')
.action(
async (
sessionId: string | undefined,
options: { port?: string; host?: string; open?: boolean },
) => {
const port = options.port === undefined ? undefined : Number.parseInt(options.port, 10);
await handleVis(createDefaultVisDeps(overrides), {
open: options.open !== false,
...(port === undefined || Number.isNaN(port) ? {} : { port }),
...(options.host === undefined ? {} : { host: options.host }),
...(sessionId === undefined ? {} : { sessionId }),
});
},
);
}
function createDefaultVisDeps(overrides: Partial<VisDeps> = {}): VisDeps {
return {
getHomeDir: overrides.getHomeDir ?? (() => createCliTelemetryBootstrap().homeDir),
startVisServer:
overrides.startVisServer ??
(async (opts) => {
// Dynamic import keeps the vis server (and Hono) out of the hot path.
const { startVisServer } = await import('@moonshot-ai/vis-server/start');
return startVisServer(opts);
}),
// `openUrl` is a synchronous fire-and-forget; adapt it to the async dep.
openUrl:
overrides.openUrl ??
(async (url: string) => {
openUrl(url);
}),
waitForShutdown: overrides.waitForShutdown ?? waitForSigint,
stdout: overrides.stdout ?? process.stdout,
stderr: overrides.stderr ?? process.stderr,
exit: overrides.exit ?? ((code: number) => process.exit(code)),
};
}
function waitForSigint(): Promise<void> {
return new Promise<void>((resolve) => {
const onSig = (): void => {
process.off('SIGINT', onSig);
resolve();
};
process.on('SIGINT', onSig);
});
}

View file

@ -1,8 +1,23 @@
import { createKimiDeviceId, KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth';
import { initializeTelemetry } from '@moonshot-ai/kimi-telemetry';
import { resolveKimiHome, type KimiConfig, type KimiHarness } from '@moonshot-ai/kimi-code-sdk';
import {
KimiAuthFacade,
loadRuntimeConfigSafe,
resolveConfigPath,
resolveKimiHome,
type KimiConfig,
type KimiHarness,
type TelemetryClient,
} from '@moonshot-ai/kimi-code-sdk';
import {
initializeTelemetry,
setTelemetryContext,
track,
withTelemetryContext,
} from '@moonshot-ai/kimi-telemetry';
import { CLI_USER_AGENT_PRODUCT } from '#/constant/app';
import { CLI_USER_AGENT_PRODUCT, WEB_UI_MODE } from '#/constant/app';
import { createKimiCodeHostIdentity } from './version';
export interface CliTelemetryBootstrap {
readonly homeDir: string;
@ -17,6 +32,7 @@ export interface InitializeCliTelemetryOptions {
readonly version: string;
readonly uiMode: string;
readonly model?: string;
readonly sessionId?: string;
}
export function createCliTelemetryBootstrap(): CliTelemetryBootstrap {
@ -39,6 +55,7 @@ export function initializeCliTelemetry(options: InitializeCliTelemetryOptions):
version: options.version,
uiMode: options.uiMode,
model: options.model ?? options.config.defaultModel,
sessionId: options.sessionId,
getAccessToken: async () =>
(await options.harness.auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null,
});
@ -46,3 +63,67 @@ export function initializeCliTelemetry(options: InitializeCliTelemetryOptions):
options.harness.track('first_launch');
}
}
export interface InitializeServerTelemetryOptions {
readonly version: string;
}
/**
* Bootstrap telemetry for the `kimi web` / `kimi server run` host.
*
* Mirrors {@link initializeCliTelemetry}: mints the device id, reads config to
* honor the `telemetry` toggle and pick up the default model, attaches the
* sink with `ui_mode = "web"`, and returns a {@link TelemetryClient} the
* caller hands to `startServer` via `coreProcessOptions.telemetry`. That wires
* the same real client into `KimiCore`, so agent-core events emitted inside the
* server process (`mcp_connected`, `session_load_failed`, plan-mode / cron
* events, ) actually leave the process carrying the enriched context
* (`app_name` / `version` / `ui_mode` / `model` / platform fields).
*
* The returned client wraps the `@moonshot-ai/kimi-telemetry` module
* functions, so the module-level `track` / `withTelemetryContext` (used to
* fire the startup event) share the same underlying client + sink.
*/
export function initializeServerTelemetry(
options: InitializeServerTelemetryOptions,
): TelemetryClient {
const bootstrap = createCliTelemetryBootstrap();
const configPath = resolveConfigPath({ homeDir: bootstrap.homeDir });
const config = readServerTelemetryConfig(configPath);
const auth = new KimiAuthFacade({
homeDir: bootstrap.homeDir,
configPath,
identity: createKimiCodeHostIdentity(options.version),
});
initializeTelemetry({
homeDir: bootstrap.homeDir,
deviceId: bootstrap.deviceId,
enabled: config.telemetry !== false,
appName: CLI_USER_AGENT_PRODUCT,
version: options.version,
uiMode: WEB_UI_MODE,
model: config.defaultModel,
getAccessToken: async () => (await auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null,
});
return {
track,
withContext: withTelemetryContext,
setContext: setTelemetryContext,
};
}
function readServerTelemetryConfig(
configPath: string,
): Pick<KimiConfig, 'telemetry' | 'defaultModel'> {
try {
const { config, fileError } = loadRuntimeConfigSafe(configPath);
// A broken config fails the server on its own inside KimiCore; for
// telemetry just degrade to "enabled, no model" so we never block startup.
if (fileError !== undefined) return {};
return config;
} catch {
return {};
}
}

View file

@ -3,13 +3,20 @@ import { z } from 'zod';
import { getUpdateStateFile } from '#/utils/paths';
import { readJsonFile, writeJsonFile } from '#/utils/persistence';
import { UpdateManifestSchema } from './cdn';
import { emptyUpdateCache, type UpdateCache } from './types';
const UpdateCacheSchema: z.ZodType<UpdateCache> = z
// Stays `.strict()` (we own this file), but a malformed manifest is treated
// as no manifest so one bad optional field does not discard the whole cache.
const UpdateCacheSchema = z
.object({
source: z.literal('cdn'),
checkedAt: z.string().min(1).nullable(),
latest: z.string().min(1).nullable(),
manifest: z.preprocess((value) => {
const parsed = UpdateManifestSchema.nullable().safeParse(value === undefined ? null : value);
return parsed.success ? parsed.data : null;
}, z.union([UpdateManifestSchema, z.null()])),
})
.strict();

View file

@ -1,6 +1,49 @@
import { valid } from 'semver';
import { z } from 'zod';
import { KIMI_CODE_CDN_LATEST_URL } from '#/constant/app';
import { KIMI_CODE_CDN_LATEST_JSON_URL, KIMI_CODE_CDN_LATEST_URL } from '#/constant/app';
import type { UpdateManifest } from './types';
const CDN_FETCH_TIMEOUT_MS = 3_000;
const RolloutBatchSchema = z.object({
percent: z.number().int().min(0).max(100),
delaySeconds: z.number().int().min(0),
});
/**
* CDN `latest.json` wire format. Deliberately NOT `.strict()` unknown
* fields are ignored so future manifest additions never break shipped
* clients (the plain-text `/latest` taught us that hard-failing on
* unexpected content bricks the update path forever).
*/
export const UpdateManifestSchema = z.object({
version: z.string().refine((value) => valid(value) !== null, { error: 'invalid semver' }),
publishedAt: z
.string()
.refine((value) => Number.isFinite(Date.parse(value)), { error: 'invalid timestamp' }),
rollout: z.array(RolloutBatchSchema).readonly().default([]),
});
export interface FetchLatestResult {
/** Raw newest version — what `kimi upgrade` installs, never rollout-gated. */
readonly latest: string;
/** Null when the JSON manifest was unavailable and we fell back to plain text. */
readonly manifest: UpdateManifest | null;
}
async function fetchWithTimeout(fetchImpl: typeof fetch, input: string): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, CDN_FETCH_TIMEOUT_MS);
try {
return await fetchImpl(input, { signal: controller.signal });
} finally {
clearTimeout(timeout);
}
}
/**
* Fetch the latest published Kimi Code version from the CDN.
@ -15,7 +58,7 @@ import { KIMI_CODE_CDN_LATEST_URL } from '#/constant/app';
export async function fetchLatestVersionFromCdn(
fetchImpl: typeof fetch = fetch,
): Promise<string> {
const response = await fetchImpl(KIMI_CODE_CDN_LATEST_URL);
const response = await fetchWithTimeout(fetchImpl, KIMI_CODE_CDN_LATEST_URL);
if (!response.ok) {
throw new Error(`CDN /latest returned HTTP ${response.status}`);
}
@ -25,3 +68,30 @@ export async function fetchLatestVersionFromCdn(
}
return raw;
}
async function fetchUpdateManifestFromCdn(fetchImpl: typeof fetch): Promise<UpdateManifest> {
const response = await fetchWithTimeout(fetchImpl, KIMI_CODE_CDN_LATEST_JSON_URL);
if (!response.ok) {
throw new Error(`CDN /latest.json returned HTTP ${response.status}`);
}
return UpdateManifestSchema.parse(JSON.parse(await response.text()));
}
/**
* Fetch the rollout manifest, falling back to the plain-text `/latest` when
* `latest.json` is unavailable or malformed. The fallback removes any
* deployment-order coupling between client releases and the CDN file, and a
* null manifest means "fully rolled out" exactly the pre-rollout behavior.
*
* **Throws** only when both sources fail; callers must catch (see above).
*/
export async function fetchLatestFromCdn(
fetchImpl: typeof fetch = fetch,
): Promise<FetchLatestResult> {
const manifest = await fetchUpdateManifestFromCdn(fetchImpl).catch(() => null);
if (manifest !== null) {
return { latest: manifest.version, manifest };
}
const latest = await fetchLatestVersionFromCdn(fetchImpl);
return { latest, manifest: null };
}

View file

@ -10,6 +10,7 @@ const InstallSourceSchema: z.ZodType<InstallSource> = z.enum([
'pnpm-global',
'yarn-global',
'bun-global',
'homebrew',
'native',
'unsupported',
]);

View file

@ -19,13 +19,22 @@ import {
type InstallPromptOptions,
} from './prompt';
import { refreshUpdateCache } from './refresh';
import { selectUpdateTarget } from './select';
import {
appendRolloutDecisionLog,
decidePassiveUpdateTarget,
isRolloutBypassedByExperimentalEnv,
resolveUpdateDeviceId,
rolloutBucket,
rolloutDelayForBucket,
type PassiveUpdateDecision,
} from './rollout';
import { detectInstallSource } from './source';
import {
NPM_PACKAGE_NAME,
type InstallSource,
type UpdateDecision,
type UpdateInstallState,
type UpdateManifest,
type UpdatePreflightResult,
type UpdateTarget,
} from './types';
@ -68,6 +77,8 @@ export function installCommandFor(
return `yarn global add ${NPM_PACKAGE_NAME}@${version}`;
case 'bun-global':
return `bun add -g ${NPM_PACKAGE_NAME}@${version}`;
case 'homebrew':
return 'brew upgrade kimi-code';
case 'native':
return platform === 'win32' ? NATIVE_INSTALL_COMMAND_WIN : NATIVE_INSTALL_COMMAND_UNIX;
case 'unsupported':
@ -82,6 +93,10 @@ export function canAutoInstall(source: InstallSource, platform: NodeJS.Platform)
case 'yarn-global':
case 'bun-global':
return true;
case 'homebrew':
// Homebrew upgrade may mutate other dependents and the formula can lag
// behind the CDN release — prompt the user to run `brew upgrade` manually.
return false;
case 'native':
return platform !== 'win32';
case 'unsupported':
@ -108,6 +123,8 @@ export function spawnForSource(
return { cmd: withCmdSuffix('yarn', platform), args: ['global', 'add', `${NPM_PACKAGE_NAME}@${version}`] };
case 'bun-global':
return { cmd: bunCommand(platform), args: ['add', '-g', `${NPM_PACKAGE_NAME}@${version}`] };
case 'homebrew':
return { cmd: 'brew', args: ['upgrade', 'kimi-code'] };
case 'native':
// `curl … | bash` reports only the trailing bash's exit status, so a
// failed download (curl can't connect → empty stdin → bash exits 0)
@ -138,6 +155,9 @@ export function renderManualUpdateMessage(
case 'bun-global':
sourceDesc = source;
break;
case 'homebrew':
sourceDesc = 'homebrew';
break;
case 'native':
sourceDesc = 'native (windows). Auto-update is not supported on this platform.';
break;
@ -166,8 +186,59 @@ function refreshInBackground(): void {
void refreshUpdateCache().catch(() => {});
}
/** Telemetry properties describing where this device sits in the rollout. */
interface RolloutTelemetry {
readonly rollout_bucket: number;
readonly rollout_delay_seconds: number;
readonly rollout_from_manifest: boolean;
readonly rollout_bypassed: boolean;
}
function rolloutTelemetryFor(
deviceId: string,
targetVersion: string,
manifest: UpdateManifest | null,
bypassRollout: boolean,
): RolloutTelemetry {
const bucket = rolloutBucket(deviceId, targetVersion);
return {
rollout_bucket: bucket,
rollout_delay_seconds:
manifest === null || bypassRollout ? 0 : rolloutDelayForBucket(manifest.rollout, bucket),
rollout_from_manifest: manifest !== null,
rollout_bypassed: bypassRollout,
};
}
type RolloutCheckPhase = 'startup-cache' | 'background-refresh' | 'prompt-refresh';
/** Record which case a passive version check hit in `updates/rollout.log`. */
function logRolloutDecision(
phase: RolloutCheckPhase,
currentVersion: string,
latest: string | null,
manifest: UpdateManifest | null,
decision: PassiveUpdateDecision,
): void {
void appendRolloutDecisionLog({
ts: nowIso(),
phase,
reason: decision.reason,
current: currentVersion,
latest,
target: decision.target?.version ?? null,
manifestPresent: manifest !== null,
publishedAt: manifest?.publishedAt ?? null,
bucket: decision.bucket,
delaySeconds: decision.delaySeconds,
eligibleAt: decision.eligibleAt,
});
}
function refreshAndMaybeInstallInBackground(
currentVersion: string,
deviceId: string,
bypassRollout: boolean,
isInteractive: boolean,
installState: UpdateInstallState,
platform: NodeJS.Platform,
@ -177,7 +248,16 @@ function refreshAndMaybeInstallInBackground(
void (async () => {
const refreshed = await refreshUpdateCache();
if (!isInteractive) return;
const target = selectUpdateTarget(currentVersion, refreshed.latest);
const decision = decidePassiveUpdateTarget(
currentVersion,
refreshed.latest,
refreshed.manifest,
deviceId,
new Date(),
bypassRollout,
);
logRolloutDecision('background-refresh', currentVersion, refreshed.latest, refreshed.manifest, decision);
const target = decision.target;
if (target === null) return;
const source = await detectInstallSource().catch(() => 'unsupported' as const);
await tryStartAutomaticBackgroundInstall(
@ -188,27 +268,54 @@ function refreshAndMaybeInstallInBackground(
platform,
track,
logger,
rolloutTelemetryFor(deviceId, target.version, refreshed.manifest, bypassRollout),
);
})().catch(() => {});
}
interface UserVisibleUpdateTarget {
readonly target: UpdateTarget | null;
readonly manifest: UpdateManifest | null;
}
async function refreshUserVisibleUpdateTarget(
currentVersion: string,
deviceId: string,
bypassRollout: boolean,
fallbackTarget: UpdateTarget,
): Promise<UpdateTarget | null> {
fallbackManifest: UpdateManifest | null,
): Promise<UserVisibleUpdateTarget> {
let timeout: ReturnType<typeof setTimeout> | undefined;
const fallback: UserVisibleUpdateTarget = {
target: fallbackTarget,
manifest: fallbackManifest,
};
try {
const refresh = refreshUpdateCache()
.then((refreshed) => selectUpdateTarget(currentVersion, refreshed.latest))
.catch(() => fallbackTarget);
const fallback = new Promise<UpdateTarget>((resolve) => {
.then((refreshed) => {
const decision = decidePassiveUpdateTarget(
currentVersion,
refreshed.latest,
refreshed.manifest,
deviceId,
new Date(),
bypassRollout,
);
logRolloutDecision('prompt-refresh', currentVersion, refreshed.latest, refreshed.manifest, decision);
return {
target: decision.target,
manifest: refreshed.manifest,
};
})
.catch(() => fallback);
const timeoutFallback = new Promise<UserVisibleUpdateTarget>((resolve) => {
timeout = setTimeout(() => {
resolve(fallbackTarget);
resolve(fallback);
}, USER_VISIBLE_UPDATE_REFRESH_TIMEOUT_MS);
});
return await Promise.race([refresh, fallback]);
return await Promise.race([refresh, timeoutFallback]);
} catch {
return fallbackTarget;
return fallback;
} finally {
if (timeout !== undefined) {
clearTimeout(timeout);
@ -293,6 +400,18 @@ async function showPendingBackgroundInstallNotice(
return nextState;
}
/**
* `KIMI_CODE_NO_AUTO_UPDATE` (or the legacy `KIMI_CLI_NO_AUTO_UPDATE` alias)
* fully disables the update preflight no check, no background install, no
* prompt. Migrated from kimi-cli, where the variable gated all auto-update
* behavior. Accepts the usual truthy values (`1`/`true`/`yes`/`on`).
*/
function isAutoUpdateDisabledByEnv(env: NodeJS.ProcessEnv = process.env): boolean {
const truthy = (value?: string): boolean =>
['1', 'true', 'yes', 'on'].includes((value ?? '').trim().toLowerCase());
return truthy(env['KIMI_CODE_NO_AUTO_UPDATE']) || truthy(env['KIMI_CLI_NO_AUTO_UPDATE']);
}
async function shouldAutoInstallUpdates(): Promise<boolean> {
try {
const config = await loadTuiConfig();
@ -308,14 +427,14 @@ function trackUpdatePrompted(
target: UpdateTarget,
source: InstallSource,
decision: UpdateDecision,
rolloutTelemetry: RolloutTelemetry,
): void {
trackUpdateEvent(track, 'update_prompted', {
current: currentVersion,
latest: target.version,
current_version: currentVersion,
target_version: target.version,
source,
decision,
...rolloutTelemetry,
});
}
@ -369,7 +488,14 @@ export async function installUpdate(
): Promise<void> {
const { cmd, args } = spawnForSource(source, version, platform);
await new Promise<void>((resolve, reject) => {
const child = spawn(cmd, [...args], { stdio: 'inherit' });
// Windows package managers (npm/pnpm/yarn) are .cmd shims. Since the
// CVE-2024-27980 fix, Node throws EINVAL when spawning a .cmd/.bat without
// a shell, so run through the shell on win32. The version is a validated
// semver and the package name is a constant, so args are shell-safe.
const child = spawn(cmd, [...args], {
stdio: 'inherit',
shell: platform === 'win32' ? true : undefined,
});
child.once('error', reject);
child.once('exit', (code, signal) => {
if (code === 0) {
@ -390,6 +516,7 @@ async function startBackgroundInstall(
platform: NodeJS.Platform,
track: RunUpdatePreflightOptions['track'],
logger: UpdateLogger,
rolloutTelemetry: RolloutTelemetry,
): Promise<void> {
const lock = await tryAcquireUpdateInstallLock({ version: target.version });
if (lock === null) return;
@ -416,6 +543,7 @@ async function startBackgroundInstall(
current_version: currentVersion,
target_version: target.version,
source,
...rolloutTelemetry,
});
logUpdateInfo(logger, 'background update install started', {
currentVersion,
@ -475,7 +603,15 @@ async function startBackgroundInstall(
});
};
const child = spawn(cmd, [...args], { detached: true, stdio: 'ignore' });
const child = spawn(cmd, [...args], {
detached: true,
stdio: 'ignore',
shell: platform === 'win32' ? true : undefined,
// On Windows a detached child gets its own console window; with shell:true
// that window would flash during a passive background update. Hide it so
// the silent updater stays silent.
windowsHide: platform === 'win32' ? true : undefined,
});
child.once('error', () => { finish(false); });
child.once('exit', (code) => { finish(code === 0); });
child.unref();
@ -492,6 +628,7 @@ async function tryStartAutomaticBackgroundInstall(
platform: NodeJS.Platform,
track: RunUpdatePreflightOptions['track'],
logger: UpdateLogger,
rolloutTelemetry: RolloutTelemetry,
): Promise<boolean> {
const sourceCanAutoInstall = canAutoInstall(source, platform);
const autoInstallUpdates = sourceCanAutoInstall ? await shouldAutoInstallUpdates() : false;
@ -508,6 +645,7 @@ async function tryStartAutomaticBackgroundInstall(
platform,
track,
logger,
rolloutTelemetry,
).catch(() => {});
}
return true;
@ -532,9 +670,15 @@ export async function runUpdatePreflight(
const logger = options.logger ?? log;
const platform = process.platform;
if (isAutoUpdateDisabledByEnv()) {
return 'continue';
}
try {
const isInteractive =
options.isTTY ?? (process.stdin.isTTY && process.stdout.isTTY);
const deviceId = resolveUpdateDeviceId();
const bypassRollout = isRolloutBypassedByExperimentalEnv();
let installState = await readUpdateInstallState().catch(() => emptyUpdateInstallState());
if (isInteractive) {
installState = await showPendingBackgroundInstallNotice(
@ -547,11 +691,22 @@ export async function runUpdatePreflight(
}
const cache = await readUpdateCache().catch(() => null);
const latest = cache?.latest ?? null;
const target = selectUpdateTarget(currentVersion, latest);
const cachedManifest = cache?.manifest ?? null;
const cachedDecision = decidePassiveUpdateTarget(
currentVersion,
cache?.latest ?? null,
cachedManifest,
deviceId,
new Date(),
bypassRollout,
);
logRolloutDecision('startup-cache', currentVersion, cache?.latest ?? null, cachedManifest, cachedDecision);
const target = cachedDecision.target;
if (target === null) {
refreshAndMaybeInstallInBackground(
currentVersion,
deviceId,
bypassRollout,
isInteractive,
installState,
platform,
@ -581,14 +736,28 @@ export async function runUpdatePreflight(
platform,
options.track,
logger,
rolloutTelemetryFor(deviceId, target.version, cachedManifest, bypassRollout),
)
) {
refreshInBackground();
return 'continue';
}
const userVisibleTarget = await refreshUserVisibleUpdateTarget(currentVersion, target);
const userVisibleUpdate = await refreshUserVisibleUpdateTarget(
currentVersion,
deviceId,
bypassRollout,
target,
cachedManifest,
);
const userVisibleTarget = userVisibleUpdate.target;
if (userVisibleTarget === null) return 'continue';
const userVisibleRollout = rolloutTelemetryFor(
deviceId,
userVisibleTarget.version,
userVisibleUpdate.manifest,
bypassRollout,
);
if (
await tryStartAutomaticBackgroundInstall(
installState,
@ -598,13 +767,14 @@ export async function runUpdatePreflight(
platform,
options.track,
logger,
userVisibleRollout,
)
) {
return 'continue';
}
const installCommand = installCommandFor(source, userVisibleTarget.version, platform);
trackUpdatePrompted(options.track, currentVersion, userVisibleTarget, source, decision);
trackUpdatePrompted(options.track, currentVersion, userVisibleTarget, source, decision, userVisibleRollout);
if (decision === 'manual-command') {
stdout.write(renderManualUpdateMessage(

View file

@ -1,13 +1,14 @@
import { writeUpdateCache } from './cache';
import { fetchLatestVersionFromCdn } from './cdn';
import { fetchLatestFromCdn, type FetchLatestResult } from './cdn';
import { type UpdateCache } from './types';
export interface RefreshUpdateCacheDeps {
/** Resolves with the latest semver. **Throws** on any failure callers
* (including the default background invocation in preflight) must catch.
* Errors intentionally skip `writeCache` so a transient CDN blip does not
* overwrite a previously known `latest` with `null`. */
readonly fetchLatest: () => Promise<string>;
/** Resolves with the latest version + rollout manifest. **Throws** on any
* failure callers (including the default background invocation in
* preflight) must catch. Errors intentionally skip `writeCache` so a
* transient CDN blip does not overwrite a previously known `latest` with
* `null`. */
readonly fetchLatest: () => Promise<FetchLatestResult>;
readonly writeCache: (cache: UpdateCache) => Promise<void>;
readonly now: () => Date;
}
@ -16,16 +17,17 @@ export async function refreshUpdateCache(
overrides: Partial<RefreshUpdateCacheDeps> = {},
): Promise<UpdateCache> {
const resolved: RefreshUpdateCacheDeps = {
fetchLatest: overrides.fetchLatest ?? (() => fetchLatestVersionFromCdn()),
fetchLatest: overrides.fetchLatest ?? (() => fetchLatestFromCdn()),
writeCache: overrides.writeCache ?? writeUpdateCache,
now: overrides.now ?? (() => new Date()),
};
const latest = await resolved.fetchLatest();
const { latest, manifest } = await resolved.fetchLatest();
const cache: UpdateCache = {
source: 'cdn',
checkedAt: resolved.now().toISOString(),
latest,
manifest,
};
await resolved.writeCache(cache);
return cache;

View file

@ -0,0 +1,210 @@
import { createHash, randomUUID } from 'node:crypto';
import { appendFile, mkdir, stat, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { readKimiDeviceId } from '@moonshot-ai/kimi-code-oauth';
import { resolveKimiHome } from '@moonshot-ai/kimi-code-sdk';
import { getUpdateRolloutLogFile } from '#/utils/paths';
import { selectUpdateTarget } from './select';
import type { RolloutBatch, UpdateManifest, UpdateTarget } from './types';
/**
* Hard ceiling for any rollout delay. Combined with the uncovered-bucket
* fallback below, it guarantees every device sees a release no later than
* `publishedAt + 24h`, no matter what the published plan says.
*/
export const MAX_ROLLOUT_DELAY_SECONDS = 86_400;
/**
* Deterministic 0-99 bucket for a device. The version is mixed into the hash
* so each release reshuffles which devices land in the early batches.
*/
export function rolloutBucket(deviceId: string, version: string): number {
const digest = createHash('sha256').update(`${deviceId}:${version}`, 'utf-8').digest();
return digest.readUInt32BE(0) % 100;
}
/**
* Delay assigned to a bucket by the published plan. Batches claim bucket
* ranges in array order; buckets left uncovered (percents summing under 100)
* fall into the slowest cohort, and oversized delays are clamped to 24h.
*/
export function rolloutDelayForBucket(rollout: readonly RolloutBatch[], bucket: number): number {
let cumulative = 0;
for (const batch of rollout) {
cumulative += batch.percent;
if (bucket < cumulative) {
return Math.min(Math.max(batch.delaySeconds, 0), MAX_ROLLOUT_DELAY_SECONDS);
}
}
if (rollout.length === 0) return 0;
return MAX_ROLLOUT_DELAY_SECONDS;
}
export function rolloutDelaySeconds(manifest: UpdateManifest, deviceId: string): number {
return rolloutDelayForBucket(manifest.rollout, rolloutBucket(deviceId, manifest.version));
}
export function isRolloutEligible(
manifest: UpdateManifest,
deviceId: string,
now: Date,
): boolean {
const publishedAt = Date.parse(manifest.publishedAt);
// Schema validation rejects unparseable timestamps before they get here;
// fail open defensively so a defect can never block updates indefinitely.
if (!Number.isFinite(publishedAt)) return true;
const delayMs = rolloutDelaySeconds(manifest, deviceId) * 1000;
return now.getTime() >= publishedAt + delayMs;
}
/** Which case a passive update check hit; written to the rollout log. */
export type PassiveUpdateReason =
/** Nothing known yet (no cache / CDN never reached). */
| 'no-latest'
/** Known version is not an upgrade over the running one. */
| 'not-newer'
/** Plain-text fallback or legacy cache: visible immediately, no gating. */
| 'no-manifest'
/** Gated: this device's batch delay has not elapsed yet. */
| 'held'
/** Gated and the batch delay has elapsed: update is visible. */
| 'eligible'
/** KIMI_CODE_EXPERIMENTAL_FLAG is on: rollout skipped, newest always visible. */
| 'experimental';
export interface PassiveUpdateDecision {
readonly target: UpdateTarget | null;
readonly reason: PassiveUpdateReason;
readonly bucket: number | null;
readonly delaySeconds: number | null;
readonly eligibleAt: string | null;
}
/**
* Update decision for the passive surfaces (background install, startup
* prompt, manual-command notice). Devices whose batch is not yet eligible see
* no update at all. A null manifest (plain-text fallback or legacy cache)
* keeps the pre-rollout behavior: the latest version is visible immediately.
*
* `kimi upgrade` must NOT go through this gate it selects directly from the
* raw latest version.
*/
export function decidePassiveUpdateTarget(
currentVersion: string,
latest: string | null,
manifest: UpdateManifest | null,
deviceId: string,
now: Date,
bypassRollout = false,
): PassiveUpdateDecision {
if (bypassRollout) {
if (latest === null) {
return { target: null, reason: 'no-latest', bucket: null, delaySeconds: null, eligibleAt: null };
}
const target = selectUpdateTarget(currentVersion, latest);
return {
target,
reason: target === null ? 'not-newer' : 'experimental',
bucket: null,
delaySeconds: null,
eligibleAt: null,
};
}
if (manifest === null) {
if (latest === null) {
return { target: null, reason: 'no-latest', bucket: null, delaySeconds: null, eligibleAt: null };
}
const target = selectUpdateTarget(currentVersion, latest);
return {
target,
reason: target === null ? 'not-newer' : 'no-manifest',
bucket: null,
delaySeconds: null,
eligibleAt: null,
};
}
const target = selectUpdateTarget(currentVersion, manifest.version);
if (target === null) {
return { target: null, reason: 'not-newer', bucket: null, delaySeconds: null, eligibleAt: null };
}
const bucket = rolloutBucket(deviceId, manifest.version);
const delaySeconds = rolloutDelayForBucket(manifest.rollout, bucket);
const publishedAt = Date.parse(manifest.publishedAt);
const eligibleAt = Number.isFinite(publishedAt)
? new Date(publishedAt + delaySeconds * 1000).toISOString()
: null;
const eligible = isRolloutEligible(manifest, deviceId, now);
return {
target: eligible ? target : null,
reason: eligible ? 'eligible' : 'held',
bucket,
delaySeconds,
eligibleAt,
};
}
export function selectPassiveUpdateTarget(
currentVersion: string,
latest: string | null,
manifest: UpdateManifest | null,
deviceId: string,
now: Date,
): UpdateTarget | null {
return decidePassiveUpdateTarget(currentVersion, latest, manifest, deviceId, now).target;
}
const ROLLOUT_LOG_MAX_BYTES = 256 * 1024;
/**
* Append one JSON line describing a passive update decision to
* `<dataDir>/updates/rollout.log`. Best-effort diagnostics: any I/O failure
* is swallowed logging must never affect update prompting. The file is
* reset once it grows past a small cap so it cannot grow unbounded.
*/
export async function appendRolloutDecisionLog(
entry: Record<string, unknown>,
filePath: string = getUpdateRolloutLogFile(),
): Promise<void> {
try {
await mkdir(dirname(filePath), { recursive: true });
const line = `${JSON.stringify(entry)}\n`;
const size = await stat(filePath).then((s) => s.size, () => 0);
if (size > ROLLOUT_LOG_MAX_BYTES) {
await writeFile(filePath, line, 'utf-8');
return;
}
await appendFile(filePath, line, 'utf-8');
} catch {
// Diagnostic logging must never affect the update flow.
}
}
/**
* Stable per-installation id used for bucketing when telemetry has already
* minted one. Missing ids stay ephemeral here so update preflight never
* creates the telemetry device_id before telemetry can emit first_launch.
*/
export function resolveUpdateDeviceId(): string {
return readKimiDeviceId(resolveKimiHome()) ?? randomUUID();
}
/**
* The experimental master switch opts a device out of staged rollouts: the
* newest version is always visible to the passive update surfaces, exactly as
* if every release were fully rolled out. Read directly from the env (same
* truthy values as `KIMI_CODE_NO_AUTO_UPDATE`) the update preflight runs
* before the harness exists, so the core flag registry is not consulted.
* `KIMI_CODE_NO_AUTO_UPDATE` still wins: disabling updates beats opting in.
*/
export function isRolloutBypassedByExperimentalEnv(
env: Readonly<Record<string, string | undefined>> = process.env,
): boolean {
const value = (env['KIMI_CODE_EXPERIMENTAL_FLAG'] ?? '').trim().toLowerCase();
return ['1', 'true', 'yes', 'on'].includes(value);
}

View file

@ -40,6 +40,10 @@ export function detectNativeInstall(): boolean {
const PNPM_PATH_SEGMENT = 'pnpm/global/';
const YARN_PATH_SEGMENTS = ['.config/yarn/global/', '/.yarn/global/'];
const BUN_PATH_SEGMENT = '.bun/install/global/';
// Homebrew installs formulae under its Cellar directory. Avoid matching the
// broader /homebrew/ prefix — on Apple Silicon, npm itself lives under
// /opt/homebrew/, so `npm install -g` paths also contain /homebrew/.
const HOMEBREW_PATH_SEGMENT = '/cellar/';
function normalizeForHeuristic(filePath: string): string {
return filePath.replaceAll('\\', '/').toLowerCase();
@ -57,6 +61,7 @@ export function classifyByPathHeuristic(packageRoot: string): InstallSource | nu
if (normalized.includes(seg)) return 'yarn-global';
}
if (normalized.includes(BUN_PATH_SEGMENT)) return 'bun-global';
if (normalized.includes(HOMEBREW_PATH_SEGMENT)) return 'homebrew';
return null;
}

View file

@ -8,6 +8,7 @@ export type InstallSource =
| 'pnpm-global'
| 'yarn-global'
| 'bun-global'
| 'homebrew'
| 'native'
| 'unsupported';
@ -15,10 +16,28 @@ export interface UpdateTarget {
readonly version: string;
}
/** One gradual-rollout cohort: `percent` of devices delayed by `delaySeconds`. */
export interface RolloutBatch {
readonly percent: number;
readonly delaySeconds: number;
}
/**
* Parsed CDN `latest.json`. `rollout` batches claim bucket ranges in array
* order; an empty array means the release is fully rolled out immediately.
*/
export interface UpdateManifest {
readonly version: string;
readonly publishedAt: string;
readonly rollout: readonly RolloutBatch[];
}
export interface UpdateCache {
readonly source: 'cdn';
readonly checkedAt: string | null;
readonly latest: string | null;
/** Null when the manifest came from the plain-text fallback or a legacy cache file. */
readonly manifest: UpdateManifest | null;
}
export interface UpdateInstallActive {
@ -53,6 +72,7 @@ export function emptyUpdateCache(): UpdateCache {
source: 'cdn',
checkedAt: null,
latest: null,
manifest: null,
};
}

View file

@ -7,10 +7,33 @@ export const PROCESS_NAME = 'kimi-code';
// Used in telemetry app names and HTTP User-Agent headers.
export const CLI_USER_AGENT_PRODUCT = 'kimi-code-cli';
export const CLI_UI_MODE = 'shell';
// Telemetry ui_mode for the `kimi web` / `kimi server run` host. Same product
// as the CLI (CLI_USER_AGENT_PRODUCT); the surface is distinguished by ui_mode.
export const WEB_UI_MODE = 'web';
// Give telemetry a short flush window without making CLI exit feel stuck.
export const CLI_SHUTDOWN_TIMEOUT_MS = 3000;
// Upper bound on headless (`kimi -p`) shutdown. A wedged cleanup step (e.g. a
// SessionEnd hook, an MCP shutdown, or a connection blackholed by a restrictive
// firewall) must not keep a completed run alive indefinitely — once this elapses
// we stop waiting on cleanup and let the run return.
export const PROMPT_CLEANUP_TIMEOUT_MS = 8000;
// Grace after a headless run has fully completed (turn done, cleanup attempted)
// before force-exiting. `kimi -p` otherwise relies on the event loop draining to
// exit; a stray ref'd handle (socket/timer/child) left over from the run would
// wedge it. The guard timer is unref'd, so a healthy run still exits naturally
// well before this fires.
export const HEADLESS_FORCE_EXIT_GRACE_MS = 2000;
// Max time to wait for buffered stdout/stderr to flush before arming the
// force-exit fallback. A slow/piped consumer's still-draining stdio is a
// legitimate ref'd handle — flushing first prevents the fallback from
// truncating completed output. Bounded so a permanently-stuck consumer can't
// re-introduce the hang.
export const HEADLESS_STDIO_DRAIN_TIMEOUT_MS = 10000;
// Published npm package name; this can differ from the executable command.
export const NPM_PACKAGE_NAME = '@moonshot-ai/kimi-code';
@ -18,11 +41,16 @@ export const NPM_PACKAGE_NAME = '@moonshot-ai/kimi-code';
export const KIMI_CODE_HOME_ENV = 'KIMI_CODE_HOME';
export const KIMI_CODE_DATA_DIR_NAME = '.kimi-code';
export const KIMI_CODE_LOG_DIR_NAME = 'logs';
export const KIMI_CODE_CACHE_DIR_NAME = 'cache';
export const KIMI_CODE_UPDATE_DIR_NAME = 'updates';
export const KIMI_CODE_BIN_DIR_NAME = 'bin';
export const KIMI_CODE_UPDATE_STATE_FILE_NAME = 'latest.json';
export const KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME = 'install.json';
export const KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME = 'install.lock';
export const KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME = 'rollout.log';
export const KIMI_CODE_INPUT_HISTORY_DIR_NAME = 'user-history';
export const KIMI_CODE_BANNER_DIR_NAME = 'banner';
export const KIMI_CODE_BANNER_STATE_FILE_NAME = 'state.json';
// Managed Kimi auth provider key shared with OAuth/SDK config.
export const DEFAULT_OAUTH_PROVIDER_NAME = 'managed:kimi-code';
@ -44,6 +72,11 @@ export const FEEDBACK_TELEMETRY_EVENT = 'feedback_submitted';
// CDN source of truth: all version checks and native install scripts pull from here.
export const KIMI_CODE_CDN_BASE = 'https://code.kimi.com/kimi-code';
export const KIMI_CODE_CDN_LATEST_URL = `${KIMI_CODE_CDN_BASE}/latest`;
// Rollout manifest consumed by update checks; the plain-text `/latest` above
// stays unchanged forever — already-shipped clients hard-fail on non-semver
// bodies, and the CDN install scripts read it for fresh installs.
export const KIMI_CODE_CDN_LATEST_JSON_URL = `${KIMI_CODE_CDN_BASE}/latest.json`;
export const KIMI_CODE_TIPS_BANNER_URL = 'https://cdn.kimi.com/kimi-code-tips/tips.json';
export const KIMI_CODE_PLUGIN_MARKETPLACE_URL = `${KIMI_CODE_CDN_BASE}/plugins/marketplace.json`;
export const KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL';
export const KIMI_CODE_INSTALL_SH_URL = `${KIMI_CODE_CDN_BASE}/install.sh`;

View file

@ -0,0 +1,72 @@
import { mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { getCacheDir } from '../utils/paths';
const STALE_ARCHIVE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours.
/**
* A file produced for a feedback attachment upload. Both the session log
* archive and the codebase archive share this shape; the generic uploader
* consumes it without caring how the file was produced.
*/
export interface FeedbackArchive {
readonly path: string;
readonly size: number;
readonly sha256: string;
readonly fingerprint: string;
readonly fileCount: number;
/** Directory created exclusively for this archive and safe to remove after upload. */
readonly cleanupDir?: string;
}
export async function createFeedbackArchivePath(filename: string): Promise<{
readonly archivePath: string;
readonly cleanupDir: string;
}> {
const archivePath = await createArchivePath(filename);
return { archivePath, cleanupDir: archivePathCleanupDir(archivePath) };
}
/**
* Remove feedback-upload archive directories older than 24 hours. Packaging
* cleans up its own archive on success and on failure, but a killed process
* or an empty parent dir can still leave leftovers behind; this is a
* best-effort backstop so the cache dir does not grow without bound.
*
* `dir` is injectable for tests; production callers leave it as the default.
*/
export async function removeStaleFeedbackUploads(
options: { readonly now?: number; readonly dir?: string } = {},
): Promise<void> {
const now = options.now ?? Date.now();
const dir = options.dir ?? join(getCacheDir(), 'feedback-uploads');
const entries = await readdir(dir, { withFileTypes: true }).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw error;
});
if (entries === null) return;
const cutoff = now - STALE_ARCHIVE_MAX_AGE_MS;
await Promise.all(
entries.map(async (entry) => {
if (!entry.isDirectory() && !entry.isSymbolicLink()) return;
const target = join(dir, entry.name);
const targetStat = await stat(target).catch(() => null);
if (targetStat === null || targetStat.mtimeMs >= cutoff) return;
await rm(target, { recursive: true, force: true }).catch(() => {});
}),
);
}
async function createArchivePath(filename: string): Promise<string> {
await removeStaleFeedbackUploads();
const root = join(getCacheDir(), 'feedback-uploads');
await mkdir(root, { recursive: true });
const dir = await mkdtemp(join(root, 'upload-'));
return join(dir, filename);
}
function archivePathCleanupDir(archivePath: string): string {
return dirname(archivePath);
}

View file

@ -0,0 +1,92 @@
export const DEFAULT_MAX_FILES = 50000;
export const DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024;
// Upper bound for the compressed codebase archive, aligned with the backend's
// per-upload limit. The scanner uses cumulative raw file size as a conservative
// estimate so the resulting zip stays within this bound.
export const DEFAULT_MAX_ARCHIVE_SIZE = 500 * 1024 * 1024;
const IGNORED_DIR_NAMES: ReadonlySet<string> = new Set([
'.git',
'.hg',
'.svn',
'node_modules',
'dist',
'build',
'out',
'.next',
'.nuxt',
'.turbo',
'.cache',
'.parcel-cache',
'coverage',
'.nyc_output',
'target',
'__pycache__',
'.pytest_cache',
'.mypy_cache',
'.venv',
'venv',
'env',
'.idea',
]);
const SENSITIVE_DIR_NAMES: ReadonlySet<string> = new Set([
'.ssh',
'.gnupg',
'.aws',
'.kube',
'.docker',
]);
const SENSITIVE_FILE_NAMES: ReadonlySet<string> = new Set([
'.env',
'id_rsa',
'id_dsa',
'id_ecdsa',
'id_ed25519',
'credentials.json',
'service-account.json',
'serviceAccount.json',
'.netrc',
'.htpasswd',
'.pypirc',
'.npmrc',
'.envrc',
'.yarnrc',
'.yarnrc.yml',
]);
const SENSITIVE_FILE_SUFFIXES: readonly string[] = [
'.pem',
'.key',
'.p12',
'.pfx',
'.jks',
'.keystore',
];
const ENV_FILE_ALLOWED_SUFFIXES: ReadonlySet<string> = new Set(['.example', '.sample', '.template']);
export function isIgnoredDirName(name: string): boolean {
return IGNORED_DIR_NAMES.has(name);
}
export function isSensitivePath(relativePath: string): boolean {
const segments = relativePath.split('/');
for (let i = 0; i < segments.length - 1; i += 1) {
const segment = segments[i];
if (segment !== undefined && SENSITIVE_DIR_NAMES.has(segment)) return true;
}
const base = segments.at(-1);
if (base === undefined || base.length === 0) return false;
if (SENSITIVE_FILE_NAMES.has(base)) return true;
if (SENSITIVE_FILE_SUFFIXES.some((suffix) => base.endsWith(suffix))) return true;
if (base.startsWith('.env.')) {
const suffix = base.slice('.env'.length);
return !ENV_FILE_ALLOWED_SUFFIXES.has(suffix);
}
return false;
}

View file

@ -0,0 +1,3 @@
export * from './packager';
export * from './scanner';
export * from './types';

View file

@ -0,0 +1,98 @@
import { createHash } from 'node:crypto';
import { createWriteStream } from 'node:fs';
import { mkdir, rm, stat } from 'node:fs/promises';
import { dirname } from 'node:path';
import { ZipFile } from 'yazl';
import type { FeedbackArchive } from '../archive';
import type { FeedbackCodebaseScanResult } from './types';
interface PackageEntry {
readonly absolutePath: string;
readonly archivePath: string;
readonly size: number;
readonly mtimeMs: number;
}
/**
* Pack the scanned codebase into a zip, with files placed at the zip root.
*/
export async function packageCodebase(
scan: FeedbackCodebaseScanResult,
archivePath: string,
): Promise<FeedbackArchive> {
const entries: PackageEntry[] = scan.files.map((file) => ({
absolutePath: file.absolutePath,
archivePath: file.path,
size: file.size,
mtimeMs: file.mtimeMs,
}));
return packageEntries(entries, archivePath);
}
async function packageEntries(
entries: readonly PackageEntry[],
archivePath: string,
): Promise<FeedbackArchive> {
if (entries.length === 0) {
throw new Error('Cannot package an empty feedback archive.');
}
await mkdir(dirname(archivePath), { recursive: true });
const zip = new ZipFile();
const hash = createHash('sha256');
const output = createWriteStream(archivePath);
try {
const done = new Promise<void>((resolvePromise, rejectPromise) => {
output.on('finish', resolvePromise);
output.on('error', rejectPromise);
zip.outputStream.on('error', rejectPromise);
});
zip.outputStream.on('data', (chunk: Buffer) => {
hash.update(chunk);
});
zip.outputStream.pipe(output);
for (const entry of entries) {
zip.addFile(entry.absolutePath, entry.archivePath, {
mtime: new Date(entry.mtimeMs),
mode: 0o100644,
});
}
zip.end();
await done;
const archiveStat = await stat(archivePath);
return {
path: archivePath,
size: archiveStat.size,
sha256: hash.digest('hex'),
fingerprint: fingerprintEntries(entries),
fileCount: entries.length,
};
} catch (error) {
// A failed zip (e.g. a source file vanished or became unreadable between
// scan and packaging) would otherwise leave a partial archive behind in
// the cache dir. Destroy the stream so the handle is released before we
// remove the file, then best-effort delete it.
output.destroy();
await rm(archivePath, { force: true }).catch(() => {});
throw error;
}
}
function fingerprintEntries(entries: readonly PackageEntry[]): string {
const hash = createHash('sha256');
for (const entry of entries) {
hash.update(entry.archivePath);
hash.update('\0');
hash.update(String(entry.size));
hash.update('\0');
hash.update(String(Math.trunc(entry.mtimeMs)));
hash.update('\n');
}
return hash.digest('hex');
}

View file

@ -0,0 +1,217 @@
import { execFile } from 'node:child_process';
import { createHash } from 'node:crypto';
import { lstat, readdir } from 'node:fs/promises';
import { join, relative, resolve } from 'node:path';
import { promisify } from 'node:util';
import {
DEFAULT_MAX_ARCHIVE_SIZE,
DEFAULT_MAX_FILES,
DEFAULT_MAX_FILE_SIZE,
isIgnoredDirName,
isSensitivePath,
} from './filter';
import type {
FeedbackCodebaseFile,
FeedbackCodebaseLimitExceeded,
FeedbackCodebaseScanResult,
} from './types';
const execFileAsync = promisify(execFile);
export interface ScanCodebaseLimits {
readonly maxFiles: number;
readonly maxFileSize: number;
readonly maxArchiveSize: number;
}
export interface ScanCodebaseOptions {
readonly limits?: {
readonly maxFiles?: number;
readonly maxFileSize?: number;
readonly maxArchiveSize?: number;
};
readonly signal?: AbortSignal;
}
interface CollectedFiles {
readonly files: FeedbackCodebaseFile[];
readonly exceedsLimit?: FeedbackCodebaseLimitExceeded;
}
export async function scanCodebase(
rootInput: string,
options: ScanCodebaseOptions = {},
): Promise<FeedbackCodebaseScanResult> {
const root = resolve(rootInput);
const limits = resolveLimits(options.limits);
throwIfAborted(options.signal);
const usedGitIgnore = await isInsideGitWorkTree(root);
const collected = usedGitIgnore
? await scanWithGit(root, limits, options.signal)
: await scanWithoutFilter(root, limits, options.signal);
const sortedFiles = collected.files.toSorted((a, b) => a.path.localeCompare(b.path));
return {
root,
files: sortedFiles,
fingerprint: fingerprintFiles(sortedFiles),
usedGitIgnore,
exceedsLimit: collected.exceedsLimit,
};
}
function resolveLimits(limits: ScanCodebaseOptions['limits']): ScanCodebaseLimits {
return {
maxFiles: limits?.maxFiles ?? DEFAULT_MAX_FILES,
maxFileSize: limits?.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
maxArchiveSize: limits?.maxArchiveSize ?? DEFAULT_MAX_ARCHIVE_SIZE,
};
}
async function isInsideGitWorkTree(root: string): Promise<boolean> {
try {
const { stdout } = await execFileAsync('git', ['-C', root, 'rev-parse', '--is-inside-work-tree']);
return stdout.trim() === 'true';
} catch {
return false;
}
}
async function scanWithGit(
root: string,
limits: ScanCodebaseLimits,
signal?: AbortSignal,
): Promise<CollectedFiles> {
const { stdout } = await execFileAsync(
'git',
['-C', root, 'ls-files', '-co', '--exclude-standard', '-z'],
{ encoding: 'buffer', maxBuffer: 1024 * 1024 * 64, signal },
);
throwIfAborted(signal);
const relativePaths = splitNull(stdout);
const files: FeedbackCodebaseFile[] = [];
let exceedsLimit: FeedbackCodebaseLimitExceeded | undefined;
let totalSize = 0;
for (const relativePath of relativePaths) {
throwIfAborted(signal);
if (files.length >= limits.maxFiles) {
exceedsLimit = { reason: 'file-count', limit: limits.maxFiles };
break;
}
if (isSensitivePath(relativePath)) continue;
const file = await statFile(root, relativePath);
if (file) {
if (file.size > limits.maxFileSize) continue;
if (totalSize + file.size > limits.maxArchiveSize) {
exceedsLimit = { reason: 'total-size', limit: limits.maxArchiveSize };
break;
}
files.push(file);
totalSize += file.size;
}
}
return { files, exceedsLimit };
}
async function scanWithoutFilter(
root: string,
limits: ScanCodebaseLimits,
signal?: AbortSignal,
): Promise<CollectedFiles> {
const files: FeedbackCodebaseFile[] = [];
let exceedsLimit: FeedbackCodebaseLimitExceeded | undefined;
let stopped = false;
let totalSize = 0;
async function walk(dir: string): Promise<void> {
if (stopped) return;
throwIfAborted(signal);
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (stopped) return;
throwIfAborted(signal);
if (files.length >= limits.maxFiles) {
exceedsLimit = { reason: 'file-count', limit: limits.maxFiles };
stopped = true;
return;
}
if (entry.isSymbolicLink()) continue;
const absolutePath = join(dir, entry.name);
if (entry.isDirectory()) {
if (isIgnoredDirName(entry.name)) continue;
await walk(absolutePath);
if (stopped) return;
continue;
}
if (!entry.isFile()) continue;
const relativePath = toPosixPath(relative(root, absolutePath));
if (isSensitivePath(relativePath)) continue;
const file = await statFile(root, relativePath);
if (file) {
if (file.size > limits.maxFileSize) continue;
if (totalSize + file.size > limits.maxArchiveSize) {
exceedsLimit = { reason: 'total-size', limit: limits.maxArchiveSize };
stopped = true;
return;
}
files.push(file);
totalSize += file.size;
}
}
}
await walk(root);
return { files, exceedsLimit };
}
async function statFile(root: string, relativePath: string): Promise<FeedbackCodebaseFile | null> {
const absolutePath = resolve(root, relativePath);
// A tracked file can be deleted from the working tree but still listed by
// `git ls-files`; lstat then throws ENOENT. Treat unreadable/vanished paths
// like any other non-regular entry so one bad path does not abort the scan.
const stat = await lstat(absolutePath).catch(() => null);
if (stat === null || stat.isSymbolicLink() || !stat.isFile()) return null;
return {
path: toPosixPath(relativePath),
absolutePath,
size: stat.size,
mtimeMs: stat.mtimeMs,
};
}
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
const error = new Error('Codebase scan aborted.');
error.name = 'AbortError';
throw error;
}
}
function fingerprintFiles(files: readonly FeedbackCodebaseFile[]): string {
const hash = createHash('sha256');
for (const file of files) {
hash.update(file.path);
hash.update('\0');
hash.update(String(file.size));
hash.update('\0');
hash.update(String(Math.trunc(file.mtimeMs)));
hash.update('\n');
}
return hash.digest('hex');
}
function splitNull(buffer: Buffer): string[] {
return buffer
.toString('utf8')
.split('\0')
.filter((item) => item.length > 0);
}
function toPosixPath(value: string): string {
return value.split('\\').join('/');
}

View file

@ -0,0 +1,19 @@
export interface FeedbackCodebaseFile {
readonly path: string;
readonly absolutePath: string;
readonly size: number;
readonly mtimeMs: number;
}
export interface FeedbackCodebaseLimitExceeded {
readonly reason: 'file-count' | 'total-size';
readonly limit: number;
}
export interface FeedbackCodebaseScanResult {
readonly root: string;
readonly files: readonly FeedbackCodebaseFile[];
readonly fingerprint: string;
readonly usedGitIgnore: boolean;
readonly exceedsLimit?: FeedbackCodebaseLimitExceeded;
}

View file

@ -0,0 +1,183 @@
import { createHash } from 'node:crypto';
import { appendFile, mkdir, readFile, rm, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { detectInstallSource } from '#/cli/update/source';
import type { SlashCommandHost } from '#/tui/commands/dispatch';
import type { FeedbackAttachmentLevel } from '#/tui/commands/prompts';
import { getLogDir } from '#/utils/paths';
import { detectShellEnvironment } from '#/utils/process/shell-env';
import { createFeedbackArchivePath, type FeedbackArchive } from './archive';
import { packageCodebase, scanCodebase, type FeedbackCodebaseScanResult } from './codebase';
import { uploadArchive, type FeedbackUploadUrlApi } from './upload';
export const CODEBASE_ARCHIVE_FILENAME = 'repo.zip';
export const SESSION_ARCHIVE_FILENAME = 'session.zip';
const CODEBASE_SCAN_TIMEOUT_MS = 3000;
/**
* Stage 3 of the `/feedback` flow: prepare and upload each requested attachment
* independently. Attachment failures are non-fatal because the text feedback
* already exists, but any requested artifact that cannot be prepared/uploaded
* is reported as a partial attachment failure instead of silently downgrading
* the request.
*
* Returns `true` when at least one requested attachment failed so the caller
* can surface a partial-failure status.
*/
export async function submitFeedbackWithAttachments(
host: SlashCommandHost,
feedbackId: number,
level: FeedbackAttachmentLevel,
): Promise<boolean> {
const api = createFeedbackUploadApi(host);
if (level === 'logs') {
const uploaded = await prepareAndUploadSessionArchive(host, api, feedbackId);
return !uploaded;
}
if (level === 'logs+codebase') {
const [sessionDir, scan] = await Promise.all([
resolveCurrentSessionDir(host),
scanCodebaseForFeedback(host.state.appState.workDir),
]);
const [uploadedSession, uploadedCodebase] = await Promise.all([
prepareAndUploadSessionArchive(host, api, feedbackId, sessionDir),
prepareAndUploadCodebaseArchive(api, feedbackId, scan),
]);
return !uploadedSession || !uploadedCodebase;
}
return false;
}
async function prepareAndUploadSessionArchive(
host: SlashCommandHost,
api: FeedbackUploadUrlApi,
feedbackId: number,
knownSessionDir?: string,
): Promise<boolean> {
const sessionDir = knownSessionDir ?? (await resolveCurrentSessionDir(host));
if (sessionDir === undefined) {
await logFeedbackUploadError(new Error('cannot locate the current session directory'));
return false;
}
return uploadProducedArchive(api, feedbackId, SESSION_ARCHIVE_FILENAME, async (archivePath) => {
const exported = await host.harness.exportSession({
id: host.state.appState.sessionId,
outputPath: archivePath,
includeGlobalLog: true,
version: host.state.appState.version,
installSource: await detectInstallSource(),
shellEnv: detectShellEnvironment(),
});
return archiveFromExportedSession(exported.zipPath);
});
}
async function prepareAndUploadCodebaseArchive(
api: FeedbackUploadUrlApi,
feedbackId: number,
scan: FeedbackCodebaseScanResult | undefined,
): Promise<boolean> {
if (scan === undefined) return false;
return uploadProducedArchive(api, feedbackId, CODEBASE_ARCHIVE_FILENAME, (archivePath) =>
packageCodebase(scan, archivePath),
);
}
/**
* Shared lifecycle for a single attachment: create a temp archive path, let
* `produce` write the archive to it, upload it, then always remove the temp
* directory even when `produce` or the upload throws. Both the session log
* archive and the codebase archive flow through here so their cleanup and
* error handling cannot drift apart.
*/
async function uploadProducedArchive(
api: FeedbackUploadUrlApi,
feedbackId: number,
filename: string,
produce: (archivePath: string) => Promise<FeedbackArchive>,
): Promise<boolean> {
const { archivePath, cleanupDir } = await createFeedbackArchivePath(filename);
try {
const archive = await produce(archivePath);
await uploadArchive(api, { ...archive, cleanupDir }, feedbackId, { filename });
return true;
} catch (error) {
await logFeedbackUploadError(error);
return false;
} finally {
await rm(cleanupDir, { recursive: true, force: true }).catch(() => {});
}
}
async function archiveFromExportedSession(zipPath: string): Promise<FeedbackArchive> {
const data = await readFile(zipPath);
const archiveStat = await stat(zipPath);
return {
path: zipPath,
size: archiveStat.size,
sha256: createHash('sha256').update(data).digest('hex'),
fingerprint: createHash('sha256').update(data).digest('hex'),
fileCount: 1,
};
}
async function resolveCurrentSessionDir(host: SlashCommandHost): Promise<string | undefined> {
try {
const sessions = await host.harness.listSessions({ workDir: host.state.appState.workDir });
return sessions.find((session) => session.id === host.state.appState.sessionId)?.sessionDir;
} catch {
return undefined;
}
}
async function scanCodebaseForFeedback(
workDir: string,
): Promise<FeedbackCodebaseScanResult | undefined> {
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort();
}, CODEBASE_SCAN_TIMEOUT_MS);
try {
return await scanCodebase(workDir, { signal: controller.signal });
} catch (error) {
await logFeedbackUploadError(error);
return undefined;
} finally {
clearTimeout(timer);
}
}
async function logFeedbackUploadError(error: unknown): Promise<void> {
try {
const logDir = getLogDir();
await mkdir(logDir, { recursive: true });
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
await appendFile(join(logDir, 'feedback-upload.log'), `${new Date().toISOString()} ${message}\n`);
} catch {
// best-effort logging only
}
}
function createFeedbackUploadApi(host: SlashCommandHost): FeedbackUploadUrlApi {
return {
async createUploadUrl(input) {
const res = await host.harness.auth.createFeedbackUploadUrl(input);
if (res.kind !== 'ok') throw new Error(res.message);
return {
uploadId: res.uploadId,
parts: res.parts,
};
},
async completeUpload(input) {
const res = await host.harness.auth.completeFeedbackUpload({
uploadId: input.uploadId,
parts: input.parts.map((part) => ({ partNumber: part.partNumber, etag: part.etag })),
});
if (res.kind !== 'ok') throw new Error(res.message);
},
};
}

View file

@ -0,0 +1,208 @@
import { createReadStream } from 'node:fs';
import { Readable } from 'node:stream';
import type { FeedbackArchive } from './archive';
const MAX_ARCHIVE_SIZE = 524_288_000; // 500 MiB, matches the backend limit.
const DEFAULT_CONCURRENCY = 3;
const DEFAULT_MAX_RETRIES = 3;
const DEFAULT_PART_TIMEOUT_MS = 60_000;
const RETRY_BASE_DELAY_MS = 1_000;
export interface FeedbackUploadPart {
readonly partNumber: number;
readonly url: string;
readonly method: string;
readonly size: number;
}
export interface CreateFeedbackUploadUrlInput {
readonly feedbackId: number;
readonly filename: string;
readonly size: number;
readonly sha256: string;
}
export interface CreateFeedbackUploadUrlResult {
readonly uploadId: number;
readonly parts: readonly FeedbackUploadPart[];
}
export interface CompletedUploadPart {
readonly partNumber: number;
readonly etag: string;
}
export interface CompleteFeedbackUploadUrlInput {
readonly uploadId: number;
readonly parts: readonly CompletedUploadPart[];
}
export interface FeedbackUploadUrlApi {
createUploadUrl(input: CreateFeedbackUploadUrlInput): Promise<CreateFeedbackUploadUrlResult>;
completeUpload(input: CompleteFeedbackUploadUrlInput): Promise<void>;
}
export interface UploadArchiveOptions {
/** Zip entry name sent to the backend. */
readonly filename: string;
/** Abort a single part PUT if it does not complete within this many milliseconds. */
readonly timeoutMs?: number;
/** Number of parts to upload concurrently (defaults to 3). */
readonly concurrency?: number;
/** Per-part retry attempts after the first failure (defaults to 3). */
readonly maxRetries?: number;
/** Called after each part finishes with the cumulative uploaded bytes. */
readonly onProgress?: (uploadedBytes: number, totalBytes: number) => void;
}
export async function uploadArchive(
api: FeedbackUploadUrlApi,
archive: FeedbackArchive,
feedbackId: number,
options: UploadArchiveOptions,
): Promise<void> {
if (archive.size > MAX_ARCHIVE_SIZE) {
throw new Error(
`Failed to upload archive: size ${archive.size} exceeds maximum allowed size ${MAX_ARCHIVE_SIZE}.`,
);
}
const created = await api.createUploadUrl({
feedbackId,
filename: options.filename,
size: archive.size,
sha256: archive.sha256,
});
const completed = await uploadParts(archive.path, created.parts, archive.size, options);
await api.completeUpload({ uploadId: created.uploadId, parts: completed });
}
interface PartLayout {
readonly part: FeedbackUploadPart;
readonly start: number;
}
function layoutParts(parts: readonly FeedbackUploadPart[]): PartLayout[] {
const sorted = parts.toSorted((a, b) => a.partNumber - b.partNumber);
let offset = 0;
return sorted.map((part) => {
const start = offset;
offset += part.size;
return { part, start };
});
}
async function uploadParts(
filePath: string,
parts: readonly FeedbackUploadPart[],
totalBytes: number,
options: UploadArchiveOptions,
): Promise<CompletedUploadPart[]> {
const layout = layoutParts(parts);
const results: CompletedUploadPart[] = Array.from({ length: layout.length });
const concurrency = Math.max(1, Math.min(options.concurrency ?? DEFAULT_CONCURRENCY, layout.length));
let nextIndex = 0;
let uploadedBytes = 0;
async function worker(): Promise<void> {
while (true) {
const index = nextIndex;
nextIndex += 1;
if (index >= layout.length) return;
const entry = layout[index];
if (entry === undefined) return;
const completed = await uploadOnePartWithRetry(filePath, entry, options);
results[index] = completed;
uploadedBytes += entry.part.size;
options.onProgress?.(uploadedBytes, totalBytes);
}
}
await Promise.all(Array.from({ length: concurrency }, () => worker()));
return results;
}
async function uploadOnePartWithRetry(
filePath: string,
layout: PartLayout,
options: UploadArchiveOptions,
): Promise<CompletedUploadPart> {
const maxRetries = Math.max(0, options.maxRetries ?? DEFAULT_MAX_RETRIES);
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
try {
return await uploadOnePart(filePath, layout, options);
} catch (error) {
lastError = error;
if (attempt === maxRetries || !isRetryable(error)) break;
await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt);
}
}
throw lastError;
}
async function uploadOnePart(
filePath: string,
layout: PartLayout,
options: UploadArchiveOptions,
): Promise<CompletedUploadPart> {
const { part, start } = layout;
const timeoutMs = options.timeoutMs ?? DEFAULT_PART_TIMEOUT_MS;
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort();
}, timeoutMs);
const stream = createReadStream(filePath, { start, end: start + part.size - 1 });
try {
const res = await fetch(part.url, {
method: part.method,
body: Readable.toWeb(stream),
headers: { 'Content-Length': String(part.size) },
duplex: 'half',
signal: controller.signal,
} as RequestInit);
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new UploadPartHttpError(part.partNumber, res.status, text);
}
const etag = res.headers.get('etag');
if (etag === null || etag.length === 0) {
throw new Error(`Failed to upload part ${part.partNumber}: missing ETag in response.`);
}
return { partNumber: part.partNumber, etag };
} catch (error) {
stream.destroy();
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(`Failed to upload part ${part.partNumber}: upload timed out.`, { cause: error });
}
throw error;
} finally {
clearTimeout(timer);
}
}
class UploadPartHttpError extends Error {
constructor(
readonly partNumber: number,
readonly status: number,
readonly responseBody: string,
) {
super(
`Failed to upload part ${partNumber}: HTTP ${String(status)}${responseBody.length > 0 ? ` ${responseBody}` : ''}`,
);
}
}
function isRetryable(error: unknown): boolean {
if (error instanceof UploadPartHttpError) {
return error.status >= 500 || error.status === 408 || error.status === 429;
}
// Network errors and timeouts are retryable.
return true;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

View file

@ -0,0 +1 @@
export declare const VIS_WEB_GZIP_B64: string;

View file

@ -8,6 +8,7 @@
import {
createKimiHarness,
flushDiagnosticLogs,
installGlobalProxyDispatcher,
log,
resolveGlobalLogPath,
resolveKimiHome,
@ -22,6 +23,7 @@ import {
} from '@moonshot-ai/kimi-telemetry';
import { createProgram } from './cli/commands';
import { finalizeHeadlessRun } from './cli/headless-exit';
import type { CLIOptions } from './cli/options';
import { OptionConflictError, validateOptions } from './cli/options';
import { runPrompt } from './cli/run-prompt';
@ -37,7 +39,22 @@ import { cleanupStaleNativeCacheForCurrent } from './native/native-assets';
import { installNativeModuleHook } from './native/module-hook';
import { runNativeAssetSmokeIfRequested } from './native/smoke';
export async function handleMainCommand(opts: CLIOptions, version: string): Promise<void> {
/**
* Outcome of a CLI command run, reported back to the process entrypoint.
*
* `handleMainCommand` is a reusable, unit-tested handler it must not terminate
* the process itself. It reports here whether a headless (`kimi -p`) run
* completed so the entrypoint (the only place that owns the process) can arm the
* force-exit fallback.
*/
export interface MainCommandOutcome {
readonly headlessCompleted: boolean;
}
export async function handleMainCommand(
opts: CLIOptions,
version: string,
): Promise<MainCommandOutcome> {
let validated: ReturnType<typeof validateOptions>;
try {
validated = validateOptions(opts);
@ -59,10 +76,11 @@ export async function handleMainCommand(opts: CLIOptions, version: string): Prom
if (validated.uiMode === 'print') {
await runPrompt(validated.options, version);
return;
return { headlessCompleted: true };
}
await runShell(validated.options, version);
return { headlessCompleted: false };
}
/** `kimi migrate`: launch the migration screen only, then exit. */
@ -117,6 +135,10 @@ const MIGRATE_CLI_OPTIONS: CLIOptions = {
export function main(): void {
process.title = PROCESS_NAME;
installCrashHandlers();
// Route all outbound fetch through HTTP_PROXY/HTTPS_PROXY (honoring NO_PROXY)
// before any client is constructed. No-op when no proxy variable is set; an
// invalid proxy URL is reported and ignored rather than aborting startup.
installGlobalProxyDispatcher();
installNativeModuleHook();
if (runNativeAssetSmokeIfRequested()) return;
@ -134,17 +156,42 @@ export function main(): void {
const program = createProgram(
version,
(opts) => {
void handleMainCommand(opts, version).catch(async (error: unknown) => {
const operation = opts.prompt !== undefined ? 'run prompt' : 'start shell';
await logStartupFailure(operation, error);
process.stderr.write(
formatStartupError(error, {
operation,
}),
);
process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`);
process.exit(1);
});
void handleMainCommand(opts, version)
.then(async (outcome) => {
// Only the process entrypoint disposes of the process. Print mode
// relies on the event loop draining to exit; flush any buffered output
// and then arm an unref'd fallback so a stray ref'd handle left over
// from the run can't wedge a completed `kimi -p` until an external
// timeout. A healthy run drains and exits before the fallback fires.
if (outcome.headlessCompleted) {
await finalizeHeadlessRun(
process,
[process.stdout, process.stderr],
() => Number(process.exitCode) || 0,
);
}
})
.catch(async (error: unknown) => {
// Set the failure exit code synchronously, before any `await`. The
// terminal `process.exit(1)` below is our intended exit, but it sits
// behind `await logStartupFailure(...)`; by the time we reach that
// await, the failed run's `finally` cleanup has already torn down its
// ref'd handles (sockets, timers, background tasks). If the event loop
// drains during the await, Node exits on its own with the DEFAULT code
// 0 and `process.exit(1)` never runs — headless (`kimi -p`) failures
// would then exit 0 nondeterministically. Setting `process.exitCode`
// up front makes that drain-exit report failure too.
process.exitCode = 1;
const operation = opts.prompt !== undefined ? 'run prompt' : 'start shell';
await logStartupFailure(operation, error);
process.stderr.write(
formatStartupError(error, {
operation,
}),
);
process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`);
process.exit(1);
});
},
() => {
void handleMigrateCommand(version).catch(async (error: unknown) => {

View file

@ -11,10 +11,11 @@
* This file implements the ask, progress, and result phases. `beginMigration`
* drives the real runMigration flow (injectable for tests).
*/
import { Container, matchesKey, Key, truncateToWidth, type Focusable } from '@earendil-works/pi-tui';
import { Container, matchesKey, Key, truncateToWidth, type Focusable } from '@moonshot-ai/pi-tui';
import chalk from 'chalk';
import type { ColorPalette } from '#/tui/theme/colors';
import { currentTheme } from '#/tui/theme';
import {
resolveMigrationScope,
runMigration as realRunMigration,
@ -46,7 +47,7 @@ export interface MigrationScreenOptions {
readonly plan: MigrationPlan;
readonly sourceHome: string;
readonly targetHome: string;
readonly colors: ColorPalette;
readonly colors?: ColorPalette;
/** Called once the screen is finished; the host then restores the editor. */
readonly onComplete: (result: MigrationScreenResult) => void;
/** Triggers a re-render; the host wires this to `ui.requestRender()`. */
@ -93,6 +94,7 @@ export class MigrationScreenComponent extends Container implements Focusable {
private spinnerTimer: ReturnType<typeof setInterval> | undefined;
private report: MigrationReport | undefined;
private migrationFailed = false;
private migrationFailureReason: string | undefined;
constructor(opts: MigrationScreenOptions) {
super();
@ -113,8 +115,9 @@ export class MigrationScreenComponent extends Container implements Focusable {
}
/** Host calls this if runMigration threw. */
showFailure(): void {
showFailure(error?: unknown): void {
this.migrationFailed = true;
this.migrationFailureReason = formatMigrationFailureReason(error);
this.phase = 'result';
this.stopSpinner();
}
@ -260,8 +263,8 @@ export class MigrationScreenComponent extends Container implements Focusable {
this.showResult(report);
this.opts.requestRender?.();
},
() => {
this.showFailure();
(error) => {
this.showFailure(error);
this.opts.requestRender?.();
},
);
@ -276,10 +279,14 @@ export class MigrationScreenComponent extends Container implements Focusable {
}
private renderResult(width: number): string[] {
const { colors } = this.opts;
const colors = this.opts.colors ?? currentTheme.palette;
const lines: string[] = [chalk.hex(colors.primary)('─'.repeat(width))];
if (this.migrationFailed) {
lines.push(chalk.hex(colors.error).bold(' Migration failed'));
if (this.migrationFailureReason !== undefined) {
lines.push('');
lines.push(chalk.hex(colors.text)(` Reason: ${this.migrationFailureReason}`));
}
lines.push('');
lines.push(chalk.hex(colors.text)(' You can retry later by running "kimi migrate".'));
lines.push('');
@ -422,7 +429,7 @@ export class MigrationScreenComponent extends Container implements Focusable {
}
private renderProgress(width: number): string[] {
const { colors } = this.opts;
const colors = this.opts.colors ?? currentTheme.palette;
const spinner = SPINNER_FRAMES[this.spinnerFrame] ?? SPINNER_FRAMES[0];
const lines: string[] = [
chalk.hex(colors.primary)('─'.repeat(width)),
@ -452,7 +459,7 @@ export class MigrationScreenComponent extends Container implements Focusable {
}
private renderAsk(width: number): string[] {
const { colors } = this.opts;
const colors = this.opts.colors ?? currentTheme.palette;
const step = this.currentStep();
const lines: string[] = [
chalk.hex(colors.primary)('─'.repeat(width)),
@ -487,6 +494,45 @@ export class MigrationScreenComponent extends Container implements Focusable {
}
}
function formatMigrationFailureReason(error: unknown): string | undefined {
let reason: string | undefined;
if (error instanceof Error) {
reason = error.message !== '' ? error.message : error.name;
} else if (typeof error === 'string') {
reason = error;
} else if (typeof error === 'object' && error !== null) {
const maybeMessage = (error as { readonly message?: unknown }).message;
if (typeof maybeMessage === 'string' && maybeMessage !== '') {
reason = maybeMessage;
}
}
if (reason === undefined) {
switch (typeof error) {
case 'number':
case 'boolean':
case 'bigint':
reason = `${error}`;
break;
case 'symbol':
reason =
error.description !== undefined ? `Symbol(${error.description})` : 'Symbol rejection';
break;
case 'function':
reason = error.name !== '' ? `Function ${error.name}` : 'Function rejection';
break;
case 'object':
if (error !== null) reason = 'Object rejection';
break;
case 'undefined':
break;
case 'string':
break;
}
}
const trimmed = reason?.trim();
return trimmed === undefined || trimmed === '' ? undefined : trimmed;
}
function summarizePlan(plan: MigrationPlan): string {
const parts: string[] = [];
if (plan.totalSessions > 0) parts.push(`${plan.totalSessions} sessions`);

View file

@ -1,6 +1,8 @@
import { existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import { join } from 'node:path';
import { loadNativePackage } from './native-require';
import { getNativePackageRoot } from './native-assets';
type ModuleLoad = (request: string, parent: unknown, isMain: boolean) => unknown;
@ -10,7 +12,16 @@ interface ModuleWithLoad {
const nodeRequire = createRequire(import.meta.url);
let installed = false;
let loadingNativePackage = false;
// pi-tui loads its platform-specific native helpers via an absolute-path
// require() computed from import.meta.url / process.execPath
// (see pi-tui dist/terminal.js and dist/native-modifiers.js). In a SEA binary
// those .node files live in the native-asset cache, so redirect any absolute
// require of a pi-tui native helper to the cached copy.
//
// Path shape: native/<darwin|win32>/prebuilds/<arch>/<file>.node — note the
// two path segments after "prebuilds", so ".+" (not "[^/]+") is required.
const PI_TUI_NATIVE_PATTERN = /native[\\/](?:win32|darwin)[\\/]prebuilds[\\/].+\.node$/;
export function installNativeModuleHook(): void {
if (installed) return;
@ -26,13 +37,18 @@ export function installNativeModuleHook(): void {
parent: unknown,
isMain: boolean,
): unknown {
if (request === 'koffi' && !loadingNativePackage) {
loadingNativePackage = true;
try {
const pkg = loadNativePackage<unknown>('koffi');
if (pkg !== null) return pkg;
} finally {
loadingNativePackage = false;
if (
typeof request === 'string' &&
PI_TUI_NATIVE_PATTERN.test(request) &&
!existsSync(request)
) {
const pkgRoot = getNativePackageRoot('@moonshot-ai/pi-tui');
if (pkgRoot !== null) {
const match = request.match(PI_TUI_NATIVE_PATTERN);
if (match !== null) {
const redirected = join(pkgRoot, match[0]);
return originalLoad.call(this, redirected, parent, isMain);
}
}
}
return originalLoad.call(this, request, parent, isMain);

View file

@ -12,6 +12,7 @@ import {
import { createRequire } from 'node:module';
import { homedir } from 'node:os';
import { dirname, join, win32 as pathWin32 } from 'node:path';
import { join as joinPosix } from 'pathe';
import { KIMI_BUILD_INFO } from '#/cli/build-info';
import { NATIVE_ASSET_MANIFEST_VERSION as MANIFEST_VERSION, buildManifestKey } from '../../scripts/native/manifest.mjs';
@ -143,7 +144,7 @@ export function getNativeCacheBase(options: NativeAssetOptions = {}): string {
const cacheDirEnv = optionalEnvValue(env, 'KIMI_CODE_CACHE_DIR');
if (cacheDirEnv !== null) return cacheDirEnv;
if (platform === 'darwin') return join(home, 'Library', 'Caches', 'kimi-code');
if (platform === 'darwin') return joinPosix(home, 'Library', 'Caches', 'kimi-code');
if (platform === 'win32') {
const localAppData = optionalEnvValue(env, 'LOCALAPPDATA');
return localAppData !== null
@ -151,7 +152,7 @@ export function getNativeCacheBase(options: NativeAssetOptions = {}): string {
: pathWin32.join(home, 'AppData', 'Local', 'kimi-code', 'Cache');
}
return join(optionalEnvValue(env, 'XDG_CACHE_HOME') ?? join(home, '.cache'), 'kimi-code');
return joinPosix(optionalEnvValue(env, 'XDG_CACHE_HOME') ?? joinPosix(home, '.cache'), 'kimi-code');
}
export function getNativeAssetCacheRoot(

View file

@ -1,6 +1,38 @@
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { getEmbeddedNativeAssetManifest, getNativePackageRoot } from './native-assets';
const smokePackages = ['@mariozechner/clipboard', 'koffi'];
const smokePackages = ['@mariozechner/clipboard', '@moonshot-ai/pi-tui'];
// Verify pi-tui's native helper can actually be loaded through the module hook.
// pi-tui computes native helper paths from process.execPath and require()s them;
// those paths do not exist next to the SEA binary, so this only succeeds when
// installNativeModuleHook() redirects the require into the native-asset cache.
function smokePiTuiNativeLoad(): void {
const platform = process.platform;
const arch = process.arch;
let rel: string | undefined;
if (platform === 'darwin' && (arch === 'x64' || arch === 'arm64')) {
rel = join('native', 'darwin', 'prebuilds', `darwin-${arch}`, 'darwin-modifiers.node');
} else if (platform === 'win32' && (arch === 'x64' || arch === 'arm64')) {
rel = join('native', 'win32', 'prebuilds', `win32-${arch}`, 'win32-console-mode.node');
}
if (rel === undefined) return; // Linux: no native helper, nothing to load.
const req = createRequire(import.meta.url);
const bogusPath = join(dirname(process.execPath), rel);
const helper = req(bogusPath) as {
isModifierPressed?: unknown;
enableVirtualTerminalInput?: unknown;
};
const ok =
typeof helper.isModifierPressed === 'function' ||
typeof helper.enableVirtualTerminalInput === 'function';
if (!ok) {
throw new Error(`pi-tui native helper loaded but exports are unexpected: ${rel}`);
}
}
export function runNativeAssetSmokeIfRequested(): boolean {
if (process.env['KIMI_CODE_NATIVE_ASSET_SMOKE'] !== '1') return false;
@ -16,6 +48,7 @@ export function runNativeAssetSmokeIfRequested(): boolean {
throw new Error(`Native package is not available: ${packageName}`);
}
}
smokePiTuiNativeLoad();
process.stdout.write(`Native asset smoke passed: ${manifest.target}\n`);
process.exit(0);
} catch (error) {

View file

@ -0,0 +1,183 @@
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { KIMI_BUILD_INFO } from '#/cli/build-info';
import {
getNativeCacheBase,
getSeaAssetSource,
type NativeAssetSource,
} from './native-assets';
import {
WEB_ASSET_MANIFEST_VERSION as MANIFEST_VERSION,
buildWebManifestKey,
} from '../../scripts/native/manifest.mjs';
export const WEB_ASSET_MANIFEST_VERSION = MANIFEST_VERSION;
export interface WebAssetFile {
readonly assetKey: string;
readonly relativePath: string;
readonly sha256: string;
}
export interface WebAssetManifest {
readonly version: typeof WEB_ASSET_MANIFEST_VERSION;
readonly target: string;
readonly root: 'dist-web';
readonly files: readonly WebAssetFile[];
}
export type WebAssetSource = NativeAssetSource;
export interface WebAssetOptions {
readonly source?: WebAssetSource | null;
readonly manifest?: WebAssetManifest | null;
readonly cacheBase?: string;
readonly env?: NodeJS.ProcessEnv;
readonly platform?: NodeJS.Platform;
readonly homeDir?: string;
readonly version?: string;
}
type RawWebAssetManifest = Omit<WebAssetManifest, 'version' | 'root'> & {
readonly version: number;
readonly root: string;
};
function currentTarget(): string {
return KIMI_BUILD_INFO.buildTarget ?? `${process.platform}-${process.arch}`;
}
function toBuffer(value: ArrayBuffer | ArrayBufferView | Buffer | string): Buffer {
if (Buffer.isBuffer(value)) return value;
if (typeof value === 'string') return Buffer.from(value);
if (ArrayBuffer.isView(value)) {
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
}
return Buffer.from(value);
}
function sha256(bytes: Buffer | Uint8Array | string): string {
return createHash('sha256').update(bytes).digest('hex');
}
function sanitizeSegment(value: string): string {
const sanitized = value.replaceAll(/[^a-zA-Z0-9._-]/g, '_');
return sanitized.length > 0 ? sanitized : 'unknown';
}
function readFileSha256(path: string): string | null {
try {
return sha256(readFileSync(path));
} catch {
return null;
}
}
function ensureFile(path: string, bytes: Buffer, expectedSha256: string): void {
if (readFileSha256(path) === expectedSha256) return;
mkdirSync(dirname(path), { recursive: true });
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
writeFileSync(tempPath, bytes, { mode: 0o644 });
try {
renameSync(tempPath, path);
return;
} catch {
if (readFileSha256(path) === expectedSha256) {
rmSync(tempPath, { force: true });
return;
}
}
try {
rmSync(path, { force: true });
renameSync(tempPath, path);
} catch (error) {
rmSync(tempPath, { force: true });
if (readFileSha256(path) === expectedSha256) return;
throw error;
}
}
function assertSafeRelativePath(relativePath: string): void {
if (
relativePath.length === 0 ||
relativePath.startsWith('/') ||
relativePath.includes('\\') ||
relativePath.split('/').includes('..') ||
/^[A-Za-z]:/.test(relativePath)
) {
throw new Error(`Invalid web asset relative path: ${relativePath}`);
}
}
export function webAssetManifestKey(target: string = currentTarget()): string {
return buildWebManifestKey(target);
}
export function getEmbeddedWebAssetManifest(
source: WebAssetSource | null = getSeaAssetSource(),
target = currentTarget(),
): WebAssetManifest | null {
if (source === null) return null;
const key = webAssetManifestKey(target);
if (!source.getAssetKeys().includes(key)) return null;
const raw = source.getRawAsset(key);
const manifest = JSON.parse(toBuffer(raw).toString('utf-8')) as RawWebAssetManifest;
if (manifest.version !== WEB_ASSET_MANIFEST_VERSION) {
throw new Error(`Unsupported web asset manifest version: ${manifest.version}`);
}
if (manifest.target !== target) {
throw new Error(`Web asset manifest target mismatch: ${manifest.target} !== ${target}`);
}
if (manifest.root !== 'dist-web') {
throw new Error(`Unsupported web asset root: ${manifest.root}`);
}
return manifest as WebAssetManifest;
}
export function getWebAssetCacheRoot(
manifest: WebAssetManifest,
options: WebAssetOptions = {},
): string {
const version = sanitizeSegment(options.version ?? KIMI_BUILD_INFO.version ?? 'dev');
const manifestHash = sha256(JSON.stringify(manifest));
return join(
getNativeCacheBase({
cacheBase: options.cacheBase,
env: options.env,
platform: options.platform,
homeDir: options.homeDir,
}),
'web',
version,
sanitizeSegment(manifest.target),
manifestHash,
manifest.root,
);
}
export function getNativeWebAssetsDir(options: WebAssetOptions = {}): string | null {
const source = options.source ?? getSeaAssetSource();
if (source === null) return null;
const manifest = options.manifest ?? getEmbeddedWebAssetManifest(source, currentTarget());
if (manifest === null) return null;
const cacheRoot = getWebAssetCacheRoot(manifest, options);
for (const file of manifest.files) {
assertSafeRelativePath(file.relativePath);
const bytes = toBuffer(source.getRawAsset(file.assetKey));
const actualSha256 = sha256(bytes);
if (actualSha256 !== file.sha256) {
throw new Error(
`Web asset checksum mismatch for ${file.assetKey}: ${actualSha256} !== ${file.sha256}`,
);
}
ensureFile(join(cacheRoot, file.relativePath), bytes, file.sha256);
}
return cacheRoot;
}

View file

@ -0,0 +1,331 @@
import { createHash } from 'node:crypto';
import { gte, valid } from 'semver';
import { KIMI_CODE_TIPS_BANNER_URL } from '#/constant/app';
import type { BannerDisplay, BannerState } from '#/tui/types';
import type { BannerDisplayState } from './state';
interface TipsBannerFallbackItem {
banner_id?: string | null;
enabled?: boolean;
banner_title?: string | null;
banner_maintext?: string;
banner_subtext?: string | null;
banner_min_version?: string | null;
banner_display?: unknown;
banner_display_ttl_hours?: unknown;
}
interface TipsBannerJson {
banner_id?: string | null;
banner_enabled?: boolean;
banner_title?: string | null;
banner_maintext?: string;
banner_subtext?: string | null;
banner_start_time?: string | null;
banner_end_time?: string | null;
banner_min_version?: string | null;
banner_display?: unknown;
banner_display_ttl_hours?: unknown;
banner_fallback_enabled?: boolean;
banner_fallback_list?: unknown[];
}
interface BannerHashInput {
tag: string | null;
mainText: string;
subText: string | null;
startTime: string | null;
endTime: string | null;
display: BannerDisplay;
ttlHours?: number;
}
interface BannerCandidateInput {
id: unknown;
tag: unknown;
mainText: string;
subText: unknown;
display: BannerDisplay;
ttlHours?: number;
startTime?: unknown;
endTime?: unknown;
}
export interface SelectDisplayableBannerArgs {
json: unknown;
clientVersion: string;
now: Date;
random: () => number;
state: BannerDisplayState;
}
interface BannerProviderLoadOptions {
state?: BannerDisplayState;
now?: Date;
random?: () => number;
}
const HOUR_MS = 60 * 60 * 1000;
export const DEFAULT_COOLDOWN_TTL_HOURS = 24;
function normalizeTag(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizeText(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizeUtcDate(value: string): string {
if (value.endsWith('Z')) return value;
if (/[+-]\d{2}:\d{2}$/.test(value)) return value;
return `${value}Z`;
}
function parseDate(value: unknown): Date | null {
if (typeof value !== 'string' || value.length === 0) return null;
const normalized = normalizeUtcDate(value);
const date = new Date(normalized);
return Number.isNaN(date.getTime()) ? null : date;
}
function isWithinWindow(start: Date | null, end: Date | null, now: Date): boolean {
if (start !== null && now < start) return false;
if (end !== null && now > end) return false;
return true;
}
function meetsMinVersion(minVersion: unknown, clientVersion: string): boolean {
if (minVersion === undefined || minVersion === null) return true;
if (typeof minVersion !== 'string' || minVersion.length === 0) return true;
const min = valid(minVersion);
const current = valid(clientVersion);
if (min === null || current === null) return false;
return gte(current, min);
}
function parseBannerDisplay(value: unknown): BannerDisplay {
if (value === 'once') return 'once';
if (value === 'cooldown') return 'cooldown';
return 'always';
}
function parseBannerDisplayTtlHours(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? value
: DEFAULT_COOLDOWN_TTL_HOURS;
}
function normalizeBannerId(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function hashBannerIdentity(input: BannerHashInput): string {
const raw = JSON.stringify([
input.tag ?? '',
input.mainText,
input.subText ?? '',
input.startTime ?? '',
input.endTime ?? '',
input.display,
input.ttlHours ?? '',
]);
return createHash('sha256').update(raw).digest('hex').slice(0, 32);
}
function getBannerKey(rawBannerId: unknown, input: BannerHashInput): string {
return normalizeBannerId(rawBannerId) ?? hashBannerIdentity(input);
}
function toBannerState(input: BannerCandidateInput): BannerState {
const tag = normalizeTag(input.tag);
const subText = normalizeText(input.subText);
const display = input.display;
const ttlHours = display === 'cooldown' ? parseBannerDisplayTtlHours(input.ttlHours) : undefined;
const startTime = normalizeText(input.startTime);
const endTime = normalizeText(input.endTime);
const key = getBannerKey(input.id, {
tag,
mainText: input.mainText,
subText,
startTime,
endTime,
display,
ttlHours,
});
return {
key,
tag,
mainText: input.mainText,
subText,
display,
ttlHours,
};
}
function pickActiveBanner(
json: TipsBannerJson,
clientVersion: string,
now: Date,
): BannerState | null {
if (json.banner_enabled !== true) return null;
if (!meetsMinVersion(json.banner_min_version, clientVersion)) return null;
const start = parseDate(json.banner_start_time);
const end = parseDate(json.banner_end_time);
if (!isWithinWindow(start, end, now)) return null;
const mainText = normalizeText(json.banner_maintext);
if (mainText === null) return null;
const display = parseBannerDisplay(json.banner_display);
return toBannerState({
id: json.banner_id,
tag: json.banner_title,
mainText,
subText: json.banner_subtext,
display,
ttlHours: display === 'cooldown' ? parseBannerDisplayTtlHours(json.banner_display_ttl_hours) : undefined,
startTime: json.banner_start_time,
endTime: json.banner_end_time,
});
}
function pickFallbackCandidates(
json: TipsBannerJson,
clientVersion: string,
): BannerState[] {
if (json.banner_fallback_enabled !== true) return [];
const list = Array.isArray(json.banner_fallback_list) ? json.banner_fallback_list : [];
const candidates: BannerState[] = [];
for (const raw of list) {
if (typeof raw !== 'object' || raw === null) continue;
const item = raw as TipsBannerFallbackItem;
if (item.enabled !== true) continue;
if (!meetsMinVersion(item.banner_min_version, clientVersion)) continue;
const mainText = normalizeText(item.banner_maintext);
if (mainText === null) continue;
const display = parseBannerDisplay(item.banner_display);
candidates.push(
toBannerState({
id: item.banner_id,
tag: item.banner_title,
mainText,
subText: item.banner_subtext,
display,
ttlHours: display === 'cooldown' ? parseBannerDisplayTtlHours(item.banner_display_ttl_hours) : undefined,
}),
);
}
return candidates;
}
function pickRandomCandidate(candidates: BannerState[], random: () => number): BannerState | null {
if (candidates.length === 0) return null;
const index = Math.floor(random() * candidates.length);
return candidates[index]!;
}
function pickFallbackBanner(
json: TipsBannerJson,
clientVersion: string,
random: () => number,
): BannerState | null {
return pickRandomCandidate(pickFallbackCandidates(json, clientVersion), random);
}
function parseShownAt(value: string | undefined): Date | null {
if (value === undefined) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function getCooldownTtlHours(banner: BannerState): number {
return typeof banner.ttlHours === 'number' && Number.isFinite(banner.ttlHours) && banner.ttlHours > 0
? banner.ttlHours
: DEFAULT_COOLDOWN_TTL_HOURS;
}
export function shouldDisplayBanner(
banner: BannerState,
state: BannerDisplayState,
now: Date,
): boolean {
if (banner.display === 'always') return true;
const lastShownAt = parseShownAt(state.shown[banner.key]?.lastShownAt);
if (lastShownAt === null) return true;
if (banner.display === 'once') return false;
return now.getTime() - lastShownAt.getTime() >= getCooldownTtlHours(banner) * HOUR_MS;
}
export function selectBannerState(
json: unknown,
clientVersion: string,
now: Date,
random: () => number,
): BannerState | null {
const typed = typeof json === 'object' && json !== null ? (json as TipsBannerJson) : {};
return (
pickActiveBanner(typed, clientVersion, now) ??
pickFallbackBanner(typed, clientVersion, random)
);
}
export function selectDisplayableBanner({
json,
clientVersion,
now,
random,
state,
}: SelectDisplayableBannerArgs): BannerState | null {
const typed = typeof json === 'object' && json !== null ? (json as TipsBannerJson) : {};
const active = pickActiveBanner(typed, clientVersion, now);
if (active !== null && shouldDisplayBanner(active, state, now)) return active;
const candidates = pickFallbackCandidates(typed, clientVersion).filter((candidate) =>
shouldDisplayBanner(candidate, state, now),
);
return pickRandomCandidate(candidates, random);
}
export class BannerProvider {
constructor(
private readonly clientVersion: string,
private readonly url: string = KIMI_CODE_TIPS_BANNER_URL,
) {}
async load(
fetchImpl: typeof fetch = fetch,
options: BannerProviderLoadOptions = {},
): Promise<BannerState | null> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, 3000);
const response = await fetchImpl(this.url, { signal: controller.signal });
clearTimeout(timeout);
if (!response.ok) return null;
const json = await response.json();
const now = options.now ?? new Date();
const random = options.random ?? Math.random;
return options.state === undefined
? selectBannerState(json, this.clientVersion, now, random)
: selectDisplayableBanner({
json,
clientVersion: this.clientVersion,
now,
random,
state: options.state,
});
} catch {
return null;
}
}
}

View file

@ -0,0 +1,69 @@
import { z } from 'zod';
import { getBannerStateFile } from '#/utils/paths';
import { readJsonFile, writeJsonFile } from '#/utils/persistence';
export type BannerDisplayRecord = {
lastShownAt: string;
};
export type BannerDisplayState = {
version: 1;
shown: Record<string, BannerDisplayRecord>;
};
const BannerDisplayRecordSchema = z
.object({
lastShownAt: z.string().min(1),
})
.strict();
const BannerDisplayStateSchema = z.preprocess(
(value) => {
if (typeof value !== 'object' || value === null) return value;
const shown = (value as { shown?: unknown }).shown;
if (typeof shown !== 'object' || shown === null) {
return { ...(value as Record<string, unknown>), shown: {} };
}
const normalizedShown: Record<string, BannerDisplayRecord> = {};
for (const [key, record] of Object.entries(shown)) {
if (key.length === 0 || typeof record !== 'object' || record === null) continue;
const lastShownAt = (record as { lastShownAt?: unknown }).lastShownAt;
if (typeof lastShownAt !== 'string' || Number.isNaN(Date.parse(lastShownAt))) continue;
normalizedShown[key] = { lastShownAt };
}
return { ...(value as Record<string, unknown>), shown: normalizedShown };
},
z
.object({
version: z.literal(1),
shown: z.record(z.string().min(1), BannerDisplayRecordSchema),
})
.strict(),
);
export function emptyBannerDisplayState(): BannerDisplayState {
return {
version: 1,
shown: {},
};
}
export async function readBannerDisplayState(
filePath: string = getBannerStateFile(),
): Promise<BannerDisplayState> {
try {
return await readJsonFile(filePath, BannerDisplayStateSchema, emptyBannerDisplayState());
} catch {
return emptyBannerDisplayState();
}
}
export async function writeBannerDisplayState(
value: BannerDisplayState,
filePath: string = getBannerStateFile(),
): Promise<void> {
await writeJsonFile(filePath, BannerDisplayStateSchema, value);
}

View file

@ -0,0 +1,91 @@
import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
import { ChoicePickerComponent } from '../components/dialogs/choice-picker';
import type { SlashCommandHost } from './dispatch';
type AddDirChoice = 'session' | 'remember' | 'cancel';
export async function handleAddDirCommand(host: SlashCommandHost, args: string): Promise<void> {
const input = args.trim();
const session = host.session;
if (input.length === 0 || input.toLowerCase() === 'list') {
const additionalDirs = session?.summary?.additionalDirs ?? [];
if (additionalDirs.length === 0) {
host.showStatus('No additional directories configured.');
return;
}
host.showStatus(formatAdditionalDirsStatus(additionalDirs));
return;
}
if (session === undefined) {
host.showError(NO_ACTIVE_SESSION_MESSAGE);
return;
}
host.mountEditorReplacement(
new ChoicePickerComponent({
title: `Add directory to workspace: ${input}`,
hint: '↑↓ navigate · Enter confirm · Esc cancel',
options: [
{
value: 'session',
label: 'Yes, for this session',
},
{
value: 'remember',
label: 'Yes, and remember this directory',
},
{
value: 'cancel',
label: 'No',
},
],
onSelect: (value) => {
void handleAddDirChoice(host, session.id, input, value as AddDirChoice);
},
onCancel: () => {
host.restoreEditor();
host.showStatus(`Did not add ${input} as a working directory.`);
},
}),
);
}
function formatAdditionalDirsStatus(additionalDirs: readonly string[]): string {
return ['Additional directories:', ...additionalDirs.map((dir) => ` ${dir}`)].join('\n');
}
async function handleAddDirChoice(
host: SlashCommandHost,
sessionId: string,
path: string,
choice: AddDirChoice,
): Promise<void> {
host.restoreEditor();
if (choice === 'cancel') {
host.showStatus(`Did not add ${path} as a working directory.`);
return;
}
const session = host.session;
if (session === undefined || session.id !== sessionId) {
host.showError(NO_ACTIVE_SESSION_MESSAGE);
return;
}
try {
const result = await session.addAdditionalDir(path, { persist: choice === 'remember' });
host.setAppState({ additionalDirs: result.additionalDirs });
host.refreshSlashCommandAutocomplete();
host.showStatus(
choice === 'remember'
? `Added workspace directory:\n ${path}\n Saved to:\n ${result.configPath}`
: `Added workspace directory:\n ${path}\n For this session only`,
'success',
);
} catch (error) {
host.showError(error instanceof Error ? error.message : String(error));
}
}

View file

@ -70,6 +70,7 @@ async function handleKimiCodeOAuthLogin(host: SlashCommandHost): Promise<void> {
}
host.track('login', {
provider: DEFAULT_OAUTH_PROVIDER_NAME,
method: 'oauth',
already_logged_in: alreadyLoggedIn,
});
if (alreadyLoggedIn) {
@ -158,7 +159,11 @@ async function handleOpenPlatformLogin(
platform,
models,
selectedModel: selection.model,
thinking: selection.thinking,
thinking: selection.thinking !== 'off',
effort:
selection.thinking !== 'off' && selection.thinking !== 'on'
? selection.thinking
: undefined,
apiKey,
});
@ -166,7 +171,7 @@ async function handleOpenPlatformLogin(
providers: config.providers,
models: config.models,
defaultModel: config.defaultModel,
defaultThinking: config.defaultThinking,
thinking: config.thinking,
});
await host.authFlow.refreshConfigAfterLogin();

View file

@ -1,4 +1,4 @@
import type { AutocompleteItem } from '@earendil-works/pi-tui';
import type { AutocompleteItem } from '@moonshot-ai/pi-tui';
/**
* A completable token (subcommand or flag) for a slash command's argument

View file

@ -1,25 +1,30 @@
import type {
ExperimentalFeatureState,
FlagId,
PermissionMode,
Session,
import {
effectiveModelAlias,
type ExperimentalFeatureState,
type ModelAlias,
type PermissionMode,
type Session,
type ThinkingEffort,
} from '@moonshot-ai/kimi-code-sdk';
import { EditorSelectorComponent } from '../components/dialogs/editor-selector';
import { EffortSelectorComponent } from '../components/dialogs/effort-selector';
import {
ExperimentsSelectorComponent,
type ExperimentalFeatureDraftChange,
} from '../components/dialogs/experiments-selector';
import { modelDisplayName, segmentsFor } from '../components/dialogs/model-selector';
import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector';
import { PermissionSelectorComponent } from '../components/dialogs/permission-selector';
import { SettingsSelectorComponent, type SettingsSelection } from '../components/dialogs/settings-selector';
import { ThemeSelectorComponent } from '../components/dialogs/theme-selector';
import { UpdatePreferenceSelectorComponent } from '../components/dialogs/update-preference-selector';
import { saveTuiConfig } from '../config';
import type { Theme } from '../theme';
import { DEFAULT_TUI_CONFIG, saveTuiConfig, type TuiConfig } from '../config';
import type { ThemeName } from '#/tui/theme';
import { currentTheme, isBuiltInTheme, lightColors, loadCustomThemeMerged } from '#/tui/theme';
import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
import { isTheme } from '../theme/index';
import { formatErrorMessage } from '../utils/event-payload';
import { thinkingEffortToConfig } from '../utils/thinking-config';
import { showUsage } from './info';
import { setExperimentalFeatures } from './experimental-flags';
import type { SlashCommandHost } from './dispatch';
@ -28,6 +33,18 @@ import type { SlashCommandHost } from './dispatch';
// Plan / Config commands
// ---------------------------------------------------------------------------
const MODEL_PICKER_REFRESH_TIMEOUT_MS = 2_000;
function currentTuiConfig(host: SlashCommandHost): TuiConfig {
return {
theme: host.state.appState.theme,
editorCommand: host.state.appState.editorCommand,
disablePasteBurst: host.state.appState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst,
notifications: host.state.appState.notifications,
upgrade: host.state.appState.upgrade,
};
}
export async function handlePlanCommand(host: SlashCommandHost, args: string): Promise<void> {
const session = host.session;
if (session === undefined) {
@ -90,7 +107,7 @@ export async function handleYoloCommand(host: SlashCommandHost, args: string): P
}
await session.setPermission('yolo');
host.setAppState({ permissionMode: 'yolo' });
host.showNotice('YOLO mode: ON', 'Workspace tools auto-approved.');
host.showNotice('YOLO mode: ON', 'AI auto-approves safe actions, asks for approval on risky ones.');
return;
}
@ -113,7 +130,7 @@ export async function handleYoloCommand(host: SlashCommandHost, args: string): P
} else {
await session.setPermission('yolo');
host.setAppState({ permissionMode: 'yolo' });
host.showNotice('YOLO mode: ON', 'Workspace tools auto-approved.');
host.showNotice('YOLO mode: ON', 'AI auto-approves safe actions, asks for approval on risky ones.');
}
}
@ -134,7 +151,7 @@ export async function handleAutoCommand(host: SlashCommandHost, args: string): P
}
await session.setPermission('auto');
host.setAppState({ permissionMode: 'auto' });
host.showNotice('Auto mode: ON', 'Tools auto-approved. Agent will not ask questions.');
host.showNotice('Auto mode: ON', 'Run all actions automatically, including risky ones.');
return;
}
@ -157,7 +174,7 @@ export async function handleAutoCommand(host: SlashCommandHost, args: string): P
} else {
await session.setPermission('auto');
host.setAppState({ permissionMode: 'auto' });
host.showNotice('Auto mode: ON', 'Tools auto-approved. Agent will not ask questions.');
host.showNotice('Auto mode: ON', 'Run all actions automatically, including risky ones.');
}
}
@ -186,15 +203,19 @@ export async function handleThemeCommand(host: SlashCommandHost, args: string):
showThemePicker(host);
return;
}
if (!isTheme(theme)) {
host.showError(`Unknown theme: ${theme}`);
return;
if (!isBuiltInTheme(theme)) {
const custom = await loadCustomThemeMerged(theme);
if (custom === null) {
host.showError(`Unknown theme: ${theme}`);
return;
}
}
await applyThemeChoice(host, theme);
}
export function handleModelCommand(host: SlashCommandHost, args: string): void {
export async function handleModelCommand(host: SlashCommandHost, args: string): Promise<void> {
const alias = args.trim();
await refreshModelsForPicker(host);
if (alias.length === 0) {
showModelPicker(host);
return;
@ -206,6 +227,56 @@ export function handleModelCommand(host: SlashCommandHost, args: string): void {
showModelPicker(host, alias);
}
export async function handleEffortCommand(host: SlashCommandHost, args: string): Promise<void> {
const alias = host.state.appState.model;
const model = host.state.appState.availableModels[alias];
if (model === undefined) {
host.showError('No model selected. Run /model to select one first.');
return;
}
const effective = effectiveModelAlias(model);
const segments = segmentsFor(effective);
const arg = args.trim().toLowerCase();
if (arg.length === 0) {
showEffortPicker(host, effective, segments);
return;
}
if (!segments.includes(arg)) {
host.showError(
`Unsupported thinking effort "${arg}" for ${alias}. Available: ${segments.join(', ')}`,
);
return;
}
await performModelSwitch(host, alias, arg, true);
}
function showEffortPicker(
host: SlashCommandHost,
model: ModelAlias,
segments: readonly string[],
): void {
const liveEffort = host.state.appState.thinkingEffort;
const currentValue = segments.includes(liveEffort) ? liveEffort : (segments[0] ?? 'off');
const alias = host.state.appState.model;
host.mountEditorReplacement(
new EffortSelectorComponent({
efforts: segments,
currentValue,
onSelect: (effort) => {
host.restoreEditor();
void performModelSwitch(host, alias, effort, true);
},
onSessionOnlySelect: (effort) => {
host.restoreEditor();
void performModelSwitch(host, alias, effort, false);
},
onCancel: () => {
host.restoreEditor();
},
}),
);
}
// ---------------------------------------------------------------------------
// Pickers & config apply
// ---------------------------------------------------------------------------
@ -215,7 +286,6 @@ function showEditorPicker(host: SlashCommandHost): void {
host.mountEditorReplacement(
new EditorSelectorComponent({
currentValue,
colors: host.state.theme.colors,
onSelect: (value) => {
host.restoreEditor();
void applyEditorChoice(host, value);
@ -227,6 +297,37 @@ function showEditorPicker(host: SlashCommandHost): void {
);
}
async function refreshModelsForPicker(host: SlashCommandHost): Promise<void> {
try {
const result = await withTimeout(
host.authFlow.refreshOAuthProviderModels(),
MODEL_PICKER_REFRESH_TIMEOUT_MS,
);
if (result === undefined) return;
for (const f of result.failed) {
host.showStatus(`Skipped refreshing ${f.provider}: ${f.reason}`, 'warning');
}
} catch (error) {
host.showStatus(`Skipped refreshing models: ${formatErrorMessage(error)}`, 'warning');
}
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | undefined> {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<undefined>((resolve) => {
timeout = setTimeout(() => {
resolve(undefined);
}, timeoutMs);
}),
]);
} finally {
if (timeout !== undefined) clearTimeout(timeout);
}
}
async function applyEditorChoice(host: SlashCommandHost, value: string): Promise<void> {
const previous = host.state.appState.editorCommand ?? '';
if (value === previous && value.length > 0) {
@ -237,15 +338,13 @@ async function applyEditorChoice(host: SlashCommandHost, value: string): Promise
const editorCommand = value.length > 0 ? value : null;
try {
await saveTuiConfig({
theme: host.state.appState.theme,
...currentTuiConfig(host),
editorCommand,
notifications: host.state.appState.notifications,
upgrade: host.state.appState.upgrade,
});
} catch (error) {
host.showStatus(
`Failed to save editor: ${formatErrorMessage(error)}`,
host.state.theme.colors.error,
'error',
);
return;
}
@ -272,11 +371,14 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string =
models: host.state.appState.availableModels,
currentValue: host.state.appState.model,
selectedValue,
currentThinking: host.state.appState.thinking,
colors: host.state.theme.colors,
currentThinkingEffort: host.state.appState.thinkingEffort,
onSelect: ({ alias, thinking }) => {
host.restoreEditor();
void performModelSwitch(host, alias, thinking);
void performModelSwitch(host, alias, thinking, true);
},
onSessionOnlySelect: ({ alias, thinking }) => {
host.restoreEditor();
void performModelSwitch(host, alias, thinking, false);
},
onCancel: () => {
host.restoreEditor();
@ -285,27 +387,34 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string =
);
}
async function performModelSwitch(host: SlashCommandHost, alias: string, thinking: boolean): Promise<void> {
async function performModelSwitch(
host: SlashCommandHost,
alias: string,
effort: ThinkingEffort,
persist: boolean,
): Promise<void> {
if (host.state.appState.streamingPhase !== 'idle') {
host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.');
return;
}
const level = thinking ? 'on' : 'off';
const prevModel = host.state.appState.model;
const prevThinking = host.state.appState.thinking;
const runtimeChanged = alias !== prevModel || thinking !== prevThinking;
const prevEffort = host.state.appState.thinkingEffort;
const modelChanged = alias !== prevModel;
const effortChanged = effort !== prevEffort;
const runtimeChanged = modelChanged || effortChanged;
const displayName = modelDisplayName(alias, host.state.appState.availableModels[alias]);
const session = host.session;
try {
if (session === undefined && runtimeChanged) {
await host.authFlow.activateModelAfterLogin(alias, thinking);
await host.authFlow.activateModelAfterLogin(alias, effort);
} else if (session !== undefined) {
if (alias !== prevModel) {
await session.setModel(alias);
}
if (thinking !== prevThinking) {
await session.setThinking(level);
if (effort !== prevEffort) {
await session.setThinking(effort);
}
}
} catch (error) {
@ -314,41 +423,65 @@ async function performModelSwitch(host: SlashCommandHost, alias: string, thinkin
return;
}
host.setAppState({ model: alias, thinking });
host.setAppState({ model: alias, thinkingEffort: effort });
if (session === undefined && runtimeChanged) {
if (alias !== prevModel) {
host.track('model_switch', { model: alias });
}
if (thinking !== prevThinking) {
host.track('thinking_toggle', { enabled: thinking });
if (effort !== prevEffort) {
host.track('thinking_toggle', {
enabled: effort !== 'off',
effort,
from: prevEffort,
});
}
}
let persisted = false;
try {
persisted = await persistModelSelection(host, alias, thinking);
} catch (error) {
const msg = formatErrorMessage(error);
host.showError(`Switched to ${alias}, but failed to save default: ${msg}`);
return;
if (persist) {
try {
persisted = await persistModelSelection(host, alias, effort);
} catch (error) {
const msg = formatErrorMessage(error);
host.showError(`Switched to ${displayName}, but failed to save default: ${msg}`);
return;
}
}
const status = runtimeChanged
? `Switched to ${alias} with thinking ${level}.`
: persisted
? `Saved ${alias} with thinking ${level} as default.`
: `Already using ${alias} with thinking ${level}.`;
host.showStatus(status, host.state.theme.colors.success);
let status: string;
if (modelChanged) {
status = persist
? `Switched to ${displayName} with thinking ${effort}.`
: `Switched to ${displayName} with thinking ${effort} for this session only.`;
} else if (effortChanged) {
status = persist
? `Thinking set to ${effort}.`
: `Thinking set to ${effort} for this session only.`;
} else if (persist && persisted) {
status = `Saved ${displayName} with thinking ${effort} as default.`;
} else {
status = `Already using ${displayName} with thinking ${effort}.`;
}
host.showStatus(status, 'success');
}
async function persistModelSelection(host: SlashCommandHost, alias: string, thinking: boolean): Promise<boolean> {
async function persistModelSelection(
host: SlashCommandHost,
alias: string,
effort: ThinkingEffort,
): Promise<boolean> {
const config = await host.harness.getConfig({ reload: true });
if (config.defaultModel === alias && config.defaultThinking === thinking) {
const patch = thinkingEffortToConfig(effort);
if (
config.defaultModel === alias &&
config.thinking?.enabled === patch.enabled &&
config.thinking?.effort === patch.effort
) {
return false;
}
await host.harness.setConfig({
defaultModel: alias,
defaultThinking: thinking,
thinking: patch,
});
return true;
}
@ -357,7 +490,6 @@ function showThemePicker(host: SlashCommandHost): void {
host.mountEditorReplacement(
new ThemeSelectorComponent({
currentValue: host.state.appState.theme,
colors: host.state.theme.colors,
onSelect: (value) => {
host.restoreEditor();
void applyThemeChoice(host, value);
@ -369,30 +501,41 @@ function showThemePicker(host: SlashCommandHost): void {
);
}
async function applyThemeChoice(host: SlashCommandHost, theme: Theme): Promise<void> {
async function applyThemeChoice(host: SlashCommandHost, theme: ThemeName): Promise<void> {
if (theme === host.state.appState.theme) {
if (theme === 'auto') host.refreshTerminalThemeTracking();
host.showStatus(`Theme unchanged: "${theme}".`);
return;
}
// Validate custom themes up front so a missing / malformed file reports an
// error instead of silently persisting a name that resolves to the dark
// fallback.
if (!isBuiltInTheme(theme)) {
const palette = await loadCustomThemeMerged(theme);
if (palette === null) {
host.showStatus(`Theme "${theme}" could not be loaded.`, 'error');
return;
}
}
try {
await saveTuiConfig({
...currentTuiConfig(host),
theme,
editorCommand: host.state.appState.editorCommand,
notifications: host.state.appState.notifications,
upgrade: host.state.appState.upgrade,
});
} catch (error) {
host.showStatus(
`Failed to save theme: ${formatErrorMessage(error)}`,
host.state.theme.colors.error,
'error',
);
return;
}
const resolved = theme === 'auto' ? host.state.theme.resolvedTheme : theme;
host.applyTheme(theme, resolved);
const resolved = theme === 'auto'
? (currentTheme.palette === lightColors ? 'light' : 'dark')
: undefined;
await host.applyTheme(theme, resolved);
host.refreshTerminalThemeTracking();
host.track('theme_switch', { theme });
const detail = theme === 'auto' ? ` (tracking terminal; current: ${resolved})` : '';
@ -403,7 +546,6 @@ export function showPermissionPicker(host: SlashCommandHost): void {
host.mountEditorReplacement(
new PermissionSelectorComponent({
currentValue: host.state.appState.permissionMode,
colors: host.state.theme.colors,
onSelect: (value) => {
host.restoreEditor();
void applyPermissionChoice(host, value);
@ -419,7 +561,6 @@ export function showUpdatePreferencePicker(host: SlashCommandHost): void {
host.mountEditorReplacement(
new UpdatePreferenceSelectorComponent({
currentValue: host.state.appState.upgrade.autoInstall,
colors: host.state.theme.colors,
onSelect: (value) => {
host.restoreEditor();
void applyUpdatePreferenceChoice(host, value);
@ -449,12 +590,12 @@ export async function applyExperimentalFeatureChanges(
if (changes.length === 0) {
host.showStatus(
'No experimental feature changes to apply.',
host.state.theme.colors.textMuted,
'textMuted',
);
return;
}
const experimental: Partial<Record<FlagId, boolean>> = {};
const experimental: Record<string, boolean> = {};
for (const change of changes) {
experimental[change.id] = change.enabled;
}
@ -472,7 +613,7 @@ export async function applyExperimentalFeatureChanges(
'Experimental features updated. Session reloaded.',
);
} else {
host.showStatus('Experimental features updated.', host.state.theme.colors.success);
host.showStatus('Experimental features updated.', 'success');
}
host.track('experimental_features_apply', { changed: changes.length });
} catch (error) {
@ -487,7 +628,6 @@ function mountExperimentsPanel(
host.mountEditorReplacement(
new ExperimentsSelectorComponent({
features,
colors: host.state.theme.colors,
onApply: (changes) => {
void applyExperimentalFeatureChanges(host, changes);
},
@ -504,7 +644,6 @@ type UpdatePreferenceHost = {
SlashCommandHost['state']['appState'],
'theme' | 'editorCommand' | 'notifications' | 'upgrade'
>;
readonly theme: Pick<SlashCommandHost['state']['theme'], 'colors'>;
};
setAppState(patch: Pick<SlashCommandHost['state']['appState'], 'upgrade'>): void;
showStatus(msg: string, color?: string): void;
@ -523,15 +662,13 @@ export async function applyUpdatePreferenceChoice(
const upgrade = { autoInstall };
try {
await saveTuiConfig({
theme: host.state.appState.theme,
editorCommand: host.state.appState.editorCommand,
notifications: host.state.appState.notifications,
...currentTuiConfig(host as unknown as SlashCommandHost),
upgrade,
});
} catch (error) {
host.showStatus(
`Failed to save automatic update setting: ${formatErrorMessage(error)}`,
host.state.theme.colors.error,
'error',
);
return;
}
@ -562,7 +699,6 @@ async function applyPermissionChoice(host: SlashCommandHost, mode: PermissionMod
export function showSettingsSelector(host: SlashCommandHost): void {
host.mountEditorReplacement(
new SettingsSelectorComponent({
colors: host.state.theme.colors,
onSelect: (value) => {
handleSettingsSelection(host, value);
},

View file

@ -1,33 +1,31 @@
import type { Component, Focusable } from '@earendil-works/pi-tui';
import type { Component, Focusable } from '@moonshot-ai/pi-tui';
import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth';
import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk';
import type { Theme } from '../theme';
import type { ResolvedTheme } from '../theme/colors';
import {
LLM_NOT_SET_MESSAGE,
} from '../constant/kimi-tui';
import { formatErrorMessage } from '../utils/event-payload';
import { parseSlashInput } from './parse';
import {
resolveSlashCommandInput,
slashBusyMessage,
} from './resolve';
import type { BuiltinSlashCommandName } from './registry';
import type { ColorToken, ThemeName } from '#/tui/theme';
import { LLM_NOT_SET_MESSAGE } from '../constant/kimi-tui';
import type { AuthFlowController } from '../controllers/auth-flow';
import type { BtwPanelController } from '../controllers/btw-panel';
import type { StreamingUIController } from '../controllers/streaming-ui';
import type { TasksBrowserController } from '../controllers/tasks-browser';
import type { AppState, LoginProgressSpinnerHandle, QueuedMessage } from '../types';
import { tryHandleDanceCommand } from '../easter-eggs/dance';
import type { ResolvedTheme } from '../theme/colors';
import type { TUIState } from '../tui-state';
import type {
AppState,
LoginProgressSpinnerHandle,
QueuedMessage,
TranscriptEntry,
} from '../types';
import { formatErrorMessage } from '../utils/event-payload';
import { handleLoginCommand, handleLogoutCommand } from './auth';
import { handleBtwCommand } from './btw';
import { tryHandleDanceCommand } from '../easter-eggs/dance';
import {
handleAutoCommand,
handleCompactCommand,
handleEditorCommand,
handleEffortCommand,
handleModelCommand,
handlePlanCommand,
handleThemeCommand,
@ -38,10 +36,14 @@ import {
showSettingsSelector,
} from './config';
import { handleGoalCommand } from './goal';
import { handleProviderCommand } from './provider';
import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info';
import { handleAddDirCommand } from './add-dir';
import { parseSlashInput } from './parse';
import { handlePluginsCommand } from './plugins';
import { handleProviderCommand } from './provider';
import type { BuiltinSlashCommandName } from './registry';
import { handleReloadCommand, handleReloadTuiCommand } from './reload';
import { resolveSlashCommandInput, slashBusyMessage } from './resolve';
import {
handleExportDebugZipCommand,
handleExportMdCommand,
@ -49,21 +51,22 @@ import {
handleInitCommand,
handleTitleCommand,
} from './session';
import { handleSwarmCommand } from './swarm';
import { handleUndoCommand } from './undo';
import { handleWebCommand } from './web';
// ---------------------------------------------------------------------------
// Re-exports — keep existing consumers working
// ---------------------------------------------------------------------------
export {
handleLoginCommand,
handleLogoutCommand,
} from './auth';
export { handleLoginCommand, handleLogoutCommand } from './auth';
export { handleBtwCommand } from './btw';
export { handleAddDirCommand } from './add-dir';
export {
handleAutoCommand,
handleCompactCommand,
handleEditorCommand,
handleEffortCommand,
handleModelCommand,
handlePlanCommand,
handleThemeCommand,
@ -73,12 +76,8 @@ export {
showPermissionPicker,
showSettingsSelector,
} from './config';
export {
handleFeedbackCommand,
showMcpServers,
showStatusReport,
showUsage,
} from './info';
export { handleSwarmCommand } from './swarm';
export { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info';
export { handlePluginsCommand } from './plugins';
export { handleReloadCommand, handleReloadTuiCommand } from './reload';
export { handleGoalCommand } from './goal';
@ -90,6 +89,7 @@ export {
handleTitleCommand,
} from './session';
export { handleUndoCommand } from './undo';
export { handleWebCommand } from './web';
// ---------------------------------------------------------------------------
// Host interface
@ -105,8 +105,9 @@ export interface SlashCommandHost {
setAppState(patch: Partial<AppState>): void;
resetLivePane(): void;
showError(msg: string): void;
showStatus(msg: string, color?: string): void;
showStatus(msg: string, color?: ColorToken): void;
showNotice(title: string, detail?: string): void;
appendTranscriptEntry(entry: TranscriptEntry): void;
track(event: string, props?: Record<string, unknown>): void;
mountEditorReplacement(panel: Component & Focusable): void;
restoreEditor(): void;
@ -120,6 +121,7 @@ export interface SlashCommandHost {
beginSessionRequest(): void;
failSessionRequest(message: string): void;
sendQueuedMessage(session: Session, item: QueuedMessage): void;
requestQueuedGoalPromotion?(): void;
// UI
showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle;
@ -127,17 +129,25 @@ export interface SlashCommandHost {
showProgressSpinner(label: string): LoginProgressSpinnerHandle;
// Theme
applyTheme(theme: Theme, resolved?: ResolvedTheme): void;
applyTheme(theme: ThemeName, resolved?: ResolvedTheme): Promise<void>;
refreshTerminalThemeTracking(): void;
// Dispatch
stop(exitCode?: number): Promise<void>;
setExitOpenUrl(url: string): void;
showHelpPanel(): void;
createNewSession(): Promise<void>;
showSessionPicker(): Promise<void>;
sendNormalUserInput(text: string): void;
sendSkillActivation(session: Session, skillName: string, skillArgs: string): void;
activatePluginCommand(
session: Session,
pluginId: string,
commandName: string,
args: string,
): void;
readonly skillCommandMap: Map<string, string>;
readonly pluginCommandMap: Map<string, string>;
// Controller refs
readonly streamingUI: StreamingUIController;
@ -163,6 +173,7 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi
const intent = resolveSlashCommandInput({
input,
skillCommandMap: host.skillCommandMap,
pluginCommandMap: host.pluginCommandMap,
isStreaming: host.state.appState.streamingPhase !== 'idle',
isCompacting: host.state.appState.isCompacting,
});
@ -194,6 +205,20 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi
host.sendSkillActivation(session, intent.skillName, intent.args);
return;
}
case 'plugin-command': {
if (host.state.appState.model.trim().length === 0) {
host.showError(LLM_NOT_SET_MESSAGE);
return;
}
const session = host.session;
if (session === undefined) {
host.showError(LLM_NOT_SET_MESSAGE);
return;
}
host.track('input_command', { command: `${intent.pluginId}:${intent.commandName}` });
host.activatePluginCommand(session, intent.pluginId, intent.commandName, intent.args);
return;
}
case 'message':
// Unknown slash command: let /dance claim it before it falls through to
// the model as a normal message. This runs *after* builtin and skill
@ -248,6 +273,9 @@ async function handleBuiltInSlashCommand(
case 'plugins':
void handlePluginsCommand(host, args);
return;
case 'add-dir':
await handleAddDirCommand(host, args);
return;
case 'experiments':
await showExperimentsPanel(host);
return;
@ -264,7 +292,10 @@ async function handleBuiltInSlashCommand(
await handleThemeCommand(host, args);
return;
case 'model':
handleModelCommand(host, args);
await handleModelCommand(host, args);
return;
case 'effort':
await handleEffortCommand(host, args);
return;
case 'provider':
await handleProviderCommand(host);
@ -299,6 +330,9 @@ async function handleBuiltInSlashCommand(
case 'plan':
await handlePlanCommand(host, args);
return;
case 'swarm':
await handleSwarmCommand(host, args);
return;
case 'compact':
await handleCompactCommand(host, args);
return;
@ -326,6 +360,9 @@ async function handleBuiltInSlashCommand(
case 'undo':
await handleUndoCommand(host, args);
return;
case 'web':
await handleWebCommand(host);
return;
default:
host.showError(`Unknown slash command: /${String(name)}`);
return;

View file

@ -13,6 +13,7 @@ import {
import {
GoalSetMessageComponent,
GoalStatusMessageComponent,
UpcomingGoalAddedMessageComponent,
} from '../components/messages/goal-panel';
import { LLM_NOT_SET_MESSAGE } from '../constant/kimi-tui';
import {
@ -28,6 +29,7 @@ import type { SlashCommandHost } from './dispatch';
const MAX_GOAL_OBJECTIVE_LENGTH = 4000;
const RESUME_GOAL_INPUT = 'Resume the active goal.';
const START_NEXT_GOAL_NOW_MESSAGE = 'No active goal. Starting this goal now.';
type GoalCommandHost = Pick<
SlashCommandHost,
@ -175,14 +177,38 @@ async function queueNextGoal(
host: SlashCommandHost,
parsed: Extract<ParsedGoalCommand, { kind: 'next-add' }>,
): Promise<void> {
const session = host.requireSession();
let hasCurrentGoal: boolean;
try {
await appendGoalQueueItem(host.requireSession(), { objective: parsed.objective });
const { goal } = await session.getGoal();
hasCurrentGoal = goal !== null;
} catch (error) {
host.showError(`Failed to inspect current goal: ${formatErrorMessage(error)}`);
return;
}
if (!hasCurrentGoal && !isBusy(host)) {
host.showStatus(START_NEXT_GOAL_NOW_MESSAGE);
await createGoal(
host,
{ kind: 'create', objective: parsed.objective, replace: false },
`next ${parsed.objective}`,
);
return;
}
try {
await appendGoalQueueItem(session, { objective: parsed.objective });
} catch (error) {
host.showError(formatErrorMessage(error));
return;
}
host.track('goal_queue_append');
host.showStatus('Upcoming goal added. It will start after the current goal is complete.');
if (!hasCurrentGoal) host.requestQueuedGoalPromotion?.();
host.state.transcriptContainer.addChild(
new UpcomingGoalAddedMessageComponent(),
);
host.state.ui.requestRender();
}
async function showGoalQueueManager(
@ -202,7 +228,6 @@ async function showGoalQueueManager(
new GoalQueueManagerComponent({
goals: snapshot.goals,
selectedGoalId,
colors: host.state.theme.colors,
onAction: async (action) => {
try {
return await handleGoalQueueManagerAction(host, action);
@ -265,7 +290,6 @@ async function showGoalQueueEditDialog(
host.mountEditorReplacement(
new GoalQueueEditDialogComponent({
goal,
colors: host.state.theme.colors,
onDone: (result) => {
void handleGoalQueueEditResult(host, result).catch((error: unknown) => {
host.showError(`Failed to update upcoming goal: ${formatErrorMessage(error)}`);
@ -304,7 +328,10 @@ export async function createGoal(
return false;
}
if (host.state.appState.permissionMode === 'manual') {
if (
host.state.appState.permissionMode === 'manual' ||
host.state.appState.permissionMode === 'yolo'
) {
showGoalStartPermissionPrompt(host, parsed, rawArgs ?? parsed.objective, options);
return false;
}
@ -325,7 +352,7 @@ function showGoalStartPermissionPrompt(
};
host.mountEditorReplacement(
new GoalStartPermissionPromptComponent({
colors: host.state.theme.colors,
mode: host.state.appState.permissionMode === 'yolo' ? 'yolo' : 'manual',
onSelect: (choice) => {
if (choice === 'cancel') {
cancelStart();
@ -345,10 +372,19 @@ async function startGoalWithPermission(
choice: GoalStartPermissionChoice,
options: GoalStartOptions,
): Promise<void> {
if (choice === 'auto' || choice === 'yolo') {
const previousMode = host.state.appState.permissionMode;
const switched =
choice !== previousMode && (choice === 'auto' || choice === 'yolo');
if (switched) {
if (!(await setPermissionForGoal(host, choice))) return;
}
await startGoal(host, parsed, options);
const started = await startGoal(host, parsed, options);
// The permission switch only exists to run this goal. If creation fails
// (e.g. a goal already exists and `replace` was not given), restore the
// previous mode so the session is not left more permissive than before.
if (!started && switched) {
await setPermissionForGoal(host, previousMode);
}
}
async function setPermissionForGoal(host: GoalCommandHost, mode: PermissionMode): Promise<boolean> {
@ -385,8 +421,7 @@ async function startGoal(
if (options.beforeSend !== undefined && !(await options.beforeSend())) {
return false;
}
host.track('goal_create', { replace: parsed.replace });
host.state.transcriptContainer.addChild(new GoalSetMessageComponent(host.state.theme.colors));
host.state.transcriptContainer.addChild(new GoalSetMessageComponent());
host.state.ui.requestRender();
if (options.sendInput !== undefined) {
options.sendInput(parsed.objective);
@ -430,7 +465,6 @@ async function resumeGoal(host: SlashCommandHost): Promise<void> {
return;
}
host.track('goal_resume');
host.showStatus('Goal resumed.');
host.sendNormalUserInput(RESUME_GOAL_INPUT);
}
@ -448,7 +482,7 @@ async function cancelGoal(host: SlashCommandHost): Promise<void> {
return;
}
host.track('goal_cancel');
host.showStatus('Goal cancelled.');
host.showNotice('Goal cancelled.');
}
async function showGoalStatus(host: SlashCommandHost): Promise<void> {
@ -459,7 +493,7 @@ async function showGoalStatus(host: SlashCommandHost): Promise<void> {
return;
}
host.state.transcriptContainer.addChild(
new GoalStatusMessageComponent(goal, host.state.theme.colors),
new GoalStatusMessageComponent(goal),
);
host.state.ui.requestRender();
}
@ -467,3 +501,7 @@ async function showGoalStatus(host: SlashCommandHost): Promise<void> {
function isStreaming(host: SlashCommandHost): boolean {
return host.state.appState.streamingPhase !== 'idle';
}
function isBusy(host: SlashCommandHost): boolean {
return isStreaming(host) || host.state.appState.isCompacting;
}

View file

@ -3,13 +3,11 @@ export * from './parse';
export * from './registry';
export * from './resolve';
export * from './skills';
export * from './plugin-commands';
export * from './types';
export { dispatchInput, type SlashCommandHost } from './dispatch';
export {
handleLoginCommand,
handleLogoutCommand,
} from './auth';
export { handleLoginCommand, handleLogoutCommand } from './auth';
export { handleBtwCommand } from './btw';
export {
handleCompactCommand,
@ -23,22 +21,15 @@ export {
showPermissionPicker,
showSettingsSelector,
} from './config';
export {
handleFeedbackCommand,
showMcpServers,
showStatusReport,
showUsage,
} from './info';
export { handleSwarmCommand } from './swarm';
export { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info';
export { handlePluginsCommand } from './plugins';
export { handleReloadCommand, handleReloadTuiCommand } from './reload';
export { handleGoalCommand, parseGoalCommand } from './goal';
export { goalArgumentCompletions } from './registry';
export {
handleForkCommand,
handleInitCommand,
handleTitleCommand,
} from './session';
export { handleForkCommand, handleInitCommand, handleTitleCommand } from './session';
export { handleUndoCommand } from './undo';
export { handleWebCommand } from './web';
export {
promptApiKey,
promptCatalogProviderSelection,

View file

@ -9,17 +9,21 @@ import {
FEEDBACK_ISSUE_URL,
FEEDBACK_STATUS_CANCELLED,
FEEDBACK_STATUS_FALLBACK,
FEEDBACK_STATUS_NETWORK_ERROR,
FEEDBACK_STATUS_NOT_SIGNED_IN,
FEEDBACK_STATUS_SUBMITTING,
FEEDBACK_STATUS_SUCCESS,
FEEDBACK_STATUS_UPLOAD_FAILED,
FEEDBACK_TELEMETRY_EVENT,
feedbackIdLine,
feedbackSessionLine,
withFeedbackVersionPrefix,
} from '../constant/feedback';
import { isManagedUsageProvider } from '../constant/kimi-tui';
import { submitFeedbackWithAttachments } from '../../feedback/feedback-attachments';
import { formatErrorMessage } from '../utils/event-payload';
import { openUrl } from '#/utils/open-url';
import { promptFeedbackInput } from './prompts';
import { promptFeedbackAttachment, promptFeedbackInput } from './prompts';
import type { SlashCommandHost } from './dispatch';
// ---------------------------------------------------------------------------
@ -39,30 +43,60 @@ export async function handleFeedbackCommand(host: SlashCommandHost): Promise<voi
return;
}
const content = await promptFeedbackInput(host);
if (content === undefined) {
// Stage 1: collect the free-form feedback text.
const input = await promptFeedbackInput(host);
if (input === undefined) {
host.showStatus(FEEDBACK_STATUS_CANCELLED);
return;
}
const spinner = host.showLoginProgressSpinner(FEEDBACK_STATUS_SUBMITTING);
const res = await host.harness.auth.submitFeedback({
content,
sessionId: host.state.appState.sessionId,
version: withFeedbackVersionPrefix(host.state.appState.version),
os: `${osType()} ${osRelease()}`,
model: host.state.appState.model.length > 0 ? host.state.appState.model : null,
});
if (res.kind === 'ok') {
spinner.stop({ ok: true, label: FEEDBACK_STATUS_SUCCESS });
host.showStatus(feedbackSessionLine(host.state.appState.sessionId));
host.track(FEEDBACK_TELEMETRY_EVENT);
// Stage 2: ask whether to attach diagnostics (logs / codebase).
const level = await promptFeedbackAttachment(host);
if (level === undefined) {
host.showStatus(FEEDBACK_STATUS_CANCELLED);
return;
}
spinner.stop({ ok: false, label: res.message });
fallback(FEEDBACK_STATUS_FALLBACK);
const version = withFeedbackVersionPrefix(host.state.appState.version);
const spinner = host.showLoginProgressSpinner(FEEDBACK_STATUS_SUBMITTING);
// Guarantee the spinner's underlying setInterval is always cleared, even when
// submitFeedback or submitFeedbackWithAttachments throws — otherwise the
// interval (and its per-frame requestRender) leaks for the rest of the session.
let stopped = false;
const stopSpinner = (opts: { ok: boolean; label: string }): void => {
if (stopped) return;
stopped = true;
spinner.stop(opts);
};
try {
const res = await host.harness.auth.submitFeedback({
content: input.value,
sessionId: host.state.appState.sessionId,
version,
os: `${osType()} ${osRelease()}`,
model: host.state.appState.model.length > 0 ? host.state.appState.model : null,
});
if (res.kind !== 'ok') {
stopSpinner({ ok: false, label: res.message });
fallback(FEEDBACK_STATUS_FALLBACK);
return;
}
// Stage 3: prepare and upload each requested attachment independently.
const attachmentFailed = await submitFeedbackWithAttachments(host, res.feedbackId, level);
stopSpinner({ ok: true, label: FEEDBACK_STATUS_SUCCESS });
host.showStatus(feedbackSessionLine(host.state.appState.sessionId));
host.showStatus(feedbackIdLine(res.feedbackId));
host.track(FEEDBACK_TELEMETRY_EVENT);
if (attachmentFailed) {
host.showStatus(FEEDBACK_STATUS_UPLOAD_FAILED);
}
} catch (error) {
stopSpinner({ ok: false, label: FEEDBACK_STATUS_NETWORK_ERROR });
throw error;
}
}
// ---------------------------------------------------------------------------
@ -87,8 +121,7 @@ interface ManagedUsageResult {
export async function showUsage(host: SlashCommandHost): Promise<void> {
const sessionUsage = await loadSessionUsageReport(host);
const managedUsage = await loadManagedUsageReport(host);
const lines = buildUsageReportLines({
colors: host.state.theme.colors,
const reportArgs = {
sessionUsage: sessionUsage.usage,
sessionUsageError: sessionUsage.error,
contextUsage: host.state.appState.contextUsage,
@ -96,8 +129,8 @@ export async function showUsage(host: SlashCommandHost): Promise<void> {
maxContextTokens: host.state.appState.maxContextTokens,
managedUsage: managedUsage?.usage,
managedUsageError: managedUsage?.error,
});
const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary);
};
const panel = new UsagePanelComponent(() => buildUsageReportLines(reportArgs), 'primary');
host.state.transcriptContainer.addChild(panel);
host.state.ui.requestRender();
}
@ -108,14 +141,13 @@ export async function showStatusReport(host: SlashCommandHost): Promise<void> {
loadManagedUsageReport(host),
]);
const appState = host.state.appState;
const lines = buildStatusReportLines({
colors: host.state.theme.colors,
const reportArgs = {
version: appState.version,
model: appState.model,
workDir: appState.workDir,
sessionId: appState.sessionId,
sessionTitle: appState.sessionTitle,
thinking: appState.thinking,
thinkingEffort: appState.thinkingEffort,
permissionMode: appState.permissionMode,
planMode: appState.planMode,
contextUsage: appState.contextUsage,
@ -126,8 +158,8 @@ export async function showStatusReport(host: SlashCommandHost): Promise<void> {
statusError: runtimeStatus.error,
managedUsage: managedUsage?.usage,
managedUsageError: managedUsage?.error,
});
const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, ' Status ');
};
const panel = new UsagePanelComponent(() => buildStatusReportLines(reportArgs), 'primary', ' Status ');
host.state.transcriptContainer.addChild(panel);
host.state.ui.requestRender();
}
@ -141,12 +173,12 @@ export async function showMcpServers(host: SlashCommandHost): Promise<void> {
return;
}
const lines = buildMcpStatusReportLines({
colors: host.state.theme.colors,
servers,
});
const title = servers.length > 0 ? ` MCP (${servers.length}) ` : ' MCP ';
const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, title);
const panel = new UsagePanelComponent(
() => buildMcpStatusReportLines({ servers }),
'primary',
title,
);
host.state.transcriptContainer.addChild(panel);
host.state.ui.requestRender();
}
@ -181,5 +213,5 @@ async function loadManagedUsageReport(host: SlashCommandHost): Promise<ManagedUs
if (res.kind === 'error') {
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

@ -7,6 +7,8 @@ export function parseSlashInput(input: string): ParsedSlashInput | null {
const spaceIdx = trimmed.indexOf(' ');
const name = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx);
const args = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1).trim();
if (name.includes('/')) return null;
// Reject file paths (e.g. `/usr/local/bin`), but allow namespaced plugin
// commands whose name itself contains `/` (e.g. `plugin:frontend/component`).
if (name.includes('/') && !name.includes(':')) return null;
return { name, args };
}

View file

@ -0,0 +1,27 @@
import type { PluginCommandDef } from '@moonshot-ai/kimi-code-sdk';
import type { KimiSlashCommand } from './types';
export interface PluginSlashCommands {
readonly commands: readonly KimiSlashCommand[];
/** Maps a namespaced command name (`plugin:command`) to its markdown body. */
readonly commandMap: ReadonlyMap<string, string>;
}
export function pluginCommandName(pluginId: string, name: string): string {
return `${pluginId}:${name}`;
}
export function buildPluginSlashCommands(defs: readonly PluginCommandDef[]): PluginSlashCommands {
const commandMap = new Map<string, string>();
const commands = defs.map((def) => {
const commandName = pluginCommandName(def.pluginId, def.name);
commandMap.set(commandName, def.body);
return {
name: commandName,
aliases: [],
description: def.description,
} satisfies KimiSlashCommand;
});
return { commands, commandMap };
}

View file

@ -4,14 +4,15 @@ import { isAbsolute, join, resolve } from 'node:path';
import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk';
import {
PluginInstallTrustConfirmComponent,
PluginMcpSelectorComponent,
PluginMarketplaceSelectorComponent,
PluginRemoveConfirmComponent,
PluginsOverviewSelectorComponent,
PluginsPanelComponent,
type PluginInstallTrustConfirmResult,
type PluginMcpSelection,
type PluginMarketplaceSelection,
type PluginRemoveConfirmResult,
type PluginsOverviewSelection,
type PluginsPanelSelection,
type PluginsPanelTabId,
} from '../components/dialogs/plugins-selector';
import {
buildPluginsInfoLines,
@ -19,8 +20,9 @@ import {
} from '../components/messages/plugins-status-panel';
import { UsagePanelComponent } from '../components/messages/usage-panel';
import { formatErrorMessage } from '../utils/event-payload';
import { formatPluginSourceLabel } from '../utils/plugin-source-label';
import { formatPluginSourceLabel, isOfficialPluginSource } from '../utils/plugin-source-label';
import { loadPluginMarketplace } from '#/utils/plugin-marketplace';
import { openUrl } from '#/utils/open-url';
import type { SlashCommandHost } from './dispatch';
interface ShowPluginsPickerOptions {
@ -29,6 +31,8 @@ interface ShowPluginsPickerOptions {
readonly id: string;
readonly text: string;
};
readonly initialTab?: PluginsPanelTabId;
readonly marketplaceSource?: string;
}
interface PluginMcpServerHint {
@ -62,6 +66,10 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri
host.showError('Usage: /plugins install <local-path-or-zip-url>');
return;
}
if (!(await confirmInstallTrust(host, source, isOfficialPluginSource(source)))) {
host.showStatus('Install cancelled.');
return;
}
const spinner = host.showProgressSpinner(`Installing plugin from ${truncateForStatus(source)}`);
try {
await installPluginFromSource(host, source);
@ -73,7 +81,15 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri
return;
}
if (sub === 'marketplace') {
await showPluginMarketplacePicker(host, rest.join(' ').trim() || undefined);
const marketplaceSource = rest.join(' ').trim() || undefined;
await showPluginsPicker(host, {
// Custom marketplaces often omit `tier`, so their entries land on the
// Third-party tab (entry.tier !== 'official'). Open there when a custom
// source is supplied; otherwise the default catalog's official entries
// make Official the right landing tab.
initialTab: marketplaceSource === undefined ? 'official' : 'third-party',
marketplaceSource,
});
return;
}
if (sub === 'info') {
@ -95,7 +111,7 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri
}
await session.setPluginMcpServerEnabled(id, server, action === 'enable');
host.showStatus(
`${action === 'enable' ? 'Enabled' : 'Disabled'} MCP server ${server} for ${id}. Run /new to apply.`,
`${action === 'enable' ? 'Enabled' : 'Disabled'} MCP server ${server} for ${id}. Run /reload or /new to apply.`,
);
return;
}
@ -118,8 +134,7 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri
host.showStatus(`Remove cancelled: ${id}.`);
return;
}
await session.removePlugin(id);
host.showStatus(`Removed ${id} (plugin files left in place).`);
await removePlugin(host, id);
return;
}
if (sub === 'reload') {
@ -149,55 +164,58 @@ async function showPluginsPicker(
return;
}
host.mountEditorReplacement(
new PluginsOverviewSelectorComponent({
plugins,
selectedId: options?.selectedId,
pluginHint: options?.pluginHint,
colors: host.state.theme.colors,
onSelect: (selection) => {
// Each branch of the handler either mounts the next view or restores
// the editor itself, so do not pre-restore here — that would flash the
// editor for in-place actions like toggling a plugin.
void handlePluginsOverviewSelection(host, selection).catch((error: unknown) => {
host.showError(`/plugins failed: ${formatErrorMessage(error)}`);
});
},
onCancel: () => {
host.restoreEditor();
},
}),
);
const panel = new PluginsPanelComponent({
installed: plugins,
installedIds: new Set(plugins.map((plugin) => plugin.id)),
initialTab: options?.initialTab,
selectedId: options?.selectedId,
pluginHint: options?.pluginHint,
onSelect: (selection) => {
// Each branch of the handler either mounts the next view or restores the
// editor itself, so do not pre-restore here — that would flash the editor
// for in-place actions like toggling a plugin.
void handlePluginsPanelSelection(host, panel, selection).catch((error: unknown) => {
host.showError(`/plugins failed: ${formatErrorMessage(error)}`);
});
},
onCancel: () => {
host.restoreEditor();
},
// Every tab except Custom needs the catalog: Official/Third-party list it,
// and Installed uses it to show update badges. The Installed/Custom tabs
// keep working even when the marketplace is unreachable (badges simply stay
// hidden until data arrives).
onRequestMarketplace: () => {
void loadMarketplaceCatalog(host, panel, options?.marketplaceSource);
},
});
host.mountEditorReplacement(panel);
// Kick off the catalog fetch for any tab that needs it: Installed uses it for
// update badges, Official/Third-party list it. Custom never reads the catalog,
// so skip the fetch there. Done here (after `panel` is initialized) rather
// than inside the component constructor, because the callback above closes
// over `panel`.
if (options?.initialTab !== 'custom') {
panel.setMarketplaceLoading();
void loadMarketplaceCatalog(host, panel, options?.marketplaceSource);
}
}
async function showPluginMarketplacePicker(host: SlashCommandHost, source?: string): Promise<void> {
async function loadMarketplaceCatalog(
host: SlashCommandHost,
panel: PluginsPanelComponent,
source?: string,
): Promise<void> {
try {
const [marketplace, installed] = await Promise.all([
loadPluginMarketplace({ workDir: host.state.appState.workDir, source }),
host.requireSession().listPlugins(),
]);
host.mountEditorReplacement(
new PluginMarketplaceSelectorComponent({
entries: marketplace.plugins,
installedIds: new Set(installed.map((plugin) => plugin.id)),
source: marketplace.source,
colors: host.state.theme.colors,
onSelect: (selection) => {
// Every marketplace action re-mounts a picker, so let the handler do
// the mounting — pre-restoring the editor here would flash.
void handlePluginMarketplaceSelection(host, selection).catch((error: unknown) => {
host.showError(`/plugins marketplace failed: ${formatErrorMessage(error)}`);
});
},
onCancel: () => {
host.restoreEditor();
void showPluginsPicker(host);
},
}),
);
const marketplace = await loadPluginMarketplace({
workDir: host.state.appState.workDir,
source,
});
panel.setMarketplace(marketplace.plugins, marketplace.source);
} catch (error) {
host.showError(`Failed to load plugin marketplace: ${formatErrorMessage(error)}`);
panel.setMarketplaceError(formatErrorMessage(error));
}
host.state.ui.requestRender();
}
async function showPluginMcpPicker(
@ -218,7 +236,6 @@ async function showPluginMcpPicker(
info,
selectedServer: options?.selectedServer,
serverHint: options?.serverHint,
colors: host.state.theme.colors,
onSelect: (selection) => {
// Every MCP action re-mounts a picker, so let the handler do the
// mounting — pre-restoring the editor here would flash on toggle.
@ -247,7 +264,6 @@ async function confirmRemovePlugin(host: SlashCommandHost, id: string): Promise<
new PluginRemoveConfirmComponent({
id,
displayName,
colors: host.state.theme.colors,
onDone: (result: PluginRemoveConfirmResult) => {
host.restoreEditor();
resolveConfirmed(result.kind === 'confirm');
@ -257,6 +273,67 @@ async function confirmRemovePlugin(host: SlashCommandHost, id: string): Promise<
});
}
async function confirmInstallTrust(
host: SlashCommandHost,
label: string,
official: boolean,
): Promise<boolean> {
// Kimi-built official plugins are trusted implicitly; anything else requires
// the user to explicitly opt in via the trust prompt.
if (official) return true;
return new Promise((resolveConfirmed) => {
host.mountEditorReplacement(
new PluginInstallTrustConfirmComponent({
label,
onDone: (result: PluginInstallTrustConfirmResult) => {
host.restoreEditor();
resolveConfirmed(result.kind === 'confirm');
},
}),
);
});
}
async function installFromPanel(
host: SlashCommandHost,
panel: PluginsPanelComponent,
source: string,
label: string,
official: boolean,
): Promise<void> {
if (!(await confirmInstallTrust(host, label, official))) {
host.showStatus(`Install cancelled: ${label}.`);
host.restoreEditor();
return;
}
// Official installs keep the panel mounted and show the inline installing
// state; third-party installs pass through a trust prompt that replaces the
// panel, so fall back to a transcript status for those.
if (official) {
panel.setInstalling(truncateForStatus(label));
} else {
host.showStatus(`Installing or updating ${label} from marketplace...`);
}
host.state.ui.requestRender();
try {
await installPluginFromSource(host, source);
} catch (error) {
if (official) {
panel.clearInstalling();
host.state.ui.requestRender();
} else {
// The trust prompt replaced the panel; re-mount it so the user can retry
// instead of being dropped back at the editor.
host.mountEditorReplacement(panel);
}
host.showError(`Failed to install ${label}: ${formatErrorMessage(error)}`);
return;
}
// Close the panel after installing so the result status and the
// "/reload or /new" tip are visible in the transcript.
host.restoreEditor();
}
async function applyPluginEnabled(
host: SlashCommandHost,
id: string,
@ -276,54 +353,71 @@ async function applyPluginEnabled(
? ` Some MCP servers are disabled; re-enable with /plugins mcp enable ${id} <server>.`
: '';
if (showStatus) {
host.showStatus(`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run /new to apply.${mcpHint}`);
host.showStatus(`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run /reload or /new to apply.${mcpHint}`);
}
const inlineMcpHint = mcpHint.length > 0 ? ' · MCP servers disabled' : '';
return `${pluginInlineChangeHint()}${inlineMcpHint}`;
}
async function handlePluginsOverviewSelection(
async function handlePluginsPanelSelection(
host: SlashCommandHost,
selection: PluginsOverviewSelection,
panel: PluginsPanelComponent,
selection: PluginsPanelSelection,
): Promise<void> {
const session = host.requireSession();
switch (selection.kind) {
case 'marketplace':
await showPluginMarketplacePicker(host);
return;
case 'reload':
await reloadPlugins(host);
await showPluginsPicker(host);
return;
case 'show-list':
host.restoreEditor();
await renderPluginsList(host);
return;
case 'toggle': {
const hint = await applyPluginEnabled(host, selection.id, selection.enabled, false);
await showPluginsPicker(host, {
initialTab: 'installed',
selectedId: selection.id,
pluginHint: { id: selection.id, text: hint },
});
return;
}
case 'mcp':
await showPluginMcpPicker(host, selection.id);
return;
case 'remove':
if (!(await confirmRemovePlugin(host, selection.id))) {
host.showStatus(`Remove cancelled: ${selection.id}.`);
await showPluginsPicker(host, { selectedId: selection.id });
await showPluginsPicker(host, { initialTab: 'installed', selectedId: selection.id });
return;
}
await session.removePlugin(selection.id);
host.showStatus(`Removed ${selection.id} (plugin files left in place).`);
await showPluginsPicker(host);
await removePlugin(host, selection.id);
await showPluginsPicker(host, { initialTab: 'installed' });
return;
case 'info':
case 'mcp':
await showPluginMcpPicker(host, selection.id);
return;
case 'details':
host.restoreEditor();
await renderPluginInfo(host, selection.id);
return;
case 'reload':
await reloadPlugins(host);
await showPluginsPicker(host, { initialTab: 'installed' });
return;
case 'install':
await installFromPanel(
host,
panel,
selection.entry.source,
selection.entry.displayName,
isOfficialPluginSource(selection.entry.source),
);
return;
case 'install-source':
await installFromPanel(
host,
panel,
selection.source,
selection.source,
isOfficialPluginSource(selection.source),
);
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;
}
}
@ -352,22 +446,10 @@ async function handlePluginMcpSelection(
}
}
async function handlePluginMarketplaceSelection(
host: SlashCommandHost,
selection: PluginMarketplaceSelection,
): Promise<void> {
switch (selection.kind) {
case 'install':
host.showStatus(`Installing or updating ${selection.entry.displayName} from marketplace...`);
await installPluginFromSource(host, selection.entry.source, {
successNotice: 'marketplace',
});
await showPluginsPicker(host, { selectedId: selection.entry.id });
return;
case 'back':
await showPluginsPicker(host);
return;
}
async function removePlugin(host: SlashCommandHost, id: string): Promise<void> {
await host.requireSession().removePlugin(id);
host.showStatus(`Removed ${id}.`);
host.showStatus(PLUGIN_RELOAD_HINT, 'warning');
}
async function renderPluginsList(
@ -375,20 +457,23 @@ async function renderPluginsList(
plugins?: readonly PluginSummary[],
): Promise<void> {
const currentPlugins = plugins ?? (await host.requireSession().listPlugins());
const lines = buildPluginsListLines({
colors: host.state.theme.colors,
plugins: currentPlugins,
});
const title = ` Plugins (${currentPlugins.length}) `;
const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, title);
const panel = new UsagePanelComponent(
() => buildPluginsListLines({ plugins: currentPlugins }),
'primary',
title,
);
host.state.transcriptContainer.addChild(panel);
host.state.ui.requestRender();
}
async function renderPluginInfo(host: SlashCommandHost, id: string): Promise<void> {
const info = await host.requireSession().getPluginInfo(id);
const lines = buildPluginsInfoLines({ colors: host.state.theme.colors, info });
const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, ` ${info.id} `);
const panel = new UsagePanelComponent(
() => buildPluginsInfoLines({ info }),
'primary',
` ${info.id} `,
);
host.state.transcriptContainer.addChild(panel);
host.state.ui.requestRender();
}
@ -396,25 +481,21 @@ async function renderPluginInfo(host: SlashCommandHost, id: string): Promise<voi
async function installPluginFromSource(
host: SlashCommandHost,
source: string,
options?: {
readonly successNotice?: 'marketplace';
},
): Promise<void> {
const session = host.requireSession();
const beforeList = await session.listPlugins();
const summary = await session.installPlugin(
resolvePluginInstallSource(source, host.state.appState.workDir),
);
showPluginInstallResult(host, beforeList, summary, options);
showPluginInstallResult(host, beforeList, summary);
}
const PLUGIN_RELOAD_HINT = 'Run /new or /reload to apply plugin changes.';
function showPluginInstallResult(
host: SlashCommandHost,
beforeList: readonly PluginSummary[],
summary: PluginSummary,
options?: {
readonly successNotice?: 'marketplace';
},
): void {
const previous = beforeList.find((entry) => entry.id === summary.id);
const serverWord = summary.mcpServerCount === 1 ? 'server' : 'servers';
@ -423,15 +504,8 @@ function showPluginInstallResult(
? ` Declares ${summary.mcpServerCount} MCP ${serverWord}; enabled by default and configurable from /plugins.`
: '';
const action = describeInstallAction(previous, summary);
host.showStatus(
`${action} (${summary.id}).${mcpHint} Run /new to apply plugin changes.`,
);
if (options?.successNotice === 'marketplace') {
host.showNotice(
`Installed or updated ${summary.displayName}`,
`Marketplace install or update succeeded for ${summary.id}. Run /new to apply plugin changes.`,
);
}
host.showStatus(`${action} (${summary.id}).${mcpHint}`);
host.showStatus(PLUGIN_RELOAD_HINT, 'warning');
}
function describeInstallAction(
@ -444,13 +518,19 @@ function describeInstallAction(
return ` ${prev}${cur ?? '-'}`;
};
if (previous === undefined) {
return `Installed ${next.displayName}${versionFromTo(undefined, next.version)} from ${sourceLabel}`;
return `Installed ${next.displayName}${versionFromTo(undefined, next.version)} ${sourcePhrase(sourceLabel)}`;
}
if (sourceIdentity(previous) !== sourceIdentity(next)) {
const prevSourceLabel = formatPluginSourceLabel(previous);
return `Migrated ${next.displayName}: ${prevSourceLabel}${sourceLabel}${versionFromTo(previous.version, next.version)}`;
}
return `Updated ${next.displayName}${versionFromTo(previous.version, next.version)} from ${sourceLabel}`;
return `Updated ${next.displayName}${versionFromTo(previous.version, next.version)} ${sourcePhrase(sourceLabel)}`;
}
// formatPluginSourceLabel already prefixes zip-url hosts with "via", so adding
// "from" would read as "from via <host>". Only prepend "from" otherwise.
function sourcePhrase(sourceLabel: string): string {
return sourceLabel.startsWith('via ') ? sourceLabel : `from ${sourceLabel}`;
}
function sourceIdentity(plugin: PluginSummary): string {
@ -481,5 +561,5 @@ function resolvePluginInstallSource(source: string, workDir: string): string {
}
function pluginInlineChangeHint(): string {
return 'require run /new to apply';
return 'run /reload or /new to apply';
}

View file

@ -4,6 +4,7 @@ import {
type Catalog,
type CatalogModel,
type ModelAlias,
type ThinkingEffort,
} from '@moonshot-ai/kimi-code-sdk';
import { capabilitiesForModel } from '@moonshot-ai/kimi-code-oauth';
import type {
@ -21,7 +22,6 @@ import type { SlashCommandHost } from './dispatch';
export function promptPlatformSelection(host: SlashCommandHost): Promise<string | undefined> {
return new Promise((resolve) => {
const selector = new PlatformSelectorComponent({
colors: host.state.theme.colors,
onSelect: (platformId) => {
host.restoreEditor();
resolve(platformId);
@ -45,7 +45,6 @@ export function promptLogoutProviderSelection(
title: 'Select a provider to log out',
options,
currentValue,
colors: host.state.theme.colors,
onSelect: (value) => {
host.restoreEditor();
resolve(value);
@ -59,16 +58,58 @@ export function promptLogoutProviderSelection(
});
}
export function promptFeedbackInput(host: SlashCommandHost): Promise<string | undefined> {
export interface FeedbackPromptResult {
readonly value: string;
}
export function promptFeedbackInput(host: SlashCommandHost): Promise<FeedbackPromptResult | undefined> {
return new Promise((resolve) => {
const dialog = new FeedbackInputDialogComponent((result: FeedbackInputDialogResult) => {
host.restoreEditor();
resolve(result.kind === 'ok' ? result.value : undefined);
}, host.state.theme.colors);
resolve(result.kind === 'ok' ? { value: result.value } : undefined);
});
host.mountEditorReplacement(dialog);
});
}
export type FeedbackAttachmentLevel = 'none' | 'logs' | 'logs+codebase';
const FEEDBACK_ATTACHMENT_OPTIONS: readonly ChoiceOption[] = [
{ value: 'none', label: 'No attachment', description: 'Text feedback only' },
{
value: 'logs',
label: 'Logs only',
description: 'Upload wire events and diagnostic logs from this session',
},
{
value: 'logs+codebase',
label: 'Logs + codebase',
description:
'Include your codebase for deeper diagnosis. Sensitive files are automatically excluded — e.g. .env, config files, secret keys. We use attachments only for diagnosis and never share them.',
descriptionTone: 'warning',
},
];
export function promptFeedbackAttachment(
host: SlashCommandHost,
): Promise<FeedbackAttachmentLevel | undefined> {
return new Promise((resolve) => {
const picker = new ChoicePickerComponent({
title: 'Share diagnostic info to help us investigate?',
options: FEEDBACK_ATTACHMENT_OPTIONS,
onSelect: (value) => {
host.restoreEditor();
resolve(value as FeedbackAttachmentLevel);
},
onCancel: () => {
host.restoreEditor();
resolve(undefined);
},
});
host.mountEditorReplacement(picker);
});
}
export function promptApiKey(
host: SlashCommandHost,
platformName: string,
@ -82,7 +123,6 @@ export function promptApiKey(
host.restoreEditor();
resolve(result.kind === 'ok' ? result.value : undefined);
},
host.state.theme.colors,
);
host.mountEditorReplacement(dialog);
});
@ -109,7 +149,6 @@ export function promptCatalogProviderSelection(host: SlashCommandHost, catalog:
const picker = new ChoicePickerComponent({
title: 'Select a provider',
options,
colors: host.state.theme.colors,
searchable: true,
onSelect: (value) => {
host.restoreEditor();
@ -128,7 +167,7 @@ export async function promptModelSelectionForOpenPlatform(
host: SlashCommandHost,
models: ManagedKimiCodeModelInfo[],
platform: OpenPlatformDefinition,
): Promise<{ model: ManagedKimiCodeModelInfo; thinking: boolean } | undefined> {
): Promise<{ model: ManagedKimiCodeModelInfo; thinking: ThinkingEffort } | undefined> {
const modelDict: Record<string, ModelAlias> = {};
for (const m of models) {
modelDict[`${platform.id}/${m.id}`] = {
@ -149,7 +188,7 @@ export async function promptModelSelectionForCatalog(
host: SlashCommandHost,
providerId: string,
models: CatalogModel[],
): Promise<{ model: CatalogModel; thinking: boolean } | undefined> {
): Promise<{ model: CatalogModel; thinking: ThinkingEffort } | undefined> {
const modelDict: Record<string, ModelAlias> = {};
for (const m of models) {
modelDict[`${providerId}/${m.id}`] = catalogModelToAlias(providerId, m);
@ -163,7 +202,7 @@ export async function promptModelSelectionForCatalog(
export function runModelSelector(
host: SlashCommandHost,
modelDict: Record<string, ModelAlias>,
): Promise<{ alias: string; thinking: boolean } | undefined> {
): Promise<{ alias: string; thinking: ThinkingEffort } | undefined> {
return new Promise((resolve) => {
const firstAlias = Object.keys(modelDict)[0] ?? '';
const caps = modelDict[firstAlias]?.capabilities ?? [];
@ -171,8 +210,7 @@ export function runModelSelector(
const selector = new ModelSelectorComponent({
models: modelDict,
currentValue: firstAlias,
currentThinking: initialThinking,
colors: host.state.theme.colors,
currentThinkingEffort: initialThinking ? 'on' : 'off',
searchable: true,
onSelect: ({ alias, thinking }) => {
host.restoreEditor();

Some files were not shown because too many files have changed in this diff Show more