@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.
@@ -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;
@@ -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
- // Lifecycle
101
+ // Runtime status reporting (daemon surfaces this in daemon.status.json)
75
102
  // ------------------------------------------------------------------
76
103
 
77
- async run() {
78
- this._running = true;
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
- // Announce agent to workspace
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
- this._log(`Warning: join failed: ${e.message} \nStack: ${e.stack}`);
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
- this._log(`Heartbeat failed: ${e.message}`);
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