@openagents-org/agent-launcher 0.2.176 → 0.2.178

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.
@@ -21,6 +21,7 @@ const https = require('https');
21
21
 
22
22
  const { whereBinary } = require('../paths');
23
23
  const BaseAdapter = require('./base');
24
+ const { redactSecrets } = require('./utils');
24
25
  const { buildOpenclawSystemPrompt } = require('./workspace-prompt');
25
26
 
26
27
  const IS_WINDOWS = process.platform === 'win32';
@@ -535,18 +536,7 @@ class CodexAdapter extends BaseAdapter {
535
536
 
536
537
  /** Redact secrets (keys, tokens, bearer/authorization, query secrets) from diagnostics. */
537
538
  static _redact(s) {
538
- let out = String(s == null ? '' : s);
539
- out = out
540
- .replace(/\bsk-[A-Za-z0-9_-]{6,}/g, 'sk-[REDACTED]')
541
- .replace(/\b(?:github_pat|gh[pousr])_[A-Za-z0-9_]{10,}/g, '[REDACTED_TOKEN]')
542
- .replace(/\bxox[baprs]-[A-Za-z0-9-]{8,}/g, '[REDACTED_TOKEN]')
543
- .replace(/\bAKIA[0-9A-Z]{12,}/g, '[REDACTED_KEY]')
544
- .replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g, '[REDACTED_JWT]')
545
- .replace(/(authorization|api[_-]?key|x-api-key|token|bearer|secret|password|passwd)(["'\s:=]+)([^\s"',}]+)/gi,
546
- (m, k, sep) => `${k}${sep}[REDACTED]`)
547
- .replace(/([?&](?:api[_-]?key|key|token|access_token)=)[^&\s"']+/gi, '$1[REDACTED]')
548
- .replace(/\b[A-Za-z0-9_-]{40,}\b/g, '[REDACTED]');
549
- return out;
539
+ return redactSecrets(s);
550
540
  }
551
541
 
552
542
  // ------------------------------------------------------------------
@@ -24,6 +24,8 @@ const PiAdapter = require('./pi');
24
24
  const DeepSeekAdapter = require('./deepseek');
25
25
  const AntigravityAdapter = require('./antigravity');
26
26
  const CommandCodeAdapter = require('./commandcode');
27
+ const OpenWorkerAdapter = require('./openworker');
28
+ const CodeBuddyAdapter = require('./codebuddy');
27
29
 
28
30
  const ADAPTER_MAP = {
29
31
  openclaw: OpenClawAdapter,
@@ -45,11 +47,13 @@ const ADAPTER_MAP = {
45
47
  deepseek: DeepSeekAdapter,
46
48
  antigravity: AntigravityAdapter,
47
49
  commandcode: CommandCodeAdapter,
50
+ openworker: OpenWorkerAdapter,
51
+ codebuddy: CodeBuddyAdapter,
48
52
  };
49
53
 
50
54
  /**
51
55
  * Create an adapter instance for the given agent type.
52
- * @param {string} type - Agent type (openclaw, claude, codex, opencode, nanoclaw, cursor, hermes, gemini, kimi, aider, goose, copilot, cline, amp, mini-swe-agent, pi, deepseek, antigravity, commandcode)
56
+ * @param {string} type - Agent type (openclaw, claude, codex, opencode, nanoclaw, cursor, hermes, gemini, kimi, aider, goose, copilot, cline, amp, mini-swe-agent, pi, deepseek, antigravity, commandcode, openworker, codebuddy)
53
57
  * @param {object} opts - Adapter constructor options
54
58
  * @returns {BaseAdapter}
55
59
  */
@@ -82,6 +86,7 @@ module.exports = {
82
86
  DeepSeekAdapter,
83
87
  AntigravityAdapter,
84
88
  CommandCodeAdapter,
89
+ OpenWorkerAdapter,
85
90
  createAdapter,
86
91
  ADAPTER_MAP,
87
92
  };
@@ -17,7 +17,7 @@ const crypto = require('crypto');
17
17
  const { execSync, spawn } = require('child_process');
18
18
 
19
19
  const BaseAdapter = require('./base');
20
- const { formatAttachmentsForPrompt } = require('./utils');
20
+ const { formatAttachmentsForPrompt, redactSecrets } = require('./utils');
21
21
  const { buildOpenCodeSkillMd, buildOpenCodeSystemPrompt, workspaceSkillName } = require('./workspace-prompt');
22
22
  const { whichBinary, whereBinary, getEnhancedEnv } = require('../paths');
23
23
 
@@ -1224,18 +1224,7 @@ class OpenCodeAdapter extends BaseAdapter {
1224
1224
 
1225
1225
  /** Redact secrets (keys, tokens, bearer/authorization, query secrets) from diagnostics. */
1226
1226
  static _redact(s) {
1227
- let out = String(s == null ? '' : s);
1228
- out = out
1229
- .replace(/\bsk-[A-Za-z0-9_-]{6,}/g, 'sk-[REDACTED]')
1230
- .replace(/\b(?:github_pat|gh[pousr])_[A-Za-z0-9_]{10,}/g, '[REDACTED_TOKEN]')
1231
- .replace(/\bxox[baprs]-[A-Za-z0-9-]{8,}/g, '[REDACTED_TOKEN]')
1232
- .replace(/\bAKIA[0-9A-Z]{12,}/g, '[REDACTED_KEY]')
1233
- .replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g, '[REDACTED_JWT]')
1234
- .replace(/(authorization|api[_-]?key|x-api-key|token|bearer|secret|password|passwd)(["'\s:=]+)([^\s"',}]+)/gi,
1235
- (m, k, sep) => `${k}${sep}[REDACTED]`)
1236
- .replace(/([?&](?:api[_-]?key|key|token|access_token)=)[^&\s"']+/gi, '$1[REDACTED]')
1237
- .replace(/\b[A-Za-z0-9_-]{40,}\b/g, '[REDACTED]');
1238
- return out;
1227
+ return redactSecrets(s);
1239
1228
  }
1240
1229
  }
1241
1230
 
@@ -0,0 +1,572 @@
1
+ /**
2
+ * Pure helpers for the OpenWorker adapter — no I/O, no process, no sockets, so
3
+ * every decision below is unit-testable on its own.
4
+ *
5
+ * OpenWorker (https://github.com/andrewyng/openworker) is the odd one out in
6
+ * this directory: it has no headless CLI. `openworker` is a Textual TUI and
7
+ * exits with the terminal; the only programmable surface is `openworker-server`,
8
+ * a FastAPI process that speaks REST for state and a WebSocket per session for
9
+ * turns. So instead of an argv + NDJSON contract, the "protocol" this file
10
+ * encodes is:
11
+ *
12
+ * 1. spawn the server on a private port with a token we minted ourselves
13
+ * (`buildServerArgs`, plus COWORKER_API_TOKEN in the child env);
14
+ * 2. open `/ws/session/<id>` with the token as the WebSocket SUBPROTOCOL —
15
+ * the server checks `sec-websocket-protocol`, not a header (`sessionUrl`);
16
+ * 3. send one `user_message` frame and read events until `turn_done`
17
+ * (`interpretEvent`);
18
+ * 4. answer every prompt frame IN LINE (`promptReply`) — this is the part
19
+ * that makes an interactive product usable unattended.
20
+ *
21
+ * That fourth point is the one to keep in mind when editing. OpenWorker blocks
22
+ * the turn on a human for approvals, folder grants, questions and plans, and it
23
+ * does so even in `bypass-approvals` mode for its hard floors (settings files,
24
+ * writes outside the workspace root, `.git/hooks`). A prompt we fail to answer
25
+ * is not a degraded turn — it is a turn that never ends. Every branch of
26
+ * `promptReply` therefore returns a frame, and the default for anything we do
27
+ * not recognise is to decline rather than to stay silent.
28
+ */
29
+
30
+ 'use strict';
31
+
32
+ const crypto = require('crypto');
33
+ const path = require('path');
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Providers
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * The providers OpenWorker can be driven with using a single API key (plus an
41
+ * optional endpoint) — i.e. everything we can configure from one launcher form.
42
+ *
43
+ * `bedrock`, `vertex` and `ark-agent-plan-cn` are deliberately absent: they take
44
+ * a multi-field credential form (role ARNs, service-account JSON, region) that
45
+ * has no honest single-key mapping. A user who needs one points
46
+ * OPENWORKER_STATE_DIR at a state dir where the desktop app already stored it.
47
+ *
48
+ * `envKey` is the environment variable OpenWorker's own provider code reads
49
+ * FIRST, ahead of its secret store (see `providers/openai_provider.resolve_api_key`
50
+ * and its per-provider siblings). Passing the key that way keeps it out of any
51
+ * file we write.
52
+ */
53
+ const PROVIDERS = {
54
+ openai: { envKey: 'OPENAI_API_KEY', needsKey: true },
55
+ anthropic: { envKey: 'ANTHROPIC_API_KEY', needsKey: true },
56
+ gemini: { envKey: 'GEMINI_API_KEY', needsKey: true },
57
+ deepseek: { envKey: 'DEEPSEEK_API_KEY', needsKey: true },
58
+ kimi: { envKey: 'MOONSHOT_API_KEY', needsKey: true },
59
+ qwen: { envKey: 'DASHSCOPE_API_KEY', needsKey: true },
60
+ minimax: { envKey: 'MINIMAX_API_KEY', needsKey: true },
61
+ xai: { envKey: 'XAI_API_KEY', needsKey: true },
62
+ mistral: { envKey: 'MISTRAL_API_KEY', needsKey: true },
63
+ together: { envKey: 'TOGETHER_API_KEY', needsKey: true },
64
+ fireworks: { envKey: 'FIREWORKS_API_KEY', needsKey: true },
65
+ openrouter: { envKey: 'OPENROUTER_API_KEY', needsKey: true },
66
+ zai: { envKey: 'ZAI_API_KEY', needsKey: true },
67
+ ark: { envKey: 'ARK_API_KEY', needsKey: true },
68
+ meta: { envKey: 'META_API_KEY', needsKey: true },
69
+ // Local server: no key at all, just a URL.
70
+ ollama: { envKey: null, needsKey: false },
71
+ // A ChatGPT subscription. The OAuth tokens live in OpenWorker's own secret
72
+ // store and are minted by a browser flow we cannot drive, so this only works
73
+ // against a state dir where the desktop app already signed in.
74
+ 'openai-codex': { envKey: null, needsKey: false, needsExistingState: true },
75
+ };
76
+
77
+ const DEFAULT_PROVIDER = 'openai';
78
+
79
+ /** Normalise a user-entered provider to a known id, or '' when unrecognised. */
80
+ function normalizeProvider(raw) {
81
+ const name = String(raw == null ? '' : raw).trim().toLowerCase();
82
+ if (!name || name === 'auto') return '';
83
+ return Object.prototype.hasOwnProperty.call(PROVIDERS, name) ? name : '';
84
+ }
85
+
86
+ /** The env var OpenWorker reads this provider's key from, or null. */
87
+ function providerKeyEnv(provider) {
88
+ const spec = PROVIDERS[normalizeProvider(provider) || DEFAULT_PROVIDER];
89
+ return (spec && spec.envKey) || null;
90
+ }
91
+
92
+ /**
93
+ * Qualify a model id with its provider prefix.
94
+ *
95
+ * OpenWorker's ProviderRouter dispatches on `prefix:rest` and only when `prefix`
96
+ * is a KNOWN provider — a bare `gpt-5.6-sol` routes to the default (OpenAI), and
97
+ * `qwen3-coder:30b` keeps its colon because `qwen3-coder` is not a provider. So:
98
+ *
99
+ * - an id the user already qualified is returned untouched (no double prefix);
100
+ * - anything else gets `<provider>:` so an Anthropic key is never handed a
101
+ * model the OpenAI client will try to serve.
102
+ */
103
+ function qualifyModel(model, provider) {
104
+ const id = String(model == null ? '' : model).trim();
105
+ if (!id) return '';
106
+ const name = normalizeProvider(provider);
107
+ if (!name) return id;
108
+ const head = id.split(':', 1)[0].toLowerCase();
109
+ if (Object.prototype.hasOwnProperty.call(PROVIDERS, head)) return id;
110
+ return `${name}:${id}`;
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Server invocation
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /** Permission modes `openworker-server --mode` accepts. */
118
+ const SERVER_MODES = ['plan', 'interactive', 'auto', 'bypass-approvals', 'auto-approve'];
119
+
120
+ /**
121
+ * The mode the server runs a turn under.
122
+ *
123
+ * `interactive` — OpenWorker's own default — means "ask a human before every
124
+ * consequential tool call", which in a workspace with no human at the socket is
125
+ * a turn that stalls until the watchdog kills it. The workspace's plan mode maps
126
+ * onto OpenWorker's read-only `plan`; everything else runs under the configured
127
+ * mode, defaulting to `bypass-approvals` (its hard floors still hold, and
128
+ * `promptReply` answers whatever gets through them).
129
+ */
130
+ function resolveServerMode(workspaceMode, configured) {
131
+ if (workspaceMode === 'plan') return 'plan';
132
+ const want = String(configured == null ? '' : configured).trim().toLowerCase();
133
+ if (SERVER_MODES.includes(want)) return want;
134
+ return 'bypass-approvals';
135
+ }
136
+
137
+ /** argv for `openworker-server`. */
138
+ function buildServerArgs({ host = '127.0.0.1', port, cwd, model, mode }) {
139
+ const args = ['--host', String(host), '--port', String(port)];
140
+ if (cwd) args.push('--cwd', String(cwd));
141
+ if (model) args.push('--model', String(model));
142
+ if (mode) args.push('--mode', String(mode));
143
+ return args;
144
+ }
145
+
146
+ /**
147
+ * The `secrets.json` OpenWorker reads provider profiles from.
148
+ *
149
+ * The API key is written as a `${VAR}` REFERENCE, never a literal: OpenWorker's
150
+ * SecretStore resolves those from the server process's environment at read time,
151
+ * so the key reaches the provider without ever landing on disk. `base_url` is
152
+ * the one value with no env path in OpenWorker (its provider builders read it
153
+ * from the profile only), which is the whole reason this file exists.
154
+ *
155
+ * Returns null when there is nothing worth writing, so the caller can leave an
156
+ * existing state dir alone.
157
+ */
158
+ function buildSecretsProfiles({ provider, keyRefVar, baseUrl }) {
159
+ const name = normalizeProvider(provider) || DEFAULT_PROVIDER;
160
+ const spec = PROVIDERS[name];
161
+ const profile = {};
162
+ if (spec && spec.needsKey && keyRefVar) profile.api_key = `\${${keyRefVar}}`;
163
+ const url = String(baseUrl == null ? '' : baseUrl).trim();
164
+ if (url) profile.base_url = url;
165
+ if (Object.keys(profile).length === 0) return null;
166
+ return { [`provider:${name}`]: profile };
167
+ }
168
+
169
+ /**
170
+ * A stable 12-hex session id for a workspace channel.
171
+ *
172
+ * OpenWorker mints session ids as `uuid4().hex[:12]` and keys its on-disk
173
+ * conversation store by them, so deriving ours from the channel identity (rather
174
+ * than storing a mapping) is what makes a channel's history survive a daemon
175
+ * restart — the same channel reconnects to the same server-side session.
176
+ */
177
+ function sessionIdFor(workspaceId, agentName, channel) {
178
+ return crypto
179
+ .createHash('sha256')
180
+ .update(`${workspaceId}${agentName}${channel}`)
181
+ .digest('hex')
182
+ .slice(0, 12);
183
+ }
184
+
185
+ /** The session WebSocket URL, with the workspace folder and agent as query. */
186
+ function sessionUrl({ host = '127.0.0.1', port, sessionId, workspace, agent = 'code' }) {
187
+ const url = new URL(`ws://${host}:${port}/ws/session/${encodeURIComponent(sessionId)}`);
188
+ if (workspace) url.searchParams.set('workspace', workspace);
189
+ if (agent) url.searchParams.set('agent', agent);
190
+ return url.toString();
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Event interpretation
195
+ // ---------------------------------------------------------------------------
196
+
197
+ /** The prompt frames that block a turn until they are answered. */
198
+ const PROMPT_EVENTS = {
199
+ permission_required: 'approval',
200
+ directory_requested: 'directory',
201
+ tool_requested: 'tool',
202
+ plan_proposed: 'plan',
203
+ question_requested: 'question',
204
+ team_proposed: 'team',
205
+ items_proposed: 'items',
206
+ };
207
+
208
+ /** Frames that carry no information we act on (progress noise, bookkeeping). */
209
+ const IGNORED_EVENTS = new Set([
210
+ 'assistant_delta',
211
+ 'reasoning_delta',
212
+ 'turn_start',
213
+ 'iteration_end',
214
+ 'compacting',
215
+ 'compacted',
216
+ 'tool_proposed',
217
+ ]);
218
+
219
+ /**
220
+ * Normalise one server frame into something the adapter can switch on.
221
+ *
222
+ * Deliberately total: an unrecognised `type` comes back as `unknown` rather than
223
+ * throwing or being silently dropped, so an upstream addition shows up in the
224
+ * log instead of turning into a stalled turn.
225
+ */
226
+ function interpretEvent(frame) {
227
+ if (!frame || typeof frame !== 'object') return { kind: 'unknown', raw: String(frame) };
228
+ const type = typeof frame.type === 'string' ? frame.type : '';
229
+ const data = frame.data && typeof frame.data === 'object' ? frame.data : {};
230
+
231
+ if (type === 'ready') {
232
+ return {
233
+ kind: 'ready',
234
+ sessionId: str(data.session_id),
235
+ model: str(data.model),
236
+ mode: str(data.mode),
237
+ workspace: str(data.workspace),
238
+ running: !!data.running,
239
+ };
240
+ }
241
+ if (type === 'assistant_message') {
242
+ return { kind: 'text', text: str(data.text), toolCalls: Array.isArray(data.tool_calls) ? data.tool_calls : [] };
243
+ }
244
+ if (type === 'tool_started') return { kind: 'tool', phase: 'started', name: str(data.name) };
245
+ if (type === 'tool_finished') {
246
+ return {
247
+ kind: 'tool',
248
+ phase: 'finished',
249
+ name: str(data.name),
250
+ status: str(data.status),
251
+ reason: str(data.reason),
252
+ };
253
+ }
254
+ if (type === 'turn_end') return { kind: 'turn_end', status: str(data.status) };
255
+ if (type === 'turn_done') return { kind: 'done' };
256
+ if (type === 'interrupted') return { kind: 'interrupted' };
257
+ if (type === 'error') return { kind: 'error', message: str(data.error) || 'the model call failed', errorType: str(data.error_type) };
258
+ if (type === 'input_rejected') return { kind: 'rejected', message: str(data.error) || 'the server rejected the message' };
259
+ if (type === 'mode_notice' || type === 'model_changed') {
260
+ return { kind: 'notice', text: str(data.text) || str(data.title) };
261
+ }
262
+ if (Object.prototype.hasOwnProperty.call(PROMPT_EVENTS, type)) {
263
+ return { kind: 'prompt', prompt: PROMPT_EVENTS[type], data };
264
+ }
265
+ if (IGNORED_EVENTS.has(type)) return { kind: 'ignore', type };
266
+ return { kind: 'unknown', type, raw: truncate(safeJson(frame), 300) };
267
+ }
268
+
269
+ /** A short, human label for the live status ticker. */
270
+ function toolLabel(name) {
271
+ const map = {
272
+ run_shell: 'running a command',
273
+ shell_task_output: 'reading command output',
274
+ shell_task_kill: 'stopping a command',
275
+ read_file: 'reading a file',
276
+ write_file: 'writing a file',
277
+ edit_file: 'editing a file',
278
+ list_directory: 'listing files',
279
+ search: 'searching',
280
+ web_fetch: 'fetching a page',
281
+ web_search: 'searching the web',
282
+ git_status: 'checking git status',
283
+ git_diff: 'reading a diff',
284
+ todo_write: 'updating its plan',
285
+ };
286
+ const key = String(name || '').trim();
287
+ return map[key] || (key ? `${key}...` : 'working...');
288
+ }
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // Answering the prompts
292
+ // ---------------------------------------------------------------------------
293
+
294
+ /** The canned answer to `ask_user`, since no human is on this socket. */
295
+ const QUESTION_ANSWER =
296
+ 'No one is available to answer interactively. Choose the most reasonable option, ' +
297
+ 'proceed, and state the assumption you made in your final reply.';
298
+
299
+ /**
300
+ * True when `target` is inside (or equal to) `root`.
301
+ *
302
+ * Compared on resolved paths with a trailing separator so `/repo-backup` is not
303
+ * read as living inside `/repo`. Case is preserved — a case-insensitive match
304
+ * would grant more than the user asked for on Linux.
305
+ */
306
+ function isInside(root, target) {
307
+ if (!root || !target) return false;
308
+ const base = path.resolve(root);
309
+ const full = path.resolve(target);
310
+ if (base === full) return true;
311
+ return full.startsWith(base.endsWith(path.sep) ? base : base + path.sep);
312
+ }
313
+
314
+ /**
315
+ * The frame that answers a blocking prompt.
316
+ *
317
+ * Every branch returns something. A prompt left unanswered suspends the turn
318
+ * indefinitely (the engine awaits an Inbox resolution), so "we don't know what
319
+ * this is" has to mean "decline", never "ignore".
320
+ */
321
+ function promptReply(event, { planMode = false, workingDir = '', allowToolInstall = false } = {}) {
322
+ if (!event || event.kind !== 'prompt') return null;
323
+ const data = event.data || {};
324
+
325
+ switch (event.prompt) {
326
+ case 'approval':
327
+ // `once` — never `always_*`. A standing rule minted on the agent's behalf
328
+ // would outlive this turn and widen what a later, unrelated message may do.
329
+ return { type: 'approval', decision: planMode ? 'deny' : 'once' };
330
+
331
+ case 'directory': {
332
+ // The agent is asking to reach outside its workspace root. Grant only what
333
+ // is already inside the working directory it was given (a redundant ask,
334
+ // which does happen) and decline the rest: widening an unattended agent's
335
+ // filesystem reach is exactly the decision a human is supposed to make.
336
+ const requested = str(data.path);
337
+ const granted = !planMode && isInside(workingDir, requested);
338
+ return {
339
+ type: 'directory_response',
340
+ granted,
341
+ path: requested,
342
+ writable: granted ? !!data.writable : false,
343
+ };
344
+ }
345
+
346
+ case 'tool':
347
+ // `request_tool` installs a pinned third-party binary. Declining is a
348
+ // first-class outcome upstream — the agent is told to fall back and say so
349
+ // — which is a better default than installing software unattended.
350
+ return { type: 'tool_response', approved: !!allowToolInstall && !planMode };
351
+
352
+ case 'plan':
353
+ // In plan mode the plan IS the deliverable: approving would flip the live
354
+ // session out of read-only and let it start editing. Decline with the
355
+ // reason so the agent finishes by writing the plan out.
356
+ return planMode
357
+ ? {
358
+ type: 'plan_response',
359
+ approved: false,
360
+ feedback:
361
+ 'This workspace channel is in plan mode. Do not execute the plan — ' +
362
+ 'reply with it as your final answer.',
363
+ }
364
+ : { type: 'plan_response', approved: true, mode: 'bypass-approvals' };
365
+
366
+ case 'question':
367
+ return { type: 'question_response', answer: QUESTION_ANSWER };
368
+
369
+ case 'team':
370
+ // `propose_team` pre-spawns worker sessions, each burning its own tokens.
371
+ // That is a spend decision, and nobody is here to make it.
372
+ return {
373
+ type: 'team_response',
374
+ approved: false,
375
+ feedback:
376
+ 'Running unattended in an OpenAgents workspace — do the work in this session ' +
377
+ 'instead of staffing a team.',
378
+ };
379
+
380
+ case 'items':
381
+ // Board bookkeeping only: approving creates work items and unblocks the turn.
382
+ return { type: 'items_response', approved: true, feedback: '' };
383
+
384
+ default:
385
+ return null;
386
+ }
387
+ }
388
+
389
+ /** A one-line description of a prompt, for the channel's status ticker. */
390
+ function promptSummary(event) {
391
+ if (!event || event.kind !== 'prompt') return '';
392
+ const d = event.data || {};
393
+ switch (event.prompt) {
394
+ case 'approval':
395
+ return `approving \`${str(d.name) || 'a tool call'}\``;
396
+ case 'directory':
397
+ return `asked for access to ${str(d.path) || 'a folder'}`;
398
+ case 'tool':
399
+ return `asked to install ${str(d.tool) || 'a tool'}`;
400
+ case 'plan':
401
+ return 'proposed a plan';
402
+ case 'question':
403
+ return `asked: ${truncate(str(d.question) || str(d.header), 200)}`;
404
+ case 'team':
405
+ return 'proposed a team of sub-agents';
406
+ case 'items':
407
+ return 'proposed work items';
408
+ default:
409
+ return '';
410
+ }
411
+ }
412
+
413
+ // ---------------------------------------------------------------------------
414
+ // Spawning the server
415
+ // ---------------------------------------------------------------------------
416
+
417
+ /**
418
+ * Shape the command and args for spawning the server binary.
419
+ *
420
+ * `uv tool install` lands an `openworker-server.exe` on Windows, which spawns
421
+ * directly — but a PATH lookup or OPENWORKER_SERVER_BIN can just as easily hand
422
+ * back a `.cmd`/`.bat` wrapper, and Node REFUSES to spawn one of those without a
423
+ * shell (EINVAL, since the CVE-2024-27980 hardening). That throw is synchronous,
424
+ * so it surfaced as a server that "stopped (exit code undefined)" with no
425
+ * output — an unfixable-looking error for what is really a wrapper script.
426
+ *
427
+ * The fix is `cmd.exe /c <bin> <args…>` passed as a real argv array. NOT
428
+ * `shell: true`: that concatenates the args into one command line, so the first
429
+ * state directory containing a space would break the launch.
430
+ *
431
+ * @param {string} bin
432
+ * @param {string[]} [args]
433
+ * @param {string} [platform]
434
+ * @returns {{command: string, args: string[]}}
435
+ */
436
+ function serverSpawnCommand(bin, args = [], platform = process.platform) {
437
+ if (platform === 'win32' && /\.(cmd|bat)$/i.test(String(bin || ''))) {
438
+ return { command: 'cmd.exe', args: ['/c', bin, ...args] };
439
+ }
440
+ return { command: bin, args: [...args] };
441
+ }
442
+
443
+ // ---------------------------------------------------------------------------
444
+ // Failure classification
445
+ // ---------------------------------------------------------------------------
446
+
447
+ /**
448
+ * Turn a dead/unusable server into a sentence the user can act on.
449
+ *
450
+ * OpenWorker publishes no exit-code contract, so unlike the CLI adapters this
451
+ * reads the startup output. The three cases below are the ones that actually
452
+ * happen on a fresh machine, and each has a different fix.
453
+ */
454
+ function classifyServerFailure({ code, signal, stderr = '', spawnError = '' } = {}) {
455
+ const text = `${spawnError}\n${stderr}`;
456
+ if (/ModuleNotFoundError|No module named/i.test(text)) {
457
+ return {
458
+ kind: 'broken_install',
459
+ message:
460
+ 'The OpenWorker install is incomplete (a Python dependency is missing). ' +
461
+ 'Reinstall it with: uv tool install --force git+https://github.com/andrewyng/openworker',
462
+ };
463
+ }
464
+ if (/Address already in use|EADDRINUSE/i.test(text)) {
465
+ return { kind: 'port_busy', message: 'The OpenWorker server could not bind a local port. Try again in a moment.' };
466
+ }
467
+ if (/ENOENT|not found|is not recognized/i.test(text)) {
468
+ return {
469
+ kind: 'not_installed',
470
+ message:
471
+ 'openworker-server was not found. Install OpenWorker with: ' +
472
+ 'uv tool install git+https://github.com/andrewyng/openworker',
473
+ };
474
+ }
475
+ // A spawn that never produced a process has no exit code to report, and
476
+ // saying "exit code undefined" hides the one string that explains it.
477
+ if (spawnError) {
478
+ return {
479
+ kind: 'spawn_failed',
480
+ message: `The OpenWorker server could not be started (${truncate(redactSecrets(String(spawnError)).trim(), 200)}).`,
481
+ };
482
+ }
483
+ const how = signal ? `signal ${signal}` : `exit code ${code}`;
484
+ return {
485
+ kind: 'crashed',
486
+ message: `The OpenWorker server stopped (${how}). ${truncate(redactSecrets(stderr).trim(), 400) || 'No output was captured.'}`,
487
+ };
488
+ }
489
+
490
+ /** A missing/unusable model key, phrased for the provider actually configured. */
491
+ function missingKeyMessage(provider) {
492
+ const name = normalizeProvider(provider) || DEFAULT_PROVIDER;
493
+ const envKey = providerKeyEnv(name);
494
+ if (name === 'ollama') {
495
+ return 'Ollama is selected but no server answered. Start `ollama serve`, or set OPENWORKER_BASE_URL to where it is listening.';
496
+ }
497
+ if (name === 'openai-codex') {
498
+ return (
499
+ 'The ChatGPT-subscription provider needs a sign-in OpenWorker performs in a browser. ' +
500
+ 'Sign in with the OpenWorker desktop app, then point OPENWORKER_STATE_DIR at its state ' +
501
+ 'directory (with the app closed).'
502
+ );
503
+ }
504
+ return `No API key for the "${name}" provider — set OPENWORKER_API_KEY (it is passed to OpenWorker as ${envKey}).`;
505
+ }
506
+
507
+ // ---------------------------------------------------------------------------
508
+ // Small shared utilities
509
+ // ---------------------------------------------------------------------------
510
+
511
+ const SECRET_PATTERNS = [
512
+ /\bsk-[A-Za-z0-9._-]{8,}\b/g,
513
+ /\bBearer\s+[A-Za-z0-9._-]{8,}\b/gi,
514
+ /\b(api[_-]?key|token|secret|password|authorization)\b\s*[:=]\s*["']?[A-Za-z0-9._-]{8,}["']?/gi,
515
+ ];
516
+
517
+ /** Redact obvious secret material from free text before it is logged or shown. */
518
+ function redactSecrets(text) {
519
+ if (text == null) return text;
520
+ let out = String(text);
521
+ for (const re of SECRET_PATTERNS) {
522
+ out = out.replace(re, (m) => {
523
+ const eq = m.match(/\s*[:=]\s*/);
524
+ if (eq) return m.slice(0, m.indexOf(eq[0])) + eq[0] + '[redacted]';
525
+ return /^bearer/i.test(m) ? 'Bearer [redacted]' : '[redacted]';
526
+ });
527
+ }
528
+ return out;
529
+ }
530
+
531
+ /** Truncate with an ellipsis, for previews and diagnostics. */
532
+ function truncate(s, n) {
533
+ s = String(s == null ? '' : s);
534
+ return s.length > n ? s.slice(0, n - 1) + '…' : s;
535
+ }
536
+
537
+ function str(v) {
538
+ return typeof v === 'string' ? v : v == null ? '' : String(v);
539
+ }
540
+
541
+ function safeJson(v) {
542
+ try {
543
+ return JSON.stringify(v);
544
+ } catch {
545
+ return String(v);
546
+ }
547
+ }
548
+
549
+ module.exports = {
550
+ PROVIDERS,
551
+ DEFAULT_PROVIDER,
552
+ SERVER_MODES,
553
+ QUESTION_ANSWER,
554
+ normalizeProvider,
555
+ providerKeyEnv,
556
+ qualifyModel,
557
+ resolveServerMode,
558
+ buildServerArgs,
559
+ buildSecretsProfiles,
560
+ sessionIdFor,
561
+ sessionUrl,
562
+ interpretEvent,
563
+ toolLabel,
564
+ promptReply,
565
+ promptSummary,
566
+ isInside,
567
+ serverSpawnCommand,
568
+ classifyServerFailure,
569
+ missingKeyMessage,
570
+ redactSecrets,
571
+ truncate,
572
+ };