groove-dev 0.27.212 → 0.27.213

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.
@@ -0,0 +1,87 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { existsSync, readdirSync } from 'fs';
3
+ import { createRequire } from 'module';
4
+ import { dirname, join } from 'path';
5
+ import { pathToFileURL } from 'url';
6
+
7
+ const port = 31415;
8
+ const projectDir = process.argv[2] || process.cwd();
9
+
10
+ function preflightCheck(daemonPath) {
11
+ if (!existsSync(daemonPath)) {
12
+ throw new Error(
13
+ `Daemon entry point not found at ${daemonPath}. ` +
14
+ 'The app may not have been packaged correctly — try reinstalling Groove.'
15
+ );
16
+ }
17
+
18
+ const daemonDir = dirname(daemonPath);
19
+ const require = createRequire(daemonPath);
20
+ const critical = ['express', 'ws'];
21
+ const missing = critical.filter(dep => {
22
+ try { require.resolve(dep); return false; } catch { return true; }
23
+ });
24
+
25
+ if (missing.length) {
26
+ const diag = [`Missing deps: ${missing.join(', ')}`, `GROOVE_DAEMON_PATH: ${daemonPath}`];
27
+ let walkDir = daemonDir;
28
+ for (let i = 0; i < 5 && walkDir !== dirname(walkDir); i++) {
29
+ try {
30
+ const entries = readdirSync(walkDir);
31
+ diag.push(`${walkDir}/: [${entries.join(', ')}]`);
32
+ } catch { diag.push(`${walkDir}/: (unreadable)`); }
33
+ walkDir = dirname(walkDir);
34
+ }
35
+ throw new Error(
36
+ `Daemon is missing dependencies. ` +
37
+ `Diagnostics:\n${diag.join('\n')}\n` +
38
+ 'The app bundle may be incomplete — try reinstalling Groove.'
39
+ );
40
+ }
41
+ }
42
+
43
+ async function main() {
44
+ let Daemon;
45
+ const daemonPath = process.env.GROOVE_DAEMON_PATH;
46
+
47
+ if (daemonPath) {
48
+ preflightCheck(daemonPath);
49
+ const mod = await import(pathToFileURL(daemonPath).href);
50
+ Daemon = mod.Daemon;
51
+ } else {
52
+ const mod = await import('@groove-dev/daemon');
53
+ Daemon = mod.Daemon;
54
+ }
55
+
56
+ const daemon = new Daemon({ port, projectDir });
57
+ await daemon.start();
58
+
59
+ process.send({ type: 'ready', port: daemon.port });
60
+
61
+ process.on('message', (msg) => {
62
+ if (msg.type === 'auth-token') {
63
+ (async () => {
64
+ try { await daemon.setAuthToken(msg.token); } catch (err) {
65
+ process.stderr.write(`[daemon-bridge] setAuthToken failed: ${err.message}\n`);
66
+ }
67
+ })();
68
+ }
69
+ });
70
+
71
+ process.on('SIGTERM', async () => {
72
+ await daemon.stop();
73
+ process.exit(0);
74
+ });
75
+
76
+ process.on('SIGINT', async () => {
77
+ await daemon.stop();
78
+ process.exit(0);
79
+ });
80
+ }
81
+
82
+ main().catch((err) => {
83
+ if (process.send) {
84
+ process.send({ type: 'error', message: err.message });
85
+ }
86
+ process.exit(1);
87
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.212",
3
+ "version": "0.27.213",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.212",
3
+ "version": "0.27.213",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -20,6 +20,12 @@ const MAX_PORT_ATTEMPTS = 10;
20
20
  const HEALTH_INTERVAL = 30000;
21
21
  const HEALTH_TIMEOUT = 5000;
22
22
  const MAX_FAIL_COUNT = 3;
23
+ // Long-timeout probe used to CONFIRM death before killing a tunnel — a busy
24
+ // remote daemon can sit on /api/health well past the 5s routine probe.
25
+ const CONFIRM_TIMEOUT = 15000;
26
+ // At most one automatic rebuild per tunnel per window; beyond that it stays
27
+ // disconnected rather than thrashing against a host that keeps dying.
28
+ const REBUILD_COOLDOWN_MS = 10 * 60 * 1000;
23
29
 
24
30
  const INJECTION_CHARS = /[;|&`$(){}[\]<>!#\n\r\\]/;
25
31
 
@@ -295,6 +301,8 @@ export class TunnelManager {
295
301
  return { localPort: existing.localPort, pid: existing.pid, name: config.name };
296
302
  }
297
303
  console.log(`[Groove:Tunnel] ${config.name}: existing tunnel is not responding — rebuilding`);
304
+ // Reuse the dead tunnel's port so any GUI window pointed at it heals.
305
+ opts = { ...opts, preferredPort: opts.preferredPort || existing.localPort };
298
306
  await this.disconnect(id);
299
307
  }
300
308
 
@@ -328,7 +336,14 @@ export class TunnelManager {
328
336
  // Establish SSH tunnel
329
337
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'connecting' } });
330
338
 
331
- const localPort = await this._findAvailablePort();
339
+ // A rebuild wants its old port back: the remote GUI window is pointed at it
340
+ // and will self-heal over WebSocket retry only if the port stays the same.
341
+ let localPort;
342
+ if (opts.preferredPort && !(await this._isPortInUse(opts.preferredPort))) {
343
+ localPort = opts.preferredPort;
344
+ } else {
345
+ localPort = await this._findAvailablePort();
346
+ }
332
347
  const target = `${config.user}@${config.host}`;
333
348
  const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
334
349
 
@@ -445,7 +460,7 @@ export class TunnelManager {
445
460
  // Does the tunnel actually serve a request, as opposed to merely holding an
446
461
  // open listening socket? A wedged forward passes a TCP connect test but never
447
462
  // answers, so only an HTTP round-trip proves it.
448
- async _tunnelResponds(localPort, timeoutMs = HEALTH_TIMEOUT) {
463
+ async _tunnelResponds(localPort, timeoutMs = this.healthTimeout ?? HEALTH_TIMEOUT) {
449
464
  try {
450
465
  const res = await fetch(`http://localhost:${localPort}/api/health`, {
451
466
  signal: AbortSignal.timeout(timeoutMs),
@@ -915,22 +930,26 @@ export class TunnelManager {
915
930
 
916
931
  async _healthCheckPass() {
917
932
  // Timers don't fire while the machine is asleep, so an interval that should
918
- // have run every HEALTH_INTERVAL arriving far later means we just woke up.
919
- // Every tunnel is suspect at that point: verify them now rather than waiting
920
- // for MAX_FAIL_COUNT normal cycles to notice.
933
+ // have run every HEALTH_INTERVAL arriving far later means we PROBABLY just
934
+ // woke up but not certainly: this daemon also blocks its event loop for
935
+ // long stretches (execFileSync ssh calls in test/upgrade paths), which
936
+ // produces the same gap on a machine that never slept. So a gap only makes
937
+ // tunnels *suspect* — it fast-tracks them to the confirmation ladder below.
938
+ // It must never lower the bar for killing one (that misdiagnosis dropped a
939
+ // healthy DGX tunnel twice in ten minutes).
921
940
  const now = Date.now();
922
941
  const gap = now - (this._lastHealthCheck || now);
923
942
  this._lastHealthCheck = now;
924
- const wokeFromSleep = gap > HEALTH_INTERVAL * 3;
925
- if (wokeFromSleep && this.active.size > 0) {
926
- console.log(`[Groove:Tunnel] Detected a ${Math.round(gap / 1000)}s gap (system sleep) — verifying tunnels`);
943
+ const suspectAll = gap > HEALTH_INTERVAL * 3;
944
+ if (suspectAll && this.active.size > 0) {
945
+ console.log(`[Groove:Tunnel] ${Math.round(gap / 1000)}s timer gap (sleep or blocked loop) — verifying tunnels`);
927
946
  }
928
947
 
929
948
  for (const [id, conn] of this.active) {
930
949
  try {
931
950
  const start = Date.now();
932
951
  const res = await fetch(`http://localhost:${conn.localPort}/api/health`, {
933
- signal: AbortSignal.timeout(HEALTH_TIMEOUT),
952
+ signal: AbortSignal.timeout(this.healthTimeout ?? HEALTH_TIMEOUT),
934
953
  });
935
954
  if (res.ok) {
936
955
  conn.latencyMs = Date.now() - start;
@@ -942,22 +961,29 @@ export class TunnelManager {
942
961
  }
943
962
  } catch {
944
963
  conn.failCount = (conn.failCount || 0) + 1;
945
- // After a sleep gap, one failure is enough the tunnel was almost
946
- // certainly cut with the network.
947
- const limit = wokeFromSleep ? 1 : MAX_FAIL_COUNT;
948
- if (conn.failCount >= limit) {
949
- conn.healthy = false;
950
- this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
951
-
952
- // Reap it. Previously a dead tunnel was only FLAGGED, so it stayed in
953
- // `active` indefinitely and every later connect() handed back its dead
954
- // port — the reason reconnecting never helped and only a full restart
955
- // did. Removing it means the next connect() builds a real tunnel.
956
- if (conn.failCount >= limit + 1) {
957
- console.log(`[Groove:Tunnel] Reaping dead tunnel ${id} after ${conn.failCount} failed checks`);
958
- this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'reaping' } });
959
- await this.disconnect(id);
960
- continue;
964
+ // A failed 5s probe is WEAK evidence: it can't distinguish a dead
965
+ // tunnel from a remote daemon that's briefly busy or our own blocked
966
+ // event loop. Never kill on it. Once failures accumulate (or a timer
967
+ // gap makes everything suspect), run the confirmation ladder, which
968
+ // can — a healthy verdict there resets the count.
969
+ if (conn.failCount >= MAX_FAIL_COUNT || suspectAll) {
970
+ const verdict = await this._confirmDead(conn);
971
+ if (verdict === 'alive') {
972
+ conn.failCount = 0;
973
+ conn.healthy = true;
974
+ conn._wedgedStreak = 0;
975
+ } else {
976
+ conn.healthy = false;
977
+ this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
978
+ // 'wedged' (port accepts, HTTP silent even at long timeout) is the
979
+ // one verdict with a false-positive path — a remote event loop
980
+ // blocked 15s+ — so demand it twice in a row. proc-dead/port-dead
981
+ // are unambiguous: the ssh client is gone or nothing is listening.
982
+ conn._wedgedStreak = verdict === 'wedged' ? (conn._wedgedStreak || 0) + 1 : 0;
983
+ if (verdict !== 'wedged' || conn._wedgedStreak >= 2) {
984
+ await this._reapAndRebuild(id, conn, verdict);
985
+ continue;
986
+ }
961
987
  }
962
988
  }
963
989
  }
@@ -968,6 +994,51 @@ export class TunnelManager {
968
994
  }
969
995
  }
970
996
 
997
+ // Escalating evidence that a tunnel is actually dead, not merely slow:
998
+ // 'alive' — answered a long-timeout HTTP probe; leave it alone
999
+ // 'proc-dead' — the ssh client process is gone
1000
+ // 'port-dead' — nothing is listening on the local port
1001
+ // 'wedged' — port accepts TCP but HTTP never answers (dead forward)
1002
+ async _confirmDead(conn) {
1003
+ if (await this._tunnelResponds(conn.localPort, this.confirmTimeout ?? CONFIRM_TIMEOUT)) return 'alive';
1004
+ if (conn.pid) {
1005
+ try { process.kill(conn.pid, 0); } catch { return 'proc-dead'; }
1006
+ }
1007
+ if (!(await this._isPortInUse(conn.localPort))) return 'port-dead';
1008
+ return 'wedged';
1009
+ }
1010
+
1011
+ // Tear down a confirmed-dead tunnel and immediately rebuild it on the SAME
1012
+ // local port. The remote GUI window points at that port and its WebSocket
1013
+ // retries every 2s, so a same-port rebuild heals an open window without the
1014
+ // user noticing. Only if the rebuild fails does this surface as a disconnect.
1015
+ // Rate-limited so a genuinely dead host degrades to disconnected instead of
1016
+ // thrashing reconnect attempts forever.
1017
+ async _reapAndRebuild(id, conn, reason) {
1018
+ const { localPort } = conn;
1019
+ console.log(`[Groove:Tunnel] Tunnel ${id} confirmed dead (${reason}) — rebuilding`);
1020
+ this.daemon.audit.log('tunnel.reap', { id, reason, failCount: conn.failCount });
1021
+ await this.disconnect(id);
1022
+
1023
+ const lastRebuild = this._rebuildAt?.get(id) || 0;
1024
+ if (Date.now() - lastRebuild < REBUILD_COOLDOWN_MS) {
1025
+ console.log(`[Groove:Tunnel] ${id} already auto-rebuilt recently — leaving disconnected`);
1026
+ return;
1027
+ }
1028
+ this._rebuildAt = this._rebuildAt || new Map();
1029
+ this._rebuildAt.set(id, Date.now());
1030
+
1031
+ try {
1032
+ this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'reconnecting' } });
1033
+ await this.connect(id, { preferredPort: localPort });
1034
+ console.log(`[Groove:Tunnel] ${id} rebuilt on port ${localPort}`);
1035
+ } catch (err) {
1036
+ console.warn(`[Groove:Tunnel] Auto-rebuild of ${id} failed: ${err.message}`);
1037
+ // disconnect() above already broadcast tunnel.disconnected — the GUI is
1038
+ // consistent; the user can reconnect manually when the host is back.
1039
+ }
1040
+ }
1041
+
971
1042
  // Signal 0 only tests for existence — no signal is delivered.
972
1043
  async _waitForExit(pid, timeoutMs) {
973
1044
  const deadline = Date.now() + timeoutMs;
@@ -66,6 +66,10 @@ describe('TunnelManager — wake-from-sleep recovery', () => {
66
66
  grooveDir = mkdtempSync(resolve(tmpdir(), 'groove-tunnel-'));
67
67
  daemon = makeDaemon(grooveDir);
68
68
  mgr = new TunnelManager(daemon);
69
+ // Shrink probe timeouts (production: 5s routine / 15s confirm) so the
70
+ // wedged-listener tests don't burn real minutes waiting them out.
71
+ mgr.healthTimeout = 400;
72
+ mgr.confirmTimeout = 1200;
69
73
  });
70
74
 
71
75
  afterEach(() => {
@@ -84,7 +88,7 @@ describe('TunnelManager — wake-from-sleep recovery', () => {
84
88
  it('_tunnelResponds rejects a tunnel that accepts but never answers', async () => {
85
89
  const wedged = await startWedgedListener();
86
90
  try {
87
- assert.equal(await mgr._tunnelResponds(wedged.port, 1500), false);
91
+ assert.equal(await mgr._tunnelResponds(wedged.port, 800), false);
88
92
  } finally { wedged.close(); }
89
93
  });
90
94
 
@@ -132,19 +136,25 @@ describe('TunnelManager — wake-from-sleep recovery', () => {
132
136
  } finally { live.close(); }
133
137
  });
134
138
 
135
- it('the health pass reaps a dead tunnel so the next connect starts clean', async () => {
139
+ it('the health pass reaps a proc-dead tunnel and tries to rebuild it', async () => {
136
140
  const wedged = await startWedgedListener();
137
141
  try {
138
142
  mgr.saved.set('s19', { id: 's19', name: 'S19 Agency', host: 'example.invalid', user: 'ops', port: 22 });
139
143
  mgr.active.set('s19', {
140
- pid: 999999, localPort: wedged.port, healthy: true,
144
+ pid: 999999, // no such process → verdict 'proc-dead', reap on first confirm
145
+ localPort: wedged.port, healthy: true,
141
146
  failCount: 99, // already past the failure limit
142
147
  startedAt: new Date().toISOString(),
143
148
  });
144
149
 
150
+ const rebuilds = [];
151
+ mgr.connect = async (id, opts) => { rebuilds.push({ id, opts }); throw new Error('host unreachable'); };
152
+
145
153
  await mgr._healthCheckAll();
146
154
 
147
155
  assert.equal(mgr.active.has('s19'), false, 'dead tunnel removed from active');
156
+ assert.equal(rebuilds.length, 1, 'an automatic rebuild was attempted');
157
+ assert.equal(rebuilds[0].opts.preferredPort, wedged.port, 'rebuild asks for the same port');
148
158
  assert.ok(
149
159
  daemon.broadcasts.some((b) => b.type === 'tunnel.unhealthy'),
150
160
  'the GUI is told the tunnel went unhealthy',
@@ -152,24 +162,84 @@ describe('TunnelManager — wake-from-sleep recovery', () => {
152
162
  } finally { wedged.close(); }
153
163
  });
154
164
 
155
- it('treats a long timer gap as a sleep and drops the failure tolerance', async () => {
165
+ it('a slow-but-alive tunnel is never reaped the long confirm probe clears it', async () => {
166
+ // Answers /api/health slower than the routine probe allows but inside the
167
+ // confirmation window. This is a busy DGX, not a dead tunnel.
168
+ const sockets = [];
169
+ const slow = createServer((sock) => {
170
+ sockets.push(sock);
171
+ sock.on('data', () => setTimeout(() => {
172
+ const body = '{"ok":true}';
173
+ try {
174
+ sock.end('HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n'
175
+ + `Content-Length: ${body.length}\r\nConnection: close\r\n\r\n${body}`);
176
+ } catch { /* closed */ }
177
+ }, 700));
178
+ });
179
+ await new Promise((r) => slow.listen(0, '127.0.0.1', r));
180
+ const port = slow.address().port;
181
+ try {
182
+ mgr.saved.set('dgx', { id: 'dgx', name: 'Axom Spark', host: 'edgexpert.local', user: 'rok', port: 22 });
183
+ mgr.active.set('dgx', {
184
+ pid: process.pid, // definitely alive
185
+ localPort: port, healthy: true, failCount: 99,
186
+ startedAt: new Date().toISOString(),
187
+ });
188
+
189
+ mgr.connect = async () => { throw new Error('rebuild must not be attempted'); };
190
+ await mgr._healthCheckAll();
191
+
192
+ const conn = mgr.active.get('dgx');
193
+ assert.ok(conn, 'the tunnel survived');
194
+ assert.equal(conn.healthy, true, 'confirmed alive by the long probe');
195
+ assert.equal(conn.failCount, 0, 'failure count reset');
196
+ } finally {
197
+ for (const s of sockets) s.destroy();
198
+ slow.close();
199
+ }
200
+ });
201
+
202
+ it('a wedged tunnel needs two consecutive confirmations before it is reaped', { timeout: 30000 }, async () => {
156
203
  const wedged = await startWedgedListener();
157
204
  try {
158
205
  mgr.saved.set('s19', { id: 's19', name: 'S19 Agency', host: 'example.invalid', user: 'ops', port: 22 });
159
206
  mgr.active.set('s19', {
160
- pid: 999999, localPort: wedged.port, healthy: true, failCount: 0,
207
+ pid: process.pid, // ssh "alive" → verdict is 'wedged', not 'proc-dead'
208
+ localPort: wedged.port, healthy: true, failCount: 99,
161
209
  startedAt: new Date().toISOString(),
162
210
  });
211
+ mgr.connect = async () => { throw new Error('unreachable'); };
163
212
 
164
- // Last check was 10 minutes ago — the machine was asleep.
165
- mgr._lastHealthCheck = Date.now() - 10 * 60 * 1000;
166
213
  await mgr._healthCheckAll();
214
+ assert.equal(mgr.active.has('s19'), true, 'first wedged verdict only flags it');
215
+ assert.equal(mgr.active.get('s19').healthy, false);
167
216
 
168
- const conn = mgr.active.get('s19');
169
- assert.equal(conn?.healthy, false, 'one failure after a sleep gap is enough');
217
+ mgr.active.get('s19').failCount = 99; // still failing next tick
218
+ await mgr._healthCheckAll();
219
+ assert.equal(mgr.active.has('s19'), false, 'second wedged verdict reaps it');
170
220
  } finally { wedged.close(); }
171
221
  });
172
222
 
223
+ it('a timer gap fast-tracks confirmation but cannot kill a live tunnel', async () => {
224
+ const live = await startHealthyListener();
225
+ try {
226
+ mgr.saved.set('dgx', { id: 'dgx', name: 'Axom Spark', host: 'edgexpert.local', user: 'rok', port: 22 });
227
+ mgr.active.set('dgx', {
228
+ pid: process.pid, localPort: live.port, healthy: true, failCount: 0,
229
+ startedAt: new Date().toISOString(),
230
+ });
231
+
232
+ // 10-minute gap: sleep — or just a blocked event loop on a busy daemon.
233
+ mgr._lastHealthCheck = Date.now() - 10 * 60 * 1000;
234
+ mgr.connect = async () => { throw new Error('rebuild must not be attempted'); };
235
+ await mgr._healthCheckAll();
236
+
237
+ const conn = mgr.active.get('dgx');
238
+ assert.ok(conn, 'live tunnel survived the suspicious gap');
239
+ assert.equal(conn.healthy, true);
240
+ } finally { live.close(); }
241
+ });
242
+
173
243
  it('_waitForPortFree reports a released port', async () => {
174
244
  const live = await startHealthyListener();
175
245
  assert.equal(await mgr._waitForPortFree(live.port, 600), false, 'still held while listening');
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.212",
3
+ "version": "0.27.213",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.27.212",
3
+ "version": "0.27.213",
4
4
  "description": "Open-source agent orchestration layer — the AI company OS. Local model agent engine (GGUF/Ollama/llama-server), HuggingFace model browser, MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama, any local model.",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.212",
3
+ "version": "0.27.213",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.212",
3
+ "version": "0.27.213",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -20,6 +20,12 @@ const MAX_PORT_ATTEMPTS = 10;
20
20
  const HEALTH_INTERVAL = 30000;
21
21
  const HEALTH_TIMEOUT = 5000;
22
22
  const MAX_FAIL_COUNT = 3;
23
+ // Long-timeout probe used to CONFIRM death before killing a tunnel — a busy
24
+ // remote daemon can sit on /api/health well past the 5s routine probe.
25
+ const CONFIRM_TIMEOUT = 15000;
26
+ // At most one automatic rebuild per tunnel per window; beyond that it stays
27
+ // disconnected rather than thrashing against a host that keeps dying.
28
+ const REBUILD_COOLDOWN_MS = 10 * 60 * 1000;
23
29
 
24
30
  const INJECTION_CHARS = /[;|&`$(){}[\]<>!#\n\r\\]/;
25
31
 
@@ -295,6 +301,8 @@ export class TunnelManager {
295
301
  return { localPort: existing.localPort, pid: existing.pid, name: config.name };
296
302
  }
297
303
  console.log(`[Groove:Tunnel] ${config.name}: existing tunnel is not responding — rebuilding`);
304
+ // Reuse the dead tunnel's port so any GUI window pointed at it heals.
305
+ opts = { ...opts, preferredPort: opts.preferredPort || existing.localPort };
298
306
  await this.disconnect(id);
299
307
  }
300
308
 
@@ -328,7 +336,14 @@ export class TunnelManager {
328
336
  // Establish SSH tunnel
329
337
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'connecting' } });
330
338
 
331
- const localPort = await this._findAvailablePort();
339
+ // A rebuild wants its old port back: the remote GUI window is pointed at it
340
+ // and will self-heal over WebSocket retry only if the port stays the same.
341
+ let localPort;
342
+ if (opts.preferredPort && !(await this._isPortInUse(opts.preferredPort))) {
343
+ localPort = opts.preferredPort;
344
+ } else {
345
+ localPort = await this._findAvailablePort();
346
+ }
332
347
  const target = `${config.user}@${config.host}`;
333
348
  const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
334
349
 
@@ -445,7 +460,7 @@ export class TunnelManager {
445
460
  // Does the tunnel actually serve a request, as opposed to merely holding an
446
461
  // open listening socket? A wedged forward passes a TCP connect test but never
447
462
  // answers, so only an HTTP round-trip proves it.
448
- async _tunnelResponds(localPort, timeoutMs = HEALTH_TIMEOUT) {
463
+ async _tunnelResponds(localPort, timeoutMs = this.healthTimeout ?? HEALTH_TIMEOUT) {
449
464
  try {
450
465
  const res = await fetch(`http://localhost:${localPort}/api/health`, {
451
466
  signal: AbortSignal.timeout(timeoutMs),
@@ -915,22 +930,26 @@ export class TunnelManager {
915
930
 
916
931
  async _healthCheckPass() {
917
932
  // Timers don't fire while the machine is asleep, so an interval that should
918
- // have run every HEALTH_INTERVAL arriving far later means we just woke up.
919
- // Every tunnel is suspect at that point: verify them now rather than waiting
920
- // for MAX_FAIL_COUNT normal cycles to notice.
933
+ // have run every HEALTH_INTERVAL arriving far later means we PROBABLY just
934
+ // woke up but not certainly: this daemon also blocks its event loop for
935
+ // long stretches (execFileSync ssh calls in test/upgrade paths), which
936
+ // produces the same gap on a machine that never slept. So a gap only makes
937
+ // tunnels *suspect* — it fast-tracks them to the confirmation ladder below.
938
+ // It must never lower the bar for killing one (that misdiagnosis dropped a
939
+ // healthy DGX tunnel twice in ten minutes).
921
940
  const now = Date.now();
922
941
  const gap = now - (this._lastHealthCheck || now);
923
942
  this._lastHealthCheck = now;
924
- const wokeFromSleep = gap > HEALTH_INTERVAL * 3;
925
- if (wokeFromSleep && this.active.size > 0) {
926
- console.log(`[Groove:Tunnel] Detected a ${Math.round(gap / 1000)}s gap (system sleep) — verifying tunnels`);
943
+ const suspectAll = gap > HEALTH_INTERVAL * 3;
944
+ if (suspectAll && this.active.size > 0) {
945
+ console.log(`[Groove:Tunnel] ${Math.round(gap / 1000)}s timer gap (sleep or blocked loop) — verifying tunnels`);
927
946
  }
928
947
 
929
948
  for (const [id, conn] of this.active) {
930
949
  try {
931
950
  const start = Date.now();
932
951
  const res = await fetch(`http://localhost:${conn.localPort}/api/health`, {
933
- signal: AbortSignal.timeout(HEALTH_TIMEOUT),
952
+ signal: AbortSignal.timeout(this.healthTimeout ?? HEALTH_TIMEOUT),
934
953
  });
935
954
  if (res.ok) {
936
955
  conn.latencyMs = Date.now() - start;
@@ -942,22 +961,29 @@ export class TunnelManager {
942
961
  }
943
962
  } catch {
944
963
  conn.failCount = (conn.failCount || 0) + 1;
945
- // After a sleep gap, one failure is enough the tunnel was almost
946
- // certainly cut with the network.
947
- const limit = wokeFromSleep ? 1 : MAX_FAIL_COUNT;
948
- if (conn.failCount >= limit) {
949
- conn.healthy = false;
950
- this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
951
-
952
- // Reap it. Previously a dead tunnel was only FLAGGED, so it stayed in
953
- // `active` indefinitely and every later connect() handed back its dead
954
- // port — the reason reconnecting never helped and only a full restart
955
- // did. Removing it means the next connect() builds a real tunnel.
956
- if (conn.failCount >= limit + 1) {
957
- console.log(`[Groove:Tunnel] Reaping dead tunnel ${id} after ${conn.failCount} failed checks`);
958
- this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'reaping' } });
959
- await this.disconnect(id);
960
- continue;
964
+ // A failed 5s probe is WEAK evidence: it can't distinguish a dead
965
+ // tunnel from a remote daemon that's briefly busy or our own blocked
966
+ // event loop. Never kill on it. Once failures accumulate (or a timer
967
+ // gap makes everything suspect), run the confirmation ladder, which
968
+ // can — a healthy verdict there resets the count.
969
+ if (conn.failCount >= MAX_FAIL_COUNT || suspectAll) {
970
+ const verdict = await this._confirmDead(conn);
971
+ if (verdict === 'alive') {
972
+ conn.failCount = 0;
973
+ conn.healthy = true;
974
+ conn._wedgedStreak = 0;
975
+ } else {
976
+ conn.healthy = false;
977
+ this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
978
+ // 'wedged' (port accepts, HTTP silent even at long timeout) is the
979
+ // one verdict with a false-positive path — a remote event loop
980
+ // blocked 15s+ — so demand it twice in a row. proc-dead/port-dead
981
+ // are unambiguous: the ssh client is gone or nothing is listening.
982
+ conn._wedgedStreak = verdict === 'wedged' ? (conn._wedgedStreak || 0) + 1 : 0;
983
+ if (verdict !== 'wedged' || conn._wedgedStreak >= 2) {
984
+ await this._reapAndRebuild(id, conn, verdict);
985
+ continue;
986
+ }
961
987
  }
962
988
  }
963
989
  }
@@ -968,6 +994,51 @@ export class TunnelManager {
968
994
  }
969
995
  }
970
996
 
997
+ // Escalating evidence that a tunnel is actually dead, not merely slow:
998
+ // 'alive' — answered a long-timeout HTTP probe; leave it alone
999
+ // 'proc-dead' — the ssh client process is gone
1000
+ // 'port-dead' — nothing is listening on the local port
1001
+ // 'wedged' — port accepts TCP but HTTP never answers (dead forward)
1002
+ async _confirmDead(conn) {
1003
+ if (await this._tunnelResponds(conn.localPort, this.confirmTimeout ?? CONFIRM_TIMEOUT)) return 'alive';
1004
+ if (conn.pid) {
1005
+ try { process.kill(conn.pid, 0); } catch { return 'proc-dead'; }
1006
+ }
1007
+ if (!(await this._isPortInUse(conn.localPort))) return 'port-dead';
1008
+ return 'wedged';
1009
+ }
1010
+
1011
+ // Tear down a confirmed-dead tunnel and immediately rebuild it on the SAME
1012
+ // local port. The remote GUI window points at that port and its WebSocket
1013
+ // retries every 2s, so a same-port rebuild heals an open window without the
1014
+ // user noticing. Only if the rebuild fails does this surface as a disconnect.
1015
+ // Rate-limited so a genuinely dead host degrades to disconnected instead of
1016
+ // thrashing reconnect attempts forever.
1017
+ async _reapAndRebuild(id, conn, reason) {
1018
+ const { localPort } = conn;
1019
+ console.log(`[Groove:Tunnel] Tunnel ${id} confirmed dead (${reason}) — rebuilding`);
1020
+ this.daemon.audit.log('tunnel.reap', { id, reason, failCount: conn.failCount });
1021
+ await this.disconnect(id);
1022
+
1023
+ const lastRebuild = this._rebuildAt?.get(id) || 0;
1024
+ if (Date.now() - lastRebuild < REBUILD_COOLDOWN_MS) {
1025
+ console.log(`[Groove:Tunnel] ${id} already auto-rebuilt recently — leaving disconnected`);
1026
+ return;
1027
+ }
1028
+ this._rebuildAt = this._rebuildAt || new Map();
1029
+ this._rebuildAt.set(id, Date.now());
1030
+
1031
+ try {
1032
+ this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'reconnecting' } });
1033
+ await this.connect(id, { preferredPort: localPort });
1034
+ console.log(`[Groove:Tunnel] ${id} rebuilt on port ${localPort}`);
1035
+ } catch (err) {
1036
+ console.warn(`[Groove:Tunnel] Auto-rebuild of ${id} failed: ${err.message}`);
1037
+ // disconnect() above already broadcast tunnel.disconnected — the GUI is
1038
+ // consistent; the user can reconnect manually when the host is back.
1039
+ }
1040
+ }
1041
+
971
1042
  // Signal 0 only tests for existence — no signal is delivered.
972
1043
  async _waitForExit(pid, timeoutMs) {
973
1044
  const deadline = Date.now() + timeoutMs;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.212",
3
+ "version": "0.27.213",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",