@openagents-org/agent-launcher 0.2.114 → 0.2.116
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 +112 -8
- 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 +38 -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,125 @@ 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
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Build a short transcript of the channel's last chat exchanges, used to
|
|
133
|
+
* re-seed context when --resume fails and we have to start a fresh
|
|
134
|
+
* Claude Code session. Returns null when there's nothing useful to add.
|
|
135
|
+
*
|
|
136
|
+
* Excludes the user's current message (the for-loop will append it
|
|
137
|
+
* normally) and any status/thinking events, which are mostly tool-call
|
|
138
|
+
* noise and inflate the prompt without adding signal.
|
|
139
|
+
*/
|
|
140
|
+
async _buildChannelRecap(channelName, currentMessage) {
|
|
141
|
+
const messages = await this.client.getRecentMessages(
|
|
142
|
+
this.workspaceId, channelName, this.token, 30
|
|
143
|
+
);
|
|
144
|
+
if (!messages || messages.length === 0) return null;
|
|
145
|
+
|
|
146
|
+
const lines = [];
|
|
147
|
+
for (const m of messages) {
|
|
148
|
+
const mt = m.messageType || 'chat';
|
|
149
|
+
if (mt === 'status' || mt === 'thinking' || mt === 'loading') continue;
|
|
150
|
+
const text = (m.content || '').trim();
|
|
151
|
+
if (!text) continue;
|
|
152
|
+
// Don't echo the user's current message back at them.
|
|
153
|
+
if (text === currentMessage) continue;
|
|
154
|
+
const who = m.senderType === 'human'
|
|
155
|
+
? (m.senderName || 'user')
|
|
156
|
+
: (m.senderName || 'agent');
|
|
157
|
+
// Cap each line so a single huge paste doesn't blow up the prompt.
|
|
158
|
+
const truncated = text.length > 800 ? text.slice(0, 800) + '…' : text;
|
|
159
|
+
lines.push(`[${who}] ${truncated}`);
|
|
160
|
+
}
|
|
161
|
+
if (lines.length === 0) return null;
|
|
162
|
+
|
|
163
|
+
// Keep only the tail; older context has diminishing value and we
|
|
164
|
+
// don't want to balloon the system prompt.
|
|
165
|
+
const tail = lines.slice(-15).join('\n');
|
|
166
|
+
return (
|
|
167
|
+
'You previously worked in this channel but your prior session is no ' +
|
|
168
|
+
'longer available, so here is the recent conversation for context:\n\n' +
|
|
169
|
+
tail
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async _stopAllProcesses(completionMessage = 'Execution stopped.') {
|
|
94
174
|
const entries = Object.entries(this._channelProcesses);
|
|
95
175
|
if (!entries.length) return;
|
|
96
176
|
this._log(`Stopping ${entries.length} running process(es)...`);
|
|
97
177
|
for (const [channel, proc] of entries) {
|
|
178
|
+
this._stoppingChannels.add(channel);
|
|
98
179
|
await this._stopProcess(proc);
|
|
99
180
|
delete this._channelProcesses[channel];
|
|
100
181
|
delete this._channelQueues[channel];
|
|
182
|
+
// Post as a chat message (not status) so the channel's last event
|
|
183
|
+
// type is non-status — the workspace UI then transitions out of
|
|
184
|
+
// "agent is working" state instead of shimmering forever.
|
|
101
185
|
try {
|
|
102
|
-
await this.
|
|
186
|
+
await this.sendResponse(channel, completionMessage);
|
|
103
187
|
} catch {}
|
|
104
188
|
}
|
|
105
189
|
}
|
|
@@ -356,6 +440,7 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
356
440
|
if (!content) return;
|
|
357
441
|
|
|
358
442
|
const msgChannel = msg.sessionId || this.channelName;
|
|
443
|
+
this._stoppingChannels.delete(msgChannel);
|
|
359
444
|
const sender = msg.senderName || msg.senderType || 'user';
|
|
360
445
|
this._log(`Processing message from ${sender} in ${msgChannel}: ${content.slice(0, 80)}...`);
|
|
361
446
|
|
|
@@ -406,10 +491,23 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
406
491
|
|
|
407
492
|
// Run up to 2 attempts: first with session resume, then fresh if stale session detected
|
|
408
493
|
let _shouldRetry = false;
|
|
494
|
+
let effectiveContent = content;
|
|
409
495
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
410
496
|
if (mcpConfigFile) { try { fs.unlinkSync(mcpConfigFile); } catch {} mcpConfigFile = null; }
|
|
497
|
+
|
|
498
|
+
// On the retry pass after a stale --resume, the spawned `claude`
|
|
499
|
+
// starts a brand-new session with no memory of prior turns. Replay
|
|
500
|
+
// the channel's recent chat history so the agent at least has a
|
|
501
|
+
// recap instead of saying "I don't see any previous messages."
|
|
502
|
+
if (attempt > 0) {
|
|
503
|
+
try {
|
|
504
|
+
const recap = await this._buildChannelRecap(msgChannel, content);
|
|
505
|
+
if (recap) effectiveContent = `${recap}\n\n---\n\n${content}`;
|
|
506
|
+
} catch {}
|
|
507
|
+
}
|
|
508
|
+
|
|
411
509
|
try {
|
|
412
|
-
const built = this._buildClaudeCmd(
|
|
510
|
+
const built = this._buildClaudeCmd(effectiveContent, msgChannel, { skipResume: attempt > 0 });
|
|
413
511
|
cmd = built.cmd;
|
|
414
512
|
mcpConfigFile = built.mcpConfigFile;
|
|
415
513
|
} catch (e) {
|
|
@@ -557,6 +655,12 @@ class ClaudeAdapter extends BaseAdapter {
|
|
|
557
655
|
}
|
|
558
656
|
|
|
559
657
|
delete this._channelProcesses[msgChannel];
|
|
658
|
+
const stoppedByUser = this._stoppingChannels.has(msgChannel);
|
|
659
|
+
if (stoppedByUser) {
|
|
660
|
+
this._stoppingChannels.delete(msgChannel);
|
|
661
|
+
resolve(false);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
560
664
|
|
|
561
665
|
if (code !== 0) {
|
|
562
666
|
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
|
*/
|
|
@@ -179,6 +191,32 @@ class WorkspaceClient {
|
|
|
179
191
|
return events.map((e) => this._eventToMessage(e));
|
|
180
192
|
}
|
|
181
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Fetch the most recent N messages in a channel, returned oldest-to-newest.
|
|
196
|
+
* Used by adapters to rebuild context for a fresh Claude Code session
|
|
197
|
+
* when --resume of the previous session fails (the channel's chat history
|
|
198
|
+
* is the only thing that survives a session-storage rotation).
|
|
199
|
+
*/
|
|
200
|
+
async getRecentMessages(workspaceId, channelName, token, limit = 30) {
|
|
201
|
+
try {
|
|
202
|
+
const params = new URLSearchParams({
|
|
203
|
+
network: workspaceId,
|
|
204
|
+
channel: channelName,
|
|
205
|
+
type: 'workspace.message',
|
|
206
|
+
sort: 'desc',
|
|
207
|
+
limit: String(limit),
|
|
208
|
+
});
|
|
209
|
+
const data = await this._get(`/v1/events?${params}`, this._wsHeaders(token));
|
|
210
|
+
const result = data.data || data;
|
|
211
|
+
const events = (result && result.events) || [];
|
|
212
|
+
// Server returned newest-first; reverse so the caller can present them
|
|
213
|
+
// in chronological order without further fiddling.
|
|
214
|
+
return events.slice().reverse().map((e) => this._eventToMessage(e));
|
|
215
|
+
} catch {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
182
220
|
/**
|
|
183
221
|
* Fetch the latest workspace.message.posted event id (head cursor).
|
|
184
222
|
* Used by adapters to skip past existing events on join in O(1) instead
|