ladder-mcp 1.1.0 → 1.1.4

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/CHANGELOG.md CHANGED
@@ -4,6 +4,75 @@ All notable changes to Ladder_mcp are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/); this project uses
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [1.1.4] - 2026-06-28
8
+
9
+ Make the live-progress line worth watching.
10
+
11
+ ### Changed
12
+
13
+ - Live progress now shows a readable content **preview** of the streamed
14
+ `message`/`thought` text (whitespace collapsed, code-point-safe truncation with
15
+ an ellipsis) instead of a bare `(N chars)` counter.
16
+ - Progress emission is throttled to at most one update per ~1.2s, so the single
17
+ progress line stops flickering and each line stays readable. `tool_call`,
18
+ `tool_update`, and `plan` events remain immediate and flush any pending preview
19
+ first to preserve order.
20
+ - The two per-transport coalescers (CLI and ACP) are unified into one shared
21
+ `createProgressCoalescer` in `src/progress.ts`; the duplicated `COALESCE_*`
22
+ constants are gone.
23
+
24
+ ### Notes
25
+
26
+ - Kimi CLI 0.20.1 does not stream thinking to stderr during `-p` runs, so live
27
+ `thought` progress is not available on the CLI transport (documented in code).
28
+ The ACP transport already emits `thought`, which now gets the preview treatment.
29
+
30
+ ## [1.1.3] - 2026-06-28
31
+
32
+ Bugfix: analysis-mode `kimi_code` was broken against Kimi CLI 0.20.1.
33
+
34
+ ### Fixed
35
+
36
+ - `kimi_code` without `edit: true` (the default analysis mode) crashed against
37
+ Kimi CLI 0.20.1 with `Cannot combine --prompt with --plan`. `buildKimiArgs` no
38
+ longer passes `--plan` in `-p` prompt mode (the CLI rejects it, along with
39
+ `--auto`/`-y`).
40
+
41
+ ### Changed
42
+
43
+ - Because Kimi 0.20.1 has no `-p`-compatible read-only flag, the `edit: false`
44
+ analysis-only contract is now enforced at the prompt level: `runKimi` prepends
45
+ a read-only guard (`applyReadOnlyGuard`) when `edit` is not `true`. This is
46
+ advisory (the model is instructed not to edit) rather than a hard CLI
47
+ guarantee; the `edit` parameter description documents the change.
48
+
49
+ ## [1.1.2] - 2026-06-28
50
+
51
+ Live-progress and cancellation reliability for `kimi_code`.
52
+
53
+ ### Added
54
+
55
+ - The CLI transport now surfaces the running action: `stream-json` `tool_calls`
56
+ records emit immediate `tool_call` progress events (e.g. `Read src/types.ts`)
57
+ and `plan` records emit `plan` events, matching the ACP transport. A CLI job
58
+ no longer looks idle while it works.
59
+
60
+ ### Fixed
61
+
62
+ - Cancelling a `kimi_code` call now actually kills the underlying `kimi.exe`
63
+ process in every mode (foreground CLI, background CLI, foreground ACP).
64
+ Previously only background ACP honored cancellation; the others leaked the
65
+ child until the timeout fired. `runKimi` accepts an `AbortSignal`, skips
66
+ spawning when already aborted, and kills the process tree on abort.
67
+ - The ACP client is hardened against a `close()`-before-`start()` race, and an
68
+ aborted ACP run now reports `Kimi cancelled` instead of a raw exit code.
69
+
70
+ ### Changed
71
+
72
+ - `kimi_code`'s `transport` and `background` parameter descriptions now explain
73
+ the cli/acp trade-off and where to read the full progress log, so agents pick
74
+ a mode deliberately.
75
+
7
76
  ## [1.1.0] - 2026-06-28
8
77
 
9
78
  Breaking tool-surface redesign: the MCP tool list is now an intent-first set of
package/README.md CHANGED
@@ -6,7 +6,7 @@ client like Claude Code can run codebase analysis, native sessions, API
6
6
  queries, ACP chat, background tasks, and CLI admin/diagnostics — all on Windows
7
7
  without hardcoded POSIX assumptions.
8
8
 
9
- > Status: **v1.0.1** ([npm](https://www.npmjs.com/package/ladder-mcp)).
9
+ > Status: **v1.1.4** ([npm](https://www.npmjs.com/package/ladder-mcp)).
10
10
  > Supported platform is **Windows 11 only**.
11
11
 
12
12
  ## Requirements
@@ -19,7 +19,7 @@ without hardcoded POSIX assumptions.
19
19
  ## Quick start (from npm)
20
20
 
21
21
  You don't need to clone or build — the package is published on npm and your MCP
22
- client launches it via `npx`.
22
+ client launches it via `npx`, or you can install the package directly.
23
23
 
24
24
  **Claude Code (one command):**
25
25
 
@@ -50,7 +50,16 @@ Then in Claude Code run `/mcp` (should show `kimi-code: connected`) and call
50
50
  Prefer a global install? `npm install -g ladder-mcp`, then use `ladder-mcp` as the
51
51
  command instead of `npx -y ladder-mcp`.
52
52
 
53
- To let Kimi Code itself host this server, use the `kimi_generate_mcp_config`
53
+ Or install locally into your project:
54
+
55
+ ```bash
56
+ npm install ladder-mcp
57
+ ```
58
+
59
+ Then point your MCP config at `./node_modules/.bin/ladder-mcp` (or use
60
+ `npx -y ladder-mcp`, which resolves the locally installed copy when available).
61
+
62
+ To let Kimi Code itself host this server, use the `kimi_setup`
54
63
  tool to produce/merge a `.kimi-code/mcp.json` entry.
55
64
 
56
65
  ## Build from source (contributors)
@@ -63,26 +72,23 @@ npm run build # compiles src/ -> dist/ (tests excluded)
63
72
  Quick checks:
64
73
 
65
74
  ```bash
66
- npm test # vitest (50 tests)
75
+ npm test # vitest (163 tests)
67
76
  npm run typecheck # tsc --noEmit (incl. tests)
68
77
  npm run dev # run the server from source via tsx
69
78
  ```
70
79
 
71
80
  ## Tools
72
81
 
73
- **Core (v1)** — `kimi_analyze`, `kimi_query`, `kimi_verify`, `kimi_resume`,
74
- `kimi_list_sessions`, `kimi_status`
75
-
76
- **CLI admin & capabilities** — `kimi_capabilities`, `kimi_doctor`,
77
- `kimi_provider_list`, `kimi_export_session`, `kimi_visualize_session`
78
-
79
- **ACP (chat over stdio)** — `kimi_chat`, `kimi_acp_sessions`, `kimi_cancel`
82
+ **Core (v1.1)** — `kimi_code`, `kimi_ask`, `kimi_sessions`, `kimi_tasks`,
83
+ `kimi_status`, `kimi_setup`
80
84
 
81
- **Background tasks** `kimi_task_status`, `kimi_task_output`, `kimi_task_cancel`
82
- (set `background=true` on `kimi_chat`)
85
+ `kimi_code` supports both the native CLI transport (default) and the ACP
86
+ JSON-RPC transport (`transport: 'acp'`); set `background=true` to track long
87
+ work as a background task.
83
88
 
84
- **Kimi-hosted config & desktop (read-only)** — `kimi_generate_mcp_config`,
85
- `kimi_desktop_status`, `kimi_budget_probe`
89
+ **Experimental (off by default)** — `kimi_export_session`,
90
+ `kimi_visualize_session`, `kimi_desktop_status`, `kimi_budget_probe`. Enable
91
+ with the environment variable `LADDER_EXPERIMENTAL=1`.
86
92
 
87
93
  ## Safety boundaries
88
94
 
package/dist/index.js CHANGED
@@ -61,9 +61,9 @@ server.tool('kimi_code', 'Agentic work in a repository: analyze and edit files.
61
61
  work_dir: z.string().describe('Absolute path to the codebase root directory.'),
62
62
  session_id: z.string().optional().describe('Explicit Kimi session id to resume.'),
63
63
  new_session: z.boolean().optional().describe('Start fresh instead of continuing the last session. Default: false.'),
64
- edit: z.boolean().optional().describe('Allow file modifications. Default: false (analysis-only intent).'),
65
- background: z.boolean().optional().describe('Track as a long-running background task.'),
66
- transport: z.enum(['cli', 'acp']).optional().describe("Transport: 'cli' edits in-process; 'acp' uses JSON-RPC. Default: 'cli'."),
64
+ edit: z.boolean().optional().describe('Allow file modifications. Default: false (analysis-only intent). On Kimi 0.20.1 this is prompt-enforced (advisory): when false/omitted the prompt is prefixed with a read-only guard, but the CLI itself provides no hard read-only flag for -p mode.'),
65
+ background: z.boolean().optional().describe('Track as a long-running background task. Every progress event (including each action) is appended to the task log, readable via kimi_tasks — the full accumulating transcript, unlike the single overwriting live-progress line of a foreground call.'),
66
+ transport: z.enum(['cli', 'acp']).optional().describe("How the server drives Kimi. Both edit files and resume sessions; they differ in robustness and live-progress detail. 'cli' (default): one-shot process, most robust on Windows, but live progress is coarse — only streaming-output volume, no per-action lines. 'acp': persistent JSON-RPC session that emits granular live progress (tool calls, plan steps) and supports interactive permission prompts, but is heavier and more fragile. Prefer 'cli' for plain codegen/analysis; choose 'acp' when you need to watch each action live or handle mid-run prompts."),
67
67
  session_mode: z.enum(['new', 'load', 'resume']).optional().describe("ACP session mode when transport='acp'. Default: inferred from session_id/new_session."),
68
68
  detail_level: z.enum(['summary', 'normal', 'detailed']).optional(),
69
69
  max_output_tokens: z.number().optional(),
@@ -89,6 +89,7 @@ server.tool('kimi_code', 'Agentic work in a repository: analyze and edit files.
89
89
  maxOutputChars: maxChars(max_output_tokens),
90
90
  includeThinking: includeThinkingValue,
91
91
  onProgress: appendReporter(append),
92
+ signal: _signal,
92
93
  });
93
94
  if (!result.ok)
94
95
  throw new Error(result.error ?? 'CLI code task failed');
@@ -106,6 +107,7 @@ server.tool('kimi_code', 'Agentic work in a repository: analyze and edit files.
106
107
  maxOutputChars: maxChars(max_output_tokens),
107
108
  includeThinking: includeThinkingValue,
108
109
  onProgress: createMcpProgressReporter(extra),
110
+ signal: extra?.signal,
109
111
  });
110
112
  if (!result.ok)
111
113
  return textResponse(`Error: ${result.error}`, true);
@@ -135,7 +137,7 @@ server.tool('kimi_code', 'Agentic work in a repository: analyze and edit files.
135
137
  });
136
138
  return textResponse(JSON.stringify(task, null, 2));
137
139
  }
138
- const result = await runAcp(undefined, createMcpProgressReporter(extra));
140
+ const result = await runAcp(extra?.signal, createMcpProgressReporter(extra));
139
141
  return textResponse(JSON.stringify(result, null, 2), !result.ok);
140
142
  });
141
143
  server.tool('kimi_ask', 'Stateless question or independent review. Text only, no repo, no edits.', {
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import * as path from 'node:path';
3
3
  import { clampTimeout } from './transports/acp.js';
4
4
  import { buildKimiEnv, resolveKimiPaths } from './environment.js';
5
- import { createStallWatchdog, makeEvent } from './progress.js';
5
+ import { createProgressCoalescer, createStallWatchdog } from './progress.js';
6
6
  const MAX_CAPTURE_CHARS = 16 * 1024 * 1024;
7
7
  const MAX_FAILURE_STREAM_CHARS = 2_000;
8
8
  // Append to a captured stream while enforcing a hard ceiling so a runaway CLI cannot
@@ -22,11 +22,21 @@ export function buildKimiArgs(config) {
22
22
  else if (config.continueLast) {
23
23
  args.push('-C');
24
24
  }
25
- if (config.edit !== true) {
26
- args.push('--plan');
27
- }
25
+ // Kimi CLI 0.20.1 rejects combining --plan with -p/--prompt (non-interactive
26
+ // prompt mode), and the other restriction flags (--auto, -y) are also
27
+ // incompatible. For edit:false / omitted edit we therefore pass no restriction
28
+ // flag and rely on the prompt/system to avoid edits.
28
29
  return args;
29
30
  }
31
+ const READ_ONLY_GUARD = '[READ-ONLY ANALYSIS MODE] Do not create, modify, or delete any files, directories, or repository state. Only read, analyze, explain, and report.';
32
+ // Prepends a read-only guard to the prompt when edit mode is not explicitly
33
+ // enabled. Kimi CLI 0.20.1 has no -p-compatible read-only flag, so this is the
34
+ // only available enforcement mechanism for the analysis-only contract.
35
+ export function applyReadOnlyGuard(prompt, edit) {
36
+ if (edit === true)
37
+ return prompt;
38
+ return `${READ_ONLY_GUARD}\n\n${prompt}`;
39
+ }
30
40
  function contentToText(content) {
31
41
  if (typeof content === 'string')
32
42
  return content;
@@ -52,6 +62,46 @@ function contentToText(content) {
52
62
  return '[non-text]';
53
63
  }).join('');
54
64
  }
65
+ // The Kimi CLI emits tool calls on `role:assistant` records as a `tool_calls`
66
+ // array shaped like OpenAI function calls. Extract a concise "Action target"
67
+ // string so CLI runs can surface the current action as an immediate progress
68
+ // event, matching the ACP transport behavior.
69
+ function extractCliToolTarget(argsJson) {
70
+ if (!argsJson)
71
+ return undefined;
72
+ try {
73
+ const parsed = JSON.parse(argsJson);
74
+ const value = parsed.path ?? parsed.skill ?? parsed.command;
75
+ if (typeof value === 'string' && value.trim()) {
76
+ const trimmed = value.trim();
77
+ return trimmed.length > 80 ? `${trimmed.slice(0, 80)}…` : trimmed;
78
+ }
79
+ }
80
+ catch {
81
+ // Ignore malformed argument JSON.
82
+ }
83
+ return undefined;
84
+ }
85
+ function formatCliToolCall(toolCall) {
86
+ if (!toolCall || typeof toolCall !== 'object')
87
+ return undefined;
88
+ const tc = toolCall;
89
+ let name;
90
+ let args;
91
+ if (tc.function && typeof tc.function === 'object') {
92
+ const fn = tc.function;
93
+ name = typeof fn.name === 'string' ? fn.name : undefined;
94
+ args = typeof fn.arguments === 'string' ? fn.arguments : undefined;
95
+ }
96
+ else {
97
+ name = typeof tc.name === 'string' ? tc.name : undefined;
98
+ args = typeof tc.arguments === 'string' ? tc.arguments : undefined;
99
+ }
100
+ if (!name)
101
+ return undefined;
102
+ const target = extractCliToolTarget(args);
103
+ return target ? `${name} ${target}` : name;
104
+ }
55
105
  function extractResumeHint(record) {
56
106
  if (record.role !== 'meta')
57
107
  return undefined;
@@ -116,32 +166,6 @@ export function truncateAtBoundary(text, maxChars) {
116
166
  const cutPoint = Math.max(slice.lastIndexOf('\n## '), slice.lastIndexOf('\n\n'), Math.floor(maxChars * 0.8));
117
167
  return `${slice.slice(0, cutPoint).trimEnd()}\n\n---\nOutput truncated (${text.length.toLocaleString()} chars exceeded ${maxChars.toLocaleString()} char budget). Use kimi_resume with the same session for follow-up questions.`;
118
168
  }
119
- const COALESCE_MS = 750;
120
- const COALESCE_CHARS = 400;
121
- function createStreamCoalescer(reporter) {
122
- let count = 0;
123
- let timer;
124
- const flush = () => {
125
- if (timer) {
126
- clearTimeout(timer);
127
- timer = undefined;
128
- }
129
- if (count > 0) {
130
- reporter(makeEvent('message', `streaming output… (${count} chars)`));
131
- count = 0;
132
- }
133
- };
134
- const add = (text) => {
135
- count += text.length;
136
- if (count >= COALESCE_CHARS) {
137
- flush();
138
- }
139
- else if (!timer) {
140
- timer = setTimeout(flush, COALESCE_MS);
141
- }
142
- };
143
- return { add, flush, stop: flush };
144
- }
145
169
  function killProcessTree(pid) {
146
170
  if (!pid)
147
171
  return;
@@ -166,9 +190,17 @@ export function runKimi(config) {
166
190
  error: 'Kimi CLI binary was not found on PATH or at ~/.kimi-code/bin/kimi.exe.',
167
191
  });
168
192
  }
193
+ if (config.signal?.aborted) {
194
+ return Promise.resolve({
195
+ ok: false,
196
+ text: '',
197
+ error: 'Kimi cancelled',
198
+ });
199
+ }
200
+ const guardedPrompt = applyReadOnlyGuard(config.prompt, config.edit);
169
201
  return new Promise((resolve) => {
170
202
  let settled = false;
171
- const proc = spawn(paths.binaryPath, buildKimiArgs(config), {
203
+ const proc = spawn(paths.binaryPath, buildKimiArgs({ ...config, prompt: guardedPrompt }), {
172
204
  env: buildKimiEnv(),
173
205
  stdio: ['ignore', 'pipe', 'pipe'],
174
206
  windowsHide: true,
@@ -178,18 +210,29 @@ export function runKimi(config) {
178
210
  let stderr = '';
179
211
  let timedOut = false;
180
212
  let streamBuffer = '';
181
- const coalescer = config.onProgress ? createStreamCoalescer(config.onProgress) : undefined;
213
+ const coalescer = config.onProgress ? createProgressCoalescer(config.onProgress) : undefined;
182
214
  const watchdog = config.onProgress ? createStallWatchdog(config.onProgress) : undefined;
215
+ let onAbort;
183
216
  const finish = (result) => {
184
217
  if (settled)
185
218
  return;
186
219
  settled = true;
187
220
  clearTimeout(timer);
188
- coalescer?.flush();
189
221
  coalescer?.stop();
190
222
  watchdog?.stop();
223
+ config.signal?.removeEventListener('abort', onAbort);
191
224
  resolve(result);
192
225
  };
226
+ onAbort = () => {
227
+ killProcessTree(proc.pid);
228
+ finish({ ok: false, text: '', error: 'Kimi cancelled' });
229
+ };
230
+ config.signal?.addEventListener('abort', onAbort, { once: true });
231
+ // Cover the race where the signal is already aborted right after registration.
232
+ if (config.signal?.aborted) {
233
+ onAbort();
234
+ return;
235
+ }
193
236
  let timer = setTimeout(() => {
194
237
  timedOut = true;
195
238
  killProcessTree(proc.pid);
@@ -213,10 +256,23 @@ export function runKimi(config) {
213
256
  continue;
214
257
  try {
215
258
  const record = JSON.parse(line);
216
- if (record.role === 'assistant') {
259
+ if (record.type === 'plan') {
260
+ const text = contentToText(record.content).trim();
261
+ if (text)
262
+ coalescer?.add('plan', text);
263
+ }
264
+ else if (record.role === 'assistant') {
217
265
  const text = contentToText(record.content);
218
266
  if (text.trim())
219
- coalescer?.add(text);
267
+ coalescer?.add('message', text);
268
+ const toolCalls = record.tool_calls;
269
+ if (Array.isArray(toolCalls)) {
270
+ for (const toolCall of toolCalls) {
271
+ const toolText = formatCliToolCall(toolCall);
272
+ if (toolText)
273
+ coalescer?.add('tool_call', toolText);
274
+ }
275
+ }
220
276
  }
221
277
  }
222
278
  catch {
@@ -224,7 +280,12 @@ export function runKimi(config) {
224
280
  }
225
281
  }
226
282
  });
227
- proc.stderr?.on('data', (chunk) => { stderr = appendCapped(stderr, chunk.toString('utf-8'), MAX_CAPTURE_CHARS); });
283
+ proc.stderr?.on('data', (chunk) => {
284
+ // Kimi CLI 0.20.1 does not stream thinking to stderr incrementally during a
285
+ // -p run; stderr is empty until process exit. We therefore do not emit live
286
+ // `thought` progress events here and keep the existing end-of-run capture.
287
+ stderr = appendCapped(stderr, chunk.toString('utf-8'), MAX_CAPTURE_CHARS);
288
+ });
228
289
  proc.on('error', (err) => {
229
290
  finish({ ok: false, text: '', error: err instanceof Error ? err.message : String(err) });
230
291
  });
package/dist/progress.js CHANGED
@@ -21,6 +21,77 @@ export function makeEvent(kind, text) {
21
21
  export function formatProgressLine(event) {
22
22
  return `[${clockFromIso(event.at)}] ${KIND_GLYPH[event.kind]} ${event.kind}: ${event.text}`;
23
23
  }
24
+ export const DEFAULT_DWELL_MS = 1_200;
25
+ export const PREVIEW_MAX_CHARS = 90;
26
+ export const TAIL_MAX_CHARS = 120;
27
+ function sliceByCodePoints(text, maxChars) {
28
+ return [...text].slice(-maxChars).join('');
29
+ }
30
+ function collapseWhitespace(text) {
31
+ return text.replace(/\s+/g, ' ').trim();
32
+ }
33
+ function formatPreview(tail, maxChars) {
34
+ const preview = collapseWhitespace(tail);
35
+ if (preview.length === 0)
36
+ return '';
37
+ const codePoints = [...preview];
38
+ if (codePoints.length <= maxChars)
39
+ return preview;
40
+ return `…${codePoints.slice(-maxChars).join('').trimStart()}`;
41
+ }
42
+ // Coalesces high-frequency `message`/`thought` streaming text into a single
43
+ // watchable preview line, emitted at most once per `dwellMs`. Action events
44
+ // (`tool_call`, `tool_update`, `plan`) are forwarded immediately, after flushing
45
+ // any pending preview so ordering is preserved.
46
+ export function createProgressCoalescer(reporter, options) {
47
+ const dwellMs = options?.dwellMs ?? DEFAULT_DWELL_MS;
48
+ const previewMaxChars = options?.previewMaxChars ?? PREVIEW_MAX_CHARS;
49
+ const tailMaxChars = options?.tailMaxChars ?? TAIL_MAX_CHARS;
50
+ const tails = { message: '', thought: '' };
51
+ let timer;
52
+ const emitTails = () => {
53
+ for (const kind of ['message', 'thought']) {
54
+ const tail = tails[kind];
55
+ if (!tail)
56
+ continue;
57
+ const text = formatPreview(tail, previewMaxChars);
58
+ tails[kind] = '';
59
+ if (text)
60
+ reporter(makeEvent(kind, text));
61
+ }
62
+ };
63
+ const flush = () => {
64
+ if (timer) {
65
+ clearTimeout(timer);
66
+ timer = undefined;
67
+ }
68
+ emitTails();
69
+ };
70
+ const schedule = () => {
71
+ if (timer)
72
+ return;
73
+ timer = setTimeout(() => {
74
+ timer = undefined;
75
+ emitTails();
76
+ }, dwellMs);
77
+ timer.unref?.();
78
+ };
79
+ const appendTail = (kind, text) => {
80
+ tails[kind] = sliceByCodePoints(tails[kind] + text, tailMaxChars);
81
+ };
82
+ const add = (kind, text) => {
83
+ if (kind === 'tool_call' || kind === 'tool_update' || kind === 'plan') {
84
+ flush();
85
+ reporter(makeEvent(kind, text));
86
+ return;
87
+ }
88
+ if (kind === 'message' || kind === 'thought') {
89
+ appendTail(kind, text);
90
+ schedule();
91
+ }
92
+ };
93
+ return { add, flush, stop: flush };
94
+ }
24
95
  export function createStallWatchdog(reporter, stallMs = DEFAULT_STALL_MS) {
25
96
  let timer;
26
97
  let stopped = false;
@@ -4,7 +4,7 @@ import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { buildKimiEnv, resolveKimiPaths } from '../environment.js';
6
6
  import { VERSION } from '../version.js';
7
- import { createStallWatchdog, makeEvent } from '../progress.js';
7
+ import { createProgressCoalescer, createStallWatchdog } from '../progress.js';
8
8
  const MAX_ACP_FRAME_BYTES = 8 * 1024 * 1024;
9
9
  const MAX_ACP_HEADER_BYTES = 16 * 1024;
10
10
  const MAX_ACP_METADATA_BYTES = 100 * 1024;
@@ -232,6 +232,8 @@ export class AcpClient extends EventEmitter {
232
232
  start() {
233
233
  if (this.proc)
234
234
  return;
235
+ if (this.closing)
236
+ throw new Error('ACP client is closing');
235
237
  const binary = resolveKimiPaths().binaryPath;
236
238
  if (!binary)
237
239
  throw new Error('Kimi CLI binary was not found on PATH or at ~/.kimi-code/bin/kimi.exe.');
@@ -310,10 +312,11 @@ export class AcpClient extends EventEmitter {
310
312
  return this.updates.join('').trim();
311
313
  }
312
314
  close() {
313
- if (!this.proc || this.closing)
315
+ if (this.closing)
314
316
  return;
315
317
  this.closing = true;
316
- killProcessTree(this.proc.pid);
318
+ if (this.proc)
319
+ killProcessTree(this.proc.pid);
317
320
  }
318
321
  handleMessages(chunk) {
319
322
  let messages;
@@ -548,43 +551,6 @@ function extractSessionId(value, fallback) {
548
551
  }
549
552
  return fallback;
550
553
  }
551
- const COALESCE_MS = 750;
552
- const COALESCE_CHARS = 400;
553
- // Streams of message/thought tokens are far too chatty to forward one event per
554
- // chunk. Accumulate character counts and emit a single summarized event at most
555
- // every COALESCE_MS or whenever the accumulated text exceeds COALESCE_CHARS.
556
- // tool_call / tool_call_update / plan events are forwarded immediately.
557
- function createCoalescingReporter(reporter) {
558
- const counts = { message: 0, thought: 0 };
559
- let timer;
560
- const flush = () => {
561
- if (timer) {
562
- clearTimeout(timer);
563
- timer = undefined;
564
- }
565
- for (const kind of ['message', 'thought']) {
566
- if (counts[kind] > 0) {
567
- reporter(makeEvent(kind, `${kind === 'message' ? 'streaming output' : 'thinking'}… (${counts[kind]} chars)`));
568
- counts[kind] = 0;
569
- }
570
- }
571
- };
572
- const add = (kind, text) => {
573
- if (kind !== 'message' && kind !== 'thought') {
574
- flush();
575
- reporter(makeEvent(kind, text));
576
- return;
577
- }
578
- counts[kind] += text.length;
579
- if (counts[kind] >= COALESCE_CHARS) {
580
- flush();
581
- }
582
- else if (!timer) {
583
- timer = setTimeout(flush, COALESCE_MS);
584
- }
585
- };
586
- return { add, flush, stop: flush };
587
- }
588
554
  export async function runAcpPrompt(options) {
589
555
  if (options.signal?.aborted)
590
556
  return { ok: false, text: '', error: 'ACP prompt was aborted before start.' };
@@ -602,7 +568,7 @@ export async function runAcpPrompt(options) {
602
568
  let watchdog;
603
569
  let onNotification;
604
570
  if (options.onProgress) {
605
- coalescer = createCoalescingReporter(options.onProgress);
571
+ coalescer = createProgressCoalescer(options.onProgress);
606
572
  watchdog = createStallWatchdog(options.onProgress);
607
573
  onNotification = (message) => {
608
574
  watchdog?.ping();
@@ -665,12 +631,14 @@ export async function runAcpPrompt(options) {
665
631
  return { ok: true, text: text || '(empty ACP response from Kimi)', sessionId, metadata };
666
632
  }
667
633
  catch (error) {
668
- return { ok: false, text: '', error: error instanceof Error ? error.message : String(error) };
634
+ const message = options.signal?.aborted
635
+ ? 'Kimi cancelled'
636
+ : error instanceof Error ? error.message : String(error);
637
+ return { ok: false, text: '', error: message };
669
638
  }
670
639
  finally {
671
640
  if (onNotification)
672
641
  client.off('notification', onNotification);
673
- coalescer?.flush();
674
642
  coalescer?.stop();
675
643
  watchdog?.stop();
676
644
  options.signal?.removeEventListener('abort', abort);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ladder-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.1.4",
4
4
  "description": "Windows-first MCP bridge for Kimi Code CLI v24",
5
5
  "license": "MIT",
6
6
  "type": "module",