@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 +1 -1
- 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/codebuddy.js +3 -29
- package/src/adapters/codex.js +3 -13
- package/src/adapters/commandcode.js +3 -29
- package/src/adapters/copilot.js +15 -31
- 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/kimi.js +2 -18
- 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 +62 -40
- package/src/adapters/openworker.js +21 -22
- package/src/adapters/pi.js +22 -29
- package/src/daemon.js +54 -4
- package/src/wsl.js +60 -8
package/src/adapters/gemini.js
CHANGED
|
@@ -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) {
|
package/src/adapters/goose.js
CHANGED
|
@@ -287,20 +287,7 @@ class GooseAdapter extends BaseAdapter {
|
|
|
287
287
|
return redactSecrets(text, this._secrets);
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
-
// --
|
|
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 {
|
package/src/adapters/hermes.js
CHANGED
|
@@ -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
|
// ------------------------------------------------------------------
|
package/src/adapters/kimi.js
CHANGED
|
@@ -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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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);
|
package/src/adapters/mini.js
CHANGED
|
@@ -297,22 +297,9 @@ class MiniSweAgentAdapter extends BaseAdapter {
|
|
|
297
297
|
}
|
|
298
298
|
|
|
299
299
|
// ------------------------------------------------------------------
|
|
300
|
-
//
|
|
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
|
|
package/src/adapters/nanoclaw.js
CHANGED
|
@@ -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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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. */
|
package/src/adapters/openclaw.js
CHANGED
|
@@ -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; }
|
package/src/adapters/opencode.js
CHANGED
|
@@ -27,8 +27,10 @@ const { whichBinary, whereBinary, getEnhancedEnv } = require('../paths');
|
|
|
27
27
|
|
|
28
28
|
const IS_WINDOWS = process.platform === 'win32';
|
|
29
29
|
|
|
30
|
-
// Max
|
|
31
|
-
//
|
|
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
|
|
731
|
-
* kills it with SIGTERM
|
|
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) => {
|
|
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
|
|
924
|
-
//
|
|
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 (
|
|
944
|
-
this._log(`opencode
|
|
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 || `
|
|
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
|
-
//
|
|
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
|
|