@openagents-org/agent-launcher 0.2.173 → 0.2.176

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.
@@ -1,22 +1,68 @@
1
1
  /**
2
- * Kimi adapter — Moonshot AI OpenAI-compatible chat completions.
2
+ * Kimi adapter — drives Moonshot AI's Kimi Code CLI (`kimi`, npm package
3
+ * `@moonshot-ai/kimi-code`), with a legacy direct-API fallback.
3
4
  *
4
- * Reuses LlmDirectAdapter's streaming chat-completions client, but:
5
- * - reads KIMI_API_KEY / MOONSHOT_API_KEY (also accepts LLM_API_KEY / OPENAI_API_KEY)
6
- * - reads KIMI_BASE_URL / LLM_BASE_URL / OPENAI_BASE_URL, defaulting to https://api.moonshot.ai/v1
7
- * - reads KIMI_MODEL / LLM_MODEL, defaulting to kimi-k2.6
5
+ * Primary (CLI) mode:
6
+ * - one `kimi -p <prompt> --output-format stream-json` subprocess per user
7
+ * message (print mode runs the full agent loop with tools auto-approved)
8
+ * - the JSONL stream is parsed (see kimi-stream.js) and mapped to the
9
+ * standard OpenAgents events (thinking / status / response / error)
10
+ * - real session continuity via `-S <session_id>`: the CLI emits the id
11
+ * inline as a `session.resume_hint` meta message
12
+ * - auth: the launcher's KIMI_API_KEY / KIMI_BASE_URL / KIMI_MODEL fields are
13
+ * mapped onto the CLI's env-provider contract (KIMI_MODEL_API_KEY /
14
+ * KIMI_MODEL_BASE_URL / KIMI_MODEL_NAME); with no key configured the CLI's
15
+ * own `kimi login` credentials apply.
8
16
  *
9
- * Priority for every value: UI-saved env > process env > default.
10
- * Stop / status / control flow is inherited from BaseAdapter.
17
+ * Fallback (direct API) mode — the pre-CLI behavior, kept so existing agents
18
+ * configured with only an API key keep working when the CLI is not installed:
19
+ * OpenAI-compatible chat completions against KIMI_BASE_URL (inherited from
20
+ * LlmDirectAdapter).
21
+ *
22
+ * Verified against Kimi Code CLI v0.39.1. NOTE: the legacy Python `kimi-cli`
23
+ * (PyPI) also installs a `kimi` binary but reports 1.x versions and has a
24
+ * different interface — it is detected and NOT driven.
11
25
  */
12
26
 
13
27
  'use strict';
14
28
 
29
+ const fs = require('fs');
30
+ const os = require('os');
31
+ const path = require('path');
32
+ const { execSync, spawn } = require('child_process');
33
+
15
34
  const LlmDirectAdapter = require('./llm-direct');
35
+ const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
36
+ const { defaultAgentWorkdir, whichBinary, whereBinary } = require('../paths');
37
+ const {
38
+ KimiStreamParser,
39
+ interpretKimiMessage,
40
+ buildKimiArgs,
41
+ buildKimiEnv,
42
+ redactArgs,
43
+ redactSecrets,
44
+ classifyKimiVersion,
45
+ classifyKimiError,
46
+ } = require('./kimi-stream');
47
+
48
+ const IS_WINDOWS = process.platform === 'win32';
16
49
 
17
50
  const DEFAULT_BASE_URL = 'https://api.moonshot.ai/v1';
18
51
  const DEFAULT_MODEL = 'kimi-k2.6';
19
52
 
53
+ // Idle watchdog: if stdout is silent this long while a run is in flight we
54
+ // nudge the user; after MAX consecutive silences we kill the (possibly hung)
55
+ // process. Kimi's provider retry backoff can reach ~1 min between attempts,
56
+ // so the kill threshold stays generous.
57
+ const WATCHDOG_INTERVAL_MS = 15_000;
58
+ const WATCHDOG_NUDGE_AT = 2; // ~30s of silence → "still working"
59
+ const WATCHDOG_MAX = 20; // ~5 min of silence → kill
60
+
61
+ // Version check cache: keyed by resolved binary path so repeated messages
62
+ // don't re-spawn `kimi --version`, yet an install/upgrade is re-detected.
63
+ const VERSION_CACHE_TTL_MS = 5 * 60 * 1000;
64
+ const _kimiVersionCache = new Map(); // binPath -> { version, product, at }
65
+
20
66
  class KimiAdapter extends LlmDirectAdapter {
21
67
  constructor(opts) {
22
68
  super({
@@ -28,20 +74,19 @@ class KimiAdapter extends LlmDirectAdapter {
28
74
 
29
75
  const env = this.agentEnv || process.env;
30
76
 
77
+ // Direct-API fallback config (also feeds the CLI env mapping).
31
78
  const apiKey =
32
79
  env.KIMI_API_KEY ||
33
80
  env.MOONSHOT_API_KEY ||
34
81
  env.LLM_API_KEY ||
35
82
  env.OPENAI_API_KEY ||
36
83
  '';
37
-
38
84
  const baseUrl = (
39
85
  env.KIMI_BASE_URL ||
40
86
  env.LLM_BASE_URL ||
41
87
  env.OPENAI_BASE_URL ||
42
88
  DEFAULT_BASE_URL
43
89
  ).replace(/\/$/, '');
44
-
45
90
  const model =
46
91
  env.KIMI_MODEL ||
47
92
  env.LLM_MODEL ||
@@ -52,15 +97,681 @@ class KimiAdapter extends LlmDirectAdapter {
52
97
  this._model = model;
53
98
  this._directMode = !!(this._apiKey && this._baseUrl);
54
99
 
55
- if (this._directMode) {
56
- this._log(`Kimi mode: ${this._baseUrl} model=${this._model}`);
100
+ // CLI-mode state (mirrors the Cline/Claude adapters).
101
+ this._channelSessions = {}; // channel → { sessionId, workingDir }
102
+ this._channelProcesses = {}; // channel → in-flight child process
103
+ this._stoppingChannels = new Set();
104
+ this._loggedCliMode = false;
105
+ this._sessionsFile = path.join(
106
+ os.homedir(), '.openagents', 'sessions',
107
+ `${this.workspaceId}_${this.agentName}_kimi.json`,
108
+ );
109
+ this._loadSessions();
110
+
111
+ if (this._findKimiBinary()) {
112
+ this._log('Kimi Code CLI detected — running in CLI mode');
113
+ } else if (this._directMode) {
114
+ this._log(`Kimi direct API mode: ${this._baseUrl} model=${this._model} (install Kimi Code CLI for full agent mode: npm install -g @moonshot-ai/kimi-code)`);
57
115
  } else {
58
116
  this._log(
59
- 'Kimi adapter started without API key. ' +
60
- 'Set KIMI_API_KEY (or MOONSHOT_API_KEY) via the Launcher Configure screen.'
117
+ 'Kimi adapter started without CLI or API key. Install the CLI ' +
118
+ '(npm install -g @moonshot-ai/kimi-code) and/or set KIMI_API_KEY in the Launcher.'
61
119
  );
62
120
  }
63
121
  }
122
+
123
+ // ------------------------------------------------------------------
124
+ // Session persistence (real Kimi session ids, bound to working dir)
125
+ // ------------------------------------------------------------------
126
+
127
+ _loadSessions() {
128
+ try {
129
+ if (fs.existsSync(this._sessionsFile)) {
130
+ const data = JSON.parse(fs.readFileSync(this._sessionsFile, 'utf-8'));
131
+ if (data && typeof data === 'object') {
132
+ Object.assign(this._channelSessions, data);
133
+ this._log(`Loaded ${Object.keys(data).length} Kimi session(s)`);
134
+ }
135
+ }
136
+ } catch {
137
+ this._log('Could not load Kimi sessions file, starting fresh');
138
+ }
139
+ }
140
+
141
+ _saveSessions() {
142
+ try {
143
+ fs.mkdirSync(path.dirname(this._sessionsFile), { recursive: true });
144
+ fs.writeFileSync(this._sessionsFile, JSON.stringify(this._channelSessions));
145
+ } catch {}
146
+ }
147
+
148
+ /** Return a valid saved session id for this channel, or null. Only resume
149
+ * when the saved working dir matches the current one (don't cross projects). */
150
+ _resumableSession(channel, workingDir) {
151
+ const entry = this._channelSessions[channel];
152
+ if (!entry || !entry.sessionId) return null;
153
+ if (entry.workingDir && workingDir && entry.workingDir !== workingDir) return null;
154
+ return entry.sessionId;
155
+ }
156
+
157
+ _clearSession(channel) {
158
+ if (this._channelSessions[channel]) {
159
+ delete this._channelSessions[channel];
160
+ this._saveSessions();
161
+ }
162
+ }
163
+
164
+ // ------------------------------------------------------------------
165
+ // Control actions (stop / restart)
166
+ // ------------------------------------------------------------------
167
+
168
+ async _onControlAction(action, payload) {
169
+ if (action === 'stop') {
170
+ const channel = (payload && typeof payload === 'object') ? payload.channel : null;
171
+ if (channel && this._channelProcesses[channel]) {
172
+ this._stoppingChannels.add(channel);
173
+ await this._stopProcess(this._channelProcesses[channel]);
174
+ delete this._channelProcesses[channel];
175
+ delete this._channelQueues[channel];
176
+ try { await this.sendResponse(channel, 'Execution stopped by user.'); } catch {}
177
+ return;
178
+ }
179
+ if (Object.keys(this._channelProcesses).length) {
180
+ await this._stopAllProcesses('Execution stopped by user.');
181
+ return;
182
+ }
183
+ // No CLI runs in flight — fall through to the direct-API stop.
184
+ await super._onControlAction(action, payload);
185
+ return;
186
+ }
187
+ if (action === 'restart') {
188
+ const channel = (payload && typeof payload === 'object') ? payload.channel : null;
189
+ if (channel) {
190
+ if (this._channelProcesses[channel]) {
191
+ try { await this._stopProcess(this._channelProcesses[channel]); } catch {}
192
+ delete this._channelProcesses[channel];
193
+ }
194
+ this._clearSession(channel);
195
+ try {
196
+ await this.client.sendMessage(this.workspaceId, channel, this.token,
197
+ 'Session restarted — next message starts a fresh Kimi session.',
198
+ { senderType: 'agent', senderName: this.agentName, messageType: 'status',
199
+ metadata: { agent_mode: this._mode }, sessionId: this._sessionId });
200
+ } catch {}
201
+ } else {
202
+ this._channelSessions = {};
203
+ this._saveSessions();
204
+ await this._stopAllProcesses('Execution stopped.');
205
+ }
206
+ return;
207
+ }
208
+ await super._onControlAction(action, payload);
209
+ }
210
+
211
+ /** Daemon shutdown — tear down any in-flight kimi runs so threads don't
212
+ * hang showing "running". Fire-and-forget; the daemon allows a short grace. */
213
+ stop() {
214
+ this._stopAllProcesses(
215
+ 'Task interrupted — daemon restarting. Send another message to continue.',
216
+ ).catch(() => {});
217
+ super.stop();
218
+ }
219
+
220
+ async _stopAllProcesses(message = 'Execution stopped.') {
221
+ const entries = Object.entries(this._channelProcesses);
222
+ if (!entries.length) return;
223
+ this._log(`Stopping ${entries.length} running Kimi process(es)...`);
224
+ for (const [channel, proc] of entries) {
225
+ this._stoppingChannels.add(channel);
226
+ await this._stopProcess(proc);
227
+ delete this._channelProcesses[channel];
228
+ delete this._channelQueues[channel];
229
+ try { await this.sendResponse(channel, message); } catch {}
230
+ }
231
+ }
232
+
233
+ /** Stop a kimi process tree gracefully then forcefully (POSIX process group
234
+ * / `taskkill /T` on Windows), same escalation as the other CLI adapters. */
235
+ async _stopProcess(proc) {
236
+ if (!proc || proc.exitCode !== null) return;
237
+ try {
238
+ if (IS_WINDOWS) {
239
+ try { proc.kill('SIGINT'); } catch {}
240
+ const exited = await this._waitExit(proc, 1500);
241
+ if (!exited) {
242
+ try { execSync(`taskkill /F /T /PID ${proc.pid}`, { timeout: 5000, windowsHide: true }); } catch {}
243
+ }
244
+ } else {
245
+ try { process.kill(-proc.pid, 'SIGINT'); } catch { try { proc.kill('SIGINT'); } catch {} }
246
+ let exited = await this._waitExit(proc, 1500);
247
+ if (!exited) {
248
+ try { process.kill(-proc.pid, 'SIGTERM'); } catch { try { proc.kill('SIGTERM'); } catch {} }
249
+ exited = await this._waitExit(proc, 1500);
250
+ }
251
+ if (!exited) {
252
+ try { process.kill(-proc.pid, 'SIGKILL'); } catch { try { proc.kill('SIGKILL'); } catch {} }
253
+ await this._waitExit(proc, 1000);
254
+ }
255
+ }
256
+ } catch {}
257
+ }
258
+
259
+ _waitExit(proc, ms) {
260
+ return new Promise((resolve) => {
261
+ if (proc.exitCode !== null) { resolve(true); return; }
262
+ const t = setTimeout(() => resolve(false), ms);
263
+ proc.once('exit', () => { clearTimeout(t); resolve(true); });
264
+ });
265
+ }
266
+
267
+ // ------------------------------------------------------------------
268
+ // Binary resolution (cross-platform; mirrors the Cline adapter)
269
+ // ------------------------------------------------------------------
270
+
271
+ _findNodeBin() {
272
+ const home = os.homedir();
273
+ const candidates = IS_WINDOWS
274
+ ? [path.join(home, '.openagents', 'nodejs', 'node.exe')]
275
+ : [path.join(home, '.openagents', 'nodejs', 'node'),
276
+ path.join(home, '.openagents', 'nodejs', 'bin', 'node')];
277
+ for (const c of candidates) if (fs.existsSync(c)) return c;
278
+ return process.execPath;
279
+ }
280
+
281
+ /**
282
+ * Resolve a shim/symlink to [nodeBin, jsEntry] so we spawn the JS wrapper
283
+ * directly. Kimi Code's package bin IS `dist/main.mjs` (a node script), so
284
+ * running it under node is the most robust path on every OS — on Windows it
285
+ * avoids `cmd.exe /c`'s 8191-char command-line cap truncating a long prompt.
286
+ */
287
+ _resolveToNodeCmd(binPath) {
288
+ const nodeBin = this._findNodeBin();
289
+ if (IS_WINDOWS && binPath.toLowerCase().endsWith('.cmd')) {
290
+ try {
291
+ const cmdDir = path.dirname(path.resolve(binPath));
292
+ const content = fs.readFileSync(binPath, 'utf-8');
293
+ const jsMatch = content.match(/%dp0%\\([^\s"*?]+\.m?js)/i);
294
+ if (jsMatch) return [nodeBin, path.resolve(cmdDir, jsMatch[1])];
295
+ const exeMatch = content.match(/%dp0%\\([^\s"*?]+\.exe)/i);
296
+ if (exeMatch) return [path.resolve(cmdDir, exeMatch[1])];
297
+ } catch {}
298
+ } else {
299
+ try {
300
+ let target = binPath;
301
+ if (fs.lstatSync(binPath).isSymbolicLink()) {
302
+ target = path.resolve(path.dirname(binPath), fs.readlinkSync(binPath));
303
+ }
304
+ if (target.endsWith('.js') || target.endsWith('.mjs')) return [nodeBin, target];
305
+ if (this._isNodeShebangScript(target)) return [nodeBin, target];
306
+ } catch {}
307
+ }
308
+ return null;
309
+ }
310
+
311
+ /** True when a file begins with a `#!...node` shebang. */
312
+ _isNodeShebangScript(filePath) {
313
+ try {
314
+ const fd = fs.openSync(filePath, 'r');
315
+ try {
316
+ const buf = Buffer.alloc(64);
317
+ const n = fs.readSync(fd, buf, 0, 64, 0);
318
+ const head = buf.slice(0, n).toString('utf-8');
319
+ return head.startsWith('#!') && /\bnode\b/.test(head.split('\n')[0]);
320
+ } finally {
321
+ fs.closeSync(fd);
322
+ }
323
+ } catch {
324
+ return false;
325
+ }
326
+ }
327
+
328
+ _findKimiBinary() {
329
+ const home = os.homedir();
330
+ const ext = IS_WINDOWS ? '.cmd' : '';
331
+
332
+ // Tier 0: isolated runtime prefix (~/.openagents/runtimes/kimi/)
333
+ const runtimeCandidate = path.join(home, '.openagents', 'runtimes', 'kimi', 'node_modules', '.bin', `kimi${ext}`);
334
+ if (fs.existsSync(runtimeCandidate)) return runtimeCandidate;
335
+
336
+ // Tier 0b: legacy portable install
337
+ const portable = path.join(home, '.openagents', 'nodejs', 'node_modules', '.bin', `kimi${ext}`);
338
+ if (fs.existsSync(portable)) return portable;
339
+
340
+ // Tier 0c: the package's own bin (`dist/main.mjs`) inside a prefix install.
341
+ for (const root of [
342
+ path.join(home, '.openagents', 'runtimes', 'kimi', 'node_modules', '@moonshot-ai', 'kimi-code'),
343
+ path.join(home, '.openagents', 'nodejs', 'node_modules', '@moonshot-ai', 'kimi-code'),
344
+ ]) {
345
+ const pkgBin = path.join(root, 'dist', 'main.mjs');
346
+ if (fs.existsSync(pkgBin)) return pkgBin;
347
+ }
348
+
349
+ // Tier 1: PATH search (codepage-safe, enriched env — see paths.js).
350
+ const viaWhere = whereBinary('kimi');
351
+ if (viaWhere) return viaWhere;
352
+
353
+ // Tier 2: next to the current Node interpreter (npm global)
354
+ const nearNode = path.join(path.dirname(process.execPath), `kimi${ext}`);
355
+ if (fs.existsSync(nearNode)) return nearNode;
356
+
357
+ // Tier 3: common install locations (incl. the vendor install script's
358
+ // ~/.local/bin target and homebrew).
359
+ const candidates = IS_WINDOWS ? [
360
+ path.join(process.env.APPDATA || '', 'npm', 'kimi.cmd'),
361
+ ] : [
362
+ path.join(home, '.local', 'bin', 'kimi'),
363
+ path.join(home, '.npm-global', 'bin', 'kimi'),
364
+ '/opt/homebrew/bin/kimi',
365
+ '/usr/local/bin/kimi',
366
+ ];
367
+ for (const c of candidates) if (fs.existsSync(c)) return c;
368
+
369
+ // Tier 4: deep scan of every known bin dir (nvm/fnm/volta/homebrew/…)
370
+ const viaWhich = whichBinary('kimi');
371
+ if (viaWhich) return viaWhich;
372
+
373
+ return null;
374
+ }
375
+
376
+ /** Resolve [cmd, ...args] for spawning, handling node-script wrappers. */
377
+ _spawnableCmd(binPath, args) {
378
+ const resolved = this._resolveToNodeCmd(binPath);
379
+ if (resolved) return [...resolved, ...args];
380
+ if (binPath.endsWith('.mjs') || binPath.endsWith('.js')) {
381
+ return [this._findNodeBin(), binPath, ...args];
382
+ }
383
+ if (IS_WINDOWS && binPath.toLowerCase().endsWith('.cmd')) return ['cmd.exe', '/c', binPath, ...args];
384
+ return [binPath, ...args];
385
+ }
386
+
387
+ // ------------------------------------------------------------------
388
+ // Version / product preflight (cached)
389
+ // ------------------------------------------------------------------
390
+
391
+ /** Run `kimi --version` and return its raw output. Isolated for testing. */
392
+ _readKimiVersionRaw(kimiBin) {
393
+ const [cmd, ...args] = this._spawnableCmd(kimiBin, ['--version']);
394
+ return execSync(
395
+ [cmd, ...args].map((a) => `"${a}"`).join(' '),
396
+ { encoding: 'utf-8', timeout: 8000, windowsHide: true },
397
+ ).trim();
398
+ }
399
+
400
+ /**
401
+ * Resolve the installed product identity, cached per binary path with a TTL.
402
+ * @returns {{version: string|null, product: 'kimi-code'|'legacy'|null}}
403
+ */
404
+ _checkKimiVersion(kimiBin) {
405
+ const now = Date.now();
406
+ const cached = _kimiVersionCache.get(kimiBin);
407
+ if (cached && (now - cached.at) < VERSION_CACHE_TTL_MS) {
408
+ return { version: cached.version, product: cached.product };
409
+ }
410
+ let version = null;
411
+ let product = null;
412
+ try {
413
+ ({ version, product } = classifyKimiVersion(this._readKimiVersionRaw(kimiBin)));
414
+ } catch {
415
+ // `kimi --version` failed → undetermined; proceed leniently.
416
+ }
417
+ _kimiVersionCache.set(kimiBin, { version, product, at: now });
418
+ return { version, product };
419
+ }
420
+
421
+ /** Clear the shared version cache (test hook; also after install/upgrade). */
422
+ static _clearVersionCache() {
423
+ _kimiVersionCache.clear();
424
+ }
425
+
426
+ // ------------------------------------------------------------------
427
+ // Prompt assembly
428
+ // ------------------------------------------------------------------
429
+
430
+ /** A compact context header. Kimi Code keeps its own coding system prompt;
431
+ * we only add workspace identity and (soft) plan-mode framing — print mode
432
+ * cannot take the CLI's --plan flag. */
433
+ _contextHeader(channel) {
434
+ const lines = [
435
+ `[OpenAgents workspace] You are "${this.agentName}", a coding agent in workspace channel "${channel}".`,
436
+ ];
437
+ if (this._mode === 'plan') {
438
+ lines.push('You are in PLAN mode: investigate and propose a plan; do not modify files.');
439
+ }
440
+ lines.push('Work in the current working directory. Reply concisely. The user request follows:');
441
+ return lines.join('\n');
442
+ }
443
+
444
+ /** Short transcript of recent chat used to re-seed context when starting a
445
+ * fresh session (resume unavailable). Bounded to keep argv small on Windows. */
446
+ async _buildChannelRecap(channel, currentMessage) {
447
+ try {
448
+ const messages = await this.client.getRecentMessages(this.workspaceId, channel, this.token, 30);
449
+ if (!messages || messages.length === 0) return null;
450
+ const lines = [];
451
+ for (const m of messages) {
452
+ const mt = m.messageType || 'chat';
453
+ if (mt === 'status' || mt === 'thinking' || mt === 'loading' || mt === 'todos') continue;
454
+ const text = (m.content || '').trim();
455
+ if (!text || text === currentMessage) continue;
456
+ const who = m.senderType === 'human' ? (m.senderName || 'user') : (m.senderName || 'agent');
457
+ lines.push(`[${who}] ${text.length > 800 ? text.slice(0, 800) + '…' : text}`);
458
+ }
459
+ if (lines.length === 0) return null;
460
+ return 'Recent conversation in this channel for context:\n\n' + lines.slice(-12).join('\n');
461
+ } catch {
462
+ return null;
463
+ }
464
+ }
465
+
466
+ // ------------------------------------------------------------------
467
+ // Message handling
468
+ // ------------------------------------------------------------------
469
+
470
+ async _handleMessage(msg) {
471
+ const kimiBin = this._findKimiBinary();
472
+
473
+ if (!kimiBin) {
474
+ // Legacy direct-API fallback: agents configured before CLI support keep
475
+ // working exactly as they did.
476
+ if (this._directMode) {
477
+ if (!this._loggedCliMode) {
478
+ this._loggedCliMode = true;
479
+ this._log('Kimi Code CLI not found — using direct API fallback (npm install -g @moonshot-ai/kimi-code for full agent mode)');
480
+ }
481
+ await super._handleMessage(msg);
482
+ return;
483
+ }
484
+ const channel = msg.sessionId || this.channelName;
485
+ await this.sendError(channel,
486
+ 'Kimi Code CLI not found. Install it with: npm install -g @moonshot-ai/kimi-code ' +
487
+ '(or set KIMI_API_KEY for direct API mode).');
488
+ return;
489
+ }
490
+
491
+ const ver = this._checkKimiVersion(kimiBin);
492
+ if (ver.product === 'legacy') {
493
+ // Wrong product on PATH: the wound-down Python kimi-cli (1.x) has a
494
+ // different headless interface. Fall back to direct API if configured.
495
+ if (this._directMode) {
496
+ if (!this._loggedCliMode) {
497
+ this._loggedCliMode = true;
498
+ this._log(`Legacy kimi-cli ${ver.version} detected — using direct API fallback. Install Kimi Code CLI: npm install -g @moonshot-ai/kimi-code`);
499
+ }
500
+ await super._handleMessage(msg);
501
+ return;
502
+ }
503
+ const channel = msg.sessionId || this.channelName;
504
+ await this.sendError(channel,
505
+ `Found the legacy Python kimi-cli (${ver.version}), which is not supported. ` +
506
+ 'Install the real Kimi Code CLI with: npm install -g @moonshot-ai/kimi-code');
507
+ return;
508
+ }
509
+
510
+ await this._handleMessageCli(msg, kimiBin);
511
+ }
512
+
513
+ async _handleMessageCli(msg, kimiBin) {
514
+ let content = (msg.content || '').trim();
515
+ const attachments = msg.attachments || [];
516
+ const attText = formatAttachmentsForPrompt(attachments, 'skills');
517
+ if (attText) content = content ? content + attText : attText.trim();
518
+ if (!content) return;
519
+
520
+ const channel = msg.sessionId || this.channelName;
521
+ this._stoppingChannels.delete(channel);
522
+ const sender = msg.senderName || msg.senderType || 'user';
523
+ this._log(`Processing message from ${sender} in ${channel}: ${redactSecrets(content.slice(0, 80))}...`);
524
+
525
+ // Resolve and validate the working directory up front. Never silently fall
526
+ // back to the launcher/repo dir — return a clear error instead.
527
+ const workingDir = this.workingDir || defaultAgentWorkdir(this.agentName);
528
+ if (this.workingDir && !this._dirExists(this.workingDir)) {
529
+ await this.sendError(channel, `Working directory does not exist: ${this.workingDir}`);
530
+ return;
531
+ }
532
+ try { fs.mkdirSync(workingDir, { recursive: true }); } catch {}
533
+
534
+ // Auto-title + resume-from on first encounter (parity with other adapters).
535
+ if (!this._titledSessions.has(channel)) {
536
+ this._titledSessions.add(channel);
537
+ try {
538
+ const info = await this.client.getSession(this.workspaceId, channel, this.token);
539
+ const resumeFrom = info.resumeFrom;
540
+ if (resumeFrom && !this._channelSessions[channel] && this._channelSessions[resumeFrom]) {
541
+ this._channelSessions[channel] = { ...this._channelSessions[resumeFrom] };
542
+ this._saveSessions();
543
+ }
544
+ const title = generateSessionTitle(content);
545
+ if (title && !info.titleManuallySet && SESSION_DEFAULT_RE.test(info.title || '')) {
546
+ await this.client.updateSession(this.workspaceId, channel, this.token, { title, autoTitle: true });
547
+ }
548
+ } catch {}
549
+ }
550
+
551
+ await this.sendStatus(channel, 'thinking...');
552
+
553
+ // One retry: if resuming a stale session fails, retry once fresh.
554
+ for (let attempt = 0; attempt < 2; attempt++) {
555
+ const resumeId = attempt === 0 ? this._resumableSession(channel, workingDir) : null;
556
+
557
+ // Resuming → Kimi already has history, send the bare turn. Fresh →
558
+ // prepend a context header (+ recap when available).
559
+ let prompt;
560
+ if (resumeId) {
561
+ prompt = content;
562
+ } else {
563
+ const header = this._contextHeader(channel);
564
+ const recap = await this._buildChannelRecap(channel, content);
565
+ prompt = recap
566
+ ? `${header}\n\n${recap}\n\n---\n\n${content}`
567
+ : `${header}\n\n${content}`;
568
+ }
569
+
570
+ const args = buildKimiArgs({ prompt, sessionId: resumeId || undefined });
571
+ const result = await this._runKimi(channel, kimiBin, args, workingDir);
572
+
573
+ if (result.userStopped) return;
574
+
575
+ // Stale-session handling: a resume that died/erred with nothing useful →
576
+ // clear and retry fresh once.
577
+ if (resumeId && !result.ok && !result.anyOutput && attempt === 0) {
578
+ this._log(`Resume of session ${resumeId} failed — clearing and retrying fresh`);
579
+ this._clearSession(channel);
580
+ continue;
581
+ }
582
+
583
+ // Persist the session id for next turn (emitted inline by the CLI).
584
+ if (result.sessionId) {
585
+ this._channelSessions[channel] = { sessionId: result.sessionId, workingDir };
586
+ this._saveSessions();
587
+ }
588
+
589
+ if (result.ok && result.finalText) {
590
+ try { await this.sendResponse(channel, result.finalText); } catch {}
591
+ } else if (!result.ok) {
592
+ const { userMessage } = classifyKimiError({
593
+ code: result.exitCode,
594
+ signal: result.exitSignal,
595
+ stderrText: result.stderrText,
596
+ retryMessage: result.retryMessage,
597
+ });
598
+ try { await this.sendError(channel, userMessage); } catch {}
599
+ } else {
600
+ try { await this.sendResponse(channel, 'No response generated. Please try again.'); } catch {}
601
+ }
602
+ return;
603
+ }
604
+ }
605
+
606
+ _dirExists(p) {
607
+ try { return fs.statSync(p).isDirectory(); } catch { return false; }
608
+ }
609
+
610
+ /**
611
+ * Spawn one `kimi -p … --output-format stream-json` run, stream-parse it,
612
+ * and resolve a summary:
613
+ * { ok, finalText, sessionId, anyOutput, userStopped,
614
+ * exitCode, exitSignal, stderrText, retryMessage }
615
+ * `ok` is exit code 0; `finalText` is the LAST assistant text of the turn.
616
+ */
617
+ _runKimi(channel, kimiBin, args, workingDir) {
618
+ const { env: cleanEnv } = buildKimiEnv(this.agentEnv || process.env);
619
+ const [cmd, ...spawnArgs] = this._spawnableCmd(kimiBin, args);
620
+
621
+ this._log(`Spawning kimi in ${workingDir}: ${redactArgs([kimiBin, ...args]).join(' ')}`);
622
+
623
+ const proc = spawn(cmd, spawnArgs, {
624
+ stdio: ['ignore', 'pipe', 'pipe'],
625
+ env: cleanEnv,
626
+ cwd: workingDir,
627
+ detached: !IS_WINDOWS,
628
+ windowsHide: true,
629
+ });
630
+ this._channelProcesses[channel] = proc;
631
+
632
+ const parser = new KimiStreamParser();
633
+ const state = {
634
+ ok: false,
635
+ finalText: '',
636
+ sessionId: null,
637
+ anyOutput: false,
638
+ userStopped: false,
639
+ exitCode: null,
640
+ exitSignal: null,
641
+ stderrText: '',
642
+ retryMessage: '',
643
+ };
644
+
645
+ return new Promise((resolve) => {
646
+ let settled = false;
647
+ let watchdogTimer = null;
648
+ let fallbackTimer = null;
649
+ let lastDataMs = Date.now();
650
+ let silences = 0;
651
+ let queue = Promise.resolve();
652
+
653
+ const cleanup = () => {
654
+ if (watchdogTimer) { clearInterval(watchdogTimer); watchdogTimer = null; }
655
+ if (fallbackTimer) { clearTimeout(fallbackTimer); fallbackTimer = null; }
656
+ // Drop data/end listeners but KEEP an 'error' guard: a SIGKILL'd
657
+ // child's pipe can still emit 'error' after we settle.
658
+ if (proc.stdout) { proc.stdout.removeAllListeners(); proc.stdout.on('error', () => {}); }
659
+ if (proc.stderr) { proc.stderr.removeAllListeners(); proc.stderr.on('error', () => {}); }
660
+ };
661
+ const settle = () => {
662
+ if (settled) return;
663
+ settled = true;
664
+ cleanup();
665
+ if (this._stoppingChannels.has(channel)) state.userStopped = true;
666
+ delete this._channelProcesses[channel];
667
+ resolve(state);
668
+ };
669
+
670
+ const handleMessage = async (m) => {
671
+ for (const e of interpretKimiMessage(m)) {
672
+ switch (e.kind) {
673
+ case 'text':
674
+ state.anyOutput = true;
675
+ state.finalText = e.text;
676
+ try { await this.sendThinking(channel, e.text); } catch {}
677
+ break;
678
+ case 'tool_start':
679
+ state.anyOutput = true;
680
+ try {
681
+ await this.sendStatus(channel, e.preview ? `${e.name}: ${e.preview}` : e.name);
682
+ } catch {}
683
+ break;
684
+ case 'session':
685
+ state.sessionId = e.sessionId;
686
+ break;
687
+ case 'retrying':
688
+ state.retryMessage = e.message || state.retryMessage;
689
+ try {
690
+ await this.sendStatus(channel, `Provider error — retrying (${e.attempt}/${e.maxAttempts})...`);
691
+ } catch {}
692
+ break;
693
+ default:
694
+ break;
695
+ }
696
+ }
697
+ };
698
+
699
+ // Swallow stdio stream errors (EPIPE after SIGKILL etc.) — we finalize
700
+ // via exit/end anyway.
701
+ if (proc.stdout) proc.stdout.on('error', () => {});
702
+ if (proc.stderr) proc.stderr.on('error', () => {});
703
+
704
+ proc.stdout.on('data', (chunk) => {
705
+ lastDataMs = Date.now();
706
+ silences = 0;
707
+ const msgs = parser.push(chunk);
708
+ for (const m of msgs) queue = queue.then(() => handleMessage(m)).catch(() => {});
709
+ });
710
+
711
+ // stderr: `error: ...` lines carry the fatal cause; collected for
712
+ // classification, never surfaced verbatim as an assistant reply.
713
+ proc.stderr.on('data', (chunk) => {
714
+ state.stderrText += chunk.toString('utf-8');
715
+ if (state.stderrText.length > 64 * 1024) {
716
+ state.stderrText = state.stderrText.slice(-32 * 1024);
717
+ }
718
+ });
719
+
720
+ // Idle watchdog
721
+ watchdogTimer = setInterval(async () => {
722
+ if (settled) return;
723
+ const elapsed = Date.now() - lastDataMs;
724
+ if (elapsed < WATCHDOG_INTERVAL_MS) { silences = 0; return; }
725
+ silences++;
726
+ lastDataMs = Date.now();
727
+ if (silences === WATCHDOG_NUDGE_AT) {
728
+ try { await this.sendStatus(channel, 'Still working...'); } catch {}
729
+ }
730
+ if (silences >= WATCHDOG_MAX) {
731
+ this._log(`Watchdog: kimi silent ${silences * 15}s on ${channel} — killing`);
732
+ if (!state.stderrText) state.stderrText = 'error: Kimi became unresponsive and was stopped.';
733
+ await this._stopProcess(proc);
734
+ }
735
+ }, WATCHDOG_INTERVAL_MS);
736
+
737
+ // Finalize only once BOTH the process has exited AND stdout has ended,
738
+ // so the final buffered lines are never dropped (same race as Cline on
739
+ // macOS). A short fallback covers a lingering child holding the pipe.
740
+ let exited = false;
741
+ let stdoutEnded = false;
742
+
743
+ const finalize = () => {
744
+ if (settled) return;
745
+ if (fallbackTimer) { clearTimeout(fallbackTimer); fallbackTimer = null; }
746
+ for (const m of parser.flush()) queue = queue.then(() => handleMessage(m)).catch(() => {});
747
+ queue = queue.then(() => {
748
+ state.ok = state.exitCode === 0;
749
+ if (!state.ok && state.stderrText.trim()) {
750
+ this._log(`kimi stderr: ${redactSecrets(state.stderrText.trim().slice(0, 500))}`);
751
+ }
752
+ settle();
753
+ }).catch(() => settle());
754
+ };
755
+ const maybeFinalize = () => { if (exited && stdoutEnded) finalize(); };
756
+
757
+ if (proc.stdout) proc.stdout.on('end', () => { stdoutEnded = true; maybeFinalize(); });
758
+ else stdoutEnded = true;
759
+
760
+ proc.on('exit', (code, signal) => {
761
+ exited = true;
762
+ state.exitCode = code;
763
+ state.exitSignal = signal;
764
+ fallbackTimer = setTimeout(() => { stdoutEnded = true; finalize(); }, 1500);
765
+ maybeFinalize();
766
+ });
767
+
768
+ proc.on('error', (err) => {
769
+ state.stderrText = state.stderrText || `error: Failed to start Kimi Code CLI: ${redactSecrets(err.message)}`;
770
+ state.exitCode = state.exitCode === null ? -1 : state.exitCode;
771
+ settle();
772
+ });
773
+ });
774
+ }
64
775
  }
65
776
 
66
777
  module.exports = KimiAdapter;