@openagents-org/agent-launcher 0.2.184 → 0.2.186

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openagents-org/agent-launcher",
3
- "version": "0.2.184",
3
+ "version": "0.2.186",
4
4
  "description": "OpenAgents Launcher — install, configure, and run AI coding agents from your terminal",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -407,15 +407,6 @@ class AiderAdapter extends BaseAdapter {
407
407
  // ------------------------------------------------------------------
408
408
 
409
409
  async _onControlAction(action, payload) {
410
- if (action === 'stop') {
411
- for (const [channel, proc] of Object.entries(this._channelProcesses)) {
412
- this._stoppingChannels.add(channel);
413
- await this._stopProcess(proc);
414
- delete this._channelProcesses[channel];
415
- try { await this.sendStatus(channel, 'Execution stopped by user'); } catch {}
416
- }
417
- return;
418
- }
419
410
  if (action === 'reset_session' || action === 'clear_session') {
420
411
  const channel = (payload && payload.channel) || this.channelName;
421
412
  this.resetChannelSession(channel);
@@ -531,6 +522,8 @@ class AiderAdapter extends BaseAdapter {
531
522
  }
532
523
 
533
524
  _spawnAider(cmd, msgChannel) {
525
+ // Stopped while this turn was being prepared: start nothing (no tokens).
526
+ if (this._stoppedBeforeStart(msgChannel)) return Promise.resolve({ text: '', error: null });
534
527
  return new Promise((resolve, reject) => {
535
528
  const env = this._buildSubprocessEnv();
536
529
 
@@ -204,23 +204,9 @@ class AmpAdapter extends BaseAdapter {
204
204
  }
205
205
 
206
206
  // ------------------------------------------------------------------
207
- // Control actions (stop)
207
+ // Process teardown (BaseAdapter._stopChannelWork calls _stopProcess)
208
208
  // ------------------------------------------------------------------
209
209
 
210
- async _onControlAction(action, payload) {
211
- if (action === 'stop') {
212
- for (const [channel, proc] of Object.entries(this._channelProcesses)) {
213
- this._stoppingChannels.add(channel);
214
- await this._stopProcess(proc);
215
- delete this._channelProcesses[channel];
216
- try { await this.sendStatus(channel, 'Execution stopped by user'); } catch {}
217
- }
218
- return;
219
- }
220
- // Shared actions (status, routines, skill.install/uninstall).
221
- await super._onControlAction(action, payload);
222
- }
223
-
224
210
  async _stopProcess(proc) {
225
211
  if (!proc || proc.exitCode !== null) return;
226
212
  try {
@@ -354,6 +340,8 @@ class AmpAdapter extends BaseAdapter {
354
340
  }
355
341
 
356
342
  async _spawnAmp(cmd, prompt, msgChannel) {
343
+ // Stopped while this turn was being prepared: start nothing (no tokens).
344
+ if (this._stoppedBeforeStart(msgChannel)) return { text: '', stale: false };
357
345
  return new Promise((resolve, reject) => {
358
346
  // Pass the agent env with an enhanced PATH (nvm/fnm/volta/homebrew,
359
347
  // ~/.local/bin, ~/.amp/bin) so the amp subprocess and any tools it shells
@@ -80,12 +80,6 @@ class AntigravityAdapter extends BaseAdapter {
80
80
  } catch {}
81
81
  }
82
82
 
83
- async _onControlAction(action, _payload) {
84
- if (action === 'stop') {
85
- await this._stopAllProcesses();
86
- }
87
- }
88
-
89
83
  async _stopProcess(proc) {
90
84
  if (!proc || proc.exitCode !== null) return;
91
85
  try {
@@ -108,20 +102,6 @@ class AntigravityAdapter extends BaseAdapter {
108
102
  } catch {}
109
103
  }
110
104
 
111
- async _stopAllProcesses() {
112
- const entries = Object.entries(this._channelProcesses);
113
- if (!entries.length) return;
114
- this._log(`Stopping ${entries.length} running process(es)...`);
115
- for (const [channel, proc] of entries) {
116
- await this._stopProcess(proc);
117
- delete this._channelProcesses[channel];
118
- delete this._channelQueues[channel];
119
- try {
120
- await this.sendStatus(channel, 'Execution stopped by user');
121
- } catch {}
122
- }
123
- }
124
-
125
105
  _findAgyBinary() {
126
106
  // PATH first (codepage-safe lookup, see whereBinary), then the install
127
107
  // script's fixed destinations.
@@ -258,6 +238,9 @@ class AntigravityAdapter extends BaseAdapter {
258
238
  return;
259
239
  }
260
240
 
241
+ // Stopped while this turn was being prepared: start nothing (no tokens).
242
+ if (this._stoppedBeforeStart(msgChannel)) return;
243
+
261
244
  try {
262
245
  const proc = spawn(cmd[0], cmd.slice(1), {
263
246
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -72,7 +72,30 @@ const MODEL_ENV_ALIASES = {
72
72
  codex: ['CODEX_MODEL', 'OPENCLAW_MODEL'],
73
73
  };
74
74
 
75
+ /**
76
+ * Wrap an adapter's `_channelProcesses` so registering a child for a turn that
77
+ * was already stopped kills it at once (see `_onProcessRegistered`).
78
+ */
79
+ function watchProcessRegistry(adapter, registry) {
80
+ if (!registry || typeof registry !== 'object') return registry;
81
+ return new Proxy(registry, {
82
+ set(target, key, value) {
83
+ target[key] = value;
84
+ if (typeof key === 'string') adapter._onProcessRegistered(key, value);
85
+ return true;
86
+ },
87
+ });
88
+ }
89
+
75
90
  class BaseAdapter {
91
+ get _channelProcesses() {
92
+ return this.__channelProcesses;
93
+ }
94
+
95
+ set _channelProcesses(registry) {
96
+ this.__channelProcesses = watchProcessRegistry(this, registry);
97
+ }
98
+
76
99
  /**
77
100
  * @param {object} opts
78
101
  * @param {string} opts.workspaceId
@@ -124,6 +147,31 @@ class BaseAdapter {
124
147
  // Per-channel task tracking for parallel execution
125
148
  this._channelBusy = new Set();
126
149
  this._channelQueues = {};
150
+ // Stop generations. A stop has to cancel work that is no longer IN the
151
+ // queue: the message a channel worker already shifted out, and anything
152
+ // the interrupted turn queues on its way out (Claude's todo nudge). The
153
+ // adapters clear `_channelQueues`, which cannot reach either — that is
154
+ // why one press of Stop used to report a stop and then let the agent
155
+ // pick straight back up, and only a second press stuck. Workers capture
156
+ // the generation they started under and abandon their drain loop the
157
+ // moment it moves.
158
+ this._stopGeneration = 0;
159
+ this._channelStopGeneration = {};
160
+ // The generation a channel's in-flight message started under, so an
161
+ // adapter can tell "a stop landed while I was preparing this turn" from
162
+ // "this turn started after the stop and is meant to run".
163
+ this._channelRunGeneration = {};
164
+ // Channels a user stopped whose next turn has not started yet. Everything
165
+ // the send* helpers would post there is dropped: output a killed run was
166
+ // still flushing lands after the stop notice otherwise, and the workspace
167
+ // UI reads the latest message to decide whether the agent is running.
168
+ this._mutedChannels = new Set();
169
+ // Channels whose current stop has been announced, so the control handler
170
+ // and an in-flight turn racing to report the same stop post it once.
171
+ this._stopNoticeSent = new Set();
172
+ // Channels being torn down by a stop. Adapters read it from their exit
173
+ // handlers to tell a user stop from a crash.
174
+ this._stoppingChannels = new Set();
127
175
  // Cached workspace.browser_enabled. Populated lazily on first read so we
128
176
  // don't pay an HTTP roundtrip per message — adapters that toggle the
129
177
  // workspace flag must reconnect/restart to pick up the change (matches
@@ -471,6 +519,11 @@ class BaseAdapter {
471
519
  this._mode = newMode;
472
520
  this._log(`Mode changed: ${oldMode} -> ${newMode}`);
473
521
  }
522
+ } else if (action === 'stop') {
523
+ // Handled here for every adapter, never by an override: a stop has
524
+ // to stay inside the channel it names, and each adapter used to
525
+ // decide that for itself — several stopped every channel.
526
+ await this._handleUserStop(payload.channel || null);
474
527
  } else {
475
528
  await this._onControlAction(action, payload);
476
529
  }
@@ -478,6 +531,212 @@ class BaseAdapter {
478
531
  } catch {}
479
532
  }
480
533
 
534
+ /**
535
+ * Record a user stop for `channel` — or for every channel when it is null,
536
+ * which is how a workspace-wide stop arrives — and drop what is still
537
+ * queued there. A stop ends the queued work; it does not defer it.
538
+ */
539
+ _markStopRequested(channel) {
540
+ if (channel) {
541
+ this._channelStopGeneration[channel] = (this._channelStopGeneration[channel] || 0) + 1;
542
+ delete this._channelQueues[channel];
543
+ } else {
544
+ this._stopGeneration++;
545
+ this._channelQueues = {};
546
+ }
547
+ }
548
+
549
+ /** Stop generation for one channel; a channel-less stop counts for all. */
550
+ _stopGenerationFor(channel) {
551
+ return `${this._stopGeneration}:${this._channelStopGeneration[channel] || 0}`;
552
+ }
553
+
554
+ /**
555
+ * True when a stop landed after the worker handed this channel its current
556
+ * message. Adapters call this at their point of no return — the checks in
557
+ * `_channelWorker` cover the queue, but `_handleMessage` then spends
558
+ * several more HTTP round trips (session lookup, thinking status, pinned
559
+ * knowledge, channel recap) before the CLI is touched, and a stop landing
560
+ * inside those otherwise still reaches it.
561
+ */
562
+ _stopRequestedDuringTurn(channel) {
563
+ const started = this._channelRunGeneration[channel];
564
+ return started !== undefined && this._stopGenerationFor(channel) !== started;
565
+ }
566
+
567
+ /**
568
+ * Stop what a user asked to stop, and nothing else.
569
+ *
570
+ * `channel` is the thread the Stop button was pressed in; the workspace
571
+ * sends it for every participant agent. Only that channel's work is
572
+ * touched — the same agent running in another thread carries on. A null
573
+ * channel is the workspace-wide "stop everything" path.
574
+ *
575
+ * Order matters, and all of it is here so no adapter can get it wrong:
576
+ * 1. record the stop and mute the channel before the first await, so
577
+ * workers and output parsers running during the teardown see it;
578
+ * 2. let the adapter stop its work for that channel;
579
+ * 3. cancel the stopped plan's todos (that posts the updated list);
580
+ * 4. announce the stop last — the UI reads the latest message.
581
+ */
582
+ async _handleUserStop(channel) {
583
+ const channels = channel ? [channel] : this._channelsWithWork();
584
+ // A repeat stop for a thread that has not started a new turn since its
585
+ // last stop — the workspace client re-sends Stop after 3s while it still
586
+ // shows "Stopping…" — has nothing new to cancel. Moving the generation
587
+ // again would only discard what the user typed after the first press.
588
+ const repeat = !!channel && this._mutedChannels.has(channel);
589
+ if (!repeat) this._markStopRequested(channel);
590
+ for (const ch of channels) {
591
+ this._mutedChannels.add(ch);
592
+ this._stoppingChannels.add(ch);
593
+ }
594
+ if (!channel) {
595
+ try { await this._stopSharedWork(); } catch (e) {
596
+ this._log(`Stop: shared work did not stop cleanly: ${e && e.message ? e.message : e}`);
597
+ }
598
+ }
599
+ for (const ch of channels) {
600
+ let outcome;
601
+ try {
602
+ outcome = await this._stopChannelWork(ch);
603
+ } catch (e) {
604
+ this._log(`Stop: ${ch} did not stop cleanly: ${e && e.message ? e.message : e}`);
605
+ outcome = 'failed';
606
+ }
607
+ // The pressed channel is always answered, so its UI settles even when
608
+ // the agent had nothing running there. A workspace-wide stop only
609
+ // speaks where it actually stopped something.
610
+ if (outcome === 'idle' && ch !== channel) continue;
611
+ await this._finishUserStop(ch, outcome === 'failed' ? 'failed' : 'stopped');
612
+ }
613
+ }
614
+
615
+ /**
616
+ * Channels with work a workspace-wide stop should end. Adapters that track
617
+ * runs somewhere other than `_channelProcesses` extend this.
618
+ */
619
+ _channelsWithWork() {
620
+ return [...new Set([...this._channelBusy, ...Object.keys(this._channelProcesses || {})])];
621
+ }
622
+
623
+ /**
624
+ * Stop this adapter's work for ONE channel. Returns 'stopped' when
625
+ * something was running, 'idle' when nothing was, 'failed' when the run
626
+ * could not be confirmed dead. Must never touch another channel.
627
+ *
628
+ * The default covers every adapter that registers one child process per
629
+ * channel in `_channelProcesses` and kills it with `_stopProcess`.
630
+ * Adapters with other kinds of work (persistent processes, sockets, open
631
+ * HTTP requests) override it, and call super for their child processes.
632
+ */
633
+ async _stopChannelWork(channel) {
634
+ const procs = this._channelProcesses || {};
635
+ const proc = procs[channel];
636
+ if (!proc) return this._channelBusy.has(channel) ? 'stopped' : 'idle';
637
+ this._log(`Stopping process for channel=${channel}`);
638
+ const ended = typeof this._stopProcess === 'function'
639
+ ? await this._stopProcess(proc)
640
+ : undefined;
641
+ delete procs[channel];
642
+ // `_stopProcess` implementations that can tell return false for a kill
643
+ // they could not confirm; the rest return nothing.
644
+ return ended === false ? 'failed' : 'stopped';
645
+ }
646
+
647
+ /** Work shared by every channel, ended only by a workspace-wide stop. */
648
+ async _stopSharedWork() {}
649
+
650
+ /**
651
+ * Close out a stopped channel: cancel its plan, then say so. Deduped per
652
+ * channel until its next turn starts.
653
+ */
654
+ async _finishUserStop(channel, outcome = 'stopped') {
655
+ if (!channel || this._stopNoticeSent.has(channel)) return;
656
+ this._stopNoticeSent.add(channel);
657
+ await this.cleanupTodos(channel);
658
+ await this._announceUserStop(channel, outcome);
659
+ }
660
+
661
+ /**
662
+ * Post the stop notice. Bypasses the mute on purpose — it is the one
663
+ * message a stopped channel is meant to get.
664
+ *
665
+ * A plain message rather than a `status`: the workspace UI folds statuses
666
+ * into the collapsed step list, and the user has to see that the stop took.
667
+ * No `status_kind` either, which would push it as "<agent> finished".
668
+ * Both wordings match the client's terminal-status regex
669
+ * (/stopped|stopping failed/i).
670
+ */
671
+ async _announceUserStop(channel, outcome) {
672
+ const content = this._stopNoticeText(outcome);
673
+ try {
674
+ await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
675
+ senderType: 'agent',
676
+ senderName: this.agentName,
677
+ metadata: { agent_mode: this._mode, stop_notice: true },
678
+ sessionId: this._sessionId,
679
+ });
680
+ } catch (e) {
681
+ if (e instanceof SessionRevokedError) this._onSessionRevoked();
682
+ }
683
+ }
684
+
685
+ /**
686
+ * The words of the stop notice. Override to tell the user something more
687
+ * specific — both must keep matching /stopped|stopping failed/i.
688
+ */
689
+ _stopNoticeText(outcome) {
690
+ return outcome === 'failed'
691
+ ? (this._stopFailedMessage || 'Stopping failed — the agent process did not exit and may still be running.')
692
+ : 'Execution stopped by user.';
693
+ }
694
+
695
+ /**
696
+ * The check every adapter makes right before it starts its CLI, sends a
697
+ * prompt, or opens a model request: did the user stop this turn while it
698
+ * was still being prepared? Preparing takes several round trips (session
699
+ * lookup, status post, pinned knowledge, recap), and a run started after
700
+ * the stop spends tokens on an answer nobody will see.
701
+ *
702
+ * Returns true when the caller must give up without starting anything. The
703
+ * stop has already been announced by `_handleUserStop`. The channel is
704
+ * (re)marked as stopping because several adapters clear that flag only
705
+ * after their own first await, which can run after the stop landed.
706
+ */
707
+ _stoppedBeforeStart(channel) {
708
+ if (!this._stopRequestedDuringTurn(channel)) return false;
709
+ this._stoppingChannels.add(channel);
710
+ this._log(`Stop landed while preparing ${channel} — not starting the run`);
711
+ return true;
712
+ }
713
+
714
+ /**
715
+ * Last line of defence behind `_stoppedBeforeStart`: a child registered in
716
+ * `_channelProcesses` for a turn that was stopped while it was being
717
+ * prepared is killed on the spot, before it can reach the model. Covers any
718
+ * adapter that misses the explicit check, including ones added later.
719
+ */
720
+ _onProcessRegistered(channel, proc) {
721
+ if (!proc || typeof proc !== 'object' || typeof proc.on !== 'function') return;
722
+ if (!this._stopRequestedDuringTurn(channel)) return;
723
+ this._stoppingChannels.add(channel);
724
+ this._log(`Stop landed before ${channel}'s run registered — killing it`);
725
+ const kill = typeof this._stopProcess === 'function'
726
+ ? this._stopProcess(proc)
727
+ : Promise.resolve(proc.kill && proc.kill('SIGTERM'));
728
+ Promise.resolve(kill).catch(() => {}).finally(() => {
729
+ if (this._channelProcesses && this._channelProcesses[channel] === proc) {
730
+ delete this._channelProcesses[channel];
731
+ }
732
+ });
733
+ }
734
+
735
+ /** True while a stopped channel is waiting for its next turn. */
736
+ _isMuted(channel) {
737
+ return !!channel && this._mutedChannels.has(channel);
738
+ }
739
+
481
740
  /**
482
741
  * Handle adapter-specific control actions. Override in subclasses to add
483
742
  * per-adapter actions (`stop`, `restart`, …); always call
@@ -486,7 +745,9 @@ class BaseAdapter {
486
745
  * working uniformly across adapter types.
487
746
  */
488
747
  async _onControlAction(action, payload) {
489
- if (action === 'status') {
748
+ if (action === 'stop') {
749
+ await this._handleUserStop((payload && payload.channel) || null);
750
+ } else if (action === 'status') {
490
751
  await this._postStatusReport(payload);
491
752
  } else if (action === 'routines') {
492
753
  await this._postRoutinesReport(payload);
@@ -924,6 +1185,7 @@ class BaseAdapter {
924
1185
  if (!this._channelQueues[channel]) this._channelQueues[channel] = [];
925
1186
  const queueId = `q-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
926
1187
  msg._queueId = queueId;
1188
+ msg._acceptedGeneration = this._stopGenerationFor(channel);
927
1189
  this._channelQueues[channel].push(msg);
928
1190
  try {
929
1191
  await this.sendStatus(channel, 'message queued — will process after current task', {
@@ -934,6 +1196,10 @@ class BaseAdapter {
934
1196
  return;
935
1197
  }
936
1198
 
1199
+ // A message belongs to the stop generation it arrived in. One accepted
1200
+ // before a stop never runs after it; one that arrives after the stop — the
1201
+ // user typing again while the stopped run is still winding down — does.
1202
+ msg._acceptedGeneration = this._stopGenerationFor(channel);
937
1203
  // Run channel worker (don't await — parallel execution)
938
1204
  this._channelWorker(channel, msg);
939
1205
  this._wakeControlPoller();
@@ -951,31 +1217,48 @@ class BaseAdapter {
951
1217
 
952
1218
  async _channelWorker(channel, msg) {
953
1219
  this._channelBusy.add(channel);
954
- try {
955
- await this._prefetchPinnedContext(channel);
956
- await this._handleMessage(msg);
957
- } catch (e) {
958
- this._log(`Error in channel worker for ${channel}: ${e.message}`);
959
- try { await this.sendError(channel, `Agent error: ${e.message}`); } catch {}
960
- }
961
-
962
- // Drain queue
963
- while (true) {
964
- const queue = this._channelQueues[channel];
965
- if (!queue || queue.length === 0) break;
966
- const nextMsg = queue.shift();
967
- if (nextMsg._queueId) {
968
- try { await this.sendStatus(channel, 'processing queued message', { queue_id: nextMsg._queueId, queue_status: 'processed' }); } catch {}
969
- }
1220
+ // Messages an adapter queues for itself (Claude's todo nudge) carry no
1221
+ // generation; they belong to the turn that queued them.
1222
+ const startGeneration = msg._acceptedGeneration !== undefined
1223
+ ? msg._acceptedGeneration
1224
+ : this._stopGenerationFor(channel);
1225
+ const generationOf = (m) => (m._acceptedGeneration !== undefined ? m._acceptedGeneration : startGeneration);
1226
+ let dropped = 0;
1227
+
1228
+ let next = msg;
1229
+ let first = true;
1230
+ while (next) {
1231
+ const current = next;
1232
+ const generation = generationOf(current);
970
1233
  try {
1234
+ if (!first && current._queueId && generation === this._stopGenerationFor(channel)) {
1235
+ try { await this.sendStatus(channel, 'processing queued message', { queue_id: current._queueId, queue_status: 'processed' }); } catch {}
1236
+ }
971
1237
  // Pinned entries may have changed while this message waited.
972
1238
  await this._prefetchPinnedContext(channel);
973
- await this._handleMessage(nextMsg);
1239
+ // Checked after the round trips above, which a stop can land inside:
1240
+ // a message from before the stop never reaches the CLI.
1241
+ if (generation !== this._stopGenerationFor(channel)) {
1242
+ dropped++;
1243
+ } else {
1244
+ this._channelRunGeneration[channel] = generation;
1245
+ // A turn the user asked for after the stop speaks normally again.
1246
+ this._mutedChannels.delete(channel);
1247
+ this._stopNoticeSent.delete(channel);
1248
+ this._stoppingChannels.delete(channel);
1249
+ await this._handleMessage(current);
1250
+ }
974
1251
  } catch (e) {
975
- this._log(`Error processing queued message in ${channel}: ${e.message}`);
1252
+ this._log(`Error ${first ? 'in channel worker' : 'processing queued message'} for ${channel}: ${e.message}`);
976
1253
  try { await this.sendError(channel, `Agent error: ${e.message}`); } catch {}
977
1254
  }
1255
+ delete this._channelRunGeneration[channel];
1256
+ first = false;
1257
+
1258
+ const queue = this._channelQueues[channel];
1259
+ next = queue && queue.length ? queue.shift() : null;
978
1260
  }
1261
+ if (dropped) this._log(`Stop in ${channel} — dropped ${dropped} message(s) accepted before it`);
979
1262
  this._channelBusy.delete(channel);
980
1263
  }
981
1264
 
@@ -1201,6 +1484,7 @@ class BaseAdapter {
1201
1484
  // ------------------------------------------------------------------
1202
1485
 
1203
1486
  async sendStatus(channel, content, extraMeta) {
1487
+ if (this._isMuted(channel)) return;
1204
1488
  try {
1205
1489
  await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
1206
1490
  senderType: 'agent',
@@ -1217,6 +1501,7 @@ class BaseAdapter {
1217
1501
  async sendThinking(channel, content) {
1218
1502
  // Skip empty thinking traces entirely.
1219
1503
  if (!content || !content.trim()) return;
1504
+ if (this._isMuted(channel)) return;
1220
1505
  try {
1221
1506
  await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
1222
1507
  senderType: 'agent',
@@ -1242,6 +1527,7 @@ class BaseAdapter {
1242
1527
  * message text and fired on every bash for-loop that ended in `done`.
1243
1528
  */
1244
1529
  async sendResponse(channel, content) {
1530
+ if (this._isMuted(channel)) return;
1245
1531
  try {
1246
1532
  await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
1247
1533
  senderType: 'agent',
@@ -1292,6 +1578,7 @@ class BaseAdapter {
1292
1578
  }
1293
1579
 
1294
1580
  async sendTodos(channel, todos) {
1581
+ if (this._isMuted(channel)) return;
1295
1582
  try {
1296
1583
  await this.client.putTodos(this.workspaceId, channel, this.token, todos, {
1297
1584
  source: `openagents:${this.agentName}`,
@@ -1324,6 +1611,7 @@ class BaseAdapter {
1324
1611
  * sendResponse's 'completed'.
1325
1612
  */
1326
1613
  async sendError(channel, error) {
1614
+ if (this._isMuted(channel)) return;
1327
1615
  try {
1328
1616
  await this.client.sendMessage(this.workspaceId, channel, this.token, error, {
1329
1617
  senderType: 'agent',