@openagents-org/agent-launcher 0.2.143 → 0.2.146

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.
@@ -462,7 +462,6 @@ function buildClaudeSystemPrompt({ agentName, workspaceId, channelName, mode = '
462
462
  );
463
463
  parts.push(buildBrowserDirective(browserEnabled));
464
464
  parts.push(buildCollaborationPrompt());
465
- parts.push(buildA2UIPrompt());
466
465
 
467
466
  if (mode === 'plan') {
468
467
  parts.push(
@@ -476,83 +475,6 @@ function buildClaudeSystemPrompt({ agentName, workspaceId, channelName, mode = '
476
475
  return parts.join('\n');
477
476
  }
478
477
 
479
- /**
480
- * Teach the LLM how to emit interactive UI alongside its text response.
481
- * The frontend (OpenAgents Go) renders any A2UI-shaped JSON spec inline
482
- * in the chat bubble; the agent-connector strips the fenced block before
483
- * posting and ferries the spec via payload.spec.
484
- */
485
- function buildA2UIPrompt() {
486
- return (
487
- '\n## Rendering interactive UI\n' +
488
- 'When you want the user to interact with structured UI (a button choice, ' +
489
- 'a form, a chart, a table, a confirmation dialog) instead of just reading ' +
490
- 'text, emit a fenced code block tagged `a2ui` containing a JSON spec. The ' +
491
- "frontend renders it inline below any markdown narration you write.\n\n" +
492
- "The spec is a tree of `{ type, props, children?, action? }` nodes. " +
493
- 'Supported component types include `Stack`, `Heading`, `Text`, `Image`, ' +
494
- '`Icon`, `Button`, `ChoiceList`, `ConfirmDialog`, `AmountInput`, `Card`, ' +
495
- '`Divider`, `Spacer`, `Alert`, `LineChart`, `PieChart`, `AssetPrice`, ' +
496
- '`BalanceCard`, `TransactionList`, `TransactionRow`. Unknown types render ' +
497
- 'as a placeholder chip, so feel free to compose freely.\n\n' +
498
- 'Interactive components attach an `action` object with a `name` field you ' +
499
- 'choose. Example:\n\n' +
500
- '```a2ui\n' +
501
- '{\n' +
502
- ' "type": "Stack",\n' +
503
- ' "props": { "direction": "vertical", "spacing": 12 },\n' +
504
- ' "children": [\n' +
505
- ' { "type": "Heading", "props": { "text": "Pick a time", "level": 2 } },\n' +
506
- ' { "type": "Button", "props": { "label": "Morning", "action": { "name": "pick_morning" } } },\n' +
507
- ' { "type": "Button", "props": { "label": "Evening", "action": { "name": "pick_evening" } } }\n' +
508
- ' ]\n' +
509
- '}\n' +
510
- '```\n\n' +
511
- '### Exact prop names (these are checked verbatim — guessing will render "No data")\n\n' +
512
- 'Layout / content:\n' +
513
- '- `Stack` — props: `direction` ("vertical"|"horizontal"), `spacing`, `alignment`\n' +
514
- '- `Card` — props: `title`, `padding`, `cornerRadius`\n' +
515
- '- `Divider`, `Spacer` — props: `size`, `orientation`\n' +
516
- '- `Heading` — props: `text`, `level` (1–4)\n' +
517
- '- `Text` — props: `content` **(NOT `text`)**, `style`, `weight`, `color`\n' +
518
- '- `Image` — props: `url`, `name`, `contentMode`, `width`, `height`\n' +
519
- '- `Icon` — props: `name`, `size`, `color`\n\n' +
520
- 'Interactive:\n' +
521
- '- `Button` — props: `label`, `style` ("primary"|"secondary"|"destructive"), `icon`, `disabled`, `action: {name, params?}`\n' +
522
- '- `ConfirmDialog` — props: `title`, `message`, `confirmLabel`, `cancelLabel`, `triggerLabel`, `action`\n' +
523
- '- `ChoiceList` — props: `question`, `options: [{id, label}]`, `action`\n' +
524
- '- `AmountInput` — props: `label`, `placeholder`, `currency`, `action`\n' +
525
- '- `input` (lowercase) — props: `inputType` ("text"|"choice"|"number"|"date"), `id`, `label`, `placeholder`, `options`\n\n' +
526
- 'Feedback:\n' +
527
- '- `Alert` — props: `title`, `message`, `severity` ("info"|"success"|"warning"|"error"), `dismissible`, `action`\n\n' +
528
- 'Charts (Swift Charts under the hood — needs **non-empty** data with the right key):\n' +
529
- '- `LineChart` — props: `title`, `color`, `points: [{x, y}]` **(NOT `data`)**\n' +
530
- '- `PieChart` — props: `title`, `segments: [{label, value, color?}]` **(NOT `slices`)**, `showLegend`\n' +
531
- '- `chart` (lowercase) — simple sparkline/bar: props: `style` ("sparkline"|"bar"), `data: [number, ...]`, `labels`, `color`\n\n' +
532
- 'Data display:\n' +
533
- '- `metric` (lowercase) — props: `label`, `value`, `caption`, `captionColor`, `icon`\n' +
534
- '- `list` (lowercase) — props: `items: [{title, subtitle, trailing, icon}]`, `maxVisible`, `expandLabel`\n' +
535
- '- `table` (lowercase) — **key-value rows only, not multi-column**: props: `rows: [{label, value}]`, `maxVisible`\n\n' +
536
- 'There is no multi-column data table in the catalog. For tabular data with ' +
537
- 'columns you have two choices: (a) use `list` with title/subtitle/trailing ' +
538
- 'mapped to your columns, or (b) compose a Stack of horizontal Stacks of Text ' +
539
- 'manually. Pick whichever reads better.\n\n' +
540
- 'If your data is empty or you can\'t fit it into the available primitives, ' +
541
- 'fall back to plain markdown — don\'t emit a spec just to wrap text in a card.\n\n' +
542
- 'When the user interacts with a rendered component, you will receive a ' +
543
- "user message shaped like `[ui_action] action=pick_morning tool_call_id=... value=...`. " +
544
- "Use the `action` name (which you chose) to decide what to do next; the " +
545
- "spec you emitted earlier is in your conversation history for context.\n\n" +
546
- 'Use a spec when:\n' +
547
- '- The user needs to make a discrete choice (offer buttons rather than ' +
548
- 'asking them to type a free-form answer).\n' +
549
- '- Structured data is easier to scan as a chart or table than as prose.\n' +
550
- '- You need explicit confirmation before a destructive action.\n' +
551
- "- A form would gather several fields faster than back-and-forth chat.\n\n" +
552
- "Don't emit a spec for ordinary prose answers — narration alone is fine.\n"
553
- );
554
- }
555
-
556
478
  /**
557
479
  * Build the full system prompt for OpenClaw/non-MCP agents.
558
480
  */
@@ -561,7 +483,6 @@ function buildOpenclawSystemPrompt({ agentName, workspaceId, channelName, endpoi
561
483
  parts.push(buildWorkspaceIdentity(agentName, workspaceId, channelName, mode));
562
484
  parts.push(buildBrowserDirective(browserEnabled));
563
485
  parts.push(buildCollaborationPrompt());
564
- parts.push(buildA2UIPrompt());
565
486
  parts.push(buildModePrompt(mode));
566
487
  parts.push(buildApiSkillsPrompt({
567
488
  endpoint, workspaceId, token, agentName, channelName, disabledModules, mode,
package/src/agent-rows.js CHANGED
@@ -40,7 +40,16 @@ function loadAgentRows(connector) {
40
40
  let health = null;
41
41
  try {
42
42
  health = connector.healthCheck(agent.type || 'openclaw');
43
- if (health && !health.ready) notReadyMsg = health.message || 'Not configured';
43
+ if (health && !health.ready) {
44
+ // "Not installed" only when the executable is genuinely missing; an
45
+ // installed-but-signed-out agent shows its login/config message, never
46
+ // "Not installed". Mirrors the launcher's formatHealthLabel rule.
47
+ const notInstalled =
48
+ health.reason === 'not_installed' || health.installed === false;
49
+ notReadyMsg = notInstalled
50
+ ? 'Not installed'
51
+ : health.message || 'Not configured';
52
+ }
44
53
  } catch {}
45
54
 
46
55
  return {
@@ -55,6 +64,9 @@ function loadAgentRows(connector) {
55
64
  path: agent.path || '',
56
65
  network: agent.network || '',
57
66
  lastError: info.last_error || '',
67
+ // Structured daemon failure reason (spawn_failed / workspace_join_failed /
68
+ // heartbeat_failed / runtime_missing / …) paired with lastError.
69
+ errorReason: info.error_reason || '',
58
70
  notReadyMsg,
59
71
  health,
60
72
  configured: true,
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Pure, content-free helpers that turn an agent's registry `check_ready` plus a
5
+ * health-check result into human-readable authentication guidance.
6
+ *
7
+ * Used by the connector TUI (`showConfigureScreen`) and CLI (`agn env`) so an
8
+ * agent whose sign-in is an OAuth/credential FILE (e.g. Gemini's
9
+ * ~/.gemini/oauth_creds.json) is guided to authenticate instead of being
10
+ * mislabeled "No configuration required for this agent type".
11
+ *
12
+ * These helpers NEVER read, parse, copy, or print credential CONTENTS. They use
13
+ * only the agent's DECLARED metadata — the login command and env-var NAMES from
14
+ * `check_ready`, and the already-computed (non-sensitive) `message` / status
15
+ * fields the installer's health check returns. No token, key, email, or file
16
+ * path is ever emitted.
17
+ */
18
+
19
+ /**
20
+ * True when an agent declares any credential / sign-in capability in
21
+ * `check_ready` — an OAuth/credential file, API-key env vars, a service-account
22
+ * path env, or a login command. This is the OPT-IN gate: agents WITHOUT any of
23
+ * these are left exactly as before (the caller keeps showing "No configuration
24
+ * required"), so other agents are never polluted by Gemini's guidance.
25
+ *
26
+ * @param {object|null|undefined} checkReady
27
+ * @returns {boolean}
28
+ */
29
+ function hasCredentialMetadata(checkReady) {
30
+ if (!checkReady || typeof checkReady !== 'object') return false;
31
+ return !!(
32
+ checkReady.creds_file ||
33
+ (Array.isArray(checkReady.env_vars) && checkReady.env_vars.length) ||
34
+ (Array.isArray(checkReady.creds_path_env) && checkReady.creds_path_env.length) ||
35
+ checkReady.login_command
36
+ );
37
+ }
38
+
39
+ /**
40
+ * A short, NON-sensitive label for a ready auth_mode. Registry-overridable via
41
+ * `check_ready.auth_detected_labels` (e.g. Gemini maps cli_login → "Google
42
+ * account sign-in detected"). Falls back to a generic, agent-agnostic phrase so
43
+ * any future credential-file agent reads sensibly without extra config.
44
+ *
45
+ * @param {object} checkReady
46
+ * @param {string|null|undefined} authMode 'cli_login' | 'api_key' | other
47
+ * @returns {string}
48
+ */
49
+ function readyLabel(checkReady, authMode) {
50
+ const labels = (checkReady && checkReady.auth_detected_labels) || {};
51
+ if (authMode === 'cli_login') return labels.cli_login || 'CLI sign-in detected';
52
+ if (authMode === 'api_key') return labels.api_key || 'API key detected';
53
+ return labels[authMode] || 'authenticated';
54
+ }
55
+
56
+ /**
57
+ * Build display lines (plain text, no markup, no secrets) describing an agent's
58
+ * authentication state and the concrete next step. The caller is responsible
59
+ * for any terminal styling.
60
+ *
61
+ * @param {object} entry Registry catalog entry (has .label/.name/.check_ready)
62
+ * @param {object|null} health Result of installer.healthCheck(type)
63
+ * @returns {{ ready: boolean, status: string, lines: string[] }}
64
+ */
65
+ function formatAuthGuidance(entry, health) {
66
+ const checkReady = (entry && entry.check_ready) || {};
67
+ const label = (entry && (entry.label || entry.name)) || 'This agent';
68
+ const h = health || {};
69
+ const lines = [];
70
+
71
+ if (h.installed === false) {
72
+ lines.push(`${label} is not installed yet.`);
73
+ return { ready: false, status: 'not_installed', lines };
74
+ }
75
+
76
+ if (h.ready) {
77
+ lines.push(`Ready — ${readyLabel(checkReady, h.auth_mode)}.`);
78
+ return { ready: true, status: h.auth_status || 'ready', lines };
79
+ }
80
+
81
+ // Not ready: lead with the registry's own (non-sensitive) reason, then the
82
+ // concrete next steps derived from the DECLARED login command + env-var names.
83
+ if (h.message) lines.push(h.message);
84
+ const loginCmd = checkReady.login_command;
85
+ if (loginCmd) {
86
+ lines.push(
87
+ `Run \`${loginCmd}\` in your terminal and complete sign-in, then return here and refresh the agent status.`,
88
+ );
89
+ }
90
+ const envVars = Array.isArray(checkReady.env_vars) ? checkReady.env_vars : [];
91
+ if (envVars.length) {
92
+ lines.push(`Alternatively, configure ${envVars.join(' or ')}.`);
93
+ }
94
+ if (!lines.length) lines.push(`${label} requires authentication before use.`);
95
+ return { ready: false, status: h.auth_status || 'no_credentials', lines };
96
+ }
97
+
98
+ module.exports = { hasCredentialMetadata, readyLabel, formatAuthGuidance };
package/src/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { AgentConnector, Daemon } = require('./index');
4
+ const { hasCredentialMetadata, formatAuthGuidance } = require('./auth-guidance');
4
5
 
5
6
  // ---------------------------------------------------------------------------
6
7
  // Arg parsing
@@ -133,6 +134,18 @@ async function cmdCreate(connector, flags, positional) {
133
134
  const type = flags.type || 'openclaw';
134
135
  const role = flags.role || 'worker';
135
136
 
137
+ // Validate the runtime type BEFORE creating any local entry. A typo'd or
138
+ // otherwise unknown type (e.g. "calude") must not leave a broken agent in
139
+ // config — an uninstalled-but-valid type is fine and handled further down.
140
+ if (!connector.registry.getEntry(type)) {
141
+ const known = connector.registry.getCatalogSync().map((e) => e.name).sort();
142
+ print(`Error: unknown agent type '${type}'.`);
143
+ print(`Supported types: ${known.join(', ')}`);
144
+ print('Run `agn search` to browse available agent types.');
145
+ process.exitCode = 1;
146
+ return;
147
+ }
148
+
136
149
  try {
137
150
  connector.addAgent({ name, type, role, path: flags.path || process.cwd() });
138
151
 
@@ -483,14 +496,30 @@ async function cmdEnv(connector, flags, positional) {
483
496
  const display = field.password && val ? '***' : (val || '(not set)');
484
497
  print(` ${field.name}: ${display} ${field.required ? '(required)' : ''}`);
485
498
  }
499
+ return;
500
+ }
501
+
502
+ // No env_config, but the agent may still require a sign-in/credential (e.g.
503
+ // Gemini's OAuth login). Surface readiness + login guidance from the registry
504
+ // check_ready rather than a misleading "No env vars configured". Gated on
505
+ // credential metadata so plain no-config agents keep their existing output.
506
+ const entry = connector.registry.getEntry(type);
507
+ if (hasCredentialMetadata(entry && entry.check_ready)) {
508
+ let health = null;
509
+ try { health = connector.healthCheck(type); } catch {}
510
+ const g = formatAuthGuidance(entry, health);
511
+ print(`${entry.label || type} authentication status: ${g.ready ? 'Ready' : 'Not ready'}`);
512
+ print('');
513
+ for (const line of g.lines) print(line);
514
+ return;
515
+ }
516
+
517
+ const entries = Object.entries(env);
518
+ if (entries.length === 0) {
519
+ print(`No env vars configured for ${type}`);
486
520
  } else {
487
- const entries = Object.entries(env);
488
- if (entries.length === 0) {
489
- print(`No env vars configured for ${type}`);
490
- } else {
491
- for (const [k, v] of entries) {
492
- print(` ${k}: ${v}`);
493
- }
521
+ for (const [k, v] of entries) {
522
+ print(` ${k}: ${v}`);
494
523
  }
495
524
  }
496
525
  }
package/src/config.js CHANGED
@@ -426,8 +426,14 @@ function parseYamlValue(val) {
426
426
  if (val === 'true') return true;
427
427
  if (val === 'false') return false;
428
428
  if (/^\d+$/.test(val)) return parseInt(val, 10);
429
- if ((val.startsWith("'") && val.endsWith("'")) ||
430
- (val.startsWith('"') && val.endsWith('"'))) {
429
+ if (val.startsWith('"') && val.endsWith('"')) {
430
+ // Reverse serializeYamlValue's escaping (\\ → \, \" → "). Without this a
431
+ // double-quoted Windows path like "D:\重要资料" (quoted because of the drive
432
+ // colon) round-trips to D:\\重要资料 and accumulates an extra backslash on
433
+ // every save/load cycle, corrupting the agent's working dir.
434
+ return val.slice(1, -1).replace(/\\(["\\])/g, '$1');
435
+ }
436
+ if (val.startsWith("'") && val.endsWith("'")) {
431
437
  return val.slice(1, -1);
432
438
  }
433
439
  if (val.startsWith('{') || val.startsWith('[')) {
package/src/daemon.js CHANGED
@@ -210,6 +210,7 @@ class Daemon {
210
210
  restarts: info.restarts,
211
211
  started_at: info.startedAt || null,
212
212
  last_error: info.lastError || null,
213
+ error_reason: info.errorReason || null,
213
214
  };
214
215
  }
215
216
  return result;
@@ -234,15 +235,17 @@ class Daemon {
234
235
  // Refuse to start if an existing daemon is already running.
235
236
  // Without this check, repeated `agn up` invocations would spawn
236
237
  // multiple daemons that each process the same message → duplicate
237
- // bot replies.
238
- const existingPid = Daemon._readPid(pidFile);
239
- if (existingPid && Daemon._isAlive(existingPid)) {
238
+ // bot replies. Check BOTH the pid file and the status file (a live daemon
239
+ // rewrites the latter every 5s with its own pid): a clobbered/stale pid
240
+ // file must not let a duplicate slip past this guard.
241
+ const existingPid = Daemon.runningDaemonPid(configDir);
242
+ if (existingPid) {
240
243
  console.error(`Daemon already running (PID ${existingPid}).`);
241
244
  console.error(`Run 'agn down' first, or 'agn status' to check.`);
242
245
  process.exit(1);
243
246
  }
244
247
  // Stale pid file — clean up before spawning fresh
245
- if (existingPid) {
248
+ if (Daemon._readPid(pidFile)) {
246
249
  try { fs.unlinkSync(pidFile); } catch {}
247
250
  }
248
251
 
@@ -286,41 +289,53 @@ class Daemon {
286
289
  const pidFile = path.join(configDir, 'daemon.pid');
287
290
  const statusFile = path.join(configDir, 'daemon.status.json');
288
291
 
289
- const pid = Daemon._readPid(pidFile);
290
- if (!pid) return false;
291
-
292
- try {
293
- if (IS_WINDOWS) {
294
- execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore', timeout: 5000 });
295
- } else {
296
- process.kill(pid, 'SIGTERM');
297
- }
298
- } catch {}
299
-
300
- // Always clean up PID and status files after kill attempt
301
- try { fs.unlinkSync(pidFile); } catch {}
302
- try { fs.unlinkSync(statusFile); } catch {}
303
-
304
- // Wait briefly for process to die
305
- for (let i = 0; i < 5; i++) {
306
- if (!Daemon._isAlive(pid)) return true;
307
- execSync(IS_WINDOWS ? 'ping -n 2 127.0.0.1 >nul' : 'sleep 0.5', {
308
- stdio: 'ignore', timeout: 5000,
309
- });
292
+ // Resolve the REAL running daemon(s) from BOTH the pid file and the status
293
+ // file. A live daemon rewrites the status file every 5s with its own pid,
294
+ // so when the pid file is stale/clobbered the status file is the only
295
+ // record of the actual process. The old code trusted only the pid file:
296
+ // `agn down` would signal a dead pid, delete the files, report "Daemon
297
+ // stopped", and leave the real daemon orphaned — which then blocked every
298
+ // subsequent `agn up` ("already running") and kept its agents wedged.
299
+ const pids = Daemon._liveDaemonPids(configDir);
300
+
301
+ // SIGTERM every distinct live pid.
302
+ for (const pid of pids) {
303
+ try {
304
+ if (IS_WINDOWS) {
305
+ execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore', timeout: 5000 });
306
+ } else {
307
+ process.kill(pid, 'SIGTERM');
308
+ }
309
+ } catch {}
310
310
  }
311
311
 
312
- // Force kill
313
- try {
314
- if (IS_WINDOWS) {
315
- execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore', timeout: 5000 });
316
- } else {
317
- process.kill(pid, 'SIGKILL');
312
+ // Wait briefly, then SIGKILL any survivor that ignored SIGTERM (this is
313
+ // what today's zombie required — a foreground daemon that didn't exit on
314
+ // SIGTERM).
315
+ for (const pid of pids) {
316
+ let alive = Daemon._isAlive(pid);
317
+ for (let i = 0; alive && i < 5; i++) {
318
+ execSync(IS_WINDOWS ? 'ping -n 2 127.0.0.1 >nul' : 'sleep 0.5', {
319
+ stdio: 'ignore', timeout: 5000,
320
+ });
321
+ alive = Daemon._isAlive(pid);
318
322
  }
319
- } catch {}
323
+ if (alive) {
324
+ try {
325
+ if (IS_WINDOWS) {
326
+ execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore', timeout: 5000 });
327
+ } else {
328
+ process.kill(pid, 'SIGKILL');
329
+ }
330
+ } catch {}
331
+ }
332
+ }
320
333
 
334
+ // Always clear BOTH sources of truth so a fresh `agn up` starts clean.
321
335
  try { fs.unlinkSync(pidFile); } catch {}
322
336
  try { fs.unlinkSync(statusFile); } catch {}
323
- return true;
337
+
338
+ return pids.length > 0;
324
339
  }
325
340
 
326
341
  /**
@@ -330,14 +345,18 @@ class Daemon {
330
345
  const pidFile = path.join(configDir, 'daemon.pid');
331
346
  const statusFile = path.join(configDir, 'daemon.status.json');
332
347
  const pid = Daemon._readPid(pidFile);
333
- if (!pid) return null;
348
+ if (pid && Daemon._isAlive(pid)) return pid;
334
349
 
335
- if (!Daemon._isAlive(pid)) {
336
- Daemon._cleanupStaleDaemonFiles(pidFile, statusFile);
337
- return null;
338
- }
350
+ // pid file missing or stale — fall back to the status file so `agn status`
351
+ // reports the SAME live daemon the `agn up` singleton guard detects.
352
+ // Otherwise status says "not running" (dead pid file) while up refuses
353
+ // ("already running", from the status file) — the exact contradiction that
354
+ // hid today's orphaned daemon.
355
+ const live = Daemon.runningDaemonPid(configDir);
356
+ if (live) return live;
339
357
 
340
- return pid;
358
+ Daemon._cleanupStaleDaemonFiles(pidFile, statusFile);
359
+ return null;
341
360
  }
342
361
 
343
362
  // ---------------------------------------------------------------------------
@@ -363,6 +382,9 @@ class Daemon {
363
382
  restarts: 0,
364
383
  startedAt: null,
365
384
  lastError: null,
385
+ // Structured failure reason (health-status REASON) paired with lastError,
386
+ // so the UI/TUI can classify "why" without parsing the message.
387
+ errorReason: null,
366
388
  proc: null,
367
389
  _backoff: 2,
368
390
  };
@@ -472,9 +494,33 @@ class Daemon {
472
494
  // Internal — adapter loop (workspace-connected agents)
473
495
  // ---------------------------------------------------------------------------
474
496
 
497
+ /**
498
+ * Apply a live status update reported by a running adapter (join/heartbeat
499
+ * health). A genuine failure reason → state 'error' + redacted last_error so
500
+ * the Agents list / TUI show the real cause; a null reason → recovered, clear
501
+ * the error. Ignored once the agent is stopping (a user stop must win).
502
+ */
503
+ _applyAdapterStatus(name, info, update) {
504
+ if (!update || this._shuttingDown || this._stoppedAgents.has(name)) return;
505
+ const { isErrorReason, redactDiagnostic } = require('./adapters/health-status');
506
+ const reason = update.reason || null;
507
+ if (reason && isErrorReason(reason)) {
508
+ info.state = 'error';
509
+ info.errorReason = reason;
510
+ info.lastError = redactDiagnostic(update.message || reason);
511
+ } else {
512
+ // Recovered / healthy again — clear any prior connectivity error.
513
+ if (info.state === 'error') info.state = 'running';
514
+ info.errorReason = null;
515
+ info.lastError = null;
516
+ }
517
+ this._writeStatus();
518
+ }
519
+
475
520
  async _adapterLoop(name, agentCfg, info, network) {
476
521
  const { createAdapter } = require('./adapters');
477
522
  const { skillsToDisabledModules } = require('./skill-catalog');
523
+ const { redactDiagnostic } = require('./adapters/health-status');
478
524
  const agentType = agentCfg.type || 'openclaw';
479
525
  const endpoint = network.endpoint || 'https://workspace-endpoint.openagents.org';
480
526
 
@@ -501,20 +547,46 @@ class Daemon {
501
547
  // .claude/skills there failed with EPERM. Root it under ~/.openagents.
502
548
  workingDir: agentCfg.path || defaultAgentWorkdir(name),
503
549
  toolMode: agentCfg.tool_mode || 'skills',
550
+ // Live runtime/connectivity status → daemon.status.json (Agents list/TUI).
551
+ onStatus: (update) => this._applyAdapterStatus(name, info, update),
504
552
  });
505
553
  } catch (e) {
506
- this._log(`${name} failed to create ${agentType} adapter: ${e.message}`);
554
+ // A construction failure (e.g. unknown agent type in this core) is a hard
555
+ // error with a real cause — surface it (redacted), do not pretend stopped.
507
556
  info.state = 'error';
508
- info.lastError = e.message;
557
+ info.errorReason = 'adapter_crashed';
558
+ info.lastError = redactDiagnostic(e.message || String(e));
559
+ this._log(`${name} failed to create ${agentType} adapter: ${info.lastError}`);
509
560
  this._writeStatus();
510
561
  return;
511
562
  }
512
563
 
564
+ // Preflight: when the agent's runtime binary is genuinely missing, surface a
565
+ // precise reason ('runtime_missing') and DO NOT join the workspace — there is
566
+ // no point spinning a join/poll loop that can never run the CLI. Adapters
567
+ // that don't override preflight() always pass.
568
+ try {
569
+ const pf =
570
+ typeof adapter.preflight === 'function' ? adapter.preflight() : null;
571
+ if (pf && pf.ok === false) {
572
+ info.state = 'error';
573
+ info.errorReason = pf.reason || 'runtime_missing';
574
+ info.lastError = redactDiagnostic(pf.message || 'runtime not available');
575
+ this._writeStatus();
576
+ this._log(`${name} preflight failed (${info.errorReason}): ${info.lastError}`);
577
+ return;
578
+ }
579
+ } catch (e) {
580
+ this._log(`${name} preflight error (ignored, continuing): ${e.message}`);
581
+ }
582
+
513
583
  // Store adapter reference for stop and duplicate detection
514
584
  this._adapters[name] = adapter;
515
585
 
516
586
  info.state = 'running';
517
587
  info.startedAt = new Date().toISOString();
588
+ info.lastError = null;
589
+ info.errorReason = null;
518
590
  this._writeStatus();
519
591
  this._log(`${name} adapter online → ${network.slug} (type: ${agentType})`);
520
592
 
@@ -531,14 +603,41 @@ class Daemon {
531
603
  await adapter.run();
532
604
  clearInterval(checkStop);
533
605
  } catch (e) {
534
- info.lastError = (e.message || String(e)).slice(0, 200);
606
+ info.errorReason = info.errorReason || 'adapter_crashed';
607
+ info.lastError = redactDiagnostic((e.message || String(e)).slice(0, 200));
535
608
  this._log(`${name} adapter error: ${info.lastError}`);
536
609
  }
537
610
 
538
611
  delete this._adapters[name];
539
- info.state = 'stopped';
612
+
613
+ // Final state: a clean stop (user stop / daemon shutdown) is NEVER an error.
614
+ // A terminal failure recorded by the adapter (join/heartbeat/session-revoked)
615
+ // or a crash surfaces as 'error' with its classified, redacted reason.
616
+ const userStopped =
617
+ this._shuttingDown ||
618
+ this._stoppedAgents.has(name) ||
619
+ (typeof adapter.wasStopRequested === 'function' && adapter.wasStopRequested());
620
+ if (userStopped) {
621
+ info.state = 'stopped';
622
+ info.lastError = null;
623
+ info.errorReason = null;
624
+ } else {
625
+ const exit =
626
+ (typeof adapter.getExitInfo === 'function' && adapter.getExitInfo()) ||
627
+ null;
628
+ if (exit && exit.reason) {
629
+ info.state = 'error';
630
+ info.errorReason = exit.reason;
631
+ info.lastError = redactDiagnostic(exit.message || exit.reason);
632
+ } else if (info.errorReason) {
633
+ // A crash, or a live error report that never recovered — keep it visible.
634
+ info.state = 'error';
635
+ } else {
636
+ info.state = 'stopped';
637
+ }
638
+ }
540
639
  this._writeStatus();
541
- this._log(`${name} adapter stopped`);
640
+ this._log(`${name} adapter stopped (state: ${info.state})`);
542
641
  }
543
642
 
544
643
  // NOTE: Adapter-specific message handling (openclaw, claude, codex)
@@ -916,12 +1015,27 @@ class Daemon {
916
1015
  * stopped for a legitimate restart doesn't block the replacement.
917
1016
  */
918
1017
  static runningDaemonPid(configDir) {
1018
+ return Daemon._liveDaemonPids(configDir)[0] || null;
1019
+ }
1020
+
1021
+ /**
1022
+ * Return all distinct live daemon PIDs for this configDir (never including
1023
+ * the calling process), gathered from BOTH the pid file and the status file.
1024
+ * The pid file is considered first so its PID sorts ahead of the status
1025
+ * file's. Used by the singleton guard, `agn status`, and `agn down` so every
1026
+ * command agrees on which process(es) are the daemon — the divergence that
1027
+ * let a stale pid file orphan a running daemon.
1028
+ */
1029
+ static _liveDaemonPids(configDir) {
919
1030
  const self = process.pid;
920
- const isOtherAlive = (pid) =>
921
- pid && pid !== self && Daemon._isAlive(pid);
1031
+ const pids = [];
1032
+ const consider = (pid) => {
1033
+ if (pid && pid !== self && Daemon._isAlive(pid) && !pids.includes(pid)) {
1034
+ pids.push(pid);
1035
+ }
1036
+ };
922
1037
 
923
- const pidFromFile = Daemon._readPid(path.join(configDir, 'daemon.pid'));
924
- if (isOtherAlive(pidFromFile)) return pidFromFile;
1038
+ consider(Daemon._readPid(path.join(configDir, 'daemon.pid')));
925
1039
 
926
1040
  try {
927
1041
  const statusFile = path.join(configDir, 'daemon.status.json');
@@ -930,11 +1044,11 @@ class Daemon {
930
1044
  // unrelated process can't masquerade as a live daemon.
931
1045
  if (age < 30000) {
932
1046
  const raw = JSON.parse(fs.readFileSync(statusFile, 'utf-8'));
933
- const pid = raw && raw.pid;
934
- if (isOtherAlive(pid)) return pid;
1047
+ consider(raw && raw.pid);
935
1048
  }
936
1049
  } catch {}
937
- return null;
1050
+
1051
+ return pids;
938
1052
  }
939
1053
  }
940
1054