@openagents-org/agent-launcher 0.2.103 → 0.2.110
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 +1 -1
- package/registry.json +17 -6
- package/src/adapters/base.js +42 -12
- package/src/adapters/claude.js +46 -62
- package/src/adapters/codex.js +21 -6
- package/src/adapters/cursor.js +1 -1
- package/src/adapters/llm-direct.js +1 -1
- package/src/adapters/nanoclaw.js +1 -1
- package/src/adapters/openclaw.js +1 -1
- package/src/adapters/opencode.js +1 -1
- package/src/adapters/utils.js +1 -1
- package/src/adapters/workspace-prompt.js +7 -3
- package/src/daemon.js +62 -0
- package/src/identity.js +1 -1
- package/src/installer.js +157 -41
- package/src/mcp-server.js +4 -2
- package/src/paths.js +10 -1
- package/src/tui.js +17 -46
- package/src/workspace-client.js +60 -14
package/package.json
CHANGED
package/registry.json
CHANGED
|
@@ -136,16 +136,27 @@
|
|
|
136
136
|
{
|
|
137
137
|
"name": "OPENAI_API_KEY",
|
|
138
138
|
"description": "OpenAI API key",
|
|
139
|
-
"required":
|
|
139
|
+
"required": false,
|
|
140
140
|
"password": true
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"name": "OPENAI_BASE_URL",
|
|
144
|
+
"description": "OpenAI-compatible base URL",
|
|
145
|
+
"required": false
|
|
141
146
|
}
|
|
142
147
|
],
|
|
143
148
|
"check_ready": {
|
|
144
|
-
"
|
|
145
|
-
"OPENAI_API_KEY"
|
|
149
|
+
"env_all": [
|
|
150
|
+
"OPENAI_API_KEY",
|
|
151
|
+
"OPENAI_BASE_URL"
|
|
146
152
|
],
|
|
147
|
-
"
|
|
148
|
-
|
|
153
|
+
"saved_env_all": [
|
|
154
|
+
"OPENAI_API_KEY",
|
|
155
|
+
"OPENAI_BASE_URL"
|
|
156
|
+
],
|
|
157
|
+
"status_command": "codex login status",
|
|
158
|
+
"login_command": "codex login",
|
|
159
|
+
"not_ready_message": "Not configured. Set OPENAI_API_KEY + OPENAI_BASE_URL, or run: codex login"
|
|
149
160
|
},
|
|
150
161
|
"resolve_env": {
|
|
151
162
|
"rules": [
|
|
@@ -525,4 +536,4 @@
|
|
|
525
536
|
"windows": "pip install sweagent"
|
|
526
537
|
}
|
|
527
538
|
}
|
|
528
|
-
]
|
|
539
|
+
]
|
package/src/adapters/base.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
// ------------------------------------------------------------------
|
package/src/adapters/claude.js
CHANGED
|
@@ -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';
|
|
@@ -272,72 +272,56 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
272
272
|
if (this.disabledModules.has('files')) mcpArgs.push('--disable-files');
|
|
273
273
|
if (this.disabledModules.has('browser')) mcpArgs.push('--disable-browser');
|
|
274
274
|
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
275
|
+
// Resolve the MCP server entry point. Prefer the sibling bin inside this
|
|
276
|
+
// very package — it's guaranteed to exist whenever claude.js is executing,
|
|
277
|
+
// so it never falls through to a broken PATH lookup. If Claude Code can't
|
|
278
|
+
// spawn the MCP server, it silently hides every workspace tool and the
|
|
279
|
+
// agent reports "workspace_read_file isn't in my tool set".
|
|
280
|
+
let mcpCommand = this._findNodeBin();
|
|
281
|
+
let mcpFinalArgs = mcpArgs;
|
|
282
|
+
const siblingBin = path.resolve(__dirname, '..', '..', 'bin', 'agent-connector.js');
|
|
283
|
+
if (fs.existsSync(siblingBin)) {
|
|
284
|
+
mcpFinalArgs = [siblingBin, ...mcpArgs];
|
|
285
|
+
} else {
|
|
286
|
+
// Fallback: search installed locations (older layouts, global installs)
|
|
287
|
+
let oaBin = null;
|
|
288
|
+
const home3 = os.homedir();
|
|
289
|
+
const oaExt = IS_WINDOWS ? '.cmd' : '';
|
|
290
|
+
const runtimesRoot = path.join(home3, '.openagents', 'runtimes');
|
|
291
|
+
try {
|
|
292
|
+
for (const d of fs.readdirSync(runtimesRoot, { withFileTypes: true })) {
|
|
293
|
+
if (d.isDirectory()) {
|
|
294
|
+
const candidate = path.join(runtimesRoot, d.name, 'node_modules', '.bin', `openagents${oaExt}`);
|
|
295
|
+
if (fs.existsSync(candidate)) { oaBin = candidate; break; }
|
|
296
|
+
}
|
|
286
297
|
}
|
|
298
|
+
} catch {}
|
|
299
|
+
if (!oaBin) {
|
|
300
|
+
const oaPortable = path.join(home3, '.openagents', 'nodejs', 'node_modules', '.bin', `openagents${oaExt}`);
|
|
301
|
+
if (fs.existsSync(oaPortable)) oaBin = oaPortable;
|
|
287
302
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
303
|
+
if (!oaBin) try {
|
|
304
|
+
if (IS_WINDOWS) {
|
|
305
|
+
oaBin = execSync('where openagents.cmd 2>nul || where openagents.exe 2>nul || where openagents 2>nul', {
|
|
306
|
+
encoding: 'utf-8', timeout: 5000,
|
|
307
|
+
}).split(/\r?\n/)[0].trim();
|
|
308
|
+
} else {
|
|
309
|
+
oaBin = execSync('which openagents', { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
310
|
+
}
|
|
311
|
+
} catch {}
|
|
312
|
+
if (!oaBin) {
|
|
313
|
+
this._log('Could not find openagents binary — MCP tools may not be available');
|
|
314
|
+
mcpCommand = 'openagents';
|
|
300
315
|
} else {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const nearNode2 = path.join(nodeBinDir2, `openagents${oaExt}`);
|
|
309
|
-
if (fs.existsSync(nearNode2)) oaBin = nearNode2;
|
|
310
|
-
}
|
|
311
|
-
// Tier 3: Common locations
|
|
312
|
-
if (!oaBin) {
|
|
313
|
-
const home2 = os.homedir();
|
|
314
|
-
const oaCandidates = IS_WINDOWS ? [
|
|
315
|
-
path.join(process.env.APPDATA || '', 'npm', 'openagents.cmd'),
|
|
316
|
-
] : [
|
|
317
|
-
path.join(home2, '.openagents', 'npm-global', 'bin', 'openagents'),
|
|
318
|
-
path.join(home2, '.local', 'bin', 'openagents'),
|
|
319
|
-
path.join(home2, '.npm-global', 'bin', 'openagents'),
|
|
320
|
-
'/opt/homebrew/bin/openagents',
|
|
321
|
-
'/usr/local/bin/openagents',
|
|
322
|
-
];
|
|
323
|
-
for (const c of oaCandidates) {
|
|
324
|
-
if (fs.existsSync(c)) { oaBin = c; break; }
|
|
316
|
+
const resolved = this._resolveToNodeCmd(oaBin);
|
|
317
|
+
if (resolved) {
|
|
318
|
+
mcpCommand = resolved[0];
|
|
319
|
+
mcpFinalArgs = [resolved[1], ...mcpArgs];
|
|
320
|
+
} else {
|
|
321
|
+
mcpCommand = oaBin;
|
|
322
|
+
}
|
|
325
323
|
}
|
|
326
324
|
}
|
|
327
|
-
if (!oaBin) {
|
|
328
|
-
oaBin = 'openagents';
|
|
329
|
-
this._log('Could not find openagents binary — MCP tools may not be available');
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// Resolve shim/symlink to node + JS entry point for MCP server
|
|
333
|
-
// (.cmd shims and #!/usr/bin/env node shebangs both fail as MCP commands)
|
|
334
|
-
let mcpCommand = oaBin;
|
|
335
|
-
let mcpFinalArgs = mcpArgs;
|
|
336
|
-
const mcpResolved = this._resolveToNodeCmd(oaBin);
|
|
337
|
-
if (mcpResolved) {
|
|
338
|
-
mcpCommand = mcpResolved[0];
|
|
339
|
-
mcpFinalArgs = [mcpResolved[1], ...mcpArgs];
|
|
340
|
-
}
|
|
341
325
|
|
|
342
326
|
const mcpConfig = {
|
|
343
327
|
mcpServers: {
|
package/src/adapters/codex.js
CHANGED
|
@@ -140,7 +140,18 @@ class CodexAdapter extends BaseAdapter {
|
|
|
140
140
|
const nearNode = path.join(nodeBinDir, `codex${ext}`);
|
|
141
141
|
if (fs.existsSync(nearNode)) return nearNode;
|
|
142
142
|
|
|
143
|
-
// Tier 3:
|
|
143
|
+
// Tier 3: npm global prefix (handles custom npm prefix like D:\node\node_global)
|
|
144
|
+
try {
|
|
145
|
+
const npmPrefix = execSync('npm config get prefix', {
|
|
146
|
+
encoding: 'utf-8', timeout: 5000, windowsHide: true,
|
|
147
|
+
}).trim();
|
|
148
|
+
if (npmPrefix) {
|
|
149
|
+
const prefixCandidate = path.join(npmPrefix, `codex${ext}`);
|
|
150
|
+
if (fs.existsSync(prefixCandidate)) return prefixCandidate;
|
|
151
|
+
}
|
|
152
|
+
} catch {}
|
|
153
|
+
|
|
154
|
+
// Tier 4: Common install locations
|
|
144
155
|
const candidates = IS_WINDOWS ? [
|
|
145
156
|
path.join(process.env.APPDATA || '', 'npm', 'codex.cmd'),
|
|
146
157
|
] : [
|
|
@@ -265,12 +276,10 @@ class CodexAdapter extends BaseAdapter {
|
|
|
265
276
|
cmd.push('-C', this.workingDir);
|
|
266
277
|
}
|
|
267
278
|
|
|
268
|
-
cmd.push(fullPrompt);
|
|
269
|
-
|
|
270
279
|
this._log(`Spawning: codex exec ${threadId && attempt === 0 ? `resume ${threadId} ` : ''}--json --full-auto -m ${this._directModel || 'default'}`);
|
|
271
280
|
|
|
272
281
|
try {
|
|
273
|
-
const result = await this._spawnCodex(cmd, env, msgChannel);
|
|
282
|
+
const result = await this._spawnCodex(cmd, env, msgChannel, fullPrompt);
|
|
274
283
|
|
|
275
284
|
if (result.responseText) {
|
|
276
285
|
await this.sendResponse(msgChannel, result.responseText);
|
|
@@ -293,14 +302,15 @@ class CodexAdapter extends BaseAdapter {
|
|
|
293
302
|
}
|
|
294
303
|
}
|
|
295
304
|
|
|
296
|
-
async _spawnCodex(cmd, env, msgChannel) {
|
|
305
|
+
async _spawnCodex(cmd, env, msgChannel, prompt) {
|
|
297
306
|
return new Promise((resolve, reject) => {
|
|
298
307
|
const proc = spawn(cmd[0], cmd.slice(1), {
|
|
299
|
-
stdio: ['
|
|
308
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
300
309
|
env,
|
|
301
310
|
cwd: this.workingDir,
|
|
302
311
|
detached: !IS_WINDOWS,
|
|
303
312
|
windowsHide: true,
|
|
313
|
+
shell: IS_WINDOWS,
|
|
304
314
|
});
|
|
305
315
|
this._channelProcesses[msgChannel] = proc;
|
|
306
316
|
|
|
@@ -314,6 +324,11 @@ class CodexAdapter extends BaseAdapter {
|
|
|
314
324
|
proc.stderr.on('data', (chunk) => { stderrBuf += chunk.toString('utf-8'); });
|
|
315
325
|
}
|
|
316
326
|
|
|
327
|
+
if (proc.stdin) {
|
|
328
|
+
proc.stdin.write(prompt || '', 'utf-8');
|
|
329
|
+
proc.stdin.end();
|
|
330
|
+
}
|
|
331
|
+
|
|
317
332
|
const processLine = async (line) => {
|
|
318
333
|
line = line.trim();
|
|
319
334
|
if (!line) return;
|
package/src/adapters/cursor.js
CHANGED
|
@@ -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';
|
package/src/adapters/nanoclaw.js
CHANGED
|
@@ -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';
|
package/src/adapters/openclaw.js
CHANGED
|
@@ -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
|
|
package/src/adapters/opencode.js
CHANGED
|
@@ -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';
|
package/src/adapters/utils.js
CHANGED
|
@@ -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/installer.js
CHANGED
|
@@ -5,6 +5,10 @@ const os = require('os');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { execSync, exec } = require('child_process');
|
|
7
7
|
const { whichBinary, getEnhancedEnv, getRuntimePrefix } = require('./paths');
|
|
8
|
+
const { EnvManager } = require('./env');
|
|
9
|
+
|
|
10
|
+
const STATUS_CACHE_TTL_MS = 10000;
|
|
11
|
+
const statusCache = new Map();
|
|
8
12
|
|
|
9
13
|
/**
|
|
10
14
|
* Manages installation and uninstallation of agent runtimes.
|
|
@@ -19,6 +23,7 @@ class Installer {
|
|
|
19
23
|
this.configDir = configDir;
|
|
20
24
|
this.markersFile = path.join(configDir, 'installed_agents.json');
|
|
21
25
|
this.markersDir = path.join(configDir, 'installed');
|
|
26
|
+
this.env = new EnvManager(configDir);
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
/**
|
|
@@ -131,7 +136,17 @@ class Installer {
|
|
|
131
136
|
*/
|
|
132
137
|
healthCheck(agentType) {
|
|
133
138
|
const binary = this._whichBinary(agentType);
|
|
134
|
-
if (!binary)
|
|
139
|
+
if (!binary) {
|
|
140
|
+
return {
|
|
141
|
+
installed: false,
|
|
142
|
+
binary: null,
|
|
143
|
+
version: null,
|
|
144
|
+
ready: false,
|
|
145
|
+
auth_mode: null,
|
|
146
|
+
execution_mode: 'unavailable',
|
|
147
|
+
message: 'Not installed',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
135
150
|
|
|
136
151
|
const entry = this.registry.getEntry(agentType);
|
|
137
152
|
const checkCmd = entry && entry.install ? entry.install.check_command : null;
|
|
@@ -150,51 +165,152 @@ class Installer {
|
|
|
150
165
|
version = match ? match[1] : raw.split('\n')[0];
|
|
151
166
|
} catch {}
|
|
152
167
|
|
|
153
|
-
|
|
154
|
-
|
|
168
|
+
const readiness = this._evaluateReadiness(agentType, entry, binary);
|
|
169
|
+
return { installed: true, binary, version, ...readiness };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
_evaluateReadiness(agentType, entry, binary) {
|
|
155
173
|
const checkReady = entry?.check_ready;
|
|
156
|
-
if (checkReady) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
174
|
+
if (!checkReady) {
|
|
175
|
+
return {
|
|
176
|
+
ready: true,
|
|
177
|
+
auth_mode: null,
|
|
178
|
+
execution_mode: 'unavailable',
|
|
179
|
+
message: 'Ready',
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const savedEnv = this.env.getEffective(agentType, this.registry);
|
|
184
|
+
const directEnv = this._hasAllValues(process.env, checkReady.env_all);
|
|
185
|
+
const directSaved = this._hasAllValues(savedEnv, checkReady.saved_env_all || checkReady.env_all);
|
|
186
|
+
const directReady = directEnv || directSaved;
|
|
187
|
+
const envAnyReady = this._hasAnyValue(process.env, checkReady.env_vars);
|
|
188
|
+
const savedAnyReady = !!(checkReady.saved_env_key && savedEnv[checkReady.saved_env_key]);
|
|
189
|
+
const credsReady = this._checkCredsReady(checkReady);
|
|
190
|
+
|
|
191
|
+
let cliReady = false;
|
|
192
|
+
if (checkReady.status_command && binary) {
|
|
193
|
+
cliReady = this._checkStatusCommand(checkReady.status_command);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (directReady) {
|
|
197
|
+
return {
|
|
198
|
+
ready: true,
|
|
199
|
+
auth_mode: 'api_key',
|
|
200
|
+
execution_mode: 'direct',
|
|
201
|
+
message: 'Ready',
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (cliReady) {
|
|
206
|
+
return {
|
|
207
|
+
ready: true,
|
|
208
|
+
auth_mode: 'cli_login',
|
|
209
|
+
execution_mode: 'subprocess',
|
|
210
|
+
message: 'Ready',
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (envAnyReady || savedAnyReady) {
|
|
215
|
+
// Legacy single-key path (e.g. agents that only advertise env_vars / saved_env_key).
|
|
216
|
+
// An API key being present means the agent can be launched with it in the environment,
|
|
217
|
+
// so treat this as a direct-launch configuration.
|
|
218
|
+
return {
|
|
219
|
+
ready: true,
|
|
220
|
+
auth_mode: 'api_key',
|
|
221
|
+
execution_mode: 'direct',
|
|
222
|
+
message: 'Ready',
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (credsReady) {
|
|
227
|
+
return {
|
|
228
|
+
ready: true,
|
|
229
|
+
auth_mode: 'cli_login',
|
|
230
|
+
execution_mode: 'subprocess',
|
|
231
|
+
message: 'Ready',
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
ready: false,
|
|
237
|
+
auth_mode: null,
|
|
238
|
+
execution_mode: 'unavailable',
|
|
239
|
+
message: checkReady.not_ready_message || 'Not configured',
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
_hasAllValues(source, keys) {
|
|
244
|
+
if (!keys || keys.length === 0) return false;
|
|
245
|
+
return keys.every((key) => !!(source && source[key]));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
_hasAnyValue(source, keys) {
|
|
249
|
+
if (!keys || keys.length === 0) return false;
|
|
250
|
+
return keys.some((key) => !!(source && source[key]));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
_checkCredsReady(checkReady) {
|
|
254
|
+
if (checkReady.creds_file) {
|
|
255
|
+
try {
|
|
256
|
+
const credsPath = checkReady.creds_file.replace('~', os.homedir());
|
|
257
|
+
if (fs.existsSync(credsPath)) {
|
|
258
|
+
const stat = fs.statSync(credsPath);
|
|
259
|
+
if (stat.isDirectory()) return fs.readdirSync(credsPath).length > 0;
|
|
260
|
+
const creds = JSON.parse(fs.readFileSync(credsPath, 'utf-8'));
|
|
261
|
+
if (checkReady.creds_key) return !!creds[checkReady.creds_key];
|
|
262
|
+
return true;
|
|
162
263
|
}
|
|
264
|
+
} catch {}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (checkReady.keychain_service && process.platform === 'darwin') {
|
|
268
|
+
if (this._checkMacKeychain(checkReady.keychain_service, checkReady.creds_key)) {
|
|
269
|
+
return true;
|
|
163
270
|
}
|
|
164
|
-
// Check credentials file or directory
|
|
165
|
-
if (!ready && checkReady.creds_file) {
|
|
166
|
-
try {
|
|
167
|
-
const credsPath = checkReady.creds_file.replace('~', os.homedir());
|
|
168
|
-
if (fs.existsSync(credsPath)) {
|
|
169
|
-
const stat = fs.statSync(credsPath);
|
|
170
|
-
if (stat.isDirectory()) {
|
|
171
|
-
// Directory exists — check if it has files (e.g. session files)
|
|
172
|
-
ready = fs.readdirSync(credsPath).length > 0;
|
|
173
|
-
} else {
|
|
174
|
-
// File — parse JSON and check key
|
|
175
|
-
const creds = JSON.parse(fs.readFileSync(credsPath, 'utf-8'));
|
|
176
|
-
if (checkReady.creds_key) {
|
|
177
|
-
ready = !!creds[checkReady.creds_key];
|
|
178
|
-
} else {
|
|
179
|
-
ready = true;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
} catch {}
|
|
184
|
-
}
|
|
185
|
-
// Also check OAuth credentials (Claude Code stores tokens in .credentials.json)
|
|
186
|
-
if (!ready) {
|
|
187
|
-
try {
|
|
188
|
-
const oauthFile = path.join(os.homedir(), '.claude', '.credentials.json');
|
|
189
|
-
if (fs.existsSync(oauthFile)) {
|
|
190
|
-
const creds = JSON.parse(fs.readFileSync(oauthFile, 'utf-8'));
|
|
191
|
-
if (creds.claudeAiOauth?.accessToken) ready = true;
|
|
192
|
-
}
|
|
193
|
-
} catch {}
|
|
194
|
-
}
|
|
195
271
|
}
|
|
196
272
|
|
|
197
|
-
return
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Check a macOS Keychain generic-password entry. If creds_key is provided the
|
|
278
|
+
* stored value is parsed as JSON and required to contain that key; otherwise any
|
|
279
|
+
* non-empty value counts as ready. Returns false on non-macOS or on any error.
|
|
280
|
+
*/
|
|
281
|
+
_checkMacKeychain(service, credsKey) {
|
|
282
|
+
try {
|
|
283
|
+
const stdout = execSync(
|
|
284
|
+
`security find-generic-password -s ${JSON.stringify(service)} -w`,
|
|
285
|
+
{ stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, encoding: 'utf-8' },
|
|
286
|
+
).trim();
|
|
287
|
+
if (!stdout) return false;
|
|
288
|
+
if (!credsKey) return true;
|
|
289
|
+
const creds = JSON.parse(stdout);
|
|
290
|
+
return !!creds[credsKey];
|
|
291
|
+
} catch {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
_checkStatusCommand(command) {
|
|
297
|
+
const cached = statusCache.get(command);
|
|
298
|
+
if (cached && (Date.now() - cached.ts) < STATUS_CACHE_TTL_MS) {
|
|
299
|
+
return cached.ok;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
let ok = false;
|
|
303
|
+
try {
|
|
304
|
+
execSync(command, {
|
|
305
|
+
stdio: 'ignore',
|
|
306
|
+
timeout: 5000,
|
|
307
|
+
env: getEnhancedEnv(),
|
|
308
|
+
});
|
|
309
|
+
ok = true;
|
|
310
|
+
} catch {}
|
|
311
|
+
|
|
312
|
+
statusCache.set(command, { ok, ts: Date.now() });
|
|
313
|
+
return ok;
|
|
198
314
|
}
|
|
199
315
|
|
|
200
316
|
/**
|
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
|
|
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
|
|
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) => {
|
package/src/paths.js
CHANGED
|
@@ -139,9 +139,18 @@ function _addWindowsPaths(dirs) {
|
|
|
139
139
|
// System32 (cmd.exe, powershell, etc) — Electron may not have it
|
|
140
140
|
_push(dirs, path.join(sysRoot, 'System32'));
|
|
141
141
|
|
|
142
|
-
// npm global bin
|
|
142
|
+
// npm global bin (default location)
|
|
143
143
|
if (appData) _push(dirs, path.join(appData, 'npm'));
|
|
144
144
|
|
|
145
|
+
// npm global bin (custom prefix — e.g. user configured npm prefix to D:\node\node_global)
|
|
146
|
+
try {
|
|
147
|
+
const npmPrefix = execSync('npm config get prefix', {
|
|
148
|
+
encoding: 'utf-8', timeout: 5000, windowsHide: true,
|
|
149
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
150
|
+
}).trim();
|
|
151
|
+
if (npmPrefix) _push(dirs, npmPrefix);
|
|
152
|
+
} catch {}
|
|
153
|
+
|
|
145
154
|
// Portable Node.js installed by OpenAgents Launcher
|
|
146
155
|
_push(dirs, path.join(HOME, '.openagents', 'nodejs'));
|
|
147
156
|
|
package/src/tui.js
CHANGED
|
@@ -85,54 +85,11 @@ function loadAgentRows(connector) {
|
|
|
85
85
|
workspace = agent.network;
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
|
-
// Check if agent type needs configuration (API key etc.)
|
|
89
88
|
let notReadyMsg = '';
|
|
89
|
+
let health = null;
|
|
90
90
|
try {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (entry && entry.check_ready) {
|
|
94
|
-
const cr = entry.check_ready;
|
|
95
|
-
let isReady = false;
|
|
96
|
-
// Check saved env
|
|
97
|
-
if (cr.saved_env_key) {
|
|
98
|
-
const saved = connector.env.load(agentType);
|
|
99
|
-
if (saved[cr.saved_env_key]) isReady = true;
|
|
100
|
-
}
|
|
101
|
-
// Check process env vars
|
|
102
|
-
if (!isReady && cr.env_vars) {
|
|
103
|
-
for (const v of cr.env_vars) {
|
|
104
|
-
if (process.env[v]) { isReady = true; break; }
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
// Check creds file/directory (for claude)
|
|
108
|
-
if (!isReady && cr.creds_file) {
|
|
109
|
-
const credsPath = cr.creds_file.replace('~', process.env.HOME || process.env.USERPROFILE || '');
|
|
110
|
-
try {
|
|
111
|
-
if (fs.existsSync(credsPath)) {
|
|
112
|
-
const stat = fs.statSync(credsPath);
|
|
113
|
-
if (stat.isDirectory()) {
|
|
114
|
-
// Directory (e.g. ~/.claude/sessions) — check if it has files
|
|
115
|
-
isReady = fs.readdirSync(credsPath).length > 0;
|
|
116
|
-
} else {
|
|
117
|
-
const creds = JSON.parse(fs.readFileSync(credsPath, 'utf-8'));
|
|
118
|
-
if (cr.creds_key) isReady = !!creds[cr.creds_key];
|
|
119
|
-
else isReady = true;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
} catch {}
|
|
123
|
-
}
|
|
124
|
-
// Also check OAuth credentials (Claude Code stores tokens in .credentials.json)
|
|
125
|
-
if (!isReady) {
|
|
126
|
-
try {
|
|
127
|
-
const oauthFile = path.join(process.env.HOME || '', '.claude', '.credentials.json');
|
|
128
|
-
if (fs.existsSync(oauthFile)) {
|
|
129
|
-
const creds = JSON.parse(fs.readFileSync(oauthFile, 'utf-8'));
|
|
130
|
-
if (creds.claudeAiOauth && creds.claudeAiOauth.accessToken) isReady = true;
|
|
131
|
-
}
|
|
132
|
-
} catch {}
|
|
133
|
-
}
|
|
134
|
-
if (!isReady) notReadyMsg = cr.not_ready_message || 'Not configured';
|
|
135
|
-
}
|
|
91
|
+
health = connector.healthCheck(agent.type || 'openclaw');
|
|
92
|
+
if (health && !health.ready) notReadyMsg = health.message || 'Not configured';
|
|
136
93
|
} catch {}
|
|
137
94
|
|
|
138
95
|
return {
|
|
@@ -144,11 +101,24 @@ function loadAgentRows(connector) {
|
|
|
144
101
|
network: agent.network || '',
|
|
145
102
|
lastError: info.last_error || '',
|
|
146
103
|
notReadyMsg,
|
|
104
|
+
health,
|
|
147
105
|
configured: true,
|
|
148
106
|
};
|
|
149
107
|
});
|
|
150
108
|
}
|
|
151
109
|
|
|
110
|
+
function describeHealth(health) {
|
|
111
|
+
if (!health) return '';
|
|
112
|
+
if (!health.ready) return health.message || 'Not configured';
|
|
113
|
+
const parts = ['Ready'];
|
|
114
|
+
if (health.auth_mode === 'api_key') parts.push('API key');
|
|
115
|
+
else if (health.auth_mode === 'cli_login') parts.push('CLI login');
|
|
116
|
+
if (health.execution_mode && health.execution_mode !== 'unavailable') {
|
|
117
|
+
parts.push(health.execution_mode);
|
|
118
|
+
}
|
|
119
|
+
return parts.join(' | ');
|
|
120
|
+
}
|
|
121
|
+
|
|
152
122
|
function loadCatalog(connector) {
|
|
153
123
|
const { execSync } = require('child_process');
|
|
154
124
|
const entries = connector.registry.getCatalogSync();
|
|
@@ -372,6 +342,7 @@ function createTUI() {
|
|
|
372
342
|
// Detail row: working dir + config warning
|
|
373
343
|
const details = [];
|
|
374
344
|
details.push(r.path || process.env.HOME || '~');
|
|
345
|
+
if (r.health) details.push(`{cyan-fg}${describeHealth(r.health)}{/cyan-fg}`);
|
|
375
346
|
if (r.notReadyMsg) details.push(`{yellow-fg}⚠ ${r.notReadyMsg}{/yellow-fg}`);
|
|
376
347
|
items.push(` {gray-fg} ${details.join(' | ')}{/gray-fg}`);
|
|
377
348
|
}
|
package/src/workspace-client.js
CHANGED
|
@@ -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
|
|
105
|
+
async heartbeat(workspaceId, agentName, token, sessionId) {
|
|
106
|
+
const body = {
|
|
90
107
|
agent_name: agentName,
|
|
91
108
|
network: workspaceId,
|
|
92
|
-
}
|
|
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
|
-
|
|
190
|
-
|
|
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
|
|
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
|
-
|
|
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 };
|