@openagents-org/agent-launcher 0.2.173 → 0.2.175

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.
@@ -212,6 +212,16 @@ class OpenClawAdapter extends BaseAdapter {
212
212
 
213
213
  async _handleMessage(msg) {
214
214
  let content = (msg.content || '').trim();
215
+ // Strip a leading mention of OURSELVES. The workspace UI prefixes targeted
216
+ // messages with "@<agentName>", but OpenClaw's inner agent doesn't know its
217
+ // workspace name — it sees a message addressed to a stranger and its
218
+ // group-chat heuristic answers NO_REPLY (empty payloads, silent agent).
219
+ // Mentions of other participants are left intact.
220
+ const selfMention = new RegExp(
221
+ '^\\s*@' + String(this.agentName || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b[,:]?\\s*',
222
+ 'i',
223
+ );
224
+ content = content.replace(selfMention, '').trim();
215
225
  const attachments = msg.attachments || [];
216
226
 
217
227
  // Append attachment info
@@ -509,18 +519,34 @@ class OpenClawAdapter extends BaseAdapter {
509
519
  const payloads = data.payloads || [];
510
520
  this._log(`CLI parsed: ${payloads.length} payloads, keys=${payloads.map(p=>Object.keys(p).join('/')).join(', ')}, text=${payloads.map(p=>(p.text||'').slice(0,50)).join('|')}`);
511
521
  if (payloads.length > 0) {
512
- const texts = payloads.filter(p => p.text).map(p => p.text);
522
+ // NO_REPLY is OpenClaw's "deliberately silent" sentinel, not an
523
+ // answer — surface it as no-response, same as empty payloads.
524
+ const texts = payloads
525
+ .filter(p => p.text && String(p.text).trim() !== 'NO_REPLY')
526
+ .map(p => p.text);
513
527
  if (texts.length > 0) {
514
528
  resolve(texts.join('\n\n'));
515
529
  return;
516
530
  }
531
+ resolve('');
532
+ return;
517
533
  }
534
+ // A parsed envelope with no text payloads means the run completed but
535
+ // OpenClaw produced no reply. The raw envelope (sessionFile, usage,
536
+ // contextBudgetStatus, …) must never reach the chat — that is exactly
537
+ // what users saw as a wall of JSON. Resolve empty so the caller posts
538
+ // its clean "no response" notice instead.
539
+ resolve('');
540
+ return;
518
541
  } catch (e) {
519
542
  this._log(`CLI JSON parse error: ${e.message}`);
520
543
  }
521
544
  }
522
545
 
523
- // Fallback: return non-diagnostic text
546
+ // Fallback: return non-diagnostic text. If the output contains an
547
+ // envelope we failed to parse, suppress it the same way — garbage JSON in
548
+ // chat is worse than the caller's "no response" notice.
549
+ if (text.includes('"payloads"')) { resolve(''); return; }
524
550
  const cleanLines = text.split('\n').filter(l =>
525
551
  !l.includes('[diagnostic]') && !l.includes('[agent/embedded]') && !l.includes('Registered plugin')
526
552
  ).map(l => l.trim()).filter(Boolean);
@@ -45,6 +45,16 @@ const OPENCODE_TESTED_MAX_VERSION = '1.17.11';
45
45
  // window across channels.
46
46
  const VERSION_PROBE_TTL_MS = 60000;
47
47
 
48
+ // Provider id this adapter writes into the run directory's
49
+ // .opencode/opencode.json when the agent points at a custom OpenAI-compatible
50
+ // gateway. opencode's built-in `openai` provider validates model names against
51
+ // its catalog (a gateway model like `deepseek-4-flash` dies with
52
+ // ProviderModelNotFoundError before any request is sent) and speaks the
53
+ // Responses API (`/v1/responses`), which most gateways don't serve (404 →
54
+ // endless retry). Declaring the gateway as an @ai-sdk/openai-compatible
55
+ // provider keeps requests on `/chat/completions` and skips catalog validation.
56
+ const CUSTOM_PROVIDER_ID = 'openagents-gateway';
57
+
48
58
  // Short, de-identified, actionable user messages per failure category. These
49
59
  // must NEVER default to "run auth login": only credential_missing / auth_failed
50
60
  // mention authentication. Diagnostics (exit code, signal, redacted stdout/
@@ -670,6 +680,10 @@ class OpenCodeAdapter extends BaseAdapter {
670
680
  if (!model) return '';
671
681
  // Already provider-qualified (e.g. "openai/gpt-4o", "anthropic/claude-…").
672
682
  if (model.includes('/')) return model;
683
+ // A custom gateway's models aren't in opencode's openai catalog — route
684
+ // them through the generated openai-compatible provider (see
685
+ // _ensureCustomProviderConfig).
686
+ if (this._customBaseUrl()) return `${CUSTOM_PROVIDER_ID}/${model}`;
673
687
  // Infer the provider from which key/base URL the env resolved to. The
674
688
  // registry maps LLM_* → OPENAI_* for OpenAI-compatible endpoints, so
675
689
  // "openai" is the right default; only switch for an Anthropic endpoint.
@@ -680,6 +694,73 @@ class OpenCodeAdapter extends BaseAdapter {
680
694
  return `${provider}/${model}`;
681
695
  }
682
696
 
697
+ /**
698
+ * The configured base URL when it points at a custom OpenAI-compatible
699
+ * gateway, '' for the real OpenAI / Anthropic endpoints (opencode's built-in
700
+ * providers handle those natively).
701
+ */
702
+ _customBaseUrl() {
703
+ const env = this.agentEnv || process.env;
704
+ const raw = (env.OPENAI_BASE_URL || env.LLM_BASE_URL || '').trim();
705
+ if (!raw) return '';
706
+ let host = '';
707
+ try { host = new URL(raw).hostname.toLowerCase(); } catch { return ''; }
708
+ if (host === 'openai.com' || host.endsWith('.openai.com')) return '';
709
+ if (host === 'anthropic.com' || host.endsWith('.anthropic.com')) return '';
710
+ return raw;
711
+ }
712
+
713
+ /**
714
+ * Declare the custom gateway in `<runCwd>/.opencode/opencode.json` so the
715
+ * `${CUSTOM_PROVIDER_ID}/<model>` id from _resolveModel actually resolves.
716
+ * Merges into an existing config (only our provider entry is touched); a
717
+ * file that exists but doesn't parse is left alone — never clobber a config
718
+ * the user hand-wrote. The API key is referenced as {env:OPENAI_API_KEY}
719
+ * (resolved by opencode from the spawn env) so it is never written to disk.
720
+ */
721
+ _ensureCustomProviderConfig(runCwd) {
722
+ const baseUrl = this._customBaseUrl();
723
+ if (!baseUrl) return;
724
+ const env = this.agentEnv || process.env;
725
+ const model = (env.OPENCODE_MODEL || env.LLM_MODEL || '').trim();
726
+ if (!model || model.includes('/')) return;
727
+
728
+ const file = path.join(runCwd, '.opencode', 'opencode.json');
729
+ let cfg = {};
730
+ try {
731
+ cfg = JSON.parse(fs.readFileSync(file, 'utf-8'));
732
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) cfg = {};
733
+ } catch (e) {
734
+ if (e && e.code !== 'ENOENT') {
735
+ this._log(`Not updating ${file} — existing file is unreadable or not valid JSON`);
736
+ return;
737
+ }
738
+ }
739
+
740
+ const before = JSON.stringify(cfg);
741
+ if (!cfg.$schema) cfg.$schema = 'https://opencode.ai/config.json';
742
+ if (!cfg.provider || typeof cfg.provider !== 'object') cfg.provider = {};
743
+ const prev = (typeof cfg.provider[CUSTOM_PROVIDER_ID] === 'object' && cfg.provider[CUSTOM_PROVIDER_ID]) || {};
744
+ cfg.provider[CUSTOM_PROVIDER_ID] = {
745
+ ...prev,
746
+ npm: '@ai-sdk/openai-compatible',
747
+ name: prev.name || 'OpenAgents model gateway',
748
+ options: { ...(prev.options || {}), baseURL: baseUrl, apiKey: '{env:OPENAI_API_KEY}' },
749
+ // Keep previously-declared models so switching models doesn't invalidate
750
+ // sessions that still reference the old id.
751
+ models: { ...(prev.models || {}), [model]: (prev.models || {})[model] || {} },
752
+ };
753
+ if (JSON.stringify(cfg) === before) return;
754
+
755
+ try {
756
+ fs.mkdirSync(path.dirname(file), { recursive: true });
757
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n', 'utf-8');
758
+ this._log(`Declared gateway provider for ${model} in ${file}`);
759
+ } catch (e) {
760
+ this._log(`Failed to write ${file}: ${e.message}`);
761
+ }
762
+ }
763
+
683
764
  _runOpencode(content, msgChannel) {
684
765
  // Preflight BEFORE spawning: bail out with a structured, actionable error
685
766
  // instead of launching a child that will hang for 5 minutes or exit 1 with
@@ -692,6 +773,11 @@ class OpenCodeAdapter extends BaseAdapter {
692
773
 
693
774
  const binary = this._opencodeBinary;
694
775
  const runCwd = this._resolveCwd();
776
+ // Custom gateways must be declared in the run dir's opencode.json before
777
+ // the spawn, or --model ${CUSTOM_PROVIDER_ID}/… won't resolve.
778
+ try { this._ensureCustomProviderConfig(runCwd); } catch (e) {
779
+ this._log(`Gateway provider config failed (continuing): ${e.message}`);
780
+ }
695
781
  const cmd = [binary, 'run', '--format', 'json', '--dir', runCwd];
696
782
 
697
783
  // Preflight guarantees a resolvable model; pin it explicitly — without it
@@ -830,6 +830,33 @@ function isValidSessionId(id) {
830
830
  * @param {boolean} [o.trustProject=false] true → --approve, false → --no-approve
831
831
  * @returns {string[]}
832
832
  */
833
+ /**
834
+ * Infer the Launcher-provider settings for a bare relay configuration.
835
+ *
836
+ * The workspace's Add-agent flow collects only key + base URL + model, which
837
+ * lands as PI_API_KEY / PI_BASE_URL / PI_MODEL — no PI_PROVIDER, no
838
+ * PI_API_FORMAT. The adapter used to refuse that outright ("PI_PROVIDER and
839
+ * PI_MODEL are required when PI_BASE_URL is set"), a dead end for every agent
840
+ * created remotely. When the provider is not set explicitly, derive it from
841
+ * the base URL: anthropic-looking hosts speak anthropic-messages; every other
842
+ * relay/gateway convention is /chat/completions — NOT OpenAI's /responses, so
843
+ * the `openai` provider default of openai-responses would be wrong here.
844
+ * Returns null when PI_PROVIDER is set (explicit config wins) or when there
845
+ * is no base URL to infer from.
846
+ */
847
+ function inferLauncherProvider(env = {}) {
848
+ const val = (k) => String(env[k] == null ? '' : env[k]).trim();
849
+ if (val('PI_PROVIDER')) return null;
850
+ const base = val('PI_BASE_URL');
851
+ if (!base) return null;
852
+ let host = '';
853
+ try { host = new URL(base).hostname.toLowerCase(); } catch { host = base.toLowerCase(); }
854
+ if (host.includes('anthropic')) {
855
+ return { provider: 'anthropic', apiFormat: 'anthropic-messages' };
856
+ }
857
+ return { provider: 'openai', apiFormat: 'openai-completions' };
858
+ }
859
+
833
860
  function buildPiArgs({
834
861
  sessionDir,
835
862
  sessionId,
@@ -915,6 +942,7 @@ module.exports = {
915
942
  parseWindowsCmdShim,
916
943
  isValidSessionId,
917
944
  buildPiArgs,
945
+ inferLauncherProvider,
918
946
  normalizeThinking,
919
947
  parseTrustProject,
920
948
  redactArgs,
@@ -45,6 +45,7 @@ const {
45
45
  PiStreamParser,
46
46
  PiAssistantAccumulator,
47
47
  buildPiArgs,
48
+ inferLauncherProvider,
48
49
  classifyNodeVersion,
49
50
  classifyPiError,
50
51
  classifyPiVersion,
@@ -1184,6 +1185,17 @@ class PiAdapter extends BaseAdapter {
1184
1185
  /** Environment for the Pi child: agent env plus Pi's own hygiene settings. */
1185
1186
  _buildEnv() {
1186
1187
  const env = { ...(this.agentEnv || process.env) };
1188
+ // A relay-only config (base URL, no explicit provider — what the
1189
+ // workspace's Add-agent flow produces) gets its provider/API format
1190
+ // inferred. Written into the child env so the launcher provider
1191
+ // extension, which reads PI_PROVIDER/PI_API_FORMAT itself, agrees with
1192
+ // the adapter's own view.
1193
+ const inferred = inferLauncherProvider(env);
1194
+ if (inferred) {
1195
+ env.PI_PROVIDER = inferred.provider;
1196
+ const fmt = String(env.PI_API_FORMAT || '').trim().toLowerCase();
1197
+ if (!fmt || fmt === 'auto') env.PI_API_FORMAT = inferred.apiFormat;
1198
+ }
1187
1199
  // The Launcher exposes one provider-agnostic secret field. Pi's native
1188
1200
  // providers still expect their conventional env var, so mirror the value
1189
1201
  // in memory for the child only. Existing provider-specific env remains
@@ -1212,11 +1224,18 @@ class PiAdapter extends BaseAdapter {
1212
1224
  _config() {
1213
1225
  const env = this.agentEnv || process.env;
1214
1226
  const val = (k) => String(env[k] == null ? '' : env[k]).trim();
1227
+ // Same inference as _buildEnv, so the preflight check in _ensureProc
1228
+ // accepts a relay-only config instead of refusing to start.
1229
+ const inferred = inferLauncherProvider(env);
1230
+ const explicitFormat = val('PI_API_FORMAT');
1215
1231
  return {
1216
- provider: val('PI_PROVIDER') || null,
1232
+ provider: val('PI_PROVIDER') || (inferred && inferred.provider) || null,
1217
1233
  model: val('PI_MODEL') || null,
1218
1234
  baseUrl: val('PI_BASE_URL') || null,
1219
- apiFormat: val('PI_API_FORMAT') || 'auto',
1235
+ apiFormat:
1236
+ explicitFormat && explicitFormat.toLowerCase() !== 'auto'
1237
+ ? explicitFormat
1238
+ : (inferred && inferred.apiFormat) || explicitFormat || 'auto',
1220
1239
  thinking: normalizeThinking(val('PI_THINKING')),
1221
1240
  trustProject: parseTrustProject(val('PI_TRUST_PROJECT')),
1222
1241
  };
@@ -209,7 +209,7 @@ function buildModePrompt(mode) {
209
209
  *
210
210
  * In plan mode, only read-only operations are documented.
211
211
  */
212
- function buildApiSkillsPrompt({ endpoint, workspaceId, token, agentName, channelName, disabledModules, mode = 'execute', isWindows = process.platform === 'win32' }) {
212
+ function buildApiSkillsPrompt({ endpoint, workspaceId, token, agentName, channelName, disabledModules, mode = 'execute', isWindows = process.platform === 'win32', execTool = 'exec' }) {
213
213
  const disabled = disabledModules || new Set();
214
214
  const baseUrl = endpoint.replace(/\/+$/, '');
215
215
  const isPlan = mode === 'plan';
@@ -231,14 +231,14 @@ function buildApiSkillsPrompt({ endpoint, workspaceId, token, agentName, channel
231
231
  'You can ' + caps.join(', ') + '.\n' +
232
232
  'These are WORKSPACE tools shared with all agents and users. ' +
233
233
  'They are different from your native tools.\n\n' +
234
- '**HOW TO USE:** Call your `exec` tool to run the `curl` commands below. ' +
235
- 'Do NOT output curl commands as text — EXECUTE them with `exec`.\n\n' +
234
+ `**HOW TO USE:** Call your \`${execTool}\` tool to run the \`curl\` commands below. ` +
235
+ `Do NOT output curl commands as text — EXECUTE them with \`${execTool}\`.\n\n` +
236
236
  '**IMPORTANT — tool priority:**\n' +
237
- '- ALWAYS use `exec` + `curl` (documented below) for workspace operations.\n' +
237
+ `- ALWAYS use \`${execTool}\` + \`curl\` (documented below) for workspace operations.\n` +
238
238
  '- Do NOT use `workspace_browser_*` native tools — they are not configured ' +
239
239
  'and will fail.\n' +
240
240
  '- Do NOT use `web_fetch`, `browser`, or any native browsing tool ' +
241
- 'when the user asks to use the workspace browser — use `exec` + `curl` instead.\n' +
241
+ `when the user asks to use the workspace browser — use \`${execTool}\` + \`curl\` instead.\n` +
242
242
  '- The workspace browser is a *shared* browser visible to all users and agents.\n\n' +
243
243
  '**Auth header** (include on every request):\n' +
244
244
  `\`X-Workspace-Token: ${token}\`\n`
@@ -251,7 +251,7 @@ function buildApiSkillsPrompt({ endpoint, workspaceId, token, agentName, channel
251
251
  if (!isPlan) {
252
252
  if (isWindows) {
253
253
  s += (
254
- '**To upload a file**, exec this (replace filename/content):\n' +
254
+ `**To upload a file**, run this (replace filename/content):\n` +
255
255
  `$CONTENT = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('YOUR_CONTENT'))\n` +
256
256
  `${curl} -s -X POST ${baseUrl}/v1/files/base64 ` +
257
257
  `-H "${h}" ` +
@@ -265,7 +265,7 @@ function buildApiSkillsPrompt({ endpoint, workspaceId, token, agentName, channel
265
265
  );
266
266
  } else {
267
267
  s += (
268
- '**To upload a file**, exec this (replace filename/content):\n' +
268
+ `**To upload a file**, run this (replace filename/content):\n` +
269
269
  `CONTENT=$(echo -n 'YOUR_CONTENT' | base64) && ` +
270
270
  `${curl} -s -X POST ${baseUrl}/v1/files/base64 ` +
271
271
  `-H "${h}" ` +
@@ -354,7 +354,7 @@ function buildApiSkillsPrompt({ endpoint, workspaceId, token, agentName, channel
354
354
  `"source":"openagents:${agentName}"}'\n` +
355
355
  'If it returns error_code AUTH_REQUIRED or BOT_CHALLENGE, open the URL ' +
356
356
  'in a shared tab (below) and share its `live_url` so a human can log in.\n\n' +
357
- '**To browse interactively** (click/type/login), exec these steps (use exec for each):\n' +
357
+ `**To browse interactively** (click/type/login), run these steps (use \`${execTool}\` for each):\n` +
358
358
  `Step 1 — open tab: ` +
359
359
  `${curl} -s -X POST ${baseUrl}/v1/browser/tabs ` +
360
360
  `-H "${h}" -H "Content-Type: application/json" ` +
@@ -1107,6 +1107,39 @@ function buildOpenCodeSkillMd({ endpoint, workspaceId, token, agentName, channel
1107
1107
  return frontmatter + identity + api + '\n' + buildGuardrails();
1108
1108
  }
1109
1109
 
1110
+ /**
1111
+ * Build a SKILL.md file for Command Code.
1112
+ *
1113
+ * Command Code discovers skills from `.commandcode/skills/` (project) and
1114
+ * `~/.commandcode/skills/` (user); this file is written to neither. It goes to
1115
+ * an OpenAgents-owned directory and is loaded with `--skill`, so a
1116
+ * token-bearing file never lands in the user's repo or personal config. The
1117
+ * shell tool is named for the CLI that will read it — `shell_command`, not the
1118
+ * `exec` other adapters advertise — because an agent told to call a tool it
1119
+ * does not have simply does nothing.
1120
+ */
1121
+ function buildCommandCodeSkillMd({ endpoint, workspaceId, token, agentName, channelName, disabledModules }) {
1122
+ const api = buildApiSkillsPrompt({
1123
+ endpoint, workspaceId, token, agentName,
1124
+ channelName: channelName || 'general',
1125
+ disabledModules,
1126
+ mode: 'execute',
1127
+ execTool: 'shell_command',
1128
+ });
1129
+
1130
+ const frontmatter =
1131
+ '---\n' +
1132
+ `name: ${workspaceSkillName(agentName)}\n` +
1133
+ 'description: OpenAgents Workspace API — shared files, browser, and agent collaboration\n' +
1134
+ '---\n\n';
1135
+
1136
+ const identity =
1137
+ `You are agent '${agentName}' connected to OpenAgents workspace ${workspaceId}.\n` +
1138
+ 'Use these APIs via `shell_command` + curl to interact with the workspace.\n\n';
1139
+
1140
+ return frontmatter + identity + api + '\n' + buildGuardrails();
1141
+ }
1142
+
1110
1143
  /**
1111
1144
  * Build a SKILL.md file for Claude Code's skill auto-discovery.
1112
1145
  *
@@ -1188,6 +1221,7 @@ module.exports = {
1188
1221
  buildOpenclawSkillMd,
1189
1222
  buildOpenCodeSystemPrompt,
1190
1223
  buildOpenCodeSkillMd,
1224
+ buildCommandCodeSkillMd,
1191
1225
  buildPiSystemPrompt,
1192
1226
  buildDeepSeekTaskFile,
1193
1227
  buildClaudeSkillMd,
package/src/cli.js CHANGED
@@ -54,6 +54,35 @@ function getConnector(flags) {
54
54
 
55
55
  function print(msg) { process.stdout.write(msg + '\n'); }
56
56
 
57
+ // ---------------------------------------------------------------------------
58
+ // Banner — the OpenAgents wordmark (figlet "Small Slant", pure ASCII so it
59
+ // renders on every console including legacy Windows codepages). Colored with
60
+ // a cyan→violet gradient only on a real TTY; pipes and NO_COLOR get plain
61
+ // text. 47 columns wide, so it fits any 80-column terminal.
62
+ // ---------------------------------------------------------------------------
63
+ const OA_LOGO = [
64
+ ' ____ ___ __ ',
65
+ ' / __ \\___ ___ ___ / _ |___ ____ ___ / /____',
66
+ '/ /_/ / _ \\/ -_) _ \\/ __ / _ `/ -_) _ \\/ __(_-<',
67
+ '\\____/ .__/\\__/_//_/_/ |_\\_, /\\__/_//_/\\__/___/',
68
+ ' /_/ /___/ ',
69
+ ];
70
+
71
+ function printBanner() {
72
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
73
+ // 256-color gradient, one shade per row: cyan → azure → blue → violet.
74
+ const shades = [51, 45, 39, 63, 99];
75
+ let version = '';
76
+ try { version = require('../package.json').version; } catch {}
77
+ print('');
78
+ OA_LOGO.forEach((line, i) => {
79
+ print(useColor ? `\x1b[38;5;${shades[i]}m${line}\x1b[0m` : line);
80
+ });
81
+ const tag = ` v${version} · multi-agent orchestration for your machine`;
82
+ print(useColor ? `\x1b[2m${tag}\x1b[0m` : tag);
83
+ print('');
84
+ }
85
+
57
86
  function table(rows, headers) {
58
87
  if (rows.length === 0) return;
59
88
  const widths = headers.map((h, i) =>
@@ -178,7 +207,18 @@ async function cmdCreate(connector, flags, positional) {
178
207
  print('This agent is local-only and will not appear in Workspace Dashboard yet.');
179
208
  print('');
180
209
  print('To connect it to a Workspace, run:');
181
- print(` agn connect ${name} <workspace-token>`);
210
+ // A paired device already holds the credential — point at the
211
+ // pairing-first form instead of the deprecated manual-token one.
212
+ let pairings = [];
213
+ try { pairings = require('./node-config').listPairings().filter((p) => p && p.workspace_slug); } catch {}
214
+ if (pairings.length) {
215
+ for (const p of pairings.slice(0, 3)) {
216
+ print(` agn connect ${name} --workspace ${p.workspace_slug}${p.workspace_name ? ` (${p.workspace_name})` : ''}`);
217
+ }
218
+ } else {
219
+ print(` agn connect ${name} <workspace-token>`);
220
+ print('Or pair this device first: agn node connect <pairing-code>');
221
+ }
182
222
  } else {
183
223
  print(`Agent '${name}' created (type: ${type})`);
184
224
  }
@@ -323,6 +363,24 @@ async function cmdRuntimes(connector, flags) {
323
363
  const runtimes = connector.registry.getCatalogSync().map((e) => {
324
364
  let h;
325
365
  try { h = connector.healthCheck(e.name); } catch { h = {}; }
366
+ // Credential contract, upward half: STATUS goes up, the secret stays on
367
+ // this device. `mode` says how the type authenticates today
368
+ // (subscription = CLI/OAuth login, api_key = a stored key, none = not
369
+ // configured); keyMasked shows only the key's tail.
370
+ let keyMasked = null;
371
+ try {
372
+ const env = connector.env.load(e.name) || {};
373
+ const key = env.LLM_API_KEY || null;
374
+ if (key && key.length > 7) keyMasked = `…${key.slice(-4)}`;
375
+ else if (key) keyMasked = '…';
376
+ } catch {}
377
+ const mode = !h.ready
378
+ ? 'none'
379
+ : h.auth_mode === 'cli_login'
380
+ ? 'subscription'
381
+ : h.auth_mode === 'api_key'
382
+ ? 'api_key'
383
+ : 'none';
326
384
  return {
327
385
  type: e.name,
328
386
  installed: !!h.installed,
@@ -331,6 +389,7 @@ async function cmdRuntimes(connector, flags) {
331
389
  reason: h.reason || null,
332
390
  message: h.message || null,
333
391
  authStatus: h.auth_status || null,
392
+ auth: { mode, keyMasked: mode === 'api_key' ? keyMasked : null },
334
393
  };
335
394
  });
336
395
  process.stdout.write(JSON.stringify(runtimes));
@@ -372,10 +431,20 @@ async function cmdConnect(connector, flags, positional) {
372
431
 
373
432
  if (!name) {
374
433
  print('Usage: agn connect <agent-name> <token>');
434
+ print(' agn connect <agent-name> --workspace <slug> (paired/known workspace, no token)');
375
435
  process.exitCode = 1;
376
436
  return;
377
437
  }
378
438
 
439
+ // --workspace <slug|id>: bind to a workspace this device already knows —
440
+ // a registered network or a device pairing. No token, no /v1/token/resolve,
441
+ // no server round-trip; the credential is already on disk. This is the
442
+ // pairing-first path; the token form below stays for manual connection.
443
+ if (flags.workspace && typeof flags.workspace === 'string') {
444
+ connectKnownWorkspace(connector, name, flags.workspace);
445
+ return;
446
+ }
447
+
379
448
  if (!token) {
380
449
  // No token supplied and none in the environment. Never prompt — keep
381
450
  // CI / non-interactive environments from hanging. Print a helpful error
@@ -406,6 +475,14 @@ async function cmdConnect(connector, flags, positional) {
406
475
  // Connect agent
407
476
  connector.connectWorkspace(name, slug);
408
477
  print(`'${name}' connected to workspace '${wsName}'`);
478
+ // Manual token connection is being retired in favor of device pairing.
479
+ // It keeps working (backward compatibility is deliberate); this is the
480
+ // CLI half of the dismissible launcher notice.
481
+ if (!process.env.OPENAGENTS_NO_DEPRECATION_NOTES) {
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>`.');
484
+ print(' (Silence this note with OPENAGENTS_NO_DEPRECATION_NOTES=1.)');
485
+ }
409
486
 
410
487
  // Signal daemon reload
411
488
  const pid = connector.getDaemonPid();
@@ -421,6 +498,78 @@ async function cmdConnect(connector, flags, positional) {
421
498
  }
422
499
  }
423
500
 
501
+ /**
502
+ * Bind an agent to a workspace already known to this device, by slug or id.
503
+ * Sources, in order: registered networks (daemon.yaml), then device pairings
504
+ * (node.json) — a pairing registers its network on the spot so the daemon can
505
+ * pick up the token. Never touches the server.
506
+ */
507
+ function connectKnownWorkspace(connector, name, ref) {
508
+ const networks = connector.config.getNetworks() || [];
509
+ let net = networks.find((n) => n.slug === ref || n.id === ref);
510
+
511
+ if (!net) {
512
+ const nodeCfg = require('./node-config');
513
+ const pairing = (nodeCfg.listPairings() || []).find(
514
+ (p) => p.workspace_slug === ref || p.workspace_id === ref,
515
+ );
516
+ if (pairing && pairing.token) {
517
+ connector.config.addNetwork({
518
+ id: pairing.workspace_id,
519
+ slug: pairing.workspace_slug,
520
+ name: pairing.workspace_name || pairing.workspace_slug,
521
+ endpoint: pairing.endpoint || connector.workspace.endpoint,
522
+ token: pairing.token,
523
+ });
524
+ net = { slug: pairing.workspace_slug, name: pairing.workspace_name };
525
+ }
526
+ }
527
+
528
+ if (!net) {
529
+ const known = [
530
+ ...new Set([
531
+ ...networks.map((n) => n.slug || n.id),
532
+ ...(() => {
533
+ try {
534
+ return require('./node-config').listPairings().map((p) => p.workspace_slug);
535
+ } catch { return []; }
536
+ })(),
537
+ ]),
538
+ ].filter(Boolean);
539
+ print(`No workspace '${ref}' is known on this device.`);
540
+ if (known.length) print(`Known workspaces: ${known.join(', ')}`);
541
+ print('Pair this device first (`agn node connect <code>`), or connect with a token.');
542
+ process.exitCode = 1;
543
+ return;
544
+ }
545
+
546
+ const slug = net.slug || ref;
547
+ connector.connectWorkspace(name, slug);
548
+ print(`'${name}' connected to workspace '${net.name || slug}'`);
549
+
550
+ const pid = connector.getDaemonPid();
551
+ if (pid) {
552
+ connector.sendDaemonCommand(`restart:${name}`);
553
+ print('Daemon notified');
554
+ } else {
555
+ print('Daemon is not running. Run `agn up` to bring this agent online.');
556
+ }
557
+ }
558
+
559
+ /**
560
+ * Browser URL of a workspace, matching the TUI's convention: a localhost/dev
561
+ * endpoint serves its own web app; everything else lives on the hosted
562
+ * dashboard. This is what a freshly paired user actually needs next — NOT
563
+ * another CLI command.
564
+ */
565
+ function workspaceWebUrl(slug, endpoint) {
566
+ const ep = String(endpoint || '');
567
+ const isLocal = ep.includes('localhost') || ep.includes('127.0.0.1');
568
+ return isLocal
569
+ ? `${ep.replace(/\/+$/, '')}/${slug}`
570
+ : `https://workspace.openagents.org/${slug}`;
571
+ }
572
+
424
573
  async function cmdNode(connector, flags, positional) {
425
574
  const sub = positional[0] || 'status';
426
575
  const nodeCfg = require('./node-config');
@@ -437,9 +586,28 @@ async function cmdNode(connector, flags, positional) {
437
586
  if (flags.type) info.deviceType = flags.type; // server | laptop | desktop
438
587
  if (flags.name) info.name = flags.name;
439
588
 
589
+ // Existing pairings up front, so a re-run shows where this device already
590
+ // lives instead of looking like a first-time setup.
591
+ const priorPairings = nodeCfg.listPairings().filter((p) => p.node_id);
592
+ if (priorPairings.length) {
593
+ print('This device is already paired with:');
594
+ for (const p of priorPairings) {
595
+ print(` ${p.workspace_name || p.workspace_slug} — ${workspaceWebUrl(p.workspace_slug || p.workspace_id, p.endpoint)}`);
596
+ }
597
+ print('');
598
+ }
599
+
440
600
  print('Connecting this device to your workspace...');
441
601
  try {
442
602
  const res = await connector.redeemNodePairingCode(code, info);
603
+ // A code always redeems against ONE workspace; if that workspace is one
604
+ // this device already belongs to, this is a re-pair, not a new pairing.
605
+ // recordPairing below still runs — it refreshes the (possibly rotated)
606
+ // credential in place — but the user should hear "already paired", not
607
+ // a duplicate "connected".
608
+ const alreadyPaired = priorPairings.some(
609
+ (p) => p.workspace_id === res.workspaceId,
610
+ );
443
611
  // recordPairing, not saveNode: this device can be a node in several
444
612
  // workspaces at once, and a bare save would drop the others' pairings.
445
613
  nodeCfg.recordPairing(info.nodeKey, {
@@ -458,7 +626,11 @@ async function cmdNode(connector, flags, positional) {
458
626
  endpoint: connector.workspace.endpoint,
459
627
  token: res.token,
460
628
  });
461
- print(`Node connected to workspace '${res.workspaceName}' (${res.workspaceSlug})`);
629
+ if (alreadyPaired) {
630
+ print(`Already paired with '${res.workspaceName}' (${res.workspaceSlug}) — refreshed this device's credential.`);
631
+ } else {
632
+ print(`Node connected to workspace '${res.workspaceName}' (${res.workspaceSlug})`);
633
+ }
462
634
  print(` This device: ${info.hostname} (${info.deviceType})`);
463
635
 
464
636
  // (Re)start the daemon so it runs the CURRENT launcher build and loads
@@ -476,8 +648,10 @@ async function cmdNode(connector, flags, positional) {
476
648
  }
477
649
  connector.startDaemon();
478
650
  print('');
479
- print('Next: add an agent from the workspace, or run:');
480
- print(' agn create <name> --type <type> --install');
651
+ // The user came from the workspace's "Connect a Node" page — send them
652
+ // straight back there with a clickable link, not more CLI homework.
653
+ print('All set! Open your workspace to add and manage agents on this device:');
654
+ print(` ${workspaceWebUrl(res.workspaceSlug, connector.workspace.endpoint)}`);
481
655
  } catch (e) {
482
656
  print(`Error: ${e.message}`);
483
657
  process.exitCode = 1;
@@ -493,7 +667,8 @@ async function cmdNode(connector, flags, positional) {
493
667
  }
494
668
  print(`Node connected to ${pairings.length} workspace(s):`);
495
669
  for (const p of pairings) {
496
- print(` ${p.workspace_slug || p.workspace_id} — node ${p.node_id}`);
670
+ const slug = p.workspace_slug || p.workspace_id;
671
+ print(` ${p.workspace_name || slug} (${slug}) — ${workspaceWebUrl(slug, p.endpoint)}`);
497
672
  }
498
673
  return;
499
674
  }
@@ -882,6 +1057,7 @@ async function cmdUpdate(connector) {
882
1057
  }
883
1058
 
884
1059
  async function cmdHelp() {
1060
+ printBanner();
885
1061
  print(`Usage: agn <command> [options]
886
1062
 
887
1063
  Commands:
@@ -898,7 +1074,7 @@ Commands:
898
1074
  uninstall <type> Uninstall an agent runtime
899
1075
  search [query] Browse agent catalog
900
1076
  runtimes List installed runtimes
901
- connect <agent> <token> Connect agent to workspace
1077
+ connect <agent> <token> Connect agent to workspace (or --workspace <slug> for a paired one)
902
1078
  disconnect <agent> Disconnect agent from workspace
903
1079
  env <type> [--set K=V] View/set env vars for agent type
904
1080
  skills [agent] [action] Manage agent skills (enable/disable)
package/src/config.js CHANGED
@@ -116,8 +116,19 @@ class Config {
116
116
 
117
117
  addNetwork({ id, slug, name, endpoint, token }) {
118
118
  const config = this.load();
119
- if (config.networks.some((n) => n.slug === slug || n.id === id)) {
120
- return; // already exists
119
+ // Upsert, not insert-only: re-registering a known workspace refreshes its
120
+ // credentials. The old early-return silently kept a stale token after a
121
+ // rotation or re-pair — both the launcher and the CLI then had to carry
122
+ // their own read-modify-write patches around it.
123
+ const existing = config.networks.find((n) => n.slug === slug || n.id === id);
124
+ if (existing) {
125
+ if (id) existing.id = id;
126
+ if (slug) existing.slug = slug;
127
+ if (name) existing.name = name;
128
+ if (endpoint) existing.endpoint = endpoint;
129
+ if (token) existing.token = token;
130
+ this.save(config);
131
+ return existing;
121
132
  }
122
133
  const entry = { id, slug };
123
134
  if (name) entry.name = name;