@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.
- package/icons/codearts.svg +9 -0
- package/package.json +1 -1
- package/registry.json +68 -0
- package/src/adapters/aider.js +2 -9
- package/src/adapters/amp.js +3 -15
- package/src/adapters/antigravity.js +3 -20
- package/src/adapters/base.js +307 -19
- package/src/adapters/claude.js +63 -47
- package/src/adapters/cline.js +2 -13
- package/src/adapters/codearts.js +316 -0
- package/src/adapters/codebuddy.js +3 -21
- package/src/adapters/codex.js +3 -13
- package/src/adapters/commandcode.js +3 -21
- package/src/adapters/copilot.js +2 -26
- package/src/adapters/cursor.js +8 -13
- package/src/adapters/deepseek.js +33 -68
- package/src/adapters/gemini.js +3 -20
- package/src/adapters/goose.js +4 -14
- package/src/adapters/hermes.js +2 -10
- package/src/adapters/index.js +4 -1
- package/src/adapters/kimi-stream.js +44 -2
- package/src/adapters/kimi.js +118 -34
- package/src/adapters/llm-direct.js +17 -6
- package/src/adapters/mini.js +3 -14
- package/src/adapters/nanoclaw.js +25 -13
- package/src/adapters/openclaw.js +41 -0
- package/src/adapters/opencode.js +66 -48
- package/src/adapters/openworker.js +21 -14
- package/src/adapters/pi.js +22 -29
- package/src/daemon.js +76 -5
- package/src/installer.js +48 -16
- package/src/paths.js +187 -92
- package/src/probe-mode.js +36 -0
- package/src/wsl.js +270 -46
package/src/adapters/base.js
CHANGED
|
@@ -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 === '
|
|
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
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
this.
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
const
|
|
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
|
-
|
|
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
|
|
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',
|
package/src/adapters/claude.js
CHANGED
|
@@ -96,12 +96,6 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
96
96
|
this._channelSessions = {}; // channel → Claude CLI session_id
|
|
97
97
|
this._channelProcesses = {}; // channel → child process
|
|
98
98
|
this._stoppingChannels = new Set();
|
|
99
|
-
// Channels that have already announced "Execution stopped by user." for the
|
|
100
|
-
// current stop. Two paths race to post it (the control-action handler that
|
|
101
|
-
// kills the process, and the in-flight message handler that sees
|
|
102
|
-
// pp.userStopped after exit), so this dedups to a single notice. Reset when
|
|
103
|
-
// a new message starts processing in the channel.
|
|
104
|
-
this._stopNoticeSent = new Set();
|
|
105
99
|
this._persistentProcs = {}; // channel → { proc, lineBuffer, pendingLines, idleTimer, messageResolve }
|
|
106
100
|
// Knowledge pinning (decision log + glossary) lives in BaseAdapter; this
|
|
107
101
|
// adapter fetches directly in _handleMessage because the result also
|
|
@@ -139,26 +133,6 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
139
133
|
}
|
|
140
134
|
|
|
141
135
|
async _onControlAction(action, payload) {
|
|
142
|
-
if (action === 'stop') {
|
|
143
|
-
const channel = (payload && typeof payload === 'object') ? payload.channel : null;
|
|
144
|
-
if (channel) {
|
|
145
|
-
const pp = this._persistentProcs[channel];
|
|
146
|
-
if (pp) pp.userStopped = true;
|
|
147
|
-
}
|
|
148
|
-
if (channel && this._channelProcesses[channel]) {
|
|
149
|
-
this._log(`Stopping process for channel=${channel}`);
|
|
150
|
-
this._stoppingChannels.add(channel);
|
|
151
|
-
const proc = this._channelProcesses[channel];
|
|
152
|
-
await this._stopProcess(proc);
|
|
153
|
-
delete this._channelProcesses[channel];
|
|
154
|
-
delete this._channelQueues[channel];
|
|
155
|
-
await this._postStopNotice(channel);
|
|
156
|
-
} else {
|
|
157
|
-
for (const pp of Object.values(this._persistentProcs)) pp.userStopped = true;
|
|
158
|
-
await this._stopAllProcesses('Execution stopped by user.');
|
|
159
|
-
}
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
136
|
if (action === 'restart') {
|
|
163
137
|
const channel = (payload && typeof payload === 'object') ? payload.channel : null;
|
|
164
138
|
if (channel) {
|
|
@@ -317,15 +291,37 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
317
291
|
}
|
|
318
292
|
|
|
319
293
|
/**
|
|
320
|
-
*
|
|
321
|
-
* stop
|
|
322
|
-
*
|
|
323
|
-
*
|
|
294
|
+
* Abandon this turn when a stop landed while it was being prepared. The
|
|
295
|
+
* stop handler kills whatever process is registered, but a turn still
|
|
296
|
+
* working through its pre-CLI round trips has none yet — so without this
|
|
297
|
+
* it goes on to spawn one and the agent resumes seconds after saying it
|
|
298
|
+
* stopped. Returns true when the caller should give up.
|
|
324
299
|
*/
|
|
325
|
-
async
|
|
326
|
-
if (!
|
|
327
|
-
|
|
328
|
-
|
|
300
|
+
async _bailOnStopDuringTurn(msgChannel) {
|
|
301
|
+
if (!this._stoppedBeforeStart(msgChannel)) return false;
|
|
302
|
+
// Deduped: normally the stop handler already announced it.
|
|
303
|
+
await this._finishUserStop(msgChannel);
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Stop ONE channel's Claude run (BaseAdapter._handleUserStop drives this).
|
|
309
|
+
*
|
|
310
|
+
* `userStopped` goes on before the kill: the turn waiting on this process
|
|
311
|
+
* must read its exit as a stop, not as a stale session to retry, and the
|
|
312
|
+
* output parser drops what the process wrote before dying.
|
|
313
|
+
*/
|
|
314
|
+
async _stopChannelWork(channel) {
|
|
315
|
+
const pp = this._persistentProcs[channel];
|
|
316
|
+
if (pp) pp.userStopped = true;
|
|
317
|
+
const outcome = await super._stopChannelWork(channel);
|
|
318
|
+
// Those leftover lines are still being parsed on this chain. They post
|
|
319
|
+
// nothing now, but wait them out so none of their bookkeeping races the
|
|
320
|
+
// notice BaseAdapter posts next.
|
|
321
|
+
if (pp) {
|
|
322
|
+
try { await pp.pendingLines; } catch {}
|
|
323
|
+
}
|
|
324
|
+
return outcome;
|
|
329
325
|
}
|
|
330
326
|
|
|
331
327
|
async _stopAllProcesses(completionMessage = 'Execution stopped.') {
|
|
@@ -839,6 +835,23 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
839
835
|
try { event = JSON.parse(line); } catch { return; }
|
|
840
836
|
const eventType = event.type;
|
|
841
837
|
|
|
838
|
+
// The user stopped this process. What it wrote before dying is still
|
|
839
|
+
// queued here, and posting any of it — a thinking block, a tool status,
|
|
840
|
+
// even the finished answer — lands after "Execution stopped by user."
|
|
841
|
+
// and makes the thread read as running again. Keep the session id so
|
|
842
|
+
// the next message resumes the conversation; drop everything else.
|
|
843
|
+
if (pp.userStopped) {
|
|
844
|
+
if (eventType === 'result' && event.session_id) {
|
|
845
|
+
this._channelSessions[pp.msgChannel] = event.session_id;
|
|
846
|
+
this._saveSessions();
|
|
847
|
+
}
|
|
848
|
+
if (eventType === 'result' && pp.messageResolve) {
|
|
849
|
+
pp.messageResolve({ resultEvent: event });
|
|
850
|
+
pp.messageResolve = null;
|
|
851
|
+
}
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
|
|
842
855
|
if (eventType === 'assistant') {
|
|
843
856
|
pp.awaitingToolResult = false;
|
|
844
857
|
const blocks = (event.message || {}).content || [];
|
|
@@ -1109,6 +1122,10 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
1109
1122
|
/** Queue a reminder about unfinished todos (skipped when this turn IS one). */
|
|
1110
1123
|
async _queueTodoNudge(msgChannel, msg) {
|
|
1111
1124
|
if (msg._todoNudge) return;
|
|
1125
|
+
// A stop ends the plan, it does not pause it. When the stop lands while
|
|
1126
|
+
// this turn is finishing, nudging here is what handed the agent its own
|
|
1127
|
+
// "please continue" right after it announced it had stopped.
|
|
1128
|
+
if (this._stoppingChannels.has(msgChannel)) return;
|
|
1112
1129
|
try {
|
|
1113
1130
|
const remaining = await this.getRemainingTodos(msgChannel);
|
|
1114
1131
|
if (remaining.length > 0) {
|
|
@@ -1136,7 +1153,6 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
1136
1153
|
|
|
1137
1154
|
const msgChannel = msg.sessionId || this.channelName;
|
|
1138
1155
|
this._stoppingChannels.delete(msgChannel);
|
|
1139
|
-
this._stopNoticeSent.delete(msgChannel);
|
|
1140
1156
|
const sender = msg.senderName || msg.senderType || 'user';
|
|
1141
1157
|
this._log(`Processing message from ${sender} in ${msgChannel}: ${content.slice(0, 80)}...`);
|
|
1142
1158
|
|
|
@@ -1213,9 +1229,14 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
1213
1229
|
await this._killPersistentProc(msgChannel);
|
|
1214
1230
|
} else {
|
|
1215
1231
|
this._log(`Reusing persistent process for ${msgChannel}`);
|
|
1232
|
+
if (await this._bailOnStopDuringTurn(msgChannel)) return;
|
|
1216
1233
|
this._resetIdleTimer(msgChannel);
|
|
1217
1234
|
existingPP.msgChannel = msgChannel;
|
|
1218
1235
|
const result = await this._sendToPersistentProc(existingPP, content);
|
|
1236
|
+
if (existingPP.userStopped) {
|
|
1237
|
+
await this._finishUserStop(msgChannel);
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1219
1240
|
if (result.resultEvent) {
|
|
1220
1241
|
const finalResponse = this._composeFinalResponse(existingPP);
|
|
1221
1242
|
if (this._isPromptTooLong(existingPP, finalResponse) && this._channelSessions[msgChannel]) {
|
|
@@ -1228,11 +1249,6 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
1228
1249
|
await this._queueTodoNudge(msgChannel, msg);
|
|
1229
1250
|
return;
|
|
1230
1251
|
}
|
|
1231
|
-
} else if (existingPP.userStopped) {
|
|
1232
|
-
if (!existingPP.everPostedAnything) {
|
|
1233
|
-
await this._postStopNotice(msgChannel);
|
|
1234
|
-
}
|
|
1235
|
-
return;
|
|
1236
1252
|
} else {
|
|
1237
1253
|
// Process died mid-message — fall through to spawn a fresh one
|
|
1238
1254
|
this._log(`Persistent process died, falling back to fresh spawn for ${msgChannel}`);
|
|
@@ -1285,6 +1301,7 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
1285
1301
|
}
|
|
1286
1302
|
|
|
1287
1303
|
try {
|
|
1304
|
+
if (await this._bailOnStopDuringTurn(msgChannel)) return;
|
|
1288
1305
|
const pp = this._spawnPersistentProc(msgChannel, cmd, cleanEnv);
|
|
1289
1306
|
// Remember the spawn-time configuration so the fast-path can detect
|
|
1290
1307
|
// staleness on later messages — the pinned knowledge and the mode
|
|
@@ -1301,14 +1318,13 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
1301
1318
|
|
|
1302
1319
|
const result = await this._sendToPersistentProc(pp, effectiveContent);
|
|
1303
1320
|
|
|
1321
|
+
if (pp.userStopped) {
|
|
1322
|
+
await this._finishUserStop(msgChannel);
|
|
1323
|
+
break;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1304
1326
|
if (result.exited) {
|
|
1305
|
-
this._log(`Process exited during first message (attempt ${attempt + 1})
|
|
1306
|
-
if (pp.userStopped) {
|
|
1307
|
-
if (!pp.everPostedAnything) {
|
|
1308
|
-
await this._postStopNotice(msgChannel);
|
|
1309
|
-
}
|
|
1310
|
-
break;
|
|
1311
|
-
}
|
|
1327
|
+
this._log(`Process exited during first message (attempt ${attempt + 1})`);
|
|
1312
1328
|
if (attempt === 0 && this._channelSessions[msgChannel]) {
|
|
1313
1329
|
this._log(`Stale session detected, retrying without resume`);
|
|
1314
1330
|
delete this._channelSessions[msgChannel];
|
package/src/adapters/cline.js
CHANGED
|
@@ -136,19 +136,6 @@ class ClineAdapter extends BaseAdapter {
|
|
|
136
136
|
// ------------------------------------------------------------------
|
|
137
137
|
|
|
138
138
|
async _onControlAction(action, payload) {
|
|
139
|
-
if (action === 'stop') {
|
|
140
|
-
const channel = (payload && typeof payload === 'object') ? payload.channel : null;
|
|
141
|
-
if (channel && this._channelProcesses[channel]) {
|
|
142
|
-
this._stoppingChannels.add(channel);
|
|
143
|
-
await this._stopProcess(this._channelProcesses[channel]);
|
|
144
|
-
delete this._channelProcesses[channel];
|
|
145
|
-
delete this._channelQueues[channel];
|
|
146
|
-
try { await this.sendResponse(channel, 'Execution stopped by user.'); } catch {}
|
|
147
|
-
} else {
|
|
148
|
-
await this._stopAllProcesses('Execution stopped by user.');
|
|
149
|
-
}
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
139
|
if (action === 'restart') {
|
|
153
140
|
const channel = (payload && typeof payload === 'object') ? payload.channel : null;
|
|
154
141
|
if (channel) {
|
|
@@ -631,6 +618,8 @@ class ClineAdapter extends BaseAdapter {
|
|
|
631
618
|
* `ok` reflects run_result.finishReason === "completed".
|
|
632
619
|
*/
|
|
633
620
|
_runCline(channel, clineBin, args, workingDir) {
|
|
621
|
+
// Stopped while this turn was being prepared: start nothing (no tokens).
|
|
622
|
+
if (this._stoppedBeforeStart(channel)) return Promise.resolve({ userStopped: true });
|
|
634
623
|
const cleanEnv = { ...(this.agentEnv || process.env) };
|
|
635
624
|
const [cmd, ...spawnArgs] = this._spawnableCmd(clineBin, args);
|
|
636
625
|
|