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 +69 -0
- package/README.md +21 -15
- package/dist/index.js +6 -4
- package/dist/kimi-runner.js +97 -36
- package/dist/progress.js +71 -0
- package/dist/transports/acp.js +11 -43
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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 (
|
|
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)** — `
|
|
74
|
-
`
|
|
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
|
-
|
|
82
|
-
(set `background=true`
|
|
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
|
-
**
|
|
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("
|
|
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(
|
|
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.', {
|
package/dist/kimi-runner.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
26
|
-
|
|
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 ?
|
|
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.
|
|
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) => {
|
|
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;
|
package/dist/transports/acp.js
CHANGED
|
@@ -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 {
|
|
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 (
|
|
315
|
+
if (this.closing)
|
|
314
316
|
return;
|
|
315
317
|
this.closing = true;
|
|
316
|
-
|
|
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 =
|
|
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
|
-
|
|
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);
|