@openagents-org/agent-connector 0.1.10 → 0.2.0

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,420 @@
1
+ /**
2
+ * Claude Code adapter for OpenAgents workspace.
3
+ *
4
+ * Bridges Claude Code to an OpenAgents workspace via:
5
+ * - Polling loop for incoming messages
6
+ * - Claude CLI subprocess (stream-json) for task execution
7
+ * - MCP server for workspace tool access
8
+ *
9
+ * Direct port of Python: src/openagents/adapters/claude.py
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ const fs = require('fs');
15
+ const os = require('os');
16
+ const path = require('path');
17
+ const { execSync, spawn } = require('child_process');
18
+
19
+ const BaseAdapter = require('./base');
20
+ const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
21
+ const { buildClaudeSystemPrompt } = require('./workspace-prompt');
22
+
23
+ const IS_WINDOWS = process.platform === 'win32';
24
+
25
+ class ClaudeAdapter extends BaseAdapter {
26
+ /**
27
+ * @param {object} opts - BaseAdapter opts plus:
28
+ * @param {Set} [opts.disabledModules]
29
+ * @param {string} [opts.workingDir]
30
+ */
31
+ constructor(opts) {
32
+ super(opts);
33
+ this.disabledModules = opts.disabledModules || new Set();
34
+ this.workingDir = opts.workingDir || undefined;
35
+ this._channelSessions = {}; // channel → Claude CLI session_id
36
+ this._channelProcesses = {}; // channel → child process
37
+ this._sessionsFile = path.join(
38
+ os.homedir(), '.openagents', 'sessions',
39
+ `${this.workspaceId}_${this.agentName}.json`
40
+ );
41
+ this._loadSessions();
42
+ }
43
+
44
+ _loadSessions() {
45
+ try {
46
+ if (fs.existsSync(this._sessionsFile)) {
47
+ const data = JSON.parse(fs.readFileSync(this._sessionsFile, 'utf-8'));
48
+ if (data && typeof data === 'object') {
49
+ Object.assign(this._channelSessions, data);
50
+ this._log(`Loaded ${Object.keys(data).length} session(s)`);
51
+ }
52
+ }
53
+ } catch {
54
+ this._log('Could not load sessions file, starting fresh');
55
+ }
56
+ }
57
+
58
+ _saveSessions() {
59
+ try {
60
+ const dir = path.dirname(this._sessionsFile);
61
+ fs.mkdirSync(dir, { recursive: true });
62
+ fs.writeFileSync(this._sessionsFile, JSON.stringify(this._channelSessions));
63
+ } catch {}
64
+ }
65
+
66
+ async _onControlAction(action, _payload) {
67
+ if (action === 'stop') {
68
+ await this._stopAllProcesses();
69
+ }
70
+ }
71
+
72
+ async _stopProcess(proc) {
73
+ if (!proc || proc.exitCode !== null) return;
74
+ try {
75
+ if (IS_WINDOWS) {
76
+ try { execSync(`taskkill /F /T /PID ${proc.pid}`, { timeout: 5000 }); } catch {}
77
+ } else {
78
+ try { process.kill(-proc.pid, 'SIGTERM'); } catch {
79
+ proc.kill('SIGTERM');
80
+ }
81
+ await new Promise((resolve) => {
82
+ const timeout = setTimeout(() => {
83
+ try { process.kill(-proc.pid, 'SIGKILL'); } catch {
84
+ proc.kill('SIGKILL');
85
+ }
86
+ resolve();
87
+ }, 5000);
88
+ proc.on('exit', () => { clearTimeout(timeout); resolve(); });
89
+ });
90
+ }
91
+ } catch {}
92
+ }
93
+
94
+ async _stopAllProcesses() {
95
+ const entries = Object.entries(this._channelProcesses);
96
+ if (!entries.length) return;
97
+ this._log(`Stopping ${entries.length} running process(es)...`);
98
+ for (const [channel, proc] of entries) {
99
+ await this._stopProcess(proc);
100
+ delete this._channelProcesses[channel];
101
+ delete this._channelQueues[channel];
102
+ try {
103
+ await this.sendStatus(channel, 'Execution stopped by user');
104
+ } catch {}
105
+ }
106
+ }
107
+
108
+ _findClaudeBinary() {
109
+ try {
110
+ if (IS_WINDOWS) {
111
+ const r = execSync('where claude.cmd 2>nul || where claude.exe 2>nul || where claude 2>nul', {
112
+ encoding: 'utf-8', timeout: 5000,
113
+ });
114
+ return r.split(/\r?\n/)[0].trim();
115
+ } else {
116
+ return execSync('which claude', { encoding: 'utf-8', timeout: 5000 }).trim();
117
+ }
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ _buildClaudeCmd(prompt, channelName) {
124
+ const claudeBin = this._findClaudeBinary();
125
+ if (!claudeBin) {
126
+ throw new Error('claude CLI not found. Install with: curl -fsSL https://claude.ai/install.sh | bash');
127
+ }
128
+
129
+ const systemPrompt = '\n' + buildClaudeSystemPrompt({
130
+ agentName: this.agentName,
131
+ workspaceId: this.workspaceId,
132
+ channelName,
133
+ mode: this._mode,
134
+ });
135
+
136
+ const cmd = [claudeBin, '-p', prompt, '--output-format', 'stream-json', '--verbose'];
137
+
138
+ // Mode-dependent permission and tool flags
139
+ const pfx = 'mcp__openagents-workspace__';
140
+ const mcpTools = [
141
+ `${pfx}workspace_get_history`,
142
+ `${pfx}workspace_get_agents`,
143
+ `${pfx}workspace_status`,
144
+ ];
145
+ const mcpWriteTools = [];
146
+
147
+ if (!this.disabledModules.has('files')) {
148
+ mcpTools.push(`${pfx}workspace_list_files`, `${pfx}workspace_read_file`);
149
+ mcpWriteTools.push(`${pfx}workspace_write_file`, `${pfx}workspace_delete_file`);
150
+ }
151
+ if (!this.disabledModules.has('browser')) {
152
+ mcpTools.push(
153
+ `${pfx}workspace_browser_list_tabs`,
154
+ `${pfx}workspace_browser_snapshot`,
155
+ `${pfx}workspace_browser_screenshot`
156
+ );
157
+ mcpWriteTools.push(
158
+ `${pfx}workspace_browser_open`,
159
+ `${pfx}workspace_browser_navigate`,
160
+ `${pfx}workspace_browser_click`,
161
+ `${pfx}workspace_browser_type`,
162
+ `${pfx}workspace_browser_close`
163
+ );
164
+ }
165
+ if (!this.disabledModules.has('tunnel')) {
166
+ mcpTools.push(`${pfx}tunnel_list`);
167
+ mcpWriteTools.push(`${pfx}tunnel_expose`, `${pfx}tunnel_close`);
168
+ }
169
+
170
+ let allowed;
171
+ if (this._mode === 'plan') {
172
+ cmd.push('--permission-mode', 'plan');
173
+ allowed = [...mcpTools, 'Read', 'Glob', 'Grep'];
174
+ } else {
175
+ cmd.push('--dangerously-skip-permissions');
176
+ allowed = [...mcpTools, ...mcpWriteTools, 'Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'];
177
+ }
178
+
179
+ cmd.push('--append-system-prompt', systemPrompt);
180
+ cmd.push('--allowedTools', ...allowed);
181
+ cmd.push('--disallowedTools', 'AskUserQuestion');
182
+
183
+ // Resume existing conversation
184
+ const sessionId = this._channelSessions[channelName];
185
+ if (sessionId) {
186
+ cmd.push('--resume', sessionId);
187
+ }
188
+
189
+ // MCP config for workspace tools
190
+ const mcpArgs = [
191
+ 'mcp-server',
192
+ '--workspace-id', this.workspaceId,
193
+ '--channel-name', channelName,
194
+ '--agent-name', this.agentName,
195
+ '--endpoint', this.endpoint,
196
+ ];
197
+ if (this.disabledModules.has('files')) mcpArgs.push('--disable-files');
198
+ if (this.disabledModules.has('browser')) mcpArgs.push('--disable-browser');
199
+
200
+ // Find openagents binary
201
+ let oaBin;
202
+ try {
203
+ if (IS_WINDOWS) {
204
+ oaBin = execSync('where openagents.cmd 2>nul || where openagents.exe 2>nul || where openagents 2>nul', {
205
+ encoding: 'utf-8', timeout: 5000,
206
+ }).split(/\r?\n/)[0].trim();
207
+ } else {
208
+ oaBin = execSync('which openagents', { encoding: 'utf-8', timeout: 5000 }).trim();
209
+ }
210
+ } catch {
211
+ oaBin = 'openagents';
212
+ this._log('Could not find openagents binary — MCP tools may not be available');
213
+ }
214
+
215
+ const mcpConfig = {
216
+ mcpServers: {
217
+ 'openagents-workspace': {
218
+ type: 'stdio',
219
+ command: oaBin,
220
+ args: mcpArgs,
221
+ env: { OA_WORKSPACE_TOKEN: this.token },
222
+ },
223
+ },
224
+ };
225
+
226
+ // Write MCP config to temp file (avoids cmd.exe JSON quoting issues)
227
+ const mcpDir = path.join(os.homedir(), '.openagents', 'mcp-configs');
228
+ fs.mkdirSync(mcpDir, { recursive: true });
229
+ const mcpFile = path.join(mcpDir, `mcp-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
230
+ fs.writeFileSync(mcpFile, JSON.stringify(mcpConfig));
231
+ cmd.push('--mcp-config', mcpFile);
232
+
233
+ return { cmd, mcpConfigFile: mcpFile };
234
+ }
235
+
236
+ async _handleMessage(msg) {
237
+ let content = (msg.content || '').trim();
238
+ const attachments = msg.attachments || [];
239
+
240
+ const attText = formatAttachmentsForPrompt(attachments);
241
+ if (attText) {
242
+ content = content ? content + attText : attText.trim();
243
+ }
244
+
245
+ if (!content) return;
246
+
247
+ const msgChannel = msg.sessionId || this.channelName;
248
+ const sender = msg.senderName || msg.senderType || 'user';
249
+ this._log(`Processing message from ${sender} in ${msgChannel}: ${content.slice(0, 80)}...`);
250
+
251
+ // Auto-title + resume-from on first encounter
252
+ if (!this._titledSessions.has(msgChannel)) {
253
+ this._titledSessions.add(msgChannel);
254
+ try {
255
+ const info = await this.client.getSession(this.workspaceId, msgChannel, this.token);
256
+ // Resume from a previous channel's Claude session if specified
257
+ const resumeFrom = info.resumeFrom;
258
+ if (resumeFrom && !this._channelSessions[msgChannel]) {
259
+ const sourceSession = this._channelSessions[resumeFrom];
260
+ if (sourceSession) {
261
+ this._channelSessions[msgChannel] = sourceSession;
262
+ this._saveSessions();
263
+ this._log(`Resuming channel ${msgChannel} from ${resumeFrom}`);
264
+ }
265
+ }
266
+ // Auto-title
267
+ const title = generateSessionTitle(content);
268
+ if (title && !info.titleManuallySet && SESSION_DEFAULT_RE.test(info.title || '')) {
269
+ await this.client.updateSession(
270
+ this.workspaceId, msgChannel, this.token,
271
+ { title, autoTitle: true }
272
+ );
273
+ }
274
+ } catch {}
275
+ }
276
+
277
+ await this.sendStatus(msgChannel, 'thinking...');
278
+
279
+ let mcpConfigFile = null;
280
+ let cmd;
281
+ try {
282
+ const result = this._buildClaudeCmd(content, msgChannel);
283
+ cmd = result.cmd;
284
+ mcpConfigFile = result.mcpConfigFile;
285
+ } catch (e) {
286
+ await this.sendError(msgChannel, e.message);
287
+ return;
288
+ }
289
+
290
+ // Clean env
291
+ const cleanEnv = { ...process.env };
292
+ delete cleanEnv.CLAUDECODE;
293
+ delete cleanEnv.CLAUDE_CODE_SESSION;
294
+
295
+ try {
296
+ // On Windows, .cmd files need cmd.exe
297
+ if (IS_WINDOWS && cmd[0].toLowerCase().endsWith('.cmd')) {
298
+ cmd = ['cmd.exe', '/c', ...cmd];
299
+ }
300
+
301
+ const proc = spawn(cmd[0], cmd.slice(1), {
302
+ stdio: ['ignore', 'pipe', 'pipe'],
303
+ env: cleanEnv,
304
+ cwd: this.workingDir,
305
+ detached: !IS_WINDOWS,
306
+ });
307
+ this._channelProcesses[msgChannel] = proc;
308
+
309
+ const lastResponseText = [];
310
+ let hasToolUseSinceLastText = false;
311
+ let postedThinking = false;
312
+
313
+ // Read stream-json output line by line
314
+ let buffer = '';
315
+ proc.stdout.on('data', (chunk) => { buffer += chunk.toString('utf-8'); });
316
+
317
+ await new Promise((resolve, reject) => {
318
+ const processLine = async (line) => {
319
+ line = line.trim();
320
+ if (!line) return;
321
+
322
+ let event;
323
+ try { event = JSON.parse(line); } catch { return; }
324
+
325
+ const eventType = event.type;
326
+
327
+ if (eventType === 'assistant') {
328
+ const blocks = (event.message || {}).content || [];
329
+ for (const block of blocks) {
330
+ if (block.type === 'text' && block.text && block.text.trim()) {
331
+ if (hasToolUseSinceLastText) {
332
+ lastResponseText.length = 0;
333
+ hasToolUseSinceLastText = false;
334
+ }
335
+ lastResponseText.push(block.text.trim());
336
+ postedThinking = true;
337
+ await this.client.sendMessage(
338
+ this.workspaceId, msgChannel, this.token,
339
+ block.text.trim(),
340
+ { senderType: 'agent', senderName: this.agentName, messageType: 'thinking', metadata: { agent_mode: this._mode } }
341
+ );
342
+ } else if (block.type === 'tool_use') {
343
+ hasToolUseSinceLastText = true;
344
+ postedThinking = false;
345
+ lastResponseText.length = 0;
346
+ const toolName = block.name || '';
347
+ const toolInput = String(block.input || '').slice(0, 200);
348
+ await this.sendStatus(msgChannel, `**Using tool:** \`${toolName}\`\n\`\`\`\n${toolInput}\n\`\`\``);
349
+ }
350
+ }
351
+ } else if (eventType === 'result') {
352
+ const sessionId = event.session_id;
353
+ if (sessionId) {
354
+ this._channelSessions[msgChannel] = sessionId;
355
+ this._saveSessions();
356
+ }
357
+ if (event.is_error) {
358
+ this._log(`Claude error: ${String(event.result || '').slice(0, 200)}`);
359
+ }
360
+ } else if (eventType === 'system') {
361
+ const subtype = event.subtype || '';
362
+ const message = event.message || '';
363
+ if (subtype.includes('compact') || String(message).toLowerCase().includes('compact')) {
364
+ await this.sendStatus(msgChannel, String(message) || 'Compacting conversation...');
365
+ }
366
+ }
367
+ };
368
+
369
+ proc.on('exit', async (code) => {
370
+ // Process remaining buffer
371
+ const lines = buffer.split('\n');
372
+ for (const line of lines) {
373
+ try { await processLine(line); } catch {}
374
+ }
375
+
376
+ delete this._channelProcesses[msgChannel];
377
+
378
+ if (code !== 0) {
379
+ this._log(`CLI exited with code ${code}`);
380
+ }
381
+
382
+ if (lastResponseText.length > 0) {
383
+ const fullResponse = lastResponseText.join('\n').trim();
384
+ if (fullResponse) {
385
+ try { await this.sendResponse(msgChannel, fullResponse); } catch {}
386
+ }
387
+ } else if (!postedThinking) {
388
+ try { await this.sendResponse(msgChannel, 'No response generated. Please try again.'); } catch {}
389
+ }
390
+ resolve();
391
+ });
392
+
393
+ proc.on('error', (err) => reject(err));
394
+
395
+ // Process lines as they arrive
396
+ let lineBuffer = '';
397
+ proc.stdout.on('data', (chunk) => {
398
+ lineBuffer += chunk.toString('utf-8');
399
+ const lines = lineBuffer.split('\n');
400
+ lineBuffer = lines.pop(); // keep incomplete line
401
+ for (const line of lines) {
402
+ processLine(line).catch(() => {});
403
+ }
404
+ });
405
+ // Override buffer since we're processing in real time
406
+ buffer = '';
407
+ });
408
+ } catch (e) {
409
+ this._log(`Error handling message: ${e.message}`);
410
+ await this.sendError(msgChannel, `Error processing message: ${e.message}`);
411
+ } finally {
412
+ if (mcpConfigFile) {
413
+ try { fs.unlinkSync(mcpConfigFile); } catch {}
414
+ }
415
+ delete this._channelProcesses[msgChannel];
416
+ }
417
+ }
418
+ }
419
+
420
+ module.exports = ClaudeAdapter;
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Codex adapter for OpenAgents workspace.
3
+ *
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
7
+ *
8
+ * Direct port of Python: src/openagents/adapters/codex.py
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const { execSync, spawn } = require('child_process');
16
+ const http = require('http');
17
+ const https = require('https');
18
+
19
+ const BaseAdapter = require('./base');
20
+ const { buildOpenclawSystemPrompt } = require('./workspace-prompt');
21
+
22
+ const IS_WINDOWS = process.platform === 'win32';
23
+ const MAX_HISTORY_ENTRIES = 50;
24
+
25
+ class CodexAdapter extends BaseAdapter {
26
+ /**
27
+ * @param {object} opts - BaseAdapter opts plus:
28
+ * @param {Set} [opts.disabledModules]
29
+ */
30
+ constructor(opts) {
31
+ super(opts);
32
+ this.disabledModules = opts.disabledModules || new Set();
33
+ this._codexThreadId = null;
34
+
35
+ // Direct LLM API mode
36
+ this._directApiKey = process.env.OPENAI_API_KEY || '';
37
+ this._directBaseUrl = (process.env.OPENAI_BASE_URL || '').replace(/\/+$/, '');
38
+ this._directModel = process.env.CODEX_MODEL || process.env.OPENCLAW_MODEL || '';
39
+ this._directMode = !!(this._directApiKey && this._directBaseUrl);
40
+
41
+ if (this._directMode) {
42
+ this._log(`Direct LLM mode: ${this._directBaseUrl} model=${this._directModel || 'gpt-4o'}`);
43
+ }
44
+
45
+ // Conversation history
46
+ this._conversationHistory = [];
47
+ }
48
+
49
+ _buildSystemContext(channelName) {
50
+ return buildOpenclawSystemPrompt({
51
+ agentName: this.agentName,
52
+ workspaceId: this.workspaceId,
53
+ channelName,
54
+ endpoint: this.endpoint,
55
+ token: this.token,
56
+ mode: this._mode,
57
+ disabledModules: this.disabledModules,
58
+ });
59
+ }
60
+
61
+ // ------------------------------------------------------------------
62
+ // Message handler
63
+ // ------------------------------------------------------------------
64
+
65
+ async _handleMessage(msg) {
66
+ const content = (msg.content || '').trim();
67
+ if (!content) return;
68
+
69
+ const msgChannel = msg.sessionId || this.channelName;
70
+ const sender = msg.senderName || msg.senderType || 'user';
71
+ this._log(`Processing message from ${sender} in ${msgChannel}: ${content.slice(0, 80)}...`);
72
+
73
+ await this._autoTitleChannel(msgChannel, content);
74
+ await this.sendStatus(msgChannel, 'thinking...');
75
+
76
+ try {
77
+ let responseText;
78
+ if (this._directMode) {
79
+ responseText = await this._callCompletionApi(content, msgChannel);
80
+ } else {
81
+ responseText = await this._runCodexSubprocess(content, msgChannel);
82
+ }
83
+
84
+ if (responseText) {
85
+ this._conversationHistory.push({ role: 'user', content });
86
+ this._conversationHistory.push({ role: 'assistant', content: responseText });
87
+ if (this._conversationHistory.length > MAX_HISTORY_ENTRIES * 2) {
88
+ this._conversationHistory = this._conversationHistory.slice(-MAX_HISTORY_ENTRIES * 2);
89
+ }
90
+ await this.sendResponse(msgChannel, responseText);
91
+ } else {
92
+ await this.sendResponse(msgChannel, 'No response generated. Please try again.');
93
+ }
94
+ } catch (e) {
95
+ this._log(`Error handling message: ${e.message}`);
96
+ await this.sendError(msgChannel, `Error processing message: ${e.message}`);
97
+ }
98
+ }
99
+
100
+ // ------------------------------------------------------------------
101
+ // Direct HTTP mode (OpenAI chat completions API)
102
+ // ------------------------------------------------------------------
103
+
104
+ async _callCompletionApi(userMessage, channel) {
105
+ const systemPrompt = this._buildSystemContext(channel);
106
+ const messages = [{ role: 'system', content: systemPrompt }];
107
+ messages.push(...this._conversationHistory);
108
+ messages.push({ role: 'user', content: userMessage });
109
+
110
+ const url = `${this._directBaseUrl}/chat/completions`;
111
+ const payload = JSON.stringify({
112
+ model: this._directModel || 'gpt-4o',
113
+ messages,
114
+ stream: true,
115
+ });
116
+
117
+ return new Promise((resolve, reject) => {
118
+ const parsed = new URL(url);
119
+ const mod = parsed.protocol === 'https:' ? https : http;
120
+ const req = mod.request(parsed, {
121
+ method: 'POST',
122
+ headers: {
123
+ 'Content-Type': 'application/json',
124
+ 'Authorization': `Bearer ${this._directApiKey}`,
125
+ 'Content-Length': Buffer.byteLength(payload),
126
+ },
127
+ timeout: 300000,
128
+ }, (res) => {
129
+ if (res.statusCode !== 200) {
130
+ let body = '';
131
+ res.on('data', (d) => { body += d; });
132
+ res.on('end', () => reject(new Error(`LLM API returned ${res.statusCode}: ${body.slice(0, 300)}`)));
133
+ return;
134
+ }
135
+
136
+ let fullText = '';
137
+ let buffer = '';
138
+ res.on('data', (chunk) => {
139
+ buffer += chunk.toString('utf-8');
140
+ const lines = buffer.split('\n');
141
+ buffer = lines.pop();
142
+ for (const line of lines) {
143
+ const trimmed = line.trim();
144
+ if (!trimmed || !trimmed.startsWith('data: ')) continue;
145
+ const data = trimmed.slice(6);
146
+ if (data === '[DONE]') continue;
147
+ try {
148
+ const parsed = JSON.parse(data);
149
+ const choices = parsed.choices || [];
150
+ if (choices.length > 0) {
151
+ const delta = choices[0].delta || {};
152
+ if (delta.content) fullText += delta.content;
153
+ }
154
+ } catch {}
155
+ }
156
+ });
157
+ res.on('end', () => resolve(fullText.trim()));
158
+ });
159
+
160
+ req.on('error', reject);
161
+ req.write(payload);
162
+ req.end();
163
+ });
164
+ }
165
+
166
+ // ------------------------------------------------------------------
167
+ // Subprocess mode (codex exec --json --full-auto)
168
+ // ------------------------------------------------------------------
169
+
170
+ _findCodexBinary() {
171
+ try {
172
+ if (IS_WINDOWS) {
173
+ const r = execSync('where codex.cmd 2>nul || where codex.exe 2>nul || where codex 2>nul', {
174
+ encoding: 'utf-8', timeout: 5000,
175
+ });
176
+ return r.split(/\r?\n/)[0].trim();
177
+ } else {
178
+ return execSync('which codex', { encoding: 'utf-8', timeout: 5000 }).trim();
179
+ }
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ async _runCodexSubprocess(content, msgChannel) {
186
+ const codexBin = this._findCodexBinary();
187
+ if (!codexBin) {
188
+ await this.sendError(msgChannel, 'codex CLI not found. Install with: npm install -g @openai/codex');
189
+ return '';
190
+ }
191
+
192
+ const context = this._buildSystemContext(msgChannel);
193
+ const fullPrompt = `${context}\n\n---\n\n${content}`;
194
+
195
+ let cmd = [codexBin, 'exec'];
196
+ if (this._codexThreadId) {
197
+ cmd.push('resume', this._codexThreadId);
198
+ }
199
+ cmd.push('--json', '--full-auto', fullPrompt);
200
+
201
+ if (IS_WINDOWS && cmd[0].toLowerCase().endsWith('.cmd')) {
202
+ cmd = ['cmd.exe', '/c', ...cmd];
203
+ }
204
+
205
+ return new Promise((resolve) => {
206
+ const proc = spawn(cmd[0], cmd.slice(1), {
207
+ stdio: ['ignore', 'pipe', 'pipe'],
208
+ });
209
+
210
+ const responseTexts = [];
211
+ let lineBuffer = '';
212
+
213
+ proc.stdout.on('data', async (chunk) => {
214
+ lineBuffer += chunk.toString('utf-8');
215
+ const lines = lineBuffer.split('\n');
216
+ lineBuffer = lines.pop();
217
+
218
+ for (const line of lines) {
219
+ const trimmed = line.trim();
220
+ if (!trimmed) continue;
221
+ let event;
222
+ try { event = JSON.parse(trimmed); } catch { continue; }
223
+
224
+ const eventType = event.type;
225
+
226
+ if (eventType === 'thread.started') {
227
+ if (event.thread_id) this._codexThreadId = event.thread_id;
228
+ } else if (eventType === 'item.completed') {
229
+ const item = event.item || {};
230
+ if (item.type === 'agent_message' && item.text) {
231
+ responseTexts.push(item.text);
232
+ } else if (item.type === 'command_execution') {
233
+ const cmdText = (item.command || '').slice(0, 200);
234
+ try { await this.sendStatus(msgChannel, `**Running:** \`${cmdText}\``); } catch {}
235
+ } else if (item.type === 'file_change') {
236
+ try { await this.sendStatus(msgChannel, `**Editing:** \`${item.filename || ''}\``); } catch {}
237
+ }
238
+ } else if (eventType === 'turn.failed') {
239
+ const error = event.error || {};
240
+ this._log(`Codex turn failed: ${error.message || JSON.stringify(error)}`);
241
+ }
242
+ }
243
+ });
244
+
245
+ proc.on('exit', (code) => {
246
+ if (code !== 0) {
247
+ this._log(`Codex CLI exited with code ${code}`);
248
+ }
249
+ resolve(responseTexts.join('\n').trim());
250
+ });
251
+
252
+ proc.on('error', (err) => {
253
+ this._log(`Codex spawn error: ${err.message}`);
254
+ resolve('');
255
+ });
256
+ });
257
+ }
258
+ }
259
+
260
+ module.exports = CodexAdapter;