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
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.213",
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.213",
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)