@openagents-org/agent-launcher 0.2.183 → 0.2.185

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;
@@ -176,29 +176,9 @@ class CodeBuddyAdapter extends BaseAdapter {
176
176
  }
177
177
 
178
178
  // ------------------------------------------------------------------
179
- // Control actions (stop / restart)
179
+ // Shutdown
180
180
  // ------------------------------------------------------------------
181
181
 
182
- async _onControlAction(action, payload) {
183
- if (action === 'stop') {
184
- const channel = (payload && payload.channel) || null;
185
- if (channel) {
186
- const proc = this._channelProcesses[channel];
187
- if (proc) {
188
- this._stoppingChannels.add(channel);
189
- await this._stopProcess(proc);
190
- delete this._channelProcesses[channel];
191
- delete this._channelQueues[channel];
192
- try { await this.sendResponse(channel, 'Execution stopped.'); } catch {}
193
- }
194
- return;
195
- }
196
- await this._stopAllProcesses();
197
- return;
198
- }
199
- return super._onControlAction(action, payload);
200
- }
201
-
202
182
  stop() {
203
183
  super.stop();
204
184
  void this._stopAllProcesses('Agent stopped.');
@@ -591,6 +571,8 @@ class CodeBuddyAdapter extends BaseAdapter {
591
571
  * @returns {Promise<object>} { code, signal, result, sessionId, anyOutput, lastAssistantText, userStopped, stderr }
592
572
  */
593
573
  _runCodeBuddy(channel, bin, args, workingDir, prompt) {
574
+ // Stopped while this turn was being prepared: start nothing (no tokens).
575
+ if (this._stoppedBeforeStart(channel)) return Promise.resolve({ userStopped: true });
594
576
  return new Promise((resolve) => {
595
577
  const [cmd, ...spawnArgs] = this._spawnableCmd(bin, args);
596
578
  this._log(`Spawning: ${path.basename(cmd)} ${redactArgs(spawnArgs).join(' ')} (cwd=${workingDir})`);
@@ -247,19 +247,6 @@ class CodexAdapter extends BaseAdapter {
247
247
  } catch {}
248
248
  }
249
249
 
250
- async _onControlAction(action, payload) {
251
- if (action === 'stop') {
252
- for (const [channel, proc] of Object.entries(this._channelProcesses)) {
253
- await this._stopProcess(proc);
254
- delete this._channelProcesses[channel];
255
- try { await this.sendStatus(channel, 'Execution stopped by user'); } catch {}
256
- }
257
- return;
258
- }
259
- // Shared actions (status, routines, skill.install, skill.uninstall).
260
- await super._onControlAction(action, payload);
261
- }
262
-
263
250
  // ------------------------------------------------------------------
264
251
  // Message handler
265
252
  // ------------------------------------------------------------------
@@ -325,6 +312,7 @@ class CodexAdapter extends BaseAdapter {
325
312
 
326
313
  try {
327
314
  const result = await this._spawnCodex(cmd, env, msgChannel, fullPrompt);
315
+ if (result.stopped) return;
328
316
 
329
317
  if (result.responseText) {
330
318
  await this.sendResponse(msgChannel, result.responseText);
@@ -351,6 +339,8 @@ class CodexAdapter extends BaseAdapter {
351
339
  }
352
340
 
353
341
  async _spawnCodex(cmd, env, msgChannel, prompt) {
342
+ // Stopped while this turn was being prepared: start nothing (no tokens).
343
+ if (this._stoppedBeforeStart(msgChannel)) return { stopped: true, responseText: '', exitCode: null };
354
344
  return new Promise((resolve, reject) => {
355
345
  const proc = spawn(cmd[0], cmd.slice(1), {
356
346
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -175,29 +175,9 @@ class CommandCodeAdapter extends BaseAdapter {
175
175
  }
176
176
 
177
177
  // ------------------------------------------------------------------
178
- // Control actions (stop / restart)
178
+ // Shutdown
179
179
  // ------------------------------------------------------------------
180
180
 
181
- async _onControlAction(action, payload) {
182
- if (action === 'stop') {
183
- const channel = (payload && payload.channel) || null;
184
- if (channel) {
185
- const proc = this._channelProcesses[channel];
186
- if (proc) {
187
- this._stoppingChannels.add(channel);
188
- await this._stopProcess(proc);
189
- delete this._channelProcesses[channel];
190
- delete this._channelQueues[channel];
191
- try { await this.sendResponse(channel, 'Execution stopped.'); } catch {}
192
- }
193
- return;
194
- }
195
- await this._stopAllProcesses();
196
- return;
197
- }
198
- return super._onControlAction(action, payload);
199
- }
200
-
201
181
  stop() {
202
182
  super.stop();
203
183
  void this._stopAllProcesses('Agent stopped.');
@@ -545,6 +525,8 @@ class CommandCodeAdapter extends BaseAdapter {
545
525
  * @returns {Promise<object>} { code, signal, result, anyOutput, userStopped, stderrSessionId }
546
526
  */
547
527
  _runCommandCode(channel, bin, args, workingDir, prompt) {
528
+ // Stopped while this turn was being prepared: start nothing (no tokens).
529
+ if (this._stoppedBeforeStart(channel)) return Promise.resolve({ userStopped: true });
548
530
  return new Promise((resolve) => {
549
531
  const [cmd, ...spawnArgs] = this._spawnableCmd(bin, args);
550
532
  this._log(`Spawning: ${path.basename(cmd)} ${redactArgs(spawnArgs).join(' ')} (cwd=${workingDir})`);
@@ -336,32 +336,6 @@ class CopilotAdapter extends BaseAdapter {
336
336
  });
337
337
  }
338
338
 
339
- // ------------------------------------------------------------------
340
- // Control actions (stop / interrupt)
341
- // ------------------------------------------------------------------
342
-
343
- async _onControlAction(action, payload) {
344
- if (action === 'stop') {
345
- const channel = (payload && typeof payload === 'object') ? payload.channel : null;
346
- if (channel && this._channelProcesses[channel]) {
347
- this._stoppingChannels.add(channel);
348
- await this._stopProcess(this._channelProcesses[channel]);
349
- delete this._channelProcesses[channel];
350
- delete this._channelQueues[channel];
351
- try { await this.sendStatus(channel, 'Execution stopped by user'); } catch {}
352
- } else {
353
- for (const [ch, proc] of Object.entries(this._channelProcesses)) {
354
- this._stoppingChannels.add(ch);
355
- await this._stopProcess(proc);
356
- delete this._channelProcesses[ch];
357
- try { await this.sendStatus(ch, 'Execution stopped by user'); } catch {}
358
- }
359
- }
360
- return;
361
- }
362
- await super._onControlAction(action, payload);
363
- }
364
-
365
339
  /**
366
340
  * Daemon shutdown: tear down any in-flight Copilot subprocess so the thread
367
341
  * doesn't get stuck showing "running".
@@ -544,6 +518,8 @@ class CopilotAdapter extends BaseAdapter {
544
518
  * `errorMessage` / `staleSession` / `timedOut` so the caller can react.
545
519
  */
546
520
  _runTurn(channel, args) {
521
+ // Stopped while this turn was being prepared: start nothing (no tokens).
522
+ if (this._stoppedBeforeStart(channel)) return Promise.resolve({ userStopped: true });
547
523
  const [file, ...prefix] = this._resolveExec(this._copilotBin);
548
524
  const env = getEnhancedEnv({ ...(this.agentEnv || process.env) });
549
525
  const cwd = this.workingDir || defaultAgentWorkdir(this.agentName);
@@ -62,18 +62,7 @@ class CursorAdapter extends BaseAdapter {
62
62
  }
63
63
 
64
64
  async _onControlAction(action, payload) {
65
- if (action === 'stop') {
66
- const channel = (payload && typeof payload === 'object') ? payload.channel : null;
67
- if (channel && this._channelProcesses[channel]) {
68
- this._stoppingChannels.add(channel);
69
- await this._stopProcess(this._channelProcesses[channel]);
70
- delete this._channelProcesses[channel];
71
- delete this._channelQueues[channel];
72
- try { await this.sendResponse(channel, 'Execution stopped.'); } catch {}
73
- } else {
74
- await this._stopAllProcesses('Execution stopped.');
75
- }
76
- } else if (action === 'restart') {
65
+ if (action === 'restart') {
77
66
  const channel = (payload && typeof payload === 'object') ? payload.channel : null;
78
67
  if (channel) {
79
68
  if (this._channelProcesses[channel]) {
@@ -86,7 +75,10 @@ class CursorAdapter extends BaseAdapter {
86
75
  this._saveSessions();
87
76
  try { await this.sendResponse(channel, 'Session cleared. Send a new message to start fresh.'); } catch {}
88
77
  }
78
+ return;
89
79
  }
80
+ // Shared actions (status, routines, skill.install, model.set).
81
+ await super._onControlAction(action, payload);
90
82
  }
91
83
 
92
84
  stop() {
@@ -444,6 +436,9 @@ class CursorAdapter extends BaseAdapter {
444
436
  return;
445
437
  }
446
438
 
439
+ // Stopped while this turn was being prepared: start nothing (no tokens).
440
+ if (this._stoppedBeforeStart(msgChannel)) return;
441
+
447
442
  try {
448
443
  const resolved = this._resolveToNodeCmd(cmd[0]);
449
444
  if (resolved) {
@@ -573,7 +568,7 @@ class CursorAdapter extends BaseAdapter {
573
568
  const stoppedByUser = this._stoppingChannels.has(msgChannel);
574
569
 
575
570
  if (stoppedByUser) {
576
- try { await this.cleanupTodos(msgChannel); } catch {}
571
+ // BaseAdapter._handleUserStop cancels the plan before announcing.
577
572
  } else if (!msg._todoNudge) {
578
573
  try {
579
574
  const remaining = await this.getRemainingTodos(msgChannel);
@@ -123,18 +123,8 @@ const RECAP_TAIL = 60;
123
123
  const TOKEN_ENV = 'OPENAGENTS_WORKSPACE_TOKEN';
124
124
 
125
125
  /**
126
- * The status that ends a run in the workspace UI.
127
- *
128
- * The client clears its "stopping" state only on a status matching
129
- * /stopped|stopping failed/i (chat-view.tsx). Any other final status leaves the
130
- * session marked as working forever — and a cancelled run produces nothing
131
- * later that could correct it. The wording is therefore load-bearing, and the
132
- * tests assert it against the client's own regex.
133
- */
134
- const STOPPED_STATUS = 'Execution stopped by user';
135
-
136
- /**
137
- * The status for a stop that could NOT be confirmed.
126
+ * The notice for a stop that could NOT be confirmed (BaseAdapter posts it when
127
+ * `_stopChannelWork` reports 'failed').
138
128
  *
139
129
  * Contains "stopping failed" because that is the other half of the client's
140
130
  * terminal-status regex. Reporting a clean stop when the child may still be
@@ -169,6 +159,7 @@ class DeepSeekAdapter extends BaseAdapter {
169
159
  // arriving in that window has no process to kill. Without this set the stop
170
160
  // would be silently dropped and the task would start anyway.
171
161
  this._busyChannels = new Set();
162
+ this._stopFailedMessage = STOP_FAILED_STATUS;
172
163
  // Channels cancelled while busy. Checked at every await boundary and once
173
164
  // more immediately before spawning.
174
165
  this._cancelledChannels = new Set();
@@ -590,78 +581,52 @@ class DeepSeekAdapter extends BaseAdapter {
590
581
  }
591
582
 
592
583
  // ------------------------------------------------------------------
593
- // Control actions (stop)
584
+ // Stop (BaseAdapter._handleUserStop drives these)
594
585
  // ------------------------------------------------------------------
595
586
 
596
- async _onControlAction(action, payload) {
597
- if (action === 'stop') {
598
- // The workspace sends the conversation it wants stopped. Honouring it is
599
- // what keeps one user's stop from tearing down every other concurrent
600
- // run — claude.js already scopes its stop this way; mini/amp do not, and
601
- // following them here was the mistake. A stop with no channel still
602
- // means "everything".
603
- const target = (payload && typeof payload === 'object' && payload.channel)
604
- ? payload.channel
605
- : null;
606
- await this._stopChannels(target);
607
- return;
608
- }
609
- await super._onControlAction(action, payload);
610
- }
611
-
612
587
  /**
613
- * Stop one channel, or every channel when `target` is null.
614
- *
615
- * Each affected channel is told exactly once what happened — and told the
616
- * TRUTH: a kill that could not be confirmed reports a failed stop rather than
617
- * a clean one, because the child may still be writing to the workspace.
588
+ * Stop ONE conversation. A run that has not spawned yet is cancelled by
589
+ * flagging it — nothing is running, so that is complete by definition.
590
+ * A child whose kill could not be confirmed reports 'failed', and the user
591
+ * is told the truth: it may still be writing to the workspace.
618
592
  */
619
- async _stopChannels(target) {
620
- // channel -> was termination confirmed
621
- const outcome = new Map();
622
-
623
- const busy = target
624
- ? (this._busyChannels.has(target) ? [target] : [])
625
- : [...this._busyChannels];
626
- for (const channel of busy) {
593
+ async _stopChannelWork(channel) {
594
+ let outcome = 'idle';
595
+ if (this._busyChannels.has(channel)) {
627
596
  this._cancelledChannels.add(channel);
628
- this._stoppingChannels.add(channel);
629
597
  // Release a waiter parked on the shared bootstrap. Marking alone is not
630
598
  // enough: the flag is only read between awaits, and this one can last
631
599
  // the whole bootstrap timeout.
632
600
  const release = this._cancelWaiters.get(channel);
633
601
  if (release) release();
634
- // Nothing has spawned yet, so cancellation is complete by definition.
635
- outcome.set(channel, true);
602
+ outcome = 'stopped';
636
603
  }
637
-
638
- for (const [channel, proc] of Object.entries(this._channelProcesses)) {
639
- if (channel === BOOTSTRAP_KEY) {
640
- // Bootstrap is SHARED: every channel waits on the same profile
641
- // compose. Tearing it down because one conversation was stopped would
642
- // fail the others too, so a scoped stop leaves it alone.
643
- if (target) continue;
644
- } else if (target && channel !== target) {
645
- continue;
646
- }
647
-
604
+ const proc = this._channelProcesses[channel];
605
+ if (proc) {
648
606
  // Untrack only once the kill has been attempted, so the child is never
649
607
  // missing from the registry while it may still be running.
650
608
  const ended = await this._stopProcess(proc);
651
609
  delete this._channelProcesses[channel];
652
-
653
- if (channel === BOOTSTRAP_KEY) continue;
654
- this._stoppingChannels.add(channel);
655
- outcome.set(channel, ended);
610
+ outcome = ended ? 'stopped' : 'failed';
656
611
  }
612
+ return outcome;
613
+ }
657
614
 
658
- // The client clears its stopping state only on a terminal status, and a
659
- // cancelled run produces nothing later that could supply one.
660
- for (const [channel, ended] of outcome) {
661
- try {
662
- await this.sendStatus(channel, ended ? STOPPED_STATUS : STOP_FAILED_STATUS);
663
- } catch { /* the run is already over; a failed post must not throw here */ }
664
- }
615
+ _channelsWithWork() {
616
+ return [...new Set([...super._channelsWithWork(), ...this._busyChannels])]
617
+ .filter((channel) => channel !== BOOTSTRAP_KEY);
618
+ }
619
+
620
+ /**
621
+ * Bootstrap is SHARED: every conversation waits on the same profile
622
+ * compose. A scoped stop leaves it alone, or it would fail the others too;
623
+ * only a workspace-wide stop tears it down.
624
+ */
625
+ async _stopSharedWork() {
626
+ const proc = this._channelProcesses[BOOTSTRAP_KEY];
627
+ if (!proc) return;
628
+ await this._stopProcess(proc);
629
+ delete this._channelProcesses[BOOTSTRAP_KEY];
665
630
  }
666
631
 
667
632
  /** True when a child has ended, by exit code OR by signal. */
@@ -987,7 +952,7 @@ class DeepSeekAdapter extends BaseAdapter {
987
952
  try {
988
953
  // Last gate before anything is started. Everything above this line is
989
954
  // reversible; a process is not.
990
- if (this._cancelled(msgChannel)) return { text: '', error: null };
955
+ if (this._cancelled(msgChannel) || this._stoppedBeforeStart(msgChannel)) return { text: '', error: null };
991
956
  return await this._spawnDsh({ taskFile, msgChannel, permissionMode });
992
957
  } finally {
993
958
  try { fs.rmSync(taskFile, { force: true }); } catch { /* best effort */ }