@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openagents-org/agent-launcher",
3
- "version": "0.2.113",
3
+ "version": "0.2.115",
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,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
- 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
+ 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.sendStatus(channel, 'Execution stopped by user');
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
- delete cleanEnv.CLAUDECODE;
396
- delete cleanEnv.CLAUDE_CODE_SESSION;
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;
@@ -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
  */