ladder-mcp 1.0.2 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import * as path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
4
6
  import { z } from 'zod';
5
7
  import { maxChars, validateWorkDir } from './input-validation.js';
6
8
  import { getKimiStatus } from './environment.js';
@@ -9,12 +11,14 @@ import { runKimi } from './kimi-runner.js';
9
11
  import { listSessions } from './session-store.js';
10
12
  import { cancelAcpSession, listAcpSessions, runAcpPrompt } from './transports/acp.js';
11
13
  import { exportKimiSession, getKimiCapabilities, listKimiProviders, runKimiDoctor, visualizeSession, } from './transports/cli-admin.js';
12
- import { taskStore } from './task-store.js';
13
14
  import { generateMcpConfig } from './kimi-mcp-config.js';
14
15
  import { buildBudgetProbeGuide, getDesktopStatus } from './desktop-work.js';
16
+ import { taskStore } from './task-store.js';
17
+ import { combineReporters, createMcpProgressReporter, formatProgressLine } from './progress.js';
18
+ import { VERSION } from './version.js';
15
19
  const server = new McpServer({
16
20
  name: 'kimi-code',
17
- version: '1.0.2',
21
+ version: VERSION,
18
22
  });
19
23
  const FORMAT_INSTRUCTIONS = {
20
24
  summary: `
@@ -48,163 +52,83 @@ function textResponse(text, isError = false) {
48
52
  function buildResponse(text, thinking, includeThinking) {
49
53
  return thinking && includeThinking ? `<kimi-thinking>\n${thinking}\n</kimi-thinking>\n\n${text}` : text;
50
54
  }
51
- server.tool('kimi_analyze', 'Send a prompt to Kimi Code for codebase analysis. Defaults to native continuation for the working directory; set new_session to true for a fresh session.', {
52
- prompt: z.string().describe('The analysis prompt for Kimi.'),
55
+ function resolveDefaultServerCommand() {
56
+ const distIndex = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'index.js');
57
+ return { command: process.execPath, args: [distIndex] };
58
+ }
59
+ server.tool('kimi_code', 'Agentic work in a repository: analyze and edit files. Defaults to in-process CLI transport.', {
60
+ prompt: z.string().describe('The analysis or coding prompt for Kimi.'),
53
61
  work_dir: z.string().describe('Absolute path to the codebase root directory.'),
54
- session_id: z.string().optional().describe('Explicit Kimi session id to resume via -S.'),
55
- new_session: z.boolean().optional().describe('Start fresh without -C or -S. Default: false.'),
62
+ session_id: z.string().optional().describe('Explicit Kimi session id to resume.'),
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. 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
+ session_mode: z.enum(['new', 'load', 'resume']).optional().describe("ACP session mode when transport='acp'. Default: inferred from session_id/new_session."),
56
68
  detail_level: z.enum(['summary', 'normal', 'detailed']).optional(),
57
69
  max_output_tokens: z.number().optional(),
58
70
  include_thinking: z.boolean().optional(),
59
- }, async ({ prompt, work_dir, session_id, new_session, detail_level, max_output_tokens, include_thinking }) => {
60
- const workDirError = validateWorkDir(work_dir);
61
- if (workDirError)
62
- return textResponse(`Error: ${workDirError}`, true);
63
- const result = await runKimi({
64
- prompt: wrapPrompt(prompt, detail_level ?? 'normal'),
65
- workDir: work_dir,
66
- sessionId: session_id,
67
- continueLast: !session_id && new_session !== true,
68
- timeoutMs: 600_000,
69
- maxOutputChars: maxChars(max_output_tokens),
70
- includeThinking: include_thinking ?? false,
71
- });
72
- if (!result.ok)
73
- return textResponse(`Error: ${result.error}`, true);
74
- const sessionLine = result.sessionId ? `\n\nSession: ${result.sessionId}` : '';
75
- return textResponse(`${buildResponse(result.text, result.thinking, include_thinking ?? false)}${sessionLine}`);
76
- });
77
- server.tool('kimi_query', 'Ask Kimi Code a contextless programming question through the Kimi Code API.', {
78
- prompt: z.string().describe('The question to ask Kimi.'),
79
- max_output_tokens: z.number().optional(),
80
- include_thinking: z.boolean().optional(),
81
- }, async ({ prompt, max_output_tokens, include_thinking }) => {
82
- const result = await runKimiApi({ prompt, timeoutMs: 120_000, maxOutputChars: maxChars(max_output_tokens) });
83
- if (!result.ok)
84
- return textResponse(`Error: ${result.error}`, true);
85
- return textResponse(buildResponse(result.text, result.thinking, include_thinking ?? false));
86
- });
87
- server.tool('kimi_verify', 'Get an independent second opinion from Kimi Code through the Kimi Code API.', {
88
- context: z.string().describe('Self-contained material for Kimi to examine.'),
89
- question: z.string().optional().describe('What to verify or focus on.'),
90
- role: z.string().optional().describe('Override the reviewer persona.'),
91
- max_output_tokens: z.number().optional(),
92
- include_thinking: z.boolean().optional(),
93
- }, async ({ context, question, role, max_output_tokens, include_thinking }) => {
94
- const focus = question?.trim() || 'Independently verify correctness. Surface bugs, edge cases, security issues, and concrete improvements.';
95
- const system = role?.trim() || 'You are a meticulous, independent senior engineer. Be skeptical, specific, and actionable.';
96
- const prompt = `## Your task\n${focus}\n\n## Material to verify\n${context}\n${AI_CONSUMER_NOTICE}`;
97
- const result = await runKimiApi({ prompt, system, timeoutMs: 300_000, maxOutputChars: maxChars(max_output_tokens) });
98
- if (!result.ok)
99
- return textResponse(`Error: ${result.error}`, true);
100
- return textResponse(buildResponse(result.text, result.thinking, include_thinking ?? false));
101
- });
102
- server.tool('kimi_resume', 'Resume an explicit Kimi Code session with a new prompt via -S.', {
103
- session_id: z.string().describe('Session ID to resume.'),
104
- prompt: z.string().describe('New prompt to send.'),
105
- work_dir: z.string().describe('Working directory for the session.'),
106
- detail_level: z.enum(['summary', 'normal', 'detailed']).optional(),
107
- max_output_tokens: z.number().optional(),
108
- include_thinking: z.boolean().optional(),
109
- }, async ({ session_id, prompt, work_dir, detail_level, max_output_tokens, include_thinking }) => {
110
- const workDirError = validateWorkDir(work_dir);
71
+ timeout_ms: z.number().int().positive().optional(),
72
+ }, async ({ prompt, work_dir, session_id, new_session, edit, background, transport, session_mode, detail_level, max_output_tokens, include_thinking, timeout_ms }, extra) => {
73
+ const workDirError = work_dir ? validateWorkDir(work_dir) : undefined;
111
74
  if (workDirError)
112
75
  return textResponse(`Error: ${workDirError}`, true);
113
- const result = await runKimi({
114
- prompt: wrapPrompt(prompt, detail_level ?? 'normal'),
76
+ const includeThinkingValue = include_thinking ?? false;
77
+ const effectiveTransport = transport ?? 'cli';
78
+ const appendReporter = (append) => (event) => append(formatProgressLine(event));
79
+ if (effectiveTransport === 'cli') {
80
+ if (background) {
81
+ const task = taskStore.create('cli-code', async (_signal, append) => {
82
+ const result = await runKimi({
83
+ prompt: wrapPrompt(prompt, detail_level ?? 'normal'),
84
+ workDir: work_dir,
85
+ sessionId: session_id,
86
+ continueLast: !session_id && new_session !== true,
87
+ edit,
88
+ timeoutMs: timeout_ms ?? 600_000,
89
+ maxOutputChars: maxChars(max_output_tokens),
90
+ includeThinking: includeThinkingValue,
91
+ onProgress: appendReporter(append),
92
+ signal: _signal,
93
+ });
94
+ if (!result.ok)
95
+ throw new Error(result.error ?? 'CLI code task failed');
96
+ return { output: result.text, metadata: { sessionId: result.sessionId } };
97
+ });
98
+ return textResponse(JSON.stringify(task, null, 2));
99
+ }
100
+ const result = await runKimi({
101
+ prompt: wrapPrompt(prompt, detail_level ?? 'normal'),
102
+ workDir: work_dir,
103
+ sessionId: session_id,
104
+ continueLast: !session_id && new_session !== true,
105
+ edit,
106
+ timeoutMs: timeout_ms ?? 600_000,
107
+ maxOutputChars: maxChars(max_output_tokens),
108
+ includeThinking: includeThinkingValue,
109
+ onProgress: createMcpProgressReporter(extra),
110
+ signal: extra?.signal,
111
+ });
112
+ if (!result.ok)
113
+ return textResponse(`Error: ${result.error}`, true);
114
+ const sessionLine = result.sessionId ? `\n\nSession: ${result.sessionId}` : '';
115
+ return textResponse(`${buildResponse(result.text, result.thinking, includeThinkingValue)}${sessionLine}`);
116
+ }
117
+ const defaultAcpSessionMode = session_mode ?? (session_id ? 'resume' : 'new');
118
+ const runAcp = async (signal, onProgress) => runAcpPrompt({
119
+ prompt,
115
120
  workDir: work_dir,
116
121
  sessionId: session_id,
117
- timeoutMs: 600_000,
118
- maxOutputChars: maxChars(max_output_tokens),
119
- includeThinking: include_thinking ?? false,
120
- });
121
- if (!result.ok)
122
- return textResponse(`Error: ${result.error}`, true);
123
- return textResponse(buildResponse(result.text, result.thinking, include_thinking ?? false));
124
- });
125
- server.tool('kimi_list_sessions', 'List existing Kimi Code sessions with ids, titles, working directories, and timestamps.', {
126
- work_dir: z.string().optional().describe('Filter sessions by working directory path.'),
127
- limit: z.number().optional().describe('Max sessions to return. Default: 20.'),
128
- }, async ({ work_dir, limit }) => {
129
- const sessions = listSessions({ workDir: work_dir, limit: limit ?? 20 });
130
- return textResponse(sessions.length ? JSON.stringify(sessions, null, 2) : 'No Kimi sessions found.');
131
- });
132
- server.tool('kimi_status', 'Check Kimi CLI installation, version, authentication, catalog, and API availability.', {}, async () => {
133
- const status = await getKimiStatus();
134
- const lines = [
135
- '## Kimi CLI Status',
136
- `- Installed: ${status.installed ? 'Yes' : 'No'}`,
137
- `- Binary: ${status.binPath ? `\`${status.binPath}\`` : 'Not found'}`,
138
- `- Version: ${status.version ?? '(unable to detect)'}`,
139
- `- Authenticated: ${status.authenticated ? 'Yes' : 'No'}`,
140
- `- Catalog Found: ${status.catalogFound ? 'Yes' : 'No'}`,
141
- `- Catalog: \`${status.catalogPath}\``,
142
- `- Credentials Found: ${status.credentialsFound ? 'Yes' : 'No'}`,
143
- `- Config Found: ${status.configFound ? 'Yes' : 'No'}`,
144
- '',
145
- '## Kimi Code API',
146
- `- Configured: ${isApiConfigured() ? 'Yes' : 'No'}`,
147
- ];
148
- if (status.error)
149
- lines.push('', `Action required: ${status.error}`);
150
- return textResponse(lines.join('\n'), !status.installed && !status.apiConfigured);
151
- });
152
- server.tool('kimi_capabilities', 'Report Kimi CLI, admin command, ACP, and experimental desktop-read-only capabilities.', {}, async () => textResponse(JSON.stringify(await getKimiCapabilities(), null, 2)));
153
- server.tool('kimi_doctor', 'Run Kimi Code configuration doctor for config.toml or tui.toml.', {
154
- target: z.enum(['config', 'tui']).optional(),
155
- path: z.string().optional().describe('Optional config/tui path to validate.'),
156
- }, async ({ target, path }) => {
157
- const result = await runKimiDoctor(target ?? 'config', path);
158
- return textResponse(JSON.stringify(result, null, 2), !result.ok);
159
- });
160
- server.tool('kimi_provider_list', 'List configured Kimi providers and models using `kimi provider list --json`.', {}, async () => textResponse(JSON.stringify(await listKimiProviders(), null, 2)));
161
- server.tool('kimi_export_session', 'Export a Kimi session ZIP. Requires explicit output_path and excludes the global diagnostic log by default.', {
162
- output_path: z.string().describe('Explicit ZIP output path.'),
163
- session_id: z.string().optional().describe('Session id to export; defaults to most recent Kimi session.'),
164
- include_global_log: z.boolean().optional().describe('Include active global diagnostic log. Default: false.'),
165
- overwrite_existing: z.boolean().optional().describe('Overwrite output_path if it already exists. Default: false.'),
166
- }, async ({ output_path, session_id, include_global_log, overwrite_existing }) => {
167
- const result = await exportKimiSession({
168
- outputPath: output_path,
169
- sessionId: session_id,
170
- includeGlobalLog: include_global_log ?? false,
171
- overwriteExisting: overwrite_existing ?? false,
122
+ sessionMode: defaultAcpSessionMode,
123
+ timeoutMs: timeout_ms,
124
+ signal,
125
+ onProgress,
172
126
  });
173
- return textResponse(JSON.stringify(result, null, 2), !result.ok);
174
- });
175
- server.tool('kimi_visualize_session', 'Preview or launch Kimi session visualizer on localhost using `kimi vis --no-open`.', {
176
- session_id: z.string().optional(),
177
- host: z.enum(['127.0.0.1', 'localhost', '::1']).optional().describe('Localhost host to bind. Default: 127.0.0.1.'),
178
- port: z.number().int().min(1).max(65535).optional().describe('Port to bind. Default: 58628.'),
179
- launch: z.boolean().optional().describe('Actually start the visualizer process. Default: false.'),
180
- }, async ({ session_id, host, port, launch }) => {
181
- try {
182
- return textResponse(JSON.stringify(visualizeSession({ sessionId: session_id, host, port, launch }), null, 2));
183
- }
184
- catch (error) {
185
- return textResponse(`Error: ${error instanceof Error ? error.message : String(error)}`, true);
186
- }
187
- });
188
- server.tool('kimi_chat', 'Send a prompt through Kimi ACP over stdio. Set background=true for long-running work tracked by task tools.', {
189
- prompt: z.string(),
190
- work_dir: z.string().optional(),
191
- session_id: z.string().optional(),
192
- session_mode: z.enum(['new', 'load', 'resume']).optional(),
193
- background: z.boolean().optional(),
194
- timeout_ms: z.number().int().positive().optional(),
195
- }, async ({ prompt, work_dir, session_id, session_mode, background, timeout_ms }) => {
196
127
  if (background) {
197
- const task = taskStore.create('acp-chat', async (signal) => {
198
- const result = await runAcpPrompt({
199
- prompt,
200
- workDir: work_dir,
201
- sessionId: session_id,
202
- sessionMode: session_mode ?? (session_id ? 'resume' : 'new'),
203
- timeoutMs: timeout_ms,
204
- signal,
205
- });
128
+ const task = taskStore.create('acp-code', async (_signal, append) => {
129
+ const result = await runAcp(_signal, combineReporters(appendReporter(append), createMcpProgressReporter(extra)));
206
130
  if (!result.ok)
207
- throw new Error(result.error ?? 'ACP chat failed');
131
+ throw new Error(result.error ?? 'ACP code task failed');
208
132
  const metadata = { ...(result.metadata ?? {}) };
209
133
  if (!('sessionId' in metadata)) {
210
134
  metadata.sessionId = result.sessionId;
@@ -213,28 +137,86 @@ server.tool('kimi_chat', 'Send a prompt through Kimi ACP over stdio. Set backgro
213
137
  });
214
138
  return textResponse(JSON.stringify(task, null, 2));
215
139
  }
216
- const result = await runAcpPrompt({
217
- prompt,
218
- workDir: work_dir,
219
- sessionId: session_id,
220
- sessionMode: session_mode ?? (session_id ? 'resume' : 'new'),
221
- timeoutMs: timeout_ms,
222
- });
140
+ const result = await runAcp(extra?.signal, createMcpProgressReporter(extra));
223
141
  return textResponse(JSON.stringify(result, null, 2), !result.ok);
224
142
  });
225
- server.tool('kimi_acp_sessions', 'List Kimi ACP sessions through `session/list`.', {
226
- limit: z.number().int().positive().optional(),
227
- work_dir: z.string().optional(),
228
- }, async ({ limit, work_dir }) => {
229
- const result = await listAcpSessions({ limit, workDir: work_dir });
230
- return textResponse(result.ok ? result.text : `Error: ${result.error}`, !result.ok);
143
+ server.tool('kimi_ask', 'Stateless question or independent review. Text only, no repo, no edits.', {
144
+ prompt: z.string().describe('The question to ask or the focus for verification.'),
145
+ context: z.string().optional().describe('Material to examine (switches to verify mode).'),
146
+ role: z.string().optional().describe('Reviewer persona override for verify mode.'),
147
+ max_output_tokens: z.number().optional(),
148
+ include_thinking: z.boolean().optional(),
149
+ timeout_ms: z.number().int().positive().optional(),
150
+ }, async ({ prompt, context, role, max_output_tokens, include_thinking, timeout_ms }) => {
151
+ const includeThinkingValue = include_thinking ?? false;
152
+ if (context) {
153
+ const focus = prompt.trim() || 'Independently verify correctness. Surface bugs, edge cases, security issues, and concrete improvements.';
154
+ const system = role?.trim() || 'You are a meticulous, independent senior engineer. Be skeptical, specific, and actionable.';
155
+ const verifyPrompt = `## Your task\n${focus}\n\n## Material to verify\n${context}\n${AI_CONSUMER_NOTICE}`;
156
+ const result = await runKimiApi({ prompt: verifyPrompt, system, timeoutMs: timeout_ms ?? 300_000, maxOutputChars: maxChars(max_output_tokens) });
157
+ if (!result.ok)
158
+ return textResponse(`Error: ${result.error}`, true);
159
+ return textResponse(buildResponse(result.text, result.thinking, includeThinkingValue));
160
+ }
161
+ const result = await runKimiApi({ prompt, timeoutMs: timeout_ms ?? 120_000, maxOutputChars: maxChars(max_output_tokens) });
162
+ if (!result.ok)
163
+ return textResponse(`Error: ${result.error}`, true);
164
+ return textResponse(buildResponse(result.text, result.thinking, includeThinkingValue));
165
+ });
166
+ server.tool('kimi_sessions', 'List/inspect Kimi sessions from the CLI catalog, ACP, or both.', {
167
+ work_dir: z.string().optional().describe('Filter sessions by working directory path.'),
168
+ limit: z.number().optional().describe('Max sessions to return. Default: 20.'),
169
+ source: z.enum(['cli', 'acp', 'all']).optional().describe("Source: 'cli', 'acp', or 'all'. Default: 'all'."),
170
+ }, async ({ work_dir, limit, source }) => {
171
+ const effectiveSource = source ?? 'all';
172
+ const effectiveLimit = limit ?? 20;
173
+ if (effectiveSource === 'cli') {
174
+ const sessions = listSessions({ workDir: work_dir, limit: effectiveLimit });
175
+ return textResponse(sessions.length ? JSON.stringify(sessions, null, 2) : 'No Kimi sessions found.');
176
+ }
177
+ if (effectiveSource === 'acp') {
178
+ const result = await listAcpSessions({ limit: effectiveLimit, workDir: work_dir });
179
+ return textResponse(result.ok ? result.text : `Error: ${result.error}`, !result.ok);
180
+ }
181
+ const [cliSessions, acpResult] = await Promise.all([
182
+ Promise.resolve(listSessions({ workDir: work_dir, limit: effectiveLimit })),
183
+ listAcpSessions({ limit: effectiveLimit, workDir: work_dir }),
184
+ ]);
185
+ let acpValue;
186
+ if (acpResult.ok) {
187
+ try {
188
+ acpValue = JSON.parse(acpResult.text);
189
+ }
190
+ catch {
191
+ acpValue = { error: 'ACP sessions response is not valid JSON', raw: acpResult.text };
192
+ }
193
+ }
194
+ else {
195
+ acpValue = { error: acpResult.error };
196
+ }
197
+ const combined = { cli: cliSessions, acp: acpValue };
198
+ return textResponse(JSON.stringify(combined, null, 2));
231
199
  });
232
- server.tool('kimi_cancel', 'Cancel an active Ladder task by task_id or a Kimi ACP session by session_id.', {
233
- task_id: z.string().optional(),
234
- session_id: z.string().optional(),
235
- }, async ({ task_id, session_id }) => {
236
- // Require exactly one target. Previously, passing both silently cancelled the task
237
- // and ignored session_id, hiding the ambiguity from the caller.
200
+ server.tool('kimi_tasks', 'Manage background work: status, output, or cancel.', {
201
+ action: z.enum(['status', 'output', 'cancel']).describe("Action: 'status', 'output', or 'cancel'."),
202
+ task_id: z.string().optional().describe('Omit for status to list all tasks. Required for output.'),
203
+ session_id: z.string().optional().describe('Cancel an ACP session instead of a task (action=cancel).'),
204
+ }, async ({ action, task_id, session_id }) => {
205
+ if (action === 'status') {
206
+ if (task_id) {
207
+ const task = taskStore.get(task_id);
208
+ return textResponse(task ? JSON.stringify(task, null, 2) : `Task not found: ${task_id}`, !task);
209
+ }
210
+ return textResponse(JSON.stringify(taskStore.list(), null, 2));
211
+ }
212
+ if (action === 'output') {
213
+ if (!task_id)
214
+ return textResponse('task_id is required for action=output.', true);
215
+ const task = taskStore.get(task_id);
216
+ return textResponse(task
217
+ ? JSON.stringify({ id: task.id, status: task.status, output: task.output, outputChunks: task.outputChunks, error: task.error }, null, 2)
218
+ : `Task not found: ${task_id}`, !task);
219
+ }
238
220
  if (task_id && session_id) {
239
221
  return textResponse('Provide either task_id or session_id, not both.', true);
240
222
  }
@@ -248,28 +230,45 @@ server.tool('kimi_cancel', 'Cancel an active Ladder task by task_id or a Kimi AC
248
230
  }
249
231
  return textResponse('Provide task_id or session_id.', true);
250
232
  });
251
- server.tool('kimi_task_status', 'Return status for one background task or all recent in-process tasks.', {
252
- task_id: z.string().optional(),
253
- }, async ({ task_id }) => {
254
- if (task_id) {
255
- const task = taskStore.get(task_id);
256
- return textResponse(task ? JSON.stringify(task, null, 2) : `Task not found: ${task_id}`, !task);
233
+ server.tool('kimi_status', 'Installation, auth, and diagnostics.', {
234
+ detail: z.enum(['basic', 'full']).optional().describe("Detail level: 'basic' or 'full'. Default: 'basic'."),
235
+ doctor_target: z.enum(['config', 'tui']).optional().describe("Target for the doctor subcall when detail='full'. Default: 'config'."),
236
+ doctor_path: z.string().optional().describe("Optional config/tui path for the doctor subcall when detail='full'."),
237
+ }, async ({ detail, doctor_target, doctor_path }) => {
238
+ const status = await getKimiStatus();
239
+ const lines = [
240
+ '## Kimi CLI Status',
241
+ `- Installed: ${status.installed ? 'Yes' : 'No'}`,
242
+ `- Binary: ${status.binPath ? `\`${status.binPath}\`` : 'Not found'}`,
243
+ `- Version: ${status.version ?? '(unable to detect)'}`,
244
+ `- Authenticated: ${status.authenticated ? 'Yes' : 'No'}`,
245
+ `- Catalog Found: ${status.catalogFound ? 'Yes' : 'No'}`,
246
+ `- Catalog: \`${status.catalogPath}\``,
247
+ `- Credentials Found: ${status.credentialsFound ? 'Yes' : 'No'}`,
248
+ `- Config Found: ${status.configFound ? 'Yes' : 'No'}`,
249
+ '',
250
+ '## Kimi Code API',
251
+ `- Configured: ${isApiConfigured() ? 'Yes' : 'No'}`,
252
+ ];
253
+ if (status.error)
254
+ lines.push('', `Action required: ${status.error}`);
255
+ let subcallsFailed = false;
256
+ if (detail === 'full') {
257
+ const [capabilities, doctor, providers] = await Promise.all([
258
+ getKimiCapabilities(),
259
+ runKimiDoctor(doctor_target ?? 'config', doctor_path),
260
+ listKimiProviders(),
261
+ ]);
262
+ const isFailedResult = (value) => typeof value === 'object' && value !== null && 'ok' in value && value.ok === false;
263
+ if (isFailedResult(doctor))
264
+ subcallsFailed = true;
265
+ if (!Array.isArray(providers) && isFailedResult(providers))
266
+ subcallsFailed = true;
267
+ lines.push('', '## Capabilities', JSON.stringify(capabilities, null, 2), '', '## Doctor', JSON.stringify(doctor, null, 2), '', '## Providers', JSON.stringify(providers, null, 2));
257
268
  }
258
- return textResponse(JSON.stringify(taskStore.list(), null, 2));
259
- });
260
- server.tool('kimi_task_output', 'Return accumulated output and error for a background task.', {
261
- task_id: z.string(),
262
- }, async ({ task_id }) => {
263
- const task = taskStore.get(task_id);
264
- return textResponse(task ? JSON.stringify({ id: task.id, status: task.status, output: task.output, outputChunks: task.outputChunks, error: task.error }, null, 2) : `Task not found: ${task_id}`, !task);
269
+ return textResponse(lines.join('\n'), Boolean(status.error) || subcallsFailed);
265
270
  });
266
- server.tool('kimi_task_cancel', 'Cancel a pending or running background task.', {
267
- task_id: z.string(),
268
- }, async ({ task_id }) => {
269
- const task = await taskStore.cancel(task_id);
270
- return textResponse(task ? JSON.stringify(task, null, 2) : `Task not found: ${task_id}`, !task);
271
- });
272
- server.tool('kimi_generate_mcp_config', 'Generate or write a Kimi Code .kimi-code/mcp.json entry that lets Kimi host Ladder_mcp as an MCP server.', {
271
+ server.tool('kimi_setup', 'Generate the Kimi-hosted MCP config entry for Ladder_mcp.', {
273
272
  scope: z.enum(['project', 'user']).optional(),
274
273
  project_dir: z.string().optional(),
275
274
  server_name: z.string().optional(),
@@ -277,19 +276,53 @@ server.tool('kimi_generate_mcp_config', 'Generate or write a Kimi Code .kimi-cod
277
276
  command: z.string().optional(),
278
277
  args: z.array(z.string()).optional(),
279
278
  }, async ({ scope, project_dir, server_name, write, command, args }) => {
279
+ const defaults = resolveDefaultServerCommand();
280
280
  const result = generateMcpConfig({
281
281
  scope,
282
282
  projectDir: project_dir,
283
283
  serverName: server_name,
284
284
  write: write ?? false,
285
- command,
286
- args,
285
+ command: command ?? defaults.command,
286
+ args: args ?? defaults.args,
287
287
  });
288
288
  return textResponse(JSON.stringify(result, null, 2));
289
289
  });
290
- server.tool('kimi_desktop_status', 'Experimental read-only Kimi Desktop Work status probe. Does not read token stores or replay web auth.', {}, async () => textResponse(JSON.stringify(await getDesktopStatus(), null, 2)));
291
- server.tool('kimi_budget_probe', 'Experimental guided budget-separation evidence workflow. Does not submit desktop Work tasks.', {
292
- include_cli_probe_note: z.boolean().optional(),
293
- }, async ({ include_cli_probe_note }) => textResponse(buildBudgetProbeGuide(include_cli_probe_note ?? false)));
294
- const transport = new StdioServerTransport();
295
- await server.connect(transport);
290
+ if (process.env.LADDER_EXPERIMENTAL === '1') {
291
+ server.tool('kimi_export_session', 'Export a Kimi session ZIP. Requires explicit output_path and excludes the global diagnostic log by default.', {
292
+ output_path: z.string().describe('Explicit ZIP output path.'),
293
+ session_id: z.string().optional().describe('Session id to export; defaults to most recent Kimi session.'),
294
+ include_global_log: z.boolean().optional().describe('Include active global diagnostic log. Default: false.'),
295
+ overwrite_existing: z.boolean().optional().describe('Overwrite output_path if it already exists. Default: false.'),
296
+ }, async ({ output_path, session_id, include_global_log, overwrite_existing }) => {
297
+ const result = await exportKimiSession({
298
+ outputPath: output_path,
299
+ sessionId: session_id,
300
+ includeGlobalLog: include_global_log ?? false,
301
+ overwriteExisting: overwrite_existing ?? false,
302
+ });
303
+ return textResponse(JSON.stringify(result, null, 2), !result.ok);
304
+ });
305
+ server.tool('kimi_visualize_session', 'Preview or launch Kimi session visualizer on localhost using `kimi vis --no-open`.', {
306
+ session_id: z.string().optional(),
307
+ host: z.enum(['127.0.0.1', 'localhost', '::1']).optional().describe('Localhost host to bind. Default: 127.0.0.1.'),
308
+ port: z.number().int().min(1).max(65535).optional().describe('Port to bind. Default: 58628.'),
309
+ launch: z.boolean().optional().describe('Actually start the visualizer process. Default: false.'),
310
+ }, async ({ session_id, host, port, launch }) => {
311
+ try {
312
+ return textResponse(JSON.stringify(visualizeSession({ sessionId: session_id, host, port, launch }), null, 2));
313
+ }
314
+ catch (error) {
315
+ return textResponse(`Error: ${error instanceof Error ? error.message : String(error)}`, true);
316
+ }
317
+ });
318
+ server.tool('kimi_desktop_status', 'Experimental read-only Kimi Desktop Work status probe. Does not read token stores or replay web auth.', {}, async () => textResponse(JSON.stringify(await getDesktopStatus(), null, 2)));
319
+ server.tool('kimi_budget_probe', 'Experimental guided budget-separation evidence workflow. Does not submit desktop Work tasks.', {
320
+ include_cli_probe_note: z.boolean().optional(),
321
+ }, async ({ include_cli_probe_note }) => textResponse(buildBudgetProbeGuide(include_cli_probe_note ?? false)));
322
+ }
323
+ export { server };
324
+ const isMain = process.argv[1] === fileURLToPath(import.meta.url);
325
+ if (isMain) {
326
+ const transport = new StdioServerTransport();
327
+ await server.connect(transport);
328
+ }
package/dist/kimi-api.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { loadApiAuth } from './environment.js';
2
2
  import { truncateAtBoundary } from './kimi-runner.js';
3
+ import { clampTimeout } from './transports/acp.js';
3
4
  const KIMI_USER_AGENT = 'KimiCLI/1.0';
4
5
  const DEFAULT_MODEL = 'kimi-for-coding';
6
+ const API_DEFAULT_TIMEOUT_MS = 300_000;
5
7
  export function isApiConfigured() {
6
8
  return loadApiAuth() !== null;
7
9
  }
@@ -15,7 +17,9 @@ export async function runKimiApi(config) {
15
17
  };
16
18
  }
17
19
  const controller = new AbortController();
18
- const timeoutMs = config.timeoutMs ?? 300_000;
20
+ // Clamp to a positive finite value; a zero/NaN/negative timeout would abort
21
+ // instantly and fail the request before the network round-trip.
22
+ const timeoutMs = clampTimeout(config.timeoutMs, API_DEFAULT_TIMEOUT_MS);
19
23
  const timer = setTimeout(() => controller.abort(), timeoutMs);
20
24
  const maxOutputChars = config.maxOutputChars ?? 60_000;
21
25
  const maxTokens = Math.ceil(maxOutputChars / 4) + 4000;
@@ -4,7 +4,10 @@ import { fileURLToPath } from 'node:url';
4
4
  import { getWindowsHome } from './environment.js';
5
5
  function defaultServerCommand() {
6
6
  const distIndex = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'index.js');
7
- return { command: 'node', args: [distIndex] };
7
+ // Use the actual Node binary that launched this process instead of a bare `node`
8
+ // name that could be spoofed via PATH. This matches the secure default in
9
+ // src/index.ts resolveDefaultServerCommand.
10
+ return { command: process.execPath, args: [distIndex] };
8
11
  }
9
12
  export function resolveMcpConfigPath(options = {}) {
10
13
  if (options.scope === 'user') {
@@ -32,30 +35,43 @@ function readExistingConfig(configPath) {
32
35
  }
33
36
  }
34
37
  function assertWritableProjectTarget(projectDir) {
35
- let resolved;
36
- try {
37
- resolved = fs.realpathSync(projectDir);
38
- }
39
- catch {
40
- resolved = path.resolve(projectDir);
41
- }
42
- const parts = resolved.split(path.sep).map((part) => part.toLowerCase());
43
- if (parts.includes('kimi-code-mcp')) {
44
- throw new Error('Refusing to write MCP config under read-only kimi-code-mcp reference tree.');
38
+ // Resolve what we can; if the target does not exist yet, fall back to an absolute
39
+ // path so containment still works against the nearest existing ancestor.
40
+ const resolved = (() => {
41
+ try {
42
+ return fs.realpathSync(projectDir);
43
+ }
44
+ catch {
45
+ return path.resolve(projectDir);
46
+ }
47
+ })();
48
+ // Walk up the path and reject if any directory segment is exactly the read-only
49
+ // reference tree name. This is containment-based (basename equality), not a
50
+ // substring match, so names like "my-kimi-code-mcp-project" are not blocked.
51
+ let current = resolved;
52
+ while (true) {
53
+ if (path.basename(current).toLowerCase() === 'kimi-code-mcp') {
54
+ throw new Error('Refusing to write MCP config under read-only kimi-code-mcp reference tree.');
55
+ }
56
+ const parent = path.dirname(current);
57
+ if (parent === current)
58
+ break;
59
+ current = parent;
45
60
  }
46
61
  }
47
- const ALLOWED_BARE_COMMANDS = new Set(['node', 'npx']);
48
62
  // Constrain the launcher written into mcp.json. Without this, an arbitrary `command`
49
63
  // (e.g. "powershell") would be persisted and later executed when Kimi loads the
50
- // server config. Allow only the known Node launchers by bare name, or an absolute
51
- // path to an existing file (a vetted local binary).
64
+ // server config. Reject bare names like `node` or `npx` because they resolve through
65
+ // the consumer's PATH and can be spoofed. Only accept the actual Node binary that is
66
+ // running this process (process.execPath) or an absolute path to an existing file that
67
+ // has been vetted by the caller.
52
68
  function assertSafeCommand(command) {
53
69
  const value = command.trim();
54
- if (ALLOWED_BARE_COMMANDS.has(value))
70
+ if (value === process.execPath)
55
71
  return;
56
72
  if (path.isAbsolute(value) && fs.existsSync(value) && fs.statSync(value).isFile())
57
73
  return;
58
- throw new Error(`command must be one of [${[...ALLOWED_BARE_COMMANDS].join(', ')}] or an absolute path to an existing file; got "${command}".`);
74
+ throw new Error(`command must be process.execPath or an absolute path to an existing file; got "${command}".`);
59
75
  }
60
76
  function readMcpServers(existing) {
61
77
  const value = existing.mcpServers;