@openagents-org/agent-launcher 0.2.99 → 0.2.101

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/adapters/codex.js +349 -115
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openagents-org/agent-launcher",
3
- "version": "0.2.99",
3
+ "version": "0.2.101",
4
4
  "description": "OpenAgents Launcher — install, configure, and run AI coding agents from your terminal",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -2,15 +2,18 @@
2
2
  * Codex adapter for OpenAgents workspace.
3
3
  *
4
4
  * Bridges OpenAI Codex CLI to an OpenAgents workspace via:
5
- * - Direct HTTP mode for OpenAI-compatible LLM APIs (when OPENAI_API_KEY set)
6
- * - Codex CLI subprocess (exec --json --full-auto) as fallback
5
+ * - Codex CLI subprocess (exec --json --full-auto) as primary mode
6
+ * - Direct HTTP mode for OpenAI-compatible LLM APIs as fallback
7
7
  *
8
- * Direct port of Python: src/openagents/adapters/codex.py
8
+ * Similar to ClaudeAdapter: spawns the CLI per message, processes
9
+ * structured JSON events, maintains session/thread IDs per channel,
10
+ * and sends real-time status updates.
9
11
  */
10
12
 
11
13
  'use strict';
12
14
 
13
15
  const fs = require('fs');
16
+ const os = require('os');
14
17
  const path = require('path');
15
18
  const { execSync, spawn } = require('child_process');
16
19
  const http = require('http');
@@ -30,23 +33,113 @@ class CodexAdapter extends BaseAdapter {
30
33
  constructor(opts) {
31
34
  super(opts);
32
35
  this.disabledModules = opts.disabledModules || new Set();
33
- this._codexThreadId = null;
34
36
 
35
- // Direct LLM API mode
36
37
  const env = this.agentEnv || process.env;
37
38
  this._directApiKey = env.OPENAI_API_KEY || '';
38
39
  this._directBaseUrl = (env.OPENAI_BASE_URL || '').replace(/\/+$/, '');
39
40
  this._directModel = env.CODEX_MODEL || env.OPENCLAW_MODEL || '';
40
- this._directMode = !!(this._directApiKey && this._directBaseUrl);
41
41
 
42
- if (this._directMode) {
43
- this._log(`Direct LLM mode: ${this._directBaseUrl} model=${this._directModel || 'gpt-4o'}`);
42
+ // Per-channel thread tracking (like Claude's session IDs)
43
+ this._channelThreads = {};
44
+ this._channelProcesses = {};
45
+ this._sessionsFile = path.join(
46
+ os.homedir(), '.openagents', 'sessions',
47
+ `${this.workspaceId}_${this.agentName}_codex.json`
48
+ );
49
+ this._loadSessions();
50
+
51
+ // Determine mode: CLI binary preferred, direct API as fallback
52
+ this._codexBin = this._findCodexBinary();
53
+ this._directMode = false;
54
+
55
+ if (this._codexBin) {
56
+ this._log(`CLI mode: ${this._codexBin}`);
57
+ } else if (this._directApiKey && this._directBaseUrl) {
58
+ this._directMode = true;
59
+ this._log(`Direct LLM mode (CLI not found): ${this._directBaseUrl} model=${this._directModel || 'gpt-4o'}`);
60
+ } else {
61
+ this._log('Warning: No codex CLI binary found and no direct API configured');
44
62
  }
45
63
 
46
- // Conversation history
64
+ // Conversation history (direct API mode only)
47
65
  this._conversationHistory = [];
48
66
  }
49
67
 
68
+ // ------------------------------------------------------------------
69
+ // Session persistence (per-channel thread IDs)
70
+ // ------------------------------------------------------------------
71
+
72
+ _loadSessions() {
73
+ try {
74
+ if (fs.existsSync(this._sessionsFile)) {
75
+ const data = JSON.parse(fs.readFileSync(this._sessionsFile, 'utf-8'));
76
+ if (data && typeof data === 'object') {
77
+ Object.assign(this._channelThreads, data);
78
+ this._log(`Loaded ${Object.keys(data).length} thread(s)`);
79
+ }
80
+ }
81
+ } catch {
82
+ this._log('Could not load sessions file, starting fresh');
83
+ }
84
+ }
85
+
86
+ _saveSessions() {
87
+ try {
88
+ const dir = path.dirname(this._sessionsFile);
89
+ fs.mkdirSync(dir, { recursive: true });
90
+ fs.writeFileSync(this._sessionsFile, JSON.stringify(this._channelThreads));
91
+ } catch {}
92
+ }
93
+
94
+ // ------------------------------------------------------------------
95
+ // Find codex binary (multi-tier, like Claude adapter)
96
+ // ------------------------------------------------------------------
97
+
98
+ _findCodexBinary() {
99
+ const home = os.homedir();
100
+ const ext = IS_WINDOWS ? '.cmd' : '';
101
+
102
+ // Tier 0: Isolated runtime prefix (~/.openagents/runtimes/codex/)
103
+ const runtimeCandidate = path.join(home, '.openagents', 'runtimes', 'codex', 'node_modules', '.bin', `codex${ext}`);
104
+ if (fs.existsSync(runtimeCandidate)) return runtimeCandidate;
105
+
106
+ // Tier 0b: Legacy portable install
107
+ const portableCandidate = path.join(home, '.openagents', 'nodejs', 'node_modules', '.bin', `codex${ext}`);
108
+ if (fs.existsSync(portableCandidate)) return portableCandidate;
109
+
110
+ // Tier 1: PATH search
111
+ try {
112
+ if (IS_WINDOWS) {
113
+ const r = execSync('where codex.cmd 2>nul || where codex.exe 2>nul || where codex 2>nul', {
114
+ encoding: 'utf-8', timeout: 5000,
115
+ });
116
+ return r.split(/\r?\n/)[0].trim();
117
+ } else {
118
+ return execSync('which codex', { encoding: 'utf-8', timeout: 5000 }).trim();
119
+ }
120
+ } catch {}
121
+
122
+ // Tier 2: Next to current Node.js interpreter (npm global)
123
+ const nodeBinDir = path.dirname(process.execPath);
124
+ const nearNode = path.join(nodeBinDir, `codex${ext}`);
125
+ if (fs.existsSync(nearNode)) return nearNode;
126
+
127
+ // Tier 3: Common install locations
128
+ const candidates = IS_WINDOWS ? [
129
+ path.join(process.env.APPDATA || '', 'npm', 'codex.cmd'),
130
+ ] : [
131
+ path.join(home, '.local', 'bin', 'codex'),
132
+ path.join(home, '.npm-global', 'bin', 'codex'),
133
+ '/opt/homebrew/bin/codex',
134
+ '/usr/local/bin/codex',
135
+ ];
136
+ for (const c of candidates) {
137
+ if (fs.existsSync(c)) return c;
138
+ }
139
+
140
+ return null;
141
+ }
142
+
50
143
  _buildSystemContext(channelName) {
51
144
  return buildOpenclawSystemPrompt({
52
145
  agentName: this.agentName,
@@ -59,6 +152,42 @@ class CodexAdapter extends BaseAdapter {
59
152
  });
60
153
  }
61
154
 
155
+ // ------------------------------------------------------------------
156
+ // Process management
157
+ // ------------------------------------------------------------------
158
+
159
+ async _stopProcess(proc) {
160
+ if (!proc || proc.exitCode !== null) return;
161
+ try {
162
+ if (IS_WINDOWS) {
163
+ try { execSync(`taskkill /F /T /PID ${proc.pid}`, { timeout: 5000 }); } catch {}
164
+ } else {
165
+ try { process.kill(-proc.pid, 'SIGTERM'); } catch {
166
+ proc.kill('SIGTERM');
167
+ }
168
+ await new Promise((resolve) => {
169
+ const timeout = setTimeout(() => {
170
+ try { process.kill(-proc.pid, 'SIGKILL'); } catch {
171
+ proc.kill('SIGKILL');
172
+ }
173
+ resolve();
174
+ }, 5000);
175
+ proc.on('exit', () => { clearTimeout(timeout); resolve(); });
176
+ });
177
+ }
178
+ } catch {}
179
+ }
180
+
181
+ async _onControlAction(action, _payload) {
182
+ if (action === 'stop') {
183
+ for (const [channel, proc] of Object.entries(this._channelProcesses)) {
184
+ await this._stopProcess(proc);
185
+ delete this._channelProcesses[channel];
186
+ try { await this.sendStatus(channel, 'Execution stopped by user'); } catch {}
187
+ }
188
+ }
189
+ }
190
+
62
191
  // ------------------------------------------------------------------
63
192
  // Message handler
64
193
  // ------------------------------------------------------------------
@@ -74,14 +203,198 @@ class CodexAdapter extends BaseAdapter {
74
203
  await this._autoTitleChannel(msgChannel, content);
75
204
  await this.sendStatus(msgChannel, 'thinking...');
76
205
 
77
- try {
78
- let responseText;
79
- if (this._directMode) {
80
- responseText = await this._callCompletionApi(content, msgChannel);
81
- } else {
82
- responseText = await this._runCodexSubprocess(content, msgChannel);
206
+ if (this._codexBin) {
207
+ await this._handleViaSubprocess(content, msgChannel);
208
+ } else if (this._directMode) {
209
+ await this._handleViaDirectApi(content, msgChannel);
210
+ } else {
211
+ await this.sendError(msgChannel, 'codex CLI not found. Install with: npm install -g @openai/codex');
212
+ }
213
+ }
214
+
215
+ // ------------------------------------------------------------------
216
+ // CLI subprocess mode (primary)
217
+ // ------------------------------------------------------------------
218
+
219
+ async _handleViaSubprocess(content, msgChannel) {
220
+ const env = { ...(this.agentEnv || process.env) };
221
+
222
+ // Set model via env if configured
223
+ if (this._directModel) env.CODEX_MODEL = this._directModel;
224
+ if (this._directApiKey) env.OPENAI_API_KEY = this._directApiKey;
225
+ if (this._directBaseUrl) env.OPENAI_BASE_URL = this._directBaseUrl;
226
+
227
+ const context = this._buildSystemContext(msgChannel);
228
+ const fullPrompt = `${context}\n\n---\n\nUser message:\n${content}`;
229
+
230
+ // Run up to 2 attempts: first with resume, then fresh if stale
231
+ for (let attempt = 0; attempt < 2; attempt++) {
232
+ const cmd = [this._codexBin, 'exec'];
233
+
234
+ // Resume existing thread for this channel
235
+ const threadId = this._channelThreads[msgChannel];
236
+ if (threadId && attempt === 0) {
237
+ cmd.push('resume', threadId);
238
+ }
239
+
240
+ cmd.push('--json', '--full-auto');
241
+
242
+ // Model override
243
+ if (this._directModel) {
244
+ cmd.push('-m', this._directModel);
245
+ }
246
+
247
+ // Working directory
248
+ if (this.workingDir) {
249
+ cmd.push('-C', this.workingDir);
250
+ }
251
+
252
+ cmd.push(fullPrompt);
253
+
254
+ this._log(`Spawning: codex exec ${threadId && attempt === 0 ? `resume ${threadId} ` : ''}--json --full-auto -m ${this._directModel || 'default'}`);
255
+
256
+ try {
257
+ const result = await this._spawnCodex(cmd, env, msgChannel);
258
+
259
+ if (result.responseText) {
260
+ await this.sendResponse(msgChannel, result.responseText);
261
+ return;
262
+ } else if (result.exitCode !== 0 && threadId && attempt === 0) {
263
+ // Stale thread — clear and retry fresh
264
+ this._log(`Stale thread detected for ${msgChannel}, clearing and retrying`);
265
+ delete this._channelThreads[msgChannel];
266
+ this._saveSessions();
267
+ continue;
268
+ } else {
269
+ await this.sendResponse(msgChannel, 'No response generated. Please try again.');
270
+ return;
271
+ }
272
+ } catch (e) {
273
+ this._log(`Error in subprocess: ${e.message}`);
274
+ await this.sendError(msgChannel, `Error: ${e.message}`);
275
+ return;
83
276
  }
277
+ }
278
+ }
84
279
 
280
+ async _spawnCodex(cmd, env, msgChannel) {
281
+ return new Promise((resolve, reject) => {
282
+ const proc = spawn(cmd[0], cmd.slice(1), {
283
+ stdio: ['ignore', 'pipe', 'pipe'],
284
+ env,
285
+ cwd: this.workingDir,
286
+ detached: !IS_WINDOWS,
287
+ windowsHide: true,
288
+ });
289
+ this._channelProcesses[msgChannel] = proc;
290
+
291
+ const responseTexts = [];
292
+ let hasToolUseSinceLastText = false;
293
+ let lineBuffer = '';
294
+ let stderrBuf = '';
295
+ let _pendingLines = Promise.resolve();
296
+
297
+ if (proc.stderr) {
298
+ proc.stderr.on('data', (chunk) => { stderrBuf += chunk.toString('utf-8'); });
299
+ }
300
+
301
+ const processLine = async (line) => {
302
+ line = line.trim();
303
+ if (!line) return;
304
+
305
+ let event;
306
+ try { event = JSON.parse(line); } catch { return; }
307
+
308
+ const eventType = event.type;
309
+
310
+ if (eventType === 'thread.started') {
311
+ if (event.thread_id) {
312
+ this._channelThreads[msgChannel] = event.thread_id;
313
+ this._saveSessions();
314
+ this._log(`Thread started: ${event.thread_id}`);
315
+ }
316
+ } else if (eventType === 'item.completed') {
317
+ const item = event.item || {};
318
+ if (item.type === 'agent_message' && item.text) {
319
+ if (hasToolUseSinceLastText) {
320
+ responseTexts.length = 0;
321
+ hasToolUseSinceLastText = false;
322
+ }
323
+ responseTexts.push(item.text);
324
+ // Stream as thinking (like Claude adapter)
325
+ try { await this.sendThinking(msgChannel, item.text); } catch {}
326
+ } else if (item.type === 'command_execution') {
327
+ hasToolUseSinceLastText = true;
328
+ const cmdText = (item.command || '').slice(0, 200);
329
+ const exitCode = item.exit_code;
330
+ const output = (item.output || '').slice(0, 500);
331
+ let status = `**Running:** \`${cmdText}\``;
332
+ if (exitCode !== undefined && exitCode !== null) {
333
+ status += ` (exit ${exitCode})`;
334
+ }
335
+ try { await this.sendStatus(msgChannel, status); } catch {}
336
+ this._log(`Command: ${cmdText} → exit ${exitCode}`);
337
+ } else if (item.type === 'file_change') {
338
+ hasToolUseSinceLastText = true;
339
+ const filename = item.filename || '';
340
+ try { await this.sendStatus(msgChannel, `**Editing:** \`${filename}\``); } catch {}
341
+ this._log(`File change: ${filename}`);
342
+ }
343
+ } else if (eventType === 'turn.failed') {
344
+ const error = event.error || {};
345
+ const errMsg = error.message || JSON.stringify(error);
346
+ this._log(`Turn failed: ${errMsg}`);
347
+ }
348
+ };
349
+
350
+ proc.stdout.on('data', (chunk) => {
351
+ lineBuffer += chunk.toString('utf-8');
352
+ const lines = lineBuffer.split('\n');
353
+ lineBuffer = lines.pop();
354
+ for (const line of lines) {
355
+ _pendingLines = _pendingLines.then(() => processLine(line)).catch(() => {});
356
+ }
357
+ });
358
+
359
+ proc.on('exit', async (code) => {
360
+ // Wait for all in-flight processLine calls
361
+ try { await _pendingLines; } catch {}
362
+
363
+ // Process remaining buffer
364
+ for (const line of lineBuffer.split('\n')) {
365
+ try { await processLine(line); } catch {}
366
+ }
367
+
368
+ delete this._channelProcesses[msgChannel];
369
+
370
+ if (code !== 0) {
371
+ this._log(`Codex CLI exited with code ${code}`);
372
+ if (stderrBuf.trim()) {
373
+ this._log(`stderr: ${stderrBuf.trim().slice(0, 500)}`);
374
+ }
375
+ }
376
+
377
+ resolve({
378
+ responseText: responseTexts.join('\n').trim(),
379
+ exitCode: code,
380
+ stderr: stderrBuf,
381
+ });
382
+ });
383
+
384
+ proc.on('error', (err) => {
385
+ delete this._channelProcesses[msgChannel];
386
+ reject(err);
387
+ });
388
+ });
389
+ }
390
+
391
+ // ------------------------------------------------------------------
392
+ // Direct HTTP mode (fallback when CLI not available)
393
+ // ------------------------------------------------------------------
394
+
395
+ async _handleViaDirectApi(content, msgChannel) {
396
+ try {
397
+ const responseText = await this._callCompletionApi(content, msgChannel);
85
398
  if (responseText) {
86
399
  this._conversationHistory.push({ role: 'user', content });
87
400
  this._conversationHistory.push({ role: 'assistant', content: responseText });
@@ -93,15 +406,11 @@ class CodexAdapter extends BaseAdapter {
93
406
  await this.sendResponse(msgChannel, 'No response generated. Please try again.');
94
407
  }
95
408
  } catch (e) {
96
- this._log(`Error handling message: ${e.message}`);
97
- await this.sendError(msgChannel, `Error processing message: ${e.message}`);
409
+ this._log(`Error in direct API: ${e.message}`);
410
+ await this.sendError(msgChannel, `Error: ${e.message}`);
98
411
  }
99
412
  }
100
413
 
101
- // ------------------------------------------------------------------
102
- // Direct HTTP mode (OpenAI chat completions API)
103
- // ------------------------------------------------------------------
104
-
105
414
  async _callCompletionApi(userMessage, channel) {
106
415
  const systemPrompt = this._buildSystemContext(channel);
107
416
  const messages = [{ role: 'system', content: systemPrompt }];
@@ -135,6 +444,7 @@ class CodexAdapter extends BaseAdapter {
135
444
  }
136
445
 
137
446
  let fullText = '';
447
+ let toolCallText = '';
138
448
  let buffer = '';
139
449
  res.on('data', (chunk) => {
140
450
  buffer += chunk.toString('utf-8');
@@ -151,11 +461,28 @@ class CodexAdapter extends BaseAdapter {
151
461
  if (choices.length > 0) {
152
462
  const delta = choices[0].delta || {};
153
463
  if (delta.content) fullText += delta.content;
464
+ if (delta.tool_calls) {
465
+ for (const tc of delta.tool_calls) {
466
+ if (tc.function && tc.function.arguments) {
467
+ toolCallText += tc.function.arguments;
468
+ }
469
+ }
470
+ }
154
471
  }
155
472
  } catch {}
156
473
  }
157
474
  });
158
- res.on('end', () => resolve(fullText.trim()));
475
+ res.on('end', () => {
476
+ if (!fullText && toolCallText) {
477
+ try {
478
+ const args = JSON.parse(toolCallText);
479
+ fullText = args.command || args.input || args.content || args.text || toolCallText;
480
+ } catch {
481
+ fullText = toolCallText;
482
+ }
483
+ }
484
+ resolve(fullText.trim());
485
+ });
159
486
  });
160
487
 
161
488
  req.on('error', reject);
@@ -163,99 +490,6 @@ class CodexAdapter extends BaseAdapter {
163
490
  req.end();
164
491
  });
165
492
  }
166
-
167
- // ------------------------------------------------------------------
168
- // Subprocess mode (codex exec --json --full-auto)
169
- // ------------------------------------------------------------------
170
-
171
- _findCodexBinary() {
172
- try {
173
- if (IS_WINDOWS) {
174
- const r = execSync('where codex.cmd 2>nul || where codex.exe 2>nul || where codex 2>nul', {
175
- encoding: 'utf-8', timeout: 5000,
176
- });
177
- return r.split(/\r?\n/)[0].trim();
178
- } else {
179
- return execSync('which codex', { encoding: 'utf-8', timeout: 5000 }).trim();
180
- }
181
- } catch {
182
- return null;
183
- }
184
- }
185
-
186
- async _runCodexSubprocess(content, msgChannel) {
187
- const codexBin = this._findCodexBinary();
188
- if (!codexBin) {
189
- await this.sendError(msgChannel, 'codex CLI not found. Install with: npm install -g @openai/codex');
190
- return '';
191
- }
192
-
193
- const context = this._buildSystemContext(msgChannel);
194
- const fullPrompt = `${context}\n\n---\n\n${content}`;
195
-
196
- let cmd = [codexBin, 'exec'];
197
- if (this._codexThreadId) {
198
- cmd.push('resume', this._codexThreadId);
199
- }
200
- cmd.push('--json', '--full-auto', fullPrompt);
201
-
202
- if (IS_WINDOWS && cmd[0].toLowerCase().endsWith('.cmd')) {
203
- cmd = ['cmd.exe', '/c', ...cmd];
204
- }
205
-
206
- return new Promise((resolve) => {
207
- const proc = spawn(cmd[0], cmd.slice(1), {
208
- stdio: ['ignore', 'pipe', 'pipe'],
209
- });
210
-
211
- const responseTexts = [];
212
- let lineBuffer = '';
213
-
214
- proc.stdout.on('data', async (chunk) => {
215
- lineBuffer += chunk.toString('utf-8');
216
- const lines = lineBuffer.split('\n');
217
- lineBuffer = lines.pop();
218
-
219
- for (const line of lines) {
220
- const trimmed = line.trim();
221
- if (!trimmed) continue;
222
- let event;
223
- try { event = JSON.parse(trimmed); } catch { continue; }
224
-
225
- const eventType = event.type;
226
-
227
- if (eventType === 'thread.started') {
228
- if (event.thread_id) this._codexThreadId = event.thread_id;
229
- } else if (eventType === 'item.completed') {
230
- const item = event.item || {};
231
- if (item.type === 'agent_message' && item.text) {
232
- responseTexts.push(item.text);
233
- } else if (item.type === 'command_execution') {
234
- const cmdText = (item.command || '').slice(0, 200);
235
- try { await this.sendStatus(msgChannel, `**Running:** \`${cmdText}\``); } catch {}
236
- } else if (item.type === 'file_change') {
237
- try { await this.sendStatus(msgChannel, `**Editing:** \`${item.filename || ''}\``); } catch {}
238
- }
239
- } else if (eventType === 'turn.failed') {
240
- const error = event.error || {};
241
- this._log(`Codex turn failed: ${error.message || JSON.stringify(error)}`);
242
- }
243
- }
244
- });
245
-
246
- proc.on('exit', (code) => {
247
- if (code !== 0) {
248
- this._log(`Codex CLI exited with code ${code}`);
249
- }
250
- resolve(responseTexts.join('\n').trim());
251
- });
252
-
253
- proc.on('error', (err) => {
254
- this._log(`Codex spawn error: ${err.message}`);
255
- resolve('');
256
- });
257
- });
258
- }
259
493
  }
260
494
 
261
495
  module.exports = CodexAdapter;