groove-dev 0.27.213 → 0.27.215

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 (30) hide show
  1. package/CLAUDE.md +2 -0
  2. package/node_modules/@groove-dev/cli/bin/groove.js +8 -0
  3. package/node_modules/@groove-dev/cli/package.json +1 -1
  4. package/node_modules/@groove-dev/cli/src/commands/handoff.js +22 -0
  5. package/node_modules/@groove-dev/daemon/package.json +1 -1
  6. package/node_modules/@groove-dev/daemon/src/index.js +18 -1
  7. package/node_modules/@groove-dev/daemon/src/journalist.js +28 -0
  8. package/node_modules/@groove-dev/daemon/src/rotator.js +179 -0
  9. package/node_modules/@groove-dev/daemon/src/routes/agents.js +25 -0
  10. package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +217 -30
  11. package/node_modules/@groove-dev/daemon/test/rotator.test.js +86 -0
  12. package/node_modules/@groove-dev/daemon/test/tunnel-manager.test.js +143 -2
  13. package/node_modules/@groove-dev/gui/dist/assets/{index-DPsim83z.css → index-B82kMi13.css} +1 -1
  14. package/node_modules/@groove-dev/gui/dist/assets/{index-BP4oE2UL.js → index-CLfmxBx1.js} +1 -1
  15. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  16. package/node_modules/@groove-dev/gui/package.json +1 -1
  17. package/package.json +1 -1
  18. package/packages/cli/bin/groove.js +8 -0
  19. package/packages/cli/package.json +1 -1
  20. package/packages/cli/src/commands/handoff.js +22 -0
  21. package/packages/daemon/package.json +1 -1
  22. package/packages/daemon/src/index.js +18 -1
  23. package/packages/daemon/src/journalist.js +28 -0
  24. package/packages/daemon/src/rotator.js +179 -0
  25. package/packages/daemon/src/routes/agents.js +25 -0
  26. package/packages/daemon/src/tunnel-manager.js +217 -30
  27. package/packages/gui/dist/assets/{index-DPsim83z.css → index-B82kMi13.css} +1 -1
  28. package/packages/gui/dist/assets/{index-BP4oE2UL.js → index-CLfmxBx1.js} +1 -1
  29. package/packages/gui/dist/index.html +2 -2
  30. package/packages/gui/package.json +1 -1
@@ -4,7 +4,8 @@
4
4
  import { execFileSync, spawn } from 'child_process';
5
5
  import { existsSync, writeFileSync, readFileSync, statSync } from 'fs';
6
6
  import { resolve } from 'path';
7
- import { createConnection } from 'net';
7
+ import { createConnection, isIP } from 'net';
8
+ import { lookup } from 'dns/promises';
8
9
  import crypto from 'crypto';
9
10
 
10
11
  function getLocalVersion() {
@@ -15,6 +16,13 @@ function getLocalVersion() {
15
16
  }
16
17
 
17
18
  const REMOTE_PORT = 31415;
19
+
20
+ // Every remote `groove start` must run from the directory of the daemon's
21
+ // EXISTING world (grooveDir is derived from cwd). The remote daemon records
22
+ // that directory in ~/.groove/last-run-dir on each boot; starting anywhere
23
+ // else boots a fresh empty .groove — which reads as "all my teams are gone".
24
+ // Falls back to $HOME (matching old behavior) when no anchor exists yet.
25
+ const ANCHOR_CD = `cd "$(cat "$HOME/.groove/last-run-dir" 2>/dev/null || echo "$HOME")" 2>/dev/null || cd "$HOME"; `;
18
26
  const DEFAULT_LOCAL_PORT = 31416;
19
27
  const MAX_PORT_ATTEMPTS = 10;
20
28
  const HEALTH_INTERVAL = 30000;
@@ -26,6 +34,10 @@ const CONFIRM_TIMEOUT = 15000;
26
34
  // At most one automatic rebuild per tunnel per window; beyond that it stays
27
35
  // disconnected rather than thrashing against a host that keeps dying.
28
36
  const REBUILD_COOLDOWN_MS = 10 * 60 * 1000;
37
+ // Restarting a crashed REMOTE daemon is cheaper and safer than a rebuild, so
38
+ // its cooldown is shorter — but still bounded: a daemon that dies right after
39
+ // every start has a real problem more starts won't fix.
40
+ const REMOTE_START_COOLDOWN_MS = 2 * 60 * 1000;
29
41
 
30
42
  const INJECTION_CHARS = /[;|&`$(){}[\]<>!#\n\r\\]/;
31
43
 
@@ -56,10 +68,57 @@ function isPermissionError(output) {
56
68
 
57
69
  const PERMISSION_HINT = 'npm global install requires write access. Either install Node via nvm (recommended) or configure passwordless sudo for npm on the remote server.';
58
70
 
71
+ // A hostname can resolve to several addresses on different interfaces — a
72
+ // dual-homed LAN box (wired + Wi-Fi) advertises all of them over mDNS, and ssh
73
+ // just takes the resolver's first pick. Landing on a weak Wi-Fi address gives
74
+ // a tunnel that dies of keepalive timeout minutes later, and every reconnect
75
+ // re-rolls the dice. Probe all candidates with a TCP handshake to the ssh port
76
+ // and take the fastest responder — on a LAN that reliably picks wired over
77
+ // Wi-Fi. Falls back to the original hostname if resolution or every probe
78
+ // fails, so behavior is unchanged for the cases that already worked.
79
+ export async function resolveBestAddress(host, port = 22, probeTimeoutMs = 2500) {
80
+ if (isIP(host)) return host; // literal IP — nothing to choose
81
+ let addrs;
82
+ try {
83
+ addrs = await lookup(host, { all: true, verbatim: true });
84
+ } catch { return host; }
85
+ if (!Array.isArray(addrs) || addrs.length <= 1) return host;
86
+
87
+ const handshake = (address) => new Promise((res) => {
88
+ const started = Date.now();
89
+ let sock;
90
+ try { sock = createConnection({ host: address, port }); } catch { return res(null); }
91
+ sock.setTimeout(probeTimeoutMs);
92
+ sock.on('connect', () => { sock.destroy(); res(Date.now() - started); });
93
+ sock.on('error', () => res(null));
94
+ sock.on('timeout', () => { sock.destroy(); res(null); });
95
+ });
96
+
97
+ // Median of three handshakes per address: one lucky round-trip can make a
98
+ // weak link look fine, but a flaky link rarely wins three in a row — a
99
+ // single retransmit (or drop, scored as the timeout) sinks its median.
100
+ const probe = async (address) => {
101
+ const times = [];
102
+ for (let i = 0; i < 3; i++) times.push(await handshake(address));
103
+ const scored = times.map((t) => (t === null ? probeTimeoutMs : t)).sort((a, b) => a - b);
104
+ if (times.every((t) => t === null)) return null; // never connected at all
105
+ return { address, ms: scored[1] };
106
+ };
107
+
108
+ const results = (await Promise.all(addrs.map((a) => probe(a.address)))).filter(Boolean);
109
+ if (results.length === 0) return host;
110
+ results.sort((a, b) => a.ms - b.ms);
111
+ return results[0].address;
112
+ }
113
+
59
114
  export class TunnelManager {
60
115
  constructor(daemon) {
61
116
  this.daemon = daemon;
62
117
  this.remotesPath = resolve(daemon.grooveDir, 'remotes.json');
118
+ // Live tunnel state, persisted separately from the configs so a daemon
119
+ // restart can re-adopt still-running ssh processes instead of forgetting
120
+ // them (the configs file is user data; this is runtime state).
121
+ this.activePath = resolve(daemon.grooveDir, 'tunnels-active.json');
63
122
  this.saved = new Map();
64
123
  this.active = new Map();
65
124
  this._healthInterval = null;
@@ -88,8 +147,9 @@ export class TunnelManager {
88
147
  }
89
148
 
90
149
  async init() {
150
+ await this._readopt();
91
151
  for (const [id, config] of this.saved) {
92
- if (config.autoConnect) {
152
+ if (config.autoConnect && !this.active.has(id)) {
93
153
  try {
94
154
  await this.connect(id);
95
155
  } catch (err) {
@@ -99,6 +159,71 @@ export class TunnelManager {
99
159
  }
100
160
  }
101
161
 
162
+ // Re-adopt tunnels whose detached ssh processes survived a daemon restart.
163
+ // Without this, every daemon/app restart orphaned the ssh (or shutdown killed
164
+ // it) and the new daemon started amnesiac — remote windows died mid-session
165
+ // and the user had to reconnect everything by hand.
166
+ async _readopt() {
167
+ let entries = [];
168
+ try {
169
+ if (existsSync(this.activePath)) entries = JSON.parse(readFileSync(this.activePath, 'utf8'));
170
+ } catch { /* corrupt — treat as none */ }
171
+ if (!Array.isArray(entries) || entries.length === 0) return;
172
+
173
+ let adopted = 0;
174
+ for (const e of entries) {
175
+ if (!e?.id || !e.pid || !e.localPort || !this.saved.has(e.id)) continue;
176
+ // Only re-adopt what is provably OUR ssh still doing THIS job: the pid
177
+ // must be alive, be an ssh process forwarding this port, and the port
178
+ // must serve HTTP.
179
+ let alive = false;
180
+ try { process.kill(e.pid, 0); alive = true; } catch { /* gone */ }
181
+ if (alive) alive = this._looksLikeOurSsh(e.pid, e.localPort);
182
+ if (alive && await this._tunnelResponds(e.localPort)) {
183
+ this.active.set(e.id, {
184
+ pid: e.pid,
185
+ localPort: e.localPort,
186
+ startedAt: e.startedAt || new Date().toISOString(),
187
+ lastPing: Date.now(),
188
+ latencyMs: null,
189
+ healthy: true,
190
+ failCount: 0,
191
+ });
192
+ adopted++;
193
+ const name = this.saved.get(e.id)?.name || e.id;
194
+ console.log(`[Groove:Tunnel] Re-adopted live tunnel to ${name} on port ${e.localPort}`);
195
+ 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)}` } });
196
+ } else if (alive) {
197
+ // ssh survives but doesn't serve — a corpse from before the restart.
198
+ try { process.kill(e.pid, 'SIGTERM'); } catch { /* gone */ }
199
+ }
200
+ }
201
+ if (adopted > 0 && !this._healthInterval) {
202
+ this._healthInterval = setInterval(() => this._healthCheckAll(), HEALTH_INTERVAL);
203
+ }
204
+ this._saveActive();
205
+ }
206
+
207
+ // Identity check for re-adoption: is this pid an ssh forwarding this port?
208
+ // Guards against pid recycling handing us an unrelated process.
209
+ _looksLikeOurSsh(pid, localPort) {
210
+ try {
211
+ const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
212
+ encoding: 'utf8', timeout: 3000,
213
+ }).trim();
214
+ return cmd.includes('ssh') && cmd.includes(String(localPort));
215
+ } catch { return false; }
216
+ }
217
+
218
+ _saveActive() {
219
+ try {
220
+ const entries = [...this.active.entries()].map(([id, c]) => ({
221
+ id, pid: c.pid, localPort: c.localPort, startedAt: c.startedAt,
222
+ }));
223
+ writeFileSync(this.activePath, JSON.stringify(entries, null, 2), { mode: 0o600 });
224
+ } catch { /* best effort */ }
225
+ }
226
+
102
227
  getSaved() {
103
228
  return Array.from(this.saved.values()).map(s => ({
104
229
  ...this._sanitize(s),
@@ -228,7 +353,7 @@ export class TunnelManager {
228
353
  const config = this.saved.get(id);
229
354
  if (!config) throw new Error(`Remote ${id} not found`);
230
355
 
231
- const target = `${config.user}@${config.host}`;
356
+ const target = `${config.user}@${await resolveBestAddress(config.host, config.port || 22)}`;
232
357
  const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
233
358
 
234
359
  try {
@@ -344,18 +469,28 @@ export class TunnelManager {
344
469
  } else {
345
470
  localPort = await this._findAvailablePort();
346
471
  }
347
- const target = `${config.user}@${config.host}`;
472
+ // Multi-homed hosts (mDNS names especially): pick the address that actually
473
+ // answers fastest instead of letting the resolver gamble on an interface.
474
+ const connectHost = await resolveBestAddress(config.host, config.port || 22);
475
+ if (connectHost !== config.host) {
476
+ console.log(`[Groove:Tunnel] ${config.name}: ${config.host} → ${connectHost} (fastest responding address)`);
477
+ }
478
+ const target = `${config.user}@${connectHost}`;
348
479
  const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
480
+ // Keep the host key pinned to the NAME when we connect by address, so every
481
+ // address of the same box shares one known_hosts entry.
482
+ const aliasArgs = connectHost !== config.host ? ['-o', `HostKeyAlias=${config.host}`] : [];
349
483
 
350
484
  const sshArgs = [
351
485
  '-N',
352
486
  '-L', `127.0.0.1:${localPort}:localhost:${REMOTE_PORT}`,
353
487
  '-p', String(config.port || 22),
354
- '-o', 'ServerAliveInterval=30',
355
- '-o', 'ServerAliveCountMax=3',
488
+ '-o', 'ServerAliveInterval=15',
489
+ '-o', 'ServerAliveCountMax=4',
356
490
  '-o', 'ExitOnForwardFailure=yes',
357
491
  '-o', 'StrictHostKeyChecking=accept-new',
358
492
  '-o', 'GSSAPIAuthentication=no',
493
+ ...aliasArgs,
359
494
  ...keyArgs,
360
495
  target,
361
496
  ];
@@ -406,6 +541,7 @@ export class TunnelManager {
406
541
  healthy: true,
407
542
  failCount: 0,
408
543
  });
544
+ this._saveActive();
409
545
 
410
546
  // Verify daemon is reachable through tunnel, start if needed
411
547
  let remoteAlive = false;
@@ -496,6 +632,7 @@ export class TunnelManager {
496
632
  if (localPort) await this._waitForPortFree(localPort, 3000);
497
633
 
498
634
  this.active.delete(id);
635
+ this._saveActive();
499
636
 
500
637
  const config = this.saved.get(id);
501
638
  this.daemon.audit.log('tunnel.disconnect', { id, name: config?.name });
@@ -559,7 +696,7 @@ export class TunnelManager {
559
696
  }
560
697
 
561
698
  // Restart remote daemon — fire and forget the SSH, verify through the tunnel
562
- const cdPrefix = config.projectDir ? `cd "${config.projectDir}" && ` : '';
699
+ const cdPrefix = config.projectDir ? `cd "${config.projectDir}" && ` : ANCHOR_CD;
563
700
  try {
564
701
  execFileSync('ssh', [...sshBase, sshCmd(`kill $(lsof -t -i:${REMOTE_PORT}) 2>/dev/null || true; sleep 1; ${cdPrefix}GROOVE_BIN=$(which groove) && nohup "$GROOVE_BIN" start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown`)], {
565
702
  encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'],
@@ -666,7 +803,7 @@ export class TunnelManager {
666
803
  const config = this.saved.get(id);
667
804
  if (!config) throw new Error(`Remote ${id} not found`);
668
805
 
669
- const target = `${config.user}@${config.host}`;
806
+ const target = `${config.user}@${await resolveBestAddress(config.host, config.port || 22)}`;
670
807
  const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
671
808
 
672
809
  // Build the remote bash command:
@@ -676,7 +813,7 @@ export class TunnelManager {
676
813
  // 4. explicitly POST /api/project-dir so the daemon's projectDir matches
677
814
  // config.projectDir even if the backgrounded cwd didn't stick (this
678
815
  // also updates the editor root used for /api/browse, /api/files/*)
679
- const cdPrefix = config.projectDir ? `cd "${config.projectDir}" && ` : '';
816
+ const cdPrefix = config.projectDir ? `cd "${config.projectDir}" && ` : ANCHOR_CD;
680
817
  const setProjectDir = config.projectDir
681
818
  ? `curl -sf -X POST -H 'Content-Type: application/json' --data '{"path":"${config.projectDir}"}' http://localhost:${REMOTE_PORT}/api/project-dir > /dev/null 2>&1 || true; `
682
819
  : '';
@@ -798,7 +935,7 @@ export class TunnelManager {
798
935
  try {
799
936
  const result = execFileSync('ssh', [
800
937
  ...sshBase,
801
- remoteCmd(`GROOVE_BIN=$(which groove) && nohup "$GROOVE_BIN" start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 5; curl -sf http://localhost:${REMOTE_PORT}/api/health > /dev/null && echo __DAEMON_OK__ || (echo __DAEMON_FAIL__; tail -20 /tmp/groove-daemon.log 2>/dev/null)`),
938
+ remoteCmd(`${ANCHOR_CD}GROOVE_BIN=$(which groove) && nohup "$GROOVE_BIN" start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 5; curl -sf http://localhost:${REMOTE_PORT}/api/health > /dev/null && echo __DAEMON_OK__ || (echo __DAEMON_FAIL__; tail -20 /tmp/groove-daemon.log 2>/dev/null)`),
802
939
  ], {
803
940
  encoding: 'utf8',
804
941
  timeout: 45000,
@@ -868,7 +1005,7 @@ export class TunnelManager {
868
1005
  }).trim();
869
1006
  const installedVer = verOutput.replace(/[^0-9.]/g, '') || verOutput.trim();
870
1007
 
871
- const restartCmd = `kill $(lsof -t -i:${REMOTE_PORT}) 2>/dev/null || true; sleep 2; GROOVE_BIN=$(which groove) && nohup "$GROOVE_BIN" start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 4; curl -sf http://localhost:${REMOTE_PORT}/api/status`;
1008
+ const restartCmd = `kill $(lsof -t -i:${REMOTE_PORT}) 2>/dev/null || true; sleep 2; ${ANCHOR_CD}GROOVE_BIN=$(which groove) && nohup "$GROOVE_BIN" start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 4; curl -sf http://localhost:${REMOTE_PORT}/api/status`;
872
1009
  const restartResult = execFileSync('ssh', [...sshBase, sshCmd(restartCmd)], {
873
1010
  encoding: 'utf8',
874
1011
  timeout: 60000,
@@ -972,6 +1109,12 @@ export class TunnelManager {
972
1109
  conn.failCount = 0;
973
1110
  conn.healthy = true;
974
1111
  conn._wedgedStreak = 0;
1112
+ } else if (verdict === 'remote-down') {
1113
+ // The tunnel is carrying traffic correctly — the far daemon is what
1114
+ // died (typically mid-upgrade). Rebuild would be useless; start it.
1115
+ conn.healthy = false;
1116
+ this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
1117
+ await this._startRemoteDaemon(id, conn);
975
1118
  } else {
976
1119
  conn.healthy = false;
977
1120
  this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
@@ -995,19 +1138,71 @@ export class TunnelManager {
995
1138
  }
996
1139
 
997
1140
  // 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)
1141
+ // 'alive' — answered a long-timeout HTTP probe; leave it alone
1142
+ // 'proc-dead' — the ssh client process is gone
1143
+ // 'port-dead' — nothing is listening on the local port
1144
+ // 'remote-down' — the tunnel forwards fine but the REMOTE end refuses:
1145
+ // the probe fails fast with a connection error, not a
1146
+ // timeout. Killing the tunnel won't fix that — the remote
1147
+ // daemon needs starting (e.g. it died during an upgrade).
1148
+ // 'wedged' — port accepts TCP but HTTP hangs to timeout (dead forward)
1002
1149
  async _confirmDead(conn) {
1003
- if (await this._tunnelResponds(conn.localPort, this.confirmTimeout ?? CONFIRM_TIMEOUT)) return 'alive';
1150
+ const started = Date.now();
1151
+ let probeErr = null;
1152
+ try {
1153
+ const res = await fetch(`http://localhost:${conn.localPort}/api/health`, {
1154
+ signal: AbortSignal.timeout(this.confirmTimeout ?? CONFIRM_TIMEOUT),
1155
+ });
1156
+ if (res.ok) return 'alive';
1157
+ } catch (err) { probeErr = err; }
1158
+
1004
1159
  if (conn.pid) {
1005
1160
  try { process.kill(conn.pid, 0); } catch { return 'proc-dead'; }
1006
1161
  }
1007
1162
  if (!(await this._isPortInUse(conn.localPort))) return 'port-dead';
1163
+
1164
+ // ssh is alive and its port listens. A hang (timeout) means the forward is
1165
+ // dead; a FAST connection-level error means ssh relayed the remote side's
1166
+ // refusal — the tunnel works, the far daemon doesn't.
1167
+ const failedFast = Date.now() - started < 2000;
1168
+ const timedOut = probeErr && (probeErr.name === 'TimeoutError' || probeErr.name === 'AbortError');
1169
+ if (failedFast && !timedOut) return 'remote-down';
1008
1170
  return 'wedged';
1009
1171
  }
1010
1172
 
1173
+ // The tunnel is healthy; the daemon on the far side is what's down. Start it
1174
+ // over ssh rather than pointlessly rebuilding the tunnel. Rate-limited: if
1175
+ // the remote daemon won't stay up, repeated starts won't save it.
1176
+ async _startRemoteDaemon(id, conn) {
1177
+ this._remoteStartAt = this._remoteStartAt || new Map();
1178
+ const last = this._remoteStartAt.get(id) || 0;
1179
+ if (Date.now() - last < REMOTE_START_COOLDOWN_MS) return;
1180
+ this._remoteStartAt.set(id, Date.now());
1181
+
1182
+ console.log(`[Groove:Tunnel] ${id}: tunnel is fine but the remote daemon is down — starting it`);
1183
+ this.daemon.audit.log('tunnel.remote-daemon-start', { id });
1184
+ this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'starting' } });
1185
+ try {
1186
+ await this.autoStart(id);
1187
+ // Confirm it came up; a success resets the failure counters immediately
1188
+ // instead of waiting out another health cycle.
1189
+ for (let i = 0; i < 10; i++) {
1190
+ await new Promise((r) => setTimeout(r, 2000));
1191
+ if (await this._tunnelResponds(conn.localPort)) {
1192
+ conn.failCount = 0;
1193
+ conn.healthy = true;
1194
+ conn._wedgedStreak = 0;
1195
+ console.log(`[Groove:Tunnel] ${id}: remote daemon is back`);
1196
+ this.daemon.broadcast({ type: 'tunnel.health', data: { id, latencyMs: conn.latencyMs, healthy: true } });
1197
+ return;
1198
+ }
1199
+ }
1200
+ console.warn(`[Groove:Tunnel] ${id}: remote daemon did not come back after start`);
1201
+ } catch (err) {
1202
+ console.warn(`[Groove:Tunnel] ${id}: could not start remote daemon: ${err.message}`);
1203
+ }
1204
+ }
1205
+
1011
1206
  // Tear down a confirmed-dead tunnel and immediately rebuild it on the SAME
1012
1207
  // local port. The remote GUI window points at that port and its WebSocket
1013
1208
  // retries every 2s, so a same-port rebuild heals an open window without the
@@ -1075,25 +1270,17 @@ export class TunnelManager {
1075
1270
  throw new Error(`No available local port found (tried ${DEFAULT_LOCAL_PORT}-${DEFAULT_LOCAL_PORT + MAX_PORT_ATTEMPTS - 1})`);
1076
1271
  }
1077
1272
 
1273
+ // Deliberately does NOT kill the ssh processes. They are spawned detached and
1274
+ // are the user's live sessions: killing them on every daemon restart (app
1275
+ // upgrade, promote, crash) is what nuked remote windows mid-session. State is
1276
+ // persisted; the next daemon re-adopts whatever is still alive and serving.
1277
+ // Explicit disconnect()/delete() remain the paths that actually kill a tunnel.
1078
1278
  shutdown() {
1079
1279
  if (this._healthInterval) {
1080
1280
  clearInterval(this._healthInterval);
1081
1281
  this._healthInterval = null;
1082
1282
  }
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
- }
1283
+ this._saveActive();
1097
1284
  this.active.clear();
1098
1285
  }
1099
1286
  }
@@ -601,4 +601,90 @@ describe('Rotator', () => {
601
601
 
602
602
  assert.equal(rotator.getHistory().length, 0);
603
603
  });
604
+
605
+ // ── Succession handoff ─────────────────────────────────────────
606
+
607
+ function seedForHandoff() {
608
+ const agent = {
609
+ id: 'old1', name: 'veteran', role: 'fullstack',
610
+ provider: 'claude-code', scope: null, model: null,
611
+ tokensUsed: 900_000, contextUsage: 0.7,
612
+ workingDir: '/tmp', teamId: 't1', prompt: 'Long-running work',
613
+ };
614
+ mockDaemon.registry.agents = [agent];
615
+ mockDaemon.journalist.generateSuccessionDossier = async () =>
616
+ '## Dossier\nEverything the veteran knows, in depth.';
617
+ let spawnCount = 0;
618
+ mockDaemon.processes.spawn = async (config) => {
619
+ spawnCount++;
620
+ const spawned = { id: 'succ' + spawnCount, ...config };
621
+ mockDaemon.registry.agents.push(spawned);
622
+ return spawned;
623
+ };
624
+ return agent;
625
+ }
626
+
627
+ it('succession spawns the successor ALONGSIDE the predecessor', async () => {
628
+ seedForHandoff();
629
+ const record = await rotator.successionHandoff('old1');
630
+
631
+ assert.equal(record.status, 'interviewing');
632
+ const ids = mockDaemon.registry.agents.map((a) => a.id);
633
+ assert.ok(ids.includes('old1'), 'predecessor still alive during the interview');
634
+ assert.ok(ids.includes(record.successorId), 'successor exists');
635
+
636
+ const successor = mockDaemon.registry.agents.find((a) => a.id === record.successorId);
637
+ assert.equal(successor.name, 'veteran-successor');
638
+ assert.ok(successor.prompt.includes('Dossier'), 'successor got the dossier');
639
+ assert.ok(successor.prompt.includes('innerchat/ask'), 'successor told to interview');
640
+ assert.ok(successor.prompt.includes(`/api/handoff/${record.id}/complete`), 'successor told how to declare takeover');
641
+ assert.equal(successor.metadata.handoff.predecessorId, 'old1');
642
+ assert.ok(broadcasts.some((b) => b.type === 'handoff:started'));
643
+ });
644
+
645
+ it('complete retires the predecessor and hands over the name', async () => {
646
+ seedForHandoff();
647
+ const record = await rotator.successionHandoff('old1');
648
+ const done = await rotator.completeHandoff(record.id);
649
+
650
+ assert.equal(done.status, 'complete');
651
+ const ids = mockDaemon.registry.agents.map((a) => a.id);
652
+ assert.ok(!ids.includes('old1'), 'predecessor retired');
653
+ const successor = mockDaemon.registry.agents.find((a) => a.id === record.successorId);
654
+ assert.equal(successor.name, 'veteran', 'successor inherited the name');
655
+ assert.equal(successor.metadata.handoff, undefined, 'handoff marker cleared');
656
+ assert.ok(broadcasts.some((b) => b.type === 'handoff:completed'));
657
+ });
658
+
659
+ it('inheritName:false keeps the successor name', async () => {
660
+ seedForHandoff();
661
+ const record = await rotator.successionHandoff('old1', { name: 'fresh-eyes', inheritName: false });
662
+ await rotator.completeHandoff(record.id);
663
+ const successor = mockDaemon.registry.agents.find((a) => a.id === record.successorId);
664
+ assert.equal(successor.name, 'fresh-eyes');
665
+ });
666
+
667
+ it('complete survives a daemon restart (reconstructs from successor metadata)', async () => {
668
+ seedForHandoff();
669
+ const record = await rotator.successionHandoff('old1');
670
+ rotator.handoffs.clear(); // simulate restart wiping in-memory state
671
+
672
+ const done = await rotator.completeHandoff(record.id);
673
+ assert.equal(done.status, 'complete');
674
+ assert.ok(!mockDaemon.registry.agents.some((a) => a.id === 'old1'), 'predecessor still retired');
675
+ });
676
+
677
+ it('blocks a second handoff while one is interviewing', async () => {
678
+ seedForHandoff();
679
+ await rotator.successionHandoff('old1');
680
+ await assert.rejects(() => rotator.successionHandoff('old1'), /already in progress/);
681
+ });
682
+
683
+ it('completing twice is idempotent', async () => {
684
+ seedForHandoff();
685
+ const record = await rotator.successionHandoff('old1');
686
+ await rotator.completeHandoff(record.id);
687
+ const again = await rotator.completeHandoff(record.id);
688
+ assert.equal(again.status, 'complete');
689
+ });
604
690
  });
@@ -10,11 +10,11 @@
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';
17
- import { TunnelManager } from '../src/tunnel-manager.js';
17
+ import { TunnelManager, resolveBestAddress } from '../src/tunnel-manager.js';
18
18
 
19
19
  function makeDaemon(grooveDir) {
20
20
  const broadcasts = [];
@@ -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');
@@ -251,3 +349,46 @@ describe('TunnelManager — wake-from-sleep recovery', () => {
251
349
  assert.equal(await mgr._waitForExit(999999, 500), true);
252
350
  });
253
351
  });
352
+
353
+ describe('resolveBestAddress — multi-homed host selection', () => {
354
+ // The failure this guards: an mDNS name advertising both a wired and a weak
355
+ // Wi-Fi interface. ssh took whichever address the resolver listed first, so
356
+ // tunnels randomly landed on the bad link and died of keepalive timeout.
357
+
358
+ it('passes literal IPs through untouched', async () => {
359
+ assert.equal(await resolveBestAddress('10.0.0.205', 22), '10.0.0.205');
360
+ assert.equal(await resolveBestAddress('::1', 22), '::1');
361
+ });
362
+
363
+ it('falls back to the name when it does not resolve', async () => {
364
+ assert.equal(
365
+ await resolveBestAddress('no-such-host-zzz.invalid', 22, 500),
366
+ 'no-such-host-zzz.invalid',
367
+ );
368
+ });
369
+
370
+ it('picks the address that actually answers when others are dead', async () => {
371
+ // 'localhost' resolves to both ::1 and 127.0.0.1 on typical systems. Bind
372
+ // only the IPv4 side: the resolver must pick 127.0.0.1, not the dead ::1.
373
+ const srv = createServer(() => {});
374
+ await new Promise((r) => srv.listen(0, '127.0.0.1', r));
375
+ const port = srv.address().port;
376
+ try {
377
+ const best = await resolveBestAddress('localhost', port, 800);
378
+ // On single-address systems resolution is a passthrough — accept that too.
379
+ assert.ok(
380
+ best === '127.0.0.1' || best === 'localhost',
381
+ `picked ${best} — expected the listening 127.0.0.1 (or passthrough)`,
382
+ );
383
+ } finally { srv.close(); }
384
+ });
385
+
386
+ it('falls back to the name when no candidate answers', async () => {
387
+ // Nobody listens on this port on any of localhost's addresses.
388
+ const srv = createServer(() => {});
389
+ await new Promise((r) => srv.listen(0, '127.0.0.1', r));
390
+ const deadPort = srv.address().port;
391
+ await new Promise((r) => srv.close(r)); // free it — now guaranteed dead
392
+ assert.equal(await resolveBestAddress('localhost', deadPort, 500), 'localhost');
393
+ });
394
+ });