@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.
- package/package.json +1 -1
- package/registry.json +274 -66
- package/src/adapters/aider.js +634 -0
- package/src/adapters/amp.js +477 -0
- package/src/adapters/base.js +112 -115
- package/src/adapters/claude.js +36 -34
- package/src/adapters/cline-stream.js +632 -0
- package/src/adapters/cline.js +914 -0
- package/src/adapters/codex.js +14 -16
- package/src/adapters/copilot-stream-parser.js +380 -0
- package/src/adapters/copilot.js +796 -0
- package/src/adapters/cursor.js +9 -15
- package/src/adapters/gemini.js +44 -12
- package/src/adapters/goose-stream.js +268 -0
- package/src/adapters/goose.js +649 -0
- package/src/adapters/health-status.js +166 -0
- package/src/adapters/hermes.js +8 -17
- package/src/adapters/index.js +16 -1
- package/src/adapters/nanoclaw-bridge.js +264 -0
- package/src/adapters/nanoclaw-channel-install.js +411 -0
- package/src/adapters/nanoclaw-control.js +491 -0
- package/src/adapters/nanoclaw-delivery-store.js +0 -0
- package/src/adapters/nanoclaw-protocol.js +349 -0
- package/src/adapters/nanoclaw.js +661 -9
- package/src/adapters/opencode.js +541 -75
- package/src/adapters/workspace-prompt.js +0 -79
- package/src/agent-rows.js +13 -1
- package/src/auth-guidance.js +98 -0
- package/src/cli.js +36 -7
- package/src/config.js +8 -2
- package/src/daemon.js +165 -51
- package/src/installer.js +572 -10
- package/src/paths.js +125 -2
- package/src/tui.js +29 -2
- package/src/update-check.js +26 -14
- package/src/workspace-client.js +31 -29
|
@@ -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)
|
|
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
|
|
488
|
-
|
|
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 (
|
|
430
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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 (
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
//
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
348
|
+
if (pid && Daemon._isAlive(pid)) return pid;
|
|
334
349
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
921
|
-
|
|
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
|
-
|
|
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
|
-
|
|
934
|
-
if (isOtherAlive(pid)) return pid;
|
|
1047
|
+
consider(raw && raw.pid);
|
|
935
1048
|
}
|
|
936
1049
|
} catch {}
|
|
937
|
-
|
|
1050
|
+
|
|
1051
|
+
return pids;
|
|
938
1052
|
}
|
|
939
1053
|
}
|
|
940
1054
|
|