SaylorTwift HF Staff commited on
Commit
6a2bc3b
·
verified ·
1 Parent(s): 9bd7455

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .github/pr-title-checker-config.json +12 -0
  2. .github/pull_request_template.md +29 -0
  3. apps/kimi-code/.gitignore +11 -0
  4. apps/kimi-code/AGENTS.md +72 -0
  5. apps/kimi-code/CHANGELOG.md +0 -0
  6. apps/kimi-code/README.md +90 -0
  7. apps/kimi-code/package.json +120 -0
  8. apps/kimi-code/tsconfig.dev.json +13 -0
  9. apps/kimi-code/tsconfig.json +11 -0
  10. apps/kimi-code/tsdown.config.ts +40 -0
  11. apps/kimi-code/tsdown.dist-worker.config.ts +41 -0
  12. apps/kimi-code/tsdown.native.config.ts +69 -0
  13. apps/kimi-code/tsdown.worker.config.ts +50 -0
  14. apps/kimi-code/vitest.config.ts +20 -0
  15. apps/kimi-inspect/AGENTS.md +44 -0
  16. apps/kimi-inspect/README.md +57 -0
  17. apps/kimi-inspect/index.html +12 -0
  18. apps/kimi-inspect/package.json +41 -0
  19. apps/kimi-inspect/tsconfig.json +22 -0
  20. apps/kimi-inspect/vite.config.ts +37 -0
  21. apps/vis/package.json +17 -0
  22. apps/vscode/.vscode-test.mjs +5 -0
  23. apps/vscode/.vscodeignore +50 -0
  24. apps/vscode/CHANGELOG.md +203 -0
  25. apps/vscode/LICENSE +202 -0
  26. apps/vscode/README.md +43 -0
  27. apps/vscode/docs/node-sdk-migration.md +396 -0
  28. apps/vscode/package.json +321 -0
  29. apps/vscode/resources/kimi-icon-storefront.png +0 -0
  30. apps/vscode/resources/kimi-icon.svg +33 -0
  31. apps/vscode/scripts/extension-host-smoke.d.mts +16 -0
  32. apps/vscode/scripts/local-cli.mjs +47 -0
  33. apps/vscode/scripts/ovsx-publish.mjs +51 -0
  34. apps/vscode/scripts/prepare-dev.mjs +83 -0
  35. apps/vscode/scripts/publish-args.mjs +54 -0
  36. apps/vscode/scripts/vsix-package.mjs +141 -0
  37. apps/vscode/scripts/vsix-publish.mjs +44 -0
  38. apps/vscode/scripts/vsix-targets.mjs +45 -0
  39. apps/vscode/scripts/vsix-verify.mjs +490 -0
  40. apps/vscode/scripts/watch-extension.mjs +96 -0
  41. apps/vscode/scripts/zip.mjs +122 -0
  42. apps/vscode/shared/bridge.ts +325 -0
  43. apps/vscode/shared/errors.ts +106 -0
  44. apps/vscode/shared/fork-turn-index.ts +43 -0
  45. apps/vscode/shared/legacy-sdk.ts +233 -0
  46. apps/vscode/shared/types.ts +73 -0
  47. apps/vscode/shared/utils.ts +3 -0
  48. apps/vscode/test/app-init.test.ts +86 -0
  49. apps/vscode/test/baseline.manager.test.ts +412 -0
  50. apps/vscode/test/bridge-handler.test.ts +695 -0
.github/pr-title-checker-config.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "LABEL": {
3
+ "name": "Invalid PR Title",
4
+ "color": "B60205"
5
+ },
6
+ "CHECKS": {
7
+ "regexp": "^(feat|fix|test|refactor|chore|style|docs|perf|build|ci|revert)(\\(.*\\))?:.*"
8
+ },
9
+ "MESSAGES": {
10
+ "failure": "The PR title is invalid. Please refer to https://www.conventionalcommits.org/en/v1.0.0/ for the convention."
11
+ }
12
+ }
.github/pull_request_template.md ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!--
2
+ Thank you for your contribution to Kimi Code!
3
+ External PRs are accepted for approved bug fixes only: link an issue that a maintainer has approved (an `/approve` comment). External feature PRs are not accepted.
4
+ 外部 PR 仅接受获批准的 bug 修复:请链接维护者已批准(`/approve` 评论)的 issue;不接受外部 feature PR。
5
+
6
+ See https://github.com/MoonshotAI/kimi-code/blob/main/CONTRIBUTING.md for more.
7
+ -->
8
+
9
+ ## Related Issue
10
+
11
+ <!-- Link the issue this change came from. External PRs must link an issue approved by a maintainer (an `/approve` comment) — PRs without one may be closed. -->
12
+
13
+ Resolve #(issue_number)
14
+
15
+ ## Problem
16
+
17
+ <!-- What user need or limitation does this address? If the linked issue already covers this, write "See linked issue". -->
18
+
19
+ ## What changed
20
+
21
+ <!-- What did you implement, and why does this approach fit Kimi Code? -->
22
+
23
+ ## Checklist
24
+
25
+ - [ ] I have read the [CONTRIBUTING](https://github.com/MoonshotAI/kimi-code/blob/main/CONTRIBUTING.md) document.
26
+ - [ ] I have linked a related issue (external PRs: the issue must have a maintainer's `/approve`).
27
+ - [ ] I have added tests that prove my feature works.
28
+ - [ ] Ran `gen-changesets` skill, or this PR needs no changeset.
29
+ - [ ] Ran `gen-docs` skill, or this PR needs no doc update.
apps/kimi-code/.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copied from packages/kimi-core at build time
2
+ agents/
3
+
4
+ # Generated at build time by scripts/build-vis-asset.mjs.
5
+ # Only the ~150KB base64 VALUE file is ignored; the committed `.d.ts` stub
6
+ # next to it keeps `#/generated/vis-web-asset` type-resolvable on a fresh
7
+ # clone (before any build has produced the `.ts`).
8
+ src/generated/vis-web-asset.ts
9
+
10
+ # Copied from packages/pi-tui/native at build time by scripts/copy-native-assets.mjs
11
+ /native/
apps/kimi-code/AGENTS.md ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # apps/kimi-code Development Guide
2
+
3
+ This file only contains rules local to `apps/kimi-code`. For cross-repo rules, see the root `AGENTS.md`.
4
+
5
+ > **Writing or modifying the TUI?** Use the `write-tui` skill (`.agents/skills/write-tui/SKILL.md`). It covers the architecture orientation, where new features go, test placement, theme mechanics, and the dialog interaction/visual spec (`DESIGN.md`). This file keeps only the map, boundaries, and hard constraints.
6
+
7
+ ## TUI File Layout
8
+
9
+ `apps/kimi-code` is the terminal UI / CLI app. The entry chain is:
10
+
11
+ `src/main.ts` -> `src/cli/commands.ts` -> `src/cli/run-shell.ts` -> SDK `KimiHarness` -> `src/tui/kimi-tui.ts`
12
+
13
+ Main directories:
14
+
15
+ - `src/constant/`: non-copy constants shared by CLI/TUI — product, protocol, paths, terminal control, updates, and so on.
16
+ - `src/cli/`: command-line arguments, subcommands, and CLI startup.
17
+ - `src/tui/`: the interactive terminal UI.
18
+ - `src/tui/kimi-tui.ts`: the `KimiTUI` coordinator — wires state, layout, editor, session, SDK events, and dialogs together, and dispatches slash-command handlers. Heavy logic is delegated to `controllers/`, not accumulated here.
19
+ - `src/tui/tui-state.ts`: `TUIState`, `createTUIState`, `createInitialAppState` — the single global UI-state shape.
20
+ - `src/tui/controllers/`: independently-testable responsibilities — `session-event-handler` (SDK event routing), `streaming-ui` (streaming render), `session-replay` (resume/replay), `tasks-browser`, `editor-keyboard`, `auth-flow`.
21
+ - `src/tui/commands/`: slash command definitions, parsing, ordering, and dynamic skill command generation.
22
+ - `src/tui/components/`: pi-tui components, organized by UI type.
23
+ - `src/tui/constant/`: non-copy constants reused across TUI modules — symbols, terminal sequences, render sizing, streaming-arg match rules, and so on.
24
+ - `src/tui/components/chrome/`: persistent UI chrome — footer, todo panel, welcome, loader, device code.
25
+ - `src/tui/components/dialogs/`: selectors, approval panels, question popups, and settings popups that temporarily replace the editor.
26
+ - `src/tui/components/editor/`: the custom input box and the file mention provider.
27
+ - `src/tui/components/media/`: image, diff, code highlight, and other media displays.
28
+ - `src/tui/components/messages/`: message blocks in the transcript — assistant, user, tool call, thinking, usage, subagent, and so on.
29
+ - `src/tui/components/panes/`: right-side / activity-area panes such as the activity pane and queue pane.
30
+ - `src/tui/reverse-rpc/`: the adapter layer that bridges SDK approval/question callbacks to the UI.
31
+ - `src/tui/theme/`: themes, color tokens, style helpers, terminal-background detection, and the pi-tui markdown theme.
32
+ - `src/tui/utils/`: TUI-only utility functions.
33
+ - `src/utils/`: app-wide utilities — clipboard, git, history, image, process, usage, and so on.
34
+
35
+ ## Module Responsibilities
36
+
37
+ - `cli` only interprets command-line input, assembles startup arguments, and invokes the TUI. Do not put TUI interaction logic into the CLI.
38
+ - `KimiTUI` coordinates; it does not accumulate complex business rules. New logic that can be tested independently should be split into `controllers`, `commands`, `components`, `reverse-rpc`, or `utils` first.
39
+ - `controllers` own the heavy, independently-testable slices (event routing, streaming render, session replay, tasks browser, editor keyboard, auth). Event-routing and rendering logic belong here, not on the `KimiTUI` class.
40
+ - `commands` only owns slash-command declaration, parsing, and the parsed-result types. The actual execution can be dispatched from `KimiTUI`, but complex logic should continue to sink downward.
41
+ - `components` only handle presentation and local interaction; they must not call the SDK directly, and must not read or write session state directly.
42
+ - `reverse-rpc` converts SDK approval/question requests into the data shape a UI panel/dialog needs, and converts the user's choice back into an SDK response.
43
+ - `theme` is the single source of truth for colors and styles. Components must not bypass the theme system and use chalk named colors directly.
44
+ - `utils` holds utility functions with no UI-state dependency. Logic that needs `TUIState` or a component instance must not live under app-level `src/utils`.
45
+ - `apps/kimi-code` may only use core capabilities through `@moonshot-ai/kimi-code-sdk`. Do not import engine packages directly in app code.
46
+
47
+ ## TUI Coding Conventions
48
+
49
+ - Do not over-encapsulate, especially for one- or two-line functions — do not introduce a two-layer wrapper, just inline.
50
+ - Functions with no state / UI side effects do not belong as private methods on the `KimiTUI` class; put them in external utils.
51
+ - Constants must live in the corresponding `constant` directory; they must not be scattered through component or logic code.
52
+ - Inside `handleInput(data)`, when comparing a printable character (letter, digit, space, punctuation), it is **forbidden** to write literal comparisons such as `data === 'q'`. With the Kitty keyboard protocol enabled in terminals like VSCode, these keys are sent as CSI-u sequences (e.g. `\x1b[113u`), and a bare comparison will never match. Decode with `printableChar(data)` from `src/tui/utils/printable-key.ts` first, then compare; function keys continue to use `matchesKey(data, Key.*)`; control characters (codepoint < 32) may still be compared against the raw `data`. `test/tui/printable-key-guard.test.ts` enforces this in CI.
53
+
54
+ ## Color Rules (normative)
55
+
56
+ The theme apply/switch mechanics live in the `write-tui` skill. The following rules are hard and guard-enforced:
57
+
58
+ - Do not use chalk named colors such as `chalk.red`, `chalk.cyan`, `chalk.white`, `chalk.gray`, `chalk.dim`, or `chalk.yellow` directly.
59
+ - If a component already has `colors`, use `chalk.hex(colors.<token>)(text)`.
60
+ - If a component already has `state.theme.styles` or styles passed in, prefer helpers such as `styles.error(text)`, `styles.dim(text)`.
61
+ - When new visual semantics have no token, first add a semantic field to `ColorPalette`, and fill in both `darkColors` and `lightColors`.
62
+ - In light themes, text tokens against a white background must be at least 4.5:1; borders and large chrome must be at least 3:1.
63
+ - Do not cache styled chalk functions at module top level. Theme switching must take effect within a single render, so styles must be generated on the render path from the current palette.
64
+ - Non-comment code must not contain chalk named colors such as `chalk.white`, `chalk.cyan`, `chalk.red`, `chalk.green`, `chalk.gray`, `chalk.yellow`, `chalk.blue`, `chalk.magenta`, `chalk.whiteBright`, or `chalk.blackBright`. `test/tui/chalk-named-color-guard.test.ts` enforces this in CI.
65
+
66
+ ## General Coding Requirements
67
+
68
+ - The startup path before the workspace trust gate (`KimiTUI.start()` -> `maybeRunWorkspaceTrustPrompt()`) must not spawn child processes by bare command name — on Windows, cmd.exe / CreateProcess resolve them from the current directory first, so a binary planted in an untrusted workspace would run before the user confirms trust. When an external command is unavoidable, resolve it with `resolveCommandPath` from `src/utils/process/resolve-command.ts`, which returns an absolute PATH hit and refuses matches inside the cwd.
69
+ - For optional object properties, pass `undefined` directly — do not use conditional spread.
70
+ - Optional object properties do not need to additionally allow `undefined` in the type.
71
+ - Internal methods with only a single parameter should not be turned into options objects just for stylistic uniformity.
72
+ - Except for a package's own `index.ts`, other `index.ts` files should prefer `export * from './module'`.
apps/kimi-code/CHANGELOG.md ADDED
The diff for this file is too large to render. See raw diff
 
apps/kimi-code/README.md ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # @moonshot-ai/kimi-code
2
+
3
+ > The Starting Point for Next-Gen Agents
4
+
5
+ [![npm](https://img.shields.io/npm/v/@moonshot-ai/kimi-code)](https://www.npmjs.com/package/@moonshot-ai/kimi-code) [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) [![Docs](https://img.shields.io/badge/docs-online-blue)](https://moonshotai.github.io/kimi-code/en/)
6
+
7
+ ## What is Kimi Code CLI
8
+
9
+ Kimi Code CLI is an AI coding agent that runs in your terminal. It can read and edit code, run shell commands, search files, fetch web pages, and choose the next step based on the feedback it receives. It works out of the box with Moonshot AI's Kimi models and can also be configured to use other compatible providers.
10
+
11
+ ## Install
12
+
13
+ The recommended install path is the official script. It does not require Node.js to be installed first.
14
+
15
+ - **macOS / Linux**:
16
+
17
+ ```sh
18
+ curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash
19
+ ```
20
+
21
+ - **Windows (PowerShell)**:
22
+
23
+ ```powershell
24
+ irm https://code.kimi.com/kimi-code/install.ps1 | iex
25
+ ```
26
+
27
+ > On Windows, install [Git for Windows](https://gitforwindows.org/) before first launch because Kimi Code CLI uses the bundled Git Bash as its shell environment. If Git Bash is installed in a custom location, set `KIMI_SHELL_PATH` to the absolute path of `bash.exe`.
28
+
29
+ Then run it with a new Terminal session:
30
+
31
+ ```sh
32
+ kimi --version
33
+ ```
34
+
35
+ ### Alternative: npm
36
+
37
+ If you prefer npm, use Node.js 22.19.0 or later:
38
+
39
+ ```sh
40
+ npm install -g @moonshot-ai/kimi-code
41
+ ```
42
+
43
+ Or with pnpm:
44
+
45
+ ```sh
46
+ pnpm add -g @moonshot-ai/kimi-code
47
+ ```
48
+
49
+ For upgrade and uninstall instructions, see the [Getting Started guide](https://moonshotai.github.io/kimi-code/en/guides/getting-started).
50
+
51
+ ## Quick Start
52
+
53
+ Open a project and start the interactive UI:
54
+
55
+ ```sh
56
+ cd your-project
57
+ kimi
58
+ ```
59
+
60
+ On first launch, run `/login` inside Kimi Code CLI and choose either Kimi Code OAuth or a Kimi Platform API key. After login, try a first task:
61
+
62
+ ```
63
+ Take a look at this project and explain the main directories.
64
+ ```
65
+
66
+ ## Key Features
67
+
68
+ - **Single-binary distribution.** Install with one command — no Node.js setup, no PATH gymnastics, no global module conflicts.
69
+ - **Blazing-fast startup.** The TUI is ready in milliseconds, so opening a session never feels heavy.
70
+ - **Polished TUI.** A carefully tuned interface designed for long, focused agent sessions.
71
+ - **Video input.** Drop a screen recording or demo clip into the chat — let the agent watch instead of typing out what's hard to describe in words.
72
+ - **AI-native MCP configuration.** Add, edit, and authenticate Model Context Protocol servers conversationally via `/mcp-config` — no hand-editing JSON.
73
+ - **Subagents for focused, parallel work.** Dispatch built-in `coder`, `explore`, and `plan` subagents in isolated context windows; the main conversation stays clean.
74
+ - **Lifecycle hooks.** Run local commands at key points — gate risky tool calls, audit decisions, fire desktop notifications, wire into your own automation.
75
+
76
+ ## Documentation
77
+
78
+ - Full docs: https://moonshotai.github.io/kimi-code/en/
79
+ - 中文文档: https://moonshotai.github.io/kimi-code/zh/
80
+ - Getting Started: https://moonshotai.github.io/kimi-code/en/guides/getting-started
81
+
82
+ ## Repository & Issues
83
+
84
+ - Source: https://github.com/MoonshotAI/kimi-code
85
+ - Issues: https://github.com/MoonshotAI/kimi-code/issues
86
+ - Security: see SECURITY.md in the main repository
87
+
88
+ ## License
89
+
90
+ MIT
apps/kimi-code/package.json ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "@moonshot-ai/kimi-code",
3
+ "version": "0.43.1",
4
+ "description": "The Starting Point for Next-Gen Agents",
5
+ "license": "MIT",
6
+ "author": "Moonshot AI",
7
+ "homepage": "https://github.com/MoonshotAI/kimi-code/tree/main/apps/kimi-code#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/MoonshotAI/kimi-code.git",
11
+ "directory": "apps/kimi-code"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/MoonshotAI/kimi-code/issues"
15
+ },
16
+ "keywords": [
17
+ "kimi",
18
+ "kimi-code",
19
+ "cli",
20
+ "agent",
21
+ "coding-agent",
22
+ "ai",
23
+ "tui"
24
+ ],
25
+ "bin": {
26
+ "kimi": "dist/main.mjs"
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "dist-web",
31
+ "native",
32
+ "scripts/postinstall.mjs",
33
+ "scripts/postinstall",
34
+ "README.md"
35
+ ],
36
+ "type": "module",
37
+ "imports": {
38
+ "#/tui/theme": "./src/tui/theme/index.ts",
39
+ "#/tui/commands": "./src/tui/commands/index.ts",
40
+ "#/cli/sub/web": "./src/cli/sub/web/index.ts",
41
+ "#/cli/sub/web/*": "./src/cli/sub/web/*.ts",
42
+ "#/generated/vis-web-asset": [
43
+ "./src/generated/vis-web-asset.ts",
44
+ "./src/generated/vis-web-asset.d.ts"
45
+ ],
46
+ "#/*": "./src/*.ts"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public",
50
+ "provenance": true
51
+ },
52
+ "scripts": {
53
+ "build": "tsdown && tsdown --config tsdown.dist-worker.config.ts && node scripts/copy-native-assets.mjs && node scripts/check-web-assets.mjs",
54
+ "prebuild": "node scripts/build-vis-asset.mjs",
55
+ "catalog:update": "node scripts/update-catalog.mjs --out dist/built-in-catalog.json",
56
+ "smoke": "node scripts/smoke.mjs",
57
+ "build:native:js": "node scripts/native/01-bundle.mjs",
58
+ "build:native:sea": "node scripts/native/build.mjs --profile=local",
59
+ "build:native:release": "node scripts/native/build.mjs --profile=release",
60
+ "package:native": "node scripts/native/package.mjs",
61
+ "produce:native:manifest": "node scripts/native/produce-manifest.mjs",
62
+ "release:native:resolve": "node scripts/native/resolve-release.mjs",
63
+ "test:native:smoke": "node scripts/native/smoke.mjs",
64
+ "dev": "node scripts/dev.mjs",
65
+ "dev:cli-only": "tsx --import ../../build/register-raw-text-loader.mjs ./src/main.ts",
66
+ "dev:server": "KIMI_CODE_DEV_SERVER=1 tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts web --no-open --debug-endpoints",
67
+ "dev:kap-server": "KIMI_CODE_DEV_SERVER=1 tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts web --no-open --debug-endpoints",
68
+ "dev:kap-server:multi": "KIMI_CODE_DEV_SERVER=1 tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts web --no-open --debug-endpoints",
69
+ "dev:server:restart": "node scripts/dev-server-restart.mjs",
70
+ "dev:plugin-marketplace": "node scripts/dev-plugin-marketplace-server.mjs",
71
+ "build:plugin-marketplace": "node scripts/build-plugin-marketplace-cdn.mjs",
72
+ "dev:prod": "node dist/main.mjs",
73
+ "clean": "rm -rf dist",
74
+ "typecheck": "tsc -p tsconfig.json --noEmit",
75
+ "test": "pnpm -w run build:packages && vitest run",
76
+ "e2e": "pnpm -w run build:packages && KIMI_E2E=1 vitest run test/e2e",
77
+ "e2e:real": "pnpm -w run build:packages && KIMI_E2E_REAL=1 vitest run test/e2e/real-llm-smoke.e2e.test.ts",
78
+ "postinstall": "node scripts/postinstall.mjs"
79
+ },
80
+ "optionalDependencies": {
81
+ "@mariozechner/clipboard": "^0.3.9",
82
+ "node-pty": "^1.1.0"
83
+ },
84
+ "devDependencies": {
85
+ "@moonshot-ai/acp-server": "workspace:^",
86
+ "@moonshot-ai/agent-core-v2": "workspace:^",
87
+ "@moonshot-ai/kap-server": "workspace:^",
88
+ "@moonshot-ai/kimi-code-oauth": "workspace:^",
89
+ "@moonshot-ai/kimi-code-sdk": "workspace:^",
90
+ "@moonshot-ai/kimi-telemetry": "workspace:^",
91
+ "@moonshot-ai/migration-legacy": "workspace:^",
92
+ "@moonshot-ai/minidb": "workspace:^",
93
+ "@moonshot-ai/pi-tui": "workspace:^",
94
+ "@moonshot-ai/remote-control": "workspace:^",
95
+ "@moonshot-ai/vis-server": "workspace:^",
96
+ "@moonshot-ai/vis-web": "workspace:*",
97
+ "@types/qrcode": "^1.5.6",
98
+ "@types/semver": "^7.7.0",
99
+ "@types/ws": "^8.18.0",
100
+ "@types/yazl": "^2.4.6",
101
+ "chalk": "^5.4.1",
102
+ "cli-highlight": "^2.1.11",
103
+ "commander": "^13.1.0",
104
+ "jimp": "^1.6.1",
105
+ "pathe": "^2.0.3",
106
+ "postject": "1.0.0-alpha.6",
107
+ "semver": "^7.7.4",
108
+ "smol-toml": "^1.6.1",
109
+ "tsx": "^4.21.0",
110
+ "yazl": "^3.3.1",
111
+ "zod": "^4.3.6"
112
+ },
113
+ "engines": {
114
+ "node": ">=22.19.0"
115
+ },
116
+ "dependencies": {
117
+ "qrcode": "^1.5.4",
118
+ "ws": "^8.18.0"
119
+ }
120
+ }
apps/kimi-code/tsconfig.dev.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "experimentalDecorators": true
5
+ },
6
+ "include": [
7
+ "src",
8
+ "test",
9
+ "../../packages/*/src/**/*.ts",
10
+ "../../packages/*/src/**/*.tsx",
11
+ "../../packages/*/test/**/*.ts"
12
+ ]
13
+ }
apps/kimi-code/tsconfig.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "allowJs": true,
5
+ "experimentalDecorators": true,
6
+ "paths": {
7
+ "@/*": ["./src/*"]
8
+ }
9
+ },
10
+ "include": ["src", "test", "../../packages/agent-core-v2/src/env.d.ts"]
11
+ }
apps/kimi-code/tsdown.config.ts ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { resolve } from 'node:path';
2
+
3
+ import { defineConfig } from 'tsdown';
4
+
5
+ import { rawTextPlugin } from '../../build/raw-text-plugin.mjs';
6
+ import { BUILT_IN_CATALOG_DEFINE, builtInCatalogDefine } from './scripts/built-in-catalog.mjs';
7
+
8
+ const appRoot = import.meta.dirname;
9
+
10
+ export default defineConfig({
11
+ entry: ['./src/main.ts'],
12
+ format: ['esm'],
13
+ outDir: 'dist',
14
+ clean: true,
15
+ dts: false,
16
+ hash: false,
17
+ banner: {
18
+ js: [
19
+ '#!/usr/bin/env node',
20
+ "import { fileURLToPath as __cjsShimFileURLToPath } from 'node:url';",
21
+ "import { dirname as __cjsShimDirname } from 'node:path';",
22
+ 'const __filename = __cjsShimFileURLToPath(import.meta.url);',
23
+ 'const __dirname = __cjsShimDirname(__filename);',
24
+ ].join('\n'),
25
+ },
26
+ plugins: [rawTextPlugin()],
27
+ alias: {
28
+ '@': resolve(appRoot, 'src'),
29
+ },
30
+ define: {
31
+ [BUILT_IN_CATALOG_DEFINE]: builtInCatalogDefine(),
32
+ },
33
+ deps: {
34
+ onlyBundle: false,
35
+ },
36
+ outputOptions: {
37
+ codeSplitting: false,
38
+ entryFileNames: 'main.mjs',
39
+ },
40
+ });
apps/kimi-code/tsdown.dist-worker.config.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Bundles the kap-server global-search worker
2
+ // (packages/kap-server/src/search/worker/entry.ts) into ONE self-contained
3
+ // `dist/search-worker.mjs` sibling of the main bundle. The search worker
4
+ // host resolves it at runtime next to `dist/main.mjs` (dev/tests use the TS
5
+ // source; the SEA binary uses the extracted asset from
6
+ // tsdown.worker.config.ts). Separate config because rolldown forbids
7
+ // `codeSplitting: false` with multiple inputs.
8
+
9
+ import { resolve } from 'node:path';
10
+
11
+ import { defineConfig } from 'tsdown';
12
+
13
+ const appRoot = import.meta.dirname;
14
+
15
+ export default defineConfig({
16
+ entry: {
17
+ 'search-worker': resolve(
18
+ appRoot,
19
+ '../../packages/kap-server/src/search/worker/entry.ts',
20
+ ),
21
+ },
22
+ format: ['esm'],
23
+ // Shares the main bundle's dist (never wipe it) and lands as
24
+ // `dist/search-worker.mjs`.
25
+ outDir: 'dist',
26
+ clean: false,
27
+ dts: false,
28
+ hash: false,
29
+ platform: 'node',
30
+ target: 'node24',
31
+ sourcemap: false,
32
+ minify: false,
33
+ silent: true,
34
+ deps: {
35
+ onlyBundle: false,
36
+ },
37
+ outputOptions: {
38
+ codeSplitting: false,
39
+ entryFileNames: '[name].mjs',
40
+ },
41
+ });
apps/kimi-code/tsdown.native.config.ts ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { readFileSync } from 'node:fs';
2
+ import { builtinModules } from 'node:module';
3
+ import { resolve } from 'node:path';
4
+
5
+ import { defineConfig } from 'tsdown';
6
+
7
+ import { rawTextPlugin } from '../../build/raw-text-plugin.mjs';
8
+ import { BUILT_IN_CATALOG_DEFINE, builtInCatalogDefine } from './scripts/built-in-catalog.mjs';
9
+
10
+ const appRoot = import.meta.dirname;
11
+ const packageJson = JSON.parse(
12
+ readFileSync(new URL('./package.json', import.meta.url), 'utf-8'),
13
+ ) as { version: string };
14
+
15
+ const builtins = new Set([
16
+ ...builtinModules,
17
+ ...builtinModules.map((name) => `node:${name}`),
18
+ ]);
19
+ const optionalNativeDependencies = new Set(['cpu-features']);
20
+
21
+ function shouldAlwaysBundle(id: string): boolean {
22
+ if (builtins.has(id) || id.startsWith('node:')) return false;
23
+ if (optionalNativeDependencies.has(id)) return false;
24
+ // Everything else is force-bundled, which covers `@moonshot-ai/*` (incl.
25
+ // vis-server for `kimi vis`) plus its transitive `hono` / `@hono/node-server`
26
+ // — so the SEA bundle is self-contained (check-bundle.mjs enforces this).
27
+ return true;
28
+ }
29
+
30
+ function buildTarget(): string {
31
+ return process.env['KIMI_CODE_BUILD_TARGET'] ?? `${process.platform}-${process.arch}`;
32
+ }
33
+
34
+ export default defineConfig({
35
+ entry: ['./src/main.ts'],
36
+ format: ['cjs'],
37
+ outDir: 'dist-native/intermediates',
38
+ clean: true,
39
+ dts: false,
40
+ fixedExtension: true,
41
+ hash: false,
42
+ platform: 'node',
43
+ target: 'node24',
44
+ banner: { js: '#!/usr/bin/env node' },
45
+ plugins: [rawTextPlugin()],
46
+ alias: {
47
+ '@': resolve(appRoot, 'src'),
48
+ },
49
+ define: {
50
+ [BUILT_IN_CATALOG_DEFINE]: builtInCatalogDefine(),
51
+ __KIMI_CODE_VERSION__: JSON.stringify(packageJson.version),
52
+ __KIMI_CODE_CHANNEL__: JSON.stringify(process.env['KIMI_CODE_CHANNEL'] ?? ''),
53
+ __KIMI_CODE_COMMIT__: JSON.stringify(process.env['KIMI_CODE_COMMIT'] ?? ''),
54
+ __KIMI_CODE_BUILD_TARGET__: JSON.stringify(buildTarget()),
55
+ __KIMI_CODE_NATIVE_BUNDLE__: 'true',
56
+ },
57
+ deps: {
58
+ alwaysBundle: shouldAlwaysBundle,
59
+ neverBundle: [...optionalNativeDependencies],
60
+ onlyBundle: false,
61
+ },
62
+ outputOptions: {
63
+ codeSplitting: false,
64
+ entryFileNames: 'main.cjs',
65
+ },
66
+ checks: {
67
+ legacyCjs: false,
68
+ },
69
+ });
apps/kimi-code/tsdown.worker.config.ts ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Dedicated tsdown config that bundles the off-main-thread workers into
2
+ // self-contained ESM files so they can ride the SEA blob as assets
3
+ // (02-sea-blob.mjs) and be spawned from disk at runtime:
4
+ // - text-build-worker.mjs: the minidb text-build worker
5
+ // (packages/minidb/src/worker/text-build-worker.ts);
6
+ // - search-worker.mjs: the kap-server global-search worker
7
+ // (packages/kap-server/src/search/worker/entry.ts).
8
+ // Without them the bundled binary lacks the worker entry files on disk and
9
+ // heavy index work degrades to the inline main-thread cores, stalling the
10
+ // event loop on large corpora. Runs after the main bundle with clean:false
11
+ // so all verified files remain.
12
+ //
13
+ // One config per entry: rolldown forbids `codeSplitting: false` with
14
+ // multiple inputs, and each worker must be a single self-contained file
15
+ // (check-bundle.mjs enforces zero remaining externals/relative imports).
16
+
17
+ import { resolve } from 'node:path';
18
+
19
+ import { defineConfig } from 'tsdown';
20
+
21
+ const here = import.meta.dirname;
22
+
23
+ function workerConfig(name: string, entry: string) {
24
+ return defineConfig({
25
+ entry: { [name]: resolve(here, entry) },
26
+ format: ['esm'],
27
+ outDir: resolve(here, 'dist-native/intermediates'),
28
+ entryFileNames: '[name].mjs',
29
+ codeSplitting: false,
30
+ platform: 'node',
31
+ target: 'node24',
32
+ dts: false,
33
+ sourcemap: false,
34
+ minify: false,
35
+ silent: true,
36
+ deps: {
37
+ // Force-bundle the workspace packages the entries import
38
+ // (`@moonshot-ai/minidb` and its subpaths) so the output is
39
+ // self-contained.
40
+ alwaysBundle: [/^@moonshot-ai\//],
41
+ },
42
+ // The intermediates dir also holds main.cjs & co. — never wipe it.
43
+ clean: false,
44
+ });
45
+ }
46
+
47
+ export default [
48
+ workerConfig('text-build-worker', '../../packages/minidb/src/worker/text-build-worker.ts'),
49
+ workerConfig('search-worker', '../../packages/kap-server/src/search/worker/entry.ts'),
50
+ ];
apps/kimi-code/vitest.config.ts ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { resolve } from 'node:path';
2
+
3
+ import { defineConfig } from 'vitest/config';
4
+
5
+ const appRoot = import.meta.dirname;
6
+
7
+ export default defineConfig({
8
+ resolve: {
9
+ alias: {
10
+ '@': resolve(appRoot, 'src'),
11
+ },
12
+ },
13
+ test: {
14
+ name: 'cli',
15
+ env: {
16
+ KIMI_LOG_LEVEL: 'off',
17
+ },
18
+ include: ['test/**/*.test.ts', 'test/**/*.test.tsx'],
19
+ },
20
+ });
apps/kimi-inspect/AGENTS.md ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # kimi-inspect Agent Guide
2
+
3
+ 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.
4
+
5
+ ## Top-level views
6
+
7
+ A left icon rail (`src/components/NavRail.tsx`) switches top-level views:
8
+
9
+ - **Chat workspace** — the per-session chat (see "Chat view" below), with the session tree on the left: `src/components/Sidebar.tsx` is a single-column workspace → session tree over the v2 list's grouped projection (`GET /api/v2/sessions?view=by_workspace`, client in `src/sessions/api.ts` — v1-style `{ code, msg, data }` envelope, opaque-cursor pagination over groups; each workspace group carries its first `group.page_size` sessions plus the full matching total, and a "Show all" row falls back to the flat per-workspace listing). Preset views in `src/sessions/views.ts` map onto the endpoint's status / archived / git query conditions; the active view, collapsed workspaces, and panel width persist to localStorage; live activity badges come from the hub.
10
+ - **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).
11
+ - **Model Catalog** (`src/components/ModelCatalogView.tsx`) — every Provider with its Models and the default marker, via `IModelCatalog` / `IModelService` channel proxies, with per-model ping and session creation actions.
12
+ - **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`.
13
+ - **DI view** (`src/components/DiInspectionView.tsx`) — the engine's Service × Effect × DI debug surface over the App-scope `IDebugLedgerService` / `IDebugGraphService` / `IDebugEventsService` / `IDebugCascadeService`: the unit tree = ledger tree with unprovide / update / dispose triggers, the dependency DAG as Miller columns (`di/DiGraphPanel.tsx`), the event-subscription ledger (unit-book `on:<name>` entries + per-bus listener counts, `di/DiEventsPanel.tsx`), the cascade history, and the waiting area; the five panels poll on a short interval and refresh eagerly off the global `event.di.unit_changed` WS frame via `src/activity/di.ts`, which invalidates the `['di']` react-query prefix.
14
+
15
+ The **Agent scope** stays in the Chat view's right dock (`src/components/RightPanel.tsx` — Audit / Agent / State / Session tabs) across two of them:
16
+
17
+ - `Agent` tab — `Inspector`: agent switcher + a Plan lookup card (`PlanCard` in `src/components/Inspector.tsx` — deriving the reviewed plan of one ExitPlanMode tool call, or every plan of the agent, from the message stream: a full `GET /sessions/{id}/history` read via `src/transcript/api.ts`'s `fetchFullHistory` + client-side `projectPlans` in `src/transcript/plan.ts`) plus the agent Service panels.
18
+ - `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`.
19
+
20
+ The **Session scope** lives in the same right dock as the `Session` tab (`src/components/SessionPane.tsx`, embedded by `RightPanel`) with two sub-tabs: Services (the pending-interactions card — `src/components/InteractionsCard.tsx`, which lists and answers approvals/questions over the public REST endpoints `/api/v1/sessions/{id}/approvals|questions` via `src/interactions/api.ts`, since the interaction kernel is a process-global singleton with no debug channel — plus the session Service panels) and State (every key a Session Service registered into the session-state container, read on demand via `ISessionStateService.snapshot()`).
21
+
22
+ ## Channel layer
23
+
24
+ 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.
25
+
26
+ ## Session activity
27
+
28
+ Session-level coarse status is the one exception to no-push: `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` / `event.session.archived` / `session.meta.updated` / `event.workspace.*` invalidate the `['sessions']` / `['v2-sessions']` / `['workspaces']` queries (an archive also drops the session's live activity entry, since no further `work_changed` frames will correct a stale badge); the session tree rows render `running` / `approval` / `question` / `failed` badges from it via `useSessionActivities` (live facts override the REST `activity.status`).
29
+
30
+ ## Dev server
31
+
32
+ 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.
33
+
34
+ ## Chat view
35
+
36
+ The per-session chat (`src/components/ChatView.tsx`) renders turn-granularly from the **message protocol v3** surface 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.
37
+
38
+ Persisted state comes from `GET /api/v1/sessions/{id}/history` only (client in `src/transcript/api.ts`): the initial load reads the newest page (default 500 messages, replace mode), older history auto-pages with `before_turn` via an IntersectionObserver sentinel at the top of the scroll view (prepend mode; a short or empty page ends paging — the response deliberately carries no has-more flag), and each timeline item is wrapped in `content-visibility: auto` + `contain-intrinsic-size` so the browser virtualizes off-screen rendering natively (no windowing library).
39
+
40
+ `/api/v3/ws` (client in `src/transcript/ws.ts`) is the live channel: server `hello` → `subscribe {id, session_id, agent_ids: [agent]}` → `ack` → recovery payload (in-flight entities + pending interactions + running tasks + todo + `session.state`) → live traffic, heartbeat at the WS protocol level. The store (`src/transcript/store.ts`) applies recovery and live messages through one idempotent path — entity messages upsert by (type, own id) with content fields authoritative, the delta family (`assistant.delta` / `thinking.delta` / `tool_call.delta`) appends by id, `tool.progress` patches the entity, `system(undo/clear)` truncates the timeline by `payload.removed_ids` (subtree included, linked interactions cascaded), and `interaction` / `task` / `todo` / `session.state` upsert their own single-source maps; an upsert older than the held entity's `timestamp` is skipped. Notifications are throttled trailing-edge so the per-token delta stream does not re-render per token. Every subscribe ack (initial and every reconnect) triggers an `after_step` catch-up from the newest terminal step; an empty catch-up whose anchor vanished (undo/clear while away) falls back to a full refresh. All of this is orchestrated by `ChatChannel` (`src/transcript/channel.ts`) — no buffering, no cursors beyond the two REST page cursors, no reset frames.
41
+
42
+ ## Transcript audit panel
43
+
44
+ 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 history page (request + replace/prepend/tail mode), every WS message (entity/delta/state as applied), channel events (subscribe ack, reconnect, catch-up fallback, protocol errors), and prompt/cancel actions — with the resulting immutable `ChatState` 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 (the flat entity timeline plus the interaction/task/todo/session.state entities), and the raw Event payload.
apps/kimi-inspect/README.md ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # kimi-inspect
2
+
3
+ Web inspector for the kap-server `/api/v1/debug` RPC surface — a read/trigger
4
+ window into a running Kimi Code engine (workspaces, sessions, agents, and the
5
+ scoped DI registry).
6
+
7
+ ## Run
8
+
9
+ 1. Start a kap-server with the debug surface mounted (repo dev scripts do this
10
+ for you): `pnpm dev:v1` / `pnpm dev:v2` from the repo root pass
11
+ `--debug-endpoints` on a loopback bind; the surface inherits the global
12
+ bearer auth.
13
+ 2. `pnpm --filter @moonshot-ai/kimi-inspect dev` — the Vite dev server proxies
14
+ `/api` to the server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`)
15
+ and auto-discovers running instances
16
+ (`~/.kimi-code/server/instances`); switch servers from the header dropdown.
17
+
18
+ A connection failure shows a blocking "Debug surface unavailable" screen —
19
+ there is no fallback data source.
20
+
21
+ ## Views (left icon rail)
22
+
23
+ - **Chat workspace** — session list (activity badges from the global-events WS)
24
+ plus a transcript-driven per-session chat; the right dock hosts the
25
+ Agent-scope Service panels, a plan lookup card, and the transcript audit
26
+ panel. The Session scope has its own column (pending interactions + session
27
+ Service panels, and a State tab).
28
+ - **Search** — cross-session full-text search over `POST /api/v1/search`
29
+ (cursor-paged; exact-match maps to the API's `literal` mode; a `live`/`index`
30
+ badge shows which server route served the results).
31
+ - **Model Catalog** — every provider with its models and the default marker,
32
+ with per-model ping and session creation actions.
33
+ - **App / Workspace Services** — the full Service reflection over the App
34
+ scope, and over each Workspace scope (picked via the directory browser;
35
+ workspace handlers materialize on demand).
36
+ - **DI** — the engine's Service × Effect × DI debug surface, four panels fed
37
+ by the App-scope debug Services (`IDebugLedgerService` / `IDebugGraphService`
38
+ / `IDebugCascadeService`) and refreshed eagerly off the `event.di.unit_changed`
39
+ WS frame:
40
+ - **Unit tree** — scope → unit → ledger entries (label, five-state
41
+ `Pending / Activating / Active / Unloading / Failed`, uid, `pinned` flag,
42
+ unit error object), with **unprovide / update / dispose** triggers.
43
+ - **Graph** — the dependency DAG (instance edges across scopes + collection
44
+ edges).
45
+ - **Cascade** — the cascade transaction history ring (changes, contagion set,
46
+ torn-down / rebuilt / failed, abort wait, duration).
47
+ - **Pending** — the waiting area: units parked on unsatisfied dependencies
48
+ with their missing-token sets.
49
+
50
+ ## Notes for maintainers
51
+
52
+ - The channel layer (`src/channel/`) is a VS Code-style `ProxyChannel`:
53
+ `GET /api/v1/debug/channels` enumerates every scoped Service — there is no
54
+ whitelist; new Services appear automatically.
55
+ - There is no Service-event push channel besides the global events listed
56
+ above; panels fetch/refresh on demand (react-query, 15 s poll) plus the
57
+ `event.di.unit_changed` invalidation for the DI view.
apps/kimi-inspect/index.html ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Kimi Inspect</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
apps/kimi-inspect/package.json ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "@moonshot-ai/kimi-inspect",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "imports": {
8
+ "#/*": {
9
+ "types": [
10
+ "./src/*.ts",
11
+ "./src/*.tsx",
12
+ "./src/*/index.ts",
13
+ "./src/*/index.tsx"
14
+ ],
15
+ "default": "./src/*"
16
+ }
17
+ },
18
+ "scripts": {
19
+ "dev": "vite",
20
+ "build": "vite build",
21
+ "typecheck": "tsc --noEmit",
22
+ "test": "vitest run"
23
+ },
24
+ "dependencies": {
25
+ "@moonshot-ai/agent-core-v2": "workspace:^",
26
+ "@moonshot-ai/kap-server": "workspace:^",
27
+ "@tanstack/react-query": "^5.74.4",
28
+ "react": "^19.1.0",
29
+ "react-dom": "^19.1.0"
30
+ },
31
+ "devDependencies": {
32
+ "@tailwindcss/vite": "^4.1.4",
33
+ "@types/react": "^19.1.2",
34
+ "@types/react-dom": "^19.1.2",
35
+ "@vitejs/plugin-react": "^4.4.1",
36
+ "tailwindcss": "^4.1.4",
37
+ "typescript": "6.0.2",
38
+ "vite": "^6.3.3",
39
+ "vitest": "4.1.4"
40
+ }
41
+ }
apps/kimi-inspect/tsconfig.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ // Mirror the repo root tsconfig: agent-core-v2 is consumed through source
3
+ // `exports`, so its sources are type-checked here too, and the strictness
4
+ // flags must match the root exactly. The src includes are required (not
5
+ // just for speed): agent-core-v2 fills interfaces like `AgentTaskInfoByKind`
6
+ // via cross-module `declare module` augmentation, and those collapse to
7
+ // `never` when the augmenting files are not in the program. Only the
8
+ // environment (DOM + vite client types) differs.
9
+ "extends": "../../tsconfig.json",
10
+ "compilerOptions": {
11
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
12
+ "types": ["node", "vite/client"],
13
+ "noEmit": true
14
+ },
15
+ "include": [
16
+ "src",
17
+ "vite",
18
+ "vite.config.ts",
19
+ "../../packages/agent-core-v2/src"
20
+ ],
21
+ "exclude": ["../../packages/agent-core-v2/src/human"]
22
+ }
apps/kimi-inspect/vite.config.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+ import tailwindcss from '@tailwindcss/vite';
4
+
5
+ import { serverDiscoveryPlugin } from './vite/serverDiscovery';
6
+
7
+ const webPort = Number(process.env['INSPECT_PORT']) || 5176;
8
+ // Where the dev proxy forwards server traffic. The app can also connect to an
9
+ // arbitrary server URL typed into the connect screen (loopback cross-origin is
10
+ // allowed by kap-server), but the default connection is same-origin through
11
+ // this proxy so no CORS / Origin handling is involved.
12
+ const serverTarget = process.env['KIMI_SERVER_URL'] || 'http://127.0.0.1:58627';
13
+
14
+ export default defineConfig({
15
+ plugins: [react(), tailwindcss(), serverDiscoveryPlugin({ proxyTarget: serverTarget })],
16
+ define: {
17
+ __KIMI_INSPECT_PROXY_TARGET__: JSON.stringify(serverTarget),
18
+ },
19
+ server: {
20
+ port: webPort,
21
+ strictPort: false,
22
+ proxy: {
23
+ '/api': { target: serverTarget, changeOrigin: true, ws: true },
24
+ },
25
+ },
26
+ preview: {
27
+ port: Number(process.env['INSPECT_PREVIEW_PORT']) || 4176,
28
+ proxy: {
29
+ '/api': { target: serverTarget, changeOrigin: true, ws: true },
30
+ },
31
+ },
32
+ build: {
33
+ outDir: 'dist',
34
+ emptyOutDir: true,
35
+ target: 'es2022',
36
+ },
37
+ });
apps/vis/package.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "@moonshot-ai/vis",
3
+ "version": "0.1.1",
4
+ "private": true,
5
+ "description": "Debug visualization tool for kimi-code sessions",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "scripts": {
9
+ "dev": "node scripts/dev.mjs",
10
+ "build": "pnpm --filter @moonshot-ai/vis-server build && pnpm --filter @moonshot-ai/vis-web build && node scripts/copy-web-dist.mjs",
11
+ "prestart": "pnpm run build",
12
+ "start": "node server/dist/server.mjs"
13
+ },
14
+ "devDependencies": {
15
+ "concurrently": "^9.1.2"
16
+ }
17
+ }
apps/vscode/.vscode-test.mjs ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import { defineConfig } from "@vscode/test-cli";
2
+
3
+ export default defineConfig({
4
+ files: "out/test/**/*.test.js",
5
+ });
apps/vscode/.vscodeignore ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Source files
2
+ src/**
3
+ webview-ui/**
4
+ shared/**
5
+ **/*.ts
6
+ **/*.tsx
7
+
8
+ # Node modules (bundled via esbuild/vite)
9
+ **/node_modules/**
10
+
11
+ # Build configs
12
+ .vscode/**
13
+ tsconfig.json
14
+ tsdown.config.ts
15
+ vitest.config.ts
16
+ .vscode-test.mjs
17
+ eslint.config.mjs
18
+ webview-ui/tsconfig.json
19
+ webview-ui/vite.config.ts
20
+ webview-ui/components.json
21
+
22
+ # Scripts
23
+ scripts/**
24
+ test/**
25
+ runtime/**
26
+ artifacts/**
27
+
28
+ # Never package generated test/debug state
29
+ **/.kimi/**
30
+ **/.kimi-code/**
31
+ **/cache/**
32
+ **/caches/**
33
+ **/credentials/**
34
+ **/logs/**
35
+ **/profile/**
36
+ **/profiles/**
37
+ **/session/**
38
+ **/sessions/**
39
+ **/tokens/**
40
+ **/*.jsonl
41
+ **/*.log
42
+
43
+ # Other
44
+ .gitignore
45
+ **/*.map
46
+ *.vsix
47
+ **/.DS_Store
48
+
49
+ # Prevent parent directory traversal via symlinks
50
+ ../**
apps/vscode/CHANGELOG.md ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ ## 0.7.5
4
+
5
+ ### Patch Changes
6
+
7
+ - [#3453](https://github.com/MoonshotAI/kimi-code/pull/3453) [`411572e`](https://github.com/MoonshotAI/kimi-code/commit/411572e166edee8581ba9c5a7f1bbf6c8b405606) Thanks [@Grapedge](https://github.com/Grapedge)! - Highlight matched characters in @ file suggestions and allow folders to be inserted as mentions.
8
+
9
+ - [#3453](https://github.com/MoonshotAI/kimi-code/pull/3453) [`411572e`](https://github.com/MoonshotAI/kimi-code/commit/411572e166edee8581ba9c5a7f1bbf6c8b405606) Thanks [@Grapedge](https://github.com/Grapedge)! - Show the image/video picker entry in the @ menu only before a search query is typed, and remove the Browse folders mode.
10
+
11
+ - [#3453](https://github.com/MoonshotAI/kimi-code/pull/3453) [`411572e`](https://github.com/MoonshotAI/kimi-code/commit/411572e166edee8581ba9c5a7f1bbf6c8b405606) Thanks [@Grapedge](https://github.com/Grapedge)! - Fix the @ and / suggestion lists jittering when the mouse rests at the scroll edge.
12
+
13
+ - Updated dependencies [[`411572e`](https://github.com/MoonshotAI/kimi-code/commit/411572e166edee8581ba9c5a7f1bbf6c8b405606)]:
14
+ - @moonshot-ai/kimi-code-sdk@0.20.0
15
+
16
+ ## 0.7.4
17
+
18
+ ### Patch Changes
19
+
20
+ - [#3371](https://github.com/MoonshotAI/kimi-code/pull/3371) [`9e88152`](https://github.com/MoonshotAI/kimi-code/commit/9e881528a89945a373002b0b229f91735e8f2c4f) Thanks [@tpoisonooo](https://github.com/tpoisonooo)! - Fix prompts remaining queued forever after reopening a session.
21
+
22
+ - [#3366](https://github.com/MoonshotAI/kimi-code/pull/3366) [`9619277`](https://github.com/MoonshotAI/kimi-code/commit/961927739ef34819d67d76fa5870cbe4ba7a01ff) Thanks [@kimi-agent-bot](https://github.com/kimi-agent-bot)! - Use the Unicode ellipsis "…" in user-facing TUI and VS Code webview text.
23
+
24
+ ## 0.7.3
25
+
26
+ ### Patch Changes
27
+
28
+ - [#3098](https://github.com/MoonshotAI/kimi-code/pull/3098) [`75550c5`](https://github.com/MoonshotAI/kimi-code/commit/75550c5686cb867c0cb34bc515997d5e6305fc94) Thanks [@LCZcn96](https://github.com/LCZcn96)! - Restore live context-window usage updates after switching to the v2 engine.
29
+
30
+ - [#3276](https://github.com/MoonshotAI/kimi-code/pull/3276) [`f34b2ec`](https://github.com/MoonshotAI/kimi-code/commit/f34b2ecfb01dc194aaa3f209a00772991caad3f3) Thanks [@Grapedge](https://github.com/Grapedge)! - Fix streamed replies occasionally showing every character twice and tool calls appearing in duplicate.
31
+
32
+ - Updated dependencies [[`75550c5`](https://github.com/MoonshotAI/kimi-code/commit/75550c5686cb867c0cb34bc515997d5e6305fc94), [`d723cc4`](https://github.com/MoonshotAI/kimi-code/commit/d723cc47ee43e5ca3c3c4ec2473f205d44acede2)]:
33
+ - @moonshot-ai/kimi-code-sdk@0.19.2
34
+
35
+ ## 0.7.2
36
+
37
+ ### Patch Changes
38
+
39
+ - [#3079](https://github.com/MoonshotAI/kimi-code/pull/3079) [`35befdc`](https://github.com/MoonshotAI/kimi-code/commit/35befdcef2be344d931ea20063cb64113350dc4b) Thanks [@gaoyuan1223m](https://github.com/gaoyuan1223m)! - Fix multi-select questions jumping to the next question after only one answer is selected.
40
+
41
+ - Updated dependencies [[`3d77620`](https://github.com/MoonshotAI/kimi-code/commit/3d7762003a4a35cbeb8571d471c6898a006152e6)]:
42
+ - @moonshot-ai/kimi-code-sdk@0.19.1
43
+
44
+ ## 0.7.1
45
+
46
+ ### Patch Changes
47
+
48
+ - [#3026](https://github.com/MoonshotAI/kimi-code/pull/3026) [`13857f3`](https://github.com/MoonshotAI/kimi-code/commit/13857f383200881aa77dc972a8963ba421eeb2b6) Thanks [@bj456736](https://github.com/bj456736)! - Show plugin- and file-declared MCP servers as read-only entries in the MCP servers panel.
49
+
50
+ - Updated dependencies [[`d833a1a`](https://github.com/MoonshotAI/kimi-code/commit/d833a1a893c4d69d96af542f40557442992085e0), [`61591bc`](https://github.com/MoonshotAI/kimi-code/commit/61591bce09f4467aa1664cb8ecb6aa6904b7accd), [`d833a1a`](https://github.com/MoonshotAI/kimi-code/commit/d833a1a893c4d69d96af542f40557442992085e0), [`13857f3`](https://github.com/MoonshotAI/kimi-code/commit/13857f383200881aa77dc972a8963ba421eeb2b6)]:
51
+ - @moonshot-ai/kimi-code-sdk@0.19.0
52
+
53
+ ## 0.7.0
54
+
55
+ ### Minor Changes
56
+
57
+ - [#2916](https://github.com/MoonshotAI/kimi-code/pull/2916) [`7475c2e`](https://github.com/MoonshotAI/kimi-code/commit/7475c2e2e3dd86ac0b8a8d51d4f1d233ed7df797) Thanks [@Grapedge](https://github.com/Grapedge)! - Run the extension on the v2 agent engine by default; the interface, sessions, and workflows are unchanged. To roll back, enable the `kimi.useAgentCoreV1` setting and reload the window.
58
+
59
+ ### Patch Changes
60
+
61
+ - Updated dependencies [[`6be2697`](https://github.com/MoonshotAI/kimi-code/commit/6be26978b123bacf1c5ebce52bbeb6f7b7ff0629), [`7475c2e`](https://github.com/MoonshotAI/kimi-code/commit/7475c2e2e3dd86ac0b8a8d51d4f1d233ed7df797), [`7475c2e`](https://github.com/MoonshotAI/kimi-code/commit/7475c2e2e3dd86ac0b8a8d51d4f1d233ed7df797), [`7475c2e`](https://github.com/MoonshotAI/kimi-code/commit/7475c2e2e3dd86ac0b8a8d51d4f1d233ed7df797)]:
62
+ - @moonshot-ai/kimi-code-sdk@0.18.0
63
+
64
+ ## 0.6.9
65
+
66
+ ### Patch Changes
67
+
68
+ - Updated dependencies [[`c9bfe8b`](https://github.com/MoonshotAI/kimi-code/commit/c9bfe8b2c8314ba4ef8806fb3b92ac654c1d1860), [`c212ae9`](https://github.com/MoonshotAI/kimi-code/commit/c212ae9715371c0d7939c15e664acbe0d7cf7fc3)]:
69
+ - @moonshot-ai/kimi-code-sdk@0.17.0
70
+
71
+ ## 0.6.8
72
+
73
+ ### Patch Changes
74
+
75
+ - Updated dependencies [[`437a1b8`](https://github.com/MoonshotAI/kimi-code/commit/437a1b8ba1b7e0f6662bdadc669564fdc58c3f5a), [`0b2e803`](https://github.com/MoonshotAI/kimi-code/commit/0b2e803d5e71afaab45212bb2ee6117ecbf8bbc9), [`3c9e3b2`](https://github.com/MoonshotAI/kimi-code/commit/3c9e3b297cf5286c761159c1b4d642c478fd394d)]:
76
+ - @moonshot-ai/kimi-code-sdk@0.16.0
77
+
78
+ ## 0.6.7
79
+
80
+ ### Patch Changes
81
+
82
+ - [#2326](https://github.com/MoonshotAI/kimi-code/pull/2326) [`302b2cd`](https://github.com/MoonshotAI/kimi-code/commit/302b2cd680e0ec66f68b4572238de84ce311c5f4) Thanks [@gaoyuan1223m](https://github.com/gaoyuan1223m)! - Fix only the first question being answerable when the agent asked multiple questions at once; each question is now answered one by one and submitted together.
83
+
84
+ ## 0.6.6
85
+
86
+ ### Patch Changes
87
+
88
+ - [#2393](https://github.com/MoonshotAI/kimi-code/pull/2393) [`6d0a046`](https://github.com/MoonshotAI/kimi-code/commit/6d0a046488edda56219961b253c4787abae7a113) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Fix new users getting stranded on "Model setup required" with no way back to sign-in when the first login finishes authorization but fails to complete model setup; the screen now offers a path back to the sign-in page so login can be retried.
89
+ - [#2402](https://github.com/MoonshotAI/kimi-code/pull/2402) [`0f3b106`](https://github.com/MoonshotAI/kimi-code/commit/0f3b106c4260ad626f66bc5c457a535d3163f2bc) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Reword the sign-in waiting message from "Waiting for authorization" to "Waiting for authentication".
90
+
91
+ - Updated dependencies [[`40172c7`](https://github.com/MoonshotAI/kimi-code/commit/40172c7ca96ca981b043b793588dd32e898979fa)]:
92
+ - @moonshot-ai/kimi-code-sdk@0.15.0
93
+
94
+ ## 0.6.5
95
+
96
+ ### Patch Changes
97
+
98
+ - [#1994](https://github.com/MoonshotAI/kimi-code/pull/1994) [`beeb964`](https://github.com/MoonshotAI/kimi-code/commit/beeb964393c8f9a38c2b1e2273e4415fc434b16d) Thanks [@RealKai42](https://github.com/RealKai42)! - Reduce webview streaming re-render churn: settled assistant messages no longer re-render on every streaming delta, and local images over 10MB are no longer inlined into the webview DOM.
99
+ - Updated dependencies [[`ec88d35`](https://github.com/MoonshotAI/kimi-code/commit/ec88d352e8f4dc5e8ffd1212f016138458f69893), [`b5efba7`](https://github.com/MoonshotAI/kimi-code/commit/b5efba7abcaf4041f81ec520097a61e6546e8c50), [`ce0e3ce`](https://github.com/MoonshotAI/kimi-code/commit/ce0e3ceb04223bdaad8e8931bad46eff561055b6), [`e458323`](https://github.com/MoonshotAI/kimi-code/commit/e45832398d0d9cad98dbad1cbf1e5b103a20aace)]:
100
+ - @moonshot-ai/kimi-code-sdk@0.14.0
101
+
102
+ ## 0.6.4
103
+
104
+ ### Changed
105
+
106
+ - Picking a model's highest thinking effort now applies to the current session
107
+ only instead of becoming the global default: the top tier saves just the
108
+ on/off toggle, lower tiers persist as the default as before, and
109
+ re-confirming the current effort no longer rewrites the saved preference.
110
+ The model and thinking pickers also note that switching mid-conversation
111
+ invalidates the existing prompt cache.
112
+ - Unified the YOLO and Auto permission mode naming and descriptions with the
113
+ CLI (`/afk` is now `/auto`), and approval requests that fall outside the
114
+ active permission mode (sensitive files, plan reviews, ask rules) are now
115
+ always shown to you instead of being auto-approved.
116
+
117
+ ## 0.6.3
118
+
119
+ ### Fixed
120
+
121
+ - Editor mentions now work for files outside the working directory, and paths
122
+ containing spaces are quoted correctly.
123
+ - Cancelling a running turn now reliably reaches the engine, and the UI no
124
+ longer reports a task as stopped when there is nothing to cancel.
125
+ - Attaching to or resuming an existing session no longer overwrites its model
126
+ and thinking effort with the configured defaults; model or effort changes
127
+ picked in the composer are applied when the prompt is sent.
128
+
129
+ ## 0.6.2
130
+
131
+ ### Fixed
132
+
133
+ - A core error arriving in the middle of a turn no longer corrupts the active
134
+ turn; the turn now ends cleanly with an error instead of leaving the chat in
135
+ a broken state.
136
+ - Kimi sign-in and connection failures now include the underlying transport
137
+ cause (for example DNS or connection refused) instead of a generic error.
138
+ - Closed several FetchURL SSRF bypasses and the DNS-rebinding window.
139
+ - Tool calls interrupted mid-stream are now recorded and closed, so they no
140
+ longer corrupt the session history.
141
+
142
+ ## 0.6.1
143
+
144
+ ### Fixed
145
+
146
+ - The **Sign in** action in the settings (gear) menu now actually starts the
147
+ Kimi login flow and shows an error toast when sign-in fails, instead of
148
+ silently doing nothing.
149
+
150
+ ## 0.6.0
151
+
152
+ ### Breaking
153
+
154
+ - Raised the minimum supported editor version to VS Code 1.100.0.
155
+ - Legacy Kimi Code OAuth credentials and MCP OAuth credentials are deliberately
156
+ not migrated. Sign in to Kimi Code again and re-authorize affected MCP
157
+ servers after upgrading.
158
+ - Removed the `kimi.executablePath` and `kimi.environmentVariables` settings.
159
+ The old `kimi.environmentVariables.KIMI_SHARE_DIR` value is consulted only to
160
+ discover legacy data during migration; it is not applied to the new runtime.
161
+ The system-level `KIMI_CODE_HOME` environment variable remains supported.
162
+
163
+ ### Changed
164
+
165
+ - Replaced the legacy Python/stdio runtime with the in-process Kimi Code Node
166
+ SDK. The extension no longer downloads or starts a separate Kimi executable.
167
+ - The in-process engine is the same one that powers the Kimi Code CLI, so the
168
+ agent gains CLI-parity capabilities beyond the legacy runtime, including
169
+ parallel subagent swarms, background tasks, and long-running goal runs.
170
+ - Added an opt-in legacy migration prompt on the first launch that detects data
171
+ from version 0.5.x. The migration copies or merges supported data into the
172
+ current Kimi Code home and does not delete the legacy source. If migration is
173
+ skipped or needs to be retried, run **Kimi Code: Migrate Legacy Data** from the
174
+ Command Palette.
175
+ - When VS Code and the Kimi Code terminal app resolve to the same
176
+ `KIMI_CODE_HOME`, they use the same configuration and session storage. Running
177
+ the same session concurrently from multiple processes is not supported or
178
+ protected by cross-process locking.
179
+ - The model picker groups models by provider when multiple providers are
180
+ configured, keeps provider identity when display names match, and recognizes
181
+ adaptive-thinking metadata. A configured custom default provider no longer
182
+ requires dismissing the Kimi account login screen on every launch.
183
+ - The file changes panel and Undo actions use extension-maintained baselines.
184
+ Files changed through Kimi's Write and Edit operations are tracked on a
185
+ best-effort basis. File deletions performed inside Bash are not tracked by
186
+ this baseline and therefore cannot be restored by the panel's Undo action.
187
+
188
+ ### Fixed
189
+
190
+ - The `kimi.yoloMode` setting now reaches the permission engine: enabling it
191
+ maps to the core `yolo` permission mode and takes effect when a session
192
+ attaches, including sessions that previously stored a disabled auto-approve
193
+ state.
194
+ - Kept the chat header and input toolbar readable when the sidebar is narrow:
195
+ controls wrap and shrink instead of being clipped.
196
+
197
+ ### Distribution boundary
198
+
199
+ Release packaging produces target-specific VSIX files for `darwin-x64`,
200
+ `darwin-arm64`, `linux-x64`, `linux-arm64`, `win32-x64`, and `win32-arm64`.
201
+ Archive and static verification for a target does not by itself prove that the
202
+ extension has run successfully in that target's Extension Host; runtime test
203
+ results must be recorded separately for each operating system and architecture.
apps/vscode/LICENSE ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
apps/vscode/README.md ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Kimi Code
2
+
3
+ AI coding assistant for VS Code, built for long-context workflows and complex coding tasks.
4
+
5
+ ## Features
6
+
7
+ - **Works alongside you**: Kimi autonomously explores your codebase, reads and writes code, and runs terminal commands with your permission
8
+ - **Thinking controls**: Toggle reasoning or choose a model-supported thinking effort
9
+ - **Provider-aware models**: Distinguish and select same-named models across configured providers
10
+ - **Native editor integration**: Review AI-proposed changes directly in VS Code's diff viewer
11
+ - **MCP support**: Extend capabilities with Model Context Protocol servers
12
+ - **Slash commands**: Quick actions like `/init` to analyze your project and `/compact` to manage context
13
+
14
+ ## Install
15
+
16
+ Kimi Code requires VS Code 1.100.0 or later.
17
+
18
+ 1. Install from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=moonshot-ai.kimi-code)
19
+ 2. Open a folder in VS Code
20
+ 3. Click the Kimi icon in the Activity Bar
21
+ 4. Sign in with a [kimi.com/code](https://www.kimi.com/code) subscription, or use a provider already configured in the shared `config.toml`
22
+
23
+ The extension runs the Kimi Code Node SDK in the VS Code Extension Host. When
24
+ the extension and the Kimi Code terminal app resolve to the same
25
+ `KIMI_CODE_HOME`, they share `config.toml`, MCP configuration, login state, and
26
+ sessions. The system-level `KIMI_CODE_HOME` environment variable is supported;
27
+ there is no separate VS Code setting for it. Do not run the same session from
28
+ both applications at the same time, because cross-process session locking is
29
+ not guaranteed.
30
+
31
+ After upgrading from version 0.5.x, the extension prompts before migrating any
32
+ legacy data it finds. Migration copies or merges data into the current Kimi Code
33
+ home and does not delete the legacy source. Legacy Kimi Code OAuth and MCP OAuth
34
+ credentials are not copied, so those connections must be authorized again.
35
+ See [the changelog](CHANGELOG.md) for the full compatibility notes.
36
+
37
+ ## Docs
38
+
39
+ Official doc for Kimi Code can be found at [www.kimi.com/code/docs](https://www.kimi.com/code/docs/en/kimi-code-for-vscode/guides/getting-started.html)
40
+
41
+ ## License
42
+
43
+ [Apache-2.0](LICENSE)
apps/vscode/docs/node-sdk-migration.md ADDED
@@ -0,0 +1,396 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # VS Code Node SDK Migration Design
2
+
3
+ Status: accepted and implemented for extension `0.6.0`
4
+
5
+ Last updated: 2026-07-16
6
+
7
+ ## Context
8
+
9
+ The `0.5.x` VS Code extension launched a separately installed Python Kimi CLI
10
+ and communicated with it over stdio. That architecture duplicated runtime
11
+ installation, configuration, authentication, and session behavior between the
12
+ editor and Kimi Code.
13
+
14
+ Version `0.6.0` moves the extension into this monorepo under `apps/vscode` and
15
+ runs the stable TypeScript v1 engine through `@moonshot-ai/kimi-code-sdk` in the
16
+ VS Code Extension Host. The migration preserves the existing extension ID,
17
+ commands, Webview, and user-visible workflows. It does not redesign the UI or
18
+ introduce unrelated TUI features.
19
+
20
+ This document records the durable design decisions behind that migration. It
21
+ is not a release checklist or a transcript of the implementation process.
22
+
23
+ ## Goals
24
+
25
+ - Keep the extension ID `moonshot-ai.kimi-code` so `0.6.0` upgrades existing
26
+ installations.
27
+ - Preserve the existing VS Code commands, shortcuts, Webview workflows, editor
28
+ integration, session management, MCP management, and file changes panel.
29
+ - Replace the Python/stdio host with the in-process v1 Node SDK.
30
+ - Share Kimi Code configuration, authentication, MCP configuration, and
31
+ sessions with the TUI when both processes resolve the same Kimi Code home.
32
+ - Reuse the shared legacy migration package instead of maintaining a VS
33
+ Code-specific session translator.
34
+ - Add only the smallest SDK/core APIs needed to preserve existing VS Code
35
+ behavior.
36
+ - Package and test platform-targeted VSIX artifacts for macOS, Linux, and
37
+ Windows.
38
+
39
+ The only intentional UI capability added during the migration is
40
+ model-aware thinking effort selection. It is necessary to represent the model
41
+ capabilities exposed by the v1 configuration.
42
+
43
+ ## Non-goals
44
+
45
+ - Replacing the React Webview with VS Code native views or Chat Participants.
46
+ - Migrating to the v2 engine.
47
+ - Adding TUI-only features such as goals, cron, swarm, or BTW.
48
+ - Keeping a Python CLI fallback or a custom executable setting.
49
+ - Adding cross-process locks for concurrent access to one session.
50
+ - Making the core wait for VS Code before file tools execute.
51
+ - Parsing arbitrary shell commands to infer file changes.
52
+ - Copying legacy OAuth or MCP OAuth credentials.
53
+ - Publishing an extension as part of the build or package commands.
54
+
55
+ ## Runtime architecture
56
+
57
+ ```mermaid
58
+ flowchart LR
59
+ UI["React Webview<br/>browser sandbox"]
60
+ Host["VS Code Extension Host<br/>Node process"]
61
+ SDK["@moonshot-ai/kimi-code-sdk<br/>KimiHarness and Session"]
62
+ Core["agent-core-v2"]
63
+ Home["Kimi Code home<br/>config, auth, MCP, sessions"]
64
+
65
+ UI <-->|"postMessage RPC and events"| Host
66
+ Host -->|"in-process calls"| SDK
67
+ SDK -->|"in-process calls"| Core
68
+ SDK <--> Home
69
+ ```
70
+
71
+ There is no Python process, secondary Node process, or local HTTP server in the
72
+ production extension path.
73
+
74
+ The Webview remains because it is the existing product UI. It cannot import the
75
+ Node SDK directly: the Webview is a browser sandbox and must not access the file
76
+ system, credentials, process environment, or session storage. Those operations
77
+ stay in the trusted Extension Host.
78
+
79
+ ### Extension identity
80
+
81
+ The runtime constructs the SDK client with:
82
+
83
+ - `productName: "kimi-code-vscode"`
84
+ - `version` from `apps/vscode/package.json`
85
+ - `uiMode: "vscode"`
86
+
87
+ For `0.6.0`, the normal HTTP User-Agent product is therefore
88
+ `kimi-code-vscode/0.6.0`. The version has one source of truth and is not copied
89
+ into runtime code or packaging scripts.
90
+
91
+ ### Package boundaries
92
+
93
+ - `apps/vscode` depends on `@moonshot-ai/kimi-code-sdk`.
94
+ - `apps/vscode` must not depend directly on engine packages.
95
+ - Core capabilities needed by released clients are exposed through the Node SDK
96
+ and tested at that public boundary.
97
+ - The Webview communicates only through the typed bridge in
98
+ `apps/vscode/shared`.
99
+
100
+ ## Main components
101
+
102
+ | Area | Primary implementation |
103
+ |---|---|
104
+ | Activation and VS Code commands | `apps/vscode/src/extension.ts` |
105
+ | Webview lifecycle | `apps/vscode/src/KimiWebviewProvider.ts` |
106
+ | Webview RPC boundary | `apps/vscode/src/bridge-handler.ts`, `apps/vscode/src/handlers` |
107
+ | SDK host | `apps/vscode/src/runtime/kimi-runtime.ts` |
108
+ | Session lifecycle and event routing | `apps/vscode/src/runtime/session-runtime.ts` |
109
+ | SDK-to-Webview event conversion | `apps/vscode/src/runtime/event-adapter.ts` |
110
+ | Session replay | `apps/vscode/src/runtime/replay-adapter.ts` |
111
+ | File changes and baselines | `apps/vscode/src/managers` |
112
+ | Legacy migration coordination | `apps/vscode/src/migration` |
113
+ | React UI | `apps/vscode/webview-ui` |
114
+ | Packaging and smoke tests | `apps/vscode/scripts`, `.github/workflows/ci.yml` |
115
+
116
+ ## Data ownership
117
+
118
+ ### Shared Kimi Code home
119
+
120
+ The SDK resolves the home directory using the normal Kimi Code rules:
121
+
122
+ 1. system-level `KIMI_CODE_HOME`, when set;
123
+ 2. otherwise `~/.kimi-code`.
124
+
125
+ The extension does not add a separate `kimi.homeDir` setting and does not pass
126
+ its own default home to the SDK. VS Code and the TUI share the following data
127
+ only when they resolve the same home:
128
+
129
+ - `config.toml`
130
+ - `mcp.json`
131
+ - authentication state
132
+ - `sessions/`
133
+ - `session_index.jsonl`
134
+ - other SDK-owned Kimi Code data
135
+
136
+ Remote SSH, WSL, and Dev Container installations use the environment and home
137
+ of the remote Extension Host. They do not automatically share the local
138
+ machine's Kimi Code home.
139
+
140
+ ### VS Code-owned state
141
+
142
+ VS Code settings and Extension Host storage own editor-specific behavior:
143
+
144
+ - autosave and keyboard behavior;
145
+ - thinking display preferences;
146
+ - editor context injection mode;
147
+ - Webview state;
148
+ - file change baselines and legacy baseline acceptance markers.
149
+
150
+ These values are not written into the shared core configuration unless they
151
+ are already a shared product setting, such as the selected model or thinking
152
+ effort.
153
+
154
+ ### Environment variables
155
+
156
+ The old `kimi.environmentVariables` setting existed to populate the environment
157
+ of the Python child process. It was removed with that process model.
158
+
159
+ - Provider-specific environment variables remain in `config.toml`.
160
+ - MCP server environment variables remain in `mcp.json`.
161
+ - proxy and other process-level variables are inherited from the Extension
162
+ Host environment.
163
+ - a legacy `KIMI_SHARE_DIR` in the removed setting is consulted only as an
164
+ additional migration source.
165
+ - other values from the removed global environment map are not migrated.
166
+
167
+ ## Webview bridge
168
+
169
+ The bridge keeps the request/response and event-broadcast shape of the previous
170
+ extension so the React UI did not require a product redesign. The Extension
171
+ Host validates method names, payloads, workspace containment, and file paths.
172
+ The Webview uses a nonce-based content security policy and does not receive
173
+ tokens, complete configuration files, or unnecessary absolute paths.
174
+
175
+ The event adapter maps v1 SDK events such as `turn.started`,
176
+ `assistant.delta`, `tool.call.started`, approvals, questions, compaction, and
177
+ errors into the UI state expected by the migrated Webview. Session replay uses
178
+ the SDK/core replay surface rather than parsing current core storage directly
179
+ from the UI.
180
+
181
+ Provider errors may arrive after a failed turn-end event. Session subscriptions
182
+ therefore remain alive long enough to deliver the final error instead of being
183
+ cancelled immediately when a turn ends.
184
+
185
+ ## Sessions
186
+
187
+ The Node SDK exposes the session operations required by the existing editor UI:
188
+
189
+ - create, resume, list, rename, and export;
190
+ - permanent delete;
191
+ - fork from a selected historical turn;
192
+ - replayed context and records needed to restore the UI;
193
+ - approval, question, stop, steer, plan mode, model, and thinking controls.
194
+
195
+ Permanent delete keeps the old VS Code meaning; it is not silently mapped to
196
+ archive. A running target session is stopped and closed before its persisted
197
+ data and index references are removed.
198
+
199
+ Historical fork keeps the selected turn and its preceding context, then removes
200
+ later conversation state. Baselines are materialized into the fork so Keep or
201
+ Undo actions in one session cannot affect the other.
202
+
203
+ The TUI and VS Code may resume sessions created by each other. They must not run
204
+ the same session concurrently because the v1 store has no cross-process write
205
+ lock.
206
+
207
+ ## Provider-aware models and thinking
208
+
209
+ Model aliases retain their provider identity. The picker groups models by
210
+ provider when multiple providers are configured, so same-named models remain
211
+ distinct. Media fallback prefers a compatible model from the current provider
212
+ before considering another provider.
213
+
214
+ Thinking controls follow each model's declared capabilities:
215
+
216
+ - models with `support_efforts` expose those effort values;
217
+ - boolean-thinking models expose on/off;
218
+ - `always_thinking` models do not expose an invalid off state;
219
+ - adaptive-thinking capability is preserved when applying the selection.
220
+
221
+ Changing the model or thinking effort updates the active session and the shared
222
+ default configuration used by new TUI and VS Code sessions.
223
+
224
+ ## MCP
225
+
226
+ Home-level MCP operations are exposed by the SDK harness rather than by direct
227
+ file access from the extension. They cover configuration CRUD, OAuth/reset,
228
+ connection testing, and user-global `mcp.json` entries. Session-level status and
229
+ reconnect behavior remain on the Session surface.
230
+
231
+ The extension supports stdio and HTTP servers, including their environment,
232
+ headers, and authorization flows. Legacy MCP OAuth credentials are not copied
233
+ and may require authorization after upgrade.
234
+
235
+ ## Legacy migration
236
+
237
+ Migration is opt-in and uses `@moonshot-ai/migration-legacy` for detection and
238
+ translation. The extension coordinates prompts and reports but does not
239
+ maintain another config/session translator.
240
+
241
+ ### Sources and target
242
+
243
+ - default source: `~/.kimi`;
244
+ - optional additional source: a valid legacy `KIMI_SHARE_DIR` from the removed
245
+ VS Code setting;
246
+ - target: the SDK-resolved Kimi Code home.
247
+
248
+ Migration covers the shared config, MCP config, user history, supported skills,
249
+ and sessions. Existing target data wins according to the shared migration
250
+ package's conflict rules. Migration is repeatable and does not delete the
251
+ legacy source.
252
+
253
+ On first launch, the extension detects work without mutating either home and
254
+ offers **Migrate now** or **Later**. The command
255
+ `Kimi Code: Migrate Legacy Data` remains available for manual runs and retries.
256
+
257
+ The shared marker `.migrated-to-kimi-code` can contain multiple target homes.
258
+ This prevents duplicate migration when the TUI migrated the same source first,
259
+ while still allowing a different `KIMI_CODE_HOME` to be migrated later.
260
+
261
+ Migrated sessions keep source metadata in `state.json.custom`, including the
262
+ legacy source path and session identity. This metadata also supports legacy
263
+ baseline fallback.
264
+
265
+ OAuth and MCP OAuth credentials are intentionally not copied. Refresh tokens
266
+ may rotate, so copying them can invalidate one installation or create ambiguous
267
+ ownership. The upgrade flow and release notes must tell users to authorize
268
+ again when needed.
269
+
270
+ ## File changes and baselines
271
+
272
+ The baseline feature exists only to support the VS Code File Changes panel,
273
+ diff view, Keep Changes, and Undo. It is not core session state and is not a TUI
274
+ feature.
275
+
276
+ ### New sessions
277
+
278
+ Baselines are stored under the extension's `globalStorage`, namespaced by the
279
+ resolved Kimi Code home and session ID. The home namespace prevents sessions
280
+ with the same ID in different homes from sharing baseline state.
281
+
282
+ The session runtime observes `tool.call.started` for the explicit `Write` and
283
+ `Edit` tools. It captures the first pre-change content for each file and
284
+ refreshes the File Changes panel after tool results. A missing original file is
285
+ represented as a newly created file.
286
+
287
+ - **Keep** removes the effective baseline entry from the panel.
288
+ - **Undo** restores the original content or deletes a file that did not exist.
289
+ - keeping a file and editing it again starts a new baseline period.
290
+ - all paths are resolved against the session work directory and checked for
291
+ traversal and symlink escape.
292
+
293
+ This is intentionally best-effort. The core does not pause tool execution for a
294
+ VS Code callback, and arbitrary Bash file mutations are not tracked.
295
+
296
+ ### Migrated sessions
297
+
298
+ Legacy baselines are not copied in bulk. A migrated session uses its recorded
299
+ legacy source path and reads `<legacy-session>/baseline` as a read-only
300
+ fallback.
301
+
302
+ The lookup order is:
303
+
304
+ 1. extension-owned baseline;
305
+ 2. an extension-owned marker saying a legacy path was accepted;
306
+ 3. the legacy baseline.
307
+
308
+ Keeping a legacy change writes the acceptance marker instead of deleting old
309
+ data. Forking materializes the currently effective baseline into the target
310
+ session's extension storage. If the user deletes the legacy home, the migrated
311
+ conversation remains usable but legacy diff/Undo information is no longer
312
+ available.
313
+
314
+ ## Packaging and CI
315
+
316
+ The Webview and Extension Host are separate build products:
317
+
318
+ - Vite builds browser assets for the Webview.
319
+ - the Extension Host bundle includes the Node SDK and v1 runtime; only VS Code
320
+ host modules remain external.
321
+
322
+ The production VSIX excludes tests, fixtures, source maps, local profiles,
323
+ sessions, caches, logs, tokens, and the old Python/stdio runtime. Package audit
324
+ checks the manifest, assets, unresolved runtime imports, and sensitive files
325
+ after test state has been created.
326
+
327
+ The extension produces six target artifacts:
328
+
329
+ - `darwin-x64`, `darwin-arm64`
330
+ - `linux-x64`, `linux-arm64`
331
+ - `win32-x64`, `win32-arm64`
332
+
333
+ The CI matrix builds and audits the target VSIX files. Installed-extension smoke
334
+ tests run on Linux, macOS, and Windows x64 runners. Linux checks the declared
335
+ minimum VS Code version and stable; macOS and Windows check the declared
336
+ minimum. Architecture targets without a matching runner remain package/audit
337
+ evidence rather than runtime E2E evidence.
338
+
339
+ Packaging never publishes. Marketplace and Open VSX publication require a
340
+ separate authorized release action.
341
+
342
+ ## Validation snapshot
343
+
344
+ The migration was validated with:
345
+
346
+ - package-local tests for the extension, Node SDK, v1 core, and legacy
347
+ migration;
348
+ - real temporary homes and workspaces for migration and baseline behavior;
349
+ - provider-aware model, identity, MCP, error, session replay, delete, and fork
350
+ contract tests;
351
+ - production Extension Host and Webview builds;
352
+ - six target VSIX package audits;
353
+ - an upgrade from the released `0.5.10` extension to local `0.6.0` in an
354
+ isolated profile;
355
+ - installed VSIX Extension Host smoke on Linux, macOS, and Windows x64 CI;
356
+ - local installed `darwin-arm64` VSIX smoke on VS Code `1.100.0`;
357
+ - repository lint, typecheck, tests, Nix build, workspace sync, changeset
358
+ status, and whitespace checks.
359
+
360
+ All automated CI checks for the migration branch were green when this document
361
+ was finalized.
362
+
363
+ ## Known limitations and release gates
364
+
365
+ - Remote SSH, WSL, and Dev Container behavior follows `extensionKind:
366
+ ["workspace"]` and remote home resolution, but still needs representative
367
+ release-candidate smoke in a real remote environment.
368
+ - Cursor installation, activation, Webview, and basic chat require a usable
369
+ Cursor environment; VS Code smoke is not a substitute.
370
+ - A release candidate should receive one real-account auth/provider smoke in a
371
+ workspace containing no sensitive files.
372
+ - Migrated nested subagents preserve the parent call/summary but cannot recreate
373
+ every child prompt, step, tool, and approval detail from the legacy format.
374
+ - Legacy OAuth and MCP OAuth require reauthorization.
375
+ - Bash-based deletes and arbitrary external file edits are outside baseline
376
+ tracking.
377
+ - Concurrent writes to one session from multiple processes are unsupported.
378
+ - The package script uses a pinned `@vscode/vsce` programmatic `pack()` entry;
379
+ upgrading that package requires revalidating the packaging contract.
380
+
381
+ ## Maintenance invariants
382
+
383
+ Future changes must preserve these boundaries unless a new design explicitly
384
+ replaces them:
385
+
386
+ 1. The Webview never imports the Node SDK or gains direct Node/file/auth access.
387
+ 2. `apps/vscode` never imports engine packages directly.
388
+ 3. Shared config and sessions live in the SDK-resolved Kimi Code home; editor
389
+ preferences and baselines remain VS Code-owned.
390
+ 4. Legacy migration translation stays in `packages/migration-legacy`.
391
+ 5. Session storage is accessed through SDK/core APIs, not parsed or mutated by
392
+ the Webview.
393
+ 6. Baselines remain an extension compatibility layer and do not make core tool
394
+ execution wait for VS Code.
395
+ 7. Package-only evidence is not reported as runtime E2E evidence.
396
+ 8. Build/package commands do not publish artifacts.
apps/vscode/package.json ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "kimi-code",
3
+ "publisher": "moonshot-ai",
4
+ "displayName": "Kimi Code",
5
+ "description": "Official Kimi Code plugin for VS Code",
6
+ "version": "0.7.5",
7
+ "private": true,
8
+ "license": "Apache-2.0",
9
+ "type": "module",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/MoonshotAI/kimi-code.git",
13
+ "directory": "apps/vscode"
14
+ },
15
+ "engines": {
16
+ "vscode": "^1.100.0"
17
+ },
18
+ "extensionKind": [
19
+ "workspace"
20
+ ],
21
+ "capabilities": {
22
+ "untrustedWorkspaces": {
23
+ "supported": false
24
+ },
25
+ "virtualWorkspaces": false
26
+ },
27
+ "categories": [
28
+ "AI",
29
+ "Chat",
30
+ "Programming Languages"
31
+ ],
32
+ "keywords": [
33
+ "ai",
34
+ "assistant",
35
+ "code",
36
+ "chat",
37
+ "agent",
38
+ "mcp",
39
+ "skills",
40
+ "json",
41
+ "autocomplete",
42
+ "moonshot",
43
+ "kimi",
44
+ "moonshot-ai"
45
+ ],
46
+ "pricing": "Free",
47
+ "activationEvents": [],
48
+ "main": "./dist/extension.js",
49
+ "icon": "resources/kimi-icon-storefront.png",
50
+ "contributes": {
51
+ "configuration": {
52
+ "title": "Kimi Code",
53
+ "properties": {
54
+ "kimi.yoloMode": {
55
+ "type": "boolean",
56
+ "default": false,
57
+ "description": "Auto-approve regular tool calls; the agent may still ask questions"
58
+ },
59
+ "kimi.autosave": {
60
+ "type": "boolean",
61
+ "default": true,
62
+ "description": "Automatically save files before Kimi reads or writes them"
63
+ },
64
+ "kimi.enableNewConversationShortcut": {
65
+ "type": "boolean",
66
+ "default": false,
67
+ "description": "Use Cmd/Ctrl+N to start a new conversation when Kimi is focused"
68
+ },
69
+ "kimi.useCtrlEnterToSend": {
70
+ "type": "boolean",
71
+ "default": false,
72
+ "description": "Use Ctrl/Cmd+Enter to send prompts instead of Enter"
73
+ },
74
+ "kimi.showThinkingContent": {
75
+ "type": "boolean",
76
+ "default": true,
77
+ "description": "Show thinking/reasoning content in the chat UI"
78
+ },
79
+ "kimi.showThinkingExpanded": {
80
+ "type": "boolean",
81
+ "default": false,
82
+ "description": "Auto-expand thinking/reasoning sections when shown (requires 'Show Thinking Content' to be enabled)"
83
+ },
84
+ "kimi.editorContext": {
85
+ "type": "string",
86
+ "default": "never",
87
+ "enum": [
88
+ "never",
89
+ "onConversationStart",
90
+ "onFileChange"
91
+ ],
92
+ "enumDescriptions": [
93
+ "Never share editor context",
94
+ "Share once when conversation starts",
95
+ "Share when active file changes"
96
+ ],
97
+ "description": "Control when to share the active editor's file and cursor position with Kimi"
98
+ }
99
+ }
100
+ },
101
+ "commands": [
102
+ {
103
+ "command": "kimi.clearAllState",
104
+ "title": "Kimi Code: [DEBUG] Clear All State"
105
+ },
106
+ {
107
+ "command": "kimi.openInTab",
108
+ "title": "Kimi Code: Open in New Tab",
109
+ "icon": "$(link-external)"
110
+ },
111
+ {
112
+ "command": "kimi.openInSideBar",
113
+ "title": "Kimi Code: Open in Side Panel",
114
+ "icon": "$(layout-sidebar-left)"
115
+ },
116
+ {
117
+ "command": "kimi.focusInput",
118
+ "title": "Kimi Code: Focus Input",
119
+ "icon": "$(edit)"
120
+ },
121
+ {
122
+ "command": "kimi.insertMention",
123
+ "title": "Kimi Code: Insert Current File",
124
+ "icon": "$(mention)"
125
+ },
126
+ {
127
+ "command": "kimi.newConversation",
128
+ "title": "Kimi Code: New Conversation",
129
+ "icon": "$(add)"
130
+ },
131
+ {
132
+ "command": "kimi.showLogs",
133
+ "title": "Kimi Code: Show Logs",
134
+ "icon": "$(output)"
135
+ },
136
+ {
137
+ "command": "kimi.resetKimi",
138
+ "title": "Kimi Code: Reset Kimi (in case of any issues/no response)",
139
+ "icon": "$(refresh)"
140
+ },
141
+ {
142
+ "command": "kimi.logout",
143
+ "title": "Kimi Code: Logout",
144
+ "icon": "$(sign-out)"
145
+ },
146
+ {
147
+ "command": "kimi.migrateLegacyData",
148
+ "title": "Kimi Code: Migrate Legacy Data"
149
+ }
150
+ ],
151
+ "keybindings": [
152
+ {
153
+ "command": "kimi.focusInput",
154
+ "key": "ctrl+shift+k",
155
+ "mac": "cmd+shift+k"
156
+ },
157
+ {
158
+ "command": "kimi.insertMention",
159
+ "key": "alt+k",
160
+ "mac": "alt+k",
161
+ "when": "editorTextFocus"
162
+ }
163
+ ],
164
+ "viewsContainers": {
165
+ "activitybar": [
166
+ {
167
+ "id": "kimi-sidebar",
168
+ "title": "Kimi Code",
169
+ "icon": "resources/kimi-icon.svg"
170
+ }
171
+ ]
172
+ },
173
+ "views": {
174
+ "kimi-sidebar": [
175
+ {
176
+ "type": "webview",
177
+ "id": "kimi.webview",
178
+ "name": "Kimi Code"
179
+ }
180
+ ]
181
+ },
182
+ "menus": {
183
+ "view/title": [
184
+ {
185
+ "command": "kimi.openInTab",
186
+ "when": "view == kimi.webview",
187
+ "group": "navigation"
188
+ },
189
+ {
190
+ "command": "kimi.newConversation",
191
+ "when": "view == kimi.webview",
192
+ "group": "navigation"
193
+ }
194
+ ],
195
+ "commandPalette": [
196
+ {
197
+ "command": "kimi.clearAllState",
198
+ "when": "isDevelopment"
199
+ },
200
+ {
201
+ "command": "kimi.openInTab",
202
+ "when": "true"
203
+ },
204
+ {
205
+ "command": "kimi.openInSideBar",
206
+ "when": "true"
207
+ },
208
+ {
209
+ "command": "kimi.focusInput",
210
+ "when": "true"
211
+ },
212
+ {
213
+ "command": "kimi.insertMention",
214
+ "when": "true"
215
+ },
216
+ {
217
+ "command": "kimi.newConversation",
218
+ "when": "true"
219
+ },
220
+ {
221
+ "command": "kimi.showLogs",
222
+ "when": "true"
223
+ },
224
+ {
225
+ "command": "kimi.resetKimi",
226
+ "when": "true"
227
+ },
228
+ {
229
+ "command": "kimi.logout",
230
+ "when": "kimi.isLoggedIn"
231
+ },
232
+ {
233
+ "command": "kimi.migrateLegacyData",
234
+ "when": "true"
235
+ }
236
+ ],
237
+ "editor/context": [
238
+ {
239
+ "command": "kimi.insertMention",
240
+ "group": "moonshot-ai",
241
+ "when": "editorTextFocus"
242
+ }
243
+ ]
244
+ }
245
+ },
246
+ "scripts": {
247
+ "vscode:prepublish": "pnpm run build",
248
+ "dev": "pnpm run dev:prepare && pnpm run \"/^dev:(extension|webview)$/\"",
249
+ "dev:prepare": "node scripts/prepare-dev.mjs",
250
+ "dev:extension": "node scripts/watch-extension.mjs",
251
+ "dev:webview": "vite build --config webview-ui/vite.config.ts --watch --sourcemap",
252
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p webview-ui/tsconfig.json --noEmit",
253
+ "build": "pnpm run build:extension && pnpm run build:webview",
254
+ "build:webview": "vite build --config webview-ui/vite.config.ts",
255
+ "build:extension": "tsdown --config tsdown.config.ts",
256
+ "test": "vitest run --config vitest.config.ts",
257
+ "test:extension-host": "node scripts/extension-host-smoke.mjs",
258
+ "package:platform": "node scripts/vsix-package.mjs",
259
+ "package:verify": "node scripts/vsix-verify.mjs",
260
+ "publish:vsix": "node scripts/vsix-publish.mjs",
261
+ "publish:ovsx": "node scripts/ovsx-publish.mjs"
262
+ },
263
+ "devDependencies": {
264
+ "@tailwindcss/vite": "^4.1.4",
265
+ "@testing-library/dom": "^10.4.1",
266
+ "@testing-library/react": "^16.3.3",
267
+ "@testing-library/user-event": "^14.6.6",
268
+ "@types/diff": "^8.0.0",
269
+ "@types/katex": "^0.16.8",
270
+ "@types/node": "^22.15.3",
271
+ "@types/react": "^19.1.2",
272
+ "@types/react-dom": "^19.1.2",
273
+ "@types/react-scroll-to-bottom": "^4.2.5",
274
+ "@types/react-syntax-highlighter": "^15.5.13",
275
+ "@types/vscode": "1.100.0",
276
+ "@vitejs/plugin-react": "^4.4.1",
277
+ "@vscode/test-cli": "^0.0.11",
278
+ "@vscode/test-electron": "^2.5.2",
279
+ "@vscode/vsce": "3.9.2",
280
+ "acorn": "8.17.0",
281
+ "jsdom": "^30.0.1",
282
+ "ovsx": "1.0.2",
283
+ "tailwindcss": "^4.1.4",
284
+ "vite": "^6.3.3",
285
+ "vite-plugin-css-injected-by-js": "^3.5.2",
286
+ "vitest": "4.1.4"
287
+ },
288
+ "dependencies": {
289
+ "@base-ui/react": "^1.0.0",
290
+ "@fontsource-variable/inter": "5.2.8",
291
+ "@moonshot-ai/kimi-code-oauth": "workspace:^",
292
+ "@moonshot-ai/kimi-code-sdk": "workspace:^",
293
+ "@moonshot-ai/migration-legacy": "workspace:^",
294
+ "@radix-ui/react-accordion": "^1.2.12",
295
+ "@tabler/icons-react": "^3.36.0",
296
+ "@tanstack/react-query": "^5.74.4",
297
+ "class-variance-authority": "^0.7.1",
298
+ "clsx": "^2.1.1",
299
+ "cmdk": "^1.1.1",
300
+ "diff": "^8.0.2",
301
+ "fuse.js": "^7.1.0",
302
+ "heic-to": "^1.0.2",
303
+ "immer": "^11.1.0",
304
+ "katex": "^0.17.0",
305
+ "next-themes": "^0.4.6",
306
+ "radix-ui": "^1.4.3",
307
+ "react": "^19.1.0",
308
+ "react-dom": "^19.1.0",
309
+ "react-markdown": "^10.1.0",
310
+ "react-scroll-to-bottom": "^4.2.0",
311
+ "react-syntax-highlighter": "^16.1.0",
312
+ "rehype-katex": "^7.0.1",
313
+ "remark-gfm": "^4.0.1",
314
+ "remark-math": "^6.0.0",
315
+ "shadcn": "^3.6.2",
316
+ "sonner": "^2.0.7",
317
+ "tailwind-merge": "^3.4.0",
318
+ "tw-animate-css": "^1.4.0",
319
+ "zustand": "^5"
320
+ }
321
+ }
apps/vscode/resources/kimi-icon-storefront.png ADDED
apps/vscode/resources/kimi-icon.svg ADDED
apps/vscode/scripts/extension-host-smoke.d.mts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface ExtensionHostSmokeOptions {
2
+ version?: string;
3
+ vsixPath?: string;
4
+ cachePath?: string;
5
+ }
6
+
7
+ export interface ExtensionHostSmokeResult {
8
+ version: string;
9
+ vscodeVersion: string;
10
+ vsixPath: string;
11
+ cachePath: string;
12
+ }
13
+
14
+ export function runExtensionHostSmoke(
15
+ options?: ExtensionHostSmokeOptions,
16
+ ): Promise<ExtensionHostSmokeResult>;
apps/vscode/scripts/local-cli.mjs ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { spawnSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { createRequire } from 'node:module';
5
+
6
+ const require = createRequire(import.meta.url);
7
+
8
+ export function resolveLocalCli(packageName, executableName) {
9
+ let packageJsonPath;
10
+ try {
11
+ packageJsonPath = require.resolve(`${packageName}/package.json`);
12
+ } catch (error) {
13
+ throw new Error(
14
+ `Local CLI dependency ${packageName} is not installed. Run pnpm install; runtime CLI downloads are disabled.`,
15
+ { cause: error },
16
+ );
17
+ }
18
+
19
+ const manifest = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
20
+ const relativeBin = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.[executableName];
21
+ if (typeof relativeBin !== 'string') {
22
+ throw new Error(`${packageName} does not declare the expected "${executableName}" binary.`);
23
+ }
24
+ return resolve(dirname(packageJsonPath), relativeBin);
25
+ }
26
+
27
+ export function runLocalCli(packageName, executableName, args, options = {}) {
28
+ const cliPath = resolveLocalCli(packageName, executableName);
29
+ const result = spawnSync(process.execPath, [cliPath, ...args], {
30
+ cwd: options.cwd,
31
+ env: options.env ?? process.env,
32
+ encoding: options.encoding,
33
+ stdio: options.stdio ?? 'inherit',
34
+ });
35
+ if (result.error !== undefined) {
36
+ throw new Error(`Unable to start local ${executableName}: ${result.error.message}`, {
37
+ cause: result.error,
38
+ });
39
+ }
40
+ if (result.status !== 0) {
41
+ const output = [result.stdout, result.stderr].filter(Boolean).join('');
42
+ throw new Error(
43
+ `Local ${executableName} exited with code ${result.status ?? 'unknown'}${output ? `:\n${output}` : ''}`,
44
+ );
45
+ }
46
+ return result;
47
+ }
apps/vscode/scripts/ovsx-publish.mjs ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ import { existsSync } from 'node:fs';
3
+
4
+ import { runLocalCli } from './local-cli.mjs';
5
+ import { parsePublishArguments, publishUsage } from './publish-args.mjs';
6
+ import { extensionRoot, isMainModule } from './vsix-targets.mjs';
7
+ import { verifyVsix } from './vsix-verify.mjs';
8
+
9
+ async function main() {
10
+ const options = parsePublishArguments(process.argv.slice(2));
11
+ if (options.help) {
12
+ console.log(publishUsage('Open VSX'));
13
+ return;
14
+ }
15
+ if (!process.env.OVSX_PAT) throw new Error('OVSX_PAT is required to publish.');
16
+
17
+ await verifyInputs(options);
18
+ for (const file of options.files) {
19
+ console.log(`Publishing verified package ${file}...`);
20
+ try {
21
+ runLocalCli('ovsx', 'ovsx', ['publish', file], {
22
+ cwd: extensionRoot,
23
+ encoding: 'utf8',
24
+ stdio: 'pipe',
25
+ });
26
+ } catch (error) {
27
+ if (/already exists/i.test(error instanceof Error ? error.message : String(error))) {
28
+ console.log(`Package already exists: ${file}`);
29
+ continue;
30
+ }
31
+ throw error;
32
+ }
33
+ }
34
+ }
35
+
36
+ async function verifyInputs(options) {
37
+ for (let index = 0; index < options.targets.length; index += 1) {
38
+ const file = options.files[index];
39
+ if (!existsSync(file)) {
40
+ throw new Error(`Missing VSIX ${file}. Run pnpm run package:platform first.`);
41
+ }
42
+ await verifyVsix(file, options.targets[index], { sourceRoot: extensionRoot });
43
+ }
44
+ }
45
+
46
+ if (isMainModule(import.meta.url)) {
47
+ main().catch((error) => {
48
+ console.error(`Open VSX publish failed: ${error instanceof Error ? error.message : String(error)}`);
49
+ process.exitCode = 1;
50
+ });
51
+ }
apps/vscode/scripts/prepare-dev.mjs ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ import { mkdir, rm, writeFile } from 'node:fs/promises';
3
+ import { basename, dirname, join, parse, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ import { isMainModule } from './vsix-targets.mjs';
7
+
8
+ const SAFE_DIRECTORY_NAME = 'vscode-extension-dev';
9
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
10
+ const monorepoRoot = resolve(scriptDir, '../../..');
11
+ const defaultBaseDir = join(monorepoRoot, '.tmp', SAFE_DIRECTORY_NAME);
12
+
13
+ export async function prepareDevEnvironment(baseDir = defaultBaseDir) {
14
+ const root = resolve(baseDir);
15
+ assertSafeRoot(root);
16
+ await rm(root, { recursive: true, force: true });
17
+
18
+ const paths = {
19
+ root,
20
+ userData: join(root, 'user-data'),
21
+ extensions: join(root, 'extensions'),
22
+ kimiHome: join(root, 'kimi-home'),
23
+ workspace: join(root, 'workspace'),
24
+ };
25
+ await Promise.all(
26
+ Object.values(paths)
27
+ .filter((path) => path !== root)
28
+ .map((path) => mkdir(path, { recursive: true })),
29
+ );
30
+ await writeFile(
31
+ join(paths.workspace, 'README.md'),
32
+ '# Isolated Kimi Code extension development workspace\n',
33
+ );
34
+ return paths;
35
+ }
36
+
37
+ function assertSafeRoot(root) {
38
+ const parsed = parse(root);
39
+ if (root === parsed.root || basename(root) !== SAFE_DIRECTORY_NAME) {
40
+ throw new Error(
41
+ `Refusing to reset unsafe development directory "${root}"; it must end in ${SAFE_DIRECTORY_NAME}.`,
42
+ );
43
+ }
44
+ }
45
+
46
+ function parseArguments(argv) {
47
+ let baseDir = defaultBaseDir;
48
+ let help = false;
49
+ for (let index = 0; index < argv.length; index += 1) {
50
+ const argument = argv[index];
51
+ if (argument === '--') {
52
+ continue;
53
+ } else if (argument === '--help' || argument === '-h') {
54
+ help = true;
55
+ } else if (argument === '--base-dir') {
56
+ const value = argv[++index];
57
+ if (value === undefined || value.startsWith('-')) throw new Error('--base-dir requires a value.');
58
+ baseDir = value;
59
+ } else {
60
+ throw new Error(`Unknown option: ${argument}`);
61
+ }
62
+ }
63
+ return { baseDir, help };
64
+ }
65
+
66
+ async function main() {
67
+ const options = parseArguments(process.argv.slice(2));
68
+ if (options.help) {
69
+ console.log('Usage: node scripts/prepare-dev.mjs [--base-dir <.../vscode-extension-dev>]');
70
+ return;
71
+ }
72
+ const paths = await prepareDevEnvironment(options.baseDir);
73
+ console.log(`Prepared isolated VS Code profile: ${paths.root}`);
74
+ console.log(`KIMI_CODE_HOME=${paths.kimiHome}`);
75
+ console.log(`Workspace=${paths.workspace}`);
76
+ }
77
+
78
+ if (isMainModule(import.meta.url)) {
79
+ main().catch((error) => {
80
+ console.error(`Development environment setup failed: ${error instanceof Error ? error.message : String(error)}`);
81
+ process.exitCode = 1;
82
+ });
83
+ }
apps/vscode/scripts/publish-args.mjs ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { join, resolve } from 'node:path';
2
+
3
+ import {
4
+ defaultVsixOutputDir,
5
+ normalizeVsixTargets,
6
+ vsixFileName,
7
+ } from './vsix-targets.mjs';
8
+
9
+ export function parsePublishArguments(argv) {
10
+ const targets = [];
11
+ let outputDir = defaultVsixOutputDir;
12
+ let help = false;
13
+
14
+ for (let index = 0; index < argv.length; index += 1) {
15
+ const argument = argv[index];
16
+ if (argument === '--') {
17
+ continue;
18
+ } else if (argument === '--help' || argument === '-h') {
19
+ help = true;
20
+ } else if (argument === '--out-dir') {
21
+ outputDir = requireOptionValue(argv, ++index, '--out-dir');
22
+ } else if (argument === '--target') {
23
+ targets.push(requireOptionValue(argv, ++index, '--target'));
24
+ } else if (argument.startsWith('-')) {
25
+ throw new Error(`Unknown option: ${argument}`);
26
+ } else {
27
+ targets.push(argument);
28
+ }
29
+ }
30
+
31
+ const normalizedTargets = normalizeVsixTargets(targets);
32
+ const resolvedOutputDir = resolve(outputDir);
33
+ return {
34
+ help,
35
+ targets: normalizedTargets,
36
+ outputDir: resolvedOutputDir,
37
+ files: normalizedTargets.map((target) => join(resolvedOutputDir, vsixFileName(target))),
38
+ };
39
+ }
40
+
41
+ function requireOptionValue(argv, index, option) {
42
+ const value = argv[index];
43
+ if (value !== undefined && !value.startsWith('-')) return value;
44
+ throw new Error(`${option} requires a value.`);
45
+ }
46
+
47
+ export function publishUsage(marketplace) {
48
+ return [
49
+ `Usage: node scripts/${marketplace === 'Open VSX' ? 'ovsx' : 'vsix'}-publish.mjs [targets...] [--out-dir <directory>]`,
50
+ '',
51
+ `Publishes already-built, re-verified VSIX files to ${marketplace}.`,
52
+ 'This command never builds or downloads a CLI.',
53
+ ].join('\n');
54
+ }
apps/vscode/scripts/vsix-package.mjs ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
3
+ import { mkdir } from 'node:fs/promises';
4
+ import { join, resolve } from 'node:path';
5
+
6
+ import { runLocalCli } from './local-cli.mjs';
7
+ import {
8
+ defaultVsixOutputDir,
9
+ extensionRoot,
10
+ isMainModule,
11
+ normalizeVsixTargets,
12
+ vsixFileName,
13
+ } from './vsix-targets.mjs';
14
+ import { verifyVsix } from './vsix-verify.mjs';
15
+
16
+ const require = createRequire(import.meta.url);
17
+
18
+ function parseArguments(argv) {
19
+ const targets = [];
20
+ let outputDir = defaultVsixOutputDir;
21
+ let dryRun = false;
22
+ let help = false;
23
+
24
+ for (let index = 0; index < argv.length; index += 1) {
25
+ const argument = argv[index];
26
+ if (argument === '--') {
27
+ continue;
28
+ } else if (argument === '--help' || argument === '-h') {
29
+ help = true;
30
+ } else if (argument === '--dry-run') {
31
+ dryRun = true;
32
+ } else if (argument === '--out-dir') {
33
+ outputDir = requireOptionValue(argv, ++index, '--out-dir');
34
+ } else if (argument === '--target') {
35
+ targets.push(requireOptionValue(argv, ++index, '--target'));
36
+ } else if (argument.startsWith('-')) {
37
+ throw new Error(`Unknown option: ${argument}`);
38
+ } else {
39
+ targets.push(argument);
40
+ }
41
+ }
42
+
43
+ return {
44
+ targets: normalizeVsixTargets(targets),
45
+ outputDir: resolve(outputDir),
46
+ dryRun,
47
+ help,
48
+ };
49
+ }
50
+
51
+ function requireOptionValue(argv, index, option) {
52
+ const value = argv[index];
53
+ if (value !== undefined && !value.startsWith('-')) return value;
54
+ throw new Error(`${option} requires a value.`);
55
+ }
56
+
57
+ function usage() {
58
+ return [
59
+ 'Usage: node scripts/vsix-package.mjs [targets...] [--out-dir <directory>]',
60
+ ' node scripts/vsix-package.mjs --target win32-x64 --dry-run',
61
+ '',
62
+ 'With no targets, all six supported VSIX targets are built and audited.',
63
+ 'This command never publishes an extension.',
64
+ ].join('\n');
65
+ }
66
+
67
+ async function main() {
68
+ const options = parseArguments(process.argv.slice(2));
69
+ if (options.help) {
70
+ console.log(usage());
71
+ return;
72
+ }
73
+
74
+ await mkdir(options.outputDir, { recursive: true });
75
+ console.log(`VSIX targets: ${options.targets.join(', ')}`);
76
+ console.log(`Output directory: ${options.outputDir}`);
77
+
78
+ if (!options.dryRun) {
79
+ console.log('Building the extension once before packaging all targets...');
80
+ buildExtension();
81
+ }
82
+
83
+ const packVsix = options.dryRun ? undefined : loadVscePack();
84
+
85
+ for (const target of options.targets) {
86
+ const outputPath = join(options.outputDir, vsixFileName(target));
87
+ if (options.dryRun) {
88
+ console.log(`Would package ${target} -> ${outputPath}`);
89
+ continue;
90
+ }
91
+
92
+ console.log(`\nPackaging ${target} with the workspace @vscode/vsce...`);
93
+ await packVsix({
94
+ cwd: extensionRoot,
95
+ dependencies: false,
96
+ packagePath: outputPath,
97
+ target,
98
+ });
99
+ const result = await verifyVsix(outputPath, target, { sourceRoot: extensionRoot });
100
+ console.log(
101
+ `Verified ${target}: ${result.files} files, ${result.bytes} unpacked bytes; package/static checks passed.`,
102
+ );
103
+ }
104
+
105
+ if (!options.dryRun) {
106
+ console.log(
107
+ '\nVSIX packaging complete. These are package-only results until each target runs in its matching extension host.',
108
+ );
109
+ }
110
+ }
111
+
112
+ function buildExtension() {
113
+ runLocalCli('tsdown', 'tsdown', ['--config', 'tsdown.config.ts'], { cwd: extensionRoot });
114
+ runLocalCli('vite', 'vite', ['build', '--config', 'webview-ui/vite.config.ts'], {
115
+ cwd: extensionRoot,
116
+ });
117
+ }
118
+
119
+ function loadVscePack() {
120
+ try {
121
+ // VSCE's public API always runs vscode:prepublish. We build once above and pin VSCE,
122
+ // so use its package phase directly instead of rebuilding for every target.
123
+ const module = require('@vscode/vsce/out/package.js');
124
+ if (typeof module.pack !== 'function') {
125
+ throw new TypeError('the installed package does not expose pack()');
126
+ }
127
+ return module.pack;
128
+ } catch (error) {
129
+ throw new Error(
130
+ 'The workspace @vscode/vsce package API is unavailable. Run pnpm install; runtime CLI downloads are disabled.',
131
+ { cause: error },
132
+ );
133
+ }
134
+ }
135
+
136
+ if (isMainModule(import.meta.url)) {
137
+ main().catch((error) => {
138
+ console.error(`VSIX packaging failed: ${error instanceof Error ? error.message : String(error)}`);
139
+ process.exitCode = 1;
140
+ });
141
+ }
apps/vscode/scripts/vsix-publish.mjs ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ import { existsSync } from 'node:fs';
3
+
4
+ import { runLocalCli } from './local-cli.mjs';
5
+ import { parsePublishArguments, publishUsage } from './publish-args.mjs';
6
+ import { extensionRoot, isMainModule } from './vsix-targets.mjs';
7
+ import { verifyVsix } from './vsix-verify.mjs';
8
+
9
+ async function main() {
10
+ const options = parsePublishArguments(process.argv.slice(2));
11
+ if (options.help) {
12
+ console.log(publishUsage('Visual Studio Marketplace'));
13
+ return;
14
+ }
15
+ if (!process.env.VSCE_PAT) throw new Error('VSCE_PAT is required to publish.');
16
+
17
+ await verifyInputs(options);
18
+ for (const file of options.files) {
19
+ console.log(`Publishing verified package ${file}...`);
20
+ runLocalCli(
21
+ '@vscode/vsce',
22
+ 'vsce',
23
+ ['publish', '--packagePath', file, '--skip-duplicate'],
24
+ { cwd: extensionRoot },
25
+ );
26
+ }
27
+ }
28
+
29
+ async function verifyInputs(options) {
30
+ for (let index = 0; index < options.targets.length; index += 1) {
31
+ const file = options.files[index];
32
+ if (!existsSync(file)) {
33
+ throw new Error(`Missing VSIX ${file}. Run pnpm run package:platform first.`);
34
+ }
35
+ await verifyVsix(file, options.targets[index], { sourceRoot: extensionRoot });
36
+ }
37
+ }
38
+
39
+ if (isMainModule(import.meta.url)) {
40
+ main().catch((error) => {
41
+ console.error(`Marketplace publish failed: ${error instanceof Error ? error.message : String(error)}`);
42
+ process.exitCode = 1;
43
+ });
44
+ }
apps/vscode/scripts/vsix-targets.mjs ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { dirname, join, resolve } from 'node:path';
2
+ import { fileURLToPath, pathToFileURL } from 'node:url';
3
+
4
+ export const VSIX_TARGETS = Object.freeze([
5
+ 'darwin-x64',
6
+ 'darwin-arm64',
7
+ 'linux-x64',
8
+ 'linux-arm64',
9
+ 'win32-x64',
10
+ 'win32-arm64',
11
+ ]);
12
+
13
+ export const extensionRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
14
+ export const defaultVsixOutputDir = join(extensionRoot, 'artifacts', 'vsix');
15
+
16
+ export function vsixFileName(target) {
17
+ assertVsixTarget(target);
18
+ return `kimi-code-${target}.vsix`;
19
+ }
20
+
21
+ export function normalizeVsixTargets(values) {
22
+ const requested = values.length === 0 || values.includes('all') ? VSIX_TARGETS : values;
23
+ if (values.includes('all') && values.length > 1) {
24
+ throw new Error('Target "all" cannot be combined with an explicit VSIX target.');
25
+ }
26
+
27
+ const targets = [];
28
+ for (const target of requested) {
29
+ assertVsixTarget(target);
30
+ if (!targets.includes(target)) targets.push(target);
31
+ }
32
+ return targets;
33
+ }
34
+
35
+ export function assertVsixTarget(target) {
36
+ if (VSIX_TARGETS.includes(target)) return;
37
+ throw new Error(
38
+ `Unknown VSIX target "${target}". Expected one of: ${VSIX_TARGETS.join(', ')}`,
39
+ );
40
+ }
41
+
42
+ export function isMainModule(metaUrl) {
43
+ const argvPath = process.argv[1];
44
+ return argvPath !== undefined && pathToFileURL(resolve(argvPath)).href === metaUrl;
45
+ }
apps/vscode/scripts/vsix-verify.mjs ADDED
@@ -0,0 +1,490 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ import { builtinModules } from 'node:module';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { existsSync } from 'node:fs';
5
+ import {
6
+ mkdtemp,
7
+ mkdir,
8
+ readFile,
9
+ readdir,
10
+ rm,
11
+ stat,
12
+ writeFile,
13
+ } from 'node:fs/promises';
14
+ import { homedir, tmpdir } from 'node:os';
15
+ import { dirname, extname, join, relative, resolve, sep } from 'node:path';
16
+ import { pathToFileURL } from 'node:url';
17
+ import { isDeepStrictEqual } from 'node:util';
18
+ import { parse } from 'acorn';
19
+
20
+ import { extractZip } from './zip.mjs';
21
+ import {
22
+ defaultVsixOutputDir,
23
+ extensionRoot,
24
+ isMainModule,
25
+ normalizeVsixTargets,
26
+ vsixFileName,
27
+ } from './vsix-targets.mjs';
28
+
29
+ const REQUIRED_WEBVIEW_FILES = [
30
+ 'dist/webview.js',
31
+ 'dist/kimi-banner-dark.svg',
32
+ 'dist/kimi-banner-light.svg',
33
+ 'dist/kimi-logo.png',
34
+ ];
35
+ const FORBIDDEN_PATH_SEGMENTS = new Set([
36
+ '.kimi',
37
+ '.kimi-code',
38
+ '.vscode',
39
+ '__tests__',
40
+ 'cache',
41
+ 'caches',
42
+ 'credentials',
43
+ 'logs',
44
+ 'node_modules',
45
+ 'profile',
46
+ 'profiles',
47
+ 'runtime',
48
+ 'scripts',
49
+ 'session',
50
+ 'sessions',
51
+ 'src',
52
+ 'state',
53
+ 'states',
54
+ 'test',
55
+ 'tests',
56
+ 'tokens',
57
+ 'webview-ui',
58
+ ]);
59
+ const FORBIDDEN_EXTENSIONS = new Set(['.jsonl', '.log', '.map', '.py', '.pyc', '.ts', '.tsx']);
60
+ const TEXT_EXTENSIONS = new Set([
61
+ '.cjs',
62
+ '.css',
63
+ '.html',
64
+ '.js',
65
+ '.json',
66
+ '.md',
67
+ '.mjs',
68
+ '.svg',
69
+ '.txt',
70
+ '.xml',
71
+ ]);
72
+ const BUILTIN_IMPORTS = new Set(
73
+ builtinModules.flatMap((name) => [name, name.startsWith('node:') ? name.slice(5) : `node:${name}`]),
74
+ );
75
+ // `ws` probes these native accelerators inside try/catch and immediately uses
76
+ // its bundled JavaScript fallback when they are absent. They are not required
77
+ // runtime dependencies and must not be shipped as cross-platform native code.
78
+ const OPTIONAL_FALLBACK_IMPORTS = new Set(['bufferutil', 'canvas', 'utf-8-validate']);
79
+ const MANIFEST_FIELDS = [
80
+ 'name',
81
+ 'publisher',
82
+ 'displayName',
83
+ 'version',
84
+ 'engines',
85
+ 'extensionKind',
86
+ 'capabilities',
87
+ 'activationEvents',
88
+ 'main',
89
+ 'icon',
90
+ ];
91
+ const CONTRIBUTE_FIELDS = [
92
+ 'commands',
93
+ 'configuration',
94
+ 'keybindings',
95
+ 'menus',
96
+ 'views',
97
+ 'viewsContainers',
98
+ ];
99
+
100
+ export async function verifyVsix(vsixPath, target, options = {}) {
101
+ const extractionRoot = await mkdtemp(join(tmpdir(), 'kimi-vsix-audit-'));
102
+ try {
103
+ await extractZip(vsixPath, extractionRoot);
104
+ return await auditExtractedVsix(extractionRoot, target, options);
105
+ } finally {
106
+ await rm(extractionRoot, { recursive: true, force: true });
107
+ }
108
+ }
109
+
110
+ export async function auditExtractedVsix(extractionRoot, target, options = {}) {
111
+ const sourceRoot = options.sourceRoot ?? extensionRoot;
112
+ const extensionDir = join(extractionRoot, 'extension');
113
+ const files = await listFiles(extractionRoot);
114
+ const fileSet = new Set(files);
115
+
116
+ requireFile(fileSet, 'extension.vsixmanifest');
117
+ requireFile(fileSet, '[Content_Types].xml');
118
+ requireFile(fileSet, 'extension/package.json');
119
+ const packagedManifest = await readJson(join(extensionDir, 'package.json'), 'package.json');
120
+ const sourceManifest = await readJson(join(sourceRoot, 'package.json'), 'source package.json');
121
+
122
+ await verifyTargetManifest(extractionRoot, target);
123
+ verifyPackageManifest(packagedManifest, sourceManifest);
124
+ verifyRequiredFiles(fileSet, packagedManifest);
125
+ verifyForbiddenFiles(files);
126
+ await verifyNoSensitiveContent(extractionRoot, files, sourceRoot, options.forbiddenText ?? []);
127
+ await verifyRuntimeImports(extensionDir, files);
128
+ await verifyEntryImport(extensionDir, packagedManifest.main);
129
+
130
+ const bytes = await totalSize(extractionRoot, files);
131
+ return { target, files: files.length, bytes };
132
+ }
133
+
134
+ async function verifyTargetManifest(extractionRoot, target) {
135
+ const xml = await readFile(join(extractionRoot, 'extension.vsixmanifest'), 'utf8');
136
+ const actual = xml.match(/\bTargetPlatform="([^"]+)"/)?.[1];
137
+ if (actual !== target) {
138
+ throw new Error(
139
+ `VSIX manifest target is ${actual ?? 'missing'}, expected ${target}.`,
140
+ );
141
+ }
142
+ }
143
+
144
+ function verifyPackageManifest(packaged, source) {
145
+ if (typeof packaged.main !== 'string' || !packaged.main.endsWith('.js')) {
146
+ throw new Error(`Packaged extension main must be a .js entry, got ${String(packaged.main)}.`);
147
+ }
148
+ if (packaged.main !== './dist/extension.js') {
149
+ throw new Error(`Packaged extension main is ${packaged.main}, expected ./dist/extension.js.`);
150
+ }
151
+
152
+ for (const field of MANIFEST_FIELDS) {
153
+ if (!isDeepStrictEqual(packaged[field], source[field])) {
154
+ throw new Error(`Packaged package.json field "${field}" does not match the source manifest.`);
155
+ }
156
+ }
157
+ for (const field of CONTRIBUTE_FIELDS) {
158
+ if (!isDeepStrictEqual(packaged.contributes?.[field], source.contributes?.[field])) {
159
+ throw new Error(
160
+ `Packaged package.json contributes.${field} does not match the source manifest.`,
161
+ );
162
+ }
163
+ }
164
+ }
165
+
166
+ function verifyRequiredFiles(fileSet, manifest) {
167
+ const required = [
168
+ 'extension/LICENSE.txt',
169
+ `extension/${stripLeadingDotSlash(manifest.main)}`,
170
+ ...REQUIRED_WEBVIEW_FILES.map((file) => `extension/${file}`),
171
+ ];
172
+ requireOneOf(fileSet, ['extension/README.md', 'extension/readme.md'], 'marketplace README');
173
+ if (typeof manifest.icon === 'string') required.push(`extension/${manifest.icon}`);
174
+
175
+ for (const container of Object.values(manifest.contributes?.viewsContainers ?? {})) {
176
+ if (!Array.isArray(container)) continue;
177
+ for (const view of container) {
178
+ if (typeof view?.icon === 'string') required.push(`extension/${view.icon}`);
179
+ }
180
+ }
181
+ for (const file of required) requireFile(fileSet, file);
182
+ }
183
+
184
+ function verifyForbiddenFiles(files) {
185
+ for (const file of files) {
186
+ const normalized = file.replaceAll('\\', '/');
187
+ const lower = normalized.toLowerCase();
188
+ const extensionRelative = lower.startsWith('extension/') ? lower.slice('extension/'.length) : lower;
189
+ const segments = extensionRelative.split('/');
190
+ const forbiddenSegment = segments.find((segment) => FORBIDDEN_PATH_SEGMENTS.has(segment));
191
+ if (forbiddenSegment !== undefined) {
192
+ throw new Error(`Forbidden package path segment "${forbiddenSegment}" in ${normalized}.`);
193
+ }
194
+ if (FORBIDDEN_EXTENSIONS.has(extname(lower))) {
195
+ throw new Error(`Forbidden package file type in ${normalized}.`);
196
+ }
197
+ if (
198
+ lower.includes('kimi-agent-sdk') ||
199
+ lower.includes('download-cli') ||
200
+ lower.includes('/bin/kimi/') ||
201
+ /(^|\/)uv(?:\.exe)?$/.test(lower)
202
+ ) {
203
+ throw new Error(`Legacy CLI/runtime artifact found in ${normalized}.`);
204
+ }
205
+ }
206
+ }
207
+
208
+ async function verifyNoSensitiveContent(extractionRoot, files, sourceRoot, extraForbiddenText) {
209
+ const secretValues = [process.env.VSCE_PAT, process.env.OVSX_PAT]
210
+ .filter((value) => typeof value === 'string' && value.length >= 8);
211
+ const forbidden = [sourceRoot, homedir(), ...extraForbiddenText, ...secretValues]
212
+ .filter((value) => typeof value === 'string' && value.length >= 4)
213
+ .flatMap((value) => [value, value.replaceAll('\\', '/'), value.replaceAll('/', '\\')]);
214
+
215
+ for (const file of files) {
216
+ if (!TEXT_EXTENSIONS.has(extname(file).toLowerCase())) continue;
217
+ const content = await readFile(join(extractionRoot, file), 'utf8');
218
+ const match = forbidden.find((value) => content.includes(value));
219
+ if (match === undefined) continue;
220
+ const label = secretValues.includes(match) ? 'a marketplace token' : 'a local filesystem path';
221
+ throw new Error(`Packaged text file ${file} contains ${label}.`);
222
+ }
223
+ }
224
+
225
+ async function verifyRuntimeImports(extensionDir, files) {
226
+ const distFiles = files.filter(
227
+ (file) => file.startsWith('extension/dist/') && ['.cjs', '.js', '.mjs'].includes(extname(file)),
228
+ );
229
+ if (distFiles.length === 0) throw new Error('No JavaScript extension bundle files were packaged.');
230
+
231
+ for (const archivePath of distFiles) {
232
+ const localPath = join(dirname(extensionDir), archivePath);
233
+ const source = await readFile(localPath, 'utf8');
234
+ for (const specifier of collectLiteralImports(source)) {
235
+ if (specifier === 'vscode' || BUILTIN_IMPORTS.has(specifier)) continue;
236
+ if (OPTIONAL_FALLBACK_IMPORTS.has(specifier)) continue;
237
+ if (specifier.startsWith('node:')) continue;
238
+ if (specifier.startsWith('.') || specifier.startsWith('/')) {
239
+ if (specifier.startsWith('/')) {
240
+ throw new Error(`Absolute runtime import "${specifier}" in ${archivePath}.`);
241
+ }
242
+ const dependencyPath = resolve(dirname(localPath), stripImportSuffix(specifier));
243
+ if (!runtimeImportExists(dependencyPath)) {
244
+ throw new Error(`Missing relative runtime import "${specifier}" in ${archivePath}.`);
245
+ }
246
+ continue;
247
+ }
248
+ if (specifier.startsWith('data:') || specifier.startsWith('file:')) continue;
249
+ throw new Error(`Bare runtime dependency "${specifier}" remains in ${archivePath}.`);
250
+ }
251
+ }
252
+ }
253
+
254
+ function collectLiteralImports(source) {
255
+ const imports = new Set();
256
+ const program = parse(source, {
257
+ allowHashBang: true,
258
+ ecmaVersion: 'latest',
259
+ sourceType: 'module',
260
+ });
261
+ walkSyntax(program, (node) => {
262
+ if (
263
+ node.type === 'ImportDeclaration' ||
264
+ node.type === 'ExportAllDeclaration' ||
265
+ node.type === 'ExportNamedDeclaration'
266
+ ) {
267
+ const specifier = literalString(node.source);
268
+ if (specifier !== undefined) imports.add(specifier);
269
+ return;
270
+ }
271
+ if (node.type === 'ImportExpression') {
272
+ const specifier = literalString(node.source);
273
+ if (specifier !== undefined) imports.add(specifier);
274
+ return;
275
+ }
276
+ if (node.type === 'CallExpression' && isRuntimeRequire(node.callee)) {
277
+ const specifier = literalString(node.arguments?.[0]);
278
+ if (specifier !== undefined) imports.add(specifier);
279
+ }
280
+ });
281
+ return imports;
282
+ }
283
+
284
+ function walkSyntax(value, visit) {
285
+ if (Array.isArray(value)) {
286
+ for (const item of value) walkSyntax(item, visit);
287
+ return;
288
+ }
289
+ if (typeof value !== 'object' || value === null) return;
290
+ if (typeof value.type === 'string') visit(value);
291
+ for (const [key, child] of Object.entries(value)) {
292
+ if (key === 'start' || key === 'end' || key === 'loc' || key === 'range') continue;
293
+ walkSyntax(child, visit);
294
+ }
295
+ }
296
+
297
+ function isRuntimeRequire(callee) {
298
+ if (callee?.type === 'Identifier') return /^(?:__)?require\d*$/.test(callee.name);
299
+ if (callee?.type !== 'MemberExpression' || callee.computed === true) return false;
300
+ // `this.require(...)` is an ordinary class method call (e.g. a private field
301
+ // accessor in bundled sources), never a CommonJS require of a bare specifier.
302
+ if (callee.object?.type === 'ThisExpression') return false;
303
+ return callee.property?.type === 'Identifier' && callee.property.name === 'require';
304
+ }
305
+
306
+ function literalString(node) {
307
+ if (node?.type === 'Literal' && typeof node.value === 'string') return node.value;
308
+ if (node?.type === 'TemplateLiteral' && node.expressions?.length === 0) {
309
+ return node.quasis?.[0]?.value?.cooked;
310
+ }
311
+ return undefined;
312
+ }
313
+
314
+ async function verifyEntryImport(extensionDir, main) {
315
+ const mainPath = join(extensionDir, stripLeadingDotSlash(main));
316
+ const stubDir = join(extensionDir, 'node_modules', 'vscode');
317
+ await mkdir(stubDir, { recursive: true });
318
+ await writeFile(
319
+ join(stubDir, 'package.json'),
320
+ `${JSON.stringify({ name: 'vscode', version: '0.0.0-test', type: 'module', exports: './index.js' }, null, 2)}\n`,
321
+ );
322
+ await writeFile(join(stubDir, 'index.js'), 'export {};\n');
323
+
324
+ const script = [
325
+ `const extension = await import(${JSON.stringify(pathToFileURL(mainPath).href)});`,
326
+ 'if (typeof extension.activate !== "function") {',
327
+ ' throw new Error("extension bundle does not export activate");',
328
+ '}',
329
+ ].join('\n');
330
+ const env = { ...process.env };
331
+ delete env.NODE_PATH;
332
+ const result = spawnSync(process.execPath, ['--input-type=module', '--eval', script], {
333
+ cwd: dirname(extensionDir),
334
+ env,
335
+ encoding: 'utf8',
336
+ timeout: 30_000,
337
+ });
338
+ await rm(join(extensionDir, 'node_modules'), { recursive: true, force: true });
339
+ if (result.error !== undefined) {
340
+ throw new Error(`Unable to import the unpacked extension entry: ${result.error.message}`);
341
+ }
342
+ if (result.status !== 0) {
343
+ const detail = conciseProcessError(result.stderr || result.stdout);
344
+ throw new Error(`Unpacked extension entry import failed: ${detail}`);
345
+ }
346
+ }
347
+
348
+ async function readJson(path, label) {
349
+ try {
350
+ return JSON.parse(await readFile(path, 'utf8'));
351
+ } catch (error) {
352
+ throw new Error(`${label} is not valid JSON: ${describeError(error)}`, { cause: error });
353
+ }
354
+ }
355
+
356
+ async function listFiles(root) {
357
+ const output = [];
358
+ async function visit(directory) {
359
+ const entries = await readdir(directory, { withFileTypes: true });
360
+ for (const entry of entries) {
361
+ const localPath = join(directory, entry.name);
362
+ if (entry.isDirectory()) {
363
+ await visit(localPath);
364
+ } else if (entry.isFile()) {
365
+ output.push(relative(root, localPath).split(sep).join('/'));
366
+ } else {
367
+ throw new Error(`Unsupported non-file entry in unpacked VSIX: ${localPath}`);
368
+ }
369
+ }
370
+ }
371
+ await visit(root);
372
+ return output.sort();
373
+ }
374
+
375
+ async function totalSize(root, files) {
376
+ let bytes = 0;
377
+ for (const file of files) bytes += (await stat(join(root, file))).size;
378
+ return bytes;
379
+ }
380
+
381
+ function requireFile(fileSet, file) {
382
+ if (fileSet.has(file)) return;
383
+ throw new Error(`Required VSIX resource is missing: ${file}`);
384
+ }
385
+
386
+ function requireOneOf(fileSet, files, label) {
387
+ if (files.some((file) => fileSet.has(file))) return;
388
+ throw new Error(`Required VSIX resource is missing: ${label} (${files.join(' or ')}).`);
389
+ }
390
+
391
+ function runtimeImportExists(path) {
392
+ return [path, `${path}.js`, `${path}.mjs`, `${path}.cjs`, join(path, 'index.js')].some(existsSync);
393
+ }
394
+
395
+ function stripImportSuffix(specifier) {
396
+ return specifier.split(/[?#]/, 1)[0];
397
+ }
398
+
399
+ function stripLeadingDotSlash(value) {
400
+ return String(value).replace(/^\.\//, '');
401
+ }
402
+
403
+ function describeError(error) {
404
+ return error instanceof Error ? error.message : String(error);
405
+ }
406
+
407
+ function conciseProcessError(output) {
408
+ const lines = String(output).trim().split(/\r?\n/).filter(Boolean);
409
+ return lines.slice(-4).join('\n') || 'process exited without an error message';
410
+ }
411
+
412
+ function parseArguments(argv) {
413
+ const targets = [];
414
+ let outputDir = defaultVsixOutputDir;
415
+ let file;
416
+ let directory;
417
+ let help = false;
418
+
419
+ for (let index = 0; index < argv.length; index += 1) {
420
+ const argument = argv[index];
421
+ if (argument === '--') {
422
+ continue;
423
+ } else if (argument === '--help' || argument === '-h') {
424
+ help = true;
425
+ } else if (argument === '--out-dir') {
426
+ outputDir = requireOptionValue(argv, ++index, '--out-dir');
427
+ } else if (argument === '--target') {
428
+ targets.push(requireOptionValue(argv, ++index, '--target'));
429
+ } else if (argument === '--file') {
430
+ file = requireOptionValue(argv, ++index, '--file');
431
+ } else if (argument === '--directory') {
432
+ directory = requireOptionValue(argv, ++index, '--directory');
433
+ } else if (argument.startsWith('-')) {
434
+ throw new Error(`Unknown option: ${argument}`);
435
+ } else {
436
+ targets.push(argument);
437
+ }
438
+ }
439
+
440
+ if (file !== undefined && directory !== undefined) {
441
+ throw new Error('--file and --directory cannot be used together.');
442
+ }
443
+ const normalizedTargets = normalizeVsixTargets(targets);
444
+ if ((file !== undefined || directory !== undefined) && normalizedTargets.length !== 1) {
445
+ throw new Error('--file and --directory require exactly one target.');
446
+ }
447
+ return { targets: normalizedTargets, outputDir: resolve(outputDir), file, directory, help };
448
+ }
449
+
450
+ function requireOptionValue(argv, index, option) {
451
+ const value = argv[index];
452
+ if (value !== undefined && !value.startsWith('-')) return value;
453
+ throw new Error(`${option} requires a value.`);
454
+ }
455
+
456
+ function usage() {
457
+ return [
458
+ 'Usage: node scripts/vsix-verify.mjs [targets...] [--out-dir <directory>]',
459
+ ' node scripts/vsix-verify.mjs --target <target> --file <file.vsix>',
460
+ ' node scripts/vsix-verify.mjs --target <target> --directory <unpacked-vsix>',
461
+ '',
462
+ 'The verifier performs a package-content audit and an entry import smoke only.',
463
+ 'It does not claim that a target passed a real operating-system E2E run.',
464
+ ].join('\n');
465
+ }
466
+
467
+ async function main() {
468
+ const options = parseArguments(process.argv.slice(2));
469
+ if (options.help) {
470
+ console.log(usage());
471
+ return;
472
+ }
473
+
474
+ for (const target of options.targets) {
475
+ const input = options.directory ?? options.file ?? join(options.outputDir, vsixFileName(target));
476
+ const result = options.directory === undefined
477
+ ? await verifyVsix(resolve(input), target, { sourceRoot: extensionRoot })
478
+ : await auditExtractedVsix(resolve(input), target, { sourceRoot: extensionRoot });
479
+ console.log(
480
+ `Verified ${target}: ${result.files} files, ${result.bytes} unpacked bytes; static audit and entry import smoke passed (package-only).`,
481
+ );
482
+ }
483
+ }
484
+
485
+ if (isMainModule(import.meta.url)) {
486
+ main().catch((error) => {
487
+ console.error(`VSIX verification failed: ${describeError(error)}`);
488
+ process.exitCode = 1;
489
+ });
490
+ }
apps/vscode/scripts/watch-extension.mjs ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ import { existsSync } from 'node:fs';
3
+ import { readdir, stat } from 'node:fs/promises';
4
+ import { extname, join, resolve } from 'node:path';
5
+
6
+ import { runLocalCli } from './local-cli.mjs';
7
+ import { extensionRoot, isMainModule } from './vsix-targets.mjs';
8
+
9
+ const sourceDirectories = [
10
+ join(extensionRoot, 'src'),
11
+ join(extensionRoot, 'shared'),
12
+ ...[
13
+ 'kaos',
14
+ 'kosong',
15
+ 'migration-legacy',
16
+ 'node-sdk',
17
+ 'oauth',
18
+ 'protocol',
19
+ 'telemetry',
20
+ ].map((name) => resolve(extensionRoot, `../../packages/${name}/src`)),
21
+ ].filter(existsSync);
22
+ const rootConfigFiles = ['package.json', 'tsconfig.json', 'tsdown.config.ts'].map((name) =>
23
+ join(extensionRoot, name),
24
+ );
25
+ const watchedExtensions = new Set(['.cts', '.js', '.json', '.md', '.mjs', '.mts', '.ts', '.tsx']);
26
+ const pollIntervalMs = 1_500;
27
+
28
+ function buildExtension() {
29
+ runLocalCli(
30
+ 'tsdown',
31
+ 'tsdown',
32
+ ['--config', join(extensionRoot, 'tsdown.config.ts'), '--sourcemap'],
33
+ { cwd: extensionRoot },
34
+ );
35
+ }
36
+
37
+ async function main() {
38
+ buildExtension();
39
+ let snapshot = await sourceSnapshot();
40
+ let checking = false;
41
+ const poll = async () => {
42
+ if (checking) return;
43
+ checking = true;
44
+ try {
45
+ const nextSnapshot = await sourceSnapshot();
46
+ if (nextSnapshot === snapshot) return;
47
+ snapshot = nextSnapshot;
48
+ console.log('\nExtension source changed; rebuilding with sourcemaps...');
49
+ buildExtension();
50
+ console.log('Extension rebuild complete.');
51
+ } catch (error) {
52
+ console.error(`Extension watch check failed: ${error instanceof Error ? error.message : String(error)}`);
53
+ } finally {
54
+ checking = false;
55
+ }
56
+ };
57
+ const interval = setInterval(() => void poll(), pollIntervalMs);
58
+
59
+ const close = () => {
60
+ clearInterval(interval);
61
+ process.exit(0);
62
+ };
63
+ process.once('SIGINT', close);
64
+ process.once('SIGTERM', close);
65
+ console.log('Extension dev build complete; watching for changes.');
66
+ }
67
+
68
+ async function sourceSnapshot() {
69
+ const records = [];
70
+ for (const directory of sourceDirectories) await collectSourceRecords(directory, records);
71
+ for (const file of rootConfigFiles) await addFileRecord(file, records);
72
+ return records.sort().join('\n');
73
+ }
74
+
75
+ async function collectSourceRecords(directory, records) {
76
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
77
+ const path = join(directory, entry.name);
78
+ if (entry.isDirectory()) {
79
+ await collectSourceRecords(path, records);
80
+ } else if (entry.isFile() && watchedExtensions.has(extname(entry.name))) {
81
+ await addFileRecord(path, records);
82
+ }
83
+ }
84
+ }
85
+
86
+ async function addFileRecord(path, records) {
87
+ const info = await stat(path);
88
+ records.push(`${path}:${info.size}:${info.mtimeMs}`);
89
+ }
90
+
91
+ if (isMainModule(import.meta.url)) {
92
+ main().catch((error) => {
93
+ console.error(`Extension watch failed: ${error instanceof Error ? error.message : String(error)}`);
94
+ process.exitCode = 1;
95
+ });
96
+ }
apps/vscode/scripts/zip.mjs ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname, resolve, sep } from 'node:path';
3
+ import { inflateRawSync } from 'node:zlib';
4
+
5
+ const END_OF_CENTRAL_DIRECTORY = 0x06054b50;
6
+ const CENTRAL_DIRECTORY_ENTRY = 0x02014b50;
7
+ const LOCAL_FILE_HEADER = 0x04034b50;
8
+ const MAX_END_RECORD_SEARCH = 65_535 + 22;
9
+
10
+ export async function extractZip(archivePath, destination) {
11
+ const archive = await readFile(archivePath);
12
+ const entries = readCentralDirectory(archive);
13
+
14
+ for (const entry of entries) {
15
+ const relativePath = safeArchivePath(entry.name);
16
+ if (relativePath === '') continue;
17
+ const outputPath = resolve(destination, relativePath);
18
+ assertInside(destination, outputPath, entry.name);
19
+
20
+ if (entry.name.endsWith('/')) {
21
+ await mkdir(outputPath, { recursive: true });
22
+ continue;
23
+ }
24
+
25
+ const content = readEntry(archive, entry);
26
+ await mkdir(dirname(outputPath), { recursive: true });
27
+ await writeFile(outputPath, content);
28
+ }
29
+
30
+ return entries.map((entry) => entry.name);
31
+ }
32
+
33
+ function readCentralDirectory(archive) {
34
+ const endOffset = findEndRecord(archive);
35
+ const entryCount = archive.readUInt16LE(endOffset + 10);
36
+ const directorySize = archive.readUInt32LE(endOffset + 12);
37
+ const directoryOffset = archive.readUInt32LE(endOffset + 16);
38
+
39
+ if (entryCount === 0xffff || directorySize === 0xffffffff || directoryOffset === 0xffffffff) {
40
+ throw new Error('ZIP64 VSIX archives are not supported by the verifier.');
41
+ }
42
+ if (directoryOffset + directorySize > archive.length) {
43
+ throw new Error('VSIX central directory points outside the archive.');
44
+ }
45
+
46
+ const entries = [];
47
+ let offset = directoryOffset;
48
+ for (let index = 0; index < entryCount; index += 1) {
49
+ if (archive.readUInt32LE(offset) !== CENTRAL_DIRECTORY_ENTRY) {
50
+ throw new Error(`Invalid VSIX central-directory entry at byte ${offset}.`);
51
+ }
52
+ const flags = archive.readUInt16LE(offset + 8);
53
+ if ((flags & 0x1) !== 0) throw new Error('Encrypted VSIX entries are not supported.');
54
+
55
+ const compression = archive.readUInt16LE(offset + 10);
56
+ const compressedSize = archive.readUInt32LE(offset + 20);
57
+ const uncompressedSize = archive.readUInt32LE(offset + 24);
58
+ const nameLength = archive.readUInt16LE(offset + 28);
59
+ const extraLength = archive.readUInt16LE(offset + 30);
60
+ const commentLength = archive.readUInt16LE(offset + 32);
61
+ const localOffset = archive.readUInt32LE(offset + 42);
62
+ const nameStart = offset + 46;
63
+ const name = archive.subarray(nameStart, nameStart + nameLength).toString('utf8');
64
+ entries.push({ name, compression, compressedSize, uncompressedSize, localOffset });
65
+ offset = nameStart + nameLength + extraLength + commentLength;
66
+ }
67
+ return entries;
68
+ }
69
+
70
+ function findEndRecord(archive) {
71
+ const lowerBound = Math.max(0, archive.length - MAX_END_RECORD_SEARCH);
72
+ for (let offset = archive.length - 22; offset >= lowerBound; offset -= 1) {
73
+ if (archive.readUInt32LE(offset) === END_OF_CENTRAL_DIRECTORY) return offset;
74
+ }
75
+ throw new Error('VSIX is not a readable ZIP archive: end record is missing.');
76
+ }
77
+
78
+ function readEntry(archive, entry) {
79
+ if (archive.readUInt32LE(entry.localOffset) !== LOCAL_FILE_HEADER) {
80
+ throw new Error(`Invalid local header for VSIX entry ${entry.name}.`);
81
+ }
82
+ const nameLength = archive.readUInt16LE(entry.localOffset + 26);
83
+ const extraLength = archive.readUInt16LE(entry.localOffset + 28);
84
+ const dataStart = entry.localOffset + 30 + nameLength + extraLength;
85
+ const dataEnd = dataStart + entry.compressedSize;
86
+ if (dataEnd > archive.length) throw new Error(`Truncated VSIX entry ${entry.name}.`);
87
+
88
+ const compressed = archive.subarray(dataStart, dataEnd);
89
+ let content;
90
+ if (entry.compression === 0) {
91
+ content = compressed;
92
+ } else if (entry.compression === 8) {
93
+ content = inflateRawSync(compressed);
94
+ } else {
95
+ throw new Error(`Unsupported compression method ${entry.compression} for ${entry.name}.`);
96
+ }
97
+ if (content.length !== entry.uncompressedSize) {
98
+ throw new Error(
99
+ `Unexpected uncompressed size for ${entry.name}: ${content.length}, expected ${entry.uncompressedSize}.`,
100
+ );
101
+ }
102
+ return content;
103
+ }
104
+
105
+ function safeArchivePath(name) {
106
+ const normalized = name.replaceAll('\\', '/');
107
+ const segments = normalized.split('/').filter((segment) => segment !== '');
108
+ if (
109
+ normalized.startsWith('/') ||
110
+ /^[A-Za-z]:/.test(normalized) ||
111
+ segments.some((segment) => segment === '..')
112
+ ) {
113
+ throw new Error(`Unsafe path in VSIX archive: ${name}`);
114
+ }
115
+ return segments.join(sep);
116
+ }
117
+
118
+ function assertInside(destination, outputPath, archiveName) {
119
+ const root = resolve(destination);
120
+ if (outputPath === root || outputPath.startsWith(`${root}${sep}`)) return;
121
+ throw new Error(`Unsafe path in VSIX archive: ${archiveName}`);
122
+ }
apps/vscode/shared/bridge.ts ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Bridge Protocol - Communication between VS Code extension and webview.
3
+ *
4
+ * Architecture:
5
+ * - Webview calls Methods via RPC (request/response)
6
+ * - Extension broadcasts Events to webview (one-way notifications)
7
+ *
8
+ * RPC flow: webview.call(method, params) -> extension.dispatch -> webview.resolve(result)
9
+ * Event flow: extension.broadcast(event, data) -> webview.on(event, handler)
10
+ */
11
+
12
+ export const Methods = {
13
+ CheckWorkspace: "checkWorkspace",
14
+ GetInputHistory: "getInputHistory",
15
+ AddInputHistory: "addInputHistory",
16
+
17
+ GetSlashCommands: "getSlashCommands",
18
+ CheckLoginStatus: "checkLoginStatus",
19
+ Login: "login",
20
+ Logout: "logout",
21
+ SaveConfig: "saveConfig",
22
+ GetExtensionConfig: "getExtensionConfig",
23
+ OpenSettings: "openSettings",
24
+ OpenFolder: "openFolder",
25
+ GetModels: "getModels",
26
+
27
+ GetMCPServers: "getMCPServers",
28
+ AddMCPServer: "addMCPServer",
29
+ UpdateMCPServer: "updateMCPServer",
30
+ RemoveMCPServer: "removeMCPServer",
31
+ AuthMCP: "authMCP",
32
+ ResetAuthMCP: "resetAuthMCP",
33
+ TestMCP: "testMCP",
34
+
35
+ StreamChat: "streamChat",
36
+ AbortChat: "abortChat",
37
+ ResetSession: "resetSession",
38
+ SetPlanMode: "setPlanMode",
39
+ SteerChat: "steerChat",
40
+ RespondApproval: "respondApproval",
41
+
42
+ GetKimiSessions: "getKimiSessions",
43
+ GetAllKimiSessions: "getAllKimiSessions",
44
+ GetRegisteredWorkDirs: "getRegisteredWorkDirs",
45
+ SetWorkDir: "setWorkDir",
46
+ BrowseWorkDir: "browseWorkDir",
47
+ LoadKimiSessionHistory: "loadKimiSessionHistory",
48
+ DeleteKimiSession: "deleteKimiSession",
49
+ ForkKimiSession: "forkKimiSession",
50
+ GetProjectFiles: "getProjectFiles",
51
+ PickMedia: "pickMedia",
52
+ OpenFile: "openFile",
53
+ CheckFileExists: "checkFileExists",
54
+ CheckFilesExist: "checkFilesExist",
55
+ OpenFileDiff: "openFileDiff",
56
+ TrackFiles: "trackFiles",
57
+ ClearTrackedFiles: "clearTrackedFiles",
58
+ RevertFiles: "revertFiles",
59
+ KeepChanges: "keepChanges",
60
+ GetImageDataUri: "getImageDataUri",
61
+ ShowLogs: "showLogs",
62
+ ReloadWebview: "reloadWebview",
63
+ RespondQuestion: "respondQuestion",
64
+ } as const;
65
+
66
+ export type RpcMethod = (typeof Methods)[keyof typeof Methods];
67
+
68
+ export interface RpcMessage {
69
+ readonly id: string;
70
+ readonly method: RpcMethod;
71
+ readonly params?: unknown;
72
+ }
73
+
74
+ export interface RpcResult {
75
+ readonly id: string;
76
+ readonly result?: unknown;
77
+ readonly error?: string;
78
+ }
79
+
80
+ export type RpcMessageValidation =
81
+ | { readonly ok: true; readonly message: RpcMessage }
82
+ | {
83
+ readonly ok: false;
84
+ readonly id: string;
85
+ readonly method: string;
86
+ readonly error: string;
87
+ };
88
+
89
+ export const Events = {
90
+ ExtensionConfigChanged: "extensionConfigChanged",
91
+ MCPServersChanged: "mcpServersChanged",
92
+ StreamEvent: "streamEvent",
93
+ FocusInput: "focusInput",
94
+ InsertMention: "insertMention",
95
+ NewConversation: "newConversation",
96
+ FileChangesUpdated: "fileChangesUpdated",
97
+ RollbackInput: "rollbackInput",
98
+ LoginUrl: "loginUrl",
99
+ } as const;
100
+
101
+ const rpcMethods = new Set<string>(Object.values(Methods));
102
+
103
+ /** Validates the untrusted Webview message before any host-side handler runs. */
104
+ export function validateRpcMessage(value: unknown): RpcMessageValidation {
105
+ if (!isPlainObject(value)) {
106
+ return invalidMessage("", "<invalid>", "Invalid bridge request: expected a plain object.");
107
+ }
108
+
109
+ const id = value["id"];
110
+ if (!Object.hasOwn(value, "id") || typeof id !== "string" || id.trim().length === 0) {
111
+ return invalidMessage("", safeMethod(value["method"]), "Invalid bridge request: id must be a non-empty string.");
112
+ }
113
+
114
+ const method = value["method"];
115
+ if (!Object.hasOwn(value, "method") || typeof method !== "string" || method.trim().length === 0) {
116
+ return invalidMessage(id, "<invalid>", "Invalid bridge request: method must be a non-empty string.");
117
+ }
118
+ if (!rpcMethods.has(method)) {
119
+ return invalidMessage(id, method, `Unknown bridge method: ${method}`);
120
+ }
121
+ const params = Object.hasOwn(value, "params") ? value["params"] : undefined;
122
+ if (!validateParams(method as RpcMethod, params)) {
123
+ return invalidMessage(id, method, `Invalid bridge params for method: ${method}`);
124
+ }
125
+
126
+ return { ok: true, message: { id, method: method as RpcMethod, params } };
127
+ }
128
+
129
+ function validateParams(method: RpcMethod, params: unknown): boolean {
130
+ switch (method) {
131
+ case Methods.CheckWorkspace:
132
+ case Methods.GetInputHistory:
133
+ case Methods.GetSlashCommands:
134
+ case Methods.CheckLoginStatus:
135
+ case Methods.Login:
136
+ case Methods.Logout:
137
+ case Methods.GetExtensionConfig:
138
+ case Methods.OpenSettings:
139
+ case Methods.OpenFolder:
140
+ case Methods.GetModels:
141
+ case Methods.GetMCPServers:
142
+ case Methods.AbortChat:
143
+ case Methods.ResetSession:
144
+ case Methods.GetKimiSessions:
145
+ case Methods.GetAllKimiSessions:
146
+ case Methods.GetRegisteredWorkDirs:
147
+ case Methods.BrowseWorkDir:
148
+ case Methods.ClearTrackedFiles:
149
+ case Methods.ShowLogs:
150
+ case Methods.ReloadWebview:
151
+ return params === undefined;
152
+
153
+ case Methods.AddInputHistory:
154
+ return hasString(params, "text");
155
+ case Methods.SaveConfig:
156
+ return isPlainObject(params)
157
+ && typeof params["model"] === "string"
158
+ && isOptionalType(params["thinking"], "boolean")
159
+ && isOptionalType(params["effort"], "string")
160
+ && isOptionalType(params["effortChanged"], "boolean");
161
+ case Methods.AddMCPServer:
162
+ return isMcpServerConfig(params);
163
+ case Methods.UpdateMCPServer:
164
+ return isMcpUpdate(params);
165
+ case Methods.RemoveMCPServer:
166
+ case Methods.AuthMCP:
167
+ case Methods.ResetAuthMCP:
168
+ case Methods.TestMCP:
169
+ return hasNonEmptyString(params, "name");
170
+ case Methods.StreamChat:
171
+ return isStreamChatParams(params);
172
+ case Methods.RespondApproval:
173
+ return isPlainObject(params)
174
+ && isNonEmptyString(params["requestId"])
175
+ && (params["response"] === "approve"
176
+ || params["response"] === "approve_for_session"
177
+ || params["response"] === "reject");
178
+ case Methods.RespondQuestion:
179
+ return isPlainObject(params)
180
+ && isNonEmptyString(params["rpcRequestId"])
181
+ && isNonEmptyString(params["questionRequestId"])
182
+ && isStringRecord(params["answers"]);
183
+ case Methods.SetPlanMode:
184
+ return hasBoolean(params, "enabled");
185
+ case Methods.SteerChat:
186
+ return isPlainObject(params) && isContent(params["content"]);
187
+ case Methods.GetProjectFiles:
188
+ return params === undefined || (
189
+ isPlainObject(params)
190
+ && isOptionalType(params["query"], "string")
191
+ && isOptionalType(params["directory"], "string")
192
+ );
193
+ case Methods.SetWorkDir:
194
+ return isPlainObject(params) && (params["workDir"] === null || typeof params["workDir"] === "string");
195
+ case Methods.LoadKimiSessionHistory:
196
+ return hasNonEmptyString(params, "kimiSessionId");
197
+ case Methods.DeleteKimiSession:
198
+ return hasNonEmptyString(params, "sessionId");
199
+ case Methods.ForkKimiSession:
200
+ return isPlainObject(params)
201
+ && isNonEmptyString(params["sessionId"])
202
+ && Number.isInteger(params["turnIndex"])
203
+ && (params["turnIndex"] as number) >= 0;
204
+ case Methods.PickMedia:
205
+ return isPlainObject(params)
206
+ && (params["maxCount"] === undefined
207
+ || (Number.isInteger(params["maxCount"]) && (params["maxCount"] as number) >= 0))
208
+ && isOptionalType(params["includeVideo"], "boolean");
209
+ case Methods.OpenFile:
210
+ case Methods.OpenFileDiff:
211
+ case Methods.CheckFileExists:
212
+ case Methods.GetImageDataUri:
213
+ return hasString(params, "filePath");
214
+ case Methods.CheckFilesExist:
215
+ case Methods.TrackFiles:
216
+ return hasStringArray(params, "paths");
217
+ case Methods.RevertFiles:
218
+ case Methods.KeepChanges:
219
+ return isPlainObject(params) && isOptionalType(params["filePath"], "string");
220
+ }
221
+ }
222
+
223
+ function isStreamChatParams(value: unknown): boolean {
224
+ return isPlainObject(value)
225
+ && isContent(value["content"])
226
+ && typeof value["model"] === "string"
227
+ && isOptionalType(value["effort"], "string")
228
+ && isOptionalType(value["thinking"], "boolean")
229
+ && isOptionalType(value["planMode"], "boolean")
230
+ && isOptionalType(value["sessionId"], "string");
231
+ }
232
+
233
+ function isContent(value: unknown): boolean {
234
+ return typeof value === "string" || (Array.isArray(value) && value.every(isContentPart));
235
+ }
236
+
237
+ function isContentPart(value: unknown): boolean {
238
+ if (!isPlainObject(value) || typeof value["type"] !== "string") return false;
239
+ switch (value["type"]) {
240
+ case "text":
241
+ return typeof value["text"] === "string";
242
+ case "think":
243
+ return typeof value["think"] === "string"
244
+ && (value["encrypted"] === undefined
245
+ || value["encrypted"] === null
246
+ || typeof value["encrypted"] === "string");
247
+ case "image_url":
248
+ case "audio_url":
249
+ case "video_url": {
250
+ const media = value[value["type"]];
251
+ return isPlainObject(media)
252
+ && typeof media["url"] === "string"
253
+ && (media["id"] === undefined || media["id"] === null || typeof media["id"] === "string");
254
+ }
255
+ default:
256
+ return false;
257
+ }
258
+ }
259
+
260
+ function isMcpUpdate(value: unknown): boolean {
261
+ if (!isPlainObject(value)) return false;
262
+ if (Object.hasOwn(value, "server")) {
263
+ return isNonEmptyString(value["originalName"]) && isMcpServerConfig(value["server"]);
264
+ }
265
+ return isMcpServerConfig(value);
266
+ }
267
+
268
+ function isMcpServerConfig(value: unknown): boolean {
269
+ return isPlainObject(value)
270
+ && isNonEmptyString(value["name"])
271
+ && (value["transport"] === "stdio" || value["transport"] === "http")
272
+ && isOptionalType(value["url"], "string")
273
+ && isOptionalType(value["command"], "string")
274
+ && (value["args"] === undefined || isStringArray(value["args"]))
275
+ && (value["env"] === undefined || isStringRecord(value["env"]))
276
+ && (value["headers"] === undefined || isStringRecord(value["headers"]))
277
+ && (value["auth"] === undefined || value["auth"] === "oauth")
278
+ && isOptionalType(value["bearerTokenEnvVar"], "string");
279
+ }
280
+
281
+ function hasString(value: unknown, key: string): boolean {
282
+ return isPlainObject(value) && typeof value[key] === "string";
283
+ }
284
+
285
+ function hasNonEmptyString(value: unknown, key: string): boolean {
286
+ return isPlainObject(value) && isNonEmptyString(value[key]);
287
+ }
288
+
289
+ function hasBoolean(value: unknown, key: string): boolean {
290
+ return isPlainObject(value) && typeof value[key] === "boolean";
291
+ }
292
+
293
+ function hasStringArray(value: unknown, key: string): boolean {
294
+ return isPlainObject(value) && isStringArray(value[key]);
295
+ }
296
+
297
+ function isStringArray(value: unknown): boolean {
298
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
299
+ }
300
+
301
+ function isStringRecord(value: unknown): boolean {
302
+ return isPlainObject(value) && Object.values(value).every((item) => typeof item === "string");
303
+ }
304
+
305
+ function isOptionalType(value: unknown, type: "string" | "boolean"): boolean {
306
+ return value === undefined || typeof value === type;
307
+ }
308
+
309
+ function isNonEmptyString(value: unknown): value is string {
310
+ return typeof value === "string" && value.trim().length > 0;
311
+ }
312
+
313
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
314
+ if (value === null || typeof value !== "object") return false;
315
+ const prototype = Object.getPrototypeOf(value);
316
+ return prototype === Object.prototype || prototype === null;
317
+ }
318
+
319
+ function invalidMessage(id: string, method: string, error: string): RpcMessageValidation {
320
+ return { ok: false, id, method, error };
321
+ }
322
+
323
+ function safeMethod(value: unknown): string {
324
+ return typeof value === "string" && value.length > 0 ? value : "<invalid>";
325
+ }
apps/vscode/shared/errors.ts ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ErrorPhase } from "./types";
2
+
3
+ // Keep the released Webview's error contract local. The upper-case values are
4
+ // accepted for sessions restored from the legacy extension; the dotted values
5
+ // are emitted by the in-process v1 core.
6
+ const LEGACY = {
7
+ CLI_NOT_FOUND: "CLI_NOT_FOUND",
8
+ SPAWN_FAILED: "SPAWN_FAILED",
9
+ ALREADY_STARTED: "ALREADY_STARTED",
10
+ STDIN_NOT_WRITABLE: "STDIN_NOT_WRITABLE",
11
+ HANDSHAKE_TIMEOUT: "HANDSHAKE_TIMEOUT",
12
+ PROCESS_CRASHED: "PROCESS_CRASHED",
13
+ LLM_NOT_SET: "LLM_NOT_SET",
14
+ LLM_NOT_SUPPORTED: "LLM_NOT_SUPPORTED",
15
+ INVALID_STATE: "INVALID_STATE",
16
+ CHAT_PROVIDER_ERROR: "CHAT_PROVIDER_ERROR",
17
+ SESSION_BUSY: "SESSION_BUSY",
18
+ SESSION_CLOSED: "SESSION_CLOSED",
19
+ TURN_INTERRUPTED: "TURN_INTERRUPTED",
20
+ INVALID_JSON: "INVALID_JSON",
21
+ INVALID_REQUEST: "INVALID_REQUEST",
22
+ INVALID_PARAMS: "INVALID_PARAMS",
23
+ INTERNAL_ERROR: "INTERNAL_ERROR",
24
+ } as const;
25
+
26
+ // Pre-flight: task didn't start at all or was blocked by "gatekeeper"
27
+ export const PREFLIGHT_CODES = new Set<string>([
28
+ LEGACY.CLI_NOT_FOUND,
29
+ LEGACY.SPAWN_FAILED,
30
+ LEGACY.ALREADY_STARTED,
31
+ LEGACY.STDIN_NOT_WRITABLE,
32
+ LEGACY.PROCESS_CRASHED,
33
+ LEGACY.LLM_NOT_SET,
34
+ LEGACY.LLM_NOT_SUPPORTED,
35
+ LEGACY.INVALID_STATE,
36
+ LEGACY.SESSION_BUSY,
37
+ "config.invalid",
38
+ "model.not_configured",
39
+ "auth.login_required",
40
+ "session.not_found",
41
+ "session.state_not_found",
42
+ "session.state_invalid",
43
+ "session.init_failed",
44
+ "shell.git_bash_not_found",
45
+ ]);
46
+
47
+ // User-friendly error messages
48
+ export const ERROR_MESSAGES: Record<string, string> = {
49
+ // Pre-flight
50
+ [LEGACY.CLI_NOT_FOUND]: "Kimi Code CLI not found.",
51
+ [LEGACY.SPAWN_FAILED]: "Failed to start Kimi Code CLI.",
52
+ [LEGACY.ALREADY_STARTED]: "A session is already running.",
53
+ [LEGACY.STDIN_NOT_WRITABLE]: "Failed to communicate with Kimi Code CLI.",
54
+ [LEGACY.HANDSHAKE_TIMEOUT]: "Connection timed out.",
55
+ [LEGACY.PROCESS_CRASHED]: "Process connection lost.",
56
+
57
+ // CLI errors
58
+ [LEGACY.LLM_NOT_SET]: "Authentication failed. Please sign in.",
59
+ [LEGACY.LLM_NOT_SUPPORTED]: "This model is not supported.",
60
+ [LEGACY.INVALID_STATE]: "Please wait for the current operation.",
61
+ [LEGACY.CHAT_PROVIDER_ERROR]: "Service temporarily unavailable.",
62
+
63
+ // Session errors
64
+ [LEGACY.SESSION_BUSY]: "A message is being sent. Please wait.",
65
+ [LEGACY.SESSION_CLOSED]: "Session was closed.",
66
+ [LEGACY.TURN_INTERRUPTED]: "Stopped by user.",
67
+
68
+ // Protocol errors
69
+ [LEGACY.INVALID_JSON]: "Communication format error.",
70
+ [LEGACY.INVALID_REQUEST]: "Invalid request.",
71
+ [LEGACY.INVALID_PARAMS]: "Invalid parameters.",
72
+ [LEGACY.INTERNAL_ERROR]: "Internal error occurred.",
73
+
74
+ "config.invalid": "Kimi Code configuration is invalid.",
75
+ "model.not_configured": "No model is configured. Please sign in or configure a provider.",
76
+ "auth.login_required": "Authentication failed. Please sign in.",
77
+ "session.not_found": "Session was not found.",
78
+ "session.state_not_found": "Session data is missing.",
79
+ "session.state_invalid": "Session data is invalid.",
80
+ "session.init_failed": "Failed to initialize the session.",
81
+ "session.closed": "Session was closed.",
82
+ "session.fork_active_turn": "Wait for the current response before forking.",
83
+ "turn.agent_busy": "A message is being sent. Please wait.",
84
+ "provider.api_error": "Service temporarily unavailable.",
85
+ "provider.rate_limit": "Too many requests. Please try again later.",
86
+ "provider.auth_error": "Authentication failed. Please sign in again.",
87
+ "provider.connection_error": "Could not connect to the model provider.",
88
+ "request.prompt_input_empty": "Prompt cannot be empty.",
89
+ internal: "Internal error occurred.",
90
+ };
91
+
92
+ export function classifyError(code: string): ErrorPhase {
93
+ return PREFLIGHT_CODES.has(code) ? "preflight" : "runtime";
94
+ }
95
+
96
+ export function getUserMessage(code: string, fallback?: string): string {
97
+ return ERROR_MESSAGES[code] || fallback || "An unknown error occurred.";
98
+ }
99
+
100
+ export function isPreflightError(code: string): boolean {
101
+ return PREFLIGHT_CODES.has(code);
102
+ }
103
+
104
+ export function isUserInterrupt(code: string): boolean {
105
+ return code === LEGACY.TURN_INTERRUPTED || code === "turn.cancelled";
106
+ }
apps/vscode/shared/fork-turn-index.ts ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ interface ForkTurnItem {
2
+ readonly type: string;
3
+ }
4
+
5
+ interface ForkTurnMessage {
6
+ readonly role: "user" | "assistant";
7
+ readonly forkable?: boolean;
8
+ readonly steps?: readonly {
9
+ readonly items: readonly ForkTurnItem[];
10
+ }[];
11
+ }
12
+
13
+ /** Return the core's zero-based user-visible turn index for an assistant bubble. */
14
+ export function getForkTurnIndex(
15
+ messages: readonly ForkTurnMessage[],
16
+ messageIndex: number,
17
+ ): number | undefined {
18
+ const target = messages[messageIndex];
19
+ if (target?.role !== "assistant" || target.forkable === false) return undefined;
20
+
21
+ let visibleTurns = 0;
22
+ for (let index = 0; index <= messageIndex; index += 1) {
23
+ const message = messages[index];
24
+ if (message === undefined) continue;
25
+ if (message.role === "user" && message.forkable !== false) {
26
+ visibleTurns += 1;
27
+ continue;
28
+ }
29
+ if (message.role === "assistant") {
30
+ visibleTurns += countSteers(message);
31
+ }
32
+ }
33
+ return visibleTurns - 1;
34
+ }
35
+
36
+ function countSteers(message: ForkTurnMessage): number {
37
+ return (
38
+ message.steps?.reduce(
39
+ (count, step) => count + step.items.filter((item) => item.type === "steer").length,
40
+ 0,
41
+ ) ?? 0
42
+ );
43
+ }
apps/vscode/shared/legacy-sdk.ts ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * UI-facing compatibility types for the released Webview.
3
+ *
4
+ * The extension host adapts v1 Node SDK events into this shape while the UI is
5
+ * migrated without a visual rewrite. This file contains no legacy SDK runtime.
6
+ */
7
+
8
+ export type ApprovalResponse = 'approve' | 'approve_for_session' | 'reject';
9
+
10
+ export type ContentPart =
11
+ | { type: 'text'; text: string }
12
+ | { type: 'think'; think: string; encrypted?: string | null }
13
+ | { type: 'image_url'; image_url: { url: string; id?: string | null } }
14
+ | { type: 'audio_url'; audio_url: { url: string; id?: string | null } }
15
+ | { type: 'video_url'; video_url: { url: string; id?: string | null } };
16
+
17
+ export interface BriefBlock {
18
+ type: 'brief';
19
+ text: string;
20
+ }
21
+
22
+ export interface DiffBlock {
23
+ type: 'diff';
24
+ path: string;
25
+ old_text: string;
26
+ new_text: string;
27
+ }
28
+
29
+ export interface TodoBlock {
30
+ type: 'todo';
31
+ items: Array<{ title: string; status: 'pending' | 'in_progress' | 'done' }>;
32
+ }
33
+
34
+ export interface ShellBlock {
35
+ type: 'shell';
36
+ language: string;
37
+ command: string;
38
+ }
39
+
40
+ export interface UnknownBlock {
41
+ type: string;
42
+ data?: Record<string, unknown>;
43
+ [key: string]: unknown;
44
+ }
45
+
46
+ export type DisplayBlock = BriefBlock | DiffBlock | TodoBlock | ShellBlock | UnknownBlock;
47
+
48
+ export interface ToolCall {
49
+ type: 'function';
50
+ id: string;
51
+ function: { name: string; arguments?: string | null };
52
+ extras?: Record<string, unknown> | null;
53
+ }
54
+
55
+ export interface ToolReturnValue {
56
+ is_error: boolean;
57
+ output: string | ContentPart[];
58
+ message: string;
59
+ display: DisplayBlock[];
60
+ extras?: Record<string, unknown> | null;
61
+ }
62
+
63
+ export interface ToolResult {
64
+ tool_call_id: string;
65
+ return_value: ToolReturnValue;
66
+ }
67
+
68
+ export interface TurnBegin {
69
+ user_input: string | ContentPart[];
70
+ }
71
+
72
+ export interface TokenUsage {
73
+ input_other: number;
74
+ output: number;
75
+ input_cache_read: number;
76
+ input_cache_creation: number;
77
+ }
78
+
79
+ export interface StatusUpdate {
80
+ context_usage?: number | null;
81
+ token_usage?: TokenUsage | null;
82
+ message_id?: string | null;
83
+ plan_mode?: boolean | null;
84
+ model?: string | null;
85
+ thinking_effort?: string | null;
86
+ retrying?: {
87
+ next_attempt: number;
88
+ max_attempts: number;
89
+ delay_ms: number;
90
+ message: string;
91
+ } | null;
92
+ }
93
+
94
+ export interface ApprovalRequestPayload {
95
+ id: string;
96
+ tool_call_id: string;
97
+ sender: string;
98
+ action: string;
99
+ description: string;
100
+ display?: DisplayBlock[];
101
+ }
102
+
103
+ export interface QuestionOption {
104
+ label: string;
105
+ description?: string;
106
+ }
107
+
108
+ export interface QuestionItem {
109
+ question: string;
110
+ header?: string;
111
+ options: QuestionOption[];
112
+ multi_select?: boolean;
113
+ }
114
+
115
+ export interface QuestionRequest {
116
+ id: string;
117
+ tool_call_id: string;
118
+ questions: QuestionItem[];
119
+ }
120
+
121
+ export interface QuestionResponse {
122
+ request_id: string;
123
+ answers: Record<string, string>;
124
+ }
125
+
126
+ export interface SubagentEvent {
127
+ parent_tool_call_id: string;
128
+ event: LegacyWireEvent;
129
+ }
130
+
131
+ export type LegacyWireEvent =
132
+ | { type: 'TurnBegin'; payload: TurnBegin & { forkable?: boolean } }
133
+ | { type: 'TurnEnd'; payload: Record<string, never> }
134
+ | { type: 'StepBegin'; payload: { n: number } }
135
+ | { type: 'StepInterrupted'; payload: Record<string, never> }
136
+ | { type: 'CompactionBegin'; payload: Record<string, never> }
137
+ | { type: 'CompactionEnd'; payload: Record<string, never> }
138
+ | { type: 'StatusUpdate'; payload: StatusUpdate }
139
+ | { type: 'ContentPart'; payload: ContentPart }
140
+ | { type: 'ToolCall'; payload: ToolCall }
141
+ | { type: 'ToolCallPart'; payload: { tool_call_id?: string; arguments_part?: string | null } }
142
+ | { type: 'ToolResult'; payload: ToolResult }
143
+ | { type: 'SteerInput'; payload: { user_input: string | ContentPart[] } }
144
+ | { type: 'SubagentEvent'; payload: SubagentEvent }
145
+ | { type: string; payload: unknown };
146
+
147
+ export type StreamEvent =
148
+ | LegacyWireEvent
149
+ | { type: 'ApprovalRequest'; payload: ApprovalRequestPayload }
150
+ | { type: 'QuestionRequest'; payload: QuestionRequest }
151
+ | { type: 'error'; code: string; message: string; raw?: string };
152
+
153
+ export interface RunResult {
154
+ status: 'finished' | 'cancelled' | 'max_steps_reached';
155
+ steps?: number;
156
+ }
157
+
158
+ export interface SlashCommandInfo {
159
+ name: string;
160
+ description: string;
161
+ aliases: string[];
162
+ }
163
+
164
+ export interface ModelConfig {
165
+ id: string;
166
+ name: string;
167
+ provider: string;
168
+ capabilities: string[];
169
+ adaptive_thinking?: boolean;
170
+ support_efforts?: string[];
171
+ default_effort?: string;
172
+ }
173
+
174
+ export interface KimiConfig {
175
+ defaultModel: string | null;
176
+ defaultThinking: boolean;
177
+ defaultThinkingEffort?: string;
178
+ models: ModelConfig[];
179
+ }
180
+
181
+ /** Placeholder returned to the Webview instead of a stored MCP credential. */
182
+ export const MCP_SECRET_MASK = '••••••••';
183
+
184
+ export interface MCPServerConfig {
185
+ name: string;
186
+ transport: 'http' | 'stdio';
187
+ url?: string;
188
+ command?: string;
189
+ args?: string[];
190
+ env?: Record<string, string>;
191
+ headers?: Record<string, string>;
192
+ auth?: 'oauth';
193
+ bearerTokenEnvVar?: string;
194
+ /** Unified management view tags; absent on cores predating the management plane. */
195
+ source?: 'global' | 'plugin' | 'caller';
196
+ /** global: defining file path; plugin: plugin id. */
197
+ origin?: string;
198
+ /** false for plugin / project-layer entries — the panel hides mutating actions for them. */
199
+ mutable?: boolean;
200
+ }
201
+
202
+ export interface UpdateMCPServerRequest {
203
+ originalName: string;
204
+ server: MCPServerConfig;
205
+ }
206
+
207
+ export interface SessionInfo {
208
+ id: string;
209
+ workDir: string;
210
+ updatedAt: number;
211
+ brief: string;
212
+ }
213
+
214
+ export type ThinkingMode = 'none' | 'switch' | 'always' | 'effort';
215
+
216
+ export interface MCPTestResult {
217
+ success: boolean;
218
+ output: string;
219
+ }
220
+
221
+ export interface LoginResult {
222
+ success: boolean;
223
+ error?: string;
224
+ }
225
+
226
+ export function formatContentOutput(output: string | ContentPart[]): string {
227
+ if (typeof output === 'string') return output;
228
+ if (!Array.isArray(output)) return JSON.stringify(output);
229
+ return output
230
+ .map((item) => item.type === 'text' ? item.text : `[${item.type}]`)
231
+ .filter(Boolean)
232
+ .join('\n');
233
+ }
apps/vscode/shared/types.ts ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { RunResult, StreamEvent } from "./legacy-sdk";
2
+
3
+ export interface SessionConfig {
4
+ model: string;
5
+ thinking?: boolean;
6
+ effort?: string;
7
+ /**
8
+ * Whether the user explicitly changed the effort. Re-confirming the effort
9
+ * already shown is not an explicit choice: the model is persisted but the
10
+ * stored effort preference is left alone (mirrors the TUI's
11
+ * persistModelSelection). Treated as true when omitted.
12
+ */
13
+ effortChanged?: boolean;
14
+ }
15
+
16
+ export interface ProjectFile {
17
+ path: string;
18
+ name: string;
19
+ isDirectory: boolean;
20
+ /** Matched-character offsets into `path`, for mention-style highlighting. */
21
+ matchPositions?: number[];
22
+ }
23
+
24
+ export interface FileChange {
25
+ path: string;
26
+ status: "Modified" | "Added" | "Deleted";
27
+ additions: number;
28
+ deletions: number;
29
+ }
30
+
31
+ export interface ExtensionConfig {
32
+ yoloMode: boolean;
33
+ autosave: boolean;
34
+ useCtrlEnterToSend: boolean;
35
+ enableNewConversationShortcut: boolean;
36
+ showThinkingContent: boolean;
37
+ showThinkingExpanded: boolean;
38
+ version: string;
39
+ }
40
+
41
+ export interface WorkspaceStatus {
42
+ hasWorkspace: boolean;
43
+ path?: string;
44
+ workspaceRoot?: string;
45
+ }
46
+
47
+ export type ErrorPhase = "preflight" | "runtime";
48
+
49
+ export interface StreamError {
50
+ type: "error";
51
+ code: string;
52
+ message: string;
53
+ detail?: string; // 原始服务器错误信息
54
+ phase: ErrorPhase;
55
+ /**
56
+ * `false` marks a mid-turn warning: the turn is still running, so UIs must
57
+ * not treat it as turn-ending. Do not unlock the composer, offer Retry, or
58
+ * flush the queued messages for non-terminal errors.
59
+ */
60
+ terminal?: boolean;
61
+ }
62
+
63
+ export type UIStreamEvent =
64
+ | { type: "session_start"; sessionId: string; model?: string; _sessionId?: string }
65
+ | { type: "stream_complete"; result: RunResult; _sessionId?: string }
66
+ | (StreamError & { _sessionId?: string })
67
+ | (StreamEvent & { _sessionId?: string });
68
+
69
+ export interface LoginStatus {
70
+ loggedIn: boolean;
71
+ }
72
+
73
+ export type { QuestionRequest, QuestionItem, QuestionOption, QuestionResponse } from "./legacy-sdk";
apps/vscode/shared/utils.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export function cleanSystemTags(text: string): string {
2
+ return text.replace(/<system>.*?<\/system>\s*/gs, "").trim();
3
+ }
apps/vscode/test/app-init.test.ts ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Scenario: App-level view routing after init, across login state transitions.
3
+ * Responsibilities: the sign-in screen must stay reachable from every state — in
4
+ * particular the no-models state (a managed OAuth token exists but config.toml
5
+ * has no models, e.g. a first login whose model provisioning failed after the
6
+ * device flow already persisted the token), where Reload alone can never change
7
+ * the on-disk state and the user would otherwise be stranded.
8
+ * Wiring: resolveAppView is pure; the bridge and toast boundaries are mocked away.
9
+ * Run: pnpm exec vitest run --config apps/vscode/vitest.config.ts test/app-init.test.ts
10
+ */
11
+ import { describe, expect, it, vi } from "vitest";
12
+
13
+ vi.mock("@/services", () => ({
14
+ bridge: {},
15
+ Events: {},
16
+ }));
17
+ vi.mock("@/components/ui/sonner", () => ({
18
+ toast: { error: vi.fn(), warning: vi.fn() },
19
+ }));
20
+
21
+ import { resolveAppView, type AppStatus } from "../webview-ui/src/hooks/useAppInit";
22
+
23
+ function resolve(
24
+ status: AppStatus,
25
+ options: { modelsCount?: number; skippedLogin?: boolean; showLogin?: boolean } = {},
26
+ ) {
27
+ return resolveAppView({
28
+ status,
29
+ modelsCount: options.modelsCount ?? 0,
30
+ skippedLogin: options.skippedLogin ?? false,
31
+ showLogin: options.showLogin ?? false,
32
+ });
33
+ }
34
+
35
+ describe("resolveAppView", () => {
36
+ it("routes a brand-new user (no token, no models) to the login screen", () => {
37
+ expect(resolve("not-logged-in")).toEqual({ view: "login" });
38
+ });
39
+
40
+ it("routes a skipped login without models to no-models with a sign-in path", () => {
41
+ expect(resolve("not-logged-in", { skippedLogin: true })).toEqual({
42
+ view: "status",
43
+ status: "no-models",
44
+ canGoToLogin: true,
45
+ });
46
+ });
47
+
48
+ it("routes a skipped login with models to the main view", () => {
49
+ expect(resolve("not-logged-in", { skippedLogin: true, modelsCount: 2 })).toEqual({
50
+ view: "main",
51
+ });
52
+ });
53
+
54
+ it("keeps a sign-in path in the no-models trap (token without model config)", () => {
55
+ // Regression: this state previously rendered "Model setup required" with only
56
+ // a Reload button, making the login screen unreachable for affected users.
57
+ expect(resolve("no-models")).toEqual({
58
+ view: "status",
59
+ status: "no-models",
60
+ canGoToLogin: true,
61
+ });
62
+ });
63
+
64
+ it("routes to the login screen when the user asks for it from any state", () => {
65
+ expect(resolve("no-models", { showLogin: true })).toEqual({ view: "login" });
66
+ expect(resolve("ready", { showLogin: true, modelsCount: 1 })).toEqual({ view: "login" });
67
+ });
68
+
69
+ it("routes a no-models user who skips again back to no-models with a sign-in path", () => {
70
+ expect(resolve("no-models", { skippedLogin: true })).toEqual({
71
+ view: "status",
72
+ status: "no-models",
73
+ canGoToLogin: true,
74
+ });
75
+ });
76
+
77
+ it("routes ready to the main view", () => {
78
+ expect(resolve("ready", { modelsCount: 1 })).toEqual({ view: "main" });
79
+ });
80
+
81
+ it("routes non-login error statuses to status screens without a sign-in path", () => {
82
+ for (const status of ["loading", "no-workspace", "runtime-error"] as const) {
83
+ expect(resolve(status)).toEqual({ view: "status", status, canGoToLogin: false });
84
+ }
85
+ });
86
+ });
apps/vscode/test/baseline.manager.test.ts ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Scenario: VSCode-owned file baselines for new, migrated, and forked sessions.
3
+ * Responsibilities: capture originals, show changes, keep/undo, persist legacy tombstones, and reject unsafe paths.
4
+ * Wiring: real temporary workspace/global-storage/legacy files; no stubbed collaborators.
5
+ * Run: pnpm --filter kimi-code test -- baseline.manager.test.ts
6
+ */
7
+ import { existsSync, writeFileSync } from 'node:fs';
8
+ import { chmod, mkdir, mkdtemp, readFile, rm, symlink, unlink, writeFile } from 'node:fs/promises';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
12
+
13
+ import {
14
+ BaselineManager,
15
+ type BaselineSession,
16
+ } from '../src/managers/baseline.manager';
17
+
18
+ let root: string;
19
+ let workDir: string;
20
+ let storageRoot: string;
21
+ let manager: BaselineManager;
22
+
23
+ beforeEach(async () => {
24
+ root = await mkdtemp(join(tmpdir(), 'vscode-baseline-'));
25
+ workDir = join(root, 'workspace');
26
+ storageRoot = join(root, 'global-storage');
27
+ await mkdir(workDir, { recursive: true });
28
+ manager = new BaselineManager(storageRoot);
29
+ });
30
+
31
+ afterEach(async () => {
32
+ await rm(root, { recursive: true, force: true });
33
+ });
34
+
35
+ describe('file baselines (capture, compare, keep, and undo)', () => {
36
+ it('keeps the first original when overlapping captures race', async () => {
37
+ const session = createSession();
38
+ const filePath = join(workDir, 'src', 'app.ts');
39
+ await mkdir(join(workDir, 'src'), { recursive: true });
40
+ writeFileSync(filePath, 'original\n', 'utf-8');
41
+
42
+ const firstCapture = manager.capture(session, filePath);
43
+ writeFileSync(filePath, 'after first edit\n', 'utf-8');
44
+ const secondCapture = manager.capture(session, filePath);
45
+ await Promise.all([firstCapture, secondCapture]);
46
+
47
+ expect(await manager.getContent(session, filePath)).toBe('original\n');
48
+ });
49
+
50
+ it('returns the captured original after the manager is recreated', async () => {
51
+ const session = createSession();
52
+ const filePath = join(workDir, 'app.ts');
53
+ await writeFile(filePath, 'persisted original\n', 'utf-8');
54
+ await manager.capture(session, filePath);
55
+
56
+ const reloadedManager = new BaselineManager(storageRoot);
57
+
58
+ await expect(reloadedManager.getContent(session, filePath)).resolves.toBe(
59
+ 'persisted original\n',
60
+ );
61
+ });
62
+
63
+ it('isolates the same session id between different Kimi homes', async () => {
64
+ const session = createSession();
65
+ const filePath = join(workDir, 'app.ts');
66
+ const firstHome = new BaselineManager(storageRoot, join(root, 'home-a'));
67
+ const secondHome = new BaselineManager(storageRoot, join(root, 'home-b'));
68
+ await writeFile(filePath, 'home A original\n', 'utf-8');
69
+ await firstHome.capture(session, filePath);
70
+ await writeFile(filePath, 'home B original\n', 'utf-8');
71
+ await secondHome.capture(session, filePath);
72
+
73
+ await expect(firstHome.getContent(session, filePath)).resolves.toBe('home A original\n');
74
+ await expect(secondHome.getContent(session, filePath)).resolves.toBe('home B original\n');
75
+ });
76
+
77
+ it('reports a modified file when current content differs from its original', async () => {
78
+ const session = createSession();
79
+ const filePath = join(workDir, 'app.ts');
80
+ await writeFile(filePath, 'before\n', 'utf-8');
81
+ await manager.capture(session, filePath);
82
+ await writeFile(filePath, 'after\n', 'utf-8');
83
+
84
+ await expect(manager.getChanges(session)).resolves.toEqual([
85
+ { path: 'app.ts', status: 'Modified', additions: 1, deletions: 1 },
86
+ ]);
87
+ });
88
+
89
+ it('reports an added file when it did not exist at capture time', async () => {
90
+ const session = createSession();
91
+ const filePath = join(workDir, 'new.ts');
92
+ await manager.capture(session, filePath);
93
+ await writeFile(filePath, 'new content\n', 'utf-8');
94
+
95
+ await expect(manager.getChanges(session)).resolves.toEqual([
96
+ { path: 'new.ts', status: 'Added', additions: 2, deletions: 0 },
97
+ ]);
98
+ });
99
+
100
+ it('reports a deleted file when an existing original is removed', async () => {
101
+ const session = createSession();
102
+ const filePath = join(workDir, 'old.ts');
103
+ await writeFile(filePath, 'line one\nline two', 'utf-8');
104
+ await manager.capture(session, filePath);
105
+ await unlink(filePath);
106
+
107
+ await expect(manager.getChanges(session)).resolves.toEqual([
108
+ { path: 'old.ts', status: 'Deleted', additions: 0, deletions: 2 },
109
+ ]);
110
+ });
111
+
112
+ it('removes a newly created file when undo restores its missing original', async () => {
113
+ const session = createSession();
114
+ const filePath = join(workDir, 'new.ts');
115
+ await manager.capture(session, filePath);
116
+ await writeFile(filePath, 'created\n', 'utf-8');
117
+
118
+ await manager.undo(session, filePath);
119
+
120
+ expect(existsSync(filePath)).toBe(false);
121
+ });
122
+
123
+ it('restores an existing empty file without deleting it when undo runs', async () => {
124
+ const session = createSession();
125
+ const filePath = join(workDir, 'empty.ts');
126
+ await writeFile(filePath, '', 'utf-8');
127
+ await manager.capture(session, filePath);
128
+ await writeFile(filePath, 'changed', 'utf-8');
129
+
130
+ await manager.undo(session, filePath);
131
+
132
+ expect(existsSync(filePath)).toBe(true);
133
+ await expect(readFile(filePath, 'utf-8')).resolves.toBe('');
134
+ });
135
+
136
+ it('preserves CRLF bytes when undo restores the original file', async () => {
137
+ const session = createSession();
138
+ const filePath = join(workDir, 'windows.txt');
139
+ await writeFile(filePath, 'first\r\nsecond\r\n', 'utf-8');
140
+ await manager.capture(session, filePath);
141
+ await writeFile(filePath, 'changed\n', 'utf-8');
142
+
143
+ await manager.undo(session, filePath);
144
+
145
+ await expect(readFile(filePath, 'utf-8')).resolves.toBe('first\r\nsecond\r\n');
146
+ });
147
+
148
+ it('captures a new original after keep accepts the previous changes', async () => {
149
+ const session = createSession();
150
+ const filePath = join(workDir, 'app.ts');
151
+ await writeFile(filePath, 'first\n', 'utf-8');
152
+ await manager.capture(session, filePath);
153
+ await writeFile(filePath, 'accepted\n', 'utf-8');
154
+ await manager.keep(session, filePath);
155
+
156
+ await manager.capture(session, filePath);
157
+ await writeFile(filePath, 'next edit\n', 'utf-8');
158
+
159
+ expect(await manager.getContent(session, filePath)).toBe('accepted\n');
160
+ });
161
+
162
+ it('restores every tracked original when undo all runs', async () => {
163
+ const session = createSession();
164
+ await writeFile(join(workDir, 'one.ts'), 'one before', 'utf-8');
165
+ await writeFile(join(workDir, 'two.ts'), 'two before', 'utf-8');
166
+ await manager.capture(session, 'one.ts');
167
+ await manager.capture(session, 'two.ts');
168
+ await writeFile(join(workDir, 'one.ts'), 'one after', 'utf-8');
169
+ await writeFile(join(workDir, 'two.ts'), 'two after', 'utf-8');
170
+
171
+ await manager.undoAll(session);
172
+
173
+ await expect(readFile(join(workDir, 'one.ts'), 'utf-8')).resolves.toBe('one before');
174
+ await expect(readFile(join(workDir, 'two.ts'), 'utf-8')).resolves.toBe('two before');
175
+ });
176
+
177
+ it('refuses undo all when a tracked directory now links outside the workspace', async () => {
178
+ const session = createSession();
179
+ const trackedDir = join(workDir, 'src');
180
+ const outsideDir = join(root, 'outside');
181
+ await mkdir(trackedDir);
182
+ await mkdir(outsideDir);
183
+ await writeFile(join(trackedDir, 'app.ts'), 'original');
184
+ await manager.capture(session, 'src/app.ts');
185
+ await rm(trackedDir, { recursive: true });
186
+ await writeFile(join(outsideDir, 'app.ts'), 'outside');
187
+ await symlink(outsideDir, trackedDir, process.platform === 'win32' ? 'junction' : 'dir');
188
+
189
+ await expect(manager.undoAll(session)).rejects.toThrow('outside the session workspace');
190
+ await expect(readFile(join(outsideDir, 'app.ts'), 'utf-8')).resolves.toBe('outside');
191
+ });
192
+
193
+ it('removes every visible change when keep all runs', async () => {
194
+ const session = createSession();
195
+ await writeFile(join(workDir, 'one.ts'), 'one before', 'utf-8');
196
+ await writeFile(join(workDir, 'two.ts'), 'two before', 'utf-8');
197
+ await manager.capture(session, 'one.ts');
198
+ await manager.capture(session, 'two.ts');
199
+ await writeFile(join(workDir, 'one.ts'), 'one after', 'utf-8');
200
+ await writeFile(join(workDir, 'two.ts'), 'two after', 'utf-8');
201
+
202
+ await manager.keepAll(session);
203
+
204
+ await expect(manager.getChanges(session)).resolves.toEqual([]);
205
+ });
206
+ });
207
+
208
+ describe('legacy baselines (fallback, tombstones, and fork isolation)', () => {
209
+ it('returns the legacy original when no local baseline exists', async () => {
210
+ const { session } = await createLegacySession('legacy original\n');
211
+ await writeFile(join(workDir, 'app.ts'), 'current content\n', 'utf-8');
212
+
213
+ await expect(manager.getContent(session, 'app.ts')).resolves.toBe('legacy original\n');
214
+ });
215
+
216
+ it('reports a legacy file when current content differs from its original', async () => {
217
+ const { session } = await createLegacySession('legacy original\n');
218
+ await writeFile(join(workDir, 'app.ts'), 'current content\n', 'utf-8');
219
+
220
+ await expect(manager.getChanges(session)).resolves.toEqual([
221
+ { path: 'app.ts', status: 'Modified', additions: 1, deletions: 1 },
222
+ ]);
223
+ });
224
+
225
+ it('restores a legacy original without mutating its snapshot when undo runs', async () => {
226
+ const { session, baselinePath } = await createLegacySession('legacy original\n');
227
+ await writeFile(join(workDir, 'app.ts'), 'current content\n', 'utf-8');
228
+
229
+ await manager.undo(session, 'app.ts');
230
+
231
+ await expect(readFile(join(workDir, 'app.ts'), 'utf-8')).resolves.toBe('legacy original\n');
232
+ await expect(readFile(baselinePath, 'utf-8')).resolves.toBe('legacy original\n');
233
+ });
234
+
235
+ it('persists a keep tombstone for legacy content without deleting the old snapshot', async () => {
236
+ const { session, baselinePath } = await createLegacySession('legacy original\n');
237
+ await writeFile(join(workDir, 'app.ts'), 'accepted content\n', 'utf-8');
238
+ await manager.keep(session, 'app.ts');
239
+
240
+ const reloadedManager = new BaselineManager(storageRoot);
241
+
242
+ await expect(reloadedManager.getChanges(session)).resolves.toEqual([]);
243
+ await expect(readFile(baselinePath, 'utf-8')).resolves.toBe('legacy original\n');
244
+ });
245
+
246
+ it('uses a materialized local original before the fork target legacy fallback', async () => {
247
+ const source = createSession('ses-source');
248
+ const filePath = join(workDir, 'app.ts');
249
+ await writeFile(filePath, 'local original\n', 'utf-8');
250
+ await manager.capture(source, filePath);
251
+ const { session: target } = await createLegacySession('legacy original\n', 'ses-target');
252
+
253
+ await manager.materializeToFork(source, target);
254
+
255
+ await expect(manager.getContent(target, 'app.ts')).resolves.toBe('local original\n');
256
+ });
257
+
258
+ it('keeps a fork baseline readable when the legacy source is later deleted', async () => {
259
+ const { session: source, legacySessionDir } = await createLegacySession(
260
+ 'legacy original\n',
261
+ 'ses-source',
262
+ );
263
+ const target = createSession('ses-target', source.metadata);
264
+ await manager.materializeToFork(source, target);
265
+ await rm(legacySessionDir, { recursive: true, force: true });
266
+
267
+ await expect(manager.getContent(target, 'app.ts')).resolves.toBe('legacy original\n');
268
+ });
269
+
270
+ it('keeps the source baseline visible when the fork accepts its own copy', async () => {
271
+ const { session: source } = await createLegacySession('legacy original\n', 'ses-source');
272
+ const target = createSession('ses-target', source.metadata);
273
+ await manager.materializeToFork(source, target);
274
+
275
+ await manager.keep(target, 'app.ts');
276
+
277
+ await expect(manager.getContent(source, 'app.ts')).resolves.toBe('legacy original\n');
278
+ });
279
+
280
+ it('preserves accepted legacy paths when a fork is materialized', async () => {
281
+ const { session: source } = await createLegacySession('legacy original\n', 'ses-source');
282
+ const target = createSession('ses-target', source.metadata);
283
+ await manager.keep(source, 'app.ts');
284
+
285
+ await manager.materializeToFork(source, target);
286
+
287
+ await expect(manager.getContent(target, 'app.ts')).rejects.toThrow('No baseline exists');
288
+ });
289
+ });
290
+
291
+ describe('baseline boundaries (errors, cleanup, and platform paths)', () => {
292
+ it.skipIf(process.platform === 'win32')(
293
+ 'rejects an unreadable original without recording an empty baseline',
294
+ async () => {
295
+ const session = createSession();
296
+ const filePath = join(workDir, 'unreadable.ts');
297
+ await writeFile(filePath, 'original', 'utf-8');
298
+ await chmod(filePath, 0o000);
299
+
300
+ try {
301
+ await expect(manager.capture(session, filePath)).rejects.toThrow(
302
+ 'Unable to capture original file',
303
+ );
304
+ await expect(manager.getContent(session, filePath)).rejects.toThrow(
305
+ 'No baseline exists',
306
+ );
307
+ } finally {
308
+ await chmod(filePath, 0o600);
309
+ }
310
+ },
311
+ );
312
+
313
+ it('rejects a directory path without recording an empty baseline', async () => {
314
+ const session = createSession();
315
+ const directoryPath = join(workDir, 'not-a-file');
316
+ await mkdir(directoryPath);
317
+
318
+ await expect(manager.capture(session, directoryPath)).rejects.toThrow(
319
+ 'is not a regular file',
320
+ );
321
+ await expect(manager.getContent(session, directoryPath)).rejects.toThrow(
322
+ 'No baseline exists',
323
+ );
324
+ });
325
+
326
+ it('throws a clear error when requested baseline content does not exist', async () => {
327
+ await expect(manager.getContent(createSession(), 'missing.ts')).rejects.toThrow(
328
+ 'No baseline exists for "missing.ts"',
329
+ );
330
+ });
331
+
332
+ it('rejects a relative path that escapes the workspace', async () => {
333
+ await expect(manager.capture(createSession(), '../outside.ts')).rejects.toThrow(
334
+ 'is outside workspace',
335
+ );
336
+ });
337
+
338
+ it('normalizes Windows drive case for an in-workspace baseline', async () => {
339
+ const session: BaselineSession = { id: 'ses-windows', workDir: 'C:\\Workspace' };
340
+ await manager.capture(session, 'C:\\Workspace\\src\\new.ts');
341
+
342
+ await expect(
343
+ manager.getContent(session, 'c:\\WORKSPACE\\SRC\\NEW.ts'),
344
+ ).resolves.toBe('');
345
+ });
346
+
347
+ it('rejects a Windows path on another drive', async () => {
348
+ const session: BaselineSession = { id: 'ses-windows', workDir: 'C:\\Workspace' };
349
+
350
+ await expect(manager.capture(session, 'D:\\Workspace\\file.ts')).rejects.toThrow(
351
+ 'is outside workspace',
352
+ );
353
+ });
354
+
355
+ it('normalizes case differences in an in-workspace UNC path', async () => {
356
+ const session: BaselineSession = {
357
+ id: 'ses-unc',
358
+ workDir: '\\\\Server\\Share\\Workspace',
359
+ };
360
+ await manager.capture(session, '\\\\server\\share\\workspace\\src\\new.ts');
361
+
362
+ await expect(manager.getContent(session, 'src\\new.ts')).resolves.toBe('');
363
+ });
364
+
365
+ it('rejects a UNC path from another share', async () => {
366
+ const session: BaselineSession = {
367
+ id: 'ses-unc',
368
+ workDir: '\\\\Server\\Share\\Workspace',
369
+ };
370
+
371
+ await expect(
372
+ manager.capture(session, '\\\\Server\\OtherShare\\Workspace\\file.ts'),
373
+ ).rejects.toThrow('is outside workspace');
374
+ });
375
+
376
+ it('removes persisted originals when the session baseline is deleted', async () => {
377
+ const session = createSession();
378
+ const filePath = join(workDir, 'app.ts');
379
+ await writeFile(filePath, 'original', 'utf-8');
380
+ await manager.capture(session, filePath);
381
+
382
+ await manager.deleteSession(session.id);
383
+
384
+ await expect(manager.getContent(session, filePath)).rejects.toThrow('No baseline exists');
385
+ });
386
+ });
387
+
388
+ function createSession(
389
+ id = 'ses-local',
390
+ metadata?: Readonly<Record<string, unknown>>,
391
+ ): BaselineSession {
392
+ return { id, workDir, metadata };
393
+ }
394
+
395
+ async function createLegacySession(
396
+ content: string,
397
+ id = 'ses-legacy',
398
+ ): Promise<{
399
+ session: BaselineSession;
400
+ legacySessionDir: string;
401
+ baselinePath: string;
402
+ }> {
403
+ const legacySessionDir = join(root, `legacy-${id}`);
404
+ const baselinePath = join(legacySessionDir, 'baseline', 'app.ts');
405
+ await mkdir(join(legacySessionDir, 'baseline'), { recursive: true });
406
+ await writeFile(baselinePath, content, 'utf-8');
407
+ return {
408
+ session: createSession(id, { kimi_cli_source_path: legacySessionDir }),
409
+ legacySessionDir,
410
+ baselinePath,
411
+ };
412
+ }
apps/vscode/test/bridge-handler.test.ts ADDED
@@ -0,0 +1,695 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Scenario: untrusted Webview RPC messages cross into the VS Code extension host.
3
+ * Responsibilities: validate requests, preserve public model metadata, omit private paths, and recover visibly from persisted state errors.
4
+ * Wiring: the real BridgeHandler and handlers; VS Code and the public Node SDK harness boundary are replaced.
5
+ * Run: pnpm --filter kimi-code exec vitest run --config vitest.config.ts test/bridge-handler.test.ts
6
+ */
7
+ import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
12
+ import type * as vscode from "vscode";
13
+
14
+ import { Methods } from "../shared/bridge";
15
+ import { BridgeHandler } from "../src/bridge-handler";
16
+
17
+ const host = vi.hoisted(() => {
18
+ const watcher = {
19
+ onDidChange: vi.fn(),
20
+ onDidCreate: vi.fn(),
21
+ onDidDelete: vi.fn(),
22
+ dispose: vi.fn(),
23
+ };
24
+ const harness = {
25
+ homeDir: "/tmp/kimi-code-test-home",
26
+ close: vi.fn(async () => undefined),
27
+ getConfig: vi.fn(),
28
+ setConfig: vi.fn(async () => undefined),
29
+ listSessions: vi.fn(async () => []),
30
+ resumeSession: vi.fn(),
31
+ forkSession: vi.fn(),
32
+ deleteSession: vi.fn(async () => undefined),
33
+ };
34
+ const showWarningMessage = vi.fn(async () => undefined as string | undefined);
35
+
36
+ class Uri {
37
+ readonly scheme = "file";
38
+ readonly authority = "";
39
+ readonly path: string;
40
+
41
+ constructor(readonly fsPath: string) {
42
+ this.path = fsPath;
43
+ }
44
+
45
+ static joinPath(base: Uri, ...segments: string[]): Uri {
46
+ return new Uri(join(base.fsPath, ...segments));
47
+ }
48
+
49
+ toString(): string {
50
+ return `file://${this.path}`;
51
+ }
52
+ }
53
+
54
+ return {
55
+ Uri,
56
+ watcher,
57
+ harness,
58
+ createKimiHarness: vi.fn(() => harness),
59
+ showWarningMessage,
60
+ workspaceFolders: [] as Array<{ uri: Uri }>,
61
+ };
62
+ });
63
+
64
+ vi.mock("vscode", () => ({
65
+ Uri: host.Uri,
66
+ workspace: {
67
+ get workspaceFolders() {
68
+ return host.workspaceFolders;
69
+ },
70
+ getConfiguration: () => ({ get: (_key: string, fallback: unknown) => fallback }),
71
+ createFileSystemWatcher: () => host.watcher,
72
+ textDocuments: [],
73
+ },
74
+ window: { showWarningMessage: host.showWarningMessage },
75
+ }));
76
+
77
+ vi.mock("@moonshot-ai/kimi-code-sdk", async (importOriginal) => {
78
+ const original = await importOriginal<typeof import("@moonshot-ai/kimi-code-sdk")>();
79
+ return {
80
+ ...original,
81
+ createKimiHarness: () => host.createKimiHarness(),
82
+ };
83
+ });
84
+
85
+ let bridge: BridgeHandler;
86
+ let root: string;
87
+ let showLogs: Mock<() => void>;
88
+ let writeLog: Mock<(message: string) => void>;
89
+ let workspaceState: { get: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
90
+
91
+ beforeEach(async () => {
92
+ root = await mkdtemp(join(tmpdir(), "kimi-vscode-bridge-"));
93
+ host.workspaceFolders.splice(0, host.workspaceFolders.length, { uri: new host.Uri(root) });
94
+ showLogs = vi.fn();
95
+ writeLog = vi.fn();
96
+ host.harness.resumeSession.mockReset();
97
+ host.harness.getConfig.mockReset();
98
+ host.harness.getConfig.mockResolvedValue({ models: {} });
99
+ host.createKimiHarness.mockImplementation(() => host.harness);
100
+ host.showWarningMessage.mockReset();
101
+ host.showWarningMessage.mockResolvedValue(undefined);
102
+ workspaceState = { get: vi.fn((_key, fallback) => fallback), update: vi.fn() };
103
+ bridge = new BridgeHandler(
104
+ vi.fn(),
105
+ workspaceState as unknown as vscode.Memento,
106
+ join(root, "global-storage"),
107
+ vi.fn(),
108
+ showLogs,
109
+ writeLog,
110
+ );
111
+ });
112
+
113
+ afterEach(async () => {
114
+ await bridge.dispose();
115
+ vi.clearAllMocks();
116
+ vi.unstubAllEnvs();
117
+ await rm(root, { recursive: true, force: true });
118
+ });
119
+
120
+ describe("Engine startup", () => {
121
+ function constructBridge(): void {
122
+ new BridgeHandler(
123
+ vi.fn(),
124
+ workspaceState as unknown as vscode.Memento,
125
+ join(root, "global-storage-2"),
126
+ vi.fn(),
127
+ showLogs,
128
+ writeLog,
129
+ );
130
+ }
131
+
132
+ it("surfaces the failure when the engine cannot start", () => {
133
+ host.createKimiHarness.mockImplementationOnce(() => {
134
+ throw new Error("engine boom");
135
+ });
136
+
137
+ expect(constructBridge).toThrow(/^Failed to start the Kimi engine: engine boom\.$/);
138
+ });
139
+ });
140
+
141
+ describe("Webview RPC boundary (validates requests before host dispatch)", () => {
142
+ it("returns a readable error when the envelope is not a plain object", async () => {
143
+ const result = await bridge.handle([], "view-1");
144
+
145
+ expect(result).toEqual({
146
+ id: "",
147
+ error: "Invalid bridge request: expected a plain object.",
148
+ });
149
+ });
150
+
151
+ it("does not execute a known handler when the request id is blank", async () => {
152
+ const result = await bridge.handle({ id: " ", method: Methods.ShowLogs }, "view-1");
153
+
154
+ expect(result).toEqual({
155
+ id: "",
156
+ error: "Invalid bridge request: id must be a non-empty string.",
157
+ });
158
+ expect(showLogs).not.toHaveBeenCalled();
159
+ });
160
+
161
+ it("reports aborted: false when the view has no runtime to cancel", async () => {
162
+ const result = await bridge.handle({ id: "rpc-1", method: Methods.AbortChat }, "view-1");
163
+
164
+ expect(result).toEqual({ id: "rpc-1", result: { aborted: false } });
165
+ });
166
+
167
+ it("cancels the view's runtime when aborting a chat", async () => {
168
+ const cancel = vi.fn(async () => undefined);
169
+ vi.spyOn(bridge.runtime, "getSessionForView").mockReturnValue({ cancel } as never);
170
+
171
+ const result = await bridge.handle({ id: "rpc-1", method: Methods.AbortChat }, "view-1");
172
+
173
+ expect(result).toEqual({ id: "rpc-1", result: { aborted: true } });
174
+ expect(cancel).toHaveBeenCalledOnce();
175
+ });
176
+
177
+ it.each(["missingMethod", "toString", "constructor", "__proto__"])(
178
+ "does not dispatch the unknown or prototype method %s",
179
+ async (method) => {
180
+ const result = await bridge.handle({ id: "rpc-1", method }, "view-1");
181
+
182
+ expect(result).toEqual({ id: "rpc-1", error: `Unknown bridge method: ${method}` });
183
+ expect(showLogs).not.toHaveBeenCalled();
184
+ },
185
+ );
186
+
187
+ it("does not execute a no-params handler when a payload is supplied", async () => {
188
+ const result = await bridge.handle(
189
+ { id: "rpc-1", method: Methods.ShowLogs, params: {} },
190
+ "view-1",
191
+ );
192
+
193
+ expect(result).toEqual({
194
+ id: "rpc-1",
195
+ error: "Invalid bridge params for method: showLogs",
196
+ });
197
+ expect(showLogs).not.toHaveBeenCalled();
198
+ });
199
+
200
+ it("does not execute an object-payload handler when a required field has the wrong type", async () => {
201
+ const result = await bridge.handle(
202
+ { id: "rpc-1", method: Methods.AddInputHistory, params: { text: 42 } },
203
+ "view-1",
204
+ );
205
+
206
+ expect(result).toEqual({
207
+ id: "rpc-1",
208
+ error: "Invalid bridge params for method: addInputHistory",
209
+ });
210
+ expect(workspaceState.update).not.toHaveBeenCalled();
211
+ });
212
+
213
+ it("dispatches a valid request through the existing bridge surface", async () => {
214
+ const result = await bridge.handle({ id: "rpc-1", method: Methods.ShowLogs }, "view-1");
215
+
216
+ expect(result).toEqual({ id: "rpc-1", result: { ok: true } });
217
+ expect(showLogs).toHaveBeenCalledOnce();
218
+ });
219
+
220
+ it("keeps provider identity when configured models share a display name", async () => {
221
+ host.harness.getConfig.mockResolvedValueOnce({
222
+ defaultModel: "openai/shared",
223
+ models: {
224
+ "openai/shared": {
225
+ provider: "openai",
226
+ model: "shared",
227
+ displayName: "Shared",
228
+ maxContextSize: 128_000,
229
+ },
230
+ "proxy/shared": {
231
+ provider: "company-proxy",
232
+ model: "shared",
233
+ displayName: "Shared",
234
+ maxContextSize: 128_000,
235
+ },
236
+ },
237
+ });
238
+
239
+ const result = await bridge.handle({ id: "rpc-models", method: Methods.GetModels }, "view-1");
240
+
241
+ expect(result).toMatchObject({
242
+ id: "rpc-models",
243
+ result: {
244
+ defaultModel: "openai/shared",
245
+ models: [
246
+ { id: "openai/shared", name: "Shared", provider: "openai" },
247
+ { id: "proxy/shared", name: "Shared", provider: "company-proxy" },
248
+ ],
249
+ },
250
+ });
251
+ });
252
+
253
+ it("preserves adaptive thinking metadata in the Webview model list", async () => {
254
+ host.harness.getConfig.mockResolvedValueOnce({
255
+ defaultModel: "anthropic/claude",
256
+ models: {
257
+ "anthropic/claude": {
258
+ provider: "anthropic",
259
+ model: "claude-sonnet",
260
+ maxContextSize: 200_000,
261
+ adaptiveThinking: true,
262
+ },
263
+ },
264
+ });
265
+
266
+ const result = await bridge.handle({ id: "rpc-models", method: Methods.GetModels }, "view-1");
267
+
268
+ expect(result).toMatchObject({
269
+ result: {
270
+ models: [{
271
+ id: "anthropic/claude",
272
+ name: "claude-sonnet",
273
+ provider: "anthropic",
274
+ adaptive_thinking: true,
275
+ }],
276
+ },
277
+ });
278
+ });
279
+
280
+ it("resolves the fallback-profile default effort with the provider type", async () => {
281
+ // claude-latest declares efforts but no default; the Anthropic fallback
282
+ // profile only matches when the provider type joins the resolution.
283
+ host.harness.getConfig.mockResolvedValueOnce({
284
+ defaultModel: "custom/claude",
285
+ providers: {
286
+ custom: { type: "anthropic", apiKey: "test-key" },
287
+ },
288
+ models: {
289
+ "custom/claude": {
290
+ provider: "custom",
291
+ model: "claude-latest",
292
+ supportEfforts: ["low", "medium", "high", "xhigh", "max"],
293
+ },
294
+ },
295
+ });
296
+
297
+ const result = await bridge.handle({ id: "rpc-models", method: Methods.GetModels }, "view-1");
298
+
299
+ expect(result).toMatchObject({
300
+ result: {
301
+ models: [{
302
+ id: "custom/claude",
303
+ support_efforts: ["low", "medium", "high", "xhigh", "max"],
304
+ default_effort: "high",
305
+ }],
306
+ },
307
+ });
308
+ });
309
+
310
+ it("does not expose the session storage path when listing sessions", async () => {
311
+ host.harness.listSessions.mockResolvedValueOnce([
312
+ {
313
+ id: "session-1",
314
+ workDir: root,
315
+ sessionDir: "/private/kimi/sessions/session-1",
316
+ updatedAt: 123,
317
+ title: "Visible title",
318
+ },
319
+ ] as never);
320
+
321
+ const result = await bridge.handle(
322
+ { id: "rpc-1", method: Methods.GetKimiSessions },
323
+ "view-1",
324
+ );
325
+
326
+ expect(result).toEqual({
327
+ id: "rpc-1",
328
+ result: [{ id: "session-1", workDir: root, updatedAt: 123, brief: "Visible title" }],
329
+ });
330
+ expect(JSON.stringify(result)).not.toContain("/private/kimi/sessions");
331
+ });
332
+
333
+ it("does not expose the session storage path when forking a session", async () => {
334
+ const source = {
335
+ id: "session-1",
336
+ workDir: root,
337
+ sessionDir: "/private/kimi/sessions/session-1",
338
+ updatedAt: 123,
339
+ };
340
+ const target = {
341
+ id: "session-2",
342
+ workDir: root,
343
+ sessionDir: "/private/kimi/sessions/session-2",
344
+ updatedAt: 124,
345
+ };
346
+ host.harness.listSessions.mockResolvedValueOnce([source] as never);
347
+ host.harness.forkSession.mockResolvedValueOnce({ summary: target, close: vi.fn() });
348
+
349
+ const result = await bridge.handle(
350
+ {
351
+ id: "rpc-1",
352
+ method: Methods.ForkKimiSession,
353
+ params: { sessionId: "session-1", turnIndex: 0 },
354
+ },
355
+ "view-1",
356
+ );
357
+
358
+ expect(result).toEqual({ id: "rpc-1", result: { sessionId: "session-2" } });
359
+ expect(JSON.stringify(result)).not.toContain("/private/kimi/sessions");
360
+ });
361
+
362
+ it("runs a fork through the active session cancellation boundary", async () => {
363
+ const source = {
364
+ id: "session-1",
365
+ workDir: root,
366
+ sessionDir: "/private/kimi/sessions/session-1",
367
+ updatedAt: 123,
368
+ };
369
+ const target = {
370
+ id: "session-2",
371
+ workDir: root,
372
+ sessionDir: "/private/kimi/sessions/session-2",
373
+ updatedAt: 124,
374
+ };
375
+ const runExclusiveAfterCancelling = vi.fn(async <T>(action: () => Promise<T>) => action());
376
+ vi.spyOn(bridge.runtime, "getSession").mockReturnValue({
377
+ runExclusiveAfterCancelling,
378
+ } as never);
379
+ host.harness.listSessions.mockResolvedValueOnce([source] as never);
380
+ host.harness.forkSession.mockResolvedValueOnce({ summary: target, close: vi.fn() });
381
+
382
+ const result = await bridge.handle(
383
+ {
384
+ id: "rpc-1",
385
+ method: Methods.ForkKimiSession,
386
+ params: { sessionId: "session-1", turnIndex: 0 },
387
+ },
388
+ "view-1",
389
+ );
390
+
391
+ expect(result).toEqual({ id: "rpc-1", result: { sessionId: "session-2" } });
392
+ expect(runExclusiveAfterCancelling).toHaveBeenCalledOnce();
393
+ expect(host.harness.forkSession).toHaveBeenCalledOnce();
394
+ });
395
+
396
+ it("closes and removes a fork when its baseline cannot be materialized", async () => {
397
+ const source = { id: "session-1", workDir: root, updatedAt: 123 };
398
+ const target = { id: "session-2", workDir: root, updatedAt: 124 };
399
+ const close = vi.fn(async () => undefined);
400
+ host.harness.listSessions.mockResolvedValueOnce([source] as never);
401
+ host.harness.forkSession.mockResolvedValueOnce({ summary: target, close });
402
+ vi.spyOn(bridge.baselineManager, "materializeToFork").mockRejectedValueOnce(
403
+ new Error("baseline unavailable"),
404
+ );
405
+ const deleteBaseline = vi.spyOn(bridge.baselineManager, "deleteSession");
406
+
407
+ const result = await bridge.handle(
408
+ {
409
+ id: "rpc-1",
410
+ method: Methods.ForkKimiSession,
411
+ params: { sessionId: "session-1", turnIndex: 0 },
412
+ },
413
+ "view-1",
414
+ );
415
+
416
+ expect(result).toEqual({ id: "rpc-1", error: "baseline unavailable" });
417
+ expect(close).toHaveBeenCalledOnce();
418
+ expect(host.harness.deleteSession).toHaveBeenCalledWith("session-2");
419
+ expect(deleteBaseline).toHaveBeenCalledWith("session-2");
420
+ });
421
+
422
+ it("keeps conversation history available when its baseline snapshot disappears", async () => {
423
+ const session = createResumedSession("session-1", root);
424
+ host.harness.resumeSession.mockResolvedValueOnce(session as never);
425
+ host.showWarningMessage.mockResolvedValueOnce("Show Logs");
426
+ const sourcePath = join(root, "app.ts");
427
+ await writeFile(sourcePath, "original\n", "utf-8");
428
+ await bridge.baselineManager.capture(session.summary, sourcePath);
429
+ const baselinesRoot = join(root, "global-storage", "baselines");
430
+ const [homeDirectory] = await readdir(baselinesRoot);
431
+ const [sessionDirectory] = await readdir(join(baselinesRoot, homeDirectory!));
432
+ const snapshotsDirectory = join(
433
+ baselinesRoot,
434
+ homeDirectory!,
435
+ sessionDirectory!,
436
+ "snapshots",
437
+ );
438
+ const [snapshot] = await readdir(snapshotsDirectory);
439
+ await rm(join(snapshotsDirectory, snapshot!));
440
+
441
+ const result = await bridge.handle(
442
+ {
443
+ id: "rpc-1",
444
+ method: Methods.LoadKimiSessionHistory,
445
+ params: { kimiSessionId: "session-1" },
446
+ },
447
+ "view-1",
448
+ );
449
+
450
+ expect(result).toEqual({
451
+ id: "rpc-1",
452
+ result: expect.arrayContaining([
453
+ expect.objectContaining({ type: "StatusUpdate", _sessionId: "session-1" }),
454
+ ]),
455
+ });
456
+ expect(writeLog).toHaveBeenCalledWith(
457
+ expect.stringMatching(/Unable to restore session file changes.*Unable to read baseline snapshot/),
458
+ );
459
+ await vi.waitFor(() => expect(showLogs).toHaveBeenCalledOnce());
460
+ });
461
+
462
+ it("returns a readable error when persisted session state is corrupt without wedging the bridge", async () => {
463
+ host.harness.resumeSession.mockRejectedValueOnce(
464
+ new Error("Session state is invalid JSON at line 4"),
465
+ );
466
+
467
+ const failed = await bridge.handle(
468
+ {
469
+ id: "rpc-1",
470
+ method: Methods.LoadKimiSessionHistory,
471
+ params: { kimiSessionId: "session-1" },
472
+ },
473
+ "view-1",
474
+ );
475
+ const next = await bridge.handle({ id: "rpc-2", method: Methods.ShowLogs }, "view-1");
476
+
477
+ expect(failed).toEqual({
478
+ id: "rpc-1",
479
+ error: "Session state is invalid JSON at line 4",
480
+ });
481
+ expect(writeLog).toHaveBeenCalledWith(
482
+ expect.stringContaining("Session state is invalid JSON at line 4"),
483
+ );
484
+ expect(next).toEqual({ id: "rpc-2", result: { ok: true } });
485
+ });
486
+ });
487
+
488
+ describe("Registered working directories", () => {
489
+ it("lists the workspace root when there is no session history", async () => {
490
+ host.harness.listSessions.mockResolvedValueOnce([] as never);
491
+
492
+ const result = await bridge.handle({ id: "rpc-1", method: Methods.GetRegisteredWorkDirs }, "view-1");
493
+
494
+ expect(result).toEqual({ id: "rpc-1", result: [root] });
495
+ });
496
+
497
+ it("keeps the selected working directory visible without session history", async () => {
498
+ const sub = join(root, "packages", "demo");
499
+ await mkdir(sub, { recursive: true });
500
+ host.harness.listSessions.mockResolvedValue([] as never);
501
+
502
+ await bridge.handle({ id: "rpc-1", method: Methods.SetWorkDir, params: { workDir: sub } }, "view-1");
503
+ const result = await bridge.handle({ id: "rpc-2", method: Methods.GetRegisteredWorkDirs }, "view-1");
504
+
505
+ expect(result).toEqual({ id: "rpc-2", result: [root, sub].toSorted() });
506
+ });
507
+
508
+ it("merges session-history directories and hides directories outside the workspace", async () => {
509
+ const inside = join(root, "nested");
510
+ host.harness.listSessions.mockResolvedValueOnce([
511
+ { id: "s-1", workDir: inside },
512
+ { id: "s-2", workDir: "/private/outside" },
513
+ { id: "s-3", workDir: root },
514
+ ] as never);
515
+
516
+ const result = await bridge.handle({ id: "rpc-1", method: Methods.GetRegisteredWorkDirs }, "view-1");
517
+
518
+ expect(result).toEqual({ id: "rpc-1", result: [inside, root].toSorted() });
519
+ expect(JSON.stringify(result)).not.toContain("/private/outside");
520
+ });
521
+ });
522
+
523
+ describe("Webview config saves (thinking effort persistence parity with the TUI)", () => {
524
+ const effortModel = {
525
+ provider: "managed:kimi-code",
526
+ model: "reasoning",
527
+ supportEfforts: ["low", "high", "max"],
528
+ defaultEffort: "high",
529
+ };
530
+
531
+ function mockConfig(thinking?: { enabled: boolean; effort?: string }) {
532
+ host.harness.getConfig.mockResolvedValue({
533
+ defaultModel: "kimi/reasoning",
534
+ thinking,
535
+ models: { "kimi/reasoning": effortModel },
536
+ } as never);
537
+ }
538
+
539
+ it("persists a non-top effort as the global default", async () => {
540
+ mockConfig();
541
+
542
+ const result = await bridge.handle(
543
+ { id: "rpc-1", method: Methods.SaveConfig, params: { model: "kimi/reasoning", thinking: true, effort: "high" } },
544
+ "view-1",
545
+ );
546
+
547
+ expect(result).toEqual({ id: "rpc-1", result: { ok: true } });
548
+ expect(host.harness.setConfig).toHaveBeenCalledWith({
549
+ defaultModel: "kimi/reasoning",
550
+ thinking: { enabled: true, effort: "high" },
551
+ });
552
+ });
553
+
554
+ it("keeps a pick above the model's delivered default session-only", async () => {
555
+ mockConfig();
556
+
557
+ await bridge.handle(
558
+ { id: "rpc-1", method: Methods.SaveConfig, params: { model: "kimi/reasoning", thinking: true, effort: "max" } },
559
+ "view-1",
560
+ );
561
+
562
+ expect(host.harness.setConfig).toHaveBeenCalledWith({
563
+ defaultModel: "kimi/reasoning",
564
+ thinking: { enabled: true },
565
+ });
566
+ });
567
+
568
+ it("persists the top tier when the model's delivered default is the top tier", async () => {
569
+ host.harness.getConfig.mockResolvedValue({
570
+ defaultModel: "kimi/reasoning",
571
+ models: { "kimi/reasoning": { ...effortModel, defaultEffort: "max" } },
572
+ } as never);
573
+
574
+ await bridge.handle(
575
+ { id: "rpc-1", method: Methods.SaveConfig, params: { model: "kimi/reasoning", thinking: true, effort: "max" } },
576
+ "view-1",
577
+ );
578
+
579
+ expect(host.harness.setConfig).toHaveBeenCalledWith({
580
+ defaultModel: "kimi/reasoning",
581
+ thinking: { enabled: true, effort: "max" },
582
+ });
583
+ });
584
+
585
+ it("keeps an xhigh pick session-only when the default comes from the Anthropic profile inference", async () => {
586
+ // claude-opus-4-7 declares no efforts; the profile inference supplies
587
+ // [low, medium, high, xhigh, max] and resolves the default to "high".
588
+ host.harness.getConfig.mockResolvedValue({
589
+ defaultModel: "custom/claude",
590
+ models: { "custom/claude": { provider: "custom", model: "claude-opus-4-7" } },
591
+ } as never);
592
+
593
+ await bridge.handle(
594
+ { id: "rpc-1", method: Methods.SaveConfig, params: { model: "custom/claude", thinking: true, effort: "xhigh" } },
595
+ "view-1",
596
+ );
597
+
598
+ expect(host.harness.setConfig).toHaveBeenCalledWith({
599
+ defaultModel: "custom/claude",
600
+ thinking: { enabled: true },
601
+ });
602
+ });
603
+
604
+ it("persists the concrete effort when the model's levels are unknown", async () => {
605
+ host.harness.getConfig.mockResolvedValue({ defaultModel: "other/model", models: {} });
606
+
607
+ await bridge.handle(
608
+ { id: "rpc-1", method: Methods.SaveConfig, params: { model: "custom/model", thinking: true, effort: "max" } },
609
+ "view-1",
610
+ );
611
+
612
+ expect(host.harness.setConfig).toHaveBeenCalledWith({
613
+ defaultModel: "custom/model",
614
+ thinking: { enabled: true, effort: "max" },
615
+ });
616
+ });
617
+
618
+ it("leaves the stored effort alone when the pick re-confirms the active effort", async () => {
619
+ mockConfig({ enabled: false, effort: "low" });
620
+
621
+ await bridge.handle(
622
+ { id: "rpc-1", method: Methods.SaveConfig, params: { model: "kimi/reasoning", thinking: true, effort: "high", effortChanged: false } },
623
+ "view-1",
624
+ );
625
+
626
+ expect(host.harness.setConfig).toHaveBeenCalledWith({
627
+ defaultModel: "kimi/reasoning",
628
+ thinking: { enabled: true },
629
+ });
630
+ });
631
+
632
+ it("skips the config write entirely when nothing changed", async () => {
633
+ mockConfig({ enabled: true, effort: "high" });
634
+
635
+ await bridge.handle(
636
+ { id: "rpc-1", method: Methods.SaveConfig, params: { model: "kimi/reasoning", thinking: true, effort: "high" } },
637
+ "view-1",
638
+ );
639
+
640
+ expect(host.harness.setConfig).not.toHaveBeenCalled();
641
+ });
642
+ });
643
+
644
+ function createResumedSession(id: string, workDir: string) {
645
+ const close = vi.fn(async () => undefined);
646
+ const summary = {
647
+ id,
648
+ workDir,
649
+ sessionDir: join("/private/kimi/sessions", id),
650
+ createdAt: 1,
651
+ updatedAt: 2,
652
+ metadata: { vscode_legacy_approval: { yolo: false, afk: false } },
653
+ };
654
+ return {
655
+ id,
656
+ workDir,
657
+ summary,
658
+ close,
659
+ getResumeState: () => ({
660
+ sessionMetadata: { agents: {} },
661
+ agents: {
662
+ main: {
663
+ type: "main",
664
+ config: {
665
+ cwd: workDir,
666
+ modelAlias: "test-model",
667
+ modelCapabilities: {
668
+ image_in: false,
669
+ video_in: false,
670
+ audio_in: false,
671
+ thinking: false,
672
+ tool_use: true,
673
+ max_context_tokens: 128_000,
674
+ },
675
+ thinkingEffort: "off",
676
+ systemPrompt: "",
677
+ },
678
+ context: { history: [], tokenCount: 0 },
679
+ replay: [],
680
+ permission: { mode: "manual", rules: [] },
681
+ plan: null,
682
+ usage: {},
683
+ tools: [],
684
+ background: [],
685
+ },
686
+ },
687
+ }),
688
+ getStatus: async () => ({ permission: "manual" }),
689
+ setPermission: async () => undefined,
690
+ updateMetadata: async () => undefined,
691
+ setApprovalHandler: () => undefined,
692
+ setQuestionHandler: () => undefined,
693
+ onEvent: () => () => undefined,
694
+ };
695
+ }