groove-dev 0.27.181 → 0.27.183

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 (63) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/scripts/repair-tokens.mjs +78 -0
  4. package/node_modules/@groove-dev/daemon/src/api.js +1 -1
  5. package/node_modules/@groove-dev/daemon/src/deliver.js +130 -0
  6. package/node_modules/@groove-dev/daemon/src/index.js +8 -3
  7. package/node_modules/@groove-dev/daemon/src/innerchat.js +213 -62
  8. package/node_modules/@groove-dev/daemon/src/journalist.js +15 -8
  9. package/node_modules/@groove-dev/daemon/src/process.js +59 -34
  10. package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +9 -1
  11. package/node_modules/@groove-dev/daemon/src/registry.js +5 -1
  12. package/node_modules/@groove-dev/daemon/src/rename.js +72 -0
  13. package/node_modules/@groove-dev/daemon/src/rotator.js +62 -5
  14. package/node_modules/@groove-dev/daemon/src/routes/agents.js +22 -100
  15. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +12 -9
  16. package/node_modules/@groove-dev/daemon/src/teams.js +7 -1
  17. package/node_modules/@groove-dev/daemon/src/tokentracker.js +46 -6
  18. package/node_modules/@groove-dev/daemon/test/claude-code-provider.test.js +52 -0
  19. package/node_modules/@groove-dev/daemon/test/innerchat.test.js +197 -111
  20. package/node_modules/@groove-dev/daemon/test/journalist.test.js +4 -2
  21. package/node_modules/@groove-dev/daemon/test/rename.test.js +108 -0
  22. package/node_modules/@groove-dev/daemon/test/rotator.test.js +131 -0
  23. package/node_modules/@groove-dev/daemon/test/tokentracker.test.js +4 -0
  24. package/node_modules/@groove-dev/gui/dist/assets/{index-DTFtRtkx.css → index-CiOy7wVS.css} +1 -1
  25. package/node_modules/@groove-dev/gui/dist/assets/{index-CTer01Vg.js → index-DPjGBQ5X.js} +225 -225
  26. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  27. package/node_modules/@groove-dev/gui/package.json +1 -1
  28. package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +3 -68
  29. package/node_modules/@groove-dev/gui/src/components/agents/innerchat-relay.jsx +145 -0
  30. package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +3 -1
  31. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-pane.jsx +15 -1
  32. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +33 -1
  33. package/node_modules/@groove-dev/gui/src/stores/groove.js +29 -44
  34. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +27 -4
  35. package/package.json +1 -1
  36. package/packages/cli/package.json +1 -1
  37. package/packages/daemon/package.json +1 -1
  38. package/packages/daemon/scripts/repair-tokens.mjs +78 -0
  39. package/packages/daemon/src/api.js +1 -1
  40. package/packages/daemon/src/deliver.js +130 -0
  41. package/packages/daemon/src/index.js +8 -3
  42. package/packages/daemon/src/innerchat.js +213 -62
  43. package/packages/daemon/src/journalist.js +15 -8
  44. package/packages/daemon/src/process.js +59 -34
  45. package/packages/daemon/src/providers/claude-code.js +9 -1
  46. package/packages/daemon/src/registry.js +5 -1
  47. package/packages/daemon/src/rename.js +72 -0
  48. package/packages/daemon/src/rotator.js +62 -5
  49. package/packages/daemon/src/routes/agents.js +22 -100
  50. package/packages/daemon/src/routes/innerchat.js +12 -9
  51. package/packages/daemon/src/teams.js +7 -1
  52. package/packages/daemon/src/tokentracker.js +46 -6
  53. package/packages/gui/dist/assets/{index-DTFtRtkx.css → index-CiOy7wVS.css} +1 -1
  54. package/packages/gui/dist/assets/{index-CTer01Vg.js → index-DPjGBQ5X.js} +225 -225
  55. package/packages/gui/dist/index.html +2 -2
  56. package/packages/gui/package.json +1 -1
  57. package/packages/gui/src/components/agents/agent-panel.jsx +3 -68
  58. package/packages/gui/src/components/agents/innerchat-relay.jsx +145 -0
  59. package/packages/gui/src/components/chat/chat-messages.jsx +3 -1
  60. package/packages/gui/src/components/fleet/fleet-pane.jsx +15 -1
  61. package/packages/gui/src/components/fleet/fleet-sidebar.jsx +33 -1
  62. package/packages/gui/src/stores/groove.js +29 -44
  63. package/packages/gui/src/stores/slices/agents-slice.js +27 -4
@@ -0,0 +1,108 @@
1
+ // GROOVE — Agent Rename Migration Tests
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { describe, it, beforeEach, afterEach } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from 'fs';
7
+ import { resolve } from 'path';
8
+ import { tmpdir } from 'os';
9
+ import { renameAgent } from '../src/rename.js';
10
+ import { Registry } from '../src/registry.js';
11
+
12
+ describe('renameAgent', () => {
13
+ let root, daemon, agent;
14
+
15
+ beforeEach(() => {
16
+ root = mkdtempSync(resolve(tmpdir(), 'groove-rename-'));
17
+ const grooveDir = resolve(root, '.groove');
18
+ mkdirSync(resolve(grooveDir, 'logs'), { recursive: true });
19
+ mkdirSync(resolve(grooveDir, 'personalities'), { recursive: true });
20
+ mkdirSync(resolve(root, 'agent-files'), { recursive: true });
21
+
22
+ const registry = new Registry({ save() {}, load() { return null; } });
23
+ daemon = {
24
+ registry,
25
+ grooveDir,
26
+ projectDir: root,
27
+ audit: { log() {} },
28
+ };
29
+ agent = registry.add({ role: 'fullstack', name: 'fullstack-1' });
30
+
31
+ writeFileSync(resolve(grooveDir, 'logs', 'fullstack-1.log'), 'session history');
32
+ writeFileSync(resolve(grooveDir, 'personalities', 'fullstack-1.md'), 'be terse');
33
+ mkdirSync(resolve(root, 'agent-files', 'fullstack-1'));
34
+ writeFileSync(resolve(root, 'agent-files', 'fullstack-1', 'notes.md'), 'notes');
35
+ });
36
+
37
+ afterEach(() => rmSync(root, { recursive: true, force: true }));
38
+
39
+ const p = {
40
+ log: (n) => resolve(root, '.groove', 'logs', `${n}.log`),
41
+ personality: (n) => resolve(root, '.groove', 'personalities', `${n}.md`),
42
+ files: (n) => resolve(root, 'agent-files', n),
43
+ };
44
+
45
+ it('migrates the log, personality and agent-files', () => {
46
+ renameAgent(daemon, agent.id, 'senior-dev');
47
+
48
+ assert.equal(daemon.registry.get(agent.id).name, 'senior-dev');
49
+
50
+ assert.ok(!existsSync(p.log('fullstack-1')), 'old log removed');
51
+ assert.equal(readFileSync(p.log('senior-dev'), 'utf8'), 'session history');
52
+
53
+ assert.ok(!existsSync(p.personality('fullstack-1')));
54
+ assert.equal(readFileSync(p.personality('senior-dev'), 'utf8'), 'be terse');
55
+
56
+ assert.ok(!existsSync(p.files('fullstack-1')));
57
+ assert.equal(readFileSync(resolve(p.files('senior-dev'), 'notes.md'), 'utf8'), 'notes');
58
+ });
59
+
60
+ it('rejects a name already taken by another agent', () => {
61
+ daemon.registry.add({ role: 'fullstack', name: 'fullstack-2' });
62
+ assert.throws(() => renameAgent(daemon, agent.id, 'fullstack-2'), /already exists/);
63
+
64
+ // Nothing moved — the original log must survive a rejected rename.
65
+ assert.equal(daemon.registry.get(agent.id).name, 'fullstack-1');
66
+ assert.equal(readFileSync(p.log('fullstack-1'), 'utf8'), 'session history');
67
+ });
68
+
69
+ it('rejects an empty name and no-ops an unchanged one', () => {
70
+ assert.throws(() => renameAgent(daemon, agent.id, ' '), /name is required/);
71
+ const same = renameAgent(daemon, agent.id, 'fullstack-1');
72
+ assert.equal(same.name, 'fullstack-1');
73
+ assert.equal(readFileSync(p.log('fullstack-1'), 'utf8'), 'session history');
74
+ });
75
+
76
+ it('throws for an unknown agent', () => {
77
+ assert.throws(() => renameAgent(daemon, 'nope', 'whatever'), /Agent not found/);
78
+ });
79
+
80
+ it('tolerates missing artifacts', () => {
81
+ rmSync(p.personality('fullstack-1'));
82
+ rmSync(p.files('fullstack-1'), { recursive: true });
83
+
84
+ renameAgent(daemon, agent.id, 'solo');
85
+ assert.equal(readFileSync(p.log('solo'), 'utf8'), 'session history');
86
+ assert.ok(!existsSync(p.personality('solo')));
87
+ });
88
+
89
+ it('rejects names that would escape their directory', () => {
90
+ // These become path segments (agent-files/<name>), so a separator or a
91
+ // dot-segment would write outside the intended directory.
92
+ for (const bad of ['front end/dev', '../escape', '..', 'a/../../b', 'has space']) {
93
+ assert.throws(() => renameAgent(daemon, agent.id, bad), /name may only contain|name must be/);
94
+ }
95
+ assert.equal(daemon.registry.get(agent.id).name, 'fullstack-1');
96
+ assert.ok(existsSync(p.log('fullstack-1')));
97
+ });
98
+
99
+ it('rejects an over-long name', () => {
100
+ assert.throws(() => renameAgent(daemon, agent.id, 'x'.repeat(65)), /64 characters/);
101
+ });
102
+
103
+ it('blocks a bare registry.update from renaming without migration', () => {
104
+ daemon.registry.update(agent.id, { name: 'sneaky' });
105
+ assert.equal(daemon.registry.get(agent.id).name, 'fullstack-1');
106
+ assert.ok(existsSync(p.log('fullstack-1')), 'log was never orphaned');
107
+ });
108
+ });
@@ -452,4 +452,135 @@ describe('Rotator', () => {
452
452
  assert.equal(appendArgs.teamId, 'team-alpha');
453
453
  assert.equal(appendArgs.role, 'backend');
454
454
  });
455
+
456
+ it('should rotate idle Claude Code agent above the replay ceiling', async () => {
457
+ const agent = {
458
+ id: 'rc1', name: 'claude-chat', role: 'backend', status: 'running',
459
+ provider: 'claude-code', scope: [], model: null,
460
+ tokensUsed: 100_000, contextUsage: 0.55, workingDir: '/tmp',
461
+ lastActivity: new Date(Date.now() - 5 * 60_000).toISOString(), // idle 5 min
462
+ spawnedAt: new Date(Date.now() - 600_000).toISOString(),
463
+ };
464
+ mockDaemon.registry.agents = [agent];
465
+
466
+ await rotator.check();
467
+
468
+ const history = rotator.getHistory();
469
+ assert.equal(history.length, 1);
470
+ assert.equal(history[0].reason, 'replay_ceiling');
471
+ });
472
+
473
+ it('should NOT replay-ceiling rotate below the ceiling or during cooldown', async () => {
474
+ const agent = {
475
+ id: 'rc2', name: 'claude-chat-2', role: 'backend', status: 'running',
476
+ provider: 'claude-code', scope: [], model: null,
477
+ tokensUsed: 100_000, contextUsage: 0.45, workingDir: '/tmp',
478
+ lastActivity: new Date(Date.now() - 5 * 60_000).toISOString(),
479
+ spawnedAt: new Date(Date.now() - 600_000).toISOString(),
480
+ };
481
+ mockDaemon.registry.agents = [agent];
482
+
483
+ // Below ceiling — no rotation
484
+ await rotator.check();
485
+ assert.equal(rotator.getHistory().length, 0);
486
+
487
+ // Above ceiling but on cooldown — still no rotation
488
+ agent.contextUsage = 0.60;
489
+ rotator.lastRotationTime.set('rc2', Date.now() - 60_000);
490
+ await rotator.check();
491
+ assert.equal(rotator.getHistory().length, 0);
492
+ });
493
+
494
+ it('should block auto-rotation of a chat agent when no conversation thread is extractable', async () => {
495
+ const agent = {
496
+ id: 'rc3', name: 'claude-chat-3', role: 'backend', status: 'running',
497
+ provider: 'claude-code', scope: [], model: null,
498
+ tokensUsed: 100_000, contextUsage: 0.70, workingDir: '/tmp',
499
+ lastActivity: new Date(Date.now() - 5 * 60_000).toISOString(),
500
+ spawnedAt: new Date(Date.now() - 600_000).toISOString(),
501
+ };
502
+ mockDaemon.registry.agents = [agent];
503
+
504
+ // Agent has chat history, but journalist can't extract the thread
505
+ rotator.recordUserMessage('rc3');
506
+ mockDaemon.journalist.buildConversationResumePrompt = () => null;
507
+
508
+ const result = await rotator.rotate('rc3', { reason: 'replay_ceiling' });
509
+
510
+ assert.equal(result, null);
511
+ assert.equal(rotator.getHistory().length, 0);
512
+ const blocked = broadcasts.find((b) => b.type === 'rotation:blocked');
513
+ assert.ok(blocked, 'should broadcast rotation:blocked');
514
+ assert.equal(blocked.reason, 'no_conversation_thread');
515
+ });
516
+
517
+ it('should not let Instructions boilerplate defeat the low-confidence handoff guard', async () => {
518
+ const agent = {
519
+ id: 'rc4', name: 'claude-chat-4', role: 'backend', status: 'running',
520
+ provider: 'claude-code', scope: [], model: null,
521
+ tokensUsed: 100_000, contextUsage: 0.70, workingDir: '/tmp',
522
+ lastActivity: new Date(Date.now() - 5 * 60_000).toISOString(),
523
+ spawnedAt: new Date(Date.now() - 600_000).toISOString(),
524
+ };
525
+ mockDaemon.registry.agents = [agent];
526
+
527
+ // A content-free brief padded with the static Instructions block —
528
+ // this is exactly what generateHandoffBrief emits when logs are empty
529
+ mockDaemon.journalist.generateHandoffBrief = async () => [
530
+ '# Handoff Brief',
531
+ '- role: backend',
532
+ '## Instructions',
533
+ '',
534
+ 'Continue and finish the in-progress task — deliver the output. Stay focused on that specific task only.',
535
+ '- Do NOT explore the codebase looking for other things to fix or improve',
536
+ '- Do NOT start new work outside the original task scope',
537
+ '- Do NOT act on TODO comments, code quality issues, or improvements you notice in passing',
538
+ '- If the task is already complete, report what was accomplished and STOP — await new instructions from the user',
539
+ ].join('\n');
540
+
541
+ const result = await rotator.rotate('rc4', { reason: 'replay_ceiling' });
542
+
543
+ assert.equal(result, null);
544
+ assert.equal(rotator.getHistory().length, 0);
545
+ const blocked = broadcasts.find((b) => b.type === 'rotation:blocked');
546
+ assert.ok(blocked, 'should broadcast rotation:blocked');
547
+ assert.equal(blocked.reason, 'low_confidence_handoff');
548
+ });
549
+
550
+ it('should force-rotate any provider on velocity ceiling', async () => {
551
+ const agent = {
552
+ id: 'v1', name: 'claude-runaway', role: 'backend', status: 'running',
553
+ provider: 'claude-code', scope: [], model: null,
554
+ tokensUsed: 400_000, contextUsage: 0.30, workingDir: '/tmp',
555
+ lastActivity: new Date(Date.now() - 5_000).toISOString(), // active
556
+ spawnedAt: new Date(Date.now() - 600_000).toISOString(),
557
+ };
558
+ mockDaemon.registry.agents = [agent];
559
+ mockDaemon.tokens.getVelocity = () => 300_000; // above 250K/5min default
560
+
561
+ // Even on cooldown — velocity is a safety override
562
+ rotator.lastRotationTime.set('v1', Date.now() - 60_000);
563
+
564
+ await rotator.check();
565
+
566
+ const history = rotator.getHistory();
567
+ assert.equal(history.length, 1);
568
+ assert.equal(history[0].reason, 'velocity_ceiling');
569
+ });
570
+
571
+ it('should not velocity-rotate under the ceiling', async () => {
572
+ const agent = {
573
+ id: 'v2', name: 'claude-normal', role: 'backend', status: 'running',
574
+ provider: 'claude-code', scope: [], model: null,
575
+ tokensUsed: 50_000, contextUsage: 0.30, workingDir: '/tmp',
576
+ lastActivity: new Date(Date.now() - 5_000).toISOString(),
577
+ spawnedAt: new Date(Date.now() - 600_000).toISOString(),
578
+ };
579
+ mockDaemon.registry.agents = [agent];
580
+ mockDaemon.tokens.getVelocity = () => 80_000;
581
+
582
+ await rotator.check();
583
+
584
+ assert.equal(rotator.getHistory().length, 0);
585
+ });
455
586
  });
@@ -61,6 +61,10 @@ describe('TokenTracker', () => {
61
61
  tracker.record('agent-1', 100);
62
62
  tracker.record('agent-2', 200);
63
63
 
64
+ // record() debounces disk writes (hot path); flush forces the pending save,
65
+ // mirroring what the daemon does on shutdown.
66
+ tracker.flush();
67
+
64
68
  const tracker2 = new TokenTracker(tmpDir);
65
69
  assert.equal(tracker2.getAgent('agent-1').total, 100);
66
70
  assert.equal(tracker2.getAgent('agent-2').total, 200);