@openagents-org/agent-launcher 0.2.192 → 0.2.194

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/src/daemon.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const crypto = require('crypto');
5
6
  const { execSync, execFileSync } = require('child_process');
6
7
  // spawn() here is the WSL bridge from ./wsl: same signature as
7
8
  // child_process.spawn, and a straight pass-through unless the resolved CLI
@@ -10,8 +11,19 @@ const { spawn } = require('./wsl');
10
11
  const os = require('os');
11
12
  const { WorkspaceClient } = require('./workspace-client');
12
13
  const { listEndpointModels } = require('./model-list');
13
- const { AUTH_MODE_KEY, isCliLogin, stripForCliLogin, typeEnvFor } = require('./env');
14
+ const { supportsCliModels } = require('./cli-models');
15
+ const { AUTH_MODE_KEY, isCliLogin, modelKeys, stripForCliLogin, typeEnvFor } = require('./env');
14
16
  const { getEnhancedEnv, whichBinary, IS_WINDOWS, defaultAgentWorkdir } = require('./paths');
17
+ const { LogWriter } = require('./log-writer');
18
+
19
+ // A signed-in agent's model list is asked for again after this long. What a
20
+ // CLI is offered changes when the CLI is upgraded (a vendor holds new models
21
+ // back from old versions), so the upgrade should show within the hour.
22
+ const CLI_MODELS_MAX_AGE_MS = 3600 * 1000;
23
+ // A sign-in whose CLI has never answered is asked again sooner: the first ask
24
+ // may have failed for a passing reason (a slow start, an update mid-way). A
25
+ // little under the 10-minute sweep, so the next pass retries it.
26
+ const CLI_MODELS_RETRY_MS = 9 * 60 * 1000;
15
27
 
16
28
  /**
17
29
  * Mask an API key for display (same shape the workspace backend uses for
@@ -110,7 +122,7 @@ class Daemon {
110
122
  this._cmdInterval = null;
111
123
  this._nodeHeartbeatInterval = null; // device-level heartbeat (connect-a-node)
112
124
  this._displayNameInterval = null; // pulls workspace-side renames back
113
- this._nodeClients = new Map(); // workspace_id -> WorkspaceClient, one per pairing
125
+ this._nodeClients = new Map(); // node_id -> WorkspaceClient, one per private connection
114
126
  this._runningCommands = new Set(); // node-command ids currently executing
115
127
  this._runtimes = []; // detected agent runtimes for the node view
116
128
  this._runtimesInterval = null;
@@ -118,6 +130,10 @@ class Daemon {
118
130
  this._probeInterval = null;
119
131
  this._probeStartupTimer = null;
120
132
  this._probeInFlight = new Set(); // types being probed right now
133
+ this._cliModels = {}; // sign-in key → { models, askedAt } from the agents' own CLI
134
+ this._cliModelsInFlight = new Set(); // sign-in keys whose CLI is being asked right now
135
+ this._cliModelsStartupTimer = null;
136
+ this._cliModelsInterval = null;
121
137
  this._reloadInFlight = null; // serialize concurrent _reload() calls
122
138
  }
123
139
 
@@ -198,6 +214,98 @@ class Daemon {
198
214
  this._nodeHeartbeat();
199
215
  }
200
216
 
217
+ // ── Models a signed-in agent's account is offered ──────────────────────
218
+ // An agent on a subscription sign-in has no endpoint whose models can be
219
+ // listed, so its own CLI is asked (see cli-models.js). The answer rides the
220
+ // heartbeat's roster as agents[].cliModels, which puts it in front of the
221
+ // workspace's model picker without a command round trip. Kept in memory
222
+ // only: asking again after a restart costs a CLI start, not a model call.
223
+
224
+ /**
225
+ * Whether this agent runs on its CLI's own sign-in: a type whose CLI can
226
+ * say what the account is offered, calling that CLI's default endpoint,
227
+ * with no key the CLI would be handed instead.
228
+ */
229
+ _onCliSignIn(a) {
230
+ if (!supportsCliModels(a.type)) return false;
231
+ const endpoint = this._agentEndpoint(a);
232
+ return !endpoint.baseUrl && !endpoint.apiKey;
233
+ }
234
+
235
+ /**
236
+ * One key per sign-in. Agents of a type run one CLI on one account unless
237
+ * their own env says otherwise (a token or a config dir of their own), so
238
+ * those with the same env share an answer: a device with nine Claude agents
239
+ * starts the CLI once, not nine times. The model each runs is left out, it
240
+ * does not change what the account is offered. Hashed, since that env can
241
+ * hold a token.
242
+ */
243
+ _cliModelsKey(a) {
244
+ const skip = modelKeys(a.type, this.registry);
245
+ const own = Object.entries(a.env || {})
246
+ .filter(([k]) => !skip.has(k))
247
+ .sort(([x], [y]) => (x < y ? -1 : x > y ? 1 : 0));
248
+ return crypto.createHash('sha256').update(JSON.stringify([a.type, own])).digest('hex');
249
+ }
250
+
251
+ /**
252
+ * Ask an agent's CLI for its models via a child `agn models <name> --json`
253
+ * (off the event loop, and on that agent's own env, like a probe).
254
+ * Resolves true when the list it keeps for that sign-in changed.
255
+ */
256
+ async _listCliModels(a) {
257
+ const key = this._cliModelsKey(a);
258
+ if (this._cliModelsInFlight.has(key)) return false;
259
+ this._cliModelsInFlight.add(key);
260
+ try {
261
+ const r = await this._runAgn(['models', a.name, '--json']);
262
+ let parsed = null;
263
+ try { parsed = JSON.parse(r.stdout.trim()); } catch {}
264
+ const models = parsed && parsed.ok && Array.isArray(parsed.models) ? parsed.models : [];
265
+ const last = this._cliModels[key];
266
+ // A CLI that would not answer keeps the list it last gave: that is
267
+ // still the best answer there is.
268
+ const kept = models.length ? models : ((last && last.models) || null);
269
+ this._cliModels[key] = { models: kept, askedAt: Date.now() };
270
+ return JSON.stringify(kept) !== JSON.stringify((last && last.models) || null);
271
+ } finally {
272
+ this._cliModelsInFlight.delete(key);
273
+ }
274
+ }
275
+
276
+ /** Ask in the background for an agent that was just created or reconfigured. */
277
+ _askCliModels(name) {
278
+ let agent = null;
279
+ try { agent = this.config.getAgent(name); } catch {}
280
+ if (!agent || !this._onCliSignIn(agent)) return;
281
+ // A changed list goes out on a heartbeat now, not on the next tick.
282
+ this._listCliModels(agent)
283
+ .then((changed) => (changed ? this._nodeHeartbeat() : undefined))
284
+ .catch(() => {});
285
+ }
286
+
287
+ /**
288
+ * Periodic sweep: ask for every sign-in whose list is missing or has aged
289
+ * out, and forget the ones no agent is on any more. Sequential, like the
290
+ * probe sweep: there is no hurry.
291
+ */
292
+ async _refreshCliModels() {
293
+ const signIns = new Set();
294
+ for (const a of this.config.getAgents()) {
295
+ if (!a.network || !this._onCliSignIn(a)) continue;
296
+ const key = this._cliModelsKey(a);
297
+ if (signIns.has(key)) continue;
298
+ signIns.add(key);
299
+ const last = this._cliModels[key];
300
+ const maxAge = last && last.models ? CLI_MODELS_MAX_AGE_MS : CLI_MODELS_RETRY_MS;
301
+ if (last && Date.now() - last.askedAt < maxAge) continue;
302
+ try { await this._listCliModels(a); } catch {}
303
+ }
304
+ for (const key of Object.keys(this._cliModels)) {
305
+ if (!signIns.has(key)) delete this._cliModels[key];
306
+ }
307
+ }
308
+
201
309
  /**
202
310
  * Heartbeat this device (node) to EVERY workspace it is paired to,
203
311
  * independent of any agent, so each workspace's "connected devices" view
@@ -239,7 +347,7 @@ class Daemon {
239
347
  if (!info || !info.networkRef || info._credRestartPending) continue;
240
348
  if (!this._adapters || !this._adapters[name]) continue;
241
349
  let fresh = null;
242
- try { fresh = this._resolveAgentNetwork(info.networkRef); } catch { /* keep running on the current token */ }
350
+ try { fresh = this._resolveAgentNetwork(info.networkRef, info.connectionId); } catch { /* keep running on the current token */ }
243
351
  if (!fresh || !fresh.token || fresh.token === info.credentialToken) continue;
244
352
  // Flag on the CURRENT info object: restartAgent replaces it with a fresh
245
353
  // one on relaunch, so the flag cannot wedge the watch permanently.
@@ -247,17 +355,17 @@ class Daemon {
247
355
  this._log(`${name}: workspace credential for '${info.networkRef}' rotated (device re-paired) — restarting to pick up the new token`);
248
356
  this.restartAgent(name).catch((e) => {
249
357
  info._credRestartPending = false;
250
- this._log(`${name}: credential-rotation restart failed: ${e.message}`);
358
+ this._log(`${name}: credential-rotation restart failed: ${e.message}`, 'warn');
251
359
  });
252
360
  }
253
361
  }
254
362
 
255
363
  /** The WorkspaceClient for one pairing, created on first use and reused. */
256
364
  _nodeClientFor(n) {
257
- let client = this._nodeClients.get(n.workspace_id);
365
+ let client = this._nodeClients.get(n.node_id);
258
366
  if (!client) {
259
367
  client = new WorkspaceClient(n.endpoint);
260
- this._nodeClients.set(n.workspace_id, client);
368
+ this._nodeClients.set(n.node_id, client);
261
369
  }
262
370
  return client;
263
371
  }
@@ -281,8 +389,8 @@ class Daemon {
281
389
  // is unreachable, and taking that as the workspace's word unpaired every
282
390
  // workspace on the device at once.
283
391
  if (_nodeGone(err)) {
284
- try { nodeCfg.clearPairing(n.workspace_id); } catch {}
285
- this._nodeClients.delete(n.workspace_id);
392
+ try { nodeCfg.clearPairing(n.workspace_id, n.node_id); } catch {}
393
+ this._nodeClients.delete(n.node_id);
286
394
  this._log(`node ${n.node_id} no longer recognized by workspace ${n.workspace_slug || n.workspace_id} — pairing cleared`);
287
395
  }
288
396
  return; // nothing more to do for this pairing this tick
@@ -351,7 +459,13 @@ class Daemon {
351
459
  */
352
460
  _agentOnNodeWorkspace(a, node) {
353
461
  if (!a || !a.network || !node) return false;
354
- return a.network === node.workspace_slug || a.network === node.workspace_id;
462
+ if (a.network !== node.workspace_slug && a.network !== node.workspace_id) return false;
463
+ if (a.node_id) return a.node_id === node.node_id;
464
+ // Legacy agents may bind automatically only when the workspace is unambiguous.
465
+ const candidates = require('./node-config').listPairings().filter(
466
+ (p) => p.workspace_id === node.workspace_id && p.workspace_id,
467
+ );
468
+ return candidates.length <= 1;
355
469
  }
356
470
 
357
471
  /**
@@ -381,6 +495,9 @@ class Daemon {
381
495
  try { typeEnv = typeEnvFor(a.type, this.envManager.load(a.type) || {}, this.registry, a.env); } catch {}
382
496
  const model = (a.env && a.env.LLM_MODEL) || typeEnv.LLM_MODEL || null;
383
497
  const apiKey = (a.env && a.env.LLM_API_KEY) || typeEnv.LLM_API_KEY || null;
498
+ // What the agent's own CLI says its signed-in account can run. Null
499
+ // for an agent on a key or a relay, and until its CLI has answered.
500
+ const listed = supportsCliModels(a.type) ? this._cliModels[this._cliModelsKey(a)] : null;
384
501
  roster.push({
385
502
  name: a.name,
386
503
  type: a.type || 'unknown',
@@ -389,6 +506,7 @@ class Daemon {
389
506
  workingDir: a.path || null,
390
507
  apiKeyMasked: apiKey ? maskApiKey(apiKey) : null,
391
508
  baseUrlHost: endpointHost(this._configuredBaseUrl(a, typeEnv)),
509
+ cliModels: listed && listed.models && this._onCliSignIn(a) ? listed.models : null,
392
510
  probe: this._probes[a.name] || null,
393
511
  });
394
512
  }
@@ -583,7 +701,7 @@ class Daemon {
583
701
  }
584
702
  for (const [key, value] of Object.entries(config)) {
585
703
  if (!allowed.has(key)) {
586
- this._log(`config for '${type}': refused non-env_config key '${key}'`);
704
+ this._log(`config for '${type}': refused non-env_config key '${key}'`, 'warn');
587
705
  continue;
588
706
  }
589
707
  if (value === null || value === undefined) continue;
@@ -634,7 +752,7 @@ async _runNodeCommand(n, cmd) {
634
752
  // pass and no /v1/token/resolve round-trip to fail (the outage class
635
753
  // that used to break remote installs).
636
754
  const r2 = await this._runAgn([
637
- 'connect', name, '--workspace', n.workspace_slug || n.workspace_id,
755
+ 'connect', name, '--workspace', n.workspace_slug || n.workspace_id, '--connection', n.node_id,
638
756
  ]);
639
757
  if (r2.code !== 0) throw new Error(r2.stderr || r2.stdout || 'connect failed');
640
758
  ok = true;
@@ -643,6 +761,7 @@ async _runNodeCommand(n, cmd) {
643
761
  // full timeout — never hold the create result hostage). The outcome
644
762
  // reaches the workspace via the heartbeat's agents[].probe.
645
763
  this._probeAgent(name).then(() => this._nodeHeartbeat()).catch(() => {});
764
+ this._askCliModels(name);
646
765
  } else if (action === 'start_agent') {
647
766
  const r = await this._runAgn(['start', name]);
648
767
  ok = r.code === 0;
@@ -673,7 +792,7 @@ async _runNodeCommand(n, cmd) {
673
792
  if (rc.code !== 0) throw new Error(rc.stderr || rc.stdout || 'recreate failed');
674
793
  this._saveNodeAgentEnv(name, type, args);
675
794
  const rc2 = await this._runAgn([
676
- 'connect', name, '--workspace', n.workspace_slug || n.workspace_id,
795
+ 'connect', name, '--workspace', n.workspace_slug || n.workspace_id, '--connection', n.node_id,
677
796
  ]);
678
797
  if (rc2.code !== 0) throw new Error(rc2.stderr || rc2.stdout || 'connect failed');
679
798
  } else {
@@ -689,6 +808,7 @@ async _runNodeCommand(n, cmd) {
689
808
  // A reconfigure changes credentials/model — re-verify in the
690
809
  // background so the workspace sees the new state without a manual test.
691
810
  this._probeAgent(name).then(() => this._nodeHeartbeat()).catch(() => {});
811
+ this._askCliModels(name);
692
812
  } else if (action === 'probe_agent') {
693
813
  // Smoke-test on demand ("Re-test" in the workspace). Probes belong to
694
814
  // configured agents (by name); the result rides agents[].probe.
@@ -805,11 +925,11 @@ async _runNodeCommand(n, cmd) {
805
925
  this._crashGuardsInstalled = true;
806
926
  process.on('unhandledRejection', (reason) => {
807
927
  const msg = reason && reason.stack ? reason.stack : String(reason);
808
- this._log(`UNHANDLED REJECTION (daemon kept alive): ${msg}`);
928
+ this._log(`UNHANDLED REJECTION (daemon kept alive): ${msg}`, 'error');
809
929
  });
810
930
  process.on('uncaughtException', (err) => {
811
931
  const msg = err && err.stack ? err.stack : String(err);
812
- this._log(`UNCAUGHT EXCEPTION (daemon kept alive): ${msg}`);
932
+ this._log(`UNCAUGHT EXCEPTION (daemon kept alive): ${msg}`, 'error');
813
933
  });
814
934
  }
815
935
 
@@ -877,6 +997,19 @@ async _runNodeCommand(n, cmd) {
877
997
  3600 * 1000,
878
998
  );
879
999
 
1000
+ // What each signed-in agent's account is offered, for the workspace's
1001
+ // model picker. Asking costs a CLI start and no model call. The pass is
1002
+ // frequent so an agent added on the device is picked up soon; it only
1003
+ // asks for a list that is missing or an hour old.
1004
+ this._cliModelsStartupTimer = setTimeout(
1005
+ () => this._refreshCliModels().catch(() => {}),
1006
+ 20 * 1000,
1007
+ );
1008
+ this._cliModelsInterval = setInterval(
1009
+ () => this._refreshCliModels().catch(() => {}),
1010
+ 10 * 60 * 1000,
1011
+ );
1012
+
880
1013
  // Watch config file for hot-reload
881
1014
  this._watchConfig();
882
1015
 
@@ -908,6 +1041,8 @@ async _runNodeCommand(n, cmd) {
908
1041
  if (this._displayNameInterval) clearInterval(this._displayNameInterval);
909
1042
  if (this._probeStartupTimer) clearTimeout(this._probeStartupTimer);
910
1043
  if (this._probeInterval) clearInterval(this._probeInterval);
1044
+ if (this._cliModelsStartupTimer) clearTimeout(this._cliModelsStartupTimer);
1045
+ if (this._cliModelsInterval) clearInterval(this._cliModelsInterval);
911
1046
  if (this._configWatcher) { try { this._configWatcher.close(); } catch {} }
912
1047
 
913
1048
  // Kill all child processes
@@ -919,6 +1054,7 @@ async _runNodeCommand(n, cmd) {
919
1054
  this._writeStatus();
920
1055
  this._cleanupPid();
921
1056
  this._log('Daemon stopped');
1057
+ if (this._logWriter) await this._logWriter.flush();
922
1058
 
923
1059
  if (this._shutdownResolve) this._shutdownResolve();
924
1060
  }
@@ -947,7 +1083,7 @@ async _runNodeCommand(n, cmd) {
947
1083
  // a hung adapter.run() promise prevents the slot from ever being
948
1084
  // released, and subsequent start/restart commands see "already running".
949
1085
  if (this._adapters && this._adapters[agentName]) {
950
- this._log(`WARNING: ${agentName} adapter did not exit after stop — force-releasing slot`);
1086
+ this._log(`WARNING: ${agentName} adapter did not exit after stop — force-releasing slot`, 'warn');
951
1087
  try { this._adapters[agentName].stop(); } catch {}
952
1088
  delete this._adapters[agentName];
953
1089
  }
@@ -1162,15 +1298,18 @@ async _runNodeCommand(n, cmd) {
1162
1298
  * fallback. Fields from the network entry win for display (name), the
1163
1299
  * pairing wins for credentials (token/endpoint).
1164
1300
  */
1165
- _resolveAgentNetwork(ref) {
1301
+ _resolveAgentNetwork(ref, nodeId) {
1166
1302
  const network =
1167
1303
  this.config.getNetworks().find((n) => n.slug === ref || n.id === ref) ||
1168
1304
  null;
1169
1305
  let pairing = null;
1170
1306
  try {
1171
- pairing = require('./node-config')
1172
- .listPairings()
1173
- .find((p) => p.workspace_slug === ref || p.workspace_id === ref) || null;
1307
+ const candidates = require('./node-config').listPairings()
1308
+ .filter((p) => p.workspace_slug === ref || p.workspace_id === ref);
1309
+ pairing = candidates.find((p) => !nodeId || p.node_id === nodeId) || null;
1310
+ if ((nodeId && !pairing) || (!nodeId && candidates.length > 1)) {
1311
+ return { ...(network || {}), token: null };
1312
+ }
1174
1313
  } catch {}
1175
1314
  if (!network && !pairing) return null;
1176
1315
  return {
@@ -1214,7 +1353,7 @@ async _runNodeCommand(n, cmd) {
1214
1353
  // Workspace-connected agents use the adapter loop (poll + CLI per message).
1215
1354
  // Local-only agents use the spawn loop (long-running child process).
1216
1355
  const network = agentCfg.network
1217
- ? this._resolveAgentNetwork(agentCfg.network)
1356
+ ? this._resolveAgentNetwork(agentCfg.network, agentCfg.node_id)
1218
1357
  : null;
1219
1358
 
1220
1359
  if (network && !network.token) {
@@ -1233,6 +1372,7 @@ async _runNodeCommand(n, cmd) {
1233
1372
  // Remember which credential this adapter runs with, so the rotation
1234
1373
  // watch can restart it when a re-pair hands the workspace a new token.
1235
1374
  info.networkRef = agentCfg.network;
1375
+ info.connectionId = agentCfg.node_id;
1236
1376
  info.credentialToken = network.token;
1237
1377
  this._adapterLoop(name, agentCfg, info, network);
1238
1378
  } else {
@@ -1282,7 +1422,7 @@ async _runNodeCommand(n, cmd) {
1282
1422
  const exitCode = await new Promise((resolve) => {
1283
1423
  proc.on('exit', (code) => resolve(code));
1284
1424
  proc.on('error', (err) => {
1285
- this._log(`${name} spawn error: ${err.message}`);
1425
+ this._log(`${name} spawn error: ${err.message}`, 'error');
1286
1426
  resolve(1);
1287
1427
  });
1288
1428
  });
@@ -1309,13 +1449,13 @@ async _runNodeCommand(n, cmd) {
1309
1449
  this._writeStatus();
1310
1450
 
1311
1451
  if (info.restarts >= 10) {
1312
- this._log(`${name} crashed ${info.restarts} times, giving up. Fix the issue and restart manually.`);
1452
+ this._log(`${name} crashed ${info.restarts} times, giving up. Fix the issue and restart manually.`, 'error');
1313
1453
  info.state = 'stopped';
1314
1454
  this._writeStatus();
1315
1455
  break;
1316
1456
  }
1317
1457
 
1318
- this._log(`${name} crashed: ${info.lastError}, restarting in ${info._backoff}s (attempt ${info.restarts})`);
1458
+ this._log(`${name} crashed: ${info.lastError}, restarting in ${info._backoff}s (attempt ${info.restarts})`, 'warn');
1319
1459
  await this._sleep(info._backoff * 1000);
1320
1460
  info._backoff = Math.min(info._backoff * 2, 60);
1321
1461
  }
@@ -1384,6 +1524,7 @@ async _runNodeCommand(n, cmd) {
1384
1524
  toolMode: agentCfg.tool_mode || 'skills',
1385
1525
  // Live runtime/connectivity status → daemon.status.json (Agents list/TUI).
1386
1526
  onStatus: (update) => this._applyAdapterStatus(name, info, update),
1527
+ logSink: (level, msg) => this._logAs(`adapter [${name}]`, level, msg),
1387
1528
  });
1388
1529
  } catch (e) {
1389
1530
  // A construction failure (e.g. unknown agent type in this core) is a hard
@@ -1391,7 +1532,7 @@ async _runNodeCommand(n, cmd) {
1391
1532
  info.state = 'error';
1392
1533
  info.errorReason = 'adapter_crashed';
1393
1534
  info.lastError = redactDiagnostic(e.message || String(e));
1394
- this._log(`${name} failed to create ${agentType} adapter: ${info.lastError}`);
1535
+ this._log(`${name} failed to create ${agentType} adapter: ${info.lastError}`, 'error');
1395
1536
  this._writeStatus();
1396
1537
  return;
1397
1538
  }
@@ -1408,11 +1549,11 @@ async _runNodeCommand(n, cmd) {
1408
1549
  info.errorReason = pf.reason || 'runtime_missing';
1409
1550
  info.lastError = redactDiagnostic(pf.message || 'runtime not available');
1410
1551
  this._writeStatus();
1411
- this._log(`${name} preflight failed (${info.errorReason}): ${info.lastError}`);
1552
+ this._log(`${name} preflight failed (${info.errorReason}): ${info.lastError}`, 'warn');
1412
1553
  return;
1413
1554
  }
1414
1555
  } catch (e) {
1415
- this._log(`${name} preflight error (ignored, continuing): ${e.message}`);
1556
+ this._log(`${name} preflight error (ignored, continuing): ${e.message}`, 'warn');
1416
1557
  }
1417
1558
 
1418
1559
  // Store adapter reference for stop and duplicate detection
@@ -1440,7 +1581,7 @@ async _runNodeCommand(n, cmd) {
1440
1581
  } catch (e) {
1441
1582
  info.errorReason = info.errorReason || 'adapter_crashed';
1442
1583
  info.lastError = redactDiagnostic((e.message || String(e)).slice(0, 200));
1443
- this._log(`${name} adapter error: ${info.lastError}`);
1584
+ this._log(`${name} adapter error: ${info.lastError}`, 'error');
1444
1585
  }
1445
1586
 
1446
1587
  delete this._adapters[name];
@@ -1756,7 +1897,7 @@ async _runNodeCommand(n, cmd) {
1756
1897
  // The old adapter will exit on its next poll iteration since its
1757
1898
  // entry in _stoppedAgents triggers adapter.stop() via checkStop.
1758
1899
  if (this._adapters && this._adapters[name]) {
1759
- this._log(`WARNING: adapter '${name}' did not clear after 10s — force-releasing slot to avoid duplicate`);
1900
+ this._log(`WARNING: adapter '${name}' did not clear after 10s — force-releasing slot to avoid duplicate`, 'warn');
1760
1901
  try { this._adapters[name].stop(); } catch {}
1761
1902
  delete this._adapters[name];
1762
1903
  }
@@ -1773,33 +1914,25 @@ async _runNodeCommand(n, cmd) {
1773
1914
  try { fs.unlinkSync(this.config.statusFile); } catch {}
1774
1915
  }
1775
1916
 
1776
- _log(msg) {
1777
- const ts = new Date().toISOString();
1778
- const line = `${ts} INFO daemon: ${msg}`;
1779
- try {
1780
- fs.appendFileSync(this.config.logFile, line + '\n', 'utf-8');
1781
- this._maybeRotateLog();
1782
- } catch {}
1917
+ _log(msg, level = 'info') {
1918
+ this._logAs('daemon', level, msg);
1919
+ }
1920
+
1921
+ /**
1922
+ * One entry in daemon.log. Adapters log through here too (see the `logSink`
1923
+ * passed in _adapterLoop), so batching, repeat suppression and rotation cover
1924
+ * their lines as well — they used to bypass all of it via console.log.
1925
+ */
1926
+ _logAs(scope, level, msg) {
1927
+ if (!this._logWriter) this._logWriter = new LogWriter(this.config.logFile);
1928
+ const written = this._logWriter.write(level, scope, msg);
1783
1929
  // Only log to console if stdout is a TTY (not redirected to log file)
1784
1930
  // to avoid duplicate lines when daemonized
1785
- if (!this._shuttingDown && process.stdout.isTTY) {
1786
- console.log(line);
1931
+ if (written && !this._shuttingDown && process.stdout.isTTY) {
1932
+ console.log(`${new Date().toISOString()} ${String(level).toUpperCase()} ${scope}: ${msg}`);
1787
1933
  }
1788
1934
  }
1789
1935
 
1790
- _maybeRotateLog() {
1791
- // Rotate at 10MB, keep 1 backup
1792
- const MAX_SIZE = 10 * 1024 * 1024;
1793
- try {
1794
- const stat = fs.statSync(this.config.logFile);
1795
- if (stat.size > MAX_SIZE) {
1796
- const backup = this.config.logFile + '.1';
1797
- try { fs.unlinkSync(backup); } catch {}
1798
- fs.renameSync(this.config.logFile, backup);
1799
- }
1800
- } catch {}
1801
- }
1802
-
1803
1936
  _sleep(ms) {
1804
1937
  return new Promise((resolve) => setTimeout(resolve, ms));
1805
1938
  }
package/src/index.js CHANGED
@@ -121,7 +121,7 @@ class AgentConnector {
121
121
  if (!network || !network.id) return null;
122
122
  const endpoint = network.endpoint || (this.workspace && this.workspace.endpoint);
123
123
  const { WorkspaceClient } = require('./workspace-client');
124
- return { client: new WorkspaceClient(endpoint), network };
124
+ return { client: new WorkspaceClient(endpoint, { agentName }), network };
125
125
  }
126
126
 
127
127
  /**
@@ -431,6 +431,15 @@ class AgentConnector {
431
431
  const { probeAgentType } = require('./probe');
432
432
  return probeAgentType(this, agentType, opts);
433
433
  }
434
+
435
+ /**
436
+ * Ask an agent type's own CLI which models the account it is signed in to
437
+ * can run. See cli-models.js.
438
+ */
439
+ async listCliModels(agentType, opts) {
440
+ const { listCliModels } = require('./cli-models');
441
+ return listCliModels(this, agentType, opts);
442
+ }
434
443
  }
435
444
 
436
445
  const adapters = require('./adapters');