@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,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;
|