ladder-mcp 1.1.2 → 1.1.5

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,86 @@ 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.5] - 2026-06-28
8
+
9
+ Turn Kimi's TODO/plan into a watchable live checklist.
10
+
11
+ ### Added
12
+
13
+ - Kimi's working TODO list is now surfaced as a `todo` progress event in both
14
+ transports. A shared `createTodoTracker` holds the current `{ text, status }`
15
+ list (replaced on each update) and renders a compact one-line summary
16
+ (`TODO 2/4 ✓✓·· · now: <current item>`) for the live progress line, plus the
17
+ full multi-line snapshot in the background task log.
18
+ - **CLI**: `role:tool` `TodoList` results (the `Current todo list:` text
19
+ snapshot) are parsed and tracked. **ACP**: structured `tool_call_update`/`plan`
20
+ TODO payloads — JSON `{"todos":[{title,status}]}` or the text snapshot — are
21
+ parsed into the same tracker. The normal `tool_call`/`plan` action event is
22
+ still emitted alongside the `todo` event, so actions stay visible.
23
+ - The progress coalescer gains a TODO-priority dwell window (`todoPriorityMs`,
24
+ default 5s): after a TODO update, chatty `message`/`thought` previews are
25
+ suppressed so the checklist line stays readable, while `tool_call`,
26
+ `tool_update`, and `plan` actions still surface immediately. Suppressed preview
27
+ tails are never lost — they flush on the next TODO, on window expiry, and on
28
+ stop/cancel.
29
+
30
+ ### Changed
31
+
32
+ - The background task log now uses `formatTaskLogLine`, which indents the full
33
+ TODO snapshot under the compact summary; foreground/MCP progress stays on the
34
+ compact `formatProgressLine`.
35
+ - Removed the CLI stall watchdog. Kimi CLI 0.20.1 works silently during long
36
+ thinking phases, so the watchdog produced false `stall` warnings. The ACP
37
+ transport keeps its watchdog (it streams granular per-action activity).
38
+
39
+ ### Notes
40
+
41
+ - Additive change: the existing `tool_call`/`tool_update`/`message`/`thought`/
42
+ `plan` events and the task log are otherwise unchanged — `todo` is an added
43
+ layer. No new runtime dependency; `kimi-code-mcp/` untouched.
44
+
45
+ ## [1.1.4] - 2026-06-28
46
+
47
+ Make the live-progress line worth watching.
48
+
49
+ ### Changed
50
+
51
+ - Live progress now shows a readable content **preview** of the streamed
52
+ `message`/`thought` text (whitespace collapsed, code-point-safe truncation with
53
+ an ellipsis) instead of a bare `(N chars)` counter.
54
+ - Progress emission is throttled to at most one update per ~1.2s, so the single
55
+ progress line stops flickering and each line stays readable. `tool_call`,
56
+ `tool_update`, and `plan` events remain immediate and flush any pending preview
57
+ first to preserve order.
58
+ - The two per-transport coalescers (CLI and ACP) are unified into one shared
59
+ `createProgressCoalescer` in `src/progress.ts`; the duplicated `COALESCE_*`
60
+ constants are gone.
61
+
62
+ ### Notes
63
+
64
+ - Kimi CLI 0.20.1 does not stream thinking to stderr during `-p` runs, so live
65
+ `thought` progress is not available on the CLI transport (documented in code).
66
+ The ACP transport already emits `thought`, which now gets the preview treatment.
67
+
68
+ ## [1.1.3] - 2026-06-28
69
+
70
+ Bugfix: analysis-mode `kimi_code` was broken against Kimi CLI 0.20.1.
71
+
72
+ ### Fixed
73
+
74
+ - `kimi_code` without `edit: true` (the default analysis mode) crashed against
75
+ Kimi CLI 0.20.1 with `Cannot combine --prompt with --plan`. `buildKimiArgs` no
76
+ longer passes `--plan` in `-p` prompt mode (the CLI rejects it, along with
77
+ `--auto`/`-y`).
78
+
79
+ ### Changed
80
+
81
+ - Because Kimi 0.20.1 has no `-p`-compatible read-only flag, the `edit: false`
82
+ analysis-only contract is now enforced at the prompt level: `runKimi` prepends
83
+ a read-only guard (`applyReadOnlyGuard`) when `edit` is not `true`. This is
84
+ advisory (the model is instructed not to edit) rather than a hard CLI
85
+ guarantee; the `edit` parameter description documents the change.
86
+
7
87
  ## [1.1.2] - 2026-06-28
8
88
 
9
89
  Live-progress and cancellation reliability for `kimi_code`.
package/README.md CHANGED
@@ -1,110 +1,110 @@
1
- # Ladder_mcp
2
-
3
- Windows-first [MCP](https://modelcontextprotocol.io/) bridge for the
4
- [Kimi Code](https://kimi.com) CLI (v24). It exposes Kimi Code as MCP tools so a
5
- client like Claude Code can run codebase analysis, native sessions, API
6
- queries, ACP chat, background tasks, and CLI admin/diagnostics — all on Windows
7
- without hardcoded POSIX assumptions.
8
-
9
- > Status: **v1.1.2** ([npm](https://www.npmjs.com/package/ladder-mcp)).
10
- > Supported platform is **Windows 11 only**.
11
-
12
- ## Requirements
13
-
14
- - Windows 11
15
- - Node.js ≥ 18
16
- - Kimi Code CLI installed (`kimi.exe` on PATH or at `~/.kimi-code/bin/kimi.exe`),
17
- authenticated (`~/.kimi-code/`)
18
-
19
- ## Quick start (from npm)
20
-
21
- You don't need to clone or build — the package is published on npm and your MCP
22
- client launches it via `npx`, or you can install the package directly.
23
-
24
- **Claude Code (one command):**
25
-
26
- ```bash
27
- claude mcp add kimi-code -- npx -y ladder-mcp
28
- ```
29
-
30
- **Or add it manually to your MCP config:**
31
-
32
- ```jsonc
33
- {
34
- "mcpServers": {
35
- "kimi-code": {
36
- "command": "npx",
37
- "args": ["-y", "ladder-mcp"]
38
- }
39
- }
40
- }
41
- ```
42
-
43
- Then in Claude Code run `/mcp` (should show `kimi-code: connected`) and call
44
- `kimi_status` to confirm the environment is detected.
45
-
46
- > The server speaks MCP over **stdio**: it is launched and managed by the client,
47
- > not run by hand. Running `npx ladder-mcp` directly will appear to "hang" — that
48
- > is the server correctly waiting for a client. Exit with Ctrl+C.
49
-
50
- Prefer a global install? `npm install -g ladder-mcp`, then use `ladder-mcp` as the
51
- command instead of `npx -y ladder-mcp`.
52
-
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`
63
- tool to produce/merge a `.kimi-code/mcp.json` entry.
64
-
65
- ## Build from source (contributors)
66
-
67
- ```bash
68
- npm install
69
- npm run build # compiles src/ -> dist/ (tests excluded)
70
- ```
71
-
72
- Quick checks:
73
-
74
- ```bash
75
- npm test # vitest (153 tests)
76
- npm run typecheck # tsc --noEmit (incl. tests)
77
- npm run dev # run the server from source via tsx
78
- ```
79
-
80
- ## Tools
81
-
82
- **Core (v1.1)** — `kimi_code`, `kimi_ask`, `kimi_sessions`, `kimi_tasks`,
83
- `kimi_status`, `kimi_setup`
84
-
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.
88
-
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`.
92
-
93
- ## Safety boundaries
94
-
95
- - `kimi_export_session` requires an explicit `output_path`, stays within the
96
- working directory, and excludes the global diagnostic log by default.
97
- - Desktop Work tools are **experimental and read-only**: they do not read the
98
- desktop token store, replay web auth, or submit desktop Work tasks.
99
- - The vendored `kimi-code-mcp/` is a **read-only reference** and is never edited
100
- or written to by the tools.
101
-
102
- ## Project layout
103
-
104
- - `src/` — the Ladder_mcp application (package `ladder-mcp`)
105
- - `kimi-code-mcp/` — upstream reference (read-only, MIT)
106
- - `_bmad-output/` — planning & implementation artifacts (BMAD workflow)
107
-
108
- ## License
109
-
110
- [MIT](./LICENSE). Ports logic from the MIT-licensed `kimi-code-mcp` reference.
1
+ # Ladder_mcp
2
+
3
+ Windows-first [MCP](https://modelcontextprotocol.io/) bridge for the
4
+ [Kimi Code](https://kimi.com) CLI (v24). It exposes Kimi Code as MCP tools so a
5
+ client like Claude Code can run codebase analysis, native sessions, API
6
+ queries, ACP chat, background tasks, and CLI admin/diagnostics — all on Windows
7
+ without hardcoded POSIX assumptions.
8
+
9
+ > Status: **v1.1.5** ([npm](https://www.npmjs.com/package/ladder-mcp)).
10
+ > Supported platform is **Windows 11 only**.
11
+
12
+ ## Requirements
13
+
14
+ - Windows 11
15
+ - Node.js ≥ 18
16
+ - Kimi Code CLI installed (`kimi.exe` on PATH or at `~/.kimi-code/bin/kimi.exe`),
17
+ authenticated (`~/.kimi-code/`)
18
+
19
+ ## Quick start (from npm)
20
+
21
+ You don't need to clone or build — the package is published on npm and your MCP
22
+ client launches it via `npx`, or you can install the package directly.
23
+
24
+ **Claude Code (one command):**
25
+
26
+ ```bash
27
+ claude mcp add kimi-code -- npx -y ladder-mcp
28
+ ```
29
+
30
+ **Or add it manually to your MCP config:**
31
+
32
+ ```jsonc
33
+ {
34
+ "mcpServers": {
35
+ "kimi-code": {
36
+ "command": "npx",
37
+ "args": ["-y", "ladder-mcp"]
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ Then in Claude Code run `/mcp` (should show `kimi-code: connected`) and call
44
+ `kimi_status` to confirm the environment is detected.
45
+
46
+ > The server speaks MCP over **stdio**: it is launched and managed by the client,
47
+ > not run by hand. Running `npx ladder-mcp` directly will appear to "hang" — that
48
+ > is the server correctly waiting for a client. Exit with Ctrl+C.
49
+
50
+ Prefer a global install? `npm install -g ladder-mcp`, then use `ladder-mcp` as the
51
+ command instead of `npx -y ladder-mcp`.
52
+
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`
63
+ tool to produce/merge a `.kimi-code/mcp.json` entry.
64
+
65
+ ## Build from source (contributors)
66
+
67
+ ```bash
68
+ npm install
69
+ npm run build # compiles src/ -> dist/ (tests excluded)
70
+ ```
71
+
72
+ Quick checks:
73
+
74
+ ```bash
75
+ npm test # vitest (174 tests)
76
+ npm run typecheck # tsc --noEmit (incl. tests)
77
+ npm run dev # run the server from source via tsx
78
+ ```
79
+
80
+ ## Tools
81
+
82
+ **Core (v1.1)** — `kimi_code`, `kimi_ask`, `kimi_sessions`, `kimi_tasks`,
83
+ `kimi_status`, `kimi_setup`
84
+
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.
88
+
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`.
92
+
93
+ ## Safety boundaries
94
+
95
+ - `kimi_export_session` requires an explicit `output_path`, stays within the
96
+ working directory, and excludes the global diagnostic log by default.
97
+ - Desktop Work tools are **experimental and read-only**: they do not read the
98
+ desktop token store, replay web auth, or submit desktop Work tasks.
99
+ - The vendored `kimi-code-mcp/` is a **read-only reference** and is never edited
100
+ or written to by the tools.
101
+
102
+ ## Project layout
103
+
104
+ - `src/` — the Ladder_mcp application (package `ladder-mcp`)
105
+ - `kimi-code-mcp/` — upstream reference (read-only, MIT)
106
+ - `_bmad-output/` — planning & implementation artifacts (BMAD workflow)
107
+
108
+ ## License
109
+
110
+ [MIT](./LICENSE). Ports logic from the MIT-licensed `kimi-code-mcp` reference.
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ import { exportKimiSession, getKimiCapabilities, listKimiProviders, runKimiDocto
14
14
  import { generateMcpConfig } from './kimi-mcp-config.js';
15
15
  import { buildBudgetProbeGuide, getDesktopStatus } from './desktop-work.js';
16
16
  import { taskStore } from './task-store.js';
17
- import { combineReporters, createMcpProgressReporter, formatProgressLine } from './progress.js';
17
+ import { combineReporters, createMcpProgressReporter, formatTaskLogLine } from './progress.js';
18
18
  import { VERSION } from './version.js';
19
19
  const server = new McpServer({
20
20
  name: 'kimi-code',
@@ -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."),
@@ -75,7 +75,7 @@ server.tool('kimi_code', 'Agentic work in a repository: analyze and edit files.
75
75
  return textResponse(`Error: ${workDirError}`, true);
76
76
  const includeThinkingValue = include_thinking ?? false;
77
77
  const effectiveTransport = transport ?? 'cli';
78
- const appendReporter = (append) => (event) => append(formatProgressLine(event));
78
+ const appendReporter = (append) => (event) => append(formatTaskLogLine(event));
79
79
  if (effectiveTransport === 'cli') {
80
80
  if (background) {
81
81
  const task = taskStore.create('cli-code', async (_signal, append) => {
@@ -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, createTodoTracker, parseCliTodoSnapshot } 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,17 +210,15 @@ 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;
229
- const watchdog = config.onProgress ? createStallWatchdog(config.onProgress) : undefined;
213
+ const coalescer = config.onProgress ? createProgressCoalescer(config.onProgress) : undefined;
214
+ const todoTracker = config.onProgress ? createTodoTracker() : undefined;
230
215
  let onAbort;
231
216
  const finish = (result) => {
232
217
  if (settled)
233
218
  return;
234
219
  settled = true;
235
220
  clearTimeout(timer);
236
- coalescer?.flush();
237
221
  coalescer?.stop();
238
- watchdog?.stop();
239
222
  config.signal?.removeEventListener('abort', onAbort);
240
223
  resolve(result);
241
224
  };
@@ -262,7 +245,6 @@ export function runKimi(config) {
262
245
  stdout = appendCapped(stdout, chunk.toString('utf-8'), MAX_CAPTURE_CHARS);
263
246
  if (!config.onProgress)
264
247
  return;
265
- watchdog?.ping();
266
248
  streamBuffer += chunk.toString('utf-8');
267
249
  const lines = streamBuffer.split(/\r?\n/);
268
250
  streamBuffer = lines.pop() ?? '';
@@ -275,28 +257,40 @@ export function runKimi(config) {
275
257
  if (record.type === 'plan') {
276
258
  const text = contentToText(record.content).trim();
277
259
  if (text)
278
- config.onProgress(makeEvent('plan', text));
260
+ coalescer?.add('plan', text);
279
261
  }
280
262
  else if (record.role === 'assistant') {
281
263
  const text = contentToText(record.content);
282
264
  if (text.trim())
283
- coalescer?.add(text);
265
+ coalescer?.add('message', text);
284
266
  const toolCalls = record.tool_calls;
285
267
  if (Array.isArray(toolCalls)) {
286
268
  for (const toolCall of toolCalls) {
287
269
  const toolText = formatCliToolCall(toolCall);
288
270
  if (toolText)
289
- config.onProgress(makeEvent('tool_call', toolText));
271
+ coalescer?.add('tool_call', toolText);
290
272
  }
291
273
  }
292
274
  }
275
+ else if (record.role === 'tool') {
276
+ const text = contentToText(record.content);
277
+ const todos = parseCliTodoSnapshot(text);
278
+ if (todos && todoTracker?.update(todos)) {
279
+ coalescer?.add('todo', todoTracker.getSummary(), { todoFullList: todoTracker.getFullList() });
280
+ }
281
+ }
293
282
  }
294
283
  catch {
295
284
  // Ignore non-JSON status lines.
296
285
  }
297
286
  }
298
287
  });
299
- proc.stderr?.on('data', (chunk) => { stderr = appendCapped(stderr, chunk.toString('utf-8'), MAX_CAPTURE_CHARS); });
288
+ proc.stderr?.on('data', (chunk) => {
289
+ // Kimi CLI 0.20.1 does not stream thinking to stderr incrementally during a
290
+ // -p run; stderr is empty until process exit. We therefore do not emit live
291
+ // `thought` progress events here and keep the existing end-of-run capture.
292
+ stderr = appendCapped(stderr, chunk.toString('utf-8'), MAX_CAPTURE_CHARS);
293
+ });
300
294
  proc.on('error', (err) => {
301
295
  finish({ ok: false, text: '', error: err instanceof Error ? err.message : String(err) });
302
296
  });
package/dist/progress.js CHANGED
@@ -7,20 +7,262 @@ const KIND_GLYPH = {
7
7
  tool_update: '⚙',
8
8
  plan: '◇',
9
9
  stall: '⚠',
10
+ todo: '☑',
10
11
  };
11
12
  function clockFromIso(iso) {
12
13
  // HH:MM:SS in local time; falls back to the raw value if it is not a valid date.
13
14
  const date = new Date(iso);
14
15
  return Number.isNaN(date.getTime()) ? iso : date.toTimeString().slice(0, 8);
15
16
  }
16
- export function makeEvent(kind, text) {
17
- return { kind, text, at: new Date().toISOString() };
17
+ export function makeEvent(kind, text, metadata) {
18
+ return { kind, text, at: new Date().toISOString(), metadata };
18
19
  }
19
20
  // One compact line per event for the background task log, e.g.
20
21
  // `[12:01:07] ⚙ tool: edit src/index.ts`.
21
22
  export function formatProgressLine(event) {
22
23
  return `[${clockFromIso(event.at)}] ${KIND_GLYPH[event.kind]} ${event.kind}: ${event.text}`;
23
24
  }
25
+ // Task-log variant: for TODO events, append the full multi-line list so the
26
+ // accumulating background log contains complete snapshots, while live progress
27
+ // surfaces only the compact one-line summary.
28
+ export function formatTaskLogLine(event) {
29
+ const base = formatProgressLine(event);
30
+ const fullList = event.metadata?.todoFullList;
31
+ if (event.kind === 'todo' && fullList) {
32
+ return `${base}\n${fullList.split('\n').map((line) => ` ${line}`).join('\n')}`;
33
+ }
34
+ return base;
35
+ }
36
+ const TODO_STATUS_MARKERS = {
37
+ done: '✓',
38
+ in_progress: '·',
39
+ pending: '·',
40
+ };
41
+ export function formatTodoSummary(items) {
42
+ if (items.length === 0)
43
+ return 'TODO 0/0';
44
+ const done = items.filter((i) => i.status === 'done').length;
45
+ const markers = items.map((i) => TODO_STATUS_MARKERS[i.status]).join('');
46
+ const current = items.find((i) => i.status === 'in_progress');
47
+ const now = current ? ` · now: ${current.text}` : '';
48
+ return `TODO ${done}/${items.length} ${markers}${now}`;
49
+ }
50
+ export function formatTodoFullList(items) {
51
+ if (items.length === 0)
52
+ return '(empty todo list)';
53
+ return items.map((i) => `[${i.status}] ${i.text}`).join('\n');
54
+ }
55
+ export function createTodoTracker() {
56
+ let items = [];
57
+ const sameItems = (a, b) => {
58
+ if (a.length !== b.length)
59
+ return false;
60
+ return a.every((item, idx) => item.text === b[idx].text && item.status === b[idx].status);
61
+ };
62
+ return {
63
+ update(next) {
64
+ if (sameItems(items, next))
65
+ return false;
66
+ items = next.map((i) => ({ text: i.text.trim(), status: i.status }));
67
+ return true;
68
+ },
69
+ getItems() {
70
+ return items;
71
+ },
72
+ getSummary() {
73
+ return formatTodoSummary(items);
74
+ },
75
+ getFullList() {
76
+ return formatTodoFullList(items);
77
+ },
78
+ clear() {
79
+ items = [];
80
+ },
81
+ };
82
+ }
83
+ const TODO_SNAPSHOT_MARKER = 'Current todo list:';
84
+ const TODO_BOILERPLATE_MARKER = 'Ensure that you continue to use the todo list';
85
+ // Parses the human-readable snapshot that Kimi's TodoList tool returns in CLI
86
+ // `role:tool` results and in ACP `tool_call_update` content.
87
+ export function parseCliTodoSnapshot(text) {
88
+ const markerIndex = text.indexOf(TODO_SNAPSHOT_MARKER);
89
+ if (markerIndex < 0)
90
+ return undefined;
91
+ const start = markerIndex + TODO_SNAPSHOT_MARKER.length;
92
+ const raw = text.slice(start);
93
+ // Stop before the trailing boilerplate if present.
94
+ const end = raw.indexOf(TODO_BOILERPLATE_MARKER);
95
+ const section = (end < 0 ? raw : raw.slice(0, end)).trim();
96
+ if (!section)
97
+ return undefined;
98
+ const items = [];
99
+ for (const line of section.split(/\r?\n/)) {
100
+ const trimmed = line.trim();
101
+ const match = trimmed.match(/^\[(done|in_progress|pending)\]\s*(.+)$/) ?? trimmed.match(/^(done|in_progress|pending):\s*(.+)$/i);
102
+ if (!match) {
103
+ // First non-matching blank line is tolerated; a second non-matching line
104
+ // marks the end of the list.
105
+ if (items.length > 0)
106
+ break;
107
+ continue;
108
+ }
109
+ const status = match[1].toLowerCase();
110
+ const text = match[2].trim();
111
+ if (text)
112
+ items.push({ status, text });
113
+ }
114
+ return items.length > 0 ? items : undefined;
115
+ }
116
+ // Parses either a JSON `{"todos":[{"title":"...","status":"..."}]}` payload
117
+ // (common in ACP `tool_call_update`) or a human-readable snapshot.
118
+ export function parseAcpTodoPayload(text) {
119
+ const trimmed = text.trim();
120
+ if (trimmed.startsWith('{')) {
121
+ try {
122
+ const parsed = JSON.parse(trimmed);
123
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
124
+ const record = parsed;
125
+ const todos = record.todos ?? record.items ?? record.plan;
126
+ if (Array.isArray(todos)) {
127
+ const items = [];
128
+ for (const entry of todos) {
129
+ if (!entry || typeof entry !== 'object')
130
+ continue;
131
+ const e = entry;
132
+ const text = typeof e.title === 'string' ? e.title : typeof e.content === 'string' ? e.content : typeof e.text === 'string' ? e.text : '';
133
+ const rawStatus = typeof e.status === 'string' ? e.status.toLowerCase() : 'pending';
134
+ const status = rawStatus === 'done' || rawStatus === 'completed' || rawStatus === 'complete' ? 'done' : rawStatus === 'in_progress' || rawStatus === 'in progress' || rawStatus === 'active' ? 'in_progress' : 'pending';
135
+ if (text.trim())
136
+ items.push({ text: text.trim(), status });
137
+ }
138
+ if (items.length > 0)
139
+ return items;
140
+ }
141
+ }
142
+ }
143
+ catch {
144
+ // Fall through to snapshot parsing.
145
+ }
146
+ }
147
+ return parseCliTodoSnapshot(text);
148
+ }
149
+ export const DEFAULT_DWELL_MS = 1_200;
150
+ export const DEFAULT_TODO_PRIORITY_MS = 5_000;
151
+ export const PREVIEW_MAX_CHARS = 90;
152
+ export const TAIL_MAX_CHARS = 120;
153
+ function sliceByCodePoints(text, maxChars) {
154
+ return [...text].slice(-maxChars).join('');
155
+ }
156
+ function collapseWhitespace(text) {
157
+ return text.replace(/\s+/g, ' ').trim();
158
+ }
159
+ function formatPreview(tail, maxChars) {
160
+ const preview = collapseWhitespace(tail);
161
+ if (preview.length === 0)
162
+ return '';
163
+ const codePoints = [...preview];
164
+ if (codePoints.length <= maxChars)
165
+ return preview;
166
+ return `…${codePoints.slice(-maxChars).join('').trimStart()}`;
167
+ }
168
+ // Coalesces high-frequency `message`/`thought` streaming text into a single
169
+ // watchable preview line, emitted at most once per `dwellMs`. Action events
170
+ // (`tool_call`, `tool_update`, `plan`) are forwarded immediately, after flushing
171
+ // any pending preview so ordering is preserved. `todo` events are also immediate
172
+ // and start a short priority window during which `message`/`thought` previews are
173
+ // suppressed so the checklist line stays readable.
174
+ export function createProgressCoalescer(reporter, options) {
175
+ const dwellMs = options?.dwellMs ?? DEFAULT_DWELL_MS;
176
+ const previewMaxChars = options?.previewMaxChars ?? PREVIEW_MAX_CHARS;
177
+ const tailMaxChars = options?.tailMaxChars ?? TAIL_MAX_CHARS;
178
+ const todoPriorityMs = options?.todoPriorityMs ?? DEFAULT_TODO_PRIORITY_MS;
179
+ const tails = { message: '', thought: '' };
180
+ let timer;
181
+ let priorityTimer;
182
+ let priorityUntil = 0;
183
+ const emitTails = () => {
184
+ for (const kind of ['message', 'thought']) {
185
+ const tail = tails[kind];
186
+ if (!tail)
187
+ continue;
188
+ const text = formatPreview(tail, previewMaxChars);
189
+ tails[kind] = '';
190
+ if (text)
191
+ reporter(makeEvent(kind, text));
192
+ }
193
+ };
194
+ const flush = () => {
195
+ if (timer) {
196
+ clearTimeout(timer);
197
+ timer = undefined;
198
+ }
199
+ emitTails();
200
+ };
201
+ const clearPriority = () => {
202
+ priorityUntil = 0;
203
+ if (priorityTimer) {
204
+ clearTimeout(priorityTimer);
205
+ priorityTimer = undefined;
206
+ }
207
+ };
208
+ const schedule = () => {
209
+ if (timer)
210
+ return;
211
+ const now = Date.now();
212
+ if (now < priorityUntil) {
213
+ // Message/thought previews are deferred while a TODO line is on screen.
214
+ // Arm a single timer that will emit the accumulated tail once the window
215
+ // expires; additional streaming text during the window only updates tails.
216
+ if (!priorityTimer) {
217
+ priorityTimer = setTimeout(() => {
218
+ priorityTimer = undefined;
219
+ priorityUntil = 0;
220
+ emitTails();
221
+ }, priorityUntil - now);
222
+ priorityTimer.unref?.();
223
+ }
224
+ return;
225
+ }
226
+ timer = setTimeout(() => {
227
+ timer = undefined;
228
+ emitTails();
229
+ }, dwellMs);
230
+ timer.unref?.();
231
+ };
232
+ const appendTail = (kind, text) => {
233
+ tails[kind] = sliceByCodePoints(tails[kind] + text, tailMaxChars);
234
+ };
235
+ const add = (kind, text, metadata) => {
236
+ if (kind === 'tool_call' || kind === 'tool_update' || kind === 'plan') {
237
+ // During a TODO priority window, action events still surface but chatty
238
+ // message/thought previews stay suppressed so the checklist remains visible.
239
+ if (Date.now() < priorityUntil) {
240
+ reporter(makeEvent(kind, text));
241
+ return;
242
+ }
243
+ flush();
244
+ reporter(makeEvent(kind, text));
245
+ return;
246
+ }
247
+ if (kind === 'todo') {
248
+ flush();
249
+ clearPriority();
250
+ priorityUntil = Date.now() + todoPriorityMs;
251
+ reporter(makeEvent(kind, text, metadata));
252
+ schedule();
253
+ return;
254
+ }
255
+ if (kind === 'message' || kind === 'thought') {
256
+ appendTail(kind, text);
257
+ schedule();
258
+ }
259
+ };
260
+ const stop = () => {
261
+ clearPriority();
262
+ flush();
263
+ };
264
+ return { add, flush, stop };
265
+ }
24
266
  export function createStallWatchdog(reporter, stallMs = DEFAULT_STALL_MS) {
25
267
  let timer;
26
268
  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, createTodoTracker, parseAcpTodoPayload } 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.' };
@@ -603,10 +566,12 @@ export async function runAcpPrompt(options) {
603
566
  }
604
567
  let coalescer;
605
568
  let watchdog;
569
+ let todoTracker;
606
570
  let onNotification;
607
571
  if (options.onProgress) {
608
- coalescer = createCoalescingReporter(options.onProgress);
572
+ coalescer = createProgressCoalescer(options.onProgress);
609
573
  watchdog = createStallWatchdog(options.onProgress);
574
+ todoTracker = createTodoTracker();
610
575
  onNotification = (message) => {
611
576
  watchdog?.ping();
612
577
  const params = message.params;
@@ -623,6 +588,10 @@ export async function runAcpPrompt(options) {
623
588
  case 'tool_call_update':
624
589
  case 'plan': {
625
590
  const text = extractAcpText(update?.content) || JSON.stringify(update);
591
+ const todos = parseAcpTodoPayload(text);
592
+ if (todos && todoTracker?.update(todos)) {
593
+ coalescer?.add('todo', todoTracker.getSummary(), { todoFullList: todoTracker.getFullList() });
594
+ }
626
595
  const kind = updateType === 'tool_call' ? 'tool_call' : updateType === 'tool_call_update' ? 'tool_update' : 'plan';
627
596
  coalescer?.add(kind, text);
628
597
  break;
@@ -676,7 +645,6 @@ export async function runAcpPrompt(options) {
676
645
  finally {
677
646
  if (onNotification)
678
647
  client.off('notification', onNotification);
679
- coalescer?.flush();
680
648
  coalescer?.stop();
681
649
  watchdog?.stop();
682
650
  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.5",
4
4
  "description": "Windows-first MCP bridge for Kimi Code CLI v24",
5
5
  "license": "MIT",
6
6
  "type": "module",