@openagents-org/agent-launcher 0.2.175 → 0.2.177

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,497 @@
1
+ /**
2
+ * Pure helpers for the CodeBuddy adapter — argv construction, stream-json frame
3
+ * interpretation, run classification, region/auth env resolution, and the
4
+ * version gate.
5
+ *
6
+ * Split out from codebuddy.js so the parts with real decision logic are unit
7
+ * testable without spawning a CLI, an account, or a workspace.
8
+ *
9
+ * Three facts about CodeBuddy Code's headless contract shape everything here,
10
+ * and all three were verified against the real CLI (2.142.0) rather than read
11
+ * off a docs page:
12
+ *
13
+ * 1. THE EXIT CODE IS NOT A CONTRACT. A run whose model call failed outright
14
+ * still exits **0** and reports the failure only inside the result frame
15
+ * (`is_error: true`, `subtype: "error_during_execution"`). Classifying on
16
+ * the exit code — the habit every other CLI adapter here can afford —
17
+ * would report a 504 from the provider as a successful empty answer. The
18
+ * RESULT FRAME is authoritative; the exit code is a tiebreaker for the
19
+ * case where no result frame arrived at all.
20
+ * 2. ERRORS ARE STRUCTURED. The result frame carries `errors_info`, an array
21
+ * of `{status, code, category, details}` where category is one of
22
+ * `auth` / `quota` / `network` / `model_service`. Classification reads
23
+ * that, so the user-facing message stays right even though the CLI's own
24
+ * error text is localized — a China-site account gets Chinese prose that
25
+ * no English regex would ever match.
26
+ * 3. THE ANSWER LIVES ON THE RESULT FRAME. `result.result` holds the final
27
+ * text. Assistant frames are progress decoration only, so a change to the
28
+ * streaming shape costs the live ticker rather than the reply.
29
+ */
30
+
31
+ 'use strict';
32
+
33
+ // Supported floor. The 2.x line is what ships the `-p --output-format
34
+ // stream-json` headless contract this module parses; anything older has a
35
+ // different (or no) machine-readable stream.
36
+ const CODEBUDDY_MIN_VERSION = '2.0.0';
37
+ // Highest version whose behavior was verified end to end. Newer is allowed —
38
+ // it just isn't claimed as tested.
39
+ const CODEBUDDY_TESTED_MAX_VERSION = '2.142.0';
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Version gate
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /** Extract a dotted version from `--version` output. Returns null if absent. */
46
+ function parseCodeBuddyVersion(raw) {
47
+ if (!raw || typeof raw !== 'string') return null;
48
+ const m = raw.match(/(\d+)\.(\d+)\.(\d+)(?:[-.][0-9A-Za-z.]+)?/);
49
+ return m ? `${m[1]}.${m[2]}.${m[3]}` : null;
50
+ }
51
+
52
+ /** Numeric compare of two dotted versions. Returns -1 / 0 / 1. */
53
+ function compareVersions(a, b) {
54
+ const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
55
+ const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
56
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
57
+ const x = pa[i] || 0;
58
+ const y = pb[i] || 0;
59
+ if (x !== y) return x < y ? -1 : 1;
60
+ }
61
+ return 0;
62
+ }
63
+
64
+ /**
65
+ * Classify a raw `--version` string.
66
+ *
67
+ * `supported: null` means "could not tell" — an unreadable version proceeds
68
+ * leniently rather than blocking a CLI that is probably fine. Only a version we
69
+ * positively read AND that is below the floor returns `false`.
70
+ */
71
+ function classifyCodeBuddyVersion(rawVersion) {
72
+ const version = parseCodeBuddyVersion(rawVersion);
73
+ if (!version) return { version: null, supported: null, tested: null };
74
+ return {
75
+ version,
76
+ supported: compareVersions(version, CODEBUDDY_MIN_VERSION) >= 0,
77
+ tested: compareVersions(version, CODEBUDDY_TESTED_MAX_VERSION) <= 0,
78
+ };
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Redaction
83
+ // ---------------------------------------------------------------------------
84
+
85
+ const SECRET_PATTERNS = [
86
+ // Provider API keys: sk-..., and the CodeBuddy console's own key format.
87
+ /\bsk-[A-Za-z0-9._-]{8,}\b/g,
88
+ // Bearer tokens in copied headers.
89
+ /\bBearer\s+[A-Za-z0-9._-]{8,}\b/gi,
90
+ // JWTs, which is what CODEBUDDY_AUTH_TOKEN and the workspace token both are.
91
+ /\beyJ[A-Za-z0-9._-]{16,}\b/g,
92
+ // Long opaque blobs assigned to a secret-looking key.
93
+ /\b(api[_-]?key|token|secret|password|authorization)\b\s*[:=]\s*["']?[A-Za-z0-9._-]{8,}["']?/gi,
94
+ ];
95
+
96
+ /** Redact obvious secret material from free text before it is logged or posted. */
97
+ function redactSecrets(text) {
98
+ if (text == null) return text;
99
+ let out = String(text);
100
+ for (const re of SECRET_PATTERNS) {
101
+ out = out.replace(re, (m) => {
102
+ const eq = m.match(/\s*[:=]\s*/);
103
+ if (eq) return m.slice(0, m.indexOf(eq[0])) + eq[0] + '[redacted]';
104
+ return /^bearer/i.test(m) ? 'Bearer [redacted]' : '[redacted]';
105
+ });
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /** Truncate with an ellipsis, for previews and diagnostics. */
111
+ function truncate(s, n) {
112
+ s = String(s == null ? '' : s);
113
+ return s.length > n ? s.slice(0, n - 1) + '…' : s;
114
+ }
115
+
116
+ /**
117
+ * Redact an argv for logging.
118
+ *
119
+ * The prompt is piped over stdin, so it never appears here. The two values
120
+ * worth hiding are the resumed session id and the system prompt, which carries
121
+ * the agent's workspace identity and can run to kilobytes.
122
+ */
123
+ function redactArgs(args) {
124
+ const out = [];
125
+ for (let i = 0; i < args.length; i++) {
126
+ const a = args[i];
127
+ out.push(a);
128
+ if ((a === '--resume' || a === '-r') && args[i + 1]) {
129
+ out.push('<session-id>');
130
+ i++;
131
+ } else if (a === '--append-system-prompt' && args[i + 1]) {
132
+ out.push('<system-prompt>');
133
+ i++;
134
+ }
135
+ }
136
+ return out;
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Child environment
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /** Region ids accepted by CODEBUDDY_REGION, and what each one means. */
144
+ const REGIONS = {
145
+ // Default. The CLI's own endpoint (https://www.codebuddy.ai), which is the
146
+ // account you get from codebuddy.ai / workbuddy.ai.
147
+ international: {},
148
+ // The China site (codebuddy.cn / workbuddy.cn). `internal` is the value the
149
+ // CLI documents for it; the endpoint that selects is Tencent's own.
150
+ china: { CODEBUDDY_INTERNET_ENVIRONMENT: 'internal' },
151
+ };
152
+
153
+ /**
154
+ * Environment overlay for one headless run — pure, so the whole auth/region
155
+ * story is testable without spawning anything.
156
+ *
157
+ * Everything it sets is either a region switch or a self-update/telemetry
158
+ * silencer. It deliberately does NOT invent credentials: a user who signed in
159
+ * with `/login` has a session on disk and no key at all, and an API key that
160
+ * the launcher collected is already in `agentEnv` under its own name.
161
+ *
162
+ * @param {object} agentEnv - the agent's configured environment
163
+ * @returns {object} variables to overlay on the child's env
164
+ */
165
+ function resolveCodeBuddyEnv(agentEnv = {}) {
166
+ const overlay = {
167
+ // A background self-update would swap the CLI — and with it the stream
168
+ // contract this module parses — under a daemon that is mid-run. Upgrades
169
+ // happen through the launcher's install path instead.
170
+ DISABLE_AUTOUPDATER: '1',
171
+ // The agent is not a person; nothing here should phone home about it.
172
+ DISABLE_TELEMETRY: '1',
173
+ DISABLE_ERROR_REPORTING: '1',
174
+ };
175
+
176
+ const region = String(agentEnv.CODEBUDDY_REGION || '').trim().toLowerCase();
177
+ if (region && REGIONS[region]) Object.assign(overlay, REGIONS[region]);
178
+
179
+ // A bearer token pasted with its scheme still attached is the single most
180
+ // common way this fails; the CLI strips "Bearer " itself, but normalizing
181
+ // here keeps the value the same in logs and in the child.
182
+ const token = String(agentEnv.CODEBUDDY_AUTH_TOKEN || '').trim();
183
+ if (token) overlay.CODEBUDDY_AUTH_TOKEN = token.replace(/^Bearer\s+/i, '');
184
+
185
+ return overlay;
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // argv
190
+ // ---------------------------------------------------------------------------
191
+
192
+ /**
193
+ * Tools that have no meaning for an agent answering messages in a workspace,
194
+ * and would either stall a headless run or reach a real person.
195
+ *
196
+ * The asking tools are the stalling half: there is nobody at a terminal to
197
+ * answer them. The messaging half (WeChat / WeCom / push) would deliver to the
198
+ * user's own IM accounts from an agent they pointed at a workspace channel —
199
+ * a surprise no prompt should be able to trigger. Cron is banned for the same
200
+ * reason as in the Claude adapter: scheduling belongs to the workspace's own
201
+ * timers, which the agent reaches through MCP.
202
+ */
203
+ const DISALLOWED_TOOLS = [
204
+ 'AskUserQuestion',
205
+ 'AskUserForStructuredInput',
206
+ 'WeChatReply',
207
+ 'WeComReply',
208
+ 'PushNotification',
209
+ 'CronCreate',
210
+ 'CronDelete',
211
+ 'CronList',
212
+ ];
213
+
214
+ /**
215
+ * Build the argv for one headless run.
216
+ *
217
+ * THE PROMPT IS NOT HERE. `-p` is passed with no value and the prompt is piped
218
+ * over stdin (verified: the CLI starts a real turn from piped input). That
219
+ * keeps an arbitrarily long turn — workspace briefing plus channel recap plus
220
+ * the user's message — away from the OS argv limit, and sidesteps quoting on
221
+ * Windows entirely.
222
+ *
223
+ * @param {object} o
224
+ * @param {string} [o.appendSystemPrompt] workspace briefing appended to the CLI's own prompt
225
+ * @param {string} [o.model] model id (empty = the account default)
226
+ * @param {string} [o.effort] reasoning effort (minimal…max)
227
+ * @param {number} [o.maxTurns] cap on agentic turns
228
+ * @param {string} [o.resumeSessionId] continue this channel's CLI session
229
+ * @param {string} [o.mcpConfigPath] path to the workspace MCP server config
230
+ * @param {boolean} [o.planMode] read-only investigation instead of full access
231
+ * @returns {string[]}
232
+ */
233
+ function buildCodeBuddyArgs(o = {}) {
234
+ const args = [
235
+ '-p',
236
+ '--output-format', 'stream-json',
237
+ // stream-json only streams with --verbose; without it the run still works
238
+ // but arrives as one lump at the end, and the channel shows no progress.
239
+ '--verbose',
240
+ ];
241
+
242
+ const systemPrompt = (o.appendSystemPrompt || '').trim();
243
+ if (systemPrompt) args.push('--append-system-prompt', systemPrompt);
244
+
245
+ if (o.planMode) {
246
+ // Investigate and propose; no writes, no shell.
247
+ args.push('--permission-mode', 'plan');
248
+ } else {
249
+ // Headless has no one to answer a permission prompt. `-y` resolves to
250
+ // permissionMode=bypassPermissions (confirmed in the init frame).
251
+ args.push('-y');
252
+ }
253
+
254
+ args.push('--disallowedTools', ...DISALLOWED_TOOLS);
255
+
256
+ const model = (o.model || '').trim();
257
+ if (model) args.push('--model', model);
258
+
259
+ const effort = (o.effort || '').trim();
260
+ if (effort) args.push('--effort', effort);
261
+
262
+ if (Number.isFinite(o.maxTurns) && o.maxTurns > 0) {
263
+ args.push('--max-turns', String(Math.floor(o.maxTurns)));
264
+ }
265
+
266
+ if (o.mcpConfigPath) args.push('--mcp-config', o.mcpConfigPath);
267
+
268
+ const resume = (o.resumeSessionId || '').trim();
269
+ if (resume) args.push('--resume', resume);
270
+
271
+ return args;
272
+ }
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // stream-json frames
276
+ // ---------------------------------------------------------------------------
277
+
278
+ /** Parse one NDJSON line. Returns null for blank lines and malformed JSON. */
279
+ function parseFrame(line) {
280
+ const s = String(line == null ? '' : line).trim();
281
+ if (!s || s[0] !== '{') return null;
282
+ try {
283
+ const v = JSON.parse(s);
284
+ return v && typeof v === 'object' ? v : null;
285
+ } catch {
286
+ return null;
287
+ }
288
+ }
289
+
290
+ /**
291
+ * A short, human-readable preview of a tool call's input, for the channel's
292
+ * activity ticker. Mirrors the field order the Claude adapter uses, because
293
+ * CodeBuddy's tool inputs are shaped the same way.
294
+ */
295
+ function toolInputPreview(input) {
296
+ if (input == null) return '';
297
+ if (typeof input !== 'object') return truncate(redactSecrets(String(input)), 150);
298
+ const first = input.command || input.file_path || input.path || input.pattern
299
+ || input.query || input.url || input.prompt;
300
+ if (typeof first === 'string' && first) return truncate(redactSecrets(first), 150);
301
+ if (typeof input.content === 'string') return truncate(redactSecrets(input.content), 100);
302
+ try {
303
+ return truncate(redactSecrets(JSON.stringify(input)), 150);
304
+ } catch {
305
+ return '';
306
+ }
307
+ }
308
+
309
+ // Frames that carry no user-visible progress. Listing them keeps them out of
310
+ // the `unknown` diagnostic channel, so a genuinely new frame type still shows
311
+ // up in the log instead of being lost in the noise.
312
+ const IGNORED_FRAMES = new Set([
313
+ 'file-history-snapshot',
314
+ 'user',
315
+ 'stream_event',
316
+ 'rate_limit_event',
317
+ ]);
318
+
319
+ /**
320
+ * Normalize one parsed frame into something the adapter can act on.
321
+ *
322
+ * @returns {{kind: string, [k: string]: any}}
323
+ */
324
+ function interpretCodeBuddyFrame(frame) {
325
+ if (!frame || typeof frame !== 'object') return { kind: 'ignored' };
326
+
327
+ if (frame.type === 'result') {
328
+ const errorsInfo = Array.isArray(frame.errors_info)
329
+ ? frame.errors_info.filter((e) => e && typeof e === 'object')
330
+ : [];
331
+ const errors = Array.isArray(frame.errors)
332
+ ? frame.errors.map((e) => redactSecrets(String(e))).filter(Boolean)
333
+ : [];
334
+ return {
335
+ kind: 'result',
336
+ subtype: typeof frame.subtype === 'string' ? frame.subtype : '',
337
+ isError: frame.is_error === true,
338
+ // `result` is present on success and absent on failure; always a string
339
+ // here so callers never have to guard it.
340
+ text: typeof frame.result === 'string' ? frame.result : '',
341
+ sessionId: typeof frame.session_id === 'string' ? frame.session_id : null,
342
+ errors,
343
+ errorsInfo,
344
+ numTurns: Number.isFinite(frame.num_turns) ? frame.num_turns : null,
345
+ durationMs: Number.isFinite(frame.duration_ms) ? frame.duration_ms : null,
346
+ };
347
+ }
348
+
349
+ if (frame.type === 'system') {
350
+ if (frame.subtype === 'init') {
351
+ return {
352
+ kind: 'init',
353
+ sessionId: typeof frame.session_id === 'string' ? frame.session_id : null,
354
+ model: typeof frame.model === 'string' ? frame.model : '',
355
+ permissionMode: typeof frame.permissionMode === 'string' ? frame.permissionMode : '',
356
+ mcpServers: Array.isArray(frame.mcp_servers) ? frame.mcp_servers : [],
357
+ };
358
+ }
359
+ // Compaction is the one system message worth showing: it is slow, and a
360
+ // silent channel during it reads as a hang.
361
+ const message = typeof frame.message === 'string' ? frame.message : '';
362
+ const subtype = typeof frame.subtype === 'string' ? frame.subtype : '';
363
+ if (subtype.includes('compact') || /compact/i.test(message)) {
364
+ return { kind: 'status', text: message || 'Compacting the conversation...' };
365
+ }
366
+ return { kind: 'ignored' };
367
+ }
368
+
369
+ if (frame.type === 'assistant') {
370
+ const blocks = (frame.message && Array.isArray(frame.message.content))
371
+ ? frame.message.content : [];
372
+ const texts = [];
373
+ const tools = [];
374
+ for (const block of blocks) {
375
+ if (!block || typeof block !== 'object') continue;
376
+ if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
377
+ texts.push(block.text.trim());
378
+ } else if (block.type === 'tool_use') {
379
+ tools.push({
380
+ name: typeof block.name === 'string' ? block.name : '',
381
+ preview: toolInputPreview(block.input),
382
+ todos: block.input && Array.isArray(block.input.todos) ? block.input.todos : null,
383
+ });
384
+ }
385
+ }
386
+ if (!texts.length && !tools.length) return { kind: 'ignored' };
387
+ return { kind: 'assistant', texts, tools };
388
+ }
389
+
390
+ if (IGNORED_FRAMES.has(frame.type)) return { kind: 'ignored' };
391
+
392
+ return { kind: 'unknown', raw: truncate(redactSecrets(String(frame.type || '')), 80) };
393
+ }
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Run classification
397
+ // ---------------------------------------------------------------------------
398
+
399
+ /**
400
+ * What each structured error category means for the user, keyed by the values
401
+ * the CLI actually emits in `errors_info[].category`.
402
+ *
403
+ * Auth is the one that has to be right: the two ways in (an API key in the
404
+ * agent's environment, or a `/login` session on disk) fail identically from
405
+ * out here, so the message names both rather than guessing which one the user
406
+ * meant to be using.
407
+ */
408
+ const ERROR_CATEGORIES = {
409
+ auth: 'CodeBuddy rejected the credentials (401/403). Either the API key is wrong or expired, or the CLI is signed out — set CODEBUDDY_API_KEY for this agent, or run `codebuddy` in a terminal and sign in with /login.',
410
+ quota: 'The CodeBuddy account is rate limited or out of credits. Wait for the quota to reset, or top the account up, then retry.',
411
+ network: 'CodeBuddy could not reach its service (network failure or a blocked proxy). Check connectivity and any proxy settings, then retry.',
412
+ model_service: 'The CodeBuddy model service returned a server error. This is usually temporary — retry shortly.',
413
+ };
414
+
415
+ /**
416
+ * Classify how a run ended.
417
+ *
418
+ * Order matters, and it is NOT the usual one: the result frame outranks the
419
+ * exit code, because CodeBuddy exits 0 on a failed run (see the file header).
420
+ * The exit code is consulted only when no result frame arrived at all, which
421
+ * is the crash / killed / "died before it started" case.
422
+ *
423
+ * @param {object} o
424
+ * @param {object|null} [o.result] the interpreted result frame, when one arrived
425
+ * @param {number|null} [o.code] process exit code
426
+ * @param {string|null} [o.signal] terminating signal, if any
427
+ * @param {string} [o.stderr] captured stderr, used only as a last resort
428
+ * @returns {{kind: string, ok: boolean, userMessage: string|null}}
429
+ */
430
+ function classifyCodeBuddyRun({ result = null, code = null, signal = null, stderr = '' } = {}) {
431
+ if (result && !result.isError) {
432
+ return { kind: 'success', ok: true, userMessage: null };
433
+ }
434
+
435
+ if (result && result.isError) {
436
+ const category = result.errorsInfo
437
+ .map((e) => String(e.category || ''))
438
+ .find((c) => ERROR_CATEGORIES[c]);
439
+ if (category) {
440
+ return { kind: category, ok: false, userMessage: ERROR_CATEGORIES[category] };
441
+ }
442
+ // No category the CLI could classify: fall back to its own text, which is
443
+ // localized but at least specific.
444
+ const detail = result.errors[0]
445
+ || result.errorsInfo.map((e) => e.details).find(Boolean)
446
+ || '';
447
+ return {
448
+ kind: 'run_error',
449
+ ok: false,
450
+ userMessage: detail
451
+ ? `CodeBuddy could not finish the run: ${truncate(redactSecrets(String(detail)), 400)}`
452
+ : 'CodeBuddy could not finish the run. Check the daemon log for details, then retry.',
453
+ };
454
+ }
455
+
456
+ // No result frame at all from here down.
457
+ if (signal) {
458
+ return { kind: 'interrupted', ok: false, userMessage: 'The run was interrupted.' };
459
+ }
460
+
461
+ // A hard startup failure prints to stderr and still exits 0 — the failure
462
+ // mode that has no frame to read, so this is where stderr earns its keep.
463
+ const err = String(stderr || '').trim();
464
+ if (err) {
465
+ return {
466
+ kind: 'startup_error',
467
+ ok: false,
468
+ userMessage: `CodeBuddy stopped before answering: ${truncate(redactSecrets(err.split('\n')[0]), 400)}`,
469
+ };
470
+ }
471
+
472
+ return {
473
+ kind: 'no_result',
474
+ ok: false,
475
+ userMessage: `CodeBuddy exited without answering (code ${code}). Check the daemon log, then retry.`,
476
+ };
477
+ }
478
+
479
+ module.exports = {
480
+ CODEBUDDY_MIN_VERSION,
481
+ CODEBUDDY_TESTED_MAX_VERSION,
482
+ DISALLOWED_TOOLS,
483
+ ERROR_CATEGORIES,
484
+ REGIONS,
485
+ parseCodeBuddyVersion,
486
+ compareVersions,
487
+ classifyCodeBuddyVersion,
488
+ redactSecrets,
489
+ redactArgs,
490
+ truncate,
491
+ resolveCodeBuddyEnv,
492
+ buildCodeBuddyArgs,
493
+ parseFrame,
494
+ toolInputPreview,
495
+ interpretCodeBuddyFrame,
496
+ classifyCodeBuddyRun,
497
+ };