@khorsheed/dsh-local-agent-kimi 0.2.0-rc.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.en.md +24 -112
  3. package/README.i18n.yaml +2 -2
  4. package/README.md +24 -112
  5. package/lib/client.js +816 -324
  6. package/lib/index.js +2121 -1311
  7. package/lib/types/client/SettingsCard.d.ts +75 -24
  8. package/lib/types/client/SettingsCard.js +110 -137
  9. package/lib/types/client/icons.d.ts +1 -1
  10. package/lib/types/client/index.d.ts +6 -25
  11. package/lib/types/client/index.js +134 -51
  12. package/lib/types/client/locales.d.ts +34 -24
  13. package/lib/types/client/locales.js +38 -28
  14. package/lib/types/endpoint-auth.d.ts +202 -0
  15. package/lib/types/endpoint-auth.js +287 -0
  16. package/lib/types/index.d.ts +19 -2
  17. package/lib/types/index.js +146 -19
  18. package/lib/types/kimi-cli-provider.d.ts +31 -4
  19. package/lib/types/kimi-cli-provider.js +110 -60
  20. package/lib/types/live-driver.d.ts +17 -5
  21. package/lib/types/live-driver.js +345 -129
  22. package/lib/types/live-switch.d.ts +16 -1
  23. package/lib/types/live-switch.js +11 -1
  24. package/lib/types/model-broker.d.ts +12 -2
  25. package/lib/types/model-broker.js +51 -6
  26. package/lib/types/model-catalog.d.ts +44 -1
  27. package/lib/types/model-catalog.js +110 -17
  28. package/lib/types/model-configuration.d.ts +26 -0
  29. package/lib/types/model-configuration.js +43 -0
  30. package/lib/types/provision.d.ts +102 -31
  31. package/lib/types/provision.js +257 -45
  32. package/lib/types/records.d.ts +0 -20
  33. package/lib/types/records.js +1 -40
  34. package/lib/types/session-mirror.d.ts +115 -52
  35. package/lib/types/session-mirror.js +286 -210
  36. package/lib/types/session-view.d.ts +23 -2
  37. package/lib/types/session-view.js +37 -7
  38. package/locale/en.json +2 -2
  39. package/locale/zh.json +2 -2
  40. package/package.json +21 -22
  41. package/lib/invariant.js +0 -31
  42. package/lib/types/credential-guard.d.ts +0 -29
  43. package/lib/types/credential-guard.js +0 -107
  44. package/lib/types/invariant.d.ts +0 -16
  45. package/lib/types/invariant.js +0 -33
package/lib/index.js CHANGED
@@ -1,16 +1,295 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
- import { LIVE_FLUSH_INTERVAL_MS, LiveFlush, LiveStreamPublisher, ModelDirectoryCache, assertResumeCwdUnchanged, assertResumeScopeUnchanged, containerExecSpawn, containerScopedHome, delegationEnv, establishSubagentCatalogChild, extendModelDirectory, modelDirectoryContextKey, persistChildSession, probeCliVersion, resolveChildCwd, resolveRoundModel, settingsFace, subagentDelegationLabel, vol } from "@khorsheed/dsh-local-agent";
2
+ import { LIVE_FLUSH_INTERVAL_MS, LiveFlush, LiveStreamPublisher, ModelDirectoryCache, assertResumeCwdUnchanged, assertResumeScopeUnchanged, containerExecSpawn, containerScopedHome, delegationEnv, establishSubagentCatalogChild, extendModelDirectory, firstModelOf, modelDirectoryContextKey, persistChildSession, probeCliVersion, readModelList, readModelListSync, resolveChildCwd, resolveRoundModel, settingsFace, subagentDelegationLabel, vol, writeModelList } from "@khorsheed/dsh-local-agent";
3
3
  import { MEMBER_BRIDGE_SOCKET_ENV, MEMBER_BRIDGE_TOKEN_ENV, endpointHost } from "@khorsheed/dsh-local-agent/types";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { SessionId } from "@deepseek-ai/dsh-session";
6
- import { NO_START_CAPABILITIES, settleRunResult, subprocessRunHandle } from "@deepseek-ai/dsh-subagent";
6
+ import { NO_START_CAPABILITIES, SUBAGENT_DESCRIPTOR_VERSION, settleRunResult, subprocessRunHandle } from "@deepseek-ai/dsh-subagent";
7
7
  import { join } from "node:path";
8
- import { copyFile, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
8
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
9
9
  import { StringDecoder } from "node:string_decoder";
10
10
  import { createAssistantMessage, createToolResultMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
11
- import { execFile } from "node:child_process";
12
11
  import { homedir } from "node:os";
13
12
  import { readFileSync, writeFileSync } from "node:fs";
13
+ //#region lib/types/endpoint-auth.js
14
+ /**
15
+ * Endpoint-key authentication — the kimi harness's ONLY auth mode. There is
16
+ * no OAuth anywhere in this package: the scoped config's managed provider
17
+ * (`managed:kimi-code`) declares `api_key_env = "DSH_KIMI_ENDPOINT_KEY"`
18
+ * (kimi-cli ≥2.1 reads the key from that environment variable — verified by
19
+ * the CLI's own "declares api_key_env but the environment variable is not
20
+ * set" diagnostic), the key itself lives only here and in the child process's
21
+ * environment, and `config.toml` carries no secret, so it stays a legitimate
22
+ * evaluation-condition input.
23
+ *
24
+ * Two layers, merged per field, file over config:
25
+ *
26
+ * - `<scopedHome>/endpoint-auth.json` (mode 0600) — the per-scope credential.
27
+ * Its name deliberately matches the eval contract hash's credential denylist
28
+ * (`auth`), so the key material never enters an evaluation's condition
29
+ * fingerprint; the file's mtime serves as the credential stamp, which makes
30
+ * "edit the file" the recovery gesture for a `rejected` grade.
31
+ * - The plugin config's `baseUrl` / `apiKey` — the deployment default every
32
+ * scope without a file shares. Read PER ROUND from the apply-time snapshot,
33
+ * so a profile edit applies to the next round (and a host restart clears
34
+ * the in-memory `rejected` mark).
35
+ *
36
+ * `baseUrl` is optional: absent means kimi's own coding service
37
+ * ({@link KIMI_MANAGED_BASE_URL} — an official platform key needs no relay).
38
+ * A present baseUrl is written into the managed provider's `base_url` by the
39
+ * config surgery (provision.ts), which is also what keeps the two layers'
40
+ * merge honest: the file the CLI reads always shows the effective endpoint.
41
+ *
42
+ * The env layer rides the family's existing plumbing: `delegationEnv` on the
43
+ * host, `containerExecSpawn`'s name-only `-e` forwarding into a container —
44
+ * a containerized round authenticates with the same credential as the host,
45
+ * nothing is authorized inside the unit.
46
+ * @module @khorsheed/dsh-local-agent-kimi/endpoint-auth
47
+ */
48
+ /**
49
+ * The scoped-home credential file's name. Chosen to match the eval contract
50
+ * hash's credential-shaped denylist (`/token|key|credential|secret|password|auth|\.env/i`),
51
+ * so the file is excluded from condition fingerprints by name.
52
+ */
53
+ const ENDPOINT_AUTH_FILE = "endpoint-auth.json";
54
+ /**
55
+ * The environment variable the managed provider's `api_key_env` names. The
56
+ * key material only ever lives in endpoint-auth.json and in the child
57
+ * process's environment under this name.
58
+ */
59
+ const ENDPOINT_ENV_KEY = "DSH_KIMI_ENDPOINT_KEY";
60
+ /** Trim and normalize one credential field; blank reads as absent. */
61
+ function field(value) {
62
+ if (typeof value !== "string") return void 0;
63
+ const trimmed = value.trim();
64
+ return trimmed === "" ? void 0 : trimmed;
65
+ }
66
+ /** Whether a resolved endpoint carries a usable credential. */
67
+ function hasEndpointCredential(auth) {
68
+ return auth !== void 0 && field(auth.apiKey) !== void 0;
69
+ }
70
+ /**
71
+ * Read the scope's endpoint-auth.json. Absent or unreadable yields undefined
72
+ * (the caller falls back to the plugin config); malformed JSON or a non-object
73
+ * payload also yields undefined — a torn file must not fail a status read.
74
+ * Non-string fields are dropped, never carried through.
75
+ * @param homeDir - the `kimi` harness's scoped home.
76
+ * @returns the file's endpoint credential, or undefined.
77
+ */
78
+ async function readEndpointAuthFile(homeDir) {
79
+ let text;
80
+ try {
81
+ text = await readFile(join(homeDir, ENDPOINT_AUTH_FILE), "utf8");
82
+ } catch {
83
+ return;
84
+ }
85
+ try {
86
+ const parsed = JSON.parse(text);
87
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
88
+ const baseUrl = field(parsed["baseUrl"]);
89
+ const apiKey = field(parsed["apiKey"]);
90
+ return {
91
+ ...baseUrl === void 0 ? {} : { baseUrl },
92
+ ...apiKey === void 0 ? {} : { apiKey }
93
+ };
94
+ } catch {
95
+ return;
96
+ }
97
+ }
98
+ /**
99
+ * Resolve one scope's effective endpoint credential: the scope's
100
+ * endpoint-auth.json merged FIELD BY FIELD over the plugin config's
101
+ * `baseUrl` / `apiKey` (a file that names only a key rotates the key while
102
+ * keeping the deployment's endpoint). The result may carry no credential at
103
+ * all — {@link hasEndpointCredential} is the judgment, kept separate so the
104
+ * caller can still route/log the resolved baseUrl.
105
+ * @param homeDir - the scope's scoped home.
106
+ * @param config - the plugin config's deployment-level endpoint pair.
107
+ * @returns the merged endpoint auth.
108
+ */
109
+ async function resolveEndpointAuth(homeDir, config) {
110
+ const file = await readEndpointAuthFile(homeDir);
111
+ const configBaseUrl = field(config.baseUrl);
112
+ const configApiKey = field(config.apiKey);
113
+ return {
114
+ ...configBaseUrl === void 0 ? {} : { baseUrl: configBaseUrl },
115
+ ...configApiKey === void 0 ? {} : { apiKey: configApiKey },
116
+ ...file
117
+ };
118
+ }
119
+ /**
120
+ * The spawn-env layer for a resolved endpoint: the key under the managed
121
+ * provider's `api_key_env` name, nothing else. A credential-less resolution
122
+ * yields an empty layer — the spread then adds no keys at all, byte-identical
123
+ * to the pre-endpoint env.
124
+ * @param auth - the resolved endpoint auth, or undefined.
125
+ * @returns the explicit env entries for the delegation spawn.
126
+ */
127
+ function endpointAuthEnv(auth) {
128
+ const apiKey = field(auth?.apiKey);
129
+ return apiKey === void 0 ? {} : { [ENDPOINT_ENV_KEY]: apiKey };
130
+ }
131
+ /**
132
+ * The credential marker's modification stamp (epoch ms): the endpoint file's
133
+ * mtime, or undefined when the scope has no file (a config-sourced credential
134
+ * has no marker — rotating it pairs with the profile edit and instance
135
+ * restart that clear the in-memory grade marks). A fresh file write is a
136
+ * fresh stamp, which is how an edited-in key recovers a `rejected` grade.
137
+ * @param homeDir - the scope's scoped home.
138
+ * @returns the stamp, or undefined.
139
+ */
140
+ async function endpointAuthStamp(homeDir) {
141
+ try {
142
+ return (await stat(join(homeDir, ENDPOINT_AUTH_FILE))).mtimeMs;
143
+ } catch {
144
+ return;
145
+ }
146
+ }
147
+ /** Whether the scope carries its own endpoint file at all. */
148
+ async function endpointAuthFileExists(homeDir) {
149
+ return await endpointAuthStamp(homeDir) !== void 0;
150
+ }
151
+ /**
152
+ * Write the scope's endpoint-auth.json (mode 0600, mkdir -p first).
153
+ * Whole-file write: the payload is exactly what {@link readEndpointAuthFile}
154
+ * parses back.
155
+ * @param homeDir - the scope's scoped home.
156
+ * @param auth - the endpoint credential to persist.
157
+ */
158
+ async function writeEndpointAuth(homeDir, auth) {
159
+ await mkdir(homeDir, { recursive: true });
160
+ const baseUrl = field(auth.baseUrl);
161
+ const apiKey = field(auth.apiKey);
162
+ const payload = {
163
+ ...baseUrl === void 0 ? {} : { baseUrl },
164
+ ...apiKey === void 0 ? {} : { apiKey }
165
+ };
166
+ await writeFile(join(homeDir, ENDPOINT_AUTH_FILE), JSON.stringify(payload, void 0, 2) + "\n", { mode: 384 });
167
+ }
168
+ /**
169
+ * Clear the scope's OWN endpoint credential (the endpoint-auth.json file).
170
+ * A deployment-level credential from the plugin config is untouched — the
171
+ * core's logout reply then names the profile row as the place to edit.
172
+ * @param homeDir - the scope's scoped home.
173
+ */
174
+ async function clearEndpointAuth(homeDir) {
175
+ await rm(join(homeDir, ENDPOINT_AUTH_FILE), { force: true });
176
+ }
177
+ /**
178
+ * The managed provider's endpoint, written into a fresh scoped config and the
179
+ * baseline the eval snapshot compares against: a scoped config still pointing
180
+ * here routes through kimi's own service, i.e. no custom endpoint is in force.
181
+ * It lives in THIS module (not provision.ts) because the endpoint check and
182
+ * the model catalog need it without a cycle — provision.ts already imports
183
+ * {@link ENDPOINT_ENV_KEY} from here.
184
+ */
185
+ const KIMI_MANAGED_BASE_URL = "https://api.kimi.com/coding/v1";
186
+ /** How many served model ids the check reply lists before truncating. */
187
+ const CHECK_MODEL_LIST_LIMIT = 8;
188
+ /** Bound on the error-body excerpt a failed check reports. */
189
+ const CHECK_ERROR_BODY_LIMIT = 200;
190
+ /**
191
+ * `GET <baseUrl>/v1/models` with `Authorization: Bearer <key>` — the
192
+ * token-free endpoint probe shared by the settings card's verify button
193
+ * (`checkEndpoint`) and the model catalog. Kimi's own coding service answers
194
+ * it with the managed key (verified 2026-10-22 against
195
+ * `https://api.kimi.com/coding/v1`: 200 with Bearer — the sibling x-api-key
196
+ * header also works, but Bearer is the one convention this package sends).
197
+ * The reply carries kimi's own fields — `display_name`,
198
+ * `think_efforts.valid_efforts`/`default_effort` (a sibling
199
+ * `reasoning.effort.valid`/`default` carries the same data; `think_efforts`
200
+ * is the field this reads), `context_length` — while a generic
201
+ * OpenAI-shaped relay answers ids only, so every optional field tolerates
202
+ * absence. Never throws: every failure mode folds into the result.
203
+ * @param endpoint - the resolved endpoint auth (caller checks the credential).
204
+ * @param fetchImpl - the HTTP fetch; default the host's global fetch.
205
+ * @param signal - abort for the whole probe (timeout bundled by the caller).
206
+ * @returns the folded result.
207
+ */
208
+ async function fetchEndpointModels(endpoint, fetchImpl, signal) {
209
+ const headers = {};
210
+ if (endpoint.apiKey !== void 0) headers["authorization"] = `Bearer ${endpoint.apiKey}`;
211
+ const fetcher = fetchImpl ?? globalThis.fetch;
212
+ const root = endpoint.baseUrl.replace(/\/+$/, "");
213
+ const url = `${root}${root.endsWith("/v1") ? "" : "/v1"}/models`;
214
+ let response;
215
+ try {
216
+ response = await fetcher(url, {
217
+ headers,
218
+ signal: signal ?? AbortSignal.timeout(8e3)
219
+ });
220
+ } catch (error) {
221
+ return {
222
+ kind: "unreachable",
223
+ message: error instanceof Error ? error.message : String(error)
224
+ };
225
+ }
226
+ if (!response.ok) return {
227
+ kind: "http",
228
+ status: response.status,
229
+ body: (await response.text().catch(() => "")).slice(0, CHECK_ERROR_BODY_LIMIT)
230
+ };
231
+ let data;
232
+ try {
233
+ data = JSON.parse(await response.text()).data;
234
+ } catch {
235
+ return { kind: "malformed" };
236
+ }
237
+ if (!Array.isArray(data)) return { kind: "malformed" };
238
+ const models = [];
239
+ for (const entry of data) {
240
+ if (typeof entry !== "object" || entry === null) continue;
241
+ const record = entry;
242
+ if (typeof record.id !== "string" || record.id === "") continue;
243
+ const thinkEfforts = typeof record.think_efforts === "object" && record.think_efforts !== null ? record.think_efforts : void 0;
244
+ const efforts = thinkEfforts?.valid_efforts;
245
+ models.push({
246
+ id: record.id,
247
+ ...typeof record.display_name === "string" && record.display_name !== "" ? { displayName: record.display_name } : {},
248
+ ...Array.isArray(efforts) && efforts.some((effort) => typeof effort === "string" && effort !== "") ? { efforts: efforts.filter((effort) => typeof effort === "string" && effort !== "") } : {},
249
+ ...typeof thinkEfforts?.default_effort === "string" && thinkEfforts.default_effort !== "" ? { defaultEffort: thinkEfforts.default_effort } : {},
250
+ ...typeof record.context_length === "number" && Number.isFinite(record.context_length) ? { contextSize: record.context_length } : {}
251
+ });
252
+ }
253
+ return {
254
+ kind: "models",
255
+ models
256
+ };
257
+ }
258
+ /**
259
+ * Probe the resolved endpoint with a cheap, token-free round-trip: `GET
260
+ * <baseUrl>/v1/models` with the resolved credential's Bearer header. A 200
261
+ * proves both reachability and credential validity — the exact evidence a
262
+ * "verify" button needs — and the served model list rides the reply so the
263
+ * user sees what the endpoint offers. One transport-level retry rides along:
264
+ * a local proxy restart (a Surge/clash reload) stalls the first attempt into
265
+ * the timeout and answers the retry immediately. Never throws: every failure
266
+ * mode folds into the reply text, since the reply IS the diagnostic.
267
+ * @param endpoint - the resolved endpoint auth (scope file over settings).
268
+ * @param fetchImpl - the HTTP fetch; default the host's global fetch.
269
+ * @param timeoutMs - one attempt's deadline (two attempts at most).
270
+ * @returns the human-readable verdict for the check reply.
271
+ */
272
+ async function checkKimiEndpoint(endpoint, fetchImpl, timeoutMs = 2e4) {
273
+ if (!hasEndpointCredential(endpoint)) return "No endpoint credential configured — fill API Key on the settings card (or the profile row) first.";
274
+ const baseUrl = (endpoint.baseUrl ?? "https://api.kimi.com/coding/v1").replace(/\/+$/, "");
275
+ let result = await fetchEndpointModels({
276
+ ...endpoint,
277
+ baseUrl
278
+ }, fetchImpl, AbortSignal.timeout(timeoutMs));
279
+ if (result.kind === "unreachable") result = await fetchEndpointModels({
280
+ ...endpoint,
281
+ baseUrl
282
+ }, fetchImpl, AbortSignal.timeout(timeoutMs));
283
+ if (result.kind === "unreachable") return `Endpoint unreachable (${baseUrl}): ${result.message}`;
284
+ if (result.kind === "http") return `Endpoint answered HTTP ${String(result.status)} (${baseUrl})${result.body === "" ? "" : `: ${result.body}`}`;
285
+ if (result.kind === "malformed") return `Endpoint reachable (${baseUrl}) but the model list did not parse — the credential worked, the reply was not /v1/models-shaped.`;
286
+ if (result.models.length === 0) return `Endpoint reachable (${baseUrl}); it serves no models.`;
287
+ const ids = result.models.map((model) => model.id);
288
+ const listed = ids.slice(0, CHECK_MODEL_LIST_LIMIT).join(", ");
289
+ const more = ids.length > CHECK_MODEL_LIST_LIMIT ? `, … +${String(ids.length - CHECK_MODEL_LIST_LIMIT)} more` : "";
290
+ return `Endpoint reachable (${baseUrl}): ${String(ids.length)} model(s) served — ${listed}${more}`;
291
+ }
292
+ //#endregion
14
293
  //#region ../../node_modules/.pnpm/smol-toml@1.9.0/node_modules/smol-toml/dist/error.js
15
294
  /*!
16
295
  * Copyright (c) Squirrel Chat et al., All rights reserved.
@@ -856,6 +1135,47 @@ function parse(toml, options = {}) {
856
1135
  //#region lib/types/model-configuration.js
857
1136
  const table = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
858
1137
  const string = (value) => typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
1138
+ /**
1139
+ * List the scoped config's `[models.*]` tables (quoted and bare keys alike —
1140
+ * smol-toml folds both to the same object keys). An absent, unreadable, or
1141
+ * malformed config yields an empty list: the catalog's enrichment layer then
1142
+ * simply has nothing to enrich with. Read-only: the config is authoritative.
1143
+ * @param home - the `kimi` harness's scoped home.
1144
+ * @returns the model tables, in file order.
1145
+ */
1146
+ async function readKimiModelTables(home) {
1147
+ let text;
1148
+ try {
1149
+ text = await readFile(join(home, "config.toml"), "utf8");
1150
+ } catch {
1151
+ return [];
1152
+ }
1153
+ let config;
1154
+ try {
1155
+ config = parse(text);
1156
+ } catch {
1157
+ return [];
1158
+ }
1159
+ const models = table(config["models"]);
1160
+ return Object.entries(models).map(([key, raw]) => {
1161
+ const model = table(raw);
1162
+ const provider = string(model["provider"]);
1163
+ const upstream = string(model["model"]);
1164
+ const displayName = string(model["display_name"]);
1165
+ const efforts = model["support_efforts"];
1166
+ const defaultEffort = string(model["default_effort"]);
1167
+ const contextSize = model["max_context_size"];
1168
+ return {
1169
+ key,
1170
+ ...provider === void 0 ? {} : { provider },
1171
+ ...upstream === void 0 ? {} : { model: upstream },
1172
+ ...displayName === void 0 ? {} : { displayName },
1173
+ ...!Array.isArray(efforts) ? {} : { efforts: efforts.filter((effort) => typeof effort === "string" && effort !== "") },
1174
+ ...defaultEffort === void 0 ? {} : { defaultEffort },
1175
+ ...typeof contextSize === "number" && Number.isFinite(contextSize) ? { contextSize } : {}
1176
+ };
1177
+ });
1178
+ }
859
1179
  /** Native configuration metadata only; provider credentials never leave this function. */
860
1180
  async function readKimiModelConfiguration(home, selectedModel) {
861
1181
  let text;
@@ -973,52 +1293,149 @@ function kimiNativeConfiguration(value) {
973
1293
  ...typeof legacy["currentModelId"] === "string" ? { currentModel: legacy["currentModelId"] } : {}
974
1294
  };
975
1295
  }
976
- /** Reads configuration before a session exists; never starts a disposable ACP session. */
1296
+ /** One directory answer that names why it is empty. kimi never offers custom
1297
+ * input: a free-typed value names no `[models.…]` table, and kimi-cli refuses
1298
+ * `-m` values without one (verified 2.1.1) — unlike claude, only the
1299
+ * listed/configured models are runnable. */
1300
+ function unsupported(reason) {
1301
+ return {
1302
+ entries: [],
1303
+ complete: false,
1304
+ customInput: false,
1305
+ unsupported: true,
1306
+ reason
1307
+ };
1308
+ }
1309
+ /**
1310
+ * The model directory, sourced from the configured ENDPOINT (`GET
1311
+ * <baseUrl>/v1/models` with the resolved credential's Bearer header — kimi's
1312
+ * own coding service answers it with the managed key, verified 2026-10-22) —
1313
+ * never from the scoped config's model tables alone: what the picker lists is
1314
+ * exactly what the endpoint serves, so a credential that does not work can
1315
+ * never hide behind a locally-known list. The answers are deliberate:
1316
+ *
1317
+ * - no credential resolved → empty with a "save a key first" reason;
1318
+ * - 401/403 → empty with the rejection (definitive — cached as data);
1319
+ * - another HTTP status → empty with it (the relay serves no model route);
1320
+ * - a transport failure THROWS, so the cache keeps the last good list and
1321
+ * retries on schedule instead of blanking a momentarily-down endpoint.
1322
+ *
1323
+ * The config's `[models.…]` tables survive as METADATA ENRICHMENT: a served
1324
+ * model whose upstream id matches a managed table's `model` takes that
1325
+ * table's KEY as its value — the runnable name `-m` resolves against — and
1326
+ * the table's display_name/support_efforts/default_effort fill whatever the
1327
+ * endpoint did not advertise; a served id with no managed table stays the
1328
+ * bare id, which the model list's write face provisions a table for
1329
+ * (provision.ts's `ensureKimiModelTables`). Reads before a session exists
1330
+ * never start a disposable ACP session.
1331
+ */
977
1332
  var KimiModelCatalog = class {
978
1333
  context;
979
- cache = new ModelDirectoryCache({ load: async (key) => {
980
- const context = JSON.parse(key);
981
- if (context.native !== void 0) return context.native;
982
- const { entries, defaultModel } = await readKimiModelConfiguration(context.homeDir);
983
- return {
984
- entries,
985
- ...defaultModel === void 0 ? {} : { defaultModel },
986
- complete: false,
987
- customInput: true,
988
- reason: "Configured candidates; native session model metadata is not available yet"
989
- };
990
- } });
991
- constructor(context) {
1334
+ deps;
1335
+ cache;
1336
+ constructor(context, deps) {
992
1337
  this.context = context;
1338
+ this.deps = deps;
1339
+ this.cache = new ModelDirectoryCache({
1340
+ load: (key, signal) => {
1341
+ const context = JSON.parse(key);
1342
+ if (context.native !== void 0) return Promise.resolve(context.native);
1343
+ return this.probe(context.homeDir, signal);
1344
+ },
1345
+ ...deps.ttlMs === void 0 ? {} : { ttlMs: deps.ttlMs },
1346
+ onError: (error) => deps.warn?.(`Kimi model discovery failed: ${error instanceof Error ? error.message : String(error)}`)
1347
+ });
993
1348
  }
994
- key(childSessionId) {
995
- const context = this.context(childSessionId);
996
- const stamp = modelDirectoryContextKey({
1349
+ stamp(homeDir, cwd) {
1350
+ return modelDirectoryContextKey({
997
1351
  provider: "kimi",
998
- homeDir: context.homeDir,
999
- cwd: context.cwd ?? context.homeDir,
1352
+ homeDir,
1353
+ cwd: cwd ?? homeDir,
1000
1354
  cli: ["kimi"],
1001
- files: [join(context.homeDir, "config.toml"), join(context.homeDir, "credentials", "kimi-code.json")]
1355
+ files: [join(homeDir, "config.toml"), join(homeDir, "endpoint-auth.json")]
1002
1356
  });
1357
+ }
1358
+ key(childSessionId) {
1359
+ const context = this.context(childSessionId);
1003
1360
  return JSON.stringify({
1004
1361
  homeDir: context.homeDir,
1005
- stamp,
1362
+ stamp: this.stamp(context.homeDir, context.cwd),
1006
1363
  childSessionId,
1007
1364
  ...context.native === void 0 ? {} : { native: context.native.directory }
1008
1365
  });
1009
1366
  }
1367
+ /**
1368
+ * The cache key for one scoped home directly (no member, no native): the
1369
+ * model-list write face's read — its entries carry the metadata the write
1370
+ * face's table provisioning (provision.ts's `ensureKimiModelTables`)
1371
+ * stamps into the tables it creates.
1372
+ */
1373
+ homeKey(homeDir) {
1374
+ return JSON.stringify({
1375
+ homeDir,
1376
+ stamp: this.stamp(homeDir)
1377
+ });
1378
+ }
1010
1379
  read(childSessionId) {
1011
1380
  return this.cache.read(this.key(childSessionId));
1012
1381
  }
1013
1382
  refresh(childSessionId) {
1014
1383
  return this.cache.refresh(this.key(childSessionId));
1015
1384
  }
1385
+ readHome(homeDir) {
1386
+ return this.cache.read(this.homeKey(homeDir));
1387
+ }
1388
+ refreshHome(homeDir) {
1389
+ return this.cache.refresh(this.homeKey(homeDir));
1390
+ }
1016
1391
  follow(childSessionId, signal) {
1017
1392
  return this.cache.follow(() => this.key(childSessionId), signal);
1018
1393
  }
1019
1394
  dispose() {
1020
1395
  this.cache.dispose();
1021
1396
  }
1397
+ async probe(homeDir, signal) {
1398
+ const endpoint = await this.deps.endpoint(homeDir);
1399
+ if (!hasEndpointCredential(endpoint)) return unsupported("No endpoint credential configured — the model list loads from the endpoint once an API Key is saved");
1400
+ const result = await fetchEndpointModels({
1401
+ ...endpoint,
1402
+ baseUrl: endpoint.baseUrl ?? "https://api.kimi.com/coding/v1"
1403
+ }, this.deps.fetchImpl, AbortSignal.any([signal, AbortSignal.timeout(this.deps.timeoutMs ?? 2e4)]));
1404
+ if (result.kind === "unreachable") throw new Error(`endpoint unreachable: ${result.message}`);
1405
+ if (result.kind === "malformed") return unsupported("The endpoint answered but the model list did not parse (not a /models-shaped reply)");
1406
+ if (result.kind === "http") {
1407
+ if (result.status === 401 || result.status === 403) return unsupported(`The endpoint rejected the credential (HTTP ${String(result.status)}) — the list loads once a valid key is saved`);
1408
+ return unsupported(`The endpoint does not serve a model list (HTTP ${String(result.status)})`);
1409
+ }
1410
+ if (result.models.length === 0) return unsupported("The endpoint serves no models");
1411
+ const byUpstream = /* @__PURE__ */ new Map();
1412
+ for (const modelTable of await readKimiModelTables(homeDir)) {
1413
+ if (modelTable.provider !== "managed:kimi-code" || modelTable.model === void 0) continue;
1414
+ if (!byUpstream.has(modelTable.model)) byUpstream.set(modelTable.model, modelTable);
1415
+ }
1416
+ return {
1417
+ entries: result.models.map((model) => {
1418
+ const modelTable = byUpstream.get(model.id);
1419
+ const value = modelTable?.key ?? model.id;
1420
+ const efforts = model.efforts ?? modelTable?.efforts;
1421
+ const defaultEffort = model.defaultEffort ?? modelTable?.defaultEffort;
1422
+ return {
1423
+ value,
1424
+ label: model.displayName ?? modelTable?.displayName ?? value,
1425
+ source: "native",
1426
+ ...efforts === void 0 || efforts.length === 0 ? {} : { reasoning: {
1427
+ options: efforts.map((effort) => ({
1428
+ value: effort,
1429
+ label: effort
1430
+ })),
1431
+ ...defaultEffort === void 0 ? {} : { default: defaultEffort }
1432
+ } }
1433
+ };
1434
+ }),
1435
+ complete: true,
1436
+ customInput: false
1437
+ };
1438
+ }
1022
1439
  };
1023
1440
  //#endregion
1024
1441
  //#region lib/types/session-configuration.js
@@ -1049,1177 +1466,1307 @@ async function configureKimiSession(sessionId, initial, desired, request) {
1049
1466
  return current;
1050
1467
  }
1051
1468
  //#endregion
1052
- //#region lib/types/session-view.js
1469
+ //#region lib/types/provision.js
1053
1470
  /**
1054
- * Kimi session viewer: read a scoped-home session's `wire.jsonl` event log
1055
- * and render a readable transcript (user prompts, assistant thinking and
1056
- * replies, tool calls with arguments and results). The kimi ACP mode writes
1057
- * responses to the session file rather than pushing them over the protocol,
1058
- * and the dsh subagent record for a one-shot run carries only the final
1059
- * output — this is the way to see what the kimi subagent actually did.
1060
- *
1061
- * The transcript is harness-comparison grade: every line is tagged with its
1062
- * wire turn number (1-based, matching the dsh child session's `turn/start`
1063
- * numbering), kimi's auto-permission `<system-reminder>` user messages are
1064
- * filtered out, tool calls carry their rendered arguments, tool results are
1065
- * matched to their owning call by `parentUuid`, and every `usage.record`
1066
- * (kimi reports accounting per LLM request, not cumulative) is surfaced with
1067
- * its transcript position so a delta mirror can sum a round's total.
1068
- * @module @khorsheed/dsh-local-agent-kimi/session-view
1471
+ * Kimi Code scoped-home provisioning: a working `config.toml` (provider and
1472
+ * model definitions) is required for the ACP server to run. The scoped home
1473
+ * is provisioned once — from the user's own config, redacted — so the scoped
1474
+ * setup mirrors the user's models while carrying no secrets. Authentication
1475
+ * is endpoint-key only (endpoint-auth.ts): the managed provider declares
1476
+ * `api_key_env = "DSH_KIMI_ENDPOINT_KEY"` and the key rides the child
1477
+ * process's environment, so `config.toml` itself never holds a secret.
1478
+ * Provisioning also sweeps the OAuth era's leftovers (`credentials/`,
1479
+ * `oauth/`) and migrates an existing managed-provider section onto the
1480
+ * env-key shape. The agent preset the delegation tool row needs is
1481
+ * bootstrapped into the user preset root on first boot, so installing the
1482
+ * bundle needs no manual copy step.
1483
+ * @module @khorsheed/dsh-local-agent-kimi/provision
1069
1484
  */
1070
- /** The wire event log path for a session directory. */
1071
- function wirePath(sessionDir) {
1072
- return join(sessionDir, "agents", "main", "wire.jsonl");
1073
- }
1485
+ const REAL_CONFIG = join(homedir(), ".kimi-code", "config.toml");
1486
+ /** Credentials directory inside the scoped home — the OAuth era's grant. */
1487
+ const CREDENTIALS_DIR = "credentials";
1488
+ /** OAuth token cache directory inside the scoped home. */
1489
+ const OAUTH_DIR = "oauth";
1074
1490
  /**
1075
- * Find a session directory under `<homeDir>/sessions/wd_*` whose id matches.
1076
- * @param homeDir - the `kimi` harness's scoped home.
1077
- * @param sessionId - the kimi session id.
1078
- * @returns the session directory, or undefined when absent.
1491
+ * The capability list every managed model table carries — the minimal
1492
+ * config's own, hoisted so the write face's on-add table provisioning
1493
+ * ({@link ensureKimiModelTables}) stamps the same shape.
1079
1494
  */
1080
- async function findKimiSessionDir(homeDir, sessionId) {
1081
- const sessionsRoot = join(homeDir, "sessions");
1082
- let workspaces;
1083
- try {
1084
- workspaces = await readdir(sessionsRoot);
1085
- } catch (error) {
1086
- if (error.code === "ENOENT") return void 0;
1087
- throw error;
1088
- }
1089
- for (const workspace of workspaces) {
1090
- const sessionDir = join(sessionsRoot, workspace, `session_${sessionId}`);
1091
- try {
1092
- await readFile(wirePath(sessionDir));
1093
- return sessionDir;
1094
- } catch {}
1095
- }
1495
+ const KIMI_MODEL_CAPABILITIES = "capabilities = [ \"thinking\", \"always_thinking\", \"image_in\", \"video_in\", \"tool_use\" ]";
1496
+ /** Quote one TOML basic string (model ids take backslash escapes). */
1497
+ function tomlString(value) {
1498
+ return `"${value.replace(/\\/gu, "\\\\").replace(/"/gu, "\\\"")}"`;
1096
1499
  }
1097
- /** Whether a user message is kimi's auto-permission reminder, never real task text. */
1098
- function isSystemReminder(text) {
1099
- return text.startsWith("<system-reminder>");
1500
+ /** Minimal managed config for a fresh home with no user config to mirror. */
1501
+ function minimalConfig(model, thinkingEffort) {
1502
+ return `default_model = "${model}"
1503
+
1504
+ [thinking]
1505
+ enabled = true
1506
+ effort = "${thinkingEffort}"
1507
+
1508
+ [providers."managed:kimi-code"]
1509
+ base_url = "${KIMI_MANAGED_BASE_URL}"
1510
+ type = "kimi"
1511
+ api_key_env = "${ENDPOINT_ENV_KEY}"
1512
+
1513
+ [models."${model}"]
1514
+ provider = "managed:kimi-code"
1515
+ model = "${model.split("/").pop()}"
1516
+ max_context_size = 1048576
1517
+ ${KIMI_MODEL_CAPABILITIES}
1518
+ display_name = "${model}"
1519
+ support_efforts = [ "low", "high", "max" ]
1520
+ default_effort = "${thinkingEffort}"
1521
+ `;
1100
1522
  }
1101
- /** Flatten the text blocks of a message content array. */
1102
- function textOf(content) {
1103
- return (content ?? []).filter((block) => typeof block === "object" && block !== null && block.type === "text").map((block) => block.text ?? "").join("");
1523
+ /**
1524
+ * Redact every api_key value so the scoped copy carries no secrets. The
1525
+ * kimi-managed provider's key comes from the `api_key_env` indirection (the
1526
+ * endpoint credential), which is exactly what an empty value means here.
1527
+ * @param config - the user's config text.
1528
+ * @returns the same config with all api_key values blanked.
1529
+ */
1530
+ function redactApiKeys(config) {
1531
+ return config.replace(/api_key\s*=\s*"[^"]*"/gu, "api_key = \"\"");
1104
1532
  }
1533
+ /** The managed provider's section header (the colon forces the quoted form). */
1534
+ const MANAGED_PROVIDER_HEADER = "[providers.\"managed:kimi-code\"]";
1535
+ /** The OAuth sub-table the endpoint-key shape drops. */
1536
+ const MANAGED_OAUTH_HEADER = "[providers.\"managed:kimi-code\".oauth]";
1105
1537
  /**
1106
- * Render a file-editing tool call's arguments apply-patch style: the path
1107
- * plus the old/new content. Without this a delegation's edits collapsed to
1108
- * the bare path (or nothing) and the transcript hid what the member actually
1109
- * changed — the kimi twin of codex's fileChange → ApplyPatch fold.
1110
- * @param name - the tool name the wire recorded.
1111
- * @param record - the call's argument object.
1112
- * @returns the patch-style summary, or undefined when the tool is not a
1113
- * known file-editing shape.
1538
+ * Migrate the managed provider section of a config.toml onto the endpoint-key
1539
+ * shape, as a pure text transform:
1540
+ *
1541
+ * - the section's `api_key = "…"` line (any value — a mirrored config is
1542
+ * already redacted, and a live one must not keep a secret in a hashed
1543
+ * file) is replaced by `api_key_env = "<ENDPOINT_ENV_KEY>"`;
1544
+ * - the `[providers."managed:kimi-code".oauth]` sub-table is dropped (the
1545
+ * OAuth flow is gone);
1546
+ * - when `baseUrl` is given, the section's `base_url` is set to it (the
1547
+ * endpoint credential owns the managed provider's endpoint; a hand-edit
1548
+ * is re-pointed at the next surgery).
1549
+ *
1550
+ * Other sections — user-added providers, model tables, permission rules —
1551
+ * are byte-preserved. A config without the managed provider section is
1552
+ * returned unchanged (a hand-built config's own providers are the user's
1553
+ * business). Idempotent: an in-sync input comes back byte-identical, so the
1554
+ * caller writes only on drift.
1555
+ * @param config - the config.toml text.
1556
+ * @param baseUrl - the endpoint to pin, or undefined to keep the file's own.
1557
+ * @returns the migrated config text.
1114
1558
  */
1115
- function editArgsOf(name, record) {
1116
- const path = typeof record["path"] === "string" ? record["path"] : void 0;
1117
- if (name === "Edit" || name === "MultiEdit") {
1118
- if (path === void 0 || path.trim() === "") return void 0;
1119
- const edits = Array.isArray(record["edits"]) ? record["edits"] : [record];
1120
- const hunks = [];
1121
- for (const edit of edits) {
1122
- const oldText = typeof edit["old_string"] === "string" ? edit["old_string"] : "";
1123
- const newText = typeof edit["new_string"] === "string" ? edit["new_string"] : "";
1124
- if (oldText === "" && newText === "") continue;
1125
- hunks.push("@@", ...oldText.split("\n").map((line) => `-${line}`), ...newText.split("\n").map((line) => `+${line}`));
1559
+ function migrateManagedProviderConfig(config, baseUrl) {
1560
+ const lines = config.split("\n");
1561
+ const start = lines.findIndex((line) => line.trim() === MANAGED_PROVIDER_HEADER);
1562
+ if (start === -1) return config;
1563
+ let bodyEnd = lines.length;
1564
+ for (let index = start + 1; index < lines.length; index += 1) if (/^\s*\[/.test(lines[index])) {
1565
+ bodyEnd = index;
1566
+ break;
1567
+ }
1568
+ let end = bodyEnd;
1569
+ if (lines[bodyEnd]?.trim() === MANAGED_OAUTH_HEADER) {
1570
+ end = lines.length;
1571
+ for (let index = bodyEnd + 1; index < lines.length; index += 1) if (/^\s*\[/.test(lines[index])) {
1572
+ end = index;
1573
+ break;
1126
1574
  }
1127
- if (hunks.length === 0) return void 0;
1128
- return [`update: ${path}`, ...hunks].join("\n");
1129
1575
  }
1130
- if (name === "Write") {
1131
- if (path === void 0 || path.trim() === "") return void 0;
1132
- const content = typeof record["content"] === "string" ? record["content"] : "";
1133
- if (content === "") return void 0;
1134
- return [`add: ${path}`, ...content.split("\n").map((line) => `+${line}`)].join("\n");
1576
+ const section = lines.slice(start + 1, bodyEnd);
1577
+ const tail = lines.slice(end);
1578
+ const out = [];
1579
+ let sawEnvKey = false;
1580
+ let sawBaseUrl = false;
1581
+ for (const raw of section) {
1582
+ const trimmed = raw.trim();
1583
+ if (/^api_key\s*=/.test(trimmed)) continue;
1584
+ if (/^api_key_env\s*=/.test(trimmed)) {
1585
+ if (sawEnvKey) continue;
1586
+ sawEnvKey = true;
1587
+ out.push(`api_key_env = "${ENDPOINT_ENV_KEY}"`);
1588
+ continue;
1589
+ }
1590
+ if (baseUrl !== void 0 && /^base_url\s*=/.test(trimmed)) {
1591
+ if (sawBaseUrl) continue;
1592
+ sawBaseUrl = true;
1593
+ out.push(`base_url = "${baseUrl.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`);
1594
+ continue;
1595
+ }
1596
+ out.push(raw);
1135
1597
  }
1136
- const patch = record["patch"] ?? record["diff"];
1137
- if (typeof patch === "string" && patch.trim() !== "") return patch;
1598
+ if (!sawEnvKey) out.push(`api_key_env = "${ENDPOINT_ENV_KEY}"`);
1599
+ if (baseUrl !== void 0 && !sawBaseUrl) out.unshift(`base_url = "${baseUrl.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`);
1600
+ return [
1601
+ ...lines.slice(0, start + 1),
1602
+ ...out,
1603
+ ...tail
1604
+ ].join("\n");
1138
1605
  }
1139
- /** Render a tool call's arguments into a compact summary. */
1140
- function argsOf(name, args) {
1141
- if (args === void 0) return void 0;
1142
- if (typeof args === "string") return args.trim() === "" ? void 0 : args;
1143
- if (typeof args !== "object" || args === null || Array.isArray(args)) return void 0;
1144
- const record = args;
1145
- const edit = editArgsOf(name, record);
1146
- if (edit !== void 0) return edit;
1147
- for (const key of [
1148
- "query",
1149
- "url",
1150
- "path",
1151
- "command"
1152
- ]) {
1153
- const value = record[key];
1154
- if (typeof value === "string" && value.trim() !== "") return value.trim();
1606
+ /**
1607
+ * Ensure the scoped config's managed provider runs on the endpoint-key
1608
+ * shape (see {@link migrateManagedProviderConfig}); writes only on drift.
1609
+ * Called at provision, at boot, on the card's credential save, and before
1610
+ * each spawn — the last makes a hand-edited endpoint-auth.json's baseUrl
1611
+ * land without a restart.
1612
+ * @param homeDir - the `kimi` harness's scoped home.
1613
+ * @param baseUrl - the resolved endpoint's baseUrl, when one is configured.
1614
+ * @returns true when the config was rewritten, false when already in sync
1615
+ * (or absent — provisioning owns creation).
1616
+ */
1617
+ async function ensureKimiEndpointProvider(homeDir, baseUrl) {
1618
+ const file = join(homeDir, "config.toml");
1619
+ let text;
1620
+ try {
1621
+ text = await readFile(file, "utf8");
1622
+ } catch {
1623
+ return false;
1155
1624
  }
1156
- const pairs = [];
1157
- for (const [key, value] of Object.entries(record)) if (typeof value === "string" && value.trim() !== "") pairs.push(`${key}=${value.trim()}`);
1158
- else if (typeof value === "number" || typeof value === "boolean") pairs.push(`${key}=${String(value)}`);
1159
- if (pairs.length === 0) return void 0;
1160
- const summary = pairs.join(" ");
1161
- return summary.length > 200 ? `${summary.slice(0, 200)}…` : summary;
1625
+ const migrated = migrateManagedProviderConfig(text, baseUrl);
1626
+ if (migrated === text) return false;
1627
+ await writeFile(file, migrated, "utf8");
1628
+ return true;
1162
1629
  }
1163
1630
  /**
1164
- * Parse a session's wire log into transcript lines plus every usage record.
1165
- * @param sessionDir - the session directory.
1166
- * @returns the transcript.
1631
+ * Sweep the OAuth era's leftovers (the credentials directory and the oauth
1632
+ * token cache). The harness no longer reads either; a leftover grant would
1633
+ * keep authenticating nothing (the managed provider no longer declares an
1634
+ * oauth section), and removing them keeps the scoped home honest about its
1635
+ * only credential being the endpoint key. Idempotent.
1636
+ * @param homeDir - the `kimi` harness's scoped home.
1167
1637
  */
1168
- async function readKimiTranscript(sessionDir) {
1169
- const sessionId = sessionDir.split("session_").pop() ?? sessionDir;
1170
- const lines = [];
1171
- const usageRecords = [];
1172
- let model;
1173
- let modelTurn;
1174
- let lineModel;
1175
- const push = (line) => {
1176
- lines.push({
1177
- ...line,
1178
- ...lineModel === void 0 ? {} : { model: lineModel }
1179
- });
1180
- };
1181
- let turn = 0;
1182
- let turnSeen = false;
1183
- const callsByUuid = /* @__PURE__ */ new Map();
1184
- const callsByToolCallId = /* @__PURE__ */ new Map();
1185
- let text;
1638
+ async function sweepKimiOauthLeftovers(homeDir) {
1639
+ await Promise.all([rm(join(homeDir, CREDENTIALS_DIR), {
1640
+ recursive: true,
1641
+ force: true
1642
+ }), rm(join(homeDir, OAUTH_DIR), {
1643
+ recursive: true,
1644
+ force: true
1645
+ })]);
1646
+ }
1647
+ /**
1648
+ * Provision `<homeDir>/config.toml` unless one already exists, and always
1649
+ * sweep the OAuth era's leftovers. A present config is migrated onto the
1650
+ * endpoint-key shape (managed provider onto `api_key_env`, oauth sub-table
1651
+ * dropped) but otherwise respected; otherwise the user's own real config is
1652
+ * copied redacted AND migrated, so the scoped home mirrors the user's models
1653
+ * while carrying no secrets; with no real config, a minimal kimi-managed
1654
+ * config is written. `thinkingEffort` feeds the minimal config's
1655
+ * `[thinking] effort` (and the model's `default_effort`) — it applies at
1656
+ * provision time only, never retroactively to an existing config.
1657
+ * @param homeDir - the `kimi` harness's scoped home.
1658
+ * @param model - the kimi-managed model id for a fresh minimal config.
1659
+ * @param thinkingEffort - the reasoning effort a fresh minimal config pins.
1660
+ * @param baseUrl - the deployment endpoint to pin on the managed provider,
1661
+ * when the plugin config names one (the scope file's own baseUrl lands via
1662
+ * the card save / the per-spawn surgery).
1663
+ * @returns true when a config was written, false when one already existed.
1664
+ */
1665
+ async function provisionKimiConfig(homeDir, model, thinkingEffort, baseUrl) {
1666
+ await sweepKimiOauthLeftovers(homeDir);
1667
+ let existed = true;
1186
1668
  try {
1187
- text = await readFile(wirePath(sessionDir), "utf8");
1669
+ await readFile(join(homeDir, "config.toml"));
1188
1670
  } catch (error) {
1189
- if (error.code === "ENOENT") return {
1190
- sessionId,
1191
- lines,
1192
- usageRecords
1193
- };
1194
- throw error;
1671
+ if (error.code !== "ENOENT") throw error;
1672
+ existed = false;
1195
1673
  }
1196
- for (const raw of text.split("\n")) {
1197
- const line = raw.trim();
1198
- if (line === "") continue;
1199
- let event;
1200
- try {
1201
- event = JSON.parse(line);
1202
- } catch {
1674
+ if (existed) {
1675
+ await ensureKimiEndpointProvider(homeDir, baseUrl);
1676
+ return false;
1677
+ }
1678
+ let config = minimalConfig(model, thinkingEffort);
1679
+ try {
1680
+ config = migrateManagedProviderConfig(redactApiKeys(await readFile(REAL_CONFIG, "utf8")), baseUrl);
1681
+ } catch (error) {
1682
+ if (error.code !== "ENOENT") throw error;
1683
+ }
1684
+ await writeFile(join(homeDir, "config.toml"), config, { flag: "wx" });
1685
+ return true;
1686
+ }
1687
+ /** The managed-service provider section provisioned into the scoped config. */
1688
+ const MANAGED_PROVIDER_SECTION = "providers.\"managed:kimi-code\"";
1689
+ /**
1690
+ * Read the scoped config's effective LLM endpoint: the
1691
+ * `[providers."managed:kimi-code"].base_url` key, if present. A missing or
1692
+ * malformed config yields undefined (the CLI falls back to its default
1693
+ * endpoint). Used only for diagnostics — the config itself is authoritative,
1694
+ * and a user-edited value is respected untouched.
1695
+ * @param homeDir - the `kimi` harness's scoped home.
1696
+ * @returns the configured base URL, or undefined when absent/unreadable.
1697
+ */
1698
+ async function readKimiBaseUrl(homeDir) {
1699
+ let text;
1700
+ try {
1701
+ text = await readFile(join(homeDir, "config.toml"), "utf8");
1702
+ } catch {
1703
+ return;
1704
+ }
1705
+ let section = "";
1706
+ for (const rawLine of text.split("\n")) {
1707
+ const line = rawLine.trim();
1708
+ const header = /^\[+([^\]]+)\]+$/.exec(line);
1709
+ if (header !== null) {
1710
+ section = header[1];
1203
1711
  continue;
1204
1712
  }
1205
- if (event.type === "usage.record") {
1206
- if (typeof event.model === "string" && event.model !== "") {
1207
- model = lineModel = event.model;
1208
- modelTurn = turn || 1;
1209
- }
1210
- const usage = usageFromWire(event.usage);
1211
- if (usage !== void 0) usageRecords.push({
1212
- line: lines.length,
1213
- usage
1214
- });
1215
- continue;
1713
+ if (section === MANAGED_PROVIDER_SECTION) {
1714
+ const match = /^base_url\s*=\s*"([^"]*)"$/.exec(line);
1715
+ if (match !== null) return match[1];
1216
1716
  }
1217
- if (event.type === "llm.request") {
1218
- lineModel = typeof event.model === "string" && event.model !== "" ? event.model : void 0;
1219
- if (typeof event.model === "string" && event.model !== "") {
1220
- model = event.model;
1221
- modelTurn = turn || 1;
1222
- }
1717
+ }
1718
+ }
1719
+ /**
1720
+ * Read the scoped config's configured default model: the top-level
1721
+ * `default_model` key — the selection a plain `kimi -p` round runs with. A
1722
+ * missing or malformed config, or no key, yields undefined — the honest
1723
+ * "unknown", never a guessed default. Read-only: the config is authoritative
1724
+ * and a user-edited value is reported as-is.
1725
+ * @param homeDir - the `kimi` harness's scoped home.
1726
+ * @returns the configured model identifier, or undefined when none is set.
1727
+ */
1728
+ async function readKimiDefaultModel(homeDir) {
1729
+ let text;
1730
+ try {
1731
+ text = await readFile(join(homeDir, "config.toml"), "utf8");
1732
+ } catch {
1733
+ return;
1734
+ }
1735
+ let section = "";
1736
+ for (const rawLine of text.split("\n")) {
1737
+ const line = rawLine.trim();
1738
+ const header = /^\[+([^\]]+)\]+$/.exec(line);
1739
+ if (header !== null) {
1740
+ section = header[1];
1223
1741
  continue;
1224
1742
  }
1225
- if (event.type === "turn.prompt") {
1226
- lineModel = void 0;
1227
- turnSeen = true;
1228
- turn += 1;
1229
- const prompt = textOf(event.input ?? []);
1230
- if (prompt.trim() !== "" && !isSystemReminder(prompt)) push({
1231
- kind: "user",
1232
- text: prompt,
1233
- turn: turn === 0 ? 1 : turn
1234
- });
1743
+ if (section !== "") continue;
1744
+ const key = /^(\w+)\s*=\s*"([^"]*)"$/.exec(line);
1745
+ if (key !== null && key[1] === "default_model") return key[2];
1746
+ }
1747
+ }
1748
+ /**
1749
+ * List the model identifiers the scoped config DEFINES: every
1750
+ * `[models."…"]` table key (bare `[models.x]` keys included). This is the
1751
+ * pickable vocabulary a model surface can offer without hardcoding a catalog —
1752
+ * the same table `-m` resolves against. A missing or malformed config yields
1753
+ * an empty list. Read-only: the config is authoritative.
1754
+ * @param homeDir - the `kimi` harness's scoped home.
1755
+ * @returns the defined model identifiers, in file order, deduplicated.
1756
+ */
1757
+ async function listKimiConfigModels(homeDir) {
1758
+ let text;
1759
+ try {
1760
+ text = await readFile(join(homeDir, "config.toml"), "utf8");
1761
+ } catch {
1762
+ return [];
1763
+ }
1764
+ const models = [];
1765
+ for (const rawLine of text.split("\n")) {
1766
+ const header = /^\[+([^\]]+)\]+$/.exec(rawLine.trim());
1767
+ if (header === null) continue;
1768
+ const table = /^models\.(?:"([^"]+)"|([\w-]+))$/.exec(header[1]);
1769
+ const name = table?.[1] ?? table?.[2];
1770
+ if (name !== void 0 && name !== "" && !models.includes(name)) models.push(name);
1771
+ }
1772
+ return models;
1773
+ }
1774
+ /**
1775
+ * The header scan's one table key: a quoted `models."…"` key (unescaped — the
1776
+ * writer escapes, so the reader must fold back) or a bare `models.x` one.
1777
+ */
1778
+ function modelTableKey(header) {
1779
+ const table = /^models\.(?:"((?:[^"\\]|\\.)*)"|([\w-]+))$/.exec(header);
1780
+ const raw = table?.[1];
1781
+ if (raw !== void 0) return raw.replace(/\\(["\\])/gu, "$1");
1782
+ return table?.[2];
1783
+ }
1784
+ /** Whether the config text already keys a `[models.…]` table for `value`. */
1785
+ function hasModelTable(text, value) {
1786
+ for (const rawLine of text.split("\n")) {
1787
+ const header = /^\[+([^\]]+)\]+$/.exec(rawLine.trim());
1788
+ if (header !== null && modelTableKey(header[1]) === value) return true;
1789
+ }
1790
+ return false;
1791
+ }
1792
+ /**
1793
+ * Give every listed model value a `[models."<value>"]` table in the scoped
1794
+ * config — the runnable name `-m` resolves against. kimi-cli ≥2.1 REQUIRES
1795
+ * the table: `kimi -m k3` fails with `Model "k3" is not configured in
1796
+ * config.toml` when only `[models."kimi-code/k3"]` (carrying `model = "k3"`)
1797
+ * exists (verified against kimi-cli 2.1.1), so an endpoint-served id the
1798
+ * catalog lists is not directly runnable until its table exists. The model
1799
+ * list's write face calls this after each save so an added endpoint id is
1800
+ * runnable from that round on.
1801
+ *
1802
+ * Only MISSING values get a table: an existing key (quoted or bare) is the
1803
+ * user's own definition and stays untouched. The appended table mirrors the
1804
+ * minimal config's managed shape — `provider = "managed:kimi-code"`, `model`
1805
+ * = the value, `max_context_size` (the metadata's endpoint-reported size,
1806
+ * else 1048576 — the minimal template's value), the shared capabilities
1807
+ * list, `display_name` (the metadata's label, else the value), and
1808
+ * `support_efforts`/`default_effort` when the metadata carries them. An
1809
+ * absent or unreadable config yields [] (provisioning owns creation;
1810
+ * inventing a whole config here would hide a broken home). Idempotent: all
1811
+ * values already keyed → no write, [].
1812
+ * @param homeDir - the `kimi` harness's scoped home.
1813
+ * @param models - the model values that must be runnable (already normalized).
1814
+ * @param metadata - per-value lookup of the catalog's endpoint/table facts.
1815
+ * @returns the table keys this call created, in the order listed.
1816
+ */
1817
+ async function ensureKimiModelTables(homeDir, models, metadata) {
1818
+ const path = join(homeDir, "config.toml");
1819
+ let text;
1820
+ try {
1821
+ text = await readFile(path, "utf8");
1822
+ } catch {
1823
+ return [];
1824
+ }
1825
+ const created = [];
1826
+ let fragment = "";
1827
+ for (const value of models) {
1828
+ if (value === "" || hasModelTable(text, value) || created.includes(value)) continue;
1829
+ const facts = metadata?.(value);
1830
+ fragment += `\n[models.${tomlString(value)}]\nprovider = "managed:kimi-code"
1831
+ model = ${tomlString(value)}\nmax_context_size = ${String(facts?.contextSize ?? 1048576)}\n${KIMI_MODEL_CAPABILITIES}\ndisplay_name = ${tomlString(facts?.label ?? value)}\n` + (facts?.efforts === void 0 || facts.efforts.length === 0 ? "" : `support_efforts = [ ${facts.efforts.map(tomlString).join(", ")} ]\n`) + (facts?.defaultEffort === void 0 ? "" : `default_effort = ${tomlString(facts.defaultEffort)}\n`);
1832
+ created.push(value);
1833
+ }
1834
+ if (created.length === 0) return [];
1835
+ await writeFile(path, `${text.trimEnd()}\n${fragment}`, "utf8");
1836
+ return created;
1837
+ }
1838
+ /**
1839
+ * Rewrite the scoped config's top-level `default_model` to `model`, so the
1840
+ * NEXT process started under this home runs that model. This is the resident
1841
+ * path's only lever: `kimi acp` takes no model flag (verified against kimi
1842
+ * 0.39.1), while `kimi -p` takes `-m` — so the one-shot rounds pass the flag
1843
+ * and the live driver writes the file before spawning its runtime.
1844
+ *
1845
+ * The write is surgical: the first top-level `default_model` assignment is
1846
+ * replaced in place and everything else — model tables, providers, thinking
1847
+ * effort, comments, key order — is left byte-identical. A config with no
1848
+ * top-level `default_model` gains one as its first line; an absent or
1849
+ * unreadable config is left alone (provisioning owns creation, and inventing
1850
+ * a config here would hide a broken home).
1851
+ * @param homeDir - the `kimi` harness's scoped home.
1852
+ * @param model - the model identifier to pin as the default.
1853
+ * @returns true when the file now names `model`, false when there was no
1854
+ * config to write into.
1855
+ */
1856
+ async function writeKimiDefaultModel(homeDir, model) {
1857
+ const path = join(homeDir, "config.toml");
1858
+ let text;
1859
+ try {
1860
+ text = await readFile(path, "utf8");
1861
+ } catch {
1862
+ return false;
1863
+ }
1864
+ const assignment = `default_model = "${model.replace(/\\/gu, "\\\\").replace(/"/gu, "\\\"")}"`;
1865
+ const lines = text.split("\n");
1866
+ let section = "";
1867
+ for (const [index, rawLine] of lines.entries()) {
1868
+ const line = rawLine.trim();
1869
+ const header = /^\[+([^\]]+)\]+$/.exec(line);
1870
+ if (header !== null) {
1871
+ section = header[1];
1235
1872
  continue;
1236
1873
  }
1237
- if (event.type === "context.append_message") {
1238
- const message = event.message;
1239
- const role = message?.role;
1240
- const textContent = textOf(message?.content ?? []);
1241
- if (role === "user" && textContent.trim() !== "" && !isSystemReminder(textContent)) {
1242
- const last = lines[lines.length - 1];
1243
- if (turnSeen && last?.kind === "user" && last.text === textContent) continue;
1244
- turnSeen = true;
1245
- turn += 1;
1246
- lineModel = void 0;
1247
- push({
1248
- kind: "user",
1249
- text: textContent,
1250
- turn: turn === 0 ? 1 : turn
1251
- });
1252
- } else if (role === "assistant" && textContent.trim() !== "") push({
1253
- kind: "assistant",
1254
- text: textContent,
1255
- turn: turn === 0 ? 1 : turn
1256
- });
1874
+ if (section !== "") continue;
1875
+ const key = /^(\w+)\s*=/.exec(line);
1876
+ if (key === null || key[1] !== "default_model") continue;
1877
+ if (line === assignment) return true;
1878
+ lines[index] = assignment;
1879
+ await writeFile(path, lines.join("\n"), "utf8");
1880
+ return true;
1881
+ }
1882
+ await writeFile(path, `${assignment}\n${text}`, "utf8");
1883
+ return true;
1884
+ }
1885
+ /**
1886
+ * Read the scoped config's effective reasoning effort: the `[thinking] effort`
1887
+ * key, falling back to a `[models."…"]` section's `default_effort` when the
1888
+ * thinking table carries none (the CLI resolves effort the same way). A
1889
+ * missing or malformed config, or no effort key anywhere, yields undefined —
1890
+ * the honest "unknown", never a guessed default. Read-only: the config is
1891
+ * authoritative, a user-edited value is reported as-is.
1892
+ * @param homeDir - the `kimi` harness's scoped home.
1893
+ * @returns the effective effort, or undefined when it cannot be determined.
1894
+ */
1895
+ async function readKimiReasoningEffort(homeDir) {
1896
+ let text;
1897
+ try {
1898
+ text = await readFile(join(homeDir, "config.toml"), "utf8");
1899
+ } catch {
1900
+ return;
1901
+ }
1902
+ let section = "";
1903
+ let thinkingEffort;
1904
+ let modelDefaultEffort;
1905
+ for (const rawLine of text.split("\n")) {
1906
+ const line = rawLine.trim();
1907
+ const header = /^\[+([^\]]+)\]+$/.exec(line);
1908
+ if (header !== null) {
1909
+ section = header[1];
1257
1910
  continue;
1258
1911
  }
1259
- if (event.type === "context.append_loop_event") {
1260
- const loop = event.event;
1261
- if (loop === void 0) continue;
1262
- const loopTurn = typeof loop.turnId === "number" ? loop.turnId + 1 : void 0;
1263
- if (loopTurn !== void 0) {
1264
- turn = Math.max(turn, loopTurn);
1265
- turnSeen = true;
1266
- }
1267
- const part = loop.part;
1268
- const lineTurn = turn === 0 ? 1 : turn;
1269
- if (part?.type === "text" && part.text !== void 0 && part.text.trim() !== "") push({
1270
- kind: "assistant",
1271
- text: part.text,
1272
- turn: lineTurn
1273
- });
1274
- else if (part?.type === "think" && part.think !== void 0 && part.think.trim() !== "") push({
1275
- kind: "think",
1276
- text: part.think,
1277
- turn: lineTurn
1278
- });
1279
- else if (loop.type === "tool.call") {
1280
- const call = loop;
1281
- const name = call.toolCall?.name ?? call.name ?? "tool";
1282
- const args = argsOf(name, call.toolCall?.args ?? call.args);
1283
- const lineIndex = lines.length;
1284
- push({
1285
- kind: "tool",
1286
- id: typeof call.toolCallId === "string" ? call.toolCallId : typeof call.uuid === "string" ? call.uuid : `kimi-tool-${lineIndex}`,
1287
- name,
1288
- ...args === void 0 ? {} : { args },
1289
- turn: lineTurn
1290
- });
1291
- if (typeof call.uuid === "string") callsByUuid.set(call.uuid, lineIndex);
1292
- if (typeof call.toolCallId === "string") callsByToolCallId.set(call.toolCallId, lineIndex);
1293
- } else if (loop.type === "tool.result") {
1294
- const result = loop;
1295
- const output = result.result?.output;
1296
- let target = typeof result.parentUuid === "string" ? callsByUuid.get(result.parentUuid) : void 0;
1297
- if (target === void 0 && typeof result.toolCallId === "string") target = callsByToolCallId.get(result.toolCallId);
1298
- if (target === void 0) {
1299
- for (let index = lines.length - 1; index >= 0; index -= 1) if (lines[index]?.kind === "tool") {
1300
- target = index;
1301
- break;
1302
- }
1303
- }
1304
- if (output !== void 0 && output.trim() !== "" && target !== void 0) {
1305
- const current = lines[target];
1306
- if (current !== void 0 && current.kind === "tool") lines[target] = {
1307
- ...current,
1308
- result: output
1309
- };
1310
- }
1311
- } else if (loop.type === "content.part" && part?.type !== void 0 && part.type !== "text" && part.type !== "think") push({
1312
- kind: "assistant",
1313
- text: `[未支持的内容类型 ${part.type}]`,
1314
- turn: lineTurn
1315
- });
1316
- }
1912
+ const key = /^(\w+)\s*=\s*"([^"]*)"$/.exec(line);
1913
+ if (key === null) continue;
1914
+ if (section === "thinking" && key[1] === "effort" && thinkingEffort === void 0) thinkingEffort = key[2];
1915
+ if (section.startsWith("models.") && key[1] === "default_effort" && modelDefaultEffort === void 0) modelDefaultEffort = key[2];
1317
1916
  }
1318
- return {
1319
- sessionId,
1320
- lines,
1321
- usageRecords,
1322
- ...model === void 0 ? {} : { model },
1323
- ...modelTurn === void 0 ? {} : { modelTurn }
1324
- };
1325
- }
1326
- /**
1327
- * Map a wire `usage.record` payload onto the shared usage contract. kimi
1328
- * reports input (uncached), cache read, cache creation, and output; the
1329
- * creation counter is its cache-write analog. Non-numeric or missing
1330
- * counters are dropped so the record stays usable when kimi omits a field.
1331
- * @param raw - the event's `usage` value.
1332
- * @returns the shared usage record, or undefined when nothing was reported.
1333
- */
1334
- function usageFromWire(raw) {
1335
- const counters = raw;
1336
- if (counters === void 0 || typeof counters !== "object") return void 0;
1337
- const num = (value) => {
1338
- const n = Number(value);
1339
- return Number.isFinite(n) ? n : void 0;
1340
- };
1341
- const input = num(counters.inputOther);
1342
- const output = num(counters.output);
1343
- const cacheRead = num(counters.inputCacheRead);
1344
- const cacheWrite = num(counters.inputCacheCreation);
1345
- if (input === void 0 && output === void 0 && cacheRead === void 0 && cacheWrite === void 0) return;
1346
- const usage = {
1347
- inputTokens: input ?? 0,
1348
- outputTokens: output ?? 0
1349
- };
1350
- if (cacheRead !== void 0 && cacheRead > 0) usage.cacheReadTokens = cacheRead;
1351
- if (cacheWrite !== void 0 && cacheWrite > 0) usage.cacheWriteTokens = cacheWrite;
1352
- return usage;
1917
+ return thinkingEffort ?? modelDefaultEffort;
1353
1918
  }
1354
1919
  /**
1355
- * Sum per-request usage records into one round total. Each `usage.record` is
1356
- * a single LLM request's accounting, so a round's totals are the sum of the
1357
- * records inside it.
1358
- * @param records - the usage records to add.
1359
- * @returns the summed usage, or undefined when the list is empty.
1920
+ * Whether the scoped config's permission rules auto-approve the subagent's
1921
+ * tool use: the `Bash(*)` allow rule the provisioning gate
1922
+ * ({@link ensureKimiPermissions}) keys on, detected with the exact same
1923
+ * substring so the snapshot and the provisioning can never disagree about
1924
+ * the same file.
1925
+ * @param homeDir - the `kimi` harness's scoped home.
1926
+ * @returns whether the auto-approve rule is present.
1360
1927
  */
1361
- function sumUsageRecords(records) {
1362
- let input = 0;
1363
- let output = 0;
1364
- let cacheRead = 0;
1365
- let cacheWrite = 0;
1366
- for (const record of records) {
1367
- input += record.usage.inputTokens ?? 0;
1368
- output += record.usage.outputTokens ?? 0;
1369
- cacheRead += record.usage.cacheReadTokens ?? 0;
1370
- cacheWrite += record.usage.cacheWriteTokens ?? 0;
1928
+ async function readKimiAutoApprove(homeDir) {
1929
+ let text;
1930
+ try {
1931
+ text = await readFile(join(homeDir, "config.toml"), "utf8");
1932
+ } catch {
1933
+ return false;
1371
1934
  }
1372
- if (input === 0 && output === 0 && cacheRead === 0 && cacheWrite === 0) return void 0;
1373
- const usage = {
1374
- inputTokens: input,
1375
- outputTokens: output
1376
- };
1377
- if (cacheRead > 0) usage.cacheReadTokens = cacheRead;
1378
- if (cacheWrite > 0) usage.cacheWriteTokens = cacheWrite;
1379
- return usage;
1935
+ return text.includes("pattern = \"Bash(*)");
1380
1936
  }
1937
+ /** Permission rule block letting the kimi subagent run shell commands. */
1938
+ const KIMI_PERMISSION_BLOCK = `
1939
+ [[permission.rules]]
1940
+ decision = "allow"
1941
+ pattern = "Bash(*)"
1942
+ reason = "let the kimi subagent run shell commands"
1943
+ `;
1381
1944
  /**
1382
- * Merge two usage windows into one running total — the delegation run's
1383
- * per-round accumulator: each mirror pass (live poll, settle) returns its
1384
- * own window's accounting, and the disjoint windows sum to the round's
1385
- * total. `undefined` operands pass through, so callers accumulate without
1386
- * absence checks.
1387
- * @param left - the running total, or undefined before the first window.
1388
- * @param right - the next window's usage, or undefined when it had none.
1389
- * @returns the merged total, or undefined when both operands are absent.
1945
+ * Ensure the scoped config allows the subagent's tool use. The one-shot
1946
+ * `kimi -p` child uses the config's permission rules, so without a rule it
1947
+ * answers without executing tools. Idempotent; an existing rule is respected.
1948
+ * @param homeDir - the `kimi` harness's scoped home.
1390
1949
  */
1391
- function addTokenUsage(left, right) {
1392
- if (left === void 0) return right;
1393
- if (right === void 0) return left;
1394
- return sumUsageRecords([{
1395
- line: 0,
1396
- usage: left
1397
- }, {
1398
- line: 0,
1399
- usage: right
1400
- }]);
1401
- }
1402
- /** Render a transcript as command-reply text. */
1403
- function renderTranscript(transcript) {
1404
- if (transcript.lines.length === 0) return `kimi session ${transcript.sessionId}: no readable transcript yet.`;
1405
- const parts = [`kimi session ${transcript.sessionId}:`];
1406
- for (const line of transcript.lines) if (line.kind === "user") parts.push(`\n用户: ${line.text}`);
1407
- else if (line.kind === "assistant") parts.push(`\nkimi: ${line.text}`);
1408
- else if (line.kind === "think") parts.push(`\n[思考] ${line.text}`);
1409
- else {
1410
- const call = `[工具 ${line.name}]${line.args !== void 0 ? ` ${line.args}` : ""}`;
1411
- parts.push(`\n ↳ ${call}${line.result !== void 0 ? ` → ${line.result}` : ""}`);
1950
+ async function ensureKimiPermissions(homeDir) {
1951
+ const file = join(homeDir, "config.toml");
1952
+ let content;
1953
+ try {
1954
+ content = await readFile(file, "utf8");
1955
+ } catch (error) {
1956
+ if (error.code === "ENOENT") return;
1957
+ throw error;
1412
1958
  }
1413
- return parts.join("\n");
1959
+ if (content.includes("pattern = \"Bash(*)")) return;
1960
+ await writeFile(file, `${content.trimEnd()}\n${KIMI_PERMISSION_BLOCK}`);
1414
1961
  }
1415
1962
  //#endregion
1416
- //#region lib/types/session-mirror.js
1963
+ //#region lib/types/session-view.js
1417
1964
  /**
1418
- * Mirror a kimi session's transcript into a dsh subagent session, so the
1419
- * delegation shows up in the standard 子代理 surface and opening the child
1420
- * session shows the full conversation (user prompts, kimi replies, thinking,
1421
- * and tool activity with arguments and results) instead of a silent one-shot
1422
- * call.
1423
- * @module @khorsheed/dsh-local-agent-kimi/session-mirror
1965
+ * Kimi session viewer: read a scoped-home session's `wire.jsonl` event log
1966
+ * and render a readable transcript (user prompts, assistant thinking and
1967
+ * replies, tool calls with arguments and results). The kimi ACP mode writes
1968
+ * responses to the session file rather than pushing them over the protocol,
1969
+ * and the dsh subagent record for a one-shot run carries only the final
1970
+ * output — this is the way to see what the kimi subagent actually did.
1971
+ *
1972
+ * The transcript is harness-comparison grade: every line is tagged with its
1973
+ * wire turn number (1-based, matching the dsh child session's `turn/start`
1974
+ * numbering), kimi's auto-permission `<system-reminder>` user messages are
1975
+ * filtered out, tool calls carry their rendered arguments, tool results are
1976
+ * matched to their owning call by `parentUuid`, and every `usage.record`
1977
+ * (kimi reports accounting per LLM request, not cumulative) is surfaced with
1978
+ * its transcript position so a delta mirror can sum a round's total.
1979
+ * @module @khorsheed/dsh-local-agent-kimi/session-view
1424
1980
  */
1425
- /** One user-role message event. */
1426
- function userEvent(text) {
1427
- return createUserMessage({
1428
- content: [{
1429
- type: "text",
1430
- text
1431
- }],
1432
- source: { kind: "user" }
1433
- });
1434
- }
1435
- /** One assistant-role message event, attributed to the kimi route. */
1436
- function assistantEvent(blocks, model) {
1437
- return createAssistantMessage({
1438
- content: blocks,
1439
- source: {
1440
- provider: "kimi-cli",
1441
- model: model ?? "unobserved"
1442
- }
1443
- });
1981
+ /** The wire event log path for a session directory. */
1982
+ function wirePath(sessionDir) {
1983
+ return join(sessionDir, "agents", "main", "wire.jsonl");
1444
1984
  }
1445
1985
  /**
1446
- * The step the next fold pass would assign in one turn (the max existing
1447
- * step + 1). The token-granularity live
1448
- * driver reserves its stream merge key here LAZILY at the first delta, so
1449
- * tool cards folded before the stream started keep their chronological
1450
- * place below it instead of the stream squatting on step 1.
1451
- * @param childSession - the run's child session.
1452
- * @param turn - the round's turn number.
1453
- * @returns the next free step in the turn.
1986
+ * Find a session directory under `<homeDir>/sessions/wd_*` whose id matches.
1987
+ * @param homeDir - the `kimi` harness's scoped home.
1988
+ * @param sessionId - the kimi session id.
1989
+ * @returns the session directory, or undefined when absent.
1454
1990
  */
1455
- function nextKimiSessionStep(childSession, turn) {
1456
- let next = 1;
1457
- for (const event of childSession.snapshotEvents()) {
1458
- const data = event.data;
1459
- if (data.turn === turn && typeof data.step === "number") next = Math.max(next, data.step + 1);
1991
+ async function findKimiSessionDir(homeDir, sessionId) {
1992
+ const sessionsRoot = join(homeDir, "sessions");
1993
+ let workspaces;
1994
+ try {
1995
+ workspaces = await readdir(sessionsRoot);
1996
+ } catch (error) {
1997
+ if (error.code === "ENOENT") return void 0;
1998
+ throw error;
1460
1999
  }
1461
- return next;
2000
+ for (const workspace of workspaces) {
2001
+ const sessionDir = join(sessionsRoot, workspace, `session_${sessionId}`);
2002
+ try {
2003
+ await readFile(wirePath(sessionDir));
2004
+ return sessionDir;
2005
+ } catch {}
2006
+ }
2007
+ }
2008
+ /** Whether a user message is kimi's auto-permission reminder, never real task text. */
2009
+ function isSystemReminder(text) {
2010
+ return text.startsWith("<system-reminder>");
2011
+ }
2012
+ /** Flatten the text blocks of a message content array. */
2013
+ function textOf(content) {
2014
+ return (content ?? []).filter((block) => typeof block === "object" && block !== null && block.type === "text").map((block) => block.text ?? "").join("");
1462
2015
  }
1463
2016
  /**
1464
- * Fold one non-tool transcript line into message blocks. Thinking maps to
1465
- * the native `reasoning` block so the standard conversation renders it as
1466
- * thinking rather than a `[思考]` text prefix; reply text stays text (content
1467
- * is never filtered — kimi's own injections remain visible). Tool lines are
1468
- * NOT folded here: they become native `tool/call`/`tool/result` event pairs
1469
- * (see the mirror loop), so the standard conversation renders them as tool
1470
- * cards instead of `[工具 X]` text.
2017
+ * Render a file-editing tool call's arguments apply-patch style: the path
2018
+ * plus the old/new content. Without this a delegation's edits collapsed to
2019
+ * the bare path (or nothing) and the transcript hid what the member actually
2020
+ * changed — the kimi twin of codex's fileChange → ApplyPatch fold.
2021
+ * @param name - the tool name the wire recorded.
2022
+ * @param record - the call's argument object.
2023
+ * @returns the patch-style summary, or undefined when the tool is not a
2024
+ * known file-editing shape.
1471
2025
  */
1472
- function lineBlocks(line) {
1473
- if (line.kind === "think") return [{
1474
- type: "reasoning",
1475
- text: line.text
1476
- }];
1477
- return [{
1478
- type: "text",
1479
- text: line.text
1480
- }];
2026
+ function editArgsOf(name, record) {
2027
+ const path = typeof record["path"] === "string" ? record["path"] : void 0;
2028
+ if (name === "Edit" || name === "MultiEdit") {
2029
+ if (path === void 0 || path.trim() === "") return void 0;
2030
+ const edits = Array.isArray(record["edits"]) ? record["edits"] : [record];
2031
+ const hunks = [];
2032
+ for (const edit of edits) {
2033
+ const oldText = typeof edit["old_string"] === "string" ? edit["old_string"] : "";
2034
+ const newText = typeof edit["new_string"] === "string" ? edit["new_string"] : "";
2035
+ if (oldText === "" && newText === "") continue;
2036
+ hunks.push("@@", ...oldText.split("\n").map((line) => `-${line}`), ...newText.split("\n").map((line) => `+${line}`));
2037
+ }
2038
+ if (hunks.length === 0) return void 0;
2039
+ return [`update: ${path}`, ...hunks].join("\n");
2040
+ }
2041
+ if (name === "Write") {
2042
+ if (path === void 0 || path.trim() === "") return void 0;
2043
+ const content = typeof record["content"] === "string" ? record["content"] : "";
2044
+ if (content === "") return void 0;
2045
+ return [`add: ${path}`, ...content.split("\n").map((line) => `+${line}`)].join("\n");
2046
+ }
2047
+ const patch = record["patch"] ?? record["diff"];
2048
+ if (typeof patch === "string" && patch.trim() !== "") return patch;
1481
2049
  }
1482
- /** The run-progress delta text for one transcript line (any kind). */
1483
- function kimiLineProgressText(line) {
1484
- if (line.kind !== "tool") return line.text;
1485
- return `${`[工具 ${line.name}]${line.args !== void 0 ? ` ${line.args}` : ""}`}${line.result !== void 0 ? ` → ${line.result}` : ""}`;
2050
+ /** Render a tool call's arguments into a compact summary. */
2051
+ function argsOf(name, args) {
2052
+ if (args === void 0) return void 0;
2053
+ if (typeof args === "string") return args.trim() === "" ? void 0 : args;
2054
+ if (typeof args !== "object" || args === null || Array.isArray(args)) return void 0;
2055
+ const record = args;
2056
+ const edit = editArgsOf(name, record);
2057
+ if (edit !== void 0) return edit;
2058
+ for (const key of [
2059
+ "query",
2060
+ "url",
2061
+ "path",
2062
+ "command"
2063
+ ]) {
2064
+ const value = record[key];
2065
+ if (typeof value === "string" && value.trim() !== "") return value.trim();
2066
+ }
2067
+ const pairs = [];
2068
+ for (const [key, value] of Object.entries(record)) if (typeof value === "string" && value.trim() !== "") pairs.push(`${key}=${value.trim()}`);
2069
+ else if (typeof value === "number" || typeof value === "boolean") pairs.push(`${key}=${String(value)}`);
2070
+ if (pairs.length === 0) return void 0;
2071
+ const summary = pairs.join(" ");
2072
+ return summary.length > 200 ? `${summary.slice(0, 200)}…` : summary;
1486
2073
  }
1487
2074
  /**
1488
- * Mirror one kimi session's transcript into the dsh subagent session: each
1489
- * user prompt becomes a `user/message`, each assistant reply (including
1490
- * folded thinking and tool activity) an `assistant/message`, then the events
1491
- * are appended to persistence so the child session is viewable.
1492
- *
1493
- * A resumed round (or a live poll) passes the already-mirrored
1494
- * transcript-line count so only the delta is appended — re-mirroring earlier
1495
- * lines would duplicate their messages. Turn numbers come from the transcript
1496
- * lines themselves (the wire's 1-based turn, matching the provider's
1497
- * `turn/start` numbering); step numbers CONTINUE from the assistant messages
1498
- * already in the child session, so a round mirrored across several live polls
1499
- * does not restart step 1 per poll.
1500
- *
1501
- * Every folded step is wrapped in the `step/start`–`step/end` boundary pair
1502
- * the live conversation assembler requires: without a boundary the step never
1503
- * enters the location index, the assistant message resolves to a turn-level
1504
- * location, and the real-time view renders nothing (only a full rebuild,
1505
- * which mints step drafts from the explicit coordinates, recovers it). A
1506
- * think/assistant line that completes an in-flight stream folds at the
1507
- * stream's reserved step instead (see {@link KimiMirrorStreams}), and a
1508
- * sequential fold never takes a step a stream reserved.
1509
- *
1510
- * Usage accounting is exactly-once ACROSS passes: each `usage.record` is
1511
- * tagged with the transcript position it occurred at, and a pass attaches the
1512
- * records in the half-open range `(fromLines, newTotal]` — the records that
1513
- * trail THIS pass's lines — summed onto the delta's final assistant message.
1514
- * (A record flushed only after its line was already mirrored belongs to no
1515
- * later pass and is dropped; kimi flushes content and usage together per
1516
- * request, so this window is a flush race, not the norm.)
1517
- * @param ctx - host context carrying the session persistence service.
1518
- * @param childSession - the dsh subagent session created for this delegation.
1519
- * @param homeDir - the `kimi` harness's scoped home.
1520
- * @param kimiSessionId - the kimi session to mirror; omitted mirrors the most
1521
- * recent session (a one-shot `kimi -p` run always creates a fresh one).
1522
- * @param fromLines - transcript lines already mirrored into the child session
1523
- * (0 for the first round); only lines after this offset are appended.
1524
- * @returns the new total and the newly mirrored lines' texts.
2075
+ * Parse a session's wire log into transcript lines plus every usage record.
2076
+ * @param sessionDir - the session directory.
2077
+ * @returns the transcript.
1525
2078
  */
1526
- async function mirrorKimiSessionDelta(ctx, childSession, homeDir, kimiSessionId, fromLines = 0, options) {
1527
- let workspaces;
2079
+ async function readKimiTranscript(sessionDir) {
2080
+ const sessionId = sessionDir.split("session_").pop() ?? sessionDir;
2081
+ const lines = [];
2082
+ const usageRecords = [];
2083
+ const closedSteps = /* @__PURE__ */ new Set();
2084
+ let model;
2085
+ let modelTurn;
2086
+ let lineModel;
2087
+ let lineConfig;
2088
+ const push = (line) => {
2089
+ lines.push({
2090
+ ...line,
2091
+ ...lineModel === void 0 ? {} : { model: lineModel },
2092
+ ...line.kind !== "user" && lineConfig !== void 0 ? { requestConfig: { ...lineConfig } } : {}
2093
+ });
2094
+ };
2095
+ let turn = 0;
2096
+ let turnSeen = false;
2097
+ const callsByUuid = /* @__PURE__ */ new Map();
2098
+ const callsByToolCallId = /* @__PURE__ */ new Map();
2099
+ let text;
1528
2100
  try {
1529
- workspaces = await readdir(join(homeDir, "sessions"));
1530
- } catch {
1531
- return {
1532
- total: 0,
1533
- texts: []
2101
+ text = await readFile(wirePath(sessionDir), "utf8");
2102
+ } catch (error) {
2103
+ if (error.code === "ENOENT") return {
2104
+ sessionId,
2105
+ lines,
2106
+ usageRecords,
2107
+ closedSteps
1534
2108
  };
2109
+ throw error;
1535
2110
  }
1536
- let transcript;
1537
- if (kimiSessionId !== void 0) {
1538
- const dirName = kimiSessionId.startsWith("session_") ? kimiSessionId : `session_${kimiSessionId}`;
1539
- for (const workspace of workspaces) {
1540
- const dir = join(homeDir, "sessions", workspace, dirName);
1541
- try {
1542
- transcript = await readKimiTranscript(dir);
1543
- } catch {
1544
- continue;
1545
- }
1546
- if (transcript.lines.length > 0) break;
2111
+ for (const raw of text.split("\n")) {
2112
+ const line = raw.trim();
2113
+ if (line === "") continue;
2114
+ let event;
2115
+ try {
2116
+ event = JSON.parse(line);
2117
+ } catch {
2118
+ continue;
1547
2119
  }
1548
- } else {
1549
- let newest;
1550
- for (const workspace of workspaces) {
1551
- let sessions;
1552
- try {
1553
- sessions = await readdir(join(homeDir, "sessions", workspace));
1554
- } catch {
1555
- continue;
1556
- }
1557
- for (const name of sessions) {
1558
- if (!name.startsWith("session_")) continue;
1559
- const dir = join(homeDir, "sessions", workspace, name);
1560
- const meta = await stat(dir);
1561
- if (newest === void 0 || meta.mtimeMs > newest.time) newest = {
1562
- dir,
1563
- time: meta.mtimeMs
2120
+ if (event.type === "usage.record") {
2121
+ if (typeof event.model === "string" && event.model !== "") {
2122
+ model = lineModel = event.model;
2123
+ modelTurn = turn || 1;
2124
+ if (lineConfig !== void 0) lineConfig = {
2125
+ ...lineConfig,
2126
+ model: event.model
1564
2127
  };
1565
2128
  }
1566
- }
1567
- if (newest !== void 0) try {
1568
- transcript = await readKimiTranscript(newest.dir);
1569
- } catch {
1570
- transcript = void 0;
1571
- }
1572
- }
1573
- if (transcript === void 0 || transcript.lines.length === 0) return {
1574
- total: 0,
1575
- texts: []
1576
- };
1577
- const newTotal = transcript.lines.length;
1578
- const delta = transcript.lines.slice(fromLines);
1579
- const observedModel = options?.turn === void 0 || transcript.modelTurn === options.turn ? transcript.model : void 0;
1580
- const toolCalls = roundToolCalls(transcript.lines, options?.turn);
1581
- const deltaUsage = sumUsageRecords(transcript.usageRecords.filter((record) => record.line >= fromLines && record.line <= newTotal));
1582
- let lastAssistant = -1;
1583
- for (let index = 0; index < delta.length; index += 1) {
1584
- const line = delta[index];
1585
- if (line !== void 0 && line.kind !== "user" && line.kind !== "tool") lastAssistant = index;
1586
- }
1587
- const steps = /* @__PURE__ */ new Map();
1588
- const openCalls = /* @__PURE__ */ new Map();
1589
- const settledCalls = /* @__PURE__ */ new Set();
1590
- for (const event of childSession.snapshotEvents()) {
1591
- const data = event.data;
1592
- if (typeof data.turn === "number" && typeof data.step === "number") steps.set(data.turn, Math.max(steps.get(data.turn) ?? 0, data.step + 1));
1593
- if (event.type === "tool/call") {
1594
- const call = event.data;
1595
- openCalls.set(call.callId, {
1596
- turn: call.turn,
1597
- step: call.step,
1598
- seq: event.seq
2129
+ const usage = usageFromWire(event.usage);
2130
+ if (usage !== void 0) usageRecords.push({
2131
+ line: lines.length,
2132
+ usage
1599
2133
  });
1600
- } else if (event.type === "tool/result") {
1601
- const message = event.data.message;
1602
- const id = message?.toolCallId ?? message?.content?.[0]?.toolCallId;
1603
- if (id !== void 0) {
1604
- openCalls.delete(id);
1605
- settledCalls.add(id);
1606
- }
2134
+ continue;
1607
2135
  }
1608
- }
1609
- /**
1610
- * The next sequential fold step in one turn: the running counter, shifted
1611
- * past every step an in-flight stream reserved (a stream's snapshots and
1612
- * its completion fold live at the reserved coordinate — a sequential fold
1613
- * landing there would write a second step/start, which the assembler
1614
- * rejects).
1615
- */
1616
- const nextFoldStep = (turn) => {
1617
- let step = steps.get(turn) ?? 1;
1618
- const reserved = options?.streams?.reservedSteps(turn);
1619
- if (reserved !== void 0 && reserved.length > 0) {
1620
- const skip = new Set(reserved);
1621
- while (skip.has(step)) step += 1;
2136
+ if (event.type === "llm.request") {
2137
+ lineModel = typeof event.model === "string" && event.model !== "" ? event.model : void 0;
2138
+ if (typeof event.model === "string" && event.model !== "") {
2139
+ model = event.model;
2140
+ modelTurn = turn || 1;
2141
+ }
2142
+ lineConfig = lineModel === void 0 ? void 0 : {
2143
+ model: lineModel,
2144
+ ...typeof event.thinkingEffort === "string" && event.thinkingEffort !== "" ? { reasoningEffort: event.thinkingEffort } : {},
2145
+ ...typeof event.maxTokens === "number" && Number.isFinite(event.maxTokens) ? { maxTokens: event.maxTokens } : {}
2146
+ };
2147
+ continue;
1622
2148
  }
1623
- steps.set(turn, step + 1);
1624
- return step;
1625
- };
1626
- /** Whether this pass attached its window's usage to a folded message. */
1627
- let usageAttached = false;
1628
- const texts = [];
1629
- for (let index = 0; index < fromLines && index < transcript.lines.length; index += 1) {
1630
- const line = transcript.lines[index];
1631
- if (line === void 0 || line.kind !== "tool" || line.result === void 0) continue;
1632
- const call = openCalls.get(line.id);
1633
- if (call === void 0) continue;
1634
- openCalls.delete(line.id);
1635
- settledCalls.add(line.id);
1636
- childSession.append("tool/result", {
1637
- turn: call.turn,
1638
- step: call.step,
1639
- message: createToolResultMessage({
1640
- callId: line.id,
1641
- content: [{
1642
- type: "text",
1643
- text: line.result
1644
- }],
1645
- isError: false
1646
- })
1647
- }, {
1648
- surfaceOp: "append",
1649
- sourceEventSeqs: [call.seq]
1650
- });
1651
- texts.push(kimiLineProgressText(line));
1652
- }
1653
- if (delta.length === 0) {
1654
- if (texts.length > 0) await persistChildSession(ctx, childSession);
1655
- return {
1656
- total: newTotal,
1657
- texts,
1658
- ...deltaUsage !== void 0 ? { usage: deltaUsage } : {},
1659
- ...usageAttached ? { usageAttached: true } : {},
1660
- ...observedModel !== void 0 ? { model: observedModel } : {},
1661
- ...toolCalls !== void 0 ? { toolCalls } : {}
1662
- };
1663
- }
1664
- for (let index = 0; index < delta.length; index += 1) {
1665
- const line = delta[index];
1666
- if (line === void 0) continue;
1667
- const turn = line.turn;
1668
- if (line.kind === "user") {
1669
- if (userAlreadyAppended(childSession, turn, line.text)) continue;
1670
- steps.set(turn, 1);
1671
- childSession.append("user/message", userEvent(line.text), { surfaceOp: "append" });
1672
- texts.push(line.text);
1673
- } else if (line.kind === "tool") {
1674
- const reserved = options?.streams?.completeTool?.(line);
1675
- const step = reserved?.step ?? nextFoldStep(turn);
1676
- steps.set(turn, Math.max(steps.get(turn) ?? 1, step + 1));
1677
- if (reserved?.opened !== true) childSession.append("step/start", {
1678
- turn,
1679
- step
2149
+ if (event.type === "turn.prompt") {
2150
+ lineModel = void 0;
2151
+ turnSeen = true;
2152
+ turn += 1;
2153
+ const prompt = textOf(event.input ?? []);
2154
+ if (prompt.trim() !== "" && !isSystemReminder(prompt)) push({
2155
+ kind: "user",
2156
+ text: prompt,
2157
+ turn: turn === 0 ? 1 : turn
1680
2158
  });
1681
- const call = childSession.append("tool/call", {
1682
- turn,
1683
- step,
1684
- callId: line.id,
1685
- name: line.name,
1686
- arguments: line.args ?? ""
2159
+ continue;
2160
+ }
2161
+ if (event.type === "context.append_message") {
2162
+ const message = event.message;
2163
+ const role = message?.role;
2164
+ const textContent = textOf(message?.content ?? []);
2165
+ if (role === "user" && textContent.trim() !== "" && !isSystemReminder(textContent)) {
2166
+ const last = lines[lines.length - 1];
2167
+ if (turnSeen && last?.kind === "user" && last.text === textContent) continue;
2168
+ turnSeen = true;
2169
+ turn += 1;
2170
+ lineModel = void 0;
2171
+ push({
2172
+ kind: "user",
2173
+ text: textContent,
2174
+ turn: turn === 0 ? 1 : turn
2175
+ });
2176
+ } else if (role === "assistant" && textContent.trim() !== "") push({
2177
+ kind: "assistant",
2178
+ text: textContent,
2179
+ turn: turn === 0 ? 1 : turn
1687
2180
  });
1688
- texts.push(kimiLineProgressText(line));
1689
- if (line.result !== void 0) childSession.append("tool/result", {
1690
- turn,
1691
- step,
1692
- message: createToolResultMessage({
1693
- callId: line.id,
1694
- content: [{
1695
- type: "text",
1696
- text: line.result
1697
- }],
1698
- isError: false
1699
- })
1700
- }, {
1701
- surfaceOp: "append",
1702
- sourceEventSeqs: [call.seq]
2181
+ continue;
2182
+ }
2183
+ if (event.type === "context.append_loop_event") {
2184
+ const loop = event.event;
2185
+ if (loop === void 0) continue;
2186
+ const loopTurn = typeof loop.turnId === "number" ? loop.turnId + 1 : void 0;
2187
+ if (loopTurn !== void 0) {
2188
+ turn = Math.max(turn, loopTurn);
2189
+ turnSeen = true;
2190
+ }
2191
+ const wireStep = typeof loop.step === "number" ? loop.step : void 0;
2192
+ const part = loop.part;
2193
+ const lineTurn = turn === 0 ? 1 : turn;
2194
+ if (loop.type === "step.end" && wireStep !== void 0) closedSteps.add(`${lineTurn}:${wireStep}`);
2195
+ if (part?.type === "text" && part.text !== void 0 && part.text.trim() !== "") push({
2196
+ kind: "assistant",
2197
+ text: part.text,
2198
+ turn: lineTurn,
2199
+ ...wireStep === void 0 ? {} : { wireStep }
1703
2200
  });
1704
- childSession.append("step/end", {
1705
- turn,
1706
- step
2201
+ else if (part?.type === "think" && part.think !== void 0 && part.think.trim() !== "") push({
2202
+ kind: "think",
2203
+ text: part.think,
2204
+ turn: lineTurn,
2205
+ ...wireStep === void 0 ? {} : { wireStep }
1707
2206
  });
1708
- } else {
1709
- const completion = options?.streams?.completeStream(line);
1710
- if (completion !== void 0) {
1711
- steps.set(turn, Math.max(steps.get(turn) ?? 1, completion.step + 1));
1712
- if (!completion.opened) childSession.append("step/start", {
1713
- turn,
1714
- step: completion.step
1715
- });
1716
- if (index === lastAssistant && deltaUsage !== void 0) usageAttached = true;
1717
- childSession.append("assistant/message", {
1718
- turn,
1719
- step: completion.step,
1720
- message: assistantEvent(lineBlocks(line), line.model),
1721
- stream: [],
1722
- ...index === lastAssistant && deltaUsage !== void 0 ? { usage: deltaUsage } : {}
1723
- }, { surfaceOp: "append" });
1724
- childSession.append("step/end", {
1725
- turn,
1726
- step: completion.step
2207
+ else if (loop.type === "tool.call") {
2208
+ const call = loop;
2209
+ const name = call.toolCall?.name ?? call.name ?? "tool";
2210
+ const args = argsOf(name, call.toolCall?.args ?? call.args);
2211
+ const lineIndex = lines.length;
2212
+ push({
2213
+ kind: "tool",
2214
+ id: typeof call.toolCallId === "string" ? call.toolCallId : typeof call.uuid === "string" ? call.uuid : `kimi-tool-${lineIndex}`,
2215
+ name,
2216
+ ...args === void 0 ? {} : { args },
2217
+ turn: lineTurn,
2218
+ ...wireStep === void 0 ? {} : { wireStep }
1727
2219
  });
1728
- texts.push(kimiLineProgressText(line));
1729
- continue;
1730
- }
1731
- const step = nextFoldStep(turn);
1732
- childSession.append("step/start", {
1733
- turn,
1734
- step
1735
- });
1736
- if (index === lastAssistant && deltaUsage !== void 0) usageAttached = true;
1737
- childSession.append("assistant/message", {
1738
- turn,
1739
- step,
1740
- message: assistantEvent(lineBlocks(line), line.model),
1741
- stream: [],
1742
- ...index === lastAssistant && deltaUsage !== void 0 ? { usage: deltaUsage } : {}
1743
- }, { surfaceOp: "append" });
1744
- childSession.append("step/end", {
1745
- turn,
1746
- step
2220
+ if (typeof call.uuid === "string") callsByUuid.set(call.uuid, lineIndex);
2221
+ if (typeof call.toolCallId === "string") callsByToolCallId.set(call.toolCallId, lineIndex);
2222
+ } else if (loop.type === "tool.result") {
2223
+ const result = loop;
2224
+ const output = result.result?.output;
2225
+ let target = typeof result.parentUuid === "string" ? callsByUuid.get(result.parentUuid) : void 0;
2226
+ if (target === void 0 && typeof result.toolCallId === "string") target = callsByToolCallId.get(result.toolCallId);
2227
+ if (target === void 0) {
2228
+ for (let index = lines.length - 1; index >= 0; index -= 1) if (lines[index]?.kind === "tool") {
2229
+ target = index;
2230
+ break;
2231
+ }
2232
+ }
2233
+ if (output !== void 0 && output.trim() !== "" && target !== void 0) {
2234
+ const current = lines[target];
2235
+ if (current !== void 0 && current.kind === "tool") lines[target] = {
2236
+ ...current,
2237
+ result: output
2238
+ };
2239
+ }
2240
+ } else if (loop.type === "content.part" && part?.type !== void 0 && part.type !== "text" && part.type !== "think") push({
2241
+ kind: "assistant",
2242
+ text: `[未支持的内容类型 ${part.type}]`,
2243
+ turn: lineTurn
1747
2244
  });
1748
- texts.push(kimiLineProgressText(line));
1749
2245
  }
1750
2246
  }
1751
- await persistChildSession(ctx, childSession);
1752
2247
  return {
1753
- total: newTotal,
1754
- texts,
1755
- ...deltaUsage !== void 0 ? { usage: deltaUsage } : {},
1756
- ...usageAttached ? { usageAttached: true } : {},
1757
- ...observedModel !== void 0 ? { model: observedModel } : {},
1758
- ...toolCalls !== void 0 ? { toolCalls } : {}
2248
+ sessionId,
2249
+ lines,
2250
+ usageRecords,
2251
+ closedSteps,
2252
+ ...model === void 0 ? {} : { model },
2253
+ ...modelTurn === void 0 ? {} : { modelTurn }
1759
2254
  };
1760
2255
  }
1761
2256
  /**
1762
- * Count one round's tool calls out of a kimi transcript, keyed by the name the
1763
- * wire gave each tool. No turn named, or no `tool.call` line carrying it,
1764
- * yields undefined — a round reports nothing rather than a zero it did not
1765
- * observe. Reads only the transcript lines the mirror already folded.
1766
- * @param lines - the whole session transcript.
1767
- * @param turn - the round's 1-based turn, when the caller named one.
1768
- * @returns the round's accounting, or undefined.
2257
+ * Map a wire `usage.record` payload onto the shared usage contract. kimi
2258
+ * reports input (uncached), cache read, cache creation, and output; the
2259
+ * creation counter is its cache-write analog. Non-numeric or missing
2260
+ * counters are dropped so the record stays usable when kimi omits a field;
2261
+ * a reported cache-read counter is always written (zero included), so a
2262
+ * reader can tell "no hits" from "not reported".
2263
+ * @param raw - the event's `usage` value.
2264
+ * @returns the shared usage record, or undefined when nothing was reported.
1769
2265
  */
1770
- function roundToolCalls(lines, turn) {
1771
- if (turn === void 0) return void 0;
1772
- const byName = {};
1773
- let count = 0;
1774
- for (const line of lines) {
1775
- if (line.kind !== "tool" || line.turn !== turn) continue;
1776
- count += 1;
1777
- byName[line.name] = (byName[line.name] ?? 0) + 1;
1778
- }
1779
- return count === 0 ? void 0 : {
1780
- count,
1781
- byName
2266
+ function usageFromWire(raw) {
2267
+ const counters = raw;
2268
+ if (counters === void 0 || typeof counters !== "object") return void 0;
2269
+ const num = (value) => {
2270
+ const n = Number(value);
2271
+ return Number.isFinite(n) ? n : void 0;
2272
+ };
2273
+ const input = num(counters.inputOther);
2274
+ const output = num(counters.output);
2275
+ const cacheRead = num(counters.inputCacheRead);
2276
+ const cacheWrite = num(counters.inputCacheCreation);
2277
+ if (input === void 0 && output === void 0 && cacheRead === void 0 && cacheWrite === void 0) return;
2278
+ const usage = {
2279
+ inputTokens: input ?? 0,
2280
+ outputTokens: output ?? 0
1782
2281
  };
2282
+ if (cacheRead !== void 0) usage.cacheReadTokens = cacheRead;
2283
+ if (cacheWrite !== void 0 && cacheWrite > 0) usage.cacheWriteTokens = cacheWrite;
2284
+ return usage;
1783
2285
  }
1784
2286
  /**
1785
- * Whether the turn already carries a user/message with this exact text (the
1786
- * live driver's round-start append). Scoped to the events at or after this
1787
- * turn's turn/start so identical prompts across rounds stay independent.
2287
+ * Sum per-request usage records into one round total. Each `usage.record` is
2288
+ * a single LLM request's accounting, so a round's totals are the sum of the
2289
+ * records inside it.
2290
+ * @param records - the usage records to add.
2291
+ * @returns the summed usage, or undefined when the list is empty.
1788
2292
  */
1789
- function userAlreadyAppended(childSession, turn, text) {
1790
- let turnStartSeq = -1;
1791
- for (const event of childSession.snapshotEvents()) if (event.type === "turn/start" && event.data.turn === turn) turnStartSeq = event.seq;
1792
- if (turnStartSeq < 0) return false;
1793
- for (const event of childSession.snapshotEvents()) {
1794
- if (event.seq < turnStartSeq || event.type !== "user/message") continue;
1795
- if ((event.data.content ?? []).map((block) => block.text ?? "").join("") === text) return true;
2293
+ function sumUsageRecords(records) {
2294
+ let input = 0;
2295
+ let output = 0;
2296
+ let cacheRead = 0;
2297
+ let cacheWrite = 0;
2298
+ for (const record of records) {
2299
+ input += record.usage.inputTokens ?? 0;
2300
+ output += record.usage.outputTokens ?? 0;
2301
+ cacheRead += record.usage.cacheReadTokens ?? 0;
2302
+ cacheWrite += record.usage.cacheWriteTokens ?? 0;
1796
2303
  }
1797
- return false;
2304
+ if (input === 0 && output === 0 && cacheRead === 0 && cacheWrite === 0) return void 0;
2305
+ const usage = {
2306
+ inputTokens: input,
2307
+ outputTokens: output
2308
+ };
2309
+ if (cacheRead > 0) usage.cacheReadTokens = cacheRead;
2310
+ if (cacheWrite > 0) usage.cacheWriteTokens = cacheWrite;
2311
+ return usage;
1798
2312
  }
1799
- //#endregion
1800
- //#region lib/types/credential-guard.js
1801
2313
  /**
1802
- * Kimi credential sentinel. The kimi CLI occasionally wipes
1803
- * `credentials/kimi-code.json` into an empty shell (both tokens zero-length)
1804
- * on its own failure paths — observed twice in production, each time killing
1805
- * every later delegation with "Authentication required" until someone
1806
- * restored the file by hand. The sentinel:
1807
- *
1808
- * - backs the file up whenever a VALID credential is observed (the backup
1809
- * lives inside `credentials/` so the family's logout — a directory delete —
1810
- * clears it too);
1811
- * - restores the backup when the file is an exact empty shell (both tokens
1812
- * empty strings) — the only corruption signature we trust;
1813
- * - logs every state transition with a process snapshot, so the next wipe
1814
- * can be attributed (which process, at which moment).
1815
- *
1816
- * Restore is deliberately narrow: a revoked-elsewhere credential never takes
1817
- * the empty-shell shape, and a stale backup at worst produces one more loud
1818
- * 401, never silent misbehavior.
1819
- * @module @khorsheed/dsh-local-agent-kimi/credential-guard
2314
+ * Merge two usage windows into one running total — the delegation run's
2315
+ * per-round accumulator: each mirror pass (live poll, settle) returns its
2316
+ * own window's accounting, and the disjoint windows sum to the round's
2317
+ * total. `undefined` operands pass through, so callers accumulate without
2318
+ * absence checks.
2319
+ * @param left - the running total, or undefined before the first window.
2320
+ * @param right - the next window's usage, or undefined when it had none.
2321
+ * @returns the merged total, or undefined when both operands are absent.
1820
2322
  */
1821
- /** The credential file inside the scoped home, and its sentinel backup. */
1822
- const CREDENTIAL_REL = join("credentials", "kimi-code.json");
1823
- const BACKUP_REL = `${CREDENTIAL_REL}.bak`;
1824
- /** Last observed state signature per home (`${mtimeMs}:${kind}`) — transition logging. */
1825
- const lastSeen = /* @__PURE__ */ new Map();
1826
- /** A one-line process snapshot for wipe attribution (kimi/dsh processes only). */
1827
- function processSnapshot() {
1828
- return new Promise((resolve) => {
1829
- execFile("ps", ["-eo", "pid,ppid,etime,command"], { timeout: 5e3 }, (error, stdout) => {
1830
- if (error !== null) {
1831
- resolve(`(ps failed: ${error.message})`);
1832
- return;
1833
- }
1834
- resolve(stdout.split("\n").filter((line) => /\bkimi\b|dsh/.test(line) && !line.includes("grep")).slice(0, 12).join(" ; "));
1835
- });
1836
- });
1837
- }
1838
- /** Classify the credential file's current content. */
1839
- async function observe(file) {
1840
- let raw;
1841
- try {
1842
- raw = await readFile(file, "utf8");
1843
- } catch {
1844
- return {
1845
- state: "absent",
1846
- mtimeMs: 0
1847
- };
1848
- }
1849
- let mtimeMs = 0;
1850
- try {
1851
- mtimeMs = (await stat(file)).mtimeMs;
1852
- } catch {}
1853
- try {
1854
- const parsed = JSON.parse(raw);
1855
- const empty = (v) => typeof v === "string" && v.length === 0;
1856
- if (empty(parsed.access_token) && empty(parsed.refresh_token)) return {
1857
- state: "empty-shell",
1858
- mtimeMs
1859
- };
1860
- return {
1861
- state: "valid",
1862
- mtimeMs
1863
- };
1864
- } catch {
1865
- return {
1866
- state: "absent",
1867
- mtimeMs
1868
- };
1869
- }
2323
+ function addTokenUsage(left, right) {
2324
+ if (left === void 0) return right;
2325
+ if (right === void 0) return left;
2326
+ return sumUsageRecords([{
2327
+ line: 0,
2328
+ usage: left
2329
+ }, {
2330
+ line: 0,
2331
+ usage: right
2332
+ }]);
1870
2333
  }
1871
- /**
1872
- * Guard the scoped home's kimi credential: back up a valid file, restore the
1873
- * backup over an empty shell, log transitions.
1874
- * @param homeDir - the `kimi` harness's scoped home.
1875
- * @param log - warn channel (plugin logger when available).
1876
- * @returns true when a usable credential exists after the guard ran.
1877
- */
1878
- async function guardKimiCredential(homeDir, log) {
1879
- const file = join(homeDir, CREDENTIAL_REL);
1880
- const backup = join(homeDir, BACKUP_REL);
1881
- const { state, mtimeMs } = await observe(file);
1882
- const signature = `${mtimeMs}:${state}`;
1883
- const previous = lastSeen.get(homeDir);
1884
- lastSeen.set(homeDir, signature);
1885
- if (state === "valid") {
1886
- if (previous !== void 0 && previous !== signature) log?.(`local-agent-kimi: credential refreshed (mtime ${new Date(mtimeMs).toISOString()}), snapshotting the backup`);
1887
- await writeFile(backup, await readFile(file), { mode: 384 });
1888
- return true;
1889
- }
1890
- if (state === "empty-shell") {
1891
- const snapshot = await processSnapshot();
1892
- log?.(`local-agent-kimi: CREDENTIAL EMPTY SHELL detected (mtime ${new Date(mtimeMs).toISOString()}); previous state ${previous ?? "unknown"}; processes: ${snapshot}`);
1893
- let backupState = "absent";
1894
- try {
1895
- backupState = (await observe(backup)).state;
1896
- } catch {}
1897
- if (backupState === "valid") {
1898
- await copyFile(backup, file);
1899
- log?.("local-agent-kimi: credential restored from the sentinel backup — the next delegation should succeed");
1900
- return true;
1901
- }
1902
- log?.("local-agent-kimi: no usable backup to restore; the harness needs a fresh login");
1903
- return false;
2334
+ /** Render a transcript as command-reply text. */
2335
+ function renderTranscript(transcript) {
2336
+ if (transcript.lines.length === 0) return `kimi session ${transcript.sessionId}: no readable transcript yet.`;
2337
+ const parts = [`kimi session ${transcript.sessionId}:`];
2338
+ for (const line of transcript.lines) if (line.kind === "user") parts.push(`\n用户: ${line.text}`);
2339
+ else if (line.kind === "assistant") parts.push(`\nkimi: ${line.text}`);
2340
+ else if (line.kind === "think") parts.push(`\n[思考] ${line.text}`);
2341
+ else {
2342
+ const call = `[工具 ${line.name}]${line.args !== void 0 ? ` ${line.args}` : ""}`;
2343
+ parts.push(`\n ↳ ${call}${line.result !== void 0 ? ` → ${line.result}` : ""}`);
1904
2344
  }
1905
- return false;
2345
+ return parts.join("\n");
1906
2346
  }
1907
2347
  //#endregion
1908
- //#region lib/types/provision.js
1909
- /**
1910
- * Kimi Code scoped-home provisioning: a working `config.toml` (provider and
1911
- * model definitions) is required for the ACP server to authenticate. The
1912
- * scoped home is provisioned once — from the user's own config, redacted —
1913
- * so the scoped setup mirrors the user's models while carrying no secrets.
1914
- * Sign-out removes the credentials and OAuth cache so a later login can
1915
- * authorize a different account. The agent preset the delegation tool row
1916
- * needs is bootstrapped into the user preset root on first boot, so
1917
- * installing the bundle needs no manual copy step.
1918
- * @module @khorsheed/dsh-local-agent-kimi/provision
1919
- */
1920
- const REAL_CONFIG = join(homedir(), ".kimi-code", "config.toml");
1921
- /** Credentials directory inside the scoped home; its presence marks auth. */
1922
- const CREDENTIALS_DIR$1 = "credentials";
1923
- /** OAuth token cache directory inside the scoped home. */
1924
- const OAUTH_DIR = "oauth";
1925
- /**
1926
- * The managed provider's endpoint, written into a fresh scoped config and the
1927
- * baseline the eval snapshot compares against: a scoped config still pointing
1928
- * here routes through kimi's own service, i.e. no custom endpoint is in force.
1929
- */
1930
- const KIMI_MANAGED_BASE_URL = "https://api.kimi.com/coding/v1";
1931
- /** Minimal managed config for a fresh home with no user config to mirror. */
1932
- function minimalConfig(model, thinkingEffort) {
1933
- return `default_model = "${model}"
1934
-
1935
- [thinking]
1936
- enabled = true
1937
- effort = "${thinkingEffort}"
1938
-
1939
- [providers."managed:kimi-code"]
1940
- base_url = "${KIMI_MANAGED_BASE_URL}"
1941
- type = "kimi"
1942
- api_key = ""
1943
-
1944
- [providers."managed:kimi-code".oauth]
1945
- storage = "file"
1946
- key = "oauth/kimi-code"
1947
-
1948
- [models."${model}"]
1949
- provider = "managed:kimi-code"
1950
- model = "${model.split("/").pop()}"
1951
- max_context_size = 1048576
1952
- capabilities = [ "thinking", "always_thinking", "image_in", "video_in", "tool_use" ]
1953
- display_name = "${model}"
1954
- support_efforts = [ "low", "high", "max" ]
1955
- default_effort = "${thinkingEffort}"
1956
- `;
1957
- }
1958
- /**
1959
- * Redact every api_key value so the scoped copy carries no secrets. The
1960
- * kimi-managed provider keeps an empty api_key (its auth routes through
1961
- * oauth), which is exactly what an empty value means here.
1962
- * @param config - the user's config text.
1963
- * @returns the same config with all api_key values blanked.
1964
- */
1965
- function redactApiKeys(config) {
1966
- return config.replace(/api_key\s*=\s*"[^"]*"/gu, "api_key = \"\"");
1967
- }
2348
+ //#region lib/types/session-mirror.js
1968
2349
  /**
1969
- * Provision `<homeDir>/config.toml` unless one already exists. A present
1970
- * config is respected untouched (a person may have edited it); otherwise the
1971
- * user's own real config is copied redacted so the scoped home mirrors the
1972
- * user's models; with no real config, a minimal kimi-managed config is
1973
- * written. `thinkingEffort` feeds the minimal config's `[thinking] effort`
1974
- * (and the model's `default_effort`) — it applies at provision time only,
1975
- * never retroactively to an existing config.
1976
- * @param homeDir - the `kimi` harness's scoped home.
1977
- * @param model - the kimi-managed model id for a fresh minimal config.
1978
- * @param thinkingEffort - the reasoning effort a fresh minimal config pins.
1979
- * @returns true when a config was written, false when one already existed.
2350
+ * Mirror a kimi session's transcript into a dsh subagent session, so the
2351
+ * delegation shows up in the standard 子代理 surface and opening the child
2352
+ * session shows the full conversation (user prompts, kimi replies, thinking,
2353
+ * and tool activity with arguments and results) instead of a silent one-shot
2354
+ * call.
2355
+ * @module @khorsheed/dsh-local-agent-kimi/session-mirror
1980
2356
  */
1981
- async function provisionKimiConfig(homeDir, model, thinkingEffort) {
1982
- try {
1983
- await readFile(join(homeDir, "config.toml"));
1984
- return false;
1985
- } catch (error) {
1986
- if (error.code !== "ENOENT") throw error;
1987
- }
1988
- let config = minimalConfig(model, thinkingEffort);
1989
- try {
1990
- config = redactApiKeys(await readFile(REAL_CONFIG, "utf8"));
1991
- } catch (error) {
1992
- if (error.code !== "ENOENT") throw error;
1993
- }
1994
- await writeFile(join(homeDir, "config.toml"), config, { flag: "wx" });
1995
- return true;
1996
- }
1997
- /** The managed-service provider section provisioned into the scoped config. */
1998
- const MANAGED_PROVIDER_SECTION = "providers.\"managed:kimi-code\"";
1999
2357
  /**
2000
- * Read the scoped config's effective LLM endpoint: the
2001
- * `[providers."managed:kimi-code"].base_url` key, if present. A missing or
2002
- * malformed config yields undefined (the CLI falls back to its default
2003
- * endpoint). Used only for diagnostics — the config itself is authoritative,
2004
- * and a user-edited value is respected untouched.
2005
- * @param homeDir - the `kimi` harness's scoped home.
2006
- * @returns the configured base URL, or undefined when absent/unreadable.
2358
+ * The synthesized result for a call whose result the wire never recorded
2359
+ * (a settle-pass fold, or a live reservation kimi never logged). The
2360
+ * released-log contract resolves every advertised call before its step
2361
+ * closes, so an interrupted call folds with this error result.
2007
2362
  */
2008
- async function readKimiBaseUrl(homeDir) {
2009
- let text;
2010
- try {
2011
- text = await readFile(join(homeDir, "config.toml"), "utf8");
2012
- } catch {
2013
- return;
2014
- }
2015
- let section = "";
2016
- for (const rawLine of text.split("\n")) {
2017
- const line = rawLine.trim();
2018
- const header = /^\[+([^\]]+)\]+$/.exec(line);
2019
- if (header !== null) {
2020
- section = header[1];
2021
- continue;
2022
- }
2023
- if (section === MANAGED_PROVIDER_SECTION) {
2024
- const match = /^base_url\s*=\s*"([^"]*)"$/.exec(line);
2025
- if (match !== null) return match[1];
2363
+ const KIMI_INTERRUPTED_TOOL_RESULT = "(工具调用被中断,未能记录返回结果)";
2364
+ /** One user-role message event. */
2365
+ function userEvent(text) {
2366
+ return createUserMessage({
2367
+ content: [{
2368
+ type: "text",
2369
+ text
2370
+ }],
2371
+ source: { kind: "user" }
2372
+ });
2373
+ }
2374
+ /** One assistant-role message event, attributed to the kimi route. */
2375
+ function assistantEvent(blocks, model) {
2376
+ return createAssistantMessage({
2377
+ content: blocks,
2378
+ source: {
2379
+ provider: "kimi-cli",
2380
+ model: model ?? "unobserved"
2026
2381
  }
2027
- }
2382
+ });
2028
2383
  }
2029
2384
  /**
2030
- * Read the scoped config's configured default model: the top-level
2031
- * `default_model` key — the selection a plain `kimi -p` round runs with. A
2032
- * missing or malformed config, or no key, yields undefined — the honest
2033
- * "unknown", never a guessed default. Read-only: the config is authoritative
2034
- * and a user-edited value is reported as-is.
2035
- * @param homeDir - the `kimi` harness's scoped home.
2036
- * @returns the configured model identifier, or undefined when none is set.
2385
+ * The step the next fold pass would assign in one turn: the smallest step
2386
+ * nothing holds (never written, never reserved at call time — callers skip
2387
+ * their own reservations on top). Filling the smallest free step keeps the
2388
+ * stored log dense: the released-log reader admits only dense, strictly
2389
+ * sequential steps, and a reservation released mid-round (its content rode an
2390
+ * earlier fold) leaves a gap only the next first-free write fills.
2391
+ * @param childSession - the run's child session.
2392
+ * @param turn - the round's turn number.
2393
+ * @returns the smallest free step in the turn.
2037
2394
  */
2038
- async function readKimiDefaultModel(homeDir) {
2039
- let text;
2040
- try {
2041
- text = await readFile(join(homeDir, "config.toml"), "utf8");
2042
- } catch {
2043
- return;
2044
- }
2045
- let section = "";
2046
- for (const rawLine of text.split("\n")) {
2047
- const line = rawLine.trim();
2048
- const header = /^\[+([^\]]+)\]+$/.exec(line);
2049
- if (header !== null) {
2050
- section = header[1];
2051
- continue;
2052
- }
2053
- if (section !== "") continue;
2054
- const key = /^(\w+)\s*=\s*"([^"]*)"$/.exec(line);
2055
- if (key !== null && key[1] === "default_model") return key[2];
2395
+ function nextKimiSessionStep(childSession, turn) {
2396
+ const taken = /* @__PURE__ */ new Set();
2397
+ for (const event of childSession.snapshotEvents()) {
2398
+ const data = event.data;
2399
+ if (data.turn === turn && typeof data.step === "number") taken.add(data.step);
2056
2400
  }
2401
+ let next = 1;
2402
+ while (taken.has(next)) next += 1;
2403
+ return next;
2057
2404
  }
2058
2405
  /**
2059
- * List the model identifiers the scoped config DEFINES: every
2060
- * `[models."…"]` table key (bare `[models.x]` keys included). This is the
2061
- * pickable vocabulary a model surface can offer without hardcoding a catalog —
2062
- * the same table `-m` resolves against. A missing or malformed config yields
2063
- * an empty list. Read-only: the config is authoritative.
2064
- * @param homeDir - the `kimi` harness's scoped home.
2065
- * @returns the defined model identifiers, in file order, deduplicated.
2406
+ * The step a durable write takes: one past the highest step this turn has
2407
+ * opened in the log. The released-log reader admits steps only in order —
2408
+ * one past the last, never below it — so the coordinate is decided when the
2409
+ * step is written, never reserved ahead (a reservation filled later lands
2410
+ * below a higher step already written: a gap, then a step out of order).
2411
+ * Stream and tool reservations only key the live preview.
2066
2412
  */
2067
- async function listKimiConfigModels(homeDir) {
2068
- let text;
2069
- try {
2070
- text = await readFile(join(homeDir, "config.toml"), "utf8");
2071
- } catch {
2072
- return [];
2073
- }
2074
- const models = [];
2075
- for (const rawLine of text.split("\n")) {
2076
- const header = /^\[+([^\]]+)\]+$/.exec(rawLine.trim());
2077
- if (header === null) continue;
2078
- const table = /^models\.(?:"([^"]+)"|([\w-]+))$/.exec(header[1]);
2079
- const name = table?.[1] ?? table?.[2];
2080
- if (name !== void 0 && name !== "" && !models.includes(name)) models.push(name);
2413
+ function nextWrittenKimiStep(childSession, turn) {
2414
+ let last = 0;
2415
+ for (const event of childSession.snapshotEvents()) {
2416
+ if (event.type !== "step/start") continue;
2417
+ const data = event.data;
2418
+ if (data.turn === turn && typeof data.step === "number" && data.step > last) last = data.step;
2081
2419
  }
2082
- return models;
2420
+ return last + 1;
2421
+ }
2422
+ /** The run-progress delta text for one transcript line (any kind). */
2423
+ function kimiLineProgressText(line) {
2424
+ if (line.kind !== "tool") return line.text;
2425
+ return `${`[工具 ${line.name}]${line.args !== void 0 ? ` ${line.args}` : ""}`}${line.result !== void 0 ? ` → ${line.result}` : ""}`;
2083
2426
  }
2084
2427
  /**
2085
- * Rewrite the scoped config's top-level `default_model` to `model`, so the
2086
- * NEXT process started under this home runs that model. This is the resident
2087
- * path's only lever: `kimi acp` takes no model flag (verified against kimi
2088
- * 0.39.1), while `kimi -p` takes `-m` — so the one-shot rounds pass the flag
2089
- * and the live driver writes the file before spawning its runtime.
2428
+ * Mirror one kimi session's transcript into a dsh subagent session: each
2429
+ * user prompt becomes a `user/message`, and each WIRE STEP (one LLM request:
2430
+ * its thinking, reply text and tool calls share the wire's own step number)
2431
+ * folds into one dsh step whose `assistant/message` carries the reasoning,
2432
+ * text and tool-call blocks together — the host session's own shape, so the
2433
+ * standard surfaces (conversation, Context Lens) read one request per LLM
2434
+ * call. Then the events are appended to persistence so the child session is
2435
+ * viewable.
2090
2436
  *
2091
- * The write is surgical: the first top-level `default_model` assignment is
2092
- * replaced in place and everything else — model tables, providers, thinking
2093
- * effort, comments, key order — is left byte-identical. A config with no
2094
- * top-level `default_model` gains one as its first line; an absent or
2095
- * unreadable config is left alone (provisioning owns creation, and inventing
2096
- * a config here would hide a broken home).
2437
+ * A resumed round (or a live poll) passes the already-mirrored
2438
+ * transcript-line count so only the delta is appended — re-mirroring earlier
2439
+ * lines would duplicate their messages. Turn numbers come from the transcript
2440
+ * lines themselves (the wire's 1-based turn, matching the provider's
2441
+ * `turn/start` numbering); step numbers CONTINUE from the assistant messages
2442
+ * already in the child session, so a round mirrored across several live polls
2443
+ * does not restart step 1 per poll.
2444
+ *
2445
+ * Every folded step is one ATOMIC `step/start`–`step/end` triple, written
2446
+ * only when its content lands: the released-log reader admits only dense,
2447
+ * strictly sequential steps (one open at a time — a second `step/start`
2448
+ * while another step is open fails the session's reload validation), and the
2449
+ * live conversation assembler needs the boundary to materialize the step at
2450
+ * all. A fold paired with in-flight streams lands at the earliest consumed
2451
+ * reservation (see {@link KimiMirrorStreams}), after `finalizeBefore` has
2452
+ * closed every stream reservation below it; a sequential fold never takes a
2453
+ * step a stream reserved. Mid-round, a wire step the wire has not CLOSED yet
2454
+ * (no `step.end`) holds the pass — its parts may still arrive — and so does
2455
+ * an unresulted tool call (the reader resolves every advertised call before
2456
+ * the step closes; the result merges into the call's line in place, so the
2457
+ * next pass sees it). The settle pass folds the remainder with a synthesized
2458
+ * interrupted result. Lines the wire never step-tagged (older kimi) fold one
2459
+ * by one, immediately, as before.
2460
+ *
2461
+ * Usage accounting is exactly-once ACROSS passes: each `usage.record` is
2462
+ * tagged with the transcript position it occurred at, a pass owns the records
2463
+ * between the old offset and its own consumed end, and inside the window each
2464
+ * record rides the unit whose line span encloses it — one record per wire
2465
+ * step, landing on its own step's message. (A record raced PAST its content
2466
+ * encloses into the next unit instead: attribution off by one in a flush
2467
+ * race, totals still exact.)
2468
+ * @param ctx - host context carrying the session persistence service.
2469
+ * @param childSession - the dsh subagent session created for this delegation.
2097
2470
  * @param homeDir - the `kimi` harness's scoped home.
2098
- * @param model - the model identifier to pin as the default.
2099
- * @returns true when the file now names `model`, false when there was no
2100
- * config to write into.
2471
+ * @param kimiSessionId - the kimi session to mirror; omitted mirrors the most
2472
+ * recent session (a one-shot `kimi -p` run always creates a fresh one).
2473
+ * @param fromLines - transcript lines already mirrored into the child session
2474
+ * (0 for the first round); only lines after this offset are appended.
2475
+ * @returns the new mirror offset and the newly mirrored lines' texts. A held
2476
+ * unit (open wire step or unresulted tool) stops the offset before itself;
2477
+ * the next pass retries from there.
2101
2478
  */
2102
- async function writeKimiDefaultModel(homeDir, model) {
2103
- const path = join(homeDir, "config.toml");
2104
- let text;
2479
+ async function mirrorKimiSessionDelta(ctx, childSession, homeDir, kimiSessionId, fromLines = 0, options) {
2480
+ let workspaces;
2105
2481
  try {
2106
- text = await readFile(path, "utf8");
2482
+ workspaces = await readdir(join(homeDir, "sessions"));
2107
2483
  } catch {
2108
- return false;
2484
+ return {
2485
+ total: 0,
2486
+ texts: []
2487
+ };
2109
2488
  }
2110
- const assignment = `default_model = "${model.replace(/\\/gu, "\\\\").replace(/"/gu, "\\\"")}"`;
2111
- const lines = text.split("\n");
2112
- let section = "";
2113
- for (const [index, rawLine] of lines.entries()) {
2114
- const line = rawLine.trim();
2115
- const header = /^\[+([^\]]+)\]+$/.exec(line);
2116
- if (header !== null) {
2117
- section = header[1];
2489
+ let transcript;
2490
+ if (kimiSessionId !== void 0) {
2491
+ const dirName = kimiSessionId.startsWith("session_") ? kimiSessionId : `session_${kimiSessionId}`;
2492
+ for (const workspace of workspaces) {
2493
+ const dir = join(homeDir, "sessions", workspace, dirName);
2494
+ try {
2495
+ transcript = await readKimiTranscript(dir);
2496
+ } catch {
2497
+ continue;
2498
+ }
2499
+ if (transcript.lines.length > 0) break;
2500
+ }
2501
+ } else {
2502
+ let newest;
2503
+ for (const workspace of workspaces) {
2504
+ let sessions;
2505
+ try {
2506
+ sessions = await readdir(join(homeDir, "sessions", workspace));
2507
+ } catch {
2508
+ continue;
2509
+ }
2510
+ for (const name of sessions) {
2511
+ if (!name.startsWith("session_")) continue;
2512
+ const dir = join(homeDir, "sessions", workspace, name);
2513
+ const meta = await stat(dir);
2514
+ if (newest === void 0 || meta.mtimeMs > newest.time) newest = {
2515
+ dir,
2516
+ time: meta.mtimeMs
2517
+ };
2518
+ }
2519
+ }
2520
+ if (newest !== void 0) try {
2521
+ transcript = await readKimiTranscript(newest.dir);
2522
+ } catch {
2523
+ transcript = void 0;
2524
+ }
2525
+ }
2526
+ if (transcript === void 0 || transcript.lines.length === 0) return {
2527
+ total: 0,
2528
+ texts: []
2529
+ };
2530
+ const delta = transcript.lines.slice(fromLines);
2531
+ const observedModel = options?.turn === void 0 || transcript.modelTurn === options.turn ? transcript.model : void 0;
2532
+ const toolCalls = roundToolCalls(transcript.lines, options?.turn);
2533
+ const units = [];
2534
+ for (const line of delta) {
2535
+ if (line === void 0) continue;
2536
+ const last = units.at(-1);
2537
+ const head = last?.[0];
2538
+ if (last !== void 0 && head !== void 0 && line.kind !== "user" && head.kind !== "user" && line.wireStep !== void 0 && line.wireStep === head.wireStep && line.turn === head.turn) {
2539
+ last.push(line);
2118
2540
  continue;
2119
2541
  }
2120
- if (section !== "") continue;
2121
- const key = /^(\w+)\s*=/.exec(line);
2122
- if (key === null || key[1] !== "default_model") continue;
2123
- if (line === assignment) return true;
2124
- lines[index] = assignment;
2125
- await writeFile(path, lines.join("\n"), "utf8");
2126
- return true;
2542
+ units.push([line]);
2543
+ }
2544
+ let holdAt = -1;
2545
+ if (options?.settle !== true) for (let index = 0; index < units.length; index += 1) {
2546
+ const unit = units[index];
2547
+ const head = unit[0];
2548
+ if (head.kind !== "user" && head.wireStep !== void 0 && !transcript.closedSteps.has(`${head.turn}:${head.wireStep}`) || unit.some((line) => line.kind === "tool" && line.result === void 0)) {
2549
+ holdAt = index;
2550
+ break;
2551
+ }
2127
2552
  }
2128
- await writeFile(path, `${assignment}\n${text}`, "utf8");
2129
- return true;
2130
- }
2131
- /**
2132
- * Read the scoped config's effective reasoning effort: the `[thinking] effort`
2133
- * key, falling back to a `[models."…"]` section's `default_effort` when the
2134
- * thinking table carries none (the CLI resolves effort the same way). A
2135
- * missing or malformed config, or no effort key anywhere, yields undefined —
2136
- * the honest "unknown", never a guessed default. Read-only: the config is
2137
- * authoritative, a user-edited value is reported as-is.
2138
- * @param homeDir - the `kimi` harness's scoped home.
2139
- * @returns the effective effort, or undefined when it cannot be determined.
2140
- */
2141
- async function readKimiReasoningEffort(homeDir) {
2142
- let text;
2143
- try {
2144
- text = await readFile(join(homeDir, "config.toml"), "utf8");
2145
- } catch {
2146
- return;
2553
+ const foldedUnits = holdAt < 0 ? units : units.slice(0, holdAt);
2554
+ const consumedTotal = fromLines + foldedUnits.reduce((n, unit) => n + unit.length, 0);
2555
+ const unitUsage = [];
2556
+ const claimed = [];
2557
+ let unitStart = fromLines;
2558
+ for (const unit of foldedUnits) {
2559
+ const start = unitStart;
2560
+ unitStart += unit.length;
2561
+ const head = unit[0];
2562
+ if (head.kind !== "user" && head.wireStep !== void 0) {
2563
+ claimed.push({
2564
+ from: start,
2565
+ to: unitStart
2566
+ });
2567
+ unitUsage.push(sumUsageRecords(transcript.usageRecords.filter((record) => record.line >= start && record.line < unitStart)));
2568
+ } else unitUsage.push(void 0);
2147
2569
  }
2148
- let section = "";
2149
- let thinkingEffort;
2150
- let modelDefaultEffort;
2151
- for (const rawLine of text.split("\n")) {
2152
- const line = rawLine.trim();
2153
- const header = /^\[+([^\]]+)\]+$/.exec(line);
2154
- if (header !== null) {
2155
- section = header[1];
2570
+ const unclaimedUsage = sumUsageRecords(transcript.usageRecords.filter((record) => record.line >= fromLines && record.line <= consumedTotal && !claimed.some((span) => record.line >= span.from && record.line < span.to)));
2571
+ const deltaUsage = sumUsageRecords(transcript.usageRecords.filter((record) => record.line >= fromLines && record.line <= consumedTotal));
2572
+ const takenSteps = /* @__PURE__ */ new Map();
2573
+ for (const event of childSession.snapshotEvents()) {
2574
+ const data = event.data;
2575
+ if (typeof data.turn === "number" && typeof data.step === "number") {
2576
+ const taken = takenSteps.get(data.turn) ?? /* @__PURE__ */ new Set();
2577
+ taken.add(data.step);
2578
+ takenSteps.set(data.turn, taken);
2579
+ }
2580
+ }
2581
+ /**
2582
+ * The fold's coordinate: the smallest step of the turn that nothing holds
2583
+ * — neither written nor currently reserved. Filling the smallest free step
2584
+ * keeps the stored log dense even when a stream reservation below was
2585
+ * released mid-round (its content rode an earlier fold's step).
2586
+ */
2587
+ const nextFoldStep = (turn) => {
2588
+ const taken = new Set(takenSteps.get(turn));
2589
+ for (const reserved of options?.streams?.reservedSteps(turn) ?? []) taken.add(reserved);
2590
+ let step = 1;
2591
+ while (taken.has(step)) step += 1;
2592
+ return step;
2593
+ };
2594
+ /** Record a fold's coordinate chosen by a stream pairing. */
2595
+ const markTaken = (turn, step) => {
2596
+ const written = takenSteps.get(turn) ?? /* @__PURE__ */ new Set();
2597
+ written.add(step);
2598
+ takenSteps.set(turn, written);
2599
+ };
2600
+ /** The window's accounting that no fold carried (dropped units' sums). */
2601
+ let carryUsage;
2602
+ let lastContentUnit = -1;
2603
+ for (let index = foldedUnits.length - 1; index >= 0; index -= 1) if (foldedUnits[index][0]?.kind !== "user") {
2604
+ lastContentUnit = index;
2605
+ break;
2606
+ }
2607
+ const texts = [];
2608
+ for (let unitIndex = 0; unitIndex < foldedUnits.length; unitIndex += 1) {
2609
+ const unit = foldedUnits[unitIndex];
2610
+ const head = unit[0];
2611
+ let usage = addTokenUsage(carryUsage, unitUsage[unitIndex]);
2612
+ carryUsage = void 0;
2613
+ if (unitIndex === lastContentUnit) usage = addTokenUsage(usage, unclaimedUsage);
2614
+ const turn = head.turn;
2615
+ if (head.kind === "user") {
2616
+ if (userAlreadyAppended(childSession, turn, head.text)) continue;
2617
+ childSession.append("user/message", userEvent(head.text), { surfaceOp: "append" });
2618
+ texts.push(head.text);
2619
+ carryUsage = usage;
2156
2620
  continue;
2157
2621
  }
2158
- const key = /^(\w+)\s*=\s*"([^"]*)"$/.exec(line);
2159
- if (key === null) continue;
2160
- if (section === "thinking" && key[1] === "effort" && thinkingEffort === void 0) thinkingEffort = key[2];
2161
- if (section.startsWith("models.") && key[1] === "default_effort" && modelDefaultEffort === void 0) modelDefaultEffort = key[2];
2622
+ const toolLines = unit.filter((line) => line.kind === "tool");
2623
+ const reasoning = unit.filter((line) => line.kind === "think").map((line) => line.text).join("");
2624
+ const text = unit.filter((line) => line.kind === "assistant").map((line) => line.text).join("");
2625
+ const kinds = [...reasoning.trim() === "" ? [] : ["think"], ...text.trim() === "" ? [] : ["text"]];
2626
+ const pairing = kinds.length === 0 ? void 0 : options?.streams?.completeStreams(turn, kinds);
2627
+ const sealed = new Set(pairing?.sealed ?? []);
2628
+ const blocks = [];
2629
+ if (reasoning.trim() !== "" && !sealed.has("think")) blocks.push({
2630
+ type: "reasoning",
2631
+ text: reasoning
2632
+ });
2633
+ if (text.trim() !== "" && !sealed.has("text")) blocks.push({
2634
+ type: "text",
2635
+ text
2636
+ });
2637
+ for (const line of toolLines) blocks.push({
2638
+ type: "tool-call",
2639
+ id: line.id,
2640
+ name: line.name,
2641
+ arguments: line.args ?? ""
2642
+ });
2643
+ for (const line of unit) texts.push(kimiLineProgressText(line));
2644
+ if (blocks.length === 0) {
2645
+ carryUsage = usage;
2646
+ continue;
2647
+ }
2648
+ for (const line of toolLines) options?.streams?.releaseTool?.(line);
2649
+ let step = pairing?.step ?? nextFoldStep(turn);
2650
+ options?.streams?.finalizeBefore?.(turn, step);
2651
+ let sequential = nextKimiSessionStep(childSession, turn);
2652
+ const reservedAfter = options?.streams?.reservedSteps(turn) ?? [];
2653
+ while (reservedAfter.includes(sequential)) sequential += 1;
2654
+ step = Math.min(step, sequential);
2655
+ markTaken(turn, step);
2656
+ step = nextWrittenKimiStep(childSession, turn);
2657
+ const requestConfig = unit.map((line) => line.kind !== "user" ? line.requestConfig : void 0).find((config) => config !== void 0);
2658
+ if (requestConfig?.model !== void 0) appendRequestHeaderIfChanged(childSession, {
2659
+ provider: "kimi-cli",
2660
+ model: requestConfig.model,
2661
+ ...requestConfig.reasoningEffort === void 0 ? {} : { reasoningEffort: requestConfig.reasoningEffort },
2662
+ ...requestConfig.maxTokens === void 0 ? {} : { maxTokens: requestConfig.maxTokens }
2663
+ });
2664
+ const model = [...unit].reverse().find((line) => line.model !== void 0)?.model;
2665
+ childSession.append("step/start", {
2666
+ turn,
2667
+ step
2668
+ });
2669
+ childSession.append("assistant/message", {
2670
+ turn,
2671
+ step,
2672
+ message: assistantEvent(blocks, model),
2673
+ stream: [],
2674
+ ...usage === void 0 ? {} : { usage }
2675
+ }, { surfaceOp: "append" });
2676
+ for (const line of toolLines) {
2677
+ const call = childSession.append("tool/call", {
2678
+ turn,
2679
+ step,
2680
+ callId: line.id,
2681
+ name: line.name,
2682
+ arguments: line.args ?? ""
2683
+ });
2684
+ childSession.append("tool/result", {
2685
+ turn,
2686
+ step,
2687
+ message: createToolResultMessage({
2688
+ callId: line.id,
2689
+ content: [{
2690
+ type: "text",
2691
+ text: line.result ?? "(工具调用被中断,未能记录返回结果)"
2692
+ }],
2693
+ isError: line.result === void 0
2694
+ })
2695
+ }, {
2696
+ surfaceOp: "append",
2697
+ sourceEventSeqs: [call.seq]
2698
+ });
2699
+ }
2700
+ childSession.append("step/end", {
2701
+ turn,
2702
+ step
2703
+ });
2162
2704
  }
2163
- return thinkingEffort ?? modelDefaultEffort;
2705
+ await persistChildSession(ctx, childSession);
2706
+ const remainder = addTokenUsage(carryUsage, lastContentUnit === -1 ? unclaimedUsage : void 0);
2707
+ return {
2708
+ total: consumedTotal,
2709
+ texts,
2710
+ ...deltaUsage !== void 0 ? { usage: deltaUsage } : {},
2711
+ ...deltaUsage !== void 0 && remainder === void 0 ? { usageAttached: true } : {},
2712
+ ...remainder !== void 0 ? { usageUnattached: remainder } : {},
2713
+ ...observedModel !== void 0 ? { model: observedModel } : {},
2714
+ ...toolCalls !== void 0 ? { toolCalls } : {}
2715
+ };
2164
2716
  }
2165
2717
  /**
2166
- * Whether the scoped config's permission rules auto-approve the subagent's
2167
- * tool use: the `Bash(*)` allow rule the provisioning gate
2168
- * ({@link ensureKimiPermissions}) keys on, detected with the exact same
2169
- * substring so the snapshot and the provisioning can never disagree about
2170
- * the same file.
2171
- * @param homeDir - the `kimi` harness's scoped home.
2172
- * @returns whether the auto-approve rule is present.
2718
+ * Count one round's tool calls out of a kimi transcript, keyed by the name the
2719
+ * wire gave each tool. No turn named, or no `tool.call` line carrying it,
2720
+ * yields undefined — a round reports nothing rather than a zero it did not
2721
+ * observe. Reads only the transcript lines the mirror already folded.
2722
+ * @param lines - the whole session transcript.
2723
+ * @param turn - the round's 1-based turn, when the caller named one.
2724
+ * @returns the round's accounting, or undefined.
2173
2725
  */
2174
- async function readKimiAutoApprove(homeDir) {
2175
- let text;
2176
- try {
2177
- text = await readFile(join(homeDir, "config.toml"), "utf8");
2178
- } catch {
2179
- return false;
2726
+ function roundToolCalls(lines, turn) {
2727
+ if (turn === void 0) return void 0;
2728
+ const byName = {};
2729
+ let count = 0;
2730
+ for (const line of lines) {
2731
+ if (line.kind !== "tool" || line.turn !== turn) continue;
2732
+ count += 1;
2733
+ byName[line.name] = (byName[line.name] ?? 0) + 1;
2180
2734
  }
2181
- return text.includes("pattern = \"Bash(*)");
2735
+ return count === 0 ? void 0 : {
2736
+ count,
2737
+ byName
2738
+ };
2182
2739
  }
2183
- /** Permission rule block letting the kimi subagent run shell commands. */
2184
- const KIMI_PERMISSION_BLOCK = `
2185
- [[permission.rules]]
2186
- decision = "allow"
2187
- pattern = "Bash(*)"
2188
- reason = "let the kimi subagent run shell commands"
2189
- `;
2190
2740
  /**
2191
- * Ensure the scoped config allows the subagent's tool use. The one-shot
2192
- * `kimi -p` child uses the config's permission rules, so without a rule it
2193
- * answers without executing tools. Idempotent; an existing rule is respected.
2194
- * @param homeDir - the `kimi` harness's scoped home.
2741
+ * Write the fold's request/header when the wire's declared config differs from
2742
+ * the child log's last one (the host logs changed snapshots, not per-call
2743
+ * duplicates): first write is `initial`, a differing one `change`, an
2744
+ * identical one skipped.
2195
2745
  */
2196
- async function ensureKimiPermissions(homeDir) {
2197
- const file = join(homeDir, "config.toml");
2198
- let content;
2199
- try {
2200
- content = await readFile(file, "utf8");
2201
- } catch (error) {
2202
- if (error.code === "ENOENT") return;
2203
- throw error;
2204
- }
2205
- if (content.includes("pattern = \"Bash(*)")) return;
2206
- await writeFile(file, `${content.trimEnd()}\n${KIMI_PERMISSION_BLOCK}`);
2746
+ function appendRequestHeaderIfChanged(childSession, config) {
2747
+ const serialized = JSON.stringify(config);
2748
+ let last;
2749
+ for (const event of childSession.snapshotEvents()) if (event.type === "request/header") last = JSON.stringify(event.data.header?.config);
2750
+ if (last === serialized) return;
2751
+ childSession.append("request/header", {
2752
+ header: { config },
2753
+ reason: last === void 0 ? "initial" : "change"
2754
+ });
2207
2755
  }
2208
2756
  /**
2209
- * Sign out of the scoped account: remove the credentials and OAuth token
2210
- * cache, so `/<name> status` reports not authenticated and the next login
2211
- * authorizes a fresh account. The kimi CLI has no `logout` command, so the
2212
- * scoped files are the logout path.
2213
- * @param homeDir - the `kimi` harness's scoped home.
2757
+ * Whether the turn already carries a user/message with this exact text (the
2758
+ * live driver's round-start append). Scoped to the events at or after this
2759
+ * turn's turn/start so identical prompts across rounds stay independent.
2214
2760
  */
2215
- async function kimiLogout(homeDir) {
2216
- await Promise.all([rm(join(homeDir, CREDENTIALS_DIR$1), {
2217
- recursive: true,
2218
- force: true
2219
- }), rm(join(homeDir, OAUTH_DIR), {
2220
- recursive: true,
2221
- force: true
2222
- })]);
2761
+ function userAlreadyAppended(childSession, turn, text) {
2762
+ let turnStartSeq = -1;
2763
+ for (const event of childSession.snapshotEvents()) if (event.type === "turn/start" && event.data.turn === turn) turnStartSeq = event.seq;
2764
+ if (turnStartSeq < 0) return false;
2765
+ for (const event of childSession.snapshotEvents()) {
2766
+ if (event.seq < turnStartSeq || event.type !== "user/message") continue;
2767
+ if ((event.data.content ?? []).map((block) => block.text ?? "").join("") === text) return true;
2768
+ }
2769
+ return false;
2223
2770
  }
2224
2771
  //#endregion
2225
2772
  //#region lib/types/live-driver.js
@@ -2241,11 +2788,14 @@ async function kimiLogout(homeDir) {
2241
2788
  * throttled `mirrorKimiDelta` passes and the settle pass stays authoritative
2242
2789
  * (one fold, one offset, zero divergence between the two drivers). Every
2243
2790
  * transcript item folds 1:1 into the child session log.
2244
- * Live output also mirrors the in-flight item: chunk
2245
- * deltas accumulate per streaming item and append throttled snapshot
2246
- * assistant/messages at the item's reserved (turn, step) — the host folds
2247
- * repeated settles at one coordinate into one live-updating chat node — and
2248
- * the wire line completing the item folds at the same step, finalizing it.
2791
+ * Live output rides the `local-agent/stream` channel: chunk deltas
2792
+ * accumulate per streaming item and publish throttled updates at the item's
2793
+ * reserved (turn, step) — the member live node renders them as-is — while
2794
+ * the item's durable content lands as ONE atomic step/start→…→step/end
2795
+ * triple at that step, written by the completing wire line's fold or at
2796
+ * settle. Durable steps stay dense and strictly sequential (the released-log
2797
+ * reader validates exactly that on every cold open); snapshots never hold a
2798
+ * step open across appends.
2249
2799
  *
2250
2800
  * Lifecycle mirrors the dsh/codex live drivers' discipline: lazy spawn with an
2251
2801
  * initialize handshake (loadSession capability required — crash recovery is
@@ -2719,6 +3269,8 @@ var KimiAcpLiveDriver = class {
2719
3269
  this.ctx.logger.warn(`local-agent-kimi: pinning default_model for the resident runtime failed: ${thrown$1(error).message}`);
2720
3270
  return false;
2721
3271
  });
3272
+ const endpoint = this.config.endpoint === void 0 ? void 0 : await this.config.endpoint(spec.homeDir).catch(() => void 0);
3273
+ await ensureKimiEndpointProvider(spec.homeDir, hasEndpointCredential(endpoint) ? endpoint.baseUrl ?? "https://api.kimi.com/coding/v1" : void 0).catch(() => false);
2722
3274
  const spawnSpec = {
2723
3275
  argv: [
2724
3276
  "kimi",
@@ -2732,7 +3284,10 @@ var KimiAcpLiveDriver = class {
2732
3284
  stderr: "pipe"
2733
3285
  },
2734
3286
  graceMs: DEFAULT_DISPOSE_GRACE_MS,
2735
- env: delegationEnv({ KIMI_CODE_HOME: spec.homeDir })
3287
+ env: delegationEnv({
3288
+ KIMI_CODE_HOME: spec.homeDir,
3289
+ ...endpointAuthEnv(endpoint)
3290
+ })
2736
3291
  };
2737
3292
  let child;
2738
3293
  try {
@@ -2800,11 +3355,8 @@ var KimiAcpLiveDriver = class {
2800
3355
  * never exits: the match runs against the settled error instead). Degrades
2801
3356
  * silently on a core predating reportAuthFailure.
2802
3357
  */
2803
- reportAuthIfShaped(error, homeDir) {
3358
+ reportAuthIfShaped(error) {
2804
3359
  if (!KIMI_LIVE_AUTH_FAILURE.test(error.message)) return;
2805
- if (homeDir !== void 0) guardKimiCredential(homeDir, (message) => {
2806
- this.ctx.logger.warn(message);
2807
- });
2808
3360
  const registry = this.ctx.localAgent;
2809
3361
  if (typeof registry.reportAuthFailure === "function") registry.reportAuthFailure("kimi", error.message.split("\n").find((line) => line.trim() !== "") ?? error.message);
2810
3362
  }
@@ -2928,25 +3480,17 @@ var KimiAcpLiveDriver = class {
2928
3480
  /** The round's accumulated assistant text (the run output — chunks are the only source). */
2929
3481
  let roundText = "";
2930
3482
  /**
2931
- * The step ledger: every step a stream reservation consumed this round
2932
- * (in increasing order — permanently, whether the stream later completes
2933
- * or stays an orphan snapshot). The file fold skips these steps for its
2934
- * sequential folds, so a tool card folded mid-stream lands ABOVE the
2935
- * stream's step and the projection renders the round's chronological
2936
- * order.
3483
+ * The step ledger: the steps in-flight reservations currently hold this
3484
+ * round. A reservation's durable content lands at the fold that consumes
3485
+ * it (possibly carrying several reservations' kinds in one message — the
3486
+ * wire step's fold), which RELEASES the other consumed coordinates for
3487
+ * later folds; only a reservation kimi never completes (a plan stream, an
3488
+ * unwired tool notification) stays held until its settle-time finalize.
3489
+ * The file fold skips held steps for its sequential folds, so a tool card
3490
+ * folded mid-stream lands ABOVE the stream's step and the projection
3491
+ * renders the round's chronological order.
2937
3492
  */
2938
3493
  const reservedSteps = [];
2939
- /**
2940
- * The streaming items seen this round . kimi's ACP
2941
- * chunks carry no item id, so the stream key is synthetic per kind per
2942
- * round (the codex live driver's fallback shape): thought chunks share
2943
- * one stream, message chunks another. Deltas accumulate into throttled
2944
- * snapshot assistant/messages appended at the stream's reserved
2945
- * (turn, step) — the host folds repeated settles at one coordinate into
2946
- * one live-updating chat node. The wire line that completes the stream's
2947
- * kind folds at the same step and finalizes it; an entry whose completion
2948
- * never folds is force-finalized at settle.
2949
- */
2950
3494
  const streams = /* @__PURE__ */ new Map();
2951
3495
  /** The stream the latest delta accumulated into (a kind switch force-snapshots it). */
2952
3496
  let activeStream;
@@ -2974,7 +3518,23 @@ var KimiAcpLiveDriver = class {
2974
3518
  };
2975
3519
  /** A stream's synthetic key: kimi's ACP deltas carry no item id, so kind + turn pairs them. */
2976
3520
  let generation = 0;
3521
+ /**
3522
+ * One step reservation per generation, shared by the think and text
3523
+ * streams of the same response segment: the host's own live shape is
3524
+ * reasoning + text on ONE step, and a per-kind step would leave the
3525
+ * consumed reservation's stream frames pointing at a step that never
3526
+ * materializes (an empty trailing step breaks the host's answer
3527
+ * detection, folding the reply into the collapsed process block).
3528
+ */
3529
+ const generationSteps = /* @__PURE__ */ new Map();
2977
3530
  const seenToolCalls = /* @__PURE__ */ new Set();
3531
+ /**
3532
+ * ACP tool_call notifications' chronological reservations (call id →
3533
+ * step, plus the notification's display title). No durable event lands at
3534
+ * notification time: the native lifecycle (advertisement, call, result in
3535
+ * one closed step) is written by the wire line's fold, or at settle with
3536
+ * an interrupted placeholder when kimi never logged the call.
3537
+ */
2978
3538
  const toolSteps = /* @__PURE__ */ new Map();
2979
3539
  const streamKey = (kind) => `kimi-stream-${kind}-${turn}-${generation}`;
2980
3540
  /** The plan stream's key: a snapshot stream beside the think/text delta streams. */
@@ -2992,7 +3552,7 @@ var KimiAcpLiveDriver = class {
2992
3552
  const stream = streams.get(key);
2993
3553
  if (stream !== void 0) {
2994
3554
  stream.text += text;
2995
- appendStreamSnapshot(stream, false, false);
3555
+ publishStreamSnapshot(stream, false);
2996
3556
  }
2997
3557
  localAgent?.reportRunProgress(childSession.id, {
2998
3558
  kind: "delta",
@@ -3018,135 +3578,283 @@ var KimiAcpLiveDriver = class {
3018
3578
  const stream = streams.get(planKey);
3019
3579
  if (stream !== void 0 && stream.text !== plan) {
3020
3580
  stream.text = plan;
3021
- appendStreamSnapshot(stream, true, false);
3581
+ publishStreamSnapshot(stream, true);
3022
3582
  }
3023
3583
  };
3024
3584
  /**
3025
- * Append one snapshot of a streaming item at its reserved (turn, step).
3026
- * Batched per stream on a bounded deadline unless `force`; the forced
3027
- * final snapshot carries `interrupted` (a cancelled turn reads 已停止
3028
- * legitimately) and, when `withUsage`, the round's uncarried usage. An
3029
- * empty stream leaves no boundary at all.
3585
+ * The live-output channel. Snapshots are browser-only previews (never
3586
+ * `local-agent/stream` log events) — durable step content lands once per
3587
+ * stream, at finalization, as the fold's atomic triple.
3030
3588
  */
3031
3589
  const streamPublisher = localAgent?.liveStreams === void 0 ? void 0 : new LiveStreamPublisher(localAgent.liveStreams, childSession, turn, persist, (error) => this.ctx.logger.warn(`live checkpoint failed: ${String(error)}`));
3032
3590
  const liveFlush = new LiveFlush((error) => this.ctx.logger.warn(`live mirror flush failed: ${String(error)}`), this.config.snapshotMinIntervalMs ?? DEFAULT_SNAPSHOT_MIN_INTERVAL_MS);
3033
3591
  const pendingStreamArrival = /* @__PURE__ */ new Map();
3034
- const appendStreamSnapshot = (stream, force, interrupted, withUsage = false, final = false) => {
3035
- if (stream.text.trim() === "") return;
3592
+ /**
3593
+ * Publish one snapshot of a streaming item at its reserved (turn, step)
3594
+ * through the live channel. Batched per stream on a bounded deadline
3595
+ * unless `force`. Never writes durable content.
3596
+ */
3597
+ const publishStreamSnapshot = (stream, force) => {
3598
+ if (stream.text.trim() === "" || stream.sealed === true) return;
3036
3599
  const now = Date.now();
3037
3600
  if (!force) {
3038
3601
  if (!pendingStreamArrival.has(stream.step)) pendingStreamArrival.set(stream.step, now);
3039
- liveFlush.schedule(stream.step, () => appendStreamSnapshot(stream, true, interrupted, withUsage));
3602
+ liveFlush.schedule(stream.step, () => publishStreamSnapshot(stream, true));
3040
3603
  return;
3041
3604
  }
3042
3605
  liveFlush.cancel(stream.step);
3043
3606
  const receivedAt = pendingStreamArrival.get(stream.step) ?? now;
3044
3607
  pendingStreamArrival.delete(stream.step);
3045
- if (streamPublisher !== void 0 && !final) {
3046
- if (!stream.opened) {
3047
- childSession.append("step/start", {
3048
- turn,
3049
- step: stream.step
3050
- });
3051
- stream.opened = true;
3608
+ streamPublisher?.preview(stream.step, stream.kind, stream.text, {
3609
+ receivedAt,
3610
+ itemId: stream.kind
3611
+ });
3612
+ stream.lastSnapshotLen = stream.text.length;
3613
+ };
3614
+ /**
3615
+ * Write the stream's durable content as ONE atomic
3616
+ * step/start→assistant/message→step/end triple at its reserved step and
3617
+ * seal the reservation (its completing wire line, when one exists, pairs
3618
+ * and drops). `interrupted` marks a non-completed round's partial (a
3619
+ * cancelled turn reads 已停止 legitimately); `withUsage` attaches the
3620
+ * round's uncarried usage.
3621
+ */
3622
+ /**
3623
+ * Write the durable content of every stream sharing one step as ONE atomic
3624
+ * step/start→assistant/message→step/end triple (a generation's think and
3625
+ * text merge into a single message — the host's own shape) and seal the
3626
+ * reservations. `interrupted` marks a non-completed round's partial (a
3627
+ * cancelled turn reads 已停止 legitimately); `withUsage` attaches the
3628
+ * round's uncarried usage.
3629
+ */
3630
+ const finalizeStep = (step, entries, interrupted, withUsage) => {
3631
+ liveFlush.cancel(step);
3632
+ pendingStreamArrival.delete(step);
3633
+ const reasoning = entries.filter(([, stream]) => stream.kind === "think").map(([, stream]) => stream.text).join("");
3634
+ const text = entries.filter(([, stream]) => stream.kind === "text").map(([, stream]) => stream.text).join("");
3635
+ const blocks = [];
3636
+ if (reasoning.trim() !== "") blocks.push({
3637
+ type: "reasoning",
3638
+ text: reasoning
3639
+ });
3640
+ if (text.trim() !== "") blocks.push({
3641
+ type: "text",
3642
+ text
3643
+ });
3644
+ if (blocks.length === 0) {
3645
+ streamPublisher?.finish(step);
3646
+ const keys = new Set(entries.map(([key]) => key));
3647
+ if (!new Set([...streams.entries()].filter(([key, stream]) => !keys.has(key) && stream.sealed !== true).map(([, stream]) => stream.step)).has(step)) {
3648
+ const index = reservedSteps.indexOf(step);
3649
+ if (index >= 0) reservedSteps.splice(index, 1);
3650
+ }
3651
+ for (const [key, stream] of entries) {
3652
+ stream.sealed = true;
3653
+ generationSteps.delete(stream.generation);
3654
+ if (activeStream === key) activeStream = void 0;
3052
3655
  }
3053
- streamPublisher.update(stream.step, stream.kind, stream.text, { receivedAt });
3054
- stream.lastSnapshotAt = now;
3055
- stream.lastSnapshotLen = stream.text.length;
3056
3656
  return;
3057
3657
  }
3058
- if (!stream.opened) {
3059
- childSession.append("step/start", {
3060
- turn,
3061
- step: stream.step
3062
- });
3063
- stream.opened = true;
3064
- }
3658
+ const durable = nextWrittenKimiStep(childSession, turn);
3659
+ childSession.append("step/start", {
3660
+ turn,
3661
+ step: durable
3662
+ });
3065
3663
  childSession.append("assistant/message", {
3066
3664
  turn,
3067
- step: stream.step,
3068
- message: assistantEvent([stream.kind === "think" ? {
3069
- type: "reasoning",
3070
- text: stream.text
3071
- } : {
3072
- type: "text",
3073
- text: stream.text
3074
- }], runtime?.modelConfiguration?.currentModel ?? roundModel),
3665
+ step: durable,
3666
+ message: assistantEvent(blocks, runtime?.modelConfiguration?.currentModel ?? roundModel),
3075
3667
  stream: [],
3076
3668
  ...withUsage && roundUsage !== void 0 ? { usage: roundUsage } : {},
3077
3669
  ...interrupted ? { interrupted: true } : {}
3078
3670
  }, { surfaceOp: "append" });
3079
3671
  if (withUsage) roundUsage = void 0;
3080
- stream.lastSnapshotAt = now;
3081
- stream.lastSnapshotLen = stream.text.length;
3672
+ streamPublisher?.finish(step);
3673
+ childSession.append("step/end", {
3674
+ turn,
3675
+ step: durable
3676
+ });
3677
+ for (const [key, stream] of entries) {
3678
+ stream.sealed = true;
3679
+ generationSteps.delete(stream.generation);
3680
+ if (activeStream === key) activeStream = void 0;
3681
+ }
3082
3682
  persist();
3083
3683
  };
3084
3684
  /**
3685
+ * Write the durable step for an ACP tool call kimi never logged to the
3686
+ * wire (a crash between the notification and the wire flush): the
3687
+ * notification's title stands in for the name and the result is the
3688
+ * shared interrupted placeholder — the native lifecycle in one step.
3689
+ */
3690
+ /** Whether this turn's log already carries the tool call (folded from the wire). */
3691
+ const toolLogged = (id) => childSession.snapshotEvents().some((event) => event.type === "tool/call" && event.data.turn === turn && event.data.callId === id);
3692
+ const finalizeOrphanTool = (id, entry) => {
3693
+ const name = entry.title ?? "tool";
3694
+ const step = nextWrittenKimiStep(childSession, turn);
3695
+ childSession.append("step/start", {
3696
+ turn,
3697
+ step
3698
+ });
3699
+ childSession.append("assistant/message", {
3700
+ turn,
3701
+ step,
3702
+ message: assistantEvent([{
3703
+ type: "tool-call",
3704
+ id,
3705
+ name,
3706
+ arguments: ""
3707
+ }], runtime?.modelConfiguration?.currentModel ?? roundModel),
3708
+ stream: []
3709
+ }, { surfaceOp: "append" });
3710
+ const call = childSession.append("tool/call", {
3711
+ turn,
3712
+ step,
3713
+ callId: id,
3714
+ name,
3715
+ arguments: ""
3716
+ });
3717
+ childSession.append("tool/result", {
3718
+ turn,
3719
+ step,
3720
+ message: createToolResultMessage({
3721
+ callId: id,
3722
+ content: [{
3723
+ type: "text",
3724
+ text: KIMI_INTERRUPTED_TOOL_RESULT
3725
+ }],
3726
+ isError: true
3727
+ })
3728
+ }, {
3729
+ surfaceOp: "append",
3730
+ sourceEventSeqs: [call.seq]
3731
+ });
3732
+ childSession.append("step/end", {
3733
+ turn,
3734
+ step
3735
+ });
3736
+ };
3737
+ /**
3738
+ * The fold's gap fill: a fold about to write at `step` first finalizes
3739
+ * every live stream reserved below it (ascending), so the stored log's
3740
+ * steps land dense and strictly sequential. Only streams can sit in the
3741
+ * gap: an unresulted tool line holds its fold instead (session-mirror).
3742
+ */
3743
+ const finalizeBefore = (targetTurn, step) => {
3744
+ if (targetTurn !== turn) return;
3745
+ const pending = [...streams.entries()].filter(([, stream]) => stream.sealed !== true && stream.step < step).sort((a, b) => a[1].step - b[1].step);
3746
+ const byStep = /* @__PURE__ */ new Map();
3747
+ for (const entry of pending) {
3748
+ const group = byStep.get(entry[1].step) ?? [];
3749
+ group.push(entry);
3750
+ byStep.set(entry[1].step, group);
3751
+ }
3752
+ for (const [at, entries] of byStep) finalizeStep(at, entries, false, false);
3753
+ };
3754
+ /**
3085
3755
  * Reserve the step one streaming item will occupy — past every folded
3086
3756
  * line (the session's own event ledger: the file fold writes events
3087
- * synchronously) and every earlier reservation. A kind switch forces one
3088
- * freshness snapshot of the current stream (skipped when its text has not
3089
- * grown since the last one); its completion still folds at its own
3090
- * reserved step when the wire line lands.
3757
+ * synchronously) and every earlier reservation. A kind switch publishes
3758
+ * one freshness snapshot of the current stream (skipped when its text has
3759
+ * not grown since the last one); its durable triple still lands at its
3760
+ * own reserved step when the wire line folds or the round settles.
3091
3761
  */
3092
3762
  const reserveStream = (key, kind) => {
3093
3763
  if (activeStream !== void 0 && activeStream !== key) {
3094
3764
  const previous = streams.get(activeStream);
3095
- if (previous !== void 0 && previous.text.length !== previous.lastSnapshotLen) appendStreamSnapshot(previous, true, false);
3765
+ if (previous !== void 0 && previous.text.length !== previous.lastSnapshotLen) publishStreamSnapshot(previous, true);
3096
3766
  activeStream = void 0;
3097
3767
  }
3098
3768
  let stream = streams.get(key);
3769
+ if (stream?.sealed === true) {
3770
+ streams.delete(key);
3771
+ stream = void 0;
3772
+ }
3099
3773
  if (stream === void 0) {
3100
- let step = nextKimiSessionStep(childSession, turn);
3101
- for (const reserved of reservedSteps) step = Math.max(step, reserved + 1);
3102
- reservedSteps.push(step);
3774
+ let step = key === planKey ? void 0 : generationSteps.get(generation);
3775
+ if (step === void 0) {
3776
+ step = nextKimiSessionStep(childSession, turn);
3777
+ while (reservedSteps.includes(step)) step += 1;
3778
+ reservedSteps.push(step);
3779
+ if (key !== planKey) generationSteps.set(generation, step);
3780
+ }
3103
3781
  stream = {
3104
3782
  kind,
3105
3783
  step,
3784
+ generation: key === planKey ? -1 : generation,
3106
3785
  text: "",
3107
- lastSnapshotAt: 0,
3108
- lastSnapshotLen: 0,
3109
- opened: false
3786
+ lastSnapshotLen: 0
3110
3787
  };
3111
3788
  streams.set(key, stream);
3112
3789
  }
3113
3790
  activeStream = key;
3114
3791
  };
3115
3792
  /**
3116
- * The fold-pass coordination surface : the file
3117
- * fold skips reserved steps for sequential folds and pairs a completed
3118
- * think/assistant line with its stream — scoped to THIS round's turn, so
3119
- * an earlier round's leftover lines never consume this round's stream.
3793
+ * The fold-pass coordination surface: the file
3794
+ * fold skips reserved steps for sequential folds, pairs one fold with the
3795
+ * round's in-flight streams of the fold's kinds (consuming both — a wire
3796
+ * step's think and text ride ONE durable step), and closes every
3797
+ * reservation below each fold step first (finalizeBefore) — scoped to
3798
+ * THIS round's turn, so an earlier round's leftover lines never consume
3799
+ * this round's stream.
3120
3800
  */
3121
3801
  const mirrorStreams = {
3122
3802
  reservedSteps: (t) => t === turn ? reservedSteps : [],
3123
- completeTool: (line) => {
3124
- if (line.turn !== turn) return void 0;
3125
- const step = toolSteps.get(line.id);
3126
- if (step === void 0) return void 0;
3803
+ finalizeBefore,
3804
+ releaseTool: (line) => {
3805
+ if (line.turn !== turn) return;
3806
+ const entry = toolSteps.get(line.id);
3807
+ if (entry === void 0) return;
3127
3808
  toolSteps.delete(line.id);
3128
- return {
3129
- step,
3130
- opened: true
3131
- };
3809
+ const index = reservedSteps.indexOf(entry.step);
3810
+ if (index >= 0) reservedSteps.splice(index, 1);
3132
3811
  },
3133
- completeStream: (line) => {
3134
- if (line.turn !== turn) return void 0;
3135
- const kind = line.kind === "think" ? "think" : "text";
3136
- const entry = [...streams.entries()].find(([key, stream]) => key !== planKey && stream.kind === kind);
3137
- if (entry === void 0) return void 0;
3138
- const [key, stream] = entry;
3139
- liveFlush.cancel(stream.step);
3140
- streamPublisher?.finish(stream.step);
3141
- streams.delete(key);
3142
- if (activeStream === key) activeStream = void 0;
3812
+ completeStreams: (t, kinds) => {
3813
+ if (t !== turn) return void 0;
3814
+ const picked = [];
3815
+ for (const kind of kinds) {
3816
+ const entry = [...streams.entries()].find(([key, stream]) => key !== planKey && stream.kind === kind);
3817
+ if (entry !== void 0) picked.push(entry);
3818
+ }
3819
+ if (picked.length === 0) return void 0;
3820
+ const sealed = [];
3821
+ for (const [key, stream] of picked) {
3822
+ streams.delete(key);
3823
+ generationSteps.delete(stream.generation);
3824
+ if (activeStream === key) activeStream = void 0;
3825
+ if (stream.sealed === true) {
3826
+ sealed.push(stream.kind);
3827
+ continue;
3828
+ }
3829
+ liveFlush.cancel(stream.step);
3830
+ }
3831
+ const open = picked.filter(([, stream]) => stream.sealed !== true);
3832
+ if (open.length === 0) return { sealed };
3833
+ const held = new Set([...streams.values()].filter((stream) => stream.sealed !== true).map((stream) => stream.step));
3834
+ for (const [, stream] of open) {
3835
+ if (held.has(stream.step)) continue;
3836
+ const index = reservedSteps.indexOf(stream.step);
3837
+ if (index >= 0) reservedSteps.splice(index, 1);
3838
+ }
3839
+ for (const step of new Set(open.map(([, stream]) => stream.step))) streamPublisher?.finish(step);
3840
+ const written = /* @__PURE__ */ new Set();
3841
+ for (const event of childSession.snapshotEvents()) {
3842
+ const data = event.data;
3843
+ if (event.type === "step/start" && data.turn === turn && typeof data.step === "number") written.add(data.step);
3844
+ }
3845
+ let free = nextKimiSessionStep(childSession, turn);
3846
+ while (reservedSteps.includes(free)) free += 1;
3847
+ const candidates = [...new Set(open.map(([, stream]) => stream.step))].filter((step) => !written.has(step));
3143
3848
  return {
3144
- step: stream.step,
3145
- opened: stream.opened
3849
+ step: Math.min(free, ...candidates),
3850
+ sealed
3146
3851
  };
3147
3852
  }
3148
3853
  };
3149
- const mirrorOptions = () => ({ streams: mirrorStreams });
3854
+ const mirrorOptions = (settle = false) => ({
3855
+ streams: mirrorStreams,
3856
+ ...settle ? { settle: true } : {}
3857
+ });
3150
3858
  /**
3151
3859
  * Observation accounting across mirror passes: a pass whose window
3152
3860
  * attached its usage to a folded message is already carried; anything else
@@ -3155,7 +3863,7 @@ var KimiAcpLiveDriver = class {
3155
3863
  * the latest.
3156
3864
  */
3157
3865
  const noteMirrorUsage = (delta) => {
3158
- if (delta.usage !== void 0 && delta.usageAttached !== true) roundUsage = addTokenUsage(roundUsage, delta.usage);
3866
+ if (delta.usageUnattached !== void 0) roundUsage = addTokenUsage(roundUsage, delta.usageUnattached);
3159
3867
  if (delta.model !== void 0) roundModel = delta.model;
3160
3868
  };
3161
3869
  /** Throttled push-triggered mirror pass; the file fold stays the only transcript source. */
@@ -3207,23 +3915,22 @@ var KimiAcpLiveDriver = class {
3207
3915
  const stream = streams.get(key);
3208
3916
  if (stream !== void 0) {
3209
3917
  stream.text += text;
3210
- appendStreamSnapshot(stream, false, false);
3918
+ publishStreamSnapshot(stream, false);
3211
3919
  }
3212
3920
  }
3213
3921
  } else if (kind === "tool_call") {
3214
3922
  const id = typeof update["toolCallId"] === "string" ? update["toolCallId"] : void 0;
3215
3923
  if (id === void 0 || !seenToolCalls.has(id)) {
3216
- if (id !== void 0) {
3924
+ if (id !== void 0 && toolLogged(id)) seenToolCalls.add(id);
3925
+ else if (id !== void 0) {
3217
3926
  seenToolCalls.add(id);
3218
3927
  let step = nextKimiSessionStep(childSession, turn);
3219
- for (const reserved of reservedSteps) step = Math.max(step, reserved + 1);
3928
+ while (reservedSteps.includes(step)) step += 1;
3220
3929
  reservedSteps.push(step);
3221
- toolSteps.set(id, step);
3222
- childSession.append("step/start", {
3223
- turn,
3224
- step
3930
+ toolSteps.set(id, {
3931
+ step,
3932
+ ...typeof update["title"] === "string" ? { title: update["title"] } : {}
3225
3933
  });
3226
- persist();
3227
3934
  }
3228
3935
  generation++;
3229
3936
  }
@@ -3293,7 +4000,7 @@ var KimiAcpLiveDriver = class {
3293
4000
  let stableReads = 0;
3294
4001
  const deadline = Date.now() + SETTLE_MIRROR_QUIESCE_MS;
3295
4002
  for (;;) {
3296
- const delta = await mirrorKimiDelta(this.ctx, childSession, spec.homeDir, sessionId, mirrorOptions());
4003
+ const delta = await mirrorKimiDelta(this.ctx, childSession, spec.homeDir, sessionId, mirrorOptions(true));
3297
4004
  noteMirrorUsage(delta);
3298
4005
  localAgent?.reportRunProgress(childSession.id, {
3299
4006
  kind: "mirror",
@@ -3314,7 +4021,7 @@ var KimiAcpLiveDriver = class {
3314
4021
  const diagnostics = runtime?.diagnostics ?? "";
3315
4022
  const suffix = diagnostics === "" ? "" : `; ${diagnostics}`;
3316
4023
  this.ctx.logger.warn(`subagent-kimi: live round failed (${stopReason}): ${error.message}${suffix}`);
3317
- this.reportAuthIfShaped(error, spec.homeDir);
4024
+ this.reportAuthIfShaped(error);
3318
4025
  },
3319
4026
  signal: request.signal,
3320
4027
  onAbort
@@ -3328,25 +4035,28 @@ var KimiAcpLiveDriver = class {
3328
4035
  } catch (error) {
3329
4036
  this.ctx.logger.warn(`subagent-kimi: live settle mirror failed: ${thrown$1(error).message}`);
3330
4037
  }
3331
- if (streams.size > 0) {
3332
- const remaining = [...streams.values()];
3333
- for (const [position, stream] of remaining.entries()) {
3334
- appendStreamSnapshot(stream, true, settled.stopReason !== "completed", position === remaining.length - 1, true);
3335
- streamPublisher?.finish(stream.step);
3336
- if (stream.opened) childSession.append("step/end", {
3337
- turn,
3338
- step: stream.step
3339
- });
3340
- }
3341
- streams.clear();
3342
- activeStream = void 0;
3343
- persist();
3344
- }
3345
- for (const step of toolSteps.values()) childSession.append("step/end", {
3346
- turn,
3347
- step
4038
+ const pending = [...[...[...streams.entries()].filter(([, stream]) => stream.sealed !== true).reduce((byStep, entry) => {
4039
+ const group = byStep.get(entry[1].step) ?? [];
4040
+ group.push(entry);
4041
+ byStep.set(entry[1].step, group);
4042
+ return byStep;
4043
+ }, /* @__PURE__ */ new Map()).entries()].map(([step, entries]) => ({
4044
+ step,
4045
+ entries
4046
+ })), ...[...toolSteps.entries()].map(([toolId, tool]) => ({
4047
+ step: tool.step,
4048
+ toolId,
4049
+ tool
4050
+ }))].sort((a, b) => a.step - b.step);
4051
+ const lastStream = pending.map((entry, index) => "entries" in entry ? index : -1).filter((index) => index >= 0).at(-1);
4052
+ pending.forEach((entry, index) => {
4053
+ if ("entries" in entry) finalizeStep(entry.step, entry.entries, settled.stopReason !== "completed", index === lastStream);
4054
+ else if (!toolLogged(entry.toolId)) finalizeOrphanTool(entry.toolId, entry.tool);
3348
4055
  });
4056
+ streams.clear();
3349
4057
  toolSteps.clear();
4058
+ activeStream = void 0;
4059
+ if (pending.length > 0) persist();
3350
4060
  if (settled.stopReason === "completed") childSession.append("turn/end", {
3351
4061
  turn,
3352
4062
  reason: { kind: "completed" }
@@ -3386,7 +4096,7 @@ var KimiAcpLiveDriver = class {
3386
4096
  });
3387
4097
  await Promise.race([accepted, abortBranch.catch(() => void 0)]);
3388
4098
  return subprocessRunHandle({
3389
- id: childSession.id,
4099
+ id: SessionId(randomUUID()),
3390
4100
  result,
3391
4101
  signal: request.signal,
3392
4102
  onAbort,
@@ -3551,6 +4261,7 @@ var KimiCliProvider = class {
3551
4261
  live;
3552
4262
  model;
3553
4263
  memberModels;
4264
+ endpoint;
3554
4265
  name = "kimi-cli";
3555
4266
  capabilities = NO_START_CAPABILITIES;
3556
4267
  inheritsParentContext = false;
@@ -3561,19 +4272,33 @@ var KimiCliProvider = class {
3561
4272
  * while a retiring generation still hosts it).
3562
4273
  * @param model - resolver for the configured model, read PER ROUND so a
3563
4274
  * settings-card write takes effect on the next delegation without a
3564
- * reload. Undefined (the resolver absent, or returning undefined) leaves
3565
- * the argv exactly as it was before the key rode every round — the scoped
3566
- * config.toml's `default_model` then decides, as it always did.
4275
+ * reload; it receives the round's scope (a named scope's curated list
4276
+ * answers first). Undefined (the resolver absent, or returning
4277
+ * undefined) leaves the argv exactly as it was before the key rode every
4278
+ * round — the scoped config.toml's `default_model` then decides, as it
4279
+ * always did.
3567
4280
  * @param memberModels - the broker's member-model ledger: the provider
3568
4281
  * records each delegation's start model here (the live driver's spawn
3569
4282
  * resolver binds it) and reads the session-level override as the exec
3570
4283
  * path's top layer. Absent (older wiring), both drop out.
3571
4284
  */
3572
- constructor(ctx, live, model, memberModels) {
4285
+ constructor(ctx, live, model, memberModels, endpoint) {
3573
4286
  this.ctx = ctx;
3574
4287
  this.live = live;
3575
4288
  this.model = model;
3576
4289
  this.memberModels = memberModels;
4290
+ this.endpoint = endpoint;
4291
+ }
4292
+ /**
4293
+ * Resolve the scope's endpoint for this round and land its baseUrl on the
4294
+ * managed provider (the surgery writes only on drift), so a hand-edited
4295
+ * endpoint-auth.json applies without a restart.
4296
+ */
4297
+ async prepareEndpoint(homeDir) {
4298
+ if (this.endpoint === void 0) return void 0;
4299
+ const endpoint = await this.endpoint(homeDir).catch(() => void 0);
4300
+ await ensureKimiEndpointProvider(homeDir, hasEndpointCredential(endpoint) ? endpoint.baseUrl ?? "https://api.kimi.com/coding/v1" : void 0).catch(() => false);
4301
+ return endpoint;
3577
4302
  }
3578
4303
  /** Resolve the live driver for one round's member, if live is on for it. */
3579
4304
  liveDriver(childSessionId) {
@@ -3646,7 +4371,7 @@ var KimiCliProvider = class {
3646
4371
  ...record?.configurationLock === void 0 ? {} : { configurationLock: record.configurationLock }
3647
4372
  }, (configuration) => this.startKimiResume(request, intent, cwd, homeDir, exec, scope, record?.model, configuration), request.signal, intent?.onAdmitted);
3648
4373
  }
3649
- const childSessionId = SessionId(intent?.preparedMemberId ?? randomUUID());
4374
+ const childSessionId = SessionId(intent?.preparedMemberId ?? intent?.childSessionId ?? randomUUID());
3650
4375
  if (typeof this.ctx.localAgent.withMemberConfigurationRound !== "function") {
3651
4376
  if (intent?.effort !== void 0) throw new Error("Kimi effort requires the configuration admission core");
3652
4377
  return this.startKimiFresh(request, cwd, homeDir, exec, scope, intent?.model, childSessionId);
@@ -3675,16 +4400,19 @@ var KimiCliProvider = class {
3675
4400
  delegationDepth: (request.parent.session.header.delegationDepth ?? 0) + 1
3676
4401
  } });
3677
4402
  const harness = this.ctx.localAgent.get("kimi");
3678
- const label = subagentDelegationLabel(harness?.displayName ?? "kimi", request.descriptor.label);
4403
+ const label = subagentDelegationLabel(harness?.displayName ?? "kimi", request.label || void 0);
3679
4404
  childSession.append("subagent/descriptor", {
3680
- ...request.descriptor,
4405
+ version: SUBAGENT_DESCRIPTOR_VERSION,
4406
+ mode: "one-shot",
4407
+ provider: this.name,
3681
4408
  label
3682
4409
  });
3683
4410
  establishSubagentCatalogChild(request.parent.session, childSession.header, label);
3684
4411
  } catch (error) {
3685
4412
  this.ctx.logger.warn(`subagent-kimi: subagent session record failed: ${error instanceof Error ? error.message : String(error)}`);
3686
4413
  }
3687
- const baseUrl = await readKimiBaseUrl(homeDir).catch(() => void 0);
4414
+ const endpoint = await this.prepareEndpoint(homeDir);
4415
+ const baseUrl = hasEndpointCredential(endpoint) ? endpoint.baseUrl : await readKimiBaseUrl(homeDir).catch(() => void 0);
3688
4416
  this.ctx.logger.info(`subagent-kimi: delegating via ${baseUrl ?? "kimi default endpoint"}`);
3689
4417
  const live = exec === void 0 ? this.liveDriver(runId) : void 0;
3690
4418
  if (live !== void 0 && childSession !== void 0 && !live.disabled) {
@@ -3717,10 +4445,13 @@ var KimiCliProvider = class {
3717
4445
  try {
3718
4446
  const run = await startKimiCliRun(request, {
3719
4447
  cwd,
3720
- env: delegationEnv({ KIMI_CODE_HOME: homeDir }),
4448
+ env: delegationEnv({
4449
+ KIMI_CODE_HOME: homeDir,
4450
+ ...endpointAuthEnv(endpoint)
4451
+ }),
3721
4452
  ...exec === void 0 ? {} : { exec },
3722
4453
  endpointLabel: baseUrl,
3723
- ...configuration?.resolved ?? resolveRoundModel(this.memberModels?.overrideFor(runId) ?? requestedModel, this.model),
4454
+ ...configuration?.resolved ?? resolveRoundModel(this.memberModels?.overrideFor(runId) ?? requestedModel, () => this.model?.(scope)),
3724
4455
  ...configuration === void 0 ? {} : { controlled: true },
3725
4456
  disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
3726
4457
  spawn: (spec) => this.ctx.subprocess.spawn(spec),
@@ -3728,9 +4459,6 @@ var KimiCliProvider = class {
3728
4459
  this.ctx.logger.warn(`subagent-kimi: child run failed (${stopReason}) via ${baseUrl ?? "kimi default endpoint"}: ${error instanceof Error ? error.message : String(error)}`);
3729
4460
  },
3730
4461
  onAuthFailure: (detail) => {
3731
- guardKimiCredential(homeDir, (message) => {
3732
- this.ctx.logger.warn(message);
3733
- });
3734
4462
  this.ctx.localAgent.reportAuthFailure("kimi", detail, scope);
3735
4463
  },
3736
4464
  onAuthSuccess: () => {
@@ -3797,16 +4525,20 @@ var KimiCliProvider = class {
3797
4525
  }
3798
4526
  }
3799
4527
  const member = exec === void 0 && scope === void 0 ? this.memberRun(intent.childSessionId, request.parent.session.id, homeDir) : void 0;
3800
- const baseUrl = await readKimiBaseUrl(homeDir).catch(() => void 0);
4528
+ const endpoint = await this.prepareEndpoint(homeDir);
4529
+ const baseUrl = hasEndpointCredential(endpoint) ? endpoint.baseUrl : await readKimiBaseUrl(homeDir).catch(() => void 0);
3801
4530
  this.ctx.logger.info(`subagent-kimi: resuming via ${baseUrl ?? "kimi default endpoint"}`);
3802
4531
  let run;
3803
4532
  try {
3804
4533
  run = await startKimiCliRun(request, {
3805
4534
  cwd,
3806
- env: delegationEnv({ KIMI_CODE_HOME: homeDir }),
4535
+ env: delegationEnv({
4536
+ KIMI_CODE_HOME: homeDir,
4537
+ ...endpointAuthEnv(endpoint)
4538
+ }),
3807
4539
  ...exec === void 0 ? {} : { exec },
3808
4540
  endpointLabel: baseUrl,
3809
- ...configuration?.resolved ?? resolveRoundModel(this.memberModels?.overrideFor(intent.childSessionId) ?? requestedModel, this.model),
4541
+ ...configuration?.resolved ?? resolveRoundModel(this.memberModels?.overrideFor(intent.childSessionId) ?? requestedModel, () => this.model?.(scope)),
3810
4542
  ...configuration === void 0 ? {} : { controlled: true },
3811
4543
  disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
3812
4544
  spawn: (spec) => this.ctx.subprocess.spawn(spec),
@@ -3814,9 +4546,6 @@ var KimiCliProvider = class {
3814
4546
  this.ctx.logger.warn(`subagent-kimi: child run failed (${stopReason}) via ${baseUrl ?? "kimi default endpoint"}: ${error instanceof Error ? error.message : String(error)}`);
3815
4547
  },
3816
4548
  onAuthFailure: (detail) => {
3817
- guardKimiCredential(homeDir, (message) => {
3818
- this.ctx.logger.warn(message);
3819
- });
3820
4549
  this.ctx.localAgent.reportAuthFailure("kimi", detail, scope);
3821
4550
  },
3822
4551
  onAuthSuccess: () => {
@@ -3926,7 +4655,10 @@ async function mirrorKimiAfterExit(spec, stderr) {
3926
4655
  try {
3927
4656
  const kimiSessionId = spec.resume?.cliSessionId ?? kimiSessionIdFromOutput(stderr);
3928
4657
  if (spec.resume === void 0) spec.onCliSessionId?.(kimiSessionId);
3929
- const delta = await mirrorKimiDelta(spec.ctx, spec.childSession, spec.homeDir, kimiSessionId, { turn: spec.resume?.turn ?? 1 });
4658
+ const delta = await mirrorKimiDelta(spec.ctx, spec.childSession, spec.homeDir, kimiSessionId, {
4659
+ turn: spec.resume?.turn ?? 1,
4660
+ settle: true
4661
+ });
3930
4662
  spec.ctx.get("localAgent")?.reportRunProgress(spec.childSession.id, {
3931
4663
  kind: "mirror",
3932
4664
  mirroredLines: delta.total
@@ -4092,29 +4824,6 @@ async function startKimiCliRun(request, spec) {
4092
4824
  onAbort
4093
4825
  }).then((settled) => {
4094
4826
  if (settled.stopReason === "completed") spec.onAuthSuccess?.();
4095
- if (spec.childSession !== void 0) {
4096
- if (settled.stopReason === "completed") spec.childSession.append("turn/end", {
4097
- turn,
4098
- reason: { kind: "completed" }
4099
- });
4100
- else if (settled.stopReason === "aborted") spec.childSession.append("turn/end", {
4101
- turn,
4102
- reason: {
4103
- kind: "aborted",
4104
- reason: { kind: "parent" }
4105
- }
4106
- });
4107
- else spec.childSession.append("turn/end", {
4108
- turn,
4109
- reason: {
4110
- kind: "error",
4111
- error: {
4112
- message: "kimi -p exited before the run completed",
4113
- code: "UNKNOWN"
4114
- }
4115
- }
4116
- });
4117
- }
4118
4827
  return settled;
4119
4828
  });
4120
4829
  result.then(() => child.done).then(() => {
@@ -4123,19 +4832,46 @@ async function startKimiCliRun(request, spec) {
4123
4832
  if (KIMI_AUTH_FAILURE.test(authDetail)) spec.onAuthFailure(authDetail.split("\n").find((line) => line.trim() !== "") ?? "auth failure");
4124
4833
  }
4125
4834
  enqueueMirror(async () => {
4126
- const settled = await mirrorKimiAfterExit(spec, stderr);
4127
- addRoundUsage(settled?.usage);
4835
+ const settled = await result;
4836
+ const mirrored = await mirrorKimiAfterExit(spec, stderr);
4837
+ addRoundUsage(mirrored?.usage);
4838
+ if (spec.childSession !== void 0) {
4839
+ if (settled.stopReason === "completed") spec.childSession.append("turn/end", {
4840
+ turn,
4841
+ reason: { kind: "completed" }
4842
+ });
4843
+ else if (settled.stopReason === "aborted") spec.childSession.append("turn/end", {
4844
+ turn,
4845
+ reason: {
4846
+ kind: "aborted",
4847
+ reason: { kind: "parent" }
4848
+ }
4849
+ });
4850
+ else spec.childSession.append("turn/end", {
4851
+ turn,
4852
+ reason: {
4853
+ kind: "error",
4854
+ error: {
4855
+ message: "kimi -p exited before the run completed",
4856
+ code: "UNKNOWN"
4857
+ }
4858
+ }
4859
+ });
4860
+ if (spec.ctx !== void 0) await persistChildSession(spec.ctx, spec.childSession).catch((error) => {
4861
+ spec.ctx?.logger.warn(`subagent-kimi: turn/end persistence failed: ${thrown(error).message}`);
4862
+ });
4863
+ }
4128
4864
  const cliVersion = await spec.cliVersion?.();
4129
4865
  spec.onRoundSettled?.({
4130
- ...settled?.model === void 0 ? {} : { observedModel: settled.model },
4866
+ ...mirrored?.model === void 0 ? {} : { observedModel: mirrored.model },
4131
4867
  ...cliVersion === void 0 ? {} : { cliVersion },
4132
4868
  ...roundUsage === void 0 ? {} : { usage: roundUsage },
4133
- ...settled?.toolCalls === void 0 ? {} : { toolCalls: settled.toolCalls }
4869
+ ...mirrored?.toolCalls === void 0 ? {} : { toolCalls: mirrored.toolCalls }
4134
4870
  });
4135
4871
  });
4136
4872
  }, () => {});
4137
4873
  return Promise.resolve(subprocessRunHandle({
4138
- id: spec.childSession?.id ?? SessionId(randomUUID()),
4874
+ id: SessionId(randomUUID()),
4139
4875
  result,
4140
4876
  signal: request.signal,
4141
4877
  onAbort,
@@ -4161,15 +4897,17 @@ var LiveDriverSwitch = class {
4161
4897
  ctx;
4162
4898
  liveIdleMs;
4163
4899
  model;
4900
+ endpoint;
4164
4901
  liveOn = false;
4165
4902
  active;
4166
4903
  /** Retiring generations still draining their in-flight rounds. */
4167
4904
  draining = /* @__PURE__ */ new Set();
4168
4905
  unwatch;
4169
- constructor(ctx, face, liveIdleMs, model) {
4906
+ constructor(ctx, face, liveIdleMs, model, endpoint) {
4170
4907
  this.ctx = ctx;
4171
4908
  this.liveIdleMs = liveIdleMs;
4172
4909
  this.model = model;
4910
+ this.endpoint = endpoint;
4173
4911
  this.apply(face.get());
4174
4912
  this.unwatch = face.watch((next) => {
4175
4913
  this.apply(next);
@@ -4193,7 +4931,8 @@ var LiveDriverSwitch = class {
4193
4931
  const retiring = this.active;
4194
4932
  this.active = next.live ? new KimiAcpLiveDriver(this.ctx, {
4195
4933
  ...this.liveIdleMs === void 0 ? {} : { liveIdleMs: this.liveIdleMs },
4196
- ...this.model === void 0 ? {} : { model: this.model }
4934
+ ...this.model === void 0 ? {} : { model: this.model },
4935
+ ...this.endpoint === void 0 ? {} : { endpoint: this.endpoint }
4197
4936
  }) : void 0;
4198
4937
  if (retiring !== void 0) {
4199
4938
  this.draining.add(retiring);
@@ -4280,6 +5019,47 @@ function dedupe(candidates) {
4280
5019
  return out;
4281
5020
  }
4282
5021
  /**
5022
+ * Scope the served directory to the surface asking for it (the same helper
5023
+ * lives in each provider's broker — fold into the family core when the
5024
+ * directory faces next evolve):
5025
+ *
5026
+ * - memberless (the card's add-model dialog, the invite picker): the
5027
+ * endpoint's served catalog only — the configuration/history extras the
5028
+ * broker's info extension adds for STATUS display (a config-defined model
5029
+ * the endpoint does not serve) must never become add candidates;
5030
+ * - a member whose harness has a curated list: the list IS the vocabulary,
5031
+ * in the list's own order (the same rule `choices` already follows) — a
5032
+ * curated value the endpoint no longer serves stays as a bare row (the
5033
+ * card shows it the same way), and the member's current effective model is
5034
+ * kept visible as a bare entry when it sits outside the list, so a pre-list
5035
+ * binding never renders blank;
5036
+ * - a member without a curated list: the legacy extended vocabulary.
5037
+ */
5038
+ function scopeServedDirectory(directory, opts) {
5039
+ if (opts.member && opts.curated.length === 0) return directory;
5040
+ const served = directory.entries.filter((entry) => entry.source === "native");
5041
+ if (!opts.member) return {
5042
+ ...directory,
5043
+ entries: served
5044
+ };
5045
+ const byValue = new Map(served.flatMap((entry) => entry.resolvedModel === void 0 ? [[entry.value, entry]] : [[entry.value, entry], [entry.resolvedModel, entry]]));
5046
+ const entries = opts.curated.map((value) => byValue.get(value) ?? {
5047
+ value,
5048
+ label: value,
5049
+ source: "configuration"
5050
+ });
5051
+ const current = opts.current?.trim();
5052
+ if (current !== void 0 && current !== "" && !entries.some((entry) => entry.value === current || entry.resolvedModel === current)) entries.push({
5053
+ value: current,
5054
+ label: current,
5055
+ source: "configuration"
5056
+ });
5057
+ return {
5058
+ ...directory,
5059
+ entries
5060
+ };
5061
+ }
5062
+ /**
4283
5063
  * The kimi harness's {@link LocalAgentModelBroker}. Also the provider-facing
4284
5064
  * member-model ledger: the provider records each delegation's start model
4285
5065
  * here and reads the session-level override for its exec-path resolution, so
@@ -4306,7 +5086,7 @@ var KimiModelBroker = class {
4306
5086
  configurationAdapter(binding) {
4307
5087
  const home = this.ctx.localAgent.homeDir("kimi", binding.scope);
4308
5088
  const resolve = async (selection) => {
4309
- const model = selection.model.mode === "value" ? selection.model.value : (selection.model.mode === "inherit" ? binding.model : void 0) ?? this.deps.settingsModel() ?? await readKimiDefaultModel(home);
5089
+ const model = selection.model.mode === "value" ? selection.model.value : (selection.model.mode === "inherit" ? binding.model : void 0) ?? this.deps.settingsModel(home) ?? await readKimiDefaultModel(home);
4310
5090
  const configured = await readKimiModelConfiguration(home, model);
4311
5091
  const entry = this.deps.liveSwitch.memberRuntimeConfiguration(binding.childSessionId)?.directory.entries.find((entry) => entry.value === model) ?? configured.entries.find((entry) => entry.value === model);
4312
5092
  const effort = (selection.effort.mode === "value" ? selection.effort.value : selection.effort.mode === "inherit" ? binding.effort : void 0) ?? configured.effort;
@@ -4344,14 +5124,19 @@ var KimiModelBroker = class {
4344
5124
  }
4345
5125
  async modelDirectory(childSessionId, refresh = false) {
4346
5126
  if (refresh) await this.deps.catalog?.refresh(childSessionId);
4347
- return (await this.modelInfo(childSessionId)).directory ?? {
5127
+ const info = await this.modelInfo(childSessionId);
5128
+ return scopeServedDirectory(info.directory ?? {
4348
5129
  entries: [],
4349
5130
  complete: false,
4350
5131
  customInput: true,
4351
5132
  status: "unsupported",
4352
5133
  refreshing: false,
4353
5134
  revision: 0
4354
- };
5135
+ }, {
5136
+ member: childSessionId !== void 0,
5137
+ curated: this.deps.modelList?.(this.deps.homeDir(childSessionId)) ?? [],
5138
+ current: info.effective
5139
+ });
4355
5140
  }
4356
5141
  async *followModelDirectory(childSessionId, signal) {
4357
5142
  if (this.deps.catalog === void 0) {
@@ -4383,7 +5168,7 @@ var KimiModelBroker = class {
4383
5168
  * nothing — the scoped config's own `default_model` decides, as it always
4384
5169
  * did.
4385
5170
  */
4386
- spawnModel = (childSessionId) => this.overrideFor(childSessionId) ?? this.startModels.get(childSessionId) ?? this.deps.settingsModel();
5171
+ spawnModel = (childSessionId) => this.overrideFor(childSessionId) ?? this.startModels.get(childSessionId) ?? this.deps.settingsModel(this.deps.homeDir(childSessionId));
4387
5172
  /**
4388
5173
  * Read the model surface. With no member this is the harness default (the
4389
5174
  * settings card's "what would a round run with"): override and delegation
@@ -4392,11 +5177,12 @@ var KimiModelBroker = class {
4392
5177
  async modelInfo(childSessionId, delegationModel) {
4393
5178
  const override = childSessionId === void 0 ? void 0 : this.overrideFor(childSessionId);
4394
5179
  const delegation = childSessionId === void 0 ? void 0 : delegationModel ?? this.startModels.get(childSessionId);
4395
- const settings = this.deps.settingsModel();
4396
5180
  const homeDir = this.deps.homeDir(childSessionId);
5181
+ const settings = this.deps.settingsModel(homeDir);
4397
5182
  const native = this.deps.catalog?.read(childSessionId);
4398
5183
  const [cliDefault, discovered] = await Promise.all([readKimiDefaultModel(homeDir).catch(() => void 0), listKimiConfigModels(homeDir).catch(() => [])]);
4399
- const choices = dedupe([
5184
+ const curated = this.deps.modelList?.(homeDir) ?? [];
5185
+ const choices = curated.length > 0 ? [...curated] : dedupe([
4400
5186
  settings,
4401
5187
  cliDefault,
4402
5188
  ...discovered,
@@ -4417,7 +5203,7 @@ var KimiModelBroker = class {
4417
5203
  settings,
4418
5204
  cliDefault,
4419
5205
  ...discovered
4420
- ], this.deps.recentModels()) },
5206
+ ], curated.length > 0 ? [] : this.deps.recentModels()) },
4421
5207
  live: this.deps.isLive(),
4422
5208
  switchable: !inFlight,
4423
5209
  ...inFlight ? { reason: SWITCH_IN_FLIGHT_REASON } : {}
@@ -4454,42 +5240,6 @@ var KimiModelBroker = class {
4454
5240
  * `session_index.jsonl`, kimi's own append-only index.
4455
5241
  * @module @khorsheed/dsh-local-agent-kimi/records
4456
5242
  */
4457
- /** Credentials live under this directory inside the scoped home. */
4458
- const CREDENTIALS_DIR = "credentials";
4459
- /**
4460
- * Whether the scoped home holds usable login credentials, guarded by the
4461
- * credential sentinel: a VALID credential file (both tokens non-empty) reads
4462
- * true and refreshes the backup; an empty shell (the kimi CLI's failure-path
4463
- * wipe) restores the backup when one exists and reads false otherwise. The
4464
- * pre-sentinel "directory non-empty" check could not tell the empty shell
4465
- * from a real credential at all.
4466
- * @param homeDir - the `kimi` harness's scoped home.
4467
- * @returns true when usable credentials are present (after any restore).
4468
- */
4469
- async function kimiAuthenticated(homeDir) {
4470
- return guardKimiCredential(homeDir);
4471
- }
4472
- /**
4473
- * The newest credential file's modification stamp (epoch ms), undefined when
4474
- * no credential exists. A completed device-code login rewrites the
4475
- * credentials directory, so the stamp distinguishes a fresh login from a
4476
- * leftover (possibly expired) credential.
4477
- * @param homeDir - the `kimi` harness's scoped home.
4478
- * @returns the newest credential mtime, or undefined when none exists.
4479
- */
4480
- async function kimiCredentialStamp(homeDir) {
4481
- try {
4482
- const entries = await readdir(join(homeDir, CREDENTIALS_DIR));
4483
- let newest;
4484
- for (const entry of entries) {
4485
- const { mtimeMs } = await stat(join(homeDir, CREDENTIALS_DIR, entry));
4486
- if (newest === void 0 || mtimeMs > newest) newest = mtimeMs;
4487
- }
4488
- return newest;
4489
- } catch {
4490
- return;
4491
- }
4492
- }
4493
5243
  /**
4494
5244
  * Read the scoped home's session index. The index is kimi's own
4495
5245
  * newline-delimited JSONL; a torn tail line (the file is append-only) is
@@ -4563,8 +5313,10 @@ async function removeLegacyVariants(ctx) {
4563
5313
  /**
4564
5314
  * Kimi Code harness instantiation of the local-agent family. Registers the
4565
5315
  * `kimi` harness into the core registry: scoped home under the shared homes
4566
- * root (`KIMI_CODE_HOME`), device-code login through `kimi login`, and the
4567
- * `session_index.jsonl` records adapter. Delegation mounts through the
5316
+ * root (`KIMI_CODE_HOME`), endpoint-key authentication (endpoint-auth.ts —
5317
+ * the managed provider reads the key from the `api_key_env` environment
5318
+ * indirection; no OAuth anywhere), and the `session_index.jsonl` records
5319
+ * adapter. Delegation mounts through the
4568
5320
  * `kimi-cli` provider (one-shot `kimi -p` under the scoped home); the
4569
5321
  * bundle patch's `tool-subagent-kimi` row puts the `subagent_kimi` tool on
4570
5322
  * the profile root, so every agent preset can delegate without variants.
@@ -4608,6 +5360,8 @@ const Config = z.object({
4608
5360
  z.const("high"),
4609
5361
  z.const("max")
4610
5362
  ]),
5363
+ baseUrl: z.string(),
5364
+ apiKey: z.string(),
4611
5365
  live: volatilize(SETTINGS_FIELDS.live),
4612
5366
  liveIdleMs: z.number().default(DEFAULT_LIVE_IDLE_MS),
4613
5367
  liveMirrorGranularity: volatilize(SETTINGS_FIELDS.liveMirrorGranularity),
@@ -4669,10 +5423,15 @@ function apply(ctx, config) {
4669
5423
  ctx.effect(() => {
4670
5424
  const homeDir = ctx.localAgent.homeDir("kimi");
4671
5425
  const thinkingEffort = config.thinkingEffort ?? "high";
5426
+ const endpointConfig = {
5427
+ ...config.baseUrl === void 0 ? {} : { baseUrl: config.baseUrl },
5428
+ ...config.apiKey === void 0 ? {} : { apiKey: config.apiKey }
5429
+ };
5430
+ const resolveEndpoint = (scopeHome) => resolveEndpointAuth(scopeHome, endpointConfig);
4672
5431
  removeLegacyVariants(ctx).catch((error) => {
4673
5432
  ctx.logger.warn(`local-agent-kimi: legacy preset cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
4674
5433
  });
4675
- provisionKimiConfig(homeDir, vol(config.model) ?? "kimi-code/k3", thinkingEffort).then(() => ensureKimiPermissions(homeDir)).catch((error) => {
5434
+ provisionKimiConfig(homeDir, vol(config.model) ?? "kimi-code/k3", thinkingEffort, config.baseUrl).then(() => ensureKimiPermissions(homeDir)).catch((error) => {
4676
5435
  ctx.logger.warn(`local-agent-kimi: config provisioning failed: ${error instanceof Error ? error.message : String(error)}`);
4677
5436
  });
4678
5437
  const readSettings = () => {
@@ -4700,12 +5459,14 @@ function apply(ctx, config) {
4700
5459
  legacyBase,
4701
5460
  read: readSettings
4702
5461
  });
4703
- const resolveModel = () => {
5462
+ const resolveModelKey = () => {
4704
5463
  const model = face.get().model?.trim();
4705
5464
  return model === void 0 || model === "" ? void 0 : model;
4706
5465
  };
5466
+ const resolveModelFor = (homeDir) => firstModelOf(readModelListSync(homeDir)) ?? resolveModelKey();
5467
+ const resolveModel = (scope) => resolveModelFor(ctx.localAgent.homeDir("kimi", scope));
4707
5468
  let broker;
4708
- const liveSwitch = new LiveDriverSwitch(ctx, face, config.liveIdleMs, (child) => broker.spawnModel(child));
5469
+ const liveSwitch = new LiveDriverSwitch(ctx, face, config.liveIdleMs, (child) => broker.spawnModel(child), resolveEndpoint);
4709
5470
  const modelCatalog = new KimiModelCatalog((childSessionId) => {
4710
5471
  const record = childSessionId === void 0 ? void 0 : ctx.localAgent.memberBinding?.(childSessionId) ?? ctx.localAgent.getDelegation(childSessionId);
4711
5472
  const native = childSessionId === void 0 ? void 0 : liveSwitch.memberRuntimeConfiguration(childSessionId);
@@ -4714,16 +5475,20 @@ function apply(ctx, config) {
4714
5475
  ...record?.cwd === void 0 ? {} : { cwd: record.cwd },
4715
5476
  ...native === void 0 ? {} : { native }
4716
5477
  };
5478
+ }, {
5479
+ endpoint: resolveEndpoint,
5480
+ warn: (message) => ctx.logger.warn(message)
4717
5481
  });
4718
5482
  broker = new KimiModelBroker(ctx, {
4719
5483
  homeDir: (childSessionId) => ctx.localAgent.homeDir("kimi", childSessionId === void 0 ? void 0 : (ctx.localAgent.memberBinding?.(childSessionId) ?? ctx.localAgent.getDelegation(childSessionId))?.scope),
4720
5484
  catalog: modelCatalog,
4721
- settingsModel: resolveModel,
5485
+ settingsModel: resolveModelFor,
5486
+ modelList: (homeDir) => readModelListSync(homeDir),
4722
5487
  recentModels: () => face.get().recentModels ?? [],
4723
5488
  isLive: () => face.get().live,
4724
5489
  liveSwitch
4725
5490
  });
4726
- const disposeProvider = ctx.subagents.registerProvider(new KimiCliProvider(ctx, liveSwitch.resolve, resolveModel, broker));
5491
+ const disposeProvider = ctx.subagents.registerProvider(new KimiCliProvider(ctx, liveSwitch.resolve, resolveModel, broker, resolveEndpoint));
4727
5492
  const disposeHarness = ctx.localAgent.register({
4728
5493
  prepareMember: async ({ binding, childSession, configuration, signal }) => {
4729
5494
  const driver = liveSwitch.resolve(binding.childSessionId);
@@ -4740,31 +5505,76 @@ function apply(ctx, config) {
4740
5505
  displayName: "Kimi Code",
4741
5506
  homeEnvVar: "KIMI_CODE_HOME",
4742
5507
  delegationProvider: "kimi-cli",
4743
- login: {
4744
- command: "kimi",
4745
- args: ["login"]
4746
- },
4747
5508
  records: { listSessions: (homeDir) => listKimiSessions(homeDir) },
4748
5509
  provision: async (scopedHome) => {
4749
- await provisionKimiConfig(scopedHome, vol(config.model) ?? "kimi-code/k3", thinkingEffort);
5510
+ await provisionKimiConfig(scopedHome, vol(config.model) ?? "kimi-code/k3", thinkingEffort, config.baseUrl);
4750
5511
  await ensureKimiPermissions(scopedHome);
4751
5512
  },
4752
- isAuthenticated: kimiAuthenticated,
4753
- credentialStamp: kimiCredentialStamp,
4754
- logout: kimiLogout,
5513
+ isAuthenticated: async (home) => hasEndpointCredential(await resolveEndpoint(home)),
5514
+ checkEndpoint: (home) => resolveEndpoint(home).then((endpoint) => checkKimiEndpoint(endpoint)),
5515
+ endpointAuth: {
5516
+ read: async (home) => {
5517
+ const endpoint = await resolveEndpoint(home);
5518
+ return {
5519
+ ...endpoint.baseUrl === void 0 ? {} : { baseUrl: endpoint.baseUrl },
5520
+ apiKeySet: endpoint.apiKey !== void 0,
5521
+ authTokenSet: false
5522
+ };
5523
+ },
5524
+ write: async (home, fields) => {
5525
+ const next = { ...await readEndpointAuthFile(home) ?? {} };
5526
+ for (const [field, value] of Object.entries(fields)) {
5527
+ if (field !== "baseUrl" && field !== "apiKey") continue;
5528
+ if (value === void 0) continue;
5529
+ const trimmed = typeof value === "string" ? value.trim() : "";
5530
+ if (value === null || trimmed === "") delete next[field];
5531
+ else next[field] = trimmed;
5532
+ }
5533
+ if (Object.keys(next).length === 0) await clearEndpointAuth(home);
5534
+ else await writeEndpointAuth(home, next);
5535
+ const resolved = await resolveEndpoint(home);
5536
+ await ensureKimiEndpointProvider(home, hasEndpointCredential(resolved) ? resolved.baseUrl ?? "https://api.kimi.com/coding/v1" : void 0);
5537
+ }
5538
+ },
5539
+ credentialStamp: endpointAuthStamp,
5540
+ logout: async (home) => {
5541
+ if (await endpointAuthFileExists(home)) {
5542
+ await clearEndpointAuth(home);
5543
+ return;
5544
+ }
5545
+ throw new Error("kimi has no scoped endpoint-auth.json to clear — the credential comes from the settings card or the profile row (baseUrl/apiKey); clear it there instead");
5546
+ },
4755
5547
  subcommand: handleSubcommand,
4756
5548
  modelBroker: broker,
5549
+ modelList: {
5550
+ read: (home) => readModelList(home),
5551
+ write: async (home, models) => {
5552
+ const written = await writeModelList(home, models);
5553
+ await ensureKimiModelTables(home, written, (value) => {
5554
+ const entry = modelCatalog.readHome(home).entries.find((candidate) => candidate.value === value);
5555
+ return entry === void 0 ? void 0 : {
5556
+ label: entry.label,
5557
+ ...entry.reasoning === void 0 ? {} : {
5558
+ efforts: entry.reasoning.options.map((option) => option.value),
5559
+ ...entry.reasoning.default === void 0 ? {} : { defaultEffort: entry.reasoning.default }
5560
+ }
5561
+ };
5562
+ });
5563
+ return written;
5564
+ }
5565
+ },
4757
5566
  effectiveSettings: async (scopedHome) => {
4758
- const [reasoningEffort, baseUrl, autoApprove, defaultModel, cliVersion] = await Promise.all([
5567
+ const [reasoningEffort, endpoint, autoApprove, defaultModel, cliVersion] = await Promise.all([
4759
5568
  readKimiReasoningEffort(scopedHome).catch(() => void 0),
4760
- readKimiBaseUrl(scopedHome).catch(() => void 0),
5569
+ resolveEndpoint(scopedHome).catch(() => void 0),
4761
5570
  readKimiAutoApprove(scopedHome).catch(() => false),
4762
5571
  readKimiDefaultModel(scopedHome).catch(() => void 0),
4763
5572
  kimiCliVersion(ctx, scopedHome).catch(() => void 0)
4764
5573
  ]);
5574
+ const baseUrl = hasEndpointCredential(endpoint) ? endpoint.baseUrl ?? "https://api.kimi.com/coding/v1" : await readKimiBaseUrl(scopedHome).catch(() => void 0);
4765
5575
  const custom = baseUrl !== void 0 && baseUrl !== "https://api.kimi.com/coding/v1";
4766
5576
  const baseUrlHost = custom ? endpointHost(baseUrl) : void 0;
4767
- const model = resolveModel() ?? defaultModel;
5577
+ const model = resolveModelFor(scopedHome) ?? defaultModel;
4768
5578
  return {
4769
5579
  drive: face.get().live ? "live" : "exec",
4770
5580
  autoApprove,