@openagents-org/agent-launcher 0.2.173 → 0.2.176

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,405 @@
1
+ /**
2
+ * Pure helpers for the Command Code adapter — argv construction, NDJSON frame
3
+ * interpretation, exit-code classification, and the version gate.
4
+ *
5
+ * Split out from commandcode.js so the parts with real decision logic are unit
6
+ * testable without spawning a CLI, a network, or a workspace.
7
+ *
8
+ * Two design choices drive everything here, and both come from Command Code's
9
+ * own headless contract (`cmd -p --output-format json`):
10
+ *
11
+ * 1. THE RESULT LINE IS AUTHORITATIVE. The stream ends with exactly one
12
+ * `{"type":"result",...}` object carrying `finalText`, `subtype` and
13
+ * `usage`. The assistant's answer is read from THERE, never reassembled
14
+ * from `text_delta` events. Event frames are progress decoration only, so
15
+ * an upstream schema change degrades the live tool/thinking ticker rather
16
+ * than losing the reply — the failure mode that keeps biting adapters
17
+ * which rebuild the answer from a delta stream.
18
+ * 2. EXIT CODES ARE A PUBLISHED CONTRACT. Command Code documents eleven of
19
+ * them (3 = not authenticated, 5 = rate limited, 10 = out of credits, …).
20
+ * Classification reads that number instead of regex-matching stderr, so
21
+ * the user-facing message is right even when the wording upstream changes.
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ // Supported floor. 1.0.0 is the release that introduced
27
+ // `-p --output-format json` (CHANGELOG: "feat: `-p --output-format json` for
28
+ // cmd headless mode and scripting"). Below it there is no JSON stream to read
29
+ // at all, so the run is blocked rather than left to fail obscurely.
30
+ const COMMANDCODE_MIN_VERSION = '1.0.0';
31
+ // Highest version whose behavior we have verified. Newer is allowed — it just
32
+ // isn't claimed as tested.
33
+ const COMMANDCODE_TESTED_MAX_VERSION = '1.36.0';
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Version gate
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /** Extract a dotted version from `--version` output. Returns null if absent. */
40
+ function parseCommandCodeVersion(raw) {
41
+ if (!raw || typeof raw !== 'string') return null;
42
+ const m = raw.match(/(\d+)\.(\d+)\.(\d+)(?:[-.][0-9A-Za-z.]+)?/);
43
+ return m ? `${m[1]}.${m[2]}.${m[3]}` : null;
44
+ }
45
+
46
+ /** Numeric compare of two dotted versions. Returns -1 / 0 / 1. */
47
+ function compareVersions(a, b) {
48
+ const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
49
+ const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
50
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
51
+ const x = pa[i] || 0;
52
+ const y = pb[i] || 0;
53
+ if (x !== y) return x < y ? -1 : 1;
54
+ }
55
+ return 0;
56
+ }
57
+
58
+ /**
59
+ * Classify a raw `--version` string.
60
+ *
61
+ * `supported: null` means "could not tell" — an unreadable version proceeds
62
+ * leniently rather than blocking a CLI that is probably fine. Only a version we
63
+ * positively read AND that is below the floor returns `false`.
64
+ */
65
+ function classifyCommandCodeVersion(rawVersion) {
66
+ const version = parseCommandCodeVersion(rawVersion);
67
+ if (!version) return { version: null, supported: null, tested: null };
68
+ return {
69
+ version,
70
+ supported: compareVersions(version, COMMANDCODE_MIN_VERSION) >= 0,
71
+ tested: compareVersions(version, COMMANDCODE_TESTED_MAX_VERSION) <= 0,
72
+ };
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Redaction
77
+ // ---------------------------------------------------------------------------
78
+
79
+ const SECRET_PATTERNS = [
80
+ // Provider API keys: sk-..., sk-ant-..., sk-or-..., and friends.
81
+ /\bsk-[A-Za-z0-9._-]{8,}\b/g,
82
+ // Bearer tokens in copied headers.
83
+ /\bBearer\s+[A-Za-z0-9._-]{8,}\b/gi,
84
+ // Long opaque blobs assigned to a secret-looking key.
85
+ /\b(api[_-]?key|token|secret|password|authorization)\b\s*[:=]\s*["']?[A-Za-z0-9._-]{8,}["']?/gi,
86
+ ];
87
+
88
+ /** Redact obvious secret material from free text before it is logged. */
89
+ function redactSecrets(text) {
90
+ if (text == null) return text;
91
+ let out = String(text);
92
+ for (const re of SECRET_PATTERNS) out = out.replace(re, (m) => {
93
+ const eq = m.match(/\s*[:=]\s*/);
94
+ if (eq) return m.slice(0, m.indexOf(eq[0])) + eq[0] + '[redacted]';
95
+ return m.startsWith('Bearer') || m.startsWith('bearer') ? 'Bearer [redacted]' : '[redacted]';
96
+ });
97
+ return out;
98
+ }
99
+
100
+ /** Truncate with an ellipsis, for previews and diagnostics. */
101
+ function truncate(s, n) {
102
+ s = String(s == null ? '' : s);
103
+ return s.length > n ? s.slice(0, n - 1) + '…' : s;
104
+ }
105
+
106
+ /**
107
+ * Redact an argv for logging. The prompt never rides in argv (it is piped over
108
+ * stdin), so the only sensitive-ish value here is a resumed session id.
109
+ */
110
+ function redactArgs(args) {
111
+ const out = [];
112
+ for (let i = 0; i < args.length; i++) {
113
+ const a = args[i];
114
+ out.push(a);
115
+ if ((a === '--resume' || a === '-r') && args[i + 1]) {
116
+ out.push('<session-id>');
117
+ i++;
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // argv
125
+ // ---------------------------------------------------------------------------
126
+
127
+ /**
128
+ * Build the argv for one headless run.
129
+ *
130
+ * The PROMPT IS NOT HERE. `-p` is passed with no value and the prompt is piped
131
+ * over stdin, which Command Code auto-detects. That keeps arbitrarily long
132
+ * prompts (a workspace skill header plus channel recap plus the user's turn)
133
+ * away from the OS argv limit, and sidesteps quoting entirely on Windows,
134
+ * where an embedded quote in a user message is otherwise a command-line
135
+ * injection waiting to happen.
136
+ *
137
+ * @param {object} o
138
+ * @param {string} [o.model] `-m` model id (empty = the CLI's default)
139
+ * @param {string} [o.effort] `--effort` low | medium | high
140
+ * @param {string} [o.resumeSessionId] `--resume <id>` to continue a channel
141
+ * @param {boolean} [o.planMode] read-only exploration instead of `--yolo`
142
+ * @param {number} [o.maxTurns] `--max-turns` (CLI default is 100)
143
+ * @returns {string[]}
144
+ */
145
+ function buildCommandCodeArgs(o = {}) {
146
+ const args = [
147
+ '-p',
148
+ '--output-format', 'json',
149
+ // Taste onboarding is an interactive first-run flow; without this an
150
+ // automated run can sit waiting for a human that will never arrive.
151
+ '--skip-onboarding',
152
+ // Auto-trust the project. The trust prompt is interactive too, and the
153
+ // working directory here was configured by the user in the launcher.
154
+ '--trust',
155
+ // A background self-update would swap the CLI — and with it the JSON
156
+ // contract this module parses — under a daemon that is mid-run. Upgrades
157
+ // happen through the launcher's install path instead.
158
+ '--no-auto-update',
159
+ // Session id to stderr. The result line carries `sessionId` too, but the
160
+ // docs mark that field OPTIONAL — it is absent exactly when a run fails
161
+ // early. This is the fallback that keeps a channel resumable.
162
+ '--verbose',
163
+ ];
164
+
165
+ if (o.planMode) {
166
+ // Read-only exploration. Deliberately NOT combined with --yolo.
167
+ args.push('--plan');
168
+ } else {
169
+ // Headless blocks writes and shell by default, which would leave the agent
170
+ // able to talk but not to act.
171
+ args.push('--yolo');
172
+ }
173
+
174
+ const model = (o.model || '').trim();
175
+ if (model) args.push('--model', model);
176
+
177
+ const effort = (o.effort || '').trim();
178
+ if (effort) args.push('--effort', effort);
179
+
180
+ if (Number.isFinite(o.maxTurns) && o.maxTurns > 0) {
181
+ args.push('--max-turns', String(Math.floor(o.maxTurns)));
182
+ }
183
+
184
+ const resume = (o.resumeSessionId || '').trim();
185
+ if (resume) args.push('--resume', resume);
186
+
187
+ return args;
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // NDJSON frames
192
+ // ---------------------------------------------------------------------------
193
+
194
+ /** Parse one NDJSON line. Returns null for blank lines and malformed JSON. */
195
+ function parseFrame(line) {
196
+ const s = String(line == null ? '' : line).trim();
197
+ if (!s || s[0] !== '{') return null;
198
+ try {
199
+ const v = JSON.parse(s);
200
+ return v && typeof v === 'object' ? v : null;
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+
206
+ // Human-readable verbs for Command Code's documented tool names, so the
207
+ // workspace ticker reads like activity rather than an API log.
208
+ const TOOL_LABELS = {
209
+ read_file: 'reading',
210
+ read_directory: 'listing',
211
+ write_file: 'writing',
212
+ edit_file: 'editing',
213
+ glob: 'searching',
214
+ grep: 'searching',
215
+ shell_command: 'running',
216
+ powershell: 'running',
217
+ shell_output: 'reading output',
218
+ monitor_command: 'monitoring',
219
+ shell_tasks: 'checking tasks',
220
+ kill_shell: 'stopping',
221
+ todo_write: 'planning',
222
+ web_search: 'searching the web',
223
+ web_fetch: 'fetching',
224
+ agent: 'delegating',
225
+ activate_skill: 'loading a skill',
226
+ taste: 'learning taste',
227
+ };
228
+
229
+ /** Friendly verb for a tool name; unknown names pass through unchanged. */
230
+ function friendlyToolLabel(toolName) {
231
+ const name = String(toolName || '').trim();
232
+ return TOOL_LABELS[name] || name || 'working';
233
+ }
234
+
235
+ // Frames that carry no user-visible progress. Listing them explicitly keeps
236
+ // them out of the `unknown` diagnostic channel, so a genuinely new event type
237
+ // still shows up in the log.
238
+ const IGNORED_EVENTS = new Set([
239
+ 'run_start', 'turn_start', 'turn_end',
240
+ 'model_request_start', 'model_request_end',
241
+ 'tool_queued', 'tool_update', 'tool_hooks',
242
+ 'text_delta', 'message_start', 'message_update', 'message_end',
243
+ 'session_start', 'session_info', 'skill_loaded',
244
+ 'compaction_start', 'compaction_done',
245
+ 'subagent_start', 'subagent_stop', 'subagent_progress',
246
+ ]);
247
+
248
+ const TOOL_STATES = {
249
+ tool_running: 'running',
250
+ tool_completed: 'completed',
251
+ tool_errored: 'errored',
252
+ tool_denied: 'denied',
253
+ tool_hook_blocked: 'blocked',
254
+ };
255
+
256
+ /**
257
+ * Normalize one parsed frame into something the adapter can act on.
258
+ *
259
+ * Only two shapes matter: the single `result` line (authoritative — it carries
260
+ * the reply) and `event` frames (progress only). Anything unrecognized becomes
261
+ * `{ kind: 'unknown' }` with a short redacted preview rather than an
262
+ * exception, because the docs explicitly ask consumers to treat unknown event
263
+ * types as forward-compatible.
264
+ *
265
+ * @returns {{kind: string, [k: string]: any}}
266
+ */
267
+ function interpretCommandCodeFrame(frame) {
268
+ if (!frame || typeof frame !== 'object') return { kind: 'ignored' };
269
+
270
+ if (frame.type === 'result') {
271
+ const usage = frame.usage && typeof frame.usage === 'object' ? frame.usage : null;
272
+ return {
273
+ kind: 'result',
274
+ subtype: typeof frame.subtype === 'string' ? frame.subtype : 'error',
275
+ // Always a string: the docs guarantee the field, empty on an error result.
276
+ finalText: typeof frame.finalText === 'string' ? frame.finalText : '',
277
+ // Both optional — absent when the run died before a session existed.
278
+ sessionId: typeof frame.sessionId === 'string' ? frame.sessionId : null,
279
+ stopReason: typeof frame.stopReason === 'string' ? frame.stopReason : null,
280
+ error: frame.error == null ? null : redactSecrets(String(frame.error)),
281
+ usage,
282
+ durationMs: Number.isFinite(frame.durationMs) ? frame.durationMs : null,
283
+ };
284
+ }
285
+
286
+ if (frame.type !== 'event') return { kind: 'unknown', raw: truncate(redactSecrets(JSON.stringify(frame)), 200) };
287
+
288
+ const ev = frame.event;
289
+ if (!ev || typeof ev !== 'object' || typeof ev.type !== 'string') return { kind: 'ignored' };
290
+
291
+ if (TOOL_STATES[ev.type]) {
292
+ return {
293
+ kind: 'tool',
294
+ state: TOOL_STATES[ev.type],
295
+ toolCallId: typeof ev.toolCallId === 'string' ? ev.toolCallId : null,
296
+ toolName: typeof ev.toolName === 'string' ? ev.toolName : '',
297
+ label: friendlyToolLabel(ev.toolName),
298
+ description: ev.description ? truncate(redactSecrets(String(ev.description)), 160) : '',
299
+ };
300
+ }
301
+
302
+ if (ev.type === 'thinking_start') return { kind: 'thinking' };
303
+
304
+ if (ev.type === 'run_error' || ev.type === 'mod_error') {
305
+ const message = ev.error || ev.message || '';
306
+ return { kind: 'error', message: truncate(redactSecrets(String(message)), 400) };
307
+ }
308
+
309
+ if (ev.type === 'run_end') {
310
+ const stopReason = ev.result && typeof ev.result === 'object' ? ev.result.stopReason : null;
311
+ return { kind: 'run_end', stopReason: typeof stopReason === 'string' ? stopReason : null };
312
+ }
313
+
314
+ if (IGNORED_EVENTS.has(ev.type)) return { kind: 'ignored' };
315
+
316
+ return { kind: 'unknown', raw: truncate(redactSecrets(ev.type), 80) };
317
+ }
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // Exit codes
321
+ // ---------------------------------------------------------------------------
322
+
323
+ /**
324
+ * Command Code's published exit-code contract.
325
+ *
326
+ * `partial` marks the codes that still produced usable output: a max-turns run
327
+ * returns the partial answer, so the adapter delivers it with a note instead of
328
+ * replacing it with an error.
329
+ */
330
+ const EXIT_CODES = {
331
+ 0: { kind: 'success', message: null },
332
+ 1: { kind: 'cli_error', message: 'Command Code failed to complete the run. Check the daemon log for details, then retry.' },
333
+ 3: { kind: 'auth_required', message: 'Command Code is not signed in. Run `command-code login`, or configure an API key, then retry.' },
334
+ 4: { kind: 'permission_denied', message: 'Command Code was denied permission for an action it needed. Check the permission settings for this agent and retry.' },
335
+ 5: { kind: 'rate_limited', message: 'Command Code hit a rate limit. Wait a moment, then retry.' },
336
+ 6: { kind: 'network_error', message: 'Command Code could not reach its provider (network failure). Check connectivity, then retry.' },
337
+ 7: { kind: 'server_error', message: 'The model provider returned a server error. This is usually temporary — retry shortly.' },
338
+ 8: { kind: 'max_turns', message: 'The run hit its turn limit before finishing. The partial answer is above.', partial: true },
339
+ 9: { kind: 'no_response', message: 'The model produced no response. Try rephrasing the request.' },
340
+ 10: { kind: 'insufficient_credits', message: 'The Command Code account is out of credits. Top it up, or switch to a BYOK provider, then retry.' },
341
+ 130: { kind: 'interrupted', message: 'The run was interrupted.' },
342
+ };
343
+
344
+ /**
345
+ * Classify how a run ended.
346
+ *
347
+ * The exit code is the primary signal (a documented contract); the result
348
+ * line's `error` field only ever refines the MESSAGE, never the verdict. An
349
+ * undocumented code degrades to a generic failure rather than being reported
350
+ * as success.
351
+ *
352
+ * @param {object} o
353
+ * @param {number|null} o.code process exit code
354
+ * @param {string|null} [o.signal] terminating signal, if any
355
+ * @param {object|null} [o.result] the interpreted result frame, when one arrived
356
+ * @returns {{kind: string, ok: boolean, partial: boolean, userMessage: string|null}}
357
+ */
358
+ function classifyCommandCodeExit({ code, signal = null, result = null } = {}) {
359
+ // A signal kill never reaches the exit-code table (code is null). Report it
360
+ // as an interruption: this is the stop-requested and watchdog-kill path.
361
+ if (signal) {
362
+ return { kind: 'interrupted', ok: false, partial: false, userMessage: EXIT_CODES[130].message };
363
+ }
364
+
365
+ const entry = EXIT_CODES[code];
366
+ if (!entry) {
367
+ return {
368
+ kind: 'cli_error',
369
+ ok: false,
370
+ partial: false,
371
+ userMessage: `Command Code exited unexpectedly (code ${code}). Check the daemon log, then retry.`,
372
+ };
373
+ }
374
+
375
+ if (entry.kind === 'success') {
376
+ return { kind: 'success', ok: true, partial: false, userMessage: null };
377
+ }
378
+
379
+ // Prefer the CLI's own error text when it gave one — it is more specific than
380
+ // the generic per-code line, and it is already redacted by the interpreter.
381
+ const specific = result && result.error ? truncate(result.error, 400) : null;
382
+ return {
383
+ kind: entry.kind,
384
+ ok: false,
385
+ partial: !!entry.partial,
386
+ userMessage: specific || entry.message,
387
+ };
388
+ }
389
+
390
+ module.exports = {
391
+ COMMANDCODE_MIN_VERSION,
392
+ COMMANDCODE_TESTED_MAX_VERSION,
393
+ parseCommandCodeVersion,
394
+ compareVersions,
395
+ classifyCommandCodeVersion,
396
+ redactSecrets,
397
+ redactArgs,
398
+ truncate,
399
+ buildCommandCodeArgs,
400
+ parseFrame,
401
+ interpretCommandCodeFrame,
402
+ friendlyToolLabel,
403
+ classifyCommandCodeExit,
404
+ EXIT_CODES,
405
+ };