groove-dev 0.27.214 → 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 +71 -10
  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 +44 -1
  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 +71 -10
  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
package/CLAUDE.md CHANGED
@@ -134,6 +134,7 @@ groove agents — list agents
134
134
  groove status — daemon status
135
135
  groove nuke — kill all + stop
136
136
  groove rotate <id> — context rotation (kill + respawn with handoff brief)
137
+ groove handoff <id> — succession: fresh agent interviews this one via InnerChat, then takes over
137
138
  groove team create <name> — create a new team
138
139
  groove team rename <id> — rename a team
139
140
  groove team list — list teams
@@ -167,6 +168,7 @@ All endpoints on `http://localhost:31415/api/`. CORS restricted to localhost. 50
167
168
  | Health | GET | /api/health, /api/status | Health check, daemon status |
168
169
  | Agents | GET/POST/DELETE | /api/agents, /api/agents/:id | Agent CRUD, detail, update |
169
170
  | Agent Actions | POST | /api/agents/:id/rotate, instruct, query | Rotation, instructions, queries |
171
+ | Succession | POST | /api/agents/:id/handoff, /api/handoff/:id/complete | Spawn successor + interview, retire predecessor |
170
172
  | Agent Routing | GET | /api/agents/:id/routing/recommend | Model routing recommendations |
171
173
  | Teams | GET/POST | /api/teams | List, create teams |
172
174
  | Teams | PATCH/DELETE | /api/teams/:id | Rename, delete teams |
@@ -12,6 +12,7 @@ import { agents } from '../src/commands/agents.js';
12
12
  import { status } from '../src/commands/status.js';
13
13
  import { nuke } from '../src/commands/nuke.js';
14
14
  import { rotate } from '../src/commands/rotate.js';
15
+ import { handoff } from '../src/commands/handoff.js';
15
16
  import { teamCreate, teamSave, teamLoad, teamList, teamDelete, teamRename, teamExport, teamImport } from '../src/commands/team.js';
16
17
  import { approvals, approve, reject } from '../src/commands/approve.js';
17
18
  import { providers, setKey } from '../src/commands/providers.js';
@@ -99,6 +100,13 @@ program
99
100
  .description('Rotate an agent (kill + respawn with fresh context)')
100
101
  .action(rotate);
101
102
 
103
+ program
104
+ .command('handoff <id>')
105
+ .description('Succession: spawn a fresh agent that interviews this one, then takes over')
106
+ .option('--name <name>', 'successor name (default: <agent>-successor)')
107
+ .option('--no-keep-name', 'successor keeps its own name instead of inheriting')
108
+ .action(handoff);
109
+
102
110
  // Teams
103
111
  const team = program.command('team').description('Manage agent teams');
104
112
  team.command('create <name>').description('Create a new team').action(teamCreate);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.214",
3
+ "version": "0.27.215",
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",
@@ -0,0 +1,22 @@
1
+ // GROOVE CLI — handoff command (succession to a fresh agent)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import chalk from 'chalk';
5
+ import { apiCall } from '../client.js';
6
+
7
+ export async function handoff(id, options = {}) {
8
+ try {
9
+ console.log(chalk.yellow(` Preparing succession dossier for ${id}...`));
10
+ const record = await apiCall('POST', `/api/agents/${id}/handoff`, {
11
+ name: options.name,
12
+ inheritName: options.keepName !== false,
13
+ });
14
+ console.log(chalk.green(` Succession started.`));
15
+ console.log(` ${chalk.bold(record.successorName)} is interviewing ${chalk.bold(record.predecessorName)} over InnerChat.`);
16
+ console.log(` When done it will retire the predecessor${record.inheritName ? ` and take over the name "${record.predecessorName}"` : ''}.`);
17
+ console.log(chalk.dim(` Handoff id: ${record.id}`));
18
+ } catch (err) {
19
+ console.error(chalk.red(' Handoff failed:'), err.message);
20
+ process.exit(1);
21
+ }
22
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.214",
3
+ "version": "0.27.215",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -4,7 +4,8 @@
4
4
  import { createServer as createHttpServer, request as httpProxyRequest } from 'http';
5
5
  import { createServer as createNetServer } from 'net';
6
6
  import { execFileSync } from 'child_process';
7
- import { resolve } from 'path';
7
+ import { resolve, dirname, basename } from 'path';
8
+ import { homedir } from 'os';
8
9
  import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, readdirSync, rmdirSync, rmSync, statSync } from 'fs';
9
10
  import express from 'express';
10
11
  import { WebSocketServer } from 'ws';
@@ -630,6 +631,22 @@ export class Daemon {
630
631
  // Read back actual port (critical for port 0 / dynamic allocation)
631
632
  this.port = this.server.address().port;
632
633
  writeFileSync(this.pidFile, String(process.pid));
634
+
635
+ // Anchor for restart tooling. grooveDir — the daemon's entire world of
636
+ // teams/agents/state — is derived from the cwd `groove start` ran in.
637
+ // Remote restart paths (tunnel autoStart, upgrades, the desktop shell)
638
+ // each guess a cwd; a wrong guess boots a fresh empty .groove that
639
+ // looks exactly like total data loss. Record where this world lives at
640
+ // a FIXED path so every restarter can re-enter it instead of guessing.
641
+ // Staging/override daemons (custom GROOVE_DIR) skip this — they must
642
+ // not hijack the anchor of the real daemon.
643
+ try {
644
+ if (basename(this.grooveDir) === '.groove') {
645
+ const anchorHome = resolve(homedir(), '.groove');
646
+ mkdirSync(anchorHome, { recursive: true });
647
+ writeFileSync(resolve(anchorHome, 'last-run-dir'), dirname(this.grooveDir));
648
+ }
649
+ } catch { /* non-fatal */ }
633
650
  // Write actual port and host so CLI can find us
634
651
  writeFileSync(resolve(this.grooveDir, 'daemon.port'), String(this.port));
635
652
  writeFileSync(resolve(this.grooveDir, 'daemon.host'), this.host);
@@ -1057,6 +1057,34 @@ export class Journalist {
1057
1057
  return brief;
1058
1058
  }
1059
1059
 
1060
+ /**
1061
+ * Succession dossier — the deep version of the handoff brief, for handing a
1062
+ * long-lived agent's role to a NEW agent. The regular brief is recency-biased
1063
+ * by design (last 3 chain entries, current session); after weeks of work the
1064
+ * older accumulated knowledge matters just as much. The dossier adds the full
1065
+ * handoff chain and the project decision log, and its reader is expected to
1066
+ * fill remaining gaps by interviewing the predecessor directly.
1067
+ */
1068
+ async generateSuccessionDossier(agent) {
1069
+ const brief = await this.generateHandoffBrief(agent, { reason: 'succession' });
1070
+
1071
+ // The whole chain (up to the retained 10 generations), not the last 3.
1072
+ const fullChain = this.daemon.memory?.getRecentHandoffMarkdown(
1073
+ agent.role, 10, 12000, agent.workingDir, agent.teamId,
1074
+ ) || '';
1075
+
1076
+ let decisions = '';
1077
+ try {
1078
+ const p = resolve(this.daemon.projectDir, 'GROOVE_DECISIONS.md');
1079
+ if (existsSync(p)) decisions = readFileSync(p, 'utf8').slice(0, 8000);
1080
+ } catch { /* optional */ }
1081
+
1082
+ const parts = [brief];
1083
+ if (fullChain) parts.push(`## Full Rotation History (oldest knowledge — read it, it is why things are the way they are)\n\n${fullChain}`);
1084
+ if (decisions) parts.push(`## Project Decision Log\n\n${decisions}`);
1085
+ return parts.join('\n\n');
1086
+ }
1087
+
1060
1088
  // --- Conversation Thread Extraction (for idle resume) ---
1061
1089
 
1062
1090
  /**
@@ -4,7 +4,9 @@
4
4
  import { EventEmitter } from 'events';
5
5
  import { readFileSync, writeFileSync, existsSync } from 'fs';
6
6
  import { resolve } from 'path';
7
+ import { randomUUID } from 'crypto';
7
8
  import { getProvider } from './providers/index.js';
9
+ import { deliverInstruction } from './deliver.js';
8
10
 
9
11
  const DEFAULT_THRESHOLD = 0.65; // For non-self-managing providers (was 0.75)
10
12
  const HARD_CEILING = 0.80; // Force rotate (was 0.85) — only for non-self-managing
@@ -40,6 +42,7 @@ export class Rotator extends EventEmitter {
40
42
  this.interval = null;
41
43
  this.rotationHistory = [];
42
44
  this.rotating = new Set();
45
+ this.handoffs = new Map(); // handoffId -> { predecessorId, successorId, status, ... }
43
46
  this.lastRotationTime = new Map(); // agentId -> timestamp of last rotation
44
47
  this._lastContextState = new Map(); // agentId -> { contextUsage, timestamp }
45
48
  this.compactionCounts = new Map(); // agentId -> number of natural compactions
@@ -638,6 +641,182 @@ export class Rotator extends EventEmitter {
638
641
  }
639
642
  }
640
643
 
644
+ // ── Succession handoff ─────────────────────────────────────────
645
+ //
646
+ // Rotation replaces an agent with a same-name clone and is recency-biased —
647
+ // right for context pressure, wrong for retiring a long-lived agent. A
648
+ // succession spawns the successor ALONGSIDE the still-running predecessor,
649
+ // seeded with a deep dossier, and mandates an InnerChat interview before the
650
+ // predecessor is retired. The interview is the point: after weeks of work
651
+ // the highest-bandwidth transfer is questions answered by the agent that
652
+ // still remembers, not any summary.
653
+
654
+ async successionHandoff(agentId, options = {}) {
655
+ const registry = this.daemon.registry;
656
+ const agent = registry.get(agentId);
657
+ if (!agent) throw new Error('Agent not found');
658
+ if (this.rotating.has(agentId)) throw new Error('Agent is mid-rotation — try again shortly');
659
+ for (const h of this.handoffs.values()) {
660
+ if (h.status === 'interviewing' && (h.predecessorId === agentId || h.successorId === agentId)) {
661
+ throw new Error('A handoff is already in progress for this agent');
662
+ }
663
+ }
664
+
665
+ const handoffId = randomUUID().slice(0, 12);
666
+ const dossier = await this.daemon.journalist.generateSuccessionDossier(agent);
667
+
668
+ // Persist to the chain up front — even a failed handoff leaves the record.
669
+ if (this.daemon.memory) {
670
+ this.daemon.memory.appendHandoffBrief(agent.role, {
671
+ timestamp: new Date().toISOString(),
672
+ agentId: agent.id,
673
+ newAgentId: null,
674
+ reason: 'succession',
675
+ oldTokens: agent.tokensUsed,
676
+ contextUsage: agent.contextUsage,
677
+ brief: dossier.slice(0, 6000),
678
+ }, agent.workingDir, agent.teamId);
679
+ }
680
+
681
+ const inheritName = options.inheritName !== false;
682
+ const successorName = (options.name && String(options.name).trim())
683
+ || `${agent.name}-successor`;
684
+ const port = this.daemon.port || 31415;
685
+
686
+ const prompt = [
687
+ `# SUCCESSION — you are taking over from ${agent.name}`,
688
+ ``,
689
+ `${agent.name} has been working this role for a long time and is being retired`,
690
+ `for context degradation. You are its successor. Its accumulated knowledge is`,
691
+ `below; its live memory is still available for a short window — use it.`,
692
+ ``,
693
+ dossier,
694
+ ``,
695
+ `## Step 1 — Interview your predecessor (do this FIRST)`,
696
+ ``,
697
+ `${agent.name} is still running and has been told to expect your questions.`,
698
+ `Ask via InnerChat (blocking; run in the foreground):`,
699
+ ``,
700
+ '```bash',
701
+ `curl -s http://localhost:${port}/api/innerchat/ask -X POST -H 'Content-Type: application/json' \\`,
702
+ ` -d '{"from":"${successorName}","to":"${agent.name}","message":"YOUR_QUESTION"}'`,
703
+ '```',
704
+ ``,
705
+ `Ask about: work currently in flight and exactly where it stands; fragile or`,
706
+ `dangerous areas; unwritten conventions the dossier missed; what it planned to`,
707
+ `do next and why. Several focused rounds beat one broad one. The exchange`,
708
+ `budget is shared — stop when answers stop teaching you.`,
709
+ ``,
710
+ `## Step 2 — Declare takeover`,
711
+ ``,
712
+ `When you have what you need (interview done or predecessor unresponsive):`,
713
+ ``,
714
+ '```bash',
715
+ `curl -s -X POST http://localhost:${port}/api/handoff/${handoffId}/complete`,
716
+ '```',
717
+ ``,
718
+ `This retires ${agent.name}${inheritName ? ` and renames you to "${agent.name}"` : ''}. Then continue the work — you own it now.`,
719
+ `Do NOT start new work before completing both steps.`,
720
+ ].join('\n');
721
+
722
+ const successor = await this.daemon.processes.spawn({
723
+ role: agent.role,
724
+ scope: agent.scope,
725
+ provider: options.provider || agent.provider,
726
+ model: options.model || agent.model,
727
+ prompt,
728
+ permission: agent.permission || 'full',
729
+ workingDir: agent.workingDir,
730
+ name: successorName,
731
+ teamId: agent.teamId,
732
+ });
733
+
734
+ // Survives a daemon restart: /complete can reconstruct from this metadata.
735
+ registry.update(successor.id, {
736
+ metadata: {
737
+ ...(successor.metadata || {}),
738
+ handoff: { handoffId, predecessorId: agent.id, predecessorName: agent.name, inheritName },
739
+ },
740
+ });
741
+
742
+ const record = {
743
+ id: handoffId,
744
+ predecessorId: agent.id,
745
+ predecessorName: agent.name,
746
+ successorId: successor.id,
747
+ successorName: successor.name || successorName,
748
+ inheritName,
749
+ status: 'interviewing',
750
+ startedAt: new Date().toISOString(),
751
+ };
752
+ this.handoffs.set(handoffId, record);
753
+
754
+ // Heads-up to the predecessor — best effort; InnerChat will wake it anyway.
755
+ deliverInstruction(this.daemon, agent.id,
756
+ `[Succession] ${record.successorName} is taking over your role. It will interview you `
757
+ + `over InnerChat shortly. Answer its questions completely and concretely — in-flight work, `
758
+ + `fragile areas, unwritten conventions, planned next steps. Do NOT start new work. `
759
+ + `Anything only you know must get said now; after the interview you will be retired.`,
760
+ { recordFeedback: false },
761
+ ).catch((err) => console.warn(` Rotator: could not brief predecessor ${agent.name}: ${err.message}`));
762
+
763
+ this.daemon.audit?.log('agent.handoff.start', { handoffId, predecessor: agent.id, successor: successor.id });
764
+ this.daemon.broadcast({ type: 'handoff:started', data: record });
765
+ if (this.daemon.timeline) {
766
+ this.daemon.timeline.recordEvent('handoff', {
767
+ agentId: successor.id, oldAgentId: agent.id,
768
+ agentName: record.successorName, role: agent.role, reason: 'succession',
769
+ });
770
+ }
771
+ return record;
772
+ }
773
+
774
+ async completeHandoff(handoffId) {
775
+ const registry = this.daemon.registry;
776
+ let record = this.handoffs.get(handoffId);
777
+ if (!record) {
778
+ // Daemon restarted mid-handoff — reconstruct from successor metadata.
779
+ const successor = registry.getAll().find((a) => a.metadata?.handoff?.handoffId === handoffId);
780
+ if (!successor) throw new Error('Handoff not found');
781
+ const h = successor.metadata.handoff;
782
+ record = {
783
+ id: handoffId, predecessorId: h.predecessorId, predecessorName: h.predecessorName,
784
+ successorId: successor.id, successorName: successor.name,
785
+ inheritName: h.inheritName, status: 'interviewing', startedAt: null,
786
+ };
787
+ }
788
+ if (record.status === 'complete') return record;
789
+
790
+ const successor = registry.get(record.successorId);
791
+ if (!successor) throw new Error('Successor no longer exists — cannot complete handoff');
792
+
793
+ const predecessor = registry.get(record.predecessorId);
794
+ if (predecessor) {
795
+ try { await this.daemon.processes.kill(predecessor.id); } catch { /* already dead */ }
796
+ registry.remove(predecessor.id);
797
+ this.daemon.locks?.release(predecessor.id);
798
+ }
799
+
800
+ if (record.inheritName && predecessor) {
801
+ registry.update(successor.id, { name: record.predecessorName });
802
+ record.successorName = record.predecessorName;
803
+ }
804
+ // Clear the marker — the successor is now just a normal agent.
805
+ const meta = { ...(successor.metadata || {}) };
806
+ delete meta.handoff;
807
+ registry.update(successor.id, { metadata: meta });
808
+
809
+ record.status = 'complete';
810
+ record.completedAt = new Date().toISOString();
811
+ this.handoffs.set(handoffId, record);
812
+
813
+ this.daemon.audit?.log('agent.handoff.complete', {
814
+ handoffId, predecessor: record.predecessorId, successor: record.successorId,
815
+ });
816
+ this.daemon.broadcast({ type: 'handoff:completed', data: record });
817
+ return record;
818
+ }
819
+
641
820
  _schedulePostRotationCheck(newAgentId, oldQualityScore, record) {
642
821
  // Wait for the new agent to accumulate MIN_EVENTS classifier events,
643
822
  // checking every 15s for up to 5 minutes (20 checks)
@@ -246,6 +246,31 @@ export function registerAgentRoutes(app, daemon) {
246
246
  }
247
247
  });
248
248
 
249
+ // Succession handoff — spawn a fresh successor alongside a degrading agent,
250
+ // seeded with a deep dossier; it interviews the predecessor over InnerChat,
251
+ // then calls /api/handoff/:id/complete to retire it and take over.
252
+ app.post('/api/agents/:id/handoff', async (req, res) => {
253
+ try {
254
+ const { name, inheritName, model, provider } = req.body || {};
255
+ const record = await daemon.rotator.successionHandoff(req.params.id, {
256
+ name, inheritName, model, provider,
257
+ });
258
+ res.json(record);
259
+ } catch (err) {
260
+ res.status(400).json({ error: err.message });
261
+ }
262
+ });
263
+
264
+ // Called by the successor itself when it has finished interviewing.
265
+ app.post('/api/handoff/:handoffId/complete', async (req, res) => {
266
+ try {
267
+ const record = await daemon.rotator.completeHandoff(req.params.handoffId);
268
+ res.json(record);
269
+ } catch (err) {
270
+ res.status(400).json({ error: err.message });
271
+ }
272
+ });
273
+
249
274
  // Instruct an agent — send message to agent loop, resume session, or rotate
250
275
  // Agent loop = direct message to running loop (local models)
251
276
  // Resume = zero cold-start (uses --resume SESSION_ID)
@@ -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;
@@ -60,6 +68,49 @@ function isPermissionError(output) {
60
68
 
61
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.';
62
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
+
63
114
  export class TunnelManager {
64
115
  constructor(daemon) {
65
116
  this.daemon = daemon;
@@ -302,7 +353,7 @@ export class TunnelManager {
302
353
  const config = this.saved.get(id);
303
354
  if (!config) throw new Error(`Remote ${id} not found`);
304
355
 
305
- const target = `${config.user}@${config.host}`;
356
+ const target = `${config.user}@${await resolveBestAddress(config.host, config.port || 22)}`;
306
357
  const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
307
358
 
308
359
  try {
@@ -418,18 +469,28 @@ export class TunnelManager {
418
469
  } else {
419
470
  localPort = await this._findAvailablePort();
420
471
  }
421
- 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}`;
422
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}`] : [];
423
483
 
424
484
  const sshArgs = [
425
485
  '-N',
426
486
  '-L', `127.0.0.1:${localPort}:localhost:${REMOTE_PORT}`,
427
487
  '-p', String(config.port || 22),
428
- '-o', 'ServerAliveInterval=30',
429
- '-o', 'ServerAliveCountMax=3',
488
+ '-o', 'ServerAliveInterval=15',
489
+ '-o', 'ServerAliveCountMax=4',
430
490
  '-o', 'ExitOnForwardFailure=yes',
431
491
  '-o', 'StrictHostKeyChecking=accept-new',
432
492
  '-o', 'GSSAPIAuthentication=no',
493
+ ...aliasArgs,
433
494
  ...keyArgs,
434
495
  target,
435
496
  ];
@@ -635,7 +696,7 @@ export class TunnelManager {
635
696
  }
636
697
 
637
698
  // Restart remote daemon — fire and forget the SSH, verify through the tunnel
638
- const cdPrefix = config.projectDir ? `cd "${config.projectDir}" && ` : '';
699
+ const cdPrefix = config.projectDir ? `cd "${config.projectDir}" && ` : ANCHOR_CD;
639
700
  try {
640
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`)], {
641
702
  encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'],
@@ -742,7 +803,7 @@ export class TunnelManager {
742
803
  const config = this.saved.get(id);
743
804
  if (!config) throw new Error(`Remote ${id} not found`);
744
805
 
745
- const target = `${config.user}@${config.host}`;
806
+ const target = `${config.user}@${await resolveBestAddress(config.host, config.port || 22)}`;
746
807
  const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
747
808
 
748
809
  // Build the remote bash command:
@@ -752,7 +813,7 @@ export class TunnelManager {
752
813
  // 4. explicitly POST /api/project-dir so the daemon's projectDir matches
753
814
  // config.projectDir even if the backgrounded cwd didn't stick (this
754
815
  // also updates the editor root used for /api/browse, /api/files/*)
755
- const cdPrefix = config.projectDir ? `cd "${config.projectDir}" && ` : '';
816
+ const cdPrefix = config.projectDir ? `cd "${config.projectDir}" && ` : ANCHOR_CD;
756
817
  const setProjectDir = config.projectDir
757
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; `
758
819
  : '';
@@ -874,7 +935,7 @@ export class TunnelManager {
874
935
  try {
875
936
  const result = execFileSync('ssh', [
876
937
  ...sshBase,
877
- 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)`),
878
939
  ], {
879
940
  encoding: 'utf8',
880
941
  timeout: 45000,
@@ -944,7 +1005,7 @@ export class TunnelManager {
944
1005
  }).trim();
945
1006
  const installedVer = verOutput.replace(/[^0-9.]/g, '') || verOutput.trim();
946
1007
 
947
- 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`;
948
1009
  const restartResult = execFileSync('ssh', [...sshBase, sshCmd(restartCmd)], {
949
1010
  encoding: 'utf8',
950
1011
  timeout: 60000,
@@ -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
  });