@openagents-org/agent-launcher 0.2.182 → 0.2.184

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.
@@ -0,0 +1,316 @@
1
+ /**
2
+ * CodeArts Agent adapter — Huawei Cloud's 码道 (CodeArts) code agent CLI.
3
+ *
4
+ * The CLI is built on OpenCode. Its binary (shipped as `agentkernel`, installed
5
+ * as `codearts`) takes OpenCode's `run --format json`, streams OpenCode's JSON
6
+ * events and resumes with `--session`, so the stream parsing, sessions, stop
7
+ * control and failure taxonomy are OpenCodeAdapter's. What differs is how the
8
+ * CLI is found, launched and authenticated (verified against 26.9.3):
9
+ *
10
+ * - Installed by Huawei's own script (install.sh / install.ps1) into
11
+ * ~/.codeartsdoer/installers, never npm. What PATH finds there is a wrapper
12
+ * (`codearts.cmd` / a shell script) that sets the CLI's environment and runs
13
+ * bin/codearts(.exe). The adapter runs that binary itself with the same
14
+ * environment: through the .cmd it would need a cmd.exe host, and an
15
+ * attached console is what makes an OpenCode-based CLI open its TUI instead
16
+ * of running headless.
17
+ * - Non-interactive commands authenticate with a Huawei Cloud access key
18
+ * pair and nothing else — CODEARTS_CLI_AK / CODEARTS_CLI_SK. The browser
19
+ * sign-in the TUI offers does not reach them: without the pair, `auth list`,
20
+ * `models` and `run` all fail with "认证失败,没有设置环境变量
21
+ * CODEARTS_CLI_AK/CODEARTS_CLI_SK".
22
+ * - `run` has no --dir; the working directory is the spawn cwd.
23
+ * - `--auto` runs tools in always_allow mode. A headless run has nobody to
24
+ * answer a permission prompt; the daemon is the approval boundary, as it is
25
+ * for every other agent here.
26
+ * - A model the account cannot use is reported on stderr — "error: 未找到模型:
27
+ * …" — and the CLI still exits 0 with nothing on stdout.
28
+ */
29
+
30
+ 'use strict';
31
+
32
+ const { execFileSync } = require('child_process');
33
+ const fs = require('fs');
34
+ const os = require('os');
35
+ const path = require('path');
36
+
37
+ const OpenCodeAdapter = require('./opencode');
38
+ const { whichBinary } = require('../paths');
39
+ const { isWslBinary } = require('../wsl');
40
+
41
+ const IS_WINDOWS = process.platform === 'win32';
42
+
43
+ /** Newest CLI this adapter was checked against. Newer still runs, flagged. */
44
+ const CODEARTS_TESTED_VERSION = '26.9.3';
45
+
46
+ /**
47
+ * Used only when `codearts models` can't be read. What an account may run is
48
+ * decided by Huawei Cloud per account: the `huaweicloud-maas/deepseek-v3.2`
49
+ * from Huawei's getting-started guide was not in a real account's list
50
+ * (2026-09-15, which listed GLM-5.2, GLM-5.2-ArkTS-SPARK and OpenPangu-2.0
51
+ * Flash/Pro), and every message to it failed.
52
+ */
53
+ const DEFAULT_MODEL = 'huaweicloud-maas/GLM-5.2';
54
+
55
+ /** How long a read of `codearts models` is trusted, and how long a failed one waits. */
56
+ const MODEL_LIST_TTL_MS = 10 * 60_000;
57
+ const MODEL_LIST_RETRY_MS = 60_000;
58
+
59
+ /** Where the CLI itself sends people to create the access key it needs. */
60
+ const AKSK_URL = 'https://codearts.huaweicloud.com/portal/settings/cli-auth';
61
+
62
+ const DOWNLOAD_URL = 'https://codearts.huaweicloud.com/download.html';
63
+
64
+ const FAILURE_MESSAGES = {
65
+ cli_not_found: `CodeArts Agent CLI not found. Install it (${DOWNLOAD_URL}) and try again.`,
66
+ cli_not_executable: 'CodeArts Agent CLI was found but could not be started. Reinstall it, then retry.',
67
+ unsupported_version: 'This CodeArts Agent CLI version is not supported. Update it with `codearts upgrade` and retry.',
68
+ model_missing: "No model is configured for CodeArts Agent. Set CODEARTS_MODEL (provider/model) in this agent's configuration, then retry.",
69
+ credential_missing: `CodeArts Agent needs a Huawei Cloud access key. Set CODEARTS_CLI_AK and CODEARTS_CLI_SK in this agent's configuration (create them at ${AKSK_URL}), then retry.`,
70
+ auth_failed: 'Huawei Cloud rejected the access key, or this account has no CodeArts seat. Check CODEARTS_CLI_AK / CODEARTS_CLI_SK and ask your administrator for a seat if needed, then retry.',
71
+ provider_not_configured: 'CodeArts Agent has no usable model provider. Check CODEARTS_MODEL, then retry.',
72
+ model_not_found: "This account can't use the configured model. Pick one from the model list in this agent's configuration (or run `codearts models`), set CODEARTS_MODEL, then retry.",
73
+ rate_limited: 'The model service is rate-limiting requests. Wait a moment and retry.',
74
+ network_error: 'CodeArts Agent could not reach Huawei Cloud (network or service error). Retry shortly.',
75
+ provider_server_error: 'The model service returned a server error. Retry shortly.',
76
+ timeout: 'CodeArts Agent timed out before producing a reply. Retry; if it persists, check the model configuration.',
77
+ stream_parse_error: 'CodeArts Agent produced output this version could not parse. Update the CLI, or open diagnostics.',
78
+ empty_response: 'CodeArts Agent finished without producing a final reply. Retry; if it persists, open diagnostics.',
79
+ incomplete_run: 'CodeArts Agent stopped mid-task right after a tool call and never produced a final reply — its progress notes are shown above. Send "continue" to pick up where it left off.',
80
+ process_crashed: 'CodeArts Agent exited unexpectedly. Open diagnostics or retry.',
81
+ cwd_unavailable: "CodeArts Agent's working directory is not accessible. Check the agent's configured path (or agent home) permissions.",
82
+ unknown_error: 'CodeArts Agent failed for an undetermined reason. Open diagnostics or retry.',
83
+ };
84
+
85
+ /**
86
+ * The CLI's own error line. On failure it prints a chunk of its bundled source
87
+ * before the line that matters, and classifying that source text would find
88
+ * "network" or "timeout" in someone's variable names — so only this line counts.
89
+ */
90
+ function errorLine(text) {
91
+ const matches = String(text || '').match(/^\s*error:\s*(.+)$/gm);
92
+ if (!matches) return '';
93
+ return matches[matches.length - 1].replace(/^\s*error:\s*/, '').trim();
94
+ }
95
+
96
+ class CodeArtsAdapter extends OpenCodeAdapter {
97
+ /** Where Huawei's installer puts the CLI. */
98
+ static installRoot(home = os.homedir()) {
99
+ return path.join(home, '.codeartsdoer', 'installers');
100
+ }
101
+
102
+ /**
103
+ * Where a command that belongs to no project (listing models) runs. Created
104
+ * on demand; falls back to the home directory if it cannot be.
105
+ */
106
+ static probeDir(home = os.homedir()) {
107
+ const dir = path.join(home, '.openagents', 'probe');
108
+ try {
109
+ fs.mkdirSync(dir, { recursive: true });
110
+ return dir;
111
+ } catch {
112
+ return home;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * `codearts models` prints a table, not OpenCode's one id per line:
118
+ *
119
+ * model_id model_name
120
+ * ------------------------------------------------
121
+ * huaweicloud-maas/GLM-5.2 GLM-5.2
122
+ */
123
+ static parseModels(out) {
124
+ const ids = [];
125
+ for (const line of String(out || '').split(/\r?\n/)) {
126
+ const id = line.trim().split(/\s+/)[0] || '';
127
+ if (/^[A-Za-z0-9][\w.-]*\/\S+$/.test(id) && !ids.includes(id)) ids.push(id);
128
+ }
129
+ return ids;
130
+ }
131
+
132
+ _cliLabel() {
133
+ return 'CodeArts Agent';
134
+ }
135
+
136
+ _installHint() {
137
+ return `Install it from ${DOWNLOAD_URL}`;
138
+ }
139
+
140
+ _failureMessages() {
141
+ return FAILURE_MESSAGES;
142
+ }
143
+
144
+ _findOpencodeBinary() {
145
+ const exe = IS_WINDOWS ? 'codearts.exe' : 'codearts';
146
+ const native = path.join(CodeArtsAdapter.installRoot(), 'bin', exe);
147
+ if (fs.existsSync(native)) return native;
148
+
149
+ // Somewhere else on PATH — a relocated install, or one inside WSL. When
150
+ // that is the installer's wrapper, run the binary beside it instead.
151
+ const found = whichBinary('codearts');
152
+ if (!found) return null;
153
+ if (!isWslBinary(found)) {
154
+ const sibling = path.join(path.dirname(found), 'bin', exe);
155
+ if (fs.existsSync(sibling)) return sibling;
156
+ }
157
+ return found;
158
+ }
159
+
160
+ _spawnEnv() {
161
+ const configDir = path.join(os.homedir(), '.codeartsdoer');
162
+ return {
163
+ ...(this.agentEnv || process.env),
164
+ // What the installer's wrapper sets before it runs the binary, set the
165
+ // same unconditional way: an OPENCODE_CONFIG inherited from the user's
166
+ // own OpenCode would otherwise point this CLI at the wrong config.
167
+ //
168
+ // One line of the wrapper is deliberately left out:
169
+ // NODE_TLS_REJECT_UNAUTHORIZED=0 turns off certificate checks for every
170
+ // request the CLI makes, and a headless run has not been seen to need it.
171
+ SCENARIO: 'codeartsdoer',
172
+ KERNEL_DATA_DIR: path.join(configDir, 'cli-data'),
173
+ KERNEL_CONFIG_DIR: configDir,
174
+ OPENCODE_CHANNEL: 'latest',
175
+ OPENCODE_CONFIG: path.join(configDir, 'codearts_cli.json'),
176
+ OPENCODE_CONFIG_FILE: 'codearts_cli.json,codearts_cli.jsonc',
177
+ OPENCODE_MODE: 'tui',
178
+ PLUGIN_ENV: 'hc',
179
+ OPENCODE_DISABLE_MODELS_FETCH: '1',
180
+ OPENCODE_DISABLE_AUTOUPDATE: 'true',
181
+ OPENCODE_ALWAYS_NOTIFY_UPDATE: 'false',
182
+ CODEARTS_DISABLE_AUTO_UPDATE: 'true',
183
+ CODEAGENT_USE_MESSAGE_FEEDBACK: 'false',
184
+ OMO_SEND_ANONYMOUS_TELEMETRY: '0',
185
+ };
186
+ }
187
+
188
+ _buildRunArgs({ model, sessionId }) {
189
+ const args = ['run', '--format', 'json', '--auto', '--model', model];
190
+ if (sessionId) args.push('--session', sessionId);
191
+ return args;
192
+ }
193
+
194
+ /**
195
+ * The configured model, else the first one this account's `codearts models`
196
+ * lists, else DEFAULT_MODEL. Always a model: an OpenCode-based `run` with
197
+ * none waits for an interactive pick that never comes.
198
+ */
199
+ _resolveModel() {
200
+ const env = this.agentEnv || process.env;
201
+ const configured = String(env.CODEARTS_MODEL || '').trim();
202
+ if (configured) return configured;
203
+ return this._listModelIds()[0] || DEFAULT_MODEL;
204
+ }
205
+
206
+ /**
207
+ * The ids `codearts models` lists for this agent's access key, cached. It
208
+ * takes a few seconds (it asks Huawei Cloud), so it is read once per key and
209
+ * not per message; a failed read is retried after a minute rather than on
210
+ * every message.
211
+ */
212
+ _listModelIds() {
213
+ const env = this.agentEnv || process.env;
214
+ const key = String(env.CODEARTS_CLI_AK || '');
215
+ const now = Date.now();
216
+ const cached = this._modelList;
217
+ if (cached && cached.key === key) {
218
+ const ttl = cached.ids.length ? MODEL_LIST_TTL_MS : MODEL_LIST_RETRY_MS;
219
+ if (now - cached.at < ttl) return cached.ids;
220
+ }
221
+ let ids = [];
222
+ const binary = this._opencodeBinary || this._findOpencodeBinary();
223
+ if (binary && this._credentialState() === 'present') {
224
+ try {
225
+ const out = execFileSync(binary, ['models'], {
226
+ encoding: 'utf-8',
227
+ timeout: 20_000,
228
+ windowsHide: true,
229
+ env: this._spawnEnv(),
230
+ // The CLI sets up a `.codeartsdoer/` project folder in whatever
231
+ // directory it starts in. A listing belongs to no project, so it runs
232
+ // in one of ours rather than in the daemon's working directory.
233
+ cwd: CodeArtsAdapter.probeDir(),
234
+ stdio: ['ignore', 'pipe', 'pipe'],
235
+ });
236
+ ids = CodeArtsAdapter.parseModels(out);
237
+ if (ids.length) this._log(`CodeArts models for this key: ${ids.join(', ')}`);
238
+ } catch (e) {
239
+ this._log(`codearts models failed: ${errorLine(e && e.stderr) || (e && e.message) || e}`);
240
+ }
241
+ }
242
+ this._modelList = { key, ids, at: now };
243
+ return ids;
244
+ }
245
+
246
+ // No OpenAI-compatible gateway to declare: models come from Huawei Cloud.
247
+ _customBaseUrl() {
248
+ return '';
249
+ }
250
+
251
+ _ensureCustomProviderConfig() {}
252
+
253
+ /** The access key pair is the only credential a non-interactive run accepts. */
254
+ _credentialState() {
255
+ const env = this.agentEnv || process.env;
256
+ const has = (k) => !!String(env[k] || '').trim();
257
+ return has('CODEARTS_CLI_AK') && has('CODEARTS_CLI_SK') ? 'present' : 'missing';
258
+ }
259
+
260
+ /**
261
+ * Versions are date-shaped (26.9.3). No floor: nothing older is known to
262
+ * break, and refusing a working CLI on a guess is worse than trying it.
263
+ */
264
+ static _classifyVersion(version) {
265
+ if (!version || !/^\d+\.\d+/.test(String(version))) return 'unknown';
266
+ return OpenCodeAdapter._cmpVer(version, CODEARTS_TESTED_VERSION) > 0 ? 'degraded' : 'ok';
267
+ }
268
+
269
+ /**
270
+ * OpenCode's taxonomy, plus the refusals this CLI words its own way: a model
271
+ * the account can't use, a missing or rejected key pair (both in Chinese) and
272
+ * a missing seat.
273
+ */
274
+ static _classifyFailure(input = {}) {
275
+ const result = super._classifyFailure(input);
276
+ const hay = [
277
+ input.stderr,
278
+ input.stdout,
279
+ input.stdoutErr && input.stdoutErr.message,
280
+ ].filter(Boolean).join(' ');
281
+ // Checked first: its wording ends "或当前未登录", which is not an auth failure
282
+ // when the same key lists models fine.
283
+ if (/未找到模型|模型不存在/.test(hay)) {
284
+ return { ...result, category: 'model_not_found' };
285
+ }
286
+ if (/认证失败|CODEARTS_CLI_AK|CODEARTS_CLI_SK|\bseat\b/i.test(hay)) {
287
+ return { ...result, category: 'auth_failed' };
288
+ }
289
+ return result;
290
+ }
291
+
292
+ /**
293
+ * An exit 0 with no reply is only "empty" when the CLI said nothing. This one
294
+ * exits 0 after printing its error to stderr, which OpenCode's reading
295
+ * reported as an empty response and hid the reason.
296
+ */
297
+ static _outcomeForCleanExit(args = {}) {
298
+ const outcome = super._outcomeForCleanExit(args);
299
+ const line = errorLine(args.stderr);
300
+ if (!outcome.failure || outcome.failure.category !== 'empty_response' || !line) return outcome;
301
+ const cls = this._classifyFailure({ code: 0, signal: null, stdout: '', stderr: line });
302
+ return {
303
+ failure: {
304
+ category: cls.category === 'unknown_error' || cls.category === 'stream_parse_error' ? 'unknown_error' : cls.category,
305
+ diagnostic: `exit 0, stderr: ${line}`,
306
+ detail: OpenCodeAdapter._redact(line).slice(0, 200),
307
+ },
308
+ };
309
+ }
310
+ }
311
+
312
+ CodeArtsAdapter.DEFAULT_MODEL = DEFAULT_MODEL;
313
+ CodeArtsAdapter.AKSK_URL = AKSK_URL;
314
+ CodeArtsAdapter.errorLine = errorLine;
315
+
316
+ module.exports = CodeArtsAdapter;
@@ -26,6 +26,7 @@ const AntigravityAdapter = require('./antigravity');
26
26
  const CommandCodeAdapter = require('./commandcode');
27
27
  const OpenWorkerAdapter = require('./openworker');
28
28
  const CodeBuddyAdapter = require('./codebuddy');
29
+ const CodeArtsAdapter = require('./codearts');
29
30
 
30
31
  const ADAPTER_MAP = {
31
32
  openclaw: OpenClawAdapter,
@@ -49,11 +50,12 @@ const ADAPTER_MAP = {
49
50
  commandcode: CommandCodeAdapter,
50
51
  openworker: OpenWorkerAdapter,
51
52
  codebuddy: CodeBuddyAdapter,
53
+ codearts: CodeArtsAdapter,
52
54
  };
53
55
 
54
56
  /**
55
57
  * Create an adapter instance for the given agent type.
56
- * @param {string} type - Agent type (openclaw, claude, codex, opencode, nanoclaw, cursor, hermes, gemini, kimi, aider, goose, copilot, cline, amp, mini-swe-agent, pi, deepseek, antigravity, commandcode, openworker, codebuddy)
58
+ * @param {string} type - Agent type (openclaw, claude, codex, opencode, nanoclaw, cursor, hermes, gemini, kimi, aider, goose, copilot, cline, amp, mini-swe-agent, pi, deepseek, antigravity, commandcode, openworker, codebuddy, codearts)
57
59
  * @param {object} opts - Adapter constructor options
58
60
  * @returns {BaseAdapter}
59
61
  */
@@ -87,6 +89,7 @@ module.exports = {
87
89
  AntigravityAdapter,
88
90
  CommandCodeAdapter,
89
91
  OpenWorkerAdapter,
92
+ CodeArtsAdapter,
90
93
  createAdapter,
91
94
  ADAPTER_MAP,
92
95
  };
@@ -26,6 +26,8 @@
26
26
 
27
27
  'use strict';
28
28
 
29
+ const crypto = require('crypto');
30
+
29
31
  // ---------------------------------------------------------------------------
30
32
  // Version / product classification
31
33
  // ---------------------------------------------------------------------------
@@ -270,6 +272,30 @@ function buildKimiEnv(agentEnv) {
270
272
  return { env, viaEnvProvider: true };
271
273
  }
272
274
 
275
+ // ---------------------------------------------------------------------------
276
+ // Session journal location
277
+ // ---------------------------------------------------------------------------
278
+
279
+ /**
280
+ * The directory Kimi Code CLI files a working directory's sessions under:
281
+ * `<KIMI_CODE_HOME>/sessions/<key>/<session id>/`. Mirrors the CLI's own
282
+ * `encodeWorkDirKey` (v0.42.0): `wd_` + a slug of the last path segment + `_` +
283
+ * the first 12 hex of sha256 over the path with `\` turned into `/` and no
284
+ * trailing slash. Case is kept, so `C:\Users\86177` → `wd_86177_5d6fa4a9b53d`.
285
+ */
286
+ function encodeKimiWorkDirKey(workDir) {
287
+ const normalized = String(workDir).replace(/\\/g, '/').replace(/\/+$/, '');
288
+ let slug = (normalized.split('/').pop() ?? normalized)
289
+ .toLowerCase()
290
+ .replace(/[^a-z0-9._-]+/g, '-')
291
+ .replace(/^-+|-+$/g, '')
292
+ .slice(0, 40)
293
+ .replace(/^-+|-+$/g, '');
294
+ if (slug === '' || slug === '.' || slug === '..') slug = 'workspace';
295
+ const hash = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 12);
296
+ return `wd_${slug}_${hash}`;
297
+ }
298
+
273
299
  // ---------------------------------------------------------------------------
274
300
  // Error classification
275
301
  // ---------------------------------------------------------------------------
@@ -292,13 +318,28 @@ function extractStderrError(stderrText) {
292
318
 
293
319
  /**
294
320
  * Classify a failed run into a user-facing message.
295
- * @param {{code: number|null, signal: string|null, stderrText: string, retryMessage: string}} info
321
+ * @param {{code: number|null, signal: string|null, stderrText: string, retryMessage: string, timedOutMs?: number}} info
322
+ * `timedOutMs` is set when the adapter's idle watchdog stopped the run after
323
+ * that long without any sign of progress.
296
324
  * @returns {{kind: string, userMessage: string}}
297
325
  */
298
326
  function classifyKimiError(info) {
299
- const { code, signal, stderrText, retryMessage } = info || {};
327
+ const { code, signal, stderrText, retryMessage, timedOutMs } = info || {};
300
328
  const detail = extractStderrError(stderrText) || redactSecrets(retryMessage || '');
301
329
 
330
+ // The watchdog's own stop wins: the signal it leaves behind (SIGINT on
331
+ // Windows) explains nothing, and stderr by then holds whatever tools printed.
332
+ if (timedOutMs) {
333
+ const minutes = Math.max(1, Math.round(timedOutMs / 60000));
334
+ return {
335
+ kind: 'timeout',
336
+ userMessage:
337
+ `Kimi showed no progress for about ${minutes} min and was stopped. ` +
338
+ 'Send the request again, or break a long task into smaller steps.' +
339
+ (detail ? `\n\nDetails: ${detail}` : ''),
340
+ };
341
+ }
342
+
302
343
  if (/auth_error|401|unauthorized|invalid.{0,20}(api.?key|token)|credential/i.test(detail)) {
303
344
  return {
304
345
  kind: 'auth',
@@ -351,6 +392,7 @@ module.exports = {
351
392
  toolPreview,
352
393
  buildKimiArgs,
353
394
  buildKimiEnv,
395
+ encodeKimiWorkDirKey,
354
396
  extractStderrError,
355
397
  classifyKimiError,
356
398
  DEFAULT_KIMI_MODEL,
@@ -47,6 +47,7 @@ const {
47
47
  redactSecrets,
48
48
  classifyKimiVersion,
49
49
  classifyKimiError,
50
+ encodeKimiWorkDirKey,
50
51
  } = require('./kimi-stream');
51
52
 
52
53
  const IS_WINDOWS = process.platform === 'win32';
@@ -54,10 +55,13 @@ const IS_WINDOWS = process.platform === 'win32';
54
55
  const DEFAULT_BASE_URL = 'https://api.moonshot.ai/v1';
55
56
  const DEFAULT_MODEL = 'kimi-k2.6';
56
57
 
57
- // Idle watchdog: if stdout is silent this long while a run is in flight we
58
- // nudge the user; after MAX consecutive silences we kill the (possibly hung)
59
- // process. Kimi's provider retry backoff can reach ~1 min between attempts,
60
- // so the kill threshold stays generous.
58
+ // Idle watchdog: if a run shows no sign of life this long we nudge the user;
59
+ // after MAX consecutive silences we kill the (possibly hung) process. Sign of
60
+ // life is any stdout or stderr, or a write to the run's session journal. The
61
+ // journal is the only trace a subagent leaves: print mode never forwards its
62
+ // events, so a main agent waiting on an `Agent` tool call is otherwise mute.
63
+ // Kimi's provider retry backoff can reach ~1 min between attempts, so the kill
64
+ // threshold stays generous.
61
65
  const WATCHDOG_INTERVAL_MS = 15_000;
62
66
  const WATCHDOG_NUDGE_AT = 2; // ~30s of silence → "still working"
63
67
  const WATCHDOG_MAX = 20; // ~5 min of silence → kill
@@ -67,6 +71,27 @@ const WATCHDOG_MAX = 20; // ~5 min of silence → kill
67
71
  const VERSION_CACHE_TTL_MS = 5 * 60 * 1000;
68
72
  const _kimiVersionCache = new Map(); // binPath -> { version, product, at }
69
73
 
74
+ /** Newest mtime (ms) of a directory and everything under it, `depth` levels deep. */
75
+ function newestMtimeMs(dir, depth) {
76
+ let newest = 0;
77
+ let entries;
78
+ try {
79
+ newest = fs.statSync(dir).mtimeMs;
80
+ entries = fs.readdirSync(dir, { withFileTypes: true });
81
+ } catch {
82
+ return newest;
83
+ }
84
+ for (const entry of entries) {
85
+ const p = path.join(dir, entry.name);
86
+ if (entry.isDirectory()) {
87
+ if (depth > 0) newest = Math.max(newest, newestMtimeMs(p, depth - 1));
88
+ } else {
89
+ try { newest = Math.max(newest, fs.statSync(p).mtimeMs); } catch {}
90
+ }
91
+ }
92
+ return newest;
93
+ }
94
+
70
95
  class KimiAdapter extends LlmDirectAdapter {
71
96
  constructor(opts) {
72
97
  super({
@@ -106,6 +131,10 @@ class KimiAdapter extends LlmDirectAdapter {
106
131
  this._channelProcesses = {}; // channel → in-flight child process
107
132
  this._stoppingChannels = new Set();
108
133
  this._loggedCliMode = false;
134
+ // Watchdog timings live on the instance so tests can run them in ms.
135
+ this._watchdogIntervalMs = WATCHDOG_INTERVAL_MS;
136
+ this._watchdogNudgeAt = WATCHDOG_NUDGE_AT;
137
+ this._watchdogMax = WATCHDOG_MAX;
109
138
  this._sessionsFile = path.join(
110
139
  os.homedir(), '.openagents', 'sessions',
111
140
  `${this.workspaceId}_${this.agentName}_kimi.json`,
@@ -577,8 +606,9 @@ class KimiAdapter extends LlmDirectAdapter {
577
606
  if (result.userStopped) return;
578
607
 
579
608
  // Stale-session handling: a resume that died/erred with nothing useful →
580
- // clear and retry fresh once.
581
- if (resumeId && !result.ok && !result.anyOutput && attempt === 0) {
609
+ // clear and retry fresh once. A watchdog stop is not a stale session, and
610
+ // retrying it would just repeat the long task.
611
+ if (resumeId && !result.ok && !result.anyOutput && !result.timedOutMs && attempt === 0) {
582
612
  this._log(`Resume of session ${resumeId} failed — clearing and retrying fresh`);
583
613
  this._clearSession(channel);
584
614
  continue;
@@ -598,6 +628,7 @@ class KimiAdapter extends LlmDirectAdapter {
598
628
  signal: result.exitSignal,
599
629
  stderrText: result.stderrText,
600
630
  retryMessage: result.retryMessage,
631
+ timedOutMs: result.timedOutMs,
601
632
  });
602
633
  try { await this.sendError(channel, userMessage); } catch {}
603
634
  } else {
@@ -611,16 +642,53 @@ class KimiAdapter extends LlmDirectAdapter {
611
642
  try { return fs.statSync(p).isDirectory(); } catch { return false; }
612
643
  }
613
644
 
645
+ /**
646
+ * Snapshot the session journals that exist before a run starts, so the
647
+ * watchdog can tell this run's writes from other sessions' in the same
648
+ * working directory. Kimi files them under
649
+ * <KIMI_CODE_HOME>/sessions/<workdir key>/<session id>/.
650
+ */
651
+ _watchKimiSessions(env, workingDir, args) {
652
+ const home = env.KIMI_CODE_HOME || path.join(os.homedir(), '.kimi-code');
653
+ const bucket = path.join(home, 'sessions', encodeKimiWorkDirKey(workingDir));
654
+ let known = [];
655
+ try { known = fs.readdirSync(bucket); } catch {}
656
+ const s = args.indexOf('-S');
657
+ return { bucket, known: new Set(known), resumeId: s >= 0 ? args[s + 1] : null };
658
+ }
659
+
660
+ /**
661
+ * Newest write (ms) inside this run's session journals: the resumed session,
662
+ * plus any session created since the run started. A second fresh run in the
663
+ * same directory can be counted too; that only ever keeps a run alive
664
+ * longer, never stops one early. 0 when there is nothing to look at.
665
+ */
666
+ _latestKimiSessionWrite(watch) {
667
+ let names;
668
+ try { names = fs.readdirSync(watch.bucket); } catch { return 0; }
669
+ let latest = 0;
670
+ for (const name of names) {
671
+ const resumed = !!watch.resumeId &&
672
+ (name === watch.resumeId || name === `session_${watch.resumeId}`);
673
+ if (watch.known.has(name) && !resumed) continue;
674
+ latest = Math.max(latest, newestMtimeMs(path.join(watch.bucket, name), 3));
675
+ }
676
+ return latest;
677
+ }
678
+
614
679
  /**
615
680
  * Spawn one `kimi -p … --output-format stream-json` run, stream-parse it,
616
681
  * and resolve a summary:
617
682
  * { ok, finalText, sessionId, anyOutput, userStopped,
618
- * exitCode, exitSignal, stderrText, retryMessage }
619
- * `ok` is exit code 0; `finalText` is the LAST assistant text of the turn.
683
+ * exitCode, exitSignal, stderrText, retryMessage, timedOutMs }
684
+ * `ok` is exit code 0; `finalText` is the LAST assistant text of the turn;
685
+ * `timedOutMs` is non-zero when the idle watchdog stopped the run.
620
686
  */
621
687
  _runKimi(channel, kimiBin, args, workingDir) {
622
688
  const { env: cleanEnv } = buildKimiEnv(this.agentEnv || process.env);
623
689
  const [cmd, ...spawnArgs] = this._spawnableCmd(kimiBin, args);
690
+ // Before the spawn, so the session this run creates shows up as new.
691
+ const sessionWatch = this._watchKimiSessions(cleanEnv, workingDir, args);
624
692
 
625
693
  this._log(`Spawning kimi in ${workingDir}: ${redactArgs([kimiBin, ...args]).join(' ')}`);
626
694
 
@@ -644,6 +712,7 @@ class KimiAdapter extends LlmDirectAdapter {
644
712
  exitSignal: null,
645
713
  stderrText: '',
646
714
  retryMessage: '',
715
+ timedOutMs: 0,
647
716
  };
648
717
 
649
718
  return new Promise((resolve) => {
@@ -651,6 +720,8 @@ class KimiAdapter extends LlmDirectAdapter {
651
720
  let watchdogTimer = null;
652
721
  let fallbackTimer = null;
653
722
  let lastDataMs = Date.now();
723
+ let seenSessionWriteMs = lastDataMs;
724
+ let loggedSessionAlive = false;
654
725
  let silences = 0;
655
726
  let queue = Promise.resolve();
656
727
 
@@ -713,8 +784,11 @@ class KimiAdapter extends LlmDirectAdapter {
713
784
  });
714
785
 
715
786
  // stderr: `error: ...` lines carry the fatal cause; collected for
716
- // classification, never surfaced verbatim as an assistant reply.
787
+ // classification, never surfaced verbatim as an assistant reply. Tool
788
+ // progress also lands here in print mode, so it counts as life.
717
789
  proc.stderr.on('data', (chunk) => {
790
+ lastDataMs = Date.now();
791
+ silences = 0;
718
792
  state.stderrText += chunk.toString('utf-8');
719
793
  if (state.stderrText.length > 64 * 1024) {
720
794
  state.stderrText = state.stderrText.slice(-32 * 1024);
@@ -722,21 +796,47 @@ class KimiAdapter extends LlmDirectAdapter {
722
796
  });
723
797
 
724
798
  // Idle watchdog
799
+ const intervalMs = this._watchdogIntervalMs;
725
800
  watchdogTimer = setInterval(async () => {
726
801
  if (settled) return;
727
802
  const elapsed = Date.now() - lastDataMs;
728
- if (elapsed < WATCHDOG_INTERVAL_MS) { silences = 0; return; }
803
+ if (elapsed < intervalMs) { silences = 0; return; }
804
+ // Both pipes are quiet. A subagent still working only shows up in the
805
+ // session journal, so a fresh write there resets the count too.
806
+ const wrote = this._latestKimiSessionWrite(sessionWatch);
807
+ if (wrote > seenSessionWriteMs) {
808
+ seenSessionWriteMs = wrote;
809
+ lastDataMs = Date.now();
810
+ silences = 0;
811
+ if (!loggedSessionAlive) {
812
+ loggedSessionAlive = true;
813
+ this._log(`Watchdog: kimi quiet on ${channel} but its session is still being written — keeping it alive`);
814
+ }
815
+ return;
816
+ }
729
817
  silences++;
730
- lastDataMs = Date.now();
731
- if (silences === WATCHDOG_NUDGE_AT) {
818
+ // Deliberately NOT touching lastDataMs here: it marks the last real
819
+ // sign of life, and the data handlers above already reset `silences`
820
+ // themselves. Refreshing it on a silent tick parked the next tick's
821
+ // `elapsed` right on the interval boundary, where an early-firing
822
+ // timer (routine on Windows) read as "data just arrived" and reset
823
+ // the count — so a genuinely hung run was never stopped.
824
+ if (silences === this._watchdogNudgeAt) {
732
825
  try { await this.sendStatus(channel, 'Still working...'); } catch {}
733
826
  }
734
- if (silences >= WATCHDOG_MAX) {
735
- this._log(`Watchdog: kimi silent ${silences * 15}s on ${channel} — killing`);
736
- if (!state.stderrText) state.stderrText = 'error: Kimi became unresponsive and was stopped.';
827
+ if (silences >= this._watchdogMax) {
828
+ // Stop ticking before the (awaited) kill: _stopProcess can take
829
+ // seconds to escalate, and a tick landing inside that wait would
830
+ // re-log, re-kill and keep inflating timedOutMs past the budget.
831
+ clearInterval(watchdogTimer);
832
+ const silentMs = silences * intervalMs;
833
+ this._log(`Watchdog: kimi silent ${Math.round(silentMs / 1000)}s on ${channel} — killing`);
834
+ // Its own field, not stderr: tool noise already there used to leave
835
+ // the user with nothing but the signal name.
836
+ state.timedOutMs = silentMs;
737
837
  await this._stopProcess(proc);
738
838
  }
739
- }, WATCHDOG_INTERVAL_MS);
839
+ }, intervalMs);
740
840
 
741
841
  // Finalize only once BOTH the process has exited AND stdout has ended,
742
842
  // so the final buffered lines are never dropped (same race as Cline on