@openagents-org/agent-launcher 0.2.113 → 0.2.115
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 +24 -3
- package/src/adapters/base.js +45 -5
- package/src/adapters/claude.js +68 -10
- package/src/adapters/gemini.js +426 -0
- package/src/adapters/index.js +3 -0
- package/src/index.js +16 -0
- package/src/workspace-client.js +12 -0
package/package.json
CHANGED
package/registry.json
CHANGED
|
@@ -271,6 +271,8 @@
|
|
|
271
271
|
"open-source",
|
|
272
272
|
"cli"
|
|
273
273
|
],
|
|
274
|
+
"builtin": true,
|
|
275
|
+
"support": { "install": true, "workspace": true, "collaboration": true },
|
|
274
276
|
"install": {
|
|
275
277
|
"binary": "gemini",
|
|
276
278
|
"requires": [
|
|
@@ -279,7 +281,26 @@
|
|
|
279
281
|
"macos": "npm install -g @google/gemini-cli",
|
|
280
282
|
"linux": "npm install -g @google/gemini-cli",
|
|
281
283
|
"windows": "npm install -g @google/gemini-cli"
|
|
282
|
-
}
|
|
284
|
+
},
|
|
285
|
+
"adapter": {
|
|
286
|
+
"module": "openagents.adapters.gemini",
|
|
287
|
+
"class": "GeminiAdapter"
|
|
288
|
+
},
|
|
289
|
+
"launch": {
|
|
290
|
+
"args": [
|
|
291
|
+
"-p",
|
|
292
|
+
"Your agent name is '{agent_name}'.",
|
|
293
|
+
"-y",
|
|
294
|
+
"-o",
|
|
295
|
+
"stream-json"
|
|
296
|
+
]
|
|
297
|
+
},
|
|
298
|
+
"check_ready": {
|
|
299
|
+
"not_ready_message": "Not logged in. Run: gemini",
|
|
300
|
+
"login_command": "gemini",
|
|
301
|
+
"alt_check": "gemini --version"
|
|
302
|
+
},
|
|
303
|
+
"env_config": []
|
|
283
304
|
},
|
|
284
305
|
{
|
|
285
306
|
"name": "goose",
|
|
@@ -554,8 +575,8 @@
|
|
|
554
575
|
"install": {
|
|
555
576
|
"binary": "hermes",
|
|
556
577
|
"requires": ["python3"],
|
|
557
|
-
"macos": "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash",
|
|
558
|
-
"linux": "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash",
|
|
578
|
+
"macos": "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup",
|
|
579
|
+
"linux": "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup",
|
|
559
580
|
"windows": "echo 'Hermes requires WSL2 on Windows — see https://github.com/NousResearch/hermes-agent'"
|
|
560
581
|
},
|
|
561
582
|
"launch": {
|
package/src/adapters/base.js
CHANGED
|
@@ -48,6 +48,7 @@ class BaseAdapter {
|
|
|
48
48
|
this._titledSessions = new Set();
|
|
49
49
|
this._mode = 'execute';
|
|
50
50
|
this._lastControlId = null;
|
|
51
|
+
this._controlWake = null;
|
|
51
52
|
// Per-channel task tracking for parallel execution
|
|
52
53
|
this._channelBusy = new Set();
|
|
53
54
|
this._channelQueues = {};
|
|
@@ -75,13 +76,13 @@ class BaseAdapter {
|
|
|
75
76
|
this._sessionId = (joinResult && joinResult.session_id) || null;
|
|
76
77
|
this._log(`Joined workspace ${this.workspaceId}${this._sessionId ? ` (session ${this._sessionId.slice(0, 8)})` : ''}`);
|
|
77
78
|
} catch (e) {
|
|
78
|
-
this._log(`Warning: join failed: ${e.message}`);
|
|
79
|
+
this._log(`Warning: join failed: ${e.message} \nStack: ${e.stack}`);
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
await this._skipExistingEvents();
|
|
82
83
|
|
|
83
84
|
const heartbeatInterval = setInterval(() => this._heartbeat(), 30000);
|
|
84
|
-
const
|
|
85
|
+
const controlPoller = this._controlPollerLoop();
|
|
85
86
|
|
|
86
87
|
try {
|
|
87
88
|
// Send initial heartbeat
|
|
@@ -92,8 +93,9 @@ class BaseAdapter {
|
|
|
92
93
|
await this._pollLoop();
|
|
93
94
|
} finally {
|
|
94
95
|
this._running = false;
|
|
96
|
+
this._wakeControlPoller();
|
|
95
97
|
clearInterval(heartbeatInterval);
|
|
96
|
-
|
|
98
|
+
try { await controlPoller; } catch {}
|
|
97
99
|
try {
|
|
98
100
|
await this.client.disconnect(this.workspaceId, this.agentName, this.token);
|
|
99
101
|
} catch {}
|
|
@@ -171,6 +173,40 @@ class BaseAdapter {
|
|
|
171
173
|
*/
|
|
172
174
|
async _onControlAction(_action, _payload) {}
|
|
173
175
|
|
|
176
|
+
_hasActiveWork() {
|
|
177
|
+
return this._channelBusy.size > 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
_controlPollDelayMs() {
|
|
181
|
+
return this._hasActiveWork() ? 250 : 2000;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
_wakeControlPoller() {
|
|
185
|
+
if (this._controlWake) {
|
|
186
|
+
this._controlWake();
|
|
187
|
+
this._controlWake = null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async _sleepUntilControlPollDue(delayMs) {
|
|
192
|
+
await new Promise((resolve) => {
|
|
193
|
+
const timeout = setTimeout(resolve, delayMs);
|
|
194
|
+
this._controlWake = () => {
|
|
195
|
+
clearTimeout(timeout);
|
|
196
|
+
resolve();
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
this._controlWake = null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async _controlPollerLoop() {
|
|
203
|
+
while (this._running) {
|
|
204
|
+
await this._pollControl();
|
|
205
|
+
if (!this._running) break;
|
|
206
|
+
await this._sleepUntilControlPollDue(this._controlPollDelayMs());
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
174
210
|
// ------------------------------------------------------------------
|
|
175
211
|
// Poll loop
|
|
176
212
|
// ------------------------------------------------------------------
|
|
@@ -193,7 +229,7 @@ class BaseAdapter {
|
|
|
193
229
|
this._log(`Poll #${pollCount}: ${messages.length} messages, cursor=${rawCursor || 'none'}`);
|
|
194
230
|
}
|
|
195
231
|
} catch (e) {
|
|
196
|
-
this._log(`Poll #${pollCount} failed: ${e.message}`);
|
|
232
|
+
this._log(`Poll #${pollCount} failed: ${e.message} \nStack: ${e.stack}`);
|
|
197
233
|
await this._sleep(5000);
|
|
198
234
|
continue;
|
|
199
235
|
}
|
|
@@ -226,7 +262,10 @@ class BaseAdapter {
|
|
|
226
262
|
idleCount++;
|
|
227
263
|
}
|
|
228
264
|
|
|
229
|
-
// Adaptive polling: 2s active, up to 15s idle
|
|
265
|
+
// Adaptive polling: 2s active, up to 15s idle.
|
|
266
|
+
// Each connected agent runs this loop, so faster rates multiply across
|
|
267
|
+
// every workspace member — keep this conservative and tune separately
|
|
268
|
+
// with a load-impact analysis on workspace-endpoint.
|
|
230
269
|
const delay = incoming.length > 0 ? 2000 : Math.min(2000 + idleCount * 1000, 15000);
|
|
231
270
|
await this._sleep(delay);
|
|
232
271
|
}
|
|
@@ -254,6 +293,7 @@ class BaseAdapter {
|
|
|
254
293
|
|
|
255
294
|
// Run channel worker (don't await — parallel execution)
|
|
256
295
|
this._channelWorker(channel, msg);
|
|
296
|
+
this._wakeControlPoller();
|
|
257
297
|
}
|
|
258
298
|
|
|
259
299
|
async _channelWorker(channel, msg) {
|
package/src/adapters/claude.js
CHANGED
|
@@ -33,6 +33,7 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
33
33
|
this.disabledModules = opts.disabledModules || new Set();
|
|
34
34
|
this._channelSessions = {}; // channel → Claude CLI session_id
|
|
35
35
|
this._channelProcesses = {}; // channel → child process
|
|
36
|
+
this._stoppingChannels = new Set();
|
|
36
37
|
this._sessionsFile = path.join(
|
|
37
38
|
os.homedir(), '.openagents', 'sessions',
|
|
38
39
|
`${this.workspaceId}_${this.agentName}.json`
|
|
@@ -64,42 +65,83 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
64
65
|
|
|
65
66
|
async _onControlAction(action, _payload) {
|
|
66
67
|
if (action === 'stop') {
|
|
67
|
-
await this._stopAllProcesses();
|
|
68
|
+
await this._stopAllProcesses('Execution stopped by user.');
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Override BaseAdapter.stop so daemon shutdown also tears down in-flight
|
|
74
|
+
* claude subprocesses cleanly. Without this, killing the daemon leaves
|
|
75
|
+
* the channel's last event as a `status` (e.g. "Bash › ..." mid-tool-call)
|
|
76
|
+
* forever — the workspace UI then shows the thread as "running" until a
|
|
77
|
+
* new message arrives. Fire-and-forget; daemon._killAgent gives us up to
|
|
78
|
+
* 5s to actually finish the cleanup before the parent exits.
|
|
79
|
+
*/
|
|
80
|
+
stop() {
|
|
81
|
+
this._stopAllProcesses(
|
|
82
|
+
'Task interrupted — daemon restarting. Send another message to continue.'
|
|
83
|
+
).catch(() => {});
|
|
84
|
+
super.stop();
|
|
85
|
+
}
|
|
86
|
+
|
|
71
87
|
async _stopProcess(proc) {
|
|
72
88
|
if (!proc || proc.exitCode !== null) return;
|
|
73
89
|
try {
|
|
74
90
|
if (IS_WINDOWS) {
|
|
75
|
-
|
|
91
|
+
// Give Claude Code a Ctrl+C-like interrupt first so it can cancel
|
|
92
|
+
// shell/background tasks it manages before the forceful process-tree
|
|
93
|
+
// cleanup below. Going straight to /F can leave detached tool work
|
|
94
|
+
// alive even though the Claude CLI process itself is gone.
|
|
95
|
+
try { proc.kill('SIGINT'); } catch {}
|
|
96
|
+
const exited = await new Promise((resolve) => {
|
|
97
|
+
if (proc.exitCode !== null) {
|
|
98
|
+
resolve(true);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const timeout = setTimeout(() => resolve(false), 1500);
|
|
102
|
+
proc.once('exit', () => { clearTimeout(timeout); resolve(true); });
|
|
103
|
+
});
|
|
104
|
+
if (!exited) {
|
|
105
|
+
try { execSync(`taskkill /F /T /PID ${proc.pid}`, { timeout: 5000 }); } catch {}
|
|
106
|
+
}
|
|
76
107
|
} else {
|
|
77
108
|
try { process.kill(-proc.pid, 'SIGTERM'); } catch {
|
|
78
109
|
proc.kill('SIGTERM');
|
|
79
110
|
}
|
|
80
111
|
await new Promise((resolve) => {
|
|
112
|
+
let done = false;
|
|
113
|
+
const finish = () => {
|
|
114
|
+
if (done) return;
|
|
115
|
+
done = true;
|
|
116
|
+
resolve();
|
|
117
|
+
};
|
|
81
118
|
const timeout = setTimeout(() => {
|
|
82
119
|
try { process.kill(-proc.pid, 'SIGKILL'); } catch {
|
|
83
120
|
proc.kill('SIGKILL');
|
|
84
121
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
122
|
+
const reapTimeout = setTimeout(finish, 1000);
|
|
123
|
+
proc.once('exit', () => { clearTimeout(reapTimeout); finish(); });
|
|
124
|
+
}, 1500);
|
|
125
|
+
proc.once('exit', () => { clearTimeout(timeout); finish(); });
|
|
88
126
|
});
|
|
89
127
|
}
|
|
90
128
|
} catch {}
|
|
91
129
|
}
|
|
92
130
|
|
|
93
|
-
async _stopAllProcesses() {
|
|
131
|
+
async _stopAllProcesses(completionMessage = 'Execution stopped.') {
|
|
94
132
|
const entries = Object.entries(this._channelProcesses);
|
|
95
133
|
if (!entries.length) return;
|
|
96
134
|
this._log(`Stopping ${entries.length} running process(es)...`);
|
|
97
135
|
for (const [channel, proc] of entries) {
|
|
136
|
+
this._stoppingChannels.add(channel);
|
|
98
137
|
await this._stopProcess(proc);
|
|
99
138
|
delete this._channelProcesses[channel];
|
|
100
139
|
delete this._channelQueues[channel];
|
|
140
|
+
// Post as a chat message (not status) so the channel's last event
|
|
141
|
+
// type is non-status — the workspace UI then transitions out of
|
|
142
|
+
// "agent is working" state instead of shimmering forever.
|
|
101
143
|
try {
|
|
102
|
-
await this.
|
|
144
|
+
await this.sendResponse(channel, completionMessage);
|
|
103
145
|
} catch {}
|
|
104
146
|
}
|
|
105
147
|
}
|
|
@@ -356,6 +398,7 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
356
398
|
if (!content) return;
|
|
357
399
|
|
|
358
400
|
const msgChannel = msg.sessionId || this.channelName;
|
|
401
|
+
this._stoppingChannels.delete(msgChannel);
|
|
359
402
|
const sender = msg.senderName || msg.senderType || 'user';
|
|
360
403
|
this._log(`Processing message from ${sender} in ${msgChannel}: ${content.slice(0, 80)}...`);
|
|
361
404
|
|
|
@@ -390,10 +433,19 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
390
433
|
let mcpConfigFile = null;
|
|
391
434
|
let cmd;
|
|
392
435
|
|
|
393
|
-
// Clean env
|
|
436
|
+
// Clean env: strip every CLAUDE_* / AI_AGENT variable inherited from a
|
|
437
|
+
// parent Claude Code (or Claude Agent SDK) process. If we don't, the
|
|
438
|
+
// spawned `claude` thinks it's running under an SDK harness and picks
|
|
439
|
+
// an org-scoped auth path that returns 403 "Account is no longer a
|
|
440
|
+
// member of the organization" even when the user is logged in fine via
|
|
441
|
+
// `claude login`. We let the child rediscover auth from
|
|
442
|
+
// ~/.claude/.credentials.json (or ANTHROPIC_API_KEY if set).
|
|
394
443
|
const cleanEnv = { ...(this.agentEnv || process.env) };
|
|
395
|
-
|
|
396
|
-
|
|
444
|
+
for (const k of Object.keys(cleanEnv)) {
|
|
445
|
+
if (k.startsWith('CLAUDE_') || k === 'CLAUDECODE' || k === 'AI_AGENT') {
|
|
446
|
+
delete cleanEnv[k];
|
|
447
|
+
}
|
|
448
|
+
}
|
|
397
449
|
|
|
398
450
|
// Run up to 2 attempts: first with session resume, then fresh if stale session detected
|
|
399
451
|
let _shouldRetry = false;
|
|
@@ -548,6 +600,12 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
548
600
|
}
|
|
549
601
|
|
|
550
602
|
delete this._channelProcesses[msgChannel];
|
|
603
|
+
const stoppedByUser = this._stoppingChannels.has(msgChannel);
|
|
604
|
+
if (stoppedByUser) {
|
|
605
|
+
this._stoppingChannels.delete(msgChannel);
|
|
606
|
+
resolve(false);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
551
609
|
|
|
552
610
|
if (code !== 0) {
|
|
553
611
|
this._log(`CLI exited with code ${code}`);
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini CLI adapter for OpenAgents workspace.
|
|
3
|
+
*
|
|
4
|
+
* Bridges Gemini CLI to an OpenAgents workspace via:
|
|
5
|
+
* - Polling loop for incoming messages
|
|
6
|
+
* - Gemini CLI subprocess (stream-json) for task execution
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { execSync, spawn } = require('child_process');
|
|
15
|
+
|
|
16
|
+
const BaseAdapter = require('./base');
|
|
17
|
+
const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
|
|
18
|
+
const { buildClaudeSystemPrompt } = require('./workspace-prompt');
|
|
19
|
+
|
|
20
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
21
|
+
|
|
22
|
+
class GeminiAdapter extends BaseAdapter {
|
|
23
|
+
/**
|
|
24
|
+
* @param {object} opts - BaseAdapter opts plus:
|
|
25
|
+
* @param {Set} [opts.disabledModules]
|
|
26
|
+
* @param {string} [opts.workingDir]
|
|
27
|
+
*/
|
|
28
|
+
constructor(opts) {
|
|
29
|
+
super(opts);
|
|
30
|
+
this.disabledModules = opts.disabledModules || new Set();
|
|
31
|
+
this._channelSessions = {}; // channel → Gemini CLI session_id
|
|
32
|
+
this._channelProcesses = {}; // channel → child process
|
|
33
|
+
this._sessionsFile = path.join(
|
|
34
|
+
os.homedir(), '.openagents', 'sessions',
|
|
35
|
+
`${this.workspaceId}_${this.agentName}_gemini.json`
|
|
36
|
+
);
|
|
37
|
+
this._loadSessions();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_loadSessions() {
|
|
41
|
+
try {
|
|
42
|
+
if (fs.existsSync(this._sessionsFile)) {
|
|
43
|
+
const data = JSON.parse(fs.readFileSync(this._sessionsFile, 'utf-8'));
|
|
44
|
+
if (data && typeof data === 'object') {
|
|
45
|
+
Object.assign(this._channelSessions, data);
|
|
46
|
+
this._log(`Loaded ${Object.keys(data).length} session(s)`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
this._log('Could not load sessions file, starting fresh');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_saveSessions() {
|
|
55
|
+
try {
|
|
56
|
+
const dir = path.dirname(this._sessionsFile);
|
|
57
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
58
|
+
fs.writeFileSync(this._sessionsFile, JSON.stringify(this._channelSessions));
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async _onControlAction(action, _payload) {
|
|
63
|
+
if (action === 'stop') {
|
|
64
|
+
await this._stopAllProcesses();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async _stopProcess(proc) {
|
|
69
|
+
if (!proc || proc.exitCode !== null) return;
|
|
70
|
+
try {
|
|
71
|
+
if (IS_WINDOWS) {
|
|
72
|
+
try { execSync(`taskkill /F /T /PID ${proc.pid}`, { timeout: 5000 }); } catch {}
|
|
73
|
+
} else {
|
|
74
|
+
try { process.kill(-proc.pid, 'SIGTERM'); } catch {
|
|
75
|
+
proc.kill('SIGTERM');
|
|
76
|
+
}
|
|
77
|
+
await new Promise((resolve) => {
|
|
78
|
+
const timeout = setTimeout(() => {
|
|
79
|
+
try { process.kill(-proc.pid, 'SIGKILL'); } catch {
|
|
80
|
+
proc.kill('SIGKILL');
|
|
81
|
+
}
|
|
82
|
+
resolve();
|
|
83
|
+
}, 5000);
|
|
84
|
+
proc.on('exit', () => { clearTimeout(timeout); resolve(); });
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
} catch {}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async _stopAllProcesses() {
|
|
91
|
+
const entries = Object.entries(this._channelProcesses);
|
|
92
|
+
if (!entries.length) return;
|
|
93
|
+
this._log(`Stopping ${entries.length} running process(es)...`);
|
|
94
|
+
for (const [channel, proc] of entries) {
|
|
95
|
+
await this._stopProcess(proc);
|
|
96
|
+
delete this._channelProcesses[channel];
|
|
97
|
+
delete this._channelQueues[channel];
|
|
98
|
+
try {
|
|
99
|
+
await this.sendStatus(channel, 'Execution stopped by user');
|
|
100
|
+
} catch {}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
_findNodeBin() {
|
|
105
|
+
const home = os.homedir();
|
|
106
|
+
const candidates = IS_WINDOWS
|
|
107
|
+
? [path.join(home, '.openagents', 'nodejs', 'node.exe')]
|
|
108
|
+
: [path.join(home, '.openagents', 'nodejs', 'node'),
|
|
109
|
+
path.join(home, '.openagents', 'nodejs', 'bin', 'node')];
|
|
110
|
+
for (const c of candidates) {
|
|
111
|
+
if (fs.existsSync(c)) return c;
|
|
112
|
+
}
|
|
113
|
+
return 'node';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
_resolveToNodeCmd(binPath) {
|
|
117
|
+
const nodeBin = this._findNodeBin();
|
|
118
|
+
if (IS_WINDOWS && binPath.toLowerCase().endsWith('.cmd')) {
|
|
119
|
+
const cmdDir = path.dirname(path.resolve(binPath));
|
|
120
|
+
const cmdContent = fs.readFileSync(binPath, 'utf-8');
|
|
121
|
+
const jsMatch = cmdContent.match(/%dp0%\\([^\s"*?]+\.m?js)/i);
|
|
122
|
+
if (jsMatch) {
|
|
123
|
+
return [nodeBin, path.resolve(cmdDir, jsMatch[1])];
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
try {
|
|
127
|
+
let target = binPath;
|
|
128
|
+
if (fs.lstatSync(binPath).isSymbolicLink()) {
|
|
129
|
+
target = path.resolve(path.dirname(binPath), fs.readlinkSync(binPath));
|
|
130
|
+
}
|
|
131
|
+
if (target.endsWith('.js') || target.endsWith('.mjs')) {
|
|
132
|
+
return [nodeBin, target];
|
|
133
|
+
}
|
|
134
|
+
} catch {}
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
_findGeminiBinary() {
|
|
140
|
+
const home = os.homedir();
|
|
141
|
+
const ext = IS_WINDOWS ? '.cmd' : '';
|
|
142
|
+
|
|
143
|
+
// Tier 0: Isolated runtime prefix
|
|
144
|
+
const runtimeCandidate = path.join(home, '.openagents', 'runtimes', 'gemini', 'node_modules', '.bin', `gemini${ext}`);
|
|
145
|
+
if (fs.existsSync(runtimeCandidate)) return runtimeCandidate;
|
|
146
|
+
|
|
147
|
+
// Tier 1: PATH search
|
|
148
|
+
try {
|
|
149
|
+
if (IS_WINDOWS) {
|
|
150
|
+
const r = execSync('where gemini.cmd 2>nul || where gemini.exe 2>nul || where gemini 2>nul', {
|
|
151
|
+
encoding: 'utf-8', timeout: 5000,
|
|
152
|
+
});
|
|
153
|
+
return r.split(/\r?\n/)[0].trim();
|
|
154
|
+
} else {
|
|
155
|
+
return execSync('which gemini', { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
156
|
+
}
|
|
157
|
+
} catch {}
|
|
158
|
+
|
|
159
|
+
// Tier 2: Next to current Node.js interpreter
|
|
160
|
+
const nodeBinDir = path.dirname(process.execPath);
|
|
161
|
+
const nearNode = path.join(nodeBinDir, `gemini${ext}`);
|
|
162
|
+
if (fs.existsSync(nearNode)) return nearNode;
|
|
163
|
+
|
|
164
|
+
// Tier 3: Common install locations
|
|
165
|
+
const candidates = IS_WINDOWS ? [
|
|
166
|
+
path.join(process.env.APPDATA || '', 'npm', 'gemini.cmd'),
|
|
167
|
+
] : [
|
|
168
|
+
path.join(home, '.local', 'bin', 'gemini'),
|
|
169
|
+
path.join(home, '.npm-global', 'bin', 'gemini'),
|
|
170
|
+
'/opt/homebrew/bin/gemini',
|
|
171
|
+
'/usr/local/bin/gemini',
|
|
172
|
+
];
|
|
173
|
+
for (const c of candidates) {
|
|
174
|
+
if (fs.existsSync(c)) return c;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
_buildGeminiCmd(prompt, channelName, { skipResume = false } = {}) {
|
|
181
|
+
const geminiBin = this._findGeminiBinary();
|
|
182
|
+
if (!geminiBin) {
|
|
183
|
+
throw new Error('gemini CLI not found. Install with: npm install -g @google/gemini-cli');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const systemPrompt = '\n' + buildClaudeSystemPrompt({
|
|
187
|
+
agentName: this.agentName,
|
|
188
|
+
workspaceId: this.workspaceId,
|
|
189
|
+
channelName,
|
|
190
|
+
mode: this._mode,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// For gemini, we combine system prompt with the user message since it doesn't have an append-system-prompt flag
|
|
194
|
+
const fullPrompt = `${systemPrompt}\n\n---\n\nUser message:\n${prompt}`;
|
|
195
|
+
|
|
196
|
+
const cmd = [geminiBin, '-p', fullPrompt, '-y', '-o', 'stream-json'];
|
|
197
|
+
|
|
198
|
+
const sessionId = this._channelSessions[channelName];
|
|
199
|
+
if (sessionId && !skipResume) {
|
|
200
|
+
cmd.push('-r', sessionId);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { cmd };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async _handleMessage(msg) {
|
|
207
|
+
let content = (msg.content || '').trim();
|
|
208
|
+
const attachments = msg.attachments || [];
|
|
209
|
+
|
|
210
|
+
const attText = formatAttachmentsForPrompt(attachments);
|
|
211
|
+
if (attText) {
|
|
212
|
+
content = content ? content + attText : attText.trim();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!content) return;
|
|
216
|
+
|
|
217
|
+
const msgChannel = msg.sessionId || this.channelName;
|
|
218
|
+
const sender = msg.senderName || msg.senderType || 'user';
|
|
219
|
+
this._log(`Processing message from ${sender} in ${msgChannel}: ${content.slice(0, 80)}...`);
|
|
220
|
+
|
|
221
|
+
if (!this._titledSessions.has(msgChannel)) {
|
|
222
|
+
this._titledSessions.add(msgChannel);
|
|
223
|
+
try {
|
|
224
|
+
const info = await this.client.getSession(this.workspaceId, msgChannel, this.token);
|
|
225
|
+
const resumeFrom = info.resumeFrom;
|
|
226
|
+
if (resumeFrom && !this._channelSessions[msgChannel]) {
|
|
227
|
+
const sourceSession = this._channelSessions[resumeFrom];
|
|
228
|
+
if (sourceSession) {
|
|
229
|
+
this._channelSessions[msgChannel] = sourceSession;
|
|
230
|
+
this._saveSessions();
|
|
231
|
+
this._log(`Resuming channel ${msgChannel} from ${resumeFrom}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const title = generateSessionTitle(content);
|
|
235
|
+
if (title && !info.titleManuallySet && SESSION_DEFAULT_RE.test(info.title || '')) {
|
|
236
|
+
await this.client.updateSession(
|
|
237
|
+
this.workspaceId, msgChannel, this.token,
|
|
238
|
+
{ title, autoTitle: true }
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
} catch {}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
await this.sendStatus(msgChannel, 'thinking...');
|
|
245
|
+
|
|
246
|
+
let cmd;
|
|
247
|
+
const cleanEnv = { ...(this.agentEnv || process.env) };
|
|
248
|
+
|
|
249
|
+
let _shouldRetry = false;
|
|
250
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
251
|
+
try {
|
|
252
|
+
const built = this._buildGeminiCmd(content, msgChannel, { skipResume: attempt > 0 });
|
|
253
|
+
cmd = built.cmd;
|
|
254
|
+
} catch (e) {
|
|
255
|
+
await this.sendError(msgChannel, e.message);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
const resolved = this._resolveToNodeCmd(cmd[0]);
|
|
261
|
+
if (resolved) {
|
|
262
|
+
cmd = [resolved[0], resolved[1], ...cmd.slice(1)];
|
|
263
|
+
} else if (IS_WINDOWS && cmd[0].toLowerCase().endsWith('.cmd')) {
|
|
264
|
+
cmd = ['cmd.exe', '/c', ...cmd];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const proc = spawn(cmd[0], cmd.slice(1), {
|
|
268
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
269
|
+
env: cleanEnv,
|
|
270
|
+
cwd: this.workingDir,
|
|
271
|
+
detached: !IS_WINDOWS,
|
|
272
|
+
windowsHide: true,
|
|
273
|
+
});
|
|
274
|
+
this._channelProcesses[msgChannel] = proc;
|
|
275
|
+
|
|
276
|
+
const lastResponseText = [];
|
|
277
|
+
let hasToolUseSinceLastText = false;
|
|
278
|
+
let postedThinking = false;
|
|
279
|
+
let stderrBuf = '';
|
|
280
|
+
let lineBuffer = '';
|
|
281
|
+
let _pendingLines = Promise.resolve();
|
|
282
|
+
|
|
283
|
+
if (proc.stderr) {
|
|
284
|
+
proc.stderr.on('data', (chunk) => { stderrBuf += chunk.toString('utf-8'); });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
_shouldRetry = await new Promise((resolve, reject) => {
|
|
288
|
+
let consecutiveTimeouts = 0;
|
|
289
|
+
let lastDataTime = Date.now();
|
|
290
|
+
let timeoutTimer = null;
|
|
291
|
+
|
|
292
|
+
const resetTimeout = () => {
|
|
293
|
+
consecutiveTimeouts = 0;
|
|
294
|
+
lastDataTime = Date.now();
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const startTimeoutMonitor = () => {
|
|
298
|
+
timeoutTimer = setInterval(async () => {
|
|
299
|
+
const elapsed = Date.now() - lastDataTime;
|
|
300
|
+
if (elapsed >= 15000) {
|
|
301
|
+
consecutiveTimeouts++;
|
|
302
|
+
lastDataTime = Date.now();
|
|
303
|
+
if (consecutiveTimeouts === 2) {
|
|
304
|
+
try { await this.sendStatus(msgChannel, 'Processing...'); } catch {}
|
|
305
|
+
}
|
|
306
|
+
if (consecutiveTimeouts >= 20) {
|
|
307
|
+
this._log(`Process idle for ${consecutiveTimeouts * 15}s, killing...`);
|
|
308
|
+
await this._stopProcess(proc);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}, 15000);
|
|
312
|
+
};
|
|
313
|
+
startTimeoutMonitor();
|
|
314
|
+
|
|
315
|
+
const processLine = async (line) => {
|
|
316
|
+
line = line.trim();
|
|
317
|
+
if (!line) return;
|
|
318
|
+
resetTimeout();
|
|
319
|
+
|
|
320
|
+
let event;
|
|
321
|
+
try { event = JSON.parse(line); } catch { return; }
|
|
322
|
+
|
|
323
|
+
const eventType = event.type;
|
|
324
|
+
|
|
325
|
+
if (eventType === 'init' && event.session_id) {
|
|
326
|
+
this._channelSessions[msgChannel] = event.session_id;
|
|
327
|
+
this._saveSessions();
|
|
328
|
+
} else if (eventType === 'message' && event.role === 'assistant') {
|
|
329
|
+
const text = event.content || '';
|
|
330
|
+
if (text) {
|
|
331
|
+
if (hasToolUseSinceLastText) {
|
|
332
|
+
lastResponseText.length = 0;
|
|
333
|
+
hasToolUseSinceLastText = false;
|
|
334
|
+
}
|
|
335
|
+
lastResponseText.push(text);
|
|
336
|
+
postedThinking = true;
|
|
337
|
+
try { await this.sendThinking(msgChannel, text); } catch {}
|
|
338
|
+
}
|
|
339
|
+
} else if (eventType === 'tool_use') {
|
|
340
|
+
hasToolUseSinceLastText = true;
|
|
341
|
+
postedThinking = false;
|
|
342
|
+
lastResponseText.length = 0;
|
|
343
|
+
const toolName = event.tool_name || '';
|
|
344
|
+
let inputPreview = '';
|
|
345
|
+
if (event.parameters && typeof event.parameters === 'object') {
|
|
346
|
+
const inp = event.parameters;
|
|
347
|
+
if (inp.command) inputPreview = inp.command;
|
|
348
|
+
else if (inp.file_path || inp.path) inputPreview = inp.file_path || inp.path;
|
|
349
|
+
else if (inp.pattern) inputPreview = inp.pattern;
|
|
350
|
+
else if (inp.query) inputPreview = inp.query;
|
|
351
|
+
else inputPreview = JSON.stringify(inp).slice(0, 150);
|
|
352
|
+
}
|
|
353
|
+
await this.sendStatus(msgChannel, `${toolName} › ${inputPreview}`);
|
|
354
|
+
} else if (eventType === 'result') {
|
|
355
|
+
if (event.session_id) {
|
|
356
|
+
this._channelSessions[msgChannel] = event.session_id;
|
|
357
|
+
this._saveSessions();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
proc.on('exit', async (code) => {
|
|
363
|
+
if (timeoutTimer) clearInterval(timeoutTimer);
|
|
364
|
+
|
|
365
|
+
try { await _pendingLines; } catch {}
|
|
366
|
+
|
|
367
|
+
const lines = lineBuffer.split('\n');
|
|
368
|
+
for (const line of lines) {
|
|
369
|
+
try { await processLine(line); } catch {}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
delete this._channelProcesses[msgChannel];
|
|
373
|
+
|
|
374
|
+
if (code !== 0) {
|
|
375
|
+
this._log(`CLI exited with code ${code}`);
|
|
376
|
+
if (stderrBuf.trim()) {
|
|
377
|
+
this._log(`stderr: ${stderrBuf.trim().slice(0, 500)}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (lastResponseText.length > 0) {
|
|
382
|
+
const fullResponse = lastResponseText.join('').trim(); // Gemini deltas are partial strings, no newline needed between them usually, but wait, delta:true means it appends. If it's multiple blocks, we should join with empty string? Let's check `delta: true`.
|
|
383
|
+
// Actually if delta: true, they are chunks. We pushed them to array. `lastResponseText.join('')` is correct.
|
|
384
|
+
if (fullResponse) {
|
|
385
|
+
try { await this.sendResponse(msgChannel, fullResponse); } catch {}
|
|
386
|
+
}
|
|
387
|
+
resolve(false);
|
|
388
|
+
} else if (code !== 0 && this._channelSessions[msgChannel]) {
|
|
389
|
+
this._log(`Stale session detected for ${msgChannel}, clearing and retrying without resume`);
|
|
390
|
+
delete this._channelSessions[msgChannel];
|
|
391
|
+
this._saveSessions();
|
|
392
|
+
resolve(true);
|
|
393
|
+
} else {
|
|
394
|
+
if (!postedThinking) {
|
|
395
|
+
try { await this.sendResponse(msgChannel, 'No response generated. Please try again.'); } catch {}
|
|
396
|
+
}
|
|
397
|
+
resolve(false);
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
proc.on('error', (err) => {
|
|
402
|
+
if (timeoutTimer) clearInterval(timeoutTimer);
|
|
403
|
+
reject(err);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
proc.stdout.on('data', (chunk) => {
|
|
407
|
+
lineBuffer += chunk.toString('utf-8');
|
|
408
|
+
resetTimeout();
|
|
409
|
+
const lines = lineBuffer.split('\n');
|
|
410
|
+
lineBuffer = lines.pop();
|
|
411
|
+
for (const line of lines) {
|
|
412
|
+
_pendingLines = _pendingLines.then(() => processLine(line)).catch(() => {});
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
} catch (e) {
|
|
417
|
+
this._log(`Error handling message: ${e.message}`);
|
|
418
|
+
await this.sendError(msgChannel, `Error processing message: ${e.message}`);
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
if (!_shouldRetry) break;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
module.exports = GeminiAdapter;
|
package/src/adapters/index.js
CHANGED
|
@@ -12,6 +12,7 @@ const OpenCodeAdapter = require('./opencode');
|
|
|
12
12
|
const NanoClawAdapter = require('./nanoclaw');
|
|
13
13
|
const CursorAdapter = require('./cursor');
|
|
14
14
|
const HermesAdapter = require('./hermes');
|
|
15
|
+
const GeminiAdapter = require('./gemini');
|
|
15
16
|
|
|
16
17
|
const ADAPTER_MAP = {
|
|
17
18
|
openclaw: OpenClawAdapter,
|
|
@@ -21,6 +22,7 @@ const ADAPTER_MAP = {
|
|
|
21
22
|
nanoclaw: NanoClawAdapter,
|
|
22
23
|
cursor: CursorAdapter,
|
|
23
24
|
hermes: HermesAdapter,
|
|
25
|
+
gemini: GeminiAdapter,
|
|
24
26
|
};
|
|
25
27
|
|
|
26
28
|
/**
|
|
@@ -46,6 +48,7 @@ module.exports = {
|
|
|
46
48
|
NanoClawAdapter,
|
|
47
49
|
CursorAdapter,
|
|
48
50
|
HermesAdapter,
|
|
51
|
+
GeminiAdapter,
|
|
49
52
|
createAdapter,
|
|
50
53
|
ADAPTER_MAP,
|
|
51
54
|
};
|
package/src/index.js
CHANGED
|
@@ -169,6 +169,22 @@ class AgentConnector {
|
|
|
169
169
|
return { success: true };
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
async removeWorkspace(slug) {
|
|
173
|
+
const networks = this.config.getNetworks();
|
|
174
|
+
const network = networks.find(n => n.slug === slug || n.id === slug);
|
|
175
|
+
if (network && network.id) {
|
|
176
|
+
// Use the network's specific endpoint (e.g., localhost vs official)
|
|
177
|
+
const endpoint = network.endpoint || (this.workspace && this.workspace.endpoint);
|
|
178
|
+
const { WorkspaceClient } = require('./workspace-client');
|
|
179
|
+
const tempClient = new WorkspaceClient(endpoint);
|
|
180
|
+
// Try to remove from backend first
|
|
181
|
+
await tempClient.deleteWorkspace(network.id, network.token || '');
|
|
182
|
+
}
|
|
183
|
+
// Remove from local config (which also disconnects any agents)
|
|
184
|
+
this.config.removeNetwork(slug);
|
|
185
|
+
return { success: true };
|
|
186
|
+
}
|
|
187
|
+
|
|
172
188
|
// -- Daemon lifecycle --
|
|
173
189
|
|
|
174
190
|
/**
|
package/src/workspace-client.js
CHANGED
|
@@ -72,6 +72,18 @@ class WorkspaceClient {
|
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Delete a workspace via DELETE /v1/workspaces/{workspaceId}.
|
|
77
|
+
*/
|
|
78
|
+
async deleteWorkspace(workspaceId, token) {
|
|
79
|
+
try {
|
|
80
|
+
await this._delete(`/v1/workspaces/${workspaceId}`, this._wsHeaders(token));
|
|
81
|
+
} catch (e) {
|
|
82
|
+
// Best-effort remote deletion.
|
|
83
|
+
console.warn(`Failed to remotely delete workspace ${workspaceId}: ${e.message}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
75
87
|
/**
|
|
76
88
|
* Join a workspace via POST /v1/join.
|
|
77
89
|
*/
|