Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
895b0aa
Run Kimi on the shared late-terminal mechanism and drop its own
Nov 10, 2026
abd36bd
Merge remote-tracking branch 'origin/main' into claude/ecstatic-barti…
Nov 10, 2026
75ec871
Fix the Windows SessionStart budget and the Sentinel review findings
Nov 10, 2026
93a3014
Address Sentinel's review of 75ec871
Nov 10, 2026
660ca6a
Read a session log only under the home its scope was taken from
Nov 10, 2026
d0a50cb
Keep late writes of an older session epoch off a newer range
Nov 10, 2026
da9ee83
Count each named task once and stop a cancelled distillation before d…
Nov 10, 2026
0a9518a
Keep a removal of an older session epoch off a newer range
Nov 10, 2026
4c85d85
Answer a distillation aborted during its Proxy request as dropped or …
Nov 10, 2026
49b8997
Give no status to a turn that runs through the unread middle of a lon…
Nov 10, 2026
e06422f
Give ACP completion tests a budget a cold Windows runner can meet
Nov 10, 2026
fa777dd
Read a session log or task file only if it is the file that was checked
Nov 10, 2026
ca10d57
Keep a late re-judge from overwriting a range's newer context, and re…
Nov 10, 2026
5c08055
Hash a credential's secret and device id as a structured value
Nov 10, 2026
119f901
Take a turn's diff from its own HEAD when the range is of another epoch
Nov 10, 2026
9d662fc
fix: derive reuse lineage from the fitted distillation record
imxyanua Nov 11, 2026
9d8bf1c
fix: retain unknown native owners during finalization
imxyanua Nov 11, 2026
d034b9e
Merge pull request #69 from EvoMap/claude/reuse-lineage-reporting
forrestlinfeng Nov 11, 2026
5beb7c3
Merge pull request #68 from EvoMap/claude/ecstatic-bartik-c7aa95
forrestlinfeng Nov 11, 2026
62a7b52
Merge origin/main and drop the superseded native owner fix
Nov 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 18 additions & 16 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,17 +367,21 @@ scripts/ pack.mjs, publish-tag.sh
## Indexed native sessions and isolated ACP completion

Kimi Code's profile uses wire protocol 1.5 for CLI 2.4.1 and Desktop 1.2.1 on Windows.
Both local clients use the same Kimi profile and managed plugin. The profile declares the native workspace/session
layout; `runtime/native-facts` owns bounded filesystem reads and refuses linked, replaced, malformed or partial
records. The driver opens a logical turn before the host admits its native turn. `app/native-turn` binds the two
using the captured byte/file boundary, then closes only the matched main-user terminal. `Stop` is an observer
hint. A detached native monitor catches terminal writes after the last hook; recovery reads those records before
orphan abort reporting. Turn number, session epoch and owner guards keep a late observer with its original turn.
Background checks remain unknown until matching native task metadata settles them. Other hosts retain their
existing transcript/tool-result adapters; Kimi subagents and concurrent prompts are outside this v1 contract.

The optional ModelRunner `capture` port records the selected alias, provider, upstream model, effort and a
credential/config binding before recall returns. The `acp` runner revalidates that binding before creating one
Both local clients use the same Kimi profile and managed plugin. Kimi uses the same late-terminal mechanism as
ZCode (`turnEnd.statusRecord` with `confirmCompletion`): only the host's own terminal status admits a turn's
evidence. Its status record is the session log rather than a database: the profile declares the workspace/session
layout and a decoder, and `runtime/session-log` reads a bounded head and tail of the JSONL log, refusing linked,
replaced, malformed or partial records. Because Kimi names a turn only once it runs, its `TurnStarted` hook maps to
the canonical `turnStart` event, which binds the open turn's id when the announced prompt is the one the turn opened
with, and schedules the shared turn monitor; `Stop`, `StopFailure`, `Interrupt` and `Notification` schedule it again.
A row carries the turn's `record` and `answer` from the log (hook tool output is truncated), and the monitor, the next
prompt's `closeUnfinishedTurn`, the session end and `recoverNativeTurns` judge the turn on it. A background check
still running when the turn ended keeps the row `pending`: the turn is not verified until its task metadata settles
the check. Kimi subagents and concurrent prompts are outside this v1 contract.

The `acp` ModelRunner follows the `configStamp` + `turnEnd.modelOf` contract: the stamp hashes the configuration and
every credential it names, and the turn's route (alias, upstream model, protocol, effort) comes from the log's model
requests. The runner checks the stamp and route again before creating one
private session and sending one prompt. It uses a private HOME and a selected-only TOML config; the credential is
passed in an environment variable. The private agent has no tools/subagents, project hooks, user plugins or MCP
servers. ACP success, the native/provider terminal, exact route and native process closure must agree. A separate
Expand All @@ -388,12 +392,10 @@ after native exit or stop. It reports proof separately from native output. Scrat
the job does not contain processes created by an external broker outside the descendant tree.

When a captured route is unavailable or has changed, preflight preserves the candidate for seven days from the
first block. The person can retry after restoring the original binding. A repeat block keeps the original
deadline. A verified turn with a missing or unmatched binding is cleared on the first distill without
calling the model or Proxy. A new verified turn can capture its own binding. Ordinary model and protocol failures
keep the existing retry policy.
first block. The person can retry after restoring the original configuration and account. A repeat block keeps the
original deadline. Ordinary model and protocol failures keep the existing retry policy.
The `kimi.com` managed route reads the current access token and device identity from the exact native profile.
It binds both to the snapshot, preserves the native provider family separately from its transport, and passes
Both are part of the stamp; it preserves the native provider family separately from its transport, and passes
only the access token in the private child's environment. It does not manage login or refresh.
Draft publication still uses the shared explicit command and Proxy contract. Other regions, clients and native
acceptance on each target OS are separate gates, not inferred from fixture tests.
Expand Down
8 changes: 4 additions & 4 deletions hosts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,10 @@ root manifest is `kimi.plugin.json`; `scripts/install-kimi.mjs` stages a version
(`KIMI_CODE_HOME/evolver`) stay outside the package; unregistering retains managed bytes. Other client types,
subagents, parallel prompts and other OAuth regions require separate native acceptance.

Kimi's `UserPromptSubmit` returns `{ message }` to the model. Tool hooks observe native evidence and never promise
inline failed-check context. Stop is a hint rather than a terminal verdict: wire/task records reconcile lifecycle
and verification, with dead-owner recovery when a session has no end event. Session ids include the native-home
scope. Commands use `node <plugin>/bin/command.mjs kimi <command>` from a terminal, without prompt interception.
Kimi's `UserPromptSubmit` returns `{ message }` to the model. Like ZCode it waits for the native terminal record
before using completed work, here read from the session log (`wire.jsonl` and task metadata): `TurnStarted` binds the
turn's native id and starts the shared turn monitor, Stop and the other lifecycle hooks only restart it, and dead-owner
recovery closes a turn whose session has no end event. Session ids include the native-home scope. Commands use `node <plugin>/bin/command.mjs kimi <command>` from a terminal, without prompt interception.
Distillation captures provider/model/account identity from the native session and uses a private ACP profile with
no user/project hooks, plugins, MCP or skills; missing or changed route identity keeps the candidate for retry.
Windows packed hooks have passed with a loopback fixture provider/Proxy, including native turn-zero evidence,
Expand Down
7 changes: 4 additions & 3 deletions hosts/kimi/dist/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,15 @@ Evolver's Proxy/Hub account are separate.

Verification keeps native task exit codes when available. Foreground Bash results
without an exit code need a supported passing summary; silent output alone cannot
verify work. Pending background checks block distillation, including recovery.
verify work. A turn whose background check still runs when it ends stays pending; it
is verified only once the check's task record settles it before the turn is closed.

Private distillation profiles exclude user/project hooks, plugins, MCP servers and
skills. The selected credential goes to the native process through its environment.
For `kimi.com` OAuth, the private profile contains the selected provider, model and
device ID; it has no OAuth token file or refresh credential. Expired or changed
credentials keep the range blocked. A range can use only one captured provider
and account binding. Start a new session to accumulate work under a new binding.
credentials keep the range blocked. A range can use only one captured provider,
model and configuration. Start a new session to accumulate work under a new one.

On Windows, ACP completion uses a Job Object with kill-on-close. It starts the
native CLI inside the job and waits for the job to empty before deleting scratch,
Expand Down
3 changes: 0 additions & 3 deletions hosts/kimi/dist/kimi.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,10 @@
{ "event": "UserPromptSubmit", "command": "node bin/hook.mjs kimi UserPromptSubmit", "timeout": 10 },
{ "event": "TurnStarted", "command": "node bin/hook.mjs kimi TurnStarted", "timeout": 3 },
{ "event": "PreToolUse", "command": "node bin/hook.mjs kimi PreToolUse", "timeout": 3 },
{ "event": "PostToolUse", "command": "node bin/hook.mjs kimi PostToolUse", "timeout": 3 },
{ "event": "PostToolUseFailure", "command": "node bin/hook.mjs kimi PostToolUseFailure", "timeout": 3 },
{ "event": "Stop", "command": "node bin/hook.mjs kimi Stop", "timeout": 3 },
{ "event": "StopFailure", "command": "node bin/hook.mjs kimi StopFailure", "timeout": 3 },
{ "event": "Interrupt", "command": "node bin/hook.mjs kimi Interrupt", "timeout": 3 },
{ "event": "SessionEnd", "command": "node bin/hook.mjs kimi SessionEnd", "timeout": 3 },
{ "event": "TaskStarted", "command": "node bin/hook.mjs kimi TaskStarted", "timeout": 3 },
{ "event": "Notification", "command": "node bin/hook.mjs kimi Notification", "timeout": 3 }
]
}
20 changes: 5 additions & 15 deletions hosts/kimi/evidence.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,16 @@

import { isAbsolute, resolve } from 'node:path';

const factsOf = (context) => context?.nativeFacts?.status === 'ok' && context.nativeFacts.schemaSupported
? context.nativeFacts
: null;

export function editedFilesOf(payload, context = {}) {
const facts = factsOf(context);
if (payload.hook_event_name !== 'PreToolUse' || !['Write', 'Edit'].includes(payload.tool_name)
|| facts?.toolTurnId == null) return [];
export function editedFilesOf(payload) {
if (payload.hook_event_name !== 'PreToolUse' || !['Write', 'Edit'].includes(payload.tool_name)) return [];
const file = payload.tool_input?.path;
if (typeof file !== 'string' || !file || file.includes('\0')) return [];
return isAbsolute(file)
? [file]
: typeof payload.cwd === 'string' && isAbsolute(payload.cwd) ? [resolve(payload.cwd, file)] : [];

if (isAbsolute(file)) return [file];
return typeof payload.cwd === 'string' && isAbsolute(payload.cwd) ? [resolve(payload.cwd, file)] : [];
}

export const kimiEvidence = Object.freeze({
// Kimi truncates hook tool_output. Only correlated native wire/task results can settle evidence.
// Kimi truncates hook tool_output: a turn's evidence is read from its session log once the turn ended.
toolNote: () => null,
editedFiles: editedFilesOf,
turnStart: (payload, context = {}) => context.nativeFacts?.mark ?? null,
turnRecord: (payload, context = {}) => factsOf(context)?.record ?? null,
});
70 changes: 48 additions & 22 deletions hosts/kimi/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,38 @@ function managedAccess(provider, providerId, endpoint, env) {
};
}

function endpointOf(provider) {
try {
const url = new URL(provider.base_url);
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
return null;
}
return url.href.replace(/\/$/, '');
} catch {
return null;
}
}

function credentialOf(provider, providerId, endpoint, env) {
if (provider.oauth !== undefined) return managedAccess(provider, providerId, endpoint, env);
const key = text(provider.api_key);
const keyEnv = text(provider.api_key_env);
if (Boolean(key) === Boolean(keyEnv) || (keyEnv && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(keyEnv))) return null;
const value = key ?? text(env[keyEnv]);
if (!value || /[\x00-\x1f\x7f]/.test(value)) return null;
return { kind: 'api-key', ...(keyEnv ? { env: keyEnv } : { value }) };
}

/** Every credential the configuration names, so a captured route's stamp changes with any account it could reach. */
export function credentialsOf(config, env = {}) {
if (!object(config?.providers)) return [];
return Object.entries(config.providers).flatMap(([providerId, provider]) => {
const endpoint = object(provider) ? endpointOf(provider) : null;
const credential = endpoint && credentialOf(provider, providerId, endpoint, env);
return credential ? [credential] : [];
});
}

/** Keep the selected account and transport; never fall back to another credential or endpoint. */
export function selectRoute(config, hint, env = {}) {
const alias = text(hint?.alias);
Expand All @@ -65,28 +97,9 @@ export function selectRoute(config, hint, env = {}) {
&& (!Number.isSafeInteger(model.max_output_size) || model.max_output_size < 1)) return null;
if (model.adaptive_thinking !== undefined && typeof model.adaptive_thinking !== 'boolean') return null;
if (model.support_efforts !== undefined && !strings(model.support_efforts)) return null;
let endpoint;
try {
const url = new URL(provider.base_url);
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
return null;
}
endpoint = url.href.replace(/\/$/, '');
} catch {
return null;
}
let credential;
if (provider.oauth !== undefined) {
credential = managedAccess(provider, providerId, endpoint, env);
if (!credential) return null;
} else {
const key = text(provider.api_key);
const keyEnv = text(provider.api_key_env);
if (Boolean(key) === Boolean(keyEnv) || (keyEnv && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(keyEnv))) return null;
const value = key ?? text(env[keyEnv]);
if (!value || /[\x00-\x1f\x7f]/.test(value)) return null;
credential = { kind: 'api-key', ...(keyEnv ? { env: keyEnv } : { value }) };
}
const endpoint = endpointOf(provider);
const credential = endpoint && credentialOf(provider, providerId, endpoint, env);
if (!credential) return null;
const thinking = supportsThinking(model);
const effort = text(hint?.effort) ?? (thinking ? null : 'off');
// Native models without named levels expose the binary thinking toggle.
Expand Down Expand Up @@ -134,6 +147,18 @@ export function prepareRoute(selected, { home, credentialEnv }) {
};
}

/**
* The route a turn ran on, from its session log's model requests (`row.model`), when the configuration stamp it began
* with (`started`) is the one it ended with (`closed`).
*/
export function modelOf(row, started, closed) {
if (!started || !closed) return { unavailable: 'credential_missing' };
if (started !== closed) return { unavailable: 'route_changed' };
const { alias, model, protocol, effort } = row?.model ?? {};
if (![alias, model, protocol].every(text)) return { unavailable: 'route_unresolved' };
return { selection: { alias, model, protocol, effort: text(effort) }, configDigest: started };
}

/** ACP's end_turn alone is insufficient: cross-check the owned private session's native evidence. */
export function completionFacts(records = []) {
const main = records.filter((record) => record.agentId === 'main');
Expand Down Expand Up @@ -190,6 +215,7 @@ export const headlessKimi = Object.freeze({
parseConfig: parse,
serializeConfig: stringify,
select: selectRoute,
credentials: credentialsOf,
},
prepare: prepareRoute,
sessionConfig: (snapshot, selected) => supportsThinking(selected.model)
Expand Down
22 changes: 17 additions & 5 deletions hosts/kimi/paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,29 @@
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';

const SCOPE = /^[a-f0-9]{64}$/;
const NATIVE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;

export const kimiHomeOf = (env = {}) => resolve(
env.KIMI_CODE_HOME || join(env.HOME || env.USERPROFILE || homedir(), '.kimi-code'),
);
export const stateDirOf = (env = {}) => join(kimiHomeOf(env), 'evolver');
export const nativeSessionIdOf = (payload) => typeof payload.session_id === 'string'
&& /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(payload.session_id) ? payload.session_id : null;
&& NATIVE_ID.test(payload.session_id) ? payload.session_id : null;
export const projectDirOf = (payload) => typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : null;

/** The plugin's session id: the Kimi home it was read from (`recordScope`, a pseudonym) and Kimi's own session id. */
export const sessionIdOf = (payload, context = {}) => {
const id = nativeSessionIdOf(payload);
return id && typeof context.nativeScope === 'string' && /^[a-f0-9]{64}$/.test(context.nativeScope)
? JSON.stringify([context.nativeScope, id])
: null;

return id && SCOPE.test(context.recordScope ?? '') ? JSON.stringify([context.recordScope, id]) : null;
};

/** Kimi's session id in a plugin session id of the Kimi home `scope`; null for another home's. */
export function nativeSessionOf(sessionId, scope) {
try {
const [owner, id] = JSON.parse(sessionId);
return owner === scope && NATIVE_ID.test(id ?? '') ? id : null;
} catch {
return null;
}
}
13 changes: 4 additions & 9 deletions hosts/kimi/payload.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,19 @@ import { nativeTurnIdOf } from './transcript.js';
import { projectDirOf, sessionIdOf } from './paths.js';

const text = (value) => typeof value === 'string' ? value : '';
const toolEvents = new Set(['PreToolUse', 'PostToolUse', 'PostToolUseFailure']);
const localClients = new Set(['kimi_code_cli', 'kimi_code_desktop']);

export const kimiPayload = Object.freeze({
sessionId: sessionIdOf,
projectDir: projectDirOf,
prompt: (payload) => Array.isArray(payload.prompt)
? payload.prompt.filter((part) => part?.type === 'text').map((part) => text(part.text)).join('\n').trim()
: '',
turnId: (payload, context = {}) => {
if (payload.hook_event_name === 'UserPromptSubmit') return null;
if (toolEvents.has(payload.hook_event_name)) return nativeTurnIdOf(context.nativeFacts?.toolTurnId);
return nativeTurnIdOf(payload.turn_id) ?? nativeTurnIdOf(context.nativeFacts?.bind?.turnId);
},
: text(payload.prompt).trim(),
turnId: (payload) => payload.hook_event_name === 'UserPromptSubmit' ? null : nativeTurnIdOf(payload.turn_id),
isIgnored: (payload) => payload.is_steer === true
|| Boolean(payload.agent_id && payload.agent_id !== 'main')
|| (payload.origin_kind !== undefined && payload.origin_kind !== 'user')
|| !localClients.has(payload.client_type),
// SessionStart.model is a global default, not the selected native profile binding.
model: (payload, context = {}) => context.nativeFacts?.modelHint ?? null,
// SessionStart.model is a global default, not the model a turn ran on; that is read from the session log.
model: () => null,
});
Loading
Loading