@openagents-org/agent-launcher 0.2.102 → 0.2.109

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openagents-org/agent-launcher",
3
- "version": "0.2.102",
3
+ "version": "0.2.109",
4
4
  "description": "OpenAgents Launcher — install, configure, and run AI coding agents from your terminal",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -12,12 +12,12 @@
12
12
  *
13
13
  * Subclasses must implement _handleMessage(msg).
14
14
  *
15
- * Direct port of Python: src/openagents/adapters/base.py
15
+ * Direct port of Python: sdk/src/openagents/adapters/base.py
16
16
  */
17
17
 
18
18
  'use strict';
19
19
 
20
- const { WorkspaceClient } = require('../workspace-client');
20
+ const { WorkspaceClient, SessionRevokedError } = require('../workspace-client');
21
21
  const { generateSessionTitle, SESSION_DEFAULT_RE } = require('./utils');
22
22
 
23
23
  const DEFAULT_ENDPOINT = 'https://workspace-endpoint.openagents.org';
@@ -43,6 +43,7 @@ class BaseAdapter {
43
43
  this.client = new WorkspaceClient(this.endpoint);
44
44
  this._lastEventId = null;
45
45
  this._running = false;
46
+ this._sessionId = null; // issued by server on /v1/join; used to prove liveness
46
47
  this._processedIds = new Set();
47
48
  this._titledSessions = new Set();
48
49
  this._mode = 'execute';
@@ -65,13 +66,14 @@ class BaseAdapter {
65
66
 
66
67
  // Announce agent to workspace
67
68
  try {
68
- await this.client.joinNetwork(this.agentName, this.token, {
69
+ const joinResult = await this.client.joinNetwork(this.agentName, this.token, {
69
70
  network: this.workspaceId,
70
71
  agentType: this.agentType || 'agent',
71
72
  serverHost: require('os').hostname(),
72
73
  workingDir: this.workingDir || process.cwd(),
73
74
  });
74
- this._log(`Joined workspace ${this.workspaceId}`);
75
+ this._sessionId = (joinResult && joinResult.session_id) || null;
76
+ this._log(`Joined workspace ${this.workspaceId}${this._sessionId ? ` (session ${this._sessionId.slice(0, 8)})` : ''}`);
75
77
  } catch (e) {
76
78
  this._log(`Warning: join failed: ${e.message}`);
77
79
  }
@@ -130,8 +132,13 @@ class BaseAdapter {
130
132
 
131
133
  async _heartbeat() {
132
134
  try {
133
- await this.client.heartbeat(this.workspaceId, this.agentName, this.token);
135
+ await this.client.heartbeat(this.workspaceId, this.agentName, this.token, this._sessionId);
134
136
  } catch (e) {
137
+ if (e instanceof SessionRevokedError) {
138
+ this._log(`SESSION REVOKED: another client joined as '${this.agentName}'. Stopping adapter.`);
139
+ this._running = false;
140
+ return;
141
+ }
135
142
  this._log(`Heartbeat failed: ${e.message}`);
136
143
  }
137
144
  }
@@ -312,8 +319,11 @@ class BaseAdapter {
312
319
  senderName: this.agentName,
313
320
  messageType: 'status',
314
321
  metadata: { agent_mode: this._mode },
322
+ sessionId: this._sessionId,
315
323
  });
316
- } catch {}
324
+ } catch (e) {
325
+ if (e instanceof SessionRevokedError) this._onSessionRevoked();
326
+ }
317
327
  }
318
328
 
319
329
  async sendThinking(channel, content) {
@@ -323,15 +333,27 @@ class BaseAdapter {
323
333
  senderName: this.agentName,
324
334
  messageType: 'thinking',
325
335
  metadata: { agent_mode: this._mode },
336
+ sessionId: this._sessionId,
326
337
  });
327
- } catch {}
338
+ } catch (e) {
339
+ if (e instanceof SessionRevokedError) this._onSessionRevoked();
340
+ }
328
341
  }
329
342
 
330
343
  async sendResponse(channel, content) {
331
- await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
332
- senderType: 'agent',
333
- senderName: this.agentName,
334
- });
344
+ try {
345
+ await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
346
+ senderType: 'agent',
347
+ senderName: this.agentName,
348
+ sessionId: this._sessionId,
349
+ });
350
+ } catch (e) {
351
+ if (e instanceof SessionRevokedError) {
352
+ this._onSessionRevoked();
353
+ return;
354
+ }
355
+ throw e;
356
+ }
335
357
  }
336
358
 
337
359
  async sendError(channel, error) {
@@ -339,8 +361,16 @@ class BaseAdapter {
339
361
  await this.client.sendMessage(this.workspaceId, channel, this.token, error, {
340
362
  senderType: 'agent',
341
363
  senderName: this.agentName,
364
+ sessionId: this._sessionId,
342
365
  });
343
- } catch {}
366
+ } catch (e) {
367
+ if (e instanceof SessionRevokedError) this._onSessionRevoked();
368
+ }
369
+ }
370
+
371
+ _onSessionRevoked() {
372
+ this._log(`SESSION REVOKED: another client joined as '${this.agentName}'. Stopping adapter.`);
373
+ this._running = false;
344
374
  }
345
375
 
346
376
  // ------------------------------------------------------------------
@@ -6,7 +6,7 @@
6
6
  * - Claude CLI subprocess (stream-json) for task execution
7
7
  * - MCP server for workspace tool access
8
8
  *
9
- * Direct port of Python: src/openagents/adapters/claude.py
9
+ * Direct port of Python: sdk/src/openagents/adapters/claude.py
10
10
  */
11
11
 
12
12
  'use strict';
@@ -48,15 +48,31 @@ class CodexAdapter extends BaseAdapter {
48
48
  );
49
49
  this._loadSessions();
50
50
 
51
- // Determine mode: CLI binary preferred, direct API as fallback
51
+ // Determine mode:
52
+ // - CLI mode: only works with OpenAI's native Responses API (api.openai.com)
53
+ // - Direct API mode: works with any OpenAI-compatible chat completions endpoint
52
54
  this._codexBin = this._findCodexBinary();
53
55
  this._directMode = false;
56
+ this._useCliMode = false;
54
57
 
55
- if (this._codexBin) {
58
+ // Check if base URL is OpenAI's native API (CLI requires Responses API)
59
+ const isOpenAiNative = !this._directBaseUrl ||
60
+ this._directBaseUrl.includes('api.openai.com');
61
+
62
+ if (this._codexBin && isOpenAiNative) {
63
+ this._useCliMode = true;
56
64
  this._log(`CLI mode: ${this._codexBin}`);
57
65
  } else if (this._directApiKey && this._directBaseUrl) {
58
66
  this._directMode = true;
59
- this._log(`Direct LLM mode (CLI not found): ${this._directBaseUrl} model=${this._directModel || 'gpt-4o'}`);
67
+ if (this._codexBin) {
68
+ this._log(`Direct LLM mode (non-OpenAI endpoint, CLI requires Responses API): ${this._directBaseUrl} model=${this._directModel || 'gpt-4o'}`);
69
+ } else {
70
+ this._log(`Direct LLM mode: ${this._directBaseUrl} model=${this._directModel || 'gpt-4o'}`);
71
+ }
72
+ } else if (this._codexBin) {
73
+ // CLI binary found, no custom base URL — assume OpenAI
74
+ this._useCliMode = true;
75
+ this._log(`CLI mode: ${this._codexBin}`);
60
76
  } else {
61
77
  this._log('Warning: No codex CLI binary found and no direct API configured');
62
78
  }
@@ -203,7 +219,7 @@ class CodexAdapter extends BaseAdapter {
203
219
  await this._autoTitleChannel(msgChannel, content);
204
220
  await this.sendStatus(msgChannel, 'thinking...');
205
221
 
206
- if (this._codexBin) {
222
+ if (this._useCliMode) {
207
223
  await this._handleViaSubprocess(content, msgChannel);
208
224
  } else if (this._directMode) {
209
225
  await this._handleViaDirectApi(content, msgChannel);
@@ -2,7 +2,7 @@
2
2
  * Cursor adapter — AI-powered code editor agent mode.
3
3
  *
4
4
  * Uses direct LLM API mode (OpenAI-compatible chat completions).
5
- * Port of Python: src/openagents/adapters/cursor.py
5
+ * Port of Python: sdk/src/openagents/adapters/cursor.py
6
6
  */
7
7
 
8
8
  'use strict';
@@ -4,7 +4,7 @@
4
4
  * Calls OpenAI-compatible chat completions API directly with SSE streaming.
5
5
  * No CLI binary needed — just OPENAI_API_KEY + OPENAI_BASE_URL.
6
6
  *
7
- * Port of Python: src/openagents/adapters/nanoclaw.py & cursor.py
7
+ * Port of Python: sdk/src/openagents/adapters/nanoclaw.py & cursor.py
8
8
  */
9
9
 
10
10
  'use strict';
@@ -2,7 +2,7 @@
2
2
  * NanoClaw adapter — lightweight containerized coding agent.
3
3
  *
4
4
  * Uses direct LLM API mode (OpenAI-compatible chat completions).
5
- * Port of Python: src/openagents/adapters/nanoclaw.py
5
+ * Port of Python: sdk/src/openagents/adapters/nanoclaw.py
6
6
  */
7
7
 
8
8
  'use strict';
@@ -5,7 +5,7 @@
5
5
  * - CLI mode: `openclaw agent --local --json` (preferred)
6
6
  * - Workspace context injected via SKILL.md auto-discovery
7
7
  *
8
- * Direct port of Python: src/openagents/adapters/openclaw.py
8
+ * Direct port of Python: sdk/src/openagents/adapters/openclaw.py
9
9
  * (CLI mode only — gateway WS and direct HTTP modes are not yet ported)
10
10
  */
11
11
 
@@ -5,7 +5,7 @@
5
5
  * `opencode run --format json` as a subprocess. OpenCode handles its own
6
6
  * model configuration, provider selection, and tool chain.
7
7
  *
8
- * Port of Python PR #316: src/openagents/adapters/opencode.py
8
+ * Port of Python PR #316: sdk/src/openagents/adapters/opencode.py
9
9
  */
10
10
 
11
11
  'use strict';
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Shared utilities for adapter implementations.
3
3
  *
4
- * Direct port of Python: src/openagents/adapters/utils.py
4
+ * Direct port of Python: sdk/src/openagents/adapters/utils.py
5
5
  */
6
6
 
7
7
  'use strict';
@@ -6,7 +6,7 @@
6
6
  * - Multi-agent collaboration (@mention delegation)
7
7
  * - Workspace REST API skills (files, browser, tunnels)
8
8
  *
9
- * Direct port of Python: src/openagents/adapters/workspace_prompt.py
9
+ * Direct port of Python: sdk/src/openagents/adapters/workspace_prompt.py
10
10
  */
11
11
 
12
12
  'use strict';
@@ -21,8 +21,12 @@ function buildWorkspaceIdentity(agentName, workspaceId, channelName, mode = 'exe
21
21
  '— just write your answer naturally.\n\n' +
22
22
  '## Workspace Context\n' +
23
23
  `- Workspace ID: ${workspaceId}\n` +
24
- `- Channel: ${channelName}\n` +
25
- `- Mode: ${mode}\n`
24
+ `- Channel: ${channelName} (this is the channel you are currently speaking in)\n` +
25
+ `- Mode: ${mode}\n\n` +
26
+ 'When you need prior context, call `workspace_get_history` with ' +
27
+ `\`channel="${channelName}"\` (the current channel). Without the ` +
28
+ 'channel argument the tool falls back to a default channel that may ' +
29
+ 'be different from where you are.\n'
26
30
  );
27
31
  }
28
32
 
package/src/daemon.js CHANGED
@@ -30,6 +30,7 @@ class Daemon {
30
30
  this._shuttingDown = false;
31
31
  this._statusInterval = null;
32
32
  this._cmdInterval = null;
33
+ this._reloadInFlight = null; // serialize concurrent _reload() calls
33
34
  }
34
35
 
35
36
  // ---------------------------------------------------------------------------
@@ -182,6 +183,22 @@ class Daemon {
182
183
  const bin = execPath || process.execPath;
183
184
 
184
185
  fs.mkdirSync(configDir, { recursive: true });
186
+
187
+ // Refuse to start if an existing daemon is already running.
188
+ // Without this check, repeated `agn up` invocations would spawn
189
+ // multiple daemons that each process the same message → duplicate
190
+ // bot replies.
191
+ const existingPid = Daemon._readPid(pidFile);
192
+ if (existingPid && Daemon._isAlive(existingPid)) {
193
+ console.error(`Daemon already running (PID ${existingPid}).`);
194
+ console.error(`Run 'agn down' first, or 'agn status' to check.`);
195
+ process.exit(1);
196
+ }
197
+ // Stale pid file — clean up before spawning fresh
198
+ if (existingPid) {
199
+ try { fs.unlinkSync(pidFile); } catch {}
200
+ }
201
+
185
202
  const logFd = fs.openSync(logFile, 'a');
186
203
 
187
204
  // Build env with enhanced PATH (ensures node/npm are findable)
@@ -636,6 +653,27 @@ class Daemon {
636
653
  }
637
654
 
638
655
  async _reload() {
656
+ // Serialize reloads. fs.watch, the 'reload' command, and SIGHUP can
657
+ // all fire concurrently. Without a mutex, two _reload() calls in flight
658
+ // may both observe the same stale `this._adapters[name]` entry between
659
+ // stopAgent() and _launchAgent(), leaving a ghost adapter running
660
+ // alongside the new one → duplicate bot replies per message.
661
+ if (this._reloadInFlight) {
662
+ // Wait for the in-flight reload to finish, then run once more
663
+ // (the config may have changed again since it started).
664
+ this._reloadInFlight = this._reloadInFlight.then(
665
+ () => this._reloadUnsafe(),
666
+ () => this._reloadUnsafe(),
667
+ );
668
+ return this._reloadInFlight;
669
+ }
670
+ this._reloadInFlight = this._reloadUnsafe().finally(() => {
671
+ this._reloadInFlight = null;
672
+ });
673
+ return this._reloadInFlight;
674
+ }
675
+
676
+ async _reloadUnsafe() {
639
677
  this._log('Reloading config...');
640
678
  const oldNames = this._cachedAgentNames || new Set();
641
679
  const oldConfigs = this._cachedAgentConfigs || {};
@@ -657,12 +695,14 @@ class Daemon {
657
695
  // Start new agents or restart agents whose network changed
658
696
  for (const agent of newAgents) {
659
697
  if (!oldNames.has(agent.name)) {
698
+ await this._ensureAdapterCleared(agent.name);
660
699
  this._launchAgent(agent);
661
700
  this._log(`Reload: started new agent '${agent.name}'`);
662
701
  } else if ((oldConfigs[agent.name] || '') !== (agent.network || '')) {
663
702
  // Network config changed — restart agent
664
703
  await this.stopAgent(agent.name);
665
704
  this._stoppedAgents.delete(agent.name);
705
+ await this._ensureAdapterCleared(agent.name);
666
706
  this._launchAgent(agent);
667
707
  this._log(`Reload: restarted '${agent.name}' (network changed)`);
668
708
  }
@@ -673,6 +713,28 @@ class Daemon {
673
713
  this._writeStatus();
674
714
  }
675
715
 
716
+ /**
717
+ * Wait until the old adapter (if any) has fully released its slot in
718
+ * this._adapters before relaunching. stopAgent already waits up to 5s,
719
+ * but on slow shutdowns that can be too short — and _launchAgent's
720
+ * duplicate-check would then silently skip the relaunch, leaving the
721
+ * OLD adapter running instead of starting the new one.
722
+ */
723
+ async _ensureAdapterCleared(name) {
724
+ for (let i = 0; i < 20; i++) {
725
+ if (!this._adapters || !this._adapters[name]) return;
726
+ await this._sleep(500);
727
+ }
728
+ // Last resort: force-clear the slot so the new adapter can start.
729
+ // The old adapter will exit on its next poll iteration since its
730
+ // entry in _stoppedAgents triggers adapter.stop() via checkStop.
731
+ if (this._adapters && this._adapters[name]) {
732
+ this._log(`WARNING: adapter '${name}' did not clear after 10s — force-releasing slot to avoid duplicate`);
733
+ try { this._adapters[name].stop(); } catch {}
734
+ delete this._adapters[name];
735
+ }
736
+ }
737
+
676
738
  _writePid() {
677
739
  try {
678
740
  fs.writeFileSync(this.config.pidFile, String(process.pid), 'utf-8');
package/src/identity.js CHANGED
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * Local identity management (~/.openagents/identity.json).
5
5
  *
6
- * Port of Python: src/openagents/client/workspace_client.py (identity section)
6
+ * Port of Python: sdk/src/openagents/client/workspace_client.py (identity section)
7
7
  */
8
8
 
9
9
  const fs = require('fs');
package/src/mcp-server.js CHANGED
@@ -27,11 +27,12 @@ function buildToolDefs(disabledModules) {
27
27
  // -- Workspace core (always enabled) --
28
28
  {
29
29
  name: 'workspace_get_history',
30
- description: 'Read recent messages in the current workspace channel.',
30
+ description: 'Read recent messages in a workspace channel. Defaults to the current channel; pass channel to query another.',
31
31
  inputSchema: {
32
32
  type: 'object',
33
33
  properties: {
34
34
  limit: { type: 'integer', description: 'Number of messages to return (default 20)', default: 20 },
35
+ channel: { type: 'string', description: 'Channel name (e.g. "channel-9bcd8e66"); omit to use the current channel.' },
35
36
  },
36
37
  },
37
38
  },
@@ -341,7 +342,8 @@ class McpServer {
341
342
 
342
343
  case 'workspace_get_history': {
343
344
  const limit = args.limit || 20;
344
- const data = await this.ws.pollMessages(this.workspaceId, this.channelName, this.token, { limit });
345
+ const channel = args.channel || this.channelName;
346
+ const data = await this.ws.pollMessages(this.workspaceId, channel, this.token, { limit });
345
347
  const events = data.events || data || [];
346
348
  if (!events.length) return text('No messages yet.');
347
349
  const lines = events.map((e) => {
@@ -5,6 +5,19 @@ const http = require('http');
5
5
 
6
6
  const DEFAULT_ENDPOINT = 'https://workspace-endpoint.openagents.org';
7
7
 
8
+ /**
9
+ * Thrown when the workspace rejects a request because our session_id has
10
+ * been revoked by a newer /v1/join as the same agent. Callers should
11
+ * stop the adapter rather than retry.
12
+ */
13
+ class SessionRevokedError extends Error {
14
+ constructor(message) {
15
+ super(message);
16
+ this.name = 'SessionRevokedError';
17
+ this.code = 'session_revoked';
18
+ }
19
+ }
20
+
8
21
  /**
9
22
  * HTTP client for workspace API operations.
10
23
  *
@@ -84,12 +97,18 @@ class WorkspaceClient {
84
97
 
85
98
  /**
86
99
  * Send heartbeat via POST /v1/heartbeat.
100
+ *
101
+ * @param {string} [sessionId] - optional session id returned by /v1/join.
102
+ * If the server's current session for this agent differs, _post()
103
+ * throws SessionRevokedError and the caller should stop its adapter.
87
104
  */
88
- async heartbeat(workspaceId, agentName, token) {
89
- const data = await this._post('/v1/heartbeat', {
105
+ async heartbeat(workspaceId, agentName, token, sessionId) {
106
+ const body = {
90
107
  agent_name: agentName,
91
108
  network: workspaceId,
92
- }, this._wsHeaders(token));
109
+ };
110
+ if (sessionId) body.session_id = sessionId;
111
+ const data = await this._post('/v1/heartbeat', body, this._wsHeaders(token));
93
112
  return data.data || data;
94
113
  }
95
114
 
@@ -107,9 +126,15 @@ class WorkspaceClient {
107
126
 
108
127
  /**
109
128
  * Post a raw event via POST /v1/events.
129
+ *
130
+ * @param {string} [sessionId] - if given, embedded in event.metadata so
131
+ * the server can reject stale sessions with SessionRevokedError.
110
132
  */
111
- async sendEvent(workspaceId, event, token) {
133
+ async sendEvent(workspaceId, event, token, sessionId) {
112
134
  event.network = workspaceId;
135
+ if (sessionId) {
136
+ event.metadata = { ...(event.metadata || {}), session_id: sessionId };
137
+ }
113
138
  const data = await this._post('/v1/events', event, this._wsHeaders(token));
114
139
  return data.data || data;
115
140
  }
@@ -118,7 +143,7 @@ class WorkspaceClient {
118
143
  * Send a chat message to a workspace channel.
119
144
  */
120
145
  async sendMessage(workspaceId, channelName, token, content, {
121
- senderType = 'agent', senderName, messageType = 'chat', metadata, attachments,
146
+ senderType = 'agent', senderName, messageType = 'chat', metadata, attachments, sessionId,
122
147
  } = {}) {
123
148
  const sourcePrefix = senderType === 'agent' ? 'openagents' : 'human';
124
149
  const source = senderName ? `${sourcePrefix}:${senderName}` : `${sourcePrefix}:unknown`;
@@ -132,7 +157,7 @@ class WorkspaceClient {
132
157
  target: `channel/${channelName}`,
133
158
  payload,
134
159
  metadata: metadata || {},
135
- }, token);
160
+ }, token, sessionId);
136
161
  }
137
162
 
138
163
  /**
@@ -175,24 +200,41 @@ class WorkspaceClient {
175
200
  cursor = events[events.length - 1].id || null;
176
201
  }
177
202
 
178
- // Filter for messages targeted at this agent
203
+ // Filter for messages targeted at this agent.
204
+ //
205
+ // target_agents semantics:
206
+ // • absent → legacy server with no routing decision
207
+ // (broadcast for human messages, ignore for agents)
208
+ // • [...agentNames] → only listed agents should respond
209
+ // • ["__no_response__"]
210
+ // → routing happened and decided nobody
211
+ // should respond. Sentinel is used instead
212
+ // of [] because pre-0.2.106 clients treat
213
+ // empty array as "broadcast" and every
214
+ // agent would reply. The sentinel is
215
+ // non-empty and matches no real agent.
179
216
  const messages = [];
180
217
  for (const e of events) {
181
218
  const source = e.source || '';
182
219
  const meta = e.metadata || {};
183
- const targetAgents = meta.target_agents || [];
220
+ const targetAgents = meta.target_agents;
221
+ const hasTargetList = Array.isArray(targetAgents);
184
222
 
185
223
  // Skip own messages
186
224
  if (source === `openagents:${agentName}`) continue;
187
225
 
188
226
  if (source.startsWith('human:')) {
189
- // Human messages: pick up if targeted at this agent or broadcast
190
- if (!targetAgents.length || targetAgents.includes(agentName)) {
227
+ if (hasTargetList) {
228
+ if (targetAgents.includes(agentName)) {
229
+ messages.push(this._eventToMessage(e));
230
+ }
231
+ } else {
232
+ // Legacy server (no target_agents): broadcast for compat
191
233
  messages.push(this._eventToMessage(e));
192
234
  }
193
235
  } else if (source.startsWith('openagents:')) {
194
- // Agent messages: only pick up if explicitly mentioned
195
- if (targetAgents.includes(agentName)) {
236
+ // Agent messages: only pick up if explicitly listed
237
+ if (hasTargetList && targetAgents.includes(agentName)) {
196
238
  messages.push(this._eventToMessage(e));
197
239
  }
198
240
  }
@@ -545,7 +587,11 @@ class WorkspaceClient {
545
587
  const parsed = JSON.parse(data);
546
588
  if (res.statusCode >= 400) {
547
589
  const msg = parsed.message || `HTTP ${res.statusCode}`;
548
- reject(new Error(msg));
590
+ if (typeof msg === 'string' && msg.toLowerCase().includes('session_revoked')) {
591
+ reject(new SessionRevokedError(msg));
592
+ } else {
593
+ reject(new Error(msg));
594
+ }
549
595
  } else {
550
596
  resolve(parsed);
551
597
  }
@@ -634,4 +680,4 @@ class WorkspaceClient {
634
680
  }
635
681
  }
636
682
 
637
- module.exports = { WorkspaceClient };
683
+ module.exports = { WorkspaceClient, SessionRevokedError };