@khorsheed/dsh-local-agent-kimi 0.2.0 → 0.2.2

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.
package/lib/index.js CHANGED
@@ -1960,6 +1960,93 @@ async function ensureKimiPermissions(homeDir) {
1960
1960
  await writeFile(file, `${content.trimEnd()}\n${KIMI_PERMISSION_BLOCK}`);
1961
1961
  }
1962
1962
  //#endregion
1963
+ //#region lib/types/member-bridge-config.js
1964
+ /**
1965
+ * Kimi scoped-home MCP config injection for the member bridge. Kimi declares
1966
+ * stdio MCP servers in `$KIMI_CODE_HOME/mcp.json` (`mcpServers` map); the file
1967
+ * is read once per CLI session start, and edits never interrupt an open
1968
+ * session — so the family writes one entry PER RUN (keyed by the run's token
1969
+ * suffix, keeping concurrent delegations of the same scoped home race-free),
1970
+ * prunes it when the run settles, and drops stale family entries (crashed
1971
+ * host) on every write. User-declared servers are preserved verbatim.
1972
+ * @module @khorsheed/dsh-local-agent-kimi/member-bridge-config
1973
+ */
1974
+ /** Kimi's user-level MCP config filename inside the scoped home. */
1975
+ const MCP_CONFIG_FILENAME = "mcp.json";
1976
+ /** The member bridge's per-call bound: as long as a question card waits for the human (30 min). */
1977
+ const MEMBER_TOOL_TIMEOUT_MS = 18e5;
1978
+ /** Server-key prefix marking the family's per-run bridge entries. */
1979
+ const MEMBER_BRIDGE_SERVER_PREFIX = "dsh-member-";
1980
+ /**
1981
+ * The per-run MCP server key. The run's token suffix keeps concurrent runs
1982
+ * distinct; each `kimi -p` round is a fresh CLI session, so per-run (rather
1983
+ * than per-member) naming costs nothing.
1984
+ * @param token - the run's member-channel token.
1985
+ * @returns the mcp.json server key.
1986
+ */
1987
+ function memberBridgeServerKey(token) {
1988
+ return `${MEMBER_BRIDGE_SERVER_PREFIX}${token.slice(0, 8)}`;
1989
+ }
1990
+ /** Read the scoped mcp.json; a missing or malformed file reads as empty. */
1991
+ function readMcpConfig(file) {
1992
+ let parsed;
1993
+ try {
1994
+ parsed = JSON.parse(readFileSync(file, "utf8"));
1995
+ } catch {
1996
+ return {};
1997
+ }
1998
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
1999
+ return parsed;
2000
+ }
2001
+ /** Write the config back, preserving every non-family key. */
2002
+ function writeMcpConfig(file, config) {
2003
+ writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
2004
+ }
2005
+ /**
2006
+ * Declare the member bridge for one run: set the per-run server entry and
2007
+ * prune other family entries (settled or crashed runs — an open session's
2008
+ * already-registered server is unaffected, per kimi's mid-session semantics).
2009
+ * Synchronous read-modify-write: single-process callers race-free by event
2010
+ * loop; the file is small and written once per run.
2011
+ * @param homeDir - the kimi scoped home.
2012
+ * @param key - this run's server key ({@link memberBridgeServerKey}).
2013
+ * @param command - the bridge spawn command (`node` + entry path).
2014
+ * @param env - the bridge environment (socket path + per-run token).
2015
+ */
2016
+ function injectMemberBridge(homeDir, key, command, env) {
2017
+ const file = join(homeDir, MCP_CONFIG_FILENAME);
2018
+ const config = readMcpConfig(file);
2019
+ const servers = { ...config.mcpServers ?? {} };
2020
+ for (const name of Object.keys(servers)) if (name.startsWith("dsh-member-") && name !== key) delete servers[name];
2021
+ servers[key] = {
2022
+ command: command.command,
2023
+ args: command.args,
2024
+ env,
2025
+ toolTimeoutMs: MEMBER_TOOL_TIMEOUT_MS
2026
+ };
2027
+ writeMcpConfig(file, {
2028
+ ...config,
2029
+ mcpServers: servers
2030
+ });
2031
+ }
2032
+ /**
2033
+ * Remove one run's bridge entry (settle path). Absent entries and a missing
2034
+ * config are no-ops.
2035
+ * @param homeDir - the kimi scoped home.
2036
+ * @param key - the run's server key.
2037
+ */
2038
+ function removeMemberBridge(homeDir, key) {
2039
+ const file = join(homeDir, MCP_CONFIG_FILENAME);
2040
+ const config = readMcpConfig(file);
2041
+ if (config.mcpServers === void 0 || !(key in config.mcpServers)) return;
2042
+ const servers = { ...config.mcpServers };
2043
+ delete servers[key];
2044
+ writeMcpConfig(file, {
2045
+ ...config,
2046
+ mcpServers: servers
2047
+ });
2048
+ }
2049
+ //#endregion
1963
2050
  //#region lib/types/session-view.js
1964
2051
  /**
1965
2052
  * Kimi session viewer: read a scoped-home session's `wire.jsonl` event log
@@ -2047,6 +2134,8 @@ function editArgsOf(name, record) {
2047
2134
  const patch = record["patch"] ?? record["diff"];
2048
2135
  if (typeof patch === "string" && patch.trim() !== "") return patch;
2049
2136
  }
2137
+ /** Cap on a structured tool call's mirrored JSON arguments. */
2138
+ const STRUCTURED_ARGS_MAX = 16e3;
2050
2139
  /** Render a tool call's arguments into a compact summary. */
2051
2140
  function argsOf(name, args) {
2052
2141
  if (args === void 0) return void 0;
@@ -2055,6 +2144,10 @@ function argsOf(name, args) {
2055
2144
  const record = args;
2056
2145
  const edit = editArgsOf(name, record);
2057
2146
  if (edit !== void 0) return edit;
2147
+ if (Object.values(record).some((value) => typeof value === "object" && value !== null)) {
2148
+ const json = JSON.stringify(record);
2149
+ return json.length <= STRUCTURED_ARGS_MAX ? json : `${json.slice(0, STRUCTURED_ARGS_MAX)}…`;
2150
+ }
2058
2151
  for (const key of [
2059
2152
  "query",
2060
2153
  "url",
@@ -2094,6 +2187,8 @@ async function readKimiTranscript(sessionDir) {
2094
2187
  };
2095
2188
  let turn = 0;
2096
2189
  let turnSeen = false;
2190
+ /** The wire marks rounds with turn.prompt (current kimi): a user message it did not prompt is an injection. */
2191
+ let promptsMarkTurns = false;
2097
2192
  const callsByUuid = /* @__PURE__ */ new Map();
2098
2193
  const callsByToolCallId = /* @__PURE__ */ new Map();
2099
2194
  let text;
@@ -2147,6 +2242,7 @@ async function readKimiTranscript(sessionDir) {
2147
2242
  continue;
2148
2243
  }
2149
2244
  if (event.type === "turn.prompt") {
2245
+ promptsMarkTurns = true;
2150
2246
  lineModel = void 0;
2151
2247
  turnSeen = true;
2152
2248
  turn += 1;
@@ -2165,6 +2261,7 @@ async function readKimiTranscript(sessionDir) {
2165
2261
  if (role === "user" && textContent.trim() !== "" && !isSystemReminder(textContent)) {
2166
2262
  const last = lines[lines.length - 1];
2167
2263
  if (turnSeen && last?.kind === "user" && last.text === textContent) continue;
2264
+ if (promptsMarkTurns) continue;
2168
2265
  turnSeen = true;
2169
2266
  turn += 1;
2170
2267
  lineModel = void 0;
@@ -2221,7 +2318,7 @@ async function readKimiTranscript(sessionDir) {
2221
2318
  if (typeof call.toolCallId === "string") callsByToolCallId.set(call.toolCallId, lineIndex);
2222
2319
  } else if (loop.type === "tool.result") {
2223
2320
  const result = loop;
2224
- const output = result.result?.output;
2321
+ const output = toolOutputText(result.result?.output);
2225
2322
  let target = typeof result.parentUuid === "string" ? callsByUuid.get(result.parentUuid) : void 0;
2226
2323
  if (target === void 0 && typeof result.toolCallId === "string") target = callsByToolCallId.get(result.toolCallId);
2227
2324
  if (target === void 0) {
@@ -2254,6 +2351,26 @@ async function readKimiTranscript(sessionDir) {
2254
2351
  };
2255
2352
  }
2256
2353
  /**
2354
+ * A wire tool result's output as display text. Most tools report a string;
2355
+ * a media read (ReadMediaFile) reports content parts — text parts keep their
2356
+ * text, an image part becomes a short marker (its bytes stay in kimi's blob
2357
+ * store). Anything else reads as no output, never a parse failure: one
2358
+ * unreadable result must not stop the whole transcript (and the round's
2359
+ * usage) from folding.
2360
+ * @param raw - the result's `output` field.
2361
+ * @returns the text, or undefined when there is none.
2362
+ */
2363
+ function toolOutputText(raw) {
2364
+ if (typeof raw === "string") return raw;
2365
+ if (!Array.isArray(raw)) return void 0;
2366
+ return raw.map((part) => {
2367
+ if (part === null || typeof part !== "object") return "";
2368
+ const entry = part;
2369
+ if (entry.type === "text" && typeof entry.text === "string") return entry.text;
2370
+ return typeof entry.type === "string" && entry.type !== "" ? `[${entry.type}]` : "";
2371
+ }).join("");
2372
+ }
2373
+ /**
2257
2374
  * Map a wire `usage.record` payload onto the shared usage contract. kimi
2258
2375
  * reports input (uncached), cache read, cache creation, and output; the
2259
2376
  * creation counter is its cache-write analog. Non-numeric or missing
@@ -2419,6 +2536,13 @@ function nextWrittenKimiStep(childSession, turn) {
2419
2536
  }
2420
2537
  return last + 1;
2421
2538
  }
2539
+ /** Wire usage records already counted, per child session (see mirrorKimiSessionDelta). */
2540
+ const countedUsage = /* @__PURE__ */ new WeakMap();
2541
+ const usageSeen = (childSession) => {
2542
+ let seen = countedUsage.get(childSession);
2543
+ if (seen === void 0) countedUsage.set(childSession, seen = /* @__PURE__ */ new Set());
2544
+ return seen;
2545
+ };
2422
2546
  /** The run-progress delta text for one transcript line (any kind). */
2423
2547
  function kimiLineProgressText(line) {
2424
2548
  if (line.kind !== "tool") return line.text;
@@ -2552,6 +2676,8 @@ async function mirrorKimiSessionDelta(ctx, childSession, homeDir, kimiSessionId,
2552
2676
  }
2553
2677
  const foldedUnits = holdAt < 0 ? units : units.slice(0, holdAt);
2554
2678
  const consumedTotal = fromLines + foldedUnits.reduce((n, unit) => n + unit.length, 0);
2679
+ const seen = usageSeen(childSession);
2680
+ const usageRecords = transcript.usageRecords.filter((record) => !seen.has(record.line));
2555
2681
  const unitUsage = [];
2556
2682
  const claimed = [];
2557
2683
  let unitStart = fromLines;
@@ -2564,11 +2690,13 @@ async function mirrorKimiSessionDelta(ctx, childSession, homeDir, kimiSessionId,
2564
2690
  from: start,
2565
2691
  to: unitStart
2566
2692
  });
2567
- unitUsage.push(sumUsageRecords(transcript.usageRecords.filter((record) => record.line >= start && record.line < unitStart)));
2693
+ unitUsage.push(sumUsageRecords(usageRecords.filter((record) => record.line >= start && record.line < unitStart)));
2568
2694
  } else unitUsage.push(void 0);
2569
2695
  }
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));
2696
+ const unclaimedUsage = sumUsageRecords(usageRecords.filter((record) => record.line >= fromLines && record.line <= consumedTotal && !claimed.some((span) => record.line >= span.from && record.line < span.to)));
2697
+ const windowRecords = usageRecords.filter((record) => record.line >= fromLines && record.line <= consumedTotal);
2698
+ const deltaUsage = sumUsageRecords(windowRecords);
2699
+ for (const record of windowRecords) seen.add(record.line);
2572
2700
  const takenSteps = /* @__PURE__ */ new Map();
2573
2701
  for (const event of childSession.snapshotEvents()) {
2574
2702
  const data = event.data;
@@ -2597,6 +2725,12 @@ async function mirrorKimiSessionDelta(ctx, childSession, homeDir, kimiSessionId,
2597
2725
  written.add(step);
2598
2726
  takenSteps.set(turn, written);
2599
2727
  };
2728
+ const closedTurns = /* @__PURE__ */ new Set();
2729
+ for (const event of childSession.snapshotEvents()) {
2730
+ if (event.type !== "turn/end") continue;
2731
+ const closed = event.data.turn;
2732
+ if (typeof closed === "number") closedTurns.add(closed);
2733
+ }
2600
2734
  /** The window's accounting that no fold carried (dropped units' sums). */
2601
2735
  let carryUsage;
2602
2736
  let lastContentUnit = -1;
@@ -2612,6 +2746,7 @@ async function mirrorKimiSessionDelta(ctx, childSession, homeDir, kimiSessionId,
2612
2746
  carryUsage = void 0;
2613
2747
  if (unitIndex === lastContentUnit) usage = addTokenUsage(usage, unclaimedUsage);
2614
2748
  const turn = head.turn;
2749
+ if (closedTurns.has(turn)) continue;
2615
2750
  if (head.kind === "user") {
2616
2751
  if (userAlreadyAppended(childSession, turn, head.text)) continue;
2617
2752
  childSession.append("user/message", userEvent(head.text), { surfaceOp: "append" });
@@ -2808,7 +2943,6 @@ function userAlreadyAppended(childSession, turn, text) {
2808
2943
  * mode's auto-approve behavior; the proposal's default policy).
2809
2944
  * @module @khorsheed/dsh-local-agent-kimi/live-driver
2810
2945
  */
2811
- /** Default idle lifetime of an unused resident runtime before reclaim. */
2812
2946
  const DEFAULT_LIVE_IDLE_MS = 18e5;
2813
2947
  /** Default timeout for a wire request acknowledgement (never for session/prompt). */
2814
2948
  const DEFAULT_LIVE_REQUEST_TIMEOUT_MS = 3e4;
@@ -3286,6 +3420,7 @@ var KimiAcpLiveDriver = class {
3286
3420
  graceMs: DEFAULT_DISPOSE_GRACE_MS,
3287
3421
  env: delegationEnv({
3288
3422
  KIMI_CODE_HOME: spec.homeDir,
3423
+ KIMI_MCP_TOOL_TIMEOUT_MS: String(MEMBER_TOOL_TIMEOUT_MS),
3289
3424
  ...endpointAuthEnv(endpoint)
3290
3425
  })
3291
3426
  };
@@ -3564,22 +3699,30 @@ var KimiAcpLiveDriver = class {
3564
3699
  * think-style lines. Each update carries the WHOLE plan, so the fold
3565
3700
  * replaces rather than appends.
3566
3701
  */
3567
- const renderPlan = (update) => {
3568
- return (Array.isArray(update["entries"]) ? update["entries"] : []).map((entry) => {
3569
- const content = typeof entry["content"] === "string" ? entry["content"] : "";
3570
- const status = entry["status"];
3571
- return `${status === "completed" ? "☑" : status === "in_progress" ? "▶" : "☐"} ${content}`;
3572
- }).filter((line) => line.trim() !== "☐" && line !== "").join("\n");
3573
- };
3702
+ /**
3703
+ * An ACP plan update is the member's todo list: written as the host's
3704
+ * `todo/write` whole-list snapshot (log-only UI state, Claude's TodoWrite
3705
+ * shape), only when it changed. Never a conversation step — a plan
3706
+ * folded as a step landed after the round's answer (it finalized at
3707
+ * settle) and the chat folded the answer into the process.
3708
+ */
3574
3709
  const foldPlan = (update) => {
3575
- const plan = renderPlan(update);
3576
- if (plan === "") return;
3577
- reserveStream(planKey, "think");
3578
- const stream = streams.get(planKey);
3579
- if (stream !== void 0 && stream.text !== plan) {
3580
- stream.text = plan;
3581
- publishStreamSnapshot(stream, true);
3710
+ const entries = Array.isArray(update["entries"]) ? update["entries"] : [];
3711
+ const todos = [];
3712
+ for (const entry of entries) {
3713
+ const content = typeof entry["content"] === "string" ? entry["content"].trim() : "";
3714
+ if (content === "") continue;
3715
+ const status = entry["status"];
3716
+ todos.push({
3717
+ content,
3718
+ status: status === "in_progress" || status === "completed" ? status : "pending"
3719
+ });
3582
3720
  }
3721
+ if (todos.length === 0) return;
3722
+ const last = childSession.snapshotEvents().filter((event) => event.type === "todo/write").at(-1);
3723
+ if (last !== void 0 && JSON.stringify(last.data) === JSON.stringify({ todos })) return;
3724
+ childSession.append("todo/write", { todos });
3725
+ persist();
3583
3726
  };
3584
3727
  /**
3585
3728
  * The live-output channel. Snapshots are browser-only previews (never
@@ -3919,7 +4062,7 @@ var KimiAcpLiveDriver = class {
3919
4062
  }
3920
4063
  }
3921
4064
  } else if (kind === "tool_call") {
3922
- const id = typeof update["toolCallId"] === "string" ? update["toolCallId"] : void 0;
4065
+ const id = typeof update["toolCallId"] === "string" ? update["toolCallId"].replace(/^\d+:/, "") : void 0;
3923
4066
  if (id === void 0 || !seenToolCalls.has(id)) {
3924
4067
  if (id !== void 0 && toolLogged(id)) seenToolCalls.add(id);
3925
4068
  else if (id !== void 0) {
@@ -4089,7 +4232,16 @@ var KimiAcpLiveDriver = class {
4089
4232
  });
4090
4233
  result.then(() => {
4091
4234
  if (turnOpened) {
4092
- const round = { ...roundModel === void 0 ? {} : { observedModel: roundModel } };
4235
+ let usage;
4236
+ for (const event of childSession.snapshotEvents()) {
4237
+ if (event.type !== "assistant/message") continue;
4238
+ const data = event.data;
4239
+ if (data.turn === turn && data.usage !== void 0) usage = addTokenUsage(usage, data.usage);
4240
+ }
4241
+ const round = {
4242
+ ...roundModel === void 0 ? {} : { observedModel: roundModel },
4243
+ ...usage === void 0 ? {} : { usage }
4244
+ };
4093
4245
  localAgent?.recordRoundSettled?.(childSession.id, round);
4094
4246
  }
4095
4247
  this.armIdleTimer(String(childSession.id));
@@ -4113,90 +4265,6 @@ var KimiAcpLiveDriver = class {
4113
4265
  /** The member handle each runtime carries (for its session declarations). */
4114
4266
  const runtimeMember = /* @__PURE__ */ new WeakMap();
4115
4267
  //#endregion
4116
- //#region lib/types/member-bridge-config.js
4117
- /**
4118
- * Kimi scoped-home MCP config injection for the member bridge. Kimi declares
4119
- * stdio MCP servers in `$KIMI_CODE_HOME/mcp.json` (`mcpServers` map); the file
4120
- * is read once per CLI session start, and edits never interrupt an open
4121
- * session — so the family writes one entry PER RUN (keyed by the run's token
4122
- * suffix, keeping concurrent delegations of the same scoped home race-free),
4123
- * prunes it when the run settles, and drops stale family entries (crashed
4124
- * host) on every write. User-declared servers are preserved verbatim.
4125
- * @module @khorsheed/dsh-local-agent-kimi/member-bridge-config
4126
- */
4127
- /** Kimi's user-level MCP config filename inside the scoped home. */
4128
- const MCP_CONFIG_FILENAME = "mcp.json";
4129
- /** Server-key prefix marking the family's per-run bridge entries. */
4130
- const MEMBER_BRIDGE_SERVER_PREFIX = "dsh-member-";
4131
- /**
4132
- * The per-run MCP server key. The run's token suffix keeps concurrent runs
4133
- * distinct; each `kimi -p` round is a fresh CLI session, so per-run (rather
4134
- * than per-member) naming costs nothing.
4135
- * @param token - the run's member-channel token.
4136
- * @returns the mcp.json server key.
4137
- */
4138
- function memberBridgeServerKey(token) {
4139
- return `${MEMBER_BRIDGE_SERVER_PREFIX}${token.slice(0, 8)}`;
4140
- }
4141
- /** Read the scoped mcp.json; a missing or malformed file reads as empty. */
4142
- function readMcpConfig(file) {
4143
- let parsed;
4144
- try {
4145
- parsed = JSON.parse(readFileSync(file, "utf8"));
4146
- } catch {
4147
- return {};
4148
- }
4149
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
4150
- return parsed;
4151
- }
4152
- /** Write the config back, preserving every non-family key. */
4153
- function writeMcpConfig(file, config) {
4154
- writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
4155
- }
4156
- /**
4157
- * Declare the member bridge for one run: set the per-run server entry and
4158
- * prune other family entries (settled or crashed runs — an open session's
4159
- * already-registered server is unaffected, per kimi's mid-session semantics).
4160
- * Synchronous read-modify-write: single-process callers race-free by event
4161
- * loop; the file is small and written once per run.
4162
- * @param homeDir - the kimi scoped home.
4163
- * @param key - this run's server key ({@link memberBridgeServerKey}).
4164
- * @param command - the bridge spawn command (`node` + entry path).
4165
- * @param env - the bridge environment (socket path + per-run token).
4166
- */
4167
- function injectMemberBridge(homeDir, key, command, env) {
4168
- const file = join(homeDir, MCP_CONFIG_FILENAME);
4169
- const config = readMcpConfig(file);
4170
- const servers = { ...config.mcpServers ?? {} };
4171
- for (const name of Object.keys(servers)) if (name.startsWith("dsh-member-") && name !== key) delete servers[name];
4172
- servers[key] = {
4173
- command: command.command,
4174
- args: command.args,
4175
- env
4176
- };
4177
- writeMcpConfig(file, {
4178
- ...config,
4179
- mcpServers: servers
4180
- });
4181
- }
4182
- /**
4183
- * Remove one run's bridge entry (settle path). Absent entries and a missing
4184
- * config are no-ops.
4185
- * @param homeDir - the kimi scoped home.
4186
- * @param key - the run's server key.
4187
- */
4188
- function removeMemberBridge(homeDir, key) {
4189
- const file = join(homeDir, MCP_CONFIG_FILENAME);
4190
- const config = readMcpConfig(file);
4191
- if (config.mcpServers === void 0 || !(key in config.mcpServers)) return;
4192
- const servers = { ...config.mcpServers };
4193
- delete servers[key];
4194
- writeMcpConfig(file, {
4195
- ...config,
4196
- mcpServers: servers
4197
- });
4198
- }
4199
- //#endregion
4200
4268
  //#region lib/types/kimi-cli-provider.js
4201
4269
  /**
4202
4270
  * One-shot and resumable Kimi CLI subagent lifecycle: spawn `kimi -p "<task>"`
@@ -5344,7 +5412,7 @@ const inject = [
5344
5412
  * the user layer exactly as before.
5345
5413
  */
5346
5414
  const SETTINGS_FIELDS = {
5347
- live: z.boolean().default(false),
5415
+ live: z.boolean().default(true),
5348
5416
  liveMirrorGranularity: z.union([z.const("event"), z.const("token")]).default("token"),
5349
5417
  model: z.string(),
5350
5418
  recentModels: z.array(z.string()).default([])
@@ -5438,7 +5506,7 @@ function apply(ctx, config) {
5438
5506
  const model = vol(config.model);
5439
5507
  const recentModels = vol(config.recentModels);
5440
5508
  return {
5441
- live: vol(config.live) ?? false,
5509
+ live: vol(config.live) ?? true,
5442
5510
  liveMirrorGranularity: vol(config.liveMirrorGranularity) ?? "token",
5443
5511
  ...model === void 0 ? {} : { model },
5444
5512
  ...recentModels === void 0 ? {} : { recentModels }
@@ -5455,6 +5523,7 @@ function apply(ctx, config) {
5455
5523
  }
5456
5524
  const face = settingsFace(ctx, {
5457
5525
  namespace: KIMI_SETTINGS_NAMESPACE,
5526
+ harness: "kimi",
5458
5527
  legacySchema: KIMI_SETTINGS_SCHEMA,
5459
5528
  legacyBase,
5460
5529
  read: readSettings
@@ -7,7 +7,7 @@ import { IconChevronDownOutlineMedium } from './icons.js';
7
7
  // the block carries no runtime identity to share.
8
8
  import { ProviderAuthBlock, } from '@khorsheed/dsh-local-agent/src/client/ProviderAuthBlock.tsx';
9
9
  import { AuthStatusDot } from '@khorsheed/dsh-local-agent/src/client/AuthStatusDot.tsx';
10
- import { publishAuthStatus, useHarnessAuthStatus } from '@khorsheed/dsh-local-agent/src/client/auth-status.ts';
10
+ import { publishAuthStatus, useHarnessAuthStatus, usePublishedAuthStatus } from '@khorsheed/dsh-local-agent/src/client/auth-status.ts';
11
11
  import { ModelListSection } from '@khorsheed/dsh-local-agent/src/client/ModelListSection.tsx';
12
12
  import css from './SettingsCard.module.css';
13
13
  /**
@@ -43,6 +43,10 @@ function useCardState(active, useSettings, scope, endpointAuth) {
43
43
  const endpointUnavailable = endpointAuth === undefined || endpointState === 'unavailable';
44
44
  const endpointLoading = !endpointUnavailable && endpointState === null;
45
45
  const apiKeyConfigured = endpointState !== null && endpointState !== 'unavailable' && endpointState.apiKeySet;
46
+ // A login or logout elsewhere in the card (the auth block) changes what
47
+ // the store holds — logout deletes the saved key: re-read, or the form
48
+ // keeps saying 已配置 for a key that is gone.
49
+ const authPublished = usePublishedAuthStatus('kimi');
46
50
  useEffect(() => {
47
51
  if (!active || endpointAuth === undefined)
48
52
  return;
@@ -51,7 +55,7 @@ function useCardState(active, useSettings, scope, endpointAuth) {
51
55
  setEndpointState(state ?? 'unavailable'); }, () => { if (!stale)
52
56
  setEndpointState('unavailable'); });
53
57
  return () => { stale = true; };
54
- }, [active, endpointAuth, endpointSaved]);
58
+ }, [active, endpointAuth, endpointSaved, authPublished]);
55
59
  // The credential save: one `configureEndpoint` call carrying only the
56
60
  // dirty fields (a blank draft clears its field); the answer is the
57
61
  // post-write resolved state, so the chips and the auth probe flip without
@@ -81,7 +85,11 @@ function useCardState(active, useSettings, scope, endpointAuth) {
81
85
  const write = (field, value) => {
82
86
  setSaved(false);
83
87
  setError(false);
84
- void scope.set(field, value).then(() => { setSaved(true); }, () => { setError(true); });
88
+ // The host answers a refused write with `false` (ConfigForm), not a rejection.
89
+ void scope.set(field, value).then((accepted) => { if (accepted === false)
90
+ setError(true);
91
+ else
92
+ setSaved(true); }, () => { setError(true); });
85
93
  };
86
94
  return {
87
95
  snapshot, saved, error, ready, live, write,
@@ -1,4 +1,4 @@
1
- import { bindSettingsScope } from '@khorsheed/dsh-local-agent/src/client/settings-scope.ts';
1
+ import { bindCardSettingsScope } from '@khorsheed/dsh-local-agent/src/client/settings-scope.ts';
2
2
  /**
3
3
  * Local-agent-kimi plugin, browser half: the kimi settings card — a section
4
4
  * of the family core's「本机 Agent 委派」family card on the Plugins page
@@ -44,7 +44,7 @@ export const inject = ['slots', 'remote', 'remote.commands', 'locale'];
44
44
  export function apply(ctx) {
45
45
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'local-agent-kimi: dictionaries');
46
46
  const t = ctx.locale.bind(NS);
47
- const scope = bindSettingsScope(ctx, 'local-agent-kimi');
47
+ const scope = bindCardSettingsScope(ctx, 'kimi', 'local-agent-kimi');
48
48
  // The auth block's copy lives in the family core's dictionary; binding is
49
49
  // stable per namespace and late dictionary registration still resolves.
50
50
  const authT = ctx.locale.bind('local-agent');
@@ -40,7 +40,7 @@ export const inject = ['localAgent', 'subagents', 'subprocess', 'settings'];
40
40
  * the user layer exactly as before.
41
41
  */
42
42
  const SETTINGS_FIELDS = {
43
- live: z.boolean().default(false),
43
+ live: z.boolean().default(true),
44
44
  liveMirrorGranularity: z.union([z.const('event'), z.const('token')]).default('token'),
45
45
  // `model` deliberately carries NO default: an unset key must resolve to
46
46
  // undefined, which is what keeps the pre-key behavior byte-identical.
@@ -161,7 +161,7 @@ export function apply(ctx, config) {
161
161
  const model = vol(config.model);
162
162
  const recentModels = vol(config.recentModels);
163
163
  return {
164
- live: vol(config.live) ?? false,
164
+ live: vol(config.live) ?? true,
165
165
  liveMirrorGranularity: vol(config.liveMirrorGranularity) ?? 'token',
166
166
  ...model === undefined ? {} : { model },
167
167
  ...recentModels === undefined ? {} : { recentModels },
@@ -175,6 +175,8 @@ export function apply(ctx, config) {
175
175
  }
176
176
  const face = settingsFace(ctx, {
177
177
  namespace: KIMI_SETTINGS_NAMESPACE,
178
+ // Card writes live in the harness's own store (card-settings.ts), never the profile patch.
179
+ harness: 'kimi',
178
180
  legacySchema: KIMI_SETTINGS_SCHEMA,
179
181
  legacyBase,
180
182
  read: readSettings,
@@ -42,7 +42,6 @@ import type { Context } from '@deepseek-ai/cordis';
42
42
  import type { Session } from '@deepseek-ai/dsh-session';
43
43
  import { type SubagentRun, type SubagentStartRequest, type SubagentStopReason } from '@deepseek-ai/dsh-subagent';
44
44
  import { type KimiEndpointAuth } from './endpoint-auth.ts';
45
- /** Default idle lifetime of an unused resident runtime before reclaim. */
46
45
  export declare const DEFAULT_LIVE_IDLE_MS: number;
47
46
  /** Default timeout for a wire request acknowledgement (never for session/prompt). */
48
47
  export declare const DEFAULT_LIVE_REQUEST_TIMEOUT_MS = 30000;
@@ -47,11 +47,11 @@ import { delegationEnv, persistChildSession, LiveStreamPublisher, LiveFlush, LIV
47
47
  import { endpointAuthEnv, hasEndpointCredential } from './endpoint-auth.js';
48
48
  import { ensureKimiEndpointProvider, KIMI_MANAGED_BASE_URL } from './provision.js';
49
49
  import { MEMBER_BRIDGE_SOCKET_ENV, MEMBER_BRIDGE_TOKEN_ENV } from '@khorsheed/dsh-local-agent/types';
50
+ import { MEMBER_TOOL_TIMEOUT_MS } from './member-bridge-config.js';
50
51
  import { DEFAULT_DISPOSE_GRACE_MS, mirrorKimiDelta, textTask, } from './kimi-cli-provider.js';
51
52
  import { assistantEvent, KIMI_INTERRUPTED_TOOL_RESULT, nextKimiSessionStep, nextWrittenKimiStep } from './session-mirror.js';
52
53
  import { addTokenUsage } from './session-view.js';
53
54
  import { writeKimiDefaultModel } from './provision.js';
54
- /** Default idle lifetime of an unused resident runtime before reclaim. */
55
55
  export const DEFAULT_LIVE_IDLE_MS = 30 * 60_000;
56
56
  /** Default timeout for a wire request acknowledgement (never for session/prompt). */
57
57
  export const DEFAULT_LIVE_REQUEST_TIMEOUT_MS = 30_000;
@@ -559,7 +559,10 @@ export class KimiAcpLiveDriver {
559
559
  cwd: spec.cwd,
560
560
  stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
561
561
  graceMs: DEFAULT_DISPOSE_GRACE_MS,
562
- env: delegationEnv({ KIMI_CODE_HOME: spec.homeDir, ...endpointAuthEnv(endpoint) }),
562
+ // The ACP session declares the member bridge inline, and ACP's MCP
563
+ // server shape carries no timeout: lift kimi's 60s MCP default for the
564
+ // whole process, or a question card dies before the human answers.
565
+ env: delegationEnv({ KIMI_CODE_HOME: spec.homeDir, KIMI_MCP_TOOL_TIMEOUT_MS: String(MEMBER_TOOL_TIMEOUT_MS), ...endpointAuthEnv(endpoint) }),
563
566
  };
564
567
  let child;
565
568
  try {
@@ -877,30 +880,30 @@ export class KimiAcpLiveDriver {
877
880
  * think-style lines. Each update carries the WHOLE plan, so the fold
878
881
  * replaces rather than appends.
879
882
  */
880
- const renderPlan = (update) => {
883
+ /**
884
+ * An ACP plan update is the member's todo list: written as the host's
885
+ * `todo/write` whole-list snapshot (log-only UI state, Claude's TodoWrite
886
+ * shape), only when it changed. Never a conversation step — a plan
887
+ * folded as a step landed after the round's answer (it finalized at
888
+ * settle) and the chat folded the answer into the process.
889
+ */
890
+ const foldPlan = (update) => {
881
891
  const entries = Array.isArray(update['entries']) ? update['entries'] : [];
882
- const lines = entries.map((entry) => {
883
- const content = typeof entry['content'] === 'string' ? entry['content'] : '';
892
+ const todos = [];
893
+ for (const entry of entries) {
894
+ const content = typeof entry['content'] === 'string' ? entry['content'].trim() : '';
895
+ if (content === '')
896
+ continue;
884
897
  const status = entry['status'];
885
- const mark = status === 'completed' ? '☑' : status === 'in_progress' ? '▶' : '☐';
886
- return `${mark} ${content}`;
887
- }).filter(line => line.trim() !== '☐' && line !== '');
888
- return lines.join('\n');
889
- };
890
- const foldPlan = (update) => {
891
- const plan = renderPlan(update);
892
- if (plan === '')
893
- return;
894
- // The plan's own snapshot stream (think-styled): every update replaces
895
- // the text and publishes at the reserved (turn, step); the stream
896
- // finalizes at settle (the wire carries no plan line that could
897
- // complete it) — or early, when a fold needs its step closed.
898
- reserveStream(planKey, 'think');
899
- const stream = streams.get(planKey);
900
- if (stream !== undefined && stream.text !== plan) {
901
- stream.text = plan;
902
- publishStreamSnapshot(stream, true);
898
+ todos.push({ content, status: status === 'in_progress' || status === 'completed' ? status : 'pending' });
903
899
  }
900
+ if (todos.length === 0)
901
+ return;
902
+ const last = childSession.snapshotEvents().filter(event => event.type === 'todo/write').at(-1);
903
+ if (last !== undefined && JSON.stringify(last.data) === JSON.stringify({ todos }))
904
+ return;
905
+ childSession.append('todo/write', { todos });
906
+ persist();
904
907
  };
905
908
  /**
906
909
  * The live-output channel. Snapshots are browser-only previews (never
@@ -1265,7 +1268,12 @@ export class KimiAcpLiveDriver {
1265
1268
  }
1266
1269
  }
1267
1270
  else if (kind === 'tool_call') {
1268
- const id = typeof update['toolCallId'] === 'string' ? update['toolCallId'] : undefined;
1271
+ // ACP prefixes the call id with kimi's turn index ("1:tool_x") from the
1272
+ // second round on; the wire records the bare id. Unprefixed, the two
1273
+ // name one call — otherwise the fold never releases the reservation
1274
+ // and settle re-writes the call as an interrupted orphan after the
1275
+ // answer (the chat then folds the answer into the process).
1276
+ const id = typeof update['toolCallId'] === 'string' ? update['toolCallId'].replace(/^\d+:/, '') : undefined;
1269
1277
  if (id === undefined || !seenToolCalls.has(id)) {
1270
1278
  // The wire flush can win the race: a call already folded into the
1271
1279
  // log (its notification arriving late) needs no reservation — one
@@ -1488,8 +1496,20 @@ export class KimiAcpLiveDriver {
1488
1496
  // a round that never opened owns no span to observe. Degrades
1489
1497
  // silently on a core predating recordRoundSettled.
1490
1498
  if (turnOpened) {
1499
+ // The round's spend for the room's usage chip: every folded step of
1500
+ // this turn carries its own usage (the uncarried rest rides the last
1501
+ // one), so the turn's sum is the round total.
1502
+ let usage;
1503
+ for (const event of childSession.snapshotEvents()) {
1504
+ if (event.type !== 'assistant/message')
1505
+ continue;
1506
+ const data = event.data;
1507
+ if (data.turn === turn && data.usage !== undefined)
1508
+ usage = addTokenUsage(usage, data.usage);
1509
+ }
1491
1510
  const round = {
1492
1511
  ...roundModel === undefined ? {} : { observedModel: roundModel },
1512
+ ...usage === undefined ? {} : { usage },
1493
1513
  };
1494
1514
  const registry = localAgent;
1495
1515
  registry?.recordRoundSettled?.(childSession.id, round);
@@ -8,6 +8,8 @@
8
8
  * host) on every write. User-declared servers are preserved verbatim.
9
9
  * @module @khorsheed/dsh-local-agent-kimi/member-bridge-config
10
10
  */
11
+ /** The member bridge's per-call bound: as long as a question card waits for the human (30 min). */
12
+ export declare const MEMBER_TOOL_TIMEOUT_MS: number;
11
13
  /** Server-key prefix marking the family's per-run bridge entries. */
12
14
  export declare const MEMBER_BRIDGE_SERVER_PREFIX = "dsh-member-";
13
15
  /**