@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.
@@ -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
  // ------------------------------------------------------------------
@@ -199,24 +199,6 @@ class KimiAdapter extends LlmDirectAdapter {
199
199
  // ------------------------------------------------------------------
200
200
 
201
201
  async _onControlAction(action, payload) {
202
- if (action === 'stop') {
203
- const channel = (payload && typeof payload === 'object') ? payload.channel : null;
204
- if (channel && this._channelProcesses[channel]) {
205
- this._stoppingChannels.add(channel);
206
- await this._stopProcess(this._channelProcesses[channel]);
207
- delete this._channelProcesses[channel];
208
- delete this._channelQueues[channel];
209
- try { await this.sendResponse(channel, 'Execution stopped by user.'); } catch {}
210
- return;
211
- }
212
- if (Object.keys(this._channelProcesses).length) {
213
- await this._stopAllProcesses('Execution stopped by user.');
214
- return;
215
- }
216
- // No CLI runs in flight — fall through to the direct-API stop.
217
- await super._onControlAction(action, payload);
218
- return;
219
- }
220
202
  if (action === 'restart') {
221
203
  const channel = (payload && typeof payload === 'object') ? payload.channel : null;
222
204
  if (channel) {
@@ -685,6 +667,8 @@ class KimiAdapter extends LlmDirectAdapter {
685
667
  * `timedOutMs` is non-zero when the idle watchdog stopped the run.
686
668
  */
687
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 });
688
672
  const { env: cleanEnv } = buildKimiEnv(this.agentEnv || process.env);
689
673
  const [cmd, ...spawnArgs] = this._spawnableCmd(kimiBin, args);
690
674
  // Before the spawn, so the session this run creates shows up as new.
@@ -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. */
@@ -41,6 +41,9 @@ class OpenClawAdapter extends BaseAdapter {
41
41
  super(opts);
42
42
  this.openclawAgentId = opts.openclawAgentId || 'main';
43
43
  this.disabledModules = opts.disabledModules || new Set();
44
+ // channel → the `openclaw agent` run in flight, so a stop can end it
45
+ // instead of letting it spend tokens until it finishes on its own.
46
+ this._channelProcesses = {};
44
47
 
45
48
  // Find the openclaw binary — always use CLI/gateway mode for full tool support
46
49
  this._openclawBinary = this._findOpenclawBinary();
@@ -250,6 +253,7 @@ class OpenClawAdapter extends BaseAdapter {
250
253
 
251
254
  try {
252
255
  const responseText = await this._runCliAgent(content, msgChannel);
256
+ if (this._stoppingChannels.has(msgChannel)) return;
253
257
 
254
258
  if (responseText) {
255
259
  await this.sendResponse(msgChannel, responseText);
@@ -268,6 +272,11 @@ class OpenClawAdapter extends BaseAdapter {
268
272
 
269
273
  _runCliAgent(userMessage, channel) {
270
274
  return new Promise((resolve, reject) => {
275
+ // Stopped while this turn was being prepared: start nothing (no tokens).
276
+ if (this._stoppedBeforeStart(channel)) {
277
+ resolve('');
278
+ return;
279
+ }
271
280
  // Re-check binary if not found at construction time (installed after daemon started)
272
281
  if (!this._openclawBinary) {
273
282
  this._openclawBinary = this._findOpenclawBinary();
@@ -383,8 +392,14 @@ class OpenClawAdapter extends BaseAdapter {
383
392
  env: spawnEnv,
384
393
  cwd: this.workingDir || process.env.HOME || '/',
385
394
  timeout: 600000,
395
+ // Its own process group, so a stop reaches the tools it started too.
396
+ detached: !IS_WINDOWS,
386
397
  windowsHide: true,
387
398
  });
399
+ this._channelProcesses[channel] = proc;
400
+ const unregister = () => {
401
+ if (this._channelProcesses[channel] === proc) delete this._channelProcesses[channel];
402
+ };
388
403
  if (proc.stdout) proc.stdout.on('data', (d) => { output += d; });
389
404
 
390
405
  // Poll stderr file every 500ms for tool events
@@ -423,6 +438,7 @@ class OpenClawAdapter extends BaseAdapter {
423
438
  }, 600000);
424
439
 
425
440
  proc.on('error', (err) => {
441
+ unregister();
426
442
  if (settled) return;
427
443
  settled = true;
428
444
  clearInterval(pollInterval);
@@ -432,6 +448,7 @@ class OpenClawAdapter extends BaseAdapter {
432
448
  reject(err);
433
449
  });
434
450
  proc.on('exit', (code) => {
451
+ unregister();
435
452
  if (settled) return;
436
453
  settled = true;
437
454
  clearInterval(pollInterval);
@@ -457,6 +474,11 @@ class OpenClawAdapter extends BaseAdapter {
457
474
  const hasPayloads = allOutput.includes('"payloads"');
458
475
  this._log(`CLI parse: hasPayloads=${hasPayloads}, total=${allOutput.length}b`);
459
476
 
477
+ // Killed by a user stop: the stop has been announced, nothing to report.
478
+ if (this._stoppingChannels.has(channel)) {
479
+ resolve('');
480
+ return;
481
+ }
460
482
  if (code !== 0) {
461
483
  reject(new Error(`CLI exited ${code}: ${allOutput.slice(-300)}`));
462
484
  return;
@@ -466,6 +488,25 @@ class OpenClawAdapter extends BaseAdapter {
466
488
  });
467
489
  }
468
490
 
491
+ /** End a run and everything it started (BaseAdapter._stopChannelWork calls this). */
492
+ async _stopProcess(proc) {
493
+ if (!proc || proc.exitCode !== null) return;
494
+ try {
495
+ if (IS_WINDOWS) {
496
+ try { execSync(`taskkill /F /T /PID ${proc.pid}`, { timeout: 5000 }); } catch {}
497
+ return;
498
+ }
499
+ try { process.kill(-proc.pid, 'SIGTERM'); } catch { proc.kill('SIGTERM'); }
500
+ await new Promise((resolve) => {
501
+ const timeout = setTimeout(() => {
502
+ try { process.kill(-proc.pid, 'SIGKILL'); } catch { proc.kill('SIGKILL'); }
503
+ resolve();
504
+ }, 5000);
505
+ proc.on('exit', () => { clearTimeout(timeout); resolve(); });
506
+ });
507
+ } catch {}
508
+ }
509
+
469
510
  _parseCliOutput(output, resolve) {
470
511
  const text = output.trim();
471
512
  if (!text) { resolve(''); return; }
@@ -27,8 +27,10 @@ const { whichBinary, whereBinary, getEnhancedEnv } = require('../paths');
27
27
 
28
28
  const IS_WINDOWS = process.platform === 'win32';
29
29
 
30
- // Max wall-clock for a single `opencode run`. When it fires, Node kills the
31
- // child with SIGTERM, the 'close' handler sees signal='SIGTERM' / code=null.
30
+ // Max time an `opencode run` may stay completely silent. This is deliberately
31
+ // an IDLE timeout, not a wall-clock cap: long coding tasks routinely exceed
32
+ // five minutes while continuing to emit text/tool events. A fixed spawn
33
+ // timeout killed those healthy runs midway through their answer.
32
34
  const TIMEOUT_MS = 300000; // 5 minutes
33
35
 
34
36
  // Pinned OpenCode CLI version. Do NOT use @latest anywhere (registry.json /
@@ -158,6 +160,34 @@ class OpenCodeAdapter extends BaseAdapter {
158
160
  }
159
161
 
160
162
  /** Environment the CLI is spawned with. */
163
+
164
+ /** Test seam for the inactivity watchdog. */
165
+ _runTimeoutMs() {
166
+ return TIMEOUT_MS;
167
+ }
168
+
169
+ /**
170
+ * Arm a watchdog that expires only after a continuous period without CLI
171
+ * output. Every stdout/stderr chunk proves the process is still making
172
+ * progress and pushes the deadline out again.
173
+ */
174
+ _createIdleWatchdog(onTimeout) {
175
+ const timeoutMs = this._runTimeoutMs();
176
+ let timer = null;
177
+ let stopped = false;
178
+ const touch = () => {
179
+ if (stopped) return;
180
+ if (timer) clearTimeout(timer);
181
+ timer = setTimeout(onTimeout, timeoutMs);
182
+ };
183
+ const cancel = () => {
184
+ stopped = true;
185
+ if (timer) clearTimeout(timer);
186
+ timer = null;
187
+ };
188
+ touch();
189
+ return { touch, cancel, timeoutMs };
190
+ }
161
191
  _spawnEnv() {
162
192
  return { ...(this.agentEnv || process.env) };
163
193
  }
@@ -237,32 +267,6 @@ class OpenCodeAdapter extends BaseAdapter {
237
267
  return path.join(this.agentHome, `sessions-${slug}.json`);
238
268
  }
239
269
 
240
- async _onControlAction(action, payload) {
241
- if (action === 'stop') {
242
- const channel = (payload && typeof payload === 'object') ? payload.channel : null;
243
- if (channel) {
244
- const proc = this._channelProcesses[channel];
245
- const hadQueuedWork = !!this._channelQueues[channel]?.length;
246
- if (proc) {
247
- this._log(`Stopping process for channel=${channel}`);
248
- this._stoppingChannels.add(channel);
249
- await this._stopProcess(proc);
250
- delete this._channelProcesses[channel];
251
- }
252
- delete this._channelQueues[channel];
253
- if (proc || hadQueuedWork) {
254
- try {
255
- await this.sendResponse(channel, 'Execution stopped by user.');
256
- } catch {}
257
- }
258
- } else {
259
- await this._stopAllProcesses('Execution stopped by user.');
260
- }
261
- return;
262
- }
263
- await super._onControlAction(action, payload);
264
- }
265
-
266
270
  /**
267
271
  * Write workspace skill to OpenCode's skill directory for auto-discovery.
268
272
  */
@@ -727,8 +731,8 @@ class OpenCodeAdapter extends BaseAdapter {
727
731
  * CRITICAL: opencode-ai does NOT read the OPENCODE_MODEL env var. When no
728
732
  * model is given on the command line AND none is set in opencode.json, the
729
733
  * non-interactive `run` command hangs forever waiting for interactive
730
- * provider/model selection — it emits zero output until the spawn timeout
731
- * kills it with SIGTERM (surfaced as the misleading "exited with code null").
734
+ * provider/model selection — it emits zero output until the inactivity
735
+ * watchdog kills it with SIGTERM.
732
736
  * Passing `--model` explicitly is what makes headless runs actually work.
733
737
  */
734
738
  _resolveModel() {
@@ -861,12 +865,14 @@ class OpenCodeAdapter extends BaseAdapter {
861
865
  spawnBinary = process.env.COMSPEC || 'cmd.exe';
862
866
  }
863
867
 
868
+ // Stopped while this turn was being prepared: start nothing (no tokens).
869
+ if (this._stoppedBeforeStart(msgChannel)) return Promise.resolve('');
870
+
864
871
  return new Promise((resolve, reject) => {
865
872
  const proc = spawn(spawnBinary, spawnArgs, {
866
873
  stdio: ['pipe', 'pipe', 'pipe'],
867
874
  env: spawnEnv,
868
875
  cwd: runCwd,
869
- timeout: TIMEOUT_MS,
870
876
  detached: !IS_WINDOWS,
871
877
  // Never let a console window appear. Besides being ugly, an attached
872
878
  // console makes opencode start its interactive TUI and hang instead of
@@ -882,9 +888,16 @@ class OpenCodeAdapter extends BaseAdapter {
882
888
  let pendingEvents = Promise.resolve();
883
889
  const responseState = { finalText: '', allText: '', seenText: false };
884
890
  let settled = false;
891
+ let timedOut = false;
892
+ const watchdog = this._createIdleWatchdog(() => {
893
+ timedOut = true;
894
+ this._log(`opencode idle for ${this._runTimeoutMs() / 1000}s; terminating`);
895
+ this._stopProcess(proc).catch(() => {});
896
+ });
885
897
  const finish = (fn, arg) => {
886
898
  if (settled) return;
887
899
  settled = true;
900
+ watchdog.cancel();
888
901
  if (this._channelProcesses[msgChannel] === proc) {
889
902
  delete this._channelProcesses[msgChannel];
890
903
  }
@@ -893,6 +906,7 @@ class OpenCodeAdapter extends BaseAdapter {
893
906
 
894
907
  if (proc.stdout) {
895
908
  proc.stdout.on('data', (d) => {
909
+ watchdog.touch();
896
910
  const chunk = d.toString('utf-8');
897
911
  stdout += chunk;
898
912
  streamBuffer += chunk;
@@ -905,7 +919,10 @@ class OpenCodeAdapter extends BaseAdapter {
905
919
  }
906
920
  });
907
921
  }
908
- if (proc.stderr) proc.stderr.on('data', (d) => { stderr += d; });
922
+ if (proc.stderr) proc.stderr.on('data', (d) => {
923
+ watchdog.touch();
924
+ stderr += d;
925
+ });
909
926
 
910
927
  // Send the prompt via stdin, then close it so opencode knows the message
911
928
  // is complete and never blocks waiting for more input. Guard the write:
@@ -920,9 +937,8 @@ class OpenCodeAdapter extends BaseAdapter {
920
937
 
921
938
  proc.on('error', (err) => finish(reject, err));
922
939
  // 'close' (not 'exit') fires after stdout/stderr have fully drained, so we
923
- // never parse a truncated response. The handler receives (code, signal):
924
- // when the spawn timeout kills the child, code is null and signal is the
925
- // signal name — report that as a timeout rather than "exited with code null".
940
+ // never parse a truncated response. The explicit `timedOut` flag keeps a
941
+ // watchdog termination distinct from an external signal or crash.
926
942
  proc.on('close', async (code, signal) => {
927
943
  try { await pendingEvents; } catch {}
928
944
 
@@ -940,14 +956,18 @@ class OpenCodeAdapter extends BaseAdapter {
940
956
  // because stderr happened to be empty.
941
957
  const stdoutErr = OpenCodeAdapter._extractErrorFromStdout(stdout);
942
958
 
943
- if (signal) {
944
- this._log(`opencode killed by signal ${signal} after ${TIMEOUT_MS / 1000}s (output: ${!!stdout})`);
959
+ if (timedOut) {
960
+ this._log(`opencode timed out after ${watchdog.timeoutMs / 1000}s idle (output: ${!!stdout})`);
945
961
  const cls = this.constructor._classifyFailure({ code, signal, stdout, stderr, stdoutErr });
946
- // A signal almost always means our own timeout fired; only override to
947
- // a more specific provider error if stdout/stderr clearly show one.
948
962
  const category = (cls.category === 'unknown_error' || cls.category === 'process_crashed')
949
963
  ? 'timeout' : cls.category;
950
- return finish(reject, this._failure(category, cls.diagnostic || `signal=${signal}`, cls.detail));
964
+ return finish(reject, this._failure(category, cls.diagnostic || `idle=${watchdog.timeoutMs}ms`, cls.detail));
965
+ }
966
+
967
+ if (signal) {
968
+ this._log(`opencode killed by signal ${signal} (output: ${!!stdout})`);
969
+ const cls = this.constructor._classifyFailure({ code, signal, stdout, stderr, stdoutErr });
970
+ return finish(reject, this._failure(cls.category, cls.diagnostic || `signal=${signal}`, cls.detail));
951
971
  }
952
972
 
953
973
  if (code !== 0) {
@@ -1177,6 +1197,8 @@ class OpenCodeAdapter extends BaseAdapter {
1177
1197
  * normal reply. Carries `error_category` in metadata so the UI can route it.
1178
1198
  */
1179
1199
  async _sendClassifiedError(channel, category, detail) {
1200
+ // A run the user stopped reads as "stopped mid-task"; it is not an error.
1201
+ if (this._isMuted(channel)) return;
1180
1202
  const messages = this._failureMessages();
1181
1203
  const base = messages[category] || messages.unknown_error;
1182
1204
  const safe = detail ? OpenCodeAdapter._redact(detail).trim() : '';
@@ -98,7 +98,6 @@ const SERVER_READY_TIMEOUT_MS = 90000;
98
98
  const HEALTH_POLL_INTERVAL_MS = 400;
99
99
 
100
100
  // Max wall-clock for one turn, after which we interrupt and report it.
101
- const TURN_TIMEOUT_MS = 900000; // 15 minutes
102
101
 
103
102
  // Idle watchdog over the event stream, in the same shape the CLI adapters use.
104
103
  const WATCHDOG_INTERVAL_MS = 15000;
@@ -536,28 +535,26 @@ class OpenWorkerAdapter extends BaseAdapter {
536
535
  }
537
536
 
538
537
  // ------------------------------------------------------------------
539
- // Control actions (stop / restart)
538
+ // Stop / shutdown
540
539
  // ------------------------------------------------------------------
541
540
 
542
- async _onControlAction(action, payload) {
543
- if (action === 'stop') {
544
- const channel = (payload && payload.channel) || null;
545
- if (channel) {
546
- await this._stopChannel(channel, 'Execution stopped.');
547
- return;
548
- }
549
- await this._stopAllChannels();
550
- return;
551
- }
552
- return super._onControlAction(action, payload);
553
- }
554
-
555
541
  stop() {
556
542
  super.stop();
557
543
  this._serverStopped = true;
558
544
  void this._stopAllChannels('Agent stopped.').finally(() => this._stopServer());
559
545
  }
560
546
 
547
+ /** A turn here is a socket to the engine, not a child process. */
548
+ async _stopChannelWork(channel) {
549
+ if (!this._channelSockets[channel]) return super._stopChannelWork(channel);
550
+ await this._stopChannel(channel, null);
551
+ return 'stopped';
552
+ }
553
+
554
+ _channelsWithWork() {
555
+ return [...new Set([...super._channelsWithWork(), ...Object.keys(this._channelSockets)])];
556
+ }
557
+
561
558
  async _stopChannel(channel, message) {
562
559
  const socket = this._channelSockets[channel];
563
560
  if (!socket) return;
@@ -637,6 +634,10 @@ class OpenWorkerAdapter extends BaseAdapter {
637
634
  * @returns {Promise<object>} { texts, error, interrupted, userStopped, sent }
638
635
  */
639
636
  _runTurn(channel, server, sessionId, workingDir, text, { planMode, model }) {
637
+ // Stopped while this turn was being prepared: start nothing (no tokens).
638
+ if (this._stoppedBeforeStart(channel)) {
639
+ return Promise.resolve({ texts: [], error: null, interrupted: false, userStopped: true, sent: false });
640
+ }
640
641
  return new Promise((resolve) => {
641
642
  const url = sessionUrl({
642
643
  port: server.port,
@@ -672,7 +673,6 @@ class OpenWorkerAdapter extends BaseAdapter {
672
673
  if (settled) return;
673
674
  settled = true;
674
675
  clearInterval(watchdog);
675
- clearTimeout(timeout);
676
676
  if (this._channelSockets[channel] === socket) delete this._channelSockets[channel];
677
677
  try { socket.close(); } catch {}
678
678
  const userStopped = this._stoppingChannels.has(channel);
@@ -686,6 +686,11 @@ class OpenWorkerAdapter extends BaseAdapter {
686
686
 
687
687
  const sendTurn = () => {
688
688
  if (sentMessage) return;
689
+ // The handshake takes a moment; a stop inside it still sends nothing.
690
+ if (this._stoppedBeforeStart(channel)) {
691
+ finish({});
692
+ return;
693
+ }
689
694
  sentMessage = true;
690
695
  // Anything observed before this point belongs to the turn we cleared out
691
696
  // of the way (see the `ready.running` branch), not to ours — carrying it
@@ -814,12 +819,6 @@ class OpenWorkerAdapter extends BaseAdapter {
814
819
  }
815
820
  }, WATCHDOG_INTERVAL_MS);
816
821
 
817
- const timeout = setTimeout(() => {
818
- this._log(`Turn exceeded ${TURN_TIMEOUT_MS}ms — interrupting`);
819
- send({ type: 'interrupt' });
820
- if (!error) error = 'The turn ran past its time limit and was stopped.';
821
- finish({});
822
- }, TURN_TIMEOUT_MS);
823
822
  });
824
823
  }
825
824