@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.
- package/icons/codearts.svg +9 -0
- package/package.json +1 -1
- package/registry.json +68 -0
- package/src/adapters/codearts.js +316 -0
- package/src/adapters/index.js +4 -1
- package/src/adapters/kimi-stream.js +44 -2
- package/src/adapters/kimi.js +116 -16
- package/src/adapters/opencode.js +69 -22
- package/src/daemon.js +22 -1
- package/src/installer.js +48 -16
- package/src/paths.js +187 -92
- package/src/probe-mode.js +36 -0
- package/src/wsl.js +217 -45
|
@@ -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;
|
package/src/adapters/index.js
CHANGED
|
@@ -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,
|
package/src/adapters/kimi.js
CHANGED
|
@@ -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
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
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
|
-
|
|
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 <
|
|
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
|
|
731
|
-
|
|
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 >=
|
|
735
|
-
|
|
736
|
-
|
|
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
|
-
},
|
|
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
|