ladder-mcp 1.1.2 → 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,48 @@ 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
+
7
49
  ## [1.1.2] - 2026-06-28
8
50
 
9
51
  Live-progress and cancellation reliability for `kimi_code`.
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.1.2** ([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
@@ -72,7 +72,7 @@ npm run build # compiles src/ -> dist/ (tests excluded)
72
72
  Quick checks:
73
73
 
74
74
  ```bash
75
- npm test # vitest (153 tests)
75
+ npm test # vitest (163 tests)
76
76
  npm run typecheck # tsc --noEmit (incl. tests)
77
77
  npm run dev # run the server from source via tsx
78
78
  ```
package/dist/index.js CHANGED
@@ -61,7 +61,7 @@ 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).'),
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
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
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."),
@@ -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;
@@ -156,32 +166,6 @@ export function truncateAtBoundary(text, maxChars) {
156
166
  const cutPoint = Math.max(slice.lastIndexOf('\n## '), slice.lastIndexOf('\n\n'), Math.floor(maxChars * 0.8));
157
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.`;
158
168
  }
159
- const COALESCE_MS = 750;
160
- const COALESCE_CHARS = 400;
161
- function createStreamCoalescer(reporter) {
162
- let count = 0;
163
- let timer;
164
- const flush = () => {
165
- if (timer) {
166
- clearTimeout(timer);
167
- timer = undefined;
168
- }
169
- if (count > 0) {
170
- reporter(makeEvent('message', `streaming output… (${count} chars)`));
171
- count = 0;
172
- }
173
- };
174
- const add = (text) => {
175
- count += text.length;
176
- if (count >= COALESCE_CHARS) {
177
- flush();
178
- }
179
- else if (!timer) {
180
- timer = setTimeout(flush, COALESCE_MS);
181
- }
182
- };
183
- return { add, flush, stop: flush };
184
- }
185
169
  function killProcessTree(pid) {
186
170
  if (!pid)
187
171
  return;
@@ -213,9 +197,10 @@ export function runKimi(config) {
213
197
  error: 'Kimi cancelled',
214
198
  });
215
199
  }
200
+ const guardedPrompt = applyReadOnlyGuard(config.prompt, config.edit);
216
201
  return new Promise((resolve) => {
217
202
  let settled = false;
218
- const proc = spawn(paths.binaryPath, buildKimiArgs(config), {
203
+ const proc = spawn(paths.binaryPath, buildKimiArgs({ ...config, prompt: guardedPrompt }), {
219
204
  env: buildKimiEnv(),
220
205
  stdio: ['ignore', 'pipe', 'pipe'],
221
206
  windowsHide: true,
@@ -225,7 +210,7 @@ export function runKimi(config) {
225
210
  let stderr = '';
226
211
  let timedOut = false;
227
212
  let streamBuffer = '';
228
- const coalescer = config.onProgress ? createStreamCoalescer(config.onProgress) : undefined;
213
+ const coalescer = config.onProgress ? createProgressCoalescer(config.onProgress) : undefined;
229
214
  const watchdog = config.onProgress ? createStallWatchdog(config.onProgress) : undefined;
230
215
  let onAbort;
231
216
  const finish = (result) => {
@@ -233,7 +218,6 @@ export function runKimi(config) {
233
218
  return;
234
219
  settled = true;
235
220
  clearTimeout(timer);
236
- coalescer?.flush();
237
221
  coalescer?.stop();
238
222
  watchdog?.stop();
239
223
  config.signal?.removeEventListener('abort', onAbort);
@@ -275,18 +259,18 @@ export function runKimi(config) {
275
259
  if (record.type === 'plan') {
276
260
  const text = contentToText(record.content).trim();
277
261
  if (text)
278
- config.onProgress(makeEvent('plan', text));
262
+ coalescer?.add('plan', text);
279
263
  }
280
264
  else if (record.role === 'assistant') {
281
265
  const text = contentToText(record.content);
282
266
  if (text.trim())
283
- coalescer?.add(text);
267
+ coalescer?.add('message', text);
284
268
  const toolCalls = record.tool_calls;
285
269
  if (Array.isArray(toolCalls)) {
286
270
  for (const toolCall of toolCalls) {
287
271
  const toolText = formatCliToolCall(toolCall);
288
272
  if (toolText)
289
- config.onProgress(makeEvent('tool_call', toolText));
273
+ coalescer?.add('tool_call', toolText);
290
274
  }
291
275
  }
292
276
  }
@@ -296,7 +280,12 @@ export function runKimi(config) {
296
280
  }
297
281
  }
298
282
  });
299
- 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
+ });
300
289
  proc.on('error', (err) => {
301
290
  finish({ ok: false, text: '', error: err instanceof Error ? err.message : String(err) });
302
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;
@@ -551,43 +551,6 @@ function extractSessionId(value, fallback) {
551
551
  }
552
552
  return fallback;
553
553
  }
554
- const COALESCE_MS = 750;
555
- const COALESCE_CHARS = 400;
556
- // Streams of message/thought tokens are far too chatty to forward one event per
557
- // chunk. Accumulate character counts and emit a single summarized event at most
558
- // every COALESCE_MS or whenever the accumulated text exceeds COALESCE_CHARS.
559
- // tool_call / tool_call_update / plan events are forwarded immediately.
560
- function createCoalescingReporter(reporter) {
561
- const counts = { message: 0, thought: 0 };
562
- let timer;
563
- const flush = () => {
564
- if (timer) {
565
- clearTimeout(timer);
566
- timer = undefined;
567
- }
568
- for (const kind of ['message', 'thought']) {
569
- if (counts[kind] > 0) {
570
- reporter(makeEvent(kind, `${kind === 'message' ? 'streaming output' : 'thinking'}… (${counts[kind]} chars)`));
571
- counts[kind] = 0;
572
- }
573
- }
574
- };
575
- const add = (kind, text) => {
576
- if (kind !== 'message' && kind !== 'thought') {
577
- flush();
578
- reporter(makeEvent(kind, text));
579
- return;
580
- }
581
- counts[kind] += text.length;
582
- if (counts[kind] >= COALESCE_CHARS) {
583
- flush();
584
- }
585
- else if (!timer) {
586
- timer = setTimeout(flush, COALESCE_MS);
587
- }
588
- };
589
- return { add, flush, stop: flush };
590
- }
591
554
  export async function runAcpPrompt(options) {
592
555
  if (options.signal?.aborted)
593
556
  return { ok: false, text: '', error: 'ACP prompt was aborted before start.' };
@@ -605,7 +568,7 @@ export async function runAcpPrompt(options) {
605
568
  let watchdog;
606
569
  let onNotification;
607
570
  if (options.onProgress) {
608
- coalescer = createCoalescingReporter(options.onProgress);
571
+ coalescer = createProgressCoalescer(options.onProgress);
609
572
  watchdog = createStallWatchdog(options.onProgress);
610
573
  onNotification = (message) => {
611
574
  watchdog?.ping();
@@ -676,7 +639,6 @@ export async function runAcpPrompt(options) {
676
639
  finally {
677
640
  if (onNotification)
678
641
  client.off('notification', onNotification);
679
- coalescer?.flush();
680
642
  coalescer?.stop();
681
643
  watchdog?.stop();
682
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.2",
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",