@openagents-org/agent-launcher 0.2.143 → 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,914 @@
1
+ /**
2
+ * Cline CLI adapter for OpenAgents workspace.
3
+ *
4
+ * Bridges the Cline CLI (https://github.com/cline/cline, `npm i -g cline`) to an
5
+ * OpenAgents workspace:
6
+ * - polling loop + per-channel task dispatch (inherited from BaseAdapter)
7
+ * - one `cline --json` subprocess per user message (Cline runs the agent loop
8
+ * in-process for a one-shot run; killing the process tree stops the task)
9
+ * - the NDJSON event stream is parsed (see cline-stream.js) and mapped to the
10
+ * standard OpenAgents events (thinking / status / todos / response / error)
11
+ * - real session continuity via Cline's `--id` resume, correlated from
12
+ * `cline history --json` (Cline does not emit the session id inline)
13
+ *
14
+ * Cline-specific behavior lives here and in cline-stream.js; the shared
15
+ * connectivity, queuing, redaction patterns and process-group teardown follow
16
+ * the same conventions as the Claude/Gemini adapters.
17
+ *
18
+ * Minimum supported CLI is 3.0.0 (HARD gate): a confirmed-older CLI refuses to
19
+ * start; an undetermined version proceeds leniently.
20
+ *
21
+ * Verified against Cline CLI v3.0.26 / v3.0.27.
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ const fs = require('fs');
27
+ const os = require('os');
28
+ const path = require('path');
29
+ const { execSync, execFile, spawn } = require('child_process');
30
+
31
+ const BaseAdapter = require('./base');
32
+ const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
33
+ const { defaultAgentWorkdir, whichBinary, whereBinary } = require('../paths');
34
+ const {
35
+ ClineStreamParser,
36
+ interpretClineEnvelope,
37
+ buildClineArgs,
38
+ redactArgs,
39
+ redactSecrets,
40
+ classifyClineError,
41
+ classifyClineAuth,
42
+ classifyClineVersion,
43
+ pickClineSessionId,
44
+ MIN_CLINE_VERSION,
45
+ } = require('./cline-stream');
46
+
47
+ const IS_WINDOWS = process.platform === 'win32';
48
+
49
+ // Idle watchdog: if stdout is silent this long while a run is in flight we
50
+ // nudge the user; after MAX consecutive silences we kill the (possibly hung)
51
+ // process. Cline can hang on connection errors without exiting, so this is the
52
+ // backstop that prevents a thread spinning on "thinking…" forever.
53
+ const WATCHDOG_INTERVAL_MS = 15_000;
54
+ const WATCHDOG_NUDGE_AT = 2; // ~30s of silence → "still working"
55
+ const WATCHDOG_MAX = 20; // ~5 min of silence → kill
56
+
57
+ // The ONE known-benign stderr {type:"error"} diagnostic Cline emits on every
58
+ // run (verified v3.0.27). Matched EXACTLY (whole, trimmed message) so it can
59
+ // never swallow a real error that merely starts with "hook dispatch failed:".
60
+ const BENIGN_STDERR_RE = /^hook dispatch failed: session\.hook requires a valid hook event payload\.?$/i;
61
+
62
+ // Cline version check cache: keyed by resolved binary path so it is shared
63
+ // across messages (no `cline --version` per status refresh) yet re-detects
64
+ // after the TTL — short enough that an install/upgrade isn't masked for long.
65
+ const VERSION_CACHE_TTL_MS = 5 * 60 * 1000;
66
+ const _clineVersionCache = new Map(); // binPath -> { version, supported, at }
67
+
68
+ class ClineAdapter extends BaseAdapter {
69
+ /**
70
+ * @param {object} opts - BaseAdapter opts plus:
71
+ * @param {Set} [opts.disabledModules]
72
+ * @param {string} [opts.workingDir]
73
+ */
74
+ constructor(opts) {
75
+ super(opts);
76
+ this.disabledModules = opts.disabledModules || new Set();
77
+ // channel → { sessionId, workingDir }
78
+ this._channelSessions = {};
79
+ // channel → child process (the in-flight `cline` run)
80
+ this._channelProcesses = {};
81
+ this._stoppingChannels = new Set();
82
+ this._sessionsFile = path.join(
83
+ os.homedir(), '.openagents', 'sessions',
84
+ `${this.workspaceId}_${this.agentName}_cline.json`,
85
+ );
86
+ this._loadSessions();
87
+ }
88
+
89
+ // ------------------------------------------------------------------
90
+ // Session persistence (real Cline session ids, bound to working dir)
91
+ // ------------------------------------------------------------------
92
+
93
+ _loadSessions() {
94
+ try {
95
+ if (fs.existsSync(this._sessionsFile)) {
96
+ const data = JSON.parse(fs.readFileSync(this._sessionsFile, 'utf-8'));
97
+ if (data && typeof data === 'object') {
98
+ Object.assign(this._channelSessions, data);
99
+ this._log(`Loaded ${Object.keys(data).length} Cline session(s)`);
100
+ }
101
+ }
102
+ } catch {
103
+ this._log('Could not load Cline sessions file, starting fresh');
104
+ }
105
+ }
106
+
107
+ _saveSessions() {
108
+ try {
109
+ fs.mkdirSync(path.dirname(this._sessionsFile), { recursive: true });
110
+ fs.writeFileSync(this._sessionsFile, JSON.stringify(this._channelSessions));
111
+ } catch {}
112
+ }
113
+
114
+ /** Return a valid saved session id for this channel, or null. Only resume
115
+ * when the saved working dir matches the current one (don't cross projects). */
116
+ _resumableSession(channel, workingDir) {
117
+ const entry = this._channelSessions[channel];
118
+ if (!entry || !entry.sessionId) return null;
119
+ if (entry.workingDir && workingDir && entry.workingDir !== workingDir) return null;
120
+ return entry.sessionId;
121
+ }
122
+
123
+ _clearSession(channel) {
124
+ if (this._channelSessions[channel]) {
125
+ delete this._channelSessions[channel];
126
+ this._saveSessions();
127
+ }
128
+ }
129
+
130
+ // ------------------------------------------------------------------
131
+ // Control actions (stop / restart)
132
+ // ------------------------------------------------------------------
133
+
134
+ async _onControlAction(action, payload) {
135
+ if (action === 'stop') {
136
+ const channel = (payload && typeof payload === 'object') ? payload.channel : null;
137
+ if (channel && this._channelProcesses[channel]) {
138
+ this._stoppingChannels.add(channel);
139
+ await this._stopProcess(this._channelProcesses[channel]);
140
+ delete this._channelProcesses[channel];
141
+ delete this._channelQueues[channel];
142
+ try { await this.sendResponse(channel, 'Execution stopped by user.'); } catch {}
143
+ } else {
144
+ await this._stopAllProcesses('Execution stopped by user.');
145
+ }
146
+ return;
147
+ }
148
+ if (action === 'restart') {
149
+ const channel = (payload && typeof payload === 'object') ? payload.channel : null;
150
+ if (channel) {
151
+ if (this._channelProcesses[channel]) {
152
+ try { await this._stopProcess(this._channelProcesses[channel]); } catch {}
153
+ delete this._channelProcesses[channel];
154
+ }
155
+ this._clearSession(channel);
156
+ try {
157
+ await this.client.sendMessage(this.workspaceId, channel, this.token,
158
+ 'Session restarted — next message starts a fresh Cline session.',
159
+ { senderType: 'agent', senderName: this.agentName, messageType: 'status',
160
+ metadata: { agent_mode: this._mode }, sessionId: this._sessionId });
161
+ } catch {}
162
+ } else {
163
+ this._channelSessions = {};
164
+ this._saveSessions();
165
+ await this._stopAllProcesses('Execution stopped.');
166
+ }
167
+ return;
168
+ }
169
+ await super._onControlAction(action, payload);
170
+ }
171
+
172
+ /** Daemon shutdown — tear down any in-flight cline runs so threads don't
173
+ * hang showing "running". Fire-and-forget; the daemon allows a short grace. */
174
+ stop() {
175
+ this._stopAllProcesses(
176
+ 'Task interrupted — daemon restarting. Send another message to continue.',
177
+ ).catch(() => {});
178
+ super.stop();
179
+ }
180
+
181
+ async _stopAllProcesses(message = 'Execution stopped.') {
182
+ const entries = Object.entries(this._channelProcesses);
183
+ if (!entries.length) return;
184
+ this._log(`Stopping ${entries.length} running Cline process(es)...`);
185
+ for (const [channel, proc] of entries) {
186
+ this._stoppingChannels.add(channel);
187
+ await this._stopProcess(proc);
188
+ delete this._channelProcesses[channel];
189
+ delete this._channelQueues[channel];
190
+ try { await this.sendResponse(channel, message); } catch {}
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Stop a cline process tree gracefully then forcefully. We SIGINT first
196
+ * (Cline's documented graceful-abort signal) so it can wind down its tool
197
+ * work, then escalate to SIGTERM/SIGKILL on the whole POSIX process group
198
+ * (the node wrapper + the bun worker share the group) or via `taskkill /T`
199
+ * on Windows. The shared `cline --cline-hub-daemon` (ppid=1, its own group)
200
+ * is intentionally left running — it is per-cwd infrastructure shared across
201
+ * runs, like a language server; `cline doctor fix` clears stale ones.
202
+ */
203
+ async _stopProcess(proc) {
204
+ if (!proc || proc.exitCode !== null) return;
205
+ try {
206
+ if (IS_WINDOWS) {
207
+ try { proc.kill('SIGINT'); } catch {}
208
+ const exited = await this._waitExit(proc, 1500);
209
+ if (!exited) {
210
+ try { execSync(`taskkill /F /T /PID ${proc.pid}`, { timeout: 5000, windowsHide: true }); } catch {}
211
+ }
212
+ } else {
213
+ try { process.kill(-proc.pid, 'SIGINT'); } catch { try { proc.kill('SIGINT'); } catch {} }
214
+ let exited = await this._waitExit(proc, 1500);
215
+ if (!exited) {
216
+ try { process.kill(-proc.pid, 'SIGTERM'); } catch { try { proc.kill('SIGTERM'); } catch {} }
217
+ exited = await this._waitExit(proc, 1500);
218
+ }
219
+ if (!exited) {
220
+ try { process.kill(-proc.pid, 'SIGKILL'); } catch { try { proc.kill('SIGKILL'); } catch {} }
221
+ await this._waitExit(proc, 1000);
222
+ }
223
+ }
224
+ } catch {}
225
+ }
226
+
227
+ _waitExit(proc, ms) {
228
+ return new Promise((resolve) => {
229
+ if (proc.exitCode !== null) { resolve(true); return; }
230
+ const t = setTimeout(() => resolve(false), ms);
231
+ proc.once('exit', () => { clearTimeout(t); resolve(true); });
232
+ });
233
+ }
234
+
235
+ // ------------------------------------------------------------------
236
+ // Binary resolution (cross-platform; mirrors the Claude/Gemini adapters)
237
+ // ------------------------------------------------------------------
238
+
239
+ _findNodeBin() {
240
+ const home = os.homedir();
241
+ const candidates = IS_WINDOWS
242
+ ? [path.join(home, '.openagents', 'nodejs', 'node.exe')]
243
+ : [path.join(home, '.openagents', 'nodejs', 'node'),
244
+ path.join(home, '.openagents', 'nodejs', 'bin', 'node')];
245
+ for (const c of candidates) if (fs.existsSync(c)) return c;
246
+ // Fall back to the node already running this daemon (absolute, always valid).
247
+ // Bare 'node' can be off-PATH in a packaged daemon or CI runner.
248
+ return process.execPath;
249
+ }
250
+
251
+ /**
252
+ * Resolve a shim/symlink to [nodeBin, jsEntry] so we spawn the JS wrapper
253
+ * directly. On Windows this avoids wrapping a `.cmd` in `cmd.exe /c`, whose
254
+ * 8191-char command-line cap would truncate a long prompt. Cline's
255
+ * `bin/cline` IS a Node script (it re-spawns the bundled native binary), so
256
+ * running it under node is the most robust path on every OS.
257
+ */
258
+ _resolveToNodeCmd(binPath) {
259
+ const nodeBin = this._findNodeBin();
260
+ if (IS_WINDOWS && binPath.toLowerCase().endsWith('.cmd')) {
261
+ try {
262
+ const cmdDir = path.dirname(path.resolve(binPath));
263
+ const content = fs.readFileSync(binPath, 'utf-8');
264
+ const jsMatch = content.match(/%dp0%\\([^\s"*?]+\.m?js)/i);
265
+ if (jsMatch) return [nodeBin, path.resolve(cmdDir, jsMatch[1])];
266
+ const exeMatch = content.match(/%dp0%\\([^\s"*?]+\.exe)/i);
267
+ if (exeMatch) return [path.resolve(cmdDir, exeMatch[1])];
268
+ } catch {}
269
+ } else {
270
+ try {
271
+ let target = binPath;
272
+ if (fs.lstatSync(binPath).isSymbolicLink()) {
273
+ target = path.resolve(path.dirname(binPath), fs.readlinkSync(binPath));
274
+ }
275
+ if (target.endsWith('.js') || target.endsWith('.mjs')) return [nodeBin, target];
276
+ // Cline's package bin (`node_modules/cline/bin/cline`) is an
277
+ // extensionless Node script with a `#!/usr/bin/env node` shebang. Run
278
+ // it under node explicitly — required on Windows (no shebang support)
279
+ // and harmless elsewhere.
280
+ if (this._isNodeShebangScript(target)) return [nodeBin, target];
281
+ } catch {}
282
+ }
283
+ return null;
284
+ }
285
+
286
+ /** True when a file begins with a `#!...node` shebang. */
287
+ _isNodeShebangScript(filePath) {
288
+ try {
289
+ const fd = fs.openSync(filePath, 'r');
290
+ try {
291
+ const buf = Buffer.alloc(64);
292
+ const n = fs.readSync(fd, buf, 0, 64, 0);
293
+ const head = buf.slice(0, n).toString('utf-8');
294
+ return head.startsWith('#!') && /\bnode\b/.test(head.split('\n')[0]);
295
+ } finally {
296
+ fs.closeSync(fd);
297
+ }
298
+ } catch {
299
+ return false;
300
+ }
301
+ }
302
+
303
+ _findClineBinary() {
304
+ const home = os.homedir();
305
+ const ext = IS_WINDOWS ? '.cmd' : '';
306
+
307
+ // Tier 0: isolated runtime prefix (~/.openagents/runtimes/cline/)
308
+ const runtimeCandidate = path.join(home, '.openagents', 'runtimes', 'cline', 'node_modules', '.bin', `cline${ext}`);
309
+ if (fs.existsSync(runtimeCandidate)) return runtimeCandidate;
310
+
311
+ // Tier 0b: legacy portable install
312
+ const portable = path.join(home, '.openagents', 'nodejs', 'node_modules', '.bin', `cline${ext}`);
313
+ if (fs.existsSync(portable)) return portable;
314
+
315
+ // Tier 0c: the package's OWN bin. npm does not create a node_modules/.bin
316
+ // shim for Cline (its `bin` is "./bin/cline"), so a local prefix install
317
+ // leaves no `.bin/cline` — but the package bin is always present and is a
318
+ // Node script we run via _resolveToNodeCmd. Check the runtime then legacy
319
+ // prefix.
320
+ for (const root of [
321
+ path.join(home, '.openagents', 'runtimes', 'cline', 'node_modules', 'cline'),
322
+ path.join(home, '.openagents', 'nodejs', 'node_modules', 'cline'),
323
+ ]) {
324
+ const pkgBin = path.join(root, 'bin', 'cline');
325
+ if (fs.existsSync(pkgBin)) return pkgBin;
326
+ }
327
+
328
+ // Tier 1: PATH search via a codepage-safe lookup (whereBinary forces UTF-8
329
+ // output + verifies existence so a non-ASCII/Chinese username isn't mangled
330
+ // into an ENOENT). Uses the ENRICHED env (a packaged daemon's PATH is
331
+ // minimal and would miss nvm/fnm/volta/homebrew/npm-global dirs).
332
+ const viaWhere = whereBinary('cline');
333
+ if (viaWhere) return viaWhere;
334
+
335
+ // Tier 2: next to the current Node interpreter (npm global)
336
+ const nearNode = path.join(path.dirname(process.execPath), `cline${ext}`);
337
+ if (fs.existsSync(nearNode)) return nearNode;
338
+
339
+ // Tier 3: common install locations
340
+ const candidates = IS_WINDOWS ? [
341
+ path.join(process.env.APPDATA || '', 'npm', 'cline.cmd'),
342
+ ] : [
343
+ path.join(home, '.local', 'bin', 'cline'),
344
+ path.join(home, '.npm-global', 'bin', 'cline'),
345
+ '/opt/homebrew/bin/cline',
346
+ '/usr/local/bin/cline',
347
+ ];
348
+ for (const c of candidates) if (fs.existsSync(c)) return c;
349
+
350
+ // Tier 4: deep scan of every known bin dir (nvm/fnm/volta/homebrew/…)
351
+ const viaWhich = whichBinary('cline');
352
+ if (viaWhich) return viaWhich;
353
+
354
+ return null;
355
+ }
356
+
357
+ /** Resolve [cmd, ...args] for spawning, handling node-script wrappers. */
358
+ _spawnableCmd(binPath, args) {
359
+ const resolved = this._resolveToNodeCmd(binPath);
360
+ if (resolved) return [...resolved, ...args];
361
+ if (IS_WINDOWS && binPath.toLowerCase().endsWith('.cmd')) return ['cmd.exe', '/c', binPath, ...args];
362
+ return [binPath, ...args];
363
+ }
364
+
365
+ // ------------------------------------------------------------------
366
+ // Version preflight (cached; HARD minimum 3.0.0)
367
+ // ------------------------------------------------------------------
368
+
369
+ /** Run `cline --version` and return its raw output. Isolated for testing. */
370
+ _readClineVersionRaw(clineBin) {
371
+ return execSync(`"${clineBin}" --version`, { encoding: 'utf-8', timeout: 8000, windowsHide: true }).trim();
372
+ }
373
+
374
+ /**
375
+ * Resolve the installed Cline version and its compatibility, cached per
376
+ * binary path with a TTL so repeated messages/status refreshes don't re-spawn
377
+ * `cline --version`, while an upgrade is picked up after the TTL.
378
+ * @returns {{version: string|null, compatible: boolean|null}}
379
+ * compatible true → >= MIN; false → CONFIRMED too old; null → undetermined.
380
+ */
381
+ _checkClineVersion(clineBin) {
382
+ const now = Date.now();
383
+ const cached = _clineVersionCache.get(clineBin);
384
+ if (cached && (now - cached.at) < VERSION_CACHE_TTL_MS) {
385
+ return { version: cached.version, compatible: cached.supported };
386
+ }
387
+ let version = null;
388
+ let supported = null; // undetermined unless we can parse a version
389
+ try {
390
+ ({ version, supported } = classifyClineVersion(this._readClineVersionRaw(clineBin)));
391
+ } catch {
392
+ // `cline --version` failed → undetermined (NOT treated as compatible).
393
+ version = null;
394
+ supported = null;
395
+ }
396
+ _clineVersionCache.set(clineBin, { version, supported, at: now });
397
+ return { version, compatible: supported };
398
+ }
399
+
400
+ /** Clear the shared version cache (test hook; also call after install/upgrade). */
401
+ static _clearVersionCache() {
402
+ _clineVersionCache.clear();
403
+ }
404
+
405
+ // ------------------------------------------------------------------
406
+ // Auth / config introspection (no secret values are read or logged)
407
+ // ------------------------------------------------------------------
408
+
409
+ _readProvidersConfig() {
410
+ const cfg = path.join(os.homedir(), '.cline', 'data', 'settings', 'providers.json');
411
+ try {
412
+ if (!fs.existsSync(cfg)) return null;
413
+ return JSON.parse(fs.readFileSync(cfg, 'utf-8'));
414
+ } catch {
415
+ return { __parse_error: true };
416
+ }
417
+ }
418
+
419
+ /** Classify auth using providers.json + the agent env (heuristic, never an
420
+ * absolute verdict). Returns the classifyClineAuth shape; a parse error maps
421
+ * to `unknown` (not a hard failure). No secret values are read or returned. */
422
+ _authState() {
423
+ const parsed = this._readProvidersConfig();
424
+ // classifyClineAuth treats the `{__parse_error:true}` sentinel as unknown.
425
+ return classifyClineAuth(parsed, this.agentEnv || process.env);
426
+ }
427
+
428
+ // ------------------------------------------------------------------
429
+ // Prompt assembly
430
+ // ------------------------------------------------------------------
431
+
432
+ /** A compact, Cline-appropriate context header. We do NOT inject the full
433
+ * workspace system prompt (it advertises MCP tools Cline isn't wired with);
434
+ * Cline keeps its own coding system prompt, and `-s` would replace it. */
435
+ _contextHeader(channel) {
436
+ const lines = [
437
+ `[OpenAgents workspace] You are "${this.agentName}", a coding agent in workspace channel "${channel}".`,
438
+ ];
439
+ if (this._mode === 'plan') {
440
+ lines.push('You are in PLAN mode: investigate and propose a plan; do not modify files.');
441
+ }
442
+ lines.push('Work in the current working directory. Reply concisely. The user request follows:');
443
+ return lines.join('\n');
444
+ }
445
+
446
+ /** Short transcript of recent chat used to re-seed context when starting a
447
+ * fresh session (resume unavailable). Bounded to keep argv small on Windows. */
448
+ async _buildChannelRecap(channel, currentMessage) {
449
+ try {
450
+ const messages = await this.client.getRecentMessages(this.workspaceId, channel, this.token, 30);
451
+ if (!messages || messages.length === 0) return null;
452
+ const lines = [];
453
+ for (const m of messages) {
454
+ const mt = m.messageType || 'chat';
455
+ if (mt === 'status' || mt === 'thinking' || mt === 'loading' || mt === 'todos') continue;
456
+ const text = (m.content || '').trim();
457
+ if (!text || text === currentMessage) continue;
458
+ const who = m.senderType === 'human' ? (m.senderName || 'user') : (m.senderName || 'agent');
459
+ lines.push(`[${who}] ${text.length > 800 ? text.slice(0, 800) + '…' : text}`);
460
+ }
461
+ if (lines.length === 0) return null;
462
+ return 'Recent conversation in this channel for context:\n\n' + lines.slice(-12).join('\n');
463
+ } catch {
464
+ return null;
465
+ }
466
+ }
467
+
468
+ // ------------------------------------------------------------------
469
+ // Message handling
470
+ // ------------------------------------------------------------------
471
+
472
+ async _handleMessage(msg) {
473
+ let content = (msg.content || '').trim();
474
+ const attachments = msg.attachments || [];
475
+ const attText = formatAttachmentsForPrompt(attachments, 'skills');
476
+ if (attText) content = content ? content + attText : attText.trim();
477
+ if (!content) return;
478
+
479
+ const channel = msg.sessionId || this.channelName;
480
+ this._stoppingChannels.delete(channel);
481
+ const sender = msg.senderName || msg.senderType || 'user';
482
+ this._log(`Processing message from ${sender} in ${channel}: ${redactSecrets(content.slice(0, 80))}...`);
483
+
484
+ // Resolve and validate the working directory up front. Never silently fall
485
+ // back to the launcher/repo dir — return a clear error instead.
486
+ const workingDir = this.workingDir || defaultAgentWorkdir(this.agentName);
487
+ if (this.workingDir && !this._dirExists(this.workingDir)) {
488
+ await this.sendError(channel, `Working directory does not exist: ${this.workingDir}`);
489
+ return;
490
+ }
491
+
492
+ const clineBin = this._findClineBinary();
493
+ if (!clineBin) {
494
+ await this.sendError(channel,
495
+ 'Cline CLI not found. Install it with: npm install -g cline');
496
+ return;
497
+ }
498
+
499
+ // HARD minimum version gate. A CONFIRMED-older CLI must not start; an
500
+ // undetermined version (compatible === null) proceeds leniently.
501
+ const ver = this._checkClineVersion(clineBin);
502
+ if (ver.compatible === false) {
503
+ this._log(`Refusing to start: Cline ${ver.version} < minimum ${MIN_CLINE_VERSION}`);
504
+ await this.sendError(channel,
505
+ `Cline CLI ${ver.version} is below the minimum supported version ${MIN_CLINE_VERSION}. ` +
506
+ 'Please upgrade with: npm install -g cline@latest');
507
+ return;
508
+ }
509
+
510
+ // Auto-title + resume-from on first encounter (parity with other adapters).
511
+ if (!this._titledSessions.has(channel)) {
512
+ this._titledSessions.add(channel);
513
+ try {
514
+ const info = await this.client.getSession(this.workspaceId, channel, this.token);
515
+ const resumeFrom = info.resumeFrom;
516
+ if (resumeFrom && !this._channelSessions[channel] && this._channelSessions[resumeFrom]) {
517
+ this._channelSessions[channel] = { ...this._channelSessions[resumeFrom] };
518
+ this._saveSessions();
519
+ }
520
+ const title = generateSessionTitle(content);
521
+ if (title && !info.titleManuallySet && SESSION_DEFAULT_RE.test(info.title || '')) {
522
+ await this.client.updateSession(this.workspaceId, channel, this.token, { title, autoTitle: true });
523
+ }
524
+ } catch {}
525
+ }
526
+
527
+ await this.sendStatus(channel, 'thinking...');
528
+
529
+ // One retry: if resuming a stale session fails, retry once fresh.
530
+ for (let attempt = 0; attempt < 2; attempt++) {
531
+ const resumeId = attempt === 0 ? this._resumableSession(channel, workingDir) : null;
532
+
533
+ // Build the prompt. Resuming → Cline already has history, send the bare
534
+ // turn. Fresh → prepend a context header (+ recap when available).
535
+ let prompt;
536
+ // For a fresh run, snapshot the existing session ids BEFORE spawning so we
537
+ // can later identify our run's NEW session unambiguously (concurrency-safe).
538
+ let beforeIds = null;
539
+ if (resumeId) {
540
+ prompt = content;
541
+ } else {
542
+ const header = this._contextHeader(channel);
543
+ const recap = await this._buildChannelRecap(channel, content);
544
+ prompt = recap
545
+ ? `${header}\n\n${recap}\n\n---\n\n${content}`
546
+ : `${header}\n\n${content}`;
547
+ const before = await this._readHistory(clineBin);
548
+ beforeIds = new Set(Array.isArray(before) ? before.map((s) => s && s.sessionId).filter(Boolean) : []);
549
+ }
550
+
551
+ const args = buildClineArgs({
552
+ prompt,
553
+ cwd: workingDir,
554
+ sessionId: resumeId || undefined,
555
+ planMode: this._mode === 'plan',
556
+ provider: (this.agentEnv.CLINE_PROVIDER || '').trim() || undefined,
557
+ model: (this.agentEnv.CLINE_MODEL || '').trim() || undefined,
558
+ apiKey: (this.agentEnv.CLINE_API_KEY || '').trim() || undefined,
559
+ thinking: (this.agentEnv.CLINE_THINKING || '').trim() || undefined,
560
+ });
561
+
562
+ const spawnStartMs = Date.now();
563
+ const result = await this._runCline(channel, clineBin, args, workingDir);
564
+
565
+ if (result.userStopped) return;
566
+
567
+ // Stale-session handling: a resume that died/erred with nothing useful →
568
+ // clear and retry fresh once.
569
+ if (resumeId && !result.ok && !result.anyOutput && attempt === 0) {
570
+ this._log(`Resume of session ${resumeId} failed — clearing and retrying fresh`);
571
+ this._clearSession(channel);
572
+ continue;
573
+ }
574
+
575
+ // Persist / refresh the session id for next turn.
576
+ if (resumeId) {
577
+ // Resume keeps the same id; ensure the binding is still recorded.
578
+ this._channelSessions[channel] = { sessionId: resumeId, workingDir };
579
+ this._saveSessions();
580
+ } else if (result.ok || result.anyOutput) {
581
+ await this._captureSessionId(channel, clineBin, workingDir, spawnStartMs, content, beforeIds);
582
+ }
583
+
584
+ // Emit final response or a classified error.
585
+ if (result.finalText) {
586
+ try { await this.sendResponse(channel, result.finalText); } catch {}
587
+ } else if (result.errorMessage) {
588
+ const { kind, userMessage } = classifyClineError(result.errorMessage);
589
+ try { await this.sendError(channel, this._withAuthHint(userMessage, kind)); } catch {}
590
+ } else if (!result.anyOutput) {
591
+ try { await this.sendResponse(channel, 'No response generated. Please try again.'); } catch {}
592
+ }
593
+ return;
594
+ }
595
+ }
596
+
597
+ _dirExists(p) {
598
+ try { return fs.statSync(p).isDirectory(); } catch { return false; }
599
+ }
600
+
601
+ /**
602
+ * Append an actionable hint to an auth/provider/model error, based on the
603
+ * (heuristic) config state. We do NOT block on config; this only enriches the
604
+ * message AFTER a real failure. For an undetermined state we say so rather
605
+ * than asserting the user is unauthenticated.
606
+ * @param {string} userMessage the classified user-facing error
607
+ * @param {string} [kind] error kind from classifyClineError
608
+ */
609
+ _withAuthHint(userMessage, kind) {
610
+ // Only auth/provider/model failures warrant a config hint.
611
+ if (kind && !['auth', 'provider', 'model'].includes(kind)) return userMessage;
612
+ try {
613
+ const auth = this._authState();
614
+ if (auth.state === 'no_credentials') {
615
+ return userMessage + `\n\nProvider "${auth.provider}" is selected but has no stored credential. Set an API key in the launcher, or run \`cline auth\`.`;
616
+ }
617
+ if (auth.state === 'unknown') {
618
+ return userMessage + '\n\nConfig detection could not confirm Cline auth (the run result is authoritative). If this keeps failing, set an API key for this agent in the launcher, or run `cline auth`.';
619
+ }
620
+ } catch {}
621
+ return userMessage;
622
+ }
623
+
624
+ /**
625
+ * Spawn one `cline --json` run, stream-parse it, and resolve a summary:
626
+ * { ok, finalText, errorMessage, anyOutput, userStopped }
627
+ * `ok` reflects run_result.finishReason === "completed".
628
+ */
629
+ _runCline(channel, clineBin, args, workingDir) {
630
+ const cleanEnv = { ...(this.agentEnv || process.env) };
631
+ const [cmd, ...spawnArgs] = this._spawnableCmd(clineBin, args);
632
+
633
+ this._log(`Spawning cline in ${workingDir}: ${redactArgs([clineBin, ...args]).join(' ')}`);
634
+
635
+ const proc = spawn(cmd, spawnArgs, {
636
+ stdio: ['ignore', 'pipe', 'pipe'],
637
+ env: cleanEnv,
638
+ cwd: workingDir,
639
+ detached: !IS_WINDOWS,
640
+ windowsHide: true,
641
+ });
642
+ this._channelProcesses[channel] = proc;
643
+
644
+ const parser = new ClineStreamParser();
645
+ const state = {
646
+ finalText: '', // last assistant text block of the turn
647
+ pendingText: [], // text blocks accumulated since the last tool use
648
+ hadToolSinceText: false,
649
+ anyOutput: false,
650
+ ok: false,
651
+ // Error sources in priority order (resolved into errorMessage at settle):
652
+ resultError: '', // run_result/done with a non-completed reason — authoritative
653
+ eventError: '', // agent_event {type:error}
654
+ stderrError: '', // fatal {type:error} on stderr (benign noise filtered)
655
+ errorMessage: '', // resolved, user-facing-bound error text
656
+ finished: false,
657
+ userStopped: false,
658
+ };
659
+
660
+ let stderrParser = new ClineStreamParser();
661
+ let stderrText = '';
662
+
663
+ return new Promise((resolve) => {
664
+ let settled = false;
665
+ let watchdogTimer = null;
666
+ let fallbackTimer = null;
667
+ let lastDataMs = Date.now();
668
+ let silences = 0;
669
+ let queue = Promise.resolve();
670
+
671
+ const cleanup = () => {
672
+ if (watchdogTimer) { clearInterval(watchdogTimer); watchdogTimer = null; }
673
+ if (fallbackTimer) { clearTimeout(fallbackTimer); fallbackTimer = null; }
674
+ // Drop data/end listeners but KEEP an 'error' guard: a SIGKILL'd child's
675
+ // pipe can still emit 'error' after we settle, and an unhandled stream
676
+ // 'error' would crash the process.
677
+ if (proc.stdout) { proc.stdout.removeAllListeners(); proc.stdout.on('error', () => {}); }
678
+ if (proc.stderr) { proc.stderr.removeAllListeners(); proc.stderr.on('error', () => {}); }
679
+ };
680
+ const settle = () => {
681
+ if (settled) return;
682
+ settled = true;
683
+ cleanup();
684
+ // Resolve the user-facing error by priority: an authoritative
685
+ // run_result/done failure beats an agent error event, which beats a
686
+ // (non-benign) stderr error line.
687
+ state.errorMessage = state.resultError || state.eventError || state.stderrError || '';
688
+ // Only promote accumulated text to a final answer on a successful run —
689
+ // never present partial text as the answer when the run failed.
690
+ if (!state.finalText && !state.errorMessage && state.pendingText.length) {
691
+ state.finalText = state.pendingText.join('\n').trim();
692
+ }
693
+ if (this._stoppingChannels.has(channel)) state.userStopped = true;
694
+ delete this._channelProcesses[channel];
695
+ resolve(state);
696
+ };
697
+
698
+ const handleEvent = async (ev) => {
699
+ const events = interpretClineEnvelope(ev);
700
+ for (const e of events) {
701
+ switch (e.kind) {
702
+ case 'reasoning':
703
+ state.anyOutput = true;
704
+ try { await this.sendThinking(channel, e.text); } catch {}
705
+ break;
706
+ case 'text':
707
+ state.anyOutput = true;
708
+ if (state.hadToolSinceText) { state.pendingText = []; state.hadToolSinceText = false; }
709
+ state.pendingText.push(e.text);
710
+ try { await this.sendThinking(channel, e.text); } catch {}
711
+ break;
712
+ case 'tool_start':
713
+ state.anyOutput = true;
714
+ state.hadToolSinceText = true;
715
+ try {
716
+ await this.sendStatus(channel, e.preview ? `${e.label}: ${e.preview}` : e.label);
717
+ } catch {}
718
+ break;
719
+ case 'tool_end':
720
+ if (!e.ok && e.error) {
721
+ try { await this.sendStatus(channel, `${e.toolName} failed: ${e.error}`); } catch {}
722
+ }
723
+ break;
724
+ case 'ask':
725
+ // Headless runs cannot round-trip an interactive answer; surface
726
+ // the question (and options) so the user at least sees it.
727
+ state.anyOutput = true;
728
+ {
729
+ const opts = e.options && e.options.length ? `\n\nOptions: ${e.options.join(' · ')}` : '';
730
+ try { await this.sendResponse(channel, `❓ ${e.question || 'The agent is asking for input.'}${opts}`); } catch {}
731
+ }
732
+ break;
733
+ case 'notice':
734
+ if (e.text) { try { await this.sendStatus(channel, e.text); } catch {} }
735
+ break;
736
+ case 'error':
737
+ if (e.message && !state.eventError) state.eventError = e.message;
738
+ break;
739
+ case 'done':
740
+ // `done` carries the turn outcome. reason "completed" → text is the
741
+ // final answer; any other reason (error/aborted/max_iterations/…) →
742
+ // text is the failure message, never an assistant reply.
743
+ if (e.reason === 'completed') {
744
+ if (e.text && !state.finalText) state.finalText = e.text.trim();
745
+ } else if (e.text && !state.resultError) {
746
+ state.resultError = e.text.trim();
747
+ }
748
+ break;
749
+ case 'aborted':
750
+ if (e.text && !state.resultError) state.resultError = e.text.trim();
751
+ break;
752
+ case 'result':
753
+ state.ok = e.ok;
754
+ // On a completed run, run_result.text is the final answer. On a
755
+ // failed run it is the ERROR text — authoritative, never a reply.
756
+ if (e.ok) {
757
+ if (e.text) { state.finalText = e.text.trim(); state.anyOutput = true; }
758
+ } else if (e.text) {
759
+ state.resultError = e.text.trim();
760
+ }
761
+ state.finished = true;
762
+ break;
763
+ default:
764
+ break;
765
+ }
766
+ }
767
+ };
768
+
769
+ // Swallow stdio stream errors. When we SIGKILL the process (stop /
770
+ // watchdog), the child's stdout/stderr pipe can emit an 'error'
771
+ // (EPIPE/EBADF/ECONNRESET) — notably on macOS — and an unhandled stream
772
+ // 'error' event would throw and crash the daemon/process. We finalize via
773
+ // exit/end anyway, so these are safe to ignore.
774
+ if (proc.stdout) proc.stdout.on('error', () => {});
775
+ if (proc.stderr) proc.stderr.on('error', () => {});
776
+
777
+ // stdout: the event stream
778
+ proc.stdout.on('data', (chunk) => {
779
+ lastDataMs = Date.now();
780
+ silences = 0;
781
+ const envs = parser.push(chunk);
782
+ for (const ev of envs) queue = queue.then(() => handleEvent(ev)).catch(() => {});
783
+ });
784
+
785
+ // stderr: kept SEPARATE. Fatal cline errors arrive here as
786
+ // {type:"error",message}; everything else is diagnostic noise that must
787
+ // never be shown as an assistant reply. We only adopt a parsed error
788
+ // message (lowest priority, used only if no stream error is known), and
789
+ // filter known-benign diagnostics so they can't mask the real cause.
790
+ const adoptStderr = (ev) => {
791
+ if (!ev || ev.type !== 'error' || !ev.message) return;
792
+ if (BENIGN_STDERR_RE.test(String(ev.message).trim())) return; // exact known noise only
793
+ state.stderrError = redactSecrets(String(ev.message)); // last non-benign wins
794
+ };
795
+ proc.stderr.on('data', (chunk) => {
796
+ for (const ev of stderrParser.push(chunk)) adoptStderr(ev);
797
+ stderrText += chunk.toString('utf-8');
798
+ });
799
+
800
+ // Idle watchdog
801
+ watchdogTimer = setInterval(async () => {
802
+ if (settled) return;
803
+ const elapsed = Date.now() - lastDataMs;
804
+ if (elapsed < WATCHDOG_INTERVAL_MS) { silences = 0; return; }
805
+ silences++;
806
+ lastDataMs = Date.now();
807
+ if (silences === WATCHDOG_NUDGE_AT) {
808
+ try { await this.sendStatus(channel, 'Still working...'); } catch {}
809
+ }
810
+ if (silences >= WATCHDOG_MAX) {
811
+ this._log(`Watchdog: cline silent ${silences * 15}s on ${channel} — killing`);
812
+ state.resultError = state.resultError || 'Cline became unresponsive and was stopped.';
813
+ await this._stopProcess(proc);
814
+ }
815
+ }, WATCHDOG_INTERVAL_MS);
816
+
817
+ // Finalize only once BOTH the process has exited AND stdout has ended.
818
+ // Listening on 'exit' alone is racy: on some platforms (notably macOS) the
819
+ // final stdout chunk carrying `run_result` is delivered AFTER 'exit'
820
+ // fires, which would drop the answer. Waiting for stdout 'end' guarantees
821
+ // every 'data' event was emitted first. A short fallback after 'exit'
822
+ // covers the rare case where a lingering child keeps the pipe open so
823
+ // 'end' never arrives (the data is already buffered/delivered by then).
824
+ let exited = false;
825
+ let stdoutEnded = false;
826
+ let exitCode = null;
827
+ let exitSignal = null;
828
+
829
+ const finalize = () => {
830
+ if (settled) return;
831
+ if (fallbackTimer) { clearTimeout(fallbackTimer); fallbackTimer = null; }
832
+ for (const ev of parser.flush()) queue = queue.then(() => handleEvent(ev)).catch(() => {});
833
+ for (const ev of stderrParser.flush()) adoptStderr(ev);
834
+ queue = queue.then(() => {
835
+ if (exitCode !== 0 && !state.ok && !state.resultError && !state.eventError && !state.stderrError) {
836
+ const why = exitSignal ? `terminated by signal ${exitSignal}` : `exited with code ${exitCode}`;
837
+ if (stderrText.trim()) this._log(`cline stderr: ${redactSecrets(stderrText.trim().slice(0, 500))}`);
838
+ if (!this._stoppingChannels.has(channel)) {
839
+ state.resultError = `Cline ${why}.`;
840
+ }
841
+ }
842
+ settle();
843
+ }).catch(() => settle());
844
+ };
845
+ const maybeFinalize = () => { if (exited && stdoutEnded) finalize(); };
846
+
847
+ if (proc.stdout) proc.stdout.on('end', () => { stdoutEnded = true; maybeFinalize(); });
848
+ else stdoutEnded = true;
849
+
850
+ proc.on('exit', (code, signal) => {
851
+ exited = true;
852
+ exitCode = code;
853
+ exitSignal = signal;
854
+ // If stdout 'end' hasn't fired shortly after exit (a lingering child
855
+ // holding the pipe), force finalization — buffered data is already in.
856
+ fallbackTimer = setTimeout(() => { stdoutEnded = true; finalize(); }, 1500);
857
+ maybeFinalize();
858
+ });
859
+
860
+ proc.on('error', (err) => {
861
+ state.resultError = state.resultError || `Failed to start Cline: ${redactSecrets(err.message)}`;
862
+ settle();
863
+ });
864
+ });
865
+ }
866
+
867
+ /**
868
+ * After a fresh run, correlate the new session via `cline history --json`
869
+ * (Cline does not emit the session id inline). Uses a BEFORE/AFTER snapshot
870
+ * diff: only sessions that are NEW since `beforeIds`, in this working dir, and
871
+ * within the run window are considered, and we bind ONLY on a single
872
+ * unambiguous candidate — never guessing. Best-effort and non-fatal; failure
873
+ * just means the next turn starts a fresh session. The prompt is never logged.
874
+ */
875
+ async _captureSessionId(channel, clineBin, workingDir, spawnStartMs, userContent, beforeIds) {
876
+ try {
877
+ const after = await this._readHistory(clineBin);
878
+ if (!Array.isArray(after)) {
879
+ this._log(`Session correlation skipped for ${channel}: history unavailable`);
880
+ return;
881
+ }
882
+ const needle = (userContent || '').replace(/\s+/g, ' ').trim().slice(0, 40) || undefined;
883
+ const opts = { cwd: workingDir, sinceMs: spawnStartMs, beforeIds: beforeIds || new Set(), promptNeedle: needle };
884
+ const sessionId = pickClineSessionId(after, opts);
885
+ if (sessionId) {
886
+ this._channelSessions[channel] = { sessionId, workingDir };
887
+ this._saveSessions();
888
+ this._log(`Captured Cline session ${sessionId} for ${channel}`);
889
+ } else {
890
+ // Count the ambiguous/zero candidates for diagnostics — no prompt, no values.
891
+ const before = opts.beforeIds instanceof Set ? opts.beforeIds : new Set(beforeIds || []);
892
+ const fresh = after.filter((s) => s && s.cwd === workingDir && s.sessionId && !before.has(s.sessionId)).length;
893
+ this._log(`Session correlation declined for ${channel}: ${fresh} new candidate(s) in workdir — next turn starts fresh`);
894
+ }
895
+ } catch (e) {
896
+ this._log(`Could not capture Cline session id (non-fatal): ${e && e.message ? e.message : e}`);
897
+ }
898
+ }
899
+
900
+ _readHistory(clineBin) {
901
+ return new Promise((resolve) => {
902
+ const [cmd, ...args] = this._spawnableCmd(clineBin, ['history', '--json', '--limit', '30']);
903
+ execFile(cmd, args, {
904
+ encoding: 'utf-8', timeout: 10000, windowsHide: true,
905
+ env: { ...(this.agentEnv || process.env) }, maxBuffer: 8 * 1024 * 1024,
906
+ }, (err, stdout) => {
907
+ if (err) { resolve(null); return; }
908
+ try { resolve(JSON.parse(stdout)); } catch { resolve(null); }
909
+ });
910
+ });
911
+ }
912
+ }
913
+
914
+ module.exports = ClineAdapter;