@openagents-org/agent-launcher 0.2.100 → 0.2.102

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 +330 -120
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openagents-org/agent-launcher",
3
- "version": "0.2.100",
3
+ "version": "0.2.102",
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);
83
238
  }
84
239
 
240
+ cmd.push('--json', '--dangerously-bypass-approvals-and-sandbox', '--skip-git-repo-check');
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;
276
+ }
277
+ }
278
+ }
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 }];
@@ -137,7 +446,6 @@ class CodexAdapter extends BaseAdapter {
137
446
  let fullText = '';
138
447
  let toolCallText = '';
139
448
  let buffer = '';
140
- let chunkCount = 0;
141
449
  res.on('data', (chunk) => {
142
450
  buffer += chunk.toString('utf-8');
143
451
  const lines = buffer.split('\n');
@@ -153,7 +461,6 @@ class CodexAdapter extends BaseAdapter {
153
461
  if (choices.length > 0) {
154
462
  const delta = choices[0].delta || {};
155
463
  if (delta.content) fullText += delta.content;
156
- // Capture tool call arguments as fallback text
157
464
  if (delta.tool_calls) {
158
465
  for (const tc of delta.tool_calls) {
159
466
  if (tc.function && tc.function.arguments) {
@@ -162,13 +469,10 @@ class CodexAdapter extends BaseAdapter {
162
469
  }
163
470
  }
164
471
  }
165
- chunkCount++;
166
472
  } catch {}
167
473
  }
168
474
  });
169
475
  res.on('end', () => {
170
- this._log(`API response: ${chunkCount} chunks, content=${fullText.length}chars, toolCalls=${toolCallText.length}chars`);
171
- // If model tried tool calls instead of text, extract the message
172
476
  if (!fullText && toolCallText) {
173
477
  try {
174
478
  const args = JSON.parse(toolCallText);
@@ -176,7 +480,6 @@ class CodexAdapter extends BaseAdapter {
176
480
  } catch {
177
481
  fullText = toolCallText;
178
482
  }
179
- this._log(`Extracted from tool_calls: ${fullText.slice(0, 100)}`);
180
483
  }
181
484
  resolve(fullText.trim());
182
485
  });
@@ -187,99 +490,6 @@ class CodexAdapter extends BaseAdapter {
187
490
  req.end();
188
491
  });
189
492
  }
190
-
191
- // ------------------------------------------------------------------
192
- // Subprocess mode (codex exec --json --full-auto)
193
- // ------------------------------------------------------------------
194
-
195
- _findCodexBinary() {
196
- try {
197
- if (IS_WINDOWS) {
198
- const r = execSync('where codex.cmd 2>nul || where codex.exe 2>nul || where codex 2>nul', {
199
- encoding: 'utf-8', timeout: 5000,
200
- });
201
- return r.split(/\r?\n/)[0].trim();
202
- } else {
203
- return execSync('which codex', { encoding: 'utf-8', timeout: 5000 }).trim();
204
- }
205
- } catch {
206
- return null;
207
- }
208
- }
209
-
210
- async _runCodexSubprocess(content, msgChannel) {
211
- const codexBin = this._findCodexBinary();
212
- if (!codexBin) {
213
- await this.sendError(msgChannel, 'codex CLI not found. Install with: npm install -g @openai/codex');
214
- return '';
215
- }
216
-
217
- const context = this._buildSystemContext(msgChannel);
218
- const fullPrompt = `${context}\n\n---\n\n${content}`;
219
-
220
- let cmd = [codexBin, 'exec'];
221
- if (this._codexThreadId) {
222
- cmd.push('resume', this._codexThreadId);
223
- }
224
- cmd.push('--json', '--full-auto', fullPrompt);
225
-
226
- if (IS_WINDOWS && cmd[0].toLowerCase().endsWith('.cmd')) {
227
- cmd = ['cmd.exe', '/c', ...cmd];
228
- }
229
-
230
- return new Promise((resolve) => {
231
- const proc = spawn(cmd[0], cmd.slice(1), {
232
- stdio: ['ignore', 'pipe', 'pipe'],
233
- });
234
-
235
- const responseTexts = [];
236
- let lineBuffer = '';
237
-
238
- proc.stdout.on('data', async (chunk) => {
239
- lineBuffer += chunk.toString('utf-8');
240
- const lines = lineBuffer.split('\n');
241
- lineBuffer = lines.pop();
242
-
243
- for (const line of lines) {
244
- const trimmed = line.trim();
245
- if (!trimmed) continue;
246
- let event;
247
- try { event = JSON.parse(trimmed); } catch { continue; }
248
-
249
- const eventType = event.type;
250
-
251
- if (eventType === 'thread.started') {
252
- if (event.thread_id) this._codexThreadId = event.thread_id;
253
- } else if (eventType === 'item.completed') {
254
- const item = event.item || {};
255
- if (item.type === 'agent_message' && item.text) {
256
- responseTexts.push(item.text);
257
- } else if (item.type === 'command_execution') {
258
- const cmdText = (item.command || '').slice(0, 200);
259
- try { await this.sendStatus(msgChannel, `**Running:** \`${cmdText}\``); } catch {}
260
- } else if (item.type === 'file_change') {
261
- try { await this.sendStatus(msgChannel, `**Editing:** \`${item.filename || ''}\``); } catch {}
262
- }
263
- } else if (eventType === 'turn.failed') {
264
- const error = event.error || {};
265
- this._log(`Codex turn failed: ${error.message || JSON.stringify(error)}`);
266
- }
267
- }
268
- });
269
-
270
- proc.on('exit', (code) => {
271
- if (code !== 0) {
272
- this._log(`Codex CLI exited with code ${code}`);
273
- }
274
- resolve(responseTexts.join('\n').trim());
275
- });
276
-
277
- proc.on('error', (err) => {
278
- this._log(`Codex spawn error: ${err.message}`);
279
- resolve('');
280
- });
281
- });
282
- }
283
493
  }
284
494
 
285
495
  module.exports = CodexAdapter;