@openagents-org/agent-launcher 0.2.184 → 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.
@@ -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; }
@@ -237,32 +237,6 @@ class OpenCodeAdapter extends BaseAdapter {
237
237
  return path.join(this.agentHome, `sessions-${slug}.json`);
238
238
  }
239
239
 
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
240
  /**
267
241
  * Write workspace skill to OpenCode's skill directory for auto-discovery.
268
242
  */
@@ -861,6 +835,9 @@ class OpenCodeAdapter extends BaseAdapter {
861
835
  spawnBinary = process.env.COMSPEC || 'cmd.exe';
862
836
  }
863
837
 
838
+ // Stopped while this turn was being prepared: start nothing (no tokens).
839
+ if (this._stoppedBeforeStart(msgChannel)) return Promise.resolve('');
840
+
864
841
  return new Promise((resolve, reject) => {
865
842
  const proc = spawn(spawnBinary, spawnArgs, {
866
843
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -1177,6 +1154,8 @@ class OpenCodeAdapter extends BaseAdapter {
1177
1154
  * normal reply. Carries `error_category` in metadata so the UI can route it.
1178
1155
  */
1179
1156
  async _sendClassifiedError(channel, category, detail) {
1157
+ // A run the user stopped reads as "stopped mid-task"; it is not an error.
1158
+ if (this._isMuted(channel)) return;
1180
1159
  const messages = this._failureMessages();
1181
1160
  const base = messages[category] || messages.unknown_error;
1182
1161
  const safe = detail ? OpenCodeAdapter._redact(detail).trim() : '';
@@ -536,28 +536,26 @@ class OpenWorkerAdapter extends BaseAdapter {
536
536
  }
537
537
 
538
538
  // ------------------------------------------------------------------
539
- // Control actions (stop / restart)
539
+ // Stop / shutdown
540
540
  // ------------------------------------------------------------------
541
541
 
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
542
  stop() {
556
543
  super.stop();
557
544
  this._serverStopped = true;
558
545
  void this._stopAllChannels('Agent stopped.').finally(() => this._stopServer());
559
546
  }
560
547
 
548
+ /** A turn here is a socket to the engine, not a child process. */
549
+ async _stopChannelWork(channel) {
550
+ if (!this._channelSockets[channel]) return super._stopChannelWork(channel);
551
+ await this._stopChannel(channel, null);
552
+ return 'stopped';
553
+ }
554
+
555
+ _channelsWithWork() {
556
+ return [...new Set([...super._channelsWithWork(), ...Object.keys(this._channelSockets)])];
557
+ }
558
+
561
559
  async _stopChannel(channel, message) {
562
560
  const socket = this._channelSockets[channel];
563
561
  if (!socket) return;
@@ -637,6 +635,10 @@ class OpenWorkerAdapter extends BaseAdapter {
637
635
  * @returns {Promise<object>} { texts, error, interrupted, userStopped, sent }
638
636
  */
639
637
  _runTurn(channel, server, sessionId, workingDir, text, { planMode, model }) {
638
+ // Stopped while this turn was being prepared: start nothing (no tokens).
639
+ if (this._stoppedBeforeStart(channel)) {
640
+ return Promise.resolve({ texts: [], error: null, interrupted: false, userStopped: true, sent: false });
641
+ }
640
642
  return new Promise((resolve) => {
641
643
  const url = sessionUrl({
642
644
  port: server.port,
@@ -686,6 +688,11 @@ class OpenWorkerAdapter extends BaseAdapter {
686
688
 
687
689
  const sendTurn = () => {
688
690
  if (sentMessage) return;
691
+ // The handshake takes a moment; a stop inside it still sends nothing.
692
+ if (this._stoppedBeforeStart(channel)) {
693
+ finish({});
694
+ return;
695
+ }
689
696
  sentMessage = true;
690
697
  // Anything observed before this point belongs to the turn we cleared out
691
698
  // of the way (see the `ready.running` branch), not to ours — carrying it
@@ -147,9 +147,6 @@ class PiAdapter extends BaseAdapter {
147
147
  // channel → child process (BaseAdapter/stop paths read this)
148
148
  this._channelProcesses = {};
149
149
  this._stoppingChannels = new Set();
150
- // Dedup for "Execution stopped by user." — the control handler and the
151
- // in-flight message handler both race to announce it (see claude.js).
152
- this._stopNoticeSent = new Set();
153
150
 
154
151
  this._sessionsFile = path.join(
155
152
  os.homedir(), '.openagents', 'sessions',
@@ -524,24 +521,6 @@ class PiAdapter extends BaseAdapter {
524
521
  // ------------------------------------------------------------------
525
522
 
526
523
  async _onControlAction(action, payload) {
527
- if (action === 'stop') {
528
- const channel = (payload && typeof payload === 'object') ? payload.channel : null;
529
- if (channel) {
530
- const pp = this._persistentProcs[channel];
531
- if (pp) pp.userStopped = true;
532
- if (this._channelProcesses[channel]) {
533
- this._log(`Stopping Pi for channel=${channel}`);
534
- this._stoppingChannels.add(channel);
535
- await this._abortChannel(channel);
536
- delete this._channelQueues[channel];
537
- await this._postStopNotice(channel);
538
- }
539
- } else {
540
- for (const pp of Object.values(this._persistentProcs)) pp.userStopped = true;
541
- await this._stopAllProcesses('Execution stopped by user.');
542
- }
543
- return;
544
- }
545
524
  if (action === 'restart') {
546
525
  const channel = (payload && typeof payload === 'object') ? payload.channel : null;
547
526
  if (channel) {
@@ -594,11 +573,20 @@ class PiAdapter extends BaseAdapter {
594
573
  }
595
574
  }
596
575
 
597
- /** Post "Execution stopped by user." at most once per stop, per channel. */
598
- async _postStopNotice(channel) {
599
- if (!channel || this._stopNoticeSent.has(channel)) return;
600
- this._stopNoticeSent.add(channel);
601
- try { await this.sendResponse(channel, 'Execution stopped by user.'); } catch {}
576
+ /**
577
+ * Stop ONE channel's Pi turn (BaseAdapter._handleUserStop drives this).
578
+ * `userStopped` goes on first so the turn that is winding down reports a
579
+ * stop rather than a failure.
580
+ */
581
+ async _stopChannelWork(channel) {
582
+ const pp = this._persistentProcs[channel];
583
+ if (pp) pp.userStopped = true;
584
+ // Abort goes over the RPC channel of the persistent record; a bare child
585
+ // with no record is simply killed.
586
+ if (!pp || !this._channelProcesses[channel]) return super._stopChannelWork(channel);
587
+ this._log(`Stopping Pi for channel=${channel}`);
588
+ await this._abortChannel(channel);
589
+ return 'stopped';
602
590
  }
603
591
 
604
592
  /**
@@ -1346,7 +1334,6 @@ class PiAdapter extends BaseAdapter {
1346
1334
  const channel = msg.sessionId || this.channelName || 'general';
1347
1335
 
1348
1336
  this._stoppingChannels.delete(channel);
1349
- this._stopNoticeSent.delete(channel);
1350
1337
 
1351
1338
  if (!content && !attachments.length) return;
1352
1339
 
@@ -1400,6 +1387,9 @@ class PiAdapter extends BaseAdapter {
1400
1387
  browserEnabled,
1401
1388
  });
1402
1389
 
1390
+ // Stopped while this turn was being prepared: start nothing (no tokens).
1391
+ if (this._stoppedBeforeStart(channel)) return;
1392
+
1403
1393
  let pp;
1404
1394
  try {
1405
1395
  pp = await this._ensureProc(channel, workingDir, systemPrompt);
@@ -1408,6 +1398,9 @@ class PiAdapter extends BaseAdapter {
1408
1398
  return;
1409
1399
  }
1410
1400
 
1401
+ // Starting the process takes a moment; the prompt is what costs tokens.
1402
+ if (this._stoppedBeforeStart(channel)) return;
1403
+
1411
1404
  // Fresh per-turn accounting.
1412
1405
  pp.msgChannel = channel;
1413
1406
  pp.turnTexts = [];
@@ -1453,7 +1446,7 @@ class PiAdapter extends BaseAdapter {
1453
1446
  pp.turnInFlight = false;
1454
1447
 
1455
1448
  if (pp.userStopped || this._stoppingChannels.has(channel)) {
1456
- if (!pp.everPosted) await this._postStopNotice(channel);
1449
+ await this._finishUserStop(channel);
1457
1450
  pp.userStopped = false;
1458
1451
  return;
1459
1452
  }
@@ -1470,7 +1463,7 @@ class PiAdapter extends BaseAdapter {
1470
1463
  async _reportTurnFailure(pp, channel, error) {
1471
1464
  const raw = (error && error.message) || String(error);
1472
1465
  if (error instanceof PiCancelledError && (pp.userStopped || this._stoppingChannels.has(channel))) {
1473
- await this._postStopNotice(channel);
1466
+ await this._finishUserStop(channel);
1474
1467
  return;
1475
1468
  }
1476
1469
  const { kind, userMessage } = classifyPiError(raw);
package/src/daemon.js CHANGED
@@ -23,6 +23,27 @@ function maskApiKey(key) {
23
23
  return key.slice(0, 4) + '...' + key.slice(-4);
24
24
  }
25
25
 
26
+ /**
27
+ * Top-level filesystem locations the workspace folder picker can jump to.
28
+ * Windows has one root per mounted drive; POSIX systems have a single root.
29
+ * Keeping this as a small pure helper makes the Windows behaviour testable on
30
+ * every CI platform.
31
+ */
32
+ function filesystemRoots(platform = os.platform(), exists = fs.existsSync) {
33
+ if (platform !== 'win32') return ['/'];
34
+
35
+ const roots = [];
36
+ for (let code = 'A'.charCodeAt(0); code <= 'Z'.charCodeAt(0); code++) {
37
+ const root = `${String.fromCharCode(code)}:\\`;
38
+ try {
39
+ if (exists(root)) roots.push(root);
40
+ } catch {
41
+ // A disconnected or unreadable mapped drive should not break heartbeat.
42
+ }
43
+ }
44
+ return roots;
45
+ }
46
+
26
47
  /**
27
48
  * Agent process lifecycle manager.
28
49
  *
@@ -327,8 +348,9 @@ class Daemon {
327
348
  }
328
349
 
329
350
  /**
330
- * Filesystem hint for the working-directory picker: home + its immediate
331
- * (non-hidden) subfolders, so the workspace can show real folders instantly.
351
+ * Filesystem hint for the working-directory picker: home, its immediate
352
+ * (non-hidden) subfolders, and the device's filesystem roots. Roots let a
353
+ * Windows user switch drives instead of being trapped on the home drive.
332
354
  */
333
355
  _buildFs() {
334
356
  try {
@@ -338,7 +360,7 @@ class Daemon {
338
360
  .map((d) => d.name)
339
361
  .sort((a, b) => a.localeCompare(b))
340
362
  .slice(0, 100);
341
- return { home, dirs };
363
+ return { home, dirs, roots: filesystemRoots() };
342
364
  } catch {
343
365
  return {};
344
366
  }
@@ -374,6 +396,31 @@ class Daemon {
374
396
  if (nativeVar) await this._runAgn(['env', type, '--set', `${nativeVar}=${val}`]);
375
397
  }
376
398
 
399
+ /**
400
+ * A node command targets one agent. The launcher may already have saved an
401
+ * instance-level model/key/URL, which wins over the type env written above.
402
+ * Mirror the command onto that instance so the running adapter and the
403
+ * launcher's "This Computer" row both see the requested settings.
404
+ */
405
+ _saveNodeAgentEnv(name, type, args) {
406
+ const changes = {};
407
+ if (args.apiKey && !args.useDeviceCredentials) changes.LLM_API_KEY = args.apiKey;
408
+ if (args.baseUrl !== undefined) changes.LLM_BASE_URL = args.baseUrl;
409
+ if (args.model !== undefined) {
410
+ const existing = this.config.getAgent(name);
411
+ if (!existing) throw new Error(`Agent '${name}' not found after create`);
412
+ // Old instance-level native model keys can outrank LLM_MODEL in the
413
+ // launcher's display and in CLIs without a resolve_env model rule.
414
+ for (const key of Object.keys(existing.env || {})) {
415
+ if (key === 'MODEL' || key.endsWith('_MODEL')) changes[key] = '';
416
+ }
417
+ changes.LLM_MODEL = String(args.model || '');
418
+ const nativeVar = { claude: 'ANTHROPIC_MODEL', gemini: 'GEMINI_MODEL' }[type] || this._nativeModelVar(type);
419
+ if (nativeVar) changes[nativeVar] = String(args.model || '');
420
+ }
421
+ if (Object.keys(changes).length) this.config.updateAgentEnv(name, changes);
422
+ }
423
+
377
424
  /**
378
425
  * The agent's own model setting, for a type whose registry declares one and
379
426
  * does not map LLM_MODEL onto anything itself (CodeArts reads CODEARTS_MODEL
@@ -477,6 +524,7 @@ async _runNodeCommand(n, cmd) {
477
524
  if (args.model) await this._setModelEnv(type, args.model);
478
525
  if (args.baseUrl) await this._runAgn(['env', type, '--set', `LLM_BASE_URL=${args.baseUrl}`]);
479
526
  await this._applyConfigMap(type, args.config);
527
+ this._saveNodeAgentEnv(name, type, args);
480
528
  // Attach to this node's workspace BY SLUG: the daemon already knows
481
529
  // which workspace this command came from, so there is no token to
482
530
  // pass and no /v1/token/resolve round-trip to fail (the outage class
@@ -519,6 +567,7 @@ async _runNodeCommand(n, cmd) {
519
567
  await this._runAgn(['remove', name]);
520
568
  const rc = await this._runAgn(['create', name, '--type', type, '--install', '--path', newDir]);
521
569
  if (rc.code !== 0) throw new Error(rc.stderr || rc.stdout || 'recreate failed');
570
+ this._saveNodeAgentEnv(name, type, args);
522
571
  const rc2 = await this._runAgn([
523
572
  'connect', name, '--workspace', n.workspace_slug || n.workspace_id,
524
573
  ]);
@@ -528,6 +577,7 @@ async _runNodeCommand(n, cmd) {
528
577
  // through daemon.cmd, and writeCommand() OVERWRITES that file — the
529
578
  // start could clobber the unread stop (agent keeps the stale env) or
530
579
  // interleave with the poll so the agent stops and never restarts.
580
+ this._saveNodeAgentEnv(name, type, args);
531
581
  await this.restartAgent(name);
532
582
  }
533
583
  ok = true;
@@ -1722,4 +1772,4 @@ async _runNodeCommand(n, cmd) {
1722
1772
  }
1723
1773
  }
1724
1774
 
1725
- module.exports = { Daemon };
1775
+ module.exports = { Daemon, filesystemRoots };