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.
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/scripts/repair-tokens.mjs +78 -0
- package/node_modules/@groove-dev/daemon/src/api.js +1 -1
- package/node_modules/@groove-dev/daemon/src/deliver.js +130 -0
- package/node_modules/@groove-dev/daemon/src/index.js +8 -3
- package/node_modules/@groove-dev/daemon/src/innerchat.js +213 -62
- package/node_modules/@groove-dev/daemon/src/journalist.js +15 -8
- package/node_modules/@groove-dev/daemon/src/process.js +59 -34
- package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +9 -1
- package/node_modules/@groove-dev/daemon/src/registry.js +5 -1
- package/node_modules/@groove-dev/daemon/src/rename.js +72 -0
- package/node_modules/@groove-dev/daemon/src/rotator.js +62 -5
- package/node_modules/@groove-dev/daemon/src/routes/agents.js +22 -100
- package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +12 -9
- package/node_modules/@groove-dev/daemon/src/teams.js +7 -1
- package/node_modules/@groove-dev/daemon/src/tokentracker.js +46 -6
- package/node_modules/@groove-dev/daemon/test/claude-code-provider.test.js +52 -0
- package/node_modules/@groove-dev/daemon/test/innerchat.test.js +197 -111
- package/node_modules/@groove-dev/daemon/test/journalist.test.js +4 -2
- package/node_modules/@groove-dev/daemon/test/rename.test.js +108 -0
- package/node_modules/@groove-dev/daemon/test/rotator.test.js +131 -0
- package/node_modules/@groove-dev/daemon/test/tokentracker.test.js +4 -0
- package/node_modules/@groove-dev/gui/dist/assets/{index-DTFtRtkx.css → index-CiOy7wVS.css} +1 -1
- package/node_modules/@groove-dev/gui/dist/assets/{index-CTer01Vg.js → index-DPjGBQ5X.js} +225 -225
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +3 -68
- package/node_modules/@groove-dev/gui/src/components/agents/innerchat-relay.jsx +145 -0
- package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +3 -1
- package/node_modules/@groove-dev/gui/src/components/fleet/fleet-pane.jsx +15 -1
- package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +33 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +29 -44
- package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +27 -4
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/scripts/repair-tokens.mjs +78 -0
- package/packages/daemon/src/api.js +1 -1
- package/packages/daemon/src/deliver.js +130 -0
- package/packages/daemon/src/index.js +8 -3
- package/packages/daemon/src/innerchat.js +213 -62
- package/packages/daemon/src/journalist.js +15 -8
- package/packages/daemon/src/process.js +59 -34
- package/packages/daemon/src/providers/claude-code.js +9 -1
- package/packages/daemon/src/registry.js +5 -1
- package/packages/daemon/src/rename.js +72 -0
- package/packages/daemon/src/rotator.js +62 -5
- package/packages/daemon/src/routes/agents.js +22 -100
- package/packages/daemon/src/routes/innerchat.js +12 -9
- package/packages/daemon/src/teams.js +7 -1
- package/packages/daemon/src/tokentracker.js +46 -6
- package/packages/gui/dist/assets/{index-DTFtRtkx.css → index-CiOy7wVS.css} +1 -1
- package/packages/gui/dist/assets/{index-CTer01Vg.js → index-DPjGBQ5X.js} +225 -225
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/agents/agent-panel.jsx +3 -68
- package/packages/gui/src/components/agents/innerchat-relay.jsx +145 -0
- package/packages/gui/src/components/chat/chat-messages.jsx +3 -1
- package/packages/gui/src/components/fleet/fleet-pane.jsx +15 -1
- package/packages/gui/src/components/fleet/fleet-sidebar.jsx +33 -1
- package/packages/gui/src/stores/groove.js +29 -44
- 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);
|