mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 17:59:24 +00:00
Compare commits
No commits in common. "main" and "@moonshot-ai/kimi-code@0.12.0" have entirely different histories.
main
...
@moonshot-
1775 changed files with 13029 additions and 257085 deletions
|
|
@ -11,23 +11,20 @@ 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 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.
|
||||
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`.
|
||||
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 changed packages and check whether each one is ignored by `.changeset/config.json`.
|
||||
1. List the packages that were actually changed.
|
||||
2. Choose a bump level for each package.
|
||||
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.
|
||||
3. If an internal package change enters the CLI bundle, add `@moonshot-ai/kimi-code`.
|
||||
4. Create a short kebab-case file under `.changeset/`.
|
||||
5. Split unrelated changes into separate changesets; keep one logical change in one file.
|
||||
|
||||
|
|
@ -46,12 +43,10 @@ Format:
|
|||
|
||||
| Level | When to use |
|
||||
|---|---|
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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.
|
||||
|
|
@ -61,72 +56,31 @@ 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 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...`
|
||||
- **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.
|
||||
- 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
|
||||
---
|
||||
|
||||
|
|
@ -143,83 +97,12 @@ 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).
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -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, then open a PR on a dedicated branch.
|
||||
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.
|
||||
---
|
||||
|
||||
# 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, translate the English increment into Chinese, wait for an optional human review, then commit on a dedicated branch and open a PR.
|
||||
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.
|
||||
|
||||
## When To Use
|
||||
|
||||
|
|
@ -41,65 +41,39 @@ 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. Prepare Branch
|
||||
|
||||
Start from an up-to-date default branch:
|
||||
### 1. Find The Version Range
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
```
|
||||
# Upstream versions
|
||||
rg '^## ' apps/kimi-code/CHANGELOG.md | head -20
|
||||
|
||||
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
|
||||
# Latest version already synced into the English docs page
|
||||
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.
|
||||
|
||||
### 3. Strip Decorations And Extract Entry Text
|
||||
### 2. Strip Decorations And Extract Entry Text
|
||||
|
||||
Upstream entries look like this:
|
||||
|
||||
```markdown
|
||||
- [#317](https://github.com/...) [`2f51db4`](https://github.com/...) Thanks [@user](https://github.com/...)! - Clean up lint warnings ...
|
||||
- [#317](https://github.com/...) [`2f51db4`](https://github.com/...) - Clean up lint warnings ...
|
||||
```
|
||||
|
||||
Changesets may add a `Thanks ...!` credit, but it must be removed every time. Keep:
|
||||
Keep:
|
||||
|
||||
- Version headings such as `## 0.2.0`.
|
||||
- Only the body text of each entry, after the PR/hash decoration and any `Thanks ...!` credit have been removed.
|
||||
- Only the body text of each entry, after the PR/hash decoration.
|
||||
|
||||
Remove:
|
||||
|
||||
|
|
@ -107,49 +81,26 @@ 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 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:
|
||||
After stripping, each entry should be only:
|
||||
|
||||
```markdown
|
||||
- web: <body text>
|
||||
- <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.
|
||||
|
||||
### 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.
|
||||
### 3. Classify Entries
|
||||
|
||||
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 |
|
||||
| `### 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 |
|
||||
| `### Polish` | `### 优化` | User-visible improvements to existing functionality, including UX adjustments, behavior tweaks, and performance improvements that are not fixes or new capabilities |
|
||||
| `### 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 |
|
||||
|
||||
|
|
@ -163,8 +114,6 @@ 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`
|
||||
|
|
@ -176,19 +125,18 @@ Keyword hints:
|
|||
Within each version, section order is:
|
||||
|
||||
```text
|
||||
Features → Polish → Bug Fixes → Refactors → Other
|
||||
Features → Bug Fixes → Polish → 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. 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.
|
||||
3. 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.
|
||||
|
||||
### 5. Write The English Page
|
||||
### 4. Write The English Page
|
||||
|
||||
Never change the English page header:
|
||||
|
||||
|
|
@ -229,7 +177,7 @@ Example:
|
|||
- Update the native release workflow to use current GitHub artifact actions.
|
||||
```
|
||||
|
||||
### 6. Translate The Increment Into Chinese
|
||||
### 5. Translate The Increment Into Chinese
|
||||
|
||||
After updating the English page, translate only the newly added English content into `docs/zh/release-notes/changelog.md`.
|
||||
|
||||
|
|
@ -257,54 +205,7 @@ 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.
|
||||
|
||||
#### 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
|
||||
### 6. Verify
|
||||
|
||||
Review:
|
||||
|
||||
|
|
@ -320,7 +221,6 @@ Check:
|
|||
- 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.
|
||||
|
|
@ -331,36 +231,7 @@ Then run the docs build:
|
|||
pnpm --filter docs run build
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
### 7. Commit
|
||||
|
||||
Use a neutral docs-sync commit message:
|
||||
|
||||
|
|
@ -370,66 +241,12 @@ 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
|
||||
|
||||
|
|
@ -437,10 +254,6 @@ Return the PR URL to the user when done.
|
|||
|---|---|
|
||||
| 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 |
|
||||
|
|
@ -455,11 +268,8 @@ Return the PR URL to the user when done.
|
|||
| 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
|
||||
|
||||
|
|
@ -469,5 +279,3 @@ Return the PR URL to the user when done.
|
|||
- 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.
|
||||
|
|
|
|||
|
|
@ -68,8 +68,6 @@ 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`.
|
||||
|
|
|
|||
|
|
@ -15,17 +15,11 @@ 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/kimi-web`
|
||||
- `@moonshot-ai/kaos`
|
||||
- `@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`
|
||||
|
|
@ -45,7 +39,6 @@ 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)
|
||||
|
||||
|
|
@ -145,7 +138,6 @@ 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`.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"changelog": ["@changesets/changelog-github", { "repo": "MoonshotAI/kimi-code" }],
|
||||
"changelog": ["@changesets/changelog-github", { "repo": "MoonshotAI/kimi-code", "disableThanks": true }],
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"linked": [],
|
||||
|
|
@ -7,7 +7,6 @@
|
|||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": [
|
||||
"@moonshot-ai/server-e2e",
|
||||
"@moonshot-ai/vis",
|
||||
"@moonshot-ai/vis-server",
|
||||
"@moonshot-ai/vis-web"
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
---
|
||||
"@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`.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Update the WebBridge install page link opened from the /plugins panel.
|
||||
10
.gitattributes
vendored
10
.gitattributes
vendored
|
|
@ -1,10 +0,0 @@
|
|||
# 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
|
||||
7
.github/workflows/_native-build.yml
vendored
7
.github/workflows/_native-build.yml
vendored
|
|
@ -85,13 +85,6 @@ 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
|
||||
|
|
|
|||
45
.github/workflows/ci.yml
vendored
45
.github/workflows/ci.yml
vendored
|
|
@ -44,45 +44,6 @@ 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
|
||||
|
||||
|
|
@ -121,9 +82,3 @@ 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
170
.github/workflows/desktop-build.yml
vendored
|
|
@ -1,170 +0,0 @@
|
|||
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
|
||||
3
.github/workflows/pkg-pr-new.yml
vendored
3
.github/workflows/pkg-pr-new.yml
vendored
|
|
@ -36,9 +36,6 @@ 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: |
|
||||
|
|
|
|||
18
.github/workflows/release.yml
vendored
18
.github/workflows/release.yml
vendored
|
|
@ -37,7 +37,7 @@ jobs:
|
|||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Upgrade npm for Trusted Publishing
|
||||
run: npm install -g npm@11
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
|
@ -97,22 +97,6 @@ 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
27
.gitignore
vendored
|
|
@ -1,7 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
dist-web/
|
||||
dist-single/
|
||||
dist-native/
|
||||
.tmp-api-extractor/
|
||||
coverage/
|
||||
|
|
@ -13,27 +11,4 @@ coverage/
|
|||
.conductor
|
||||
.kimi-stash-dir
|
||||
plugins/cdn/
|
||||
.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
|
||||
superpowers
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@
|
|||
"node_modules/",
|
||||
"apps/*/scripts/",
|
||||
"docs/smoke-archive/",
|
||||
"packages/pi-tui/",
|
||||
"plugins/curated/superpowers/",
|
||||
"*.generated.ts"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
13
AGENTS.md
13
AGENTS.md
|
|
@ -15,16 +15,13 @@ 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, the in-process DI service layer (`src/services/`), and other core capabilities.
|
||||
- `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, 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
|
||||
|
||||
|
|
@ -35,11 +32,9 @@ 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` — 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.
|
||||
- **Whenever you add or remove a workspace package, you MUST update both `pnpm-workspace.yaml` and `flake.nix`.**
|
||||
- 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
|
||||
|
||||
|
|
@ -77,7 +72,3 @@ 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 +0,0 @@
|
|||
AGENTS.md
|
||||
9
apps/kimi-code/.gitignore
vendored
9
apps/kimi-code/.gitignore
vendored
|
|
@ -1,11 +1,2 @@
|
|||
# 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/
|
||||
|
|
|
|||
|
|
@ -1,921 +1,5 @@
|
|||
# @moonshot-ai/kimi-code
|
||||
|
||||
## 0.23.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1542](https://github.com/MoonshotAI/kimi-code/pull/1542) [`f80b2ea`](https://github.com/MoonshotAI/kimi-code/commit/f80b2eaf04925ce920f693fc8d4d81cb00e825d7) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the "Turn finished" desktop notification and completion sound firing twice per turn.
|
||||
|
||||
- [#1535](https://github.com/MoonshotAI/kimi-code/pull/1535) [`04041eb`](https://github.com/MoonshotAI/kimi-code/commit/04041eb998b6798898fa5df97f7587b3aa119b27) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Hide the internal image-compression note so it no longer renders as user message text.
|
||||
|
||||
- [#1536](https://github.com/MoonshotAI/kimi-code/pull/1536) [`db61c9e`](https://github.com/MoonshotAI/kimi-code/commit/db61c9e2dddcb6c35b019ef7f374385248ece881) Thanks [@RealKai42](https://github.com/RealKai42)! - Stop unsupported image formats (AVIF, BMP, TIFF, ICO, …) from breaking sessions at every entry point — including remote image URLs and images mislabeled by a tool — and recover an already-stuck session by dropping the offending image and retrying, so one such image can no longer make every later request fail.
|
||||
|
||||
- [#1530](https://github.com/MoonshotAI/kimi-code/pull/1530) [`9f66ec4`](https://github.com/MoonshotAI/kimi-code/commit/9f66ec416cc658842dc1414b79b6447d1b4cc7f9) Thanks [@sailist](https://github.com/sailist)! - Retry provider 429, overload, and other transient errors more reliably, honoring the server Retry-After delay, and surface retries in `-p --output-format stream-json`.
|
||||
|
||||
## 0.23.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1501](https://github.com/MoonshotAI/kimi-code/pull/1501) [`b91099e`](https://github.com/MoonshotAI/kimi-code/commit/b91099ed7a2590d1afa4d6e3675671da52b7661c) Thanks [@liruifengv](https://github.com/liruifengv)! - Display Extra Usage (fuel pack) balance in `/usage` and `/status` commands.
|
||||
|
||||
- [#1517](https://github.com/MoonshotAI/kimi-code/pull/1517) [`173bdfd`](https://github.com/MoonshotAI/kimi-code/commit/173bdfdab1f484ed79927aeaac7dc8116d3fd346) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix resuming sessions whose original working directory no longer exists.
|
||||
|
||||
- [#1516](https://github.com/MoonshotAI/kimi-code/pull/1516) [`9fb1915`](https://github.com/MoonshotAI/kimi-code/commit/9fb19154accf6b6f7abfbf7a9820ccda517bc87e) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix prompt-mode goals so they run until completion and report invalid goal commands before sending prompts.
|
||||
|
||||
- [#1508](https://github.com/MoonshotAI/kimi-code/pull/1508) [`1bf2c9a`](https://github.com/MoonshotAI/kimi-code/commit/1bf2c9afee4643fbf6755f0b92fd60aa14240501) Thanks [@RealKai42](https://github.com/RealKai42)! - Keep image-heavy sessions within provider request-size limits: model-read images now honor a 256 KB per-image budget and a 2000px downscale cap (configurable via `[image]` in config.toml or `KIMI_IMAGE_*` env vars), oversized WebP is compressed as well, HEIC/HEIF reads are refused with a platform-matched conversion command instead of poisoning the session, and a request-too-large rejection (HTTP 413) now recovers automatically — the request and /compact both retry with older media replaced by text markers instead of failing the session.
|
||||
|
||||
- [#1519](https://github.com/MoonshotAI/kimi-code/pull/1519) [`170ae44`](https://github.com/MoonshotAI/kimi-code/commit/170ae4420526b6592d696cd597d1693dbd1a660b) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Polish the session sidebar layout, colors, icons, and typography.
|
||||
|
||||
- [#1521](https://github.com/MoonshotAI/kimi-code/pull/1521) [`046b6c4`](https://github.com/MoonshotAI/kimi-code/commit/046b6c417581792933732c7ffe154e120c96171d) Thanks [@RealKai42](https://github.com/RealKai42)! - The `[image]` limits in config.toml now also apply to pasted images (CLI paste and ACP prompts), and each core now uses its own settings, so reloading one client's config no longer changes another client's image compression.
|
||||
|
||||
- [#1494](https://github.com/MoonshotAI/kimi-code/pull/1494) [`a354803`](https://github.com/MoonshotAI/kimi-code/commit/a3548035a8b6d25df9a11daab37a21daee1ef73f) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Add a Kimi WebBridge entry to the Official tab of the /plugins panel that opens the WebBridge install page in your browser.
|
||||
|
||||
- [#1479](https://github.com/MoonshotAI/kimi-code/pull/1479) [`735922c`](https://github.com/MoonshotAI/kimi-code/commit/735922c291ec3d32d60da6af053f75e1c6179f92) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Add notifications when a tool needs approval, and improve notification reliability.
|
||||
|
||||
- [#1522](https://github.com/MoonshotAI/kimi-code/pull/1522) [`ec8dc34`](https://github.com/MoonshotAI/kimi-code/commit/ec8dc3456c1696a5eba6c37b6e26ef99837c35e2) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix an occasional "another turn is active" error when sending the first message of a new conversation, and show a starting state while it is being sent.
|
||||
|
||||
- [#1502](https://github.com/MoonshotAI/kimi-code/pull/1502) [`ad30a1c`](https://github.com/MoonshotAI/kimi-code/commit/ad30a1c6328327729221f9f5fc700b621dfef779) Thanks [@chengluyu](https://github.com/chengluyu)! - web: Polish the chat UI with Inter typography, localized labels, and tighter sidebar, composer, and menu styling.
|
||||
|
||||
## 0.23.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1506](https://github.com/MoonshotAI/kimi-code/pull/1506) [`e83511a`](https://github.com/MoonshotAI/kimi-code/commit/e83511a7118652a67676bbcfd41148907ad7b8de) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix a misleading "OAuth login expired" message shown when a model is not available for the current account.
|
||||
|
||||
## 0.23.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1489](https://github.com/MoonshotAI/kimi-code/pull/1489) [`2206d21`](https://github.com/MoonshotAI/kimi-code/commit/2206d21327129aa2331b6b159cfce61110b7f94f) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Add the Vercel plugin to the bundled plugin marketplace. Run /plugins and select Vercel Plugin to install it.
|
||||
|
||||
- [#1477](https://github.com/MoonshotAI/kimi-code/pull/1477) [`150206a`](https://github.com/MoonshotAI/kimi-code/commit/150206a6f7027879df954e26736b4baa5d336235) Thanks [@chengluyu](https://github.com/chengluyu)! - Count the turn that starts an autonomous goal toward its goal turn usage.
|
||||
|
||||
- [#1483](https://github.com/MoonshotAI/kimi-code/pull/1483) [`f30781b`](https://github.com/MoonshotAI/kimi-code/commit/f30781bb273321f3e3bbb548a9d0724ab6299fc6) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Fix `kimi -p` runs exiting with code 0 when a turn fails.
|
||||
|
||||
- [#1466](https://github.com/MoonshotAI/kimi-code/pull/1466) [`063bce2`](https://github.com/MoonshotAI/kimi-code/commit/063bce2a2f52601abaa0d13173ab88371cbbe9ae) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix console windows flashing on Windows each time a hook runs.
|
||||
|
||||
- [#1474](https://github.com/MoonshotAI/kimi-code/pull/1474) [`11c6a37`](https://github.com/MoonshotAI/kimi-code/commit/11c6a37ce030f8e64de5c810da07ec7fab3b0615) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the connection error toast lingering after the WebSocket reconnects when returning from the background.
|
||||
|
||||
- [#1476](https://github.com/MoonshotAI/kimi-code/pull/1476) [`d1a964f`](https://github.com/MoonshotAI/kimi-code/commit/d1a964fba9b3dca902ea6f81bacaccc839955c03) Thanks [@chengluyu](https://github.com/chengluyu)! - Prevent autonomous goals from being paused by model-reported status updates.
|
||||
|
||||
- [#1481](https://github.com/MoonshotAI/kimi-code/pull/1481) [`1317000`](https://github.com/MoonshotAI/kimi-code/commit/131700097a732b97b3d17c5e2efa1c5a44b013ef) Thanks [@chengluyu](https://github.com/chengluyu)! - Tighten goal-mode guidance for blocked and complete status updates.
|
||||
|
||||
- [#1460](https://github.com/MoonshotAI/kimi-code/pull/1460) [`474ce28`](https://github.com/MoonshotAI/kimi-code/commit/474ce289dd39aa42d1a77a9a2e15531aee49aa15) Thanks [@RealKai42](https://github.com/RealKai42)! - Raise the image downscale cap from 2000px to 3000px, and fix swapped width/height for EXIF-rotated (portrait) photos in compression captions and media read notes so region readback coordinates map correctly.
|
||||
|
||||
- [#1467](https://github.com/MoonshotAI/kimi-code/pull/1467) [`ee38545`](https://github.com/MoonshotAI/kimi-code/commit/ee385456d0eda380fec067db92c025462db13f5a) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Compile icons at build time so the bundled web UI only carries the icons it renders.
|
||||
|
||||
- [#1471](https://github.com/MoonshotAI/kimi-code/pull/1471) [`9b76e5b`](https://github.com/MoonshotAI/kimi-code/commit/9b76e5bff631cceaeecb2b0cbc096533c5fdc8cc) Thanks [@starquakee](https://github.com/starquakee)! - Progressive tool disclosure (`select_tools`, experimental): compaction now discards the loaded tool schemas instead of re-injecting them. After a compaction the boundary announcement re-lists every loadable tool name and the model re-selects what it still needs; a from-memory call to a no-longer-loaded tool is rejected with guidance to select it first. This keeps the post-compaction context at its minimal users+summary floor and removes the schema-rebuild budget heuristics. No effect unless the `tool-select` experimental flag and a `select_tools`-capable model are active.
|
||||
|
||||
- [#1491](https://github.com/MoonshotAI/kimi-code/pull/1491) [`0cc9831`](https://github.com/MoonshotAI/kimi-code/commit/0cc9831a2f79d93903259bd3353e746abac01b67) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: The composer model switcher switches the active session's model as before and additionally bumps the global default model, so new sessions inherit the choice.
|
||||
|
||||
- [#1490](https://github.com/MoonshotAI/kimi-code/pull/1490) [`b30a45e`](https://github.com/MoonshotAI/kimi-code/commit/b30a45efecfa5ece4f4f10f2c5403ba097e7690b) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Press Enter to confirm in archive and other confirmation dialogs.
|
||||
|
||||
- [#1480](https://github.com/MoonshotAI/kimi-code/pull/1480) [`2ad0120`](https://github.com/MoonshotAI/kimi-code/commit/2ad0120c2a5c8383892e4da1ee7c6853926ed365) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Redesign the scheduled reminder UI.
|
||||
|
||||
- [#1492](https://github.com/MoonshotAI/kimi-code/pull/1492) [`b0809dd`](https://github.com/MoonshotAI/kimi-code/commit/b0809ddac833d8d920d95187f7ef64f97bafdbc6) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Show session skills in the slash menu as `/skill:<name>` so they are distinguishable from built-in commands; typing the bare skill name still works.
|
||||
|
||||
## 0.23.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1432](https://github.com/MoonshotAI/kimi-code/pull/1432) [`25a655c`](https://github.com/MoonshotAI/kimi-code/commit/25a655cf88b2f5861f9c0b7ea95ba9308f48d23a) Thanks [@RealKai42](https://github.com/RealKai42)! - Preserve prior turns' thinking by default on the Anthropic provider (Claude and Kimi's Anthropic-compatible mode), matching the Kimi default. Disable with `[thinking] keep = "off"` or `KIMI_MODEL_THINKING_KEEP=off`.
|
||||
|
||||
- [#1451](https://github.com/MoonshotAI/kimi-code/pull/1451) [`16dc940`](https://github.com/MoonshotAI/kimi-code/commit/16dc940834d9cc693b1f0022c4c70ef0004a6102) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Recover chat streaming after a stale background-tab WebSocket instead of requiring a page refresh.
|
||||
|
||||
- [#1456](https://github.com/MoonshotAI/kimi-code/pull/1456) [`e9ef939`](https://github.com/MoonshotAI/kimi-code/commit/e9ef9399d075461c7753d1f0467bd742a81cdbf6) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix goal completion and blocked updates to produce one final user-facing outcome summary from the tool result.
|
||||
|
||||
- [#1456](https://github.com/MoonshotAI/kimi-code/pull/1456) [`e9ef939`](https://github.com/MoonshotAI/kimi-code/commit/e9ef9399d075461c7753d1f0467bd742a81cdbf6) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix goal startup and queue handling so failed starts restore permission mode and queued goals wait behind new user messages.
|
||||
|
||||
- [#1456](https://github.com/MoonshotAI/kimi-code/pull/1456) [`e9ef939`](https://github.com/MoonshotAI/kimi-code/commit/e9ef9399d075461c7753d1f0467bd742a81cdbf6) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix goal token budgets to count model completion tokens and stop without extra continuation steps when the budget is exhausted.
|
||||
|
||||
- [#1452](https://github.com/MoonshotAI/kimi-code/pull/1452) [`244ec07`](https://github.com/MoonshotAI/kimi-code/commit/244ec077f98c2b498cee1d0002978b6963ccfd4d) Thanks [@sailist](https://github.com/sailist)! - Fix kimi -p abandoning background subagents that start late or run long, so their results reach the main agent.
|
||||
|
||||
- [#1457](https://github.com/MoonshotAI/kimi-code/pull/1457) [`260a807`](https://github.com/MoonshotAI/kimi-code/commit/260a80793a95d7796950a00bdc89cf99f8b196ad) Thanks [@liruifengv](https://github.com/liruifengv)! - Respect the --skills-dir flag in interactive mode.
|
||||
|
||||
- [#1445](https://github.com/MoonshotAI/kimi-code/pull/1445) [`809a88c`](https://github.com/MoonshotAI/kimi-code/commit/809a88cb34d2d5d02e43f030530bb1cd320b4a6a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix `/btw [<question>]` opening an empty side chat on the new-session screen.
|
||||
|
||||
- [#1445](https://github.com/MoonshotAI/kimi-code/pull/1445) [`809a88c`](https://github.com/MoonshotAI/kimi-code/commit/809a88cb34d2d5d02e43f030530bb1cd320b4a6a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix `/goal <objective>` silently doing nothing on the new-session screen.
|
||||
|
||||
- [#1445](https://github.com/MoonshotAI/kimi-code/pull/1445) [`809a88c`](https://github.com/MoonshotAI/kimi-code/commit/809a88cb34d2d5d02e43f030530bb1cd320b4a6a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix slash skill activations (for example `/pre-changelog`) silently doing nothing on the new-session screen.
|
||||
|
||||
- [#1437](https://github.com/MoonshotAI/kimi-code/pull/1437) [`743f66e`](https://github.com/MoonshotAI/kimi-code/commit/743f66e547279916d5e37454e78b11eb4b54dca3) Thanks [@RealKai42](https://github.com/RealKai42)! - Stop showing tool-produced `<system>` metadata in tool outputs; failed tools now show their own error text.
|
||||
|
||||
- [#1465](https://github.com/MoonshotAI/kimi-code/pull/1465) [`bfdbce5`](https://github.com/MoonshotAI/kimi-code/commit/bfdbce593f1dd667530cbfb5b10b5659b1968e48) Thanks [@liruifengv](https://github.com/liruifengv)! - Honor explicit Anthropic `max_output_size` settings instead of clamping them to built-in ceilings.
|
||||
|
||||
- [#1456](https://github.com/MoonshotAI/kimi-code/pull/1456) [`e9ef939`](https://github.com/MoonshotAI/kimi-code/commit/e9ef9399d075461c7753d1f0467bd742a81cdbf6) Thanks [@chengluyu](https://github.com/chengluyu)! - Keep goal tools available to the main agent and return clear messages for invalid goal-control calls.
|
||||
|
||||
- [#1463](https://github.com/MoonshotAI/kimi-code/pull/1463) [`03e78ae`](https://github.com/MoonshotAI/kimi-code/commit/03e78ae19063b38119f27b1bc89097a09614c0ce) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix newer Claude minor versions (e.g. Opus 4.8) defaulting to the family-baseline max output tokens; an uncatalogued minor now reuses the nearest earlier known version's limit.
|
||||
|
||||
- [#1456](https://github.com/MoonshotAI/kimi-code/pull/1456) [`e9ef939`](https://github.com/MoonshotAI/kimi-code/commit/e9ef9399d075461c7753d1f0467bd742a81cdbf6) Thanks [@chengluyu](https://github.com/chengluyu)! - Show long-running goal wall-clock budget reminders in hours.
|
||||
|
||||
- [#1456](https://github.com/MoonshotAI/kimi-code/pull/1456) [`e9ef939`](https://github.com/MoonshotAI/kimi-code/commit/e9ef9399d075461c7753d1f0467bd742a81cdbf6) Thanks [@chengluyu](https://github.com/chengluyu)! - Tighten goal-mode guidance so agents continue reasonable work across turns instead of ending goals prematurely.
|
||||
|
||||
- [#1450](https://github.com/MoonshotAI/kimi-code/pull/1450) [`7a65e0d`](https://github.com/MoonshotAI/kimi-code/commit/7a65e0d1c0da515dbd69f1266ba7e75713e0108e) Thanks [@liruifengv](https://github.com/liruifengv)! - Clarify the permission mode descriptions shown by `/permission`, `/auto`, and `/yolo`, and reorder `/auto` and `/yolo` in the command list.
|
||||
|
||||
- [#1448](https://github.com/MoonshotAI/kimi-code/pull/1448) [`65d3017`](https://github.com/MoonshotAI/kimi-code/commit/65d30177adc11a56bdbbe9fbc3c4b92f96efd6bb) Thanks [@RealKai42](https://github.com/RealKai42)! - Record a per-request trace in the session wire log, so model requests can be reconstructed for debugging. Not a user-facing feature.
|
||||
|
||||
## 0.23.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#1417](https://github.com/MoonshotAI/kimi-code/pull/1417) [`79b360c`](https://github.com/MoonshotAI/kimi-code/commit/79b360c96ad5d0af6a8c1d3a8df73adc65254d7c) Thanks [@RealKai42](https://github.com/RealKai42)! - Enable Preserved Thinking by default for Kimi models when Thinking is on, keeping prior reasoning across turns. Set `[thinking] keep = "off"` in config.toml (or `KIMI_MODEL_THINKING_KEEP=off`) to disable it.
|
||||
|
||||
- [#1073](https://github.com/MoonshotAI/kimi-code/pull/1073) [`6c0ce09`](https://github.com/MoonshotAI/kimi-code/commit/6c0ce09414bbfd42d8991c88c89b82c088ff2099) Thanks [@sailist](https://github.com/sailist)! - Add server APIs to restore archived sessions and list only archived sessions.
|
||||
|
||||
- [#1369](https://github.com/MoonshotAI/kimi-code/pull/1369) [`f0896a5`](https://github.com/MoonshotAI/kimi-code/commit/f0896a53b01f7e5b9bf5b8f93d2cd7387d765f07) Thanks [@starquakee](https://github.com/starquakee)! - Add experimental progressive tool disclosure (`select_tools`). When the `tool-select` experimental flag is on and the active model declares the `select_tools` capability, MCP tool schemas stay out of the request's top-level `tools[]` (preserving the provider prompt cache); the model loads tools on demand by exact name via the new built-in `select_tools` tool, guided by `<tools_added>/<tools_removed>` announcements. Off by default and inert on models without the capability — behavior is unchanged until a supporting model is catalogued. The SDK additionally maps the `select_tools` capability when building model aliases from a catalog and reports the new flag through `getExperimentalFeatures()`.
|
||||
|
||||
- [#1073](https://github.com/MoonshotAI/kimi-code/pull/1073) [`6c0ce09`](https://github.com/MoonshotAI/kimi-code/commit/6c0ce09414bbfd42d8991c88c89b82c088ff2099) Thanks [@sailist](https://github.com/sailist)! - web: Add an Archived sessions page in Settings to browse and restore archived sessions. Open Settings → Archived to find it.
|
||||
|
||||
- [#1425](https://github.com/MoonshotAI/kimi-code/pull/1425) [`c5e3e80`](https://github.com/MoonshotAI/kimi-code/commit/c5e3e80041a763143934c271d2524ac555d48d2a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Replace the swarm footer with a single inline tool card that shows live subagent progress and the aggregated result, and keep swarm badges stable after refresh.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1414](https://github.com/MoonshotAI/kimi-code/pull/1414) [`c12f309`](https://github.com/MoonshotAI/kimi-code/commit/c12f30951f2c278bebb42d0f61d67946c42b9577) Thanks [@RealKai42](https://github.com/RealKai42)! - AskUserQuestion now feeds answers back to the model as question text and option labels (e.g. `{"answers":{"Which database?":"Postgres"}}`) instead of synthesized ids like `q_0`/`opt_0_1`, so the model no longer has to map positional ids back to the original options — the wire protocol is unchanged, clients still answer with option ids. Question texts must now be unique per call and option labels unique per question; the web transcript card resolves both the new label form and legacy id transcripts.
|
||||
|
||||
- [#1408](https://github.com/MoonshotAI/kimi-code/pull/1408) [`fc259ab`](https://github.com/MoonshotAI/kimi-code/commit/fc259abdb415fe9ac10132a142bdb5ce507ccda2) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix `@` file completion missing deeply nested files in large projects after adding extra workspace directories.
|
||||
|
||||
- [#1346](https://github.com/MoonshotAI/kimi-code/pull/1346) [`b9258ee`](https://github.com/MoonshotAI/kimi-code/commit/b9258ee07d32ff63afe9a2eb40fce6d136548fb2) Thanks [@liruifengv](https://github.com/liruifengv)! - Show compaction summaries in the TUI after compaction. Press Ctrl-O to show or hide the summary.
|
||||
|
||||
- [#1419](https://github.com/MoonshotAI/kimi-code/pull/1419) [`5ea3ec4`](https://github.com/MoonshotAI/kimi-code/commit/5ea3ec489e0a7d66b844c39ee65162fd6a8ed8b1) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix the Bash tool card collapsing in height when a multi-line command finishes with short output, and visually separate the command from its output.
|
||||
|
||||
- [#1410](https://github.com/MoonshotAI/kimi-code/pull/1410) [`1c817df`](https://github.com/MoonshotAI/kimi-code/commit/1c817df1e522f438d4392568b64fc039dc867031) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix the edit approval preview shown by ctrl+e to include surrounding context lines, matching the summary panel.
|
||||
|
||||
- [#1421](https://github.com/MoonshotAI/kimi-code/pull/1421) [`1de0286`](https://github.com/MoonshotAI/kimi-code/commit/1de028612c80c38cd6fbe4483c123ded57a0a678) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix the Edit tool card jumping in height and flickering while its result streams in.
|
||||
|
||||
- [#1389](https://github.com/MoonshotAI/kimi-code/pull/1389) [`ebdffc7`](https://github.com/MoonshotAI/kimi-code/commit/ebdffc7df7b89dcafcf62f5705eafb50bfbaf5ab) Thanks [@sailist](https://github.com/sailist)! - Fix tool calling with Google Gemini models, including Gemini 3 thinking-signature round-trips across turns.
|
||||
|
||||
- [#1393](https://github.com/MoonshotAI/kimi-code/pull/1393) [`4c43935`](https://github.com/MoonshotAI/kimi-code/commit/4c43935e31170140699a54cba631792872628655) Thanks [@justjavac](https://github.com/justjavac)! - web: Show the correct session search shortcut on Windows.
|
||||
|
||||
- [#1406](https://github.com/MoonshotAI/kimi-code/pull/1406) [`ce41f4b`](https://github.com/MoonshotAI/kimi-code/commit/ce41f4b58d128ae47b0312eab24a845bbc0d08a3) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the collapsed sidebar not hiding and squeezing the conversation layout.
|
||||
|
||||
- [#1413](https://github.com/MoonshotAI/kimi-code/pull/1413) [`913d042`](https://github.com/MoonshotAI/kimi-code/commit/913d042208b4bfe45dc13144c4797dba1cac5d05) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix the input box shifting upward after the slash command menu closes.
|
||||
|
||||
- [#1433](https://github.com/MoonshotAI/kimi-code/pull/1433) [`ac5b5e4`](https://github.com/MoonshotAI/kimi-code/commit/ac5b5e4cbfdb050817c9fce7e08dd3bdd8ea354e) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Fix tool components jumping the conversation when expanded or collapsed.
|
||||
|
||||
- [#1394](https://github.com/MoonshotAI/kimi-code/pull/1394) [`e95fc83`](https://github.com/MoonshotAI/kimi-code/commit/e95fc83cc295dccf3e4c748fba087530dba614b6) Thanks [@justjavac](https://github.com/justjavac)! - web: Fix the font size setting so chat text, composer text, and sidebar text follow the selected size.
|
||||
|
||||
- [#1411](https://github.com/MoonshotAI/kimi-code/pull/1411) [`e6e6dd5`](https://github.com/MoonshotAI/kimi-code/commit/e6e6dd53ce9106f47684534a91acb1a803d1ab07) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Stop the chat history from replaying its entrance animation every time a session is opened.
|
||||
|
||||
- [#1409](https://github.com/MoonshotAI/kimi-code/pull/1409) [`578f7d3`](https://github.com/MoonshotAI/kimi-code/commit/578f7d334c7919e5987229c157060b1daae30139) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the end of a reply staying missing after reopening a session.
|
||||
|
||||
- [#1390](https://github.com/MoonshotAI/kimi-code/pull/1390) [`083d0ca`](https://github.com/MoonshotAI/kimi-code/commit/083d0caf0524ed9cc7978007cd0f342f6bd2917e) Thanks [@sailist](https://github.com/sailist)! - Fix sessions that exist on disk but were missing from the session list or returned 404 on direct access, by rebuilding the session index at server startup and keeping it consistent.
|
||||
|
||||
- [#1357](https://github.com/MoonshotAI/kimi-code/pull/1357) [`be7c991`](https://github.com/MoonshotAI/kimi-code/commit/be7c9916b019b19e057301c39bc7944fcac09414) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix queued media messages not loading back into the composer and keep attachments when undoing a message.
|
||||
|
||||
- [#1428](https://github.com/MoonshotAI/kimi-code/pull/1428) [`903e8ed`](https://github.com/MoonshotAI/kimi-code/commit/903e8ed93afc5b35d0fa1d33c86da2b3fae9ba9f) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Add an Archived sessions entry to the mobile settings sheet and clarify the archive confirmation to mention restoring from Settings.
|
||||
|
||||
- [#1391](https://github.com/MoonshotAI/kimi-code/pull/1391) [`c5c6282`](https://github.com/MoonshotAI/kimi-code/commit/c5c6282f447dba202c79cf0e3b7524712d2c2748) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Render AskUserQuestion answers as a readable option list with the chosen option(s) highlighted, instead of raw JSON.
|
||||
|
||||
- [#1423](https://github.com/MoonshotAI/kimi-code/pull/1423) [`fa6d198`](https://github.com/MoonshotAI/kimi-code/commit/fa6d198b0174ad76aa4ca3c0ea2ed45e099e521b) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Fix an almost-invisible composer input caret and a washed-out strikethrough on completed todos.
|
||||
|
||||
- [#1436](https://github.com/MoonshotAI/kimi-code/pull/1436) [`a5fbcb7`](https://github.com/MoonshotAI/kimi-code/commit/a5fbcb75b4b3ab937536a7a2f621c0374812c753) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Keep the composer toolbar from clipping its controls on narrow windows and phones, with the context ring staying visible at every width.
|
||||
|
||||
- [#1426](https://github.com/MoonshotAI/kimi-code/pull/1426) [`2374bc4`](https://github.com/MoonshotAI/kimi-code/commit/2374bc41c35adc1d2e2b5116559946c8de1b98a8) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Show scheduled-reminder (cron) fires as notice cards in the chat instead of hiding them.
|
||||
|
||||
- [#1391](https://github.com/MoonshotAI/kimi-code/pull/1391) [`c5c6282`](https://github.com/MoonshotAI/kimi-code/commit/c5c6282f447dba202c79cf0e3b7524712d2c2748) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Align the markdown diff code block with the design system: code text keeps the normal ink colour while the sign and a soft row background carry the change, matching the ~/diff panel.
|
||||
|
||||
- [#1434](https://github.com/MoonshotAI/kimi-code/pull/1434) [`4aacddc`](https://github.com/MoonshotAI/kimi-code/commit/4aacddc43222d0a44f202360462617788ca75660) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Show the Kimi icon and clearer titles in web desktop notifications.
|
||||
|
||||
- [#1392](https://github.com/MoonshotAI/kimi-code/pull/1392) [`4963c90`](https://github.com/MoonshotAI/kimi-code/commit/4963c9016fa19d1e01f8dc938c8d250afec87965) Thanks [@sailist](https://github.com/sailist)! - web: Show available skills in the composer before a session is created.
|
||||
|
||||
- [#1438](https://github.com/MoonshotAI/kimi-code/pull/1438) [`d86fa38`](https://github.com/MoonshotAI/kimi-code/commit/d86fa38e119c5834fff13a67194efe8d62c117e1) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Prevent chat text from hyphenating at line breaks and render code without font ligatures.
|
||||
|
||||
- [#1391](https://github.com/MoonshotAI/kimi-code/pull/1391) [`c5c6282`](https://github.com/MoonshotAI/kimi-code/commit/c5c6282f447dba202c79cf0e3b7524712d2c2748) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Drop the stray left indent in the tool-call card body so expanded content aligns with the header.
|
||||
|
||||
## 0.22.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1367](https://github.com/MoonshotAI/kimi-code/pull/1367) [`23daf0f`](https://github.com/MoonshotAI/kimi-code/commit/23daf0f3c199b4aaa9bd9388a2903d7827f98d32) - Revert the recent TUI transcript rendering changes to the original upstream behavior and fix related rendering issues.
|
||||
|
||||
- [#1343](https://github.com/MoonshotAI/kimi-code/pull/1343) [`ec758c7`](https://github.com/MoonshotAI/kimi-code/commit/ec758c747a95555847b8a0275ed0809010c7d5e7) - Add click-to-enlarge for images uploaded in the web chat. Click an image in a message to open it.
|
||||
|
||||
- [#1343](https://github.com/MoonshotAI/kimi-code/pull/1343) [`ec758c7`](https://github.com/MoonshotAI/kimi-code/commit/ec758c747a95555847b8a0275ed0809010c7d5e7) - Fix uploaded videos failing to play in the web chat.
|
||||
|
||||
- [#1371](https://github.com/MoonshotAI/kimi-code/pull/1371) [`5394fea`](https://github.com/MoonshotAI/kimi-code/commit/5394feaabb5d373fab046b3986b10a1180b4991d) - Wait for background subagents to finish and respond to their results before exiting in `kimi -p`, instead of ending the turn early.
|
||||
|
||||
- [#1373](https://github.com/MoonshotAI/kimi-code/pull/1373) [`e715b16`](https://github.com/MoonshotAI/kimi-code/commit/e715b1648c57bd0863edf859cb67db0327b7bb94) - Add `--dangerous-bypass-auth` and `--keep-alive` flags to `kimi server run`, so the server can run without a token on trusted networks and stay alive past the idle timeout.
|
||||
|
||||
- [#1344](https://github.com/MoonshotAI/kimi-code/pull/1344) [`26b9022`](https://github.com/MoonshotAI/kimi-code/commit/26b90225d21bd18f4f7e3b775f3f7f49034afad9) - Add a segmented thinking-level control in the web model picker for models that support multiple reasoning efforts. Open the composer model menu to choose a level.
|
||||
|
||||
## 0.22.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1353](https://github.com/MoonshotAI/kimi-code/pull/1353) [`68ad686`](https://github.com/MoonshotAI/kimi-code/commit/68ad686211760eb1c3e6b5c23eb28ace9009c17f) - Fix duplicated transcript content appearing in scrollback during streaming.
|
||||
|
||||
- [#1340](https://github.com/MoonshotAI/kimi-code/pull/1340) [`e2fe62a`](https://github.com/MoonshotAI/kimi-code/commit/e2fe62a5eff124b816a0f656355fc5bea85e3893) - Fix sessions silently dropping later user messages after a turn was interrupted between a tool call and its result.
|
||||
|
||||
- [#1342](https://github.com/MoonshotAI/kimi-code/pull/1342) [`84d8d5b`](https://github.com/MoonshotAI/kimi-code/commit/84d8d5b06399d29a9d8caba701835061c30a4817) - Have context-compaction notes capture a forward plan for the remaining work — upcoming steps, settled decisions, and foreseeable obstacles — instead of only the immediate next step, so the agent continues more coherently after auto-compaction.
|
||||
|
||||
- [#1340](https://github.com/MoonshotAI/kimi-code/pull/1340) [`e2fe62a`](https://github.com/MoonshotAI/kimi-code/commit/e2fe62a5eff124b816a0f656355fc5bea85e3893) - Fix requests being rejected by strict providers when the model emits duplicate tool call ids.
|
||||
|
||||
- [#1339](https://github.com/MoonshotAI/kimi-code/pull/1339) [`021786f`](https://github.com/MoonshotAI/kimi-code/commit/021786f5a201df5466a963d4d1ac915b3977582b) - Enrich PATH from the user's login shell at startup, so shell commands find user-installed tools (e.g. Homebrew's `gh`) even when kimi-code was launched without the full profile PATH.
|
||||
|
||||
- [#1336](https://github.com/MoonshotAI/kimi-code/pull/1336) [`4c1d0a1`](https://github.com/MoonshotAI/kimi-code/commit/4c1d0a1633c98ae5703addbf86ffe50b81545c08) - Keep automatic background updates from flashing a console window on Windows.
|
||||
|
||||
- [#1332](https://github.com/MoonshotAI/kimi-code/pull/1332) [`93f16c3`](https://github.com/MoonshotAI/kimi-code/commit/93f16c32d71d974f30c3ea3b1134691936ac5f53) - Fix `kimi upgrade` failing on Windows with a spawn error when installing the new version.
|
||||
|
||||
- [#1348](https://github.com/MoonshotAI/kimi-code/pull/1348) [`175b95f`](https://github.com/MoonshotAI/kimi-code/commit/175b95f3af684f7c5447967b9fe7c8a58b6ffe1b) - Fix compressed-image prompts leaking an internal `<system>` compression note into the visible message and the session title.
|
||||
|
||||
- [#1338](https://github.com/MoonshotAI/kimi-code/pull/1338) [`276407d`](https://github.com/MoonshotAI/kimi-code/commit/276407d2a46b03ce32cce02b73c5d485b1b02b17) - Promote the language-matching rule to a dedicated section in the system prompt, so replies and reasoning consistently follow the user's language through long English tool output, while repository artifacts keep project conventions.
|
||||
|
||||
- [#1347](https://github.com/MoonshotAI/kimi-code/pull/1347) [`02da587`](https://github.com/MoonshotAI/kimi-code/commit/02da5877953ce082826ba5ab1a1abd914d82b24a) - In `kimi -p` runs, wait for background subagents to finish before exiting when `background.keep_alive_on_exit` is enabled. Set `keep_alive_on_exit = true` to let concurrent background subagents complete.
|
||||
|
||||
- [#1349](https://github.com/MoonshotAI/kimi-code/pull/1349) [`e9db9ca`](https://github.com/MoonshotAI/kimi-code/commit/e9db9cafcf7a0d26122b2cac247d866d7724fd7a) - Record model response ids in session wire logs to make individual model requests easier to trace.
|
||||
|
||||
- [#1345](https://github.com/MoonshotAI/kimi-code/pull/1345) [`3ed22e3`](https://github.com/MoonshotAI/kimi-code/commit/3ed22e35a4ee09ce353e699406c6c994423ff39f) - Keep subagent cards at a stable height and show a live status spinner with a compact two-row activity window.
|
||||
|
||||
- [#1305](https://github.com/MoonshotAI/kimi-code/pull/1305) [`9091627`](https://github.com/MoonshotAI/kimi-code/commit/909162725770700efd3051f4cfa68156d9b84fa8) - Add a TUI preference to keep rapid multi-line pastes from submitting line by line when bracketed paste is unavailable. Set `disable_paste_burst = true` in `tui.toml` to turn it off.
|
||||
|
||||
- [#1328](https://github.com/MoonshotAI/kimi-code/pull/1328) [`01b65bd`](https://github.com/MoonshotAI/kimi-code/commit/01b65bdddc28c7c492096000103687f6a507e353) - Rebuild the web design-system easter egg as an in-app overlay that uses the app's real design tokens, so it stays in sync instead of drifting as a separate copy.
|
||||
|
||||
## 0.22.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1304](https://github.com/MoonshotAI/kimi-code/pull/1304) [`0fc0ae3`](https://github.com/MoonshotAI/kimi-code/commit/0fc0ae380b09aa96aad0eff1ae66f239e061d01a) - When large images are compressed, tell the model the original and delivered image details. Keep the original image available, and support cropped or full-resolution reads for fine details.
|
||||
|
||||
- [#1315](https://github.com/MoonshotAI/kimi-code/pull/1315) [`b40bb71`](https://github.com/MoonshotAI/kimi-code/commit/b40bb7139939eb2ba734ce5dd4871b894d7033e8) - Fix TUI rendering bugs that caused the screen to go blank and the input box to disappear.
|
||||
|
||||
- [#1303](https://github.com/MoonshotAI/kimi-code/pull/1303) [`2639786`](https://github.com/MoonshotAI/kimi-code/commit/2639786ce578f15c020a2c11c344797dae18de61) - Fix the TUI crashing when the terminal is resized to a very narrow width while the input contains CJK or emoji text.
|
||||
|
||||
- [#1315](https://github.com/MoonshotAI/kimi-code/pull/1315) [`b40bb71`](https://github.com/MoonshotAI/kimi-code/commit/b40bb7139939eb2ba734ce5dd4871b894d7033e8) - Clear the screen fully when starting a new session via /new, /clear, or a session switch.
|
||||
|
||||
- [#1301](https://github.com/MoonshotAI/kimi-code/pull/1301) [`c3653a1`](https://github.com/MoonshotAI/kimi-code/commit/c3653a1c50ffa3856484599e132980628eb9fca4) - Show an up arrow on the web composer send button.
|
||||
|
||||
- [#1290](https://github.com/MoonshotAI/kimi-code/pull/1290) [`3ea84a5`](https://github.com/MoonshotAI/kimi-code/commit/3ea84a56e4dfdeaddd58add5b269be0342f3f986) - Fix the session search dialog showing a horizontal scrollbar for long session titles or snippets.
|
||||
|
||||
- [#1316](https://github.com/MoonshotAI/kimi-code/pull/1316) [`5322c63`](https://github.com/MoonshotAI/kimi-code/commit/5322c638895a934c1ce220fefed54f5077d2a49e) - Fix web tooltips that could get stuck on screen when their trigger element is removed while open.
|
||||
|
||||
- [#1319](https://github.com/MoonshotAI/kimi-code/pull/1319) [`e8ab7ca`](https://github.com/MoonshotAI/kimi-code/commit/e8ab7ca78661de7f00a8196444be1db93e7c14b4) - Fix the sidebar session row shifting its title and status badges when hovered.
|
||||
|
||||
- [#1293](https://github.com/MoonshotAI/kimi-code/pull/1293) [`6a469b3`](https://github.com/MoonshotAI/kimi-code/commit/6a469b3e07022e56b29b1fd8a7c58df36b2111fe) - Refresh the web UI icon set and unify the message copy and undo button hover states and tooltips.
|
||||
|
||||
- [#1311](https://github.com/MoonshotAI/kimi-code/pull/1311) [`b40649b`](https://github.com/MoonshotAI/kimi-code/commit/b40649b2ae7a4b6a0aea04e32eba200555393064) - Remove duplicate newline-shortcut handling from the prompt editor.
|
||||
|
||||
- [#1317](https://github.com/MoonshotAI/kimi-code/pull/1317) [`78a058a`](https://github.com/MoonshotAI/kimi-code/commit/78a058acd2fc91de5cca0c1d66d415ee35884889) - Remove the experimental micro compaction feature and its toggle from the experiments panel.
|
||||
|
||||
- [#1283](https://github.com/MoonshotAI/kimi-code/pull/1283) [`ea55911`](https://github.com/MoonshotAI/kimi-code/commit/ea55911062eefcb0414cfddb84c8a4494c45f363) - Improve compaction handoff summaries for more reliable resumed sessions. They now keep the latest intent, key tool results, decisions, open questions, and context to re-check.
|
||||
|
||||
- [#1295](https://github.com/MoonshotAI/kimi-code/pull/1295) [`77eb3a9`](https://github.com/MoonshotAI/kimi-code/commit/77eb3a9fe40c93fa32e335f07160b8128355bab6) - Save shell commands to input history and recall them in bash mode. Press Up on an empty `!` prompt to browse previous shell commands.
|
||||
|
||||
- [#1316](https://github.com/MoonshotAI/kimi-code/pull/1316) [`5322c63`](https://github.com/MoonshotAI/kimi-code/commit/5322c638895a934c1ce220fefed54f5077d2a49e) - Trim redundant and incorrect tooltips in the web UI.
|
||||
|
||||
- [#1320](https://github.com/MoonshotAI/kimi-code/pull/1320) [`444e6b1`](https://github.com/MoonshotAI/kimi-code/commit/444e6b15f0e53b6c4d75d1bfdc0b35639dce6f4c) - Fix the web UI becoming sluggish after opening many sessions.
|
||||
|
||||
- [#1322](https://github.com/MoonshotAI/kimi-code/pull/1322) [`5441ad1`](https://github.com/MoonshotAI/kimi-code/commit/5441ad1838a5cfa1f3df0ca2ee1524e1433fb513) - Let the web sidebar collapse an expanded workspace session list back to its first page.
|
||||
|
||||
## 0.22.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#1243](https://github.com/MoonshotAI/kimi-code/pull/1243) [`ace7901`](https://github.com/MoonshotAI/kimi-code/commit/ace79010669d19ad175bc25443b6efb41ca2e2ac) - Automatically compress oversized images before they reach the model. Whatever the source — pasted into the CLI, uploaded from the web/desktop client, sent over ACP, read via `ReadMediaFile`, or returned by an MCP tool — images are downsampled (longest edge ≤ 2000px) and re-encoded to fit a per-image byte budget, cutting vision-token cost and avoiding provider image-size errors. Screenshots stay lossless PNG and only degrade to JPEG when the byte budget cannot otherwise be met. Compression runs as an input-stage step at each ingestion point (while the content part is built), and guards against decompression bombs by skipping absurdly large pixel/byte payloads before decoding. Best-effort: if it fails for any reason the original image is sent unchanged.
|
||||
|
||||
- [#1262](https://github.com/MoonshotAI/kimi-code/pull/1262) [`c070fbe`](https://github.com/MoonshotAI/kimi-code/commit/c070fbeddeb1c147d8859a76046f9465f696c9cb) - Add model alias overrides so manual thinking effort levels and model metadata survive provider catalog refreshes. Set them under `[models."<alias>".overrides]`.
|
||||
|
||||
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Refresh the web UI with a new design system, including updated colors, typography, spacing, light and dark palettes, restyled tooltips, and subtle enter/exit and expand/collapse animations.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Show draft pull requests with a distinct draft status instead of displaying them as open.
|
||||
|
||||
- [#1254](https://github.com/MoonshotAI/kimi-code/pull/1254) [`7859b0a`](https://github.com/MoonshotAI/kimi-code/commit/7859b0afe8898852806e5a0c21b9dd52cb82f834) - Fix the transcript jumping to the top when scrolling up through history during streaming output.
|
||||
|
||||
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Fix plan, swarm, and goal modes being shared across sessions in the web UI; each session now keeps its own toggles.
|
||||
|
||||
- [#1264](https://github.com/MoonshotAI/kimi-code/pull/1264) [`003733c`](https://github.com/MoonshotAI/kimi-code/commit/003733c751584ce30d8ebae4f5e608f0df049d32) - Hide the unsupported Off option in the /model thinking switcher for always-on models that already expose multiple effort levels.
|
||||
|
||||
- [#1272](https://github.com/MoonshotAI/kimi-code/pull/1272) [`54703d9`](https://github.com/MoonshotAI/kimi-code/commit/54703d9457dcda7bc782301fc2dbb41a2c8d7293) - Release pasted images and streaming timers once they are no longer shown, so memory stops growing in long sessions.
|
||||
|
||||
- [#1272](https://github.com/MoonshotAI/kimi-code/pull/1272) [`54703d9`](https://github.com/MoonshotAI/kimi-code/commit/54703d9457dcda7bc782301fc2dbb41a2c8d7293) - Fix the terminal being left in raw mode with a hidden cursor and disabled flow control after a crash or abrupt exit.
|
||||
|
||||
- [#1265](https://github.com/MoonshotAI/kimi-code/pull/1265) [`8cfb165`](https://github.com/MoonshotAI/kimi-code/commit/8cfb1657ad7bf525269df4ab6cf5c12aa1d406a9) - Reduce the default TUI transcript window to keep long sessions responsive.
|
||||
|
||||
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Replace the Explore and Native theme options with a single chat layout and a Blue or Black accent-color setting.
|
||||
|
||||
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Show time, duration, connection, and stack details in web error and warning toasts.
|
||||
|
||||
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Fix an active workspace showing only its five most recent sessions on load, so it now keeps loading older sessions from the last 12 hours.
|
||||
|
||||
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Reduce the web composer's default height for a more compact empty state, and fix ArrowUp recalling the previous message while editing a multi-line draft; ArrowUp now recalls only from the very start of the text and is disabled in the expanded editor.
|
||||
|
||||
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Fix the Thinking-by-default setting not taking effect, so new sessions correctly start with thinking enabled.
|
||||
|
||||
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Fix spurious errors from the web question, approval, and task actions when the action was already complete, and add loading feedback so each click is acknowledged immediately.
|
||||
|
||||
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Show queued prompts inline below the running turn in the web chat, and split Stop into its own button so Send no longer interrupts.
|
||||
|
||||
- [#1278](https://github.com/MoonshotAI/kimi-code/pull/1278) [`bbda90a`](https://github.com/MoonshotAI/kimi-code/commit/bbda90af846ca66232158d2e9605d3d59a7e3a49) - Hide the conversation outline when there is not enough room to expand its labels, so it no longer clips against the window edge.
|
||||
|
||||
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Show the conversation outline as one entry per user query that expands into a labeled list on hover.
|
||||
|
||||
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Remove the fade-out animation when undoing a message in the web chat.
|
||||
|
||||
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Improve session search with a Cmd/Ctrl+K palette that filters by title, workspace, and last prompt with highlighted matches. Press Cmd+K or Ctrl+K to open it.
|
||||
|
||||
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Group consecutive tool calls into a collapsible stack with per-tool renderers, including diff line-count chips for edits and inline previews for image, video, and audio results.
|
||||
|
||||
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Use one consistent modal dialog for confirmations in the web UI (archive session, delete workspace, delete provider, undo message, and mode toggles).
|
||||
|
||||
- [#1258](https://github.com/MoonshotAI/kimi-code/pull/1258) [`b905dd4`](https://github.com/MoonshotAI/kimi-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Add workspace sorting by manual order or last-edited time, plus collapse-all and expand-all controls, to the sidebar.
|
||||
|
||||
## 0.21.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1256](https://github.com/MoonshotAI/kimi-code/pull/1256) [`0cc02ac`](https://github.com/MoonshotAI/kimi-code/commit/0cc02ac67d465d1d4d7fe070422bab17053cdaa3) - Keep the waiting spinner visible while encrypted reasoning streams, fixing a blank spinner-less gap before the first response text appears.
|
||||
|
||||
## 0.21.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#1204](https://github.com/MoonshotAI/kimi-code/pull/1204) [`5cb80ce`](https://github.com/MoonshotAI/kimi-code/commit/5cb80ce879406d239048c32d61202778cb860e58) - Plugins can now provide slash commands via a `commands` field in their manifest, registered as `<plugin>:<command>` and invoked with `$ARGUMENTS` expansion.
|
||||
|
||||
- [#1214](https://github.com/MoonshotAI/kimi-code/pull/1214) [`86e0c92`](https://github.com/MoonshotAI/kimi-code/commit/86e0c9201ed58c7c1ce5543b1dfb47a4cf5117f6) - Rework conversation compaction:
|
||||
|
||||
- Keep only recent user prompts plus a single user-role summary; drop assistant and tool messages.
|
||||
- Repair tool_use/tool_result adjacency before sending, fixing a strict-provider HTTP 400 when a tool call and its result became non-adjacent.
|
||||
- Merge consecutive user turns for strict providers (Gemini/Vertex), fixing an HTTP 400 ("roles must alternate") after compaction or when a turn is steered in right after a tool result.
|
||||
- Micro-compaction now defaults off.
|
||||
|
||||
- [#1132](https://github.com/MoonshotAI/kimi-code/pull/1132) [`108299b`](https://github.com/MoonshotAI/kimi-code/commit/108299be3cdffc31a23f64efd3ff5ba50976b412) - Refactor the thinking effort system
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1231](https://github.com/MoonshotAI/kimi-code/pull/1231) [`ceb27f5`](https://github.com/MoonshotAI/kimi-code/commit/ceb27f5e449e177493f320d90e292487a8fc3410) - Add a server-side key-value store API for persisting web UI preferences to the user's data directory.
|
||||
|
||||
- [#1220](https://github.com/MoonshotAI/kimi-code/pull/1220) [`ec51324`](https://github.com/MoonshotAI/kimi-code/commit/ec51324230484f2ebaad1ab0aebf2e38f531d914) - Add a double-Esc shortcut to open the undo selector. Press Esc twice while idle to undo.
|
||||
|
||||
- [#1223](https://github.com/MoonshotAI/kimi-code/pull/1223) [`80e6888`](https://github.com/MoonshotAI/kimi-code/commit/80e6888e34e4362247c0eac5b77340df014ba286) - Fix @ file mentions not opening when typed inside a slash command argument.
|
||||
|
||||
- [#1233](https://github.com/MoonshotAI/kimi-code/pull/1233) [`020992c`](https://github.com/MoonshotAI/kimi-code/commit/020992c286f0f6bff6a038a7c7bd7e9db639e3c9) - Force-exit headless runs (`kimi -p`) so a stray ref'd handle left over from the run can't keep a completed run alive until an external timeout, and bound prompt cleanup so a wedged shutdown step can't hang shutdown.
|
||||
|
||||
- [#1225](https://github.com/MoonshotAI/kimi-code/pull/1225) [`659062d`](https://github.com/MoonshotAI/kimi-code/commit/659062d11cc272fe631fc6d4faf64d0e0b1a0142) - Show file path completions when typing `/` in shell mode (`!`).
|
||||
|
||||
- [#1236](https://github.com/MoonshotAI/kimi-code/pull/1236) [`bfe8e6a`](https://github.com/MoonshotAI/kimi-code/commit/bfe8e6ace3cda76b1991bf29c25b9444611d5512) - Fix adding a workspace by path in the web UI failing silently when the daemon rejects the path; it now shows an error instead of a broken workspace.
|
||||
|
||||
- [#1221](https://github.com/MoonshotAI/kimi-code/pull/1221) [`a3f9cec`](https://github.com/MoonshotAI/kimi-code/commit/a3f9cec8a975f11e37e992e42f954789ed394207) - Fix duplicate workspaces showing in the web sidebar when the same folder is registered more than once.
|
||||
|
||||
- [#1241](https://github.com/MoonshotAI/kimi-code/pull/1241) [`8ac337a`](https://github.com/MoonshotAI/kimi-code/commit/8ac337a2b2ac800aa79a373459308abb6c9e63bb) - Stop a malformed message history from permanently bricking a session on strict providers (Anthropic). The request is repaired before sending — orphaned tool calls are closed and empty/whitespace-only text blocks dropped — and if the provider still rejects its structure, it is resent once with a wire-compliant rebuild.
|
||||
|
||||
- [#1228](https://github.com/MoonshotAI/kimi-code/pull/1228) [`42e37eb`](https://github.com/MoonshotAI/kimi-code/commit/42e37eb898b722829d2ec83e909525ff18e336a5) - Split LLM streaming timing in the session log and `KIMI_CODE_DEBUG=1` output into client vs. API-server portions, so slow turns can be attributed without parsing the wire log. Time-to-first-token splits into the API-server portion (network + server) and the client portion (in-process request building); the decode window splits into time awaiting tokens from the server and time the client spends processing each streamed chunk.
|
||||
|
||||
- [#1234](https://github.com/MoonshotAI/kimi-code/pull/1234) [`882cf35`](https://github.com/MoonshotAI/kimi-code/commit/882cf355a9cb45bb5b3424a27b953bde8e106bb0) - Hide the provider management dialog in the web UI until the server supports it.
|
||||
|
||||
- [#1226](https://github.com/MoonshotAI/kimi-code/pull/1226) [`7f05f58`](https://github.com/MoonshotAI/kimi-code/commit/7f05f589e7bc77a2f26463a41317ff7087e3c3a0) - Add Mermaid diagram rendering to the web chat. Fenced `mermaid` blocks in assistant responses now render as diagrams. KaTeX math and Mermaid diagram parsing also run in Web Workers to keep the UI responsive during live streaming.
|
||||
|
||||
- [#1232](https://github.com/MoonshotAI/kimi-code/pull/1232) [`aa6b0d0`](https://github.com/MoonshotAI/kimi-code/commit/aa6b0d065ee888056c3812781483ddb74739897f) - Always show the usage-data opt-out toggle in the web settings with a clearer label and description.
|
||||
|
||||
- [#1234](https://github.com/MoonshotAI/kimi-code/pull/1234) [`882cf35`](https://github.com/MoonshotAI/kimi-code/commit/882cf355a9cb45bb5b3424a27b953bde8e106bb0) - Fix the web workspace rename not persisting after a page refresh.
|
||||
|
||||
## 0.20.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1207](https://github.com/MoonshotAI/kimi-code/pull/1207) [`14d9e98`](https://github.com/MoonshotAI/kimi-code/commit/14d9e98903f30f83199e30b5fa20b3c61ab28781) - Refresh provider model lists automatically in the background instead of only at startup, so newly available models appear without restarting.
|
||||
|
||||
- [#1191](https://github.com/MoonshotAI/kimi-code/pull/1191) [`0df1812`](https://github.com/MoonshotAI/kimi-code/commit/0df18125022103dabb149b4f26f90959b669187b) - Fix provider error messages rendering as blank lines in the TUI when the server returns an HTML error page.
|
||||
|
||||
- [#1212](https://github.com/MoonshotAI/kimi-code/pull/1212) [`636ccc4`](https://github.com/MoonshotAI/kimi-code/commit/636ccc40f19f259bdd6653b2ca563a75b3548e23) - Fix the web composer being hidden behind the mobile Safari toolbar and the page auto-zooming when the composer is focused.
|
||||
|
||||
- [#1068](https://github.com/MoonshotAI/kimi-code/pull/1068) [`c82dcf9`](https://github.com/MoonshotAI/kimi-code/commit/c82dcf9cd8276eddf6acbf1030d1712b83a38083) - Glob now uses ripgrep, so it respects .gitignore by default, supports brace patterns, returns only files, and keeps partial results with a warning when some directories are unreadable.
|
||||
|
||||
- [#1209](https://github.com/MoonshotAI/kimi-code/pull/1209) [`0635387`](https://github.com/MoonshotAI/kimi-code/commit/063538744f64a1bd3da6f37ebd0643d10bfc068f) - Align malformed tool call argument handling with schema validation fallback.
|
||||
|
||||
## 0.20.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1166](https://github.com/MoonshotAI/kimi-code/pull/1166) [`dfcfdfd`](https://github.com/MoonshotAI/kimi-code/commit/dfcfdfd9ddbe14fb6e358694394e1ddcc21b8911) - Add an optional exclude_empty parameter to the session list API to omit sessions that have no messages.
|
||||
|
||||
- [#1156](https://github.com/MoonshotAI/kimi-code/pull/1156) [`794db55`](https://github.com/MoonshotAI/kimi-code/commit/794db55538e01b4bf0c008c493de5d8b8bf67c5d) - Cap compaction output at 128k tokens by default to avoid provider max_tokens errors.
|
||||
|
||||
- [#1129](https://github.com/MoonshotAI/kimi-code/pull/1129) [`d02b5c4`](https://github.com/MoonshotAI/kimi-code/commit/d02b5c49844d65e005632fafcb1c172a7d32bfbe) - Fix compaction ignoring the configured max output size.
|
||||
|
||||
- [#1188](https://github.com/MoonshotAI/kimi-code/pull/1188) [`db5fbc5`](https://github.com/MoonshotAI/kimi-code/commit/db5fbc53c00c9945fc1fa98c69c4e5c7efb8077e) - Fix unnecessary full-screen redraws when typing in the input box or toggling the slash panel.
|
||||
|
||||
- [#1187](https://github.com/MoonshotAI/kimi-code/pull/1187) [`97f9263`](https://github.com/MoonshotAI/kimi-code/commit/97f9263c6f13ead5edc051f96993f8d1d7d5ec6f) - Fix debug timing output lingering after undoing a turn.
|
||||
|
||||
- [#1163](https://github.com/MoonshotAI/kimi-code/pull/1163) [`ff6e8bb`](https://github.com/MoonshotAI/kimi-code/commit/ff6e8bbd7c328dcc6575902cfd0cb3e522f20948) - Fix the web composer occasionally keeping typed text after sending the first message of a new session.
|
||||
|
||||
- [#1189](https://github.com/MoonshotAI/kimi-code/pull/1189) [`04b3492`](https://github.com/MoonshotAI/kimi-code/commit/04b3492e740dad5fca2af9f66eca98da3e14058a) - Fix working tips getting squeezed against the agent swarm progress bar.
|
||||
|
||||
- [#1159](https://github.com/MoonshotAI/kimi-code/pull/1159) [`23a553b`](https://github.com/MoonshotAI/kimi-code/commit/23a553bb91e9ee794aaf769f78f5acec739aec85) - In the bundled web UI, `/new` and `/clear` are now aliases that open the session onboarding composer and focus the input. iOS auto-zoom is prevented by keeping text inputs at 16px instead of disabling viewport scaling.
|
||||
|
||||
- [#1186](https://github.com/MoonshotAI/kimi-code/pull/1186) [`821847c`](https://github.com/MoonshotAI/kimi-code/commit/821847cb4b88d9128014609aad307ab8d9e9a5f3) - Add `KIMI_CODE_CUSTOM_HEADERS` for custom outbound LLM request headers and send the `User-Agent` header to non-Kimi providers. Set `KIMI_CODE_CUSTOM_HEADERS` to newline-separated `Name: Value` lines.
|
||||
|
||||
- [#1186](https://github.com/MoonshotAI/kimi-code/pull/1186) [`821847c`](https://github.com/MoonshotAI/kimi-code/commit/821847cb4b88d9128014609aad307ab8d9e9a5f3) - Route managed Kimi Code models on the Anthropic-compatible protocol through the beta Messages API.
|
||||
|
||||
- [#1170](https://github.com/MoonshotAI/kimi-code/pull/1170) [`cf558cd`](https://github.com/MoonshotAI/kimi-code/commit/cf558cd74267393d6497ddedf25e192eaac4f94b) - Recover from provider 413 context overflows by compacting before retrying.
|
||||
|
||||
- [#1170](https://github.com/MoonshotAI/kimi-code/pull/1170) [`cf558cd`](https://github.com/MoonshotAI/kimi-code/commit/cf558cd74267393d6497ddedf25e192eaac4f94b) - Support the Anthropic-compatible protocol for managed Kimi Code, including video input.
|
||||
|
||||
- [#1186](https://github.com/MoonshotAI/kimi-code/pull/1186) [`821847c`](https://github.com/MoonshotAI/kimi-code/commit/821847cb4b88d9128014609aad307ab8d9e9a5f3) - Add provider type and protocol attributes to turn and API error telemetry.
|
||||
|
||||
- [#1155](https://github.com/MoonshotAI/kimi-code/pull/1155) [`54baf5d`](https://github.com/MoonshotAI/kimi-code/commit/54baf5d07fe718b70b8840e509a905ac48b1ccac) - Upgrade web markdown renderer dependencies (katex, markstream-vue, shiki) for bug fixes and performance improvements.
|
||||
|
||||
- [#1162](https://github.com/MoonshotAI/kimi-code/pull/1162) [`b070846`](https://github.com/MoonshotAI/kimi-code/commit/b0708464f4160f7b73f25a520e493bf87e92149f) - Rework the web ask-user-question card into a step-by-step wizard so multi-question navigation and the final Submit action are easier to see.
|
||||
|
||||
- [#1179](https://github.com/MoonshotAI/kimi-code/pull/1179) [`fc3d69d`](https://github.com/MoonshotAI/kimi-code/commit/fc3d69dbdc965e525b5486a6b91e4ec44194ca97) - Add a completion sound and question notifications to the web UI, with separate Settings toggles for completion notifications, question notifications, and sound. Question notifications default off so question text only reaches your desktop after you opt in.
|
||||
|
||||
- [#1165](https://github.com/MoonshotAI/kimi-code/pull/1165) [`f3b1532`](https://github.com/MoonshotAI/kimi-code/commit/f3b15322da518b0e3d0560d19651435793c790d9) - Replace the web composer attach button's plus icon with an image icon.
|
||||
|
||||
- [#1167](https://github.com/MoonshotAI/kimi-code/pull/1167) [`c63edd5`](https://github.com/MoonshotAI/kimi-code/commit/c63edd5bf6d764c3ab771cb697a334ac100a0944) - In the bundled web UI, a new session is now created only when the first message is sent, so + New without a workspace opens the composer instead of making an empty session.
|
||||
|
||||
- [#1166](https://github.com/MoonshotAI/kimi-code/pull/1166) [`dfcfdfd`](https://github.com/MoonshotAI/kimi-code/commit/dfcfdfd9ddbe14fb6e358694394e1ddcc21b8911) - Hide unused "New Session" entries from the web session list by default.
|
||||
|
||||
- [#1181](https://github.com/MoonshotAI/kimi-code/pull/1181) [`1dab2c2`](https://github.com/MoonshotAI/kimi-code/commit/1dab2c2268af6f74464b6573981c1e1bb4bda703) - Restore each session's scroll position when switching back to it in the web UI.
|
||||
|
||||
- [#1181](https://github.com/MoonshotAI/kimi-code/pull/1181) [`1dab2c2`](https://github.com/MoonshotAI/kimi-code/commit/1dab2c2268af6f74464b6573981c1e1bb4bda703) - Keep the open side panel when switching between sessions in the web UI.
|
||||
|
||||
- [#1166](https://github.com/MoonshotAI/kimi-code/pull/1166) [`dfcfdfd`](https://github.com/MoonshotAI/kimi-code/commit/dfcfdfd9ddbe14fb6e358694394e1ddcc21b8911) - Remove the /sessions slash command from the web UI; the sidebar already covers session browsing.
|
||||
|
||||
- [#1181](https://github.com/MoonshotAI/kimi-code/pull/1181) [`1dab2c2`](https://github.com/MoonshotAI/kimi-code/commit/1dab2c2268af6f74464b6573981c1e1bb4bda703) - Keep unsent composer attachments scoped to their session in the web UI, so switching sessions no longer leaks them into another session's next message.
|
||||
|
||||
- [#1161](https://github.com/MoonshotAI/kimi-code/pull/1161) [`d968642`](https://github.com/MoonshotAI/kimi-code/commit/d968642384f672295756394ee07a536dbfdb4dfd) - Show the first five sessions per workspace in the web sidebar instead of ten.
|
||||
|
||||
- [#1181](https://github.com/MoonshotAI/kimi-code/pull/1181) [`1dab2c2`](https://github.com/MoonshotAI/kimi-code/commit/1dab2c2268af6f74464b6573981c1e1bb4bda703) - Scope the web composer's up/down input history to the current session instead of sharing it across all sessions.
|
||||
|
||||
## 0.20.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1125](https://github.com/MoonshotAI/kimi-code/pull/1125) [`e9a3b7c`](https://github.com/MoonshotAI/kimi-code/commit/e9a3b7c83a623c7323da509ba885567c465093fc) - Add an `update` alias for the `kimi upgrade` command. Run `kimi update` to upgrade to the latest version.
|
||||
|
||||
- [#1122](https://github.com/MoonshotAI/kimi-code/pull/1122) [`820d77a`](https://github.com/MoonshotAI/kimi-code/commit/820d77ab4cfad7752358a4692fd3d7def49f005d) - Show the done / in progress / pending breakdown of hidden todos in the collapsed todo panel.
|
||||
|
||||
- [#1131](https://github.com/MoonshotAI/kimi-code/pull/1131) [`76c643b`](https://github.com/MoonshotAI/kimi-code/commit/76c643bcb6da447c8c47728b4f58512a7a11cfa6) - Cap completion tokens to the remaining context window for chat-completions providers, avoiding context-overflow and invalid max_tokens errors.
|
||||
|
||||
- [#1120](https://github.com/MoonshotAI/kimi-code/pull/1120) [`e736349`](https://github.com/MoonshotAI/kimi-code/commit/e736349a7c8ff55b73e05cc0192dfaf0114745fa) - Add optional feedback attachments for diagnostic logs and codebase context.
|
||||
|
||||
- [#1135](https://github.com/MoonshotAI/kimi-code/pull/1135) [`bf51fb7`](https://github.com/MoonshotAI/kimi-code/commit/bf51fb7a105b2f34a59ed4e83d2588e790cfb086) - Fix the local server failing to start on Windows after the first run because the persistent token file's synthesized mode was rejected as too permissive.
|
||||
|
||||
- [#1102](https://github.com/MoonshotAI/kimi-code/pull/1102) [`9c97161`](https://github.com/MoonshotAI/kimi-code/commit/9c9716125e104b217540d0591229d03c6d676ead) - Harden the default system prompt and built-in tool descriptions: stop the agent from blocking on background tasks it should let run, keep its guidance matched to the tools each profile actually provides, and surface tool-result details (fetched-page mode, Grep match totals) it previously missed.
|
||||
|
||||
- [#1127](https://github.com/MoonshotAI/kimi-code/pull/1127) [`184acf5`](https://github.com/MoonshotAI/kimi-code/commit/184acf5db521a964a8af9dfdb1502121a9be76dc) - Plugins can now declare hooks in their manifest to run scripts on lifecycle events.
|
||||
|
||||
- [#1128](https://github.com/MoonshotAI/kimi-code/pull/1128) [`0886bff`](https://github.com/MoonshotAI/kimi-code/commit/0886bff2bcd3aed954990c948201d84787c0f3f3) - Add a --allowed-host flag to kimi server run 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.
|
||||
|
||||
- [#1119](https://github.com/MoonshotAI/kimi-code/pull/1119) [`b0b2aee`](https://github.com/MoonshotAI/kimi-code/commit/b0b2aee8c5a496c2b679fc9dbbc05e3d1934d5d9) - Keep the terminal responsive in long conversations by caching rendered message lines.
|
||||
|
||||
- [#1119](https://github.com/MoonshotAI/kimi-code/pull/1119) [`b0b2aee`](https://github.com/MoonshotAI/kimi-code/commit/b0b2aee8c5a496c2b679fc9dbbc05e3d1934d5d9) - Keep long sessions responsive by retaining only recent turns in the transcript and collapsing older steps within each turn.
|
||||
|
||||
- [#1121](https://github.com/MoonshotAI/kimi-code/pull/1121) [`81ba48f`](https://github.com/MoonshotAI/kimi-code/commit/81ba48f45534e133947c4e5e78907c2ad0db0b90) - Make the web chat input grow with its content and add an expandable editor for longer messages.
|
||||
|
||||
- [#1133](https://github.com/MoonshotAI/kimi-code/pull/1133) [`f1c8175`](https://github.com/MoonshotAI/kimi-code/commit/f1c8175f9c5766f6a928fd07fb680e3159c564b0) - Fix the /web slash command not carrying the server token, so the opened web UI signs in automatically and the token is shown before the terminal exits.
|
||||
|
||||
## 0.20.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#1079](https://github.com/MoonshotAI/kimi-code/pull/1079) [`2db5fc2`](https://github.com/MoonshotAI/kimi-code/commit/2db5fc20ecdf3212afd47e7c26195e428f8eddd5) - Add shell mode for running shell commands.
|
||||
Type `!` in the input box to enable it.
|
||||
The command output is visible to the AI.
|
||||
For long-running commands, press Ctrl+B to move them to the background.
|
||||
For example, you can run `!gh auth login` to sign in to the GitHub CLI without opening a new terminal, so Kimi can use `gh`.
|
||||
|
||||
- [#1088](https://github.com/MoonshotAI/kimi-code/pull/1088) [`0030f76`](https://github.com/MoonshotAI/kimi-code/commit/0030f76c5cc6465c5a6646c166375127d83696d3) - Add a confirmation prompt before installing third-party plugins.
|
||||
|
||||
- [#1066](https://github.com/MoonshotAI/kimi-code/pull/1066) [`3554f7e`](https://github.com/MoonshotAI/kimi-code/commit/3554f7e7d6e472413aa7a9873d7a2eef5f2b819c) - Show update badges on the /plugins Installed tab, where Enter now installs the available update and I opens plugin details.
|
||||
|
||||
- [#1025](https://github.com/MoonshotAI/kimi-code/pull/1025) [`5ef66dd`](https://github.com/MoonshotAI/kimi-code/commit/5ef66ddfeda2f23c40fc0cf53225cdaf3cc1147d) - Redesign `/plugins` as a single tabbed panel: **Installed** (manage installed
|
||||
plugins — toggle, remove, MCP, details, reload), **Official** (Kimi-maintained
|
||||
marketplace plugins), **Third-party** (marketplace plugins from other
|
||||
publishers), and **Custom** (install straight from a GitHub URL, zip URL, or
|
||||
local path). `Tab` / `Shift-Tab` switch tabs. The Official and Third-party
|
||||
catalogs load lazily, so `/plugins` opens instantly and keeps working offline —
|
||||
a marketplace fetch failure is shown inline instead of closing the panel. The
|
||||
tab strip is shared with the `/model` provider tabs via the new `renderTabStrip`
|
||||
helper.
|
||||
|
||||
- [#1006](https://github.com/MoonshotAI/kimi-code/pull/1006) [`60dfb68`](https://github.com/MoonshotAI/kimi-code/commit/60dfb68a2d4c342cfbad5f48d4d269fb6cdd43c0) - Add server authentication and safe `--host` exposure. The local server now
|
||||
requires a per-start bearer token on all API and WebSocket calls (the CLI reads
|
||||
it automatically), enforces Host/Origin checks, and gains `--host` with a
|
||||
public-binding hardening tier: mandatory `KIMI_CODE_PASSWORD`, TLS (or
|
||||
`--insecure-no-tls`), auth-failure rate limiting, disabled remote
|
||||
shutdown/terminals, and security response headers. See `packages/server/SECURITY.md`.
|
||||
|
||||
- [#1040](https://github.com/MoonshotAI/kimi-code/pull/1040) [`6664038`](https://github.com/MoonshotAI/kimi-code/commit/66640380ebf60141994986beadf5347617f82814) - Replace silent AGENTS.md truncation with a visible warning in the TUI status bar and web UI.
|
||||
|
||||
- [#1101](https://github.com/MoonshotAI/kimi-code/pull/1101) [`3ea6ac2`](https://github.com/MoonshotAI/kimi-code/commit/3ea6ac278d2e57bb859ab423704bbd0fb2033c72) - Show the plan body and approach choices in the plan review card when exiting plan mode in the web UI.
|
||||
|
||||
- [#1103](https://github.com/MoonshotAI/kimi-code/pull/1103) [`18f7c34`](https://github.com/MoonshotAI/kimi-code/commit/18f7c34a0739dab454af1f09d951a1bbf278cccb) - Show a line-by-line diff when the agent edits or writes a file in the web chat.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1072](https://github.com/MoonshotAI/kimi-code/pull/1072) [`a86bb97`](https://github.com/MoonshotAI/kimi-code/commit/a86bb9757d99f32983e82a6a82fd3ccaab691b1a) - Improve the image paste hint.
|
||||
|
||||
- [#1076](https://github.com/MoonshotAI/kimi-code/pull/1076) [`500677a`](https://github.com/MoonshotAI/kimi-code/commit/500677ab8baf9081b73a35df5fbbcfc49cb2f9b7) - Fix Ctrl-C during compaction so it clears a pending editor draft first instead of cancelling immediately.
|
||||
|
||||
- [#1067](https://github.com/MoonshotAI/kimi-code/pull/1067) [`0e227ba`](https://github.com/MoonshotAI/kimi-code/commit/0e227ba18aec793aa4c233be7c578068ae91e604) - Fix explore subagents silently losing git context when git commands time out or the directory is not a repository.
|
||||
|
||||
- [#1075](https://github.com/MoonshotAI/kimi-code/pull/1075) [`3aaf1e5`](https://github.com/MoonshotAI/kimi-code/commit/3aaf1e58037c4045aaa3b9fbabaffa158c60d2ca) - Fix a startup crash on Linux caused by an unhandled native clipboard error.
|
||||
|
||||
- [#1094](https://github.com/MoonshotAI/kimi-code/pull/1094) [`8ee5c0f`](https://github.com/MoonshotAI/kimi-code/commit/8ee5c0ff813d361733226a1606e7c724e5e38f2e) - Fix the terminal window repeatedly losing focus on Linux Wayland, which broke IME input.
|
||||
|
||||
- [#1057](https://github.com/MoonshotAI/kimi-code/pull/1057) [`ee69e16`](https://github.com/MoonshotAI/kimi-code/commit/ee69e16dc8fb18153d7ddff04bef1f4fc593688a) - Fix MCP server working directories when sessions are hosted by the web server.
|
||||
|
||||
- [#1064](https://github.com/MoonshotAI/kimi-code/pull/1064) [`a752a53`](https://github.com/MoonshotAI/kimi-code/commit/a752a5309b3c456f7da0e6141bcd435b497d127a) - Fix truncated skill descriptions missing an ellipsis in the model's skill listing.
|
||||
|
||||
- [#903](https://github.com/MoonshotAI/kimi-code/pull/903) [`bbd8a1a`](https://github.com/MoonshotAI/kimi-code/commit/bbd8a1a947ba26c0e59f98819cab9e20898ff0b7) - Fix `kimi web` and `/web` failing to start the background server daemon on Windows with `spawn EFTYPE` when the CLI is installed via npm/pnpm or run from source. The official single-binary install script was not affected.
|
||||
|
||||
- [#1070](https://github.com/MoonshotAI/kimi-code/pull/1070) [`ff17715`](https://github.com/MoonshotAI/kimi-code/commit/ff177155ca630248bcd692421faab21e7b5be069) - Stop auto-dismissing questions in the web UI after 60 seconds so they wait for the user's answer.
|
||||
|
||||
- [#1097](https://github.com/MoonshotAI/kimi-code/pull/1097) [`27ef516`](https://github.com/MoonshotAI/kimi-code/commit/27ef5166955b5deaecc367a4b3393909b0ccc9f9) - Add a hint to the per-turn step limit error pointing users to the loop_control.max_steps_per_turn config option.
|
||||
|
||||
- [#1062](https://github.com/MoonshotAI/kimi-code/pull/1062) [`ea6a4bf`](https://github.com/MoonshotAI/kimi-code/commit/ea6a4bfe6ef8914f67f254f24b0c5c543c48a341) - Preserve full tool output logs when previews are truncated and link background task completion notifications to saved output.
|
||||
|
||||
- [#1086](https://github.com/MoonshotAI/kimi-code/pull/1086) [`fe667d7`](https://github.com/MoonshotAI/kimi-code/commit/fe667d7c2ef113aef8a9546148f980d9adf560a3) - `/reload` now refreshes the assistant's view of plugin skills, so plugin changes take effect in the current session instead of requiring a new one.
|
||||
|
||||
- [#1081](https://github.com/MoonshotAI/kimi-code/pull/1081) [`8fc6aa5`](https://github.com/MoonshotAI/kimi-code/commit/8fc6aa5f6842aa78acf8f23912342b721efcf7a9) - Sync session title changes across all connected clients in server mode.
|
||||
|
||||
- [#1078](https://github.com/MoonshotAI/kimi-code/pull/1078) [`75ca3b2`](https://github.com/MoonshotAI/kimi-code/commit/75ca3b21609d7197bb2c9b4389901595840ac7e3) - Add Ctrl+U and Ctrl+D as page up and page down shortcuts in the task output viewer.
|
||||
|
||||
- [#1069](https://github.com/MoonshotAI/kimi-code/pull/1069) [`d18aa16`](https://github.com/MoonshotAI/kimi-code/commit/d18aa1666a09b038d5a107e9a37fff1031b2e847) - Reduce streaming redraw cost for long assistant messages with code blocks.
|
||||
|
||||
- [#1112](https://github.com/MoonshotAI/kimi-code/pull/1112) [`6a97d0b`](https://github.com/MoonshotAI/kimi-code/commit/6a97d0bf431bc7038ce801da21164a67e07422d8) - Add a copy button to user messages in the web chat.
|
||||
|
||||
- [#1035](https://github.com/MoonshotAI/kimi-code/pull/1035) [`ea03f30`](https://github.com/MoonshotAI/kimi-code/commit/ea03f30e5174825049ed4dfedebf8e43fbe751a4) - Render LaTeX display math (`$$…$$`) in the web chat via KaTeX. Single `$` is intentionally left as literal text, so prices, env vars, and shell paths (e.g. `$PATH`, `$5/$10`, `$HOME/bin`) are never swallowed as a formula.
|
||||
|
||||
- [#1084](https://github.com/MoonshotAI/kimi-code/pull/1084) [`d6e5246`](https://github.com/MoonshotAI/kimi-code/commit/d6e524682d9fb95460fceb86e17632ed858f7fcb) - Page the web session list per workspace so the first screen no longer fetches every session up front.
|
||||
|
||||
- [#1113](https://github.com/MoonshotAI/kimi-code/pull/1113) [`6194d3f`](https://github.com/MoonshotAI/kimi-code/commit/6194d3fad3b53e6c2b80c422fe98043145494655) - Keep the web session sidebar from re-rendering on every streaming token. The
|
||||
event reducer now reuses the `sessions` array reference for events that do not
|
||||
change sessions, so the sidebar computeds (`sessionsForView` / `workspaceGroups`
|
||||
/ `mergedWorkspaces`) are no longer dirtied by unrelated high-frequency events.
|
||||
|
||||
- [#1087](https://github.com/MoonshotAI/kimi-code/pull/1087) [`884b65a`](https://github.com/MoonshotAI/kimi-code/commit/884b65a04014be8d68ffd406f89fc2d26af6e62c) - Fix duplicate session snapshot reloads in the bundled web UI during resync.
|
||||
|
||||
- [#1109](https://github.com/MoonshotAI/kimi-code/pull/1109) [`d554f9a`](https://github.com/MoonshotAI/kimi-code/commit/d554f9ac8771be09b5c9a56943167dd45108dc4f) - Show the full accumulated progress of a subagent in its detail panel, with concise tool-call summaries instead of raw JSON.
|
||||
|
||||
- [#1065](https://github.com/MoonshotAI/kimi-code/pull/1065) [`4b837d6`](https://github.com/MoonshotAI/kimi-code/commit/4b837d6bfbf3850807b5f88ccdd10f31e69b019c) - Create missing parent directories automatically when writing a file.
|
||||
|
||||
## 0.19.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#999](https://github.com/MoonshotAI/kimi-code/pull/999) [`6b68aa8`](https://github.com/MoonshotAI/kimi-code/commit/6b68aa85e2a58cfdaacba5580f66a6a74550ccf6) - Add `-c` as a shorthand for `--continue`.
|
||||
|
||||
- [#1028](https://github.com/MoonshotAI/kimi-code/pull/1028) [`be77d5d`](https://github.com/MoonshotAI/kimi-code/commit/be77d5da03b96ebc24169ef563be1dc1c545590f) - Show a transient footer hint when an image is detected in the clipboard, displaying the platform-appropriate paste shortcut.
|
||||
|
||||
- [#1004](https://github.com/MoonshotAI/kimi-code/pull/1004) [`d70c3a8`](https://github.com/MoonshotAI/kimi-code/commit/d70c3a8c0121f55e5f29f9a2ad01b17df449467a) - Show the command in running Bash tool cards and allow expanding it with Ctrl+O before the result arrives.
|
||||
|
||||
- [#1009](https://github.com/MoonshotAI/kimi-code/pull/1009) [`e47de61`](https://github.com/MoonshotAI/kimi-code/commit/e47de610e4de9b11ccd182c0c16387f9d3fb0de4) - Add a Ctrl+T shortcut to expand and collapse a truncated todo list.
|
||||
|
||||
- [#1028](https://github.com/MoonshotAI/kimi-code/pull/1028) [`be77d5d`](https://github.com/MoonshotAI/kimi-code/commit/be77d5da03b96ebc24169ef563be1dc1c545590f) - Fix stale rows occasionally leaving duplicate input boxes after tall content shrinks.
|
||||
|
||||
- [#1028](https://github.com/MoonshotAI/kimi-code/pull/1028) [`be77d5d`](https://github.com/MoonshotAI/kimi-code/commit/be77d5da03b96ebc24169ef563be1dc1c545590f) - Fix inline images being rendered as broken escape sequences in the transcript.
|
||||
|
||||
- [#1027](https://github.com/MoonshotAI/kimi-code/pull/1027) [`c240bfa`](https://github.com/MoonshotAI/kimi-code/commit/c240bfab7d2b00d41b993f681be612f2db45baa7) - Fix resume not realigning a tool call that was interrupted mid-history. The synthetic interrupted result is now closed in place at the next step boundary, so later turns and deferred messages keep their recorded order instead of only the trailing exchange being repaired. The `/messages` wire transcript reducer mirrors the same closure so its folded length stays aligned with live history, preventing the later turn from being duplicated/reordered. Replay also drops a tool result whose call is no longer awaiting one, so a stale interrupted result left at the log tail by an older resume of a damaged session is not re-applied as a duplicate.
|
||||
|
||||
- [#1012](https://github.com/MoonshotAI/kimi-code/pull/1012) [`fd16ffb`](https://github.com/MoonshotAI/kimi-code/commit/fd16ffb80a90fda8a611a27a158b9b7a33e13303) - Show subcommand suggestions after Tab-completing a slash command name.
|
||||
|
||||
- [#1012](https://github.com/MoonshotAI/kimi-code/pull/1012) [`fd16ffb`](https://github.com/MoonshotAI/kimi-code/commit/fd16ffb80a90fda8a611a27a158b9b7a33e13303) - Fix the Tab key unexpectedly opening the file completion list.
|
||||
|
||||
- [#1044](https://github.com/MoonshotAI/kimi-code/pull/1044) [`9d197e0`](https://github.com/MoonshotAI/kimi-code/commit/9d197e0f67c879306b9d7659d66e9295e63faa5a) - Fix clipboard copy actions in the web UI when served over plain HTTP.
|
||||
|
||||
- [#1032](https://github.com/MoonshotAI/kimi-code/pull/1032) [`a753b05`](https://github.com/MoonshotAI/kimi-code/commit/a753b0535e44f624289715bd560cf1346149e786) - Fix code blocks nested inside list items rendering blank in the web chat after a turn finishes generating.
|
||||
|
||||
- [#1015](https://github.com/MoonshotAI/kimi-code/pull/1015) [`83384ee`](https://github.com/MoonshotAI/kimi-code/commit/83384ee6d46b37c00b7b8f160a7c48aebbd6921e) - Fix the composer's ↑/↓ input-history recall doing nothing right after the first message of a new session. The history is now persisted to localStorage and re-read on mount, so the docked composer no longer starts empty when it takes over from the empty-session composer. Slash commands are now recorded too — both typed-and-submitted and ones picked from the slash menu — so they can be recalled like plain messages.
|
||||
|
||||
- [#1003](https://github.com/MoonshotAI/kimi-code/pull/1003) [`e15edfd`](https://github.com/MoonshotAI/kimi-code/commit/e15edfd017506fde396b8b0dcf68008b61b39752) - Fix the web question prompt missing the free-text Other option.
|
||||
|
||||
- [#1056](https://github.com/MoonshotAI/kimi-code/pull/1056) [`b93e936`](https://github.com/MoonshotAI/kimi-code/commit/b93e9365b68d53f8f1a148e7349a5865b1b669a8) - Fix yolo mode in the web app auto-approving plan reviews and sensitive file access.
|
||||
|
||||
- [#971](https://github.com/MoonshotAI/kimi-code/pull/971) [`b84704b`](https://github.com/MoonshotAI/kimi-code/commit/b84704bff39ae5cb382d2a8dc0911db286e84ead) - Read large text files in bounded memory and read tail lines without scanning whole files.
|
||||
|
||||
- [#1020](https://github.com/MoonshotAI/kimi-code/pull/1020) [`9c553e4`](https://github.com/MoonshotAI/kimi-code/commit/9c553e4bf7d0a2c09030212fe06577343ea76a60) - Add an Alt+S shortcut in the model picker to switch the model for the current session only, without saving it as the default.
|
||||
|
||||
- [#1043](https://github.com/MoonshotAI/kimi-code/pull/1043) [`27df39c`](https://github.com/MoonshotAI/kimi-code/commit/27df39c7ed2b012815c380a33fe56bd37c7fc7c1) - Fix web chat stop actions so stale prompt ids fall back to cancelling the active session.
|
||||
|
||||
- [#1036](https://github.com/MoonshotAI/kimi-code/pull/1036) [`866b91c`](https://github.com/MoonshotAI/kimi-code/commit/866b91c8f5dc98dfc18e5c658beaa11afea5032e) - Reorganize the web app's components into area subdirectories (chat/settings/dialogs/mobile) and refresh the component path comments.
|
||||
|
||||
- [#1042](https://github.com/MoonshotAI/kimi-code/pull/1042) [`dc6b9ef`](https://github.com/MoonshotAI/kimi-code/commit/dc6b9ef02bf7583d166c8c5b001a960329c225f8) - Add a development-mode indicator to the web sidebar for local development.
|
||||
|
||||
- [#1047](https://github.com/MoonshotAI/kimi-code/pull/1047) [`98d3e5b`](https://github.com/MoonshotAI/kimi-code/commit/98d3e5b71d5760475f7a5a23b2b794584d12b89b) - Keep the web sidebar's workspace order stable and let workspaces be reordered by drag-and-drop, persisted locally instead of following recent activity; sessions now also float to the top of their group as soon as a new message arrives.
|
||||
|
||||
- [#1034](https://github.com/MoonshotAI/kimi-code/pull/1034) [`603a767`](https://github.com/MoonshotAI/kimi-code/commit/603a7679de91e221802a7f7b0ab7df23c7e5526c) - Extract the composer's image/video attachment handling into a reusable composable.
|
||||
|
||||
- [#1031](https://github.com/MoonshotAI/kimi-code/pull/1031) [`2bfd686`](https://github.com/MoonshotAI/kimi-code/commit/2bfd6860e487f902be53fd5f52f03e66d1839ae2) - Extract the composer's text state and per-session draft persistence into a reusable composable.
|
||||
|
||||
- [#1011](https://github.com/MoonshotAI/kimi-code/pull/1011) [`fb780fc`](https://github.com/MoonshotAI/kimi-code/commit/fb780fce9665e2119cee6d0bc7f85895c6970865) - Extract the composer's shell-style input-history recall into a reusable composable.
|
||||
|
||||
- [#1030](https://github.com/MoonshotAI/kimi-code/pull/1030) [`661c1fb`](https://github.com/MoonshotAI/kimi-code/commit/661c1fbe5b026ec32d80696290a18313b24eafef) - Extract the composer's @-mention menu logic into a reusable composable.
|
||||
|
||||
- [#1026](https://github.com/MoonshotAI/kimi-code/pull/1026) [`318c964`](https://github.com/MoonshotAI/kimi-code/commit/318c964f074123ad228cbddcf7809fa4baaa7fb2) - Extract the composer's slash-command menu logic into a reusable composable.
|
||||
|
||||
- [#1045](https://github.com/MoonshotAI/kimi-code/pull/1045) [`ac1882f`](https://github.com/MoonshotAI/kimi-code/commit/ac1882fe28c906904ffaacd8434bb20e689d6677) - Persist the collapsed state of workspace groups in the web sidebar across page reloads.
|
||||
|
||||
- [#1001](https://github.com/MoonshotAI/kimi-code/pull/1001) [`ea1b33b`](https://github.com/MoonshotAI/kimi-code/commit/ea1b33b6743b822aa5083dbeb2d5e84a78b0ab3d) - Extract pure turn-rendering helpers out of the chat pane into their own module.
|
||||
|
||||
- [#1010](https://github.com/MoonshotAI/kimi-code/pull/1010) [`a2650f8`](https://github.com/MoonshotAI/kimi-code/commit/a2650f85d467707e7c85d22cff590f68852d33f3) - Extract the beta conversation outline (table of contents) into its own component.
|
||||
|
||||
- [#998](https://github.com/MoonshotAI/kimi-code/pull/998) [`3e4793d`](https://github.com/MoonshotAI/kimi-code/commit/3e4793d6111059cbfb97159f682ed4bd7a33441d) - Extract the workspace group rendering out of the sidebar into its own component.
|
||||
|
||||
- [#985](https://github.com/MoonshotAI/kimi-code/pull/985) [`92c2cf0`](https://github.com/MoonshotAI/kimi-code/commit/92c2cf0ef57f00928d337bcfeb1d7eff9b0d0f7f) - Allow the web sidebar and detail panel to be resized up to the available viewport width, keeping their resize handles reachable on narrow windows.
|
||||
|
||||
- [#1033](https://github.com/MoonshotAI/kimi-code/pull/1033) [`b1e6b64`](https://github.com/MoonshotAI/kimi-code/commit/b1e6b6431903fde002fdddbdfcabfab39f3ef5c5) - Optimize the loading tips display.
|
||||
|
||||
## 0.19.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#992](https://github.com/MoonshotAI/kimi-code/pull/992) [`7341fb4`](https://github.com/MoonshotAI/kimi-code/commit/7341fb4979523d4429ccf9177b5e3907f544d8c0) - Fix ACP editors such as Zed failing to start a new thread.
|
||||
|
||||
- [#984](https://github.com/MoonshotAI/kimi-code/pull/984) [`da81858`](https://github.com/MoonshotAI/kimi-code/commit/da81858802127cb8bb8ed2deaa1989793b356adf) - Clear all per-session state when a session is archived or removed, so archived sessions no longer leave orphaned data behind.
|
||||
|
||||
- [#978](https://github.com/MoonshotAI/kimi-code/pull/978) [`d4ae02d`](https://github.com/MoonshotAI/kimi-code/commit/d4ae02d82e9da0d163ea4235a54d6535c591172e) - Fix the web sidebar's unread dots getting out of sync across browser tabs.
|
||||
|
||||
- [#979](https://github.com/MoonshotAI/kimi-code/pull/979) [`8c6cade`](https://github.com/MoonshotAI/kimi-code/commit/8c6cade69efa42fdcc280f51a283ea6f717d62fc) - Consolidate web client localStorage access and split the root state store and app shell into focused composables.
|
||||
|
||||
## 0.19.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#812](https://github.com/MoonshotAI/kimi-code/pull/812) [`c0eeca2`](https://github.com/MoonshotAI/kimi-code/commit/c0eeca24692edd736eecd3c2541d7566bac9f80f) - Added the ability to add extra workspace directories:
|
||||
|
||||
- Use the `/add-dir <path>` command to add extra working directories to the current session, or remember them for the project.
|
||||
- Use `kimi --add-dir <path>` to add them on startup.
|
||||
- Project-level local config is now managed in `.kimi-code/local.toml`; we recommend adding it to your `.gitignore`.
|
||||
|
||||
- [#975](https://github.com/MoonshotAI/kimi-code/pull/975) [`c5c1834`](https://github.com/MoonshotAI/kimi-code/commit/c5c18347251221fab74e4f452ac4910116c4224d) - Speed up session snapshot loading with a direct disk reader and a request timeout safeguard, keeping the previous path as a legacy fallback.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#910](https://github.com/MoonshotAI/kimi-code/pull/910) [`7644f10`](https://github.com/MoonshotAI/kimi-code/commit/7644f1036ca1079e4527c0b1c825ec5384d6d8da) - Fix provider requests failing when restored conversation history contains empty text content blocks.
|
||||
|
||||
- [#963](https://github.com/MoonshotAI/kimi-code/pull/963) [`4292ae9`](https://github.com/MoonshotAI/kimi-code/commit/4292ae9f9bc49e9edaaaeae50dbddabbd4b9bb25) - Surface provider safety-policy blocks instead of silently treating them as completed turns, and prevent the context token count from dropping to zero after a filtered response.
|
||||
|
||||
- [#970](https://github.com/MoonshotAI/kimi-code/pull/970) [`2730079`](https://github.com/MoonshotAI/kimi-code/commit/27300797f2149900219b05dda49dce65e71fa85a) - Detect the real image format from file contents when reading media, so a mismatched filename extension no longer produces a data URL the model API rejects.
|
||||
|
||||
- [#977](https://github.com/MoonshotAI/kimi-code/pull/977) [`d521932`](https://github.com/MoonshotAI/kimi-code/commit/d521932c3e99a0c5fa1d5d658cf1cd64f0306a75) - Stop showing unread dots on cancelled or failed sessions in the web sidebar.
|
||||
|
||||
- [#957](https://github.com/MoonshotAI/kimi-code/pull/957) [`b57fc90`](https://github.com/MoonshotAI/kimi-code/commit/b57fc905fe480aac07839dd0213768dbeb2a8002) - Fix commands flashing an empty console window on Windows.
|
||||
|
||||
- [#821](https://github.com/MoonshotAI/kimi-code/pull/821) [`ba64072`](https://github.com/MoonshotAI/kimi-code/commit/ba64072559c1e9bb3447ede39991ac2e8bdb7645) - Allow long-running foreground commands and subagents to be moved into background tasks with Ctrl+B, and inspect them via the `/tasks` panel.
|
||||
|
||||
- [#812](https://github.com/MoonshotAI/kimi-code/pull/812) [`c0eeca2`](https://github.com/MoonshotAI/kimi-code/commit/c0eeca24692edd736eecd3c2541d7566bac9f80f) - Polish file mention UX.
|
||||
|
||||
- [#974](https://github.com/MoonshotAI/kimi-code/pull/974) [`d434d8f`](https://github.com/MoonshotAI/kimi-code/commit/d434d8f0d809599f4ae7de77b58e337bfd4ebcc9) - Unify image format detection when sniffing fails.
|
||||
|
||||
- [#958](https://github.com/MoonshotAI/kimi-code/pull/958) [`98905eb`](https://github.com/MoonshotAI/kimi-code/commit/98905eb409ec643fd916a13beecec85212f834bd) - Show longer branch names in the web chat header and expose the full name on hover.
|
||||
|
||||
- [#964](https://github.com/MoonshotAI/kimi-code/pull/964) [`4223739`](https://github.com/MoonshotAI/kimi-code/commit/42237392ddc3a0816c045da23e77c4875cc692e5) - Keep the web page title fixed instead of changing with the session or workspace name.
|
||||
|
||||
- [#973](https://github.com/MoonshotAI/kimi-code/pull/973) [`3b9938b`](https://github.com/MoonshotAI/kimi-code/commit/3b9938b4c3a386394ed4d35c7b89b48878476977) - Consolidate web client localStorage access and decouple appearance/notification state into dedicated modules.
|
||||
|
||||
## 0.18.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#888](https://github.com/MoonshotAI/kimi-code/pull/888) [`58898de`](https://github.com/MoonshotAI/kimi-code/commit/58898de0200d6626ca634e344fe85b860abcfd1b) - Add an environment variable to cap AgentSwarm concurrency during the initial ramp, so large swarms do not trip provider rate limits as easily.
|
||||
|
||||
- [#895](https://github.com/MoonshotAI/kimi-code/pull/895) [`495fe8c`](https://github.com/MoonshotAI/kimi-code/commit/495fe8c674d654cdf87217ca4ada775507f861f6) - Add instant session search to the web sidebar, filtering by title and the last user prompt.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#896](https://github.com/MoonshotAI/kimi-code/pull/896) [`de610de`](https://github.com/MoonshotAI/kimi-code/commit/de610deb5f760606b82cc595e59c5176cc66ce82) - Fix the web workspace session count so it drops to 0 after archiving the last session instead of staying at 1.
|
||||
|
||||
- [#876](https://github.com/MoonshotAI/kimi-code/pull/876) [`49183d8`](https://github.com/MoonshotAI/kimi-code/commit/49183d8729e3e7d361a253dc5c68f409e6382ba9) - Suggest `/reload` alongside `/new` in plugin-change hints.
|
||||
|
||||
- [#867](https://github.com/MoonshotAI/kimi-code/pull/867) [`d1dc2a3`](https://github.com/MoonshotAI/kimi-code/commit/d1dc2a3e77ec1422d60cb008c5520a44a2ed7c00) - Redesign the web OAuth login dialog: lead with a single "Authorize in browser" button that opens the verification link with the device code already embedded, demote manual code entry to a clearly secondary fallback, and drop the duplicate open-browser and cancel controls so the order of steps is unambiguous.
|
||||
|
||||
- [#867](https://github.com/MoonshotAI/kimi-code/pull/867) [`d1dc2a3`](https://github.com/MoonshotAI/kimi-code/commit/d1dc2a3e77ec1422d60cb008c5520a44a2ed7c00) - Fix the web login slash command description to match the browser authorization flow.
|
||||
|
||||
- [#893](https://github.com/MoonshotAI/kimi-code/pull/893) [`d7ec056`](https://github.com/MoonshotAI/kimi-code/commit/d7ec05686a09580f9ffd99f6ef26385aed8eb02c) - Add scroll-up lazy loading for older messages in the web chat session view, and fix the "new messages" pill overlapping the composer dock.
|
||||
|
||||
- [#882](https://github.com/MoonshotAI/kimi-code/pull/882) [`8ab9e96`](https://github.com/MoonshotAI/kimi-code/commit/8ab9e969637ffee18b09a0b265ffa860c5a2e11c) - Fix the web app only loading the 20 most recent sessions; it now follows pagination so older sessions are reachable.
|
||||
|
||||
- [#889](https://github.com/MoonshotAI/kimi-code/pull/889) [`23277a5`](https://github.com/MoonshotAI/kimi-code/commit/23277a574c7e0782c04f62e10370494247be3a66) - Show the connected server version in the web settings General tab.
|
||||
|
||||
- [#881](https://github.com/MoonshotAI/kimi-code/pull/881) [`7bc3d99`](https://github.com/MoonshotAI/kimi-code/commit/7bc3d99933b0bbc3f9188a2b02bcc90e81623f72) - Keep the highlighted web slash command visible while navigating a long slash menu.
|
||||
|
||||
- [#878](https://github.com/MoonshotAI/kimi-code/pull/878) [`a74a6b7`](https://github.com/MoonshotAI/kimi-code/commit/a74a6b7f6b1d13d24eae356a2208c012128b180d) - Allow long web slash command names and descriptions to wrap without overflowing the slash menu.
|
||||
|
||||
- [#878](https://github.com/MoonshotAI/kimi-code/pull/878) [`a74a6b7`](https://github.com/MoonshotAI/kimi-code/commit/a74a6b7f6b1d13d24eae356a2208c012128b180d) - Fix web slash skill selection sending immediately and allow slash search to match skill names by substring.
|
||||
|
||||
## 0.17.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#861](https://github.com/MoonshotAI/kimi-code/pull/861) [`bd09795`](https://github.com/MoonshotAI/kimi-code/commit/bd0979578bcad5fe3bf989e022b7823824f3f25c) - Prevent the web login dialog from closing when clicking the backdrop.
|
||||
|
||||
- [#860](https://github.com/MoonshotAI/kimi-code/pull/860) [`0e2877b`](https://github.com/MoonshotAI/kimi-code/commit/0e2877bee347466ed6cc8afda9f9faf338069012) - Stop the background local server from locking the directory it was started in.
|
||||
|
||||
- [#860](https://github.com/MoonshotAI/kimi-code/pull/860) [`0e2877b`](https://github.com/MoonshotAI/kimi-code/commit/0e2877bee347466ed6cc8afda9f9faf338069012) - Fix the local server failing to start in the background on the native binary.
|
||||
|
||||
- [#861](https://github.com/MoonshotAI/kimi-code/pull/861) [`bd09795`](https://github.com/MoonshotAI/kimi-code/commit/bd0979578bcad5fe3bf989e022b7823824f3f25c) - Group the default model dropdown in web settings by provider.
|
||||
|
||||
## 0.17.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#625](https://github.com/MoonshotAI/kimi-code/pull/625) [`9a8fea5`](https://github.com/MoonshotAI/kimi-code/commit/9a8fea5c85177cd887896108c05ba9e174f28250) - Add the server-hosted web UI and the CLI commands that power it:
|
||||
|
||||
- `kimi server` to start, stop, and manage the local server.
|
||||
- `kimi web` to open the server-hosted web UI in a browser.
|
||||
- Server REST and WebSocket APIs for the web client.
|
||||
- Web chat layout, session list, auto-scroll, and related behaviors.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#838](https://github.com/MoonshotAI/kimi-code/pull/838) [`843a731`](https://github.com/MoonshotAI/kimi-code/commit/843a731097fc18b2e41ab0405b5fbcb6149ba55c) - Show the underlying connection error when OAuth token refresh fails after internal retries, instead of prompting for login. Token refresh failures are no longer re-retried at the agent loop level.
|
||||
|
||||
- [#849](https://github.com/MoonshotAI/kimi-code/pull/849) [`254f946`](https://github.com/MoonshotAI/kimi-code/commit/254f946a506b01df7a559ed63bd8d705e9fa7496) - Skip debug TPS when the output stream is too short to measure reliably.
|
||||
|
||||
- [#833](https://github.com/MoonshotAI/kimi-code/pull/833) [`a71b2e3`](https://github.com/MoonshotAI/kimi-code/commit/a71b2e3123ff8454f725b3d24e8c985608c5c4f9) - Restore the turn counter from persisted loop events on resume so post-resume turns no longer reuse turn ids that already appear in history.
|
||||
|
||||
- [#853](https://github.com/MoonshotAI/kimi-code/pull/853) [`05fe759`](https://github.com/MoonshotAI/kimi-code/commit/05fe7595ab9bac8230fd9f2fe7bdbaaa157ddc9b) - Fix the web login page and no-workspace conversation startup flow.
|
||||
|
||||
## 0.16.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#788](https://github.com/MoonshotAI/kimi-code/pull/788) [`efdf8a1`](https://github.com/MoonshotAI/kimi-code/commit/efdf8a1b2d4e906fbb35620083c3e7b490e0e88a) - Add a built-in `kimi vis` command that launches the session visualizer in your browser, pointed at your local sessions. Supports `--port`/`--host`, `--no-open`, and `kimi vis <sessionId>` deep-links.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#790](https://github.com/MoonshotAI/kimi-code/pull/790) [`d0d5821`](https://github.com/MoonshotAI/kimi-code/commit/d0d58219007cd9d7355f1ea8900e9777b66abda2) - Stop Anthropic-compatible providers from reading ambient Anthropic shell credentials and custom headers.
|
||||
|
||||
- [#809](https://github.com/MoonshotAI/kimi-code/pull/809) [`6f442bd`](https://github.com/MoonshotAI/kimi-code/commit/6f442bd8cde29e21526fa36c9836e2d4c282b4bf) - Add configurable banner display frequencies with local display state.
|
||||
|
||||
- [#807](https://github.com/MoonshotAI/kimi-code/pull/807) [`b45672c`](https://github.com/MoonshotAI/kimi-code/commit/b45672cdaac9959024c3ae36bf35b16a423aa1dc) - Close wrapped output streams when buffered readers are destroyed.
|
||||
|
||||
- [#813](https://github.com/MoonshotAI/kimi-code/pull/813) [`7b5b818`](https://github.com/MoonshotAI/kimi-code/commit/7b5b8188157ec902e5cd4e73545bc5ca6c52bb76) - Fix repeated compaction handling when context remains over the blocking threshold.
|
||||
|
||||
- [#801](https://github.com/MoonshotAI/kimi-code/pull/801) [`ff332be`](https://github.com/MoonshotAI/kimi-code/commit/ff332be6d364ce3d5974133deb7c76220684181a) - Polish queue pane styling
|
||||
|
||||
- [#802](https://github.com/MoonshotAI/kimi-code/pull/802) [`aa1896c`](https://github.com/MoonshotAI/kimi-code/commit/aa1896ca749e41a67d7c4b655dcc8be830cbec82) - Reduce the maximum height of the /btw side panel from half to one-third of the terminal.
|
||||
|
||||
- [#805](https://github.com/MoonshotAI/kimi-code/pull/805) [`3e6196e`](https://github.com/MoonshotAI/kimi-code/commit/3e6196e6b227c66860651f4335e06973865b2714) - Project session replay ranges over rendered replay records instead of raw persisted records.
|
||||
|
||||
- [#804](https://github.com/MoonshotAI/kimi-code/pull/804) [`299b9fc`](https://github.com/MoonshotAI/kimi-code/commit/299b9fcad4c9c4b755fae4dfae01a1dbf60aec3c) - Prevent session shutdown from resuming the agent when stopping background tasks.
|
||||
|
||||
- [#823](https://github.com/MoonshotAI/kimi-code/pull/823) [`90fc04b`](https://github.com/MoonshotAI/kimi-code/commit/90fc04b7072ec20055022c50583d35286ca715a6) - Remove redundant LLM request logging context plumbing.
|
||||
|
||||
## 0.15.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#779](https://github.com/MoonshotAI/kimi-code/pull/779) [`2746c71`](https://github.com/MoonshotAI/kimi-code/commit/2746c71c47058d9a3bb73e27a07ebfcf44bf4119) - Add an all-sessions picker view with name search, paginated browsing, and clipboard-ready resume commands for sessions in other working directories.
|
||||
|
||||
- [#744](https://github.com/MoonshotAI/kimi-code/pull/744) [`18f299f`](https://github.com/MoonshotAI/kimi-code/commit/18f299fd0b266545a1f7cebae9f58b83b9d9776e) - Add support for legacy SSE MCP servers alongside stdio and streamable HTTP transports.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#777](https://github.com/MoonshotAI/kimi-code/pull/777) [`4516f62`](https://github.com/MoonshotAI/kimi-code/commit/4516f62f6a7e4dd7675a3aec16b2a26c5e310d83) - Clarify AGENTS.md prompt guidance and mark truncated instruction files.
|
||||
|
||||
- [#780](https://github.com/MoonshotAI/kimi-code/pull/780) [`8a92db6`](https://github.com/MoonshotAI/kimi-code/commit/8a92db6a0c110a21c6e6e86622f498e836178e5f) - Prompt the CLI to show one brief same-language status sentence before non-trivial tool calls.
|
||||
|
||||
- [#786](https://github.com/MoonshotAI/kimi-code/pull/786) [`e10b25f`](https://github.com/MoonshotAI/kimi-code/commit/e10b25f9be18ca64aada0d0a3cab0e02fdbd46df) - Stop writing resume version markers into persisted agent metadata.
|
||||
|
||||
- [#768](https://github.com/MoonshotAI/kimi-code/pull/768) [`c6a9967`](https://github.com/MoonshotAI/kimi-code/commit/c6a996756cd8f1fb317b6eee6f4e668eebc7dc14) - Recover resumed sessions when an interrupted tool call result was not recorded.
|
||||
|
||||
- [#775](https://github.com/MoonshotAI/kimi-code/pull/775) [`3fa1b8e`](https://github.com/MoonshotAI/kimi-code/commit/3fa1b8ea7deb558b88073b5f7b02857e52c3f60c) - Optimize the npm packaging system.
|
||||
|
||||
- [#343](https://github.com/MoonshotAI/kimi-code/pull/343) [`73be7ba`](https://github.com/MoonshotAI/kimi-code/commit/73be7ba17d41df7999d4c1fba410994e7024eb7b) - Repair mismatched JSON Schema types emitted by Xcode 26.5 MCP server for Moonshot compatibility.
|
||||
|
||||
- [#777](https://github.com/MoonshotAI/kimi-code/pull/777) [`4516f62`](https://github.com/MoonshotAI/kimi-code/commit/4516f62f6a7e4dd7675a3aec16b2a26c5e310d83) - Collapse hidden directories in the workspace prompt and explain how to inspect them.
|
||||
|
||||
- [#766](https://github.com/MoonshotAI/kimi-code/pull/766) [`9cef896`](https://github.com/MoonshotAI/kimi-code/commit/9cef89656311974a57e6675f474ea6c2adb1d8e9) - Clarify that compaction summaries must be emitted in the final answer.
|
||||
|
||||
- [#765](https://github.com/MoonshotAI/kimi-code/pull/765) [`046856b`](https://github.com/MoonshotAI/kimi-code/commit/046856b740afb604132e914f1fc489de72394036) - Read media files using header-detected types before falling back to media extensions.
|
||||
|
||||
- [#779](https://github.com/MoonshotAI/kimi-code/pull/779) [`2746c71`](https://github.com/MoonshotAI/kimi-code/commit/2746c71c47058d9a3bb73e27a07ebfcf44bf4119) - Show the all-sessions toggle hint when the current working directory has no sessions.
|
||||
|
||||
- [#785](https://github.com/MoonshotAI/kimi-code/pull/785) [`4578f05`](https://github.com/MoonshotAI/kimi-code/commit/4578f05f44101f24d45c6452e2a6993cbb52e331) - Include the skill's directory on the loaded-skill context block so the agent can locate a skill's bundled resources (scripts, templates) after it is invoked.
|
||||
|
||||
- [#784](https://github.com/MoonshotAI/kimi-code/pull/784) [`a562ef5`](https://github.com/MoonshotAI/kimi-code/commit/a562ef54e537a36211c48f0fe19e9252e83397a0) - Decouple agent skill access from session-specific registry implementations.
|
||||
|
||||
- [#772](https://github.com/MoonshotAI/kimi-code/pull/772) [`d47e699`](https://github.com/MoonshotAI/kimi-code/commit/d47e699015f02f4f76723aa8fb17d51a74aa74ff) - Do not carry obsolete legacy loop, background, plan, yolo, or unknown experimental flags into migrated config files.
|
||||
|
||||
- [#783](https://github.com/MoonshotAI/kimi-code/pull/783) [`e2a407c`](https://github.com/MoonshotAI/kimi-code/commit/e2a407ce31685220b2f891a7f6d8b89c62418c98) - Keep TUI components within narrow terminal widths by wrapping, compacting, or truncating lines that could exceed the render width.
|
||||
|
||||
- [#776](https://github.com/MoonshotAI/kimi-code/pull/776) [`ecd7a0a`](https://github.com/MoonshotAI/kimi-code/commit/ecd7a0afb646d14a14c780a4088fd8a59da134ad) - Resolve model capabilities through a static lookup instead of instantiating a temporary provider.
|
||||
|
||||
- [#767](https://github.com/MoonshotAI/kimi-code/pull/767) [`a355f2a`](https://github.com/MoonshotAI/kimi-code/commit/a355f2af2fd68ad9e2bdc72ce854cd18c8242ce8) - Prioritize clearing draft editor text before Ctrl-C cancels an active stream.
|
||||
|
||||
- [#787](https://github.com/MoonshotAI/kimi-code/pull/787) [`1eb363f`](https://github.com/MoonshotAI/kimi-code/commit/1eb363f655aa44abc1e5c3af89016f00764ecc95) - Extend the same-language rule to the model's reasoning, so thinking follows the user's language while keeping code and technical terms in their original form.
|
||||
|
||||
## 0.14.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#713](https://github.com/MoonshotAI/kimi-code/pull/713) [`f874251`](https://github.com/MoonshotAI/kimi-code/commit/f874251288927243a9b9d4bfd546e8c17754d566) - Refresh provider model metadata before opening the model picker.
|
||||
|
||||
## 0.14.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#683](https://github.com/MoonshotAI/kimi-code/pull/683) [`ad239cb`](https://github.com/MoonshotAI/kimi-code/commit/ad239cb1c08266a442c9ca0382fefed87bcb1fd4) - Allow `--auto`, `--yolo`, and `--plan` to be combined with `--session` or `--continue` by applying the requested mode to the resumed session.
|
||||
|
||||
- [#690](https://github.com/MoonshotAI/kimi-code/pull/690) [`7f0dde2`](https://github.com/MoonshotAI/kimi-code/commit/7f0dde2ece3f9a004e934d69258dfd47c954043c) - Fix endless desktop notifications in iTerm2 by only sending terminal progress sequences to terminals that support them.
|
||||
|
||||
- [#651](https://github.com/MoonshotAI/kimi-code/pull/651) [`c39c625`](https://github.com/MoonshotAI/kimi-code/commit/c39c62590db708fc81bd8627ea661c38f3fff9af) - Qualify sub-skill names with their parent prefix and expose sub-skills as dotted slash commands in the TUI.
|
||||
|
||||
- [#617](https://github.com/MoonshotAI/kimi-code/pull/617) [`911e7c3`](https://github.com/MoonshotAI/kimi-code/commit/911e7c3fcfc8a005b1b8d90388260d1a4032f76f) - Show completed and cancelled compaction records correctly when resuming a session.
|
||||
|
||||
- [#676](https://github.com/MoonshotAI/kimi-code/pull/676) [`dcf3075`](https://github.com/MoonshotAI/kimi-code/commit/dcf30754d09c7560101bc410387792194c3fe2b4) - Stream foreground Bash stdout and stderr while commands are still running.
|
||||
|
||||
- [#692](https://github.com/MoonshotAI/kimi-code/pull/692) [`7ca9bdf`](https://github.com/MoonshotAI/kimi-code/commit/7ca9bdfed516d148b063229a9686a28f9e29aaef) - Skip re-entering plan mode when resuming a session that is already in plan mode (previously failed with "Already in plan mode"), and stop re-applying `--auto`/`--yolo`/`--plan` startup flags when switching sessions through the `/sessions` picker.
|
||||
|
||||
- [#675](https://github.com/MoonshotAI/kimi-code/pull/675) [`d1ba145`](https://github.com/MoonshotAI/kimi-code/commit/d1ba14562bafdb6b93c3eec1b5c453186507ed56) - Sync custom registry provider additions, removals, and rotated registry keys during startup refresh.
|
||||
|
||||
- [#689](https://github.com/MoonshotAI/kimi-code/pull/689) [`8d251f8`](https://github.com/MoonshotAI/kimi-code/commit/8d251f8ab44ead65f6c1bb264980ee7d075142ad) - Drop invalid config.toml sections with a warning instead of failing to start.
|
||||
|
||||
## 0.14.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#643](https://github.com/MoonshotAI/kimi-code/pull/643) [`4e5043b`](https://github.com/MoonshotAI/kimi-code/commit/4e5043b03b2fb03374550dc65d04871bc83e932a) - Require AgentSwarm tool calls to run alone in a model response.
|
||||
|
||||
- [#631](https://github.com/MoonshotAI/kimi-code/pull/631) [`2961425`](https://github.com/MoonshotAI/kimi-code/commit/296142544ec64e93c9083a51d3a53a83496d10cb) - Wrap long command and skill descriptions in the autocomplete menu onto a second line instead of cutting them off.
|
||||
|
||||
- [#661](https://github.com/MoonshotAI/kimi-code/pull/661) [`0927f79`](https://github.com/MoonshotAI/kimi-code/commit/0927f79883e036d0127d4384f60f8e486afb3b8c) - Cancel active turns during session shutdown so foreground shell commands do not outlive prompt-mode exits.
|
||||
|
||||
- [#604](https://github.com/MoonshotAI/kimi-code/pull/604) [`7ec738c`](https://github.com/MoonshotAI/kimi-code/commit/7ec738c4a1de41b3a042cfb48700dfaf51e9de94) - Fix premature stream close errors when shell processes time out or are killed.
|
||||
|
||||
- [#632](https://github.com/MoonshotAI/kimi-code/pull/632) [`d8cdebf`](https://github.com/MoonshotAI/kimi-code/commit/d8cdebf3c03efa3a3dfa4f1deb3186a8f8f7f5ef) - Degrade unsupported audio/video to placeholder text and reattach tool result media instead of silently dropping them.
|
||||
|
||||
- [#628](https://github.com/MoonshotAI/kimi-code/pull/628) [`0ee9106`](https://github.com/MoonshotAI/kimi-code/commit/0ee91066eaa8ec794c8337faefc14d1b1200ce82) - Fix ACP file reads and edits for Windows workspaces opened through IDE clients.
|
||||
|
||||
- [#658](https://github.com/MoonshotAI/kimi-code/pull/658) [`0381329`](https://github.com/MoonshotAI/kimi-code/commit/0381329570d3dca9fd861761c843968cc1c5e927) - Send OpenAI Responses system prompts as request instructions.
|
||||
|
||||
- [#654](https://github.com/MoonshotAI/kimi-code/pull/654) [`ff80327`](https://github.com/MoonshotAI/kimi-code/commit/ff803273440f3a2ff53d2c529c6fc892fde1d93f) - Propagate configured execution environment overrides across spawned processes.
|
||||
|
||||
- [#644](https://github.com/MoonshotAI/kimi-code/pull/644) [`a58b5b2`](https://github.com/MoonshotAI/kimi-code/commit/a58b5b20bb42228c72277daba9fa07bb1cd539a6) - Polish builtin skills.
|
||||
|
||||
- [#649](https://github.com/MoonshotAI/kimi-code/pull/649) [`a2c5e1b`](https://github.com/MoonshotAI/kimi-code/commit/a2c5e1be25484f7c52f729e333196c485f83b84c) - Add runtime support for dynamic MCP server updates, reference skills, replay timestamps, and Node file uploads.
|
||||
|
||||
- [#631](https://github.com/MoonshotAI/kimi-code/pull/631) [`2961425`](https://github.com/MoonshotAI/kimi-code/commit/296142544ec64e93c9083a51d3a53a83496d10cb) - Find slash commands by their aliases in autocomplete — typing `/clear` now suggests `new (clear)`.
|
||||
|
||||
- [#648](https://github.com/MoonshotAI/kimi-code/pull/648) [`54302ad`](https://github.com/MoonshotAI/kimi-code/commit/54302ad612294056a47ada74b76737f2284861b5) - Prevent overlapping interactive agent requests from using the wrong active agent.
|
||||
|
||||
- [#641](https://github.com/MoonshotAI/kimi-code/pull/641) [`30459af`](https://github.com/MoonshotAI/kimi-code/commit/30459af6abc8308e7f13822d9dbef3a5be80dd4a) - Stop background tasks by default when sessions close.
|
||||
|
||||
- [#645](https://github.com/MoonshotAI/kimi-code/pull/645) [`1b58aa8`](https://github.com/MoonshotAI/kimi-code/commit/1b58aa8cdf675e6f4c02cd083feb55debbe9b3f1) - Add a YOLO choice when starting swarm tasks from Manual mode.
|
||||
|
||||
- [#655](https://github.com/MoonshotAI/kimi-code/pull/655) [`1e2e679`](https://github.com/MoonshotAI/kimi-code/commit/1e2e679693af2fc97826078aa671555a3a900349) - Display a tips banner below the welcome panel on startup.
|
||||
|
||||
## 0.14.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#607](https://github.com/MoonshotAI/kimi-code/pull/607) [`b253a82`](https://github.com/MoonshotAI/kimi-code/commit/b253a82a7a5f7d91883dc77a30b8b38f8b6e1470) - Add an `Interrupt` hook event that fires when the user interrupts a turn (e.g. pressing Esc), letting hooks observe the turn stopping instead of getting stuck on a working state.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#626](https://github.com/MoonshotAI/kimi-code/pull/626) [`856ec00`](https://github.com/MoonshotAI/kimi-code/commit/856ec002906f4964086915ceb9aa616b89ab6594) - Preserve image outputs from tools when using OpenAI-compatible chat completions.
|
||||
|
||||
## 0.13.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#610](https://github.com/MoonshotAI/kimi-code/pull/610) [`b747c6a`](https://github.com/MoonshotAI/kimi-code/commit/b747c6a9501e208250d09cf9a2810c885c6ce91b) - Add Claude Fable 5 support to the Anthropic provider.
|
||||
|
||||
- [#615](https://github.com/MoonshotAI/kimi-code/pull/615) [`494554e`](https://github.com/MoonshotAI/kimi-code/commit/494554eac5d34d6a3c5c36b6fb2b2e5397b07f0c) - Add an interactive undo selector and clearer undo-limit messages.
|
||||
|
||||
- [#598](https://github.com/MoonshotAI/kimi-code/pull/598) [`32d7080`](https://github.com/MoonshotAI/kimi-code/commit/32d708083730c14090f855b1fcb650e2bc713797) - Clarify active skill prompts so loaded skills are no longer represented as system reminders.
|
||||
|
||||
- [#595](https://github.com/MoonshotAI/kimi-code/pull/595) [`1580f35`](https://github.com/MoonshotAI/kimi-code/commit/1580f35136eed02331dcff6c8482247d5cf35458) - Fix Kimi Datasource to use the matching OAuth credentials and service endpoint for the active Kimi Code environment.
|
||||
|
||||
- [#619](https://github.com/MoonshotAI/kimi-code/pull/619) [`1fbe0e4`](https://github.com/MoonshotAI/kimi-code/commit/1fbe0e4ee89241bee6b5b1d5a4a38b6c6de3c5bf) - Fix goal marker text overflowing terminal width.
|
||||
|
||||
- [#612](https://github.com/MoonshotAI/kimi-code/pull/612) [`4603d8a`](https://github.com/MoonshotAI/kimi-code/commit/4603d8ad6e92a303f396f3d79d4e4d212d1c4b14) - Prevent forking sessions during active turns and consolidate wire protocol definitions into a shared internal package.
|
||||
|
||||
- [#540](https://github.com/MoonshotAI/kimi-code/pull/540) [`2ebe387`](https://github.com/MoonshotAI/kimi-code/commit/2ebe38769fc50215a7c94a362cd4e943130e1143) - Tighten file tool guidance to route incremental edits through Edit.
|
||||
|
||||
- [#606](https://github.com/MoonshotAI/kimi-code/pull/606) [`a1b419a`](https://github.com/MoonshotAI/kimi-code/commit/a1b419ab5901d16ab9527eef62bcd468e76b27a3) - YOLO mode no longer asks before writing or editing files outside the working directory.
|
||||
|
||||
## 0.13.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#484](https://github.com/MoonshotAI/kimi-code/pull/484) [`f863127`](https://github.com/MoonshotAI/kimi-code/commit/f863127ab7e8b8e2e9af11c54694c08900e3103a) - Add custom color themes. Define your own palette as a JSON file in `~/.kimi-code/themes/`, or generate one with the built-in `/custom-theme` skill command.
|
||||
|
||||
- [#582](https://github.com/MoonshotAI/kimi-code/pull/582) [`d85dc0b`](https://github.com/MoonshotAI/kimi-code/commit/d85dc0b96a3c98c6951b8f6e6fa8b663d4c95360) - Add `/import-from-cc-codex` to import selected Claude Code and Codex instructions, Skills, and MCP settings.
|
||||
|
||||
- [#593](https://github.com/MoonshotAI/kimi-code/pull/593) [`40506f4`](https://github.com/MoonshotAI/kimi-code/commit/40506f49d689aaf3e920c6bc9ae2b91219ee3f7f) - Show available plugin updates in the marketplace. An installed plugin whose marketplace version is newer than the local version now renders an `update <local> → <latest>` badge (and updates in place on Enter); up-to-date plugins show `installed · v<version>`. The marketplace `version` served in dev and written by the CDN build is now stamped from each plugin's manifest so "latest" stays accurate.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#587](https://github.com/MoonshotAI/kimi-code/pull/587) [`0abde86`](https://github.com/MoonshotAI/kimi-code/commit/0abde8662a531293fc8faa7cf9089c43ad8d6d76) - Clarify grouped subagent progress with active status breakdowns and elapsed time.
|
||||
|
||||
- [#594](https://github.com/MoonshotAI/kimi-code/pull/594) [`f2863af`](https://github.com/MoonshotAI/kimi-code/commit/f2863af267b2e7d5ff5b99ff80c95c379a5b0272) - Fix device login to keep the URL and code visible when the browser cannot be opened.
|
||||
|
||||
- [#591](https://github.com/MoonshotAI/kimi-code/pull/591) [`e48234a`](https://github.com/MoonshotAI/kimi-code/commit/e48234af576e41e630736450c66b690226707bc3) - Fix Windows builds and development launches that could fail when package binaries resolve to command shims.
|
||||
|
||||
- [#586](https://github.com/MoonshotAI/kimi-code/pull/586) [`7cb4a23`](https://github.com/MoonshotAI/kimi-code/commit/7cb4a23e01dfaf0e049891b90a27b36000714151) - Truncate queued message display to a single line with ellipsis when it exceeds terminal width.
|
||||
|
||||
## 0.12.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#584](https://github.com/MoonshotAI/kimi-code/pull/584) [`11bb62c`](https://github.com/MoonshotAI/kimi-code/commit/11bb62c12f38d380a0ca1bb89ee2df67f93300e1) - Allow obsolete experimental config entries to remain without blocking startup.
|
||||
|
||||
- [#581](https://github.com/MoonshotAI/kimi-code/pull/581) [`aa3471f`](https://github.com/MoonshotAI/kimi-code/commit/aa3471f5d3d2960834ba3239c0b8459144bc79fa) - Pass through xhigh reasoning effort for OpenAI-compatible chat completions requests.
|
||||
|
||||
## 0.12.0
|
||||
|
||||
### Minor Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@moonshot-ai/kimi-code",
|
||||
"version": "0.23.5",
|
||||
"version": "0.12.0",
|
||||
"description": "The Starting Point for Next-Gen Agents",
|
||||
"license": "MIT",
|
||||
"author": "Moonshot AI",
|
||||
|
|
@ -27,21 +27,15 @@
|
|||
},
|
||||
"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/*.d.ts"
|
||||
"./src/*/index.ts"
|
||||
]
|
||||
},
|
||||
"publishConfig": {
|
||||
|
|
@ -49,8 +43,7 @@
|
|||
"provenance": true
|
||||
},
|
||||
"scripts": {
|
||||
"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",
|
||||
"build": "tsdown",
|
||||
"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",
|
||||
|
|
@ -62,8 +55,6 @@
|
|||
"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",
|
||||
|
|
@ -74,34 +65,28 @@
|
|||
"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"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@mariozechner/clipboard": "^0.3.9",
|
||||
"node-pty": "^1.1.0"
|
||||
"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",
|
||||
"pathe": "^2.0.3",
|
||||
"semver": "^7.7.4",
|
||||
"smol-toml": "^1.6.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"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",
|
||||
"zod": "^4.3.6"
|
||||
"yazl": "^3.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ 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');
|
||||
|
|
@ -48,13 +46,7 @@ export async function buildPluginMarketplaceCdn({ pluginsRoot, outDir }) {
|
|||
continue;
|
||||
}
|
||||
const result = await materializeEntrySource(entry.source, pluginsRoot, outDir);
|
||||
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);
|
||||
plugins.push({ ...entry, source: result.source });
|
||||
if (result.archive !== undefined) archives.push(result.archive);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
// 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)`);
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
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}`);
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
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}`);
|
||||
|
|
@ -7,8 +7,6 @@ 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');
|
||||
|
|
@ -111,10 +109,7 @@ async function rewriteMarketplaceJson(raw, pluginsRoot) {
|
|||
if (!isLocalRelativeSource(entry.source)) return entry;
|
||||
const sourcePath = resolveInsideRoot(pluginsRoot, entry.source);
|
||||
if (!(await isDirectory(sourcePath))) return entry;
|
||||
// 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) };
|
||||
return { ...entry, source: withZipExtension(entry.source) };
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,127 +0,0 @@
|
|||
#!/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();
|
||||
|
|
@ -1,64 +1,31 @@
|
|||
#!/usr/bin/env node
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { fileURLToPath } 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 };
|
||||
|
||||
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();
|
||||
if (env[MARKETPLACE_ENV] === undefined || env[MARKETPLACE_ENV]?.trim().length === 0) {
|
||||
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 tsxCli = require.resolve('tsx/cli');
|
||||
const tsxBin = process.platform === 'win32' ? 'tsx.cmd' : 'tsx';
|
||||
const cliArgs = process.argv.slice(2);
|
||||
if (cliArgs[0] === '--') cliArgs.shift();
|
||||
const child = spawn(
|
||||
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,
|
||||
],
|
||||
tsxBin,
|
||||
['--import', '../../build/register-raw-text-loader.mjs', './src/main.ts', ...cliArgs],
|
||||
{
|
||||
cwd: REPO_ROOT,
|
||||
cwd: APP_ROOT,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,14 +6,8 @@ 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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import {
|
|||
nativeSeaConfigPath,
|
||||
targetTriple,
|
||||
} from './paths.mjs';
|
||||
import { collectWebAssets, webAssetManifestKey } from './web-assets.mjs';
|
||||
|
||||
async function ensureBundleExists() {
|
||||
try {
|
||||
|
|
@ -32,19 +31,13 @@ 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(),
|
||||
|
|
@ -62,9 +55,6 @@ 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() {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ export const NATIVE_TARGETS = Object.freeze(
|
|||
SUPPORTED_TARGETS.map((t) => {
|
||||
const deps = resolveTargetDeps(t);
|
||||
const clipboardTarget = deps.find((d) => d.id === 'clipboard-target')?.resolvedName;
|
||||
return [t, { clipboardPackage: clipboardTarget }];
|
||||
const koffiNativeFile = deps.find((d) => d.id === 'koffi')?.nativeFileRelatives?.[0];
|
||||
const koffiTriplet = koffiNativeFile?.match(/koffi\/([^/]+)\/koffi\.node$/)?.[1] ?? null;
|
||||
return [t, { clipboardPackage: clipboardTarget, koffiTriplet }];
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
|
@ -159,19 +161,16 @@ 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]);
|
||||
|
||||
if (includeEntryJs) {
|
||||
const entry = resolvePackageEntry(packageRoot, packageJson);
|
||||
if (entry !== null) {
|
||||
selected.add(entry);
|
||||
await addRuntimeDependencyFiles(packageRoot, entry, selected);
|
||||
}
|
||||
const entry = resolvePackageEntry(packageRoot, packageJson);
|
||||
if (entry !== null) {
|
||||
selected.add(entry);
|
||||
await addRuntimeDependencyFiles(packageRoot, entry, selected);
|
||||
}
|
||||
|
||||
for (const nativeFileRelative of nativeFileRelatives) {
|
||||
|
|
@ -251,7 +250,6 @@ 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({
|
||||
|
|
|
|||
|
|
@ -18,12 +18,10 @@ 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();
|
||||
const handledNativeRuntimeRequires = new Set(['koffi']);
|
||||
|
||||
function isAllowedSpecifier(specifier) {
|
||||
if (builtins.has(specifier) || specifier.startsWith('node:')) return true;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
export const NATIVE_ASSET_MANIFEST_VERSION = 1;
|
||||
export const WEB_ASSET_MANIFEST_VERSION = 1;
|
||||
|
||||
export function buildManifestKey(target) {
|
||||
return `native/${target}/manifest.json`;
|
||||
|
|
@ -12,11 +11,3 @@ 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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,16 +27,13 @@ const clipboardSubpackageByTarget = Object.freeze({
|
|||
'win32-x64': '@mariozechner/clipboard-win32-x64-msvc',
|
||||
});
|
||||
|
||||
// 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'],
|
||||
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',
|
||||
});
|
||||
|
||||
export function isSupportedTarget(target) {
|
||||
|
|
@ -48,15 +45,13 @@ 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'|'native-file-only'|'virtual'} collect
|
||||
* @property {'js-only'|'native-files'|'js-and-native-file'|'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' 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.
|
||||
* (used by 'js-and-native-file'; native-files mode auto-scans *.node)
|
||||
*/
|
||||
|
||||
/** @type {readonly NativeDepDescriptor[]} */
|
||||
|
|
@ -75,14 +70,18 @@ export const nativeDeps = Object.freeze([
|
|||
},
|
||||
{
|
||||
id: 'pi-tui',
|
||||
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',
|
||||
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',
|
||||
parent: null,
|
||||
nativeFileRelatives: (target) => piTuiNativeFileByTarget[target] ?? [],
|
||||
},
|
||||
{
|
||||
id: 'koffi',
|
||||
name: () => 'koffi',
|
||||
collect: 'js-and-native-file',
|
||||
parent: 'pi-tui',
|
||||
nativeFileRelatives: (target) => [`build/koffi/${koffiTripletByTarget[target]}/koffi.node`],
|
||||
},
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,118 +0,0 @@
|
|||
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,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -7,7 +7,6 @@ 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;
|
||||
|
||||
|
|
@ -24,14 +23,6 @@ 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], {
|
||||
|
|
@ -54,7 +45,6 @@ function assertIncludes(output, expected, command) {
|
|||
}
|
||||
|
||||
await ensureBundleExists();
|
||||
await ensureRuntimeAssetsExist();
|
||||
|
||||
const versionOutput = await runBundle(['--version']);
|
||||
assertIncludes(versionOutput, expectedVersion, '--version');
|
||||
|
|
@ -65,7 +55,4 @@ 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}`);
|
||||
|
|
|
|||
|
|
@ -24,10 +24,6 @@ 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) {
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@ 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;
|
||||
|
|
@ -44,8 +42,7 @@ 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)
|
||||
.addOption(new Option('-C').hideHelp().default(false))
|
||||
.option('-C, --continue', 'Continue the previous session for the working directory.', false)
|
||||
.option('-y, --yolo', 'Automatically approve all actions.', false)
|
||||
.option('--auto', 'Start in auto permission mode.', false)
|
||||
.addOption(
|
||||
|
|
@ -74,14 +71,6 @@ 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);
|
||||
|
|
@ -89,14 +78,11 @@ 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();
|
||||
|
|
@ -125,7 +111,7 @@ export function createProgram(
|
|||
|
||||
const opts: CLIOptions = {
|
||||
session: sessionValue,
|
||||
continue: raw['continue'] === true || raw['C'] === true,
|
||||
continue: raw['continue'] as boolean,
|
||||
yolo: yoloValue,
|
||||
auto: autoValue,
|
||||
plan: raw['plan'] as boolean,
|
||||
|
|
@ -133,7 +119,6 @@ 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);
|
||||
|
|
|
|||
|
|
@ -46,18 +46,13 @@ 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. Malformed create commands throw instead of
|
||||
* falling through, so validation errors are reported before anything is sent to
|
||||
* the model.
|
||||
* through to normal prompt handling.
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,96 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -11,7 +11,6 @@ export interface CLIOptions {
|
|||
outputFormat: PromptOutputFormat | undefined;
|
||||
prompt: string | undefined;
|
||||
skillsDirs: string[];
|
||||
addDirs?: string[];
|
||||
}
|
||||
|
||||
export interface ValidatedOptions {
|
||||
|
|
@ -56,5 +55,14 @@ 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' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {
|
|||
} from '@moonshot-ai/kimi-code-sdk';
|
||||
import { resolve } from 'pathe';
|
||||
|
||||
import { CLI_SHUTDOWN_TIMEOUT_MS, PROMPT_CLEANUP_TIMEOUT_MS } from '#/constant/app';
|
||||
import { CLI_SHUTDOWN_TIMEOUT_MS } from '#/constant/app';
|
||||
|
||||
import type { CLIOptions, PromptOutputFormat } from './options';
|
||||
import {
|
||||
|
|
@ -32,47 +32,6 @@ 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;
|
||||
|
|
@ -119,12 +78,11 @@ export async function runPrompt(
|
|||
telemetry: telemetryClient,
|
||||
onOAuthRefresh: (outcome) => {
|
||||
if (outcome.success) {
|
||||
track('oauth_refresh', { outcome: 'success' });
|
||||
track('oauth_refresh', { success: true });
|
||||
return;
|
||||
}
|
||||
track('oauth_refresh', { outcome: 'error', reason: outcome.reason });
|
||||
track('oauth_refresh', { success: false, reason: outcome.reason });
|
||||
},
|
||||
sessionStartedProperties: { yolo: false, plan: false, afk: true },
|
||||
});
|
||||
log.info('kimi-code starting', {
|
||||
version,
|
||||
|
|
@ -137,7 +95,7 @@ export async function runPrompt(
|
|||
let removeTerminationCleanup: (() => void) | undefined;
|
||||
let cleanupPromise: Promise<void> | undefined;
|
||||
const cleanupPromptRun = async (): Promise<void> => {
|
||||
const pending = (cleanupPromise ??= (async () => {
|
||||
cleanupPromise ??= (async () => {
|
||||
removeTerminationCleanup?.();
|
||||
setCrashPhase('shutdown');
|
||||
try {
|
||||
|
|
@ -146,23 +104,15 @@ export async function runPrompt(
|
|||
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
|
||||
await harness.close();
|
||||
}
|
||||
})());
|
||||
// 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);
|
||||
})();
|
||||
await cleanupPromise;
|
||||
};
|
||||
removeTerminationCleanup = installPromptTerminationCleanup(promptProcess, cleanupPromptRun);
|
||||
|
||||
try {
|
||||
await harness.ensureConfigFile();
|
||||
const config = await harness.getConfig();
|
||||
for (const warning of (await harness.getConfigDiagnostics()).warnings) {
|
||||
stderr.write(`Warning: ${warning}\n`);
|
||||
}
|
||||
const { session, restorePermission, telemetryModel, goalModel } =
|
||||
const { session, resumed, restorePermission, telemetryModel, goalModel } =
|
||||
await resolvePromptSession(
|
||||
harness,
|
||||
opts,
|
||||
|
|
@ -182,10 +132,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
|
||||
|
|
@ -200,7 +156,7 @@ export async function runPrompt(
|
|||
writeResumeHint(session.id, outputFormat, stdout, stderr);
|
||||
|
||||
withTelemetryContext({ sessionId: session.id }).track('exit', {
|
||||
duration_ms: Date.now() - startedAt,
|
||||
duration_s: (Date.now() - startedAt) / 1000,
|
||||
});
|
||||
} finally {
|
||||
await cleanupPromptRun();
|
||||
|
|
@ -234,7 +190,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, true);
|
||||
await runPromptTurn(session, goal.objective, outputFormat, stdout, stderr);
|
||||
} finally {
|
||||
unsubscribeGoalEvents();
|
||||
const snapshot = completedSnapshot ?? (await session.getGoal()).goal;
|
||||
|
|
@ -284,10 +240,7 @@ async function resolvePromptSession(
|
|||
`Session "${opts.session}" was created under a different directory.`,
|
||||
);
|
||||
}
|
||||
const session = await harness.resumeSession({
|
||||
id: opts.session,
|
||||
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
|
||||
});
|
||||
const session = await harness.resumeSession({ id: opts.session });
|
||||
const status = await session.getStatus();
|
||||
const restorePermission = await forcePromptPermission(
|
||||
session,
|
||||
|
|
@ -311,10 +264,7 @@ async function resolvePromptSession(
|
|||
const sessions = await harness.listSessions({ workDir });
|
||||
const previous = sessions[0];
|
||||
if (previous !== undefined) {
|
||||
const session = await harness.resumeSession({
|
||||
id: previous.id,
|
||||
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
|
||||
});
|
||||
const session = await harness.resumeSession({ id: previous.id });
|
||||
const status = await session.getStatus();
|
||||
const restorePermission = await forcePromptPermission(
|
||||
session,
|
||||
|
|
@ -337,13 +287,7 @@ async function resolvePromptSession(
|
|||
}
|
||||
|
||||
const model = requireConfiguredModel(opts.model, defaultModel);
|
||||
const session = await harness.createSession({
|
||||
workDir,
|
||||
model,
|
||||
permission: 'auto',
|
||||
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
|
||||
drainAgentTasksOnStop: true,
|
||||
});
|
||||
const session = await harness.createSession({ workDir, model, permission: 'auto' });
|
||||
installHeadlessHandlers(session);
|
||||
return {
|
||||
session,
|
||||
|
|
@ -409,21 +353,16 @@ 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 {
|
||||
if (signal === 'SIGINT') return 130;
|
||||
if (signal === 'SIGHUP') return 129;
|
||||
return 143;
|
||||
return signal === 'SIGINT' ? 130 : 143;
|
||||
}
|
||||
|
||||
function runPromptTurn(
|
||||
|
|
@ -432,11 +371,9 @@ 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)
|
||||
|
|
@ -471,18 +408,6 @@ 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 (
|
||||
|
|
@ -501,7 +426,6 @@ function runPromptTurn(
|
|||
return;
|
||||
case 'turn.step.retrying':
|
||||
outputWriter.discardAssistant();
|
||||
outputWriter.writeRetrying(event);
|
||||
return;
|
||||
case 'assistant.delta':
|
||||
outputWriter.writeAssistantDelta(event.delta);
|
||||
|
|
@ -530,29 +454,7 @@ function runPromptTurn(
|
|||
return;
|
||||
case 'turn.ended':
|
||||
if (event.reason === 'completed') {
|
||||
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();
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
finish(new Error(formatTurnEndedFailure(event)));
|
||||
|
|
@ -585,20 +487,6 @@ 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -613,7 +501,6 @@ 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;
|
||||
|
|
@ -650,14 +537,7 @@ class PromptTranscriptWriter implements PromptTurnWriter {
|
|||
|
||||
writeToolResult(): void {}
|
||||
|
||||
// Text `-p` keeps retries silent: only the failed attempt's partial assistant
|
||||
// text is discarded (handled by the caller). No human-readable retry line is
|
||||
// emitted, matching the prior behavior.
|
||||
writeRetrying(): void {}
|
||||
|
||||
flushAssistant(): void {
|
||||
this.assistantWriter.finish();
|
||||
}
|
||||
flushAssistant(): void {}
|
||||
|
||||
discardAssistant(): void {}
|
||||
|
||||
|
|
@ -696,18 +576,6 @@ 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,
|
||||
|
|
@ -806,24 +674,6 @@ 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();
|
||||
}
|
||||
|
|
@ -843,9 +693,7 @@ class PromptJsonWriter implements PromptTurnWriter {
|
|||
return toolCall;
|
||||
}
|
||||
|
||||
private writeJsonLine(
|
||||
message: PromptJsonAssistantMessage | PromptJsonToolMessage | PromptJsonRetryMetaMessage,
|
||||
): void {
|
||||
private writeJsonLine(message: PromptJsonAssistantMessage | PromptJsonToolMessage): void {
|
||||
this.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
}
|
||||
|
|
@ -945,8 +793,5 @@ 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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
import { execSync, spawnSync } from 'node:child_process';
|
||||
import { execSync } 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,
|
||||
|
|
@ -15,6 +9,12 @@ 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,10 +22,7 @@ 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 { currentTheme, getColorPalette } from '#/tui/theme';
|
||||
import { combineStartupNotice } from '#/tui/utils/startup';
|
||||
import { toTerminalHyperlink } from '#/utils/terminal-hyperlink';
|
||||
import { restoreTerminalModes } from '#/utils/terminal-restore';
|
||||
import { detectTerminalTheme } from '#/tui/theme/detect';
|
||||
|
||||
import type { CLIOptions } from './options';
|
||||
import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry';
|
||||
|
|
@ -48,9 +45,9 @@ export async function runShell(
|
|||
configWarning = error.message;
|
||||
}
|
||||
|
||||
// Initialise the global Theme singleton before pi-tui grabs stdin.
|
||||
const palette = await getColorPalette(tuiConfig.theme);
|
||||
currentTheme.setPalette(palette);
|
||||
// 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;
|
||||
|
||||
const workDir = process.cwd();
|
||||
const telemetryBootstrap = createCliTelemetryBootstrap();
|
||||
|
|
@ -62,19 +59,17 @@ 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', { outcome: 'success' });
|
||||
track('oauth_refresh', { success: true });
|
||||
return;
|
||||
}
|
||||
track('oauth_refresh', {
|
||||
outcome: 'error',
|
||||
success: false,
|
||||
reason: outcome.reason,
|
||||
});
|
||||
},
|
||||
sessionStartedProperties: { yolo: opts.yolo, auto: opts.auto, plan: opts.plan, afk: false },
|
||||
});
|
||||
log.info('kimi-code starting', {
|
||||
version,
|
||||
|
|
@ -96,17 +91,14 @@ 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,
|
||||
});
|
||||
|
|
@ -120,6 +112,7 @@ export async function runShell(
|
|||
});
|
||||
setCrashPhase('runtime');
|
||||
|
||||
const resumed = opts.continue || opts.session !== undefined;
|
||||
const trackLifecycleForSession = (
|
||||
sessionId: string,
|
||||
event: string,
|
||||
|
|
@ -135,85 +128,35 @@ 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_ms: Date.now() - startedAt });
|
||||
trackLifecycle('exit', { duration_s: (Date.now() - startedAt) / 1000 });
|
||||
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
|
||||
const gutter = ' '.repeat(CHROME_GUTTER);
|
||||
process.stdout.write(`${gutter}Bye!\n`);
|
||||
const hints: string[] = [];
|
||||
if (sessionId !== '' && hasContent) {
|
||||
hints.push(`${gutter}To resume this session: kimi -r ${sessionId}`);
|
||||
process.stderr.write(`\n${gutter}To resume this session: kimi -r ${sessionId}\n`);
|
||||
}
|
||||
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', {
|
||||
|
|
@ -223,9 +166,8 @@ export async function runShell(
|
|||
mcp_ms: mcpMs,
|
||||
});
|
||||
} catch (error) {
|
||||
removeCrashHandlers();
|
||||
setCrashPhase('shutdown');
|
||||
trackLifecycle('exit', { duration_ms: Date.now() - startedAt });
|
||||
trackLifecycle('exit', { duration_s: (Date.now() - startedAt) / 1000 });
|
||||
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
|
||||
await harness.close();
|
||||
throw error;
|
||||
|
|
|
|||
|
|
@ -17,17 +17,18 @@ 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;
|
||||
// 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.
|
||||
// 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);
|
||||
process.stderr.write(
|
||||
[
|
||||
'',
|
||||
|
|
@ -42,20 +43,15 @@ 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 (error) {
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
process.stderr.write('Login cancelled.\n');
|
||||
} else {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`Login failed: ${message}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
|
|
|
|||
|
|
@ -7,9 +7,8 @@
|
|||
*
|
||||
* `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, retries
|
||||
* available API-key candidates, and re-fetches the model list, so periodic
|
||||
* refresh is automatic.
|
||||
* (apps/kimi-code/src/tui/utils/refresh-providers.ts) groups by `{url, apiKey}`
|
||||
* and re-fetches the model list, so periodic refresh is automatic.
|
||||
*/
|
||||
|
||||
import {
|
||||
|
|
@ -340,7 +339,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 previousThinking = config.thinking;
|
||||
const previousDefaultThinking = config.defaultThinking;
|
||||
|
||||
if (config.providers[providerId] !== undefined) {
|
||||
config = await harness.removeProvider(providerId);
|
||||
|
|
@ -348,7 +347,7 @@ export async function handleCatalogAdd(
|
|||
|
||||
const baseUrl = catalogBaseUrl(entry, wire);
|
||||
// `applyCatalogProvider` always overwrites both `defaultModel` and
|
||||
// `[thinking]`. The values we pass here are temporary; we restore
|
||||
// `defaultThinking`. The values we pass here are temporary; we restore
|
||||
// a consistent state in the post-apply block below.
|
||||
applyCatalogProvider(config, {
|
||||
providerId,
|
||||
|
|
@ -373,18 +372,18 @@ export async function handleCatalogAdd(
|
|||
config.defaultModel = stillResolves ? previousDefaultModel : undefined;
|
||||
}
|
||||
|
||||
// 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;
|
||||
// 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;
|
||||
|
||||
await harness.setConfig({
|
||||
providers: config.providers,
|
||||
models: config.models,
|
||||
defaultModel: config.defaultModel,
|
||||
thinking: config.thinking,
|
||||
defaultThinking: config.defaultThinking,
|
||||
});
|
||||
|
||||
const displayName = entry.name ?? providerId;
|
||||
|
|
@ -411,26 +410,13 @@ 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 runAction(resolved, () => handleProviderAdd(resolved, url, { apiKey: options.apiKey }));
|
||||
await handleProviderAdd(resolved, url, { apiKey: options.apiKey });
|
||||
});
|
||||
|
||||
provider
|
||||
|
|
@ -438,7 +424,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 runAction(resolved, () => handleProviderRemove(resolved, providerId));
|
||||
await handleProviderRemove(resolved, providerId);
|
||||
});
|
||||
|
||||
provider
|
||||
|
|
@ -447,7 +433,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 runAction(resolved, () => handleProviderList(resolved, { json: options.json === true }));
|
||||
await handleProviderList(resolved, { json: options.json === true });
|
||||
});
|
||||
|
||||
const catalog = provider
|
||||
|
|
@ -466,13 +452,11 @@ export function registerProviderCommand(parent: Command, deps?: Partial<Provider
|
|||
options: { filter?: string; url?: string; json?: boolean },
|
||||
) => {
|
||||
const resolved = resolveDeps(deps);
|
||||
await runAction(resolved, () =>
|
||||
handleCatalogList(resolved, providerId, {
|
||||
json: options.json === true,
|
||||
...(options.filter === undefined ? {} : { filter: options.filter }),
|
||||
...(options.url === undefined ? {} : { url: options.url }),
|
||||
}),
|
||||
);
|
||||
await handleCatalogList(resolved, providerId, {
|
||||
json: options.json === true,
|
||||
...(options.filter === undefined ? {} : { filter: options.filter }),
|
||||
...(options.url === undefined ? {} : { url: options.url }),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -488,13 +472,11 @@ export function registerProviderCommand(parent: Command, deps?: Partial<Provider
|
|||
options: { apiKey?: string; defaultModel?: string; url?: string },
|
||||
) => {
|
||||
const resolved = resolveDeps(deps);
|
||||
await runAction(resolved, () =>
|
||||
handleCatalogAdd(resolved, providerId, {
|
||||
...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }),
|
||||
...(options.defaultModel === undefined ? {} : { defaultModel: options.defaultModel }),
|
||||
...(options.url === undefined ? {} : { url: options.url }),
|
||||
}),
|
||||
);
|
||||
await handleCatalogAdd(resolved, providerId, {
|
||||
...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }),
|
||||
...(options.defaultModel === undefined ? {} : { defaultModel: options.defaultModel }),
|
||||
...(options.url === undefined ? {} : { url: options.url }),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,85 +0,0 @@
|
|||
/**
|
||||
* 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) }];
|
||||
}
|
||||
|
|
@ -1,412 +0,0 @@
|
|||
/**
|
||||
* `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 '';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
/**
|
||||
* `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 };
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
/**
|
||||
* `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(),
|
||||
};
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
/**
|
||||
* `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);
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
/**
|
||||
* `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)}…`;
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
/**
|
||||
* `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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,559 +0,0 @@
|
|||
/**
|
||||
* `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,
|
||||
};
|
||||
|
|
@ -1,279 +0,0 @@
|
|||
/**
|
||||
* 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}` };
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
/**
|
||||
* `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 },
|
||||
);
|
||||
}
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
/**
|
||||
* `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);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,23 +1,8 @@
|
|||
import { createKimiDeviceId, KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth';
|
||||
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 { initializeTelemetry } from '@moonshot-ai/kimi-telemetry';
|
||||
import { resolveKimiHome, type KimiConfig, type KimiHarness } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import { CLI_USER_AGENT_PRODUCT, WEB_UI_MODE } from '#/constant/app';
|
||||
|
||||
import { createKimiCodeHostIdentity } from './version';
|
||||
import { CLI_USER_AGENT_PRODUCT } from '#/constant/app';
|
||||
|
||||
export interface CliTelemetryBootstrap {
|
||||
readonly homeDir: string;
|
||||
|
|
@ -32,7 +17,6 @@ export interface InitializeCliTelemetryOptions {
|
|||
readonly version: string;
|
||||
readonly uiMode: string;
|
||||
readonly model?: string;
|
||||
readonly sessionId?: string;
|
||||
}
|
||||
|
||||
export function createCliTelemetryBootstrap(): CliTelemetryBootstrap {
|
||||
|
|
@ -55,7 +39,6 @@ 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,
|
||||
});
|
||||
|
|
@ -63,67 +46,3 @@ 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 {};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,20 +3,13 @@ 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';
|
||||
|
||||
// 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
|
||||
const UpdateCacheSchema: z.ZodType<UpdateCache> = 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();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,49 +1,6 @@
|
|||
import { valid } from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
import { KIMI_CODE_CDN_LATEST_URL } from '#/constant/app';
|
||||
|
||||
/**
|
||||
* Fetch the latest published Kimi Code version from the CDN.
|
||||
|
|
@ -58,7 +15,7 @@ async function fetchWithTimeout(fetchImpl: typeof fetch, input: string): Promise
|
|||
export async function fetchLatestVersionFromCdn(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<string> {
|
||||
const response = await fetchWithTimeout(fetchImpl, KIMI_CODE_CDN_LATEST_URL);
|
||||
const response = await fetchImpl(KIMI_CODE_CDN_LATEST_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`CDN /latest returned HTTP ${response.status}`);
|
||||
}
|
||||
|
|
@ -68,30 +25,3 @@ 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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,22 +19,13 @@ import {
|
|||
type InstallPromptOptions,
|
||||
} from './prompt';
|
||||
import { refreshUpdateCache } from './refresh';
|
||||
import {
|
||||
appendRolloutDecisionLog,
|
||||
decidePassiveUpdateTarget,
|
||||
isRolloutBypassedByExperimentalEnv,
|
||||
resolveUpdateDeviceId,
|
||||
rolloutBucket,
|
||||
rolloutDelayForBucket,
|
||||
type PassiveUpdateDecision,
|
||||
} from './rollout';
|
||||
import { selectUpdateTarget } from './select';
|
||||
import { detectInstallSource } from './source';
|
||||
import {
|
||||
NPM_PACKAGE_NAME,
|
||||
type InstallSource,
|
||||
type UpdateDecision,
|
||||
type UpdateInstallState,
|
||||
type UpdateManifest,
|
||||
type UpdatePreflightResult,
|
||||
type UpdateTarget,
|
||||
} from './types';
|
||||
|
|
@ -186,59 +177,8 @@ 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,
|
||||
|
|
@ -248,16 +188,7 @@ function refreshAndMaybeInstallInBackground(
|
|||
void (async () => {
|
||||
const refreshed = await refreshUpdateCache();
|
||||
if (!isInteractive) return;
|
||||
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;
|
||||
const target = selectUpdateTarget(currentVersion, refreshed.latest);
|
||||
if (target === null) return;
|
||||
const source = await detectInstallSource().catch(() => 'unsupported' as const);
|
||||
await tryStartAutomaticBackgroundInstall(
|
||||
|
|
@ -268,54 +199,27 @@ 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,
|
||||
fallbackManifest: UpdateManifest | null,
|
||||
): Promise<UserVisibleUpdateTarget> {
|
||||
): Promise<UpdateTarget | null> {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const fallback: UserVisibleUpdateTarget = {
|
||||
target: fallbackTarget,
|
||||
manifest: fallbackManifest,
|
||||
};
|
||||
try {
|
||||
const refresh = refreshUpdateCache()
|
||||
.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) => {
|
||||
.then((refreshed) => selectUpdateTarget(currentVersion, refreshed.latest))
|
||||
.catch(() => fallbackTarget);
|
||||
const fallback = new Promise<UpdateTarget>((resolve) => {
|
||||
timeout = setTimeout(() => {
|
||||
resolve(fallback);
|
||||
resolve(fallbackTarget);
|
||||
}, USER_VISIBLE_UPDATE_REFRESH_TIMEOUT_MS);
|
||||
});
|
||||
return await Promise.race([refresh, timeoutFallback]);
|
||||
return await Promise.race([refresh, fallback]);
|
||||
} catch {
|
||||
return fallback;
|
||||
return fallbackTarget;
|
||||
} finally {
|
||||
if (timeout !== undefined) {
|
||||
clearTimeout(timeout);
|
||||
|
|
@ -427,14 +331,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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -488,14 +392,7 @@ export async function installUpdate(
|
|||
): Promise<void> {
|
||||
const { cmd, args } = spawnForSource(source, version, platform);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// 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,
|
||||
});
|
||||
const child = spawn(cmd, [...args], { stdio: 'inherit' });
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code === 0) {
|
||||
|
|
@ -516,7 +413,6 @@ 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;
|
||||
|
|
@ -543,7 +439,6 @@ async function startBackgroundInstall(
|
|||
current_version: currentVersion,
|
||||
target_version: target.version,
|
||||
source,
|
||||
...rolloutTelemetry,
|
||||
});
|
||||
logUpdateInfo(logger, 'background update install started', {
|
||||
currentVersion,
|
||||
|
|
@ -603,15 +498,7 @@ async function startBackgroundInstall(
|
|||
});
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
const child = spawn(cmd, [...args], { detached: true, stdio: 'ignore' });
|
||||
child.once('error', () => { finish(false); });
|
||||
child.once('exit', (code) => { finish(code === 0); });
|
||||
child.unref();
|
||||
|
|
@ -628,7 +515,6 @@ 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;
|
||||
|
|
@ -645,7 +531,6 @@ async function tryStartAutomaticBackgroundInstall(
|
|||
platform,
|
||||
track,
|
||||
logger,
|
||||
rolloutTelemetry,
|
||||
).catch(() => {});
|
||||
}
|
||||
return true;
|
||||
|
|
@ -677,8 +562,6 @@ export async function runUpdatePreflight(
|
|||
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(
|
||||
|
|
@ -691,22 +574,11 @@ export async function runUpdatePreflight(
|
|||
}
|
||||
|
||||
const cache = await readUpdateCache().catch(() => null);
|
||||
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;
|
||||
const latest = cache?.latest ?? null;
|
||||
const target = selectUpdateTarget(currentVersion, latest);
|
||||
if (target === null) {
|
||||
refreshAndMaybeInstallInBackground(
|
||||
currentVersion,
|
||||
deviceId,
|
||||
bypassRollout,
|
||||
isInteractive,
|
||||
installState,
|
||||
platform,
|
||||
|
|
@ -736,28 +608,14 @@ export async function runUpdatePreflight(
|
|||
platform,
|
||||
options.track,
|
||||
logger,
|
||||
rolloutTelemetryFor(deviceId, target.version, cachedManifest, bypassRollout),
|
||||
)
|
||||
) {
|
||||
refreshInBackground();
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
const userVisibleUpdate = await refreshUserVisibleUpdateTarget(
|
||||
currentVersion,
|
||||
deviceId,
|
||||
bypassRollout,
|
||||
target,
|
||||
cachedManifest,
|
||||
);
|
||||
const userVisibleTarget = userVisibleUpdate.target;
|
||||
const userVisibleTarget = await refreshUserVisibleUpdateTarget(currentVersion, target);
|
||||
if (userVisibleTarget === null) return 'continue';
|
||||
const userVisibleRollout = rolloutTelemetryFor(
|
||||
deviceId,
|
||||
userVisibleTarget.version,
|
||||
userVisibleUpdate.manifest,
|
||||
bypassRollout,
|
||||
);
|
||||
if (
|
||||
await tryStartAutomaticBackgroundInstall(
|
||||
installState,
|
||||
|
|
@ -767,14 +625,13 @@ 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, userVisibleRollout);
|
||||
trackUpdatePrompted(options.track, currentVersion, userVisibleTarget, source, decision);
|
||||
|
||||
if (decision === 'manual-command') {
|
||||
stdout.write(renderManualUpdateMessage(
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
import { writeUpdateCache } from './cache';
|
||||
import { fetchLatestFromCdn, type FetchLatestResult } from './cdn';
|
||||
import { fetchLatestVersionFromCdn } from './cdn';
|
||||
import { type UpdateCache } from './types';
|
||||
|
||||
export interface RefreshUpdateCacheDeps {
|
||||
/** 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>;
|
||||
/** 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>;
|
||||
readonly writeCache: (cache: UpdateCache) => Promise<void>;
|
||||
readonly now: () => Date;
|
||||
}
|
||||
|
|
@ -17,17 +16,16 @@ export async function refreshUpdateCache(
|
|||
overrides: Partial<RefreshUpdateCacheDeps> = {},
|
||||
): Promise<UpdateCache> {
|
||||
const resolved: RefreshUpdateCacheDeps = {
|
||||
fetchLatest: overrides.fetchLatest ?? (() => fetchLatestFromCdn()),
|
||||
fetchLatest: overrides.fetchLatest ?? (() => fetchLatestVersionFromCdn()),
|
||||
writeCache: overrides.writeCache ?? writeUpdateCache,
|
||||
now: overrides.now ?? (() => new Date()),
|
||||
};
|
||||
|
||||
const { latest, manifest } = await resolved.fetchLatest();
|
||||
const latest = await resolved.fetchLatest();
|
||||
const cache: UpdateCache = {
|
||||
source: 'cdn',
|
||||
checkedAt: resolved.now().toISOString(),
|
||||
latest,
|
||||
manifest,
|
||||
};
|
||||
await resolved.writeCache(cache);
|
||||
return cache;
|
||||
|
|
|
|||
|
|
@ -1,210 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -16,28 +16,10 @@ 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 {
|
||||
|
|
@ -72,7 +54,6 @@ export function emptyUpdateCache(): UpdateCache {
|
|||
source: 'cdn',
|
||||
checkedAt: null,
|
||||
latest: null,
|
||||
manifest: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,33 +7,10 @@ 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';
|
||||
|
||||
|
|
@ -41,16 +18,12 @@ 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';
|
||||
|
|
@ -72,11 +45,6 @@ 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`;
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export * from './packager';
|
||||
export * from './scanner';
|
||||
export * from './types';
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
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');
|
||||
}
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
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('/');
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
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);
|
||||
});
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export declare const VIS_WEB_GZIP_B64: string;
|
||||
|
|
@ -23,7 +23,6 @@ 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';
|
||||
|
|
@ -39,22 +38,7 @@ import { cleanupStaleNativeCacheForCurrent } from './native/native-assets';
|
|||
import { installNativeModuleHook } from './native/module-hook';
|
||||
import { runNativeAssetSmokeIfRequested } from './native/smoke';
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
export async function handleMainCommand(opts: CLIOptions, version: string): Promise<void> {
|
||||
let validated: ReturnType<typeof validateOptions>;
|
||||
try {
|
||||
validated = validateOptions(opts);
|
||||
|
|
@ -76,11 +60,10 @@ export async function handleMainCommand(
|
|||
|
||||
if (validated.uiMode === 'print') {
|
||||
await runPrompt(validated.options, version);
|
||||
return { headlessCompleted: true };
|
||||
return;
|
||||
}
|
||||
|
||||
await runShell(validated.options, version);
|
||||
return { headlessCompleted: false };
|
||||
}
|
||||
|
||||
/** `kimi migrate`: launch the migration screen only, then exit. */
|
||||
|
|
@ -156,42 +139,17 @@ export function main(): void {
|
|||
const program = createProgram(
|
||||
version,
|
||||
(opts) => {
|
||||
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 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 handleMigrateCommand(version).catch(async (error: unknown) => {
|
||||
|
|
|
|||
|
|
@ -11,11 +11,10 @@
|
|||
* 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 '@moonshot-ai/pi-tui';
|
||||
import { Container, matchesKey, Key, truncateToWidth, type Focusable } from '@earendil-works/pi-tui';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import type { ColorPalette } from '#/tui/theme/colors';
|
||||
import { currentTheme } from '#/tui/theme';
|
||||
import {
|
||||
resolveMigrationScope,
|
||||
runMigration as realRunMigration,
|
||||
|
|
@ -47,7 +46,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()`. */
|
||||
|
|
@ -279,7 +278,7 @@ export class MigrationScreenComponent extends Container implements Focusable {
|
|||
}
|
||||
|
||||
private renderResult(width: number): string[] {
|
||||
const colors = this.opts.colors ?? currentTheme.palette;
|
||||
const { colors } = this.opts;
|
||||
const lines: string[] = [chalk.hex(colors.primary)('─'.repeat(width))];
|
||||
if (this.migrationFailed) {
|
||||
lines.push(chalk.hex(colors.error).bold(' Migration failed'));
|
||||
|
|
@ -429,7 +428,7 @@ export class MigrationScreenComponent extends Container implements Focusable {
|
|||
}
|
||||
|
||||
private renderProgress(width: number): string[] {
|
||||
const colors = this.opts.colors ?? currentTheme.palette;
|
||||
const { colors } = this.opts;
|
||||
const spinner = SPINNER_FRAMES[this.spinnerFrame] ?? SPINNER_FRAMES[0];
|
||||
const lines: string[] = [
|
||||
chalk.hex(colors.primary)('─'.repeat(width)),
|
||||
|
|
@ -459,7 +458,7 @@ export class MigrationScreenComponent extends Container implements Focusable {
|
|||
}
|
||||
|
||||
private renderAsk(width: number): string[] {
|
||||
const colors = this.opts.colors ?? currentTheme.palette;
|
||||
const { colors } = this.opts;
|
||||
const step = this.currentStep();
|
||||
const lines: string[] = [
|
||||
chalk.hex(colors.primary)('─'.repeat(width)),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import { existsSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { getNativePackageRoot } from './native-assets';
|
||||
import { loadNativePackage } from './native-require';
|
||||
|
||||
type ModuleLoad = (request: string, parent: unknown, isMain: boolean) => unknown;
|
||||
|
||||
|
|
@ -12,16 +10,7 @@ interface ModuleWithLoad {
|
|||
|
||||
const nodeRequire = createRequire(import.meta.url);
|
||||
let installed = 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$/;
|
||||
let loadingNativePackage = false;
|
||||
|
||||
export function installNativeModuleHook(): void {
|
||||
if (installed) return;
|
||||
|
|
@ -37,18 +26,13 @@ export function installNativeModuleHook(): void {
|
|||
parent: unknown,
|
||||
isMain: boolean,
|
||||
): unknown {
|
||||
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);
|
||||
}
|
||||
if (request === 'koffi' && !loadingNativePackage) {
|
||||
loadingNativePackage = true;
|
||||
try {
|
||||
const pkg = loadNativePackage<unknown>('koffi');
|
||||
if (pkg !== null) return pkg;
|
||||
} finally {
|
||||
loadingNativePackage = false;
|
||||
}
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ 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';
|
||||
|
|
@ -144,7 +143,7 @@ export function getNativeCacheBase(options: NativeAssetOptions = {}): string {
|
|||
const cacheDirEnv = optionalEnvValue(env, 'KIMI_CODE_CACHE_DIR');
|
||||
if (cacheDirEnv !== null) return cacheDirEnv;
|
||||
|
||||
if (platform === 'darwin') return joinPosix(home, 'Library', 'Caches', 'kimi-code');
|
||||
if (platform === 'darwin') return join(home, 'Library', 'Caches', 'kimi-code');
|
||||
if (platform === 'win32') {
|
||||
const localAppData = optionalEnvValue(env, 'LOCALAPPDATA');
|
||||
return localAppData !== null
|
||||
|
|
@ -152,7 +151,7 @@ export function getNativeCacheBase(options: NativeAssetOptions = {}): string {
|
|||
: pathWin32.join(home, 'AppData', 'Local', 'kimi-code', 'Cache');
|
||||
}
|
||||
|
||||
return joinPosix(optionalEnvValue(env, 'XDG_CACHE_HOME') ?? joinPosix(home, '.cache'), 'kimi-code');
|
||||
return join(optionalEnvValue(env, 'XDG_CACHE_HOME') ?? join(home, '.cache'), 'kimi-code');
|
||||
}
|
||||
|
||||
export function getNativeAssetCacheRoot(
|
||||
|
|
|
|||
|
|
@ -1,38 +1,6 @@
|
|||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
import { getEmbeddedNativeAssetManifest, getNativePackageRoot } from './native-assets';
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
const smokePackages = ['@mariozechner/clipboard', 'koffi'];
|
||||
|
||||
export function runNativeAssetSmokeIfRequested(): boolean {
|
||||
if (process.env['KIMI_CODE_NATIVE_ASSET_SMOKE'] !== '1') return false;
|
||||
|
|
@ -48,7 +16,6 @@ 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) {
|
||||
|
|
|
|||
|
|
@ -1,183 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,331 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -70,7 +70,6 @@ async function handleKimiCodeOAuthLogin(host: SlashCommandHost): Promise<void> {
|
|||
}
|
||||
host.track('login', {
|
||||
provider: DEFAULT_OAUTH_PROVIDER_NAME,
|
||||
method: 'oauth',
|
||||
already_logged_in: alreadyLoggedIn,
|
||||
});
|
||||
if (alreadyLoggedIn) {
|
||||
|
|
@ -159,11 +158,7 @@ async function handleOpenPlatformLogin(
|
|||
platform,
|
||||
models,
|
||||
selectedModel: selection.model,
|
||||
thinking: selection.thinking !== 'off',
|
||||
effort:
|
||||
selection.thinking !== 'off' && selection.thinking !== 'on'
|
||||
? selection.thinking
|
||||
: undefined,
|
||||
thinking: selection.thinking,
|
||||
apiKey,
|
||||
});
|
||||
|
||||
|
|
@ -171,7 +166,7 @@ async function handleOpenPlatformLogin(
|
|||
providers: config.providers,
|
||||
models: config.models,
|
||||
defaultModel: config.defaultModel,
|
||||
thinking: config.thinking,
|
||||
defaultThinking: config.defaultThinking,
|
||||
});
|
||||
|
||||
await host.authFlow.refreshConfigAfterLogin();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { AutocompleteItem } from '@moonshot-ai/pi-tui';
|
||||
import type { AutocompleteItem } from '@earendil-works/pi-tui';
|
||||
|
||||
/**
|
||||
* A completable token (subcommand or flag) for a slash command's argument
|
||||
|
|
|
|||
|
|
@ -1,30 +1,25 @@
|
|||
import {
|
||||
effectiveModelAlias,
|
||||
type ExperimentalFeatureState,
|
||||
type ModelAlias,
|
||||
type PermissionMode,
|
||||
type Session,
|
||||
type ThinkingEffort,
|
||||
import type {
|
||||
ExperimentalFeatureState,
|
||||
FlagId,
|
||||
PermissionMode,
|
||||
Session,
|
||||
} 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 { DEFAULT_TUI_CONFIG, saveTuiConfig, type TuiConfig } from '../config';
|
||||
import type { ThemeName } from '#/tui/theme';
|
||||
import { currentTheme, isBuiltInTheme, lightColors, loadCustomThemeMerged } from '#/tui/theme';
|
||||
import { saveTuiConfig } from '../config';
|
||||
import type { Theme } from '../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';
|
||||
|
|
@ -33,18 +28,6 @@ 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) {
|
||||
|
|
@ -107,7 +90,7 @@ export async function handleYoloCommand(host: SlashCommandHost, args: string): P
|
|||
}
|
||||
await session.setPermission('yolo');
|
||||
host.setAppState({ permissionMode: 'yolo' });
|
||||
host.showNotice('YOLO mode: ON', 'AI auto-approves safe actions, asks for approval on risky ones.');
|
||||
host.showNotice('YOLO mode: ON', 'Workspace tools auto-approved.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -130,7 +113,7 @@ export async function handleYoloCommand(host: SlashCommandHost, args: string): P
|
|||
} else {
|
||||
await session.setPermission('yolo');
|
||||
host.setAppState({ permissionMode: 'yolo' });
|
||||
host.showNotice('YOLO mode: ON', 'AI auto-approves safe actions, asks for approval on risky ones.');
|
||||
host.showNotice('YOLO mode: ON', 'Workspace tools auto-approved.');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -151,7 +134,7 @@ export async function handleAutoCommand(host: SlashCommandHost, args: string): P
|
|||
}
|
||||
await session.setPermission('auto');
|
||||
host.setAppState({ permissionMode: 'auto' });
|
||||
host.showNotice('Auto mode: ON', 'Run all actions automatically, including risky ones.');
|
||||
host.showNotice('Auto mode: ON', 'Tools auto-approved. Agent will not ask questions.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -174,7 +157,7 @@ export async function handleAutoCommand(host: SlashCommandHost, args: string): P
|
|||
} else {
|
||||
await session.setPermission('auto');
|
||||
host.setAppState({ permissionMode: 'auto' });
|
||||
host.showNotice('Auto mode: ON', 'Run all actions automatically, including risky ones.');
|
||||
host.showNotice('Auto mode: ON', 'Tools auto-approved. Agent will not ask questions.');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -203,19 +186,15 @@ export async function handleThemeCommand(host: SlashCommandHost, args: string):
|
|||
showThemePicker(host);
|
||||
return;
|
||||
}
|
||||
if (!isBuiltInTheme(theme)) {
|
||||
const custom = await loadCustomThemeMerged(theme);
|
||||
if (custom === null) {
|
||||
host.showError(`Unknown theme: ${theme}`);
|
||||
return;
|
||||
}
|
||||
if (!isTheme(theme)) {
|
||||
host.showError(`Unknown theme: ${theme}`);
|
||||
return;
|
||||
}
|
||||
await applyThemeChoice(host, theme);
|
||||
}
|
||||
|
||||
export async function handleModelCommand(host: SlashCommandHost, args: string): Promise<void> {
|
||||
export function handleModelCommand(host: SlashCommandHost, args: string): void {
|
||||
const alias = args.trim();
|
||||
await refreshModelsForPicker(host);
|
||||
if (alias.length === 0) {
|
||||
showModelPicker(host);
|
||||
return;
|
||||
|
|
@ -227,56 +206,6 @@ export async function handleModelCommand(host: SlashCommandHost, args: string):
|
|||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -286,6 +215,7 @@ function showEditorPicker(host: SlashCommandHost): void {
|
|||
host.mountEditorReplacement(
|
||||
new EditorSelectorComponent({
|
||||
currentValue,
|
||||
colors: host.state.theme.colors,
|
||||
onSelect: (value) => {
|
||||
host.restoreEditor();
|
||||
void applyEditorChoice(host, value);
|
||||
|
|
@ -297,37 +227,6 @@ 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) {
|
||||
|
|
@ -338,13 +237,15 @@ async function applyEditorChoice(host: SlashCommandHost, value: string): Promise
|
|||
const editorCommand = value.length > 0 ? value : null;
|
||||
try {
|
||||
await saveTuiConfig({
|
||||
...currentTuiConfig(host),
|
||||
theme: host.state.appState.theme,
|
||||
editorCommand,
|
||||
notifications: host.state.appState.notifications,
|
||||
upgrade: host.state.appState.upgrade,
|
||||
});
|
||||
} catch (error) {
|
||||
host.showStatus(
|
||||
`Failed to save editor: ${formatErrorMessage(error)}`,
|
||||
'error',
|
||||
host.state.theme.colors.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -371,14 +272,11 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string =
|
|||
models: host.state.appState.availableModels,
|
||||
currentValue: host.state.appState.model,
|
||||
selectedValue,
|
||||
currentThinkingEffort: host.state.appState.thinkingEffort,
|
||||
currentThinking: host.state.appState.thinking,
|
||||
colors: host.state.theme.colors,
|
||||
onSelect: ({ alias, thinking }) => {
|
||||
host.restoreEditor();
|
||||
void performModelSwitch(host, alias, thinking, true);
|
||||
},
|
||||
onSessionOnlySelect: ({ alias, thinking }) => {
|
||||
host.restoreEditor();
|
||||
void performModelSwitch(host, alias, thinking, false);
|
||||
void performModelSwitch(host, alias, thinking);
|
||||
},
|
||||
onCancel: () => {
|
||||
host.restoreEditor();
|
||||
|
|
@ -387,34 +285,27 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string =
|
|||
);
|
||||
}
|
||||
|
||||
async function performModelSwitch(
|
||||
host: SlashCommandHost,
|
||||
alias: string,
|
||||
effort: ThinkingEffort,
|
||||
persist: boolean,
|
||||
): Promise<void> {
|
||||
async function performModelSwitch(host: SlashCommandHost, alias: string, thinking: 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 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 prevThinking = host.state.appState.thinking;
|
||||
const runtimeChanged = alias !== prevModel || thinking !== prevThinking;
|
||||
|
||||
const session = host.session;
|
||||
try {
|
||||
if (session === undefined && runtimeChanged) {
|
||||
await host.authFlow.activateModelAfterLogin(alias, effort);
|
||||
await host.authFlow.activateModelAfterLogin(alias, thinking);
|
||||
} else if (session !== undefined) {
|
||||
if (alias !== prevModel) {
|
||||
await session.setModel(alias);
|
||||
}
|
||||
if (effort !== prevEffort) {
|
||||
await session.setThinking(effort);
|
||||
if (thinking !== prevThinking) {
|
||||
await session.setThinking(level);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -423,65 +314,41 @@ async function performModelSwitch(
|
|||
return;
|
||||
}
|
||||
|
||||
host.setAppState({ model: alias, thinkingEffort: effort });
|
||||
host.setAppState({ model: alias, thinking });
|
||||
if (session === undefined && runtimeChanged) {
|
||||
if (alias !== prevModel) {
|
||||
host.track('model_switch', { model: alias });
|
||||
}
|
||||
if (effort !== prevEffort) {
|
||||
host.track('thinking_toggle', {
|
||||
enabled: effort !== 'off',
|
||||
effort,
|
||||
from: prevEffort,
|
||||
});
|
||||
if (thinking !== prevThinking) {
|
||||
host.track('thinking_toggle', { enabled: thinking });
|
||||
}
|
||||
}
|
||||
|
||||
let persisted = false;
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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');
|
||||
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);
|
||||
}
|
||||
|
||||
async function persistModelSelection(
|
||||
host: SlashCommandHost,
|
||||
alias: string,
|
||||
effort: ThinkingEffort,
|
||||
): Promise<boolean> {
|
||||
async function persistModelSelection(host: SlashCommandHost, alias: string, thinking: boolean): Promise<boolean> {
|
||||
const config = await host.harness.getConfig({ reload: true });
|
||||
const patch = thinkingEffortToConfig(effort);
|
||||
if (
|
||||
config.defaultModel === alias &&
|
||||
config.thinking?.enabled === patch.enabled &&
|
||||
config.thinking?.effort === patch.effort
|
||||
) {
|
||||
if (config.defaultModel === alias && config.defaultThinking === thinking) {
|
||||
return false;
|
||||
}
|
||||
await host.harness.setConfig({
|
||||
defaultModel: alias,
|
||||
thinking: patch,
|
||||
defaultThinking: thinking,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
|
@ -490,6 +357,7 @@ 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);
|
||||
|
|
@ -501,41 +369,30 @@ function showThemePicker(host: SlashCommandHost): void {
|
|||
);
|
||||
}
|
||||
|
||||
async function applyThemeChoice(host: SlashCommandHost, theme: ThemeName): Promise<void> {
|
||||
async function applyThemeChoice(host: SlashCommandHost, theme: Theme): 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)}`,
|
||||
'error',
|
||||
host.state.theme.colors.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = theme === 'auto'
|
||||
? (currentTheme.palette === lightColors ? 'light' : 'dark')
|
||||
: undefined;
|
||||
await host.applyTheme(theme, resolved);
|
||||
const resolved = theme === 'auto' ? host.state.theme.resolvedTheme : theme;
|
||||
host.applyTheme(theme, resolved);
|
||||
host.refreshTerminalThemeTracking();
|
||||
host.track('theme_switch', { theme });
|
||||
const detail = theme === 'auto' ? ` (tracking terminal; current: ${resolved})` : '';
|
||||
|
|
@ -546,6 +403,7 @@ 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);
|
||||
|
|
@ -561,6 +419,7 @@ 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);
|
||||
|
|
@ -590,12 +449,12 @@ export async function applyExperimentalFeatureChanges(
|
|||
if (changes.length === 0) {
|
||||
host.showStatus(
|
||||
'No experimental feature changes to apply.',
|
||||
'textMuted',
|
||||
host.state.theme.colors.textMuted,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const experimental: Record<string, boolean> = {};
|
||||
const experimental: Partial<Record<FlagId, boolean>> = {};
|
||||
for (const change of changes) {
|
||||
experimental[change.id] = change.enabled;
|
||||
}
|
||||
|
|
@ -613,7 +472,7 @@ export async function applyExperimentalFeatureChanges(
|
|||
'Experimental features updated. Session reloaded.',
|
||||
);
|
||||
} else {
|
||||
host.showStatus('Experimental features updated.', 'success');
|
||||
host.showStatus('Experimental features updated.', host.state.theme.colors.success);
|
||||
}
|
||||
host.track('experimental_features_apply', { changed: changes.length });
|
||||
} catch (error) {
|
||||
|
|
@ -628,6 +487,7 @@ function mountExperimentsPanel(
|
|||
host.mountEditorReplacement(
|
||||
new ExperimentsSelectorComponent({
|
||||
features,
|
||||
colors: host.state.theme.colors,
|
||||
onApply: (changes) => {
|
||||
void applyExperimentalFeatureChanges(host, changes);
|
||||
},
|
||||
|
|
@ -644,6 +504,7 @@ 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;
|
||||
|
|
@ -662,13 +523,15 @@ export async function applyUpdatePreferenceChoice(
|
|||
const upgrade = { autoInstall };
|
||||
try {
|
||||
await saveTuiConfig({
|
||||
...currentTuiConfig(host as unknown as SlashCommandHost),
|
||||
theme: host.state.appState.theme,
|
||||
editorCommand: host.state.appState.editorCommand,
|
||||
notifications: host.state.appState.notifications,
|
||||
upgrade,
|
||||
});
|
||||
} catch (error) {
|
||||
host.showStatus(
|
||||
`Failed to save automatic update setting: ${formatErrorMessage(error)}`,
|
||||
'error',
|
||||
host.state.theme.colors.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -699,6 +562,7 @@ 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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,31 +1,33 @@
|
|||
import type { Component, Focusable } from '@moonshot-ai/pi-tui';
|
||||
import type { Component, Focusable } from '@earendil-works/pi-tui';
|
||||
import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth';
|
||||
import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import type { ColorToken, ThemeName } from '#/tui/theme';
|
||||
|
||||
import { LLM_NOT_SET_MESSAGE } from '../constant/kimi-tui';
|
||||
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 { 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 { tryHandleDanceCommand } from '../easter-eggs/dance';
|
||||
import type { ResolvedTheme } from '../theme/colors';
|
||||
import type { AppState, LoginProgressSpinnerHandle, QueuedMessage } from '../types';
|
||||
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,
|
||||
|
|
@ -36,14 +38,11 @@ import {
|
|||
showSettingsSelector,
|
||||
} from './config';
|
||||
import { handleGoalCommand } from './goal';
|
||||
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 { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info';
|
||||
import { handlePluginsCommand } from './plugins';
|
||||
import { handleReloadCommand, handleReloadTuiCommand } from './reload';
|
||||
import { resolveSlashCommandInput, slashBusyMessage } from './resolve';
|
||||
import { handleSwarmCommand } from './swarm';
|
||||
import {
|
||||
handleExportDebugZipCommand,
|
||||
handleExportMdCommand,
|
||||
|
|
@ -51,22 +50,21 @@ 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,
|
||||
|
|
@ -77,7 +75,12 @@ export {
|
|||
showSettingsSelector,
|
||||
} from './config';
|
||||
export { handleSwarmCommand } from './swarm';
|
||||
export { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info';
|
||||
export {
|
||||
handleFeedbackCommand,
|
||||
showMcpServers,
|
||||
showStatusReport,
|
||||
showUsage,
|
||||
} from './info';
|
||||
export { handlePluginsCommand } from './plugins';
|
||||
export { handleReloadCommand, handleReloadTuiCommand } from './reload';
|
||||
export { handleGoalCommand } from './goal';
|
||||
|
|
@ -89,7 +92,6 @@ export {
|
|||
handleTitleCommand,
|
||||
} from './session';
|
||||
export { handleUndoCommand } from './undo';
|
||||
export { handleWebCommand } from './web';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host interface
|
||||
|
|
@ -105,9 +107,8 @@ export interface SlashCommandHost {
|
|||
setAppState(patch: Partial<AppState>): void;
|
||||
resetLivePane(): void;
|
||||
showError(msg: string): void;
|
||||
showStatus(msg: string, color?: ColorToken): void;
|
||||
showStatus(msg: string, color?: string): 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;
|
||||
|
|
@ -129,25 +130,17 @@ export interface SlashCommandHost {
|
|||
showProgressSpinner(label: string): LoginProgressSpinnerHandle;
|
||||
|
||||
// Theme
|
||||
applyTheme(theme: ThemeName, resolved?: ResolvedTheme): Promise<void>;
|
||||
applyTheme(theme: Theme, resolved?: ResolvedTheme): 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;
|
||||
|
|
@ -173,7 +166,6 @@ 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,
|
||||
});
|
||||
|
|
@ -205,20 +197,6 @@ 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
|
||||
|
|
@ -273,9 +251,6 @@ async function handleBuiltInSlashCommand(
|
|||
case 'plugins':
|
||||
void handlePluginsCommand(host, args);
|
||||
return;
|
||||
case 'add-dir':
|
||||
await handleAddDirCommand(host, args);
|
||||
return;
|
||||
case 'experiments':
|
||||
await showExperimentsPanel(host);
|
||||
return;
|
||||
|
|
@ -292,10 +267,7 @@ async function handleBuiltInSlashCommand(
|
|||
await handleThemeCommand(host, args);
|
||||
return;
|
||||
case 'model':
|
||||
await handleModelCommand(host, args);
|
||||
return;
|
||||
case 'effort':
|
||||
await handleEffortCommand(host, args);
|
||||
handleModelCommand(host, args);
|
||||
return;
|
||||
case 'provider':
|
||||
await handleProviderCommand(host);
|
||||
|
|
@ -360,9 +332,6 @@ 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;
|
||||
|
|
|
|||
|
|
@ -206,7 +206,7 @@ async function queueNextGoal(
|
|||
host.track('goal_queue_append');
|
||||
if (!hasCurrentGoal) host.requestQueuedGoalPromotion?.();
|
||||
host.state.transcriptContainer.addChild(
|
||||
new UpcomingGoalAddedMessageComponent(),
|
||||
new UpcomingGoalAddedMessageComponent(host.state.theme.colors),
|
||||
);
|
||||
host.state.ui.requestRender();
|
||||
}
|
||||
|
|
@ -228,6 +228,7 @@ async function showGoalQueueManager(
|
|||
new GoalQueueManagerComponent({
|
||||
goals: snapshot.goals,
|
||||
selectedGoalId,
|
||||
colors: host.state.theme.colors,
|
||||
onAction: async (action) => {
|
||||
try {
|
||||
return await handleGoalQueueManagerAction(host, action);
|
||||
|
|
@ -290,6 +291,7 @@ 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)}`);
|
||||
|
|
@ -352,6 +354,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') {
|
||||
|
|
@ -372,19 +375,10 @@ async function startGoalWithPermission(
|
|||
choice: GoalStartPermissionChoice,
|
||||
options: GoalStartOptions,
|
||||
): Promise<void> {
|
||||
const previousMode = host.state.appState.permissionMode;
|
||||
const switched =
|
||||
choice !== previousMode && (choice === 'auto' || choice === 'yolo');
|
||||
if (switched) {
|
||||
if (choice !== host.state.appState.permissionMode && (choice === 'auto' || choice === 'yolo')) {
|
||||
if (!(await setPermissionForGoal(host, choice))) return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
await startGoal(host, parsed, options);
|
||||
}
|
||||
|
||||
async function setPermissionForGoal(host: GoalCommandHost, mode: PermissionMode): Promise<boolean> {
|
||||
|
|
@ -421,7 +415,8 @@ async function startGoal(
|
|||
if (options.beforeSend !== undefined && !(await options.beforeSend())) {
|
||||
return false;
|
||||
}
|
||||
host.state.transcriptContainer.addChild(new GoalSetMessageComponent());
|
||||
host.track('goal_create', { replace: parsed.replace });
|
||||
host.state.transcriptContainer.addChild(new GoalSetMessageComponent(host.state.theme.colors));
|
||||
host.state.ui.requestRender();
|
||||
if (options.sendInput !== undefined) {
|
||||
options.sendInput(parsed.objective);
|
||||
|
|
@ -493,7 +488,7 @@ async function showGoalStatus(host: SlashCommandHost): Promise<void> {
|
|||
return;
|
||||
}
|
||||
host.state.transcriptContainer.addChild(
|
||||
new GoalStatusMessageComponent(goal),
|
||||
new GoalStatusMessageComponent(goal, host.state.theme.colors),
|
||||
);
|
||||
host.state.ui.requestRender();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,13 @@ 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,
|
||||
|
|
@ -22,14 +24,22 @@ export {
|
|||
showSettingsSelector,
|
||||
} from './config';
|
||||
export { handleSwarmCommand } from './swarm';
|
||||
export { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info';
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -9,21 +9,17 @@ 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 { promptFeedbackAttachment, promptFeedbackInput } from './prompts';
|
||||
import { promptFeedbackInput } from './prompts';
|
||||
import type { SlashCommandHost } from './dispatch';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -43,60 +39,30 @@ export async function handleFeedbackCommand(host: SlashCommandHost): Promise<voi
|
|||
return;
|
||||
}
|
||||
|
||||
// Stage 1: collect the free-form feedback text.
|
||||
const input = await promptFeedbackInput(host);
|
||||
if (input === undefined) {
|
||||
const content = await promptFeedbackInput(host);
|
||||
if (content === undefined) {
|
||||
host.showStatus(FEEDBACK_STATUS_CANCELLED);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stage 2: ask whether to attach diagnostics (logs / codebase).
|
||||
const level = await promptFeedbackAttachment(host);
|
||||
if (level === undefined) {
|
||||
host.showStatus(FEEDBACK_STATUS_CANCELLED);
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
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') {
|
||||
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 });
|
||||
if (res.kind === 'ok') {
|
||||
spinner.stop({ 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;
|
||||
return;
|
||||
}
|
||||
|
||||
spinner.stop({ ok: false, label: res.message });
|
||||
fallback(FEEDBACK_STATUS_FALLBACK);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -121,7 +87,8 @@ interface ManagedUsageResult {
|
|||
export async function showUsage(host: SlashCommandHost): Promise<void> {
|
||||
const sessionUsage = await loadSessionUsageReport(host);
|
||||
const managedUsage = await loadManagedUsageReport(host);
|
||||
const reportArgs = {
|
||||
const lines = buildUsageReportLines({
|
||||
colors: host.state.theme.colors,
|
||||
sessionUsage: sessionUsage.usage,
|
||||
sessionUsageError: sessionUsage.error,
|
||||
contextUsage: host.state.appState.contextUsage,
|
||||
|
|
@ -129,8 +96,8 @@ export async function showUsage(host: SlashCommandHost): Promise<void> {
|
|||
maxContextTokens: host.state.appState.maxContextTokens,
|
||||
managedUsage: managedUsage?.usage,
|
||||
managedUsageError: managedUsage?.error,
|
||||
};
|
||||
const panel = new UsagePanelComponent(() => buildUsageReportLines(reportArgs), 'primary');
|
||||
});
|
||||
const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary);
|
||||
host.state.transcriptContainer.addChild(panel);
|
||||
host.state.ui.requestRender();
|
||||
}
|
||||
|
|
@ -141,13 +108,14 @@ export async function showStatusReport(host: SlashCommandHost): Promise<void> {
|
|||
loadManagedUsageReport(host),
|
||||
]);
|
||||
const appState = host.state.appState;
|
||||
const reportArgs = {
|
||||
const lines = buildStatusReportLines({
|
||||
colors: host.state.theme.colors,
|
||||
version: appState.version,
|
||||
model: appState.model,
|
||||
workDir: appState.workDir,
|
||||
sessionId: appState.sessionId,
|
||||
sessionTitle: appState.sessionTitle,
|
||||
thinkingEffort: appState.thinkingEffort,
|
||||
thinking: appState.thinking,
|
||||
permissionMode: appState.permissionMode,
|
||||
planMode: appState.planMode,
|
||||
contextUsage: appState.contextUsage,
|
||||
|
|
@ -158,8 +126,8 @@ export async function showStatusReport(host: SlashCommandHost): Promise<void> {
|
|||
statusError: runtimeStatus.error,
|
||||
managedUsage: managedUsage?.usage,
|
||||
managedUsageError: managedUsage?.error,
|
||||
};
|
||||
const panel = new UsagePanelComponent(() => buildStatusReportLines(reportArgs), 'primary', ' Status ');
|
||||
});
|
||||
const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, ' Status ');
|
||||
host.state.transcriptContainer.addChild(panel);
|
||||
host.state.ui.requestRender();
|
||||
}
|
||||
|
|
@ -173,12 +141,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(
|
||||
() => buildMcpStatusReportLines({ servers }),
|
||||
'primary',
|
||||
title,
|
||||
);
|
||||
const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, title);
|
||||
host.state.transcriptContainer.addChild(panel);
|
||||
host.state.ui.requestRender();
|
||||
}
|
||||
|
|
@ -213,5 +181,5 @@ async function loadManagedUsageReport(host: SlashCommandHost): Promise<ManagedUs
|
|||
if (res.kind === 'error') {
|
||||
return { error: res.message };
|
||||
}
|
||||
return { usage: { summary: res.summary, limits: res.limits, extraUsage: res.extraUsage } };
|
||||
return { usage: { summary: res.summary, limits: res.limits } };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@ 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();
|
||||
// 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;
|
||||
if (name.includes('/')) return null;
|
||||
return { name, args };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
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 };
|
||||
}
|
||||
|
|
@ -4,15 +4,14 @@ import { isAbsolute, join, resolve } from 'node:path';
|
|||
import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import {
|
||||
PluginInstallTrustConfirmComponent,
|
||||
PluginMcpSelectorComponent,
|
||||
PluginMarketplaceSelectorComponent,
|
||||
PluginRemoveConfirmComponent,
|
||||
PluginsPanelComponent,
|
||||
type PluginInstallTrustConfirmResult,
|
||||
PluginsOverviewSelectorComponent,
|
||||
type PluginMcpSelection,
|
||||
type PluginMarketplaceSelection,
|
||||
type PluginRemoveConfirmResult,
|
||||
type PluginsPanelSelection,
|
||||
type PluginsPanelTabId,
|
||||
type PluginsOverviewSelection,
|
||||
} from '../components/dialogs/plugins-selector';
|
||||
import {
|
||||
buildPluginsInfoLines,
|
||||
|
|
@ -20,9 +19,8 @@ import {
|
|||
} from '../components/messages/plugins-status-panel';
|
||||
import { UsagePanelComponent } from '../components/messages/usage-panel';
|
||||
import { formatErrorMessage } from '../utils/event-payload';
|
||||
import { formatPluginSourceLabel, isOfficialPluginSource } from '../utils/plugin-source-label';
|
||||
import { formatPluginSourceLabel } from '../utils/plugin-source-label';
|
||||
import { loadPluginMarketplace } from '#/utils/plugin-marketplace';
|
||||
import { openUrl } from '#/utils/open-url';
|
||||
import type { SlashCommandHost } from './dispatch';
|
||||
|
||||
interface ShowPluginsPickerOptions {
|
||||
|
|
@ -31,8 +29,6 @@ interface ShowPluginsPickerOptions {
|
|||
readonly id: string;
|
||||
readonly text: string;
|
||||
};
|
||||
readonly initialTab?: PluginsPanelTabId;
|
||||
readonly marketplaceSource?: string;
|
||||
}
|
||||
|
||||
interface PluginMcpServerHint {
|
||||
|
|
@ -66,10 +62,6 @@ 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);
|
||||
|
|
@ -81,15 +73,7 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri
|
|||
return;
|
||||
}
|
||||
if (sub === 'marketplace') {
|
||||
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,
|
||||
});
|
||||
await showPluginMarketplacePicker(host, rest.join(' ').trim() || undefined);
|
||||
return;
|
||||
}
|
||||
if (sub === 'info') {
|
||||
|
|
@ -111,7 +95,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 /reload or /new to apply.`,
|
||||
`${action === 'enable' ? 'Enabled' : 'Disabled'} MCP server ${server} for ${id}. Run /new to apply.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -134,7 +118,8 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri
|
|||
host.showStatus(`Remove cancelled: ${id}.`);
|
||||
return;
|
||||
}
|
||||
await removePlugin(host, id);
|
||||
await session.removePlugin(id);
|
||||
host.showStatus(`Removed ${id} (plugin files left in place).`);
|
||||
return;
|
||||
}
|
||||
if (sub === 'reload') {
|
||||
|
|
@ -164,58 +149,55 @@ async function showPluginsPicker(
|
|||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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();
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadMarketplaceCatalog(
|
||||
host: SlashCommandHost,
|
||||
panel: PluginsPanelComponent,
|
||||
source?: string,
|
||||
): Promise<void> {
|
||||
async function showPluginMarketplacePicker(host: SlashCommandHost, source?: string): Promise<void> {
|
||||
try {
|
||||
const marketplace = await loadPluginMarketplace({
|
||||
workDir: host.state.appState.workDir,
|
||||
source,
|
||||
});
|
||||
panel.setMarketplace(marketplace.plugins, marketplace.source);
|
||||
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);
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
panel.setMarketplaceError(formatErrorMessage(error));
|
||||
host.showError(`Failed to load plugin marketplace: ${formatErrorMessage(error)}`);
|
||||
}
|
||||
host.state.ui.requestRender();
|
||||
}
|
||||
|
||||
async function showPluginMcpPicker(
|
||||
|
|
@ -236,6 +218,7 @@ 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.
|
||||
|
|
@ -264,6 +247,7 @@ 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');
|
||||
|
|
@ -273,67 +257,6 @@ 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,
|
||||
|
|
@ -353,71 +276,54 @@ 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 /reload or /new to apply.${mcpHint}`);
|
||||
host.showStatus(`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run /new to apply.${mcpHint}`);
|
||||
}
|
||||
const inlineMcpHint = mcpHint.length > 0 ? ' · MCP servers disabled' : '';
|
||||
return `${pluginInlineChangeHint()}${inlineMcpHint}`;
|
||||
}
|
||||
|
||||
async function handlePluginsPanelSelection(
|
||||
async function handlePluginsOverviewSelection(
|
||||
host: SlashCommandHost,
|
||||
panel: PluginsPanelComponent,
|
||||
selection: PluginsPanelSelection,
|
||||
selection: PluginsOverviewSelection,
|
||||
): 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 'remove':
|
||||
if (!(await confirmRemovePlugin(host, selection.id))) {
|
||||
host.showStatus(`Remove cancelled: ${selection.id}.`);
|
||||
await showPluginsPicker(host, { initialTab: 'installed', selectedId: selection.id });
|
||||
return;
|
||||
}
|
||||
await removePlugin(host, selection.id);
|
||||
await showPluginsPicker(host, { initialTab: 'installed' });
|
||||
return;
|
||||
case 'mcp':
|
||||
await showPluginMcpPicker(host, selection.id);
|
||||
return;
|
||||
case 'details':
|
||||
case 'remove':
|
||||
if (!(await confirmRemovePlugin(host, selection.id))) {
|
||||
host.showStatus(`Remove cancelled: ${selection.id}.`);
|
||||
await showPluginsPicker(host, { selectedId: selection.id });
|
||||
return;
|
||||
}
|
||||
await session.removePlugin(selection.id);
|
||||
host.showStatus(`Removed ${selection.id} (plugin files left in place).`);
|
||||
await showPluginsPicker(host);
|
||||
return;
|
||||
case 'info':
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -446,10 +352,22 @@ async function handlePluginMcpSelection(
|
|||
}
|
||||
}
|
||||
|
||||
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 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 renderPluginsList(
|
||||
|
|
@ -457,23 +375,20 @@ 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(
|
||||
() => buildPluginsListLines({ plugins: currentPlugins }),
|
||||
'primary',
|
||||
title,
|
||||
);
|
||||
const panel = new UsagePanelComponent(lines, host.state.theme.colors.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 panel = new UsagePanelComponent(
|
||||
() => buildPluginsInfoLines({ info }),
|
||||
'primary',
|
||||
` ${info.id} `,
|
||||
);
|
||||
const lines = buildPluginsInfoLines({ colors: host.state.theme.colors, info });
|
||||
const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, ` ${info.id} `);
|
||||
host.state.transcriptContainer.addChild(panel);
|
||||
host.state.ui.requestRender();
|
||||
}
|
||||
|
|
@ -481,21 +396,25 @@ 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);
|
||||
showPluginInstallResult(host, beforeList, summary, options);
|
||||
}
|
||||
|
||||
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';
|
||||
|
|
@ -504,8 +423,15 @@ 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}`);
|
||||
host.showStatus(PLUGIN_RELOAD_HINT, 'warning');
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function describeInstallAction(
|
||||
|
|
@ -518,19 +444,13 @@ function describeInstallAction(
|
|||
return ` ${prev} → ${cur ?? '-'}`;
|
||||
};
|
||||
if (previous === undefined) {
|
||||
return `Installed ${next.displayName}${versionFromTo(undefined, next.version)} ${sourcePhrase(sourceLabel)}`;
|
||||
return `Installed ${next.displayName}${versionFromTo(undefined, next.version)} from ${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)} ${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}`;
|
||||
return `Updated ${next.displayName}${versionFromTo(previous.version, next.version)} from ${sourceLabel}`;
|
||||
}
|
||||
|
||||
function sourceIdentity(plugin: PluginSummary): string {
|
||||
|
|
@ -561,5 +481,5 @@ function resolvePluginInstallSource(source: string, workDir: string): string {
|
|||
}
|
||||
|
||||
function pluginInlineChangeHint(): string {
|
||||
return 'run /reload or /new to apply';
|
||||
return 'require run /new to apply';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ 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 {
|
||||
|
|
@ -22,6 +21,7 @@ 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,6 +45,7 @@ export function promptLogoutProviderSelection(
|
|||
title: 'Select a provider to log out',
|
||||
options,
|
||||
currentValue,
|
||||
colors: host.state.theme.colors,
|
||||
onSelect: (value) => {
|
||||
host.restoreEditor();
|
||||
resolve(value);
|
||||
|
|
@ -58,58 +59,16 @@ export function promptLogoutProviderSelection(
|
|||
});
|
||||
}
|
||||
|
||||
export interface FeedbackPromptResult {
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
export function promptFeedbackInput(host: SlashCommandHost): Promise<FeedbackPromptResult | undefined> {
|
||||
export function promptFeedbackInput(host: SlashCommandHost): Promise<string | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const dialog = new FeedbackInputDialogComponent((result: FeedbackInputDialogResult) => {
|
||||
host.restoreEditor();
|
||||
resolve(result.kind === 'ok' ? { value: result.value } : undefined);
|
||||
});
|
||||
resolve(result.kind === 'ok' ? result.value : undefined);
|
||||
}, host.state.theme.colors);
|
||||
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,
|
||||
|
|
@ -123,6 +82,7 @@ export function promptApiKey(
|
|||
host.restoreEditor();
|
||||
resolve(result.kind === 'ok' ? result.value : undefined);
|
||||
},
|
||||
host.state.theme.colors,
|
||||
);
|
||||
host.mountEditorReplacement(dialog);
|
||||
});
|
||||
|
|
@ -149,6 +109,7 @@ 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();
|
||||
|
|
@ -167,7 +128,7 @@ export async function promptModelSelectionForOpenPlatform(
|
|||
host: SlashCommandHost,
|
||||
models: ManagedKimiCodeModelInfo[],
|
||||
platform: OpenPlatformDefinition,
|
||||
): Promise<{ model: ManagedKimiCodeModelInfo; thinking: ThinkingEffort } | undefined> {
|
||||
): Promise<{ model: ManagedKimiCodeModelInfo; thinking: boolean } | undefined> {
|
||||
const modelDict: Record<string, ModelAlias> = {};
|
||||
for (const m of models) {
|
||||
modelDict[`${platform.id}/${m.id}`] = {
|
||||
|
|
@ -188,7 +149,7 @@ export async function promptModelSelectionForCatalog(
|
|||
host: SlashCommandHost,
|
||||
providerId: string,
|
||||
models: CatalogModel[],
|
||||
): Promise<{ model: CatalogModel; thinking: ThinkingEffort } | undefined> {
|
||||
): Promise<{ model: CatalogModel; thinking: boolean } | undefined> {
|
||||
const modelDict: Record<string, ModelAlias> = {};
|
||||
for (const m of models) {
|
||||
modelDict[`${providerId}/${m.id}`] = catalogModelToAlias(providerId, m);
|
||||
|
|
@ -202,7 +163,7 @@ export async function promptModelSelectionForCatalog(
|
|||
export function runModelSelector(
|
||||
host: SlashCommandHost,
|
||||
modelDict: Record<string, ModelAlias>,
|
||||
): Promise<{ alias: string; thinking: ThinkingEffort } | undefined> {
|
||||
): Promise<{ alias: string; thinking: boolean } | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const firstAlias = Object.keys(modelDict)[0] ?? '';
|
||||
const caps = modelDict[firstAlias]?.capabilities ?? [];
|
||||
|
|
@ -210,7 +171,8 @@ export function runModelSelector(
|
|||
const selector = new ModelSelectorComponent({
|
||||
models: modelDict,
|
||||
currentValue: firstAlias,
|
||||
currentThinkingEffort: initialThinking ? 'on' : 'off',
|
||||
currentThinking: initialThinking,
|
||||
colors: host.state.theme.colors,
|
||||
searchable: true,
|
||||
onSelect: ({ alias, thinking }) => {
|
||||
host.restoreEditor();
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import {
|
|||
fetchCatalog,
|
||||
inferWireType,
|
||||
type Catalog,
|
||||
type ThinkingEffort,
|
||||
} from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import { ChoicePickerComponent } from '../components/dialogs/choice-picker';
|
||||
|
|
@ -28,7 +27,6 @@ import {
|
|||
import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector';
|
||||
import { DEFAULT_OAUTH_PROVIDER_NAME } from '../constant/kimi-tui';
|
||||
import { formatErrorMessage } from '../utils/event-payload';
|
||||
import { thinkingEffortToConfig } from '../utils/thinking-config';
|
||||
import {
|
||||
promptApiKey,
|
||||
promptCatalogProviderSelection,
|
||||
|
|
@ -51,15 +49,12 @@ function buildProviderManagerOptions(host: SlashCommandHost): ProviderManagerOpt
|
|||
return {
|
||||
providers: host.state.appState.availableProviders,
|
||||
activeProviderId,
|
||||
colors: host.state.theme.colors,
|
||||
onAdd: () => {
|
||||
void handleProviderAdd(host).catch((error: unknown) => {
|
||||
host.showError(`Add provider failed: ${formatErrorMessage(error)}`);
|
||||
});
|
||||
void handleProviderAdd(host);
|
||||
},
|
||||
onDeleteSource: (providerIds) => {
|
||||
void handleProviderManagerDeleteSource(host, providerIds).catch((error: unknown) => {
|
||||
host.showError(`Remove provider failed: ${formatErrorMessage(error)}`);
|
||||
});
|
||||
void handleProviderManagerDeleteSource(host, providerIds);
|
||||
},
|
||||
onClose: () => {
|
||||
host.restoreEditor();
|
||||
|
|
@ -137,6 +132,7 @@ function promptProviderAddSource(
|
|||
{ value: 'known', label: 'Known third-party provider' },
|
||||
{ value: 'custom', label: 'Custom registry (api.json)' },
|
||||
],
|
||||
colors: host.state.theme.colors,
|
||||
onSelect: (value) => {
|
||||
host.restoreEditor();
|
||||
resolve(value === 'known' || value === 'custom' ? value : undefined);
|
||||
|
|
@ -235,13 +231,12 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise<void> {
|
|||
models: mergedModels,
|
||||
currentValue: host.state.appState.model,
|
||||
selectedValue: Object.keys(mergedModels).find((a) => a.startsWith(`${providerId}/`)),
|
||||
currentThinkingEffort: host.state.appState.thinkingEffort,
|
||||
currentThinking: host.state.appState.thinking,
|
||||
colors: host.state.theme.colors,
|
||||
initialTabId: providerId,
|
||||
onSelect: ({ alias, thinking }) => {
|
||||
host.restoreEditor();
|
||||
void setDefaultModel(host, alias, thinking).catch((error: unknown) => {
|
||||
host.showError(`Set default model failed: ${formatErrorMessage(error)}`);
|
||||
});
|
||||
void setDefaultModel(host, alias, thinking);
|
||||
},
|
||||
onCancel: () => {
|
||||
host.restoreEditor();
|
||||
|
|
@ -253,15 +248,15 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise<void> {
|
|||
async function setDefaultModel(
|
||||
host: SlashCommandHost,
|
||||
alias: string,
|
||||
effort: ThinkingEffort,
|
||||
thinking: boolean,
|
||||
): Promise<void> {
|
||||
await host.harness.setConfig({
|
||||
defaultModel: alias,
|
||||
thinking: thinkingEffortToConfig(effort),
|
||||
defaultThinking: thinking,
|
||||
});
|
||||
await host.authFlow.refreshConfigAfterLogin();
|
||||
host.track('model_switch', { model: alias });
|
||||
host.showStatus(`Default model set to ${alias} with thinking ${effort}.`);
|
||||
host.showStatus(`Default model set to ${alias} with thinking ${thinking ? 'on' : 'off'}.`);
|
||||
}
|
||||
|
||||
async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise<boolean> {
|
||||
|
|
@ -277,8 +272,8 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise
|
|||
let entries: Awaited<ReturnType<typeof fetchCustomRegistry>>;
|
||||
try {
|
||||
entries = await fetchCustomRegistry(source);
|
||||
} catch (error) {
|
||||
host.showError(`Failed to import registry: ${formatErrorMessage(error)}`);
|
||||
} catch (err) {
|
||||
host.showError(`Failed to import registry: ${formatErrorMessage(err)}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -295,8 +290,8 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise
|
|||
models: config.models,
|
||||
});
|
||||
await host.authFlow.refreshConfigAfterLogin();
|
||||
} catch (error) {
|
||||
host.showError(`Failed to apply registry: ${formatErrorMessage(error)}`);
|
||||
} catch (err) {
|
||||
host.showError(`Failed to apply registry: ${formatErrorMessage(err)}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -309,7 +304,7 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise
|
|||
count === 1
|
||||
? 'Imported 1 provider from registry.'
|
||||
: `Imported ${String(count)} providers from registry.`,
|
||||
'success',
|
||||
host.state.theme.colors.success,
|
||||
);
|
||||
|
||||
// Offer the model selector so the user can pick a default, just like the
|
||||
|
|
@ -325,13 +320,12 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise
|
|||
models: stateModels,
|
||||
currentValue: host.state.appState.model,
|
||||
selectedValue: firstNewAlias,
|
||||
currentThinkingEffort: host.state.appState.thinkingEffort,
|
||||
currentThinking: host.state.appState.thinking,
|
||||
colors: host.state.theme.colors,
|
||||
initialTabId: firstNewProvider,
|
||||
onSelect: ({ alias, thinking }) => {
|
||||
host.restoreEditor();
|
||||
void setDefaultModel(host, alias, thinking).catch((error: unknown) => {
|
||||
host.showError(`Set default model failed: ${formatErrorMessage(error)}`);
|
||||
});
|
||||
void setDefaultModel(host, alias, thinking);
|
||||
},
|
||||
onCancel: () => {
|
||||
host.restoreEditor();
|
||||
|
|
@ -350,6 +344,7 @@ function promptCustomRegistryImport(
|
|||
host.restoreEditor();
|
||||
resolve(result.kind === 'ok' ? result.value : undefined);
|
||||
},
|
||||
host.state.theme.colors,
|
||||
);
|
||||
host.mountEditorReplacement(dialog);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
import { readdirSync, statSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, dirname, join, relative, resolve } from 'pathe';
|
||||
|
||||
import type { AutocompleteItem } from '@moonshot-ai/pi-tui';
|
||||
import type { AutocompleteItem } from '@earendil-works/pi-tui';
|
||||
|
||||
import { completeLeadingArg, type ArgCompletionSpec } from './complete-args';
|
||||
import type { KimiSlashCommand, SlashCommandAvailability } from './types';
|
||||
|
|
@ -26,10 +22,6 @@ const SWARM_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
|
|||
{ value: 'off', description: 'Turn swarm mode off' },
|
||||
];
|
||||
|
||||
const ADD_DIR_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
|
||||
{ value: 'list', description: 'Show configured additional workspace directories' },
|
||||
];
|
||||
|
||||
/** Argument autocompletion for the `/goal` command (subcommands). */
|
||||
export function goalArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
|
||||
const nextMatch = argumentPrefix.match(/^next\s+(\S*)$/i);
|
||||
|
|
@ -49,102 +41,19 @@ export function swarmArgumentCompletions(argumentPrefix: string): AutocompleteIt
|
|||
return completeLeadingArg(SWARM_ARG_COMPLETIONS, argumentPrefix);
|
||||
}
|
||||
|
||||
/** Argument autocompletion for the `/add-dir` command. */
|
||||
export function addDirArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
|
||||
if (isPathLikeAddDirArgument(argumentPrefix)) {
|
||||
return completeAddDirPath(argumentPrefix);
|
||||
}
|
||||
return completeLeadingArg(ADD_DIR_ARG_COMPLETIONS, argumentPrefix);
|
||||
}
|
||||
|
||||
function isPathLikeAddDirArgument(argumentPrefix: string): boolean {
|
||||
return argumentPrefix === '.' || argumentPrefix === '..' || argumentPrefix.startsWith('./') || argumentPrefix.startsWith('../') || argumentPrefix.startsWith('/') || argumentPrefix.startsWith('~');
|
||||
}
|
||||
|
||||
function completeAddDirPath(argumentPrefix: string): AutocompleteItem[] | null {
|
||||
const normalizedPrefix = argumentPrefix === '~' ? '~/' : argumentPrefix;
|
||||
const expandedPrefix = expandHomePrefix(normalizedPrefix);
|
||||
const parentInput = getDirectoryCompletionParentInput(normalizedPrefix, expandedPrefix);
|
||||
const partialName = normalizedPrefix.endsWith('/') ? '' : basename(expandedPrefix);
|
||||
const parentDir = resolveDirectoryCompletionParent(parentInput);
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(parentDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items: AutocompleteItem[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.name === '.' || entry.name === '..' || entry.name.startsWith('.')) continue;
|
||||
if (partialName.length > 0 && !entry.name.toLowerCase().startsWith(partialName.toLowerCase())) continue;
|
||||
const absolutePath = join(parentDir, entry.name);
|
||||
if (!isDirectoryPath(absolutePath, entry.isDirectory(), entry.isSymbolicLink())) continue;
|
||||
const value = formatDirectoryCompletionValue(normalizedPrefix, parentInput, entry.name);
|
||||
items.push({
|
||||
value,
|
||||
label: `${entry.name}/`,
|
||||
description: absolutePath,
|
||||
});
|
||||
}
|
||||
|
||||
return items.length > 0 ? items : null;
|
||||
}
|
||||
|
||||
function expandHomePrefix(argumentPrefix: string): string {
|
||||
if (argumentPrefix === '~') return homedir();
|
||||
if (argumentPrefix.startsWith('~/')) return join(homedir(), argumentPrefix.slice(2));
|
||||
return argumentPrefix;
|
||||
}
|
||||
|
||||
function getDirectoryCompletionParentInput(argumentPrefix: string, expandedPrefix: string): string {
|
||||
if (argumentPrefix === '/') return '/';
|
||||
if (argumentPrefix === '~/') return homedir();
|
||||
if (argumentPrefix.endsWith('/')) return expandedPrefix.slice(0, -1);
|
||||
return dirname(expandedPrefix);
|
||||
}
|
||||
|
||||
function resolveDirectoryCompletionParent(parentInput: string): string {
|
||||
if (parentInput === '~') return homedir();
|
||||
if (parentInput.startsWith('~/')) return join(homedir(), parentInput.slice(2));
|
||||
return resolve(parentInput);
|
||||
}
|
||||
|
||||
function isDirectoryPath(path: string, isDirectory: boolean, isSymlink: boolean): boolean {
|
||||
if (isDirectory) return true;
|
||||
if (!isSymlink) return false;
|
||||
try {
|
||||
return statSync(path).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDirectoryCompletionValue(argumentPrefix: string, parentInput: string, entryName: string): string {
|
||||
if (argumentPrefix.startsWith('~/')) {
|
||||
const home = homedir();
|
||||
const homeRelative = relative(home, parentInput);
|
||||
return `~${homeRelative.length > 0 ? `/${homeRelative}` : ''}/${entryName}/`;
|
||||
}
|
||||
if (argumentPrefix.startsWith('/')) {
|
||||
return `${join(parentInput, entryName)}/`;
|
||||
}
|
||||
return `${join(parentInput, entryName)}/`;
|
||||
}
|
||||
|
||||
export const BUILTIN_SLASH_COMMANDS = [
|
||||
{
|
||||
name: 'yolo',
|
||||
aliases: ['yes'],
|
||||
description: 'Toggle YOLO mode: AI auto-approves safe actions, asks for approval on risky ones.',
|
||||
priority: 101,
|
||||
description: 'Toggle auto-approve mode',
|
||||
priority: 100,
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
name: 'auto',
|
||||
aliases: [],
|
||||
description: 'Toggle Auto mode: run all actions automatically, including risky ones.',
|
||||
priority: 99,
|
||||
description: 'Toggle auto permission mode',
|
||||
priority: 100,
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
|
|
@ -173,7 +82,6 @@ export const BUILTIN_SLASH_COMMANDS = [
|
|||
aliases: [],
|
||||
description: 'Toggle swarm mode or run one task in swarm mode',
|
||||
priority: 100,
|
||||
argumentHint: '[on|off] | <task>',
|
||||
completeArgs: swarmArgumentCompletions,
|
||||
availability: 'idle-only',
|
||||
},
|
||||
|
|
@ -184,13 +92,6 @@ export const BUILTIN_SLASH_COMMANDS = [
|
|||
priority: 100,
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
name: 'effort',
|
||||
aliases: ['thinking'],
|
||||
description: 'Switch thinking effort',
|
||||
priority: 95,
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
name: 'provider',
|
||||
aliases: ['providers'],
|
||||
|
|
@ -245,15 +146,6 @@ export const BUILTIN_SLASH_COMMANDS = [
|
|||
priority: 60,
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
name: 'add-dir',
|
||||
aliases: [],
|
||||
description: 'Add or list an additional workspace directory',
|
||||
priority: 60,
|
||||
availability: 'idle-only',
|
||||
argumentHint: '[list] | <path>',
|
||||
completeArgs: addDirArgumentCompletions,
|
||||
},
|
||||
{
|
||||
name: 'experiments',
|
||||
aliases: ['experimental'],
|
||||
|
|
@ -280,14 +172,16 @@ export const BUILTIN_SLASH_COMMANDS = [
|
|||
aliases: [],
|
||||
description: 'Compact the conversation context',
|
||||
priority: 80,
|
||||
argumentHint: '<instruction>',
|
||||
},
|
||||
{
|
||||
name: 'goal',
|
||||
aliases: [],
|
||||
description: 'Start or manage an autonomous goal',
|
||||
priority: 80,
|
||||
argumentHint: '[status|pause|resume|cancel|replace|next] | <objective>',
|
||||
// No argumentHint: the menu description stays as short as every other
|
||||
// command's. The subcommands (status/pause/resume/cancel/replace) surface in
|
||||
// the argument autocomplete list once the user types `/goal ` (see
|
||||
// completeArgs), so they don't need to be spelled out inline.
|
||||
completeArgs: goalArgumentCompletions,
|
||||
// status / pause / cancel are always available; creation, replacement, and
|
||||
// resume start (or restart) a turn and so are idle-only.
|
||||
|
|
@ -315,7 +209,6 @@ export const BUILTIN_SLASH_COMMANDS = [
|
|||
aliases: ['rename'],
|
||||
description: 'Set or show session title',
|
||||
priority: 60,
|
||||
argumentHint: '<title>',
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
|
|
@ -384,13 +277,6 @@ export const BUILTIN_SLASH_COMMANDS = [
|
|||
description: 'Export current session as a debug ZIP archive',
|
||||
priority: 40,
|
||||
},
|
||||
{
|
||||
name: 'web',
|
||||
aliases: [],
|
||||
description: 'Open the current session in the Web UI and exit the terminal',
|
||||
priority: 40,
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
name: 'exit',
|
||||
aliases: ['quit', 'q'],
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
import type { KimiConfig } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import { currentTheme, lightColors } from '#/tui/theme';
|
||||
import { loadTuiConfig, type TuiConfig } from '../config';
|
||||
import type { SlashCommandHost } from './dispatch';
|
||||
import { setExperimentalFeatures } from './experimental-flags';
|
||||
|
||||
export async function handleReloadTuiCommand(host: SlashCommandHost): Promise<void> {
|
||||
const tuiConfig = await loadTuiConfig();
|
||||
await applyReloadedTuiConfig(host, tuiConfig);
|
||||
host.showStatus('TUI config reloaded.', 'success');
|
||||
applyReloadedTuiConfig(host, tuiConfig);
|
||||
host.showStatus('TUI config reloaded.', host.state.theme.colors.success);
|
||||
}
|
||||
|
||||
export async function handleReloadCommand(host: SlashCommandHost): Promise<void> {
|
||||
|
|
@ -16,7 +15,7 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise<void>
|
|||
const session = host.session;
|
||||
|
||||
if (session !== undefined) {
|
||||
await session.reloadSession({ forcePluginSessionStartReminder: true });
|
||||
await session.reloadSession();
|
||||
await host.reloadCurrentSessionView(session, 'Session reloaded.');
|
||||
}
|
||||
|
||||
|
|
@ -24,32 +23,28 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise<void>
|
|||
setExperimentalFeatures(await host.harness.getExperimentalFeatures());
|
||||
host.refreshSlashCommandAutocomplete();
|
||||
applyRuntimeConfig(host, config);
|
||||
await applyReloadedTuiConfig(host, tuiConfig);
|
||||
applyReloadedTuiConfig(host, tuiConfig);
|
||||
|
||||
if (session === undefined) {
|
||||
host.showStatus(
|
||||
'Runtime and TUI config reloaded; no active session.',
|
||||
'success',
|
||||
host.state.theme.colors.success,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyReloadedTuiConfig(
|
||||
export function applyReloadedTuiConfig(
|
||||
host: SlashCommandHost,
|
||||
config: TuiConfig,
|
||||
): Promise<void> {
|
||||
const resolved = config.theme === 'auto'
|
||||
? (currentTheme.palette === lightColors ? 'light' : 'dark')
|
||||
: undefined;
|
||||
await host.applyTheme(config.theme, resolved);
|
||||
): void {
|
||||
const resolved = config.theme === 'auto' ? host.state.theme.resolvedTheme : config.theme;
|
||||
host.applyTheme(config.theme, resolved);
|
||||
host.refreshTerminalThemeTracking();
|
||||
host.setAppState({
|
||||
editorCommand: config.editorCommand,
|
||||
disablePasteBurst: config.disablePasteBurst,
|
||||
notifications: config.notifications,
|
||||
upgrade: config.upgrade,
|
||||
});
|
||||
host.state.editor.setDisablePasteBurst(config.disablePasteBurst);
|
||||
}
|
||||
|
||||
function applyRuntimeConfig(host: SlashCommandHost, config: KimiConfig): void {
|
||||
|
|
|
|||
|
|
@ -26,12 +26,6 @@ export type SlashCommandIntent =
|
|||
readonly skillName: string;
|
||||
readonly args: string;
|
||||
}
|
||||
| {
|
||||
readonly kind: 'plugin-command';
|
||||
readonly commandName: string;
|
||||
readonly pluginId: string;
|
||||
readonly args: string;
|
||||
}
|
||||
| { readonly kind: 'message'; readonly input: string }
|
||||
| {
|
||||
readonly kind: 'blocked';
|
||||
|
|
@ -47,7 +41,6 @@ export type SlashCommandIntent =
|
|||
export interface ResolveSlashCommandInput {
|
||||
readonly input: string;
|
||||
readonly skillCommandMap: ReadonlyMap<string, string>;
|
||||
readonly pluginCommandMap: ReadonlyMap<string, string>;
|
||||
readonly isStreaming: boolean;
|
||||
readonly isCompacting: boolean;
|
||||
}
|
||||
|
|
@ -99,26 +92,6 @@ export function resolveSlashCommandInput(options: ResolveSlashCommandInput): Sla
|
|||
};
|
||||
}
|
||||
|
||||
if (options.pluginCommandMap.has(parsed.name)) {
|
||||
const busyReason = slashCommandBusyReason(options);
|
||||
if (busyReason !== undefined) {
|
||||
return {
|
||||
kind: 'blocked',
|
||||
commandName: parsed.name,
|
||||
reason: busyReason,
|
||||
};
|
||||
}
|
||||
const separator = parsed.name.indexOf(':');
|
||||
const pluginId = separator === -1 ? parsed.name : parsed.name.slice(0, separator);
|
||||
const commandName = separator === -1 ? '' : parsed.name.slice(separator + 1);
|
||||
return {
|
||||
kind: 'plugin-command',
|
||||
commandName,
|
||||
pluginId,
|
||||
args: parsed.args.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'message',
|
||||
input: options.input,
|
||||
|
|
|
|||
|
|
@ -33,10 +33,7 @@ export function buildSkillSlashCommands(skills: readonly SkillSummary[]): SkillS
|
|||
const commandMap = new Map<string, string>();
|
||||
const sortedSkills = [...skills].toSorted(compareSkillSlashCommands);
|
||||
const commands = sortedSkills.filter(isUserActivatableSkill).map((skill) => {
|
||||
const commandName =
|
||||
skill.source === 'builtin' || skill.isSubSkill === true
|
||||
? skill.name
|
||||
: `skill:${skill.name}`;
|
||||
const commandName = skill.source === 'builtin' ? skill.name : `skill:${skill.name}`;
|
||||
commandMap.set(commandName, skill.name);
|
||||
return {
|
||||
name: commandName,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue