@openagents-org/agent-launcher 0.2.193 → 0.2.195

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/cli.js CHANGED
@@ -217,7 +217,7 @@ async function cmdCreate(connector, flags, positional) {
217
217
  }
218
218
  } else {
219
219
  print(` agn connect ${name} <workspace-token>`);
220
- print('Or pair this device first: agn node connect <pairing-code>');
220
+ print('Or pair this device first: agn device connect <pairing-code>');
221
221
  }
222
222
  } else {
223
223
  print(`Agent '${name}' created (type: ${type})`);
@@ -441,7 +441,7 @@ async function cmdConnect(connector, flags, positional) {
441
441
  // no server round-trip; the credential is already on disk. This is the
442
442
  // pairing-first path; the token form below stays for manual connection.
443
443
  if (flags.workspace && typeof flags.workspace === 'string') {
444
- connectKnownWorkspace(connector, name, flags.workspace);
444
+ connectKnownWorkspace(connector, name, flags.workspace, flags.connection);
445
445
  return;
446
446
  }
447
447
 
@@ -480,7 +480,7 @@ async function cmdConnect(connector, flags, positional) {
480
480
  // CLI half of the dismissible launcher notice.
481
481
  if (!process.env.OPENAGENTS_NO_DEPRECATION_NOTES) {
482
482
  print('note: manual token connection is being retired in favor of device pairing.');
483
- print(' Pair once with `agn node connect <code>`, then `agn connect <agent> --workspace <slug>`.');
483
+ print(' Pair once with `agn device connect <code>`, then `agn connect <agent> --workspace <slug>`.');
484
484
  print(' (Silence this note with OPENAGENTS_NO_DEPRECATION_NOTES=1.)');
485
485
  }
486
486
 
@@ -504,7 +504,12 @@ async function cmdConnect(connector, flags, positional) {
504
504
  * (node.json) — a pairing registers its network on the spot so the daemon can
505
505
  * pick up the token. Never touches the server.
506
506
  */
507
- function connectKnownWorkspace(connector, name, ref) {
507
+ function connectKnownWorkspace(connector, name, ref, connectionId) {
508
+ const matches = require('./node-config').listPairings().filter((p) => p.workspace_slug === ref || p.workspace_id === ref);
509
+ if ((!connectionId && matches.length > 1) || (connectionId && !matches.some((p) => p.node_id === connectionId))) {
510
+ throw new Error("Choose a device connection with --connection <id>");
511
+ }
512
+ const chosen = matches.find((p) => !connectionId || p.node_id === connectionId);
508
513
  const networks = connector.config.getNetworks() || [];
509
514
  let net = networks.find((n) => n.slug === ref || n.id === ref);
510
515
 
@@ -538,13 +543,14 @@ function connectKnownWorkspace(connector, name, ref) {
538
543
  ].filter(Boolean);
539
544
  print(`No workspace '${ref}' is known on this device.`);
540
545
  if (known.length) print(`Known workspaces: ${known.join(', ')}`);
541
- print('Pair this device first (`agn node connect <code>`), or connect with a token.');
546
+ print('Pair this device first (`agn device connect <code>`), or connect with a token.');
542
547
  process.exitCode = 1;
543
548
  return;
544
549
  }
545
550
 
546
551
  const slug = net.slug || ref;
547
552
  connector.connectWorkspace(name, slug);
553
+ if (chosen) connector.config.updateAgent(name, { node_id: chosen.node_id });
548
554
  print(`'${name}' connected to workspace '${net.name || slug}'`);
549
555
 
550
556
  const pid = connector.getDaemonPid();
@@ -570,6 +576,11 @@ function workspaceWebUrl(slug, endpoint) {
570
576
  : `https://workspace.openagents.org/${slug}`;
571
577
  }
572
578
 
579
+ /**
580
+ * `agn device <connect|status>` — pair this device (formerly "node") to a
581
+ * workspace. `agn node`, `agn devices` and `agn nodes` are aliases so existing
582
+ * scripts and older docs keep working; the product wording is "device".
583
+ */
573
584
  async function cmdNode(connector, flags, positional) {
574
585
  const sub = positional[0] || 'status';
575
586
  const nodeCfg = require('./node-config');
@@ -577,8 +588,8 @@ async function cmdNode(connector, flags, positional) {
577
588
  if (sub === 'connect') {
578
589
  const code = positional[1] || flags.code;
579
590
  if (!code) {
580
- print('Usage: agn node connect <pairing-code>');
581
- print('Get a pairing code from your workspace: Connect a Node.');
591
+ print('Usage: agn device connect <pairing-code>');
592
+ print('Get a pairing code from your workspace: Devices → Connect a device.');
582
593
  process.exitCode = 1;
583
594
  return;
584
595
  }
@@ -616,12 +627,19 @@ async function cmdNode(connector, flags, positional) {
616
627
  // credential in place — but the user should hear "already paired", not
617
628
  // a duplicate "connected".
618
629
  const alreadyPaired = priorPairings.some(
619
- (p) => p.workspace_id === res.workspaceId,
630
+ (p) => p.node_id === res.nodeId,
620
631
  );
621
632
  // recordPairing, not saveNode: this device can be a node in several
622
633
  // workspaces at once, and a bare save would drop the others' pairings.
634
+ // Freeze legacy bindings before another owner adds a connection here.
635
+ const prior = priorPairings.filter((p) => p.workspace_id === res.workspaceId);
636
+ if (prior.length === 1) for (const agent of connector.config.getAgents()) {
637
+ if (!agent.node_id && (agent.network === prior[0].workspace_slug || agent.network === prior[0].workspace_id))
638
+ connector.config.updateAgent(agent.name, { node_id: prior[0].node_id });
639
+ }
623
640
  nodeCfg.recordPairing(info.nodeKey, {
624
641
  node_id: res.nodeId,
642
+ owner_email: res.ownerEmail,
625
643
  workspace_id: res.workspaceId,
626
644
  workspace_slug: res.workspaceSlug,
627
645
  workspace_name: res.workspaceName,
@@ -639,7 +657,7 @@ async function cmdNode(connector, flags, positional) {
639
657
  if (alreadyPaired) {
640
658
  print(`Already paired with '${res.workspaceName}' (${res.workspaceSlug}) — refreshed this device's credential.`);
641
659
  } else {
642
- print(`Node connected to workspace '${res.workspaceName}' (${res.workspaceSlug})`);
660
+ print(`Device connected to workspace '${res.workspaceName}' (${res.workspaceSlug})`);
643
661
  }
644
662
  print(` This device: ${info.hostname} (${info.deviceType})`);
645
663
 
@@ -672,10 +690,10 @@ async function cmdNode(connector, flags, positional) {
672
690
  if (sub === 'status') {
673
691
  const pairings = nodeCfg.listPairings().filter((p) => p.node_id);
674
692
  if (!pairings.length) {
675
- print('No node connected. Run: agn node connect <pairing-code>');
693
+ print('No device connected. Run: agn device connect <pairing-code>');
676
694
  return;
677
695
  }
678
- print(`Node connected to ${pairings.length} workspace(s):`);
696
+ print(`Device connected to ${pairings.length} workspace(s):`);
679
697
  for (const p of pairings) {
680
698
  const slug = p.workspace_slug || p.workspace_id;
681
699
  print(` ${p.workspace_name || slug} (${slug}) — ${workspaceWebUrl(slug, p.endpoint)}`);
@@ -683,7 +701,7 @@ async function cmdNode(connector, flags, positional) {
683
701
  return;
684
702
  }
685
703
 
686
- print('Usage: agn node <connect|status> [pairing-code]');
704
+ print('Usage: agn device <connect|status> [pairing-code] (alias: agn node)');
687
705
  process.exitCode = 1;
688
706
  }
689
707
 
@@ -1024,6 +1042,37 @@ async function cmdProbe(connector, flags, positional) {
1024
1042
  }
1025
1043
  }
1026
1044
 
1045
+ async function cmdModels(connector, flags, positional) {
1046
+ const target = positional[0];
1047
+ if (!target) { print('Usage: agn models <type|agent> [--json]'); return; }
1048
+
1049
+ // Accept an agent name, as `agn probe` does: its CLI is asked on that
1050
+ // agent's own env, so a signed-in agent answers for its sign-in.
1051
+ let type = target;
1052
+ let agentEnv;
1053
+ try {
1054
+ const agent = connector.config.getAgent(target);
1055
+ if (agent) { type = agent.type || 'openclaw'; agentEnv = agent.env || {}; }
1056
+ } catch {}
1057
+
1058
+ const result = await connector.listCliModels(type, { agentEnv });
1059
+
1060
+ if (flags && flags.json) {
1061
+ // Machine consumers (the daemon's model sweep) read ONLY stdout JSON —
1062
+ // same contract as `agn probe --json`.
1063
+ process.stdout.write(JSON.stringify(result));
1064
+ if (!result.ok) process.exitCode = 1;
1065
+ return;
1066
+ }
1067
+
1068
+ if (!result.ok) {
1069
+ print(`FAILED ${result.error || ''}`.trim());
1070
+ process.exitCode = 1;
1071
+ return;
1072
+ }
1073
+ for (const m of result.models) print(m.label === m.id ? m.id : `${m.id} ${m.label}`);
1074
+ }
1075
+
1027
1076
  async function cmdVersion() {
1028
1077
  const pkg = require('../package.json');
1029
1078
  print(`${pkg.name} v${pkg.version}`);
@@ -1086,6 +1135,8 @@ Commands:
1086
1135
  uninstall <type> Uninstall an agent runtime
1087
1136
  search [query] Browse agent catalog
1088
1137
  runtimes List installed runtimes
1138
+ device connect <code> Pair this device (formerly "node") to a workspace with a pairing code
1139
+ device status Show the workspaces this device is paired with (alias: node)
1089
1140
  connect <agent> <token> Connect agent to workspace (or --workspace <slug> for a paired one)
1090
1141
  disconnect <agent> Disconnect agent from workspace
1091
1142
  env <type> [--set K=V] View/set env vars for agent type
@@ -1094,6 +1145,7 @@ Commands:
1094
1145
  autostart [--disable] Enable/disable auto-start on login
1095
1146
  test-llm <type> Test LLM connection
1096
1147
  probe <type|agent> [--json] Smoke-test an agent (tiny end-to-end prompt)
1148
+ models <type|agent> [--json] List the models the agent's CLI offers its signed-in account
1097
1149
  logs [agent] [--lines N] View daemon logs
1098
1150
  workspace create [name] Create a new workspace
1099
1151
  workspace join <token> Join workspace with token
@@ -1173,7 +1225,10 @@ async function main() {
1173
1225
  search: () => cmdSearch(connector, flags, positional),
1174
1226
  runtimes: () => cmdRuntimes(connector, flags),
1175
1227
  connect: () => cmdConnect(connector, flags, positional),
1176
- node: () => cmdNode(connector, flags, positional),
1228
+ device: () => cmdNode(connector, flags, positional),
1229
+ devices: () => cmdNode(connector, flags, positional),
1230
+ node: () => cmdNode(connector, flags, positional), // legacy spelling
1231
+ nodes: () => cmdNode(connector, flags, positional), // legacy spelling
1177
1232
  disconnect: () => cmdDisconnect(connector, flags, positional),
1178
1233
  logs: () => cmdLogs(connector, flags, positional),
1179
1234
  autostart: () => cmdAutostart(connector, flags),
@@ -1183,6 +1238,7 @@ async function main() {
1183
1238
  'tool-mode': () => cmdToolMode(connector, flags, positional),
1184
1239
  'test-llm': () => cmdTestLLM(connector, flags, positional),
1185
1240
  probe: () => cmdProbe(connector, flags, positional),
1241
+ models: () => cmdModels(connector, flags, positional),
1186
1242
  update: () => cmdUpdate(connector),
1187
1243
  'mcp-server': () => {
1188
1244
  const { runMcpServer } = require('./mcp-server');
package/src/config.js CHANGED
@@ -110,6 +110,7 @@ class Config {
110
110
  agent.network = networkSlug;
111
111
  } else {
112
112
  delete agent.network;
113
+ delete agent.node_id;
113
114
  }
114
115
  this.save(config);
115
116
  }
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,10 +11,20 @@ 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');
15
17
  const { LogWriter } = require('./log-writer');
16
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;
27
+
17
28
  /**
18
29
  * Mask an API key for display (same shape the workspace backend uses for
19
30
  * cloud agents): enough to recognize which key it is, never enough to use it.
@@ -111,7 +122,7 @@ class Daemon {
111
122
  this._cmdInterval = null;
112
123
  this._nodeHeartbeatInterval = null; // device-level heartbeat (connect-a-node)
113
124
  this._displayNameInterval = null; // pulls workspace-side renames back
114
- this._nodeClients = new Map(); // workspace_id -> WorkspaceClient, one per pairing
125
+ this._nodeClients = new Map(); // node_id -> WorkspaceClient, one per private connection
115
126
  this._runningCommands = new Set(); // node-command ids currently executing
116
127
  this._runtimes = []; // detected agent runtimes for the node view
117
128
  this._runtimesInterval = null;
@@ -119,6 +130,10 @@ class Daemon {
119
130
  this._probeInterval = null;
120
131
  this._probeStartupTimer = null;
121
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;
122
137
  this._reloadInFlight = null; // serialize concurrent _reload() calls
123
138
  }
124
139
 
@@ -199,6 +214,98 @@ class Daemon {
199
214
  this._nodeHeartbeat();
200
215
  }
201
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
+
202
309
  /**
203
310
  * Heartbeat this device (node) to EVERY workspace it is paired to,
204
311
  * independent of any agent, so each workspace's "connected devices" view
@@ -240,7 +347,7 @@ class Daemon {
240
347
  if (!info || !info.networkRef || info._credRestartPending) continue;
241
348
  if (!this._adapters || !this._adapters[name]) continue;
242
349
  let fresh = null;
243
- 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 */ }
244
351
  if (!fresh || !fresh.token || fresh.token === info.credentialToken) continue;
245
352
  // Flag on the CURRENT info object: restartAgent replaces it with a fresh
246
353
  // one on relaunch, so the flag cannot wedge the watch permanently.
@@ -255,10 +362,10 @@ class Daemon {
255
362
 
256
363
  /** The WorkspaceClient for one pairing, created on first use and reused. */
257
364
  _nodeClientFor(n) {
258
- let client = this._nodeClients.get(n.workspace_id);
365
+ let client = this._nodeClients.get(n.node_id);
259
366
  if (!client) {
260
367
  client = new WorkspaceClient(n.endpoint);
261
- this._nodeClients.set(n.workspace_id, client);
368
+ this._nodeClients.set(n.node_id, client);
262
369
  }
263
370
  return client;
264
371
  }
@@ -282,8 +389,8 @@ class Daemon {
282
389
  // is unreachable, and taking that as the workspace's word unpaired every
283
390
  // workspace on the device at once.
284
391
  if (_nodeGone(err)) {
285
- try { nodeCfg.clearPairing(n.workspace_id); } catch {}
286
- this._nodeClients.delete(n.workspace_id);
392
+ try { nodeCfg.clearPairing(n.workspace_id, n.node_id); } catch {}
393
+ this._nodeClients.delete(n.node_id);
287
394
  this._log(`node ${n.node_id} no longer recognized by workspace ${n.workspace_slug || n.workspace_id} — pairing cleared`);
288
395
  }
289
396
  return; // nothing more to do for this pairing this tick
@@ -352,7 +459,13 @@ class Daemon {
352
459
  */
353
460
  _agentOnNodeWorkspace(a, node) {
354
461
  if (!a || !a.network || !node) return false;
355
- 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;
356
469
  }
357
470
 
358
471
  /**
@@ -382,6 +495,9 @@ class Daemon {
382
495
  try { typeEnv = typeEnvFor(a.type, this.envManager.load(a.type) || {}, this.registry, a.env); } catch {}
383
496
  const model = (a.env && a.env.LLM_MODEL) || typeEnv.LLM_MODEL || null;
384
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;
385
501
  roster.push({
386
502
  name: a.name,
387
503
  type: a.type || 'unknown',
@@ -390,6 +506,7 @@ class Daemon {
390
506
  workingDir: a.path || null,
391
507
  apiKeyMasked: apiKey ? maskApiKey(apiKey) : null,
392
508
  baseUrlHost: endpointHost(this._configuredBaseUrl(a, typeEnv)),
509
+ cliModels: listed && listed.models && this._onCliSignIn(a) ? listed.models : null,
393
510
  probe: this._probes[a.name] || null,
394
511
  });
395
512
  }
@@ -635,7 +752,7 @@ async _runNodeCommand(n, cmd) {
635
752
  // pass and no /v1/token/resolve round-trip to fail (the outage class
636
753
  // that used to break remote installs).
637
754
  const r2 = await this._runAgn([
638
- 'connect', name, '--workspace', n.workspace_slug || n.workspace_id,
755
+ 'connect', name, '--workspace', n.workspace_slug || n.workspace_id, '--connection', n.node_id,
639
756
  ]);
640
757
  if (r2.code !== 0) throw new Error(r2.stderr || r2.stdout || 'connect failed');
641
758
  ok = true;
@@ -644,6 +761,7 @@ async _runNodeCommand(n, cmd) {
644
761
  // full timeout — never hold the create result hostage). The outcome
645
762
  // reaches the workspace via the heartbeat's agents[].probe.
646
763
  this._probeAgent(name).then(() => this._nodeHeartbeat()).catch(() => {});
764
+ this._askCliModels(name);
647
765
  } else if (action === 'start_agent') {
648
766
  const r = await this._runAgn(['start', name]);
649
767
  ok = r.code === 0;
@@ -674,7 +792,7 @@ async _runNodeCommand(n, cmd) {
674
792
  if (rc.code !== 0) throw new Error(rc.stderr || rc.stdout || 'recreate failed');
675
793
  this._saveNodeAgentEnv(name, type, args);
676
794
  const rc2 = await this._runAgn([
677
- 'connect', name, '--workspace', n.workspace_slug || n.workspace_id,
795
+ 'connect', name, '--workspace', n.workspace_slug || n.workspace_id, '--connection', n.node_id,
678
796
  ]);
679
797
  if (rc2.code !== 0) throw new Error(rc2.stderr || rc2.stdout || 'connect failed');
680
798
  } else {
@@ -690,6 +808,7 @@ async _runNodeCommand(n, cmd) {
690
808
  // A reconfigure changes credentials/model — re-verify in the
691
809
  // background so the workspace sees the new state without a manual test.
692
810
  this._probeAgent(name).then(() => this._nodeHeartbeat()).catch(() => {});
811
+ this._askCliModels(name);
693
812
  } else if (action === 'probe_agent') {
694
813
  // Smoke-test on demand ("Re-test" in the workspace). Probes belong to
695
814
  // configured agents (by name); the result rides agents[].probe.
@@ -878,6 +997,19 @@ async _runNodeCommand(n, cmd) {
878
997
  3600 * 1000,
879
998
  );
880
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
+
881
1013
  // Watch config file for hot-reload
882
1014
  this._watchConfig();
883
1015
 
@@ -909,6 +1041,8 @@ async _runNodeCommand(n, cmd) {
909
1041
  if (this._displayNameInterval) clearInterval(this._displayNameInterval);
910
1042
  if (this._probeStartupTimer) clearTimeout(this._probeStartupTimer);
911
1043
  if (this._probeInterval) clearInterval(this._probeInterval);
1044
+ if (this._cliModelsStartupTimer) clearTimeout(this._cliModelsStartupTimer);
1045
+ if (this._cliModelsInterval) clearInterval(this._cliModelsInterval);
912
1046
  if (this._configWatcher) { try { this._configWatcher.close(); } catch {} }
913
1047
 
914
1048
  // Kill all child processes
@@ -1164,15 +1298,18 @@ async _runNodeCommand(n, cmd) {
1164
1298
  * fallback. Fields from the network entry win for display (name), the
1165
1299
  * pairing wins for credentials (token/endpoint).
1166
1300
  */
1167
- _resolveAgentNetwork(ref) {
1301
+ _resolveAgentNetwork(ref, nodeId) {
1168
1302
  const network =
1169
1303
  this.config.getNetworks().find((n) => n.slug === ref || n.id === ref) ||
1170
1304
  null;
1171
1305
  let pairing = null;
1172
1306
  try {
1173
- pairing = require('./node-config')
1174
- .listPairings()
1175
- .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
+ }
1176
1313
  } catch {}
1177
1314
  if (!network && !pairing) return null;
1178
1315
  return {
@@ -1216,7 +1353,7 @@ async _runNodeCommand(n, cmd) {
1216
1353
  // Workspace-connected agents use the adapter loop (poll + CLI per message).
1217
1354
  // Local-only agents use the spawn loop (long-running child process).
1218
1355
  const network = agentCfg.network
1219
- ? this._resolveAgentNetwork(agentCfg.network)
1356
+ ? this._resolveAgentNetwork(agentCfg.network, agentCfg.node_id)
1220
1357
  : null;
1221
1358
 
1222
1359
  if (network && !network.token) {
@@ -1235,6 +1372,7 @@ async _runNodeCommand(n, cmd) {
1235
1372
  // Remember which credential this adapter runs with, so the rotation
1236
1373
  // watch can restart it when a re-pair hands the workspace a new token.
1237
1374
  info.networkRef = agentCfg.network;
1375
+ info.connectionId = agentCfg.node_id;
1238
1376
  info.credentialToken = network.token;
1239
1377
  this._adapterLoop(name, agentCfg, info, network);
1240
1378
  } else {
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');