@openagents-org/agent-launcher 0.2.173 → 0.2.175

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,853 @@
1
+ /**
2
+ * Command Code adapter for OpenAgents workspace.
3
+ *
4
+ * Bridges the Command Code CLI (https://commandcode.ai, `npm i -g command-code`)
5
+ * to an OpenAgents workspace:
6
+ * - polling loop + per-channel task dispatch (inherited from BaseAdapter)
7
+ * - one headless `command-code -p --output-format json` subprocess per user
8
+ * message; killing the process group stops the task
9
+ * - the NDJSON stream is parsed by commandcode-stream.js and mapped to the
10
+ * standard OpenAgents events (thinking / status / response / error)
11
+ * - real session continuity: the run's `sessionId` comes back on the result
12
+ * line and is replayed with `--resume` on the channel's next turn
13
+ * - workspace collaboration via a generated SKILL.md loaded with `--skill`
14
+ *
15
+ * Three CLI-specific decisions are worth knowing before editing this file:
16
+ *
17
+ * THE PROMPT IS PIPED, NOT PASSED. `-p` is given no value and the prompt goes
18
+ * in over stdin. A workspace turn is skill header + recap + user message,
19
+ * which routinely outgrows a Windows command line, and a quote inside a user
20
+ * message would otherwise be a command-line injection.
21
+ *
22
+ * THE BINARY IS NEVER `cmd`. The npm package installs four equivalent bins —
23
+ * `cmd`, `cmdc`, `command-code`, `commandcode` — and `cmd` is the Windows
24
+ * command shell. We resolve `command-code` (and only fall back to names that
25
+ * can't collide with a system shell), so a Windows agent never launches
26
+ * cmd.exe by accident.
27
+ *
28
+ * THE SKILL LIVES IN OUR OWN DIRECTORY. Command Code discovers skills from
29
+ * `.commandcode/skills/` in the project and `~/.commandcode/skills/` in the
30
+ * user's home. Writing to either would put a token-bearing file inside the
31
+ * user's repo (and into their commits) or into their personal config. The
32
+ * skill is written under ~/.openagents/ instead and loaded explicitly with
33
+ * `--skill`, which is documented to work alongside normal discovery.
34
+ *
35
+ * Minimum supported CLI is 1.0.0 (HARD gate) — the release that introduced
36
+ * `-p --output-format json`. A confirmed-older CLI refuses to start; an
37
+ * undetermined version proceeds leniently.
38
+ *
39
+ * Verified against the documented headless contract of command-code 1.36.0;
40
+ * newer releases are allowed and install by default.
41
+ */
42
+
43
+ 'use strict';
44
+
45
+ const fs = require('fs');
46
+ const os = require('os');
47
+ const path = require('path');
48
+ const { execFileSync, spawn } = require('child_process');
49
+
50
+ const BaseAdapter = require('./base');
51
+ const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
52
+ const { buildCommandCodeSkillMd, workspaceSkillName } = require('./workspace-prompt');
53
+ const { defaultAgentWorkdir, whichBinary, whereBinary, getEnhancedEnv } = require('../paths');
54
+ const {
55
+ buildCommandCodeArgs,
56
+ parseFrame,
57
+ interpretCommandCodeFrame,
58
+ classifyCommandCodeExit,
59
+ classifyCommandCodeVersion,
60
+ redactArgs,
61
+ redactSecrets,
62
+ truncate,
63
+ COMMANDCODE_MIN_VERSION,
64
+ } = require('./commandcode-stream');
65
+
66
+ const IS_WINDOWS = process.platform === 'win32';
67
+
68
+ // Max wall-clock for a single headless run, after which the process group is
69
+ // killed and the turn reported as interrupted.
70
+ const TIMEOUT_MS = 600000; // 10 minutes
71
+
72
+ // Idle watchdog: with no stdout frame for this long we nudge the channel, and
73
+ // after MAX consecutive silences we kill a run that is probably wedged.
74
+ const WATCHDOG_INTERVAL_MS = 15000;
75
+ const WATCHDOG_NUDGE_AT = 2; // ~30s of silence → "still working"
76
+ const WATCHDOG_MAX = 20; // ~5 min of silence → kill
77
+
78
+ // Command Code requires Node 22+. The launcher ships v22.x at
79
+ // ~/.openagents/nodejs, but a daemon running under an older interpreter must
80
+ // not silently pick that one to execute the CLI's .mjs entry point.
81
+ const MIN_NODE_MAJOR = 22;
82
+
83
+ // Cache window for the version / executability probe, so preflight never
84
+ // spawns `--version` more than once per window across channels.
85
+ const VERSION_PROBE_TTL_MS = 60000;
86
+
87
+ // Bin names the npm package installs, best first. `cmd` is deliberately
88
+ // ABSENT: on Windows it resolves to the system command shell, and there is no
89
+ // platform where we need it — the package points all four names at the same
90
+ // entry point.
91
+ const BIN_NAMES = ['command-code', 'commandcode', 'cmdc'];
92
+
93
+ // Unpinned on purpose: the adapter gates on a floor (COMMANDCODE_MIN_VERSION),
94
+ // not on one exact release, so the hint should get the user the newest CLI.
95
+ const INSTALL_HINT = 'npm install -g command-code';
96
+
97
+ class CommandCodeAdapter extends BaseAdapter {
98
+ /**
99
+ * @param {object} opts - BaseAdapter opts plus:
100
+ * @param {Set} [opts.disabledModules]
101
+ * @param {string} [opts.workingDir]
102
+ */
103
+ constructor(opts) {
104
+ super(opts);
105
+ this.disabledModules = opts.disabledModules || new Set();
106
+ // channel → { sessionId, workingDir }
107
+ this._channelSessions = {};
108
+ // channel → child process (the in-flight headless run)
109
+ this._channelProcesses = {};
110
+ this._stoppingChannels = new Set();
111
+ this._sessionsFile = path.join(
112
+ os.homedir(), '.openagents', 'sessions',
113
+ `${this.workspaceId}_${this.agentName}_commandcode.json`,
114
+ );
115
+ this._versionProbe = null; // { at, bin, version, supported }
116
+ this._loadSessions();
117
+ }
118
+
119
+ // ------------------------------------------------------------------
120
+ // Session persistence (Command Code session ids, bound to working dir)
121
+ // ------------------------------------------------------------------
122
+
123
+ _loadSessions() {
124
+ try {
125
+ if (fs.existsSync(this._sessionsFile)) {
126
+ const data = JSON.parse(fs.readFileSync(this._sessionsFile, 'utf-8'));
127
+ if (data && typeof data === 'object') {
128
+ Object.assign(this._channelSessions, data);
129
+ this._log(`Loaded ${Object.keys(data).length} Command Code session(s)`);
130
+ }
131
+ }
132
+ } catch {
133
+ this._log('Could not load Command Code sessions file, starting fresh');
134
+ }
135
+ }
136
+
137
+ _saveSessions() {
138
+ try {
139
+ fs.mkdirSync(path.dirname(this._sessionsFile), { recursive: true });
140
+ fs.writeFileSync(this._sessionsFile, JSON.stringify(this._channelSessions));
141
+ } catch {}
142
+ }
143
+
144
+ /**
145
+ * A resumable session id for this channel, or null.
146
+ *
147
+ * Command Code keys its session catalog by working directory, so a saved id
148
+ * from a different directory would not resolve — resume only when the
149
+ * directory still matches.
150
+ */
151
+ _resumableSession(channel, workingDir) {
152
+ const entry = this._channelSessions[channel];
153
+ if (!entry || !entry.sessionId) return null;
154
+ if (entry.workingDir && workingDir && entry.workingDir !== workingDir) return null;
155
+ return entry.sessionId;
156
+ }
157
+
158
+ _rememberSession(channel, sessionId, workingDir) {
159
+ if (!sessionId) return;
160
+ const prev = this._channelSessions[channel];
161
+ if (prev && prev.sessionId === sessionId && prev.workingDir === workingDir) return;
162
+ this._channelSessions[channel] = { sessionId, workingDir };
163
+ this._saveSessions();
164
+ }
165
+
166
+ _clearSession(channel) {
167
+ if (this._channelSessions[channel]) {
168
+ delete this._channelSessions[channel];
169
+ this._saveSessions();
170
+ }
171
+ }
172
+
173
+ // ------------------------------------------------------------------
174
+ // Control actions (stop / restart)
175
+ // ------------------------------------------------------------------
176
+
177
+ async _onControlAction(action, payload) {
178
+ if (action === 'stop') {
179
+ const channel = (payload && payload.channel) || null;
180
+ if (channel) {
181
+ const proc = this._channelProcesses[channel];
182
+ if (proc) {
183
+ this._stoppingChannels.add(channel);
184
+ await this._stopProcess(proc);
185
+ delete this._channelProcesses[channel];
186
+ delete this._channelQueues[channel];
187
+ try { await this.sendResponse(channel, 'Execution stopped.'); } catch {}
188
+ }
189
+ return;
190
+ }
191
+ await this._stopAllProcesses();
192
+ return;
193
+ }
194
+ return super._onControlAction(action, payload);
195
+ }
196
+
197
+ stop() {
198
+ super.stop();
199
+ void this._stopAllProcesses('Agent stopped.');
200
+ }
201
+
202
+ async _stopAllProcesses(message = 'Execution stopped.') {
203
+ const entries = Object.entries(this._channelProcesses);
204
+ if (!entries.length) return;
205
+ this._log(`Stopping ${entries.length} running Command Code process(es)...`);
206
+ for (const [channel, proc] of entries) {
207
+ this._stoppingChannels.add(channel);
208
+ await this._stopProcess(proc);
209
+ delete this._channelProcesses[channel];
210
+ delete this._channelQueues[channel];
211
+ try { await this.sendResponse(channel, message); } catch {}
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Stop a run gracefully, then forcefully.
217
+ *
218
+ * SIGINT first — Command Code documents exit code 130 for an interrupted run,
219
+ * so it handles the signal and commits the turn it was on rather than tearing
220
+ * down mid-write. Escalation targets the whole POSIX process group (the CLI
221
+ * spawns shell tools of its own) or `taskkill /T` on Windows.
222
+ */
223
+ async _stopProcess(proc) {
224
+ if (!proc || proc.exitCode !== null) return;
225
+ try {
226
+ if (IS_WINDOWS) {
227
+ try { proc.kill('SIGINT'); } catch {}
228
+ const exited = await this._waitExit(proc, 1500);
229
+ if (!exited) {
230
+ try { execFileSync('taskkill', ['/F', '/T', '/PID', String(proc.pid)], { timeout: 5000, windowsHide: true }); } catch {}
231
+ }
232
+ } else {
233
+ try { process.kill(-proc.pid, 'SIGINT'); } catch { try { proc.kill('SIGINT'); } catch {} }
234
+ let exited = await this._waitExit(proc, 1500);
235
+ if (!exited) {
236
+ try { process.kill(-proc.pid, 'SIGTERM'); } catch { try { proc.kill('SIGTERM'); } catch {} }
237
+ exited = await this._waitExit(proc, 1500);
238
+ }
239
+ if (!exited) {
240
+ try { process.kill(-proc.pid, 'SIGKILL'); } catch { try { proc.kill('SIGKILL'); } catch {} }
241
+ await this._waitExit(proc, 1000);
242
+ }
243
+ }
244
+ } catch {}
245
+ }
246
+
247
+ _waitExit(proc, ms) {
248
+ return new Promise((resolve) => {
249
+ if (proc.exitCode !== null) { resolve(true); return; }
250
+ const t = setTimeout(() => resolve(false), ms);
251
+ proc.once('exit', () => { clearTimeout(t); resolve(true); });
252
+ });
253
+ }
254
+ // ------------------------------------------------------------------
255
+ // Binary resolution (cross-platform)
256
+ // ------------------------------------------------------------------
257
+
258
+ /**
259
+ * Node interpreter used to execute the CLI's .mjs entry point.
260
+ *
261
+ * Command Code requires Node 22+. The launcher ships one at
262
+ * ~/.openagents/nodejs, so prefer it; only fall back to the interpreter
263
+ * running this daemon when it is itself new enough. Returning null means "no
264
+ * suitable Node" and the caller spawns the shim directly instead of guessing.
265
+ */
266
+ _findNodeBin() {
267
+ const home = os.homedir();
268
+ const candidates = IS_WINDOWS
269
+ ? [path.join(home, '.openagents', 'nodejs', 'node.exe')]
270
+ : [path.join(home, '.openagents', 'nodejs', 'node'),
271
+ path.join(home, '.openagents', 'nodejs', 'bin', 'node')];
272
+ for (const c of candidates) {
273
+ if (fs.existsSync(c) && this._nodeMajor(c) >= MIN_NODE_MAJOR) return c;
274
+ }
275
+ const ownMajor = parseInt(String(process.versions.node).split('.')[0], 10) || 0;
276
+ return ownMajor >= MIN_NODE_MAJOR ? process.execPath : null;
277
+ }
278
+
279
+ /** Major version of a Node binary, or 0 when it cannot be read. */
280
+ _nodeMajor(nodeBin) {
281
+ try {
282
+ const out = execFileSync(nodeBin, ['--version'], { encoding: 'utf-8', timeout: 5000, windowsHide: true });
283
+ const m = String(out).match(/v(\d+)\./);
284
+ return m ? parseInt(m[1], 10) : 0;
285
+ } catch {
286
+ return 0;
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Locate the Command Code CLI.
292
+ *
293
+ * Every tier iterates BIN_NAMES, which deliberately excludes `cmd` — see the
294
+ * file header. The isolated runtime prefix is checked first so the version
295
+ * the launcher installed wins over anything else on PATH.
296
+ */
297
+ _findCommandCodeBinary() {
298
+ const home = os.homedir();
299
+ const ext = IS_WINDOWS ? '.cmd' : '';
300
+
301
+ const prefixes = [
302
+ path.join(home, '.openagents', 'runtimes', 'commandcode', 'node_modules'),
303
+ path.join(home, '.openagents', 'nodejs', 'node_modules'),
304
+ ];
305
+
306
+ // Tier 0: npm shim inside an OpenAgents-managed prefix.
307
+ for (const root of prefixes) {
308
+ for (const name of BIN_NAMES) {
309
+ const c = path.join(root, '.bin', `${name}${ext}`);
310
+ if (fs.existsSync(c)) return c;
311
+ }
312
+ }
313
+
314
+ // Tier 0b: the package's own entry point. Running it under Node skips the
315
+ // shim entirely, which on Windows avoids cmd.exe's 8191-char command line.
316
+ for (const root of prefixes) {
317
+ const entry = path.join(root, 'command-code', 'dist', 'index.mjs');
318
+ if (fs.existsSync(entry)) return entry;
319
+ }
320
+
321
+ // Tier 1: PATH, via the codepage-safe lookup (a non-ASCII username would
322
+ // otherwise be mangled into an ENOENT) using the enriched PATH.
323
+ for (const name of BIN_NAMES) {
324
+ const viaWhere = whereBinary(name);
325
+ if (viaWhere) return viaWhere;
326
+ }
327
+
328
+ // Tier 2: next to the current Node interpreter (npm global installs).
329
+ for (const name of BIN_NAMES) {
330
+ const nearNode = path.join(path.dirname(process.execPath), `${name}${ext}`);
331
+ if (fs.existsSync(nearNode)) return nearNode;
332
+ }
333
+
334
+ // Tier 3: common install locations.
335
+ for (const name of BIN_NAMES) {
336
+ const candidates = IS_WINDOWS ? [
337
+ path.join(process.env.APPDATA || '', 'npm', `${name}.cmd`),
338
+ ] : [
339
+ path.join(home, '.local', 'bin', name),
340
+ path.join(home, '.npm-global', 'bin', name),
341
+ `/opt/homebrew/bin/${name}`,
342
+ `/usr/local/bin/${name}`,
343
+ ];
344
+ for (const c of candidates) if (fs.existsSync(c)) return c;
345
+ }
346
+
347
+ // Tier 4: deep scan of every known bin dir (nvm/fnm/volta/homebrew/…).
348
+ for (const name of BIN_NAMES) {
349
+ const viaWhich = whichBinary(name);
350
+ if (viaWhich) return viaWhich;
351
+ }
352
+
353
+ return null;
354
+ }
355
+
356
+ /**
357
+ * Resolve a shim to [nodeBin, jsEntry] so the JS entry point runs directly.
358
+ * Returns null when no suitable Node exists or the target isn't a JS entry,
359
+ * leaving the caller to spawn the shim as-is.
360
+ */
361
+ _resolveToNodeCmd(binPath) {
362
+ const nodeBin = this._findNodeBin();
363
+ if (!nodeBin) return null;
364
+
365
+ if (binPath.endsWith('.mjs') || binPath.endsWith('.js')) return [nodeBin, binPath];
366
+
367
+ if (IS_WINDOWS && binPath.toLowerCase().endsWith('.cmd')) {
368
+ try {
369
+ const cmdDir = path.dirname(path.resolve(binPath));
370
+ const content = fs.readFileSync(binPath, 'utf-8');
371
+ const jsMatch = content.match(/%dp0%\\([^\s"*?]+\.m?js)/i);
372
+ if (jsMatch) return [nodeBin, path.resolve(cmdDir, jsMatch[1])];
373
+ } catch {}
374
+ return null;
375
+ }
376
+
377
+ try {
378
+ let target = binPath;
379
+ if (fs.lstatSync(binPath).isSymbolicLink()) {
380
+ target = path.resolve(path.dirname(binPath), fs.readlinkSync(binPath));
381
+ }
382
+ if (target.endsWith('.mjs') || target.endsWith('.js')) return [nodeBin, target];
383
+ if (this._isNodeShebangScript(target)) return [nodeBin, target];
384
+ } catch {}
385
+ return null;
386
+ }
387
+
388
+ /** True when a file begins with a `#!...node` shebang. */
389
+ _isNodeShebangScript(filePath) {
390
+ try {
391
+ const fd = fs.openSync(filePath, 'r');
392
+ try {
393
+ const buf = Buffer.alloc(64);
394
+ const n = fs.readSync(fd, buf, 0, 64, 0);
395
+ const head = buf.slice(0, n).toString('utf-8');
396
+ return head.startsWith('#!') && /\bnode\b/.test(head.split('\n')[0]);
397
+ } finally {
398
+ fs.closeSync(fd);
399
+ }
400
+ } catch {
401
+ return false;
402
+ }
403
+ }
404
+
405
+ /** Resolve [cmd, ...args] for spawning, handling node-script wrappers. */
406
+ _spawnableCmd(binPath, args) {
407
+ const resolved = this._resolveToNodeCmd(binPath);
408
+ if (resolved) return [...resolved, ...args];
409
+ if (IS_WINDOWS && binPath.toLowerCase().endsWith('.cmd')) return ['cmd.exe', '/c', binPath, ...args];
410
+ return [binPath, ...args];
411
+ }
412
+
413
+ // ------------------------------------------------------------------
414
+ // Version preflight (cached; HARD minimum 1.0.0)
415
+ // ------------------------------------------------------------------
416
+
417
+ /** Run `--version` and return raw output. Isolated for testing. */
418
+ _readVersionRaw(bin) {
419
+ try {
420
+ const [cmd, ...args] = this._spawnableCmd(bin, ['--version']);
421
+ return execFileSync(cmd, args, {
422
+ encoding: 'utf-8',
423
+ timeout: 15000,
424
+ windowsHide: true,
425
+ env: getEnhancedEnv(this.agentEnv),
426
+ });
427
+ } catch (e) {
428
+ // A CLI that errors on --version still tells us its version sometimes.
429
+ return (e && (e.stdout || e.stderr)) ? String(e.stdout || e.stderr) : '';
430
+ }
431
+ }
432
+
433
+ /** Cached version verdict for a resolved binary. */
434
+ _checkVersion(bin) {
435
+ const now = Date.now();
436
+ if (this._versionProbe && this._versionProbe.bin === bin && now - this._versionProbe.at < VERSION_PROBE_TTL_MS) {
437
+ return this._versionProbe;
438
+ }
439
+ const verdict = classifyCommandCodeVersion(this._readVersionRaw(bin));
440
+ this._versionProbe = { ...verdict, bin, at: now };
441
+ return this._versionProbe;
442
+ }
443
+
444
+ // ------------------------------------------------------------------
445
+ // Workspace skill
446
+ // ------------------------------------------------------------------
447
+
448
+ /**
449
+ * Write the workspace SKILL.md and return the directory to pass to `--skill`.
450
+ *
451
+ * The file carries the workspace token, so it is written 0600 into an
452
+ * OpenAgents-owned tree — never into the user's project (where it would be
453
+ * committed) or `~/.commandcode/skills/` (their personal config). Returns
454
+ * null on failure; the run still proceeds, just without workspace tools.
455
+ */
456
+ _writeWorkspaceSkill(channel) {
457
+ try {
458
+ const skillName = workspaceSkillName(this.agentName);
459
+ const root = path.join(
460
+ os.homedir(), '.openagents', 'commandcode-skills',
461
+ `${this.workspaceId}_${this.agentName}`.replace(/[^A-Za-z0-9._-]/g, '_'),
462
+ );
463
+ const skillDir = path.join(root, skillName);
464
+ fs.mkdirSync(skillDir, { recursive: true });
465
+ const md = buildCommandCodeSkillMd({
466
+ endpoint: this.endpoint,
467
+ workspaceId: this.workspaceId,
468
+ token: this.token,
469
+ agentName: this.agentName,
470
+ channelName: channel,
471
+ disabledModules: this.disabledModules,
472
+ });
473
+ const file = path.join(skillDir, 'SKILL.md');
474
+ fs.writeFileSync(file, md, { mode: 0o600 });
475
+ try { fs.chmodSync(file, 0o600); } catch {}
476
+ return skillDir;
477
+ } catch (e) {
478
+ this._log(`Could not write the workspace skill: ${redactSecrets(e && e.message)}`);
479
+ return null;
480
+ }
481
+ }
482
+
483
+ // ------------------------------------------------------------------
484
+ // Prompt assembly
485
+ // ------------------------------------------------------------------
486
+
487
+ /**
488
+ * A compact header naming the agent, the channel and the mode. The workspace
489
+ * API reference is NOT inlined here — it lives in the skill, which Command
490
+ * Code activates on demand, keeping an ordinary turn cheap.
491
+ */
492
+ _contextHeader(channel) {
493
+ const skillName = workspaceSkillName(this.agentName);
494
+ const lines = [
495
+ `[OpenAgents workspace] You are "${this.agentName}", a coding agent in workspace channel "${channel}".`,
496
+ `Workspace collaboration (sharing files, the shared browser, other agents) is documented in the "${skillName}" skill — activate it when a request needs the workspace rather than the local project.`,
497
+ ];
498
+ if (this._mode === 'plan') {
499
+ lines.push('You are in PLAN mode: investigate and propose a plan; do not modify files.');
500
+ }
501
+ lines.push('Work in the current working directory. Reply concisely. The user request follows:');
502
+ return lines.join('\n');
503
+ }
504
+
505
+ /**
506
+ * Short transcript of recent channel chat, used to re-seed a fresh session.
507
+ * Only needed when there is no session to resume — Command Code already has
508
+ * the history in that case.
509
+ */
510
+ async _buildChannelRecap(channel, currentMessage) {
511
+ try {
512
+ const messages = await this.client.getRecentMessages(this.workspaceId, channel, this.token, 30);
513
+ if (!messages || messages.length === 0) return null;
514
+ const lines = [];
515
+ for (const m of messages) {
516
+ const mt = m.messageType || 'chat';
517
+ if (mt === 'status' || mt === 'thinking' || mt === 'loading' || mt === 'todos') continue;
518
+ const text = (m.content || '').trim();
519
+ if (!text || text === currentMessage) continue;
520
+ const who = m.senderType === 'human' ? (m.senderName || 'user') : (m.senderName || 'agent');
521
+ lines.push(`[${who}] ${truncate(text, 800)}`);
522
+ }
523
+ if (lines.length === 0) return null;
524
+ return 'Recent conversation in this channel for context:\n\n' + lines.slice(-12).join('\n');
525
+ } catch {
526
+ return null;
527
+ }
528
+ }
529
+
530
+ // ------------------------------------------------------------------
531
+ // Run
532
+ // ------------------------------------------------------------------
533
+
534
+ /**
535
+ * Run one headless turn and consume its NDJSON stream.
536
+ *
537
+ * The prompt goes in over stdin and the reply comes out of the single
538
+ * `result` line — event frames only drive the live ticker, so a new upstream
539
+ * event type costs a progress update, never the answer.
540
+ *
541
+ * @returns {Promise<object>} { code, signal, result, anyOutput, userStopped, stderrSessionId }
542
+ */
543
+ _runCommandCode(channel, bin, args, workingDir, prompt) {
544
+ return new Promise((resolve) => {
545
+ const [cmd, ...spawnArgs] = this._spawnableCmd(bin, args);
546
+ this._log(`Spawning: ${path.basename(cmd)} ${redactArgs(spawnArgs).join(' ')} (cwd=${workingDir})`);
547
+
548
+ let proc;
549
+ try {
550
+ proc = spawn(cmd, spawnArgs, {
551
+ cwd: workingDir,
552
+ env: {
553
+ ...getEnhancedEnv(this.agentEnv),
554
+ // A background self-update would swap the CLI under a running
555
+ // daemon, moving the JSON contract this adapter parses.
556
+ COMMANDCODE_SKIP_UPDATES: '1',
557
+ },
558
+ stdio: ['pipe', 'pipe', 'pipe'],
559
+ // Own process group so the CLI's own shell tools die with it.
560
+ detached: !IS_WINDOWS,
561
+ windowsHide: true,
562
+ });
563
+ } catch (e) {
564
+ resolve({ code: null, signal: null, result: null, anyOutput: false, userStopped: false, spawnError: e && e.message });
565
+ return;
566
+ }
567
+
568
+ this._channelProcesses[channel] = proc;
569
+
570
+ let settled = false;
571
+ let stdoutBuf = '';
572
+ let stderrBuf = '';
573
+ let anyOutput = false;
574
+ let result = null;
575
+ let lastFrameAt = Date.now();
576
+ let silences = 0;
577
+ let lastToolLabel = null;
578
+ let killedByWatchdog = false;
579
+
580
+ const finish = (payload) => {
581
+ if (settled) return;
582
+ settled = true;
583
+ clearInterval(watchdog);
584
+ clearTimeout(timeout);
585
+ if (this._channelProcesses[channel] === proc) delete this._channelProcesses[channel];
586
+ resolve(payload);
587
+ };
588
+
589
+ const onFrame = (line) => {
590
+ const frame = parseFrame(line);
591
+ if (!frame) return;
592
+ lastFrameAt = Date.now();
593
+ silences = 0;
594
+ anyOutput = true;
595
+
596
+ const ev = interpretCommandCodeFrame(frame);
597
+ if (ev.kind === 'result') {
598
+ result = ev;
599
+ return;
600
+ }
601
+ if (ev.kind === 'tool') {
602
+ // Only announce a CHANGE of activity: a run touching thirty files
603
+ // would otherwise post thirty near-identical status lines.
604
+ if (ev.state === 'running' && ev.label && ev.label !== lastToolLabel) {
605
+ lastToolLabel = ev.label;
606
+ const detail = ev.description ? `${ev.label} — ${ev.description}` : `${ev.label}...`;
607
+ void this.sendStatus(channel, detail).catch(() => {});
608
+ }
609
+ return;
610
+ }
611
+ if (ev.kind === 'error') {
612
+ this._log(`Run error: ${ev.message}`);
613
+ return;
614
+ }
615
+ if (ev.kind === 'unknown') {
616
+ this._log(`Unrecognized frame (ignored): ${ev.raw}`);
617
+ }
618
+ };
619
+
620
+ proc.stdout.on('data', (chunk) => {
621
+ stdoutBuf += chunk.toString('utf-8');
622
+ let nl;
623
+ while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
624
+ const line = stdoutBuf.slice(0, nl);
625
+ stdoutBuf = stdoutBuf.slice(nl + 1);
626
+ try { onFrame(line); } catch (e) { this._log(`Frame handling failed: ${redactSecrets(e && e.message)}`); }
627
+ }
628
+ });
629
+
630
+ proc.stderr.on('data', (chunk) => {
631
+ // Bounded: stderr carries progress under --verbose and must not grow
632
+ // without limit on a long run.
633
+ stderrBuf = truncate(stderrBuf + chunk.toString('utf-8'), 8000);
634
+ });
635
+
636
+ proc.on('error', (e) => {
637
+ finish({ code: null, signal: null, result, anyOutput, userStopped: false, spawnError: e && e.message });
638
+ });
639
+
640
+ proc.on('close', (code, signal) => {
641
+ // Flush a trailing line with no newline (the result line, if the CLI
642
+ // exits without one).
643
+ if (stdoutBuf.trim()) { try { onFrame(stdoutBuf); } catch {} }
644
+ const userStopped = this._stoppingChannels.has(channel);
645
+ this._stoppingChannels.delete(channel);
646
+ finish({
647
+ code,
648
+ signal: killedByWatchdog ? 'SIGKILL' : signal,
649
+ result,
650
+ anyOutput,
651
+ userStopped,
652
+ stderrSessionId: this._sessionIdFromStderr(stderrBuf),
653
+ stderr: redactSecrets(stderrBuf),
654
+ });
655
+ });
656
+
657
+ const watchdog = setInterval(() => {
658
+ if (Date.now() - lastFrameAt < WATCHDOG_INTERVAL_MS) return;
659
+ silences += 1;
660
+ if (silences === WATCHDOG_NUDGE_AT) {
661
+ void this.sendStatus(channel, 'still working...').catch(() => {});
662
+ }
663
+ if (silences >= WATCHDOG_MAX) {
664
+ this._log('No output for ~5 minutes — killing a wedged run');
665
+ killedByWatchdog = true;
666
+ void this._stopProcess(proc);
667
+ }
668
+ }, WATCHDOG_INTERVAL_MS);
669
+
670
+ const timeout = setTimeout(() => {
671
+ this._log(`Run exceeded ${TIMEOUT_MS}ms — killing`);
672
+ killedByWatchdog = true;
673
+ void this._stopProcess(proc);
674
+ }, TIMEOUT_MS);
675
+
676
+ // Feed the prompt. stdin is closed immediately: Command Code times out a
677
+ // piped stdin after 30s of silence, so an unclosed pipe would stall.
678
+ try {
679
+ proc.stdin.on('error', () => {});
680
+ proc.stdin.write(prompt, 'utf-8');
681
+ proc.stdin.end();
682
+ } catch (e) {
683
+ this._log(`Could not write the prompt to stdin: ${redactSecrets(e && e.message)}`);
684
+ }
685
+ });
686
+ }
687
+
688
+ /**
689
+ * Recover the session id from `--verbose` stderr.
690
+ *
691
+ * The result line's `sessionId` is documented as OPTIONAL — absent exactly
692
+ * when a run fails early — so this is the fallback that keeps a channel
693
+ * resumable after a failed turn.
694
+ */
695
+ _sessionIdFromStderr(stderr) {
696
+ // Deliberately format-agnostic: match the token after `session:` rather
697
+ // than assuming a hex UUID. Pinning the shape to today's id format would
698
+ // silently stop recovering ids the day upstream changes it — and this is
699
+ // the fallback that runs precisely when the result line already failed us.
700
+ const m = String(stderr || '').match(/\bsession:\s*(\S{6,})/i);
701
+ return m ? m[1].trim() : null;
702
+ }
703
+
704
+ _dirExists(p) {
705
+ try { return fs.statSync(p).isDirectory(); } catch { return false; }
706
+ }
707
+
708
+ // ------------------------------------------------------------------
709
+ // Message handling
710
+ // ------------------------------------------------------------------
711
+
712
+ async _handleMessage(msg) {
713
+ let content = (msg.content || '').trim();
714
+ const attachments = msg.attachments || [];
715
+ const attText = formatAttachmentsForPrompt(attachments, 'skills');
716
+ if (attText) content = content ? content + attText : attText.trim();
717
+ if (!content) return;
718
+
719
+ const channel = msg.sessionId || this.channelName;
720
+ this._stoppingChannels.delete(channel);
721
+ const sender = msg.senderName || msg.senderType || 'user';
722
+ this._log(`Processing message from ${sender} in ${channel}: ${redactSecrets(truncate(content, 80))}`);
723
+
724
+ // Resolve the working directory up front. Never silently fall back to the
725
+ // launcher/repo dir — a wrong cwd is a destructive surprise, not a default.
726
+ const workingDir = this.workingDir || defaultAgentWorkdir(this.agentName);
727
+ if (this.workingDir && !this._dirExists(this.workingDir)) {
728
+ await this.sendError(channel, `Working directory does not exist: ${this.workingDir}`);
729
+ return;
730
+ }
731
+
732
+ const bin = this._findCommandCodeBinary();
733
+ if (!bin) {
734
+ await this.sendError(channel, `Command Code CLI not found. Install it with: ${INSTALL_HINT}`);
735
+ return;
736
+ }
737
+
738
+ // HARD minimum gate. A confirmed-older CLI has no JSON stream to parse; an
739
+ // undetermined version (supported === null) proceeds leniently.
740
+ const ver = this._checkVersion(bin);
741
+ if (ver.supported === false) {
742
+ this._log(`Refusing to start: Command Code ${ver.version} < minimum ${COMMANDCODE_MIN_VERSION}`);
743
+ await this.sendError(channel,
744
+ `Command Code CLI ${ver.version} is below the minimum supported version ${COMMANDCODE_MIN_VERSION} ` +
745
+ `(the release that added headless JSON output). Upgrade with: ${INSTALL_HINT}`);
746
+ return;
747
+ }
748
+ if (ver.supported === null) {
749
+ this._log('Could not determine the Command Code version — proceeding leniently');
750
+ }
751
+
752
+ // Auto-title + resume-from on first encounter (parity with other adapters).
753
+ if (!this._titledSessions.has(channel)) {
754
+ this._titledSessions.add(channel);
755
+ try {
756
+ const info = await this.client.getSession(this.workspaceId, channel, this.token);
757
+ const resumeFrom = info.resumeFrom;
758
+ if (resumeFrom && !this._channelSessions[channel] && this._channelSessions[resumeFrom]) {
759
+ this._channelSessions[channel] = { ...this._channelSessions[resumeFrom] };
760
+ this._saveSessions();
761
+ }
762
+ const title = generateSessionTitle(content);
763
+ if (title && !info.titleManuallySet && SESSION_DEFAULT_RE.test(info.title || '')) {
764
+ await this.client.updateSession(this.workspaceId, channel, this.token, { title, autoTitle: true });
765
+ }
766
+ } catch {}
767
+ }
768
+
769
+ await this.sendStatus(channel, 'thinking...');
770
+
771
+ const skillDir = this._writeWorkspaceSkill(channel);
772
+ const model = (this.workspaceModel || this.agentEnv.COMMANDCODE_MODEL || '').trim();
773
+ const effort = (this.agentEnv.COMMANDCODE_EFFORT || '').trim();
774
+ const maxTurns = parseInt(this.agentEnv.COMMANDCODE_MAX_TURNS || '', 10);
775
+
776
+ // One retry: a stale session id (its transcript pruned, or the directory
777
+ // re-keyed) fails the resume, and the turn is worth re-running fresh.
778
+ for (let attempt = 0; attempt < 2; attempt++) {
779
+ const resumeId = attempt === 0 ? this._resumableSession(channel, workingDir) : null;
780
+
781
+ // Resuming → the CLI already holds the history, so send the bare turn.
782
+ // Fresh → prepend the context header and a recap of the channel.
783
+ let prompt;
784
+ if (resumeId) {
785
+ prompt = content;
786
+ } else {
787
+ const header = this._contextHeader(channel);
788
+ const recap = await this._buildChannelRecap(channel, content);
789
+ prompt = recap ? `${header}\n\n${recap}\n\n---\n\n${content}` : `${header}\n\n${content}`;
790
+ }
791
+
792
+ const args = buildCommandCodeArgs({
793
+ model,
794
+ effort,
795
+ maxTurns: Number.isFinite(maxTurns) ? maxTurns : undefined,
796
+ resumeSessionId: resumeId || undefined,
797
+ planMode: this._mode === 'plan',
798
+ });
799
+ if (skillDir) args.push('--skill', skillDir);
800
+
801
+ const run = await this._runCommandCode(channel, bin, args, workingDir, prompt);
802
+
803
+ if (run.userStopped) return;
804
+
805
+ if (run.spawnError) {
806
+ await this.sendError(channel,
807
+ `Command Code CLI could not be started (${redactSecrets(run.spawnError)}). Reinstall it with: ${INSTALL_HINT}`);
808
+ return;
809
+ }
810
+
811
+ const verdict = classifyCommandCodeExit(run);
812
+
813
+ // A resume that produced nothing usable is the stale-session case: drop
814
+ // the binding and retry once from scratch.
815
+ if (resumeId && !verdict.ok && !verdict.partial && !run.anyOutput && attempt === 0) {
816
+ this._log(`Resume of session ${resumeId} produced nothing — clearing and retrying fresh`);
817
+ this._clearSession(channel);
818
+ continue;
819
+ }
820
+
821
+ // Persist the session id for the next turn, preferring the result line
822
+ // and falling back to what --verbose printed to stderr.
823
+ const sessionId = (run.result && run.result.sessionId) || run.stderrSessionId || resumeId;
824
+ this._rememberSession(channel, sessionId, workingDir);
825
+
826
+ const finalText = run.result && run.result.finalText ? run.result.finalText.trim() : '';
827
+
828
+ if (finalText) {
829
+ // A partial answer (turn limit) is still an answer — deliver it, with
830
+ // the reason it stopped appended rather than replacing it.
831
+ const body = verdict.partial && verdict.userMessage
832
+ ? `${finalText}\n\n_${verdict.userMessage}_`
833
+ : finalText;
834
+ try { await this.sendResponse(channel, body); } catch {}
835
+ if (!verdict.ok && !verdict.partial) {
836
+ this._log(`Run reported ${verdict.kind} but returned text; text delivered`);
837
+ }
838
+ return;
839
+ }
840
+
841
+ if (!verdict.ok) {
842
+ try { await this.sendError(channel, verdict.userMessage); } catch {}
843
+ return;
844
+ }
845
+
846
+ try { await this.sendResponse(channel, 'No response generated. Please try again.'); } catch {}
847
+ return;
848
+ }
849
+ }
850
+
851
+ }
852
+
853
+ module.exports = CommandCodeAdapter;