@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.
@@ -68,12 +68,6 @@ class GeminiAdapter extends BaseAdapter {
68
68
  } catch {}
69
69
  }
70
70
 
71
- async _onControlAction(action, _payload) {
72
- if (action === 'stop') {
73
- await this._stopAllProcesses();
74
- }
75
- }
76
-
77
71
  async _stopProcess(proc) {
78
72
  if (!proc || proc.exitCode !== null) return;
79
73
  try {
@@ -96,20 +90,6 @@ class GeminiAdapter extends BaseAdapter {
96
90
  } catch {}
97
91
  }
98
92
 
99
- async _stopAllProcesses() {
100
- const entries = Object.entries(this._channelProcesses);
101
- if (!entries.length) return;
102
- this._log(`Stopping ${entries.length} running process(es)...`);
103
- for (const [channel, proc] of entries) {
104
- await this._stopProcess(proc);
105
- delete this._channelProcesses[channel];
106
- delete this._channelQueues[channel];
107
- try {
108
- await this.sendStatus(channel, 'Execution stopped by user');
109
- } catch {}
110
- }
111
- }
112
-
113
93
  _findNodeBin() {
114
94
  const home = os.homedir();
115
95
  const candidates = IS_WINDOWS
@@ -316,6 +296,9 @@ class GeminiAdapter extends BaseAdapter {
316
296
  return;
317
297
  }
318
298
 
299
+ // Stopped while this turn was being prepared: start nothing (no tokens).
300
+ if (this._stoppedBeforeStart(msgChannel)) return;
301
+
319
302
  try {
320
303
  const resolved = this._resolveToNodeCmd(cmd[0]);
321
304
  if (resolved) {
@@ -287,20 +287,7 @@ class GooseAdapter extends BaseAdapter {
287
287
  return redactSecrets(text, this._secrets);
288
288
  }
289
289
 
290
- // -- control actions (stop) ---------------------------------------------
291
-
292
- async _onControlAction(action, payload) {
293
- if (action === 'stop') {
294
- const channel = (payload && typeof payload === 'object') ? payload.channel : null;
295
- if (channel) {
296
- await this._stopChannel(channel, 'Execution stopped by user.');
297
- } else {
298
- await this._stopAll('Execution stopped by user.');
299
- }
300
- return;
301
- }
302
- await super._onControlAction(action, payload);
303
- }
290
+ // -- teardown -----------------------------------------------------------
304
291
 
305
292
  async _stopChannel(channel, message) {
306
293
  const proc = this._channelProcesses[channel];
@@ -490,6 +477,9 @@ class GooseAdapter extends BaseAdapter {
490
477
  this._log(`CLI: goose run --output-format stream-json --name ${sessionName}`
491
478
  + `${resume ? ' --resume' : ''} --no-profile --with-builtin developer`);
492
479
 
480
+ // Stopped while this turn was being prepared: start nothing (no tokens).
481
+ if (this._stoppedBeforeStart(channel)) return Promise.resolve(null);
482
+
493
483
  return new Promise((resolve) => {
494
484
  let proc;
495
485
  try {
@@ -283,6 +283,8 @@ class HermesAdapter extends BaseAdapter {
283
283
  }
284
284
 
285
285
  async _runHermes(prompt, channelName) {
286
+ // Stopped while this turn was being prepared: start nothing (no tokens).
287
+ if (this._stoppedBeforeStart(channelName)) return '';
286
288
  const resumeId = this._channelSessions[channelName];
287
289
  const args = this._buildHermesCmd(prompt, resumeId);
288
290
  this._log(`Running hermes (profile=${this.hermesProfile}, channel=${channelName}, resume=${!!resumeId})`);
@@ -357,16 +359,6 @@ class HermesAdapter extends BaseAdapter {
357
359
  } catch {}
358
360
  }
359
361
 
360
- async _onControlAction(action, _payload) {
361
- if (action === 'stop') {
362
- for (const [channel, proc] of Object.entries(this._channelProcesses)) {
363
- await this._stopProcess(proc);
364
- delete this._channelProcesses[channel];
365
- try { await this.sendStatus(channel, 'Execution stopped by user'); } catch {}
366
- }
367
- }
368
- }
369
-
370
362
  // ------------------------------------------------------------------
371
363
  // Message handler
372
364
  // ------------------------------------------------------------------
@@ -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`,
@@ -170,24 +199,6 @@ class KimiAdapter extends LlmDirectAdapter {
170
199
  // ------------------------------------------------------------------
171
200
 
172
201
  async _onControlAction(action, payload) {
173
- if (action === 'stop') {
174
- const channel = (payload && typeof payload === 'object') ? payload.channel : null;
175
- if (channel && this._channelProcesses[channel]) {
176
- this._stoppingChannels.add(channel);
177
- await this._stopProcess(this._channelProcesses[channel]);
178
- delete this._channelProcesses[channel];
179
- delete this._channelQueues[channel];
180
- try { await this.sendResponse(channel, 'Execution stopped by user.'); } catch {}
181
- return;
182
- }
183
- if (Object.keys(this._channelProcesses).length) {
184
- await this._stopAllProcesses('Execution stopped by user.');
185
- return;
186
- }
187
- // No CLI runs in flight — fall through to the direct-API stop.
188
- await super._onControlAction(action, payload);
189
- return;
190
- }
191
202
  if (action === 'restart') {
192
203
  const channel = (payload && typeof payload === 'object') ? payload.channel : null;
193
204
  if (channel) {
@@ -577,8 +588,9 @@ class KimiAdapter extends LlmDirectAdapter {
577
588
  if (result.userStopped) return;
578
589
 
579
590
  // 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) {
591
+ // clear and retry fresh once. A watchdog stop is not a stale session, and
592
+ // retrying it would just repeat the long task.
593
+ if (resumeId && !result.ok && !result.anyOutput && !result.timedOutMs && attempt === 0) {
582
594
  this._log(`Resume of session ${resumeId} failed — clearing and retrying fresh`);
583
595
  this._clearSession(channel);
584
596
  continue;
@@ -598,6 +610,7 @@ class KimiAdapter extends LlmDirectAdapter {
598
610
  signal: result.exitSignal,
599
611
  stderrText: result.stderrText,
600
612
  retryMessage: result.retryMessage,
613
+ timedOutMs: result.timedOutMs,
601
614
  });
602
615
  try { await this.sendError(channel, userMessage); } catch {}
603
616
  } else {
@@ -611,16 +624,55 @@ class KimiAdapter extends LlmDirectAdapter {
611
624
  try { return fs.statSync(p).isDirectory(); } catch { return false; }
612
625
  }
613
626
 
627
+ /**
628
+ * Snapshot the session journals that exist before a run starts, so the
629
+ * watchdog can tell this run's writes from other sessions' in the same
630
+ * working directory. Kimi files them under
631
+ * <KIMI_CODE_HOME>/sessions/<workdir key>/<session id>/.
632
+ */
633
+ _watchKimiSessions(env, workingDir, args) {
634
+ const home = env.KIMI_CODE_HOME || path.join(os.homedir(), '.kimi-code');
635
+ const bucket = path.join(home, 'sessions', encodeKimiWorkDirKey(workingDir));
636
+ let known = [];
637
+ try { known = fs.readdirSync(bucket); } catch {}
638
+ const s = args.indexOf('-S');
639
+ return { bucket, known: new Set(known), resumeId: s >= 0 ? args[s + 1] : null };
640
+ }
641
+
642
+ /**
643
+ * Newest write (ms) inside this run's session journals: the resumed session,
644
+ * plus any session created since the run started. A second fresh run in the
645
+ * same directory can be counted too; that only ever keeps a run alive
646
+ * longer, never stops one early. 0 when there is nothing to look at.
647
+ */
648
+ _latestKimiSessionWrite(watch) {
649
+ let names;
650
+ try { names = fs.readdirSync(watch.bucket); } catch { return 0; }
651
+ let latest = 0;
652
+ for (const name of names) {
653
+ const resumed = !!watch.resumeId &&
654
+ (name === watch.resumeId || name === `session_${watch.resumeId}`);
655
+ if (watch.known.has(name) && !resumed) continue;
656
+ latest = Math.max(latest, newestMtimeMs(path.join(watch.bucket, name), 3));
657
+ }
658
+ return latest;
659
+ }
660
+
614
661
  /**
615
662
  * Spawn one `kimi -p … --output-format stream-json` run, stream-parse it,
616
663
  * and resolve a summary:
617
664
  * { 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.
665
+ * exitCode, exitSignal, stderrText, retryMessage, timedOutMs }
666
+ * `ok` is exit code 0; `finalText` is the LAST assistant text of the turn;
667
+ * `timedOutMs` is non-zero when the idle watchdog stopped the run.
620
668
  */
621
669
  _runKimi(channel, kimiBin, args, workingDir) {
670
+ // Stopped while this turn was being prepared: start nothing (no tokens).
671
+ if (this._stoppedBeforeStart(channel)) return Promise.resolve({ userStopped: true });
622
672
  const { env: cleanEnv } = buildKimiEnv(this.agentEnv || process.env);
623
673
  const [cmd, ...spawnArgs] = this._spawnableCmd(kimiBin, args);
674
+ // Before the spawn, so the session this run creates shows up as new.
675
+ const sessionWatch = this._watchKimiSessions(cleanEnv, workingDir, args);
624
676
 
625
677
  this._log(`Spawning kimi in ${workingDir}: ${redactArgs([kimiBin, ...args]).join(' ')}`);
626
678
 
@@ -644,6 +696,7 @@ class KimiAdapter extends LlmDirectAdapter {
644
696
  exitSignal: null,
645
697
  stderrText: '',
646
698
  retryMessage: '',
699
+ timedOutMs: 0,
647
700
  };
648
701
 
649
702
  return new Promise((resolve) => {
@@ -651,6 +704,8 @@ class KimiAdapter extends LlmDirectAdapter {
651
704
  let watchdogTimer = null;
652
705
  let fallbackTimer = null;
653
706
  let lastDataMs = Date.now();
707
+ let seenSessionWriteMs = lastDataMs;
708
+ let loggedSessionAlive = false;
654
709
  let silences = 0;
655
710
  let queue = Promise.resolve();
656
711
 
@@ -713,8 +768,11 @@ class KimiAdapter extends LlmDirectAdapter {
713
768
  });
714
769
 
715
770
  // stderr: `error: ...` lines carry the fatal cause; collected for
716
- // classification, never surfaced verbatim as an assistant reply.
771
+ // classification, never surfaced verbatim as an assistant reply. Tool
772
+ // progress also lands here in print mode, so it counts as life.
717
773
  proc.stderr.on('data', (chunk) => {
774
+ lastDataMs = Date.now();
775
+ silences = 0;
718
776
  state.stderrText += chunk.toString('utf-8');
719
777
  if (state.stderrText.length > 64 * 1024) {
720
778
  state.stderrText = state.stderrText.slice(-32 * 1024);
@@ -722,21 +780,47 @@ class KimiAdapter extends LlmDirectAdapter {
722
780
  });
723
781
 
724
782
  // Idle watchdog
783
+ const intervalMs = this._watchdogIntervalMs;
725
784
  watchdogTimer = setInterval(async () => {
726
785
  if (settled) return;
727
786
  const elapsed = Date.now() - lastDataMs;
728
- if (elapsed < WATCHDOG_INTERVAL_MS) { silences = 0; return; }
787
+ if (elapsed < intervalMs) { silences = 0; return; }
788
+ // Both pipes are quiet. A subagent still working only shows up in the
789
+ // session journal, so a fresh write there resets the count too.
790
+ const wrote = this._latestKimiSessionWrite(sessionWatch);
791
+ if (wrote > seenSessionWriteMs) {
792
+ seenSessionWriteMs = wrote;
793
+ lastDataMs = Date.now();
794
+ silences = 0;
795
+ if (!loggedSessionAlive) {
796
+ loggedSessionAlive = true;
797
+ this._log(`Watchdog: kimi quiet on ${channel} but its session is still being written — keeping it alive`);
798
+ }
799
+ return;
800
+ }
729
801
  silences++;
730
- lastDataMs = Date.now();
731
- if (silences === WATCHDOG_NUDGE_AT) {
802
+ // Deliberately NOT touching lastDataMs here: it marks the last real
803
+ // sign of life, and the data handlers above already reset `silences`
804
+ // themselves. Refreshing it on a silent tick parked the next tick's
805
+ // `elapsed` right on the interval boundary, where an early-firing
806
+ // timer (routine on Windows) read as "data just arrived" and reset
807
+ // the count — so a genuinely hung run was never stopped.
808
+ if (silences === this._watchdogNudgeAt) {
732
809
  try { await this.sendStatus(channel, 'Still working...'); } catch {}
733
810
  }
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.';
811
+ if (silences >= this._watchdogMax) {
812
+ // Stop ticking before the (awaited) kill: _stopProcess can take
813
+ // seconds to escalate, and a tick landing inside that wait would
814
+ // re-log, re-kill and keep inflating timedOutMs past the budget.
815
+ clearInterval(watchdogTimer);
816
+ const silentMs = silences * intervalMs;
817
+ this._log(`Watchdog: kimi silent ${Math.round(silentMs / 1000)}s on ${channel} — killing`);
818
+ // Its own field, not stderr: tool noise already there used to leave
819
+ // the user with nothing but the signal name.
820
+ state.timedOutMs = silentMs;
737
821
  await this._stopProcess(proc);
738
822
  }
739
- }, WATCHDOG_INTERVAL_MS);
823
+ }, intervalMs);
740
824
 
741
825
  // Finalize only once BOTH the process has exited AND stdout has ended,
742
826
  // so the final buffered lines are never dropped (same race as Cline on
@@ -62,13 +62,21 @@ class LlmDirectAdapter extends BaseAdapter {
62
62
  this._activeRequests.clear();
63
63
  }
64
64
 
65
- async _onControlAction(action, _payload) {
66
- if (action === 'stop') {
67
- for (const req of this._activeRequests) {
68
- try { req.destroy(new Error('LLM API request stopped')); } catch {}
69
- }
70
- this._activeRequests.clear();
65
+ /**
66
+ * A stop ends this channel's open completion requests, and only those —
67
+ * the same agent may be answering in another thread. Subclasses that also
68
+ * run child processes (kimi) get those stopped by super.
69
+ */
70
+ async _stopChannelWork(channel) {
71
+ let aborted = 0;
72
+ for (const req of this._activeRequests) {
73
+ if (req._oaChannel !== channel) continue;
74
+ try { req.destroy(new Error('LLM API request stopped')); } catch {}
75
+ this._activeRequests.delete(req);
76
+ aborted++;
71
77
  }
78
+ const processes = await super._stopChannelWork(channel);
79
+ return aborted && processes === 'idle' ? 'stopped' : processes;
72
80
  }
73
81
 
74
82
  /** The model this runner actually calls with — see `_model` above. */
@@ -114,6 +122,8 @@ class LlmDirectAdapter extends BaseAdapter {
114
122
  return;
115
123
  }
116
124
 
125
+ // Stopped while this turn was being prepared: start nothing (no tokens).
126
+ if (this._stoppedBeforeStart(msgChannel)) return;
117
127
  const responseText = await this._callCompletionApi(content, msgChannel);
118
128
 
119
129
  if (responseText) {
@@ -205,6 +215,7 @@ class LlmDirectAdapter extends BaseAdapter {
205
215
  });
206
216
  });
207
217
 
218
+ req._oaChannel = channel;
208
219
  this._activeRequests.add(req);
209
220
  req.on('error', (err) => {
210
221
  this._activeRequests.delete(req);
@@ -297,22 +297,9 @@ class MiniSweAgentAdapter extends BaseAdapter {
297
297
  }
298
298
 
299
299
  // ------------------------------------------------------------------
300
- // Control actions (stop)
300
+ // Process teardown (BaseAdapter._stopChannelWork calls _stopProcess)
301
301
  // ------------------------------------------------------------------
302
302
 
303
- async _onControlAction(action, payload) {
304
- if (action === 'stop') {
305
- for (const [channel, proc] of Object.entries(this._channelProcesses)) {
306
- this._stoppingChannels.add(channel);
307
- await this._stopProcess(proc);
308
- delete this._channelProcesses[channel];
309
- try { await this.sendStatus(channel, 'Execution stopped by user'); } catch {}
310
- }
311
- return;
312
- }
313
- await super._onControlAction(action, payload);
314
- }
315
-
316
303
  async _stopProcess(proc) {
317
304
  if (!proc || proc.exitCode !== null) return;
318
305
  try {
@@ -460,6 +447,8 @@ class MiniSweAgentAdapter extends BaseAdapter {
460
447
  }
461
448
 
462
449
  _spawnMini(cmd, msgChannel) {
450
+ // Stopped while this turn was being prepared: start nothing (no tokens).
451
+ if (this._stoppedBeforeStart(msgChannel)) return Promise.resolve({ text: '', error: null });
463
452
  return new Promise((resolve, reject) => {
464
453
  const env = this._buildSubprocessEnv();
465
454
 
@@ -248,6 +248,12 @@ class NanoClawAdapter extends BaseAdapter {
248
248
  await this._autoTitleChannel(channel, content);
249
249
  await this.sendStatus(channel, 'thinking...');
250
250
 
251
+ // Stopped while this turn was being prepared: inject nothing (no tokens).
252
+ if (this._stoppedBeforeStart(channel)) {
253
+ if (this._activeTurn.get(channel) === turnId) this._activeTurn.delete(channel);
254
+ return;
255
+ }
256
+
251
257
  // Inject and await the turn.
252
258
  this._injectedIds.add(msgId);
253
259
  let injected = false;
@@ -278,14 +284,25 @@ class NanoClawAdapter extends BaseAdapter {
278
284
  // never kills the Agent Group, the NanoClaw host, or any shared container.
279
285
  // ------------------------------------------------------------------
280
286
 
281
- async _onControlAction(action, payload) {
282
- if (action === 'stop') {
283
- const ch = payload && (payload.channel || payload.sessionId || payload.session);
284
- if (ch) this._detachChannel(String(ch));
285
- else for (const c of this._activeChannels()) this._detachChannel(c);
286
- return;
287
- }
288
- await super._onControlAction(action, payload);
287
+ // BaseAdapter._handleUserStop drives these. A named channel is always
288
+ // detached (its epoch moves even if nothing was in flight), matching what
289
+ // the stop meant here before it moved into the base class.
290
+ async _stopChannelWork(channel) {
291
+ const wasActive = this._activeTurn.has(channel)
292
+ || this._pending.has(proto.platformIdFor(this.workspaceId, channel));
293
+ this._detachChannel(channel);
294
+ return wasActive ? 'stopped' : 'idle';
295
+ }
296
+
297
+ _channelsWithWork() {
298
+ return [...new Set([...super._channelsWithWork(), ...this._activeChannels()])];
299
+ }
300
+
301
+ /** The container task is not cancelled, only let go of — say so. */
302
+ _stopNoticeText(outcome) {
303
+ if (outcome === 'failed') return super._stopNoticeText(outcome);
304
+ return 'Stopped \u2014 replies from the previous task are dropped; a new message starts a fresh '
305
+ + 'NanoClaw session. The previous task may keep running in the background.';
289
306
  }
290
307
 
291
308
  _activeChannels() {
@@ -329,11 +346,6 @@ class NanoClawAdapter extends BaseAdapter {
329
346
  const state = this._pending.get(platformId);
330
347
  if (state && state.settle) state.settle({ detached: true });
331
348
  if (this._bridge) this._bridge.sendCancel(platformId, null); // best-effort notify
332
- this.sendStatus(
333
- channel,
334
- 'Stopped — replies from the previous task are dropped; a new message starts a fresh NanoClaw session. The previous task may keep running in the background.',
335
- { nanoclaw_state: 'detached' },
336
- ).catch(() => {});
337
349
  }
338
350
 
339
351
  /** A queued NanoClaw reply was dropped (overflow / expiry / corrupt) — surface it. */