@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.
- package/icons/codebuddy.svg +38 -0
- package/icons/openworker.svg +3 -0
- package/package.json +1 -1
- package/registry.json +269 -0
- package/src/adapters/base.js +19 -0
- package/src/adapters/claude.js +36 -5
- package/src/adapters/codebuddy-stream.js +497 -0
- package/src/adapters/codebuddy.js +913 -0
- package/src/adapters/codex.js +2 -12
- package/src/adapters/index.js +6 -1
- package/src/adapters/opencode.js +2 -13
- package/src/adapters/openworker-runtime.js +572 -0
- package/src/adapters/openworker.js +921 -0
- package/src/adapters/utils.js +27 -0
- package/src/adapters/workspace-prompt.js +33 -0
- package/src/install-preflight.js +84 -6
- package/src/installer.js +16 -0
- package/src/paths.js +20 -8
- package/src/probe.js +53 -4
- package/src/utils.js +24 -5
package/src/adapters/codex.js
CHANGED
|
@@ -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
|
-
|
|
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
|
// ------------------------------------------------------------------
|
package/src/adapters/index.js
CHANGED
|
@@ -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
|
};
|
package/src/adapters/opencode.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|