Skip to content
5 changes: 5 additions & 0 deletions .changeset/context-gauge-counts-cjk-per-character.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix the context gauge under-reporting CJK text.
5 changes: 5 additions & 0 deletions .changeset/recover-request-structure-rejection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Recover automatically when a provider rejects the structure of a request.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

The remote-control page prefix is now escaped for embedding in a script block, so a prefix containing a closing tag can no longer break out of it.
5 changes: 5 additions & 0 deletions .changeset/undo-restores-workspace-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Undo now also restores the task list and plan state.
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ it from `packages/i18n-catalog/src/locales/en.ts` when you touch the catalog.
question at all — v2's `src/runtime/` capability layer that §6.8.2 needs is
registered in `packages/kimi-agent/ROADMAP.md` §6.24, and the engine's
missing runtime layer is upstream's to match, not a fork preference.
**Corrected 2026-10-01 (§6.42):** the *layer* is a real unported subsystem,
but it is **not** a prerequisite for the watcher. `RuntimeCapability` is
`'fs' | 'process' | 'terminal'` — there is no `watch` rung
(`runtime/runtime.ts:8`); upstream's watcher is a standalone service
(`human/utils/watch.ts:473`, own `[watch]` config section). Schedule §6.8.2
as its own subsystem.
- **The Grep / Glob notices were the reverse case, and it is fixed (2026-10-01).**
Seven of them — `noMatches`, `noNonSensitive`, `noNonSensitiveFiltered`,
`noMoreMatches`, `noFilesMatched`, `filteredSensitive`,
Expand Down
164 changes: 164 additions & 0 deletions MEMORY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Project memory — kimi-code fork

## Remote transports (user rule, 2026-10-01)

- **GitHub → SSH.** `origin` = `git@github.com:7723qqq/kimi-code.git`,
`upstream` = `git@github.com:MoonshotAI/kimi-code.git`.
- **cnb stays as-is (HTTPS).** Do not convert it. It is a separate host; the rule is
GitHub-specific.
- Why it matters here: **direct `github.com:443` is unreachable from this machine** —
`git clone https://github.com/...` hangs ~20s then fails. SSH to github works and needs
no extra env (key is in the default agent). So an HTTPS GitHub URL is not merely
inelegant, it is broken on this box.
- Consequence: the upstream-v2-delta ratchet can be refreshed with the command the root
`AGENTS.md` documents — `git fetch upstream main:refs/remotes/upstream/main --force`.
Push target for everyday work is `origin` (per AGENTS.md, push every commit there).

## Upstream v2 reference checkout (`.tmp/v2-ref-upstream`)

Primary route (works now that `upstream` is SSH):

```sh
git fetch upstream main:refs/remotes/upstream/main --force # refresh the ref first
sha=$(git rev-parse upstream/main)
ref=.tmp/v2-ref-upstream
[ -d $ref ] || git clone --filter=blob:none --no-checkout --single-branch \
--branch main https://github.com/MoonshotAI/kimi-code.git $ref # or borrow objects, below
git -C $ref sparse-checkout set packages/agent-core-v2 packages/kosong packages/transcript packages/minidb packages/oauth
git -C $ref checkout --detach $sha
```

Offline fallback (used 2026-10-01 when HTTPS to github was the only transport tried): `git init`
the dir, point `.git/objects/info/alternates` at the main repo's object store, then
`remote add origin https://github.com/MoonshotAI/kimi-code.git` so `git remote -v` names the
official origin.

It stays a reference only: `.tmp/` is gitignored (`.gitignore:36`) and no glob in the root
`workspaces` field covers it, so it never enters the Bun workspace or any gate. Re-read
`.tmp/v2-ref-upstream` before citing it — it is a checkout, and it drifts.

**Always pass `-C $ref` to every git command in that directory.** A failed `cd .tmp/...` followed by
a bare `git sparse-checkout set ...` runs in the *main* repo instead: it rewrites the main
worktree's sparse cone, and `git sparse-checkout disable` afterwards re-materializes every
index-clean-but-deleted file, silently resurrecting another task's uncommitted deletions
(here 43 deleted test files). Recovery: `git ls-files` the paths, confirm each is unmodified
(`git diff --quiet`), delete them again, then re-check `git diff --stat` against the baseline.

## Clearing `check:normify` (`evidence/fingerprint-drift`)

`bun run check:normify` runs `bun packages/normify/src/cli.ts normify_validate`. Every
`evidence/fingerprint-drift` error is cleared by the normify tool itself — **never** by hand-editing
`normify-kimi-code/tree.json`:

```sh
bun packages/normify/src/cli.ts normify_module_refresh \
'{"project":"kimi-code","repoRoot":".","ids":["<module>", "<its parent>"]}'
```

Pass the **child and its parent together** (plus any other ancestor whose subtree moved), then
re-run `check:normify` — refreshing only the child leaves the parent's aggregate fingerprint stale
and the gate still red. The 60 `structure/leaf-too-coarse` / `evidence/root-no-source` warnings are
advisory and do not gate. `bun packages/normify/src/cli.ts --list` lists the tool set
(`normify_sync` is read-only: it only prints a rebuild plan).

One bad id in the `ids` array aborts the **whole** call (it returns
`module/not-found` and writes nothing) — so a typo in a guessed parent name silently costs a
full retry. Get the ids from the `check:normify` error output itself, and add only ancestors that
actually appear as ancestors in `normify-kimi-code/tree.json`.

`bun run check:architecture -- --update` is the equivalent for `architecture.json`'s Check E
fingerprints; it prints each module's old → new hash so the diff is reviewable.

## Skill scan roots: a root is a *container* of skill directories

`scan_directory(dir)` reads `dir/<skill>/SKILL.md`, so every scan root — project, user,
`extra_skill_dirs`, **plugin** — is a directory *containing* skill folders, never a skill
folder itself. The plugin-identity lookup therefore compares the plugin root against
`Path::new(&found.path).parent().and_then(Path::parent)` (skill dir → its parent), not against
the skill dir. Getting this off by one level silently yields `plugin: None`, and the
`<plugin-instructions>` prefix just disappears with no error.

A scan root is scanned **regardless of `merge_all_available_skills`**: that flag only collapses
the *brand* groups (`.agents/skills` + `.kimi-code/skills` → first existing). Extra and plugin
roots are walked one by one. So a precedence change among them is order-only.

Order is first-wins, and `roots_in_precedence_order()` is the single place that expresses it
(extra before plugin, i.e. v2's rank 10 > 5) — `SkillScanRoots::catalog()` and `scan_skill()` both
call it, because the `Skill` tool and the prompt must never disagree about what exists.

## A wire type with `[key: string]: any` hides producer gaps

`PluginInfo` carried `readonly [key: string]: any`, so the TUI plugin panel could read twelve
fields the engine never sent and **nothing failed to compile** — the panel's manifest lines,
`installedAt`, `github`, `diagnostics` were all silently blank. Any wire type that is a plain
`JSON.parse` of engine output should be typed field by field; the index signature is what turned a
missing producer into an invisible bug. When auditing a "dead read", check the producer's struct
before concluding the consumer is wrong — here the consumer was faithful to v2 and the engine was
behind.

The sharper version of the same trap is an **enum-valued field**: `PluginInfo.source` is compared
against `'github'` / `'zip-url'` by four separate host functions (provenance label, trust badge,
official-install check, the update notifier's identity key). The engine sent the raw catalog string,
so every comparison missed and all four took their "unknown" branch — every plugin was badged
third-party and the official-marketplace update notice could never fire. A vocabulary field is not
a display value: consumers branch on it, so a producer that skips it disables features silently.
Ask "what branches on this?" before treating a field as cosmetic.

## Two things about testing plugin code offline

- `harness.installPlugin(id)` on a **remote** catalog source performs a real download (it routes to
the fetch-and-extract path), so a TS test cannot install a `github` / `zip-url` plugin without
network. Those wire shapes are asserted at the Rust + serde level instead; the TS layer can only
cover `local-path`.
- The whole `node-sdk` native suite tears its temp home dir down with a 10 × 200 ms `rmSync` retry
(Windows file locks). Under a full 526-file parallel run that budget can be exhausted, producing
an `EBUSY` failure **in `afterEach`** with the test body green. It passes in isolation. Do not
"fix" it by enlarging the budget — 10 is the repo-wide convention (`removeTempDir` in
`test/session-runtime-helpers.ts` uses the same), and the two such flakes seen so far
(`footer-status-line` throttle timing, this one) are load, not logic.

## PowerShell round-trips corrupt line endings

`[System.IO.File]::WriteAllLines` writes `\r\n`, so using it to patch a source file converts the
**whole file** to CRLF while every other file in the tree is LF. `ReadAllText` / `WriteAllText`
preserve the string verbatim and are safe. After any bulk rewrite, check:
`git diff --stat` should show a plausible line count, and `cargo fmt --check` plus
`bun run check:*` will not catch it. To repair: strip CR bytes before LF.


## Plugin manifest paths are third-party input

`parse_plugin_manifest` enforces v2's rules (`app/plugin/manifest.ts:161-212`): every `skills` /
`agents` entry must start with `./`, is canonicalized, and must be **component-wise** inside the
plugin root (`isWithin` is not a string-prefix test — `/plugin-evil` is not within `/plugin`).
Before this, the fork did `root.join(rel.trim_start_matches("./"))` and additionally accepted an
absolute path outright, so a manifest could point the skill scan anywhere on the machine.

**Comparing a path against a canonicalized root canonicalizes neither side consistently.** The first
version canonicalized the *target* and fell back to "the path as written" when that failed, while
the root was canonicalized. Windows spells a path with an 8.3 short name (`C:\Users\ADMINI~1\...`)
and `canonicalize` expands it (`C:\Users\Administrator\...`), so a **missing** directory came back as
"resolves outside the plugin" — an error, on the most ordinary manifest there is. `canonicalize`
failing on a non-existent path is the trap; `canonicalize_allowing_missing()` (canonicalize the
deepest *existing* ancestor, re-append the tail) fixes it. Whenever a check compares a path against a
canonical reference, ask what that check returns for a path that does not exist.

Two Windows details worth remembering:
- `std::fs::canonicalize` returns the `\\?\` verbatim form. It works for every filesystem call and
reads terribly everywhere else, so strip it (`without_verbatim_prefix`) before the value leaves
the module — a plugin panel is not the place for `\\?\C:\...`. Keep the canonical form for the
containment comparisons themselves.
- `#[serde(skip_serializing_if = "Vec::is_empty")]` means the host sees `undefined`, not `[]`. A
host assertion written as `toEqual([])` fails; assert `toBeUndefined()` or normalize host-side.



## `packages/node-sdk` tests cannot observe model-facing turn content

The `@moonshot-ai/kosong` fake-provider mock in `packages/node-sdk/test/*` is never consumed by
an assertion: the native Rust engine builds its own providers, so a turn there makes no
provider call, `session.getContext()` comes back empty, and no `agents/main/wire.jsonl` is
written. Anything asserted about **what the model receives** (message part order, block
composition) cannot be pinned in that suite — it needs a fixture that really runs a model call.
Metadata-level facts (session title / lastPrompt in `session-meta.json`, SDK events) *are*
observable and are asserted there.
14 changes: 13 additions & 1 deletion apps/kimi-code/src/cli/sub/web/remote-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,19 @@ export function rewriteRemoteControlResponse(
): Buffer {
const normalizedPrefix = publicPrefix.replace(/\/+$/, '');
if (contentType.toLowerCase().includes('text/html')) {
const prefixLiteral = JSON.stringify(normalizedPrefix);
// `JSON.stringify` escapes quotes and backslashes but **not** `<`, so a
// prefix containing `</script>` closes the block early and everything after
// it is parsed as markup. Escaping `<` keeps the literal valid JavaScript
// and inert to the HTML parser; U+2028 / U+2029 are line terminators to a
// pre-ES2019 parser and would break the statement on their own.
//
// Today's only caller percent-encodes both halves of the prefix, so this is
// hardening rather than a live hole — but the function takes an arbitrary
// string, and the escaping it relied on did not make that true.
const prefixLiteral = JSON.stringify(normalizedPrefix)
.replaceAll('<', '\\u003c')
.replaceAll('\u2028', '\\u2028')
.replaceAll('\u2029', '\\u2029');
const injected = `<script>(function(){var p=${prefixLiteral};try{sessionStorage.setItem('kimi-desktop-server-origin',location.origin+p)}catch(e){}var w=function(f){return function(s,t,u){if(typeof u==='string'&&u.charAt(0)==='/'&&u.indexOf(p)!==0)u=p+u;return f.apply(this,[s,t,u])}};history.pushState=w(history.pushState);history.replaceState=w(history.replaceState)})();</script>`;
let text = body.toString('utf8');
const headMatch = /<head(?:\s[^>]*)?>/i.exec(text);
Expand Down
7 changes: 6 additions & 1 deletion apps/kimi-code/src/tui/utils/mcp-tool-name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ export function decodeMcpToolName(
if (!name.startsWith(PREFIX)) return null;
const rest = name.slice(PREFIX.length);
const sep = rest.indexOf('__');
if (sep <= 0 || sep === rest.length - 2) return null;
// `sep < 0` is the not-found case, `sep === 0` an empty server name, and
// `sep + 2 === rest.length` an empty tool name. The not-found check is spelled
// out rather than left to the `<= 0` fold because a bare `indexOf` result is
// compared against a length below, and `-1` silently satisfying that is the
// kind of thing a reader has to stop and re-derive.
if (sep < 0 || sep === 0 || sep + 2 === rest.length) return null;
return {
serverName: rest.slice(0, sep),
toolName: rest.slice(sep + 2),
Expand Down
24 changes: 24 additions & 0 deletions apps/kimi-code/test/cli/web/remote-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,30 @@ describe('Remote Control HTTP forwarding', () => {
).toString();
expect(css).toBe(`.x{background:url(${prefix}/assets/x.png)}`);
});

it('keeps a prefix that contains a script-closing tag from breaking out of the block', () => {
// `JSON.stringify` escapes quotes and backslashes but not `<`, so a prefix
// holding `</script>` used to end the block early and leave the rest of the
// literal to be parsed as markup. The one caller percent-encodes its halves
// today, but this function takes an arbitrary string and the escaping it
// relied on did not make that safe.
const html = rewriteRemoteControlResponse(
'text/html; charset=utf-8',
Buffer.from('<html><head></head><body></body></html>'),
'/relay/devices/d1</script><img src=x onerror=alert(1)>',
).toString();

// Exactly the one script block we inject: every `<` the payload carried is
// escaped, so it stays inside the literal instead of closing the block and
// letting the rest be parsed as markup. The text itself is still there — it
// is inert data in a JS string, which is where it belongs. Only `<` matters:
// a bare `>` cannot open a tag.
expect(html.match(/<script>/g)).toHaveLength(1);
Comment thread
7723qqq marked this conversation as resolved.
Dismissed
expect(html.match(/<\/script>/g)).toHaveLength(1);
expect(html).toContain('\\u003c');
expect(html).toContain('onerror=alert(1)');
expect(html).not.toContain('<img');
});
});

describe('Remote Control tunnel', () => {
Expand Down
44 changes: 44 additions & 0 deletions apps/kimi-code/test/tui/utils/mcp-tool-name.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';

import { decodeMcpToolName } from '#/tui/utils/mcp-tool-name';

describe('decodeMcpToolName', () => {
it('splits a qualified name into its server and tool', () => {
expect(decodeMcpToolName('mcp__github__create_issue')).toEqual({
serverName: 'github',
toolName: 'create_issue',
});
// The separator inside the server name wins: the split is at the first `__`,
// which is what the qualifier format guarantees.
expect(decodeMcpToolName('mcp__a__b__c')).toEqual({
serverName: 'a',
toolName: 'b__c',
});
});

it('returns null for a name that is not a qualified MCP tool', () => {
for (const name of [
'read_file',
'',
'mcp_github__tool',
'notmcp__github__tool',
]) {
expect(decodeMcpToolName(name)).toBeNull();
}
});

it('returns null when either half would be empty', () => {
// The not-found case is `-1`, and a length comparison against it is exactly
// the shape that reads as "found" if the guard is folded into `<= 0` — so
// each of these is pinned separately.
for (const name of [
'mcp____tool',
'mcp__server__',
'mcp__',
'mcp__nope',
'mcp__server',
]) {
expect(decodeMcpToolName(name)).toBeNull();
}
});
});
6 changes: 3 additions & 3 deletions architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"description": "ACP stdio protocol server"
}
],
"fingerprint": "742b72691c0ef941"
"fingerprint": "48fa12ac072808e7"
},
{
"id": "node-sdk",
Expand All @@ -46,7 +46,7 @@
"description": "Public SDK package"
}
],
"fingerprint": "2a77593bbea2580b"
"fingerprint": "68ae8e09460d1579"
},
{
"id": "kimi-code",
Expand Down Expand Up @@ -77,7 +77,7 @@
"description": "Terminal UI"
}
],
"fingerprint": "818da542445607dd"
"fingerprint": "55272b4d4b0ab5e4"
},
{
"id": "kosong",
Expand Down
6 changes: 3 additions & 3 deletions normify-kimi-code/modules/kimi-code/engine/core/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ description:
en: >
Engine core: the run_turn state machine, session lifecycle, compaction, subagent orchestration and the event bus.

revision: 7f51dbe916b917d5243c352efa2daf0c94bcb921
updated_at: "2026-10-01T15:12:03.014Z"
fingerprint: 80533daef7e5a950268a6822311de5bb455092ca6305a7dda823272cc4d35624
revision: 53ea8e5b33152202997612d20c3679e4841d5806
updated_at: "2026-10-02T09:11:48.975Z"
fingerprint: 01e626c0ee1840ddbdddd1fe7672e1954597c80207dcb08f561694cc1859ac0b
source:
- path: "packages/kimi-agent/src/turn_loop/run_turn.rs"
- path: "packages/kimi-agent/src/session/mod.rs"
Expand Down
6 changes: 3 additions & 3 deletions normify-kimi-code/modules/kimi-code/engine/core/turn-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ description:
en: >
The turn state machine: run_turn, step scheduler, retry, tool-call id ledger and wall-time budget.

revision: 73ef575d283131d72261a277b2f9d21ae0f9c4c8
updated_at: "2026-09-30T20:53:09.891Z"
fingerprint: 142c88da0d281aec5a37301cc23e2b611901a4f655dcd36fc6bc6372400bd2ff
revision: 53ea8e5b33152202997612d20c3679e4841d5806
updated_at: "2026-10-02T09:11:48.976Z"
fingerprint: 65fc2ca0987701f3f1341b5614983f9e28314586ece4a5a2dd62ca64bc5a72ba
source:
- path: "packages/kimi-agent/src/turn_loop/run_turn.rs"
- path: "packages/kimi-agent/src/turn_loop/turn_step.rs"
Expand Down
6 changes: 3 additions & 3 deletions normify-kimi-code/modules/kimi-code/engine/llm/anthropic.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ description:
en: >
Anthropic Messages protocol adapter: request shaping, thinking passthrough and stream mapping.

revision: 7f51dbe916b917d5243c352efa2daf0c94bcb921
updated_at: "2026-10-01T12:57:24.168Z"
fingerprint: 4fd61c1c342269f552398328d677455fe5ff8a7aa6fb688569be306c34847c58
revision: 53ea8e5b33152202997612d20c3679e4841d5806
updated_at: "2026-10-02T09:11:48.977Z"
fingerprint: 5fd4a404350be30b65ecfdaa47b850da10bc19a9022bee2c2c6e7d1c542f500f
source:
- path: "packages/kimi-agent/src/llm/anthropic.rs"
apis: []
Expand Down
6 changes: 3 additions & 3 deletions normify-kimi-code/modules/kimi-code/engine/llm/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ description:
en: >
Model protocol adapters: Anthropic, OpenAI Responses, Google GenAI and the shared HTTP layer.

revision: 7f51dbe916b917d5243c352efa2daf0c94bcb921
updated_at: "2026-10-01T12:57:24.169Z"
fingerprint: 4b2afd2dec268af8a9ef72606b214c8d8c3101f58c9f3d83580dba4f9a038d84
revision: 53ea8e5b33152202997612d20c3679e4841d5806
updated_at: "2026-10-02T09:11:48.977Z"
fingerprint: 5d4514495e98ff15280e9a4c7d7a423d0c49c1b7720c8355d203a65ccfbed142
source:
- path: "packages/kimi-agent/src/llm/anthropic.rs"
- path: "packages/kimi-agent/src/llm/openai_responses.rs"
Expand Down
Loading
Loading