@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
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenWorker adapter for OpenAgents workspace.
|
|
3
|
+
*
|
|
4
|
+
* Bridges OpenWorker (https://github.com/andrewyng/openworker) to an OpenAgents
|
|
5
|
+
* workspace. OpenWorker is a local-first desktop agent, not a headless CLI, so
|
|
6
|
+
* this adapter is shaped unlike every other file in this directory:
|
|
7
|
+
*
|
|
8
|
+
* ONE SERVER, MANY CHANNELS. `openworker-server` is started once per agent and
|
|
9
|
+
* shared by every channel, instead of a subprocess per message. Booting it
|
|
10
|
+
* imports FastAPI, uvicorn, three model SDKs and the MCP client — seconds of
|
|
11
|
+
* startup that must not be paid on every turn.
|
|
12
|
+
*
|
|
13
|
+
* THE TURN RUNS OVER A WEBSOCKET. `/ws/session/<id>` carries the event stream
|
|
14
|
+
* and the reply. We open one socket per turn and close it after `turn_done`:
|
|
15
|
+
* the engine state lives on the server (keyed by session id, persisted to
|
|
16
|
+
* disk), so a channel keeps its history across daemon restarts without us
|
|
17
|
+
* holding sockets open for idle channels.
|
|
18
|
+
*
|
|
19
|
+
* EVERY PROMPT IS ANSWERED IN LINE. OpenWorker suspends a turn on approvals,
|
|
20
|
+
* folder grants, questions and plans — indefinitely, waiting on a human. There
|
|
21
|
+
* is no human on this socket, so `promptReply` answers all of them; an
|
|
22
|
+
* unanswered prompt is a turn that never ends, not a degraded one.
|
|
23
|
+
*
|
|
24
|
+
* ITS STATE DIRECTORY IS OURS, NOT THE USER'S. The server runs with
|
|
25
|
+
* `COWORKER_STATE_DIR` pointed at ~/.openagents, so conversations, secrets and
|
|
26
|
+
* the SQLite stores of an agent the launcher runs never collide with the
|
|
27
|
+
* OpenWorker desktop app the user may also have open — two processes writing
|
|
28
|
+
* one SQLite file is the failure its own `ocw` CLI warns about. A user who
|
|
29
|
+
* needs the desktop app's sign-ins (a ChatGPT subscription, OAuth connectors)
|
|
30
|
+
* points OPENWORKER_STATE_DIR at that directory deliberately.
|
|
31
|
+
*
|
|
32
|
+
* The API key is passed in the server's ENVIRONMENT and referenced from the
|
|
33
|
+
* profile we write as `${OPENWORKER_API_KEY}` — OpenWorker's SecretStore
|
|
34
|
+
* resolves `${VAR}` refs at read time, so the key never lands on disk.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
'use strict';
|
|
38
|
+
|
|
39
|
+
const fs = require('fs');
|
|
40
|
+
const http = require('http');
|
|
41
|
+
const net = require('net');
|
|
42
|
+
const os = require('os');
|
|
43
|
+
const path = require('path');
|
|
44
|
+
const crypto = require('crypto');
|
|
45
|
+
const { spawn, execFileSync } = require('child_process');
|
|
46
|
+
|
|
47
|
+
const BaseAdapter = require('./base');
|
|
48
|
+
const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
|
|
49
|
+
const { buildOpenWorkerSystemPrompt } = require('./workspace-prompt');
|
|
50
|
+
const {
|
|
51
|
+
defaultAgentWorkdir,
|
|
52
|
+
whichBinary,
|
|
53
|
+
whereBinary,
|
|
54
|
+
getEnhancedEnv,
|
|
55
|
+
uvToolBinDirs,
|
|
56
|
+
IS_WINDOWS,
|
|
57
|
+
} = require('../paths');
|
|
58
|
+
const {
|
|
59
|
+
PROVIDERS,
|
|
60
|
+
DEFAULT_PROVIDER,
|
|
61
|
+
normalizeProvider,
|
|
62
|
+
providerKeyEnv,
|
|
63
|
+
qualifyModel,
|
|
64
|
+
resolveServerMode,
|
|
65
|
+
buildServerArgs,
|
|
66
|
+
buildSecretsProfiles,
|
|
67
|
+
sessionIdFor,
|
|
68
|
+
sessionUrl,
|
|
69
|
+
interpretEvent,
|
|
70
|
+
toolLabel,
|
|
71
|
+
promptReply,
|
|
72
|
+
promptSummary,
|
|
73
|
+
classifyServerFailure,
|
|
74
|
+
serverSpawnCommand,
|
|
75
|
+
missingKeyMessage,
|
|
76
|
+
redactSecrets,
|
|
77
|
+
truncate,
|
|
78
|
+
} = require('./openworker-runtime');
|
|
79
|
+
|
|
80
|
+
// The command `uv tool install git+…/openworker` puts on PATH. `openworker`
|
|
81
|
+
// itself is the Textual TUI — never spawn that one, it wants a terminal.
|
|
82
|
+
const SERVER_BIN = 'openworker-server';
|
|
83
|
+
|
|
84
|
+
// uv/pipx key their tool directory by the DISTRIBUTION name, which is `coworker`
|
|
85
|
+
// (see pyproject.toml `[project] name`), not the repo or command name.
|
|
86
|
+
const DIST_NAME = 'coworker';
|
|
87
|
+
|
|
88
|
+
const INSTALL_HINT = 'uv tool install git+https://github.com/andrewyng/openworker';
|
|
89
|
+
|
|
90
|
+
// The server imports FastAPI, uvicorn and three model SDKs before it answers
|
|
91
|
+
// /v1/health. On a cold first run that is genuinely slow, and timing out early
|
|
92
|
+
// reports "not installed" for a server that was seconds from being ready.
|
|
93
|
+
const SERVER_READY_TIMEOUT_MS = 90000;
|
|
94
|
+
const HEALTH_POLL_INTERVAL_MS = 400;
|
|
95
|
+
|
|
96
|
+
// Max wall-clock for one turn, after which we interrupt and report it.
|
|
97
|
+
const TURN_TIMEOUT_MS = 900000; // 15 minutes
|
|
98
|
+
|
|
99
|
+
// Idle watchdog over the event stream, in the same shape the CLI adapters use.
|
|
100
|
+
const WATCHDOG_INTERVAL_MS = 15000;
|
|
101
|
+
const WATCHDOG_NUDGE_AT = 2; // ~30s quiet → "still working"
|
|
102
|
+
const WATCHDOG_MAX = 20; // ~5 min quiet → give up on the turn
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* `ws`, loaded on first use.
|
|
106
|
+
*
|
|
107
|
+
* This module is required through adapters/index.js, which every adapter, the
|
|
108
|
+
* CLI and the daemon load — so a top-level require would turn "the ws package is
|
|
109
|
+
* missing" into "no agent of any type can start". Deferring it keeps the failure
|
|
110
|
+
* where it belongs: on an OpenWorker turn, with a message naming the cause.
|
|
111
|
+
*/
|
|
112
|
+
let _WebSocket = null;
|
|
113
|
+
function loadWebSocket() {
|
|
114
|
+
if (_WebSocket) return _WebSocket;
|
|
115
|
+
_WebSocket = require('ws');
|
|
116
|
+
return _WebSocket;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
class OpenWorkerAdapter extends BaseAdapter {
|
|
120
|
+
/**
|
|
121
|
+
* @param {object} opts - BaseAdapter opts plus:
|
|
122
|
+
* @param {Set} [opts.disabledModules]
|
|
123
|
+
*/
|
|
124
|
+
constructor(opts) {
|
|
125
|
+
super(opts);
|
|
126
|
+
this.disabledModules = opts.disabledModules || new Set();
|
|
127
|
+
|
|
128
|
+
// The shared server: { proc, port, token, stateDir, workingDir, stderr }.
|
|
129
|
+
this._server = null;
|
|
130
|
+
this._serverStarting = null; // in-flight _ensureServer promise, so channels share one boot
|
|
131
|
+
this._serverStopped = false;
|
|
132
|
+
|
|
133
|
+
// channel → { sessionId, workingDir, seeded }
|
|
134
|
+
this._channelSessions = {};
|
|
135
|
+
// channel → the live WebSocket of an in-flight turn
|
|
136
|
+
this._channelSockets = {};
|
|
137
|
+
this._stoppingChannels = new Set();
|
|
138
|
+
|
|
139
|
+
this._sessionsFile = path.join(
|
|
140
|
+
os.homedir(), '.openagents', 'sessions',
|
|
141
|
+
`${this.workspaceId}_${this.agentName}_openworker.json`,
|
|
142
|
+
);
|
|
143
|
+
this._loadSessions();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ------------------------------------------------------------------
|
|
147
|
+
// Session bookkeeping
|
|
148
|
+
// ------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
_loadSessions() {
|
|
151
|
+
try {
|
|
152
|
+
if (fs.existsSync(this._sessionsFile)) {
|
|
153
|
+
const data = JSON.parse(fs.readFileSync(this._sessionsFile, 'utf-8'));
|
|
154
|
+
if (data && typeof data === 'object') {
|
|
155
|
+
Object.assign(this._channelSessions, data);
|
|
156
|
+
this._log(`Loaded ${Object.keys(data).length} OpenWorker session binding(s)`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
this._log('Could not load the OpenWorker session file, starting fresh');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
_saveSessions() {
|
|
165
|
+
try {
|
|
166
|
+
fs.mkdirSync(path.dirname(this._sessionsFile), { recursive: true });
|
|
167
|
+
fs.writeFileSync(this._sessionsFile, JSON.stringify(this._channelSessions));
|
|
168
|
+
} catch {}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The server-side session for a channel, and whether it still needs seeding.
|
|
173
|
+
*
|
|
174
|
+
* The id is DERIVED from the channel (not stored), so a channel reconnects to
|
|
175
|
+
* the same server-side conversation after a restart. What is stored is whether
|
|
176
|
+
* we already seeded that conversation with the workspace prompt, and under
|
|
177
|
+
* which working directory — a changed directory invalidates both the seed and
|
|
178
|
+
* the engine's root, so the session starts again.
|
|
179
|
+
*/
|
|
180
|
+
_sessionFor(channel, workingDir) {
|
|
181
|
+
const sessionId = sessionIdFor(this.workspaceId, this.agentName, channel);
|
|
182
|
+
const entry = this._channelSessions[channel];
|
|
183
|
+
const seeded = !!(entry && entry.seeded && entry.workingDir === workingDir && entry.sessionId === sessionId);
|
|
184
|
+
return { sessionId, seeded };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
_markSeeded(channel, sessionId, workingDir) {
|
|
188
|
+
const prev = this._channelSessions[channel];
|
|
189
|
+
if (prev && prev.seeded && prev.sessionId === sessionId && prev.workingDir === workingDir) return;
|
|
190
|
+
this._channelSessions[channel] = { sessionId, workingDir, seeded: true };
|
|
191
|
+
this._saveSessions();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ------------------------------------------------------------------
|
|
195
|
+
// Configuration
|
|
196
|
+
// ------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
_provider() {
|
|
199
|
+
return normalizeProvider(this.agentEnv.OPENWORKER_PROVIDER) || DEFAULT_PROVIDER;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
_apiKey() {
|
|
203
|
+
return String(this.agentEnv.OPENWORKER_API_KEY || '').trim();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The model for a turn: the workspace's selection wins over the saved env. */
|
|
207
|
+
_model() {
|
|
208
|
+
const raw = String(this.workspaceModel || this.agentEnv.OPENWORKER_MODEL || '').trim();
|
|
209
|
+
return qualifyModel(raw, this._provider());
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Where the server keeps its state.
|
|
214
|
+
*
|
|
215
|
+
* Ours by default, one directory per (workspace, agent) so two agents in the
|
|
216
|
+
* same workspace never share a conversation store. An explicit
|
|
217
|
+
* OPENWORKER_STATE_DIR is honoured verbatim — that is the escape hatch for
|
|
218
|
+
* reusing the desktop app's sign-ins, and we then leave every file in it alone.
|
|
219
|
+
*/
|
|
220
|
+
_stateDir() {
|
|
221
|
+
const override = String(this.agentEnv.OPENWORKER_STATE_DIR || '').trim();
|
|
222
|
+
if (override) return { dir: path.resolve(override.replace(/^~(?=$|\/|\\)/, os.homedir())), owned: false };
|
|
223
|
+
const slug = `${this.workspaceId}_${this.agentName}`.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
224
|
+
return { dir: path.join(os.homedir(), '.openagents', 'openworker', slug), owned: true };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Create the state dir and write the provider profile into it.
|
|
229
|
+
*
|
|
230
|
+
* Only a directory we own is written to. The key is stored as a `${VAR}`
|
|
231
|
+
* reference rather than a literal, so the file carries no secret even at 0600.
|
|
232
|
+
*/
|
|
233
|
+
_provisionStateDir() {
|
|
234
|
+
const { dir, owned } = this._stateDir();
|
|
235
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
236
|
+
if (!owned) return dir;
|
|
237
|
+
|
|
238
|
+
const profiles = buildSecretsProfiles({
|
|
239
|
+
provider: this._provider(),
|
|
240
|
+
keyRefVar: this._apiKey() ? 'OPENWORKER_API_KEY' : '',
|
|
241
|
+
baseUrl: this.agentEnv.OPENWORKER_BASE_URL,
|
|
242
|
+
});
|
|
243
|
+
const file = path.join(dir, 'secrets.json');
|
|
244
|
+
if (!profiles) return dir;
|
|
245
|
+
|
|
246
|
+
// Merge, never clobber: the directory also holds profiles OpenWorker itself
|
|
247
|
+
// wrote (MCP OAuth tokens, connector credentials) and a blind overwrite
|
|
248
|
+
// would sign the agent out of all of them.
|
|
249
|
+
let store = {};
|
|
250
|
+
try {
|
|
251
|
+
if (fs.existsSync(file)) store = JSON.parse(fs.readFileSync(file, 'utf-8')) || {};
|
|
252
|
+
} catch {
|
|
253
|
+
store = {};
|
|
254
|
+
}
|
|
255
|
+
const next = { ...store, ...profiles };
|
|
256
|
+
try {
|
|
257
|
+
fs.writeFileSync(file, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
258
|
+
try { fs.chmodSync(file, 0o600); } catch {}
|
|
259
|
+
} catch (e) {
|
|
260
|
+
this._log(`Could not write the OpenWorker provider profile: ${redactSecrets(e && e.message)}`);
|
|
261
|
+
}
|
|
262
|
+
return dir;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ------------------------------------------------------------------
|
|
266
|
+
// Binary resolution
|
|
267
|
+
// ------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Locate `openworker-server`.
|
|
271
|
+
*
|
|
272
|
+
* OpenWorker ships no npm package and is not on PyPI, so there is no isolated
|
|
273
|
+
* runtime prefix to check first the way the npm-installed agents have. The
|
|
274
|
+
* tiers below are the places `uv tool install`, pipx and the project's own
|
|
275
|
+
* `packaging/setup_dev_env.sh` actually put it.
|
|
276
|
+
*/
|
|
277
|
+
_findServerBinary() {
|
|
278
|
+
const override = String(this.agentEnv.OPENWORKER_SERVER_BIN || '').trim();
|
|
279
|
+
if (override && fs.existsSync(override)) return override;
|
|
280
|
+
|
|
281
|
+
const home = os.homedir();
|
|
282
|
+
const ext = IS_WINDOWS ? '.exe' : '';
|
|
283
|
+
const name = `${SERVER_BIN}${ext}`;
|
|
284
|
+
|
|
285
|
+
// Tier 0: the uv tools venv — the executable lands here on a successful
|
|
286
|
+
// `uv tool install` even when the bin-dir copy or PATH edit did not happen.
|
|
287
|
+
for (const dir of uvToolBinDirs(DIST_NAME)) {
|
|
288
|
+
const candidate = path.join(dir, name);
|
|
289
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Tier 1: PATH, via the codepage-safe lookup (a non-ASCII username would
|
|
293
|
+
// otherwise come back mangled and fail with ENOENT).
|
|
294
|
+
const viaWhere = whereBinary(SERVER_BIN);
|
|
295
|
+
if (viaWhere) return viaWhere;
|
|
296
|
+
|
|
297
|
+
// Tier 2: pipx, and a from-source checkout's venv (setup_dev_env.sh).
|
|
298
|
+
const venvBin = IS_WINDOWS ? 'Scripts' : 'bin';
|
|
299
|
+
const candidates = [
|
|
300
|
+
path.join(home, '.local', 'pipx', 'venvs', DIST_NAME, venvBin, name),
|
|
301
|
+
path.join(home, '.openagents', 'runtimes', 'openworker', 'openworker', '.venv', venvBin, name),
|
|
302
|
+
];
|
|
303
|
+
for (const c of candidates) if (fs.existsSync(c)) return c;
|
|
304
|
+
|
|
305
|
+
// Tier 3: the deep scan of every known bin dir.
|
|
306
|
+
return whichBinary(SERVER_BIN);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Preflight gate, run by the daemon before it joins the workspace: no server
|
|
311
|
+
* binary means every message would fail identically, so say so once here
|
|
312
|
+
* instead of once per message.
|
|
313
|
+
*/
|
|
314
|
+
preflight() {
|
|
315
|
+
if (this._findServerBinary()) return { ok: true };
|
|
316
|
+
return {
|
|
317
|
+
ok: false,
|
|
318
|
+
reason: 'runtime_missing',
|
|
319
|
+
message: `OpenWorker is not installed (${SERVER_BIN} not found). Install it with: ${INSTALL_HINT}`,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ------------------------------------------------------------------
|
|
324
|
+
// Server lifecycle
|
|
325
|
+
// ------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
/** An ephemeral port the OS just told us is free. */
|
|
328
|
+
_freePort() {
|
|
329
|
+
return new Promise((resolve, reject) => {
|
|
330
|
+
const srv = net.createServer();
|
|
331
|
+
srv.once('error', reject);
|
|
332
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
333
|
+
const { port } = srv.address();
|
|
334
|
+
srv.close(() => resolve(port));
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** The environment the server runs under. */
|
|
340
|
+
_serverEnv(stateDir, token) {
|
|
341
|
+
const env = getEnhancedEnv(this.agentEnv);
|
|
342
|
+
env.COWORKER_STATE_DIR = stateDir;
|
|
343
|
+
// Supplying the token ourselves means the server never writes a
|
|
344
|
+
// `sidecar-<port>.token` file, so there is no file to race with or clean up.
|
|
345
|
+
env.COWORKER_API_TOKEN = token;
|
|
346
|
+
// Die with the daemon, even on a kill that skips our own cleanup.
|
|
347
|
+
env.COWORKER_EXIT_WITH_PARENT = '1';
|
|
348
|
+
env.COWORKER_PARENT_PID = String(process.pid);
|
|
349
|
+
|
|
350
|
+
const key = this._apiKey();
|
|
351
|
+
if (key) {
|
|
352
|
+
// Two destinations, both needed: OpenWorker's provider code reads the
|
|
353
|
+
// vendor variable first, and the profile we wrote references
|
|
354
|
+
// OPENWORKER_API_KEY for the providers that only look at the store.
|
|
355
|
+
env.OPENWORKER_API_KEY = key;
|
|
356
|
+
const vendorVar = providerKeyEnv(this._provider());
|
|
357
|
+
if (vendorVar) env[vendorVar] = key;
|
|
358
|
+
}
|
|
359
|
+
return env;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Start the shared server (or return the running one).
|
|
364
|
+
*
|
|
365
|
+
* Concurrent channels funnel through a single in-flight promise: two messages
|
|
366
|
+
* arriving together must not race two servers onto two ports.
|
|
367
|
+
*/
|
|
368
|
+
async _ensureServer(workingDir) {
|
|
369
|
+
if (this._server && this._server.proc && this._server.proc.exitCode === null
|
|
370
|
+
&& this._server.workingDir === workingDir) {
|
|
371
|
+
return this._server;
|
|
372
|
+
}
|
|
373
|
+
if (this._serverStarting) return this._serverStarting;
|
|
374
|
+
|
|
375
|
+
this._serverStarting = this._startServer(workingDir).finally(() => {
|
|
376
|
+
this._serverStarting = null;
|
|
377
|
+
});
|
|
378
|
+
return this._serverStarting;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async _startServer(workingDir) {
|
|
382
|
+
// A working directory change re-roots every session, so the old server goes.
|
|
383
|
+
if (this._server) this._stopServer();
|
|
384
|
+
|
|
385
|
+
const bin = this._findServerBinary();
|
|
386
|
+
if (!bin) {
|
|
387
|
+
const err = new Error(`openworker-server not found. Install OpenWorker with: ${INSTALL_HINT}`);
|
|
388
|
+
err.userMessage = err.message;
|
|
389
|
+
throw err;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const stateDir = this._provisionStateDir();
|
|
393
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
394
|
+
const port = await this._freePort();
|
|
395
|
+
const args = buildServerArgs({
|
|
396
|
+
host: '127.0.0.1',
|
|
397
|
+
port,
|
|
398
|
+
cwd: workingDir,
|
|
399
|
+
model: this._model(),
|
|
400
|
+
mode: resolveServerMode('execute', this.agentEnv.OPENWORKER_MODE),
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
this._log(`Starting ${SERVER_BIN} on 127.0.0.1:${port} (cwd=${workingDir}, state=${stateDir})`);
|
|
404
|
+
|
|
405
|
+
// A .cmd/.bat wrapper cannot be spawned directly on Windows — see
|
|
406
|
+
// serverSpawnCommand, which routes it through cmd.exe with a real argv.
|
|
407
|
+
const launch = serverSpawnCommand(bin, args);
|
|
408
|
+
|
|
409
|
+
let proc;
|
|
410
|
+
try {
|
|
411
|
+
proc = spawn(launch.command, launch.args, {
|
|
412
|
+
cwd: workingDir,
|
|
413
|
+
env: this._serverEnv(stateDir, token),
|
|
414
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
415
|
+
detached: !IS_WINDOWS, // own process group, so its own children die with it
|
|
416
|
+
windowsHide: true,
|
|
417
|
+
});
|
|
418
|
+
} catch (e) {
|
|
419
|
+
const verdict = classifyServerFailure({ spawnError: e && e.message });
|
|
420
|
+
const err = new Error(verdict.message);
|
|
421
|
+
err.userMessage = verdict.message;
|
|
422
|
+
throw err;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const server = { proc, port, token, stateDir, workingDir, stderr: '', exited: null };
|
|
426
|
+
this._server = server;
|
|
427
|
+
|
|
428
|
+
// Bounded: uvicorn logs every request, and a long-lived server must not grow
|
|
429
|
+
// an unbounded buffer just so a failure message can quote the tail.
|
|
430
|
+
const capture = (chunk) => {
|
|
431
|
+
server.stderr = truncate(server.stderr + chunk.toString('utf-8'), 8000);
|
|
432
|
+
};
|
|
433
|
+
if (proc.stdout) proc.stdout.on('data', capture);
|
|
434
|
+
if (proc.stderr) proc.stderr.on('data', capture);
|
|
435
|
+
proc.on('error', (e) => { server.spawnError = e && e.message; });
|
|
436
|
+
proc.on('exit', (code, signal) => {
|
|
437
|
+
server.exited = { code, signal };
|
|
438
|
+
if (this._server === server) this._server = null;
|
|
439
|
+
if (!this._serverStopped) this._log(`${SERVER_BIN} exited (code=${code}, signal=${signal})`);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
try {
|
|
443
|
+
await this._waitForHealth(server);
|
|
444
|
+
} catch (e) {
|
|
445
|
+
this._stopServer(server);
|
|
446
|
+
throw e;
|
|
447
|
+
}
|
|
448
|
+
this._log(`${SERVER_BIN} is ready on port ${port}`);
|
|
449
|
+
return server;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Poll /v1/health until the server answers, it dies, or we run out of patience. */
|
|
453
|
+
async _waitForHealth(server) {
|
|
454
|
+
const deadline = Date.now() + SERVER_READY_TIMEOUT_MS;
|
|
455
|
+
for (;;) {
|
|
456
|
+
if (server.exited || server.spawnError) {
|
|
457
|
+
const verdict = classifyServerFailure({
|
|
458
|
+
code: server.exited ? server.exited.code : null,
|
|
459
|
+
signal: server.exited ? server.exited.signal : null,
|
|
460
|
+
stderr: server.stderr,
|
|
461
|
+
spawnError: server.spawnError || '',
|
|
462
|
+
});
|
|
463
|
+
const err = new Error(verdict.message);
|
|
464
|
+
err.userMessage = verdict.message;
|
|
465
|
+
throw err;
|
|
466
|
+
}
|
|
467
|
+
if (await this._probeHealth(server)) return;
|
|
468
|
+
if (Date.now() >= deadline) {
|
|
469
|
+
const message =
|
|
470
|
+
`The OpenWorker server did not start within ${Math.round(SERVER_READY_TIMEOUT_MS / 1000)}s. ` +
|
|
471
|
+
(truncate(redactSecrets(server.stderr).trim(), 400) || 'It produced no output.');
|
|
472
|
+
const err = new Error(message);
|
|
473
|
+
err.userMessage = message;
|
|
474
|
+
throw err;
|
|
475
|
+
}
|
|
476
|
+
await this._sleep(HEALTH_POLL_INTERVAL_MS);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
_probeHealth(server) {
|
|
481
|
+
return new Promise((resolve) => {
|
|
482
|
+
const req = http.request(
|
|
483
|
+
{
|
|
484
|
+
host: '127.0.0.1',
|
|
485
|
+
port: server.port,
|
|
486
|
+
path: '/v1/health',
|
|
487
|
+
method: 'GET',
|
|
488
|
+
headers: { 'X-OpenWorker-Token': server.token },
|
|
489
|
+
timeout: 3000,
|
|
490
|
+
},
|
|
491
|
+
(res) => {
|
|
492
|
+
res.resume();
|
|
493
|
+
resolve(res.statusCode === 200);
|
|
494
|
+
},
|
|
495
|
+
);
|
|
496
|
+
req.on('error', () => resolve(false));
|
|
497
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
498
|
+
req.end();
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Stop the server, escalating from the process group to a hard kill. */
|
|
503
|
+
_stopServer(target) {
|
|
504
|
+
const server = target || this._server;
|
|
505
|
+
if (!server || !server.proc) return;
|
|
506
|
+
const { proc } = server;
|
|
507
|
+
if (this._server === server) this._server = null;
|
|
508
|
+
if (proc.exitCode !== null) return;
|
|
509
|
+
try {
|
|
510
|
+
if (IS_WINDOWS) {
|
|
511
|
+
try { execFileSync('taskkill', ['/F', '/T', '/PID', String(proc.pid)], { timeout: 5000, windowsHide: true }); } catch {}
|
|
512
|
+
} else {
|
|
513
|
+
try { process.kill(-proc.pid, 'SIGTERM'); } catch { try { proc.kill('SIGTERM'); } catch {} }
|
|
514
|
+
// The server's own orphan watchdog also exits it, so a SIGKILL chaser is
|
|
515
|
+
// a belt-and-braces timer rather than the primary path.
|
|
516
|
+
setTimeout(() => {
|
|
517
|
+
if (proc.exitCode === null) {
|
|
518
|
+
try { process.kill(-proc.pid, 'SIGKILL'); } catch { try { proc.kill('SIGKILL'); } catch {} }
|
|
519
|
+
}
|
|
520
|
+
}, 3000).unref();
|
|
521
|
+
}
|
|
522
|
+
} catch {}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ------------------------------------------------------------------
|
|
526
|
+
// Control actions (stop / restart)
|
|
527
|
+
// ------------------------------------------------------------------
|
|
528
|
+
|
|
529
|
+
async _onControlAction(action, payload) {
|
|
530
|
+
if (action === 'stop') {
|
|
531
|
+
const channel = (payload && payload.channel) || null;
|
|
532
|
+
if (channel) {
|
|
533
|
+
await this._stopChannel(channel, 'Execution stopped.');
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
await this._stopAllChannels();
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
return super._onControlAction(action, payload);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
stop() {
|
|
543
|
+
super.stop();
|
|
544
|
+
this._serverStopped = true;
|
|
545
|
+
void this._stopAllChannels('Agent stopped.').finally(() => this._stopServer());
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async _stopChannel(channel, message) {
|
|
549
|
+
const socket = this._channelSockets[channel];
|
|
550
|
+
if (!socket) return;
|
|
551
|
+
this._stoppingChannels.add(channel);
|
|
552
|
+
try { socket.send(JSON.stringify({ type: 'interrupt' })); } catch {}
|
|
553
|
+
// Give the engine a moment to unwind the current tool before the socket goes.
|
|
554
|
+
await this._sleep(500);
|
|
555
|
+
try { socket.close(); } catch {}
|
|
556
|
+
delete this._channelSockets[channel];
|
|
557
|
+
delete this._channelQueues[channel];
|
|
558
|
+
if (message) { try { await this.sendResponse(channel, message); } catch {} }
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async _stopAllChannels(message = 'Execution stopped.') {
|
|
562
|
+
const channels = Object.keys(this._channelSockets);
|
|
563
|
+
if (!channels.length) return;
|
|
564
|
+
this._log(`Stopping ${channels.length} running OpenWorker turn(s)...`);
|
|
565
|
+
for (const channel of channels) await this._stopChannel(channel, message);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ------------------------------------------------------------------
|
|
569
|
+
// Prompt assembly
|
|
570
|
+
// ------------------------------------------------------------------
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* The workspace briefing, prepended to the FIRST message of a session.
|
|
574
|
+
*
|
|
575
|
+
* OpenWorker has no `--append-system-prompt` and no skill directory we could
|
|
576
|
+
* write to without touching the user's own config, so the briefing rides in
|
|
577
|
+
* the conversation itself. That is durable rather than wasteful: the server
|
|
578
|
+
* persists history, so the block is sent once per session and every later turn
|
|
579
|
+
* still has it in context.
|
|
580
|
+
*/
|
|
581
|
+
async _seedPrompt(channel) {
|
|
582
|
+
const browserEnabled = await this.getBrowserEnabled();
|
|
583
|
+
return buildOpenWorkerSystemPrompt({
|
|
584
|
+
agentName: this.agentName,
|
|
585
|
+
workspaceId: this.workspaceId,
|
|
586
|
+
channelName: channel,
|
|
587
|
+
endpoint: this.endpoint,
|
|
588
|
+
token: this.token,
|
|
589
|
+
mode: this._mode,
|
|
590
|
+
disabledModules: this.disabledModules,
|
|
591
|
+
browserEnabled,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Short transcript of recent channel chat, to seed a fresh session. */
|
|
596
|
+
async _buildChannelRecap(channel, currentMessage) {
|
|
597
|
+
try {
|
|
598
|
+
const messages = await this.client.getRecentMessages(this.workspaceId, channel, this.token, 30);
|
|
599
|
+
if (!messages || messages.length === 0) return null;
|
|
600
|
+
const lines = [];
|
|
601
|
+
for (const m of messages) {
|
|
602
|
+
const mt = m.messageType || 'chat';
|
|
603
|
+
if (mt === 'status' || mt === 'thinking' || mt === 'loading' || mt === 'todos') continue;
|
|
604
|
+
const text = (m.content || '').trim();
|
|
605
|
+
if (!text || text === currentMessage) continue;
|
|
606
|
+
const who = m.senderType === 'human' ? (m.senderName || 'user') : (m.senderName || 'agent');
|
|
607
|
+
lines.push(`[${who}] ${truncate(text, 800)}`);
|
|
608
|
+
}
|
|
609
|
+
if (lines.length === 0) return null;
|
|
610
|
+
return 'Recent conversation in this channel for context:\n\n' + lines.slice(-12).join('\n');
|
|
611
|
+
} catch {
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ------------------------------------------------------------------
|
|
617
|
+
// One turn over the session WebSocket
|
|
618
|
+
// ------------------------------------------------------------------
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Run one turn and resolve with what came back.
|
|
622
|
+
*
|
|
623
|
+
* @returns {Promise<object>} { texts, error, interrupted, userStopped, sent }
|
|
624
|
+
*/
|
|
625
|
+
_runTurn(channel, server, sessionId, workingDir, text, { planMode, model }) {
|
|
626
|
+
return new Promise((resolve) => {
|
|
627
|
+
const url = sessionUrl({
|
|
628
|
+
port: server.port,
|
|
629
|
+
sessionId,
|
|
630
|
+
workspace: workingDir,
|
|
631
|
+
agent: 'code',
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
// Both subprotocols are required. The token is what `_websocket_authenticated`
|
|
635
|
+
// matches against, and `openworker` is what the server ECHOES back on accept
|
|
636
|
+
// — a client that offered only the token would reject the handshake locally
|
|
637
|
+
// because the server picked a protocol it never proposed.
|
|
638
|
+
let socket;
|
|
639
|
+
try {
|
|
640
|
+
const WS = loadWebSocket();
|
|
641
|
+
socket = new WS(url, ['openworker', server.token], { handshakeTimeout: 15000 });
|
|
642
|
+
} catch (e) {
|
|
643
|
+
resolve({ texts: [], error: `Could not open the OpenWorker session: ${redactSecrets(e && e.message)}` });
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
this._channelSockets[channel] = socket;
|
|
647
|
+
|
|
648
|
+
const texts = [];
|
|
649
|
+
let settled = false;
|
|
650
|
+
let error = null;
|
|
651
|
+
let interrupted = false;
|
|
652
|
+
let lastEventAt = Date.now();
|
|
653
|
+
let silences = 0;
|
|
654
|
+
let lastToolName = null;
|
|
655
|
+
let sentMessage = false;
|
|
656
|
+
|
|
657
|
+
const finish = (payload) => {
|
|
658
|
+
if (settled) return;
|
|
659
|
+
settled = true;
|
|
660
|
+
clearInterval(watchdog);
|
|
661
|
+
clearTimeout(timeout);
|
|
662
|
+
if (this._channelSockets[channel] === socket) delete this._channelSockets[channel];
|
|
663
|
+
try { socket.close(); } catch {}
|
|
664
|
+
const userStopped = this._stoppingChannels.has(channel);
|
|
665
|
+
this._stoppingChannels.delete(channel);
|
|
666
|
+
resolve({ texts, error, interrupted, userStopped, sent: sentMessage, ...payload });
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
const send = (frame) => {
|
|
670
|
+
try { socket.send(JSON.stringify(frame)); } catch {}
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
const sendTurn = () => {
|
|
674
|
+
if (sentMessage) return;
|
|
675
|
+
sentMessage = true;
|
|
676
|
+
// Anything observed before this point belongs to the turn we cleared out
|
|
677
|
+
// of the way (see the `ready.running` branch), not to ours — carrying it
|
|
678
|
+
// forward would label a perfectly good answer as interrupted.
|
|
679
|
+
interrupted = false;
|
|
680
|
+
error = null;
|
|
681
|
+
send({ type: 'user_message', text });
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
socket.on('open', () => { lastEventAt = Date.now(); });
|
|
685
|
+
|
|
686
|
+
socket.on('message', (raw) => {
|
|
687
|
+
lastEventAt = Date.now();
|
|
688
|
+
silences = 0;
|
|
689
|
+
|
|
690
|
+
let frame;
|
|
691
|
+
try {
|
|
692
|
+
frame = JSON.parse(raw.toString('utf-8'));
|
|
693
|
+
} catch {
|
|
694
|
+
this._log(`Ignoring an unparseable frame: ${truncate(String(raw), 200)}`);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const ev = interpretEvent(frame);
|
|
699
|
+
switch (ev.kind) {
|
|
700
|
+
case 'ready': {
|
|
701
|
+
// A turn already marked running is a session the previous daemon left
|
|
702
|
+
// mid-flight. Interrupting first is what makes a restart recoverable —
|
|
703
|
+
// otherwise every message on this channel is rejected forever.
|
|
704
|
+
if (ev.running) {
|
|
705
|
+
this._log(`Session ${sessionId} was still running a turn — interrupting it first`);
|
|
706
|
+
send({ type: 'interrupt' });
|
|
707
|
+
return; // the resulting turn_done sends ours
|
|
708
|
+
}
|
|
709
|
+
if (model && ev.model !== model) send({ type: 'set_model', model });
|
|
710
|
+
const wantMode = resolveServerMode(planMode ? 'plan' : 'execute', this.agentEnv.OPENWORKER_MODE);
|
|
711
|
+
if (ev.mode !== wantMode) send({ type: 'set_mode', mode: wantMode });
|
|
712
|
+
sendTurn();
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
case 'text': {
|
|
716
|
+
const body = (ev.text || '').trim();
|
|
717
|
+
if (body) texts.push(body);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
case 'tool': {
|
|
721
|
+
if (ev.phase === 'started' && ev.name && ev.name !== lastToolName) {
|
|
722
|
+
lastToolName = ev.name;
|
|
723
|
+
void this.sendStatus(channel, toolLabel(ev.name)).catch(() => {});
|
|
724
|
+
}
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
case 'prompt': {
|
|
728
|
+
const reply = promptReply(ev, {
|
|
729
|
+
planMode,
|
|
730
|
+
workingDir,
|
|
731
|
+
allowToolInstall: String(this.agentEnv.OPENWORKER_ALLOW_TOOL_INSTALL || '') === '1',
|
|
732
|
+
});
|
|
733
|
+
const summary = promptSummary(ev);
|
|
734
|
+
// Say what was decided on the user's behalf. A question in particular
|
|
735
|
+
// is information they would otherwise never see.
|
|
736
|
+
if (summary) void this.sendStatus(channel, summary).catch(() => {});
|
|
737
|
+
if (reply) send(reply);
|
|
738
|
+
else this._log(`Unanswerable prompt frame (turn may stall): ${ev.prompt}`);
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
case 'error': {
|
|
742
|
+
error = ev.message;
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
case 'rejected': {
|
|
746
|
+
// Not a provider failure: the server refused the frame itself (rate
|
|
747
|
+
// limit, oversized text, a turn already running).
|
|
748
|
+
error = ev.message;
|
|
749
|
+
finish({});
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
case 'interrupted': {
|
|
753
|
+
interrupted = true;
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
case 'notice': {
|
|
757
|
+
if (ev.text) this._log(`Server notice: ${truncate(ev.text, 200)}`);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
case 'done': {
|
|
761
|
+
// A `turn_done` before we sent anything is the interrupt above
|
|
762
|
+
// completing — send the real message now.
|
|
763
|
+
if (!sentMessage) { sendTurn(); return; }
|
|
764
|
+
finish({});
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
case 'unknown': {
|
|
768
|
+
this._log(`Unrecognized frame (ignored): ${ev.raw}`);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
default:
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
socket.on('error', (e) => {
|
|
776
|
+
if (!error) error = `OpenWorker session error: ${redactSecrets(e && e.message)}`;
|
|
777
|
+
finish({});
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
socket.on('close', (code) => {
|
|
781
|
+
if (!settled && !error && code !== 1000 && code !== 1005) {
|
|
782
|
+
error = code === 1008
|
|
783
|
+
? 'The OpenWorker server rejected the session (authentication or origin check failed).'
|
|
784
|
+
: `The OpenWorker session closed unexpectedly (code ${code}).`;
|
|
785
|
+
}
|
|
786
|
+
finish({});
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
const watchdog = setInterval(() => {
|
|
790
|
+
if (Date.now() - lastEventAt < WATCHDOG_INTERVAL_MS) return;
|
|
791
|
+
silences += 1;
|
|
792
|
+
if (silences === WATCHDOG_NUDGE_AT) {
|
|
793
|
+
void this.sendStatus(channel, 'still working...').catch(() => {});
|
|
794
|
+
}
|
|
795
|
+
if (silences >= WATCHDOG_MAX) {
|
|
796
|
+
this._log('No events for ~5 minutes — interrupting a wedged turn');
|
|
797
|
+
send({ type: 'interrupt' });
|
|
798
|
+
if (!error) error = 'The turn produced no output for five minutes and was stopped.';
|
|
799
|
+
finish({});
|
|
800
|
+
}
|
|
801
|
+
}, WATCHDOG_INTERVAL_MS);
|
|
802
|
+
|
|
803
|
+
const timeout = setTimeout(() => {
|
|
804
|
+
this._log(`Turn exceeded ${TURN_TIMEOUT_MS}ms — interrupting`);
|
|
805
|
+
send({ type: 'interrupt' });
|
|
806
|
+
if (!error) error = 'The turn ran past its time limit and was stopped.';
|
|
807
|
+
finish({});
|
|
808
|
+
}, TURN_TIMEOUT_MS);
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
_dirExists(p) {
|
|
813
|
+
try { return fs.statSync(p).isDirectory(); } catch { return false; }
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// ------------------------------------------------------------------
|
|
817
|
+
// Message handling
|
|
818
|
+
// ------------------------------------------------------------------
|
|
819
|
+
|
|
820
|
+
async _handleMessage(msg) {
|
|
821
|
+
let content = (msg.content || '').trim();
|
|
822
|
+
const attachments = msg.attachments || [];
|
|
823
|
+
const attText = formatAttachmentsForPrompt(attachments, 'skills');
|
|
824
|
+
if (attText) content = content ? content + attText : attText.trim();
|
|
825
|
+
if (!content) return;
|
|
826
|
+
|
|
827
|
+
const channel = msg.sessionId || this.channelName;
|
|
828
|
+
this._stoppingChannels.delete(channel);
|
|
829
|
+
const sender = msg.senderName || msg.senderType || 'user';
|
|
830
|
+
this._log(`Processing message from ${sender} in ${channel}: ${redactSecrets(truncate(content, 80))}`);
|
|
831
|
+
|
|
832
|
+
// Resolve the working directory up front. Never silently fall back to the
|
|
833
|
+
// launcher/repo dir — a wrong cwd is a destructive surprise, not a default.
|
|
834
|
+
const workingDir = this.workingDir || defaultAgentWorkdir(this.agentName);
|
|
835
|
+
if (this.workingDir && !this._dirExists(this.workingDir)) {
|
|
836
|
+
await this.sendError(channel, `Working directory does not exist: ${this.workingDir}`);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const provider = this._provider();
|
|
841
|
+
if (!this._apiKey() && PROVIDERS[provider] && PROVIDERS[provider].needsKey) {
|
|
842
|
+
await this.sendError(channel, missingKeyMessage(provider));
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Auto-title + resume-from on first encounter (parity with other adapters).
|
|
847
|
+
if (!this._titledSessions.has(channel)) {
|
|
848
|
+
this._titledSessions.add(channel);
|
|
849
|
+
try {
|
|
850
|
+
const info = await this.client.getSession(this.workspaceId, channel, this.token);
|
|
851
|
+
const title = generateSessionTitle(content);
|
|
852
|
+
if (title && !info.titleManuallySet && SESSION_DEFAULT_RE.test(info.title || '')) {
|
|
853
|
+
await this.client.updateSession(this.workspaceId, channel, this.token, { title, autoTitle: true });
|
|
854
|
+
}
|
|
855
|
+
} catch {}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
await this.sendStatus(channel, 'thinking...');
|
|
859
|
+
|
|
860
|
+
let server;
|
|
861
|
+
try {
|
|
862
|
+
server = await this._ensureServer(workingDir);
|
|
863
|
+
} catch (e) {
|
|
864
|
+
await this.sendError(channel, e && e.userMessage ? e.userMessage : redactSecrets(e && e.message));
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const { sessionId, seeded } = this._sessionFor(channel, workingDir);
|
|
869
|
+
const planMode = this._mode === 'plan';
|
|
870
|
+
|
|
871
|
+
let text = content;
|
|
872
|
+
if (!seeded) {
|
|
873
|
+
const header = await this._seedPrompt(channel);
|
|
874
|
+
const recap = await this._buildChannelRecap(channel, content);
|
|
875
|
+
text = recap
|
|
876
|
+
? `${header}\n\n${recap}\n\n---\n\n${content}`
|
|
877
|
+
: `${header}\n\n${content}`;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const run = await this._runTurn(channel, server, sessionId, workingDir, text, {
|
|
881
|
+
planMode,
|
|
882
|
+
model: this._model(),
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
if (run.userStopped) return;
|
|
886
|
+
|
|
887
|
+
// The briefing is recorded as delivered only once the server actually took
|
|
888
|
+
// the message. A handshake that never connected must not leave the session
|
|
889
|
+
// marked briefed — the agent would then run without ever being told what
|
|
890
|
+
// workspace it is in.
|
|
891
|
+
if (!seeded && run.sent) this._markSeeded(channel, sessionId, workingDir);
|
|
892
|
+
|
|
893
|
+
const reply = run.texts.join('\n\n').trim();
|
|
894
|
+
|
|
895
|
+
if (reply) {
|
|
896
|
+
// A partial answer is still an answer: deliver it with the reason it
|
|
897
|
+
// stopped appended, rather than replacing it with an error.
|
|
898
|
+
const suffix = run.interrupted
|
|
899
|
+
? '_The turn was interrupted before it finished._'
|
|
900
|
+
: run.error
|
|
901
|
+
? `_${redactSecrets(run.error)}_`
|
|
902
|
+
: '';
|
|
903
|
+
try { await this.sendResponse(channel, suffix ? `${reply}\n\n${suffix}` : reply); } catch {}
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if (run.error) {
|
|
908
|
+
try { await this.sendError(channel, redactSecrets(run.error)); } catch {}
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (run.interrupted) {
|
|
913
|
+
try { await this.sendResponse(channel, 'The turn was interrupted before it produced an answer.'); } catch {}
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
try { await this.sendResponse(channel, 'No response generated. Please try again.'); } catch {}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
module.exports = OpenWorkerAdapter;
|