@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.
@@ -17,7 +17,7 @@ const { execSync, spawn } = require('child_process');
17
17
  const BaseAdapter = require('./base');
18
18
  const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
19
19
  const { buildCursorSkillMd } = require('./workspace-prompt');
20
- const { defaultAgentWorkdir, whichBinary, getEnhancedEnv } = require('../paths');
20
+ const { defaultAgentWorkdir, whichBinary, whereBinary } = require('../paths');
21
21
 
22
22
  const IS_WINDOWS = process.platform === 'win32';
23
23
 
@@ -191,19 +191,11 @@ class CursorAdapter extends BaseAdapter {
191
191
  // which the daemon's already-running process won't see, so `where` against
192
192
  // the daemon's own PATH comes up empty even though cursor-agent is installed.
193
193
  // windowsHide stops a console window from flashing.
194
- try {
195
- const env = getEnhancedEnv();
196
- if (IS_WINDOWS) {
197
- const r = execSync('where cursor-agent.cmd 2>nul || where cursor-agent.exe 2>nul || where cursor-agent 2>nul || where agent.cmd 2>nul || where agent.exe 2>nul || where agent 2>nul', {
198
- encoding: 'utf-8', timeout: 5000, windowsHide: true, env,
199
- });
200
- const hit = r.split(/\r?\n/)[0].trim();
201
- if (hit) return hit;
202
- } else {
203
- const hit = execSync('which cursor-agent || which agent', { encoding: 'utf-8', timeout: 5000, windowsHide: true, env }).trim();
204
- if (hit) return hit;
205
- }
206
- } catch {}
194
+ // Codepage-safe lookup (whereBinary forces UTF-8 output + verifies existence
195
+ // so a non-ASCII/Chinese username isn't mangled into an ENOENT). Tries both
196
+ // cursor-agent and the bare `agent` name across .cmd/.exe/no-ext.
197
+ const viaWhere = whereBinary(names);
198
+ if (viaWhere) return viaWhere;
207
199
 
208
200
  // Tier 2: Next to current Node.js interpreter (npm global)
209
201
  const nodeBinDir = path.dirname(process.execPath);
@@ -273,7 +265,9 @@ class CursorAdapter extends BaseAdapter {
273
265
  for (const c of candidates) {
274
266
  if (fs.existsSync(c)) return c;
275
267
  }
276
- return 'node';
268
+ // Fall back to the node already running this daemon (absolute, always valid).
269
+ // Bare 'node' can be off-PATH in a packaged daemon or CI runner.
270
+ return process.execPath;
277
271
  }
278
272
 
279
273
  _resolveToNodeCmd(binPath) {
@@ -14,6 +14,7 @@ const path = require('path');
14
14
  const { execSync, spawn } = require('child_process');
15
15
 
16
16
  const BaseAdapter = require('./base');
17
+ const { whereBinary } = require('../paths');
17
18
  const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
18
19
  const { buildClaudeSystemPrompt } = require('./workspace-prompt');
19
20
 
@@ -110,7 +111,9 @@ class GeminiAdapter extends BaseAdapter {
110
111
  for (const c of candidates) {
111
112
  if (fs.existsSync(c)) return c;
112
113
  }
113
- return 'node';
114
+ // Fall back to the node already running this daemon (absolute, always valid).
115
+ // Bare 'node' can be off-PATH in a packaged daemon or CI runner.
116
+ return process.execPath;
114
117
  }
115
118
 
116
119
  _resolveToNodeCmd(binPath) {
@@ -151,17 +154,12 @@ class GeminiAdapter extends BaseAdapter {
151
154
  const runtimeCandidate = path.join(home, '.openagents', 'runtimes', 'gemini', 'node_modules', '.bin', `gemini${ext}`);
152
155
  if (fs.existsSync(runtimeCandidate)) return runtimeCandidate;
153
156
 
154
- // Tier 1: PATH search
155
- try {
156
- if (IS_WINDOWS) {
157
- const r = execSync('where gemini.cmd 2>nul || where gemini.exe 2>nul || where gemini 2>nul', {
158
- encoding: 'utf-8', timeout: 5000,
159
- });
160
- return r.split(/\r?\n/)[0].trim();
161
- } else {
162
- return execSync('which gemini', { encoding: 'utf-8', timeout: 5000 }).trim();
163
- }
164
- } catch {}
157
+ // Tier 1: PATH search via a codepage-safe lookup (whereBinary forces UTF-8
158
+ // output + verifies existence so a non-ASCII/Chinese username isn't mangled
159
+ // into an ENOENT; it also no longer returns an empty string on a miss, which
160
+ // used to short-circuit the Node-derived fallback tiers below).
161
+ const viaWhere = whereBinary('gemini');
162
+ if (viaWhere) return viaWhere;
165
163
 
166
164
  // Tier 2: Next to current Node.js interpreter
167
165
  const nodeBinDir = path.dirname(process.execPath);
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Pure, line-oriented parser for Goose's `goose run --output-format stream-json`
3
+ * output, plus helpers for secret redaction and error classification.
4
+ *
5
+ * No I/O / process code lives here so it can be unit-tested in isolation and
6
+ * kept behaviourally identical to the Python port
7
+ * (sdk/src/openagents/adapters/goose_stream.py).
8
+ *
9
+ * Verified against block/goose v1.38.0. The CLI emits one JSON object per line
10
+ * (NDJSON). StreamEvent is `#[serde(tag="type", rename_all="snake_case")]`:
11
+ *
12
+ * {"type":"message","message":{"role":"assistant"|"user","created":N,"content":[...]}}
13
+ * {"type":"notification","extension_id":"...","log":{"message":"..."}} // or "progress"
14
+ * {"type":"error","error":"..."}
15
+ * {"type":"complete","total_tokens":N,"input_tokens":N,"output_tokens":N}
16
+ *
17
+ * Content items are `#[serde(tag="type", rename_all="camelCase")]`:
18
+ * text {text}, thinking {thinking,signature}, redactedThinking {data},
19
+ * toolRequest {id, toolCall:{status:"success", value:{name, arguments}}|{status:"error", error}},
20
+ * toolResponse {id, toolResult:{status, ...}}, image / systemNotification / ...
21
+ *
22
+ * Important: an agent/provider error during a run is emitted as an `error`
23
+ * event but `goose run` still exits 0. Callers MUST treat `hadError` (or any
24
+ * event of kind `error`) as a failure regardless of the process exit code.
25
+ */
26
+
27
+ 'use strict';
28
+
29
+ // Hard cap on a single un-terminated line we will buffer before giving up.
30
+ const MAX_LINE_BYTES = 8 * 1024 * 1024;
31
+ // Cap on how much of a tool-argument preview we surface as workspace status.
32
+ const TOOL_PREVIEW_LIMIT = 160;
33
+
34
+ /**
35
+ * Mask credentials in arbitrary text.
36
+ * @param {string} text
37
+ * @param {string[]} [secrets] exact secret values to mask (e.g. the API key)
38
+ * @returns {string}
39
+ */
40
+ function redactSecrets(text, secrets) {
41
+ if (!text) return '';
42
+ let out = String(text);
43
+ for (const secret of secrets || []) {
44
+ if (secret && typeof secret === 'string' && secret.length >= 4) {
45
+ out = out.split(secret).join('***');
46
+ }
47
+ }
48
+ out = out.replace(
49
+ /\b(api[_-]?key|secret|token|authorization|auth|bearer)\b\s*[:=]?\s*['"]?([A-Za-z0-9._-]{8,})['"]?/gi,
50
+ (_m, label) => `${label}=***`,
51
+ );
52
+ out = out.replace(/\bsk-[A-Za-z0-9._-]{6,}\b/g, 'sk-***');
53
+ return out;
54
+ }
55
+
56
+ /**
57
+ * Map a raw Goose stderr/error string to a readable, actionable message, or
58
+ * null when there's nothing useful. Callers should still pass the result
59
+ * through redactSecrets.
60
+ * @param {string} text
61
+ * @returns {string|null}
62
+ */
63
+ function classifyGooseError(text) {
64
+ if (!text || !String(text).trim()) return null;
65
+ const low = String(text).toLowerCase();
66
+ const has = (...needles) => needles.some((n) => low.includes(n));
67
+
68
+ if (has('401', '403', 'unauthorized', 'invalid api key', 'invalid_api_key',
69
+ 'authentication', 'incorrect api key', 'no api key', 'missing api key')) {
70
+ return "Goose authentication failed. Check this agent's provider API key "
71
+ + '(GOOSE_PROVIDER__API_KEY) and host — the key may be missing, invalid, or expired.';
72
+ }
73
+ if (has('429', 'rate limit', 'rate_limit', 'too many requests', 'quota', 'overloaded')) {
74
+ return "Goose hit the provider's rate limit or quota. Wait and retry, or use a "
75
+ + 'different key/provider.';
76
+ }
77
+ if (has('no such model', 'unknown model', 'model not found', 'does not exist') || has('model')) {
78
+ if (has('model')) {
79
+ return 'Goose could not use the configured model. Check GOOSE_MODEL is valid for '
80
+ + 'the selected GOOSE_PROVIDER.';
81
+ }
82
+ }
83
+ if (has('provider', 'unknown provider', 'no provider', 'provider not', 'configure')) {
84
+ return 'Goose has no usable provider configured. Set GOOSE_PROVIDER (and a key/host), '
85
+ + 'or run `goose configure` once outside OpenAgents.';
86
+ }
87
+ if (has('permission denied', 'not permitted', 'tool execution denied', 'denied by')) {
88
+ return 'A Goose tool call was denied. The workspace runs Goose headless with '
89
+ + 'GOOSE_MODE=auto; check the project directory permissions.';
90
+ }
91
+ if (has('extension', 'mcp', 'failed to start', 'failed to load')) {
92
+ return 'A Goose extension failed to start. The workspace only enables the built-in '
93
+ + 'developer extension by default; check your Goose extension config.';
94
+ }
95
+ if (has('connection', 'network', 'timed out', 'timeout', 'dns', 'could not resolve',
96
+ 'connection refused', 'unreachable')) {
97
+ return 'Goose could not reach the provider endpoint. Check the network and the '
98
+ + 'GOOSE_PROVIDER__HOST URL.';
99
+ }
100
+ if (has('no such file', 'not a directory', 'os error 13')) {
101
+ return 'Goose hit a filesystem error. Check the agent\'s project directory exists and '
102
+ + 'is writable.';
103
+ }
104
+ return null;
105
+ }
106
+
107
+ class GooseStreamParser {
108
+ constructor() {
109
+ this._buf = '';
110
+ this._pendingFinal = null;
111
+ this._finalText = null;
112
+ this._finalEmitted = false;
113
+ this.hadError = false;
114
+ this.errorMessage = null;
115
+ }
116
+
117
+ get finalText() {
118
+ return this._finalText;
119
+ }
120
+
121
+ /**
122
+ * Feed a raw stdout chunk; returns semantic events parsed from completed lines.
123
+ * @param {string} chunk
124
+ * @returns {Array<object>}
125
+ */
126
+ feed(chunk) {
127
+ const events = [];
128
+ if (!chunk) return events;
129
+ this._buf += chunk;
130
+ if (this._buf.length > MAX_LINE_BYTES && !this._buf.includes('\n')) {
131
+ this._buf = this._buf.slice(-MAX_LINE_BYTES);
132
+ }
133
+ let idx;
134
+ while ((idx = this._buf.indexOf('\n')) >= 0) {
135
+ const line = this._buf.slice(0, idx);
136
+ this._buf = this._buf.slice(idx + 1);
137
+ this._handleLine(line, events);
138
+ }
139
+ return events;
140
+ }
141
+
142
+ /** Flush at EOF; emits the final answer if the stream ended without `complete`. */
143
+ finish() {
144
+ const events = [];
145
+ const leftover = this._buf;
146
+ this._buf = '';
147
+ if (leftover.trim()) this._handleLine(leftover, events);
148
+ if (!this._finalEmitted && !this.hadError) this._emitFinal(events);
149
+ return events;
150
+ }
151
+
152
+ _emitFinal(events) {
153
+ if (this._pendingFinal) {
154
+ this._finalText = this._pendingFinal;
155
+ this._pendingFinal = null;
156
+ }
157
+ if (!this._finalEmitted && this._finalText) {
158
+ events.push({ kind: 'final', text: this._finalText });
159
+ this._finalEmitted = true;
160
+ }
161
+ }
162
+
163
+ _handleLine(line, events) {
164
+ line = line.trim();
165
+ if (!line) return;
166
+ let ev;
167
+ try {
168
+ ev = JSON.parse(line);
169
+ } catch {
170
+ return; // half-line / non-JSON noise: ignore defensively
171
+ }
172
+ if (!ev || typeof ev !== 'object' || Array.isArray(ev)) return;
173
+ const etype = ev.type;
174
+ if (etype === 'message') {
175
+ if (ev.message && typeof ev.message === 'object') this._handleMessage(ev.message, events);
176
+ } else if (etype === 'error') {
177
+ const msg = ev.error || 'Goose reported an error';
178
+ this.hadError = true;
179
+ this.errorMessage = String(msg);
180
+ events.push({ kind: 'error', message: String(msg) });
181
+ } else if (etype === 'complete') {
182
+ if (!this.hadError) this._emitFinal(events);
183
+ events.push({ kind: 'complete', tokens: ev.total_tokens ?? null });
184
+ } else if (etype === 'notification') {
185
+ const text = GooseStreamParser._notificationText(ev);
186
+ if (text) events.push({ kind: 'notification', text });
187
+ }
188
+ // Unknown event types are ignored on purpose (forward-compatible).
189
+ }
190
+
191
+ static _notificationText(ev) {
192
+ if (ev.progress && typeof ev.progress === 'object' && ev.progress.message) {
193
+ return String(ev.progress.message);
194
+ }
195
+ if (ev.log && typeof ev.log === 'object' && ev.log.message) {
196
+ return String(ev.log.message);
197
+ }
198
+ return null;
199
+ }
200
+
201
+ _handleMessage(message, events) {
202
+ const role = String(message.role || '').toLowerCase();
203
+ const content = message.content;
204
+ if (!Array.isArray(content)) return;
205
+ const texts = [];
206
+ for (const item of content) {
207
+ if (!item || typeof item !== 'object') continue;
208
+ const itype = item.type;
209
+ if (itype === 'text') {
210
+ if (item.text) texts.push(String(item.text));
211
+ } else if (itype === 'thinking') {
212
+ if (item.thinking) events.push({ kind: 'thinking', text: String(item.thinking) });
213
+ } else if (itype === 'toolRequest') {
214
+ events.push(GooseStreamParser._toolEvent(item));
215
+ } else if (itype === 'toolResponse') {
216
+ events.push(GooseStreamParser._toolResultEvent(item));
217
+ }
218
+ // image / redactedThinking / systemNotification / etc → ignore
219
+ }
220
+ if (role === 'assistant' && texts.length) {
221
+ const joined = texts.filter(Boolean).join('\n').trim();
222
+ if (joined) {
223
+ // Intermediate assistant text is interim narration (assistant output),
224
+ // not model-internal reasoning — surface it as `progress` (→ status),
225
+ // NOT `thinking`. Only genuine `thinking` content items emit `thinking`.
226
+ if (this._pendingFinal) events.push({ kind: 'progress', text: this._pendingFinal });
227
+ this._pendingFinal = joined;
228
+ }
229
+ }
230
+ }
231
+
232
+ static _toolEvent(item) {
233
+ const toolCall = item.toolCall;
234
+ if (!toolCall || typeof toolCall !== 'object') {
235
+ return { kind: 'tool', name: 'tool', summary: '' };
236
+ }
237
+ if (toolCall.status === 'error') {
238
+ return { kind: 'tool', name: 'tool', summary: '(tool call could not be parsed)' };
239
+ }
240
+ const value = toolCall.value;
241
+ if (!value || typeof value !== 'object') {
242
+ return { kind: 'tool', name: 'tool', summary: '' };
243
+ }
244
+ const name = String(value.name || 'tool');
245
+ return { kind: 'tool', name, summary: GooseStreamParser._summarizeArgs(name, value.arguments) };
246
+ }
247
+
248
+ static _toolResultEvent(item) {
249
+ const tr = item.toolResult;
250
+ const ok = !!(tr && typeof tr === 'object' && tr.status === 'success');
251
+ return { kind: 'tool_result', ok };
252
+ }
253
+
254
+ static _summarizeArgs(_name, args) {
255
+ if (!args || typeof args !== 'object') return '';
256
+ for (const key of ['command', 'path', 'file_path', 'file', 'pattern', 'query', 'uri', 'url']) {
257
+ const val = args[key];
258
+ if (typeof val === 'string' && val) {
259
+ let preview = val.split(/\s+/).join(' ');
260
+ if (preview.length > TOOL_PREVIEW_LIMIT) preview = preview.slice(0, TOOL_PREVIEW_LIMIT) + '…';
261
+ return preview;
262
+ }
263
+ }
264
+ return Object.keys(args).filter((k) => typeof k === 'string').slice(0, 6).join(', ');
265
+ }
266
+ }
267
+
268
+ module.exports = { GooseStreamParser, redactSecrets, classifyGooseError };