@openagents-org/agent-launcher 0.2.176 → 0.2.177

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.
@@ -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;