diff --git a/.agents/skills/agent-core-dev/config.md b/.agents/skills/agent-core-dev/config.md index 2eb48516eb..6529924c11 100644 --- a/.agents/skills/agent-core-dev/config.md +++ b/.agents/skills/agent-core-dev/config.md @@ -152,15 +152,18 @@ registerSection('providers', ProvidersSectionSchema, { ``` Each field is an `EnvBinding` — a string (env var name) or -`{ env, parse?, default? }`. IConfig resolves every field by +`{ env, deprecatedEnv?, parse?, default? }`. IConfig resolves every field by `env > config.toml > default`, sets it on the effective value, and validates the section. Empty nested entries (no field resolved) are omitted, so a synthetic entry like `__kimi_env__` only appears when at least one of its env vars is set. +When `deprecatedEnv` is set and `env` itself is absent or fails `parse`, the +deprecated var still supplies the value and a warning diagnostic is reported — +use it to rename an env var without breaking existing setups. `stripEnv(value, raw?, getEnv?)` removes env-derived fields before `set`/`replace` persists, so env overrides never leak into `config.toml`. `raw` is the section's -env-free camelCase base (already `fromToml`-normalized, so legacy key renames -are honored), and `getEnv` reads the live env bag. For fields that are **both +env-free camelCase base (already `fromToml`-normalized), and `getEnv` reads the +live env bag. For fields that are **both user-persistable and env-overridable**, register `stripEnv: stripEnvBoundFields(sectionEnvBindings)` (from `#/app/config/config`) — it derives the guard from the same bindings the read path uses: while a @@ -231,7 +234,7 @@ This means registration order is never a correctness concern — you do not need `config.toml` stores keys in **snake_case**; in-memory values are **camelCase**. `ConfigService` converts both ways by dispatching to each section's registered transform: -- **Read**: `transformTomlData(fileData, registry)` maps each top-level key to a domain and applies that domain's `fromToml` hook (or a plain key-casing pass when none is registered). Owner domains register their own normalization — e.g. provider `oauth`/`env`/`customHeaders`, permission `deny/allow/ask` → `rules`, `loop_control.max_steps_per_run` → `maxStepsPerTurn`, `experimental` keys preserved verbatim. When a section registers after the initial load, `ConfigService` re-applies its `fromToml` against the preserved snake_case raw value (see "Late registration"), so registration order is never a correctness concern. +- **Read**: `transformTomlData(fileData, registry)` maps each top-level key to a domain and applies that domain's `fromToml` hook (or a plain key-casing pass when none is registered). Owner domains register their own normalization — e.g. provider `oauth`/`env`/`customHeaders`, permission `deny/allow/ask` → `rules`, `experimental` keys preserved verbatim. When a section registers after the initial load, `ConfigService` re-applies its `fromToml` against the preserved snake_case raw value (see "Late registration"), so registration order is never a correctness concern. - **Write**: `applySectionToToml(rawSnake, domain, value, registry)` applies the domain's `toToml` hook (or a plain camelCase→snake_case mapping) into a raw clone of the file, preserving unknown top-level keys and unknown sub-fields (lossless round-trip). `ConfigService` keeps four views: @@ -241,6 +244,23 @@ This means registration order is never a correctness concern — you do not need - `validated` — validated `raw`, env-free; the base every live env re-application starts from, so a degraded or removed env value falls back to the file instead of a stale overlay. - `effective` — `validated` plus the env overlay, recomputed on load/set; `get()`/`getAll()` re-apply the overlay on a fresh `validated` copy per read rather than caching it. +### Renaming config keys and env vars (deprecations) + +Renames are declared once on the section, never hand-rolled in `fromToml`: + +```ts +registerSection(MY_SECTION, MySectionSchema, { + deprecations: [{ key: 'old_key', replacement: 'new_key' }], // snake_case, on-disk + env: envBindings(MySectionSchema, { + newKey: { env: 'KIMI_NEW_KEY', deprecatedEnv: 'KIMI_OLD_KEY', parse }, + }), +}); +``` + +- A deprecated TOML key is **ignored** (its value no longer applies — the schema only knows the new key) and reports a warning `ConfigDiagnostic` while present; the file is never rewritten, so the warning is the migration guide. Diagnostics are recomputed on every load/reload and surface to clients via `IConfigService.diagnostics()` and `onDidChangeDiagnostics` (kap-server republishes them as the global `event.config.warning` WS event). +- A deprecated env var still **resolves** as a fallback (new var first), with the same warning treatment, and `stripEnvBoundFields` treats it as env-owned for writes. +- See `src/agent/loop/configSection.ts` for a worked example (`max_retries_per_step` → `max_attempts_per_step`). + ### `KIMI_MODEL_*` env overlay When `KIMI_MODEL_NAME` is set, the `kosongConfig` wrapper's `kimiModelEnvOverlay` (`src/app/kosongConfig/envOverlay.ts`) injects a reserved model alias (`__kimi_env_model__`) into `effective`, points `defaultModel` at it, and merges the request `modelOverrides`; the reserved provider (`__kimi_env__`) comes from the `providers` section env bindings. The overlay is registered via `IConfigRegistry.registerEffectiveOverlay` and applied **only to `effective`**, never to `rawSnake`, so it is never persisted. Its `strip` (plus the providers section `stripEnv`) is the final guard so a caller that read `effective` (with the overlay) cannot write the reserved entries or the shell API key back to disk. `config` itself only runs registered overlays — it does not know the `KIMI_MODEL_*` semantics. diff --git a/.agents/skills/agent-core-dev/edge-exposure.md b/.agents/skills/agent-core-dev/edge-exposure.md index 9105bb9488..5039201ac9 100644 --- a/.agents/skills/agent-core-dev/edge-exposure.md +++ b/.agents/skills/agent-core-dev/edge-exposure.md @@ -55,9 +55,9 @@ Read = `GET`, write = `POST`. `sid` = `session_id`, `aid` = `agent_id`. | resource | action | Service.method | verb | |---|---|---|---| -| `sessions` | `list` | ISessionIndex.list | GET | +| `sessions` | `listRecent` | ISessionIndex.listRecent | GET | | `sessions` | `get` | ISessionIndex.get | GET | -| `sessions` | `countActive` | ISessionIndex.countActive | GET | +| `sessions` | `count` | ISessionIndex.count | GET | | `workspaces` | `list` | IWorkspaceService.list | GET | | `workspaces` | `get` | IWorkspaceService.get | GET | | `workspaces` | `createOrTouch` | IWorkspaceService.createOrTouch | POST | @@ -105,7 +105,7 @@ Read = `GET`, write = `POST`. `sid` = `session_id`, `aid` = `agent_id`. | `tasks` | `list` / `get` / `readOutput` | IBackgroundService.* | GET | | `tasks` | `stop` / `detach` | IBackgroundService.* | POST | | `usage` | `status` | IUsageService.status | GET | -| `context` | `status` | IAgentContextSizeService.get | GET | +| `context` | `status` | IAgentTokenCountingService.get | GET | | `swarm` | `isActive` | ISwarmService.isActive | GET | | `swarm` | `enter` / `exit` | ISwarmService.* | POST | | `permission` | `getMode` | IPermissionModeService.mode | GET | diff --git a/.agents/skills/pre-changelog/SKILL.md b/.agents/skills/pre-changelog/SKILL.md index adde7c1b36..4937ea2a08 100644 --- a/.agents/skills/pre-changelog/SKILL.md +++ b/.agents/skills/pre-changelog/SKILL.md @@ -37,7 +37,7 @@ If the CLI changelog is not in the diff (for example an SDK-only release), stop 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. +- **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, hook/event payload mechanics such as what an event reports or carries) 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 新功能 / 修复 / 优化 / 重构 / 其他. @@ -48,6 +48,8 @@ If an upstream entry is not in English, flag it and stop (changeset entries must Print the preview directly. Use `(预览)` as the heading because the version is not released yet. Write `无` for empty sections. Do not write any file. +The preview is pasted into chat tools (for example Lark), where relative docs links do not resolve. Rewrite every docs link to its absolute published URL: map `../.md[#anchor]` to `https://moonshotai.github.io/kimi-code/zh/.html[#anchor]` — for example `../configuration/config-files.md#loop-control` → `https://moonshotai.github.io/kimi-code/zh/configuration/config-files.html#loop-control`. Never emit raw relative paths, and never wrap a link in backticks; code-style the link text inside the brackets instead ([`loop_control`](...)). + ``` 发版 PR: diff --git a/.agents/skills/sync-changelog/SKILL.md b/.agents/skills/sync-changelog/SKILL.md index 25e542d4b9..fad6e4c8b3 100644 --- a/.agents/skills/sync-changelog/SKILL.md +++ b/.agents/skills/sync-changelog/SKILL.md @@ -115,6 +115,7 @@ Drop SDK-only and provider-internal detail. This changelog serves `@moonshot-ai/ - 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 ``, protocol field explanations, "the wire protocol is unchanged", cache-hit mechanics) unless they are the behavior a user perceives. +- Drop hook/event payload mechanics — clauses about what extra fields an event payload carries or what an event reports in a specific case (for example "enrich hook payloads with the session title and client type", "`SessionEnd` reports `archive` when a session is archived"). Keep the new events or capability itself and how to configure it. - 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. @@ -229,6 +230,8 @@ Example: - Update the native release workflow to use current GitHub artifact actions. ``` +Doc links: an entry that changes a documented config surface may end with a pointer to the docs page — `see [X](...) for details` (Chinese: `详见 [X](...)。`). Keep it a real Markdown link into the docs tree with a relative path (for example `../configuration/config-files.md#loop-control`). When the link text is a config key or another identifier, code-style the text inside the brackets: [`loop_control`](../configuration/config-files.md#loop-control). Never wrap the whole link in backticks — `` `[loop_control](...)` `` renders as raw inline code that exposes the relative path instead of a clickable link. + ### 6. Translate The Increment Into Chinese After updating the English page, translate only the newly added English content into `docs/zh/release-notes/changelog.md`. @@ -322,6 +325,7 @@ Check: - PR links and commit hashes were stripped. - No `Thanks ...!` credit remains (remove it every time). - Real internal identifiers were replaced with neutral placeholders. +- Doc links are real Markdown links (code-styled text inside the brackets when needed), never wrapped in backticks. - There are no empty sections. - Markdown indentation and blank lines are intact. @@ -454,6 +458,8 @@ Return the PR URL to the user when done. | Leaving empty sections | Delete sections with no entries | | Putting everything under Other for convenience | Classify what can be classified first | | Translating tool names, command names, or config keys | Keep them as written | +| Wrapping a whole doc link in backticks | Code-style the link text inside the brackets instead, so the link stays clickable: [`loop_control`](...) | +| Keeping hook/event payload-mechanics clauses | Drop what an event reports or carries; keep the new capability and how to configure it | | Creating a changeset for docs sync | Do not create one | | Committing or pushing directly on `main` | Create `docs/changelog-sync-`, 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 | diff --git a/.changeset/built-in-capabilities.md b/.changeset/built-in-capabilities.md new file mode 100644 index 0000000000..768ae8f648 --- /dev/null +++ b/.changeset/built-in-capabilities.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add Kimi Computer Use and Kimi WebBridge as built-in official marketplace entries in the v2 CLI. Installing from `/plugins` sets up the latest managed runtime and plugin together, reports incomplete manual steps, and supports retrying interrupted setup. diff --git a/.changeset/catalog-builtin-fallback.md b/.changeset/catalog-builtin-fallback.md deleted file mode 100644 index 712144ec88..0000000000 --- a/.changeset/catalog-builtin-fallback.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fall back to the built-in models.dev catalog snapshot when the public catalog is unreachable, so Known third-party provider import still works offline or in blocked networks. diff --git a/.changeset/custom-agent-identity.md b/.changeset/custom-agent-identity.md new file mode 100644 index 0000000000..b008816ef4 --- /dev/null +++ b/.changeset/custom-agent-identity.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add a custom agent identity, plus a switch for the built-in skills that document Kimi Code itself. Set `[identity] name` in `config.toml` (or `KIMI_CODE_IDENTITY_NAME`) to change the name the agent uses for itself and the identifier it presents to third-party providers and MCP servers; set `builtin_product_skills = false` to drop the product-documentation skills. diff --git a/.changeset/feedback-bug-alias.md b/.changeset/feedback-bug-alias.md new file mode 100644 index 0000000000..a616998039 --- /dev/null +++ b/.changeset/feedback-bug-alias.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add /bug as an alias for the /feedback slash command. Type /bug to submit feedback. diff --git a/.changeset/fix-dark-mono-composer.md b/.changeset/fix-dark-mono-composer.md deleted file mode 100644 index 929ca229c9..0000000000 --- a/.changeset/fix-dark-mono-composer.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -web: Fix dark-mode monochrome controls and align the chat composer corner radius with the design system. diff --git a/.changeset/fork-stay-current-session.md b/.changeset/fork-stay-current-session.md new file mode 100644 index 0000000000..8481368f94 --- /dev/null +++ b/.changeset/fork-stay-current-session.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +`/fork` no longer switches to the forked session: the current session stays active and its background tasks keep running. Find the fork in `/sessions`. diff --git a/.changeset/kap-server-meta-experimental-flags.md b/.changeset/kap-server-meta-experimental-flags.md deleted file mode 100644 index ac946e9449..0000000000 --- a/.changeset/kap-server-meta-experimental-flags.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kap-server": patch ---- - -Expose the effective experimental-flag map as `experimental_flags` on `GET /api/v1/meta`. diff --git a/.changeset/mcp-first-turn-ready.md b/.changeset/mcp-first-turn-ready.md new file mode 100644 index 0000000000..419372e4e3 --- /dev/null +++ b/.changeset/mcp-first-turn-ready.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Ensure the first request waits for MCP startup to finish while the interface still opens immediately. diff --git a/.changeset/mcp-structured-result-passthrough.md b/.changeset/mcp-structured-result-passthrough.md new file mode 100644 index 0000000000..d63740fe8b --- /dev/null +++ b/.changeset/mcp-structured-result-passthrough.md @@ -0,0 +1,5 @@ +--- +'@moonshot-ai/kimi-code': patch +--- + +MCP tool results now surface the spec-defined `structuredContent` field and `_meta` server metadata to the model as a serialized `` block, instead of silently dropping them. Servers that return their machine-readable contract in these fields work the same as on other MCP hosts. diff --git a/.changeset/perf-system-prompt-cache.md b/.changeset/perf-system-prompt-cache.md new file mode 100644 index 0000000000..b72bc92a0e --- /dev/null +++ b/.changeset/perf-system-prompt-cache.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Replace the per-session timestamp in the default system prompt with a static time-lookup reminder. The timestamp changed on every new session, breaking DeepSeek's byte-prefix cache from that point onward — including the ~16.8k-token tools definition — so every new session's first LLM call missed the cache. First-turn cache-miss input drops from ~19.5k tokens to ~88 tokens (99.6% cache hit measured on the built artifact). The static `## Date and Time` section keeps the instruction to fetch the real current time from the environment (e.g. via the `date` command) when it matters, without the cache-breaking value. diff --git a/.oxlintrc.json b/.oxlintrc.json index 003359f31d..51969ea255 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -90,6 +90,26 @@ "eslint/no-console": "off" } }, + { + // The stage-6 worker closure: these modules (and everything + // packages/minidb/src/worker/ pulls in) are loaded by a bare + // node:worker_threads Worker under Node's native type stripping with + // `execArgv: ['--experimental-transform-types']`, which requires + // explicit `.ts` import specifiers (the strip loader does not remap + // `.js` -> `.ts`). Keep the exception scoped to exactly that closure. + "files": [ + "packages/minidb/src/worker/**/*.ts", + "packages/minidb/src/codec.ts", + "packages/minidb/src/crc32.ts", + "packages/minidb/src/trigram.ts", + "packages/minidb/src/text-postings.ts", + "packages/minidb/src/text-index/tokenize.ts", + "packages/minidb/src/gen-codec.ts" + ], + "rules": { + "import/extensions": "off" + } + }, { "files": ["packages/kosong/src/providers/**/*.ts"], "rules": { diff --git a/AGENTS.md b/AGENTS.md index c21a6a1d90..b360bd3f86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,18 +19,18 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays. - `apps/kimi-inspect`: web inspector for the kap-server `/api/v1/debug` RPC surface — workspace/session browser, per-session chat, and Service panels (data + trigger buttons) for the Session and Agent scopes. A left icon rail (`src/components/NavRail.tsx`) switches top-level views: the Chat workspace, the global message search (`src/components/SearchView.tsx` — cross-session full-text search over `POST /api/v1/search`, cursor-paged via a manual Load more; an exact-match checkbox maps to the API's `mode: 'literal'` substring search, which ignores sort and orders newest-first; a `live`/`index` badge on the results shows which server route served them (in-memory session transcript vs the persisted index)), the Model Catalog (`src/components/ModelCatalogView.tsx` — every Provider with its Models and the default marker, via `IModelCatalog` / `IModelService` channel proxies), and App Services (`src/components/AppServicesView.tsx` — the app-scope Service reflection, full width, joined by the Workspace Services view (`src/components/WorkspaceServicesView.tsx`) — the workspace-scope counterpart with a left sidebar directory browser (`src/components/WorkspaceDirBrowser.tsx` — server-side fs browsing over the App-scope `IHostFolderBrowser`, marking entries that are registered workspaces with their `IWorkspaceTrust` trust state, and registering a picked folder on demand via `IWorkspaceService.createOrTouch`), its proxies riding the `/workspace/:id` route, which materializes the handler on demand via `IWorkspaceLifecycleService.handlerFor`; the Agent scope stays in the Chat view's right dock (`src/components/RightPanel.tsx`) across two tabs: the `Agent` tab (`Inspector`: agent switcher + a Plan lookup card — `PlanCard` in `src/components/Inspector.tsx` — querying `GET /sessions/{id}/transcript/plan` (one tool_call_id, or every plan of the agent) via `src/transcript/api.ts`'s `fetchTranscriptPlan` — plus the agent Service panels) and the `State` tab (every key an Agent Service registered into the agent-state container, polled live via `IAgentStateService.snapshot()` — the same live diff-tree view as the session State tab, sharing `StateCard` from `src/components/StateCard.tsx`), while the Session scope has its own column right next to the session-list sidebar (`src/components/SessionPane.tsx`) with two tabs: Services (the pending-interactions card — `src/components/InteractionsCard.tsx` — plus the session Service panels) and State (every key a Session Service registered into the session-state container, read on demand via `ISessionStateService.snapshot()`)). Expanding a Model opens the model inspector inside that view: provider/model config layers plus the resolved runtime view with per-value provenance (config / override / builtin / env / synthesized), served on demand by `IModelCatalog.inspect` — the same resolution pass the runtime's `get` serves, traced via `ResolutionTraceCollector` and assembled by `kosong/model/inspection.ts`. Built on its own old-klient-style channel layer (`src/channel/`: the VS Code `ProxyChannel` model — service-bound `IChannel`, HTTP `ProxyChannel` for calls routed to `/api/v1/debug`), typed by `agent-core-v2` Service interfaces; `GET /api/v1/debug/channels` loads the whole wire protocol 1:1 (every scoped Service, no whitelist). There is no Service-event push channel: panels fetch/refresh on demand (`Sidebar` polls react-query on a 15 s interval), and a connection failure shows a blocking "Debug surface unavailable" screen instead of falling back anywhere. Session-level coarse status is the one exception: `src/activity/` holds a second `/api/v1/ws` client (`GlobalEventsWs`) that subscribes to nothing and consumes the server-pushed global facts — `event.session.work_changed` updates a per-session activity map (`SessionActivityHub` + subscribe/version store, seeded on connect/reconnect from `GET /api/v1/sessions`), while `event.session.created` / `session.meta.updated` invalidate the `['sessions']` query; the `Sidebar` session rows render `running` / `approval` / `question` / `failed` badges from it via `useSessionActivities`. The Vite dev server proxies `/api` to a running kap-server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`) and exposes `GET /__inspect/servers` (`vite/serverDiscovery.ts`), which scans the local kap-server instance registry (`~/.kimi-code/server/instances` + legacy `lock`) and the home token so the app can zero-config auto-connect and switch servers from the header dropdown at runtime. The per-session chat (`src/components/ChatView.tsx`) renders turn-granularly from the **transcript** surface instead of context memory and carries an in-chat search bar (`src/components/ChatSearchBar.tsx`): it searches the current session via `POST /api/v1/search` with `container: { session_id }` (usually served by the live route, since selecting a session resumes it), and a result click funnels through the app shell's `openSearchHit` — the same agent-switch + `ChatJump` (page-back, scroll, flash) path the global search view uses; full state is read from `GET /api/v1/sessions/{id}/transcript` (initial load = newest page, refreshes re-read from the tail backwards), older history auto-pages with `before_turn` via an IntersectionObserver sentinel at the top of the scroll view, and each timeline item is wrapped in `content-visibility: auto` + `contain-intrinsic-size` so the browser virtualizes off-screen rendering natively (no windowing library); `/api/v1/ws` is an incremental channel (`transcript.ops`, grade `block` — the cheapest grade that still carries whole-state frame upserts, dropping per-token `append` frames; `transcript.reset` is ignored by the store, surfaced only to the audit recorder via the optional `onReset` handler). The channel tracks the op-batch watermark: a dedicated `subscribe_v2` control frame carries the per-agent grades and the `transcript_since` cursor, a seq gap / reconnect / `resync_required` / append gap triggers a point-to-point catch-up (`fetchTranscriptOps` → `GET .../transcript/ops?since_seq=`), and any legacy/incomplete answer falls back to the full REST refresh. Convergence reuses `@moonshot-ai/transcript`'s L2 reducer (`src/transcript/`: REST/WS clients + store; the data model and reducer come from the package, nothing is re-implemented locally). The Transcript audit panel (`src/components/audit/`, the `Audit` tab of the chat view's right dock — `src/components/RightPanel.tsx`, fed the trail by `ChatView`'s `onTrailChange`) replays how the visible store was built: an `AuditTrail` (`src/audit/`) records every step — each REST page (request + replace/prepend), every WS frame (`transcript.ops` live/buffered/flushed/catchup, `transcript.reset`), loss signals, and prompt/cancel actions — with the resulting immutable `AgentState` per entry; the panel offers a draggable timeline plus a Diff tab (structural diff vs the previous entry: added/modified/removed colored, long strings tail-truncated, all fields kept), a full State view, and the raw Event payload. - `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-v2`: the DI × Scope agent engine (the v2 port behind kap-server). Four `LifecycleScope` tiers — `App` / `Workspace` / `Session` / `Agent` (`_base/di/scope.ts`). The `workspace/` domain owns one Workspace scope per materialized workspace handler: the App-scope `IWorkspaceLifecycleService` keeps the live handler registry (create-or-get + join, handlers never closed), and each handler's `ISessionLifecycleService` owns session create/resume/fork/close as its child scopes — there is no App-level session lifecycle facade, callers compose `ISessionIndex` → `handlerFor` → the handler. Workspace-scope services hold the handler-shared resources loaded once per handler and refreshed by fs watch: skills / AGENTS.md (`workspaceSkillCatalog` / `workspaceInstructions`), the workspace agent-profile loader (`workspaceAgentProfileLoader` — agent profiles follow the Contribution / Registry / Catalog extension point: the domain owns agent-file discovery end to end (parse / roots / SYSTEM.md / explicit files) and its Workspace-scope loaders (`workspace` / `user` / `plugin` / `extra` / `explicit`) register `AgentProfileContribution`s into the App-scope `IAgentProfileRegistry`, tagged with the handler's `workspaceId`; the App-scope `builtinAgentProfileLoader` contributes the code-defined profiles, and each Session-scope `sessionAgentProfileCatalog` projects the registry directly (name-level dedup + builtin-override rule in the projection), seeded with only the workspace key), one shared MCP connection set (`workspaceMcp`, pure connection orchestration over the scope-agnostic `mcpCore` layer; the effective server set is owned by `workspaceMcpConfig` — mcp.json files + plugin contributions, fs-watch refreshed — and MCP persistence, the `[mcp]` config section plus OAuth credentials, lives in `app/mcpConfig`, the same wrapper shape as `kosongConfig` over kosong), fs / fs-watch / process runner / git (`workspaceFs` / `workspaceFsWatch` / `workspaceProcess` / `workspaceGit`), the additional-directory set (`workspaceDirs`, backed by `.kimi-code/local.toml`), the os-level tool veto (`workspaceToolPolicy`), and the trust marker (`workspaceTrust` — persisted under the home, keyed by `encodeWorkDirKey(root)`; while a workspace is untrusted, `workspaceMcpConfig` skips the project-level `.mcp.json` / `.kimi-code/mcp.json` files, and the state flips through kap-server's `GET|POST /workspaces/{id}/trust` + `POST /workspaces/{id}/untrust` routes). Session/Agent scopes consume these through session-domain seed contracts with change events (`session/mcp`, `session/workspaceInfo`, `session/sessionSkillCatalog` data, …). See `packages/agent-core-v2/AGENTS.md` and use the `agent-core-dev` skill (`.agents/skills/agent-core-dev/SKILL.md`) when developing here. +- `packages/agent-core-v2`: the DI × Scope agent engine (the v2 port behind kap-server). Four `LifecycleScope` tiers — `App` / `Workspace` / `Session` / `Agent` (`_base/di/scope.ts`). The `workspace/` domain owns one Workspace scope per materialized workspace handler: the App-scope `IWorkspaceLifecycleService` keeps the live handler registry (create-or-get + join, handlers never closed), and each handler's `ISessionLifecycleService` owns session create/resume/fork/close/delete as its child scopes — there is no App-level session lifecycle facade, callers compose `ISessionIndex` → `handlerFor` → the handler. Workspace-scope services hold the handler-shared resources loaded once per handler and refreshed by fs watch: skills / AGENTS.md (`workspaceSkillCatalog` / `workspaceInstructions`), the workspace agent-profile loader (`workspaceAgentProfileLoader` — agent profiles follow the Contribution / Registry / Catalog extension point: the domain owns agent-file discovery end to end (parse / roots / SYSTEM.md / explicit files) and its Workspace-scope loaders (`workspace` / `user` / `plugin` / `extra` / `explicit`) register `AgentProfileContribution`s into the App-scope `IAgentProfileRegistry`, tagged with the handler's `workspaceId`; the App-scope `builtinAgentProfileLoader` contributes the code-defined profiles, and each Session-scope `sessionAgentProfileCatalog` projects the registry directly (name-level dedup + builtin-override rule in the projection), seeded with only the workspace key), one shared MCP connection set (`workspaceMcp`, pure connection orchestration over the scope-agnostic `mcpCore` layer; the effective server set is owned by `workspaceMcpConfig` — mcp.json files + plugin contributions, fs-watch refreshed — and MCP persistence, the `[mcp]` config section plus OAuth credentials, lives in `app/mcpConfig`, the same wrapper shape as `kosongConfig` over kosong; a session created with `CreateSessionOptions.mcpServers` additionally gets ephemeral per-session servers on a session-owned overlay manager from `workspaceMcp.sessionOverlay` — merged into the session's MCP seed, released on session close, never persisted, not gated by `workspaceTrust`), fs / fs-watch / process runner / git (`workspaceFs` / `workspaceFsWatch` / `workspaceProcess` / `workspaceGit`), the additional-directory set (`workspaceDirs`, backed by `.kimi-code/local.toml`), the os-level tool veto (`workspaceToolPolicy`), and the trust marker (`workspaceTrust` — persisted under the home, keyed by `encodeWorkDirKey(root)`; while a workspace is untrusted, `workspaceMcpConfig` skips the project-level `.mcp.json` / `.kimi-code/mcp.json` files, and the state flips through kap-server's `GET|POST /workspaces/{id}/trust` + `POST /workspaces/{id}/untrust` routes). Session/Agent scopes consume these through session-domain seed contracts with change events (`session/mcp`, `session/workspaceInfo`, `session/sessionSkillCatalog` data, …). See `packages/agent-core-v2/AGENTS.md` and use the `agent-core-dev` skill (`.agents/skills/agent-core-dev/SKILL.md`) when developing here. - `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/transcript`: the isomorphic transcript rendering data layer — agent-granular L1 store, idempotent L2 operations, `off/turn/block/delta` L3 subscription granularity, framework-free L4 view registry, and turn-cursor pagination. Pure TypeScript (browser-safe, no engine imports) and the sole owner of all transcript contract types (`src/contract/`); consumed by `packages/kap-server` (engine events → transcript, REST + WS surface; live stores backfill history from the persisted per-agent wire records — main on first attach, any agent on demand, cold sessions rebuild any agent — with 0-based turn ordinals matching the engine's). The cold rebuild is a two-level fold over `wire.jsonl` as the single source of truth: `history/groupTurns.ts` (context messages → turn tree) plus `history/foldFacts.ts` (non-context records → tasks, interactions, todos, goal/plan/swarm meta, and end-appended markers/taskrefs; interactions left pending at shutdown fold to `cancelled`). Plan content is a recorded fact too: each ExitPlanMode review submission offloads the document to `agents//plan//v.md` and persists a reference-only `plan.revision` record (`{id, version, path, sha256, bytes}`), which projects — live and cold — to a `plan.revision` marker and the `modes.plan` badge (`{reviewPath, version}`). It also owns the op-batch sequencing contract (`transcriptSeqSchema` in `contract/schema.ts`): a per-(session, agent) monotonic batch `seq` on `transcript.ops` / `transcript.reset` / the REST transcript response, the `transcript_since` subscription cursor, and the `GET .../transcript/ops` catch-up response shape — every field optional so pre-seq peers fall back to loss-signal-driven refreshes. Beyond the timeline, the model carries wire-equivalent detail: steps carry `usage` / `finishReason` / `timing` (LLM latencies) / `retry` / interrupt reason, turns carry `durationMs` / `error` / `usage`, tool frames carry the streamed `inputText` and the latest `progress`, tasks carry subagent `resultSummary` / `error` / `stateReason` / `usage`, `meta.agent` mirrors the agent status slices (model / usage / context / permission / phase), a global `prompts` entity (op `prompt.upsert`) tracks the prompt queue, and `hook.result` lands as a `'hook'` marker. These live-projected fields are NOT backfilled by the cold rebuild (known limitation). -- `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2` — four scopes, App/Workspace/Session/Agent; session create/resume/fork routes compose `ISessionIndex` → `IWorkspaceLifecycleService.handlerFor` → the handler's `ISessionLifecycleService`, and the fs routes resolve session → handler → the Workspace-scope fs services, with one exception: `fs:search` also accepts a workspace reference (registered id or absolute root) in the `{session_id}` slot, so a not-yet-created draft session's `@` file mention resolves the workspace handler directly; the first-class session-less form is `POST /api/v1/workspace/fs:search` (the workspace reference travels in the body)). Exposes sessions over REST + WebSocket (`/api/v1` + `/api/v1/ws`); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. The RPC surface is `/api/v1/debug/*` — a reflection dispatcher over the ENTIRE scoped DI registry (every Service callable, no whitelist, Workspace scope addressable alongside App/Session/Agent; `src/transport/registerDebugRoutes.ts` + `serviceDispatcherRoutes.ts`), mounted only with `--debug-endpoints` on a loopback bind and gated by the global bearer auth; repo dev scripts pass the flag. Its transcript surface implements the op-batch sequencing contract: `TranscriptService.dispatchOps` assigns every dispatched batch a per-agent consecutive `seq` and retains it in a bounded in-memory journal (`TRANSCRIPT_OPS_JOURNAL_CAPACITY`, dies with the live store); WS `transcript.ops`/`transcript.reset` payloads carry the seq/watermark, a `transcript_since` subscription cursor (carried, with the per-agent grades, by the `subscribe_v2` control frame — the only transcript subscription channel; its agent-grained counterpart `unsubscribe_v2` detaches listed agents' streams, or the whole session's when `agent_ids` is absent, letting the detached agents' legacy events flow again) replays journaled batches instead of a baseline reset when the journal covers it, and `GET /sessions/{id}/transcript/ops?since_seq=` serves point-to-point catch-up (`complete: false` = journal can't cover or session cold → caller falls back to a full refresh). Beside the paged route, `GET /sessions/{id}/transcript/plan?agent_id=[&tool_call_id=]` projects an agent's ExitPlanMode plan info (content / path / options / review outcome; `tool_call_id` narrows to one call, omitted lists every recoverable plan) from the first available fact — the linked approval interaction's persisted request display, the live tool frame's display, or the tool result output text. The baseline `transcript.reset` itself is items-empty (`TRANSCRIPT_RESET_TAIL_TURNS = 0`): it carries only global state + the watermark + `has_more_older`, because history always pages in over REST. When a WS connection subscribes to the transcript protocol (grade ≠ `off` for an agent), the broadcaster suppresses the transcript-projected `session_event` types for that connection × agent (`TRANSCRIPT_PROJECTED_EVENT_TYPES` + `suppressedByTranscript` in `sessionEventBroadcaster.ts`; cursor replay via `getBufferedSince` applies the same filter). Suppression is only a per-connection send view — the journal still records everything, and connections without transcript grades are unaffected. The session's work aggregate behind `event.session.work_changed` (`busy` / `main_turn_active` / `pending_interaction` / `last_turn_reason`) is owned by the core's `ISessionActivityView` (`sessionActivity` domain, Session scope): the broadcaster only schedules the wire emission around turn frames (`busy:false` lands after `turn.ended`), and `resolveSessionFacts` (`src/routes/sessions.ts`) reads the same view — never fold per-agent activity at the edge. Delivery split on `/api/v1/ws`: global events (`session.meta.updated` and the `event.session.*` / `event.workspace.*` / `event.config.*` families, including every activated session's `event.session.work_changed`) fan out to EVERY established connection — `WsConnectionV1` registers itself via `broadcaster.addGlobalTarget` on construction and unregisters on close — while session/agent-grained events only reach connections subscribed to that session (subject to `agent_filter` and the transcript suppression above); transcript frames are a separate channel governed by the per-agent grades alone and bypass `agent_filter` entirely. The global search surface is `POST /api/v1/search` (`src/search/` + `src/routes/search.ts`): a cross-session full-text search over user messages, assistant text, and session titles, backed by a single minidb database at `/search-index` (`IGlobalSearchService`, App scope — the write-lock holder is the indexer, other processes open read-only and catch up via WAL). It serves two modes: `terms` (the default — minidb's inverted text index over ASCII words + CJK uni/bigrams, no positions, term-level AND) and `literal` (substring-exact search: a hashed 2/3-gram index supplies candidates, every candidate's text is then confirmed with `includes`, so hits carry zero false positives; literal ignores `sort` and returns newest-first, and a candidate set truncated at `LITERAL_CANDIDATE_CAP` is flagged `incomplete: 'candidate_cap'`). When `container.session_id` is provided and that session is live in this process (`TranscriptService.forSessionLive` returns a store, wired via `setLiveTranscriptSource` in `start.ts`), BOTH modes instead scan the in-memory transcript store (turn prompts + assistant text frames, history established via `whenReady`/`ensureAgentHistory`) — no index involved; terms-mode live hits are scored Σ log(1+tf) (comparable only within a route, per the `GlobalSearchSource` contract), live-route errors never fall back to the index, and the response's `source: 'live' | 'index'` field (also mixed into the page-token fingerprint, so a mid-pagination route flip invalidates the old token) tells the caller which route served the page. +- `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2` — four scopes, App/Workspace/Session/Agent; session create/resume/fork routes compose `ISessionIndex` → `IWorkspaceLifecycleService.handlerFor` → the handler's `ISessionLifecycleService`, and the fs routes resolve session → handler → the Workspace-scope fs services, with one exception: `fs:search` also accepts a workspace reference (registered id or absolute root) in the `{session_id}` slot, so a not-yet-created draft session's `@` file mention resolves the workspace handler directly; the first-class session-less form is `POST /api/v1/workspace/fs:search` (the workspace reference travels in the body)). Exposes sessions over REST + WebSocket (`/api/v1` + `/api/v1/ws`); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. The RPC surface is `/api/v1/debug/*` — a reflection dispatcher over the ENTIRE scoped DI registry (every Service callable, no whitelist, Workspace scope addressable alongside App/Session/Agent; `src/transport/registerDebugRoutes.ts` + `serviceDispatcherRoutes.ts`), mounted only with `--debug-endpoints` on a loopback bind and gated by the global bearer auth; repo dev scripts pass the flag. Its transcript surface implements the op-batch sequencing contract: `TranscriptService.dispatchOps` assigns every dispatched batch a per-agent consecutive `seq` and retains it in a bounded in-memory journal (`TRANSCRIPT_OPS_JOURNAL_CAPACITY`, dies with the live store); WS `transcript.ops`/`transcript.reset` payloads carry the seq/watermark, a `transcript_since` subscription cursor (carried, with the per-agent grades, by the `subscribe_v2` control frame — the only transcript subscription channel; its agent-grained counterpart `unsubscribe_v2` detaches listed agents' streams, or the whole session's when `agent_ids` is absent, letting the detached agents' legacy events flow again) replays journaled batches instead of a baseline reset when the journal covers it, and `GET /sessions/{id}/transcript/ops?since_seq=` serves point-to-point catch-up (`complete: false` = journal can't cover or session cold → caller falls back to a full refresh). Beside the paged route, `GET /sessions/{id}/transcript/plan?agent_id=[&tool_call_id=]` projects an agent's ExitPlanMode plan info (content / path / options / review outcome; `tool_call_id` narrows to one call, omitted lists every recoverable plan) from the first available fact — the linked approval interaction's persisted request display, the live tool frame's display, or the tool result output text. The baseline `transcript.reset` itself is items-empty (`TRANSCRIPT_RESET_TAIL_TURNS = 0`): it carries only global state + the watermark + `has_more_older`, because history always pages in over REST. When a WS connection subscribes to the transcript protocol (grade ≠ `off` for an agent), the broadcaster suppresses the transcript-projected `session_event` types for that connection × agent (`TRANSCRIPT_PROJECTED_EVENT_TYPES` + `suppressedByTranscript` in `sessionEventBroadcaster.ts`; cursor replay via `getBufferedSince` applies the same filter). Suppression is only a per-connection send view — the journal still records everything, and connections without transcript grades are unaffected. The session's work aggregate behind `event.session.work_changed` (`busy` / `main_turn_active` / `pending_interaction` / `last_turn_reason`) is owned by the core's `ISessionActivityView` (`sessionActivity` domain, Session scope): the broadcaster only schedules the wire emission around turn frames (`busy:false` lands after `turn.ended`), and `resolveSessionFacts` (`src/routes/sessions.ts`) reads the same view — never fold per-agent activity at the edge. Delivery split on `/api/v1/ws`: global events (`session.meta.updated` and the `event.session.*` / `event.workspace.*` / `event.config.*` families, including every activated session's `event.session.work_changed`) fan out to EVERY established connection — `WsConnectionV1` registers itself via `broadcaster.addGlobalTarget` on construction and unregisters on close — while session/agent-grained events only reach connections subscribed to that session (subject to `agent_filter` and the transcript suppression above); transcript frames are a separate channel governed by the per-agent grades alone and bypass `agent_filter` entirely. The global search surface is `POST /api/v1/search` (`src/search/` + `src/routes/search.ts`): a cross-session full-text search over user messages, assistant text, and session titles, backed by a single minidb database at `/search-index` (`IGlobalSearchService`, App scope — the write-lock holder is the indexer, other processes open read-only and catch up via WAL). It serves two modes: `terms` (the default — minidb's inverted text index over ASCII words + CJK uni/bigrams, no positions, term-level AND) and `literal` (substring-exact search: a hashed 2/3-gram index supplies candidates, every candidate's text is then confirmed with `includes`, so hits carry zero false positives; literal ignores `sort` and returns newest-first). The index route is fully bounded (stage 4): a search request serves the currently published generation and never awaits a sync/reopen/reindex — it kicks the single-flight + debounced background coordinator instead, and reports `index_state.stale` / `index_state.degraded` when serving a behind view or after a failed refresh, and `index_state.state: 'building'` while the served handle's text base is still being (re)built by the deferred fallback build (searches get the empty building page, never a partial result); every query runs under explicit budgets (max terms, postings visits via `MiniDb.searchBoundedAsync`, candidate caps, confirmation text volume, a match deadline) with over-budget pages flagged `incomplete: 'candidate_cap' | 'postings_budget' | 'deadline'`; pagination is keyset over `(time, key)` / `(score, time, key)` with versioned v2 tokens pinning the index generation (a rebuild/reopen/rescan invalidates old tokens with `invalid_page_token`; legacy v1 offset tokens are still accepted and upgraded), and per-session sync scans only that session's file-meta keys (`\0meta\file\\`, migrated from the pre-v2 hash-only keys by a one-time background pass). When `container.session_id` is provided and that session is live in this process (`TranscriptService.forSessionLive` returns a store, wired via `setLiveTranscriptSource` in `start.ts`), BOTH modes instead scan the in-memory transcript store (turn prompts + assistant text frames, history established via `whenReady`/`ensureAgentHistory`) — no index involved; terms-mode live hits are scored Σ log(1+tf) (comparable only within a route, per the `GlobalSearchSource` contract), live-route errors never fall back to the index, and the response's `source: 'live' | 'index'` field (also mixed into the page-token fingerprint, so a mid-pagination route flip invalidates the old token) tells the caller which route served the page. - `packages/klient`: the client SDK — a contract-driven facade over agent-core-v2 with aggregated `global.*` / `session(id).*` / `agent(id).*` methods, zod validation on every call, and klient-level typed event forwarding. Transport is chosen once at creation via subpath entry (`@moonshot-ai/klient/ipc|memory`); both return the same `Klient`. The package also hosts the e2e suites: the legacy `/api/v1` live suites (`test/e2e/legacy/`) and the docker e2e runner (`pnpm --filter @moonshot-ai/klient docker:e2e`). See `packages/klient/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`. - `packages/tree-sitter-bash`: a pure-TypeScript bash parser (no runtime deps, no wasm) that produces a syntax tree with tree-sitter-bash 0.25.0 named-node type names and UTF-16 code-unit offsets. `parse(source, { timeoutMs, maxNodes })` runs under a deterministic budget (default 50 ms / 50k nodes, plus per-chain recursion depth caps) and returns a discriminated `ParseResult` (`{ ok, rootNode, hasError }` or `{ ok: false, reason: 'aborted' }`) — callers must treat aborted/hasError trees as "cannot analyze" and degrade. Parser only, no safety judgments; consumers (e.g. Bash tool permission matching) live elsewhere. Known deviations from the reference are tracked in the package README's "Known differences" section, pinned by differential fixtures tested against the real `tree-sitter-bash` wasm (dev-only). -- `packages/minidb`: the embedded JSON document store (`MiniDb`) behind kap-server's search index — snapshot + WAL persistence with an exclusive write lock (losers open read-only and catch up from the WAL), plus a larger-than-RAM full-text layer: `src/text-index.ts` is the inverted index (in-RAM dictionary + delta, on-disk postings in `src/text-postings.ts`, rebuilt from the Store on open and on compaction) with an injectable `tokenizer`/`queryTokenizer`; the default tokenizer keeps ASCII words and CJK uni/bigrams, while `src/trigram.ts` provides the hashed 2/3-gram tokenizer (NFKC + lowercase, code-point windows) that backs substring-exact search. Text-index definitions (including the tokenizer name) persist in `db.textindexes.json`. +- `packages/minidb`: the embedded JSON document store (`MiniDb`) behind kap-server's search index — snapshot + WAL persistence with an exclusive write lock (losers open read-only and catch up from the WAL), plus a larger-than-RAM full-text layer: `src/text-index/` is the inverted index (in-RAM dictionary + delta, on-disk postings in `src/text-postings.ts`; the module is split into `tokenize.ts` / `types.ts` / `builder.ts` / `image.ts` around the `TextIndex` core in `index.ts`) with an injectable `tokenizer`/`queryTokenizer`; the default tokenizer keeps ASCII words and CJK uni/bigrams, while `src/trigram.ts` provides the hashed 2/3-gram tokenizer (NFKC + lowercase, code-point windows) that backs substring-exact search. Text-index definitions (including the tokenizer name) persist in `db.textindexes.json`. Derived state (store image, dt/secondary/compound indexes, text dictionary + postings + doc table) is checkpointed as persistent index **generations** (`generations/g-NNNNNN/` + `CURRENT`, format v1 — `src/generation.ts` layout/manifest, `src/gen-codec.ts` binary images): the writer builds them into a `g-N.tmp-*` dir and atomically publishes (rename + CURRENT swap, fsyncs strict), each compaction's rotation and the generation publish form one transaction (replacing the old synchronous `rebuildTextPostings()` tail), and open loads the published generation + WAL delta replay instead of re-decoding every value / re-tokenizing the corpus / rewriting postings (the full recovery remains the automatic **fallback** for missing/invalid/unknown-version generations; `OpenOptions.indexGenerations: false` forces that path). On the fallback path the corpus-scale text rebuild is no longer awaited inside `open()`: it runs as a `'text-build'` maintenance task on the same bounded engine pinned at the recovery checkpoint (rollback: `OpenOptions.deferOpenTextBuilds: false`), searches on a not-yet-committed index raise `TextIndexBuildingError` (state surfaced via `MiniDb.textIndexBuilding` — the guard is a dedicated `basePending` flag, so staged builds over a live old base keep serving), and a read-only opener builds into a private scratch dir next to the db dir (`.ro-scratch/-*`, dropped on close) and adopts the disk base there instead of aggregating a full in-RAM base. Async base reads are commit-safe via a base-swap epoch (`TextIndex.baseEpoch`): a read straddling a base commit re-reads from the fresh base and never caches a stale list. A healthy writer also keeps a valid generation around at runtime — the per-write WAL-growth trigger (`MiniDb.maybeAutoGenerationBuild`, 4 MiB staleness rule, throttled with failure backoff) covers the started-from-empty window the open-time kick cannot, and `close()` publishes a missing/stale generation best-effort (`buildGeneration('close')`). Load-time integrity is per-file crc32 + definition hashes — a corrupt or definition-mismatched image rebuilds only the affected index from the loaded store. Heavy maintenance runs through the unified **maintenance scheduler** (`src/maintenance.ts` — one heavy task per database at a time, queue backpressure, disk free-space preflight, deadline/cancellation, shutdown drain-or-cancel, `MiniDb.maintenanceStatus()` read model; nested submissions from inside a task run inline via AsyncLocalStorage to avoid self-deadlock). Full-text generation artifacts (tokenization → bounded-memory aggregation → segmented external merge → postings/dictionary/base-docs) are produced **off the main thread** by a worker build (`src/worker/text-build-core.ts`, hosted by `src/worker/text-build.ts` + `src/worker/text-build-worker.ts` — Node-native type-stripping with `execArgv: ['--experimental-transform-types']`, explicit `.ts` import specifiers in the whole worker closure; worker writes only inside the tmp generation dir, the main thread verifies (sanity + streaming crc) and swaps the live base via `TextIndex.commitRebase` after `beginRebase`; `OpenOptions.textBuildWorker: false` is the rollback switch; a missing worker file or slot pressure hosts the SAME bounded core inline on the main thread instead — the in-thread staged aggregation is kept only for small corpora (< 4096 docs), custom function tokenizers, and the explicit rollback). The same bounded engine (worker-or-inline + rebase, `MiniDb.boundedTextBuild`) also backs the two full-corpus initial-build entries — `createTextIndex` and the open-time loader rebuild of a corrupt/definition-mismatched image — so first-time indexing of a large existing store no longer aggregates the whole term->postings map in RAM. The async read surface is additive: `getAsync` / `searchAsync` / `searchBoundedAsync` / `queryAsync` (`ValueReader.readAsync`, `PostingsFile.readAsync`, byte-bounded decoded-postings cache via `TextIndexOptions.cacheBytes`); recovery scans use the async sequential scanner (`scanFrameRefsFdAsync` — windowed reads, sliced CRC, periodic yields, AbortSignal, bounded corruption-resync candidate budget shared with the sync scanner); compaction's disk-mode snapshot groups live refs by (file, offset) and reads them with bounded-concurrency async positioned reads (`src/snapshot.ts`). ## Environment Requirements diff --git a/apps/kimi-code/.gitignore b/apps/kimi-code/.gitignore index 901b7a6d26..762220ab4c 100644 --- a/apps/kimi-code/.gitignore +++ b/apps/kimi-code/.gitignore @@ -8,4 +8,4 @@ agents/ src/generated/vis-web-asset.ts # Copied from packages/pi-tui/native at build time by scripts/copy-native-assets.mjs -native/ +/native/ diff --git a/apps/kimi-code/CHANGELOG.md b/apps/kimi-code/CHANGELOG.md index 479eefd700..3ccd65f0cb 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -1,5 +1,33 @@ # @moonshot-ai/kimi-code +## 0.32.0 + +### Minor Changes + +- [#2558](https://github.com/MoonshotAI/kimi-code/pull/2558) [`75395f6`](https://github.com/MoonshotAI/kimi-code/commit/75395f6abb17f83f30d16b51f4e060a639f43622) Thanks [@sailist](https://github.com/sailist)! - Add the TurnStarted, UserPromptQueued, TaskStarted, and SessionHeartbeat hook events, enrich hook payloads with the session title and client type, include the model and profile in SessionStart, and report SessionEnd as archive when a session is archived instead of exited. Configure the new events under [[hooks]] in config.toml. + +### Patch Changes + +- [#2416](https://github.com/MoonshotAI/kimi-code/pull/2416) [`eaab2b6`](https://github.com/MoonshotAI/kimi-code/commit/eaab2b6f28c0b958edf8ab5ae5e78a4c0426af26) Thanks [@mangeshraut712](https://github.com/mangeshraut712)! - Fall back to the built-in models.dev catalog snapshot when the public catalog is unreachable, so Known third-party provider import still works offline or in blocked networks. + +- [#2083](https://github.com/MoonshotAI/kimi-code/pull/2083) [`bfa0080`](https://github.com/MoonshotAI/kimi-code/commit/bfa00807c975fdc5b84dda32d47b16b09e8d42c1) Thanks [@StaR4y](https://github.com/StaR4y)! - web: Fix dark-mode monochrome controls and align the chat composer corner radius with the design system. + +- [#2559](https://github.com/MoonshotAI/kimi-code/pull/2559) [`dfc55a5`](https://github.com/MoonshotAI/kimi-code/commit/dfc55a5c977dbff657e1da74ff5c2b9d488807be) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Render the "/login" already-logged-in confirmation in the success color instead of dim text, so the "Already logged in. Model configuration refreshed." message is clearly visible. + +- [#2572](https://github.com/MoonshotAI/kimi-code/pull/2572) [`6ba75a1`](https://github.com/MoonshotAI/kimi-code/commit/6ba75a173b595904bc70d0d7161de2f9b964c961) Thanks [@sailist](https://github.com/sailist)! - Rename the `[loop_control] max_retries_per_step` config key to `max_attempts_per_step` and `max_steps_per_run` to `max_steps_per_turn`: on the v2 engine the old keys no longer take effect and a startup warning prompts the rename in `config.toml`. The `KIMI_LOOP_MAX_RETRIES_PER_STEP` env var is likewise deprecated in favor of `KIMI_LOOP_MAX_ATTEMPTS_PER_STEP` but keeps working with a warning. + +- [#2585](https://github.com/MoonshotAI/kimi-code/pull/2585) [`c396873`](https://github.com/MoonshotAI/kimi-code/commit/c39687318c64bf8a305a10bf9ca86ef6ef2c6656) Thanks [@sailist](https://github.com/sailist)! - Fix submitting answers to interactive question prompts being rejected when the model provider returns tool call IDs containing colons (some OpenAI-compatible gateways). + +- [#2562](https://github.com/MoonshotAI/kimi-code/pull/2562) [`071b6a5`](https://github.com/MoonshotAI/kimi-code/commit/071b6a50d9c2ce9c4b45dc4d58dac1101b8c4f52) Thanks [@sailist](https://github.com/sailist)! - Serve v1 message history from the server layer and drop the engine-side legacy message adapter; the /api/v1 message contract is unchanged. + +- [#2562](https://github.com/MoonshotAI/kimi-code/pull/2562) [`071b6a5`](https://github.com/MoonshotAI/kimi-code/commit/071b6a50d9c2ce9c4b45dc4d58dac1101b8c4f52) Thanks [@sailist](https://github.com/sailist)! - Assemble the session snapshot endpoint from the engine's services for both cold and live sessions, and remove the KIMI_SNAPSHOT_READER, KIMI_SNAPSHOT_TIMEOUT_MS, and KIMI_SNAPSHOT_CACHE_LIMIT environment knobs. + +- [#2563](https://github.com/MoonshotAI/kimi-code/pull/2563) [`2118544`](https://github.com/MoonshotAI/kimi-code/commit/21185447fe0f04dbe342bebb6c6d0b364fd43daa) Thanks [@sailist](https://github.com/sailist)! - Fix the context window limit showing as 0 in session status updates when no model is bound yet or the configured model no longer resolves; the limit now falls back to the default model or is omitted when unknown. + +- [#2563](https://github.com/MoonshotAI/kimi-code/pull/2563) [`2118544`](https://github.com/MoonshotAI/kimi-code/commit/21185447fe0f04dbe342bebb6c6d0b364fd43daa) Thanks [@sailist](https://github.com/sailist)! - The `[token_counting]` strategy now only selects the reported context size: `estimated` keeps provider-reported usage out of the context-size display, and `measured` no longer gets stuck retrying an oversized compaction request until it fails. + +- [#2563](https://github.com/MoonshotAI/kimi-code/pull/2563) [`2118544`](https://github.com/MoonshotAI/kimi-code/commit/21185447fe0f04dbe342bebb6c6d0b364fd43daa) Thanks [@sailist](https://github.com/sailist)! - Add a `[token_counting]` config section to choose how context token counts are derived: `measured+estimated` (default), `measured` (provider usage only), or `estimated` (heuristic only, for providers without usage reporting). Set `strategy` under `[token_counting]` in config.toml (or `KIMI_TOKEN_COUNTING_STRATEGY`) to switch. + ## 0.31.1 ### Patch Changes diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 8ac3ff0f76..c67b35fda1 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kimi-code", - "version": "0.31.1", + "version": "0.32.0", "description": "The Starting Point for Next-Gen Agents", "license": "MIT", "author": "Moonshot AI", @@ -83,6 +83,7 @@ }, "devDependencies": { "@moonshot-ai/acp-adapter": "workspace:^", + "@moonshot-ai/acp-server": "workspace:^", "@moonshot-ai/agent-core-v2": "workspace:^", "@moonshot-ai/kap-server": "workspace:^", "@moonshot-ai/kimi-code-oauth": "workspace:^", @@ -90,6 +91,7 @@ "@moonshot-ai/kimi-telemetry": "workspace:^", "@moonshot-ai/kimi-web": "workspace:^", "@moonshot-ai/migration-legacy": "workspace:^", + "@moonshot-ai/minidb": "workspace:^", "@moonshot-ai/pi-tui": "workspace:^", "@moonshot-ai/vis-server": "workspace:^", "@moonshot-ai/vis-web": "workspace:*", diff --git a/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs b/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs index eaa8d331fd..64578319f3 100644 --- a/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs +++ b/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs @@ -15,6 +15,7 @@ const DEFAULT_OUT_DIR = resolve(DEFAULT_PLUGINS_ROOT, 'cdn'); const SENTINEL = '.kimi-plugin-marketplace-build.json'; const SKIP_DIRS = new Set(['.git', 'node_modules']); const SKIP_FILES = new Set(['.DS_Store']); +const EXTRA_CDN_PLUGIN_SOURCES = ['./official/kimi-webbridge']; const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href; if (isMain) { @@ -58,6 +59,15 @@ export async function buildPluginMarketplaceCdn({ pluginsRoot, outDir }) { if (result.archive !== undefined) archives.push(result.archive); } + // WebBridge is injected by v2 clients rather than listed in the remote + // catalog, but its managed plugin still needs a CDN artifact. + for (const source of EXTRA_CDN_PLUGIN_SOURCES) { + const archive = stripRelativePrefix(withZipExtension(source)); + if (archives.includes(archive)) continue; + const result = await materializeEntrySource(source, pluginsRoot, outDir); + if (result.archive !== undefined) archives.push(result.archive); + } + const outputMarketplace = { ...parsed, plugins, diff --git a/apps/kimi-code/scripts/native/01-bundle.mjs b/apps/kimi-code/scripts/native/01-bundle.mjs index df46acc110..9f917e0196 100644 --- a/apps/kimi-code/scripts/native/01-bundle.mjs +++ b/apps/kimi-code/scripts/native/01-bundle.mjs @@ -15,6 +15,12 @@ export async function runBundleStep() { // 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']); + // Bundle the minidb text-build worker into one self-contained ESM file so + // it can ride the SEA blob as an asset (02-sea-blob.mjs) and be spawned + // from disk at runtime — bundled binaries otherwise lack the worker entry + // and heavy text-index builds degrade to the inline main-thread core. + // Runs after the main bundle with clean:false so both verified files remain. + await run(process.execPath, [tsdownCliPath, '--config', 'tsdown.worker.config.ts']); await run(process.execPath, [checkBundlePath]); } diff --git a/apps/kimi-code/scripts/native/assets.mjs b/apps/kimi-code/scripts/native/assets.mjs index 859262449f..3c9f3b2047 100644 --- a/apps/kimi-code/scripts/native/assets.mjs +++ b/apps/kimi-code/scripts/native/assets.mjs @@ -5,7 +5,12 @@ import { createRequire } from 'node:module'; import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { NATIVE_ASSET_MANIFEST_VERSION, buildManifestKey } from './manifest.mjs'; +import { + MINIDB_TEXT_BUILD_WORKER_ASSET, + NATIVE_ASSET_MANIFEST_VERSION, + buildManifestKey, + buildRuntimeAssetKey, +} from './manifest.mjs'; import { resolveTargetDeps, SUPPORTED_TARGETS } from './native-deps.mjs'; export { NATIVE_ASSET_MANIFEST_VERSION }; @@ -229,7 +234,10 @@ async function packageManifestEntries({ packageName, packageRoot, files, target export const nativeAssetManifestKey = buildManifestKey; export function nativeAssetSummary(manifest) { - return manifest.packages.map((pkg) => `${pkg.name}: ${pkg.files.length} files`); + return [ + ...manifest.packages.map((pkg) => `${pkg.name}: ${pkg.files.length} files`), + `runtime: ${manifest.runtimeFiles.length} files`, + ]; } export async function collectNativeAssets({ appRoot, target }) { @@ -264,10 +272,25 @@ export async function collectNativeAssets({ appRoot, target }) { Object.assign(assets, result.assets); } + const workerSource = resolve(appRoot, 'dist-native', 'intermediates', 'text-build-worker.mjs'); + const workerBytes = await readFile(workerSource); + const workerAssetKey = buildRuntimeAssetKey(target, MINIDB_TEXT_BUILD_WORKER_ASSET.key); + const runtimeFiles = [ + { + key: MINIDB_TEXT_BUILD_WORKER_ASSET.key, + assetKey: workerAssetKey, + relativePath: MINIDB_TEXT_BUILD_WORKER_ASSET.relativePath, + sha256: sha256(workerBytes), + mode: MINIDB_TEXT_BUILD_WORKER_ASSET.mode, + }, + ]; + assets[workerAssetKey] = workerSource; + const manifest = { version: NATIVE_ASSET_MANIFEST_VERSION, target, packages: manifestPackages, + runtimeFiles, }; return { diff --git a/apps/kimi-code/scripts/native/check-bundle.mjs b/apps/kimi-code/scripts/native/check-bundle.mjs index 3cd10c278d..bf63064068 100644 --- a/apps/kimi-code/scripts/native/check-bundle.mjs +++ b/apps/kimi-code/scripts/native/check-bundle.mjs @@ -1,10 +1,8 @@ +import { existsSync, readFileSync } from 'node:fs'; import { builtinModules } from 'node:module'; -import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; -import { nativeJsBundlePath } from './paths.mjs'; - -const bundlePath = nativeJsBundlePath(); -const text = readFileSync(bundlePath, 'utf-8'); +import { nativeIntermediatesDir, nativeJsBundlePath } from './paths.mjs'; const builtins = new Set([ ...builtinModules, @@ -23,18 +21,8 @@ const optionalRuntimeRequires = new Set([ 'utf-8-validate', ]); const optionalRelativeRuntimeRequires = new Set(['./crypto/build/Release/sshcrypto.node']); -const handledNativeRuntimeRequires = new Set(); - -function isAllowedSpecifier(specifier) { - if (builtins.has(specifier) || specifier.startsWith('node:')) return true; - if (optionalRuntimeRequires.has(specifier)) return true; - if (handledNativeRuntimeRequires.has(specifier)) return true; - return false; -} -const errors = []; - -function executableLines() { +function executableLines(text) { return text .split('\n') .map((line) => line.trim()) @@ -45,48 +33,51 @@ function executableLines() { }); } -for (const line of executableLines()) { - for (const match of line.matchAll(/(? { if (specifier.startsWith('.') || specifier.startsWith('/')) { - if (optionalRelativeRuntimeRequires.has(specifier)) continue; - errors.push(`relative require remains: ${specifier}`); - continue; + if (!allowedRelative.has(specifier)) errors.push(`relative ${kind} remains: ${specifier}`); + return; } - if (!isAllowedSpecifier(specifier)) { - errors.push(`external require remains: ${specifier}`); + if (!builtins.has(specifier) && !specifier.startsWith('node:') && !allowedExternal.has(specifier)) { + errors.push(`external ${kind} remains: ${specifier}`); } - } + }; - for (const match of line.matchAll(/(? 0) { - console.error(`Native JS bundle check failed for ${bundlePath}:`); - for (const error of errors) { - console.error(`- ${error}`); - } - process.exit(1); +const bundles = [ + { path: nativeJsBundlePath(), worker: false }, + { path: resolve(nativeIntermediatesDir(), 'text-build-worker.mjs'), worker: true }, +]; +let failed = false; +for (const bundle of bundles) { + const errors = checkBundle(bundle.path, { worker: bundle.worker }); + if (errors.length === 0) continue; + failed = true; + console.error(`Native JS bundle check failed for ${bundle.path}:`); + for (const error of errors) console.error(`- ${error}`); } +if (failed) process.exit(1); diff --git a/apps/kimi-code/scripts/native/manifest.mjs b/apps/kimi-code/scripts/native/manifest.mjs index 30d5e9da32..1344a24f46 100644 --- a/apps/kimi-code/scripts/native/manifest.mjs +++ b/apps/kimi-code/scripts/native/manifest.mjs @@ -1,10 +1,20 @@ -export const NATIVE_ASSET_MANIFEST_VERSION = 1; +export const NATIVE_ASSET_MANIFEST_VERSION = 2; export const WEB_ASSET_MANIFEST_VERSION = 1; +export const MINIDB_TEXT_BUILD_WORKER_ASSET = Object.freeze({ + key: 'minidb-text-build-worker', + relativePath: 'runtime/minidb/text-build-worker.mjs', + mode: 0o644, +}); + export function buildManifestKey(target) { return `native/${target}/manifest.json`; } +export function buildRuntimeAssetKey(target, key) { + return `native/${target}/runtime/${key}`; +} + export function isManifestVersionSupported(version) { return version === NATIVE_ASSET_MANIFEST_VERSION; } diff --git a/apps/kimi-code/scripts/native/smoke.mjs b/apps/kimi-code/scripts/native/smoke.mjs index ed3a8624f7..0d0f2604b4 100644 --- a/apps/kimi-code/scripts/native/smoke.mjs +++ b/apps/kimi-code/scripts/native/smoke.mjs @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process'; -import { readFile, stat } from 'node:fs/promises'; +import { mkdir, readFile, rm, stat } from 'node:fs/promises'; import { resolve } from 'node:path'; import { promisify } from 'node:util'; @@ -73,10 +73,19 @@ assertIncludes(helpOutput, 'Usage: kimi', '--help'); const exportHelpOutput = await runKimi(['export', '--help']); assertIncludes(exportHelpOutput, 'Usage: kimi export', 'export --help'); -const nativeAssetOutput = await runKimiWithEnv(['--version'], { - KIMI_CODE_HOME: smokeHome, - KIMI_CODE_NATIVE_ASSET_SMOKE: '1', -}); -assertIncludes(nativeAssetOutput, `Native asset smoke passed: ${target}`, 'native asset smoke'); +const smokeCache = resolve(smokeHome, 'cache'); +await rm(smokeHome, { recursive: true, force: true }); +await mkdir(smokeCache, { recursive: true }); +try { + const nativeAssetOutput = await runKimiWithEnv(['--version'], { + KIMI_CODE_CACHE_DIR: smokeCache, + KIMI_CODE_HOME: smokeHome, + KIMI_CODE_NATIVE_ASSET_SMOKE: '1', + }); + assertIncludes(nativeAssetOutput, `Native asset smoke passed: ${target}`, 'native asset smoke'); + assertIncludes(nativeAssetOutput, 'MiniDb worker build passed', 'MiniDb worker smoke'); +} finally { + await rm(smokeHome, { recursive: true, force: true }); +} console.log(`Native smoke passed: ${executablePath}`); diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index a090df4d0f..4690912f2a 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -2,8 +2,10 @@ import { CLI_COMMAND_NAME } from '#/constant/app'; import { registerMigrateCommand } from '#/migration/index'; import { Command, InvalidArgumentError, Option } from 'commander'; +import { isAcpV2Enabled } from './experimental-v2'; import type { CLIOptions } from './options'; import { registerAcpCommand } from './sub/acp'; +import { registerAcpV2Command } from './sub/acp-v2'; import { registerDoctorCommand } from './sub/doctor'; import { registerExportCommand } from './sub/export'; import { registerLoginCommand } from './sub/login'; @@ -117,6 +119,9 @@ export function createProgram( registerProviderCommand(program); registerAcpCommand(program); registerWebCommand(program); + if (isAcpV2Enabled()) { + registerAcpV2Command(program); + } registerLoginCommand(program); registerDoctorCommand(program); registerVisCommand(program); diff --git a/apps/kimi-code/src/cli/experimental-v2.ts b/apps/kimi-code/src/cli/experimental-v2.ts index 4f53508bae..de40d76c2d 100644 --- a/apps/kimi-code/src/cli/experimental-v2.ts +++ b/apps/kimi-code/src/cli/experimental-v2.ts @@ -3,10 +3,11 @@ * * When the master switch `KIMI_CODE_EXPERIMENTAL_FLAG` is truthy, `kimi -p` * (print mode) routes to the native agent-core-v2 runner (see - * `run-prompt.ts`) and the interactive TUI builds its harness through the - * SDK's v2-backed client (see `run-shell.ts`), both instead of the default - * v1 engine. The master switch also enables every experimental feature flag - * in the engine. Read directly from the env (matching + * `run-prompt.ts`), the interactive TUI builds its harness through the + * SDK's v2-backed client (see `run-shell.ts`), and `kimi doctor` validates + * config.toml against the v2 section registry (see `sub/doctor.ts` / + * `v2/validate-config.ts`), all instead of the default v1 engine. The + * master switch also enables every experimental feature flag in the engine. Read directly from the env (matching * `cli/update/rollout.ts`) because the CLI must not depend on the core flag * registry. Unset / any non-truthy value keeps the v1 path. * @@ -15,6 +16,7 @@ */ export const KIMI_V2_ENV = 'KIMI_CODE_EXPERIMENTAL_FLAG'; +export const KIMI_ACP_V2_ENV = 'KIMI_CODE_EXPERIMENTAL_ACP_V2'; const TRUTHY_VALUES = new Set(['1', 'true', 'yes', 'on']); @@ -30,3 +32,9 @@ export function isKimiV2Enabled( ): boolean { return isTruthyEnv(KIMI_V2_ENV, env); } + +export function isAcpV2Enabled( + env: Readonly> = process.env, +): boolean { + return isTruthyEnv(KIMI_ACP_V2_ENV, env) || isKimiV2Enabled(env); +} diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 84ad5897bb..35b0ca9e01 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -25,8 +25,8 @@ 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 { startupTrace } from '#/utils/startup-trace'; import { currentTheme, getColorPalette } from '#/tui/theme'; -import { combineStartupNotice } from '#/tui/utils/startup'; import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; import { restoreTerminalModes } from '#/utils/terminal-restore'; @@ -88,6 +88,7 @@ export async function runShell( const harness = engineV2 ? createKimiHarnessV2(harnessOptions) : createKimiHarness(harnessOptions); + startupTrace('harness:created'); log.info('kimi-code starting', { version, uiMode: CLI_UI_MODE, @@ -108,9 +109,10 @@ export async function runShell( return; } const config = await harness.getConfig(); - for (const warning of (await harness.getConfigDiagnostics()).warnings) { - configWarning = combineStartupNotice(configWarning, warning); - } + startupTrace('config:loaded'); + // Config diagnostics (deprecated keys, invalid sections, ...) are surfaced + // by the TUI itself at `finishStartup` via `showConfigWarningsIfAny` — + // folded into the dim startup notice they were too easy to miss. const configMs = Date.now() - configStartedAt; // Resolve --agent/--agent-file once for the startup session; validateOptions // has already rejected them alongside --session/--continue. @@ -244,7 +246,9 @@ export async function runShell( }; try { const initStartedAt = Date.now(); + startupTrace('tui.start:begin'); await tui.start(); + startupTrace('tui.start:end'); const initMs = Date.now() - initStartedAt; const startupSessionId = tui.getCurrentSessionId(); const mcpMs = await tui.getStartupMcpMs(); diff --git a/apps/kimi-code/src/cli/sub/acp-v2.ts b/apps/kimi-code/src/cli/sub/acp-v2.ts new file mode 100644 index 0000000000..d1aef65eaf --- /dev/null +++ b/apps/kimi-code/src/cli/sub/acp-v2.ts @@ -0,0 +1,77 @@ +/** + * `kimi acp-v2` sub-command. + * + * Starts the Agent Client Protocol (ACP) server backed directly by the + * DI × Scope agent engine (`agent-core-v2`) over stdio, so ACP-compatible + * clients can drive a kimi-code session on the new engine. This is the v2 + * counterpart to `kimi acp` (which runs the legacy `@moonshot-ai/acp-adapter` + * over the SDK harness). + * + * Wire-up mirrors `kimi acp` for the parts that are host-independent: + * - `--login` pivots into the shared device-code login flow (the entry point + * ACP clients hit via the first-class `AuthMethodTerminal` path, re-invoking + * the agent binary with the advertised `args:['--login']`). + * - `KIMI_CODE_HOME` (if set) is forwarded into `authMethods[0].env` so the + * login subprocess writes its token under the same data root the server + * reads from, and `process.argv[1]` is advertised as the legacy + * `_meta['terminal-auth'].command` fallback. + * + * `@moonshot-ai/acp-server` (and its `agent-core-v2` engine) is loaded via a + * lazy dynamic import so the default CLI / `kimi acp` module graph stays free + * of the experimental v2 engine — mirroring the `kimi server run` v2 routing + * in `#/cli/sub/server/run.ts`. + */ + +import type { Command } from 'commander'; + +import { getVersion } from '#/cli/version'; +import { KIMI_CODE_HOME_ENV } from '#/constant/app'; +import { getDataDir } from '#/utils/paths'; + +import { runLoginFlow } from './login-flow'; + +export function registerAcpV2Command(parent: Command): void { + parent + .command('acp-v2') + .description( + 'Run kimi-code as an Agent Client Protocol (ACP) server over stdio (experimental agent-core-v2 engine).', + ) + .option( + '--login', + 'Run the device-code login flow then exit (entry point for ACP terminal-auth).', + false, + ) + .action(async (opts: { login?: boolean }) => { + if (opts.login === true) { + await runLoginFlow(); + return; + } + // Forward `KIMI_CODE_HOME` (if set) into `authMethods[0].env` so the + // login subprocess clients spawn for terminal-auth writes its token + // under the same data root the ACP server reads from. + const sandboxHome = process.env[KIMI_CODE_HOME_ENV]; + const terminalAuthEnv = + sandboxHome !== undefined && sandboxHome.length > 0 + ? { [KIMI_CODE_HOME_ENV]: sandboxHome } + : undefined; + // Legacy `_meta.terminal-auth` fallback for clients that don't yet + // honor the first-class `type:'terminal'`. `command` is the absolute + // path to this very binary so the client can spawn it for login. + const legacyCommand = process.argv[1]; + try { + const { runAcpServer } = await import('@moonshot-ai/acp-server'); + await runAcpServer({ + homeDir: getDataDir(), + agentInfo: { name: 'Kimi Code CLI', version: getVersion() }, + ...(terminalAuthEnv ? { terminalAuthEnv } : {}), + ...(legacyCommand !== undefined && legacyCommand.length > 0 + ? { terminalAuthLegacyCommand: legacyCommand } + : {}), + }); + process.exit(0); + } catch (error) { + process.stderr.write(`acp-v2 server: fatal error: ${String(error)}\n`); + process.exit(1); + } + }); +} diff --git a/apps/kimi-code/src/cli/sub/doctor.ts b/apps/kimi-code/src/cli/sub/doctor.ts index 0ccc38d281..d6d5db3d1b 100644 --- a/apps/kimi-code/src/cli/sub/doctor.ts +++ b/apps/kimi-code/src/cli/sub/doctor.ts @@ -10,6 +10,7 @@ import { import type { Command } from 'commander'; import { z } from 'zod'; +import { isKimiV2Enabled } from '#/cli/experimental-v2'; import { getTuiConfigPath, parseTuiConfig } from '#/tui/config'; interface WritableLike { @@ -28,7 +29,7 @@ export interface DoctorDeps { readonly configRpc?: KimiConfigRpc; readonly fileExists?: (path: string) => boolean; readonly readTextFile?: (path: string) => Promise; - readonly validateConfigToml?: (text: string, path: string) => MaybePromise; + readonly validateConfigToml?: (text: string, path: string) => MaybePromise; } export interface DoctorOptions { @@ -40,7 +41,8 @@ interface CheckSpec { readonly label: 'config.toml' | 'tui.toml'; readonly path: string; readonly explicit: boolean; - readonly parse: (text: string, path: string) => MaybePromise; + /** Throws on invalid content; may return a non-fatal warning message. */ + readonly parse: (text: string, path: string) => MaybePromise; } interface CheckResult { @@ -59,7 +61,7 @@ interface ResolvedDoctorDeps { readonly exit: (code: number) => never; readonly fileExists: (path: string) => boolean; readonly readTextFile: (path: string) => Promise; - readonly validateConfigToml: (text: string, path: string) => MaybePromise; + readonly validateConfigToml: (text: string, path: string) => MaybePromise; } export async function handleDoctor(deps: DoctorDeps, options: DoctorOptions): Promise { @@ -130,7 +132,17 @@ function resolveDeps(deps: Partial | DoctorDeps | undefined): Resolv readTextFile: deps?.readTextFile ?? ((path) => readFile(path, 'utf-8')), validateConfigToml: deps?.validateConfigToml ?? - ((text, filePath) => getConfigRpc().validateConfigToml({ text, filePath })), + (async (text, filePath) => { + if (isKimiV2Enabled()) { + // Experimental v2 route (same master switch as `kimi -p`): validate + // with the agent-core-v2 section registry instead of the v1 schema. + // Loaded lazily so the v2 module graph stays off the default path. + const { validateConfigTomlV2 } = await import('../v2/validate-config'); + return validateConfigTomlV2(text, filePath); + } + await getConfigRpc().validateConfigToml({ text, filePath }); + return undefined; + }), }; } @@ -204,8 +216,8 @@ async function checkTomlFile(deps: ResolvedDoctorDeps, spec: CheckSpec): Promise try { const text = await deps.readTextFile(spec.path); - await spec.parse(text, spec.path); - return { label: spec.label, path: spec.path, status: 'OK' }; + const warning = await spec.parse(text, spec.path); + return { label: spec.label, path: spec.path, status: 'OK', message: warning ?? undefined }; } catch (error) { return { label: spec.label, diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index 891032b4d8..aac6062fce 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -330,8 +330,7 @@ async function resolveNativeSession( }; if (opts.session !== undefined) { - const page = await index.list({}); - const target = page.items.find((summary) => summary.id === opts.session); + const target = await index.get(opts.session); if (target === undefined) { throw new Error(`Session "${opts.session}" not found.`); } @@ -358,7 +357,7 @@ async function resolveNativeSession( } if (opts.continue) { - const page = await index.list({}); + const page = await index.listRecent({}); const previous = page.items.find((summary) => summary.cwd === workDir); if (previous !== undefined) { const session = await resumeById(previous.id); diff --git a/apps/kimi-code/src/cli/v2/validate-config.ts b/apps/kimi-code/src/cli/v2/validate-config.ts new file mode 100644 index 0000000000..89d14869b5 --- /dev/null +++ b/apps/kimi-code/src/cli/v2/validate-config.ts @@ -0,0 +1,187 @@ +/** + * Experimental v2 config.toml validation for `kimi doctor`. + * + * Loaded lazily (dynamic import) by the doctor command only when the + * agent-core-v2 master switch (`KIMI_CODE_EXPERIMENTAL_FLAG`) is on, so the + * v2 module graph stays off the default (v1) doctor path. Validation uses the + * engine's own section registry instead of v1's whole-document strict schema: + * importing the package root runs every built-in section's side-effect + * registration ("import = register"), and `ConfigRegistry` is then + * constructed directly — no DI container, no `ConfigService`, no file IO. + * + * Semantics deliberately mirror the v2 engine rather than v1: + * - a registered section that fails schema validation is an error (the + * engine would silently ignore that section at runtime; surfacing it is + * doctor's job); + * - a top-level key with no registered section passes through the engine + * untouched, so it is reported as a non-fatal warning — except the known + * schema-less domains the engine consumes directly (`default_model`, …); + * - section-declared key renames (`deprecations`) and renamed env vars + * (`deprecatedEnv` bindings actually supplying a value) surface as + * non-fatal warnings, reusing the engine's own detection + * (`collectKeyDeprecations`) and mirroring `ConfigService`'s env-fallback + * warning rule. + */ + +import { parse as parseToml } from 'smol-toml'; +import { z } from 'zod'; + +import { + ConfigRegistry, + type AnyEnvBindings, + type EnvBinding, +} from '@moonshot-ai/agent-core-v2'; +import { collectKeyDeprecations } from '@moonshot-ai/agent-core-v2/app/config/deprecations'; +import { + camelToSnake, + describeTomlSyntaxError, + isPlainObject, + transformTomlData, +} from '@moonshot-ai/agent-core-v2/app/config/toml'; + +/** + * Top-level domains the v2 engine reads via `IConfigService.get` / `inspect` + * without registering a schema (free-form values, structurally validated + * nowhere): `defaultModel` / `defaultProvider` (`kosongConfig` default + * pointers), `modelOverrides` (`llmRequester` / `profile`), and `telemetry` + * (read by the CLI itself). + */ +const SCHEMALESS_DOMAINS: ReadonlySet = new Set([ + 'defaultModel', + 'defaultProvider', + 'modelOverrides', + 'telemetry', +]); + +interface V2ConfigValidationIssue { + readonly path: readonly (string | number)[]; + readonly message: string; +} + +/** + * Matches the shape `handleDoctor` extracts from `error.details` (the SDK's + * `KimiConfigValidationIssue` list), so the doctor formatter renders v2 + * issues exactly like v1 ones. + */ +class V2ConfigValidationError extends Error { + readonly details: { readonly validationIssues: readonly V2ConfigValidationIssue[] }; + + constructor(issues: readonly V2ConfigValidationIssue[]) { + super('v2 config validation failed'); + this.details = { validationIssues: issues }; + } +} + +/** + * Validate `text` as config.toml against the v2 engine's section registry. + * Throws on TOML syntax errors and on any registered section failing its + * schema; returns non-fatal warnings (one per line) for unknown top-level + * keys, deprecated config keys, and deprecated env vars in use. + */ +export function validateConfigTomlV2( + text: string, + filePath: string, + getEnv: (name: string) => string | undefined = (name) => process.env[name], +): string | undefined { + let data: Record = {}; + if (text.trim().length > 0) { + try { + data = parseToml(text) as Record; + } catch (error) { + throw new Error(`Invalid TOML in ${filePath}: ${describeTomlSyntaxError(error)}`, { + cause: error, + }); + } + } + + const registry = new ConfigRegistry(); + const transformed = transformTomlData(data, registry); + + const issues: V2ConfigValidationIssue[] = []; + const unknownKeys: string[] = []; + for (const [domain, value] of Object.entries(transformed)) { + if (registry.getSection(domain) === undefined) { + if (!SCHEMALESS_DOMAINS.has(domain)) unknownKeys.push(camelToSnake(domain)); + continue; + } + try { + registry.validate(domain, value); + } catch (error) { + if (!(error instanceof z.ZodError)) throw error; + for (const issue of error.issues) { + issues.push({ + path: [ + domain, + ...issue.path.map((segment) => + typeof segment === 'number' ? segment : String(segment), + ), + ], + message: issue.message, + }); + } + } + } + + if (issues.length > 0) throw new V2ConfigValidationError(issues); + + const warnings: string[] = []; + for (const diagnostic of collectKeyDeprecations(data, registry.listSections())) { + warnings.push(diagnostic.message); + } + warnings.push(...collectEnvDeprecations(registry, getEnv)); + if (unknownKeys.length > 0) { + warnings.push( + `Unknown top-level ${unknownKeys.length === 1 ? 'key' : 'keys'} ignored by the v2 engine: ${unknownKeys.join(', ')}.`, + ); + } + return warnings.length > 0 ? warnings.join('\n') : undefined; +} + +/** + * Warn about renamed env vars that actually supply a value, mirroring + * `ConfigService`'s `resolveBinding`: the deprecated name only resolves (and + * thus only warns) when the primary var is absent or fails to parse. + */ +function collectEnvDeprecations( + registry: ConfigRegistry, + getEnv: (name: string) => string | undefined, +): string[] { + const warnings = new Set(); + for (const section of registry.listSections()) { + if (section.env === undefined) continue; + walkEnvBindings(section.env, (binding) => { + if (typeof binding === 'string' || binding.deprecatedEnv === undefined) return; + const primary = getEnv(binding.env); + if ( + primary !== undefined && + (binding.parse === undefined || binding.parse(primary) !== undefined) + ) { + return; + } + const deprecated = getEnv(binding.deprecatedEnv); + if (deprecated === undefined) return; + if (binding.parse !== undefined && binding.parse(deprecated) === undefined) return; + warnings.add( + `Environment variable ${binding.deprecatedEnv} is deprecated; use ${binding.env} instead.`, + ); + }); + } + return [...warnings]; +} + +function isEnvBinding(value: AnyEnvBindings): value is EnvBinding { + return typeof value === 'string' || (isPlainObject(value) && 'env' in value); +} + +function walkEnvBindings( + bindings: AnyEnvBindings, + visit: (binding: EnvBinding) => void, +): void { + if (isEnvBinding(bindings)) { + visit(bindings); + return; + } + for (const value of Object.values(bindings)) { + if (value !== undefined) walkEnvBindings(value, visit); + } +} diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index 28ed63a900..7c4e1040b0 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -24,6 +24,7 @@ import { import { createProgram } from './cli/commands'; import { finalizeHeadlessRun } from './cli/headless-exit'; +import { startupTrace } from './utils/startup-trace'; import type { CLIOptions } from './cli/options'; import { OptionConflictError, validateOptions } from './cli/options'; import { runPrompt } from './cli/run-prompt'; @@ -36,6 +37,7 @@ import { runUpdatePreflight } from './cli/update/preflight'; import { createKimiCodeHostIdentity, getVersion } from './cli/version'; import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, PROCESS_NAME } from './constant/app'; import { cleanupStaleNativeCacheForCurrent } from './native/native-assets'; +import { installMinidbTextBuildWorker } from './native/minidb-worker'; import { installNativeModuleHook } from './native/module-hook'; import { runNativeAssetSmokeIfRequested } from './native/smoke'; @@ -56,6 +58,7 @@ export async function handleMainCommand( version: string, ): Promise { let validated: ReturnType; + startupTrace('main:enter'); try { validated = validateOptions(opts); } catch (error) { @@ -66,10 +69,12 @@ export async function handleMainCommand( throw error; } + startupTrace('preflight:begin'); const preflightResult = await runUpdatePreflight( version, validated.uiMode === 'print' ? { track, isTTY: false } : { track }, ); + startupTrace('preflight:end'); if (preflightResult === 'exit') { process.exit(0); } @@ -79,6 +84,7 @@ export async function handleMainCommand( return { headlessCompleted: true }; } + startupTrace('runShell:begin'); await runShell(validated.options, version); return { headlessCompleted: false }; } @@ -142,6 +148,16 @@ export function main(): void { // invalid proxy URL is reported and ignored rather than aborting startup. installGlobalProxyDispatcher(); installNativeModuleHook(); + // Best-effort SEA worker installation. Diagnostics are trace-only and avoid + // exposing the user's cache path; failure keeps MiniDb's bounded inline mode. + const workerInstall = installMinidbTextBuildWorker(); + startupTrace( + workerInstall.status === 'installed' + ? `minidb-worker:installed basename=${workerInstall.basename} sha256=${workerInstall.assetSha256}` + : workerInstall.status === 'failed' + ? `minidb-worker:failed code=${workerInstall.errorCode} sha256=${workerInstall.assetSha256 ?? 'unknown'}` + : `minidb-worker:${workerInstall.status}`, + ); if (runNativeAssetSmokeIfRequested()) return; // Start the background cleanup of stale native cache. Fire-and-forget; must not block startup or throw. diff --git a/apps/kimi-code/src/native/minidb-worker.ts b/apps/kimi-code/src/native/minidb-worker.ts new file mode 100644 index 0000000000..babad491e5 --- /dev/null +++ b/apps/kimi-code/src/native/minidb-worker.ts @@ -0,0 +1,69 @@ +import { basename } from 'node:path'; + +import { + configureTextBuildWorkerRuntime, + getTextBuildWorkerRuntimeState, +} from '@moonshot-ai/minidb/worker-runtime'; + +import { MINIDB_TEXT_BUILD_WORKER_ASSET } from '../../scripts/native/manifest.mjs'; +import { + getEmbeddedNativeAssetManifest, + getMinidbTextBuildWorkerFile, + getSeaAssetSource, + type NativeAssetOptions, +} from './native-assets'; + +export type MinidbTextBuildWorkerInstallStatus = + | { readonly status: 'not-sea' } + | { readonly status: 'asset-missing' } + | { + readonly status: 'installed'; + readonly assetSha256: string; + readonly basename: string; + } + | { + readonly status: 'failed'; + readonly errorCode: string; + readonly assetSha256?: string; + }; + +function errorCode(error: unknown): string { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (typeof code === 'string' && code.length > 0) return code; + return error instanceof Error ? error.name : 'UNKNOWN'; +} + +/** Install the SEA-bundled worker without making optional extraction fatal. */ +export function installMinidbTextBuildWorker( + options: NativeAssetOptions = {}, +): MinidbTextBuildWorkerInstallStatus { + const source = options.source ?? getSeaAssetSource(); + if (source === null) return { status: 'not-sea' }; + + let assetSha256: string | undefined; + try { + const manifest = options.manifest ?? getEmbeddedNativeAssetManifest(source); + const file = manifest?.runtimeFiles.find( + (entry) => entry.key === MINIDB_TEXT_BUILD_WORKER_ASSET.key, + ); + if (manifest === null || file === undefined) return { status: 'asset-missing' }; + assetSha256 = file.sha256; + + const workerPath = getMinidbTextBuildWorkerFile({ ...options, source, manifest }); + if (workerPath === null) return { status: 'asset-missing' }; + configureTextBuildWorkerRuntime(workerPath); + const runtime = getTextBuildWorkerRuntimeState(); + if (!runtime.configured) throw new Error('MiniDb worker runtime was not configured'); + return { + status: 'installed', + assetSha256, + basename: basename(workerPath), + }; + } catch (error) { + return { + status: 'failed', + errorCode: errorCode(error), + assetSha256, + }; + } +} diff --git a/apps/kimi-code/src/native/native-assets.ts b/apps/kimi-code/src/native/native-assets.ts index d66547695c..a692246614 100644 --- a/apps/kimi-code/src/native/native-assets.ts +++ b/apps/kimi-code/src/native/native-assets.ts @@ -11,11 +11,15 @@ import { } from 'node:fs'; import { createRequire } from 'node:module'; import { homedir } from 'node:os'; -import { dirname, join, win32 as pathWin32 } from 'node:path'; +import { dirname, isAbsolute, join, relative, resolve, 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'; +import { + MINIDB_TEXT_BUILD_WORKER_ASSET, + NATIVE_ASSET_MANIFEST_VERSION as MANIFEST_VERSION, + buildManifestKey, +} from '../../scripts/native/manifest.mjs'; export const NATIVE_ASSET_MANIFEST_VERSION = MANIFEST_VERSION; @@ -32,10 +36,15 @@ export interface NativeAssetPackage { readonly files: readonly NativeAssetFile[]; } +export interface NativeRuntimeAssetFile extends NativeAssetFile { + readonly key: string; +} + export interface NativeAssetManifest { readonly version: typeof NATIVE_ASSET_MANIFEST_VERSION; readonly target: string; readonly packages: readonly NativeAssetPackage[]; + readonly runtimeFiles: readonly NativeRuntimeAssetFile[]; } export interface NativeAssetSource { @@ -53,10 +62,6 @@ export interface NativeAssetOptions { readonly version?: string; } -type RawNativeAssetManifest = Omit & { - readonly version: number; -}; - interface NodeSeaModule { isSea(): boolean; getAssetKeys(): string[]; @@ -97,6 +102,149 @@ function sha256(bytes: Buffer | Uint8Array | string): string { return createHash('sha256').update(bytes).digest('hex'); } +function manifestObject(value: unknown, label: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`Invalid native asset manifest: ${label} must be an object`); + } + return value as Record; +} + +function manifestString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`Invalid native asset manifest: ${label} must be a non-empty string`); + } + return value; +} + +function validateRelativePath(value: unknown, label: string): string { + const path = manifestString(value, label); + const segments = path.split(/[\\/]/); + if ( + isAbsolute(path) || + /^[a-zA-Z]:/.test(path) || + path.startsWith('\\\\') || + segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..') + ) { + throw new Error(`Invalid native asset manifest: ${label} must be a safe relative path`); + } + return path; +} + +function validateAssetFile( + value: unknown, + label: string, + assetKeys: Set, + relativePaths: Set, +): NativeAssetFile { + const file = manifestObject(value, label); + const assetKey = manifestString(file['assetKey'], `${label}.assetKey`); + if (assetKeys.has(assetKey)) { + throw new Error(`Invalid native asset manifest: duplicate assetKey ${assetKey}`); + } + assetKeys.add(assetKey); + const relativePath = validateRelativePath(file['relativePath'], `${label}.relativePath`); + const portableRelativePath = relativePath.replaceAll('\\', '/'); + if (relativePaths.has(portableRelativePath)) { + throw new Error(`Invalid native asset manifest: duplicate relativePath ${relativePath}`); + } + relativePaths.add(portableRelativePath); + const fileSha256 = file['sha256']; + if (typeof fileSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(fileSha256)) { + throw new Error(`Invalid native asset manifest: ${label}.sha256 must be 64 lowercase hex characters`); + } + const mode = file['mode']; + if ( + mode !== undefined && + (!Number.isInteger(mode) || (mode as number) < 0 || (mode as number) > 0o777) + ) { + throw new Error(`Invalid native asset manifest: ${label}.mode must be an integer between 0 and 0777`); + } + return { + assetKey, + relativePath, + sha256: fileSha256, + mode: mode as number | undefined, + }; +} + +export function validateNativeAssetManifest( + value: unknown, + expectedTarget?: string, +): NativeAssetManifest { + const manifest = manifestObject(value, 'root'); + if (manifest['version'] !== NATIVE_ASSET_MANIFEST_VERSION) { + throw new Error(`Unsupported native asset manifest version: ${String(manifest['version'])}`); + } + const target = manifestString(manifest['target'], 'target'); + if (expectedTarget !== undefined && target !== expectedTarget) { + throw new Error(`Native asset manifest target mismatch: ${target} !== ${expectedTarget}`); + } + const manifestPackages = manifest['packages']; + if (!Array.isArray(manifestPackages)) { + throw new TypeError('Invalid native asset manifest: packages must be an array'); + } + const manifestRuntimeFiles = manifest['runtimeFiles']; + if (!Array.isArray(manifestRuntimeFiles)) { + throw new TypeError('Invalid native asset manifest: runtimeFiles must be an array'); + } + + const assetKeys = new Set(); + const relativePaths = new Set(); + const packageNames = new Set(); + const packages = manifestPackages.map((value, packageIndex): NativeAssetPackage => { + const label = `packages[${packageIndex}]`; + const pkg = manifestObject(value, label); + const name = manifestString(pkg['name'], `${label}.name`); + if (packageNames.has(name)) { + throw new Error(`Invalid native asset manifest: duplicate package name ${name}`); + } + packageNames.add(name); + const root = validateRelativePath(pkg['root'], `${label}.root`); + const packageFiles = pkg['files']; + if (!Array.isArray(packageFiles)) { + throw new TypeError(`Invalid native asset manifest: ${label}.files must be an array`); + } + return { + name, + root, + files: packageFiles.map((file, fileIndex) => + validateAssetFile(file, `${label}.files[${fileIndex}]`, assetKeys, relativePaths), + ), + }; + }); + + const runtimeKeys = new Set(); + const runtimeFiles = manifestRuntimeFiles.map((value, index): NativeRuntimeAssetFile => { + const label = `runtimeFiles[${index}]`; + const raw = manifestObject(value, label); + const key = manifestString(raw['key'], `${label}.key`); + if (runtimeKeys.has(key)) { + throw new Error(`Invalid native asset manifest: duplicate runtime key ${key}`); + } + runtimeKeys.add(key); + return { + ...validateAssetFile(raw, label, assetKeys, relativePaths), + key, + }; + }); + + return { + version: NATIVE_ASSET_MANIFEST_VERSION, + target, + packages, + runtimeFiles, + }; +} + +function resolveAssetPath(cacheRoot: string, relativePath: string): string { + const path = resolve(cacheRoot, ...relativePath.split(/[\\/]/)); + const fromRoot = relative(cacheRoot, path); + if (fromRoot === '..' || fromRoot.startsWith('../') || fromRoot.startsWith('..\\') || isAbsolute(fromRoot)) { + throw new Error(`Native asset path escapes cache root: ${relativePath}`); + } + return path; +} + function optionalEnvValue(env: NodeJS.ProcessEnv, key: string): string | null { const value = env[key]; return typeof value === 'string' && value.length > 0 ? value : null; @@ -124,14 +272,9 @@ export function getEmbeddedNativeAssetManifest( const key = nativeAssetManifestKey(target); if (!source.getAssetKeys().includes(key)) return null; const raw = source.getRawAsset(key); - const manifest = JSON.parse(toBuffer(raw).toString('utf-8')) as RawNativeAssetManifest; - if (manifest.version !== NATIVE_ASSET_MANIFEST_VERSION) { - throw new Error(`Unsupported native asset manifest version: ${manifest.version}`); - } - if (manifest.target !== target) { - throw new Error(`Native asset manifest target mismatch: ${manifest.target} !== ${target}`); - } - return manifest as NativeAssetManifest; + const parsed: unknown = JSON.parse(toBuffer(raw).toString('utf-8')); + validateNativeAssetManifest(parsed, target); + return parsed as NativeAssetManifest; } export function getNativeCacheBase(options: NativeAssetOptions = {}): string { @@ -159,13 +302,14 @@ export function getNativeAssetCacheRoot( manifest: NativeAssetManifest, options: NativeAssetOptions = {}, ): string { + const validated = validateNativeAssetManifest(manifest); const version = sanitizeSegment(options.version ?? KIMI_BUILD_INFO.version ?? 'dev'); const manifestHash = sha256(JSON.stringify(manifest)); return join( getNativeCacheBase(options), 'native', version, - sanitizeSegment(manifest.target), + sanitizeSegment(validated.target), manifestHash, ); } @@ -219,27 +363,59 @@ export function ensureNativeAssetTree(options: NativeAssetOptions = {}): string const source = options.source ?? getSeaAssetSource(); if (source === null) return null; - const manifest = + const rawManifest = options.manifest ?? getEmbeddedNativeAssetManifest(source, currentTarget()); - if (manifest === null) return null; - - const cacheRoot = getNativeAssetCacheRoot(manifest, options); - for (const pkg of manifest.packages) { - for (const file of pkg.files) { - const bytes = toBuffer(source.getRawAsset(file.assetKey)); - const actualSha256 = sha256(bytes); - if (actualSha256 !== file.sha256) { - throw new Error( - `Native asset checksum mismatch for ${file.assetKey}: ${actualSha256} !== ${file.sha256}`, - ); - } - ensureFile(join(cacheRoot, file.relativePath), bytes, file.sha256, file.mode); + if (rawManifest === null) return null; + const manifest = validateNativeAssetManifest(rawManifest); + + const cacheRoot = getNativeAssetCacheRoot(rawManifest, options); + const sourceKeys = new Set(source.getAssetKeys()); + const files = [ + ...manifest.packages.flatMap((pkg) => pkg.files), + ...manifest.runtimeFiles, + ]; + for (const file of files) { + if (!sourceKeys.has(file.assetKey)) { + throw new Error(`Native asset is missing: ${file.assetKey}`); } + const bytes = toBuffer(source.getRawAsset(file.assetKey)); + const actualSha256 = sha256(bytes); + if (actualSha256 !== file.sha256) { + throw new Error( + `Native asset checksum mismatch for ${file.assetKey}: ${actualSha256} !== ${file.sha256}`, + ); + } + ensureFile(resolveAssetPath(cacheRoot, file.relativePath), bytes, file.sha256, file.mode); } ensureEntryFile(cacheRoot); return cacheRoot; } +export function getNativeRuntimeFile( + key: string, + options: NativeAssetOptions = {}, +): string | null { + const source = options.source ?? getSeaAssetSource(); + if (source === null) return null; + + const rawManifest = + options.manifest ?? getEmbeddedNativeAssetManifest(source, currentTarget()); + if (rawManifest === null) return null; + const manifest = validateNativeAssetManifest(rawManifest); + + const file = manifest.runtimeFiles.find((entry) => entry.key === key); + if (file === undefined) return null; + + const cacheRoot = ensureNativeAssetTree({ ...options, source, manifest: rawManifest }); + return cacheRoot === null ? null : resolveAssetPath(cacheRoot, file.relativePath); +} + +export function getMinidbTextBuildWorkerFile( + options: NativeAssetOptions = {}, +): string | null { + return getNativeRuntimeFile(MINIDB_TEXT_BUILD_WORKER_ASSET.key, options); +} + export function getNativePackageRoot( packageName: string, options: NativeAssetOptions = {}, @@ -247,15 +423,16 @@ export function getNativePackageRoot( const source = options.source ?? getSeaAssetSource(); if (source === null) return null; - const manifest = + const rawManifest = options.manifest ?? getEmbeddedNativeAssetManifest(source, currentTarget()); - if (manifest === null) return null; + if (rawManifest === null) return null; + const manifest = validateNativeAssetManifest(rawManifest); const pkg = manifest.packages.find((entry) => entry.name === packageName); if (pkg === undefined) return null; - const cacheRoot = ensureNativeAssetTree({ ...options, source, manifest }); - return cacheRoot === null ? null : join(cacheRoot, pkg.root); + const cacheRoot = ensureNativeAssetTree({ ...options, source, manifest: rawManifest }); + return cacheRoot === null ? null : resolveAssetPath(cacheRoot, pkg.root); } export function hasNativePackage(packageName: string, manifest: NativeAssetManifest): boolean { diff --git a/apps/kimi-code/src/native/smoke.ts b/apps/kimi-code/src/native/smoke.ts index c77f1419d0..1d330bc877 100644 --- a/apps/kimi-code/src/native/smoke.ts +++ b/apps/kimi-code/src/native/smoke.ts @@ -1,14 +1,17 @@ +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; -import { getEmbeddedNativeAssetManifest, getNativePackageRoot } from './native-assets'; +import { MiniDb } from '@moonshot-ai/minidb'; + +import { + getEmbeddedNativeAssetManifest, + getNativeCacheBase, + 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; @@ -18,42 +21,81 @@ function smokePiTuiNativeLoad(): void { } 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. + if (rel === undefined) return; const req = createRequire(import.meta.url); - const bogusPath = join(dirname(process.execPath), rel); - const helper = req(bogusPath) as { + const helper = req(join(dirname(process.execPath), rel)) 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}`); + if ( + typeof helper.isModifierPressed !== 'function' && + typeof helper.enableVirtualTerminalInput !== 'function' + ) { + throw new TypeError(`pi-tui native helper exports are unexpected: ${rel}`); } } -export function runNativeAssetSmokeIfRequested(): boolean { - if (process.env['KIMI_CODE_NATIVE_ASSET_SMOKE'] !== '1') return false; - +async function smokeMinidbWorker(): Promise { + const cacheBase = getNativeCacheBase(); + mkdirSync(cacheBase, { recursive: true }); + const dir = mkdtempSync(join(cacheBase, 'sea-minidb-smoke-')); + let db: MiniDb> | null = null; try { - const manifest = getEmbeddedNativeAssetManifest(); - if (manifest === null) { - throw new Error('Native asset manifest is not available.'); + db = await MiniDb.open>({ dir, valueCodec: 'json' }); + const total = 4_200; + for (let base = 0; base < total; base += 500) { + await db.batch( + Array.from({ length: Math.min(500, total - base) }, (_, offset) => { + const id = base + offset; + return { + op: 'set' as const, + key: `doc-${id}`, + value: { text: `sea worker searchable document ${id}` }, + }; + }), + ); + } + await db.createTextIndex('smoke', { fields: ['text'] }); + if (db.stats.textWorkerBuilds < 1) { + throw new Error(`MiniDb worker did not run: ${JSON.stringify(db.stats)}`); + } + if (db.stats.textWorkerFallbacks !== 0) { + throw new Error( + `MiniDb worker unexpectedly fell back: ${db.stats.lastTextWorkerFallback ?? 'unknown'}`, + ); + } + if (!db.search('smoke', 'searchable').some((hit) => hit.key === 'doc-0')) { + throw new Error('MiniDb worker-built text index returned an incorrect search result'); } - for (const packageName of smokePackages) { - const packageRoot = getNativePackageRoot(packageName, { manifest }); - if (packageRoot === null) { - throw new Error(`Native package is not available: ${packageName}`); - } + } finally { + await db?.close().catch(() => {}); + rmSync(dir, { recursive: true, force: true }); + } +} + +async function runSmoke(): Promise { + const manifest = getEmbeddedNativeAssetManifest(); + if (manifest === null) throw new Error('Native asset manifest is not available.'); + for (const packageName of smokePackages) { + if (getNativePackageRoot(packageName, { manifest }) === null) { + 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) { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`Native asset smoke failed: ${message}\n`); - process.exit(1); } + smokePiTuiNativeLoad(); + await smokeMinidbWorker(); + process.stdout.write(`Native asset smoke passed: ${manifest.target}; MiniDb worker build passed\n`); +} + +export function runNativeAssetSmokeIfRequested(): boolean { + if (process.env['KIMI_CODE_NATIVE_ASSET_SMOKE'] !== '1') return false; + void runSmoke().then( + () => process.exit(0), + (error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`Native asset smoke failed: ${message}\n`); + process.exit(1); + }, + ); + return true; } diff --git a/apps/kimi-code/src/tui/commands/add-dir.ts b/apps/kimi-code/src/tui/commands/add-dir.ts index 90636cea55..228b71d14c 100644 --- a/apps/kimi-code/src/tui/commands/add-dir.ts +++ b/apps/kimi-code/src/tui/commands/add-dir.ts @@ -1,15 +1,19 @@ import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; import type { SlashCommandHost } from './dispatch'; +import { slashBusyMessage, slashCommandBusyReason } from './resolve'; type AddDirChoice = 'session' | 'remember' | 'cancel'; export async function handleAddDirCommand(host: SlashCommandHost, args: string): Promise { const input = args.trim(); - const session = host.session; + let session = host.session; if (input.length === 0 || input.toLowerCase() === 'list') { - const additionalDirs = session?.summary?.additionalDirs ?? []; + // With no session yet (v2 session-less startup) the pending startup + // directories live in appState and will be passed to the lazy-created + // session; reflect them instead of reporting an empty list. + const additionalDirs = session?.summary?.additionalDirs ?? host.state.appState.additionalDirs; if (additionalDirs.length === 0) { host.showStatus('No additional directories configured.'); return; @@ -19,8 +23,24 @@ export async function handleAddDirCommand(host: SlashCommandHost, args: string): } if (session === undefined) { - host.showError(NO_ACTIVE_SESSION_MESSAGE); - return; + if (!host.engineV2) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + // The path-adding form needs a live session; lazy-create it on first use + // (the read-only `list`/bare forms above tolerate a missing session). + session = await host.ensureSession(); + if (session === undefined) return; + // A first prompt may have started a turn during the await; /add-dir is + // idle-only, so re-check the busy gate resolved before it. + const busyReason = slashCommandBusyReason({ + isStreaming: host.state.appState.streamingPhase !== 'idle', + isCompacting: host.state.appState.isCompacting, + }); + if (busyReason !== undefined) { + host.showError(slashBusyMessage('add-dir', busyReason)); + return; + } } host.mountEditorReplacement( diff --git a/apps/kimi-code/src/tui/commands/auth.ts b/apps/kimi-code/src/tui/commands/auth.ts index 773638485f..a44b4fab5d 100644 --- a/apps/kimi-code/src/tui/commands/auth.ts +++ b/apps/kimi-code/src/tui/commands/auth.ts @@ -74,7 +74,7 @@ async function handleKimiCodeOAuthLogin(host: SlashCommandHost): Promise { already_logged_in: alreadyLoggedIn, }); if (alreadyLoggedIn) { - host.showStatus('Already logged in. Model configuration refreshed.'); + host.showStatus('Already logged in. Model configuration refreshed.', 'success'); } } catch (error) { const cancelled = controller.signal.aborted; diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 2422dc5648..221b39ecfb 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -93,6 +93,13 @@ export async function handlePlanCommand(host: SlashCommandHost, args: string): P return; } + // The session may already be in the requested mode (e.g. it was created + // with config.defaultPlanMode applied), and re-entering plan mode throws. + if (host.state.appState.planMode === enabled) { + host.showNotice(`Plan mode is already ${enabled ? 'on' : 'off'}`); + return; + } + await applyPlanMode(host, session, enabled); } @@ -117,10 +124,12 @@ async function applyPlanMode(host: SlashCommandHost, session: Session, enabled: export async function handleYoloCommand(host: SlashCommandHost, args: string): Promise { const session = host.session; - if (session === undefined) { + if (session === undefined && !host.engineV2) { host.showError(NO_ACTIVE_SESSION_MESSAGE); return; } + // v2 session-less: the chosen mode is recorded in appState and passed to the + // lazy-created session; apply the runtime permission only when one exists. const subcmd = args.trim().toLowerCase(); const currentMode = host.state.appState.permissionMode; @@ -130,7 +139,7 @@ export async function handleYoloCommand(host: SlashCommandHost, args: string): P host.showNotice('YOLO mode is already on'); return; } - await session.setPermission('yolo'); + await session?.setPermission('yolo'); host.setAppState({ permissionMode: 'yolo' }); host.showNotice('YOLO mode: ON', 'Tool actions auto-approved; the agent may still ask you questions.'); return; @@ -141,7 +150,7 @@ export async function handleYoloCommand(host: SlashCommandHost, args: string): P host.showNotice('YOLO mode is already off'); return; } - await session.setPermission('manual'); + await session?.setPermission('manual'); host.setAppState({ permissionMode: 'manual' }); host.showNotice('YOLO mode: OFF'); return; @@ -149,11 +158,11 @@ export async function handleYoloCommand(host: SlashCommandHost, args: string): P // toggle if (currentMode === 'yolo') { - await session.setPermission('manual'); + await session?.setPermission('manual'); host.setAppState({ permissionMode: 'manual' }); host.showNotice('YOLO mode: OFF'); } else { - await session.setPermission('yolo'); + await session?.setPermission('yolo'); host.setAppState({ permissionMode: 'yolo' }); host.showNotice('YOLO mode: ON', 'Tool actions auto-approved; the agent may still ask you questions.'); } @@ -161,10 +170,12 @@ export async function handleYoloCommand(host: SlashCommandHost, args: string): P export async function handleAutoCommand(host: SlashCommandHost, args: string): Promise { const session = host.session; - if (session === undefined) { + if (session === undefined && !host.engineV2) { host.showError(NO_ACTIVE_SESSION_MESSAGE); return; } + // v2 session-less: the chosen mode is recorded in appState and passed to the + // lazy-created session; apply the runtime permission only when one exists. const subcmd = args.trim().toLowerCase(); const currentMode = host.state.appState.permissionMode; @@ -174,7 +185,7 @@ export async function handleAutoCommand(host: SlashCommandHost, args: string): P host.showNotice('Auto mode is already on'); return; } - await session.setPermission('auto'); + await session?.setPermission('auto'); host.setAppState({ permissionMode: 'auto' }); host.showNotice('Auto mode: ON', 'All actions auto-approved; the agent will not ask you questions.'); return; @@ -185,7 +196,7 @@ export async function handleAutoCommand(host: SlashCommandHost, args: string): P host.showNotice('Auto mode is already off'); return; } - await session.setPermission('manual'); + await session?.setPermission('manual'); host.setAppState({ permissionMode: 'manual' }); host.showNotice('Auto mode: OFF'); return; @@ -193,11 +204,11 @@ export async function handleAutoCommand(host: SlashCommandHost, args: string): P // toggle if (currentMode === 'auto') { - await session.setPermission('manual'); + await session?.setPermission('manual'); host.setAppState({ permissionMode: 'manual' }); host.showNotice('Auto mode: OFF'); } else { - await session.setPermission('auto'); + await session?.setPermission('auto'); host.setAppState({ permissionMode: 'auto' }); host.showNotice('Auto mode: ON', 'All actions auto-approved; the agent will not ask you questions.'); } @@ -463,6 +474,14 @@ async function performModelSwitch( effort: ThinkingEffort, persist: boolean, ): Promise { + let session = host.session; + if (session === undefined && host.engineV2) { + // A first prompt may still be inside lazy creation: wait it out so the + // switch lands on the new session instead of being overwritten by its + // assembly. + await host.waitForLazyCreation(); + session = host.session; + } if (host.state.appState.streamingPhase !== 'idle') { host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.'); return; @@ -476,7 +495,6 @@ async function performModelSwitch( let effectiveAlias = alias; let effectiveEffort = effort; - const session = host.session; try { if (session === undefined && runtimeChanged) { await host.authFlow.activateModelAfterLogin(alias, effort); @@ -875,7 +893,14 @@ async function applyPermissionChoice(host: SlashCommandHost, mode: PermissionMod } try { - await host.requireSession().setPermission(mode); + if (host.session !== undefined) { + await host.session.setPermission(mode); + } else if (!host.engineV2) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + // v2 session-less: the chosen mode is recorded in appState and passed to + // the lazy-created session. } catch (error) { const msg = formatErrorMessage(error); host.showError(`Failed to set permission mode: ${msg}`); diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index b2feffaebe..e5aef1639c 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -43,9 +43,18 @@ import { handleAddDirCommand } from './add-dir'; import { parseSlashInput } from './parse'; import { handlePluginsCommand } from './plugins'; import { handleProviderCommand } from './provider'; -import type { BuiltinSlashCommandName } from './registry'; +import { + findBuiltInSlashCommand, + resolveSlashCommandAvailability, + type BuiltinSlashCommandName, +} from './registry'; import { handleReloadCommand, handleReloadTuiCommand } from './reload'; -import { resolveSlashCommandInput, slashBusyMessage } from './resolve'; +import type { SkillListSession } from './skills'; +import { + resolveSlashCommandInput, + slashBusyMessage, + slashCommandBusyReason, +} from './resolve'; import { handleExportDebugZipCommand, handleExportMdCommand, @@ -103,6 +112,8 @@ export interface SlashCommandHost { state: TUIState; session: Session | undefined; readonly harness: KimiHarness; + /** agent-core-v2 engine (KIMI_CODE_EXPERIMENTAL_FLAG); enables lazy session creation. */ + readonly engineV2: boolean; cancelInFlight: (() => void) | undefined; deferUserMessages: boolean; @@ -117,9 +128,35 @@ export interface SlashCommandHost { restoreEditor(): void; restoreInputText(text: string): void; refreshSlashCommandAutocomplete(): void; + /** + * Rebuild the plugin slash-command list. With no session (v2 session-less + * startup) this reads the app-global plugin commands instead, so `/plugins` + * mutations apply before the first session exists. + */ + refreshPluginCommands(session?: Session): Promise; + /** + * Rebuild the skill slash-command list. With no session (v2 session-less + * startup) this reads the workspace skills instead. + */ + refreshSkillCommands(session?: SkillListSession): Promise; + /** + * Seed appState with the config defaults the v2 engine would apply at + * createSession time (model, permission, plan mode, thinking effort, + * context cap). No-op semantics on a live session path: only /reload calls + * it while still session-less. + */ + hydrateLazyConfigDefaults(): Promise; // Session requireSession(): Session; + /** + * Lazy-create the session on first use (v2 engine). Returns the existing + * session, or undefined (with the error already surfaced) when creation + * fails. + */ + ensureSession(): Promise; + /** Await the in-flight lazy session creation, if any (v2); no-op otherwise. */ + waitForLazyCreation(): Promise; switchToSession(session: Session, message: string): Promise; reloadCurrentSessionView(session: Session, message: string): Promise; beginSessionRequest(): void; @@ -204,11 +241,26 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi host.showError(`Invalid slash command: /${intent.commandName}`); return; case 'skill': { - const session = host.session; - if (host.state.appState.model.trim().length === 0 || session === undefined) { + if (host.state.appState.model.trim().length === 0) { host.showError(LLM_NOT_SET_MESSAGE); return; } + let session = host.session; + if (session === undefined) { + session = await ensureSessionForCommand(host); + if (session === undefined) return; + // A first prompt may have started a turn while the session was being + // created; skill commands are always busy-gated, so re-check the gate + // resolved before the await. + const busyReason = slashCommandBusyReason({ + isStreaming: host.state.appState.streamingPhase !== 'idle', + isCompacting: host.state.appState.isCompacting, + }); + if (busyReason !== undefined) { + host.showError(slashBusyMessage(intent.commandName, busyReason)); + return; + } + } host.track('input_command', { command: intent.commandName, skill_name: intent.skillName, @@ -221,10 +273,20 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi host.showError(LLM_NOT_SET_MESSAGE); return; } - const session = host.session; + let session = host.session; if (session === undefined) { - host.showError(LLM_NOT_SET_MESSAGE); - return; + session = await ensureSessionForCommand(host); + if (session === undefined) return; + // Same busy re-check as the skill path: plugin commands are always + // busy-gated too. + const busyReason = slashCommandBusyReason({ + isStreaming: host.state.appState.streamingPhase !== 'idle', + isCompacting: host.state.appState.isCompacting, + }); + if (busyReason !== undefined) { + host.showError(slashBusyMessage(intent.commandName, busyReason)); + return; + } } host.track('input_command', { command: `${intent.pluginId}:${intent.commandName}` }); host.activatePluginCommand(session, intent.pluginId, intent.commandName, intent.args); @@ -253,11 +315,60 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi } } +/** + * Lazy-create the session for a slash command that needs one (v2 engine). + * v1 keeps the historical "no active session" error; on v2 a missing session + * means the TUI started session-less, so commands create it on first use. + * Returns undefined (error already shown) when creation fails. + */ +async function ensureSessionForCommand(host: SlashCommandHost): Promise { + if (!host.engineV2) { + host.showError(LLM_NOT_SET_MESSAGE); + return undefined; + } + return host.ensureSession(); +} + +/** Builtin commands that need an active session; lazy-created on the v2 engine. */ +const SESSION_REQUIRING_COMMANDS: ReadonlySet = new Set([ + 'btw', + 'compact', + 'export-debug-zip', + 'export-md', + 'fork', + 'goal', + 'init', + 'plan', + 'swarm', + 'undo', + 'web', +]); + async function handleBuiltInSlashCommand( host: SlashCommandHost, name: BuiltinSlashCommandName, args: string, ): Promise { + if (host.session === undefined && SESSION_REQUIRING_COMMANDS.has(name)) { + const session = await ensureSessionForCommand(host); + if (session === undefined) return; + // A first prompt may have started a turn while the session was being + // created; re-check the availability gate that was resolved before the + // await (idle-only commands are blocked while a turn is active). + const command = findBuiltInSlashCommand(name); + const busyReason = slashCommandBusyReason({ + isStreaming: host.state.appState.streamingPhase !== 'idle', + isCompacting: host.state.appState.isCompacting, + }); + if ( + busyReason !== undefined && + command !== undefined && + resolveSlashCommandAvailability(command, args) === 'idle-only' + ) { + host.showError(slashBusyMessage(name, busyReason)); + return; + } + } switch (name) { case 'exit': void host.stop(); @@ -268,10 +379,24 @@ async function handleBuiltInSlashCommand( case 'version': host.showStatus(`Kimi Code v${host.state.appState.version}`); return; - case 'new': + case 'new': { + // A first-use lazy creation may still be in flight: wait it out so /new + // never races a second createSession against the pending prompt. + await host.waitForLazyCreation(); + // The waited-out prompt may have started a turn meanwhile; /new is + // idle-only, so re-run the busy gate resolved before the await. + const busyReason = slashCommandBusyReason({ + isStreaming: host.state.appState.streamingPhase !== 'idle', + isCompacting: host.state.appState.isCompacting, + }); + if (busyReason !== undefined) { + host.showError(slashBusyMessage(name, busyReason)); + return; + } await host.createNewSession(); host.state.ui.requestRender(); return; + } case 'sessions': void host.showSessionPicker(); return; @@ -282,7 +407,14 @@ async function handleBuiltInSlashCommand( void showMcpServers(host); return; case 'plugins': - void handlePluginsCommand(host, args); + // `handlePluginsCommand` throws when no session is active (its own + // requireSession), so catch here instead of letting the `void` call + // reject unhandled. + try { + await handlePluginsCommand(host, args); + } catch (error) { + host.showError(formatErrorMessage(error)); + } return; case 'add-dir': await handleAddDirCommand(host, args); diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index fd5d397f4b..baebf3f57c 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -167,7 +167,15 @@ export async function showStatusReport(host: SlashCommandHost): Promise { export async function showMcpServers(host: SlashCommandHost): Promise { let servers: readonly McpServerInfo[]; try { - servers = await host.requireSession().listMcpServers(); + if (host.session !== undefined) { + servers = await host.session.listMcpServers(); + } else if (host.engineV2) { + // v2 session-less: the MCP connection set is workspace-scoped, so it is + // inspectable before the first session exists. + servers = await host.harness.listWorkspaceMcpServers(host.state.appState.workDir); + } else { + servers = await host.requireSession().listMcpServers(); + } } catch (error) { host.showError(`Failed to load MCP servers: ${formatErrorMessage(error)}`); return; diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index 51bd3515a0..4e729aec3a 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -1,13 +1,16 @@ import { homedir as osHomedir } from 'node:os'; import { isAbsolute, join, resolve } from 'node:path'; -import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; +import type { CapabilityStatus, PluginInfo, PluginSummary, Session } from '@moonshot-ai/kimi-code-sdk'; +import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; import { PluginInstallTrustConfirmComponent, PluginMcpSelectorComponent, PluginRemoveConfirmComponent, PluginsPanelComponent, + describeCapabilityIssues, + formatCapabilityVersion, type PluginInstallTrustConfirmResult, type PluginMcpSelection, type PluginRemoveConfirmResult, @@ -25,8 +28,8 @@ import { isOfficialPluginInstall, isOfficialPluginSource, } from '../utils/plugin-source-label'; -import { QUOTA_CONSUMING_PLUGIN_IDS } from '#/constant/app'; -import { loadPluginMarketplace } from '#/utils/plugin-marketplace'; +import { KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV, QUOTA_CONSUMING_PLUGIN_IDS } from '#/constant/app'; +import { loadPluginMarketplace, type PluginMarketplaceEntry } from '#/utils/plugin-marketplace'; import { openUrl } from '#/utils/open-url'; import type { SlashCommandHost } from './dispatch'; @@ -50,11 +53,46 @@ interface ShowPluginMcpPickerOptions { readonly serverHint?: PluginMcpServerHint; } +/** The plugin-management surface `/plugins` operates on. */ +type PluginApi = Pick< + Session, + | 'listPlugins' + | 'installPlugin' + | 'setPluginEnabled' + | 'setPluginMcpServerEnabled' + | 'removePlugin' + | 'reloadPlugins' + | 'getPluginInfo' +>; + +/** + * Resolve the plugin-management API. On the v2 engine plugin state is + * app-global, so a session-less startup still gets a working `/plugins` + * through the harness's global facade; on v1 (and once a session exists) the + * session's own API is used. + */ +async function resolvePluginApi(host: SlashCommandHost): Promise { + if (host.session !== undefined) return host.session; + if (!host.engineV2) { + throw new Error(NO_ACTIVE_SESSION_MESSAGE); + } + return { + listPlugins: () => host.harness.listPlugins(), + installPlugin: (source) => host.harness.installPlugin(source), + setPluginEnabled: (id, enabled) => host.harness.setPluginEnabled(id, enabled), + setPluginMcpServerEnabled: (id, server, enabled) => + host.harness.setPluginMcpServerEnabled(id, server, enabled), + removePlugin: (id) => host.harness.removePlugin(id), + reloadPlugins: () => host.harness.reloadPlugins(), + getPluginInfo: (id) => host.harness.getPluginInfo(id), + }; +} + export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: string): Promise { const args = rawArgs.trim().split(/\s+/).filter((part) => part.length > 0); const sub = args[0]; const rest = args.slice(1); - const session = host.requireSession(); + const session = await resolvePluginApi(host); try { if (sub === undefined) { @@ -163,15 +201,31 @@ async function showPluginsPicker( ): Promise { let plugins: readonly PluginSummary[]; try { - plugins = await host.requireSession().listPlugins(); + plugins = await (await resolvePluginApi(host)).listPlugins(); } catch (error) { host.showError(`Failed to load plugins: ${formatErrorMessage(error)}`); return; } + let capabilities: readonly CapabilityStatus[] = []; + if (host.engineV2) { + try { + capabilities = await host.requireSession().listCapabilities(); + } catch (error) { + host.showStatus( + `Capability status unavailable: ${formatErrorMessage(error)}. Plugin management remains available.`, + 'warning', + ); + } + } + const panel = new PluginsPanelComponent({ installed: plugins, installedIds: new Set(plugins.map((plugin) => plugin.id)), + capabilities, + catalogIsDefault: + options?.marketplaceSource === undefined && + process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV] === undefined, initialTab: options?.initialTab, selectedId: options?.selectedId, pluginHint: options?.pluginHint, @@ -191,7 +245,7 @@ async function showPluginsPicker( // keep working even when the marketplace is unreachable (badges simply stay // hidden until data arrives). onRequestMarketplace: () => { - void loadMarketplaceCatalog(host, panel, options?.marketplaceSource); + void loadMarketplaceCatalog(host, panel, options?.marketplaceSource, capabilities); }, }); host.mountEditorReplacement(panel); @@ -202,19 +256,47 @@ async function showPluginsPicker( // over `panel`. if (options?.initialTab !== 'custom') { panel.setMarketplaceLoading(); - void loadMarketplaceCatalog(host, panel, options?.marketplaceSource); + void loadMarketplaceCatalog(host, panel, options?.marketplaceSource, capabilities); } } +/** + * Adapt a capability from the engine's registry into a catalog row. The + * engine is the single source of truth for what the built-in capabilities + * are — the CLI only renders them. The `capability:` source marker + * routes installs through the capability flow (never a plain plugin + * install), so the row needs no real URL. + */ +function capabilityMarketplaceEntry(capability: CapabilityStatus): PluginMarketplaceEntry { + return { + id: capability.id, + displayName: capability.displayName, + description: capability.description, + tier: 'official', + source: `capability:${capability.id}`, + builtIn: true, + }; +} + async function loadMarketplaceCatalog( host: SlashCommandHost, panel: PluginsPanelComponent, - source?: string, + source: string | undefined, + capabilities: readonly CapabilityStatus[], ): Promise { try { + // Injection is part of the DEFAULT catalog experience only: any explicit + // replacement (the slash-command source or the env override) opts out + // wholesale — its same-id rows are never masked and its failures surface. + const isDefaultCatalog = + source === undefined && process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV] === undefined; const marketplace = await loadPluginMarketplace({ workDir: host.state.appState.workDir, source, + builtInEntries: + host.engineV2 && isDefaultCatalog + ? capabilities.map(capabilityMarketplaceEntry) + : undefined, }); panel.setMarketplace(marketplace.plugins, marketplace.source); } catch (error) { @@ -230,7 +312,7 @@ async function showPluginMcpPicker( ): Promise { let info: PluginInfo; try { - info = await host.requireSession().getPluginInfo(id); + info = await (await resolvePluginApi(host)).getPluginInfo(id); } catch (error) { host.showError(`Failed to load plugin MCP servers: ${formatErrorMessage(error)}`); return; @@ -259,7 +341,7 @@ async function showPluginMcpPicker( async function confirmRemovePlugin(host: SlashCommandHost, id: string): Promise { let displayName = id; try { - displayName = (await host.requireSession().getPluginInfo(id)).displayName; + displayName = (await (await resolvePluginApi(host)).getPluginInfo(id)).displayName; } catch { // Keep the confirmation available even when plugin details cannot be loaded. } @@ -299,6 +381,136 @@ async function confirmInstallTrust( }); } +const CAPABILITY_POLL_INTERVAL_MS = 700; +const CAPABILITY_POLL_ATTEMPTS = 260; // ~3 minutes of runtime setup budget + +/** Client-injected v2 entries install their runtime and plugin together. + * Trust keys on the parser-proof `builtIn` flag — the `capability:` + * source string stays purely diagnostic. */ +function isCapabilityEntry(host: SlashCommandHost, entry: PluginMarketplaceEntry): boolean { + return host.engineV2 && entry.builtIn === true; +} + +/** + * Closed-set id check for the post-remove note. The capability ids are part + * of the client/engine CONTRACT (mirrored in the klient zod enum), not + * product data that drifts — so they may be named here. What must not + * happen is the alternative: answering set membership by running + * `listCapabilities()`, which fires every entry's detector (seconds of + * probes) just to decide whether to print one hint line. + */ +function isCapabilityId(host: SlashCommandHost, id: string): boolean { + return host.engineV2 && (id === 'kimi-cu' || id === 'kimi-webbridge'); +} + +/** Poll a background capability install, mirroring progress into the + * panel's inline installing line until it settles (or we run out of budget). */ +async function pollCapabilityInstall( + host: SlashCommandHost, + panel: PluginsPanelComponent, + id: string, + label: string, +): Promise { + const session = host.requireSession(); + for (let attempt = 0; attempt < CAPABILITY_POLL_ATTEMPTS; attempt += 1) { + await new Promise((resolve) => { + setTimeout(resolve, CAPABILITY_POLL_INTERVAL_MS); + }); + const status = await session.getCapability(id); + if (!status.install.running) return status; + const step = status.install.step ?? 'configuring runtime'; + const percent = status.install.percent; + panel.setInstalling( + `${truncateForStatus(label)} — ${step}${percent !== undefined ? ` ${percent}%` : ''}`, + ); + host.state.ui.requestRender(); + } + return undefined; +} + +export const __pluginsCommandInternals = { + isCapabilityEntry, + installCapabilityFromPanel, + pollCapabilityInstall, + removePlugin, +}; + +async function installCapabilityFromPanel( + host: SlashCommandHost, + panel: PluginsPanelComponent, + entry: PluginMarketplaceEntry, +): Promise { + const label = entry.displayName; + // Capability entries are official by construction; the trust prompt is + // reserved for unreviewed third-party plugins. + panel.setInstalling(truncateForStatus(label)); + host.state.ui.requestRender(); + const session = host.requireSession(); + try { + // An install already running (started from another panel or client) is + // followed, not restarted — the service rejects duplicate starts even + // though the original is healthy. + const alreadyRunning = await session + .getCapability(entry.id) + .then((status) => status.install.running, () => false); + if (!alreadyRunning) { + await session.installCapability(entry.id); + } + } catch (error) { + panel.clearInstalling(); + host.state.ui.requestRender(); + host.showError(`Failed to install ${label}: ${formatErrorMessage(error)}`); + host.restoreEditor(); + return; + } + let result: CapabilityStatus | undefined; + try { + result = await pollCapabilityInstall(host, panel, entry.id, label); + } catch { + result = undefined; + } + panel.clearInstalling(); + // Close the panel so the result lines land in the transcript, matching the + // plain plugin install flow. + host.restoreEditor(); + if (result === undefined) { + host.showStatus(`${label} setup is still running in the background; /plugins shows its state.`); + return; + } + if (result.install.error !== undefined) { + host.showError(`${label} setup failed: ${result.install.error}. Install again from /plugins to retry.`); + return; + } + if (result.state !== 'ready') { + const issues = describeCapabilityIssues(result); + host.showStatus( + `${label} setup is incomplete${issues.length > 0 ? `: ${issues}` : ''}.`, + 'warning', + ); + if (result.id === 'kimi-cu' && result.steps.some((step) => step.id === 'permissions' && step.state !== 'ok')) { + host.showStatus( + 'Grant Accessibility and Screen Recording in System Settings → Privacy & Security, then reopen /plugins to recheck.', + 'warning', + ); + } + host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); + return; + } + host.showStatus( + `${label} is ready${result.version !== undefined ? ` (${formatCapabilityVersion(result.version)})` : ''}.`, + ); + const skillShadow = result.steps.find( + (step) => step.id === 'skill-shadow' && step.state !== 'ok', + ); + if (skillShadow?.detail !== undefined) { + host.showStatus( + `A user-installed kimi-webbridge skill is shadowing the managed plugin. Remove it manually: ${skillShadow.detail}`, + 'warning', + ); + } + host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); +} + async function installFromPanel( host: SlashCommandHost, panel: PluginsPanelComponent, @@ -345,7 +557,7 @@ async function applyPluginEnabled( enabled: boolean, showStatus = true, ): Promise { - const session = host.requireSession(); + const session = await resolvePluginApi(host); await session.setPluginEnabled(id, enabled); let info: PluginInfo | undefined; try { @@ -400,6 +612,10 @@ async function handlePluginsPanelSelection( await showPluginsPicker(host, { initialTab: 'installed' }); return; case 'install': + if (isCapabilityEntry(host, selection.entry)) { + await installCapabilityFromPanel(host, panel, selection.entry); + return; + } await installFromPanel( host, panel, @@ -432,11 +648,9 @@ async function handlePluginMcpSelection( ): Promise { switch (selection.kind) { case 'toggle': - await host.requireSession().setPluginMcpServerEnabled( - selection.pluginId, - selection.server, - selection.enabled, - ); + await ( + await resolvePluginApi(host) + ).setPluginMcpServerEnabled(selection.pluginId, selection.server, selection.enabled); await showPluginMcpPicker(host, selection.pluginId, { selectedServer: selection.server, serverHint: { @@ -452,8 +666,13 @@ async function handlePluginMcpSelection( } async function removePlugin(host: SlashCommandHost, id: string): Promise { - await host.requireSession().removePlugin(id); + await (await resolvePluginApi(host)).removePlugin(id); host.showStatus(`Removed ${id}.`); + if (isCapabilityId(host, id)) { + host.showStatus( + 'Note: the runtime binaries were left untouched, but Kimi Code plugin wiring is disabled for new sessions. Reinstall any time from the Official tab.', + ); + } host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); } @@ -461,7 +680,7 @@ async function renderPluginsList( host: SlashCommandHost, plugins?: readonly PluginSummary[], ): Promise { - const currentPlugins = plugins ?? (await host.requireSession().listPlugins()); + const currentPlugins = plugins ?? (await (await resolvePluginApi(host)).listPlugins()); const title = ` Plugins (${currentPlugins.length}) `; const panel = new UsagePanelComponent( () => buildPluginsListLines({ plugins: currentPlugins }), @@ -473,7 +692,7 @@ async function renderPluginsList( } async function renderPluginInfo(host: SlashCommandHost, id: string): Promise { - const info = await host.requireSession().getPluginInfo(id); + const info = await (await resolvePluginApi(host)).getPluginInfo(id); const panel = new UsagePanelComponent( () => buildPluginsInfoLines({ info }), 'primary', @@ -487,7 +706,7 @@ async function installPluginFromSource( host: SlashCommandHost, source: string, ): Promise { - const session = host.requireSession(); + const session = await resolvePluginApi(host); const beforeList = await session.listPlugins(); const summary = await session.installPlugin( resolvePluginInstallSource(source, host.state.appState.workDir), @@ -558,10 +777,13 @@ function truncateForStatus(input: string): string { } async function reloadPlugins(host: SlashCommandHost): Promise { - const summary = await host.requireSession().reloadPlugins(); + const summary = await (await resolvePluginApi(host)).reloadPlugins(); const line = `Reload: +${summary.added.length} -${summary.removed.length}` + (summary.errors.length > 0 ? ` (${summary.errors.length} errors)` : ''); host.showStatus(line); + // Rebuild the TUI's plugin slash-command list from the reloaded service so + // newly added/enabled commands resolve in this session-less UI right away. + await host.refreshPluginCommands(host.session); } function resolvePluginInstallSource(source: string, workDir: string): string { diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 2cea404918..48b57aa3f8 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -315,7 +315,7 @@ export const BUILTIN_SLASH_COMMANDS = [ { name: 'fork', aliases: [], - description: 'Fork the current session', + description: 'Fork the current session into a copy without switching to it', priority: 80, }, { @@ -342,7 +342,7 @@ export const BUILTIN_SLASH_COMMANDS = [ }, { name: 'feedback', - aliases: [], + aliases: ['bug'], description: 'Send feedback to make Kimi Code better', priority: 60, availability: 'always', diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index 2c0010334b..15dc411651 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -26,11 +26,24 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise const config = await host.harness.getConfig({ reload: true }); setExperimentalFeatures(await host.harness.getExperimentalFeatures()); + const sessionlessV2 = session === undefined && host.engineV2; + if (sessionlessV2) { + // Session-less v2: rebuild the workspace-level dynamic commands too, so + // skill/plugin changes apply before the first session exists. + await host.refreshSkillCommands(); + await host.refreshPluginCommands(); + } host.refreshSlashCommandAutocomplete(); applyRuntimeConfig(host, config); await applyReloadedTuiConfig(host, tuiConfig); if (session === undefined) { + // Still session-less on the v2 engine: refresh the lazy defaults too, so + // defaults edited externally (config.toml, a newly added default model) + // reach the first lazy-created session instead of staying stale. + if (sessionlessV2) { + await host.hydrateLazyConfigDefaults(); + } host.showStatus( 'Runtime and TUI config reloaded; no active session.', 'success', diff --git a/apps/kimi-code/src/tui/commands/session.ts b/apps/kimi-code/src/tui/commands/session.ts index 2f0870db0d..1a80c1947f 100644 --- a/apps/kimi-code/src/tui/commands/session.ts +++ b/apps/kimi-code/src/tui/commands/session.ts @@ -29,10 +29,16 @@ export async function handleTitleCommand(host: SlashCommandHost, args: string): return; } - const session = host.session; + let session = host.session; if (session === undefined) { - host.showError(NO_ACTIVE_SESSION_MESSAGE); - return; + if (!host.engineV2) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + // Setting a title needs a live session; lazy-create it on first use (the + // bare read-only form above works session-less). + session = await host.ensureSession(); + if (session === undefined) return; } const newTitle = title.slice(0, 200); @@ -55,26 +61,28 @@ export async function handleForkCommand(host: SlashCommandHost, args: string): P } const sourceTitle = forkSourceTitle(host, session); - let forked: Session; try { - forked = await host.harness.forkSession({ + const forked = await host.harness.forkSession({ id: session.id, title: `Fork: ${sourceTitle}`, }); - } catch (error) { - const msg = formatErrorMessage(error); - host.showError(`Failed to fork session: ${msg}`); - return; - } - - try { - await host.switchToSession( - forked, - `Session forked (${forked.id}). To return to the original session: kimi -r ${session.id}`, + const forkId = forked.id; + try { + await forked.close(); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Session forked (${forkId}), but failed to release its runtime: ${msg}`); + return; + } + // Stay in the source session: switching to the fork would close the + // source, killing its in-flight turn and background tasks. The fork is + // an independent copy the user can switch to explicitly via /sessions. + host.showStatus( + `Session forked (${forkId}). Still in the original session; switch to the fork via /sessions.`, ); } catch (error) { const msg = formatErrorMessage(error); - host.showError(`Failed to switch to forked session: ${msg}`); + host.showError(`Failed to fork session: ${msg}`); } } diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index 64537df22e..0299c6fde0 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -134,7 +134,7 @@ export function effortLabel(effort: string): string { * middle `support_efforts` entry, else `'on'` for boolean models, `'off'` when * thinking is unsupported. */ -function defaultThinkingEffortFor(model: ModelAlias): ThinkingEffort { +export function defaultThinkingEffortFor(model: ModelAlias): ThinkingEffort { if (thinkingAvailability(model) === 'unsupported') return 'off'; const efforts = effortsOf(model); if (efforts.length > 0) { diff --git a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts index 64ec286148..b2238df571 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -7,7 +7,12 @@ import { visibleWidth, type Focusable, } from '@moonshot-ai/pi-tui'; -import type { PluginInfo, PluginMcpServerInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; +import type { + CapabilityStatus, + PluginInfo, + PluginMcpServerInfo, + PluginSummary, +} from '@moonshot-ai/kimi-code-sdk'; import chalk from 'chalk'; import { SELECT_POINTER } from '#/tui/constant/symbols'; @@ -28,10 +33,11 @@ const INSTALL_TRUST_EXIT = 'exit'; const INSTALL_TRUST_TRUST = 'trust'; const ELLIPSIS = '…'; -// Hardcoded Web Bridge promotion: a built-in entry that always leads the -// Official tab, even when the marketplace catalog is unavailable. Selecting it -// opens the install page in the browser rather than installing from a source, -// because Web Bridge is a browser extension + daemon, not a plugin package. +// Hardcoded Web Bridge promotion: a built-in fallback shown only while the +// marketplace catalog is loading, unreachable, or predates the real +// `kimi-webbridge` entry. Selecting it opens the install page in the browser; +// once the catalog carries the real entry, that row wins and installs +// normally. const WEB_BRIDGE_URL = 'https://www.kimi.com/features/webbridge#local-agent'; const WEB_BRIDGE_ENTRY: PluginMarketplaceEntry = { id: 'kimi-webbridge', @@ -284,10 +290,15 @@ function pluginStatus(plugin: PluginSummary): string | undefined { } function marketplaceStatusStyle(status: string, colors: ColorPalette): (text: string) => string { - // "update …" is a warning (actionable); "installed …" is success; - // "install …" is the available action. + // States recede, actions pop: "installed …" is a quiet fact (dim), while + // "install …" (the available action) stays primary and "update …" stays a + // warning — the two used to share near-identical green-ish treatments in + // the same column and read as interchangeable. if (status.startsWith('update')) return chalk.hex(colors.warning); - if (status.startsWith('installed')) return chalk.hex(colors.success); + if (status === 'finish setup' || status === 'installing…' || status === 'unsupported') { + return chalk.hex(colors.warning); + } + if (status.startsWith('installed')) return chalk.hex(colors.textDim); return chalk.hex(colors.primary); } @@ -331,6 +342,13 @@ export type PluginsPanelSelection = export interface PluginsPanelOptions { readonly installed: readonly PluginSummary[]; readonly installedIds: ReadonlySet; + readonly capabilities?: readonly CapabilityStatus[]; + /** + * False when the marketplace was explicitly replaced (slash-command + * source or env override): built-in rows then stay out of the Official + * tab entirely. Undefined means the default catalog. + */ + readonly catalogIsDefault?: boolean; readonly initialTab?: PluginsPanelTabId; readonly selectedId?: string; readonly pluginHint?: { readonly id: string; readonly text: string }; @@ -423,20 +441,50 @@ export class PluginsPanelComponent extends Container implements Focusable { return new Map(this.opts.installed.map((plugin) => [plugin.id, plugin.version])); } + private capabilityFor(id: string): CapabilityStatus | undefined { + return this.opts.capabilities?.find((capability) => capability.id === id); + } + + /** Capability state for a MARKETPLACE row: only our own injected rows + * (flagged `builtIn` — a custom catalog cannot forge the flag) may show + * capability status, matching how Enter routes them. */ + private capabilityForEntry(entry: PluginMarketplaceEntry): CapabilityStatus | undefined { + return entry.builtIn === true ? this.capabilityFor(entry.id) : undefined; + } + private get officialEntries(): readonly PluginMarketplaceEntry[] { - // The hardcoded Web Bridge entry always leads the Official tab, even when - // the catalog is loading or unreachable. Dedupe by id so a catalog that - // also lists it does not render a second row. - return [WEB_BRIDGE_ENTRY, ...this.officialCatalogEntries]; + // While the catalog is loading or unreachable, the locally-known + // capability rows still render and install — built-in runtime setup + // must never be blocked by an unrelated catalog fetch. + if (this.market.status !== 'loaded') { + return this.pendingBuiltInEntries.some((entry) => entry.id === WEB_BRIDGE_ENTRY.id) + ? this.pendingBuiltInEntries + : [...this.pendingBuiltInEntries, WEB_BRIDGE_ENTRY]; + } + // The real catalog entry wins when present (it installs the actual + // plugin); the hardcoded promo row is only a fallback while the catalog + // is loading, unreachable, or predates it — never a duplicate row. + return this.officialCatalogEntries.some((entry) => entry.id === WEB_BRIDGE_ENTRY.id) + ? this.officialCatalogEntries + : [WEB_BRIDGE_ENTRY, ...this.officialCatalogEntries]; + } + + /** Capability rows synthesized from the engine's registry, independent of + * the marketplace state; unsupported platforms hide them entirely. Only + * the default catalog gets built-in rows — an explicitly overridden + * marketplace must be able to fully replace the Official tab. */ + private get pendingBuiltInEntries(): readonly PluginMarketplaceEntry[] { + if (this.opts.catalogIsDefault === false) return []; + return (this.opts.capabilities ?? []) + .filter((capability) => capability.supported) + .map(capabilityMarketplaceEntry); } private get officialCatalogEntries(): readonly PluginMarketplaceEntry[] { - // Dedupe by id (not reference): if the official catalog also lists - // kimi-webbridge, the pinned row already represents it, so suppress the - // catalog copy to avoid a duplicate row on the Official tab. - return this.marketplaceEntries.filter( - (entry) => entry.tier === 'official' && entry.id !== WEB_BRIDGE_ENTRY.id, - ); + return this.marketplaceEntries.filter((entry) => { + if (entry.tier !== 'official') return false; + return this.capabilityForEntry(entry)?.supported !== false; + }); } private get thirdPartyEntries(): readonly PluginMarketplaceEntry[] { @@ -522,6 +570,11 @@ export class PluginsPanelComponent extends Container implements Focusable { } if (matchesKey(data, Key.enter)) { if (plugin === undefined) return; + const capability = this.capabilityFor(plugin.id); + if (capability !== undefined && capabilityNeedsSetup(capability)) { + this.opts.onSelect({ kind: 'install', entry: capabilityMarketplaceEntry(capability) }); + return; + } const update = this.installedUpdateStatus(plugin); if (update !== undefined) { this.opts.onSelect({ kind: 'install', entry: update.entry }); @@ -614,8 +667,10 @@ export class PluginsPanelComponent extends Container implements Focusable { private installedHint(): string { const plugin = this.opts.installed[this.selectedIndex]; + const capability = plugin === undefined ? undefined : this.capabilityFor(plugin.id); + const needsSetup = capability !== undefined && capabilityNeedsSetup(capability); const hasUpdate = plugin !== undefined && this.installedUpdateStatus(plugin) !== undefined; - const enter = hasUpdate ? 'Enter update' : 'Enter details'; + const enter = needsSetup ? 'Enter finish setup' : hasUpdate ? 'Enter update' : 'Enter details'; return ` Tab switch · Space toggle · D remove · M MCP · ${enter} · I details · R reload · Esc cancel`; } @@ -637,6 +692,7 @@ export class PluginsPanelComponent extends Container implements Focusable { const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); const status = pluginStatus(plugin); const update = this.installedUpdateStatus(plugin); + const capability = this.capabilityFor(plugin.id); let line = prefix + labelStyle(plugin.displayName); if (status !== undefined) { line += ' ' + statusStyle({ kind: 'plugin', value: '', label: '', description: '', status }, colors)(status); @@ -645,12 +701,31 @@ export class PluginsPanelComponent extends Container implements Focusable { const badge = `update ${update.local} → ${update.latest}`; line += ' ' + marketplaceStatusStyle(badge, colors)(badge); } + if (capability !== undefined && capability.state !== 'ready') { + const badge = capability.install.running + ? 'installing…' + : capabilityNeedsSetup(capability) + ? 'setup incomplete' + : capability.state === 'unsupported' + ? 'unsupported' + : undefined; + if (badge !== undefined) { + // Unsupported is a fact, not a problem: dim it; actionable setup + // states keep the warning tone. + line += ' ' + (badge === 'unsupported' ? chalk.hex(colors.textDim)(badge) : chalk.hex(colors.warning)(badge)); + } + } if (this.opts.pluginHint?.id === plugin.id) { line += ' ' + chalk.hex(colors.warning)(this.opts.pluginHint.text); } const descWidth = Math.max(1, width - 4); const out = [line]; - for (const descLine of wrapOverviewDescription(overviewPluginDescription(plugin), descWidth)) { + const capabilityIssues = capability === undefined ? '' : describeCapabilityIssues(capability); + const description = + capabilityIssues.length === 0 + ? overviewPluginDescription(plugin) + : `${overviewPluginDescription(plugin)} · ${capabilityIssues}`; + for (const descLine of wrapOverviewDescription(description, descWidth)) { out.push(mutedHintLine(` ${descLine}`, colors)); } return out; @@ -661,6 +736,10 @@ export class PluginsPanelComponent extends Container implements Focusable { width: number, entries: readonly PluginMarketplaceEntry[], indexOffset = 0, + // Counts (installed/available footer) are computed over this list: + // the Official tab renders the pinned promo as a row but excludes it + // from the catalog counts, matching its pre-catalog semantics. + entriesForCount: readonly PluginMarketplaceEntry[] = entries, ): void { const colors = currentTheme.palette; if (this.market.status === 'loading' || this.market.status === 'idle') { @@ -679,20 +758,31 @@ export class PluginsPanelComponent extends Container implements Focusable { lines.push(...this.renderMarketplaceRow(entries[i]!, i + indexOffset, width)); } } - const installedCount = entries.filter((e) => this.opts.installedIds.has(e.id)).length; + const installedCount = entriesForCount.filter((e) => this.opts.installedIds.has(e.id)).length; lines.push(''); lines.push( - mutedHintLine(` ${installedCount} installed · ${entries.length - installedCount} available`, colors), + mutedHintLine( + ` ${installedCount} installed · ${entriesForCount.length - installedCount} available`, + colors, + ), ); lines.push(mutedHintLine(` Source: ${this.market.source}`, colors)); } private renderOfficial(lines: string[], width: number): void { - // Web Bridge is pinned above the catalog and stays visible while the - // catalog loads or errors, since it's built into the TUI rather than - // fetched. Catalog rows shift down by one index to match. - lines.push(...this.renderMarketplaceRow(WEB_BRIDGE_ENTRY, 0, width)); - this.renderMarketplaceTab(lines, width, this.officialCatalogEntries, 1); + // Loading / error: `officialEntries` carries the locally-known + // capability rows (plus the promo fallback when webbridge is not among + // them), so built-in setup works before the catalog arrives. Once + // loaded, the promo appears only when the catalog lacks the real entry. + if (this.market.status !== 'loaded') { + const entries = this.officialEntries; + for (let i = 0; i < entries.length; i += 1) { + lines.push(...this.renderMarketplaceRow(entries[i]!, i, width)); + } + this.renderMarketplaceTab(lines, width, [], entries.length); + return; + } + this.renderMarketplaceTab(lines, width, this.officialEntries, 0, this.officialCatalogEntries); } private renderThirdParty(lines: string[], width: number): void { @@ -705,14 +795,22 @@ export class PluginsPanelComponent extends Container implements Focusable { const pointer = selected ? SELECT_POINTER : ' '; const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); + const capability = this.capabilityForEntry(entry); const status = isPinnedWebBridgeEntry(entry) ? 'open in browser' - : marketplaceEntryStatus(entry, this.installedVersions); + : capability === undefined + ? marketplaceEntryStatus(entry, this.installedVersions) + : capabilityRowStatus(capability, entry); const line = prefix + labelStyle(entry.displayName) + ' ' + marketplaceStatusStyle(status, colors)(status); const descWidth = Math.max(1, width - 4); const out = [line]; - for (const descLine of wrapOverviewDescription(marketplaceEntryDescription(entry), descWidth)) { + const capabilityIssues = capability === undefined ? '' : describeCapabilityIssues(capability); + const description = + capabilityIssues.length === 0 + ? marketplaceEntryDescription(entry) + : `${marketplaceEntryDescription(entry)} · ${capabilityIssues}`; + for (const descLine of wrapOverviewDescription(description, descWidth)) { out.push(mutedHintLine(` ${descLine}`, colors)); } return out; @@ -790,6 +888,86 @@ function marketplaceTierLabel(tier: PluginMarketplaceEntry['tier']): string { return 'Plugin'; } +function capabilityMarketplaceEntry(capability: CapabilityStatus): PluginMarketplaceEntry { + return { + id: capability.id, + displayName: capability.displayName, + source: `capability:${capability.id}`, + tier: 'official', + description: capability.description, + builtIn: true, + }; +} + +/** + * Setup is actionable only for these states. An `unsupported` capability + * (wrong OS/arch) can only fail — the service rejects its install — so the + * panel must not offer a "finish setup" action that ends in an error; it + * renders as unsupported instead. + */ +function capabilityNeedsSetup(capability: CapabilityStatus): boolean { + return ( + (capability.state === 'not_installed' || capability.state === 'partial') && + !capability.install.running + ); +} + +function capabilityRowStatus( + capability: CapabilityStatus, + entry: PluginMarketplaceEntry, +): string { + if (capability.install.running) return 'installing…'; + switch (capability.state) { + case 'ready': + return capability.version === undefined + ? 'ready' + : `ready · ${formatCapabilityVersion(capability.version)}`; + case 'partial': + return 'finish setup'; + case 'not_installed': + return installStatus(entry); + case 'unsupported': + return 'unsupported'; + } +} + +export function formatCapabilityVersion(version: string): string { + return version.startsWith('v') ? version : `v${version}`; +} + +export function describeCapabilityIssues(capability: CapabilityStatus): string { + const issues: string[] = []; + const required = capability.steps.filter( + (step) => step.optional !== true && step.state !== 'ok', + ); + if (required.length > 0) { + issues.push(`needs ${required.map(formatCapabilityStep).join(', ')}`); + } + const extension = capability.steps.find( + (step) => step.id === 'extension' && step.state !== 'ok', + ); + if (extension !== undefined) issues.push('browser extension not connected'); + const skillShadow = capability.steps.find( + (step) => step.id === 'skill-shadow' && step.state !== 'ok', + ); + if (skillShadow !== undefined) issues.push('user skill shadows managed plugin'); + return issues.join(', '); +} + +function formatCapabilityStep(step: CapabilityStatus['steps'][number]): string { + const label = + step.id === 'daemon-binary' + ? 'daemon binary' + : step.id === 'skill' + ? 'agent skill' + : step.id; + if (step.detail === undefined || step.detail.length === 0) return label; + const detail = step.detail + .replaceAll('screenRecording', 'screen recording') + .replaceAll(',', ', '); + return `${label} (${detail})`; +} + function installStatus(entry: PluginMarketplaceEntry): string { return entry.version === undefined ? 'install' : `install v${entry.version}`; } diff --git a/apps/kimi-code/src/tui/constant/kimi-tui.ts b/apps/kimi-code/src/tui/constant/kimi-tui.ts index 8c8f9807b4..4539d1b9fe 100644 --- a/apps/kimi-code/src/tui/constant/kimi-tui.ts +++ b/apps/kimi-code/src/tui/constant/kimi-tui.ts @@ -8,6 +8,8 @@ export const CTRL_D_HINT = 'Press Ctrl+D again to exit'; export const CTRL_C_HINT = 'Press Ctrl+C again to exit'; export const MAIN_AGENT_ID = 'main'; export const OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE = 'OAuth login expired. Send /login to login.'; +export const SESSIONLESS_STARTUP_NOTICE = + 'No session yet — one will be created on your first message.'; export const EXIT_CONFIRM_WINDOW_MS = 1500; // Time window for treating two consecutive Esc presses as a double-Esc, which // opens the undo selector. Kept short (double-click feel) so two deliberate diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index 06c5f46d83..67fac913c2 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -5,6 +5,7 @@ import { type KimiHarness, type OAuthRef, type Session, + type ThinkingEffort, } from '@moonshot-ai/kimi-code-sdk'; import { createKimiCodeUserAgent } from '#/cli/version'; @@ -32,6 +33,7 @@ export interface AuthFlowHost { session: Session | undefined; readonly harness: KimiHarness; readonly options: KimiTUIOptions; + readonly engineV2: boolean; setAppState(patch: Partial): void; setStartupReady(): void; @@ -40,6 +42,7 @@ export interface AuthFlowHost { syncRuntimeState(session?: Session): Promise; closeSession(reason: string): Promise; appendStartupNotice(extra: string): void; + hydrateLazyConfigDefaults(): Promise; readonly sessionEventHandler: SessionEventHandler; fetchSessions(): Promise; updateTerminalTitle(): void; @@ -83,6 +86,20 @@ export class AuthFlowController { return; } + if (host.engineV2) { + // Lazy session creation (v2 engine): configure the model only; the + // session is created on the first message. The effort is carried as the + // first session's thinking override so a session-only choice (Alt+S) + // made before any session exists is applied on creation. + const patch: Partial = { model }; + if (effort !== undefined) { + patch.thinkingEffort = effort as ThinkingEffort; + patch.lazySessionThinking = effort as ThinkingEffort; + } + host.setAppState(patch); + return; + } + const options: MutableCreateSessionOptions = { workDir: host.state.appState.workDir, model, @@ -138,11 +155,23 @@ export class AuthFlowController { const selected = defaultModel !== undefined ? availableModels[defaultModel] : undefined; if (defaultModel === undefined || selected === undefined) { + if (host.session === undefined && host.engineV2) { + // Session-less v2: hydrate permission/plan defaults even without a + // default model. + await host.hydrateLazyConfigDefaults(); + } host.setAppState({ availableModels, availableProviders }); return; } await this.activateModelAfterLogin(defaultModel, thinkingEffortFromConfig(config.thinking)); + if (host.session === undefined && host.engineV2) { + // Session-less v2: also hydrate permission/plan defaults from the + // refreshed config, same as startup. + await host.hydrateLazyConfigDefaults(); + host.setAppState({ availableModels, availableProviders }); + return; + } const appStatePatch: Partial = { availableModels, availableProviders, diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 76d0363f80..55df80609b 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -23,6 +23,7 @@ import type { BtwPanelController } from './btw-panel'; export interface EditorKeyboardHost { state: TUIState; session: Session | undefined; + readonly engineV2: boolean; cancelInFlight: (() => void) | undefined; /** * The host's harness (KimiTUI always has one). Its `imageLimits` drives @@ -51,6 +52,7 @@ export interface EditorKeyboardHost { hideSessionPicker(): void; openUndoSelector(): void; stop(exitCode?: number): Promise; + ensureSession(): Promise; handlePlanToggle(next: boolean): void; handleInputModeChange(mode: 'prompt' | 'bash'): void; clearQueuedMessages(): void; @@ -212,14 +214,25 @@ export class EditorKeyboardController { }; editor.onShiftTab = () => { + const togglePlan = (): void => { + const next = !host.state.appState.planMode; + host.track('shortcut_plan_toggle', { enabled: next }); + host.track('shortcut_mode_switch', { to_mode: next ? 'plan' : 'agent' }); + host.handlePlanToggle(next); + }; if (host.session === undefined) { - host.showError(NO_ACTIVE_SESSION_MESSAGE); + if (!host.engineV2) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + // v2 session-less: lazy-create the session, then toggle — the same + // path /plan takes. + void host.ensureSession().then((session) => { + if (session !== undefined) togglePlan(); + }); return; } - const next = !host.state.appState.planMode; - host.track('shortcut_plan_toggle', { enabled: next }); - host.track('shortcut_mode_switch', { to_mode: next ? 'plan' : 'agent' }); - host.handlePlanToggle(next); + togglePlan(); }; editor.onInputModeChange = (mode) => { diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 834bd77771..a979de7219 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -2,7 +2,7 @@ import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth'; -import { log } from '@moonshot-ai/kimi-code-sdk'; +import { effectiveModelAlias, log } from '@moonshot-ai/kimi-code-sdk'; import type { ApprovalRequest, ApprovalResponse, @@ -10,8 +10,10 @@ import type { CreateSessionOptions, KimiHarness, PermissionMode, + PluginCommandDef, PromptPart, Session, + SkillSummary, WorkspaceTrustInfo, } from '@moonshot-ai/kimi-code-sdk'; import type { MigrationPlan } from '@moonshot-ai/migration-legacy'; @@ -63,6 +65,7 @@ import { } from './components/dialogs/approval-preview'; import { CompactionComponent } from './components/dialogs/compaction'; import { HelpPanelComponent } from './components/dialogs/help-panel'; +import { defaultThinkingEffortFor } from './components/dialogs/model-selector'; import { QuestionDialogComponent } from './components/dialogs/question-dialog'; import { SessionPickerComponent, type SessionRow } from './components/dialogs/session-picker'; import { TrustPromptComponent, type TrustPromptChoice } from './components/dialogs/trust-prompt'; @@ -100,6 +103,7 @@ import { MAIN_AGENT_ID, NO_ACTIVE_SESSION_MESSAGE, PRODUCT_NAME, + SESSIONLESS_STARTUP_NOTICE, } from './constant/kimi-tui'; import { CHROME_GUTTER } from './constant/rendering'; import { MAX_TERMINAL_TITLE_LENGTH } from './constant/terminal'; @@ -140,10 +144,13 @@ import { formatErrorMessage } from './utils/event-payload'; import { pickForegroundTasks } from './utils/foreground-task'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; import { extractMediaAttachments, rewriteMediaPlaceholders } from './utils/image-placeholder'; +import { installInputLatencyProbe } from './utils/input-latency'; +import { startupTrace } from '#/utils/startup-trace'; import { REPLAY_TURN_LIMIT } from './utils/message-replay'; import { hasPatchChanges } from './utils/object-patch'; import { sessionRowsForPicker } from './utils/session-picker-rows'; import { formatBashOutputForDisplay } from './utils/shell-output'; +import { thinkingEffortFromConfig } from './utils/thinking-config'; import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup'; import { installTerminalFocusTracking } from './utils/terminal-focus'; import { notifyTerminalOnce } from './utils/terminal-notification'; @@ -305,6 +312,8 @@ export class KimiTUI { readonly options: KimiTUIOptions; session: Session | undefined; state: TUIState; + /** In-flight lazy session creation (v2 engine), shared by concurrent first-use triggers. */ + private ensureSessionPromise: Promise | null = null; private readonly approvalController = new ApprovalController(); private readonly questionController = new QuestionController(); private readonly reverseRpcDisposers: Array<() => void> = []; @@ -328,7 +337,8 @@ export class KimiTUI { private backgroundRefreshPromise: Promise | undefined; private readonly migrationPlan: MigrationPlan | null; private readonly migrateOnly: boolean; - private readonly engineV2: boolean; + /** Whether the harness runs on the agent-core-v2 engine (lazy session creation). */ + readonly engineV2: boolean; private startupNotice: string | undefined; private lastActivityMode: string | undefined; private currentLoadingTip: { kind: LoadingTipKind; tip: string | undefined } | undefined = @@ -486,6 +496,18 @@ export class KimiTUI { async refreshSkillCommands(session?: SkillListSession): Promise { if (session === undefined) { + // v2 engine: skills live on the workspace handler, not the session, so + // they are available before the first (lazy) session is created — the + // workspace catalog is the same merged view a session would serve. + if (this.engineV2) { + try { + const skills = await this.harness.listWorkspaceSkills(this.state.appState.workDir); + this.applySkillCommands(skills); + return; + } catch { + return; + } + } this.skillCommands = []; this.skillCommandMap.clear(); this.setupAutocomplete(); @@ -498,6 +520,10 @@ export class KimiTUI { } catch { return; } + this.applySkillCommands(skills); + } + + private applySkillCommands(skills: readonly SkillSummary[]): void { const skillCommands = buildSkillSlashCommands(skills); this.skillCommands = skillCommands.commands; this.skillCommandMap.clear(); @@ -509,6 +535,17 @@ export class KimiTUI { async refreshPluginCommands(session?: Session): Promise { if (session === undefined) { + // v2 engine: the enabled plugin commands are an app-global live view, + // available before the first (lazy) session is created. + if (this.engineV2) { + try { + const defs = await this.harness.listPluginCommands(); + this.applyPluginCommands(defs); + return; + } catch { + return; + } + } this.pluginCommands = []; this.pluginCommandMap.clear(); this.setupAutocomplete(); @@ -521,6 +558,10 @@ export class KimiTUI { } catch { return; } + this.applyPluginCommands(defs); + } + + private applyPluginCommands(defs: readonly PluginCommandDef[]): void { const pluginSlashCommands = buildPluginSlashCommands(defs); this.pluginCommands = pluginSlashCommands.commands; this.pluginCommandMap.clear(); @@ -535,6 +576,7 @@ export class KimiTUI { // ========================================================================= async start(): Promise { + startupTrace('tui:start'); // Signal handlers must be installed before raw mode to avoid EIO loops. this.registerSignalHandlers(); // Outer try rolls back signal listeners on startup failure. @@ -562,16 +604,25 @@ export class KimiTUI { return; } + startupTrace('trustPrompt:begin'); const trustPromptStartedLoop = await this.maybeRunWorkspaceTrustPrompt(); + startupTrace('trustPrompt:end'); + startupTrace('initMainTui:begin'); const shouldReplayHistory = await this.initMainTui(); + startupTrace('initMainTui:end'); + // Debug-only input→render latency overlay (KIMI_TUI_INPUT_LATENCY=1). + if (process.env['KIMI_TUI_INPUT_LATENCY']) installInputLatencyProbe(this.state.ui); // When the trust prompt already started the event loop, starting it // again would re-run pi-tui's terminal.start() — stacking a second // Kitty keyboard-protocol push (leaking CSI-u mode past exit) and // duplicate stdin listeners. if (!trustPromptStartedLoop) this.startEventLoop(); + startupTrace('eventLoop:started'); try { this.startBackgroundFdAutocomplete(); + startupTrace('finishStartup:begin'); await this.finishStartup(shouldReplayHistory); + startupTrace('finishStartup:end'); } catch (error) { this.disposeTerminalTracking(); this.state.ui.stop(); @@ -703,6 +754,10 @@ export class KimiTUI { this.startupNotice = undefined; } void this.showTmuxKeyboardWarningIfNeeded(); + // Config diagnostics (deprecated keys/env vars, invalid sections) in + // warning yellow at boot; `run-prompt`/`run-v2-print` print them to + // stderr for non-interactive runs. + void this.showConfigWarningsIfAny(); if (this.state.startupState === 'picker') { void this.bootstrapFromPicker(); return; @@ -823,6 +878,14 @@ export class KimiTUI { ); } } + } else if (this.engineV2) { + // Lazy session creation (v2 engine): start session-less and create the + // session on the first message. Startup flags are carried in appState + // and applied when that session is created; until then the footer + // shows the config defaults the engine would apply at createSession + // time (model, permission, plan mode, thinking effort, context cap). + await this.hydrateLazyConfigDefaults(); + this.appendStartupNotice(SESSIONLESS_STARTUP_NOTICE); } else { session = await this.harness.createSession(createSessionOptions); } @@ -838,11 +901,13 @@ export class KimiTUI { return false; } - if (session === undefined) { + if (!this.engineV2 && session === undefined) { throw new Error('Startup session was not initialized.'); } - await this.setSession(session); - await this.syncRuntimeState(session); + if (session !== undefined) { + await this.setSession(session); + await this.syncRuntimeState(session); + } this.applyStartupPermissionAndPlanToAppState(); this.state.startupState = 'ready'; return shouldReplayHistory; @@ -1040,17 +1105,31 @@ export class KimiTUI { this.state.ui.requestRender(); return; } - this.runShellCommandFromInput(text); + void this.runShellCommandFromInput(text); return; } slashCommands.dispatchInput(this, text); } - private runShellCommandFromInput(command: string): void { - const session = this.session; + private async runShellCommandFromInput(command: string): Promise { + let session = this.session; if (session === undefined) { - this.showError('No active session for shell command.'); - return; + if (!this.engineV2) { + this.showError('No active session for shell command.'); + return; + } + session = await this.ensureSession(); + if (session === undefined) return; + // A concurrent first message may have started a prompt while this lazy + // creation was in flight (both inputs share the same creation promise); + // honor the busy gate here, like handleUserInput does before the await, + // instead of running the shell command concurrently with an agent turn. + if (this.state.appState.streamingPhase !== 'idle') { + this.enqueueMessage(command, undefined, 'bash'); + this.updateQueueDisplay(); + this.state.ui.requestRender(); + return; + } } // Echo the command locally (bash-input) with a `$` prompt. The agent also // records it for resume; this is the live view. @@ -1154,14 +1233,14 @@ export class KimiTUI { const session = this.session; if (session === undefined) return; if (item.mode === 'bash') { - this.runShellCommandFromInput(item.text); + void this.runShellCommandFromInput(item.text); } else { this.sendQueuedMessage(session, item); } this.updateQueueDisplay(); } - sendNormalUserInput(text: string): void { + async sendNormalUserInput(text: string): Promise { if (this.btwPanelController.sendUserInput(text)) return; if (this.state.appState.model.trim().length === 0) { this.showError(LLM_NOT_SET_MESSAGE); @@ -1180,10 +1259,14 @@ export class KimiTUI { return; } if (!this.validateMediaCapabilities(extraction)) return; - const session = this.session; + let session = this.session; if (session === undefined) { - this.showError(LLM_NOT_SET_MESSAGE); - return; + if (!this.engineV2) { + this.showError(LLM_NOT_SET_MESSAGE); + return; + } + session = await this.ensureSession(); + if (session === undefined) return; } if (extraction.hasMedia) { this.sendMessage(session, text, { @@ -1309,7 +1392,7 @@ export class KimiTUI { sendQueuedMessage(session: Session, item: QueuedMessage): void { if (item.mode === 'bash') { - this.runShellCommandFromInput(item.text); + void this.runShellCommandFromInput(item.text); return; } this.harness.withInteractiveAgent(item.agentId ?? MAIN_AGENT_ID, () => { @@ -1571,24 +1654,181 @@ export class KimiTUI { return this.session; } - private async createSessionFromCurrentState(): Promise { + /** + * Seed appState with the config defaults the v2 engine would apply at + * createSession time (model, permission, plan mode, thinking effort, + * context cap), so the footer and the lazy create path reflect them while + * no session exists. Runs at session-less startup and again on /reload + * while still session-less, so externally edited defaults take effect + * before the first lazy-created session. + */ + async hydrateLazyConfigDefaults(): Promise { + const { startup } = this.options; + const config = await this.harness.getConfig({ reload: true }); + const patch: Partial = {}; + const startupModel = startup.model ?? config.defaultModel; + if (startupModel !== undefined) { + patch.model = startupModel; + const selected = config.models?.[startupModel]; + if (selected?.maxContextSize !== undefined) { + patch.maxContextTokens = selected.maxContextSize; + } + } else { + // The default disappeared from config (edited externally): clear the + // previously hydrated value instead of passing a stale explicit model + // to the first lazy-created session. + patch.model = ''; + patch.maxContextTokens = 0; + } + // CLI --auto/--yolo/--plan win over config defaults; the flags are + // re-applied by applyStartupPermissionAndPlanToAppState at startup. + if (!startup.auto && !startup.yolo) { + // Reset to manual when the default was removed from config — a stale + // elevated mode must not be passed to the first lazy-created session. + patch.permissionMode = config.defaultPermissionMode ?? 'manual'; + } + // Track the config default itself (vs an explicit CLI --plan) so the lazy + // create path can tell which one would activate plan mode; a removed + // default also clears the hydrated footer value. + patch.configDefaultPlanMode = config.defaultPlanMode === true; + if (!startup.plan) { + patch.planMode = config.defaultPlanMode === true; + } + const effort = thinkingEffortFromConfig(config.thinking); + if (effort !== undefined) { + patch.thinkingEffort = effort; + } else if (startupModel !== undefined) { + // No concrete effort configured: mirror the engine, which resolves the + // model's default effort at createSession time. + const raw = config.models?.[startupModel]; + if (raw !== undefined) { + const providerType = config.providers?.[raw.provider]?.type; + patch.thinkingEffort = defaultThinkingEffortFor( + effectiveModelAlias(raw, providerType ?? raw.protocol), + ); + } + } + if (startup.agentProfile !== undefined || startup.agentFiles !== undefined) { + patch.agentProfile = startup.agentProfile; + patch.agentFiles = startup.agentFiles?.length ? [...startup.agentFiles] : undefined; + } + this.setAppState(patch); + } + + private async createSessionFromCurrentState(bindStartupAgent = false): Promise { const model = this.state.appState.model.trim(); if (model.length === 0) { throw new Error(LLM_NOT_SET_MESSAGE); } + // With an active session, carry the live plan state. Session-less (lazy + // creation / `/new` before the first session) on v2, pass only the + // explicit CLI --plan intent — and only when the engine is not already + // applying `defaultPlanMode` at create time (sessionLifecycleService), + // since re-entering an active plan mode throws. On v1 (which never + // pre-fills plan mode from config), keep the historical appState value. + const explicitPlanMode = + this.session !== undefined || !this.engineV2 + ? this.state.appState.planMode + : this.options.startup.plan && this.state.appState.configDefaultPlanMode !== true; const options: MutableCreateSessionOptions = { workDir: this.state.appState.workDir, model, - thinking: this.session === undefined ? undefined : this.state.appState.thinkingEffort, + // With an active session, carry the live effort. Session-less (lazy + // creation / `/new` before the first session), carry the session-only + // thinking override chosen via Alt+S if any — never the initial 'off' + // default, which would force thinking off where the engine's config or + // model default would apply. + thinking: + this.session === undefined + ? this.state.appState.lazySessionThinking + : this.state.appState.thinkingEffort, permission: this.state.appState.permissionMode, - planMode: this.state.appState.planMode ? true : undefined, + planMode: explicitPlanMode ? true : undefined, }; if (this.state.appState.additionalDirs.length > 0) { options.additionalDirs = [...this.state.appState.additionalDirs]; } + if (bindStartupAgent) { + // The --agent/--agent-file startup binding is consumed by the first + // lazy-created session; `/new` sessions fall back to the default profile. + if (this.state.appState.agentProfile !== undefined) { + options.agentProfile = this.state.appState.agentProfile; + } + if (this.state.appState.agentFiles !== undefined) { + options.agentFiles = [...this.state.appState.agentFiles]; + } + } return this.harness.createSession(options); } + /** + * Lazy-create the session on first use (v2 engine, session-less startup). + * Returns the existing session, or creates one from the current state and + * runs the same assembly `createNewSession` performs. Returns undefined and + * shows the error when creation fails; callers must still guard on + * `appState.model`. + * + * Concurrent first-use triggers (a double Enter, or a slash command right + * after a prompt) both observe `session === undefined`, so the first caller + * owns the creation and the rest share the in-flight promise — otherwise + * two sessions would be created and the later `setSession` would close the + * first one mid-dispatch. + */ + async ensureSession(): Promise { + // Even when a session is already assigned, a previous lazy creation may + // still be finishing its assembly (runtime sync, command refresh, + // subscription). Wait for it so callers never dispatch against a + // partially initialized session. + if (this.ensureSessionPromise !== null) return this.ensureSessionPromise; + if (this.session !== undefined) return this.session; + this.ensureSessionPromise = this.lazyCreateSession().finally(() => { + this.ensureSessionPromise = null; + }); + return this.ensureSessionPromise; + } + + /** Await the in-flight lazy session creation, if any (v2); no-op otherwise. */ + async waitForLazyCreation(): Promise { + await this.ensureSessionPromise; + } + + private async lazyCreateSession(): Promise { + let session: Session; + try { + session = await this.createSessionFromCurrentState(true); + } catch (error) { + const msg = formatErrorMessage(error); + this.showError(`Failed to start a session: ${msg}`); + return undefined; + } + this.resetSessionRuntime(); + await this.setSession(session); + this.setAppState({ sessionId: session.id }); + try { + await this.activateRuntime(); + await this.syncRuntimeState(session); + } catch (error) { + this.sessionEventHandler.startSubscription(); + const msg = formatErrorMessage(error); + this.showError(`Post-create setup failed: ${msg}`); + return undefined; + } + try { + await this.refreshSkillCommands(session); + await this.refreshPluginCommands(session); + } catch { + /* keep the new session usable even if dynamic skills fail */ + } + this.sessionEventHandler.startSubscription(); + void this.showSessionWarnings(session); + // The session-only thinking override was consumed by this session; the + // runtime status now owns the displayed effort. + if (this.state.appState.lazySessionThinking !== undefined) { + this.setAppState({ lazySessionThinking: undefined }); + } + return session; + } + async setSession(session: Session): Promise { const previous = this.unloadCurrentSession('switching session'); await previous?.close(); @@ -1755,6 +1995,10 @@ export class KimiTUI { } private async resumeSession(targetSessionId: string): Promise { + // A first-use lazy creation may still be in flight: wait it out so the + // checks below see settled state — the pending prompt would otherwise + // replace the resumed session when creation completes. + await this.waitForLazyCreation(); if (targetSessionId === this.state.appState.sessionId) { this.showStatus('Already on this session.'); return true; diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 29cc57bff5..8ff0041a04 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -31,6 +31,11 @@ export interface AppState { sessionId: string; permissionMode: PermissionMode; planMode: boolean; + /** Resolved profile name from --agent/--agent-file, carried to the + * lazy-created first session when the TUI starts session-less. */ + agentProfile?: string; + /** Raw --agent-file paths, passed to session creation alongside `agentProfile`. */ + agentFiles?: readonly string[]; /** 'bash' when the editor is in `!` shell-command mode. */ inputMode: 'prompt' | 'bash'; swarmMode: boolean; @@ -38,6 +43,20 @@ export interface AppState { * mirrors the runtime. The single source of truth for the thinking state in * the TUI. */ thinkingEffort: ThinkingEffort; + /** + * The current `defaultPlanMode` value from config (false when absent), + * refreshed by `hydrateLazyConfigDefaults`. Used to tell a config-driven + * plan-mode entry apart from an explicit CLI `--plan` when lazy-creating + * the first session (the engine applies the config default itself). + */ + configDefaultPlanMode?: boolean; + /** + * Session-only thinking effort chosen (e.g. via the model picker's Alt+S) + * while no session exists yet on the v2 engine. Applied to the first + * lazy-created session and cleared once it exists; the engine's config + * default is used instead when unset. + */ + lazySessionThinking?: ThinkingEffort; contextUsage: number; contextTokens: number; maxContextTokens: number; diff --git a/apps/kimi-code/src/tui/utils/input-latency.ts b/apps/kimi-code/src/tui/utils/input-latency.ts new file mode 100644 index 0000000000..8ad69f718b --- /dev/null +++ b/apps/kimi-code/src/tui/utils/input-latency.ts @@ -0,0 +1,105 @@ +// src/tui/utils/input-latency.ts +// +// Debug-only input→render latency probe, enabled with KIMI_TUI_INPUT_LATENCY=1. +// Registers a pi-tui input listener (event timestamps) and mounts a +// non-capturing overlay in the top-right corner whose render() drains the +// queue: each pending input event is stamped against the frame that first +// renders after it, and the overlay shows the live stats (last / p50 / p95 / +// p99 / max, plus >100ms / >300ms / >1s counters and the five worst samples). +// Optional JSONL sink: KIMI_TUI_INPUT_LATENCY_LOG= appends one record +// per event for post-hoc analysis. +// +// The measured latency is "input event → start of the first frame rendered +// after it" — it includes input handling and the 16ms render throttle, and +// underestimates by the frame's own diff/write tail (sub-ms to a few ms), +// which is the right granularity for diagnosing >100ms stalls. + +import { appendFileSync, mkdirSync } from 'node:fs'; +import path from 'node:path'; +import type { Component, TUI } from '@moonshot-ai/pi-tui'; + +/** Rolling sample cap for the percentile window. */ +const MAX_SAMPLES = 500; + +export interface LatencySample { + latency: number; + at: string; +} + +/** The pure stats core (exported for tests): feed it input→render latencies + * and it keeps the rolling window, counters, and the five worst samples. */ +export class LatencyStats { + last = 0; + events = 0; + over100 = 0; + over300 = 0; + over1000 = 0; + readonly worst: LatencySample[] = []; + private readonly samples: number[] = []; + + record(latency: number, at: string): void { + this.last = latency; + this.events++; + if (latency > 100) this.over100++; + if (latency > 300) this.over300++; + if (latency > 1000) this.over1000++; + this.samples.push(latency); + if (this.samples.length > MAX_SAMPLES) this.samples.shift(); + const smallestKept = this.worst[this.worst.length - 1]?.latency ?? -1; + if (this.worst.length < 5 || latency >= smallestKept) { + this.worst.push({ latency, at }); + this.worst.sort((a, b) => b.latency - a.latency); + if (this.worst.length > 5) this.worst.length = 5; + } + } + + percentile(p: number): number { + if (this.samples.length === 0) return 0; + const sorted = [...this.samples].sort((a, b) => a - b); + return sorted[Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1)]!; + } + + max(): number { + return this.samples.length === 0 ? 0 : Math.max(...this.samples); + } + + formatLines(): string[] { + if (this.events === 0) return [' input→render: (type something) ']; + const head = + ` io ${this.last.toFixed(0)}ms | p50 ${this.percentile(50).toFixed(0)} p95 ${this.percentile(95).toFixed(0)}` + + ` p99 ${this.percentile(99).toFixed(0)} max ${this.max().toFixed(0)}ms | n=${this.events}` + + ` >100:${this.over100} >300:${this.over300} >1s:${this.over1000} `; + const worstLine = ` worst: ${this.worst.map((w) => `${w.latency.toFixed(0)}ms@${w.at}`).join(' ')} `; + return [head, worstLine]; + } +} + +/** Install the probe on a running TUI (call only when the env flag is set). */ +export function installInputLatencyProbe(tui: TUI): void { + const stats = new LatencyStats(); + const pending: number[] = []; + const logPath = process.env['KIMI_TUI_INPUT_LATENCY_LOG']; + if (logPath) mkdirSync(path.dirname(logPath), { recursive: true }); + + tui.addInputListener(() => { + pending.push(performance.now()); + return undefined; + }); + + const overlay: Component = { + invalidate: () => {}, + render: () => { + if (pending.length > 0) { + const now = performance.now(); + const at = new Date().toISOString().slice(11, 23); + for (const t of pending.splice(0)) { + const latency = now - t; + stats.record(latency, at); + if (logPath) appendFileSync(logPath, `${JSON.stringify({ t: new Date().toISOString(), latencyMs: Math.round(latency) })}\n`); + } + } + return stats.formatLines(); + }, + }; + tui.showOverlay(overlay, { nonCapturing: true, anchor: 'top-right', margin: 0 }); +} diff --git a/apps/kimi-code/src/tui/utils/plugin-source-label.ts b/apps/kimi-code/src/tui/utils/plugin-source-label.ts index 5a902db7de..ee32c94260 100644 --- a/apps/kimi-code/src/tui/utils/plugin-source-label.ts +++ b/apps/kimi-code/src/tui/utils/plugin-source-label.ts @@ -35,13 +35,14 @@ export function pluginTrustLabel(plugin: PluginSummary): PluginTrustLabel { } try { const url = new URL(plugin.originalSource); - if (url.protocol !== 'https:' || url.hostname !== 'code.kimi.com') { - return 'third-party'; - } - if (url.pathname.startsWith('/kimi-code/plugins/official/')) { + if (isOfficialPluginUrl(url)) { return 'official'; } - if (url.pathname.startsWith('/kimi-code/plugins/curated/')) { + if ( + url.protocol === 'https:' && + url.hostname === 'code.kimi.com' && + url.pathname.startsWith('/kimi-code/plugins/curated/') + ) { return 'curated'; } return 'third-party'; @@ -60,11 +61,7 @@ export function isOfficialPluginSource(source: string): boolean { const trimmed = source.trim(); if (!trimmed.startsWith('https://')) return false; try { - const url = new URL(trimmed); - return ( - url.hostname === 'code.kimi.com' && - url.pathname.startsWith('/kimi-code/plugins/official/') - ); + return isOfficialPluginUrl(new URL(trimmed)); } catch { return false; } @@ -84,6 +81,16 @@ export function isOfficialPluginInstall(plugin: PluginSummary): boolean { ); } +function isOfficialPluginUrl(url: URL): boolean { + if (url.protocol !== 'https:') return false; + return ( + (url.hostname === 'code.kimi.com' && + url.pathname.startsWith('/kimi-code/plugins/official/')) || + (url.hostname === 'cdn.kimi.com' && + url.pathname.startsWith('/kimi-computer-use/')) + ); +} + function hostFromUrl(raw: string): string | undefined { try { const url = new URL(raw); diff --git a/apps/kimi-code/src/utils/plugin-marketplace.ts b/apps/kimi-code/src/utils/plugin-marketplace.ts index be55e798e9..4ab1d7bd04 100644 --- a/apps/kimi-code/src/utils/plugin-marketplace.ts +++ b/apps/kimi-code/src/utils/plugin-marketplace.ts @@ -23,6 +23,12 @@ export interface PluginMarketplaceEntry { readonly description?: string; readonly homepage?: string; readonly keywords?: readonly string[]; + /** + * Internal provenance flag for client-injected built-in rows. The catalog + * parser builds entries field-by-field and never sets it, so a custom + * catalog cannot forge it (unlike the `capability:` source string). + */ + readonly builtIn?: boolean; } export interface PluginMarketplace { @@ -71,6 +77,12 @@ export interface LoadPluginMarketplaceOptions { readonly workDir: string; readonly source?: string; readonly fetchImpl?: typeof fetch; + /** + * Built-in capability rows to inject, supplied by the caller from the + * engine's capability registry (this util owns no product knowledge). + * Undefined means no injection. + */ + readonly builtInEntries?: readonly PluginMarketplaceEntry[]; } export async function loadPluginMarketplace( @@ -88,11 +100,42 @@ export async function loadPluginMarketplace( } catch (error) { const fallback = configuredSource === undefined ? await getSourceCheckoutMarketplaceLocation() : undefined; - if (fallback === undefined) throw error; + if (fallback === undefined) { + if (options.builtInEntries !== undefined) { + // The built-in entries do not come from the catalog — keep them + // visible when the catalog itself is unreachable. + return withBuiltInEntries({ source: location.resolved, plugins: [] }, options.builtInEntries); + } + throw error; + } raw = await readMarketplaceText(fallback, fetchImpl); - return withLatestVersions(parsePluginMarketplace(raw, fallback), fetchImpl); + const marketplace = await withLatestVersions(parsePluginMarketplace(raw, fallback), fetchImpl); + return options.builtInEntries !== undefined + ? withBuiltInEntries(marketplace, options.builtInEntries) + : marketplace; } - return withLatestVersions(parsePluginMarketplace(raw, location), fetchImpl); + const marketplace = await withLatestVersions(parsePluginMarketplace(raw, location), fetchImpl); + return options.builtInEntries !== undefined + ? withBuiltInEntries(marketplace, options.builtInEntries) + : marketplace; +} + +/** + * Built-in capability entries (kimi-cu, kimi-webbridge) are injected by the + * client instead of being served by the marketplace catalog, so their + * visibility is bound to the client version — older clients never see them. + * Same-id catalog rows are MASKED, not merged: what these ids mean stays + * decided by the client release, and a future official marketplace listing + * only reaches older clients (whose fix is to upgrade). No `version` is + * pinned: reinstalling uses the latest managed artifacts. + */ +function withBuiltInEntries( + marketplace: PluginMarketplace, + builtIns: readonly PluginMarketplaceEntry[], +): PluginMarketplace { + const builtInIds = new Set(builtIns.map((entry) => entry.id)); + const catalog = marketplace.plugins.filter((entry) => !builtInIds.has(entry.id)); + return { ...marketplace, plugins: [...catalog, ...builtIns] }; } async function withLatestVersions( diff --git a/apps/kimi-code/src/utils/startup-trace.ts b/apps/kimi-code/src/utils/startup-trace.ts new file mode 100644 index 0000000000..65ac7eb441 --- /dev/null +++ b/apps/kimi-code/src/utils/startup-trace.ts @@ -0,0 +1,34 @@ +// src/utils/startup-trace.ts +// +// Debug-only startup phase tracer, enabled with KIMI_STARTUP_TRACE=1. +// Each call appends one `