@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,632 @@
1
+ /**
2
+ * Pure, side-effect-free helpers for the Cline CLI adapter.
3
+ *
4
+ * Everything here is I/O-free and deterministic so it can be unit-tested
5
+ * without spawning the `cline` binary or touching the network: the NDJSON
6
+ * stream parser, the event interpreter, secret redaction, error/auth/version
7
+ * classification, the (shell-safe) argument builder, and the session-id
8
+ * correlation used for `--id` resume.
9
+ *
10
+ * Verified against Cline CLI v3.0.26 (`cline --json`). The `--json` stream is
11
+ * NDJSON on stdout where each line is an envelope:
12
+ * {ts, type:"hook_event", hookEventName, agentId, taskId, parentAgentId}
13
+ * {ts, type:"run_start", providerId, modelId, catalog, thinking, mode} (verbose)
14
+ * {ts, type:"agent_event", event:{...}} <- the model's activity
15
+ * {ts, type:"team_event", event:{...}} <- only when teams/spawn enabled
16
+ * {ts, type:"run_result", finishReason, iterations, usage, durationMs, text, model}
17
+ * and stderr carries {ts, type:"error", message} on fatal errors.
18
+ *
19
+ * The inner agent_event `event.type` is one of nine variants; text, reasoning
20
+ * and tool activity are multiplexed through content_start/content_update/
21
+ * content_end keyed by `contentType` ("text" | "reasoning" | "tool").
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ // Minimum Cline CLI version whose headless `--json` / `-c` / `--id` interface
27
+ // matches what this adapter drives. Cline's CLI changed its surface across
28
+ // majors (v1 preview → v2 → v3); the v3 line is what we target.
29
+ //
30
+ // Policy is a HARD minimum: a CONFIRMED-older version (parseable, < 3.0.0) is
31
+ // incompatible and the agent must not start. Only an UNDETERMINED version
32
+ // (output unparseable, or `cline --version` failed) is treated leniently as
33
+ // "unknown" — we proceed rather than lock users out on a future `--version`
34
+ // format change — but it is never reported as compatible.
35
+ const MIN_CLINE_VERSION = '3.0.0';
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Version handling
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Pull a dotted version (e.g. "3.0.26") out of `cline --version` output.
43
+ * Returns null when nothing version-like is present.
44
+ */
45
+ function parseClineVersion(raw) {
46
+ if (!raw || typeof raw !== 'string') return null;
47
+ const m = raw.match(/(\d+)\.(\d+)\.(\d+)(?:[-.][0-9A-Za-z.]+)?/);
48
+ return m ? m[0] : null;
49
+ }
50
+
51
+ /** Compare two dotted versions. Returns -1, 0, or 1. */
52
+ function compareVersions(a, b) {
53
+ const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
54
+ const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
55
+ const len = Math.max(pa.length, pb.length);
56
+ for (let i = 0; i < len; i++) {
57
+ const x = pa[i] || 0;
58
+ const y = pb[i] || 0;
59
+ if (x < y) return -1;
60
+ if (x > y) return 1;
61
+ }
62
+ return 0;
63
+ }
64
+
65
+ /**
66
+ * Classify an installed Cline version against MIN_CLINE_VERSION.
67
+ * @returns {{version: string|null, supported: boolean|null}}
68
+ * supported === true → meets the minimum (>= 3.0.0) → may start
69
+ * supported === false → CONFIRMED too old → incompatible, must NOT start
70
+ * supported === null → version undetermined (unparseable) → unknown, proceed
71
+ * leniently but never claim compatible
72
+ */
73
+ function classifyClineVersion(rawVersion) {
74
+ const version = parseClineVersion(rawVersion);
75
+ if (!version) return { version: null, supported: null };
76
+ return { version, supported: compareVersions(version, MIN_CLINE_VERSION) >= 0 };
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Secret redaction
81
+ // ---------------------------------------------------------------------------
82
+
83
+ // Redaction is applied to anything that could reach a log line: stderr text,
84
+ // error messages, and the spawn argv we echo for debugging. We never log the
85
+ // user's prompt or raw env, but defense-in-depth keeps key material out of the
86
+ // daemon log even if an upstream message echoes it back.
87
+ const SECRET_PATTERNS = [
88
+ // Provider API keys: sk-..., sk-ant-..., sk-or-..., etc.
89
+ /\bsk-[A-Za-z0-9._-]{8,}\b/g,
90
+ // OpenRouter / generic long opaque tokens prefixed with a vendor tag
91
+ /\b(?:or|rk|gsk|ghp|gho|ghu|ghs|github_pat)[-_][A-Za-z0-9._-]{12,}\b/g,
92
+ // Bearer / Authorization header values
93
+ /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi,
94
+ // AWS access key ids
95
+ /\bAKIA[0-9A-Z]{16}\b/g,
96
+ ];
97
+
98
+ // Sensitive keys we scrub when serializing an arbitrary object (e.g. a tool
99
+ // input) for a preview/log line.
100
+ const SENSITIVE_KEY_RE = /(api[_-]?key|secret|token|password|passwd|authorization|cookie|credential)/i;
101
+
102
+ /** Redact obvious secret material from a free-text string. */
103
+ function redactSecrets(text) {
104
+ if (text == null) return text;
105
+ let out = String(text);
106
+ for (const re of SECRET_PATTERNS) out = out.replace(re, '«redacted»');
107
+ return out;
108
+ }
109
+
110
+ /**
111
+ * Redact an arbitrary value for previews: any object key matching
112
+ * SENSITIVE_KEY_RE has its value masked, and remaining strings are run through
113
+ * redactSecrets. Returns a JSON-safe clone (never mutates the input).
114
+ */
115
+ function redactValue(value, depth = 0) {
116
+ if (depth > 6) return '…';
117
+ if (value == null) return value;
118
+ if (typeof value === 'string') return redactSecrets(value);
119
+ if (typeof value === 'number' || typeof value === 'boolean') return value;
120
+ if (Array.isArray(value)) return value.map((v) => redactValue(v, depth + 1));
121
+ if (typeof value === 'object') {
122
+ const out = {};
123
+ for (const [k, v] of Object.entries(value)) {
124
+ out[k] = SENSITIVE_KEY_RE.test(k) ? '«redacted»' : redactValue(v, depth + 1);
125
+ }
126
+ return out;
127
+ }
128
+ return value;
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Error classification
133
+ // ---------------------------------------------------------------------------
134
+
135
+ /**
136
+ * Map a raw Cline error string to a coarse category plus a user-facing message.
137
+ * Frontends should show `userMessage`; the raw (redacted) text goes to logs.
138
+ *
139
+ * @returns {{kind: string, userMessage: string}}
140
+ * kind ∈ auth | provider | model | rate_limit | network | config | timeout | unknown
141
+ */
142
+ function classifyClineError(rawMessage) {
143
+ const msg = redactSecrets(String(rawMessage || '').trim());
144
+ const low = msg.toLowerCase();
145
+
146
+ if (/unauthorized|authenticat|invalid.*(api[ _-]?key|token|credential)|\b401\b|\b403\b|re-authenticate|not signed in|no api key|api key (is )?(missing|required|not)/i.test(low)) {
147
+ return {
148
+ kind: 'auth',
149
+ userMessage:
150
+ 'Cline is not authenticated. Configure an API key for this agent in the launcher, ' +
151
+ 'or run `cline auth` to sign in. (' + truncate(msg, 160) + ')',
152
+ };
153
+ }
154
+ if (/rate.?limit|too many requests|\b429\b|quota|overloaded/i.test(low)) {
155
+ return {
156
+ kind: 'rate_limit',
157
+ userMessage: 'The provider is rate-limiting or over quota. Please retry shortly. (' + truncate(msg, 160) + ')',
158
+ };
159
+ }
160
+ if (/\bmodel\b.*(not found|unavailable|invalid|does not exist|unknown)|no such model|unsupported model/i.test(low)) {
161
+ return {
162
+ kind: 'model',
163
+ userMessage: 'The configured model is unavailable. Pick a valid model for this provider. (' + truncate(msg, 160) + ')',
164
+ };
165
+ }
166
+ if (/provider.*(not|un)(available|configured|supported|known)|unknown provider|no provider/i.test(low)) {
167
+ return {
168
+ kind: 'provider',
169
+ userMessage: 'The selected provider is unavailable or unconfigured. Check the provider/model settings. (' + truncate(msg, 160) + ')',
170
+ };
171
+ }
172
+ if (/timed?\s?out|timeout|deadline exceeded/i.test(low)) {
173
+ return { kind: 'timeout', userMessage: 'The task timed out. (' + truncate(msg, 160) + ')' };
174
+ }
175
+ if (/econn|enotfound|etimedout|network|fetch failed|socket|dns|tls|certificate|getaddrinfo/i.test(low)) {
176
+ return { kind: 'network', userMessage: 'A network error occurred reaching the provider. (' + truncate(msg, 160) + ')' };
177
+ }
178
+ if (/config|settings|providers\.json|parse|malformed|invalid json/i.test(low)) {
179
+ return { kind: 'config', userMessage: 'Cline configuration looks invalid. (' + truncate(msg, 160) + ')' };
180
+ }
181
+ return { kind: 'unknown', userMessage: msg ? 'Cline error: ' + truncate(msg, 200) : 'Cline returned an error.' };
182
+ }
183
+
184
+ function truncate(s, n) {
185
+ s = String(s || '');
186
+ return s.length > n ? s.slice(0, n - 1) + '…' : s;
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Auth / config classification (from a parsed providers.json object)
191
+ // ---------------------------------------------------------------------------
192
+
193
+ // Field names that, when present and non-empty inside a provider's `settings`,
194
+ // indicate a stored credential. We only ever test for PRESENCE — values are
195
+ // never read into the return value or logged.
196
+ const CREDENTIAL_FIELDS = ['apiKey', 'apikey', 'token', 'accessToken', 'access_token', 'refreshToken', 'sessionToken'];
197
+
198
+ // Env vars Cline reads natively (a key here means the run can authenticate even
199
+ // when providers.json has no stored credential).
200
+ const CLINE_AUTH_ENV_VARS = [
201
+ 'CLINE_API_KEY',
202
+ 'ANTHROPIC_API_KEY',
203
+ 'OPENAI_API_KEY',
204
+ 'OPENROUTER_API_KEY',
205
+ 'AI_GATEWAY_API_KEY',
206
+ 'V0_API_KEY',
207
+ ];
208
+
209
+ // Providers that authenticate WITHOUT an apiKey in providers.json (account /
210
+ // OAuth — the credential lives elsewhere). For these, a missing apiKey is NOT
211
+ // evidence of "no credentials" — we must report unknown, not no_credentials.
212
+ const ACCOUNT_PROVIDERS = new Set(['cline']);
213
+
214
+ function hasCredential(settings) {
215
+ if (!settings || typeof settings !== 'object') return false;
216
+ return CREDENTIAL_FIELDS.some(
217
+ (f) => typeof settings[f] === 'string' && settings[f].trim().length > 0,
218
+ );
219
+ }
220
+
221
+ /**
222
+ * Classify Cline auth/config from a parsed providers.json object and the agent
223
+ * env. Pure: never reads files, never exposes secret values.
224
+ *
225
+ * providers.json is a Cline-version-specific internal file, so this is a
226
+ * HEURISTIC, never an absolute auth verdict — the real authority is the run
227
+ * result. The classification is deliberately conservative:
228
+ * - `ready` only on a POSITIVE signal: a credential field is present,
229
+ * or a native env key is set.
230
+ * - `no_credentials` only when we can CONFIRM a key-based provider is selected
231
+ * with no credential anywhere (and it isn't an account/OAuth
232
+ * provider). Used for a softer hint, never to block startup.
233
+ * - `unknown` everything we cannot confirm: file missing, unparseable,
234
+ * unrecognized shape, an account/OAuth provider without a
235
+ * stored key, or no identifiable provider. Never reported as
236
+ * ready, and never blocks startup.
237
+ *
238
+ * @param {object|null} providersJson parsed providers.json, or null (missing),
239
+ * or a non-plain value (unparseable/changed)
240
+ * @param {object} env the agent env (checked for native key vars)
241
+ * @returns {{state: string, provider: string|null, model: string|null, detail: string}}
242
+ * state ∈ ready | no_credentials | unknown
243
+ */
244
+ function classifyClineAuth(providersJson, env = {}) {
245
+ const envKey = CLINE_AUTH_ENV_VARS.find((k) => (env[k] || '').trim());
246
+ if (envKey) {
247
+ return { state: 'ready', provider: null, model: null, detail: `API key via ${envKey}` };
248
+ }
249
+
250
+ // No config file → cannot confirm anything (could be env/OAuth/custom data-dir).
251
+ if (providersJson === undefined || providersJson === null) {
252
+ return { state: 'unknown', provider: null, model: null, detail: 'No providers.json; auth undetermined' };
253
+ }
254
+ // Unparseable / not a recognizable object → undetermined, NOT a hard "no creds".
255
+ if (typeof providersJson !== 'object' || Array.isArray(providersJson) || providersJson.__parse_error) {
256
+ return { state: 'unknown', provider: null, model: null, detail: 'providers.json unparseable or unrecognized' };
257
+ }
258
+
259
+ const providers = providersJson.providers && typeof providersJson.providers === 'object' && !Array.isArray(providersJson.providers)
260
+ ? providersJson.providers
261
+ : null;
262
+ if (!providers) {
263
+ return { state: 'unknown', provider: null, model: null, detail: 'providers.json shape unrecognized' };
264
+ }
265
+ const active = providersJson.lastUsedProvider || null;
266
+
267
+ // Positive signal: any provider carries a stored credential.
268
+ for (const id of Object.keys(providers)) {
269
+ if (hasCredential((providers[id] || {}).settings)) {
270
+ return { state: 'ready', provider: id, model: (providers[id].settings || {}).model || null, detail: 'Provider credential present' };
271
+ }
272
+ }
273
+
274
+ // No credential anywhere. Decide between no_credentials (confident) vs unknown.
275
+ const activeEntry = active && providers[active] ? providers[active] : null;
276
+ if (activeEntry && activeEntry.settings && !ACCOUNT_PROVIDERS.has(active)) {
277
+ // A key-based provider is selected with no stored credential → confident.
278
+ return { state: 'no_credentials', provider: active, model: activeEntry.settings.model || null, detail: 'Key-based provider selected but no credential stored' };
279
+ }
280
+ // Account/OAuth provider without a stored key, empty providers, or no active
281
+ // provider → cannot confirm; the credential may live outside providers.json.
282
+ return { state: 'unknown', provider: active, model: (activeEntry && activeEntry.settings && activeEntry.settings.model) || null, detail: 'No stored credential found; auth undetermined' };
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Argument building (shell-safe — consumed as a spawn args array, never a string)
287
+ // ---------------------------------------------------------------------------
288
+
289
+ /**
290
+ * Build the argv (after the binary) for a one-shot headless Cline run.
291
+ * The prompt is always the LAST positional so any leading dashes in user text
292
+ * cannot be parsed as flags. Returns a flat string array suitable for
293
+ * child_process.spawn(bin, args) — there is NO shell, so quotes/newlines/$()
294
+ * in the prompt are passed verbatim and safely.
295
+ *
296
+ * @param {object} o
297
+ * @param {string} o.prompt required — the task text (positional)
298
+ * @param {string} [o.cwd] working directory (-c)
299
+ * @param {string} [o.sessionId] resume an existing session (--id)
300
+ * @param {boolean} [o.planMode] plan mode (-p) vs default act+auto-approve
301
+ * @param {string} [o.provider] provider id (-P)
302
+ * @param {string} [o.model] model id (-m)
303
+ * @param {string} [o.apiKey] per-run API key (-k) — caller must keep out of logs
304
+ * @param {string} [o.thinking] reasoning level none|low|medium|high|xhigh (--thinking)
305
+ * @param {number} [o.timeoutSec] run timeout in seconds (-t); 0/undefined = no timeout
306
+ */
307
+ function buildClineArgs(o) {
308
+ if (!o || typeof o.prompt !== 'string') {
309
+ throw new Error('buildClineArgs: prompt (string) is required');
310
+ }
311
+ const args = ['--json'];
312
+ if (o.cwd) args.push('-c', o.cwd);
313
+ if (o.sessionId) args.push('--id', o.sessionId);
314
+ // Default (no -p) is "act mode with auto-approve enabled" per `cline --help`.
315
+ // We make auto-approval explicit so a future change to Cline's default can't
316
+ // silently start blocking on tool prompts in our non-interactive context.
317
+ if (o.planMode) {
318
+ args.push('-p');
319
+ } else {
320
+ args.push('--auto-approve', 'true');
321
+ }
322
+ if (o.provider) args.push('-P', o.provider);
323
+ if (o.model) args.push('-m', o.model);
324
+ if (o.apiKey) args.push('-k', o.apiKey);
325
+ if (o.thinking) args.push('--thinking', o.thinking);
326
+ if (o.timeoutSec && Number(o.timeoutSec) > 0) args.push('-t', String(Math.floor(Number(o.timeoutSec))));
327
+ // Prompt LAST and positional so it is never mistaken for an option.
328
+ args.push(o.prompt);
329
+ return args;
330
+ }
331
+
332
+ /** Return a copy of an args array with the value after `-k` redacted, for logging. */
333
+ function redactArgs(args) {
334
+ const out = [];
335
+ for (let i = 0; i < args.length; i++) {
336
+ out.push(args[i]);
337
+ if (args[i] === '-k' || args[i] === '--key') {
338
+ out.push('«redacted»');
339
+ i++; // skip the real key
340
+ } else if (i === args.length - 1) {
341
+ // last positional is the prompt — don't echo user content to logs
342
+ out[out.length - 1] = '«prompt»';
343
+ } else {
344
+ out[out.length - 1] = redactSecrets(args[i]);
345
+ }
346
+ }
347
+ return out;
348
+ }
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // NDJSON stream parser
352
+ // ---------------------------------------------------------------------------
353
+
354
+ /**
355
+ * Incremental NDJSON parser. Handles:
356
+ * - chunked input where one JSON object is split across many chunks
357
+ * - a single chunk that contains several complete lines
358
+ * - mixed "\n" and "\r\n" line endings and a trailing incomplete line
359
+ * - stray non-JSON lines (skipped, surfaced via `garbage` for optional logging)
360
+ *
361
+ * Usage:
362
+ * const p = new ClineStreamParser();
363
+ * for (const chunk of chunks) for (const env of p.push(chunk)) handle(env);
364
+ * for (const env of p.flush()) handle(env); // trailing buffer at EOF
365
+ */
366
+ class ClineStreamParser {
367
+ constructor() {
368
+ this._buf = '';
369
+ this.garbage = []; // lines that failed JSON.parse (for debugging only)
370
+ }
371
+
372
+ push(chunk) {
373
+ if (chunk == null) return [];
374
+ this._buf += chunk.toString('utf-8');
375
+ const out = [];
376
+ let nl;
377
+ while ((nl = this._buf.indexOf('\n')) !== -1) {
378
+ const line = this._buf.slice(0, nl);
379
+ this._buf = this._buf.slice(nl + 1);
380
+ const env = this._parseLine(line);
381
+ if (env !== undefined) out.push(env);
382
+ }
383
+ return out;
384
+ }
385
+
386
+ flush() {
387
+ const out = [];
388
+ if (this._buf.trim()) {
389
+ const env = this._parseLine(this._buf);
390
+ if (env !== undefined) out.push(env);
391
+ }
392
+ this._buf = '';
393
+ return out;
394
+ }
395
+
396
+ _parseLine(line) {
397
+ const trimmed = line.replace(/\r$/, '').trim();
398
+ if (!trimmed) return undefined;
399
+ try {
400
+ return JSON.parse(trimmed);
401
+ } catch {
402
+ if (this.garbage.length < 50) this.garbage.push(trimmed.slice(0, 500));
403
+ return undefined;
404
+ }
405
+ }
406
+ }
407
+
408
+ // ---------------------------------------------------------------------------
409
+ // Envelope → normalized event interpretation
410
+ // ---------------------------------------------------------------------------
411
+
412
+ // Tools that represent a question for the human rather than an autonomous action.
413
+ const ASK_TOOLS = new Set(['ask_question', 'ask_followup_question']);
414
+
415
+ /** Human-friendly verb for a Cline tool name (best-effort; unknown → the name). */
416
+ function friendlyToolLabel(toolName) {
417
+ switch (toolName) {
418
+ case 'editor':
419
+ case 'apply_patch':
420
+ return 'Editing file';
421
+ case 'read_files':
422
+ return 'Reading files';
423
+ case 'run_commands':
424
+ return 'Running command';
425
+ case 'search_codebase':
426
+ return 'Searching codebase';
427
+ case 'fetch_web_content':
428
+ return 'Fetching web content';
429
+ case 'skills':
430
+ return 'Using skill';
431
+ case 'spawn_agent':
432
+ return 'Spawning subagent';
433
+ default:
434
+ return toolName || 'Tool';
435
+ }
436
+ }
437
+
438
+ /** Best-effort one-line preview of a tool's input, with secrets scrubbed. */
439
+ function toolInputPreview(input) {
440
+ if (input == null) return '';
441
+ if (typeof input === 'string') return truncate(redactSecrets(input), 160);
442
+ if (typeof input !== 'object') return truncate(String(input), 160);
443
+ const i = input;
444
+ let v =
445
+ (Array.isArray(i.commands) && i.commands.join('; ')) ||
446
+ i.command ||
447
+ (Array.isArray(i.file_paths) && i.file_paths.join(', ')) ||
448
+ i.file_path || i.path ||
449
+ (Array.isArray(i.queries) && i.queries.join(', ')) ||
450
+ i.query || i.pattern ||
451
+ (Array.isArray(i.requests) && i.requests.map((r) => r && r.url).filter(Boolean).join(', ')) ||
452
+ i.url || i.skill || i.question || '';
453
+ if (!v) {
454
+ try { v = JSON.stringify(redactValue(i)); } catch { v = ''; }
455
+ }
456
+ return truncate(redactSecrets(String(v)), 160);
457
+ }
458
+
459
+ /**
460
+ * Interpret one stream envelope into zero or more normalized events the adapter
461
+ * can act on. Stateless: relies on Cline emitting whole text/output in the
462
+ * `content_end` events (so we never double-count streamed deltas).
463
+ *
464
+ * Normalized event kinds:
465
+ * {kind:'text', text} final assistant text block for a turn
466
+ * {kind:'reasoning', text} a thinking/reasoning block
467
+ * {kind:'tool_start', toolName, toolCallId, label, preview, input}
468
+ * {kind:'tool_end', toolName, toolCallId, ok, error, durationMs}
469
+ * {kind:'ask', question, options, toolCallId}
470
+ * {kind:'notice', noticeType, reason, text}
471
+ * {kind:'error', message, recoverable} (from agent_event error)
472
+ * {kind:'done', reason, text} inner "done" event
473
+ * {kind:'result', finishReason, text, model, ok} terminal run_result
474
+ * {kind:'aborted', reason, text} run_aborted
475
+ * {kind:'session', taskId, agentId} hook_event agent_start (informational)
476
+ */
477
+ function interpretClineEnvelope(env) {
478
+ if (!env || typeof env !== 'object') return [];
479
+ const type = env.type;
480
+
481
+ if (type === 'run_result') {
482
+ const reason = env.finishReason || 'unknown';
483
+ return [{
484
+ kind: 'result',
485
+ finishReason: reason,
486
+ ok: reason === 'completed',
487
+ text: typeof env.text === 'string' ? env.text : '',
488
+ model: env.model && env.model.id ? env.model.id : null,
489
+ }];
490
+ }
491
+ if (type === 'run_aborted') {
492
+ return [{ kind: 'aborted', reason: env.reason || 'aborted', text: env.message || '' }];
493
+ }
494
+ if (type === 'hook_event') {
495
+ if (env.hookEventName === 'agent_start') {
496
+ return [{ kind: 'session', taskId: env.taskId || null, agentId: env.agentId || null }];
497
+ }
498
+ return [];
499
+ }
500
+ if (type !== 'agent_event') return []; // run_start/team_event/unknown → no UI action
501
+
502
+ const ev = env.event;
503
+ if (!ev || typeof ev !== 'object') return [];
504
+
505
+ switch (ev.type) {
506
+ case 'content_end': {
507
+ // The whole, final content for a block. Deltas (content_start/_update)
508
+ // are intentionally ignored to avoid duplicate output.
509
+ if (ev.contentType === 'text') {
510
+ const t = (ev.text || '').trim();
511
+ return t ? [{ kind: 'text', text: t }] : [];
512
+ }
513
+ if (ev.contentType === 'reasoning') {
514
+ const t = (ev.reasoning || '').trim();
515
+ return t ? [{ kind: 'reasoning', text: t }] : [];
516
+ }
517
+ if (ev.contentType === 'tool') {
518
+ return [{
519
+ kind: 'tool_end',
520
+ toolName: ev.toolName || '',
521
+ toolCallId: ev.toolCallId || null,
522
+ ok: !ev.error,
523
+ error: ev.error ? redactSecrets(String(ev.error)) : null,
524
+ durationMs: typeof ev.durationMs === 'number' ? ev.durationMs : null,
525
+ }];
526
+ }
527
+ return [];
528
+ }
529
+ case 'content_start': {
530
+ // We only surface tool *starts* (text/reasoning starts are deltas we skip).
531
+ if (ev.contentType !== 'tool') return [];
532
+ const toolName = ev.toolName || '';
533
+ if (ASK_TOOLS.has(toolName)) {
534
+ const input = ev.input || {};
535
+ return [{
536
+ kind: 'ask',
537
+ toolCallId: ev.toolCallId || null,
538
+ question: typeof input.question === 'string' ? input.question : '',
539
+ options: Array.isArray(input.options) ? input.options.slice(0, 12) : [],
540
+ }];
541
+ }
542
+ return [{
543
+ kind: 'tool_start',
544
+ toolName,
545
+ toolCallId: ev.toolCallId || null,
546
+ label: friendlyToolLabel(toolName),
547
+ preview: toolInputPreview(ev.input),
548
+ input: ev.input,
549
+ }];
550
+ }
551
+ case 'error': {
552
+ const e = ev.error;
553
+ const message = e && typeof e === 'object' ? (e.message || '') : String(e || '');
554
+ return [{ kind: 'error', message: redactSecrets(message), recoverable: ev.recoverable === true }];
555
+ }
556
+ case 'notice': {
557
+ return [{
558
+ kind: 'notice',
559
+ noticeType: ev.noticeType || null,
560
+ reason: ev.reason || null,
561
+ text: redactSecrets(ev.message || ''),
562
+ }];
563
+ }
564
+ case 'done': {
565
+ return [{ kind: 'done', reason: ev.reason || 'completed', text: typeof ev.text === 'string' ? ev.text : '' }];
566
+ }
567
+ // content_update / iteration_start / iteration_end / usage → no UI action
568
+ default:
569
+ return [];
570
+ }
571
+ }
572
+
573
+ // ---------------------------------------------------------------------------
574
+ // Session-id correlation (for --id resume)
575
+ // ---------------------------------------------------------------------------
576
+
577
+ /**
578
+ * Cline does NOT emit a resumable session id in its `--json` stream — it only
579
+ * records sessions in `cline history --json`. We correlate the new session with
580
+ * a strict BEFORE/AFTER snapshot diff: the caller records the set of session ids
581
+ * that existed before spawning; this picks the run's session ONLY from records
582
+ * that are (a) new since then, (b) in the same working directory, and (c) within
583
+ * the run's time window. It binds ONLY when exactly one candidate remains —
584
+ * never guessing — so two concurrent runs in the same directory both decline
585
+ * rather than risk binding the wrong session.
586
+ *
587
+ * @param {Array} historyAfter parsed `cline history --json` taken AFTER the run
588
+ * @param {object} o
589
+ * @param {string} o.cwd the run's working directory (required to match)
590
+ * @param {Set|Array} [o.beforeIds] session ids that existed BEFORE the run (excluded)
591
+ * @param {number} [o.sinceMs] spawn timestamp (ms); only sessions started at/after count
592
+ * @param {string} [o.promptNeedle] a slice of the prompt; further narrows candidates
593
+ * @returns {string|null} the session id when exactly one candidate matches, else null
594
+ */
595
+ function pickClineSessionId(historyAfter, o) {
596
+ if (!Array.isArray(historyAfter) || !o || !o.cwd) return null;
597
+ const before = o.beforeIds instanceof Set
598
+ ? o.beforeIds
599
+ : new Set(Array.isArray(o.beforeIds) ? o.beforeIds : []);
600
+ const since = typeof o.sinceMs === 'number' ? o.sinceMs - 2000 : null; // small clock-skew slack
601
+ const candidates = historyAfter.filter((s) => {
602
+ if (!s || s.cwd !== o.cwd || !s.sessionId) return false;
603
+ if (before.has(s.sessionId)) return false; // only sessions NEW since the before-snapshot
604
+ if (since != null) {
605
+ const started = Date.parse(s.startedAt || s.updatedAt || '');
606
+ if (Number.isFinite(started) && started < since) return false;
607
+ }
608
+ if (o.promptNeedle && typeof s.prompt === 'string' && !s.prompt.includes(o.promptNeedle)) return false;
609
+ return true;
610
+ });
611
+ // Strict: bind only on a single unambiguous candidate; otherwise decline.
612
+ return candidates.length === 1 ? candidates[0].sessionId : null;
613
+ }
614
+
615
+ module.exports = {
616
+ MIN_CLINE_VERSION,
617
+ parseClineVersion,
618
+ compareVersions,
619
+ classifyClineVersion,
620
+ redactSecrets,
621
+ redactValue,
622
+ classifyClineError,
623
+ classifyClineAuth,
624
+ CLINE_AUTH_ENV_VARS,
625
+ buildClineArgs,
626
+ redactArgs,
627
+ ClineStreamParser,
628
+ interpretClineEnvelope,
629
+ friendlyToolLabel,
630
+ toolInputPreview,
631
+ pickClineSessionId,
632
+ };