@openagents-org/agent-launcher 0.2.143 → 0.2.145
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/package.json +1 -1
- package/registry.json +274 -66
- package/src/adapters/aider.js +634 -0
- package/src/adapters/amp.js +477 -0
- package/src/adapters/base.js +108 -7
- package/src/adapters/claude.js +36 -34
- package/src/adapters/cline-stream.js +632 -0
- package/src/adapters/cline.js +914 -0
- package/src/adapters/codex.js +14 -16
- package/src/adapters/copilot-stream-parser.js +380 -0
- package/src/adapters/copilot.js +796 -0
- package/src/adapters/cursor.js +9 -15
- package/src/adapters/gemini.js +10 -12
- package/src/adapters/goose-stream.js +268 -0
- package/src/adapters/goose.js +649 -0
- package/src/adapters/health-status.js +166 -0
- package/src/adapters/hermes.js +8 -17
- package/src/adapters/index.js +16 -1
- package/src/adapters/nanoclaw-bridge.js +264 -0
- package/src/adapters/nanoclaw-channel-install.js +411 -0
- package/src/adapters/nanoclaw-control.js +491 -0
- package/src/adapters/nanoclaw-delivery-store.js +0 -0
- package/src/adapters/nanoclaw-protocol.js +349 -0
- package/src/adapters/nanoclaw.js +661 -9
- package/src/adapters/opencode.js +541 -75
- package/src/agent-rows.js +13 -1
- package/src/auth-guidance.js +98 -0
- package/src/cli.js +24 -7
- package/src/config.js +8 -2
- package/src/daemon.js +86 -5
- package/src/installer.js +509 -10
- package/src/paths.js +125 -2
- package/src/tui.js +29 -2
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goose adapter for OpenAgents workspace.
|
|
3
|
+
*
|
|
4
|
+
* Bridges the Goose CLI (block/goose) to an OpenAgents workspace via Goose's
|
|
5
|
+
* official headless mode:
|
|
6
|
+
*
|
|
7
|
+
* goose run --output-format stream-json --name <session> [--resume] \
|
|
8
|
+
* --no-profile --with-builtin developer \
|
|
9
|
+
* --max-turns N --max-tool-repetitions M --system <ctx> -i -
|
|
10
|
+
*
|
|
11
|
+
* - The task prompt is written to the child's stdin (`-i -`), never argv.
|
|
12
|
+
* - stdout is parsed incrementally as NDJSON stream-json (goose-stream.js);
|
|
13
|
+
* stderr is drained concurrently.
|
|
14
|
+
* - Each (workspace, agent, channel) maps to a stable, unique Goose session
|
|
15
|
+
* name so conversations resume per-channel and never cross-talk.
|
|
16
|
+
* - Provider/model/key/host come from native Goose env vars (this.agentEnv);
|
|
17
|
+
* the key is never placed on the command line or in logs.
|
|
18
|
+
* - GOOSE_MODE=auto runs tools without interactive approval (required for
|
|
19
|
+
* headless); only the built-in `developer` extension is enabled by default.
|
|
20
|
+
*
|
|
21
|
+
* Port of sdk/src/openagents/adapters/goose.py. Verified against block/goose
|
|
22
|
+
* v1.38.0. Goose is currently Beta — real end-to-end runs are pending.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
const os = require('os');
|
|
30
|
+
const crypto = require('crypto');
|
|
31
|
+
const { spawn, execSync, execFileSync } = require('child_process');
|
|
32
|
+
|
|
33
|
+
const BaseAdapter = require('./base');
|
|
34
|
+
const { GooseStreamParser, classifyGooseError, redactSecrets } = require('./goose-stream');
|
|
35
|
+
const {
|
|
36
|
+
buildWorkspaceIdentity,
|
|
37
|
+
buildCollaborationPrompt,
|
|
38
|
+
buildModePrompt,
|
|
39
|
+
} = require('./workspace-prompt');
|
|
40
|
+
const { whichBinary, getEnhancedEnv, defaultAgentWorkdir } = require('../paths');
|
|
41
|
+
|
|
42
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
43
|
+
|
|
44
|
+
const DEFAULT_INACTIVITY_TIMEOUT = 900; // seconds
|
|
45
|
+
const DEFAULT_MAX_TURNS = 100;
|
|
46
|
+
const DEFAULT_MAX_TOOL_REPETITIONS = 12;
|
|
47
|
+
const STDERR_CAP = 64 * 1024;
|
|
48
|
+
const STATUS_PREVIEW = 280;
|
|
49
|
+
|
|
50
|
+
// Minimum Goose CLI version verified against the stable tag (block/goose
|
|
51
|
+
// v1.37.0). Older releases may lack stream-json / the flags used here, so we
|
|
52
|
+
// refuse them with a clear upgrade prompt rather than failing obscurely.
|
|
53
|
+
const MIN_GOOSE_VERSION = [1, 37, 0];
|
|
54
|
+
|
|
55
|
+
/** Parse `goose --version` output (e.g. "goose 1.37.0") → [1,37,0], or null. */
|
|
56
|
+
function parseGooseVersion(text) {
|
|
57
|
+
if (!text) return null;
|
|
58
|
+
const m = String(text).match(/(\d+)\.(\d+)\.(\d+)/);
|
|
59
|
+
if (!m) return null;
|
|
60
|
+
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True if parsed >= minimum (or unknown — lenient). */
|
|
64
|
+
function gooseVersionMeetsMinimum(parsed, minimum = MIN_GOOSE_VERSION) {
|
|
65
|
+
if (!parsed) return true;
|
|
66
|
+
for (let i = 0; i < 3; i++) {
|
|
67
|
+
const a = parsed[i] || 0;
|
|
68
|
+
const b = minimum[i] || 0;
|
|
69
|
+
if (a > b) return true;
|
|
70
|
+
if (a < b) return false;
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function tooOldMessage(parsed) {
|
|
76
|
+
const cur = parsed ? parsed.join('.') : 'unknown';
|
|
77
|
+
const minv = MIN_GOOSE_VERSION.join('.');
|
|
78
|
+
return `Goose CLI ${cur} is too old — OpenAgents requires Goose >= ${minv} `
|
|
79
|
+
+ '(headless stream-json support). Upgrade it: curl -fsSL '
|
|
80
|
+
+ 'https://github.com/block/goose/releases/download/stable/download_cli.sh '
|
|
81
|
+
+ '| CONFIGURE=false bash';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Stable, unique, filesystem-safe Goose session name for a channel.
|
|
86
|
+
* Mirrors goose_session_name() in goose_stream.py / goose.py.
|
|
87
|
+
*/
|
|
88
|
+
function gooseSessionName(workspaceId, agentName, channel) {
|
|
89
|
+
const digest = crypto
|
|
90
|
+
.createHash('sha256')
|
|
91
|
+
.update(`${workspaceId}|${agentName}|${channel}`)
|
|
92
|
+
.digest('hex');
|
|
93
|
+
return `oa_${digest.slice(0, 16)}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Locate the real `goose` CLI across platforms (PATH + well-known dirs). */
|
|
97
|
+
function findGooseBinary() {
|
|
98
|
+
const home = os.homedir();
|
|
99
|
+
// PATH (enriched with nvm/homebrew/etc), windowsHide avoids a console flash.
|
|
100
|
+
try {
|
|
101
|
+
const env = getEnhancedEnv();
|
|
102
|
+
if (IS_WINDOWS) {
|
|
103
|
+
const r = execSync('where goose.exe 2>nul || where goose.cmd 2>nul || where goose 2>nul', {
|
|
104
|
+
encoding: 'utf-8', timeout: 5000, windowsHide: true, env,
|
|
105
|
+
});
|
|
106
|
+
const hit = r.split(/\r?\n/)[0].trim();
|
|
107
|
+
if (hit) return hit;
|
|
108
|
+
} else {
|
|
109
|
+
const hit = execSync('command -v goose', {
|
|
110
|
+
encoding: 'utf-8', timeout: 5000, windowsHide: true, env,
|
|
111
|
+
}).trim();
|
|
112
|
+
if (hit) return hit;
|
|
113
|
+
}
|
|
114
|
+
} catch {}
|
|
115
|
+
|
|
116
|
+
const candidates = IS_WINDOWS ? [
|
|
117
|
+
path.join(process.env.USERPROFILE || home, 'goose', 'goose.exe'),
|
|
118
|
+
path.join(home, '.local', 'bin', 'goose.exe'),
|
|
119
|
+
] : [
|
|
120
|
+
path.join(home, '.local', 'bin', 'goose'), // official installer default
|
|
121
|
+
'/opt/homebrew/bin/goose', // macOS Homebrew (Apple silicon)
|
|
122
|
+
'/usr/local/bin/goose', // macOS Homebrew (Intel) / Linux
|
|
123
|
+
path.join(home, 'bin', 'goose'),
|
|
124
|
+
];
|
|
125
|
+
for (const c of candidates) {
|
|
126
|
+
try { if (fs.existsSync(c)) return c; } catch {}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Deep scan of every known bin dir (nvm/fnm/volta/homebrew/…).
|
|
130
|
+
const viaWhich = whichBinary('goose');
|
|
131
|
+
if (viaWhich) return viaWhich;
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
class GooseAdapter extends BaseAdapter {
|
|
136
|
+
/**
|
|
137
|
+
* @param {object} opts BaseAdapter opts plus { disabledModules, workingDir }
|
|
138
|
+
*/
|
|
139
|
+
constructor(opts) {
|
|
140
|
+
super(opts);
|
|
141
|
+
this.disabledModules = opts.disabledModules || new Set();
|
|
142
|
+
|
|
143
|
+
// channel → Goose session name. Persisted so the mapping survives a daemon
|
|
144
|
+
// restart (Goose stores the conversation itself in its SQLite session DB).
|
|
145
|
+
this._channelSessions = {};
|
|
146
|
+
this._sessionsFile = path.join(
|
|
147
|
+
os.homedir(), '.openagents', 'sessions',
|
|
148
|
+
`${this.workspaceId}_${this.agentName}_goose.json`,
|
|
149
|
+
);
|
|
150
|
+
this._loadSessions();
|
|
151
|
+
|
|
152
|
+
this._channelProcesses = {};
|
|
153
|
+
this._stoppingChannels = new Set();
|
|
154
|
+
|
|
155
|
+
// One-time minimum-version pre-flight (cached for the adapter lifetime).
|
|
156
|
+
this._versionChecked = false;
|
|
157
|
+
this._versionTooOld = null;
|
|
158
|
+
|
|
159
|
+
this._gooseBinary = findGooseBinary();
|
|
160
|
+
if (this._gooseBinary) {
|
|
161
|
+
this._log(`Using Goose CLI: ${this._gooseBinary}`);
|
|
162
|
+
} else {
|
|
163
|
+
this._log('goose CLI not found. Install (non-interactively): '
|
|
164
|
+
+ 'curl -fsSL '
|
|
165
|
+
+ 'https://github.com/block/goose/releases/download/stable/download_cli.sh '
|
|
166
|
+
+ '| CONFIGURE=false bash');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
this._secrets = this._collectSecretValues();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// -- session mapping persistence ----------------------------------------
|
|
173
|
+
|
|
174
|
+
_loadSessions() {
|
|
175
|
+
try {
|
|
176
|
+
if (fs.existsSync(this._sessionsFile)) {
|
|
177
|
+
const data = JSON.parse(fs.readFileSync(this._sessionsFile, 'utf-8'));
|
|
178
|
+
if (data && typeof data === 'object') {
|
|
179
|
+
for (const [k, v] of Object.entries(data)) {
|
|
180
|
+
if (typeof v === 'string') this._channelSessions[k] = v;
|
|
181
|
+
}
|
|
182
|
+
this._log(`Loaded ${Object.keys(this._channelSessions).length} Goose session mapping(s)`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
this._log('Could not load Goose sessions file, starting fresh');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
_saveSessions() {
|
|
191
|
+
try {
|
|
192
|
+
fs.mkdirSync(path.dirname(this._sessionsFile), { recursive: true });
|
|
193
|
+
fs.writeFileSync(this._sessionsFile, JSON.stringify(this._channelSessions));
|
|
194
|
+
} catch {}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// -- environment / provider config --------------------------------------
|
|
198
|
+
|
|
199
|
+
_collectSecretValues() {
|
|
200
|
+
const env = this.agentEnv || process.env;
|
|
201
|
+
const secrets = [];
|
|
202
|
+
for (const [key, val] of Object.entries(env)) {
|
|
203
|
+
if (!val || typeof val !== 'string' || val.length < 4) continue;
|
|
204
|
+
const upper = key.toUpperCase();
|
|
205
|
+
if (upper.includes('API_KEY') || upper.includes('TOKEN') || upper.includes('SECRET')) {
|
|
206
|
+
secrets.push(val);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return secrets;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
_buildEnv() {
|
|
213
|
+
const env = { ...(this.agentEnv || process.env) };
|
|
214
|
+
const mode = String(env.GOOSE_MODE || '').trim().toLowerCase();
|
|
215
|
+
if (mode === 'auto' || mode === 'chat') {
|
|
216
|
+
env.GOOSE_MODE = mode;
|
|
217
|
+
} else {
|
|
218
|
+
if (mode === 'approve' || mode === 'smart_approve') {
|
|
219
|
+
this._log(`GOOSE_MODE=${mode} waits for interactive approval and cannot run `
|
|
220
|
+
+ "headless; overriding to 'auto' for this agent.");
|
|
221
|
+
}
|
|
222
|
+
env.GOOSE_MODE = 'auto';
|
|
223
|
+
}
|
|
224
|
+
return env;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
_maxTurns() {
|
|
228
|
+
const env = this.agentEnv || process.env;
|
|
229
|
+
const n = parseInt(env.GOOSE_MAX_TURNS, 10);
|
|
230
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_TURNS;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
_maxToolRepetitions() {
|
|
234
|
+
const env = this.agentEnv || process.env;
|
|
235
|
+
const n = parseInt(env.GOOSE_MAX_TOOL_REPETITIONS, 10);
|
|
236
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_TOOL_REPETITIONS;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
_inactivityTimeout() {
|
|
240
|
+
const env = this.agentEnv || process.env;
|
|
241
|
+
const n = parseFloat(env.GOOSE_INACTIVITY_TIMEOUT);
|
|
242
|
+
return Number.isFinite(n) && n >= 30 ? n : DEFAULT_INACTIVITY_TIMEOUT;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
_buildSystemPrompt(channelName) {
|
|
246
|
+
let prompt = buildWorkspaceIdentity(this.agentName, this.workspaceId, channelName, this._mode)
|
|
247
|
+
+ buildCollaborationPrompt()
|
|
248
|
+
+ buildModePrompt(this._mode);
|
|
249
|
+
if (this.workingDir) {
|
|
250
|
+
prompt += `\n## Project Directory\nYou are working in: ${this.workingDir}\n`
|
|
251
|
+
+ 'Make all file changes within this directory.\n';
|
|
252
|
+
}
|
|
253
|
+
return prompt;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
_resolveCwd() {
|
|
257
|
+
if (!this.workingDir) return defaultAgentWorkdir(this.agentName);
|
|
258
|
+
let stat;
|
|
259
|
+
try {
|
|
260
|
+
stat = fs.statSync(this.workingDir);
|
|
261
|
+
} catch {
|
|
262
|
+
throw new Error(`Project directory does not exist: ${this.workingDir}`);
|
|
263
|
+
}
|
|
264
|
+
if (!stat.isDirectory()) {
|
|
265
|
+
throw new Error(`Project path is not a directory: ${this.workingDir}`);
|
|
266
|
+
}
|
|
267
|
+
return this.workingDir;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
_safe(text) {
|
|
271
|
+
return redactSecrets(text, this._secrets);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// -- control actions (stop) ---------------------------------------------
|
|
275
|
+
|
|
276
|
+
async _onControlAction(action, payload) {
|
|
277
|
+
if (action === 'stop') {
|
|
278
|
+
const channel = (payload && typeof payload === 'object') ? payload.channel : null;
|
|
279
|
+
if (channel) {
|
|
280
|
+
await this._stopChannel(channel, 'Execution stopped by user.');
|
|
281
|
+
} else {
|
|
282
|
+
await this._stopAll('Execution stopped by user.');
|
|
283
|
+
}
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
await super._onControlAction(action, payload);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async _stopChannel(channel, message) {
|
|
290
|
+
const proc = this._channelProcesses[channel];
|
|
291
|
+
const hadQueue = !!(this._channelQueues[channel] && this._channelQueues[channel].length);
|
|
292
|
+
if (proc) {
|
|
293
|
+
this._stoppingChannels.add(channel);
|
|
294
|
+
await this._stopProcess(proc);
|
|
295
|
+
delete this._channelProcesses[channel];
|
|
296
|
+
}
|
|
297
|
+
delete this._channelQueues[channel];
|
|
298
|
+
if (proc || hadQueue) {
|
|
299
|
+
try { await this.sendStatus(channel, message); } catch {}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async _stopAll(message) {
|
|
304
|
+
for (const channel of Object.keys(this._channelProcesses)) {
|
|
305
|
+
// eslint-disable-next-line no-await-in-loop
|
|
306
|
+
await this._stopChannel(channel, message);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Override BaseAdapter.stop so daemon shutdown tears down in-flight runs. */
|
|
311
|
+
stop() {
|
|
312
|
+
this._stopAll('Task interrupted — daemon restarting. Send another message to continue.')
|
|
313
|
+
.catch(() => {});
|
|
314
|
+
super.stop();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async _stopProcess(proc) {
|
|
318
|
+
if (!proc || proc.exitCode !== null) return;
|
|
319
|
+
try {
|
|
320
|
+
if (IS_WINDOWS) {
|
|
321
|
+
try { proc.kill('SIGINT'); } catch {}
|
|
322
|
+
const exited = await new Promise((resolve) => {
|
|
323
|
+
if (proc.exitCode !== null) { resolve(true); return; }
|
|
324
|
+
const timeout = setTimeout(() => resolve(false), 1500);
|
|
325
|
+
proc.once('exit', () => { clearTimeout(timeout); resolve(true); });
|
|
326
|
+
});
|
|
327
|
+
if (!exited) {
|
|
328
|
+
try { execSync(`taskkill /F /T /PID ${proc.pid}`, { timeout: 5000 }); } catch {}
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
// Kill the whole process group (Goose's shell commands, dev servers and
|
|
332
|
+
// any MCP/extension children share it — we spawn detached).
|
|
333
|
+
try { process.kill(-proc.pid, 'SIGTERM'); } catch {
|
|
334
|
+
proc.kill('SIGTERM');
|
|
335
|
+
}
|
|
336
|
+
await new Promise((resolve) => {
|
|
337
|
+
let done = false;
|
|
338
|
+
const finish = () => { if (!done) { done = true; resolve(); } };
|
|
339
|
+
const timeout = setTimeout(() => {
|
|
340
|
+
try { process.kill(-proc.pid, 'SIGKILL'); } catch {
|
|
341
|
+
proc.kill('SIGKILL');
|
|
342
|
+
}
|
|
343
|
+
const reap = setTimeout(finish, 1000);
|
|
344
|
+
proc.once('exit', () => { clearTimeout(reap); finish(); });
|
|
345
|
+
}, 1500);
|
|
346
|
+
proc.once('exit', () => { clearTimeout(timeout); finish(); });
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
} catch {}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// -- message handler ----------------------------------------------------
|
|
353
|
+
|
|
354
|
+
async _handleMessage(msg) {
|
|
355
|
+
const content = (msg.content || '').trim();
|
|
356
|
+
if (!content) return;
|
|
357
|
+
|
|
358
|
+
const msgChannel = msg.sessionId || this.channelName;
|
|
359
|
+
const sender = msg.senderName || msg.senderType || 'user';
|
|
360
|
+
this._log(`Processing message from ${sender} in ${msgChannel}: ${content.slice(0, 80)}...`);
|
|
361
|
+
|
|
362
|
+
await this._autoTitleChannel(msgChannel, content);
|
|
363
|
+
await this.sendStatus(msgChannel, 'thinking...');
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
// null → stopped or a failure already reported; '' → empty success; str → answer.
|
|
367
|
+
const result = await this._runGoose(content, msgChannel);
|
|
368
|
+
if (result === null) return;
|
|
369
|
+
if (result) {
|
|
370
|
+
await this.sendResponse(msgChannel, result);
|
|
371
|
+
} else {
|
|
372
|
+
await this.sendResponse(
|
|
373
|
+
msgChannel,
|
|
374
|
+
'Goose ran but produced no response. This usually means no provider/model is '
|
|
375
|
+
+ 'configured — set GOOSE_PROVIDER and GOOSE_MODEL (and a key) for this agent, '
|
|
376
|
+
+ 'or run `goose configure` once outside OpenAgents.',
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
} catch (e) {
|
|
380
|
+
if (this._stoppingChannels.has(msgChannel)) {
|
|
381
|
+
this._stoppingChannels.delete(msgChannel);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
this._log(`Error handling message: ${e.message}`);
|
|
385
|
+
await this.sendError(msgChannel, `Error processing message: ${this._safe(e.message)}`);
|
|
386
|
+
} finally {
|
|
387
|
+
this._stoppingChannels.delete(msgChannel);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// -- subprocess execution ----------------------------------------------
|
|
392
|
+
|
|
393
|
+
_buildCmd(sessionName, resume, systemPrompt) {
|
|
394
|
+
const binary = this._gooseBinary || findGooseBinary();
|
|
395
|
+
if (!binary) {
|
|
396
|
+
throw new Error('goose CLI not found. Install (non-interactively): '
|
|
397
|
+
+ 'curl -fsSL '
|
|
398
|
+
+ 'https://github.com/block/goose/releases/download/stable/download_cli.sh '
|
|
399
|
+
+ '| CONFIGURE=false bash');
|
|
400
|
+
}
|
|
401
|
+
this._gooseBinary = binary;
|
|
402
|
+
const cmd = [
|
|
403
|
+
binary, 'run',
|
|
404
|
+
'--output-format', 'stream-json',
|
|
405
|
+
'--name', sessionName,
|
|
406
|
+
'--no-profile',
|
|
407
|
+
'--with-builtin', 'developer',
|
|
408
|
+
'--max-turns', String(this._maxTurns()),
|
|
409
|
+
'--max-tool-repetitions', String(this._maxToolRepetitions()),
|
|
410
|
+
];
|
|
411
|
+
if (resume) cmd.push('--resume');
|
|
412
|
+
cmd.push('--system', systemPrompt, '-i', '-');
|
|
413
|
+
return cmd;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Run one headless `goose run`. Resolves null (stopped/failure already
|
|
418
|
+
* reported), '' (empty success), or the answer text.
|
|
419
|
+
*/
|
|
420
|
+
/**
|
|
421
|
+
* Check the installed Goose CLI meets MIN_GOOSE_VERSION once (cached).
|
|
422
|
+
* Returns an upgrade-prompt string if it is definitively too old, else null
|
|
423
|
+
* (new enough, missing, or undeterminable — lenient).
|
|
424
|
+
*/
|
|
425
|
+
_versionTooOldMessage() {
|
|
426
|
+
if (this._versionChecked) return this._versionTooOld;
|
|
427
|
+
this._versionChecked = true;
|
|
428
|
+
this._versionTooOld = null;
|
|
429
|
+
const binary = this._gooseBinary || findGooseBinary();
|
|
430
|
+
if (!binary) return null;
|
|
431
|
+
let parsed = null;
|
|
432
|
+
try {
|
|
433
|
+
const out = execFileSync(binary, ['--version'], {
|
|
434
|
+
encoding: 'utf-8', timeout: 10000, windowsHide: true,
|
|
435
|
+
});
|
|
436
|
+
parsed = parseGooseVersion(out);
|
|
437
|
+
} catch {
|
|
438
|
+
return null; // can't determine → don't block
|
|
439
|
+
}
|
|
440
|
+
if (!gooseVersionMeetsMinimum(parsed)) this._versionTooOld = tooOldMessage(parsed);
|
|
441
|
+
return this._versionTooOld;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
_runGoose(content, channel, retry = false) {
|
|
445
|
+
let cwd;
|
|
446
|
+
try {
|
|
447
|
+
cwd = this._resolveCwd();
|
|
448
|
+
} catch (e) {
|
|
449
|
+
return this.sendError(channel, e.message).then(() => null, () => null);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Refuse a Goose CLI older than the verified-stable minimum.
|
|
453
|
+
const tooOld = this._versionTooOldMessage();
|
|
454
|
+
if (tooOld) return this.sendError(channel, tooOld).then(() => null, () => null);
|
|
455
|
+
|
|
456
|
+
const sessionName = this._channelSessions[channel]
|
|
457
|
+
|| gooseSessionName(this.workspaceId, this.agentName, channel);
|
|
458
|
+
const resume = Object.prototype.hasOwnProperty.call(this._channelSessions, channel);
|
|
459
|
+
const systemPrompt = this._buildSystemPrompt(channel);
|
|
460
|
+
|
|
461
|
+
let cmd;
|
|
462
|
+
try {
|
|
463
|
+
cmd = this._buildCmd(sessionName, resume, systemPrompt);
|
|
464
|
+
} catch (e) {
|
|
465
|
+
return this.sendError(channel, e.message).then(() => null, () => null);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const env = this._buildEnv();
|
|
469
|
+
const parser = new GooseStreamParser();
|
|
470
|
+
const captured = { finalText: null };
|
|
471
|
+
let stderr = '';
|
|
472
|
+
let lastActivity = Date.now();
|
|
473
|
+
|
|
474
|
+
this._log(`CLI: goose run --output-format stream-json --name ${sessionName}`
|
|
475
|
+
+ `${resume ? ' --resume' : ''} --no-profile --with-builtin developer`);
|
|
476
|
+
|
|
477
|
+
return new Promise((resolve) => {
|
|
478
|
+
let proc;
|
|
479
|
+
try {
|
|
480
|
+
proc = spawn(cmd[0], cmd.slice(1), {
|
|
481
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
482
|
+
env,
|
|
483
|
+
cwd,
|
|
484
|
+
detached: !IS_WINDOWS, // own process group for tree kill
|
|
485
|
+
windowsHide: true,
|
|
486
|
+
});
|
|
487
|
+
} catch (e) {
|
|
488
|
+
this.sendError(channel, `Failed to start Goose: ${this._safe(e.message)}`)
|
|
489
|
+
.then(() => resolve(null), () => resolve(null));
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
this._channelProcesses[channel] = proc;
|
|
494
|
+
|
|
495
|
+
// Guard the child's stdio streams against 'error'. When the process is
|
|
496
|
+
// SIGKILL'd on stop, macOS Node can emit a benign EPIPE/ECONNRESET 'error'
|
|
497
|
+
// on stdout/stderr a tick later; with a 'data' listener but no 'error'
|
|
498
|
+
// listener that becomes an uncaughtException which crashes the
|
|
499
|
+
// (single-process, e.g. Node 18) test run with exit 1 and no `not ok`.
|
|
500
|
+
// No-op — data handling below is unchanged.
|
|
501
|
+
if (proc.stdout) proc.stdout.on('error', () => {});
|
|
502
|
+
if (proc.stderr) proc.stderr.on('error', () => {});
|
|
503
|
+
|
|
504
|
+
let settled = false;
|
|
505
|
+
let pending = Promise.resolve();
|
|
506
|
+
const dispatch = (ev) => {
|
|
507
|
+
pending = pending.then(() => this._dispatchEvent(channel, ev, captured)).catch(() => {});
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
// Inactivity watchdog: kill a truly hung run (no output for a long time).
|
|
511
|
+
const timeoutSec = this._inactivityTimeout();
|
|
512
|
+
const watchdog = setInterval(() => {
|
|
513
|
+
if (proc.exitCode !== null) return;
|
|
514
|
+
if ((Date.now() - lastActivity) / 1000 > timeoutSec) {
|
|
515
|
+
this._log(`Goose produced no output for ${timeoutSec}s — treating as hung, killing.`);
|
|
516
|
+
this._stoppingChannels.add(channel);
|
|
517
|
+
this._stopProcess(proc).catch(() => {});
|
|
518
|
+
this.sendError(
|
|
519
|
+
channel,
|
|
520
|
+
'Goose appears to have hung (no output for a long time) and was stopped. '
|
|
521
|
+
+ 'Try a smaller task or check the provider.',
|
|
522
|
+
).catch(() => {});
|
|
523
|
+
}
|
|
524
|
+
}, Math.min(30000, timeoutSec * 1000));
|
|
525
|
+
|
|
526
|
+
if (proc.stdout) {
|
|
527
|
+
proc.stdout.on('data', (chunk) => {
|
|
528
|
+
lastActivity = Date.now();
|
|
529
|
+
for (const ev of parser.feed(chunk.toString('utf-8'))) dispatch(ev);
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
if (proc.stderr) {
|
|
533
|
+
proc.stderr.on('data', (chunk) => {
|
|
534
|
+
lastActivity = Date.now();
|
|
535
|
+
if (stderr.length < STDERR_CAP) stderr += chunk.toString('utf-8');
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Write the prompt via stdin, then close it. Goose reads stdin to EOF
|
|
540
|
+
// before processing, so writing+closing here is required and safe.
|
|
541
|
+
if (proc.stdin) {
|
|
542
|
+
proc.stdin.on('error', () => {});
|
|
543
|
+
try {
|
|
544
|
+
proc.stdin.write(content, 'utf-8');
|
|
545
|
+
proc.stdin.end();
|
|
546
|
+
} catch { /* child gone — close handler reports it */ }
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
proc.on('error', (err) => {
|
|
550
|
+
if (settled) return;
|
|
551
|
+
settled = true;
|
|
552
|
+
clearInterval(watchdog);
|
|
553
|
+
if (this._channelProcesses[channel] === proc) delete this._channelProcesses[channel];
|
|
554
|
+
this.sendError(channel, `Failed to run Goose: ${this._safe(err.message)}`)
|
|
555
|
+
.then(() => resolve(null), () => resolve(null));
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// 'close' fires after stdout/stderr drain — never parse a truncated stream.
|
|
559
|
+
proc.on('close', async (code) => {
|
|
560
|
+
if (settled) return;
|
|
561
|
+
settled = true;
|
|
562
|
+
clearInterval(watchdog);
|
|
563
|
+
if (this._channelProcesses[channel] === proc) delete this._channelProcesses[channel];
|
|
564
|
+
|
|
565
|
+
for (const ev of parser.finish()) dispatch(ev);
|
|
566
|
+
await pending;
|
|
567
|
+
|
|
568
|
+
if (this._stoppingChannels.has(channel)) {
|
|
569
|
+
resolve(null);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const stderrText = stderr.trim();
|
|
574
|
+
|
|
575
|
+
// Auto-heal a stale/missing session (Goose exits non-zero when --resume
|
|
576
|
+
// names a session it can't find). Recreate once.
|
|
577
|
+
if (resume && code && !retry && stderrText.toLowerCase().includes('no session found')) {
|
|
578
|
+
this._log(`Goose session ${sessionName} missing; creating a fresh one.`);
|
|
579
|
+
delete this._channelSessions[channel];
|
|
580
|
+
this._saveSessions();
|
|
581
|
+
await this.sendStatus(
|
|
582
|
+
channel,
|
|
583
|
+
'Previous Goose session was unavailable — starting a new one (earlier context is reset).',
|
|
584
|
+
);
|
|
585
|
+
resolve(await this._runGoose(content, channel, true));
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Failure: non-zero exit OR an error event (Goose can exit 0 after an
|
|
590
|
+
// agent error). A non-zero exit is never a success, even with partial text.
|
|
591
|
+
if (code !== 0 || parser.hadError) {
|
|
592
|
+
const detail = parser.errorMessage || stderrText;
|
|
593
|
+
const message = classifyGooseError(detail)
|
|
594
|
+
|| (detail ? this._safe(detail).slice(0, 500) : `Goose exited with code ${code}.`);
|
|
595
|
+
await this.sendError(channel, this._safe(message));
|
|
596
|
+
resolve(null);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (!Object.prototype.hasOwnProperty.call(this._channelSessions, channel)) {
|
|
601
|
+
this._channelSessions[channel] = sessionName;
|
|
602
|
+
this._saveSessions();
|
|
603
|
+
}
|
|
604
|
+
resolve(captured.finalText || '');
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async _dispatchEvent(channel, event, captured) {
|
|
610
|
+
const kind = event.kind;
|
|
611
|
+
if (kind === 'final') {
|
|
612
|
+
captured.finalText = event.text || '';
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
if (kind === 'tool') {
|
|
616
|
+
const name = event.name || 'tool';
|
|
617
|
+
const summary = this._safe(event.summary || '');
|
|
618
|
+
await this.sendStatus(channel, `🔧 ${name}${summary ? ` — ${summary}` : ''}`);
|
|
619
|
+
} else if (kind === 'progress') {
|
|
620
|
+
// Intermediate assistant narration is assistant output, not internal
|
|
621
|
+
// reasoning — post it as transient status, never as a "thinking" message.
|
|
622
|
+
let text = this._safe(event.text || '').trim();
|
|
623
|
+
if (text) {
|
|
624
|
+
if (text.length > STATUS_PREVIEW) text = text.slice(0, STATUS_PREVIEW) + '…';
|
|
625
|
+
await this.sendStatus(channel, text);
|
|
626
|
+
}
|
|
627
|
+
} else if (kind === 'thinking') {
|
|
628
|
+
// Genuine model thinking content (ThinkingContent) — legitimately a
|
|
629
|
+
// thinking message.
|
|
630
|
+
let text = this._safe(event.text || '').trim();
|
|
631
|
+
if (text) {
|
|
632
|
+
if (text.length > STATUS_PREVIEW) text = text.slice(0, STATUS_PREVIEW) + '…';
|
|
633
|
+
await this.sendThinking(channel, text);
|
|
634
|
+
}
|
|
635
|
+
} else if (kind === 'notification') {
|
|
636
|
+
const text = this._safe(event.text || '').trim();
|
|
637
|
+
if (text) await this.sendStatus(channel, text.slice(0, STATUS_PREVIEW));
|
|
638
|
+
}
|
|
639
|
+
// tool_result / complete / error → no direct status (error handled by caller)
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
GooseAdapter.gooseSessionName = gooseSessionName;
|
|
644
|
+
GooseAdapter.findGooseBinary = findGooseBinary;
|
|
645
|
+
GooseAdapter.parseGooseVersion = parseGooseVersion;
|
|
646
|
+
GooseAdapter.gooseVersionMeetsMinimum = gooseVersionMeetsMinimum;
|
|
647
|
+
GooseAdapter.MIN_GOOSE_VERSION = MIN_GOOSE_VERSION;
|
|
648
|
+
|
|
649
|
+
module.exports = GooseAdapter;
|