@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.
@@ -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 };
package/src/wsl.js CHANGED
@@ -27,9 +27,10 @@
27
27
  * The mirror direction needs no marker: a path ending in .exe/.cmd/.bat while
28
28
  * we are running inside a distro is unambiguously a Windows binary.
29
29
  *
30
- * Cost: resolution never spawns wsl.exe per binary. One probe (5 min TTL) reads
31
- * the distro's login PATH, and every later lookup is a filesystem check through
32
- * the \\wsl.localhost UNC mount.
30
+ * Background resolution checks whether the default distro is already running
31
+ * before probing its login PATH or UNC mount. A stopped distro is left stopped;
32
+ * after the user starts it, the next scan can detect its CLIs. One PATH probe
33
+ * (5 min TTL) then serves later lookups through the \\wsl.localhost UNC mount.
33
34
  *
34
35
  * Inside the desktop app's main process none of that may block: starting
35
36
  * wsl.exe can mean booting the WSL VM, and the first touch of the UNC mount
@@ -53,6 +54,9 @@ const CACHE_TTL_MS = 30 * 1000;
53
54
  * the VM (and, in the desktop app, the window) busy for nothing.
54
55
  */
55
56
  const MACHINE_TTL_MS = 5 * 60 * 1000;
57
+ // A stopped distro must never be woken by background detection. Keep this
58
+ // short so a distro the user starts becomes visible on the next scan.
59
+ const RUNNING_TTL_MS = 2000;
56
60
 
57
61
  function _fresh(cache, ttl) {
58
62
  return !!cache.at && Date.now() - cache.at < ttl;
@@ -126,6 +130,8 @@ function isRunningInWsl() {
126
130
 
127
131
  let distrosCache = { value: null, at: 0 };
128
132
  let distrosRefresh = null;
133
+ let runningDistrosCache = { value: null, at: 0 };
134
+ let runningDistrosRefresh = null;
129
135
 
130
136
  function _parseDistros(buf) {
131
137
  return Buffer.from(buf)
@@ -203,6 +209,49 @@ function defaultDistro() {
203
209
  return wslDistros()[0] || null;
204
210
  }
205
211
 
212
+ function _refreshRunningDistros() {
213
+ if (_fresh(runningDistrosCache, RUNNING_TTL_MS)) return Promise.resolve();
214
+ if (!runningDistrosRefresh) {
215
+ runningDistrosRefresh = _execFileOut('wsl.exe', ['-l', '--running', '-q'], {
216
+ encoding: 'buffer', maxBuffer: 1024 * 1024,
217
+ })
218
+ .then((out) => { runningDistrosCache = { value: out ? _parseDistros(out) : [], at: Date.now() }; })
219
+ .finally(() => { runningDistrosRefresh = null; });
220
+ }
221
+ return runningDistrosRefresh;
222
+ }
223
+
224
+ /** Query WSL's management state without executing a command inside a distro. */
225
+ function _defaultDistroRunning() {
226
+ const distro = defaultDistro();
227
+ if (!distro) return false;
228
+ if (!_fresh(runningDistrosCache, RUNNING_TTL_MS)) {
229
+ if (!canBlock()) {
230
+ void _refreshRunningDistros();
231
+ } else {
232
+ let names = [];
233
+ try {
234
+ names = _parseDistros(execFileSync('wsl.exe', ['-l', '--running', '-q'], {
235
+ timeout: PROBE_TIMEOUT_MS,
236
+ windowsHide: true,
237
+ stdio: ['ignore', 'pipe', 'ignore'],
238
+ maxBuffer: 1024 * 1024,
239
+ }));
240
+ } catch { /* WSL unavailable: do not wake it */ }
241
+ runningDistrosCache = { value: names, at: Date.now() };
242
+ }
243
+ }
244
+ return (runningDistrosCache.value || []).includes(distro);
245
+ }
246
+
247
+ async function _defaultDistroRunningAsync() {
248
+ await _refreshDistros();
249
+ const distro = (distrosCache.value || [])[0];
250
+ if (!distro) return false;
251
+ await _refreshRunningDistros();
252
+ return (runningDistrosCache.value || []).includes(distro);
253
+ }
254
+
206
255
  // ---------------------------------------------------------------------------
207
256
  // Windows side: reaching into the distro's filesystem without starting one
208
257
  // ---------------------------------------------------------------------------
@@ -216,7 +265,7 @@ function _refreshUncRoot() {
216
265
  if (_fresh(uncRootCache, MACHINE_TTL_MS)) return Promise.resolve();
217
266
  if (!uncRootRefresh) {
218
267
  uncRootRefresh = (async () => {
219
- await _refreshDistros();
268
+ if (!(await _defaultDistroRunningAsync())) return;
220
269
  const distro = (distrosCache.value || [])[0];
221
270
  if (!distro) return;
222
271
  let root = null;
@@ -242,6 +291,7 @@ function _refreshUncRoot() {
242
291
  * WSL2 serves \\wsl.localhost\<distro>; WSL1 and older builds only \\wsl$\.
243
292
  */
244
293
  function _uncRoot() {
294
+ if (!_defaultDistroRunning()) return null;
245
295
  if (_fresh(uncRootCache, MACHINE_TTL_MS)) {
246
296
  return uncRootCache.value;
247
297
  }
@@ -334,8 +384,7 @@ function _refreshShellProbe() {
334
384
  if (_fresh(shellProbeCache, MACHINE_TTL_MS)) return Promise.resolve();
335
385
  if (!shellProbeRefresh) {
336
386
  shellProbeRefresh = (async () => {
337
- await _refreshDistros();
338
- if (!(distrosCache.value || []).length) return;
387
+ if (!(await _defaultDistroRunningAsync())) return;
339
388
  let out = null;
340
389
  for (const argv of SHELL_PROBE_ARGVS) {
341
390
  const raw = await _execFileOut('wsl.exe', argv, { encoding: 'utf-8', maxBuffer: 4 * 1024 * 1024 });
@@ -365,6 +414,7 @@ function _refreshShellProbe() {
365
414
  */
366
415
  function _shellProbe() {
367
416
  const empty = _emptyShellProbe();
417
+ if (!_defaultDistroRunning()) return empty;
368
418
  if (_fresh(shellProbeCache, MACHINE_TTL_MS)) {
369
419
  return shellProbeCache.value || empty;
370
420
  }
@@ -428,7 +478,7 @@ const wslBinaryRefresh = new Map();
428
478
  function _refreshWslBinary(key, list) {
429
479
  if (wslBinaryRefresh.has(key)) return wslBinaryRefresh.get(key);
430
480
  const job = (async () => {
431
- await _refreshDistros();
481
+ if (!(await _defaultDistroRunningAsync())) return;
432
482
  let value = null;
433
483
  if ((distrosCache.value || []).length) {
434
484
  await Promise.all([_refreshUncRoot(), _refreshShellProbe()]);
@@ -465,13 +515,14 @@ function _refreshWslBinary(key, list) {
465
515
  * Returns the path as the DISTRO sees it, behind the `wsl:` marker
466
516
  * (`wsl:/home/u/.local/bin/claude`) — the inner path is what `wsl.exe -e` must
467
517
  * be handed. Existence is proved through the UNC mount, so a machine with WSL
468
- * installed but no distro pays nothing beyond the single cached distro listing.
518
+ * installed but no running default distro pays only for WSL management queries.
469
519
  *
470
520
  * @param {string|string[]} names binary name(s), e.g. 'claude' or ['cursor-agent','agent']
471
521
  * @returns {string|null} marked in-distro path
472
522
  */
473
523
  function resolveWslBinary(names) {
474
524
  if (!IS_WINDOWS) return null;
525
+ if (!_defaultDistroRunning()) return null;
475
526
  const list = (Array.isArray(names) ? names : [names]).filter(Boolean);
476
527
  if (!list.length) return null;
477
528
  const key = list.join('\0');
@@ -520,6 +571,7 @@ function clearWslBinaryCache() {
520
571
  /** Full reset, including the per-process probes. For tests and rare rescans. */
521
572
  function clearWslCache() {
522
573
  distrosCache = { value: null, at: 0 };
574
+ runningDistrosCache = { value: null, at: 0 };
523
575
  uncRootCache = { value: null, at: 0 };
524
576
  shellProbeCache = { value: null, at: 0 };
525
577
  wslBinaryCache.clear();