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.
- package/CLAUDE.md +2 -0
- package/node_modules/@groove-dev/cli/bin/groove.js +8 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/cli/src/commands/handoff.js +22 -0
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/index.js +18 -1
- package/node_modules/@groove-dev/daemon/src/journalist.js +28 -0
- package/node_modules/@groove-dev/daemon/src/rotator.js +179 -0
- package/node_modules/@groove-dev/daemon/src/routes/agents.js +25 -0
- package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +71 -10
- package/node_modules/@groove-dev/daemon/test/rotator.test.js +86 -0
- package/node_modules/@groove-dev/daemon/test/tunnel-manager.test.js +44 -1
- package/node_modules/@groove-dev/gui/dist/assets/{index-DPsim83z.css → index-B82kMi13.css} +1 -1
- package/node_modules/@groove-dev/gui/dist/assets/{index-BP4oE2UL.js → index-CLfmxBx1.js} +1 -1
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/package.json +1 -1
- package/packages/cli/bin/groove.js +8 -0
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/commands/handoff.js +22 -0
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/index.js +18 -1
- package/packages/daemon/src/journalist.js +28 -0
- package/packages/daemon/src/rotator.js +179 -0
- package/packages/daemon/src/routes/agents.js +25 -0
- package/packages/daemon/src/tunnel-manager.js +71 -10
- package/packages/gui/dist/assets/{index-DPsim83z.css → index-B82kMi13.css} +1 -1
- package/packages/gui/dist/assets/{index-BP4oE2UL.js → index-CLfmxBx1.js} +1 -1
- package/packages/gui/dist/index.html +2 -2
- 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);
|
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
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=
|
|
429
|
-
'-o', 'ServerAliveCountMax=
|
|
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(
|
|
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
|
});
|