@openagents-org/agent-launcher 0.2.175 → 0.2.177
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/icons/codebuddy.svg +3 -0
- package/icons/kimi.svg +5 -10
- package/icons/openworker.svg +3 -0
- package/package.json +1 -1
- package/registry.json +271 -1
- package/src/adapters/codebuddy-stream.js +497 -0
- package/src/adapters/codebuddy.js +913 -0
- package/src/adapters/index.js +6 -1
- package/src/adapters/openworker-runtime.js +572 -0
- package/src/adapters/openworker.js +921 -0
- package/src/adapters/workspace-prompt.js +33 -0
- package/src/install-preflight.js +84 -6
- package/src/paths.js +20 -8
- package/src/registry.js +12 -2
|
@@ -0,0 +1,913 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CodeBuddy Code adapter for OpenAgents workspace.
|
|
3
|
+
*
|
|
4
|
+
* Bridges Tencent's CodeBuddy Code CLI (`npm i -g @tencent-ai/codebuddy-code`)
|
|
5
|
+
* to an OpenAgents workspace:
|
|
6
|
+
* - polling loop + per-channel task dispatch (inherited from BaseAdapter)
|
|
7
|
+
* - one headless `codebuddy -p --output-format stream-json` run per user
|
|
8
|
+
* message; killing the process group stops the task
|
|
9
|
+
* - workspace tools over MCP, the same stdio server the Claude adapter wires
|
|
10
|
+
* up, pointed at with `--mcp-config`
|
|
11
|
+
* - session continuity per channel: the CLI's `session_id` comes back on the
|
|
12
|
+
* init frame and is replayed with `--resume` on the channel's next turn
|
|
13
|
+
*
|
|
14
|
+
* WHY THIS IS THE WORKBUDDY INTEGRATION. WorkBuddy (workbuddy.ai /
|
|
15
|
+
* workbuddy.cn) is a desktop application with no command line and no public
|
|
16
|
+
* API of its own — but its agent engine IS this CLI: the desktop app spawns
|
|
17
|
+
* `codebuddy --serve` in a sidecar and talks to it over local ACP. Driving the
|
|
18
|
+
* published CLI directly gets the same engine and the same account, without
|
|
19
|
+
* reverse-engineering a private port that only exists while a GUI is open.
|
|
20
|
+
* What it does NOT get is WorkBuddy's bundled office skills, which ship inside
|
|
21
|
+
* the desktop app rather than the npm package — so this agent is the coding /
|
|
22
|
+
* general terminal agent, not the deck-and-spreadsheet one.
|
|
23
|
+
*
|
|
24
|
+
* Three CLI-specific decisions are worth knowing before editing this file:
|
|
25
|
+
*
|
|
26
|
+
* THE EXIT CODE MEANS NOTHING. A run whose model call failed still exits 0
|
|
27
|
+
* and reports the failure only inside the result frame. Verdicts come from
|
|
28
|
+
* classifyCodeBuddyRun(), which reads that frame first; treating exit 0 as
|
|
29
|
+
* success would post an empty answer over a 401.
|
|
30
|
+
*
|
|
31
|
+
* THE PROMPT IS PIPED, NOT PASSED. `-p` takes no value and the turn goes in
|
|
32
|
+
* over stdin, so a long briefing plus recap can never hit the argv limit and
|
|
33
|
+
* a quote in a user message is not a command-line injection. The system
|
|
34
|
+
* prompt still rides in argv (the CLI has no other hook for it), which is why
|
|
35
|
+
* shims are resolved to their JS entry point before spawning — wrapping them
|
|
36
|
+
* in `cmd.exe /c` would cap the command line at 8191 characters.
|
|
37
|
+
*
|
|
38
|
+
* TWO WAYS IN, ONE FAILURE. An agent authenticates either with
|
|
39
|
+
* CODEBUDDY_API_KEY / CODEBUDDY_AUTH_TOKEN in its environment or with a
|
|
40
|
+
* `/login` session on disk, and from out here a signed-out CLI and a bad key
|
|
41
|
+
* look identical (`errors_info[].category === "auth"`). The auth message
|
|
42
|
+
* names both paths rather than guessing which one the user meant.
|
|
43
|
+
*
|
|
44
|
+
* Verified end to end against codebuddy 2.142.0.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
'use strict';
|
|
48
|
+
|
|
49
|
+
const fs = require('fs');
|
|
50
|
+
const os = require('os');
|
|
51
|
+
const path = require('path');
|
|
52
|
+
const { execFileSync, spawn } = require('child_process');
|
|
53
|
+
|
|
54
|
+
const BaseAdapter = require('./base');
|
|
55
|
+
const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
|
|
56
|
+
const { buildClaudeSystemPrompt } = require('./workspace-prompt');
|
|
57
|
+
const { defaultAgentWorkdir, whichBinary, whereBinary, getEnhancedEnv } = require('../paths');
|
|
58
|
+
const {
|
|
59
|
+
buildCodeBuddyArgs,
|
|
60
|
+
classifyCodeBuddyRun,
|
|
61
|
+
classifyCodeBuddyVersion,
|
|
62
|
+
interpretCodeBuddyFrame,
|
|
63
|
+
parseFrame,
|
|
64
|
+
redactArgs,
|
|
65
|
+
redactSecrets,
|
|
66
|
+
resolveCodeBuddyEnv,
|
|
67
|
+
truncate,
|
|
68
|
+
CODEBUDDY_MIN_VERSION,
|
|
69
|
+
} = require('./codebuddy-stream');
|
|
70
|
+
|
|
71
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
72
|
+
|
|
73
|
+
// Max wall-clock for a single headless run, after which the process group is
|
|
74
|
+
// killed and the turn reported as interrupted.
|
|
75
|
+
const TIMEOUT_MS = 600000; // 10 minutes
|
|
76
|
+
|
|
77
|
+
// Idle watchdog: with no stdout frame for this long we nudge the channel, and
|
|
78
|
+
// after MAX consecutive silences we kill a run that is probably wedged.
|
|
79
|
+
const WATCHDOG_INTERVAL_MS = 15000;
|
|
80
|
+
const WATCHDOG_NUDGE_AT = 2; // ~30s of silence → "still working"
|
|
81
|
+
const WATCHDOG_MAX = 20; // ~5 min of silence → kill
|
|
82
|
+
|
|
83
|
+
// The CLI's own floor (its launcher refuses to start below this), so an
|
|
84
|
+
// interpreter under it cannot run the agent at all.
|
|
85
|
+
const MIN_NODE_MAJOR = 18;
|
|
86
|
+
|
|
87
|
+
// Cache window for the version / executability probe, so preflight never
|
|
88
|
+
// spawns `--version` more than once per window across channels.
|
|
89
|
+
const VERSION_PROBE_TTL_MS = 60000;
|
|
90
|
+
|
|
91
|
+
// Bin names the npm package installs, best first. All four point at the same
|
|
92
|
+
// entry point; `cbc` is last because it is the one most likely to collide with
|
|
93
|
+
// something else a user put on PATH.
|
|
94
|
+
const BIN_NAMES = ['codebuddy', 'codebuddy-code', 'cbc'];
|
|
95
|
+
|
|
96
|
+
const INSTALL_HINT = 'npm install -g @tencent-ai/codebuddy-code';
|
|
97
|
+
|
|
98
|
+
class CodeBuddyAdapter extends BaseAdapter {
|
|
99
|
+
/**
|
|
100
|
+
* @param {object} opts - BaseAdapter opts plus:
|
|
101
|
+
* @param {Set} [opts.disabledModules]
|
|
102
|
+
* @param {string} [opts.workingDir]
|
|
103
|
+
*/
|
|
104
|
+
constructor(opts) {
|
|
105
|
+
super(opts);
|
|
106
|
+
this.disabledModules = opts.disabledModules || new Set();
|
|
107
|
+
// channel → { sessionId, workingDir }
|
|
108
|
+
this._channelSessions = {};
|
|
109
|
+
// channel → child process (the in-flight headless run)
|
|
110
|
+
this._channelProcesses = {};
|
|
111
|
+
this._stoppingChannels = new Set();
|
|
112
|
+
this._sessionsFile = path.join(
|
|
113
|
+
os.homedir(), '.openagents', 'sessions',
|
|
114
|
+
`${this.workspaceId}_${this.agentName}_codebuddy.json`,
|
|
115
|
+
);
|
|
116
|
+
this._versionProbe = null; // { at, bin, version, supported }
|
|
117
|
+
this._loadSessions();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ------------------------------------------------------------------
|
|
121
|
+
// Session persistence (CodeBuddy session ids, bound to working dir)
|
|
122
|
+
// ------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
_loadSessions() {
|
|
125
|
+
try {
|
|
126
|
+
if (fs.existsSync(this._sessionsFile)) {
|
|
127
|
+
const data = JSON.parse(fs.readFileSync(this._sessionsFile, 'utf-8'));
|
|
128
|
+
if (data && typeof data === 'object') {
|
|
129
|
+
Object.assign(this._channelSessions, data);
|
|
130
|
+
this._log(`Loaded ${Object.keys(data).length} CodeBuddy session(s)`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
this._log('Could not load CodeBuddy sessions file, starting fresh');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
_saveSessions() {
|
|
139
|
+
try {
|
|
140
|
+
fs.mkdirSync(path.dirname(this._sessionsFile), { recursive: true });
|
|
141
|
+
fs.writeFileSync(this._sessionsFile, JSON.stringify(this._channelSessions));
|
|
142
|
+
} catch {}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A resumable session id for this channel, or null.
|
|
147
|
+
*
|
|
148
|
+
* CodeBuddy stores a session's transcript against the directory it ran in, so
|
|
149
|
+
* a saved id from a different directory would not resolve — resume only when
|
|
150
|
+
* the directory still matches.
|
|
151
|
+
*/
|
|
152
|
+
_resumableSession(channel, workingDir) {
|
|
153
|
+
const entry = this._channelSessions[channel];
|
|
154
|
+
if (!entry || !entry.sessionId) return null;
|
|
155
|
+
if (entry.workingDir && workingDir && entry.workingDir !== workingDir) return null;
|
|
156
|
+
return entry.sessionId;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
_rememberSession(channel, sessionId, workingDir) {
|
|
160
|
+
if (!sessionId) return;
|
|
161
|
+
const prev = this._channelSessions[channel];
|
|
162
|
+
if (prev && prev.sessionId === sessionId && prev.workingDir === workingDir) return;
|
|
163
|
+
this._channelSessions[channel] = { sessionId, workingDir };
|
|
164
|
+
this._saveSessions();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
_clearSession(channel) {
|
|
168
|
+
if (this._channelSessions[channel]) {
|
|
169
|
+
delete this._channelSessions[channel];
|
|
170
|
+
this._saveSessions();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ------------------------------------------------------------------
|
|
175
|
+
// Control actions (stop / restart)
|
|
176
|
+
// ------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
async _onControlAction(action, payload) {
|
|
179
|
+
if (action === 'stop') {
|
|
180
|
+
const channel = (payload && payload.channel) || null;
|
|
181
|
+
if (channel) {
|
|
182
|
+
const proc = this._channelProcesses[channel];
|
|
183
|
+
if (proc) {
|
|
184
|
+
this._stoppingChannels.add(channel);
|
|
185
|
+
await this._stopProcess(proc);
|
|
186
|
+
delete this._channelProcesses[channel];
|
|
187
|
+
delete this._channelQueues[channel];
|
|
188
|
+
try { await this.sendResponse(channel, 'Execution stopped.'); } catch {}
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
await this._stopAllProcesses();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
return super._onControlAction(action, payload);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
stop() {
|
|
199
|
+
super.stop();
|
|
200
|
+
void this._stopAllProcesses('Agent stopped.');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async _stopAllProcesses(message = 'Execution stopped.') {
|
|
204
|
+
const entries = Object.entries(this._channelProcesses);
|
|
205
|
+
if (!entries.length) return;
|
|
206
|
+
this._log(`Stopping ${entries.length} running CodeBuddy process(es)...`);
|
|
207
|
+
for (const [channel, proc] of entries) {
|
|
208
|
+
this._stoppingChannels.add(channel);
|
|
209
|
+
await this._stopProcess(proc);
|
|
210
|
+
delete this._channelProcesses[channel];
|
|
211
|
+
delete this._channelQueues[channel];
|
|
212
|
+
try { await this.sendResponse(channel, message); } catch {}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Stop a run gracefully, then forcefully.
|
|
218
|
+
*
|
|
219
|
+
* SIGINT first, which the CLI handles by finishing the turn it is on rather
|
|
220
|
+
* than tearing down mid-write. Escalation targets the whole POSIX process
|
|
221
|
+
* group (the CLI 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
|
+
// ------------------------------------------------------------------
|
|
256
|
+
// Binary resolution (cross-platform)
|
|
257
|
+
// ------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Node interpreter used to execute the CLI's JS entry point.
|
|
261
|
+
*
|
|
262
|
+
* Prefer the launcher's managed runtime, and fall back to the interpreter
|
|
263
|
+
* running this daemon when it clears the CLI's own Node floor. Returning null
|
|
264
|
+
* means "no suitable Node" and the caller spawns the shim directly instead of
|
|
265
|
+
* guessing.
|
|
266
|
+
*/
|
|
267
|
+
_findNodeBin() {
|
|
268
|
+
const home = os.homedir();
|
|
269
|
+
const candidates = IS_WINDOWS
|
|
270
|
+
? [path.join(home, '.openagents', 'nodejs', 'node.exe')]
|
|
271
|
+
: [path.join(home, '.openagents', 'nodejs', 'node'),
|
|
272
|
+
path.join(home, '.openagents', 'nodejs', 'bin', 'node')];
|
|
273
|
+
for (const c of candidates) {
|
|
274
|
+
if (fs.existsSync(c) && this._nodeMajor(c) >= MIN_NODE_MAJOR) return c;
|
|
275
|
+
}
|
|
276
|
+
const ownMajor = parseInt(String(process.versions.node).split('.')[0], 10) || 0;
|
|
277
|
+
return ownMajor >= MIN_NODE_MAJOR ? process.execPath : null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Major version of a Node binary, or 0 when it cannot be read. */
|
|
281
|
+
_nodeMajor(nodeBin) {
|
|
282
|
+
try {
|
|
283
|
+
const out = execFileSync(nodeBin, ['--version'], { encoding: 'utf-8', timeout: 5000, windowsHide: true });
|
|
284
|
+
const m = String(out).match(/v(\d+)\./);
|
|
285
|
+
return m ? parseInt(m[1], 10) : 0;
|
|
286
|
+
} catch {
|
|
287
|
+
return 0;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Locate the CodeBuddy CLI.
|
|
293
|
+
*
|
|
294
|
+
* The isolated runtime prefix is checked first so the version the launcher
|
|
295
|
+
* installed wins over anything else on PATH. Tier 3 also covers the native
|
|
296
|
+
* installer (`codebuddy.cn/cli/install.sh`), which drops a standalone binary
|
|
297
|
+
* under ~/.codebuddy rather than into a Node prefix.
|
|
298
|
+
*/
|
|
299
|
+
_findCodeBuddyBinary() {
|
|
300
|
+
const home = os.homedir();
|
|
301
|
+
const ext = IS_WINDOWS ? '.cmd' : '';
|
|
302
|
+
|
|
303
|
+
const prefixes = [
|
|
304
|
+
path.join(home, '.openagents', 'runtimes', 'codebuddy', 'node_modules'),
|
|
305
|
+
path.join(home, '.openagents', 'nodejs', 'node_modules'),
|
|
306
|
+
];
|
|
307
|
+
|
|
308
|
+
// Tier 0: npm shim inside an OpenAgents-managed prefix.
|
|
309
|
+
for (const root of prefixes) {
|
|
310
|
+
for (const name of BIN_NAMES) {
|
|
311
|
+
const c = path.join(root, '.bin', `${name}${ext}`);
|
|
312
|
+
if (fs.existsSync(c)) return c;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Tier 0b: the package's own entry point. Running it under Node skips the
|
|
317
|
+
// shim entirely, which on Windows avoids cmd.exe's 8191-char command line —
|
|
318
|
+
// and the system prompt this adapter passes in argv is kilobytes long.
|
|
319
|
+
for (const root of prefixes) {
|
|
320
|
+
const entry = path.join(root, '@tencent-ai', 'codebuddy-code', 'bin', 'codebuddy');
|
|
321
|
+
if (fs.existsSync(entry)) return entry;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Tier 1: PATH, via the codepage-safe lookup (a non-ASCII username would
|
|
325
|
+
// otherwise be mangled into an ENOENT) using the enriched PATH.
|
|
326
|
+
for (const name of BIN_NAMES) {
|
|
327
|
+
const viaWhere = whereBinary(name);
|
|
328
|
+
if (viaWhere) return viaWhere;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Tier 2: next to the current Node interpreter (npm global installs).
|
|
332
|
+
for (const name of BIN_NAMES) {
|
|
333
|
+
const nearNode = path.join(path.dirname(process.execPath), `${name}${ext}`);
|
|
334
|
+
if (fs.existsSync(nearNode)) return nearNode;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Tier 3: common install locations. The standalone build (the `install.sh`
|
|
338
|
+
// / `install.ps1` route, which needs no Node at all) puts itself on PATH
|
|
339
|
+
// rather than in a documented directory, so the plausible homes are listed
|
|
340
|
+
// here and the PATH lookups above and below are what actually catch it.
|
|
341
|
+
for (const name of BIN_NAMES) {
|
|
342
|
+
const candidates = IS_WINDOWS ? [
|
|
343
|
+
path.join(process.env.APPDATA || '', 'npm', `${name}.cmd`),
|
|
344
|
+
path.join(process.env.LOCALAPPDATA || '', 'CodeBuddy', 'bin', `${name}.exe`),
|
|
345
|
+
] : [
|
|
346
|
+
path.join(home, '.codebuddy', 'bin', name),
|
|
347
|
+
path.join(home, '.local', 'bin', name),
|
|
348
|
+
path.join(home, '.npm-global', 'bin', name),
|
|
349
|
+
`/opt/homebrew/bin/${name}`,
|
|
350
|
+
`/usr/local/bin/${name}`,
|
|
351
|
+
];
|
|
352
|
+
for (const c of candidates) if (fs.existsSync(c)) return c;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Tier 4: deep scan of every known bin dir (nvm/fnm/volta/homebrew/…).
|
|
356
|
+
for (const name of BIN_NAMES) {
|
|
357
|
+
const viaWhich = whichBinary(name);
|
|
358
|
+
if (viaWhich) return viaWhich;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Resolve a shim to [nodeBin, jsEntry] so the JS entry point runs directly.
|
|
366
|
+
* Returns null when no suitable Node exists or the target isn't a JS entry,
|
|
367
|
+
* leaving the caller to spawn the shim as-is.
|
|
368
|
+
*/
|
|
369
|
+
_resolveToNodeCmd(binPath) {
|
|
370
|
+
const nodeBin = this._findNodeBin();
|
|
371
|
+
if (!nodeBin) return null;
|
|
372
|
+
|
|
373
|
+
if (binPath.endsWith('.mjs') || binPath.endsWith('.js')) return [nodeBin, binPath];
|
|
374
|
+
|
|
375
|
+
if (IS_WINDOWS && binPath.toLowerCase().endsWith('.cmd')) {
|
|
376
|
+
try {
|
|
377
|
+
const cmdDir = path.dirname(path.resolve(binPath));
|
|
378
|
+
const content = fs.readFileSync(binPath, 'utf-8');
|
|
379
|
+
const jsMatch = content.match(/%dp0%\\([^\s"*?]+(?:\.m?js)?)/i);
|
|
380
|
+
if (jsMatch) {
|
|
381
|
+
const target = path.resolve(cmdDir, jsMatch[1]);
|
|
382
|
+
if (fs.existsSync(target) && !target.toLowerCase().endsWith('.exe')) return [nodeBin, target];
|
|
383
|
+
}
|
|
384
|
+
} catch {}
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
let target = binPath;
|
|
390
|
+
if (fs.lstatSync(binPath).isSymbolicLink()) {
|
|
391
|
+
target = path.resolve(path.dirname(binPath), fs.readlinkSync(binPath));
|
|
392
|
+
}
|
|
393
|
+
if (target.endsWith('.mjs') || target.endsWith('.js')) return [nodeBin, target];
|
|
394
|
+
if (this._isNodeShebangScript(target)) return [nodeBin, target];
|
|
395
|
+
} catch {}
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** True when a file begins with a `#!...node` shebang. */
|
|
400
|
+
_isNodeShebangScript(filePath) {
|
|
401
|
+
try {
|
|
402
|
+
const fd = fs.openSync(filePath, 'r');
|
|
403
|
+
try {
|
|
404
|
+
const buf = Buffer.alloc(64);
|
|
405
|
+
const n = fs.readSync(fd, buf, 0, 64, 0);
|
|
406
|
+
const head = buf.slice(0, n).toString('utf-8');
|
|
407
|
+
return head.startsWith('#!') && /\bnode\b/.test(head.split('\n')[0]);
|
|
408
|
+
} finally {
|
|
409
|
+
fs.closeSync(fd);
|
|
410
|
+
}
|
|
411
|
+
} catch {
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Resolve [cmd, ...args] for spawning, handling node-script wrappers. */
|
|
417
|
+
_spawnableCmd(binPath, args) {
|
|
418
|
+
const resolved = this._resolveToNodeCmd(binPath);
|
|
419
|
+
if (resolved) return [...resolved, ...args];
|
|
420
|
+
if (IS_WINDOWS && binPath.toLowerCase().endsWith('.cmd')) return ['cmd.exe', '/c', binPath, ...args];
|
|
421
|
+
return [binPath, ...args];
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ------------------------------------------------------------------
|
|
425
|
+
// Version preflight (cached; HARD minimum 2.0.0)
|
|
426
|
+
// ------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
/** Run `--version` and return raw output. Isolated for testing. */
|
|
429
|
+
_readVersionRaw(bin) {
|
|
430
|
+
try {
|
|
431
|
+
const [cmd, ...args] = this._spawnableCmd(bin, ['--version']);
|
|
432
|
+
return execFileSync(cmd, args, {
|
|
433
|
+
encoding: 'utf-8',
|
|
434
|
+
timeout: 15000,
|
|
435
|
+
windowsHide: true,
|
|
436
|
+
env: { ...getEnhancedEnv(this.agentEnv), ...resolveCodeBuddyEnv(this.agentEnv) },
|
|
437
|
+
});
|
|
438
|
+
} catch (e) {
|
|
439
|
+
// A CLI that errors on --version still tells us its version sometimes.
|
|
440
|
+
return (e && (e.stdout || e.stderr)) ? String(e.stdout || e.stderr) : '';
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Cached version verdict for a resolved binary. */
|
|
445
|
+
_checkVersion(bin) {
|
|
446
|
+
const now = Date.now();
|
|
447
|
+
if (this._versionProbe && this._versionProbe.bin === bin && now - this._versionProbe.at < VERSION_PROBE_TTL_MS) {
|
|
448
|
+
return this._versionProbe;
|
|
449
|
+
}
|
|
450
|
+
const verdict = classifyCodeBuddyVersion(this._readVersionRaw(bin));
|
|
451
|
+
this._versionProbe = { ...verdict, bin, at: now };
|
|
452
|
+
return this._versionProbe;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ------------------------------------------------------------------
|
|
456
|
+
// Workspace MCP server
|
|
457
|
+
// ------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Write the MCP config that gives this run the workspace_* tools, and return
|
|
461
|
+
* its path (or null when the server entry point cannot be located — the run
|
|
462
|
+
* still proceeds, just without workspace tools).
|
|
463
|
+
*
|
|
464
|
+
* The file carries the workspace token, so it is written 0600 into an
|
|
465
|
+
* OpenAgents-owned directory rather than the user's project or their
|
|
466
|
+
* ~/.codebuddy config. The caller deletes it when the run ends.
|
|
467
|
+
*/
|
|
468
|
+
_writeMcpConfig(channel) {
|
|
469
|
+
try {
|
|
470
|
+
const mcpArgs = [
|
|
471
|
+
'mcp-server',
|
|
472
|
+
'--workspace-id', this.workspaceId,
|
|
473
|
+
'--channel-name', channel,
|
|
474
|
+
'--agent-name', this.agentName,
|
|
475
|
+
'--endpoint', this.endpoint,
|
|
476
|
+
];
|
|
477
|
+
if (this.disabledModules.has('files')) mcpArgs.push('--disable-files');
|
|
478
|
+
if (this.disabledModules.has('browser')) mcpArgs.push('--disable-browser');
|
|
479
|
+
if (this.disabledModules.has('knowledge')) mcpArgs.push('--disable-knowledge');
|
|
480
|
+
|
|
481
|
+
const resolved = this._resolveMcpServerCmd(mcpArgs);
|
|
482
|
+
if (!resolved) {
|
|
483
|
+
this._log('Could not find the openagents binary — workspace MCP tools are unavailable this run');
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const config = {
|
|
488
|
+
mcpServers: {
|
|
489
|
+
'openagents-workspace': {
|
|
490
|
+
type: 'stdio',
|
|
491
|
+
command: resolved.command,
|
|
492
|
+
args: resolved.args,
|
|
493
|
+
env: { OA_WORKSPACE_TOKEN: this.token },
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const dir = path.join(os.homedir(), '.openagents', 'mcp-configs');
|
|
499
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
500
|
+
const file = path.join(dir, `codebuddy-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
|
|
501
|
+
fs.writeFileSync(file, JSON.stringify(config), { mode: 0o600 });
|
|
502
|
+
try { fs.chmodSync(file, 0o600); } catch {}
|
|
503
|
+
return file;
|
|
504
|
+
} catch (e) {
|
|
505
|
+
this._log(`Could not write the MCP config: ${redactSecrets(e && e.message)}`);
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Locate this package's own CLI, which hosts the workspace MCP server.
|
|
512
|
+
* Returns { command, args } or null.
|
|
513
|
+
*/
|
|
514
|
+
_resolveMcpServerCmd(mcpArgs) {
|
|
515
|
+
const siblingBin = path.resolve(__dirname, '..', '..', 'bin', 'agent-connector.js');
|
|
516
|
+
if (fs.existsSync(siblingBin)) {
|
|
517
|
+
const node = this._findNodeBin();
|
|
518
|
+
if (node) return { command: node, args: [siblingBin, ...mcpArgs] };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const home = os.homedir();
|
|
522
|
+
const ext = IS_WINDOWS ? '.cmd' : '';
|
|
523
|
+
const candidates = [];
|
|
524
|
+
try {
|
|
525
|
+
const runtimesRoot = path.join(home, '.openagents', 'runtimes');
|
|
526
|
+
for (const d of fs.readdirSync(runtimesRoot, { withFileTypes: true })) {
|
|
527
|
+
if (d.isDirectory()) {
|
|
528
|
+
candidates.push(path.join(runtimesRoot, d.name, 'node_modules', '.bin', `openagents${ext}`));
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
} catch {}
|
|
532
|
+
candidates.push(path.join(home, '.openagents', 'nodejs', 'node_modules', '.bin', `openagents${ext}`));
|
|
533
|
+
|
|
534
|
+
let bin = candidates.find((c) => fs.existsSync(c)) || null;
|
|
535
|
+
if (!bin) bin = whereBinary('openagents');
|
|
536
|
+
if (!bin) return null;
|
|
537
|
+
|
|
538
|
+
const resolved = this._resolveToNodeCmd(bin);
|
|
539
|
+
if (resolved) return { command: resolved[0], args: [...resolved.slice(1), ...mcpArgs] };
|
|
540
|
+
return { command: bin, args: mcpArgs };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ------------------------------------------------------------------
|
|
544
|
+
// Prompt assembly
|
|
545
|
+
// ------------------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Short transcript of recent channel chat, used to re-seed a fresh session.
|
|
549
|
+
* Only needed when there is no session to resume — the CLI already holds the
|
|
550
|
+
* history in that case.
|
|
551
|
+
*/
|
|
552
|
+
async _buildChannelRecap(channel, currentMessage) {
|
|
553
|
+
try {
|
|
554
|
+
const messages = await this.client.getRecentMessages(this.workspaceId, channel, this.token, 30);
|
|
555
|
+
if (!messages || messages.length === 0) return null;
|
|
556
|
+
const lines = [];
|
|
557
|
+
for (const m of messages) {
|
|
558
|
+
const mt = m.messageType || 'chat';
|
|
559
|
+
if (mt === 'status' || mt === 'thinking' || mt === 'loading' || mt === 'todos') continue;
|
|
560
|
+
const text = (m.content || '').trim();
|
|
561
|
+
if (!text || text === currentMessage) continue;
|
|
562
|
+
const who = m.senderType === 'human' ? (m.senderName || 'user') : (m.senderName || 'agent');
|
|
563
|
+
lines.push(`[${who}] ${truncate(text, 800)}`);
|
|
564
|
+
}
|
|
565
|
+
if (lines.length === 0) return null;
|
|
566
|
+
return 'Recent conversation in this channel for context:\n\n' + lines.slice(-12).join('\n');
|
|
567
|
+
} catch {
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
_dirExists(p) {
|
|
573
|
+
try { return fs.statSync(p).isDirectory(); } catch { return false; }
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ------------------------------------------------------------------
|
|
577
|
+
// Run
|
|
578
|
+
// ------------------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Run one headless turn and consume its stream-json output.
|
|
582
|
+
*
|
|
583
|
+
* The prompt goes in over stdin; the reply comes off the single `result`
|
|
584
|
+
* frame. Assistant frames only drive the live ticker, so a change to the
|
|
585
|
+
* streaming shape costs a progress update, never the answer.
|
|
586
|
+
*
|
|
587
|
+
* @returns {Promise<object>} { code, signal, result, sessionId, anyOutput, lastAssistantText, userStopped, stderr }
|
|
588
|
+
*/
|
|
589
|
+
_runCodeBuddy(channel, bin, args, workingDir, prompt) {
|
|
590
|
+
return new Promise((resolve) => {
|
|
591
|
+
const [cmd, ...spawnArgs] = this._spawnableCmd(bin, args);
|
|
592
|
+
this._log(`Spawning: ${path.basename(cmd)} ${redactArgs(spawnArgs).join(' ')} (cwd=${workingDir})`);
|
|
593
|
+
|
|
594
|
+
let proc;
|
|
595
|
+
try {
|
|
596
|
+
proc = spawn(cmd, spawnArgs, {
|
|
597
|
+
cwd: workingDir,
|
|
598
|
+
env: { ...getEnhancedEnv(this.agentEnv), ...resolveCodeBuddyEnv(this.agentEnv) },
|
|
599
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
600
|
+
// Own process group so the CLI's own shell tools die with it.
|
|
601
|
+
detached: !IS_WINDOWS,
|
|
602
|
+
windowsHide: true,
|
|
603
|
+
});
|
|
604
|
+
} catch (e) {
|
|
605
|
+
resolve({ code: null, signal: null, result: null, sessionId: null, anyOutput: false, userStopped: false, spawnError: e && e.message });
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
this._channelProcesses[channel] = proc;
|
|
610
|
+
|
|
611
|
+
let settled = false;
|
|
612
|
+
let stdoutBuf = '';
|
|
613
|
+
let stderrBuf = '';
|
|
614
|
+
let anyOutput = false;
|
|
615
|
+
let result = null;
|
|
616
|
+
let sessionId = null;
|
|
617
|
+
// The most recent assistant text, kept as a fallback answer. The CLI
|
|
618
|
+
// builds the result frame's `result` from its last completed assistant
|
|
619
|
+
// message, so an empty one on an otherwise clean run means the answer was
|
|
620
|
+
// streamed but never summarized — posting "no response" over text the
|
|
621
|
+
// user already watched arrive is the worse failure.
|
|
622
|
+
let lastAssistantText = '';
|
|
623
|
+
let lastFrameAt = Date.now();
|
|
624
|
+
let silences = 0;
|
|
625
|
+
let lastToolLabel = null;
|
|
626
|
+
let killedByWatchdog = false;
|
|
627
|
+
|
|
628
|
+
const finish = (payload) => {
|
|
629
|
+
if (settled) return;
|
|
630
|
+
settled = true;
|
|
631
|
+
clearInterval(watchdog);
|
|
632
|
+
clearTimeout(timeout);
|
|
633
|
+
if (this._channelProcesses[channel] === proc) delete this._channelProcesses[channel];
|
|
634
|
+
resolve(payload);
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
const onFrame = (line) => {
|
|
638
|
+
const frame = parseFrame(line);
|
|
639
|
+
if (!frame) return;
|
|
640
|
+
lastFrameAt = Date.now();
|
|
641
|
+
silences = 0;
|
|
642
|
+
anyOutput = true;
|
|
643
|
+
|
|
644
|
+
const ev = interpretCodeBuddyFrame(frame);
|
|
645
|
+
if (ev.kind === 'result') {
|
|
646
|
+
result = ev;
|
|
647
|
+
if (ev.sessionId) sessionId = ev.sessionId;
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if (ev.kind === 'init') {
|
|
651
|
+
// The id arrives here, BEFORE any model call — which is what keeps a
|
|
652
|
+
// channel resumable even when the turn itself fails.
|
|
653
|
+
if (ev.sessionId) sessionId = ev.sessionId;
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (ev.kind === 'assistant') {
|
|
657
|
+
if (ev.texts.length) lastAssistantText = ev.texts.join('\n');
|
|
658
|
+
for (const text of ev.texts) {
|
|
659
|
+
void this.sendThinking(channel, text).catch(() => {});
|
|
660
|
+
}
|
|
661
|
+
for (const tool of ev.tools) {
|
|
662
|
+
if (tool.todos) {
|
|
663
|
+
const wsTodos = tool.todos.map((t) => ({
|
|
664
|
+
content: t.content, status: t.status || 'pending', assignee: t.assignee,
|
|
665
|
+
}));
|
|
666
|
+
void this.sendTodos(channel, wsTodos).catch(() => {});
|
|
667
|
+
}
|
|
668
|
+
// Only announce a CHANGE of activity: a run touching thirty files
|
|
669
|
+
// would otherwise post thirty near-identical status lines.
|
|
670
|
+
const label = tool.preview ? `${tool.name} › ${tool.preview}` : tool.name;
|
|
671
|
+
if (label && label !== lastToolLabel) {
|
|
672
|
+
lastToolLabel = label;
|
|
673
|
+
void this.sendStatus(channel, label).catch(() => {});
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
if (ev.kind === 'status') {
|
|
679
|
+
void this.sendStatus(channel, ev.text).catch(() => {});
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (ev.kind === 'unknown') {
|
|
683
|
+
this._log(`Unrecognized frame (ignored): ${ev.raw}`);
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
proc.stdout.on('data', (chunk) => {
|
|
688
|
+
stdoutBuf += chunk.toString('utf-8');
|
|
689
|
+
let nl;
|
|
690
|
+
while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
|
|
691
|
+
const line = stdoutBuf.slice(0, nl);
|
|
692
|
+
stdoutBuf = stdoutBuf.slice(nl + 1);
|
|
693
|
+
try { onFrame(line); } catch (e) { this._log(`Frame handling failed: ${redactSecrets(e && e.message)}`); }
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
proc.stderr.on('data', (chunk) => {
|
|
698
|
+
// Bounded: stderr carries progress under --verbose and must not grow
|
|
699
|
+
// without limit on a long run.
|
|
700
|
+
stderrBuf = truncate(stderrBuf + chunk.toString('utf-8'), 8000);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
proc.on('error', (e) => {
|
|
704
|
+
finish({ code: null, signal: null, result, sessionId, anyOutput, lastAssistantText, userStopped: false, spawnError: e && e.message });
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
proc.on('close', (code, signal) => {
|
|
708
|
+
// Flush a trailing line with no newline (the result frame, if the CLI
|
|
709
|
+
// exits without one).
|
|
710
|
+
if (stdoutBuf.trim()) { try { onFrame(stdoutBuf); } catch {} }
|
|
711
|
+
const userStopped = this._stoppingChannels.has(channel);
|
|
712
|
+
this._stoppingChannels.delete(channel);
|
|
713
|
+
finish({
|
|
714
|
+
code,
|
|
715
|
+
signal: killedByWatchdog ? 'SIGKILL' : signal,
|
|
716
|
+
result,
|
|
717
|
+
sessionId,
|
|
718
|
+
anyOutput,
|
|
719
|
+
lastAssistantText,
|
|
720
|
+
userStopped,
|
|
721
|
+
stderr: redactSecrets(stderrBuf),
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
const watchdog = setInterval(() => {
|
|
726
|
+
if (Date.now() - lastFrameAt < WATCHDOG_INTERVAL_MS) return;
|
|
727
|
+
silences += 1;
|
|
728
|
+
if (silences === WATCHDOG_NUDGE_AT) {
|
|
729
|
+
void this.sendStatus(channel, 'still working...').catch(() => {});
|
|
730
|
+
}
|
|
731
|
+
if (silences >= WATCHDOG_MAX) {
|
|
732
|
+
this._log('No output for ~5 minutes — killing a wedged run');
|
|
733
|
+
killedByWatchdog = true;
|
|
734
|
+
void this._stopProcess(proc);
|
|
735
|
+
}
|
|
736
|
+
}, WATCHDOG_INTERVAL_MS);
|
|
737
|
+
|
|
738
|
+
const timeout = setTimeout(() => {
|
|
739
|
+
this._log(`Run exceeded ${TIMEOUT_MS}ms — killing`);
|
|
740
|
+
killedByWatchdog = true;
|
|
741
|
+
void this._stopProcess(proc);
|
|
742
|
+
}, TIMEOUT_MS);
|
|
743
|
+
|
|
744
|
+
try {
|
|
745
|
+
proc.stdin.on('error', () => {});
|
|
746
|
+
proc.stdin.write(prompt, 'utf-8');
|
|
747
|
+
proc.stdin.end();
|
|
748
|
+
} catch (e) {
|
|
749
|
+
this._log(`Could not write the prompt to stdin: ${redactSecrets(e && e.message)}`);
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// ------------------------------------------------------------------
|
|
755
|
+
// Message handling
|
|
756
|
+
// ------------------------------------------------------------------
|
|
757
|
+
|
|
758
|
+
async _handleMessage(msg) {
|
|
759
|
+
let content = (msg.content || '').trim();
|
|
760
|
+
const attachments = msg.attachments || [];
|
|
761
|
+
const attText = formatAttachmentsForPrompt(attachments, 'mcp');
|
|
762
|
+
if (attText) content = content ? content + attText : attText.trim();
|
|
763
|
+
if (!content) return;
|
|
764
|
+
|
|
765
|
+
const channel = msg.sessionId || this.channelName;
|
|
766
|
+
this._stoppingChannels.delete(channel);
|
|
767
|
+
const sender = msg.senderName || msg.senderType || 'user';
|
|
768
|
+
this._log(`Processing message from ${sender} in ${channel}: ${redactSecrets(truncate(content, 80))}`);
|
|
769
|
+
|
|
770
|
+
// Resolve the working directory up front. Never silently fall back to the
|
|
771
|
+
// launcher/repo dir — a wrong cwd is a destructive surprise, not a default.
|
|
772
|
+
const workingDir = this.workingDir || defaultAgentWorkdir(this.agentName);
|
|
773
|
+
if (this.workingDir && !this._dirExists(this.workingDir)) {
|
|
774
|
+
await this.sendError(channel, `Working directory does not exist: ${this.workingDir}`);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const bin = this._findCodeBuddyBinary();
|
|
779
|
+
if (!bin) {
|
|
780
|
+
await this.sendError(channel, `CodeBuddy CLI not found. Install it with: ${INSTALL_HINT}`);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// HARD minimum gate. A confirmed-older CLI has no stream-json contract to
|
|
785
|
+
// parse; an undetermined version (supported === null) proceeds leniently.
|
|
786
|
+
const ver = this._checkVersion(bin);
|
|
787
|
+
if (ver.supported === false) {
|
|
788
|
+
this._log(`Refusing to start: CodeBuddy ${ver.version} < minimum ${CODEBUDDY_MIN_VERSION}`);
|
|
789
|
+
await this.sendError(channel,
|
|
790
|
+
`CodeBuddy CLI ${ver.version} is below the minimum supported version ${CODEBUDDY_MIN_VERSION} ` +
|
|
791
|
+
`(the line that ships headless stream-json output). Upgrade with: ${INSTALL_HINT}`);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (ver.supported === null) {
|
|
795
|
+
this._log('Could not determine the CodeBuddy version — proceeding leniently');
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Auto-title + resume-from on first encounter (parity with other adapters).
|
|
799
|
+
if (!this._titledSessions.has(channel)) {
|
|
800
|
+
this._titledSessions.add(channel);
|
|
801
|
+
try {
|
|
802
|
+
const info = await this.client.getSession(this.workspaceId, channel, this.token);
|
|
803
|
+
const resumeFrom = info.resumeFrom;
|
|
804
|
+
if (resumeFrom && !this._channelSessions[channel] && this._channelSessions[resumeFrom]) {
|
|
805
|
+
this._channelSessions[channel] = { ...this._channelSessions[resumeFrom] };
|
|
806
|
+
this._saveSessions();
|
|
807
|
+
}
|
|
808
|
+
const title = generateSessionTitle(content);
|
|
809
|
+
if (title && !info.titleManuallySet && SESSION_DEFAULT_RE.test(info.title || '')) {
|
|
810
|
+
await this.client.updateSession(this.workspaceId, channel, this.token, { title, autoTitle: true });
|
|
811
|
+
}
|
|
812
|
+
} catch {}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
await this.sendStatus(channel, 'thinking...');
|
|
816
|
+
|
|
817
|
+
// The Claude builder is reused verbatim: CodeBuddy takes the same
|
|
818
|
+
// --append-system-prompt hook and reaches the workspace through the same
|
|
819
|
+
// stdio MCP server, so the briefing it needs is character-for-character the
|
|
820
|
+
// one that adapter already writes. A CodeBuddy-shaped copy would be a fork
|
|
821
|
+
// to keep in sync for no behavioral difference.
|
|
822
|
+
const systemPrompt = buildClaudeSystemPrompt({
|
|
823
|
+
agentName: this.agentName,
|
|
824
|
+
workspaceId: this.workspaceId,
|
|
825
|
+
channelName: channel,
|
|
826
|
+
mode: this._mode,
|
|
827
|
+
browserEnabled: await this.getBrowserEnabled(),
|
|
828
|
+
toolMode: 'mcp',
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
const model = (this.workspaceModel || this.agentEnv.CODEBUDDY_MODEL || '').trim();
|
|
832
|
+
const effort = (this.agentEnv.CODEBUDDY_EFFORT || '').trim();
|
|
833
|
+
const maxTurns = parseInt(this.agentEnv.CODEBUDDY_MAX_TURNS || '', 10);
|
|
834
|
+
|
|
835
|
+
// One retry: a stale session id (its transcript pruned, or the directory
|
|
836
|
+
// re-keyed) fails the resume, and the turn is worth re-running fresh.
|
|
837
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
838
|
+
const resumeId = attempt === 0 ? this._resumableSession(channel, workingDir) : null;
|
|
839
|
+
|
|
840
|
+
// Resuming → the CLI already holds the history, so send the bare turn.
|
|
841
|
+
// Fresh → prepend a recap of the channel.
|
|
842
|
+
let prompt = content;
|
|
843
|
+
if (!resumeId) {
|
|
844
|
+
const recap = await this._buildChannelRecap(channel, content);
|
|
845
|
+
if (recap) prompt = `${recap}\n\n---\n\n${content}`;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const mcpConfigPath = this._writeMcpConfig(channel);
|
|
849
|
+
const args = buildCodeBuddyArgs({
|
|
850
|
+
appendSystemPrompt: systemPrompt,
|
|
851
|
+
model,
|
|
852
|
+
effort,
|
|
853
|
+
maxTurns: Number.isFinite(maxTurns) ? maxTurns : undefined,
|
|
854
|
+
resumeSessionId: resumeId || undefined,
|
|
855
|
+
mcpConfigPath: mcpConfigPath || undefined,
|
|
856
|
+
planMode: this._mode === 'plan',
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
let run;
|
|
860
|
+
try {
|
|
861
|
+
run = await this._runCodeBuddy(channel, bin, args, workingDir, prompt);
|
|
862
|
+
} finally {
|
|
863
|
+
if (mcpConfigPath) { try { fs.unlinkSync(mcpConfigPath); } catch {} }
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (run.userStopped) return;
|
|
867
|
+
|
|
868
|
+
if (run.spawnError) {
|
|
869
|
+
await this.sendError(channel,
|
|
870
|
+
`CodeBuddy CLI could not be started (${redactSecrets(run.spawnError)}). Reinstall it with: ${INSTALL_HINT}`);
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const verdict = classifyCodeBuddyRun(run);
|
|
875
|
+
|
|
876
|
+
// A resume that produced nothing usable is the stale-session case: drop
|
|
877
|
+
// the binding and retry once from scratch.
|
|
878
|
+
if (resumeId && !verdict.ok && !run.anyOutput && attempt === 0) {
|
|
879
|
+
this._log(`Resume of session ${resumeId} produced nothing — clearing and retrying fresh`);
|
|
880
|
+
this._clearSession(channel);
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Persist the session id for the next turn. It comes off the init frame,
|
|
885
|
+
// so this survives a turn that failed at the model call.
|
|
886
|
+
this._rememberSession(channel, run.sessionId || resumeId, workingDir);
|
|
887
|
+
|
|
888
|
+
const resultText = run.result && run.result.text ? run.result.text.trim() : '';
|
|
889
|
+
const finalText = resultText || (verdict.ok ? (run.lastAssistantText || '').trim() : '');
|
|
890
|
+
|
|
891
|
+
if (verdict.ok) {
|
|
892
|
+
try {
|
|
893
|
+
await this.sendResponse(channel, finalText || 'No response generated. Please try again.');
|
|
894
|
+
} catch {}
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// A failed run that still produced text (the model answered, then a later
|
|
899
|
+
// step failed) delivers the text with the reason appended rather than
|
|
900
|
+
// replacing an answer the user can use.
|
|
901
|
+
if (finalText) {
|
|
902
|
+
this._log(`Run reported ${verdict.kind} but returned text; text delivered`);
|
|
903
|
+
try { await this.sendResponse(channel, `${finalText}\n\n_${verdict.userMessage}_`); } catch {}
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
try { await this.sendError(channel, verdict.userMessage); } catch {}
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
module.exports = CodeBuddyAdapter;
|