@openagents-org/agent-launcher 0.2.142 → 0.2.145
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 +108 -7
- package/src/adapters/claude.js +50 -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 +10 -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 +665 -79
- package/src/agent-rows.js +13 -1
- package/src/auth-guidance.js +98 -0
- package/src/cli.js +29 -7
- package/src/config.js +8 -2
- package/src/daemon.js +108 -6
- package/src/installer.js +509 -10
- package/src/paths.js +125 -2
- package/src/tui.js +29 -2
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Amp adapter for OpenAgents workspace.
|
|
3
|
+
*
|
|
4
|
+
* Bridges Sourcegraph's Amp CLI to an OpenAgents workspace by running the
|
|
5
|
+
* agent in its official non-interactive *execute* mode with structured output:
|
|
6
|
+
*
|
|
7
|
+
* amp -x --stream-json // first turn (new thread)
|
|
8
|
+
* amp threads continue <threadId> -x --stream-json // follow-up turns
|
|
9
|
+
*
|
|
10
|
+
* The prompt is fed on stdin (the documented `echo "..." | amp -x` path), which
|
|
11
|
+
* avoids ARG_MAX / shell-quoting issues with the large workspace system context.
|
|
12
|
+
* Amp's `--stream-json` output is intentionally compatible with Claude Code's
|
|
13
|
+
* stream-json schema, so the event handling mirrors the Claude adapter: `system`
|
|
14
|
+
* (init / session id), `assistant` (text + tool_use blocks), and `result`
|
|
15
|
+
* (final text + session id).
|
|
16
|
+
*
|
|
17
|
+
* Reuses all shared connectivity / dispatch / state machinery in BaseAdapter;
|
|
18
|
+
* only the Amp-specific subprocess invocation and parsing live here.
|
|
19
|
+
*
|
|
20
|
+
* Mirrors the Python adapter: sdk/src/openagents/adapters/amp.py
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const os = require('os');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const { execSync, spawn } = require('child_process');
|
|
29
|
+
|
|
30
|
+
const BaseAdapter = require('./base');
|
|
31
|
+
const { buildOpenclawSystemPrompt } = require('./workspace-prompt');
|
|
32
|
+
const { whichBinary, getEnhancedEnv } = require('../paths');
|
|
33
|
+
const { REASON, classifySpawnError, redactDiagnostic, shouldUseShellForBinary } = require('./health-status');
|
|
34
|
+
|
|
35
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
36
|
+
// Terminate the subprocess if it produces no output for this long (a wedged
|
|
37
|
+
// turn) — guards the daemon against a hung Amp process without arbitrary sleeps.
|
|
38
|
+
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
39
|
+
|
|
40
|
+
/** Platform-appropriate Amp CLI install command for "not found" messages. */
|
|
41
|
+
function ampInstallHint() {
|
|
42
|
+
return IS_WINDOWS
|
|
43
|
+
? 'powershell -NoProfile -Command "irm https://ampcode.com/install.ps1 | iex"'
|
|
44
|
+
: 'curl -fsSL https://ampcode.com/install.sh | bash';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class AmpAdapter extends BaseAdapter {
|
|
48
|
+
constructor(opts) {
|
|
49
|
+
super(opts);
|
|
50
|
+
this.disabledModules = opts.disabledModules || new Set();
|
|
51
|
+
|
|
52
|
+
// channel -> Amp thread id (for `amp threads continue <id>`)
|
|
53
|
+
this._channelThreads = {};
|
|
54
|
+
// channel -> running child process (for stop / cleanup)
|
|
55
|
+
this._channelProcesses = {};
|
|
56
|
+
// channels the user explicitly stopped (suppress "no response" noise)
|
|
57
|
+
this._stoppingChannels = new Set();
|
|
58
|
+
|
|
59
|
+
this._sessionsFile = path.join(
|
|
60
|
+
os.homedir(), '.openagents', 'sessions',
|
|
61
|
+
`${this.workspaceId}_${this.agentName}_amp.json`,
|
|
62
|
+
);
|
|
63
|
+
this._loadSessions();
|
|
64
|
+
|
|
65
|
+
this._ampBin = this._findAmpBinary();
|
|
66
|
+
if (this._ampBin) {
|
|
67
|
+
this._log(`Using Amp CLI: ${this._ampBin}`);
|
|
68
|
+
} else {
|
|
69
|
+
this._log(`Warning: Amp CLI not found — install with: ${ampInstallHint()}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ------------------------------------------------------------------
|
|
74
|
+
// Session (thread id) persistence
|
|
75
|
+
// ------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
_loadSessions() {
|
|
78
|
+
try {
|
|
79
|
+
if (fs.existsSync(this._sessionsFile)) {
|
|
80
|
+
const data = JSON.parse(fs.readFileSync(this._sessionsFile, 'utf-8'));
|
|
81
|
+
if (data && typeof data === 'object') {
|
|
82
|
+
Object.assign(this._channelThreads, data);
|
|
83
|
+
this._log(`Loaded ${Object.keys(data).length} Amp thread(s)`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
this._log('Could not load Amp sessions file, starting fresh');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
_saveSessions() {
|
|
92
|
+
try {
|
|
93
|
+
fs.mkdirSync(path.dirname(this._sessionsFile), { recursive: true });
|
|
94
|
+
fs.writeFileSync(this._sessionsFile, JSON.stringify(this._channelThreads));
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
_rememberThread(channel, threadId) {
|
|
99
|
+
if (!threadId) return;
|
|
100
|
+
if (this._channelThreads[channel] !== threadId) {
|
|
101
|
+
this._channelThreads[channel] = threadId;
|
|
102
|
+
this._saveSessions();
|
|
103
|
+
this._log(`Amp thread for ${channel}: ${threadId}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ------------------------------------------------------------------
|
|
108
|
+
// Binary resolution (cross-platform)
|
|
109
|
+
// ------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
_findAmpBinary() {
|
|
112
|
+
const home = os.homedir();
|
|
113
|
+
const ext = IS_WINDOWS ? '.cmd' : '';
|
|
114
|
+
|
|
115
|
+
// Tier 0: isolated runtime prefix (~/.openagents/runtimes/amp/)
|
|
116
|
+
const runtimeCandidate = path.join(home, '.openagents', 'runtimes', 'amp', 'node_modules', '.bin', `amp${ext}`);
|
|
117
|
+
if (fs.existsSync(runtimeCandidate)) return runtimeCandidate;
|
|
118
|
+
|
|
119
|
+
// Tier 1: shared cross-platform resolver. It runs `which`/`where` against
|
|
120
|
+
// an ENHANCED PATH that includes nvm/fnm/volta/homebrew, ~/.local/bin and
|
|
121
|
+
// the Amp installer's ~/.amp/bin. This is what makes detection work inside
|
|
122
|
+
// a GUI- or daemon-spawned process that did NOT inherit the user's
|
|
123
|
+
// interactive shell PATH (the common "installed but not found" case).
|
|
124
|
+
const resolved = whichBinary('amp');
|
|
125
|
+
if (resolved) return resolved;
|
|
126
|
+
|
|
127
|
+
// Tier 2: explicit known install dirs as a last resort. The official
|
|
128
|
+
// installer (curl https://ampcode.com/install.sh | bash) writes the
|
|
129
|
+
// canonical binary to ~/.amp/bin and only symlinks into ~/.local/bin
|
|
130
|
+
// (or ~/bin, ~/.bin) when one of those is already on PATH.
|
|
131
|
+
const candidates = IS_WINDOWS ? [
|
|
132
|
+
path.join(home, '.amp', 'bin', 'amp.exe'),
|
|
133
|
+
path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'amp', 'amp.exe'),
|
|
134
|
+
path.join(process.env.APPDATA || '', 'npm', 'amp.cmd'),
|
|
135
|
+
] : [
|
|
136
|
+
path.join(home, '.amp', 'bin', 'amp'),
|
|
137
|
+
path.join(home, '.local', 'bin', 'amp'),
|
|
138
|
+
path.join(home, 'bin', 'amp'),
|
|
139
|
+
'/usr/local/bin/amp',
|
|
140
|
+
'/opt/homebrew/bin/amp',
|
|
141
|
+
];
|
|
142
|
+
for (const c of candidates) {
|
|
143
|
+
if (c && fs.existsSync(c)) return c;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Preflight gate (run by the daemon before join). Amp needs a resolvable CLI
|
|
151
|
+
* binary to do anything useful, so when none can be found we surface a precise
|
|
152
|
+
* 'runtime_missing' reason and skip the workspace join entirely — instead of
|
|
153
|
+
* joining and then failing every message. This is also the one place that
|
|
154
|
+
* logs the resolved Amp path for diagnostics. NOTE: 'runtime_missing' (binary
|
|
155
|
+
* gone at run time), NOT 'not_installed' — install detection lives elsewhere.
|
|
156
|
+
*/
|
|
157
|
+
preflight() {
|
|
158
|
+
if (!this._ampBin) this._ampBin = this._findAmpBinary();
|
|
159
|
+
if (!this._ampBin) {
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
reason: REASON.RUNTIME_MISSING,
|
|
163
|
+
message: `Amp CLI not found — install with: ${ampInstallHint()}`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
this._log(`Amp CLI resolved: ${this._ampBin}`);
|
|
167
|
+
return { ok: true };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ------------------------------------------------------------------
|
|
171
|
+
// Prompt / command construction
|
|
172
|
+
// ------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
_buildSystemContext(channelName, browserEnabled = false) {
|
|
175
|
+
return buildOpenclawSystemPrompt({
|
|
176
|
+
agentName: this.agentName,
|
|
177
|
+
workspaceId: this.workspaceId,
|
|
178
|
+
channelName,
|
|
179
|
+
endpoint: this.endpoint,
|
|
180
|
+
token: this.token,
|
|
181
|
+
mode: this._mode,
|
|
182
|
+
disabledModules: this.disabledModules,
|
|
183
|
+
browserEnabled,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
_buildAmpCmd(channelName, resume) {
|
|
188
|
+
const threadId = resume ? this._channelThreads[channelName] : null;
|
|
189
|
+
if (threadId) {
|
|
190
|
+
return [this._ampBin, 'threads', 'continue', threadId, '-x', '--stream-json'];
|
|
191
|
+
}
|
|
192
|
+
return [this._ampBin, '-x', '--stream-json'];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ------------------------------------------------------------------
|
|
196
|
+
// Control actions (stop)
|
|
197
|
+
// ------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
async _onControlAction(action, payload) {
|
|
200
|
+
if (action === 'stop') {
|
|
201
|
+
for (const [channel, proc] of Object.entries(this._channelProcesses)) {
|
|
202
|
+
this._stoppingChannels.add(channel);
|
|
203
|
+
await this._stopProcess(proc);
|
|
204
|
+
delete this._channelProcesses[channel];
|
|
205
|
+
try { await this.sendStatus(channel, 'Execution stopped by user'); } catch {}
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
// Shared actions (status, routines, skill.install/uninstall).
|
|
210
|
+
await super._onControlAction(action, payload);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async _stopProcess(proc) {
|
|
214
|
+
if (!proc || proc.exitCode !== null) return;
|
|
215
|
+
try {
|
|
216
|
+
if (IS_WINDOWS) {
|
|
217
|
+
try { execSync(`taskkill /F /T /PID ${proc.pid}`, { timeout: 5000 }); } catch {}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
// POSIX: kill the whole process group (proc was detached) so child
|
|
221
|
+
// tool processes are reaped too.
|
|
222
|
+
try { process.kill(-proc.pid, 'SIGTERM'); } catch { proc.kill('SIGTERM'); }
|
|
223
|
+
await new Promise((resolve) => {
|
|
224
|
+
const timeout = setTimeout(() => {
|
|
225
|
+
try { process.kill(-proc.pid, 'SIGKILL'); } catch { proc.kill('SIGKILL'); }
|
|
226
|
+
resolve();
|
|
227
|
+
}, 5000);
|
|
228
|
+
proc.on('exit', () => { clearTimeout(timeout); resolve(); });
|
|
229
|
+
});
|
|
230
|
+
} catch {}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ------------------------------------------------------------------
|
|
234
|
+
// Message handler
|
|
235
|
+
// ------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
async _handleMessage(msg) {
|
|
238
|
+
const content = (msg.content || '').trim();
|
|
239
|
+
if (!content) return;
|
|
240
|
+
|
|
241
|
+
const msgChannel = msg.sessionId || this.channelName;
|
|
242
|
+
const sender = msg.senderName || msg.senderType || 'user';
|
|
243
|
+
this._log(`Processing message from ${sender} in ${msgChannel}: ${content.slice(0, 80)}...`);
|
|
244
|
+
|
|
245
|
+
if (!this._ampBin) this._ampBin = this._findAmpBinary();
|
|
246
|
+
if (!this._ampBin) {
|
|
247
|
+
const message = `Amp CLI not found — install with: ${ampInstallHint()}`;
|
|
248
|
+
this._reportStatus(REASON.RUNTIME_MISSING, message);
|
|
249
|
+
await this.sendError(msgChannel, message);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
await this._autoTitleChannel(msgChannel, content);
|
|
254
|
+
this._stoppingChannels.delete(msgChannel);
|
|
255
|
+
await this.sendStatus(msgChannel, 'thinking...');
|
|
256
|
+
|
|
257
|
+
let responseText = '';
|
|
258
|
+
try {
|
|
259
|
+
responseText = await this._runAmp(content, msgChannel);
|
|
260
|
+
} catch (e) {
|
|
261
|
+
// A failure to LAUNCH the amp process (ENOENT/EACCES/spawn error) is a
|
|
262
|
+
// distinct, actionable failure — surface it as spawn_failed with the
|
|
263
|
+
// resolved path + code, never a generic "Not installed". Other errors keep
|
|
264
|
+
// a redacted generic message. Both report up so the agent row shows why.
|
|
265
|
+
if (this._isSpawnError(e)) {
|
|
266
|
+
const { reason, message } = classifySpawnError(e, {
|
|
267
|
+
label: 'Amp',
|
|
268
|
+
bin: this._ampBin,
|
|
269
|
+
});
|
|
270
|
+
this._log(message);
|
|
271
|
+
this._reportStatus(reason, message);
|
|
272
|
+
await this.sendError(msgChannel, message);
|
|
273
|
+
} else {
|
|
274
|
+
this._log(`Error handling message: ${redactDiagnostic(e.message)}`);
|
|
275
|
+
await this.sendError(
|
|
276
|
+
msgChannel,
|
|
277
|
+
`Error processing message: ${redactDiagnostic(e.message)}`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (this._stoppingChannels.has(msgChannel)) {
|
|
284
|
+
this._stoppingChannels.delete(msgChannel);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
// A successful turn proves the runtime is healthy — clear any prior
|
|
288
|
+
// spawn/runtime error the agent row may be showing.
|
|
289
|
+
this._reportStatus(null);
|
|
290
|
+
if (responseText) {
|
|
291
|
+
await this.sendResponse(msgChannel, responseText);
|
|
292
|
+
} else {
|
|
293
|
+
await this.sendResponse(msgChannel, 'No response generated. Please try again.');
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** True when an error is a child_process spawn failure (vs. a runtime error). */
|
|
298
|
+
_isSpawnError(e) {
|
|
299
|
+
if (!e) return false;
|
|
300
|
+
const code = e.code || e.errno;
|
|
301
|
+
return (
|
|
302
|
+
e.syscall === 'spawn' ||
|
|
303
|
+
e.syscall === 'spawn amp' ||
|
|
304
|
+
code === 'ENOENT' ||
|
|
305
|
+
code === 'EACCES' ||
|
|
306
|
+
code === 'EPERM'
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ------------------------------------------------------------------
|
|
311
|
+
// Subprocess execution + stream-json parsing
|
|
312
|
+
// ------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
async _runAmp(content, msgChannel) {
|
|
315
|
+
const browserEnabled = await this.getBrowserEnabled();
|
|
316
|
+
|
|
317
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
318
|
+
const resume = attempt === 0 && !!this._channelThreads[msgChannel];
|
|
319
|
+
const cmd = this._buildAmpCmd(msgChannel, resume);
|
|
320
|
+
|
|
321
|
+
let prompt;
|
|
322
|
+
if (resume) {
|
|
323
|
+
// Resumed Amp thread already carries the workspace context.
|
|
324
|
+
prompt = content;
|
|
325
|
+
} else {
|
|
326
|
+
const context = this._buildSystemContext(msgChannel, browserEnabled);
|
|
327
|
+
prompt = `${context}\n\n---\n\n${content}`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const { text, stale } = await this._spawnAmp(cmd, prompt, msgChannel);
|
|
331
|
+
|
|
332
|
+
if (this._stoppingChannels.has(msgChannel)) return '';
|
|
333
|
+
if (text) return text;
|
|
334
|
+
if (stale && resume) {
|
|
335
|
+
this._log(`Amp thread for ${msgChannel} appears stale — retrying fresh`);
|
|
336
|
+
delete this._channelThreads[msgChannel];
|
|
337
|
+
this._saveSessions();
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
return '';
|
|
341
|
+
}
|
|
342
|
+
return '';
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async _spawnAmp(cmd, prompt, msgChannel) {
|
|
346
|
+
return new Promise((resolve, reject) => {
|
|
347
|
+
// Pass the agent env with an enhanced PATH (nvm/fnm/volta/homebrew,
|
|
348
|
+
// ~/.local/bin, ~/.amp/bin) so the amp subprocess and any tools it shells
|
|
349
|
+
// out to resolve, even in a GUI/daemon process. AMP_API_KEY / AMP_URL
|
|
350
|
+
// flow through unchanged and are never logged.
|
|
351
|
+
const env = getEnhancedEnv(this.agentEnv);
|
|
352
|
+
|
|
353
|
+
const proc = spawn(cmd[0], cmd.slice(1), {
|
|
354
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
355
|
+
env,
|
|
356
|
+
cwd: this.workingDir,
|
|
357
|
+
detached: !IS_WINDOWS,
|
|
358
|
+
windowsHide: true,
|
|
359
|
+
// Windows .cmd/.bat shims (npm/pnpm/yarn global bins) are not directly
|
|
360
|
+
// executable — must go through a shell. Shared helper so the daemon,
|
|
361
|
+
// adapter and launcher probe all agree on the rule.
|
|
362
|
+
shell: shouldUseShellForBinary(cmd[0]),
|
|
363
|
+
});
|
|
364
|
+
this._channelProcesses[msgChannel] = proc;
|
|
365
|
+
// Diagnostic: the exact argv we launched (no secrets — flags only). The
|
|
366
|
+
// resolved binary path was logged at preflight/init.
|
|
367
|
+
this._log(`Running Amp: ${cmd.map((c) => String(c)).join(' ')}`);
|
|
368
|
+
|
|
369
|
+
// Guard the child's stdio streams against 'error'. When the process is
|
|
370
|
+
// SIGKILL'd on stop, macOS Node can emit a benign EPIPE/ECONNRESET 'error'
|
|
371
|
+
// on stdout/stderr a tick later; with a 'data' listener but no 'error'
|
|
372
|
+
// listener that becomes an uncaughtException which crashes the
|
|
373
|
+
// (single-process, e.g. Node 18) test run with exit 1 and no `not ok`.
|
|
374
|
+
// No-op — data handling below is unchanged.
|
|
375
|
+
if (proc.stdout) proc.stdout.on('error', () => {});
|
|
376
|
+
if (proc.stderr) proc.stderr.on('error', () => {});
|
|
377
|
+
|
|
378
|
+
let lastTurnText = [];
|
|
379
|
+
let resultText = '';
|
|
380
|
+
let hasToolUseSinceText = false;
|
|
381
|
+
let lineBuffer = '';
|
|
382
|
+
let stderrBuf = '';
|
|
383
|
+
let pending = Promise.resolve();
|
|
384
|
+
|
|
385
|
+
// Idle watchdog — reset on any stdout activity.
|
|
386
|
+
let idleTimer = null;
|
|
387
|
+
const armIdle = () => {
|
|
388
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
389
|
+
idleTimer = setTimeout(() => {
|
|
390
|
+
this._log(`Amp produced no output for ${IDLE_TIMEOUT_MS / 1000}s — terminating`);
|
|
391
|
+
this._stopProcess(proc);
|
|
392
|
+
}, IDLE_TIMEOUT_MS);
|
|
393
|
+
};
|
|
394
|
+
armIdle();
|
|
395
|
+
|
|
396
|
+
if (proc.stderr) {
|
|
397
|
+
proc.stderr.on('data', (chunk) => { stderrBuf += chunk.toString('utf-8'); });
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (proc.stdin) {
|
|
401
|
+
proc.stdin.write(prompt || '', 'utf-8');
|
|
402
|
+
proc.stdin.end();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const processLine = async (line) => {
|
|
406
|
+
line = line.trim();
|
|
407
|
+
if (!line) return;
|
|
408
|
+
let event;
|
|
409
|
+
try { event = JSON.parse(line); } catch { return; }
|
|
410
|
+
|
|
411
|
+
const eventType = event.type;
|
|
412
|
+
if (eventType === 'system') {
|
|
413
|
+
if (event.session_id) this._rememberThread(msgChannel, event.session_id);
|
|
414
|
+
} else if (eventType === 'assistant') {
|
|
415
|
+
const blocks = (event.message && event.message.content) || [];
|
|
416
|
+
for (const block of blocks) {
|
|
417
|
+
if (block.type === 'text') {
|
|
418
|
+
const text = (block.text || '').trim();
|
|
419
|
+
if (!text) continue;
|
|
420
|
+
if (hasToolUseSinceText) {
|
|
421
|
+
lastTurnText = [];
|
|
422
|
+
hasToolUseSinceText = false;
|
|
423
|
+
}
|
|
424
|
+
lastTurnText.push(text);
|
|
425
|
+
try { await this.sendThinking(msgChannel, text); } catch {}
|
|
426
|
+
} else if (block.type === 'tool_use') {
|
|
427
|
+
hasToolUseSinceText = true;
|
|
428
|
+
lastTurnText = [];
|
|
429
|
+
const toolName = block.name || '';
|
|
430
|
+
const toolInput = String(JSON.stringify(block.input || {})).slice(0, 200);
|
|
431
|
+
try { await this.sendStatus(msgChannel, `**Using tool:** \`${toolName}\`\n\`\`\`\n${toolInput}\n\`\`\``); } catch {}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
} else if (eventType === 'result') {
|
|
435
|
+
if (event.session_id) this._rememberThread(msgChannel, event.session_id);
|
|
436
|
+
if (event.is_error) this._log(`Amp result error: ${String(event.result || '').slice(0, 200)}`);
|
|
437
|
+
if (event.result) resultText = event.result;
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
proc.stdout.on('data', (chunk) => {
|
|
442
|
+
armIdle();
|
|
443
|
+
lineBuffer += chunk.toString('utf-8');
|
|
444
|
+
const lines = lineBuffer.split('\n');
|
|
445
|
+
lineBuffer = lines.pop();
|
|
446
|
+
for (const line of lines) {
|
|
447
|
+
pending = pending.then(() => processLine(line)).catch(() => {});
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
proc.on('exit', async (code) => {
|
|
452
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
453
|
+
try { await pending; } catch {}
|
|
454
|
+
for (const line of lineBuffer.split('\n')) {
|
|
455
|
+
try { await processLine(line); } catch {}
|
|
456
|
+
}
|
|
457
|
+
delete this._channelProcesses[msgChannel];
|
|
458
|
+
|
|
459
|
+
if (code !== 0 && stderrBuf.trim()) {
|
|
460
|
+
this._log(`Amp exited ${code}; stderr: ${stderrBuf.trim().slice(0, 300)}`);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const text = lastTurnText.filter(Boolean).join('\n').trim() || resultText.trim();
|
|
464
|
+
const stale = code !== 0 && !text;
|
|
465
|
+
resolve({ text, exitCode: code, stale });
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
proc.on('error', (err) => {
|
|
469
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
470
|
+
delete this._channelProcesses[msgChannel];
|
|
471
|
+
reject(err);
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
module.exports = AmpAdapter;
|
package/src/adapters/base.js
CHANGED
|
@@ -20,9 +20,21 @@
|
|
|
20
20
|
const { WorkspaceClient, SessionRevokedError } = require('../workspace-client');
|
|
21
21
|
const { generateSessionTitle, SESSION_DEFAULT_RE } = require('./utils');
|
|
22
22
|
const { defaultAgentWorkdir } = require('../paths');
|
|
23
|
+
const {
|
|
24
|
+
REASON,
|
|
25
|
+
classifyJoinError,
|
|
26
|
+
classifyHeartbeatError,
|
|
27
|
+
} = require('./health-status');
|
|
23
28
|
|
|
24
29
|
const DEFAULT_ENDPOINT = 'https://workspace-endpoint.openagents.org';
|
|
25
30
|
|
|
31
|
+
// Heartbeat runs every 30s. A SINGLE failure is usually a transient blip (brief
|
|
32
|
+
// network hiccup, server redeploy) that the next tick recovers from — surfacing
|
|
33
|
+
// it as a hard 'error' would make the agent flap red for no real reason. Only
|
|
34
|
+
// after this many CONSECUTIVE failures (~60s+ of real downtime) do we report
|
|
35
|
+
// heartbeat_failed up to the daemon. A success resets the streak immediately.
|
|
36
|
+
const HEARTBEAT_ERROR_THRESHOLD = 2;
|
|
37
|
+
|
|
26
38
|
class BaseAdapter {
|
|
27
39
|
/**
|
|
28
40
|
* @param {object} opts
|
|
@@ -32,7 +44,7 @@ class BaseAdapter {
|
|
|
32
44
|
* @param {string} opts.agentName
|
|
33
45
|
* @param {string} [opts.endpoint]
|
|
34
46
|
*/
|
|
35
|
-
constructor({ workspaceId, channelName, token, agentName, endpoint, agentEnv, agentType, workingDir }) {
|
|
47
|
+
constructor({ workspaceId, channelName, token, agentName, endpoint, agentEnv, agentType, workingDir, onStatus }) {
|
|
36
48
|
this.workspaceId = workspaceId;
|
|
37
49
|
this.channelName = channelName;
|
|
38
50
|
this.token = token;
|
|
@@ -41,6 +53,21 @@ class BaseAdapter {
|
|
|
41
53
|
this.agentEnv = agentEnv || process.env;
|
|
42
54
|
this.agentType = agentType;
|
|
43
55
|
this.workingDir = workingDir || undefined;
|
|
56
|
+
// Optional callback the daemon supplies to surface live runtime/connectivity
|
|
57
|
+
// status (reason + redacted message) into daemon.status.json so the Agents
|
|
58
|
+
// list / TUI can show the REAL failure instead of a swallowed log line. A
|
|
59
|
+
// null reason means "healthy again" (clears any prior error).
|
|
60
|
+
this._onStatus = typeof onStatus === 'function' ? onStatus : null;
|
|
61
|
+
this._lastReportedStatusKey = null;
|
|
62
|
+
// Consecutive heartbeat failures — a transient single blip must not flip the
|
|
63
|
+
// agent to a hard error (see HEARTBEAT_ERROR_THRESHOLD).
|
|
64
|
+
this._heartbeatFailStreak = 0;
|
|
65
|
+
// Structured terminal exit reason ({ reason, message }) read by the daemon
|
|
66
|
+
// after run() returns, to distinguish a clean stop from a real failure.
|
|
67
|
+
this._exitInfo = null;
|
|
68
|
+
// Set when the user explicitly stops this adapter (vs an error/revoke), so a
|
|
69
|
+
// clean stop is never mislabeled as an error.
|
|
70
|
+
this._stopRequested = false;
|
|
44
71
|
this.client = new WorkspaceClient(this.endpoint);
|
|
45
72
|
this._lastEventId = null;
|
|
46
73
|
this._lastToolResultId = null;
|
|
@@ -71,13 +98,62 @@ class BaseAdapter {
|
|
|
71
98
|
}
|
|
72
99
|
|
|
73
100
|
// ------------------------------------------------------------------
|
|
74
|
-
//
|
|
101
|
+
// Runtime status reporting (daemon surfaces this in daemon.status.json)
|
|
75
102
|
// ------------------------------------------------------------------
|
|
76
103
|
|
|
77
|
-
|
|
78
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Surface a live status transition to the daemon. `reason` null/'' means the
|
|
106
|
+
* agent is healthy again (clears any prior error). Deduped so a repeated
|
|
107
|
+
* failure (e.g. heartbeat every 30s on a down workspace) writes the status
|
|
108
|
+
* file once, not on every tick. Never throws — status is best-effort.
|
|
109
|
+
*/
|
|
110
|
+
_reportStatus(reason, message) {
|
|
111
|
+
const key = `${reason || ''}|${message || ''}`;
|
|
112
|
+
if (key === this._lastReportedStatusKey) return;
|
|
113
|
+
this._lastReportedStatusKey = key;
|
|
114
|
+
if (!this._onStatus) return;
|
|
115
|
+
try {
|
|
116
|
+
this._onStatus({ reason: reason || null, message: message || null });
|
|
117
|
+
} catch { /* status is best-effort */ }
|
|
118
|
+
}
|
|
79
119
|
|
|
80
|
-
|
|
120
|
+
/** Record the FIRST terminal failure reason; later teardown noise can't mask it. */
|
|
121
|
+
_setExitInfo(reason, message) {
|
|
122
|
+
if (!this._exitInfo) this._exitInfo = { reason, message };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Read by the daemon after run() returns. null = clean exit. */
|
|
126
|
+
getExitInfo() {
|
|
127
|
+
return this._exitInfo;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** True when stop() was called explicitly (a clean user stop, not a failure). */
|
|
131
|
+
wasStopRequested() {
|
|
132
|
+
return this._stopRequested === true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Preflight gate, run by the daemon BEFORE join. Default: always runnable.
|
|
137
|
+
* Subclasses whose agent needs a resolvable CLI binary override this to return
|
|
138
|
+
* { ok:false, reason:'runtime_missing', message } so the daemon surfaces a
|
|
139
|
+
* precise reason and skips the workspace join (no pointless join loop).
|
|
140
|
+
*/
|
|
141
|
+
preflight() {
|
|
142
|
+
return { ok: true };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ------------------------------------------------------------------
|
|
146
|
+
// Lifecycle
|
|
147
|
+
// ------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Announce this agent to the workspace (/v1/join). Returns true on success.
|
|
151
|
+
* On failure surfaces the REAL reason (e.g. "Workspace join failed: HTTP 401")
|
|
152
|
+
* to the daemon status instead of only logging it — non-fatal, the poll/
|
|
153
|
+
* heartbeat loops keep retrying and a later success clears it. Extracted from
|
|
154
|
+
* run() so the failure-reporting can be unit-tested without the poll loop.
|
|
155
|
+
*/
|
|
156
|
+
async _joinWorkspace() {
|
|
81
157
|
try {
|
|
82
158
|
const joinResult = await this.client.joinNetwork(this.agentName, this.token, {
|
|
83
159
|
network: this.workspaceId,
|
|
@@ -87,9 +163,21 @@ class BaseAdapter {
|
|
|
87
163
|
});
|
|
88
164
|
this._sessionId = (joinResult && joinResult.session_id) || null;
|
|
89
165
|
this._log(`Joined workspace ${this.workspaceId}${this._sessionId ? ` (session ${this._sessionId.slice(0, 8)})` : ''}`);
|
|
166
|
+
this._reportStatus(null); // joined OK → clear any prior error
|
|
167
|
+
return true;
|
|
90
168
|
} catch (e) {
|
|
91
|
-
|
|
169
|
+
const { reason, message } = classifyJoinError(e);
|
|
170
|
+
this._log(`${message} (status: ${e && e.statusCode != null ? e.statusCode : 'n/a'})`);
|
|
171
|
+
this._reportStatus(reason, message);
|
|
172
|
+
return false;
|
|
92
173
|
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async run() {
|
|
177
|
+
this._running = true;
|
|
178
|
+
|
|
179
|
+
// Announce agent to workspace
|
|
180
|
+
await this._joinWorkspace();
|
|
93
181
|
|
|
94
182
|
// Sync workspace-managed skills into disabledModules
|
|
95
183
|
try {
|
|
@@ -137,6 +225,7 @@ class BaseAdapter {
|
|
|
137
225
|
}
|
|
138
226
|
|
|
139
227
|
stop() {
|
|
228
|
+
this._stopRequested = true;
|
|
140
229
|
this._running = false;
|
|
141
230
|
}
|
|
142
231
|
|
|
@@ -164,13 +253,25 @@ class BaseAdapter {
|
|
|
164
253
|
async _heartbeat() {
|
|
165
254
|
try {
|
|
166
255
|
await this.client.heartbeat(this.workspaceId, this.agentName, this.token, this._sessionId);
|
|
256
|
+
this._heartbeatFailStreak = 0;
|
|
257
|
+
this._reportStatus(null); // alive → clear any prior connectivity error
|
|
167
258
|
} catch (e) {
|
|
168
259
|
if (e instanceof SessionRevokedError) {
|
|
169
260
|
this._log(`SESSION REVOKED: another client joined as '${this.agentName}'. Stopping adapter.`);
|
|
261
|
+
// Terminal (not a user stop): record so the daemon can show why it ended.
|
|
262
|
+
this._setExitInfo(REASON.SESSION_REVOKED, 'Workspace session revoked — another client joined with the same agent name');
|
|
263
|
+
this._reportStatus(REASON.SESSION_REVOKED, 'Workspace session revoked');
|
|
170
264
|
this._running = false;
|
|
171
265
|
return;
|
|
172
266
|
}
|
|
173
|
-
|
|
267
|
+
// Only surface a hard error after repeated consecutive failures, so a
|
|
268
|
+
// single transient blip (or an expected brief reconnect) isn't mislabeled.
|
|
269
|
+
this._heartbeatFailStreak++;
|
|
270
|
+
const { reason, message } = classifyHeartbeatError(e);
|
|
271
|
+
this._log(`${message} (consecutive failures: ${this._heartbeatFailStreak})`);
|
|
272
|
+
if (this._heartbeatFailStreak >= HEARTBEAT_ERROR_THRESHOLD) {
|
|
273
|
+
this._reportStatus(reason, message);
|
|
274
|
+
}
|
|
174
275
|
}
|
|
175
276
|
}
|
|
176
277
|
|