@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.
@@ -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; }
@@ -114,12 +114,54 @@ class OpenCodeAdapter extends BaseAdapter {
114
114
 
115
115
  this._opencodeBinary = this._findOpencodeBinary();
116
116
  if (this._opencodeBinary) {
117
- this._log(`Using OpenCode subprocess mode: ${this._opencodeBinary}`);
117
+ this._log(`Using ${this._cliLabel()} subprocess mode: ${this._opencodeBinary}`);
118
118
  } else {
119
- this._log(`OpenCode binary not found. Install with: npm install -g opencode-ai@${OPENCODE_PINNED_VERSION}`);
119
+ this._log(`${this._cliLabel()} binary not found. ${this._installHint()}`);
120
120
  }
121
121
  }
122
122
 
123
+ // ------------------------------------------------------------------
124
+ // What differs between OpenCode and the CLIs built on it
125
+ // ------------------------------------------------------------------
126
+ //
127
+ // Vendors ship their own agents on top of OpenCode (Huawei's CodeArts Agent
128
+ // is one — see codearts.js). They keep its `run --format json` stream, its
129
+ // sessions and its failure shapes, and change how the CLI is found, launched
130
+ // and authenticated. These hooks are exactly that difference.
131
+
132
+ /** Product name used in logs and user-facing errors. */
133
+ _cliLabel() {
134
+ return 'OpenCode';
135
+ }
136
+
137
+ _installHint() {
138
+ return `Install with: npm install -g opencode-ai@${OPENCODE_PINNED_VERSION}`;
139
+ }
140
+
141
+ /** User-facing message per failure category. */
142
+ _failureMessages() {
143
+ return FAILURE_MESSAGES;
144
+ }
145
+
146
+ /** Whether a run may not start without a model (OpenCode hangs without one). */
147
+ _modelRequired() {
148
+ return true;
149
+ }
150
+
151
+ /** Arguments after the binary for one `run`. */
152
+ _buildRunArgs({ runCwd, model, sessionId }) {
153
+ // Preflight guarantees a resolvable model; pin it explicitly — without it
154
+ // opencode hangs waiting for interactive provider/model selection.
155
+ const args = ['run', '--format', 'json', '--dir', runCwd, '--model', model];
156
+ if (sessionId) args.push('--session', sessionId);
157
+ return args;
158
+ }
159
+
160
+ /** Environment the CLI is spawned with. */
161
+ _spawnEnv() {
162
+ return { ...(this.agentEnv || process.env) };
163
+ }
164
+
123
165
  /**
124
166
  * Migrate sessions file from old location to agent home.
125
167
  */
@@ -195,32 +237,6 @@ class OpenCodeAdapter extends BaseAdapter {
195
237
  return path.join(this.agentHome, `sessions-${slug}.json`);
196
238
  }
197
239
 
198
- async _onControlAction(action, payload) {
199
- if (action === 'stop') {
200
- const channel = (payload && typeof payload === 'object') ? payload.channel : null;
201
- if (channel) {
202
- const proc = this._channelProcesses[channel];
203
- const hadQueuedWork = !!this._channelQueues[channel]?.length;
204
- if (proc) {
205
- this._log(`Stopping process for channel=${channel}`);
206
- this._stoppingChannels.add(channel);
207
- await this._stopProcess(proc);
208
- delete this._channelProcesses[channel];
209
- }
210
- delete this._channelQueues[channel];
211
- if (proc || hadQueuedWork) {
212
- try {
213
- await this.sendResponse(channel, 'Execution stopped by user.');
214
- } catch {}
215
- }
216
- } else {
217
- await this._stopAllProcesses('Execution stopped by user.');
218
- }
219
- return;
220
- }
221
- await super._onControlAction(action, payload);
222
- }
223
-
224
240
  /**
225
241
  * Write workspace skill to OpenCode's skill directory for auto-discovery.
226
242
  */
@@ -793,30 +809,25 @@ class OpenCodeAdapter extends BaseAdapter {
793
809
  try { this._ensureCustomProviderConfig(runCwd); } catch (e) {
794
810
  this._log(`Gateway provider config failed (continuing): ${e.message}`);
795
811
  }
796
- const cmd = [binary, 'run', '--format', 'json', '--dir', runCwd];
797
-
798
- // Preflight guarantees a resolvable model; pin it explicitly — without it
799
- // opencode hangs waiting for interactive provider/model selection.
800
812
  const model = this._resolveModel();
801
- cmd.push('--model', model);
802
-
803
813
  const sessionId = this._channelSessions[msgChannel];
814
+ const args = this._buildRunArgs({ runCwd, model, sessionId });
815
+
804
816
  let fullPrompt;
805
817
  if (sessionId) {
806
818
  fullPrompt = content;
807
- cmd.push('--session', sessionId);
808
819
  } else {
809
820
  this._ensureWorkspaceSkill(msgChannel);
810
821
  const context = this._buildSystemContext(msgChannel);
811
822
  fullPrompt = `${context}\n\n---\n\n${content}`;
812
823
  }
813
824
 
814
- this._log(`CLI: ${binary} run --format json --dir ${runCwd} --model ${model || '(none)'}`);
825
+ this._log(`CLI: ${binary} ${args.join(' ')}`);
815
826
 
816
- const spawnEnv = { ...(this.agentEnv || process.env) };
827
+ const spawnEnv = this._spawnEnv();
817
828
 
818
- let spawnBinary = cmd[0];
819
- let spawnArgs = cmd.slice(1);
829
+ let spawnBinary = binary;
830
+ let spawnArgs = args;
820
831
  // Only the npm `.cmd` shim needs a cmd.exe host. The native opencode.exe
821
832
  // (preferred by _findOpencodeBinary) is spawned directly — no console host.
822
833
  if (IS_WINDOWS && spawnBinary.toLowerCase().endsWith('.cmd')) {
@@ -824,6 +835,9 @@ class OpenCodeAdapter extends BaseAdapter {
824
835
  spawnBinary = process.env.COMSPEC || 'cmd.exe';
825
836
  }
826
837
 
838
+ // Stopped while this turn was being prepared: start nothing (no tokens).
839
+ if (this._stoppedBeforeStart(msgChannel)) return Promise.resolve('');
840
+
827
841
  return new Promise((resolve, reject) => {
828
842
  const proc = spawn(spawnBinary, spawnArgs, {
829
843
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -905,7 +919,7 @@ class OpenCodeAdapter extends BaseAdapter {
905
919
 
906
920
  if (signal) {
907
921
  this._log(`opencode killed by signal ${signal} after ${TIMEOUT_MS / 1000}s (output: ${!!stdout})`);
908
- const cls = OpenCodeAdapter._classifyFailure({ code, signal, stdout, stderr, stdoutErr });
922
+ const cls = this.constructor._classifyFailure({ code, signal, stdout, stderr, stdoutErr });
909
923
  // A signal almost always means our own timeout fired; only override to
910
924
  // a more specific provider error if stdout/stderr clearly show one.
911
925
  const category = (cls.category === 'unknown_error' || cls.category === 'process_crashed')
@@ -914,14 +928,14 @@ class OpenCodeAdapter extends BaseAdapter {
914
928
  }
915
929
 
916
930
  if (code !== 0) {
917
- const cls = OpenCodeAdapter._classifyFailure({ code, signal, stdout, stderr, stdoutErr });
931
+ const cls = this.constructor._classifyFailure({ code, signal, stdout, stderr, stdoutErr });
918
932
  this._log(`opencode exited code ${code} → [${cls.category}]: ${OpenCodeAdapter._redact(cls.diagnostic).slice(0, 300)}`);
919
933
  return finish(reject, this._failure(cls.category, cls.diagnostic, cls.detail));
920
934
  }
921
935
 
922
936
  // Exit 0.
923
937
  if (stdout) this._persistSessionId(msgChannel, stdout);
924
- const outcome = OpenCodeAdapter._outcomeForCleanExit({ stdout, stderr, stdoutErr, responseState });
938
+ const outcome = this.constructor._outcomeForCleanExit({ stdout, stderr, stdoutErr, responseState });
925
939
  if (outcome.text) return finish(resolve, outcome.text);
926
940
  const f = outcome.failure;
927
941
  this._log(`opencode exit 0 → [${f.category}]: ${OpenCodeAdapter._redact(f.diagnostic).slice(0, 300)}`);
@@ -951,7 +965,7 @@ class OpenCodeAdapter extends BaseAdapter {
951
965
 
952
966
  if (endedOnTool) {
953
967
  if (stdoutErr) {
954
- const cls = OpenCodeAdapter._classifyFailure({ code: 0, signal: null, stdout, stderr, stdoutErr });
968
+ const cls = this._classifyFailure({ code: 0, signal: null, stdout, stderr, stdoutErr });
955
969
  const vague = ['unknown_error', 'process_crashed', 'empty_response'].includes(cls.category);
956
970
  return {
957
971
  failure: {
@@ -977,7 +991,7 @@ class OpenCodeAdapter extends BaseAdapter {
977
991
  // provider — it can be a structured error on stdout, an incomplete
978
992
  // stream, or a genuinely empty completion. Classify rather than guess.
979
993
  if (stdoutErr) {
980
- const cls = OpenCodeAdapter._classifyFailure({ code: 0, signal: null, stdout, stderr, stdoutErr });
994
+ const cls = this._classifyFailure({ code: 0, signal: null, stdout, stderr, stdoutErr });
981
995
  return { failure: { category: cls.category, diagnostic: `exit 0 with error event: ${cls.diagnostic || ''}`, detail: cls.detail } };
982
996
  }
983
997
  const emptyCat = OpenCodeAdapter._emptyExitCategory(stdout);
@@ -1010,7 +1024,7 @@ class OpenCodeAdapter extends BaseAdapter {
1010
1024
  if (!probe.executable) {
1011
1025
  return { ok: false, category: 'cli_not_executable', diagnostic: 'opencode --version did not run' };
1012
1026
  }
1013
- const vclass = OpenCodeAdapter._classifyVersion(probe.version);
1027
+ const vclass = this.constructor._classifyVersion(probe.version);
1014
1028
  if (vclass === 'unsupported') {
1015
1029
  return {
1016
1030
  ok: false,
@@ -1022,7 +1036,7 @@ class OpenCodeAdapter extends BaseAdapter {
1022
1036
  // 'unknown' (unparseable) and 'degraded' (newer than tested) still run.
1023
1037
 
1024
1038
  const model = this._resolveModel();
1025
- if (!model) return { ok: false, category: 'model_missing' };
1039
+ if (!model && this._modelRequired()) return { ok: false, category: 'model_missing' };
1026
1040
 
1027
1041
  const cred = this._credentialState();
1028
1042
  if (cred === 'missing') return { ok: false, category: 'credential_missing' };
@@ -1140,10 +1154,14 @@ class OpenCodeAdapter extends BaseAdapter {
1140
1154
  * normal reply. Carries `error_category` in metadata so the UI can route it.
1141
1155
  */
1142
1156
  async _sendClassifiedError(channel, category, detail) {
1143
- const base = FAILURE_MESSAGES[category] || FAILURE_MESSAGES.unknown_error;
1157
+ // A run the user stopped reads as "stopped mid-task"; it is not an error.
1158
+ if (this._isMuted(channel)) return;
1159
+ const messages = this._failureMessages();
1160
+ const base = messages[category] || messages.unknown_error;
1144
1161
  const safe = detail ? OpenCodeAdapter._redact(detail).trim() : '';
1145
1162
  const body = safe ? `${base}\n\n> ${safe}` : base;
1146
- const headline = category === 'incomplete_run' ? 'OpenCode stopped mid-task' : "OpenCode couldn't run";
1163
+ const label = this._cliLabel();
1164
+ const headline = category === 'incomplete_run' ? `${label} stopped mid-task` : `${label} couldn't run`;
1147
1165
  const content = `⚠️ **${headline}** — ${body}`;
1148
1166
  try {
1149
1167
  await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
@@ -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
  }
@@ -369,11 +391,57 @@ class Daemon {
369
391
  */
370
392
  async _setModelEnv(type, model) {
371
393
  const val = String(model || '');
372
- const nativeVar = { claude: 'ANTHROPIC_MODEL', gemini: 'GEMINI_MODEL' }[type];
373
394
  await this._runAgn(['env', type, '--set', `LLM_MODEL=${val}`]);
395
+ const nativeVar = { claude: 'ANTHROPIC_MODEL', gemini: 'GEMINI_MODEL' }[type] || this._nativeModelVar(type);
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
+
424
+ /**
425
+ * The agent's own model setting, for a type whose registry declares one and
426
+ * does not map LLM_MODEL onto anything itself (CodeArts reads CODEARTS_MODEL
427
+ * and nothing else). A type with a resolve_env rule for LLM_MODEL already
428
+ * gets there through env resolution, so it is left alone.
429
+ */
430
+ _nativeModelVar(type) {
431
+ try {
432
+ const entry = this.registry.getEntry(type);
433
+ if (!entry) return null;
434
+ const rules = (entry.resolve_env && entry.resolve_env.rules) || [];
435
+ if (rules.some((r) => r && r.from === 'LLM_MODEL')) return null;
436
+ const field = (entry.env_config || []).find(
437
+ (f) => f && /_MODEL$/.test(f.name || '') && f.name !== 'LLM_MODEL',
438
+ );
439
+ return field ? field.name : null;
440
+ } catch {
441
+ return null;
442
+ }
443
+ }
444
+
377
445
  async _refreshRuntimes() {
378
446
  try {
379
447
  const r = await this._runAgn(['runtimes', '--json']);
@@ -456,6 +524,7 @@ async _runNodeCommand(n, cmd) {
456
524
  if (args.model) await this._setModelEnv(type, args.model);
457
525
  if (args.baseUrl) await this._runAgn(['env', type, '--set', `LLM_BASE_URL=${args.baseUrl}`]);
458
526
  await this._applyConfigMap(type, args.config);
527
+ this._saveNodeAgentEnv(name, type, args);
459
528
  // Attach to this node's workspace BY SLUG: the daemon already knows
460
529
  // which workspace this command came from, so there is no token to
461
530
  // pass and no /v1/token/resolve round-trip to fail (the outage class
@@ -498,6 +567,7 @@ async _runNodeCommand(n, cmd) {
498
567
  await this._runAgn(['remove', name]);
499
568
  const rc = await this._runAgn(['create', name, '--type', type, '--install', '--path', newDir]);
500
569
  if (rc.code !== 0) throw new Error(rc.stderr || rc.stdout || 'recreate failed');
570
+ this._saveNodeAgentEnv(name, type, args);
501
571
  const rc2 = await this._runAgn([
502
572
  'connect', name, '--workspace', n.workspace_slug || n.workspace_id,
503
573
  ]);
@@ -507,6 +577,7 @@ async _runNodeCommand(n, cmd) {
507
577
  // through daemon.cmd, and writeCommand() OVERWRITES that file — the
508
578
  // start could clobber the unread stop (agent keeps the stale env) or
509
579
  // interleave with the poll so the agent stops and never restarts.
580
+ this._saveNodeAgentEnv(name, type, args);
510
581
  await this.restartAgent(name);
511
582
  }
512
583
  ok = true;
@@ -1701,4 +1772,4 @@ async _runNodeCommand(n, cmd) {
1701
1772
  }
1702
1773
  }
1703
1774
 
1704
- module.exports = { Daemon };
1775
+ module.exports = { Daemon, filesystemRoots };