@openagents-org/agent-launcher 0.1.17 → 0.2.2

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,380 @@
1
+ /**
2
+ * OpenCode adapter for OpenAgents workspace.
3
+ *
4
+ * Bridges OpenCode (opencode-ai) to an OpenAgents workspace by running
5
+ * `opencode run --format json` as a subprocess. OpenCode handles its own
6
+ * model configuration, provider selection, and tool chain.
7
+ *
8
+ * Port of Python PR #316: src/openagents/adapters/opencode.py
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const os = require('os');
16
+ const { execSync, spawn } = require('child_process');
17
+
18
+ const BaseAdapter = require('./base');
19
+ const { formatAttachmentsForPrompt } = require('./utils');
20
+ const { buildOpenCodeSkillMd, buildOpenCodeSystemPrompt } = require('./workspace-prompt');
21
+
22
+ const IS_WINDOWS = process.platform === 'win32';
23
+
24
+ class OpenCodeAdapter extends BaseAdapter {
25
+ /**
26
+ * @param {object} opts - BaseAdapter opts plus:
27
+ * @param {Set} [opts.disabledModules]
28
+ * @param {string} [opts.workingDir]
29
+ */
30
+ constructor(opts) {
31
+ super(opts);
32
+ this.disabledModules = opts.disabledModules || new Set();
33
+ this.workingDir = opts.workingDir || undefined;
34
+
35
+ // Agent home directory: ~/.openagents/agents/{agentName}/
36
+ this.agentHome = path.join(os.homedir(), '.openagents', 'agents', this.agentName);
37
+ fs.mkdirSync(this.agentHome, { recursive: true });
38
+
39
+ this._channelSessions = {};
40
+ this._sessionsFile = path.join(this.agentHome, 'sessions.json');
41
+ this._migrateSessionsFile();
42
+ this._loadSessions();
43
+
44
+ this._opencodeBinary = this._findOpencodeBinary();
45
+ if (this._opencodeBinary) {
46
+ this._log(`Using OpenCode subprocess mode: ${this._opencodeBinary}`);
47
+ } else {
48
+ this._log('OpenCode binary not found. Install with: npm install -g opencode-ai@latest');
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Migrate sessions file from old location to agent home.
54
+ */
55
+ _migrateSessionsFile() {
56
+ const oldPath = path.join(
57
+ os.homedir(), '.openagents', 'sessions',
58
+ `${this.workspaceId}_${this.agentName}_opencode.json`
59
+ );
60
+ try {
61
+ if (fs.existsSync(oldPath) && !fs.existsSync(this._sessionsFile)) {
62
+ fs.copyFileSync(oldPath, this._sessionsFile);
63
+ fs.unlinkSync(oldPath);
64
+ this._log(`Migrated sessions file from ${oldPath}`);
65
+ }
66
+ } catch {}
67
+ }
68
+
69
+ _loadSessions() {
70
+ try {
71
+ if (fs.existsSync(this._sessionsFile)) {
72
+ const data = JSON.parse(fs.readFileSync(this._sessionsFile, 'utf-8'));
73
+ if (data && typeof data === 'object') {
74
+ Object.assign(this._channelSessions, data);
75
+ this._log(`Loaded ${Object.keys(data).length} session(s)`);
76
+ }
77
+ }
78
+ } catch {
79
+ this._log('Could not load sessions file, starting fresh');
80
+ }
81
+ }
82
+
83
+ _saveSessions() {
84
+ try {
85
+ fs.mkdirSync(path.dirname(this._sessionsFile), { recursive: true });
86
+ fs.writeFileSync(this._sessionsFile, JSON.stringify(this._channelSessions));
87
+ } catch {}
88
+ }
89
+
90
+ /**
91
+ * Write workspace skill to OpenCode's skill directory for auto-discovery.
92
+ */
93
+ _ensureWorkspaceSkill(channelName) {
94
+ const skillDir = path.join(this.agentHome, '.opencode', 'skills');
95
+ const skillFile = path.join(skillDir, 'openagents-workspace.md');
96
+ try {
97
+ const content = buildOpenCodeSkillMd({
98
+ endpoint: this.endpoint,
99
+ workspaceId: this.workspaceId,
100
+ token: this.token,
101
+ agentName: this.agentName,
102
+ channelName,
103
+ disabledModules: this.disabledModules,
104
+ });
105
+ fs.mkdirSync(skillDir, { recursive: true });
106
+ fs.writeFileSync(skillFile, content, 'utf-8');
107
+ } catch {}
108
+ }
109
+
110
+ _buildSystemContext(channelName) {
111
+ return buildOpenCodeSystemPrompt({
112
+ agentName: this.agentName,
113
+ workspaceId: this.workspaceId,
114
+ channelName,
115
+ endpoint: this.endpoint,
116
+ token: this.token,
117
+ mode: this._mode,
118
+ disabledModules: this.disabledModules,
119
+ });
120
+ }
121
+
122
+ // ------------------------------------------------------------------
123
+ // Binary discovery
124
+ // ------------------------------------------------------------------
125
+
126
+ _findOpencodeBinary() {
127
+ // Tier 1: PATH
128
+ try {
129
+ if (IS_WINDOWS) {
130
+ const r = execSync('where opencode.cmd 2>nul || where opencode.exe 2>nul || where opencode 2>nul', {
131
+ encoding: 'utf-8', timeout: 5000,
132
+ });
133
+ return r.split(/\r?\n/)[0].trim();
134
+ } else {
135
+ return execSync('which opencode', { encoding: 'utf-8', timeout: 5000 }).trim();
136
+ }
137
+ } catch {}
138
+
139
+ // Tier 2: Next to Node.js
140
+ const ext = IS_WINDOWS ? '.cmd' : '';
141
+ const nearNode = path.join(path.dirname(process.execPath), `opencode${ext}`);
142
+ if (fs.existsSync(nearNode)) return nearNode;
143
+
144
+ // Tier 3: Common locations
145
+ const home = os.homedir();
146
+ const candidates = IS_WINDOWS ? [
147
+ path.join(process.env.APPDATA || '', 'npm', 'opencode.cmd'),
148
+ ] : [
149
+ path.join(home, '.openagents', 'npm-global', 'bin', 'opencode'),
150
+ path.join(home, '.npm-global', 'bin', 'opencode'),
151
+ path.join(home, '.local', 'bin', 'opencode'),
152
+ '/usr/local/bin/opencode',
153
+ ];
154
+ for (const c of candidates) {
155
+ if (fs.existsSync(c)) return c;
156
+ }
157
+ return null;
158
+ }
159
+
160
+ // ------------------------------------------------------------------
161
+ // Message handler
162
+ // ------------------------------------------------------------------
163
+
164
+ async _handleMessage(msg) {
165
+ let content = (msg.content || '').trim();
166
+ const attachments = msg.attachments || [];
167
+
168
+ const attText = formatAttachmentsForPrompt(attachments);
169
+ if (attText) {
170
+ content = content ? content + attText : attText.trim();
171
+ }
172
+
173
+ if (!content) return;
174
+
175
+ const msgChannel = msg.sessionId || this.channelName;
176
+ const sender = msg.senderName || msg.senderType || 'user';
177
+ this._log(`Processing message from ${sender} in ${msgChannel}: ${content.slice(0, 80)}...`);
178
+
179
+ await this._autoTitleChannel(msgChannel, content);
180
+ await this.sendStatus(msgChannel, 'thinking...');
181
+
182
+ try {
183
+ const responseText = await this._runOpencode(content, msgChannel);
184
+
185
+ if (responseText) {
186
+ await this.sendResponse(msgChannel, responseText);
187
+ } else {
188
+ await this.sendResponse(msgChannel, 'No response generated. Please try again.');
189
+ }
190
+ } catch (e) {
191
+ this._log(`Error handling message: ${e.message}`);
192
+ await this.sendError(msgChannel, `Error processing message: ${e.message}`);
193
+ }
194
+ }
195
+
196
+ // ------------------------------------------------------------------
197
+ // JSON output parsing
198
+ // ------------------------------------------------------------------
199
+
200
+ /**
201
+ * Split a string containing concatenated JSON objects.
202
+ */
203
+ static _splitJsonObjects(raw) {
204
+ const objects = [];
205
+ raw = raw.trim();
206
+ let pos = 0;
207
+ while (pos < raw.length) {
208
+ if (' \t\r\n'.includes(raw[pos])) { pos++; continue; }
209
+ if (raw[pos] !== '{') { pos++; continue; }
210
+ // Find matching brace
211
+ let depth = 0;
212
+ let inStr = false;
213
+ let escape = false;
214
+ let start = pos;
215
+ for (let i = pos; i < raw.length; i++) {
216
+ const ch = raw[i];
217
+ if (escape) { escape = false; continue; }
218
+ if (ch === '\\' && inStr) { escape = true; continue; }
219
+ if (ch === '"') { inStr = !inStr; continue; }
220
+ if (inStr) continue;
221
+ if (ch === '{') depth++;
222
+ else if (ch === '}') {
223
+ depth--;
224
+ if (depth === 0) {
225
+ try {
226
+ const obj = JSON.parse(raw.slice(start, i + 1));
227
+ if (typeof obj === 'object' && obj !== null) objects.push(obj);
228
+ } catch {}
229
+ pos = i + 1;
230
+ break;
231
+ }
232
+ }
233
+ if (i === raw.length - 1) pos = raw.length; // no match, skip
234
+ }
235
+ if (depth !== 0) break; // unbalanced, stop
236
+ }
237
+ return objects;
238
+ }
239
+
240
+ /**
241
+ * Extract user-visible text from a single opencode JSON event.
242
+ */
243
+ static _extractTextFromEvent(event) {
244
+ const eventType = event.type || '';
245
+ if (['step_start', 'step_finish', 'tool_use'].includes(eventType)) return null;
246
+
247
+ const part = event.part;
248
+ if (part && typeof part === 'object') {
249
+ const text = part.text || part.content || '';
250
+ if (text) return text;
251
+ }
252
+
253
+ const item = event.item || event;
254
+ const text = item.text || item.content || '';
255
+ return text || null;
256
+ }
257
+
258
+ /**
259
+ * Extract human-readable text from opencode --format json output.
260
+ */
261
+ static _extractTextFromJson(raw) {
262
+ const events = OpenCodeAdapter._splitJsonObjects(raw);
263
+ if (!events.length) return raw.trim();
264
+
265
+ const texts = [];
266
+ for (const event of events) {
267
+ const text = OpenCodeAdapter._extractTextFromEvent(event);
268
+ if (text) texts.push(text);
269
+ }
270
+ return texts.length ? texts.join('\n').trim() : raw.trim();
271
+ }
272
+
273
+ /**
274
+ * Extract and persist session_id from OpenCode JSON events.
275
+ */
276
+ _persistSessionId(channel, rawOutput) {
277
+ const events = OpenCodeAdapter._splitJsonObjects(rawOutput);
278
+ let sessionId = null;
279
+ for (const event of events) {
280
+ let sid = event.sessionID;
281
+ if (!sid && event.session && typeof event.session === 'object') {
282
+ sid = event.session.id;
283
+ }
284
+ if (!sid && event.part && typeof event.part === 'object') {
285
+ sid = event.part.sessionID;
286
+ }
287
+ if (sid && typeof sid === 'string') sessionId = sid;
288
+ }
289
+
290
+ if (sessionId) {
291
+ const prev = this._channelSessions[channel];
292
+ this._channelSessions[channel] = sessionId;
293
+ this._saveSessions();
294
+ if (prev !== sessionId) {
295
+ this._log(`OpenCode session for channel ${channel}: ${sessionId}`);
296
+ }
297
+ }
298
+ }
299
+
300
+ // ------------------------------------------------------------------
301
+ // Subprocess execution
302
+ // ------------------------------------------------------------------
303
+
304
+ _runOpencode(content, msgChannel) {
305
+ const binary = this._opencodeBinary || this._findOpencodeBinary();
306
+ if (binary) this._opencodeBinary = binary;
307
+ if (!binary) {
308
+ return Promise.reject(new Error(
309
+ 'opencode CLI not found. Install with: npm install -g opencode-ai@latest'
310
+ ));
311
+ }
312
+
313
+ const cmd = [binary, 'run', '--format', 'json', '--dir', this.agentHome];
314
+
315
+ const sessionId = this._channelSessions[msgChannel];
316
+ let fullPrompt;
317
+ if (sessionId) {
318
+ fullPrompt = content;
319
+ cmd.push('--session', sessionId);
320
+ } else {
321
+ this._ensureWorkspaceSkill(msgChannel);
322
+ const context = this._buildSystemContext(msgChannel);
323
+ fullPrompt = `${context}\n\n---\n\n${content}`;
324
+ }
325
+
326
+ this._log(`CLI: ${binary} ${cmd.slice(1, 5).join(' ')} ...`);
327
+
328
+ const spawnEnv = { ...(this.agentEnv || process.env) };
329
+
330
+ let spawnBinary = cmd[0];
331
+ let spawnArgs = cmd.slice(1);
332
+ if (IS_WINDOWS && spawnBinary.toLowerCase().endsWith('.cmd')) {
333
+ spawnArgs = ['/C', spawnBinary, ...spawnArgs];
334
+ spawnBinary = process.env.COMSPEC || 'cmd.exe';
335
+ }
336
+
337
+ return new Promise((resolve, reject) => {
338
+ const proc = spawn(spawnBinary, spawnArgs, {
339
+ stdio: ['pipe', 'pipe', 'pipe'],
340
+ env: spawnEnv,
341
+ cwd: this.agentHome,
342
+ timeout: 300000, // 5 minutes
343
+ });
344
+
345
+ let stdout = '';
346
+ let stderr = '';
347
+
348
+ if (proc.stdout) proc.stdout.on('data', (d) => { stdout += d; });
349
+ if (proc.stderr) proc.stderr.on('data', (d) => { stderr += d; });
350
+
351
+ // Send the prompt via stdin
352
+ if (proc.stdin) {
353
+ proc.stdin.write(fullPrompt, 'utf-8');
354
+ proc.stdin.end();
355
+ }
356
+
357
+ proc.on('error', (err) => reject(err));
358
+ proc.on('exit', (code) => {
359
+ stdout = stdout.trim();
360
+ stderr = stderr.trim();
361
+
362
+ if (code !== 0) {
363
+ this._log(`opencode exited with code ${code}: ${stderr.slice(0, 300)}`);
364
+ }
365
+
366
+ if (stdout) {
367
+ this._persistSessionId(msgChannel, stdout);
368
+ resolve(OpenCodeAdapter._extractTextFromJson(stdout));
369
+ } else {
370
+ if (stderr) {
371
+ this._log(`opencode stderr: ${stderr.slice(0, 300)}`);
372
+ }
373
+ resolve('');
374
+ }
375
+ });
376
+ });
377
+ }
378
+ }
379
+
380
+ module.exports = OpenCodeAdapter;
@@ -282,6 +282,41 @@ function buildOpenclawSkillMd({ endpoint, workspaceId, token, agentName, channel
282
282
  return frontmatter + identity + '\n' + collab + '\n' + body;
283
283
  }
284
284
 
285
+ /**
286
+ * Build system prompt for OpenCode adapter.
287
+ */
288
+ function buildOpenCodeSystemPrompt({ agentName, workspaceId, channelName, endpoint, token, mode = 'execute', disabledModules }) {
289
+ const identity = buildWorkspaceIdentity(agentName, workspaceId, channelName, mode);
290
+ const collab = buildCollaborationPrompt();
291
+ const modePrompt = buildModePrompt(mode);
292
+ const api = buildApiSkillsPrompt({ endpoint, workspaceId, token, agentName, channelName, disabledModules, mode });
293
+ return identity + '\n' + collab + '\n' + modePrompt + '\n' + api;
294
+ }
295
+
296
+ /**
297
+ * Build workspace skill markdown for OpenCode (written to .opencode/skills/).
298
+ */
299
+ function buildOpenCodeSkillMd({ endpoint, workspaceId, token, agentName, channelName, disabledModules }) {
300
+ const api = buildApiSkillsPrompt({
301
+ endpoint, workspaceId, token, agentName,
302
+ channelName: channelName || 'general',
303
+ disabledModules,
304
+ mode: 'execute',
305
+ });
306
+
307
+ const frontmatter =
308
+ '---\n' +
309
+ 'name: openagents-workspace\n' +
310
+ 'description: OpenAgents Workspace API — shared files, browser, and agent collaboration\n' +
311
+ '---\n\n';
312
+
313
+ const identity =
314
+ `You are agent '${agentName}' connected to OpenAgents workspace ${workspaceId}.\n` +
315
+ 'Use these APIs via bash + curl to interact with the workspace.\n\n';
316
+
317
+ return frontmatter + identity + api;
318
+ }
319
+
285
320
  module.exports = {
286
321
  buildWorkspaceIdentity,
287
322
  buildCollaborationPrompt,
@@ -290,4 +325,6 @@ module.exports = {
290
325
  buildClaudeSystemPrompt,
291
326
  buildOpenclawSystemPrompt,
292
327
  buildOpenclawSkillMd,
328
+ buildOpenCodeSystemPrompt,
329
+ buildOpenCodeSkillMd,
293
330
  };
package/src/daemon.js CHANGED
@@ -160,17 +160,31 @@ class Daemon {
160
160
  fs.mkdirSync(configDir, { recursive: true });
161
161
  const logFd = fs.openSync(logFile, 'a');
162
162
 
163
+ // Build env with enhanced PATH (ensures node/npm are findable)
164
+ const env = getEnhancedEnv();
165
+ // Ensure the directory containing the node binary is on PATH
166
+ const nodeBinDir = path.dirname(bin);
167
+ if (env.PATH && !env.PATH.includes(nodeBinDir)) {
168
+ env.PATH = nodeBinDir + path.delimiter + env.PATH;
169
+ }
170
+
163
171
  const opts = {
164
172
  detached: true,
165
173
  stdio: ['ignore', logFd, logFd],
166
- env: getEnhancedEnv(),
174
+ env,
175
+ cwd: configDir,
167
176
  };
168
177
  if (IS_WINDOWS) opts.windowsHide = true;
169
178
 
170
179
  const proc = spawn(bin, foregroundArgs, opts);
171
180
  proc.unref();
172
181
  fs.writeFileSync(pidFile, String(proc.pid), 'utf-8');
173
- fs.closeSync(logFd);
182
+
183
+ // Give child a moment to start before closing the log fd
184
+ setTimeout(() => {
185
+ try { fs.closeSync(logFd); } catch {}
186
+ }, 1000);
187
+
174
188
  console.log(`Daemon started (PID ${proc.pid})`);
175
189
  console.log(`Logs: ${logFile}`);
176
190
  console.log('Stop: agent-connector down');
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Local identity management (~/.openagents/identity.json).
5
+ *
6
+ * Port of Python: src/openagents/client/workspace_client.py (identity section)
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const os = require('os');
12
+ const crypto = require('crypto');
13
+
14
+ const IDENTITY_DIR = path.join(os.homedir(), '.openagents');
15
+ const IDENTITY_FILE = path.join(IDENTITY_DIR, 'identity.json');
16
+
17
+ function _loadIdentities() {
18
+ try {
19
+ if (fs.existsSync(IDENTITY_FILE)) {
20
+ return JSON.parse(fs.readFileSync(IDENTITY_FILE, 'utf-8'));
21
+ }
22
+ } catch {}
23
+ return { agents: {}, user_email: null };
24
+ }
25
+
26
+ function _saveIdentities(data) {
27
+ try {
28
+ fs.mkdirSync(IDENTITY_DIR, { recursive: true });
29
+ fs.writeFileSync(IDENTITY_FILE, JSON.stringify(data, null, 2));
30
+ // Restrict permissions (best-effort)
31
+ try { fs.chmodSync(IDENTITY_FILE, 0o600); } catch {}
32
+ } catch {}
33
+ }
34
+
35
+ /**
36
+ * Get the saved identity for an agent type.
37
+ * @returns {{ agentName, agentType, apiKey, createdAt } | null}
38
+ */
39
+ function getIdentity(agentType) {
40
+ const data = _loadIdentities();
41
+ const entry = (data.agents || {})[agentType];
42
+ if (entry) {
43
+ return {
44
+ agentName: entry.agent_name,
45
+ agentType,
46
+ apiKey: entry.api_key || null,
47
+ createdAt: entry.created_at || null,
48
+ };
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /**
54
+ * Save an agent identity to local storage.
55
+ */
56
+ function saveIdentity({ agentName, agentType, apiKey, createdAt }) {
57
+ const data = _loadIdentities();
58
+ if (!data.agents) data.agents = {};
59
+ data.agents[agentType] = {
60
+ agent_name: agentName,
61
+ api_key: apiKey || null,
62
+ created_at: createdAt || new Date().toISOString(),
63
+ };
64
+ _saveIdentities(data);
65
+ }
66
+
67
+ /**
68
+ * Get the logged-in user email.
69
+ */
70
+ function getUserEmail() {
71
+ return _loadIdentities().user_email || null;
72
+ }
73
+
74
+ /**
75
+ * Set the logged-in user email.
76
+ */
77
+ function setUserEmail(email) {
78
+ const data = _loadIdentities();
79
+ data.user_email = email;
80
+ _saveIdentities(data);
81
+ }
82
+
83
+ /**
84
+ * Clear the logged-in user email (logout).
85
+ */
86
+ function clearUserEmail() {
87
+ const data = _loadIdentities();
88
+ data.user_email = null;
89
+ _saveIdentities(data);
90
+ }
91
+
92
+ /**
93
+ * Generate an auto-name: {type}-{context}-{4hex} or {type}-{4hex}.
94
+ */
95
+ function generateAgentName(agentType, context) {
96
+ const suffix = crypto.randomBytes(2).toString('hex');
97
+ if (context) {
98
+ const ctx = context.toLowerCase().replace(/\s+/g, '-').slice(0, 20);
99
+ return `${agentType}-${ctx}-${suffix}`;
100
+ }
101
+ return `${agentType}-${suffix}`;
102
+ }
103
+
104
+ module.exports = {
105
+ getIdentity,
106
+ saveIdentity,
107
+ getUserEmail,
108
+ setUserEmail,
109
+ clearUserEmail,
110
+ generateAgentName,
111
+ IDENTITY_DIR,
112
+ IDENTITY_FILE,
113
+ };
package/src/index.js CHANGED
@@ -139,6 +139,11 @@ class AgentConnector {
139
139
  * Start daemon in background (daemonize).
140
140
  */
141
141
  startDaemon(foregroundArgs) {
142
+ if (!foregroundArgs) {
143
+ // Auto-detect the CLI entry point for foreground mode
144
+ const binPath = require.resolve('../bin/agent-connector.js');
145
+ foregroundArgs = [binPath, 'up', '--foreground'];
146
+ }
142
147
  Daemon.daemonize(this._configDir, foregroundArgs);
143
148
  }
144
149