@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openagents-org/agent-launcher",
3
- "version": "0.2.114",
3
+ "version": "0.2.116",
4
4
  "description": "OpenAgents Launcher — install, configure, and run AI coding agents from your terminal",
5
5
  "main": "src/index.js",
6
6
  "bin": {
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": {
@@ -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 controlInterval = setInterval(() => this._pollControl(), 2000);
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
- clearInterval(controlInterval);
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) {
@@ -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
- try { execSync(`taskkill /F /T /PID ${proc.pid}`, { timeout: 5000 }); } catch {}
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
- resolve();
86
- }, 5000);
87
- proc.on('exit', () => { clearTimeout(timeout); resolve(); });
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
+ /**
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.sendStatus(channel, 'Execution stopped by user');
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(content, msgChannel, { skipResume: attempt > 0 });
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;
@@ -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
  /**
@@ -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