@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.
- package/icons/commandcode.svg +3 -0
- package/icons/kimi.svg +5 -10
- package/package.json +1 -1
- package/registry.json +261 -36
- package/src/adapters/base.js +19 -0
- package/src/adapters/codex.js +41 -1
- package/src/adapters/commandcode-stream.js +405 -0
- package/src/adapters/commandcode.js +853 -0
- package/src/adapters/hermes.js +56 -0
- package/src/adapters/index.js +4 -1
- package/src/adapters/kimi-stream.js +360 -0
- package/src/adapters/kimi.js +724 -13
- package/src/adapters/openclaw.js +28 -2
- package/src/adapters/opencode.js +86 -0
- package/src/adapters/pi-stream.js +28 -0
- package/src/adapters/pi.js +21 -2
- package/src/adapters/workspace-prompt.js +42 -8
- package/src/cli.js +182 -6
- package/src/config.js +13 -2
- package/src/daemon.js +192 -59
- package/src/env.js +22 -2
- package/src/index.js +13 -0
- package/src/installer.js +69 -1
- package/src/paths.js +315 -11
- package/src/probe.js +29 -8
- package/src/registry.js +16 -3
|
@@ -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;
|