groove-dev 0.27.213 → 0.27.214

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.213",
3
+ "version": "0.27.214",
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.213",
3
+ "version": "0.27.214",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -26,6 +26,10 @@ const CONFIRM_TIMEOUT = 15000;
26
26
  // At most one automatic rebuild per tunnel per window; beyond that it stays
27
27
  // disconnected rather than thrashing against a host that keeps dying.
28
28
  const REBUILD_COOLDOWN_MS = 10 * 60 * 1000;
29
+ // Restarting a crashed REMOTE daemon is cheaper and safer than a rebuild, so
30
+ // its cooldown is shorter — but still bounded: a daemon that dies right after
31
+ // every start has a real problem more starts won't fix.
32
+ const REMOTE_START_COOLDOWN_MS = 2 * 60 * 1000;
29
33
 
30
34
  const INJECTION_CHARS = /[;|&`$(){}[\]<>!#\n\r\\]/;
31
35
 
@@ -60,6 +64,10 @@ export class TunnelManager {
60
64
  constructor(daemon) {
61
65
  this.daemon = daemon;
62
66
  this.remotesPath = resolve(daemon.grooveDir, 'remotes.json');
67
+ // Live tunnel state, persisted separately from the configs so a daemon
68
+ // restart can re-adopt still-running ssh processes instead of forgetting
69
+ // them (the configs file is user data; this is runtime state).
70
+ this.activePath = resolve(daemon.grooveDir, 'tunnels-active.json');
63
71
  this.saved = new Map();
64
72
  this.active = new Map();
65
73
  this._healthInterval = null;
@@ -88,8 +96,9 @@ export class TunnelManager {
88
96
  }
89
97
 
90
98
  async init() {
99
+ await this._readopt();
91
100
  for (const [id, config] of this.saved) {
92
- if (config.autoConnect) {
101
+ if (config.autoConnect && !this.active.has(id)) {
93
102
  try {
94
103
  await this.connect(id);
95
104
  } catch (err) {
@@ -99,6 +108,71 @@ export class TunnelManager {
99
108
  }
100
109
  }
101
110
 
111
+ // Re-adopt tunnels whose detached ssh processes survived a daemon restart.
112
+ // Without this, every daemon/app restart orphaned the ssh (or shutdown killed
113
+ // it) and the new daemon started amnesiac — remote windows died mid-session
114
+ // and the user had to reconnect everything by hand.
115
+ async _readopt() {
116
+ let entries = [];
117
+ try {
118
+ if (existsSync(this.activePath)) entries = JSON.parse(readFileSync(this.activePath, 'utf8'));
119
+ } catch { /* corrupt — treat as none */ }
120
+ if (!Array.isArray(entries) || entries.length === 0) return;
121
+
122
+ let adopted = 0;
123
+ for (const e of entries) {
124
+ if (!e?.id || !e.pid || !e.localPort || !this.saved.has(e.id)) continue;
125
+ // Only re-adopt what is provably OUR ssh still doing THIS job: the pid
126
+ // must be alive, be an ssh process forwarding this port, and the port
127
+ // must serve HTTP.
128
+ let alive = false;
129
+ try { process.kill(e.pid, 0); alive = true; } catch { /* gone */ }
130
+ if (alive) alive = this._looksLikeOurSsh(e.pid, e.localPort);
131
+ if (alive && await this._tunnelResponds(e.localPort)) {
132
+ this.active.set(e.id, {
133
+ pid: e.pid,
134
+ localPort: e.localPort,
135
+ startedAt: e.startedAt || new Date().toISOString(),
136
+ lastPing: Date.now(),
137
+ latencyMs: null,
138
+ healthy: true,
139
+ failCount: 0,
140
+ });
141
+ adopted++;
142
+ const name = this.saved.get(e.id)?.name || e.id;
143
+ console.log(`[Groove:Tunnel] Re-adopted live tunnel to ${name} on port ${e.localPort}`);
144
+ this.daemon.broadcast({ type: 'tunnel.connected', data: { id: e.id, name, localPort: e.localPort, host: this.saved.get(e.id)?.host, url: `http://localhost:${e.localPort}?instance=${encodeURIComponent(name)}` } });
145
+ } else if (alive) {
146
+ // ssh survives but doesn't serve — a corpse from before the restart.
147
+ try { process.kill(e.pid, 'SIGTERM'); } catch { /* gone */ }
148
+ }
149
+ }
150
+ if (adopted > 0 && !this._healthInterval) {
151
+ this._healthInterval = setInterval(() => this._healthCheckAll(), HEALTH_INTERVAL);
152
+ }
153
+ this._saveActive();
154
+ }
155
+
156
+ // Identity check for re-adoption: is this pid an ssh forwarding this port?
157
+ // Guards against pid recycling handing us an unrelated process.
158
+ _looksLikeOurSsh(pid, localPort) {
159
+ try {
160
+ const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
161
+ encoding: 'utf8', timeout: 3000,
162
+ }).trim();
163
+ return cmd.includes('ssh') && cmd.includes(String(localPort));
164
+ } catch { return false; }
165
+ }
166
+
167
+ _saveActive() {
168
+ try {
169
+ const entries = [...this.active.entries()].map(([id, c]) => ({
170
+ id, pid: c.pid, localPort: c.localPort, startedAt: c.startedAt,
171
+ }));
172
+ writeFileSync(this.activePath, JSON.stringify(entries, null, 2), { mode: 0o600 });
173
+ } catch { /* best effort */ }
174
+ }
175
+
102
176
  getSaved() {
103
177
  return Array.from(this.saved.values()).map(s => ({
104
178
  ...this._sanitize(s),
@@ -406,6 +480,7 @@ export class TunnelManager {
406
480
  healthy: true,
407
481
  failCount: 0,
408
482
  });
483
+ this._saveActive();
409
484
 
410
485
  // Verify daemon is reachable through tunnel, start if needed
411
486
  let remoteAlive = false;
@@ -496,6 +571,7 @@ export class TunnelManager {
496
571
  if (localPort) await this._waitForPortFree(localPort, 3000);
497
572
 
498
573
  this.active.delete(id);
574
+ this._saveActive();
499
575
 
500
576
  const config = this.saved.get(id);
501
577
  this.daemon.audit.log('tunnel.disconnect', { id, name: config?.name });
@@ -972,6 +1048,12 @@ export class TunnelManager {
972
1048
  conn.failCount = 0;
973
1049
  conn.healthy = true;
974
1050
  conn._wedgedStreak = 0;
1051
+ } else if (verdict === 'remote-down') {
1052
+ // The tunnel is carrying traffic correctly — the far daemon is what
1053
+ // died (typically mid-upgrade). Rebuild would be useless; start it.
1054
+ conn.healthy = false;
1055
+ this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
1056
+ await this._startRemoteDaemon(id, conn);
975
1057
  } else {
976
1058
  conn.healthy = false;
977
1059
  this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
@@ -995,19 +1077,71 @@ export class TunnelManager {
995
1077
  }
996
1078
 
997
1079
  // 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)
1080
+ // 'alive' — answered a long-timeout HTTP probe; leave it alone
1081
+ // 'proc-dead' — the ssh client process is gone
1082
+ // 'port-dead' — nothing is listening on the local port
1083
+ // 'remote-down' the tunnel forwards fine but the REMOTE end refuses:
1084
+ // the probe fails fast with a connection error, not a
1085
+ // timeout. Killing the tunnel won't fix that — the remote
1086
+ // daemon needs starting (e.g. it died during an upgrade).
1087
+ // 'wedged' — port accepts TCP but HTTP hangs to timeout (dead forward)
1002
1088
  async _confirmDead(conn) {
1003
- if (await this._tunnelResponds(conn.localPort, this.confirmTimeout ?? CONFIRM_TIMEOUT)) return 'alive';
1089
+ const started = Date.now();
1090
+ let probeErr = null;
1091
+ try {
1092
+ const res = await fetch(`http://localhost:${conn.localPort}/api/health`, {
1093
+ signal: AbortSignal.timeout(this.confirmTimeout ?? CONFIRM_TIMEOUT),
1094
+ });
1095
+ if (res.ok) return 'alive';
1096
+ } catch (err) { probeErr = err; }
1097
+
1004
1098
  if (conn.pid) {
1005
1099
  try { process.kill(conn.pid, 0); } catch { return 'proc-dead'; }
1006
1100
  }
1007
1101
  if (!(await this._isPortInUse(conn.localPort))) return 'port-dead';
1102
+
1103
+ // ssh is alive and its port listens. A hang (timeout) means the forward is
1104
+ // dead; a FAST connection-level error means ssh relayed the remote side's
1105
+ // refusal — the tunnel works, the far daemon doesn't.
1106
+ const failedFast = Date.now() - started < 2000;
1107
+ const timedOut = probeErr && (probeErr.name === 'TimeoutError' || probeErr.name === 'AbortError');
1108
+ if (failedFast && !timedOut) return 'remote-down';
1008
1109
  return 'wedged';
1009
1110
  }
1010
1111
 
1112
+ // The tunnel is healthy; the daemon on the far side is what's down. Start it
1113
+ // over ssh rather than pointlessly rebuilding the tunnel. Rate-limited: if
1114
+ // the remote daemon won't stay up, repeated starts won't save it.
1115
+ async _startRemoteDaemon(id, conn) {
1116
+ this._remoteStartAt = this._remoteStartAt || new Map();
1117
+ const last = this._remoteStartAt.get(id) || 0;
1118
+ if (Date.now() - last < REMOTE_START_COOLDOWN_MS) return;
1119
+ this._remoteStartAt.set(id, Date.now());
1120
+
1121
+ console.log(`[Groove:Tunnel] ${id}: tunnel is fine but the remote daemon is down — starting it`);
1122
+ this.daemon.audit.log('tunnel.remote-daemon-start', { id });
1123
+ this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'starting' } });
1124
+ try {
1125
+ await this.autoStart(id);
1126
+ // Confirm it came up; a success resets the failure counters immediately
1127
+ // instead of waiting out another health cycle.
1128
+ for (let i = 0; i < 10; i++) {
1129
+ await new Promise((r) => setTimeout(r, 2000));
1130
+ if (await this._tunnelResponds(conn.localPort)) {
1131
+ conn.failCount = 0;
1132
+ conn.healthy = true;
1133
+ conn._wedgedStreak = 0;
1134
+ console.log(`[Groove:Tunnel] ${id}: remote daemon is back`);
1135
+ this.daemon.broadcast({ type: 'tunnel.health', data: { id, latencyMs: conn.latencyMs, healthy: true } });
1136
+ return;
1137
+ }
1138
+ }
1139
+ console.warn(`[Groove:Tunnel] ${id}: remote daemon did not come back after start`);
1140
+ } catch (err) {
1141
+ console.warn(`[Groove:Tunnel] ${id}: could not start remote daemon: ${err.message}`);
1142
+ }
1143
+ }
1144
+
1011
1145
  // Tear down a confirmed-dead tunnel and immediately rebuild it on the SAME
1012
1146
  // local port. The remote GUI window points at that port and its WebSocket
1013
1147
  // retries every 2s, so a same-port rebuild heals an open window without the
@@ -1075,25 +1209,17 @@ export class TunnelManager {
1075
1209
  throw new Error(`No available local port found (tried ${DEFAULT_LOCAL_PORT}-${DEFAULT_LOCAL_PORT + MAX_PORT_ATTEMPTS - 1})`);
1076
1210
  }
1077
1211
 
1212
+ // Deliberately does NOT kill the ssh processes. They are spawned detached and
1213
+ // are the user's live sessions: killing them on every daemon restart (app
1214
+ // upgrade, promote, crash) is what nuked remote windows mid-session. State is
1215
+ // persisted; the next daemon re-adopts whatever is still alive and serving.
1216
+ // Explicit disconnect()/delete() remain the paths that actually kill a tunnel.
1078
1217
  shutdown() {
1079
1218
  if (this._healthInterval) {
1080
1219
  clearInterval(this._healthInterval);
1081
1220
  this._healthInterval = null;
1082
1221
  }
1083
- for (const [id] of this.active) {
1084
- try {
1085
- const conn = this.active.get(id);
1086
- if (conn?.pid) {
1087
- const cmd = execFileSync('ps', ['-p', String(conn.pid), '-o', 'command='], {
1088
- encoding: 'utf8',
1089
- timeout: 3000,
1090
- }).trim();
1091
- if (cmd.includes('ssh')) {
1092
- process.kill(conn.pid, 'SIGTERM');
1093
- }
1094
- }
1095
- } catch { /* ignore */ }
1096
- }
1222
+ this._saveActive();
1097
1223
  this.active.clear();
1098
1224
  }
1099
1225
  }
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { describe, it, beforeEach, afterEach } from 'node:test';
12
12
  import assert from 'node:assert/strict';
13
- import { mkdtempSync, rmSync } from 'fs';
13
+ import { mkdtempSync, rmSync, readFileSync, existsSync } from 'fs';
14
14
  import { tmpdir } from 'os';
15
15
  import { resolve } from 'path';
16
16
  import { createServer } from 'net';
@@ -240,6 +240,104 @@ describe('TunnelManager — wake-from-sleep recovery', () => {
240
240
  } finally { live.close(); }
241
241
  });
242
242
 
243
+ it('remote-down (fast refusal through a live tunnel) starts the daemon, not a rebuild', async () => {
244
+ // ssh alive + port listens + connections REFUSED at the far end: ssh relays
245
+ // the refusal as an immediate close, so the probe fails fast rather than
246
+ // hanging. The tunnel is fine; only the remote daemon needs starting.
247
+ const refusing = createServer((sock) => sock.destroy());
248
+ await new Promise((r) => refusing.listen(0, '127.0.0.1', r));
249
+ const port = refusing.address().port;
250
+ try {
251
+ mgr.saved.set('dgx', { id: 'dgx', name: 'Axom Spark', host: 'edgexpert.local', user: 'axom', port: 22 });
252
+ mgr.active.set('dgx', {
253
+ pid: process.pid, localPort: port, healthy: true, failCount: 99,
254
+ startedAt: new Date().toISOString(),
255
+ });
256
+
257
+ const started = [];
258
+ mgr.autoStart = async (id) => { started.push(id); };
259
+ mgr.connect = async () => { throw new Error('rebuild must not be attempted'); };
260
+
261
+ await mgr._healthCheckAll();
262
+
263
+ assert.deepEqual(started, ['dgx'], 'the remote daemon was started over ssh');
264
+ assert.equal(mgr.active.has('dgx'), true, 'the healthy tunnel was NOT torn down');
265
+ } finally { refusing.close(); }
266
+ });
267
+
268
+ it('re-adopts a surviving tunnel after a daemon restart instead of forgetting it', async () => {
269
+ const live = await startHealthyListener();
270
+ try {
271
+ mgr.saved.set('s19', { id: 's19', name: 'S19 Agency', host: '3.22.211.238', user: 'ubuntu', port: 22 });
272
+ mgr._save(); // the new daemon loads configs from disk
273
+ mgr.active.set('s19', {
274
+ pid: process.pid, localPort: live.port,
275
+ startedAt: new Date().toISOString(), healthy: true, failCount: 0,
276
+ });
277
+ mgr._saveActive();
278
+ mgr.shutdown(); // daemon going down — must NOT kill the tunnel
279
+
280
+ // "New daemon" after restart. The stand-in pid is node, not ssh, so the
281
+ // identity gate is stubbed — everything else runs for real.
282
+ const daemon2 = makeDaemon(grooveDir);
283
+ const mgr2 = new TunnelManager(daemon2);
284
+ mgr2.healthTimeout = 400;
285
+ mgr2._looksLikeOurSsh = (pid, port) => pid === process.pid && port === live.port;
286
+
287
+ await mgr2._readopt();
288
+
289
+ assert.equal(mgr2.active.has('s19'), true, 'the surviving tunnel was re-adopted');
290
+ assert.equal(mgr2.active.get('s19').localPort, live.port, 'on its original port');
291
+ assert.ok(
292
+ daemon2.broadcasts.some((b) => b.type === 'tunnel.connected'),
293
+ 'the GUI is told the tunnel is (still) connected',
294
+ );
295
+ mgr2.shutdown();
296
+ } finally { live.close(); }
297
+ });
298
+
299
+ it('does not re-adopt a dead or hijacked pid', async () => {
300
+ const live = await startHealthyListener();
301
+ try {
302
+ mgr.saved.set('s19', { id: 's19', name: 'S19 Agency', host: '3.22.211.238', user: 'ubuntu', port: 22 });
303
+ mgr._save();
304
+ mgr.active.set('s19', {
305
+ pid: 999999, localPort: live.port, // no such process
306
+ startedAt: new Date().toISOString(), healthy: true, failCount: 0,
307
+ });
308
+ mgr._saveActive();
309
+ mgr.shutdown();
310
+
311
+ const daemon2 = makeDaemon(grooveDir);
312
+ const mgr2 = new TunnelManager(daemon2);
313
+ mgr2.healthTimeout = 400;
314
+ await mgr2._readopt();
315
+
316
+ assert.equal(mgr2.active.has('s19'), false, 'a dead pid is not adopted');
317
+ mgr2.shutdown();
318
+ } finally { live.close(); }
319
+ });
320
+
321
+ it('shutdown persists state and does not kill the tunnel process', async () => {
322
+ const live = await startHealthyListener();
323
+ try {
324
+ mgr.saved.set('s19', { id: 's19', name: 'S19', host: 'x', user: 'u', port: 22 });
325
+ mgr.active.set('s19', {
326
+ pid: process.pid, localPort: live.port,
327
+ startedAt: new Date().toISOString(), healthy: true, failCount: 0,
328
+ });
329
+ mgr.shutdown();
330
+
331
+ // Our stand-in "tunnel process" (this test runner) must still be alive —
332
+ // shutdown killing it would have killed the test.
333
+ assert.doesNotThrow(() => process.kill(process.pid, 0));
334
+ const persisted = JSON.parse(readFileSync(resolve(grooveDir, 'tunnels-active.json'), 'utf8'));
335
+ assert.equal(persisted.length, 1);
336
+ assert.equal(persisted[0].id, 's19');
337
+ assert.equal(persisted[0].localPort, live.port);
338
+ } finally { live.close(); }
339
+ });
340
+
243
341
  it('_waitForPortFree reports a released port', async () => {
244
342
  const live = await startHealthyListener();
245
343
  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.213",
3
+ "version": "0.27.214",
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.213",
3
+ "version": "0.27.214",
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.213",
3
+ "version": "0.27.214",
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.213",
3
+ "version": "0.27.214",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -26,6 +26,10 @@ const CONFIRM_TIMEOUT = 15000;
26
26
  // At most one automatic rebuild per tunnel per window; beyond that it stays
27
27
  // disconnected rather than thrashing against a host that keeps dying.
28
28
  const REBUILD_COOLDOWN_MS = 10 * 60 * 1000;
29
+ // Restarting a crashed REMOTE daemon is cheaper and safer than a rebuild, so
30
+ // its cooldown is shorter — but still bounded: a daemon that dies right after
31
+ // every start has a real problem more starts won't fix.
32
+ const REMOTE_START_COOLDOWN_MS = 2 * 60 * 1000;
29
33
 
30
34
  const INJECTION_CHARS = /[;|&`$(){}[\]<>!#\n\r\\]/;
31
35
 
@@ -60,6 +64,10 @@ export class TunnelManager {
60
64
  constructor(daemon) {
61
65
  this.daemon = daemon;
62
66
  this.remotesPath = resolve(daemon.grooveDir, 'remotes.json');
67
+ // Live tunnel state, persisted separately from the configs so a daemon
68
+ // restart can re-adopt still-running ssh processes instead of forgetting
69
+ // them (the configs file is user data; this is runtime state).
70
+ this.activePath = resolve(daemon.grooveDir, 'tunnels-active.json');
63
71
  this.saved = new Map();
64
72
  this.active = new Map();
65
73
  this._healthInterval = null;
@@ -88,8 +96,9 @@ export class TunnelManager {
88
96
  }
89
97
 
90
98
  async init() {
99
+ await this._readopt();
91
100
  for (const [id, config] of this.saved) {
92
- if (config.autoConnect) {
101
+ if (config.autoConnect && !this.active.has(id)) {
93
102
  try {
94
103
  await this.connect(id);
95
104
  } catch (err) {
@@ -99,6 +108,71 @@ export class TunnelManager {
99
108
  }
100
109
  }
101
110
 
111
+ // Re-adopt tunnels whose detached ssh processes survived a daemon restart.
112
+ // Without this, every daemon/app restart orphaned the ssh (or shutdown killed
113
+ // it) and the new daemon started amnesiac — remote windows died mid-session
114
+ // and the user had to reconnect everything by hand.
115
+ async _readopt() {
116
+ let entries = [];
117
+ try {
118
+ if (existsSync(this.activePath)) entries = JSON.parse(readFileSync(this.activePath, 'utf8'));
119
+ } catch { /* corrupt — treat as none */ }
120
+ if (!Array.isArray(entries) || entries.length === 0) return;
121
+
122
+ let adopted = 0;
123
+ for (const e of entries) {
124
+ if (!e?.id || !e.pid || !e.localPort || !this.saved.has(e.id)) continue;
125
+ // Only re-adopt what is provably OUR ssh still doing THIS job: the pid
126
+ // must be alive, be an ssh process forwarding this port, and the port
127
+ // must serve HTTP.
128
+ let alive = false;
129
+ try { process.kill(e.pid, 0); alive = true; } catch { /* gone */ }
130
+ if (alive) alive = this._looksLikeOurSsh(e.pid, e.localPort);
131
+ if (alive && await this._tunnelResponds(e.localPort)) {
132
+ this.active.set(e.id, {
133
+ pid: e.pid,
134
+ localPort: e.localPort,
135
+ startedAt: e.startedAt || new Date().toISOString(),
136
+ lastPing: Date.now(),
137
+ latencyMs: null,
138
+ healthy: true,
139
+ failCount: 0,
140
+ });
141
+ adopted++;
142
+ const name = this.saved.get(e.id)?.name || e.id;
143
+ console.log(`[Groove:Tunnel] Re-adopted live tunnel to ${name} on port ${e.localPort}`);
144
+ this.daemon.broadcast({ type: 'tunnel.connected', data: { id: e.id, name, localPort: e.localPort, host: this.saved.get(e.id)?.host, url: `http://localhost:${e.localPort}?instance=${encodeURIComponent(name)}` } });
145
+ } else if (alive) {
146
+ // ssh survives but doesn't serve — a corpse from before the restart.
147
+ try { process.kill(e.pid, 'SIGTERM'); } catch { /* gone */ }
148
+ }
149
+ }
150
+ if (adopted > 0 && !this._healthInterval) {
151
+ this._healthInterval = setInterval(() => this._healthCheckAll(), HEALTH_INTERVAL);
152
+ }
153
+ this._saveActive();
154
+ }
155
+
156
+ // Identity check for re-adoption: is this pid an ssh forwarding this port?
157
+ // Guards against pid recycling handing us an unrelated process.
158
+ _looksLikeOurSsh(pid, localPort) {
159
+ try {
160
+ const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
161
+ encoding: 'utf8', timeout: 3000,
162
+ }).trim();
163
+ return cmd.includes('ssh') && cmd.includes(String(localPort));
164
+ } catch { return false; }
165
+ }
166
+
167
+ _saveActive() {
168
+ try {
169
+ const entries = [...this.active.entries()].map(([id, c]) => ({
170
+ id, pid: c.pid, localPort: c.localPort, startedAt: c.startedAt,
171
+ }));
172
+ writeFileSync(this.activePath, JSON.stringify(entries, null, 2), { mode: 0o600 });
173
+ } catch { /* best effort */ }
174
+ }
175
+
102
176
  getSaved() {
103
177
  return Array.from(this.saved.values()).map(s => ({
104
178
  ...this._sanitize(s),
@@ -406,6 +480,7 @@ export class TunnelManager {
406
480
  healthy: true,
407
481
  failCount: 0,
408
482
  });
483
+ this._saveActive();
409
484
 
410
485
  // Verify daemon is reachable through tunnel, start if needed
411
486
  let remoteAlive = false;
@@ -496,6 +571,7 @@ export class TunnelManager {
496
571
  if (localPort) await this._waitForPortFree(localPort, 3000);
497
572
 
498
573
  this.active.delete(id);
574
+ this._saveActive();
499
575
 
500
576
  const config = this.saved.get(id);
501
577
  this.daemon.audit.log('tunnel.disconnect', { id, name: config?.name });
@@ -972,6 +1048,12 @@ export class TunnelManager {
972
1048
  conn.failCount = 0;
973
1049
  conn.healthy = true;
974
1050
  conn._wedgedStreak = 0;
1051
+ } else if (verdict === 'remote-down') {
1052
+ // The tunnel is carrying traffic correctly — the far daemon is what
1053
+ // died (typically mid-upgrade). Rebuild would be useless; start it.
1054
+ conn.healthy = false;
1055
+ this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
1056
+ await this._startRemoteDaemon(id, conn);
975
1057
  } else {
976
1058
  conn.healthy = false;
977
1059
  this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
@@ -995,19 +1077,71 @@ export class TunnelManager {
995
1077
  }
996
1078
 
997
1079
  // 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)
1080
+ // 'alive' — answered a long-timeout HTTP probe; leave it alone
1081
+ // 'proc-dead' — the ssh client process is gone
1082
+ // 'port-dead' — nothing is listening on the local port
1083
+ // 'remote-down' the tunnel forwards fine but the REMOTE end refuses:
1084
+ // the probe fails fast with a connection error, not a
1085
+ // timeout. Killing the tunnel won't fix that — the remote
1086
+ // daemon needs starting (e.g. it died during an upgrade).
1087
+ // 'wedged' — port accepts TCP but HTTP hangs to timeout (dead forward)
1002
1088
  async _confirmDead(conn) {
1003
- if (await this._tunnelResponds(conn.localPort, this.confirmTimeout ?? CONFIRM_TIMEOUT)) return 'alive';
1089
+ const started = Date.now();
1090
+ let probeErr = null;
1091
+ try {
1092
+ const res = await fetch(`http://localhost:${conn.localPort}/api/health`, {
1093
+ signal: AbortSignal.timeout(this.confirmTimeout ?? CONFIRM_TIMEOUT),
1094
+ });
1095
+ if (res.ok) return 'alive';
1096
+ } catch (err) { probeErr = err; }
1097
+
1004
1098
  if (conn.pid) {
1005
1099
  try { process.kill(conn.pid, 0); } catch { return 'proc-dead'; }
1006
1100
  }
1007
1101
  if (!(await this._isPortInUse(conn.localPort))) return 'port-dead';
1102
+
1103
+ // ssh is alive and its port listens. A hang (timeout) means the forward is
1104
+ // dead; a FAST connection-level error means ssh relayed the remote side's
1105
+ // refusal — the tunnel works, the far daemon doesn't.
1106
+ const failedFast = Date.now() - started < 2000;
1107
+ const timedOut = probeErr && (probeErr.name === 'TimeoutError' || probeErr.name === 'AbortError');
1108
+ if (failedFast && !timedOut) return 'remote-down';
1008
1109
  return 'wedged';
1009
1110
  }
1010
1111
 
1112
+ // The tunnel is healthy; the daemon on the far side is what's down. Start it
1113
+ // over ssh rather than pointlessly rebuilding the tunnel. Rate-limited: if
1114
+ // the remote daemon won't stay up, repeated starts won't save it.
1115
+ async _startRemoteDaemon(id, conn) {
1116
+ this._remoteStartAt = this._remoteStartAt || new Map();
1117
+ const last = this._remoteStartAt.get(id) || 0;
1118
+ if (Date.now() - last < REMOTE_START_COOLDOWN_MS) return;
1119
+ this._remoteStartAt.set(id, Date.now());
1120
+
1121
+ console.log(`[Groove:Tunnel] ${id}: tunnel is fine but the remote daemon is down — starting it`);
1122
+ this.daemon.audit.log('tunnel.remote-daemon-start', { id });
1123
+ this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'starting' } });
1124
+ try {
1125
+ await this.autoStart(id);
1126
+ // Confirm it came up; a success resets the failure counters immediately
1127
+ // instead of waiting out another health cycle.
1128
+ for (let i = 0; i < 10; i++) {
1129
+ await new Promise((r) => setTimeout(r, 2000));
1130
+ if (await this._tunnelResponds(conn.localPort)) {
1131
+ conn.failCount = 0;
1132
+ conn.healthy = true;
1133
+ conn._wedgedStreak = 0;
1134
+ console.log(`[Groove:Tunnel] ${id}: remote daemon is back`);
1135
+ this.daemon.broadcast({ type: 'tunnel.health', data: { id, latencyMs: conn.latencyMs, healthy: true } });
1136
+ return;
1137
+ }
1138
+ }
1139
+ console.warn(`[Groove:Tunnel] ${id}: remote daemon did not come back after start`);
1140
+ } catch (err) {
1141
+ console.warn(`[Groove:Tunnel] ${id}: could not start remote daemon: ${err.message}`);
1142
+ }
1143
+ }
1144
+
1011
1145
  // Tear down a confirmed-dead tunnel and immediately rebuild it on the SAME
1012
1146
  // local port. The remote GUI window points at that port and its WebSocket
1013
1147
  // retries every 2s, so a same-port rebuild heals an open window without the
@@ -1075,25 +1209,17 @@ export class TunnelManager {
1075
1209
  throw new Error(`No available local port found (tried ${DEFAULT_LOCAL_PORT}-${DEFAULT_LOCAL_PORT + MAX_PORT_ATTEMPTS - 1})`);
1076
1210
  }
1077
1211
 
1212
+ // Deliberately does NOT kill the ssh processes. They are spawned detached and
1213
+ // are the user's live sessions: killing them on every daemon restart (app
1214
+ // upgrade, promote, crash) is what nuked remote windows mid-session. State is
1215
+ // persisted; the next daemon re-adopts whatever is still alive and serving.
1216
+ // Explicit disconnect()/delete() remain the paths that actually kill a tunnel.
1078
1217
  shutdown() {
1079
1218
  if (this._healthInterval) {
1080
1219
  clearInterval(this._healthInterval);
1081
1220
  this._healthInterval = null;
1082
1221
  }
1083
- for (const [id] of this.active) {
1084
- try {
1085
- const conn = this.active.get(id);
1086
- if (conn?.pid) {
1087
- const cmd = execFileSync('ps', ['-p', String(conn.pid), '-o', 'command='], {
1088
- encoding: 'utf8',
1089
- timeout: 3000,
1090
- }).trim();
1091
- if (cmd.includes('ssh')) {
1092
- process.kill(conn.pid, 'SIGTERM');
1093
- }
1094
- }
1095
- } catch { /* ignore */ }
1096
- }
1222
+ this._saveActive();
1097
1223
  this.active.clear();
1098
1224
  }
1099
1225
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.213",
3
+ "version": "0.27.214",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",