@openagents-org/agent-launcher 0.2.143 → 0.2.146

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;