@openagents-org/agent-launcher 0.2.171 → 0.2.172
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 +2 -2
- package/registry.json +49 -2
- package/src/adapters/antigravity-stream.js +242 -0
- package/src/adapters/antigravity.js +390 -0
- package/src/adapters/index.js +4 -1
- package/src/cli.js +33 -0
- package/src/daemon.js +116 -1
- package/src/index.js +9 -0
- package/src/probe.js +318 -0
- package/src/registry.js +2 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openagents-org/agent-launcher",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "OpenAgents Launcher
|
|
3
|
+
"version": "0.2.172",
|
|
4
|
+
"description": "OpenAgents Launcher — install, configure, and run AI coding agents from your terminal",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"agn": "bin/agent-connector.js",
|
package/registry.json
CHANGED
|
@@ -152,6 +152,7 @@
|
|
|
152
152
|
"login_command": "claude login",
|
|
153
153
|
"alt_check": "claude --print hi"
|
|
154
154
|
},
|
|
155
|
+
"probe": { "args": ["--print", "hi"], "timeout_s": 120 },
|
|
155
156
|
"env_config": []
|
|
156
157
|
},
|
|
157
158
|
{
|
|
@@ -417,7 +418,7 @@
|
|
|
417
418
|
{
|
|
418
419
|
"name": "gemini",
|
|
419
420
|
"label": "Gemini CLI",
|
|
420
|
-
"description": "Google's
|
|
421
|
+
"description": "Google's Gemini CLI (legacy — superseded by Antigravity CLI; needs a Gemini API key or Code Assist license since June 2026)",
|
|
421
422
|
"homepage": "https://github.com/google-gemini/gemini-cli",
|
|
422
423
|
"tags": [
|
|
423
424
|
"coding",
|
|
@@ -462,13 +463,59 @@
|
|
|
462
463
|
],
|
|
463
464
|
"saved_env_key": "GEMINI_API_KEY",
|
|
464
465
|
"login_command": "gemini",
|
|
465
|
-
"not_ready_message": "
|
|
466
|
+
"not_ready_message": "Set GEMINI_API_KEY (Google ended individual sign-in for Gemini CLI in June 2026), or run `gemini` if your org has a Code Assist license.",
|
|
466
467
|
"unreadable_message": "Gemini credentials were found but could not be read. Check permissions on ~/.gemini, or run `gemini` to sign in again.",
|
|
467
468
|
"auth_detected_labels": {
|
|
468
469
|
"cli_login": "Google account sign-in detected",
|
|
469
470
|
"api_key": "API key detected"
|
|
470
471
|
}
|
|
471
472
|
},
|
|
473
|
+
"probe": { "args": ["-p", "hi"], "timeout_s": 120 },
|
|
474
|
+
"env_config": []
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
"name": "antigravity",
|
|
478
|
+
"label": "Antigravity CLI",
|
|
479
|
+
"description": "Google's agent-first CLI (agy) — the successor to Gemini CLI",
|
|
480
|
+
"homepage": "https://antigravity.google",
|
|
481
|
+
"tags": [
|
|
482
|
+
"coding",
|
|
483
|
+
"google",
|
|
484
|
+
"cli"
|
|
485
|
+
],
|
|
486
|
+
"builtin": true,
|
|
487
|
+
"support": { "install": true, "workspace": true, "collaboration": true },
|
|
488
|
+
"install": {
|
|
489
|
+
"binary": "agy",
|
|
490
|
+
"macos": "curl -fsSL https://antigravity.google/cli/install.sh | bash",
|
|
491
|
+
"linux": "curl -fsSL https://antigravity.google/cli/install.sh | bash",
|
|
492
|
+
"windows": "irm https://antigravity.google/cli/install.ps1 | iex"
|
|
493
|
+
},
|
|
494
|
+
"adapter": {
|
|
495
|
+
"module": "openagents.adapters.antigravity",
|
|
496
|
+
"class": "AntigravityAdapter"
|
|
497
|
+
},
|
|
498
|
+
"launch": {
|
|
499
|
+
"args": [
|
|
500
|
+
"-p",
|
|
501
|
+
"Your agent name is '{agent_name}'.",
|
|
502
|
+
"--dangerously-skip-permissions",
|
|
503
|
+
"--output-format",
|
|
504
|
+
"stream-json"
|
|
505
|
+
]
|
|
506
|
+
},
|
|
507
|
+
"check_ready": {
|
|
508
|
+
"env_vars": [
|
|
509
|
+
"GEMINI_API_KEY"
|
|
510
|
+
],
|
|
511
|
+
"saved_env_key": "GEMINI_API_KEY",
|
|
512
|
+
"login_command": "agy",
|
|
513
|
+
"not_ready_message": "Needs sign-in — run `agy` to sign in with Google, or set GEMINI_API_KEY.",
|
|
514
|
+
"auth_detected_labels": {
|
|
515
|
+
"cli_login": "Google account sign-in detected",
|
|
516
|
+
"api_key": "API key detected"
|
|
517
|
+
}
|
|
518
|
+
},
|
|
472
519
|
"env_config": []
|
|
473
520
|
},
|
|
474
521
|
{
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure helpers for the Antigravity CLI (agy) adapter — argv construction,
|
|
5
|
+
* stream-json event interpretation, and failure classification. No I/O and no
|
|
6
|
+
* process state, so every branch is unit-testable (see
|
|
7
|
+
* test/antigravity-stream.test.js).
|
|
8
|
+
*
|
|
9
|
+
* agy's headless protocol (`--output-format stream-json`) is NDJSON with an
|
|
10
|
+
* `event` field per line: exactly one `init`, any number of `step_update`,
|
|
11
|
+
* exactly one `result`. That differs from the old Gemini CLI stream (`type`
|
|
12
|
+
* field, `message`/`tool_use` events), which is why this is a new module
|
|
13
|
+
* rather than a tweak to the gemini adapter.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Build the argv tail (everything after the binary) for one headless run. */
|
|
17
|
+
function buildAgyArgv({ prompt, model, conversationId, skipResume = false } = {}) {
|
|
18
|
+
const args = [
|
|
19
|
+
'-p', prompt || '',
|
|
20
|
+
// Counterpart of gemini's `-y`: headless runs have no interactive
|
|
21
|
+
// approval prompt, and a soft-denied tool call silently degrades the
|
|
22
|
+
// answer. The daemon is the approval boundary here, same as every other
|
|
23
|
+
// CLI adapter.
|
|
24
|
+
'--dangerously-skip-permissions',
|
|
25
|
+
'--output-format', 'stream-json',
|
|
26
|
+
];
|
|
27
|
+
if (model) args.push('--model', model);
|
|
28
|
+
if (conversationId && !skipResume) args.push('--conversation', conversationId);
|
|
29
|
+
return args;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Parse one NDJSON line. Returns the event object, or null for non-events. */
|
|
33
|
+
function parseAgyEvent(line) {
|
|
34
|
+
const trimmed = (line || '').trim();
|
|
35
|
+
if (!trimmed) return null;
|
|
36
|
+
let obj;
|
|
37
|
+
try { obj = JSON.parse(trimmed); } catch { return null; }
|
|
38
|
+
if (!obj || typeof obj !== 'object' || typeof obj.event !== 'string') return null;
|
|
39
|
+
return obj;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One-line preview of a tool call's parameters for the status ticker. */
|
|
43
|
+
function toolPreview(parameters) {
|
|
44
|
+
if (!parameters || typeof parameters !== 'object') return '';
|
|
45
|
+
// agy uses PascalCase parameter names (e.g. CommandLine); check both.
|
|
46
|
+
const pick = (...names) => {
|
|
47
|
+
for (const n of names) {
|
|
48
|
+
const v = parameters[n];
|
|
49
|
+
if (typeof v === 'string' && v) return v;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
};
|
|
53
|
+
return (
|
|
54
|
+
pick('CommandLine', 'command') ||
|
|
55
|
+
pick('AbsolutePath', 'file_path', 'path') ||
|
|
56
|
+
pick('Query', 'query', 'pattern') ||
|
|
57
|
+
JSON.stringify(parameters).slice(0, 150)
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Reducer over one run's event stream. `consume(event)` returns display
|
|
63
|
+
* actions for the adapter to translate into workspace calls:
|
|
64
|
+
*
|
|
65
|
+
* {type:'conversation', id} — persist for --conversation resume
|
|
66
|
+
* {type:'thinking', text} — a finished agent_response block
|
|
67
|
+
* {type:'tool', name, preview} — a tool step began
|
|
68
|
+
* {type:'result', status, response, error}
|
|
69
|
+
*
|
|
70
|
+
* `finalResponse()` afterwards yields the text to post: the result envelope's
|
|
71
|
+
* `response` when present (authoritative), else the last completed
|
|
72
|
+
* agent_response block — mirroring the gemini adapter's "text after the last
|
|
73
|
+
* tool use" semantics for streams that die before their result event.
|
|
74
|
+
*/
|
|
75
|
+
class AgyRunState {
|
|
76
|
+
constructor() {
|
|
77
|
+
this.conversationId = null;
|
|
78
|
+
this.status = null;
|
|
79
|
+
this.error = null;
|
|
80
|
+
this._resultResponse = '';
|
|
81
|
+
this._blocks = []; // completed agent_response texts since last tool
|
|
82
|
+
this._pending = ''; // text_delta accumulator for the ACTIVE block
|
|
83
|
+
this._toolSteps = new Set(); // step_index values already announced
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
consume(event) {
|
|
87
|
+
if (!event || typeof event !== 'object') return [];
|
|
88
|
+
const actions = [];
|
|
89
|
+
|
|
90
|
+
if (event.event === 'init') {
|
|
91
|
+
if (event.conversation_id) {
|
|
92
|
+
this.conversationId = event.conversation_id;
|
|
93
|
+
actions.push({ type: 'conversation', id: event.conversation_id });
|
|
94
|
+
}
|
|
95
|
+
return actions;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (event.event === 'step_update') {
|
|
99
|
+
const step = event.step_update || {};
|
|
100
|
+
if (step.step_type === 'agent_response') {
|
|
101
|
+
if (typeof step.text_delta === 'string') this._pending += step.text_delta;
|
|
102
|
+
if (step.state === 'DONE') {
|
|
103
|
+
const text = this._pending.trim();
|
|
104
|
+
this._pending = '';
|
|
105
|
+
if (text) {
|
|
106
|
+
this._blocks.push(text);
|
|
107
|
+
actions.push({ type: 'thinking', text });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} else if (step.step_type === 'tool') {
|
|
111
|
+
// Announce each tool step once, on first sight — agy repeats the step
|
|
112
|
+
// across ACTIVE/DONE updates.
|
|
113
|
+
const key = step.step_index != null ? step.step_index : `t${this._toolSteps.size}`;
|
|
114
|
+
if (!this._toolSteps.has(key)) {
|
|
115
|
+
this._toolSteps.add(key);
|
|
116
|
+
const info = step.tool_info || {};
|
|
117
|
+
const name = info.name || step.tool_name || 'tool';
|
|
118
|
+
actions.push({ type: 'tool', name, preview: toolPreview(info.parameters) });
|
|
119
|
+
// A tool ran: any text before it was narration, not the answer.
|
|
120
|
+
this._blocks = [];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return actions;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (event.event === 'result') {
|
|
127
|
+
const result = event.result || {};
|
|
128
|
+
if (result.conversation_id && !this.conversationId) {
|
|
129
|
+
this.conversationId = result.conversation_id;
|
|
130
|
+
actions.push({ type: 'conversation', id: result.conversation_id });
|
|
131
|
+
}
|
|
132
|
+
this.status = result.status || null;
|
|
133
|
+
this.error = result.error || null;
|
|
134
|
+
if (typeof result.response === 'string') this._resultResponse = result.response;
|
|
135
|
+
actions.push({
|
|
136
|
+
type: 'result',
|
|
137
|
+
status: this.status,
|
|
138
|
+
response: this._resultResponse,
|
|
139
|
+
error: this.error,
|
|
140
|
+
});
|
|
141
|
+
return actions;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return actions;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** True once the stream produced any user-visible text. */
|
|
148
|
+
sawText() {
|
|
149
|
+
return this._blocks.length > 0 || !!this._resultResponse.trim();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
finalResponse() {
|
|
153
|
+
const fromResult = this._resultResponse.trim();
|
|
154
|
+
if (fromResult) return fromResult;
|
|
155
|
+
// Stream died before its result event — fall back to the accumulated
|
|
156
|
+
// agent_response text (plus any half-flushed delta).
|
|
157
|
+
return [...this._blocks, this._pending.trim()].filter(Boolean).join('\n\n').trim();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const AUTH_RE = /authentication (required|failed)|not authenticated|sign[ -]?in|log[ -]?in required|no valid credentials|GEMINI_API_KEY/i;
|
|
162
|
+
const PROVIDER_RE = /modelProvider|model provider/i;
|
|
163
|
+
const MODEL_RE = /unknown model|invalid model/i;
|
|
164
|
+
const TIMEOUT_RE = /print-timeout|timed? ?out/i;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Turn a failed run (non-zero exit, or a result event with status ERROR) into
|
|
168
|
+
* a { kind, message } the adapter can post verbatim. This is the fix for the
|
|
169
|
+
* "No response generated. Please try again." dead end: the real reason was in
|
|
170
|
+
* stderr all along, so surface it.
|
|
171
|
+
*/
|
|
172
|
+
function classifyAgyFailure({ code, stderr, error } = {}) {
|
|
173
|
+
// The docs describe `result.error` as {type, message}; agy 1.1.17 actually
|
|
174
|
+
// emits a plain string ("authentication failed or timed out"). Take both.
|
|
175
|
+
const err = error && typeof error === 'object' ? error : {};
|
|
176
|
+
const errText =
|
|
177
|
+
typeof error === 'string' ? error : [err.type, err.message].filter(Boolean).join(' ');
|
|
178
|
+
const text = [errText, stderr].filter(Boolean).join('\n');
|
|
179
|
+
|
|
180
|
+
if (AUTH_RE.test(text)) {
|
|
181
|
+
return {
|
|
182
|
+
kind: 'auth',
|
|
183
|
+
message:
|
|
184
|
+
'Antigravity CLI needs authentication. Run `agy` once in a terminal to ' +
|
|
185
|
+
'sign in with Google, or set GEMINI_API_KEY for this agent (the ' +
|
|
186
|
+
'connector configures the provider automatically).',
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (PROVIDER_RE.test(text)) {
|
|
190
|
+
return {
|
|
191
|
+
kind: 'config',
|
|
192
|
+
message:
|
|
193
|
+
'Antigravity CLI is configured for API-key auth but no key is set. ' +
|
|
194
|
+
'Set GEMINI_API_KEY for this agent, or remove "modelProvider" from ' +
|
|
195
|
+
'~/.gemini/antigravity-cli/settings.json to use Google sign-in.',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
if (MODEL_RE.test(text)) {
|
|
199
|
+
return {
|
|
200
|
+
kind: 'model',
|
|
201
|
+
message:
|
|
202
|
+
'Antigravity CLI rejected the configured model. Check ANTIGRAVITY_MODEL ' +
|
|
203
|
+
'(list valid slugs with `agy models`).',
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (TIMEOUT_RE.test(text)) {
|
|
207
|
+
return { kind: 'timeout', message: 'Antigravity CLI timed out before producing a response.' };
|
|
208
|
+
}
|
|
209
|
+
const detail = (errText || (stderr || '').trim().split('\n').pop() || '').slice(0, 200);
|
|
210
|
+
return {
|
|
211
|
+
kind: 'unknown',
|
|
212
|
+
message: detail
|
|
213
|
+
? `Antigravity CLI failed (exit ${code ?? '?'}): ${detail}`
|
|
214
|
+
: `Antigravity CLI exited with code ${code ?? '?'} without a response.`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Filesystem candidates for the agy binary, beyond a PATH lookup. The install
|
|
220
|
+
* script targets ~/.local/bin on macOS/Linux and %LOCALAPPDATA%\agy\bin on
|
|
221
|
+
* Windows. Pure: the adapter applies fs.existsSync.
|
|
222
|
+
*/
|
|
223
|
+
function agyBinaryCandidates({ home, isWindows, localAppData } = {}) {
|
|
224
|
+
if (isWindows) {
|
|
225
|
+
const base = localAppData || '';
|
|
226
|
+
return base ? [`${base}\\agy\\bin\\agy.exe`] : [];
|
|
227
|
+
}
|
|
228
|
+
return [
|
|
229
|
+
`${home}/.local/bin/agy`,
|
|
230
|
+
'/opt/homebrew/bin/agy',
|
|
231
|
+
'/usr/local/bin/agy',
|
|
232
|
+
];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
module.exports = {
|
|
236
|
+
buildAgyArgv,
|
|
237
|
+
parseAgyEvent,
|
|
238
|
+
toolPreview,
|
|
239
|
+
AgyRunState,
|
|
240
|
+
classifyAgyFailure,
|
|
241
|
+
agyBinaryCandidates,
|
|
242
|
+
};
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Antigravity CLI (agy) adapter for OpenAgents workspace.
|
|
3
|
+
*
|
|
4
|
+
* Successor to the Gemini CLI adapter: Google transitioned Gemini CLI into
|
|
5
|
+
* Antigravity CLI in May 2026 and cut individual-account access to the old
|
|
6
|
+
* CLI that June. agy is a closed-source Go binary — no npm runtime prefix and
|
|
7
|
+
* no .cmd/.js shim resolution needed — driven headless via
|
|
8
|
+
* `agy -p <prompt> --output-format stream-json`.
|
|
9
|
+
*
|
|
10
|
+
* Stream interpretation, argv building, and failure classification live in
|
|
11
|
+
* ./antigravity-stream.js (pure, unit-tested); this file owns process
|
|
12
|
+
* lifecycle and workspace I/O, structurally mirroring gemini.js.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const os = require('os');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const { execSync, spawn } = require('child_process');
|
|
21
|
+
|
|
22
|
+
const BaseAdapter = require('./base');
|
|
23
|
+
const { whereBinary } = require('../paths');
|
|
24
|
+
const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
|
|
25
|
+
const { buildClaudeSystemPrompt } = require('./workspace-prompt');
|
|
26
|
+
const {
|
|
27
|
+
buildAgyArgv,
|
|
28
|
+
parseAgyEvent,
|
|
29
|
+
AgyRunState,
|
|
30
|
+
classifyAgyFailure,
|
|
31
|
+
agyBinaryCandidates,
|
|
32
|
+
} = require('./antigravity-stream');
|
|
33
|
+
|
|
34
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
35
|
+
|
|
36
|
+
class AntigravityAdapter extends BaseAdapter {
|
|
37
|
+
/**
|
|
38
|
+
* @param {object} opts - BaseAdapter opts plus:
|
|
39
|
+
* @param {Set} [opts.disabledModules]
|
|
40
|
+
* @param {string} [opts.workingDir]
|
|
41
|
+
*/
|
|
42
|
+
constructor(opts) {
|
|
43
|
+
super(opts);
|
|
44
|
+
this.disabledModules = opts.disabledModules || new Set();
|
|
45
|
+
// Read-only pinned context, same as gemini: agy has no MCP bridge to the
|
|
46
|
+
// workspace knowledge tools, so it can see the decision log but not write it.
|
|
47
|
+
this._usesPinnedContext = true;
|
|
48
|
+
this._channelConversations = {}; // channel → agy conversation_id
|
|
49
|
+
this._channelProcesses = {}; // channel → child process
|
|
50
|
+
this._sessionsFile = path.join(
|
|
51
|
+
os.homedir(), '.openagents', 'sessions',
|
|
52
|
+
`${this.workspaceId}_${this.agentName}_antigravity.json`
|
|
53
|
+
);
|
|
54
|
+
this._loadSessions();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
_loadSessions() {
|
|
58
|
+
try {
|
|
59
|
+
if (fs.existsSync(this._sessionsFile)) {
|
|
60
|
+
const data = JSON.parse(fs.readFileSync(this._sessionsFile, 'utf-8'));
|
|
61
|
+
if (data && typeof data === 'object') {
|
|
62
|
+
Object.assign(this._channelConversations, data);
|
|
63
|
+
this._log(`Loaded ${Object.keys(data).length} conversation(s)`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
this._log('Could not load sessions file, starting fresh');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
_saveSessions() {
|
|
72
|
+
try {
|
|
73
|
+
const dir = path.dirname(this._sessionsFile);
|
|
74
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
75
|
+
fs.writeFileSync(this._sessionsFile, JSON.stringify(this._channelConversations));
|
|
76
|
+
} catch {}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async _onControlAction(action, _payload) {
|
|
80
|
+
if (action === 'stop') {
|
|
81
|
+
await this._stopAllProcesses();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async _stopProcess(proc) {
|
|
86
|
+
if (!proc || proc.exitCode !== null) return;
|
|
87
|
+
try {
|
|
88
|
+
if (IS_WINDOWS) {
|
|
89
|
+
try { execSync(`taskkill /F /T /PID ${proc.pid}`, { timeout: 5000 }); } catch {}
|
|
90
|
+
} else {
|
|
91
|
+
try { process.kill(-proc.pid, 'SIGTERM'); } catch {
|
|
92
|
+
proc.kill('SIGTERM');
|
|
93
|
+
}
|
|
94
|
+
await new Promise((resolve) => {
|
|
95
|
+
const timeout = setTimeout(() => {
|
|
96
|
+
try { process.kill(-proc.pid, 'SIGKILL'); } catch {
|
|
97
|
+
proc.kill('SIGKILL');
|
|
98
|
+
}
|
|
99
|
+
resolve();
|
|
100
|
+
}, 5000);
|
|
101
|
+
proc.on('exit', () => { clearTimeout(timeout); resolve(); });
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async _stopAllProcesses() {
|
|
108
|
+
const entries = Object.entries(this._channelProcesses);
|
|
109
|
+
if (!entries.length) return;
|
|
110
|
+
this._log(`Stopping ${entries.length} running process(es)...`);
|
|
111
|
+
for (const [channel, proc] of entries) {
|
|
112
|
+
await this._stopProcess(proc);
|
|
113
|
+
delete this._channelProcesses[channel];
|
|
114
|
+
delete this._channelQueues[channel];
|
|
115
|
+
try {
|
|
116
|
+
await this.sendStatus(channel, 'Execution stopped by user');
|
|
117
|
+
} catch {}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
_findAgyBinary() {
|
|
122
|
+
// PATH first (codepage-safe lookup, see whereBinary), then the install
|
|
123
|
+
// script's fixed destinations.
|
|
124
|
+
const viaWhere = whereBinary('agy');
|
|
125
|
+
if (viaWhere) return viaWhere;
|
|
126
|
+
const candidates = agyBinaryCandidates({
|
|
127
|
+
home: os.homedir(),
|
|
128
|
+
isWindows: IS_WINDOWS,
|
|
129
|
+
localAppData: process.env.LOCALAPPDATA,
|
|
130
|
+
});
|
|
131
|
+
for (const c of candidates) {
|
|
132
|
+
if (fs.existsSync(c)) return c;
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* agy ignores a bare GEMINI_API_KEY unless the settings file also selects
|
|
139
|
+
* the gemini provider — and, inversely, refuses to start when the provider
|
|
140
|
+
* is selected but the key is missing. When the user configured a key for
|
|
141
|
+
* this agent, select the provider for them; never touch the file otherwise
|
|
142
|
+
* (a keyring/OAuth sign-in needs no provider entry and must not gain one,
|
|
143
|
+
* or the CLI stops starting the day the key is removed).
|
|
144
|
+
*/
|
|
145
|
+
_ensureAgyAuth() {
|
|
146
|
+
if (this._agyAuthReady) return;
|
|
147
|
+
this._agyAuthReady = true;
|
|
148
|
+
try {
|
|
149
|
+
const env = this.agentEnv || process.env;
|
|
150
|
+
const apiKey = (env.GEMINI_API_KEY || '').trim();
|
|
151
|
+
if (!apiKey) return; // OAuth / keyring — leave the user's setup alone.
|
|
152
|
+
const dir = path.join(os.homedir(), '.gemini', 'antigravity-cli');
|
|
153
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
154
|
+
const file = path.join(dir, 'settings.json');
|
|
155
|
+
let settings = {};
|
|
156
|
+
try { settings = JSON.parse(fs.readFileSync(file, 'utf-8')) || {}; } catch {}
|
|
157
|
+
if (settings.modelProvider === 'gemini') return; // already selected.
|
|
158
|
+
settings.modelProvider = 'gemini';
|
|
159
|
+
fs.writeFileSync(file, JSON.stringify(settings, null, 2));
|
|
160
|
+
this._log('Configured Antigravity CLI for API-key auth (~/.gemini/antigravity-cli/settings.json)');
|
|
161
|
+
} catch (e) {
|
|
162
|
+
this._log(`agy auth setup skipped: ${e.message}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
_buildAgyCmd(prompt, channelName, { skipResume = false } = {}) {
|
|
167
|
+
this._ensureAgyAuth();
|
|
168
|
+
const agyBin = this._findAgyBinary();
|
|
169
|
+
if (!agyBin) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
'agy CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const pinned = this.pinnedPromptOpts(channelName);
|
|
176
|
+
const systemPrompt = '\n' + buildClaudeSystemPrompt({
|
|
177
|
+
agentName: this.agentName,
|
|
178
|
+
workspaceId: this.workspaceId,
|
|
179
|
+
channelName,
|
|
180
|
+
mode: this._mode,
|
|
181
|
+
decisionLog: pinned.decisionLog ? { ...pinned.decisionLog, writeAccess: false } : null,
|
|
182
|
+
glossary: pinned.glossary ? { ...pinned.glossary, writeAccess: false } : null,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// No system-prompt flag in agy's print mode either — prepend it.
|
|
186
|
+
const fullPrompt = `${systemPrompt}\n\n---\n\nUser message:\n${prompt}`;
|
|
187
|
+
|
|
188
|
+
const env = this.agentEnv || process.env;
|
|
189
|
+
const model = (env.ANTIGRAVITY_MODEL || env.AGY_MODEL || '').trim();
|
|
190
|
+
|
|
191
|
+
const args = buildAgyArgv({
|
|
192
|
+
prompt: fullPrompt,
|
|
193
|
+
model,
|
|
194
|
+
conversationId: this._channelConversations[channelName],
|
|
195
|
+
skipResume,
|
|
196
|
+
});
|
|
197
|
+
return { cmd: [agyBin, ...args] };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async _handleMessage(msg) {
|
|
201
|
+
let content = (msg.content || '').trim();
|
|
202
|
+
const attachments = msg.attachments || [];
|
|
203
|
+
|
|
204
|
+
const attText = formatAttachmentsForPrompt(attachments);
|
|
205
|
+
if (attText) {
|
|
206
|
+
content = content ? content + attText : attText.trim();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!content) return;
|
|
210
|
+
|
|
211
|
+
const msgChannel = msg.sessionId || this.channelName;
|
|
212
|
+
const sender = msg.senderName || msg.senderType || 'user';
|
|
213
|
+
this._log(`Processing message from ${sender} in ${msgChannel}: ${content.slice(0, 80)}...`);
|
|
214
|
+
|
|
215
|
+
if (!this._titledSessions.has(msgChannel)) {
|
|
216
|
+
this._titledSessions.add(msgChannel);
|
|
217
|
+
try {
|
|
218
|
+
const info = await this.client.getSession(this.workspaceId, msgChannel, this.token);
|
|
219
|
+
const resumeFrom = info.resumeFrom;
|
|
220
|
+
if (resumeFrom && !this._channelConversations[msgChannel]) {
|
|
221
|
+
const sourceConversation = this._channelConversations[resumeFrom];
|
|
222
|
+
if (sourceConversation) {
|
|
223
|
+
this._channelConversations[msgChannel] = sourceConversation;
|
|
224
|
+
this._saveSessions();
|
|
225
|
+
this._log(`Resuming channel ${msgChannel} from ${resumeFrom}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const title = generateSessionTitle(content);
|
|
229
|
+
if (title && !info.titleManuallySet && SESSION_DEFAULT_RE.test(info.title || '')) {
|
|
230
|
+
await this.client.updateSession(
|
|
231
|
+
this.workspaceId, msgChannel, this.token,
|
|
232
|
+
{ title, autoTitle: true }
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
} catch {}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
await this.sendStatus(msgChannel, 'thinking...');
|
|
239
|
+
|
|
240
|
+
let cmd;
|
|
241
|
+
const cleanEnv = { ...(this.agentEnv || process.env) };
|
|
242
|
+
|
|
243
|
+
let _shouldRetry = false;
|
|
244
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
245
|
+
try {
|
|
246
|
+
const built = this._buildAgyCmd(content, msgChannel, { skipResume: attempt > 0 });
|
|
247
|
+
cmd = built.cmd;
|
|
248
|
+
} catch (e) {
|
|
249
|
+
await this.sendError(msgChannel, e.message);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
const proc = spawn(cmd[0], cmd.slice(1), {
|
|
255
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
256
|
+
env: cleanEnv,
|
|
257
|
+
cwd: this.workingDir,
|
|
258
|
+
detached: !IS_WINDOWS,
|
|
259
|
+
windowsHide: true,
|
|
260
|
+
});
|
|
261
|
+
this._channelProcesses[msgChannel] = proc;
|
|
262
|
+
|
|
263
|
+
const run = new AgyRunState();
|
|
264
|
+
let stderrBuf = '';
|
|
265
|
+
let lineBuffer = '';
|
|
266
|
+
let _pendingLines = Promise.resolve();
|
|
267
|
+
|
|
268
|
+
if (proc.stderr) {
|
|
269
|
+
proc.stderr.on('data', (chunk) => { stderrBuf += chunk.toString('utf-8'); });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
_shouldRetry = await new Promise((resolve, reject) => {
|
|
273
|
+
let consecutiveTimeouts = 0;
|
|
274
|
+
let lastDataTime = Date.now();
|
|
275
|
+
let timeoutTimer = null;
|
|
276
|
+
|
|
277
|
+
const resetTimeout = () => {
|
|
278
|
+
consecutiveTimeouts = 0;
|
|
279
|
+
lastDataTime = Date.now();
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const startTimeoutMonitor = () => {
|
|
283
|
+
timeoutTimer = setInterval(async () => {
|
|
284
|
+
const elapsed = Date.now() - lastDataTime;
|
|
285
|
+
if (elapsed >= 15000) {
|
|
286
|
+
consecutiveTimeouts++;
|
|
287
|
+
lastDataTime = Date.now();
|
|
288
|
+
if (consecutiveTimeouts === 2) {
|
|
289
|
+
try { await this.sendStatus(msgChannel, 'Processing...'); } catch {}
|
|
290
|
+
}
|
|
291
|
+
if (consecutiveTimeouts >= 20) {
|
|
292
|
+
this._log(`Process idle for ${consecutiveTimeouts * 15}s, killing...`);
|
|
293
|
+
await this._stopProcess(proc);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}, 15000);
|
|
297
|
+
};
|
|
298
|
+
startTimeoutMonitor();
|
|
299
|
+
|
|
300
|
+
const processLine = async (line) => {
|
|
301
|
+
const event = parseAgyEvent(line);
|
|
302
|
+
if (!event) return;
|
|
303
|
+
resetTimeout();
|
|
304
|
+
for (const action of run.consume(event)) {
|
|
305
|
+
if (action.type === 'conversation') {
|
|
306
|
+
this._channelConversations[msgChannel] = action.id;
|
|
307
|
+
this._saveSessions();
|
|
308
|
+
} else if (action.type === 'thinking') {
|
|
309
|
+
try { await this.sendThinking(msgChannel, action.text); } catch {}
|
|
310
|
+
} else if (action.type === 'tool') {
|
|
311
|
+
try { await this.sendStatus(msgChannel, `${action.name} › ${action.preview}`); } catch {}
|
|
312
|
+
}
|
|
313
|
+
// 'result' is read from `run` after exit.
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
proc.on('exit', async (code) => {
|
|
318
|
+
if (timeoutTimer) clearInterval(timeoutTimer);
|
|
319
|
+
|
|
320
|
+
try { await _pendingLines; } catch {}
|
|
321
|
+
|
|
322
|
+
const lines = lineBuffer.split('\n');
|
|
323
|
+
for (const line of lines) {
|
|
324
|
+
try { await processLine(line); } catch {}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
delete this._channelProcesses[msgChannel];
|
|
328
|
+
|
|
329
|
+
if (code !== 0) {
|
|
330
|
+
this._log(`CLI exited with code ${code}`);
|
|
331
|
+
if (stderrBuf.trim()) {
|
|
332
|
+
this._log(`stderr: ${stderrBuf.trim().slice(0, 500)}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const response = run.finalResponse();
|
|
337
|
+
const failed = code !== 0 || run.status === 'ERROR';
|
|
338
|
+
|
|
339
|
+
if (response && !failed) {
|
|
340
|
+
try { await this.sendResponse(msgChannel, response); } catch {}
|
|
341
|
+
resolve(false);
|
|
342
|
+
} else if (failed && this._channelConversations[msgChannel] && attempt === 0) {
|
|
343
|
+
// A stored conversation id the CLI no longer knows fails the
|
|
344
|
+
// whole run — clear it and retry once from scratch.
|
|
345
|
+
this._log(`Stale conversation for ${msgChannel}, clearing and retrying without resume`);
|
|
346
|
+
delete this._channelConversations[msgChannel];
|
|
347
|
+
this._saveSessions();
|
|
348
|
+
resolve(true);
|
|
349
|
+
} else if (failed) {
|
|
350
|
+
// Surface the real reason (auth, provider config, bad model)
|
|
351
|
+
// instead of a generic retry prompt — the gemini adapter's
|
|
352
|
+
// silent-stderr dead end is what this branch fixes.
|
|
353
|
+
const { kind, message } = classifyAgyFailure({ code, stderr: stderrBuf, error: run.error });
|
|
354
|
+
this._log(`run failed (${kind})`);
|
|
355
|
+
try { await this.sendError(msgChannel, message); } catch {}
|
|
356
|
+
resolve(false);
|
|
357
|
+
} else {
|
|
358
|
+
if (!run.sawText()) {
|
|
359
|
+
try { await this.sendResponse(msgChannel, 'No response generated. Please try again.'); } catch {}
|
|
360
|
+
}
|
|
361
|
+
resolve(false);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
proc.on('error', (err) => {
|
|
366
|
+
if (timeoutTimer) clearInterval(timeoutTimer);
|
|
367
|
+
reject(err);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
proc.stdout.on('data', (chunk) => {
|
|
371
|
+
lineBuffer += chunk.toString('utf-8');
|
|
372
|
+
resetTimeout();
|
|
373
|
+
const lines = lineBuffer.split('\n');
|
|
374
|
+
lineBuffer = lines.pop();
|
|
375
|
+
for (const line of lines) {
|
|
376
|
+
_pendingLines = _pendingLines.then(() => processLine(line)).catch(() => {});
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
} catch (e) {
|
|
381
|
+
this._log(`Error handling message: ${e.message}`);
|
|
382
|
+
await this.sendError(msgChannel, `Error processing message: ${e.message}`);
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
if (!_shouldRetry) break;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
module.exports = AntigravityAdapter;
|
package/src/adapters/index.js
CHANGED
|
@@ -22,6 +22,7 @@ const AmpAdapter = require('./amp');
|
|
|
22
22
|
const MiniSweAgentAdapter = require('./mini');
|
|
23
23
|
const PiAdapter = require('./pi');
|
|
24
24
|
const DeepSeekAdapter = require('./deepseek');
|
|
25
|
+
const AntigravityAdapter = require('./antigravity');
|
|
25
26
|
|
|
26
27
|
const ADAPTER_MAP = {
|
|
27
28
|
openclaw: OpenClawAdapter,
|
|
@@ -41,11 +42,12 @@ const ADAPTER_MAP = {
|
|
|
41
42
|
'mini-swe-agent': MiniSweAgentAdapter,
|
|
42
43
|
pi: PiAdapter,
|
|
43
44
|
deepseek: DeepSeekAdapter,
|
|
45
|
+
antigravity: AntigravityAdapter,
|
|
44
46
|
};
|
|
45
47
|
|
|
46
48
|
/**
|
|
47
49
|
* Create an adapter instance for the given agent type.
|
|
48
|
-
* @param {string} type - Agent type (openclaw, claude, codex, opencode, nanoclaw, cursor, hermes, gemini, kimi, aider, goose, copilot, cline, amp, mini-swe-agent, pi, deepseek)
|
|
50
|
+
* @param {string} type - Agent type (openclaw, claude, codex, opencode, nanoclaw, cursor, hermes, gemini, kimi, aider, goose, copilot, cline, amp, mini-swe-agent, pi, deepseek, antigravity)
|
|
49
51
|
* @param {object} opts - Adapter constructor options
|
|
50
52
|
* @returns {BaseAdapter}
|
|
51
53
|
*/
|
|
@@ -76,6 +78,7 @@ module.exports = {
|
|
|
76
78
|
MiniSweAgentAdapter,
|
|
77
79
|
PiAdapter,
|
|
78
80
|
DeepSeekAdapter,
|
|
81
|
+
AntigravityAdapter,
|
|
79
82
|
createAdapter,
|
|
80
83
|
ADAPTER_MAP,
|
|
81
84
|
};
|
package/src/cli.js
CHANGED
|
@@ -806,6 +806,37 @@ async function cmdTestLLM(connector, _flags, positional) {
|
|
|
806
806
|
}
|
|
807
807
|
}
|
|
808
808
|
|
|
809
|
+
async function cmdProbe(connector, flags, positional) {
|
|
810
|
+
const target = positional[0];
|
|
811
|
+
if (!target) { print('Usage: agn probe <type|agent> [--json]'); return; }
|
|
812
|
+
|
|
813
|
+
// Accept an agent name as a convenience — probe its type.
|
|
814
|
+
let type = target;
|
|
815
|
+
try {
|
|
816
|
+
const agent = connector.config.getAgent(target);
|
|
817
|
+
if (agent) type = agent.type || 'openclaw';
|
|
818
|
+
} catch {}
|
|
819
|
+
|
|
820
|
+
const result = await connector.probeAgentType(type);
|
|
821
|
+
|
|
822
|
+
if (flags && flags.json) {
|
|
823
|
+
// Machine consumers (the daemon's probe_agent node command) read ONLY
|
|
824
|
+
// stdout JSON — same contract as `agn runtimes --json`.
|
|
825
|
+
process.stdout.write(JSON.stringify(result));
|
|
826
|
+
if (!result.ok) process.exitCode = 1;
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (result.ok) {
|
|
831
|
+
print(`OK (${result.method}, ${result.durationMs}ms)${result.reply ? ` — ${result.reply}` : ''}`);
|
|
832
|
+
if (result.code === 'static_only') print(result.message);
|
|
833
|
+
} else {
|
|
834
|
+
print(`FAILED [${result.code}] ${result.message || ''}`.trim());
|
|
835
|
+
for (const line of result.guidance || []) print(` → ${line}`);
|
|
836
|
+
process.exitCode = 1;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
809
840
|
async function cmdVersion() {
|
|
810
841
|
const pkg = require('../package.json');
|
|
811
842
|
print(`${pkg.name} v${pkg.version}`);
|
|
@@ -874,6 +905,7 @@ Commands:
|
|
|
874
905
|
tool-mode [agent] [mode] View/set tool mode (mcp or skills)
|
|
875
906
|
autostart [--disable] Enable/disable auto-start on login
|
|
876
907
|
test-llm <type> Test LLM connection
|
|
908
|
+
probe <type|agent> [--json] Smoke-test an agent (tiny end-to-end prompt)
|
|
877
909
|
logs [agent] [--lines N] View daemon logs
|
|
878
910
|
workspace create [name] Create a new workspace
|
|
879
911
|
workspace join <token> Join workspace with token
|
|
@@ -962,6 +994,7 @@ async function main() {
|
|
|
962
994
|
skills: () => cmdSkills(connector, flags, positional),
|
|
963
995
|
'tool-mode': () => cmdToolMode(connector, flags, positional),
|
|
964
996
|
'test-llm': () => cmdTestLLM(connector, flags, positional),
|
|
997
|
+
probe: () => cmdProbe(connector, flags, positional),
|
|
965
998
|
update: () => cmdUpdate(connector),
|
|
966
999
|
'mcp-server': () => {
|
|
967
1000
|
const { runMcpServer } = require('./mcp-server');
|
package/src/daemon.js
CHANGED
|
@@ -47,9 +47,86 @@ class Daemon {
|
|
|
47
47
|
this._runningCommands = new Set(); // node-command ids currently executing
|
|
48
48
|
this._runtimes = []; // detected agent runtimes for the node view
|
|
49
49
|
this._runtimesInterval = null;
|
|
50
|
+
this._probes = this._loadProbes(); // type → last smoke-test result (persisted)
|
|
51
|
+
this._probeInterval = null;
|
|
52
|
+
this._probeStartupTimer = null;
|
|
53
|
+
this._probeInFlight = new Set(); // types being probed right now
|
|
50
54
|
this._reloadInFlight = null; // serialize concurrent _reload() calls
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
// ── Agent smoke tests (probes) ─────────────────────────────────────────
|
|
58
|
+
// A probe runs one tiny end-to-end prompt through an agent type (see
|
|
59
|
+
// probe.js) — the check that catches "connected but never answers".
|
|
60
|
+
// Results are persisted so a daemon restart doesn't re-spend LLM calls,
|
|
61
|
+
// and are reported to every paired workspace on the node heartbeat.
|
|
62
|
+
|
|
63
|
+
_probesFile() {
|
|
64
|
+
return path.join(os.homedir(), '.openagents', 'probes.json');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_loadProbes() {
|
|
68
|
+
try {
|
|
69
|
+
const data = JSON.parse(fs.readFileSync(this._probesFile(), 'utf-8'));
|
|
70
|
+
return data && typeof data === 'object' ? data : {};
|
|
71
|
+
} catch { return {}; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_saveProbes() {
|
|
75
|
+
try {
|
|
76
|
+
fs.mkdirSync(path.dirname(this._probesFile()), { recursive: true });
|
|
77
|
+
fs.writeFileSync(this._probesFile(), JSON.stringify(this._probes, null, 2), 'utf-8');
|
|
78
|
+
} catch {}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Probe one type via a child `agn probe --json` (same off-event-loop
|
|
83
|
+
* pattern as _refreshRuntimes; a probe can block for its full timeout).
|
|
84
|
+
*/
|
|
85
|
+
async _probeType(type) {
|
|
86
|
+
if (this._probeInFlight.has(type)) return this._probes[type] || null;
|
|
87
|
+
this._probeInFlight.add(type);
|
|
88
|
+
try {
|
|
89
|
+
const r = await this._runAgn(['probe', type, '--json']);
|
|
90
|
+
let parsed = null;
|
|
91
|
+
try { parsed = JSON.parse(r.stdout.trim()); } catch {}
|
|
92
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
93
|
+
parsed = {
|
|
94
|
+
type, ok: false, method: 'none', code: 'probe_error',
|
|
95
|
+
message: (r.stderr || 'probe failed').slice(0, 400),
|
|
96
|
+
guidance: [], at: new Date().toISOString(),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
this._probes[type] = parsed;
|
|
100
|
+
this._saveProbes();
|
|
101
|
+
return parsed;
|
|
102
|
+
} finally {
|
|
103
|
+
this._probeInFlight.delete(type);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Periodic sweep: probe each type that has a configured agent and whose
|
|
109
|
+
* last result is missing or stale. Sequential on purpose — probes cost a
|
|
110
|
+
* (tiny) model call each; there is no hurry.
|
|
111
|
+
*/
|
|
112
|
+
async _probeConfiguredTypes(maxAgeMs) {
|
|
113
|
+
const types = [...new Set(this.config.getAgents().map((a) => a.type || 'openclaw'))];
|
|
114
|
+
for (const type of types) {
|
|
115
|
+
const last = this._probes[type];
|
|
116
|
+
const age = last && last.at ? Date.now() - Date.parse(last.at) : Infinity;
|
|
117
|
+
if (age < maxAgeMs) continue;
|
|
118
|
+
try { await this._probeType(type); } catch {}
|
|
119
|
+
}
|
|
120
|
+
this._nodeHeartbeat();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Runtimes annotated with each type's last probe, for the heartbeat. */
|
|
124
|
+
_runtimesWithProbes() {
|
|
125
|
+
return (this._runtimes || []).map((r) =>
|
|
126
|
+
this._probes[r.type] ? { ...r, probe: this._probes[r.type] } : r
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
53
130
|
/**
|
|
54
131
|
* Heartbeat this device (node) to EVERY workspace it is paired to,
|
|
55
132
|
* independent of any agent, so each workspace's "connected devices" view
|
|
@@ -67,7 +144,7 @@ class Daemon {
|
|
|
67
144
|
const nodeCfg = require('./node-config');
|
|
68
145
|
const pairings = nodeCfg.listPairings().filter((p) => p && p.node_id && p.token);
|
|
69
146
|
if (!pairings.length) return; // no node connected
|
|
70
|
-
const info = { ...nodeCfg.gatherDeviceInfo(), runtimes: this.
|
|
147
|
+
const info = { ...nodeCfg.gatherDeviceInfo(), runtimes: this._runtimesWithProbes(), fs: this._buildFs() };
|
|
71
148
|
await Promise.all(pairings.map((p) => this._nodeHeartbeatOne(nodeCfg, p, info)));
|
|
72
149
|
}
|
|
73
150
|
|
|
@@ -270,6 +347,10 @@ class Daemon {
|
|
|
270
347
|
if (r2.code !== 0) throw new Error(r2.stderr || r2.stdout || 'connect failed');
|
|
271
348
|
ok = true;
|
|
272
349
|
message = `Agent '${name}' created`;
|
|
350
|
+
// First-connect smoke test, in the background (a probe can take its
|
|
351
|
+
// full timeout — never hold the create result hostage). The outcome
|
|
352
|
+
// reaches the workspace via the heartbeat's runtimes[].probe.
|
|
353
|
+
this._probeType(type).then(() => this._nodeHeartbeat()).catch(() => {});
|
|
273
354
|
} else if (action === 'start_agent') {
|
|
274
355
|
const r = await this._runAgn(['start', name]);
|
|
275
356
|
ok = r.code === 0;
|
|
@@ -304,6 +385,24 @@ class Daemon {
|
|
|
304
385
|
}
|
|
305
386
|
ok = true;
|
|
306
387
|
message = `Agent '${name}' reconfigured`;
|
|
388
|
+
} else if (action === 'probe_agent') {
|
|
389
|
+
// Smoke-test on demand ("Test agent" in the workspace). Accepts a
|
|
390
|
+
// type directly, or an agent name (must belong to this workspace).
|
|
391
|
+
let type = (args.type || '').trim();
|
|
392
|
+
if (!type && name) {
|
|
393
|
+
const agent = this.config.getAgent(name);
|
|
394
|
+
if (!agent || !this._agentOnNodeWorkspace(agent, n)) {
|
|
395
|
+
throw new Error(`Agent '${name}' is not managed by this workspace`);
|
|
396
|
+
}
|
|
397
|
+
type = agent.type || 'openclaw';
|
|
398
|
+
}
|
|
399
|
+
if (!type) throw new Error('Missing agent type');
|
|
400
|
+
const probe = await this._probeType(type);
|
|
401
|
+
ok = !!(probe && probe.ok);
|
|
402
|
+
message = (probe && probe.message) || (ok ? 'Smoke test passed' : 'Smoke test failed');
|
|
403
|
+
data = { probe };
|
|
404
|
+
// Push the fresh result right away (same pattern as detect_runtimes).
|
|
405
|
+
this._nodeHeartbeat();
|
|
307
406
|
} else if (action === 'detect_runtimes') {
|
|
308
407
|
await this._refreshRuntimes();
|
|
309
408
|
// Push the fresh detection right away rather than waiting for the next
|
|
@@ -428,6 +527,20 @@ class Daemon {
|
|
|
428
527
|
this._refreshRuntimes();
|
|
429
528
|
this._runtimesInterval = setInterval(() => this._refreshRuntimes(), 120000);
|
|
430
529
|
|
|
530
|
+
// Smoke-test configured agent types periodically (each probe spends one
|
|
531
|
+
// tiny model call, so: only types with agents, only when the last result
|
|
532
|
+
// is a day old, and a few minutes after startup so install/login churn
|
|
533
|
+
// settles first). Persisted results keep restarts from re-spending calls.
|
|
534
|
+
const PROBE_MAX_AGE_MS = 24 * 3600 * 1000;
|
|
535
|
+
this._probeStartupTimer = setTimeout(
|
|
536
|
+
() => this._probeConfiguredTypes(PROBE_MAX_AGE_MS).catch(() => {}),
|
|
537
|
+
3 * 60 * 1000,
|
|
538
|
+
);
|
|
539
|
+
this._probeInterval = setInterval(
|
|
540
|
+
() => this._probeConfiguredTypes(PROBE_MAX_AGE_MS).catch(() => {}),
|
|
541
|
+
6 * 3600 * 1000,
|
|
542
|
+
);
|
|
543
|
+
|
|
431
544
|
// Watch config file for hot-reload
|
|
432
545
|
this._watchConfig();
|
|
433
546
|
|
|
@@ -455,6 +568,8 @@ class Daemon {
|
|
|
455
568
|
if (this._cmdInterval) clearInterval(this._cmdInterval);
|
|
456
569
|
if (this._nodeHeartbeatInterval) clearInterval(this._nodeHeartbeatInterval);
|
|
457
570
|
if (this._runtimesInterval) clearInterval(this._runtimesInterval);
|
|
571
|
+
if (this._probeStartupTimer) clearTimeout(this._probeStartupTimer);
|
|
572
|
+
if (this._probeInterval) clearInterval(this._probeInterval);
|
|
458
573
|
if (this._configWatcher) { try { this._configWatcher.close(); } catch {} }
|
|
459
574
|
|
|
460
575
|
// Kill all child processes
|
package/src/index.js
CHANGED
|
@@ -289,6 +289,15 @@ class AgentConnector {
|
|
|
289
289
|
const { testLLMConnection } = require('./utils');
|
|
290
290
|
return testLLMConnection(env);
|
|
291
291
|
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Smoke-test an agent type end to end (tiny "hi" prompt through its CLI or
|
|
295
|
+
* LLM API) and classify any failure into actionable guidance. See probe.js.
|
|
296
|
+
*/
|
|
297
|
+
async probeAgentType(agentType, opts) {
|
|
298
|
+
const { probeAgentType } = require('./probe');
|
|
299
|
+
return probeAgentType(this, agentType, opts);
|
|
300
|
+
}
|
|
292
301
|
}
|
|
293
302
|
|
|
294
303
|
const adapters = require('./adapters');
|
package/src/probe.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Agent smoke test ("probe") — answers the question static health checks
|
|
5
|
+
* can't: does this agent actually produce a response on this device?
|
|
6
|
+
*
|
|
7
|
+
* healthCheck() proves a binary exists and credentials LOOK present; users
|
|
8
|
+
* still hit "No response generated", invalid-key errors, and silent hangs at
|
|
9
|
+
* message time. The probe closes that gap by running one tiny end-to-end
|
|
10
|
+
* prompt ("hi") the same way the adapter would, then classifying whatever
|
|
11
|
+
* came back into a machine code plus human guidance ("run `claude login` on
|
|
12
|
+
* the device", "the API key is invalid", ...).
|
|
13
|
+
*
|
|
14
|
+
* Tiering (cheapest thing that proves liveness):
|
|
15
|
+
* 1. healthCheck gate — not installed / not configured fails fast with the
|
|
16
|
+
* existing auth guidance, no process spawned, no tokens spent.
|
|
17
|
+
* 2. Live check, picked from the registry entry:
|
|
18
|
+
* - `probe.args` → run the agent CLI itself (e.g. --print hi)
|
|
19
|
+
* - `check_ready.alt_check` → run the entry's declared liveness command
|
|
20
|
+
* - saved API key → one direct LLM API call (testLLMConnection)
|
|
21
|
+
* - none of the above → report static checks only (never guess a
|
|
22
|
+
* CLI invocation; a wrong flag would misreport a healthy agent).
|
|
23
|
+
*
|
|
24
|
+
* Results carry NO environment values. CLI output is truncated and scrubbed
|
|
25
|
+
* of anything key-shaped before it leaves this module.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const os = require('os');
|
|
29
|
+
const { spawn } = require('child_process');
|
|
30
|
+
const { getEnhancedEnv } = require('./paths');
|
|
31
|
+
const { formatAuthGuidance } = require('./auth-guidance');
|
|
32
|
+
const { testLLMConnection } = require('./utils');
|
|
33
|
+
|
|
34
|
+
const PROBE_PROMPT = 'hi';
|
|
35
|
+
const DEFAULT_TIMEOUT_MS = 90_000;
|
|
36
|
+
const OUTPUT_CAP = 400; // max chars of CLI output kept for diagnostics
|
|
37
|
+
const REPLY_CAP = 160; // max chars of a successful reply echoed back
|
|
38
|
+
|
|
39
|
+
/** Failure codes, ordered roughly by how actionable they are for the user. */
|
|
40
|
+
const CODE = {
|
|
41
|
+
OK: 'ok',
|
|
42
|
+
STATIC_ONLY: 'static_only',
|
|
43
|
+
UNKNOWN_TYPE: 'unknown_type',
|
|
44
|
+
NOT_INSTALLED: 'not_installed',
|
|
45
|
+
NOT_READY: 'not_ready',
|
|
46
|
+
INVALID_API_KEY: 'invalid_api_key',
|
|
47
|
+
MISSING_API_KEY: 'missing_api_key',
|
|
48
|
+
NOT_LOGGED_IN: 'not_logged_in',
|
|
49
|
+
OUT_OF_CREDIT: 'out_of_credit',
|
|
50
|
+
RATE_LIMITED: 'rate_limited',
|
|
51
|
+
NETWORK: 'network',
|
|
52
|
+
TIMEOUT: 'timeout',
|
|
53
|
+
EMPTY_RESPONSE: 'empty_response',
|
|
54
|
+
CLI_ERROR: 'cli_error',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Redact anything that looks like a secret before output leaves the device. */
|
|
58
|
+
function scrub(text) {
|
|
59
|
+
if (!text) return '';
|
|
60
|
+
return String(text)
|
|
61
|
+
.replace(/sk-[A-Za-z0-9_-]{8,}/g, 'sk-****')
|
|
62
|
+
.replace(/(api[-_ ]?key["':\s=]+)[A-Za-z0-9_-]{8,}/gi, '$1****')
|
|
63
|
+
.replace(/Bearer\s+[A-Za-z0-9._-]{8,}/gi, 'Bearer ****');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function tail(text, cap) {
|
|
67
|
+
const s = scrub(String(text || '').trim());
|
|
68
|
+
return s.length > cap ? '…' + s.slice(-cap) : s;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether this agent authenticates via API key (configured/expected) rather
|
|
73
|
+
* than a CLI login. Steers the wording for auth-flavored failures.
|
|
74
|
+
*/
|
|
75
|
+
function authFlavor(entry, agentEnv) {
|
|
76
|
+
const cr = (entry && entry.check_ready) || {};
|
|
77
|
+
const envCfg = (entry && entry.env_config) || [];
|
|
78
|
+
const hasKeyField = envCfg.some((f) => f && /key/i.test(f.name || ''));
|
|
79
|
+
const hasSavedKey = !!(agentEnv && (agentEnv.LLM_API_KEY || agentEnv.OPENAI_API_KEY || agentEnv.ANTHROPIC_API_KEY));
|
|
80
|
+
if (hasSavedKey || (hasKeyField && !cr.login_command)) return 'api_key';
|
|
81
|
+
if (cr.login_command) return 'cli_login';
|
|
82
|
+
return hasKeyField ? 'api_key' : 'cli_login';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Classify a failed live check into a code. Pattern-matches the combined
|
|
87
|
+
* output; callers turn the code into guidance via buildGuidance().
|
|
88
|
+
*/
|
|
89
|
+
function classifyFailure(output, { timedOut = false, spawnError = null } = {}) {
|
|
90
|
+
if (spawnError && /ENOENT/.test(String(spawnError))) return CODE.NOT_INSTALLED;
|
|
91
|
+
if (timedOut) return CODE.TIMEOUT;
|
|
92
|
+
const text = String(output || '').toLowerCase();
|
|
93
|
+
if (!text && spawnError) return CODE.CLI_ERROR;
|
|
94
|
+
|
|
95
|
+
if (/credit balance|insufficient[_ ]?(quota|credits|funds)|payment required|\b402\b|billing/.test(text)) return CODE.OUT_OF_CREDIT;
|
|
96
|
+
if (/rate[- ]?limit|too many requests|\b429\b|overloaded|\b529\b/.test(text)) return CODE.RATE_LIMITED;
|
|
97
|
+
if (/enotfound|econnrefused|econnreset|etimedout|eai_again|fetch failed|socket hang up|network error|self[- ]signed certificate|unable to get local issuer/.test(text)) return CODE.NETWORK;
|
|
98
|
+
if (/api key.{0,24}(not set|not found|missing)|no api key|missing api key|environment variable.{0,40}(is )?(not set|required)/.test(text)) return CODE.MISSING_API_KEY;
|
|
99
|
+
if (/invalid.{0,12}(api.?key|x-api-key|token)|incorrect api key|authentication[_ ]?error|invalid credentials|\b401\b|unauthorized|forbidden|\b403\b/.test(text)) return CODE.INVALID_API_KEY;
|
|
100
|
+
if (/not logged in|not authenticated|login required|please (log ?in|sign ?in|authenticate)|run .{0,40}login|no credentials/.test(text)) return CODE.NOT_LOGGED_IN;
|
|
101
|
+
return CODE.CLI_ERROR;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Human next-steps for a code. Plain strings, no markup, no secrets. */
|
|
105
|
+
function buildGuidance(code, entry, agentEnv, extra = {}) {
|
|
106
|
+
const label = (entry && (entry.label || entry.name)) || 'This agent';
|
|
107
|
+
const type = (entry && entry.name) || 'agent';
|
|
108
|
+
const login = entry && entry.check_ready && entry.check_ready.login_command;
|
|
109
|
+
const flavor = authFlavor(entry, agentEnv);
|
|
110
|
+
const lines = [];
|
|
111
|
+
switch (code) {
|
|
112
|
+
case CODE.NOT_INSTALLED:
|
|
113
|
+
lines.push(`${label} is not installed on this device. Install it from the launcher's Install page.`);
|
|
114
|
+
if (entry && entry.install_command) lines.push(`Or install manually: ${entry.install_command}`);
|
|
115
|
+
break;
|
|
116
|
+
case CODE.INVALID_API_KEY:
|
|
117
|
+
case CODE.MISSING_API_KEY:
|
|
118
|
+
if (flavor === 'cli_login' && login) {
|
|
119
|
+
lines.push(`${label}'s sign-in looks invalid or expired. On the device, run: ${login}`);
|
|
120
|
+
} else {
|
|
121
|
+
lines.push(code === CODE.MISSING_API_KEY
|
|
122
|
+
? `${label} has no API key configured. Add one in the launcher (Agents → ${label} → Configure) or edit the agent in the workspace and enter a key.`
|
|
123
|
+
: `${label}'s API key was rejected by the provider. Replace it in the launcher (Agents → ${label} → Configure) or edit the agent in the workspace.`);
|
|
124
|
+
lines.push(`To verify a key on the device: agn test-llm ${type}`);
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
case CODE.NOT_LOGGED_IN:
|
|
128
|
+
lines.push(login
|
|
129
|
+
? `${label} is not signed in on this device. In a terminal there, run: ${login}`
|
|
130
|
+
: `${label} is not signed in on this device. Sign in with its CLI, then re-run the test.`);
|
|
131
|
+
break;
|
|
132
|
+
case CODE.OUT_OF_CREDIT:
|
|
133
|
+
lines.push(`The provider account for ${label} is out of credit or quota. Add credit, or switch the agent to a different API key or model.`);
|
|
134
|
+
break;
|
|
135
|
+
case CODE.RATE_LIMITED:
|
|
136
|
+
lines.push(`The provider is rate-limiting ${label}. Wait a few minutes and test again.`);
|
|
137
|
+
break;
|
|
138
|
+
case CODE.NETWORK:
|
|
139
|
+
lines.push(`The device could not reach the model provider. Check the device's network, proxy, and firewall, then test again.`);
|
|
140
|
+
break;
|
|
141
|
+
case CODE.TIMEOUT:
|
|
142
|
+
lines.push(`${label} did not answer within ${Math.round((extra.timeoutMs || DEFAULT_TIMEOUT_MS) / 1000)}s. It may be waiting for interactive input (a first-run consent or login prompt).`);
|
|
143
|
+
lines.push(`On the device, run the agent's CLI once in a terminal to clear any prompt, then test again.`);
|
|
144
|
+
break;
|
|
145
|
+
case CODE.EMPTY_RESPONSE:
|
|
146
|
+
lines.push(`${label} exited without producing a response. Run its CLI manually on the device to see what it prints.`);
|
|
147
|
+
break;
|
|
148
|
+
case CODE.NOT_READY:
|
|
149
|
+
// Caller supplies the static-auth guidance lines instead.
|
|
150
|
+
break;
|
|
151
|
+
default:
|
|
152
|
+
lines.push(`${label} failed to respond. Run its CLI manually on the device to inspect the error.`);
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
return lines;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Run a CLI to completion with a hard timeout. Never rejects. */
|
|
159
|
+
function runCommand(cmd, args, { env, timeoutMs, shell = false }) {
|
|
160
|
+
return new Promise((resolve) => {
|
|
161
|
+
let child;
|
|
162
|
+
try {
|
|
163
|
+
child = spawn(cmd, args, {
|
|
164
|
+
env,
|
|
165
|
+
cwd: os.homedir(),
|
|
166
|
+
shell,
|
|
167
|
+
windowsHide: true,
|
|
168
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
169
|
+
});
|
|
170
|
+
} catch (e) {
|
|
171
|
+
resolve({ code: 1, stdout: '', stderr: '', spawnError: e.message, timedOut: false });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
let stdout = '';
|
|
175
|
+
let stderr = '';
|
|
176
|
+
let timedOut = false;
|
|
177
|
+
let settled = false;
|
|
178
|
+
const timer = setTimeout(() => {
|
|
179
|
+
timedOut = true;
|
|
180
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
181
|
+
}, timeoutMs);
|
|
182
|
+
const finish = (code, spawnError) => {
|
|
183
|
+
if (settled) return;
|
|
184
|
+
settled = true;
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
resolve({ code, stdout, stderr, spawnError: spawnError || null, timedOut });
|
|
187
|
+
};
|
|
188
|
+
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
189
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
190
|
+
child.on('error', (e) => finish(1, e.message));
|
|
191
|
+
child.on('close', (code) => finish(code == null ? 1 : code));
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Smoke-test one agent type end to end. Returns a result object; never throws.
|
|
197
|
+
*
|
|
198
|
+
* @param {object} connector AgentConnector (registry/installer/env access)
|
|
199
|
+
* @param {string} type agent type name (registry entry name)
|
|
200
|
+
* @param {object} [opts] { timeoutMs }
|
|
201
|
+
*/
|
|
202
|
+
async function probeAgentType(connector, type, opts = {}) {
|
|
203
|
+
const startedAt = Date.now();
|
|
204
|
+
const done = (fields) => ({
|
|
205
|
+
type,
|
|
206
|
+
at: new Date().toISOString(),
|
|
207
|
+
durationMs: Date.now() - startedAt,
|
|
208
|
+
guidance: [],
|
|
209
|
+
...fields,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const entry = connector.registry.getEntry(type);
|
|
213
|
+
if (!entry) {
|
|
214
|
+
return done({ ok: false, method: 'none', code: CODE.UNKNOWN_TYPE, message: `Unknown agent type '${type}'` });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let health = {};
|
|
218
|
+
try { health = connector.healthCheck(type) || {}; } catch (e) {
|
|
219
|
+
health = { installed: false, ready: false, message: e.message };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let agentEnv = {};
|
|
223
|
+
try {
|
|
224
|
+
const saved = connector.getAgentEnv(type) || {};
|
|
225
|
+
const resolved = connector.resolveAgentEnv(type, saved) || {};
|
|
226
|
+
agentEnv = { ...saved, ...resolved };
|
|
227
|
+
} catch {}
|
|
228
|
+
|
|
229
|
+
if (!health.installed) {
|
|
230
|
+
return done({
|
|
231
|
+
ok: false, method: 'none', code: CODE.NOT_INSTALLED,
|
|
232
|
+
message: 'Not installed',
|
|
233
|
+
guidance: buildGuidance(CODE.NOT_INSTALLED, entry, agentEnv),
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Definitively unconfigured (auth_status 'unknown' still gets a live try:
|
|
238
|
+
// the real run is the final authority for unverifiable logins).
|
|
239
|
+
if (!health.ready && health.auth_status !== 'unknown') {
|
|
240
|
+
let lines = [];
|
|
241
|
+
try { lines = formatAuthGuidance(entry, health).lines; } catch {}
|
|
242
|
+
return done({
|
|
243
|
+
ok: false, method: 'none', code: CODE.NOT_READY,
|
|
244
|
+
message: health.message || 'Not configured',
|
|
245
|
+
guidance: lines.length ? lines : buildGuidance(CODE.NOT_LOGGED_IN, entry, agentEnv),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const timeoutMs = opts.timeoutMs
|
|
250
|
+
|| (entry.probe && entry.probe.timeout_s ? entry.probe.timeout_s * 1000 : DEFAULT_TIMEOUT_MS);
|
|
251
|
+
const env = { ...getEnhancedEnv(), ...agentEnv };
|
|
252
|
+
|
|
253
|
+
// ── Live tier 1: the agent's own CLI, when the registry declares how ──
|
|
254
|
+
const probeArgs = entry.probe && Array.isArray(entry.probe.args) ? entry.probe.args : null;
|
|
255
|
+
const altCheck = entry.check_ready && entry.check_ready.alt_check;
|
|
256
|
+
if ((probeArgs && health.binary) || altCheck) {
|
|
257
|
+
const res = probeArgs && health.binary
|
|
258
|
+
? await runCommand(health.binary, probeArgs, { env, timeoutMs })
|
|
259
|
+
: await runCommand(altCheck, [], { env, timeoutMs, shell: true });
|
|
260
|
+
const output = `${res.stdout}\n${res.stderr}`;
|
|
261
|
+
if (!res.timedOut && !res.spawnError && res.code === 0 && res.stdout.trim()) {
|
|
262
|
+
return done({
|
|
263
|
+
ok: true, method: 'cli', code: CODE.OK,
|
|
264
|
+
message: 'Responded',
|
|
265
|
+
reply: tail(res.stdout, REPLY_CAP),
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
let code = res.code === 0 && !res.timedOut && !res.spawnError
|
|
269
|
+
? CODE.EMPTY_RESPONSE
|
|
270
|
+
: classifyFailure(output, res);
|
|
271
|
+
// A CLI probe spawn-ENOENT despite installed=true means the binary went
|
|
272
|
+
// away between checks — report it as not installed, not a mystery error.
|
|
273
|
+
if (code === CODE.NOT_INSTALLED) {
|
|
274
|
+
return done({
|
|
275
|
+
ok: false, method: 'cli', code,
|
|
276
|
+
message: 'The agent binary could not be executed',
|
|
277
|
+
guidance: buildGuidance(code, entry, agentEnv),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return done({
|
|
281
|
+
ok: false, method: 'cli', code,
|
|
282
|
+
message: tail(res.stderr || res.stdout, OUTPUT_CAP) || (res.timedOut ? 'Timed out' : `Exited with code ${res.code}`),
|
|
283
|
+
guidance: buildGuidance(code, entry, agentEnv, { timeoutMs }),
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── Live tier 2: direct LLM API call for API-key agents (e.g. OpenClaw) ──
|
|
288
|
+
const hasApiKey = !!(agentEnv.LLM_API_KEY || agentEnv.OPENAI_API_KEY || agentEnv.ANTHROPIC_API_KEY
|
|
289
|
+
|| agentEnv.KIMI_API_KEY || agentEnv.MOONSHOT_API_KEY);
|
|
290
|
+
if (hasApiKey || (entry.install && entry.install.api_only)) {
|
|
291
|
+
let res;
|
|
292
|
+
try { res = await testLLMConnection(agentEnv); } catch (e) { res = { success: false, error: e.message }; }
|
|
293
|
+
if (res && res.success) {
|
|
294
|
+
return done({
|
|
295
|
+
ok: true, method: 'llm_api', code: CODE.OK,
|
|
296
|
+
message: 'Responded',
|
|
297
|
+
reply: tail(res.response || '', REPLY_CAP),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
const code = classifyFailure(res && res.error, {});
|
|
301
|
+
return done({
|
|
302
|
+
ok: false, method: 'llm_api', code,
|
|
303
|
+
message: tail(res && res.error, OUTPUT_CAP) || 'LLM API test failed',
|
|
304
|
+
guidance: buildGuidance(code, entry, agentEnv),
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── No safe live probe — report the static result honestly rather than
|
|
309
|
+
// inventing a CLI invocation that could misfire. ──
|
|
310
|
+
return done({
|
|
311
|
+
ok: health.ready === true, method: 'none', code: CODE.STATIC_ONLY,
|
|
312
|
+
message: health.ready
|
|
313
|
+
? 'Static checks passed (no live probe is defined for this agent type)'
|
|
314
|
+
: (health.message || 'Not configured'),
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
module.exports = { probeAgentType, classifyFailure, buildGuidance, authFlavor, scrub, CODE, DEFAULT_TIMEOUT_MS };
|
package/src/registry.js
CHANGED
|
@@ -100,6 +100,7 @@ class Registry {
|
|
|
100
100
|
if (b.install) entry.install = { ...b.install };
|
|
101
101
|
if (!entry.check_ready && b.check_ready) entry.check_ready = b.check_ready;
|
|
102
102
|
if (!entry.launch && b.launch) entry.launch = b.launch;
|
|
103
|
+
if (!entry.probe && b.probe) entry.probe = b.probe;
|
|
103
104
|
// Always take featured/order/support from bundled (source of truth for ordering)
|
|
104
105
|
if (b.featured !== undefined) entry.featured = b.featured;
|
|
105
106
|
if (b.order !== undefined) entry.order = b.order;
|
|
@@ -146,6 +147,7 @@ class Registry {
|
|
|
146
147
|
if (b.install) entry.install = { ...b.install };
|
|
147
148
|
if (!entry.check_ready && b.check_ready) entry.check_ready = b.check_ready;
|
|
148
149
|
if (!entry.launch && b.launch) entry.launch = b.launch;
|
|
150
|
+
if (!entry.probe && b.probe) entry.probe = b.probe;
|
|
149
151
|
if ((!entry.env_config || !entry.env_config.length) && b.env_config?.length) entry.env_config = b.env_config;
|
|
150
152
|
}
|
|
151
153
|
return entry;
|