groove-dev 0.27.30 → 0.27.33

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.
Files changed (46) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/cli/src/commands/start.js +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/api.js +32 -2
  5. package/node_modules/@groove-dev/daemon/src/firstrun.js +1 -0
  6. package/node_modules/@groove-dev/daemon/src/index.js +14 -0
  7. package/node_modules/@groove-dev/daemon/src/journalist.js +16 -4
  8. package/node_modules/@groove-dev/daemon/src/memory.js +6 -1
  9. package/node_modules/@groove-dev/daemon/src/process.js +44 -28
  10. package/node_modules/@groove-dev/daemon/src/providers/local.js +4 -2
  11. package/node_modules/@groove-dev/daemon/src/providers/ollama.js +35 -3
  12. package/node_modules/@groove-dev/daemon/src/rotator.js +1 -0
  13. package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +19 -7
  14. package/node_modules/@groove-dev/daemon/test/rotator.test.js +1 -0
  15. package/node_modules/@groove-dev/gui/dist/assets/{index-BwNjgBny.css → index-BnLiWvrh.css} +1 -1
  16. package/node_modules/@groove-dev/gui/dist/assets/{index-PxWmJjcJ.js → index-BoU6IhQI.js} +1635 -1635
  17. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  18. package/node_modules/@groove-dev/gui/package.json +1 -1
  19. package/node_modules/@groove-dev/gui/src/components/layout/app-shell.jsx +2 -0
  20. package/node_modules/@groove-dev/gui/src/components/layout/breadcrumb-bar.jsx +16 -1
  21. package/node_modules/@groove-dev/gui/src/components/layout/project-picker.jsx +127 -0
  22. package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +22 -15
  23. package/node_modules/@groove-dev/gui/src/stores/groove.js +39 -2
  24. package/package.json +1 -1
  25. package/packages/cli/package.json +1 -1
  26. package/packages/cli/src/commands/start.js +1 -1
  27. package/packages/daemon/package.json +1 -1
  28. package/packages/daemon/src/api.js +32 -2
  29. package/packages/daemon/src/firstrun.js +1 -0
  30. package/packages/daemon/src/index.js +14 -0
  31. package/packages/daemon/src/journalist.js +16 -4
  32. package/packages/daemon/src/memory.js +6 -1
  33. package/packages/daemon/src/process.js +44 -28
  34. package/packages/daemon/src/providers/local.js +4 -2
  35. package/packages/daemon/src/providers/ollama.js +35 -3
  36. package/packages/daemon/src/rotator.js +1 -0
  37. package/packages/daemon/src/tunnel-manager.js +19 -7
  38. package/packages/gui/dist/assets/{index-BwNjgBny.css → index-BnLiWvrh.css} +1 -1
  39. package/packages/gui/dist/assets/{index-PxWmJjcJ.js → index-BoU6IhQI.js} +1635 -1635
  40. package/packages/gui/dist/index.html +2 -2
  41. package/packages/gui/package.json +1 -1
  42. package/packages/gui/src/components/layout/app-shell.jsx +2 -0
  43. package/packages/gui/src/components/layout/breadcrumb-bar.jsx +16 -1
  44. package/packages/gui/src/components/layout/project-picker.jsx +127 -0
  45. package/packages/gui/src/components/settings/quick-connect.jsx +22 -15
  46. package/packages/gui/src/stores/groove.js +39 -2
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.30",
3
+ "version": "0.27.33",
4
4
  "description": "GROOVE CLI — manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -19,7 +19,7 @@ export async function start(options) {
19
19
  setupKeys = result.keys || {};
20
20
  } catch (err) {
21
21
  // If stdin is not interactive (piped), skip wizard
22
- if (err.code === 'ERR_USE_AFTER_CLOSE') {
22
+ if (err.code === 'ERR_USE_AFTER_CLOSE' || !process.stdin.isTTY) {
23
23
  console.log(chalk.dim(' Non-interactive mode — skipping setup wizard.'));
24
24
  } else {
25
25
  throw err;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.30",
3
+ "version": "0.27.33",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -426,7 +426,10 @@ export function createApi(app, daemon) {
426
426
  app.post('/api/providers/ollama/pull', async (req, res) => {
427
427
  const { model } = req.body;
428
428
  if (!model) return res.status(400).json({ error: 'model is required' });
429
- if (!OllamaProvider.isInstalled()) return res.status(400).json({ error: 'Ollama is not installed. Install with: brew install ollama' });
429
+ if (!OllamaProvider.isInstalled()) {
430
+ const install = OllamaProvider.installCommand();
431
+ return res.status(400).json({ error: `Ollama is not installed. Install with: ${install.command}` });
432
+ }
430
433
  const broadcast = daemon.broadcast.bind(daemon);
431
434
  try {
432
435
  // Auto-start Ollama server if not running
@@ -692,6 +695,28 @@ export function createApi(app, daemon) {
692
695
  });
693
696
  });
694
697
 
698
+ // --- Project Directory ---
699
+
700
+ app.get('/api/project-dir', (req, res) => {
701
+ res.json({
702
+ projectDir: daemon.projectDir,
703
+ recentProjects: daemon.config.recentProjects || [],
704
+ });
705
+ });
706
+
707
+ app.post('/api/project-dir', (req, res) => {
708
+ const { path: dirPath } = req.body || {};
709
+ if (!dirPath || typeof dirPath !== 'string') {
710
+ return res.status(400).json({ error: 'path is required' });
711
+ }
712
+ try {
713
+ daemon.setProjectDir(dirPath);
714
+ res.json({ projectDir: daemon.projectDir, recentProjects: daemon.config.recentProjects || [] });
715
+ } catch (err) {
716
+ res.status(400).json({ error: err.message });
717
+ }
718
+ });
719
+
695
720
  // --- Teams (live agent groups) ---
696
721
 
697
722
  app.get('/api/teams', (req, res) => {
@@ -3327,7 +3352,12 @@ Keep responses concise. Help them think, don't lecture them about the system the
3327
3352
 
3328
3353
  app.post('/api/tunnels/:id/connect', proOnly, async (req, res) => {
3329
3354
  try {
3330
- const result = await daemon.tunnelManager.connect(req.params.id);
3355
+ const opts = {};
3356
+ if (req.body?.skipTest && req.body?.testResult) {
3357
+ opts.skipTest = true;
3358
+ opts.testResult = req.body.testResult;
3359
+ }
3360
+ const result = await daemon.tunnelManager.connect(req.params.id, opts);
3331
3361
  res.json(result);
3332
3362
  } catch (err) {
3333
3363
  const body = { error: err.message };
@@ -25,6 +25,7 @@ const DEFAULT_CONFIG = {
25
25
  autoRotate: true,
26
26
  tokenCeilingPerAgent: 5_000_000,
27
27
  },
28
+ recentProjects: [],
28
29
  };
29
30
 
30
31
  export function isFirstRun(grooveDir) {
@@ -337,6 +337,20 @@ export class Daemon {
337
337
  }
338
338
  }
339
339
 
340
+ setProjectDir(dirPath) {
341
+ if (!dirPath || typeof dirPath !== 'string') throw new Error('Invalid path');
342
+ const resolved = resolve(dirPath);
343
+ if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
344
+ throw new Error('Directory does not exist');
345
+ }
346
+ this.projectDir = resolved;
347
+ const recents = (this.config.recentProjects || []).filter((r) => r.path !== resolved);
348
+ recents.unshift({ path: resolved, name: resolved.split('/').pop(), openedAt: new Date().toISOString() });
349
+ this.config.recentProjects = recents.slice(0, 10);
350
+ saveConfig(this.grooveDir, this.config);
351
+ this.broadcast({ type: 'project-dir:changed', data: { projectDir: resolved } });
352
+ }
353
+
340
354
  async _pollSubscription() {
341
355
  if (!this.authToken) return;
342
356
  const API_BASE = 'https://docs.groovedev.ai/api/v1';
@@ -22,6 +22,7 @@ export class Journalist {
22
22
  this.history = []; // recent synthesis summaries
23
23
  this._debounceTimer = null;
24
24
  this._debounceReason = null;
25
+ this._forceNextCycle = false;
25
26
  }
26
27
 
27
28
  start(intervalMs = DEFAULT_INTERVAL) {
@@ -61,6 +62,9 @@ export class Journalist {
61
62
  }
62
63
 
63
64
  requestSynthesis(reason = 'unknown') {
65
+ if (reason === 'completion' || reason === 'rotation_complete') {
66
+ this._forceNextCycle = true;
67
+ }
64
68
  if (this._debounceTimer) {
65
69
  this._debounceReason = reason;
66
70
  return;
@@ -84,7 +88,10 @@ export class Journalist {
84
88
  }
85
89
 
86
90
  async cycle() {
87
- if (this.synthesizing) return; // Don't overlap
91
+ if (this.synthesizing) {
92
+ console.log(' Journalist: skipping cycle (already synthesizing)');
93
+ return;
94
+ }
88
95
 
89
96
  const agents = this.daemon.registry.getAll();
90
97
  const running = agents.filter((a) => a.status === 'running');
@@ -99,14 +106,19 @@ export class Journalist {
99
106
  const activeAgents = [...running, ...recentlyCompleted];
100
107
 
101
108
  // Skip if no agents to synthesize
102
- if (activeAgents.length === 0) return;
109
+ if (activeAgents.length === 0) {
110
+ console.log(' Journalist: skipping cycle (no active agents)');
111
+ return;
112
+ }
103
113
 
104
- // Smart scheduling: skip if no new log output since last cycle
105
- if (this.lastCycleAt && !this.hasNewActivity(activeAgents)) {
114
+ // Smart scheduling: skip if no new log output since last cycle (unless forced by completion/rotation)
115
+ if (this.lastCycleAt && !this._forceNextCycle && !this.hasNewActivity(activeAgents)) {
116
+ console.log(' Journalist: skipping cycle (no new activity)');
106
117
  return;
107
118
  }
108
119
 
109
120
  this.synthesizing = true;
121
+ this._forceNextCycle = false;
110
122
  this.cycleCount++;
111
123
  this.lastCycleAt = Date.now();
112
124
 
@@ -165,12 +165,13 @@ export class MemoryStore {
165
165
  const entries = [];
166
166
  const blocks = content.split(/\n(?=## Rotation )/);
167
167
  for (const block of blocks) {
168
- const headerMatch = block.match(/^## Rotation (\d+) —/);
168
+ const headerMatch = block.match(/^## Rotation (\d+) — [^(]*\(([\w?-]+) →/);
169
169
  if (!headerMatch) continue;
170
170
  const body = block.replace(/\n---\s*$/, '').trim();
171
171
  entries.push({
172
172
  rotationN: parseInt(headerMatch[1], 10),
173
173
  body,
174
+ agentId: headerMatch[2] || null,
174
175
  });
175
176
  }
176
177
  return entries;
@@ -182,6 +183,10 @@ export class MemoryStore {
182
183
  appendHandoffBrief(role, entry, workingDir, teamId) {
183
184
  if (!role || !entry) return false;
184
185
  const chain = this.getHandoffChain(role, workingDir, teamId);
186
+
187
+ // Dedup: prevent the same agent from having multiple entries in the chain
188
+ if (entry.agentId && chain.some(c => c.agentId === entry.agentId)) return false;
189
+
185
190
  const nextN = (chain[0]?.rotationN || 0) + 1;
186
191
 
187
192
  const block = [
@@ -296,6 +296,7 @@ export class ProcessManager {
296
296
  this.peakContextUsage = new Map(); // agentId -> highest contextUsage seen
297
297
  this.pendingMessages = new Map(); // agentId -> { message, timestamp }
298
298
  this._streamThrottle = new Map(); // agentId -> { timer, pending }
299
+ this._rotatingAgents = new Set(); // agentIds currently being rotated (rotator wrote handoff)
299
300
 
300
301
  }
301
302
 
@@ -623,7 +624,11 @@ For normal file edits within your scope, proceed without review.
623
624
  const files = this.daemon.journalist?.getAgentFiles(agent) || [];
624
625
  if (files.length > 0) this._triggerIdleQC(agent);
625
626
  this._processHandoffs(agent);
626
- this._writeCompletionHandoff(agent);
627
+ if (this._rotatingAgents.has(agent.id)) {
628
+ this._rotatingAgents.delete(agent.id);
629
+ } else {
630
+ this._writeCompletionHandoff(agent).catch(err => console.error(`[Groove] Completion handoff failed for ${agent.name}:`, err.message));
631
+ }
627
632
  }
628
633
  });
629
634
 
@@ -815,7 +820,11 @@ For normal file edits within your scope, proceed without review.
815
820
  const files = this.daemon.journalist?.getAgentFiles(agent) || [];
816
821
  if (files.length > 0) this._triggerIdleQC(agent);
817
822
  this._processHandoffs(agent);
818
- this._writeCompletionHandoff(agent);
823
+ if (this._rotatingAgents.has(agent.id)) {
824
+ this._rotatingAgents.delete(agent.id);
825
+ } else {
826
+ this._writeCompletionHandoff(agent).catch(err => console.error(`[Groove] Completion handoff failed for ${agent.name}:`, err.message));
827
+ }
819
828
  }
820
829
 
821
830
  // Update Layer 7 specialization profile for this agent's session
@@ -1149,34 +1158,41 @@ For normal file edits within your scope, proceed without review.
1149
1158
  });
1150
1159
  }
1151
1160
 
1152
- _writeCompletionHandoff(agent) {
1161
+ async _writeCompletionHandoff(agent) {
1153
1162
  if (!this.daemon.memory || !this.daemon.journalist) return;
1154
1163
  try {
1155
1164
  const agentData = this.daemon.registry.get(agent.id);
1156
- const filteredLogs = this.daemon.journalist.collectFilteredLogs([agent]);
1157
- const agentLog = filteredLogs[agent.id];
1158
- const entries = agentLog?.entries || [];
1159
- const files = this.daemon.journalist.getAgentFiles(agent) || [];
1160
-
1161
- const toolSummary = entries
1162
- .filter(e => e.type === 'tool')
1163
- .map(e => `- ${e.tool}: ${e.input}`)
1164
- .slice(-15)
1165
- .join('\n');
1166
-
1167
- const errorSummary = entries
1168
- .filter(e => e.type === 'error')
1169
- .map(e => `- ${e.text}`)
1170
- .slice(-5)
1171
- .join('\n');
1172
-
1173
- const brief = [
1174
- `Agent ${agent.name} (${agent.role}) completed.`,
1175
- agent.prompt ? `Task: ${agent.prompt.slice(0, 300)}` : '',
1176
- files.length > 0 ? `\nFiles modified:\n${files.slice(0, 15).map(f => '- ' + f).join('\n')}` : '',
1177
- toolSummary ? `\nRecent actions:\n${toolSummary}` : '',
1178
- errorSummary ? `\nErrors encountered:\n${errorSummary}` : '',
1179
- ].filter(Boolean).join('\n');
1165
+
1166
+ let brief;
1167
+ try {
1168
+ brief = await this.daemon.journalist.generateHandoffBrief(agent, { reason: 'completed' });
1169
+ } catch {
1170
+ // Fallback to structural brief if AI synthesis fails
1171
+ const filteredLogs = this.daemon.journalist.collectFilteredLogs([agent]);
1172
+ const agentLog = filteredLogs[agent.id];
1173
+ const entries = agentLog?.entries || [];
1174
+ const files = this.daemon.journalist.getAgentFiles(agent) || [];
1175
+
1176
+ const toolSummary = entries
1177
+ .filter(e => e.type === 'tool')
1178
+ .map(e => `- ${e.tool}: ${e.input}`)
1179
+ .slice(-15)
1180
+ .join('\n');
1181
+
1182
+ const errorSummary = entries
1183
+ .filter(e => e.type === 'error')
1184
+ .map(e => `- ${e.text}`)
1185
+ .slice(-5)
1186
+ .join('\n');
1187
+
1188
+ brief = [
1189
+ `Agent ${agent.name} (${agent.role}) completed.`,
1190
+ agent.prompt ? `Task: ${agent.prompt.slice(0, 300)}` : '',
1191
+ files.length > 0 ? `\nFiles modified:\n${files.slice(0, 15).map(f => '- ' + f).join('\n')}` : '',
1192
+ toolSummary ? `\nRecent actions:\n${toolSummary}` : '',
1193
+ errorSummary ? `\nErrors encountered:\n${errorSummary}` : '',
1194
+ ].filter(Boolean).join('\n');
1195
+ }
1180
1196
 
1181
1197
  this.daemon.memory.appendHandoffBrief(agent.role, {
1182
1198
  timestamp: new Date().toISOString(),
@@ -1184,7 +1200,7 @@ For normal file edits within your scope, proceed without review.
1184
1200
  reason: 'completed',
1185
1201
  oldTokens: agentData?.tokensUsed || 0,
1186
1202
  contextUsage: agentData?.contextUsage || 0,
1187
- brief: brief.slice(0, 4000),
1203
+ brief: (typeof brief === 'string' ? brief : '').slice(0, 4000),
1188
1204
  }, agent.workingDir, agent.teamId);
1189
1205
  } catch { /* best-effort */ }
1190
1206
  }
@@ -93,14 +93,16 @@ export class LocalProvider extends Provider {
93
93
 
94
94
  static _hasOllama() {
95
95
  try {
96
- execSync('which ollama', { stdio: 'ignore' });
96
+ const cmd = process.platform === 'win32' ? 'where ollama' : 'which ollama';
97
+ execSync(cmd, { stdio: 'ignore' });
97
98
  return true;
98
99
  } catch { return false; }
99
100
  }
100
101
 
101
102
  static _hasLlamaServer() {
102
103
  try {
103
- execSync('which llama-server', { stdio: 'ignore' });
104
+ const cmd = process.platform === 'win32' ? 'where llama-server' : 'which llama-server';
105
+ execSync(cmd, { stdio: 'ignore' });
104
106
  return true;
105
107
  } catch { return false; }
106
108
  }
@@ -52,7 +52,8 @@ export class OllamaProvider extends Provider {
52
52
 
53
53
  static isInstalled() {
54
54
  try {
55
- execSync('which ollama', { stdio: 'ignore' });
55
+ const cmd = process.platform === 'win32' ? 'where ollama' : 'which ollama';
56
+ execSync(cmd, { stdio: 'ignore' });
56
57
  return true;
57
58
  } catch {
58
59
  return false;
@@ -67,6 +68,9 @@ export class OllamaProvider extends Provider {
67
68
  if (platform === 'linux') {
68
69
  return { command: 'curl -fsSL https://ollama.ai/install.sh | sh', platform: 'Linux' };
69
70
  }
71
+ if (platform === 'win32') {
72
+ return { command: 'winget install Ollama.Ollama', alt: 'Or download from https://ollama.ai/download', platform: 'Windows' };
73
+ }
70
74
  return { command: 'Download from https://ollama.ai/download', platform: 'other' };
71
75
  }
72
76
 
@@ -98,6 +102,22 @@ export class OllamaProvider extends Provider {
98
102
  }
99
103
  }
100
104
  }
105
+ if (platform === 'win32') {
106
+ try {
107
+ let cmd = 'ollama';
108
+ try {
109
+ execSync('where ollama', { stdio: 'ignore' });
110
+ } catch {
111
+ const localAppData = process.env.LOCALAPPDATA || '';
112
+ const fallback = localAppData + '\\Programs\\Ollama\\ollama.exe';
113
+ cmd = fallback;
114
+ }
115
+ execFile(cmd, ['serve'], { stdio: 'ignore', detached: true, shell: true }).unref();
116
+ return { started: true, method: 'ollama serve' };
117
+ } catch {
118
+ return { started: false, command: 'ollama serve' };
119
+ }
120
+ }
101
121
  // Linux / other
102
122
  try {
103
123
  execFile('ollama', ['serve'], { stdio: 'ignore', detached: true }).unref();
@@ -109,6 +129,14 @@ export class OllamaProvider extends Provider {
109
129
 
110
130
  static stopServer() {
111
131
  const platform = process.platform;
132
+ if (platform === 'win32') {
133
+ try {
134
+ execSync('taskkill /IM ollama.exe /F', { stdio: 'ignore', timeout: 5000 });
135
+ return { stopped: true, method: 'taskkill' };
136
+ } catch {
137
+ return { stopped: false };
138
+ }
139
+ }
112
140
  if (platform === 'darwin') {
113
141
  try {
114
142
  execSync('brew services stop ollama', { stdio: 'ignore', timeout: 10000 });
@@ -135,7 +163,11 @@ export class OllamaProvider extends Provider {
135
163
  minRAM: 4,
136
164
  recommendedRAM: 16,
137
165
  gpuRecommended: true,
138
- note: 'Apple Silicon Macs use unified memory — all RAM is GPU RAM. NVIDIA GPUs recommended on Linux.',
166
+ note: process.platform === 'win32'
167
+ ? 'NVIDIA or AMD GPUs recommended. Ensure GPU drivers are up to date.'
168
+ : process.platform === 'darwin'
169
+ ? 'Apple Silicon Macs use unified memory — all RAM is GPU RAM.'
170
+ : 'NVIDIA GPUs recommended on Linux.',
139
171
  };
140
172
  }
141
173
 
@@ -151,7 +183,7 @@ export class OllamaProvider extends Provider {
151
183
  let gpu = null;
152
184
  if (isAppleSilicon) {
153
185
  gpu = { type: 'apple-silicon', name: cpuModel.replace(/Apple /g, ''), vram: totalRamGb, note: 'Unified memory — all RAM available to GPU' };
154
- } else if (platform === 'linux') {
186
+ } else if (platform === 'linux' || platform === 'win32') {
155
187
  try {
156
188
  const out = execSync('nvidia-smi --query-gpu=name,memory.total --format=csv,noheader,nounits', { encoding: 'utf8', timeout: 5000 });
157
189
  const [name, vram] = out.trim().split(', ');
@@ -309,6 +309,7 @@ export class Rotator extends EventEmitter {
309
309
  timestamp: new Date().toISOString(),
310
310
  };
311
311
 
312
+ processes._rotatingAgents.add(agentId);
312
313
  await processes.kill(agentId);
313
314
 
314
315
  const routingMode = this.daemon.router.getMode(agentId);
@@ -211,7 +211,7 @@ export class TunnelManager {
211
211
  }
212
212
  }
213
213
 
214
- async connect(id) {
214
+ async connect(id, opts = {}) {
215
215
  const config = this.saved.get(id);
216
216
  if (!config) throw new Error(`Remote ${id} not found`);
217
217
 
@@ -220,7 +220,14 @@ export class TunnelManager {
220
220
  return { localPort: existing.localPort, pid: existing.pid };
221
221
  }
222
222
 
223
- const testResult = await this.test(id);
223
+ this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'testing' } });
224
+
225
+ let testResult;
226
+ if (opts.skipTest && opts.testResult) {
227
+ testResult = opts.testResult;
228
+ } else {
229
+ testResult = await this.test(id);
230
+ }
224
231
  if (!testResult.reachable) {
225
232
  throw new Error(testResult.error || 'Host unreachable');
226
233
  }
@@ -233,6 +240,8 @@ export class TunnelManager {
233
240
  await this.autoStart(id);
234
241
  }
235
242
 
243
+ this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'connecting' } });
244
+
236
245
  const localPort = await this._findAvailablePort();
237
246
  const target = `${config.user}@${config.host}`;
238
247
  const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
@@ -257,13 +266,16 @@ export class TunnelManager {
257
266
  let stderrBuf = '';
258
267
  tunnel.stderr.on('data', (chunk) => { stderrBuf += chunk.toString(); });
259
268
 
260
- await new Promise((r) => setTimeout(r, 2000));
261
-
262
- if (tunnel.exitCode !== null) {
263
- throw new Error(`Tunnel failed to start: ${stderrBuf.trim() || 'unknown error'}`);
269
+ let tunnelUp = false;
270
+ for (let elapsed = 0; elapsed < 8000; elapsed += 500) {
271
+ await new Promise((r) => setTimeout(r, 500));
272
+ if (tunnel.exitCode !== null) {
273
+ throw new Error(`Tunnel failed to start: ${stderrBuf.trim() || 'unknown error'}`);
274
+ }
275
+ tunnelUp = await this._isPortInUse(localPort);
276
+ if (tunnelUp) break;
264
277
  }
265
278
 
266
- const tunnelUp = await this._isPortInUse(localPort);
267
279
  if (!tunnelUp) {
268
280
  try { process.kill(tunnel.pid); } catch { /* ignore */ }
269
281
  throw new Error('Tunnel started but port forward not active');
@@ -23,6 +23,7 @@ describe('Rotator', () => {
23
23
  mockDaemon = {
24
24
  registry: mockRegistry,
25
25
  processes: {
26
+ _rotatingAgents: new Set(),
26
27
  async kill(id) { /* mock */ },
27
28
  async spawn(config) { return { id: 'new-' + config.role, name: config.name, ...config }; },
28
29
  },