groove-dev 0.8.0

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 (84) hide show
  1. package/CLAUDE.md +197 -0
  2. package/LICENSE +40 -0
  3. package/README.md +115 -0
  4. package/docs/GUI_DESIGN_SPEC.md +402 -0
  5. package/favicon.png +0 -0
  6. package/groove-logo-short.png +0 -0
  7. package/groove-logo.png +0 -0
  8. package/package.json +70 -0
  9. package/packages/cli/bin/groove.js +98 -0
  10. package/packages/cli/package.json +15 -0
  11. package/packages/cli/src/client.js +25 -0
  12. package/packages/cli/src/commands/agents.js +38 -0
  13. package/packages/cli/src/commands/approve.js +50 -0
  14. package/packages/cli/src/commands/config.js +35 -0
  15. package/packages/cli/src/commands/kill.js +15 -0
  16. package/packages/cli/src/commands/nuke.js +19 -0
  17. package/packages/cli/src/commands/providers.js +40 -0
  18. package/packages/cli/src/commands/rotate.js +16 -0
  19. package/packages/cli/src/commands/spawn.js +91 -0
  20. package/packages/cli/src/commands/start.js +31 -0
  21. package/packages/cli/src/commands/status.js +38 -0
  22. package/packages/cli/src/commands/stop.js +15 -0
  23. package/packages/cli/src/commands/team.js +77 -0
  24. package/packages/daemon/package.json +18 -0
  25. package/packages/daemon/src/adaptive.js +237 -0
  26. package/packages/daemon/src/api.js +533 -0
  27. package/packages/daemon/src/classifier.js +126 -0
  28. package/packages/daemon/src/credentials.js +121 -0
  29. package/packages/daemon/src/firstrun.js +93 -0
  30. package/packages/daemon/src/index.js +208 -0
  31. package/packages/daemon/src/introducer.js +238 -0
  32. package/packages/daemon/src/journalist.js +600 -0
  33. package/packages/daemon/src/lockmanager.js +58 -0
  34. package/packages/daemon/src/pm.js +108 -0
  35. package/packages/daemon/src/process.js +361 -0
  36. package/packages/daemon/src/providers/aider.js +72 -0
  37. package/packages/daemon/src/providers/base.js +38 -0
  38. package/packages/daemon/src/providers/claude-code.js +167 -0
  39. package/packages/daemon/src/providers/codex.js +68 -0
  40. package/packages/daemon/src/providers/gemini.js +62 -0
  41. package/packages/daemon/src/providers/index.js +38 -0
  42. package/packages/daemon/src/providers/ollama.js +94 -0
  43. package/packages/daemon/src/registry.js +89 -0
  44. package/packages/daemon/src/rotator.js +185 -0
  45. package/packages/daemon/src/router.js +132 -0
  46. package/packages/daemon/src/state.js +34 -0
  47. package/packages/daemon/src/supervisor.js +178 -0
  48. package/packages/daemon/src/teams.js +203 -0
  49. package/packages/daemon/src/terminal/base.js +27 -0
  50. package/packages/daemon/src/terminal/generic.js +27 -0
  51. package/packages/daemon/src/terminal/tmux.js +64 -0
  52. package/packages/daemon/src/tokentracker.js +124 -0
  53. package/packages/daemon/src/validate.js +122 -0
  54. package/packages/daemon/templates/api-builder.json +18 -0
  55. package/packages/daemon/templates/fullstack.json +18 -0
  56. package/packages/daemon/templates/monorepo.json +24 -0
  57. package/packages/gui/dist/assets/index-BO95Rm1F.js +73 -0
  58. package/packages/gui/dist/assets/index-CPzm9ZE9.css +1 -0
  59. package/packages/gui/dist/favicon.png +0 -0
  60. package/packages/gui/dist/groove-logo-short.png +0 -0
  61. package/packages/gui/dist/groove-logo.png +0 -0
  62. package/packages/gui/dist/index.html +13 -0
  63. package/packages/gui/index.html +12 -0
  64. package/packages/gui/package.json +22 -0
  65. package/packages/gui/public/favicon.png +0 -0
  66. package/packages/gui/public/groove-logo-short.png +0 -0
  67. package/packages/gui/public/groove-logo.png +0 -0
  68. package/packages/gui/src/App.jsx +215 -0
  69. package/packages/gui/src/components/AgentActions.jsx +347 -0
  70. package/packages/gui/src/components/AgentChat.jsx +479 -0
  71. package/packages/gui/src/components/AgentNode.jsx +117 -0
  72. package/packages/gui/src/components/AgentPanel.jsx +115 -0
  73. package/packages/gui/src/components/AgentStats.jsx +333 -0
  74. package/packages/gui/src/components/ApprovalQueue.jsx +156 -0
  75. package/packages/gui/src/components/EmptyState.jsx +100 -0
  76. package/packages/gui/src/components/SpawnPanel.jsx +515 -0
  77. package/packages/gui/src/components/TeamSelector.jsx +162 -0
  78. package/packages/gui/src/main.jsx +9 -0
  79. package/packages/gui/src/stores/groove.js +247 -0
  80. package/packages/gui/src/theme.css +67 -0
  81. package/packages/gui/src/views/AgentTree.jsx +148 -0
  82. package/packages/gui/src/views/CommandCenter.jsx +620 -0
  83. package/packages/gui/src/views/JournalistFeed.jsx +149 -0
  84. package/packages/gui/vite.config.js +19 -0
@@ -0,0 +1,132 @@
1
+ // GROOVE — Adaptive Model Router
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
5
+ import { resolve } from 'path';
6
+ import { getProvider } from './providers/index.js';
7
+
8
+ // Routing modes per agent
9
+ const MODES = {
10
+ FIXED: 'fixed', // User-selected model, no changes
11
+ AUTO: 'auto', // GROOVE picks the model based on task tier
12
+ AUTO_FLOOR: 'auto-floor', // Auto, but never below a floor model
13
+ };
14
+
15
+ // Role-based tier hints for new agents with no classifier data yet
16
+ const ROLE_HINTS = {
17
+ planner: 'heavy', // Planning is foundational — needs deep reasoning
18
+ docs: 'light',
19
+ testing: 'medium',
20
+ backend: 'medium',
21
+ frontend: 'medium',
22
+ devops: 'medium',
23
+ fullstack: 'heavy',
24
+ };
25
+
26
+ export class ModelRouter {
27
+ constructor(daemon) {
28
+ this.daemon = daemon;
29
+ this.profilesPath = resolve(daemon.grooveDir, 'routing-profiles.json');
30
+ this.agentModes = {}; // agentId -> { mode, fixedModel, floorModel }
31
+ this.costLog = []; // [{ agentId, model, tokens, tier, timestamp }]
32
+ this.profiles = {};
33
+ this.loadProfiles();
34
+ }
35
+
36
+ loadProfiles() {
37
+ if (existsSync(this.profilesPath)) {
38
+ try {
39
+ this.profiles = JSON.parse(readFileSync(this.profilesPath, 'utf8'));
40
+ } catch {
41
+ this.profiles = {};
42
+ }
43
+ }
44
+ }
45
+
46
+ saveProfiles() {
47
+ writeFileSync(this.profilesPath, JSON.stringify(this.profiles, null, 2));
48
+ }
49
+
50
+ // Set routing mode for an agent
51
+ setMode(agentId, mode, options = {}) {
52
+ this.agentModes[agentId] = {
53
+ mode: mode || MODES.FIXED,
54
+ fixedModel: options.fixedModel || null,
55
+ floorModel: options.floorModel || null,
56
+ };
57
+ }
58
+
59
+ getMode(agentId) {
60
+ return this.agentModes[agentId] || { mode: MODES.FIXED, fixedModel: null, floorModel: null };
61
+ }
62
+
63
+ // Get the recommended model for an agent based on current task
64
+ recommend(agentId) {
65
+ const config = this.getMode(agentId);
66
+ const agent = this.daemon.registry.get(agentId);
67
+ if (!agent) return null;
68
+
69
+ const provider = getProvider(agent.provider);
70
+ if (!provider) return null;
71
+ const models = provider.constructor.models;
72
+
73
+ // Fixed mode — just return the locked model
74
+ if (config.mode === MODES.FIXED) {
75
+ const model = config.fixedModel
76
+ ? models.find((m) => m.id === config.fixedModel) || models[0]
77
+ : models[0];
78
+ return { model, mode: MODES.FIXED, reason: 'Fixed model' };
79
+ }
80
+
81
+ // Auto mode — use classifier
82
+ const classifier = this.daemon.classifier;
83
+ if (!classifier) {
84
+ return { model: models[0], mode: config.mode, reason: 'No classifier available' };
85
+ }
86
+
87
+ // Use role-based hint when classifier has no activity data yet
88
+ const hasData = classifier.agentWindows[agentId]?.length > 0;
89
+ if (!hasData && agent.role && ROLE_HINTS[agent.role]) {
90
+ const hintTier = ROLE_HINTS[agent.role];
91
+ const match = models.find((m) => m.tier === hintTier) || models[0];
92
+ return { model: match, mode: config.mode, reason: `Role hint: ${agent.role} → ${hintTier}` };
93
+ }
94
+
95
+ const rec = classifier.getRecommendation(agentId, models);
96
+
97
+ // Auto-with-floor — ensure we don't go below floor
98
+ if (config.mode === MODES.AUTO_FLOOR && config.floorModel) {
99
+ const floorIdx = models.findIndex((m) => m.id === config.floorModel);
100
+ const recIdx = models.findIndex((m) => m.id === rec.model.id);
101
+
102
+ // Higher index = cheaper model. If rec is cheaper than floor, use floor.
103
+ if (floorIdx >= 0 && recIdx > floorIdx) {
104
+ const floor = models[floorIdx];
105
+ return { model: floor, mode: MODES.AUTO_FLOOR, reason: `Floor: ${floor.name}` };
106
+ }
107
+ }
108
+
109
+ return { model: rec.model, mode: config.mode, reason: rec.reason };
110
+ }
111
+
112
+ // Record a routing decision for cost tracking
113
+ recordUsage(agentId, modelId, tokens, tier) {
114
+ this.costLog.push({
115
+ agentId,
116
+ model: modelId,
117
+ tokens,
118
+ tier,
119
+ timestamp: new Date().toISOString(),
120
+ });
121
+ // Keep last 1000 entries
122
+ if (this.costLog.length > 1000) this.costLog = this.costLog.slice(-1000);
123
+ }
124
+
125
+ getStatus() {
126
+ return {
127
+ agentModes: this.agentModes,
128
+ costLogSize: this.costLog.length,
129
+ modes: MODES,
130
+ };
131
+ }
132
+ }
@@ -0,0 +1,34 @@
1
+ // GROOVE — State Persistence
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
5
+ import { resolve } from 'path';
6
+
7
+ export class StateManager {
8
+ constructor(grooveDir) {
9
+ this.path = resolve(grooveDir, 'state.json');
10
+ this.data = {};
11
+ }
12
+
13
+ load() {
14
+ if (existsSync(this.path)) {
15
+ try {
16
+ this.data = JSON.parse(readFileSync(this.path, 'utf8'));
17
+ } catch {
18
+ this.data = {};
19
+ }
20
+ }
21
+ }
22
+
23
+ save() {
24
+ writeFileSync(this.path, JSON.stringify(this.data, null, 2));
25
+ }
26
+
27
+ get(key) {
28
+ return this.data[key];
29
+ }
30
+
31
+ set(key, value) {
32
+ this.data[key] = value;
33
+ }
34
+ }
@@ -0,0 +1,178 @@
1
+ // GROOVE — QC Supervisor + Approval System
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { writeFileSync, readFileSync, existsSync } from 'fs';
5
+ import { resolve } from 'path';
6
+
7
+ const QC_AUTO_SPAWN_THRESHOLD = 4; // Auto-spawn QC when this many agents running
8
+
9
+ export class Supervisor {
10
+ constructor(daemon) {
11
+ this.daemon = daemon;
12
+ this.pendingApprovals = new Map();
13
+ this.resolvedApprovals = [];
14
+ this.counter = 0;
15
+ this.qcActive = false;
16
+ this.conflicts = [];
17
+ }
18
+
19
+ // --- Approval System ---
20
+
21
+ requestApproval(agentId, action) {
22
+ const id = `approval-${Date.now()}-${++this.counter}`;
23
+ const agent = this.daemon.registry?.get(agentId);
24
+
25
+ const approval = {
26
+ id,
27
+ agentId,
28
+ agentName: agent?.name || agentId,
29
+ action,
30
+ status: 'pending',
31
+ requestedAt: new Date().toISOString(),
32
+ };
33
+
34
+ this.pendingApprovals.set(id, approval);
35
+ this.daemon.broadcast({ type: 'approval:request', data: approval });
36
+ return approval;
37
+ }
38
+
39
+ approve(approvalId) {
40
+ const approval = this.pendingApprovals.get(approvalId);
41
+ if (!approval) return null;
42
+
43
+ approval.status = 'approved';
44
+ approval.resolvedAt = new Date().toISOString();
45
+ this.pendingApprovals.delete(approvalId);
46
+ this.resolvedApprovals.push(approval);
47
+ if (this.resolvedApprovals.length > 200) this.resolvedApprovals = this.resolvedApprovals.slice(-200);
48
+
49
+ this.daemon.broadcast({ type: 'approval:resolved', data: approval });
50
+ return approval;
51
+ }
52
+
53
+ reject(approvalId, reason) {
54
+ const approval = this.pendingApprovals.get(approvalId);
55
+ if (!approval) return null;
56
+
57
+ approval.status = 'rejected';
58
+ approval.reason = reason || '';
59
+ approval.resolvedAt = new Date().toISOString();
60
+ this.pendingApprovals.delete(approvalId);
61
+ this.resolvedApprovals.push(approval);
62
+ if (this.resolvedApprovals.length > 200) this.resolvedApprovals = this.resolvedApprovals.slice(-200);
63
+
64
+ this.daemon.broadcast({ type: 'approval:resolved', data: approval });
65
+ return approval;
66
+ }
67
+
68
+ getPending() {
69
+ return Array.from(this.pendingApprovals.values());
70
+ }
71
+
72
+ getResolved() {
73
+ return this.resolvedApprovals;
74
+ }
75
+
76
+ getApproval(id) {
77
+ return this.pendingApprovals.get(id) || this.resolvedApprovals.find((a) => a.id === id) || null;
78
+ }
79
+
80
+ // --- Conflict Detection ---
81
+
82
+ recordConflict(agentId, filePath, ownerId) {
83
+ const agent = this.daemon.registry?.get(agentId);
84
+ const owner = this.daemon.registry?.get(ownerId);
85
+
86
+ const conflict = {
87
+ timestamp: new Date().toISOString(),
88
+ agentId,
89
+ agentName: agent?.name || agentId,
90
+ filePath,
91
+ ownerId,
92
+ ownerName: owner?.name || ownerId,
93
+ };
94
+
95
+ this.conflicts.push(conflict);
96
+ if (this.conflicts.length > 200) this.conflicts = this.conflicts.slice(-200);
97
+
98
+ // Track in token savings
99
+ this.daemon.tokens?.recordConflictPrevented();
100
+
101
+ // Create an approval for the out-of-scope access
102
+ this.requestApproval(agentId, {
103
+ type: 'scope_violation',
104
+ description: `${agent?.name || agentId} tried to modify ${filePath} (owned by ${owner?.name || ownerId})`,
105
+ filePath,
106
+ ownerId,
107
+ });
108
+
109
+ // Update GROOVE_CONFLICTS.md
110
+ this.writeConflictsFile();
111
+
112
+ this.daemon.broadcast({
113
+ type: 'conflict:detected',
114
+ data: conflict,
115
+ });
116
+
117
+ return conflict;
118
+ }
119
+
120
+ getConflicts() {
121
+ return this.conflicts;
122
+ }
123
+
124
+ writeConflictsFile() {
125
+ if (!this.daemon.projectDir) return;
126
+ if (this.conflicts.length === 0) return;
127
+
128
+ const lines = [
129
+ `# GROOVE Conflicts Log`,
130
+ ``,
131
+ `*Auto-generated by QC Supervisor.*`,
132
+ ``,
133
+ `| Time | Agent | Attempted File | Owner | Status |`,
134
+ `|------|-------|---------------|-------|--------|`,
135
+ ];
136
+
137
+ for (const c of this.conflicts.slice(-50)) {
138
+ const time = new Date(c.timestamp).toLocaleTimeString();
139
+ lines.push(`| ${time} | ${c.agentName} | ${c.filePath} | ${c.ownerName} | blocked |`);
140
+ }
141
+
142
+ writeFileSync(resolve(this.daemon.projectDir, 'GROOVE_CONFLICTS.md'), lines.join('\n'));
143
+ }
144
+
145
+ // --- QC Auto-spawn ---
146
+
147
+ checkQcThreshold() {
148
+ if (this.qcActive) return false;
149
+
150
+ const running = this.daemon.registry?.getAll().filter(
151
+ (a) => a.status === 'running' && a.role !== 'qc'
152
+ ) || [];
153
+
154
+ if (running.length >= QC_AUTO_SPAWN_THRESHOLD) {
155
+ this.qcActive = true;
156
+ this.daemon.broadcast({
157
+ type: 'qc:activated',
158
+ agentCount: running.length,
159
+ });
160
+ return true;
161
+ }
162
+
163
+ return false;
164
+ }
165
+
166
+ isQcActive() {
167
+ return this.qcActive;
168
+ }
169
+
170
+ getStatus() {
171
+ return {
172
+ qcActive: this.qcActive,
173
+ pendingApprovals: this.getPending().length,
174
+ resolvedApprovals: this.resolvedApprovals.length,
175
+ conflicts: this.conflicts.length,
176
+ };
177
+ }
178
+ }
@@ -0,0 +1,203 @@
1
+ // GROOVE — Teams (Saved Agent Configurations)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from 'fs';
5
+ import { resolve } from 'path';
6
+ import { validateTeamName, sanitizeForFilename, validateAgentConfig } from './validate.js';
7
+
8
+ export class Teams {
9
+ constructor(daemon) {
10
+ this.daemon = daemon;
11
+ this.teamsDir = resolve(daemon.grooveDir, 'teams');
12
+ this.activeTeam = null; // Name of the currently active team
13
+ this.autoSave = false;
14
+
15
+ mkdirSync(this.teamsDir, { recursive: true });
16
+ }
17
+
18
+ // Save current agents as a team
19
+ save(name) {
20
+ validateTeamName(name);
21
+
22
+ const agents = this.daemon.registry.getAll();
23
+ if (agents.length === 0) throw new Error('No agents to save');
24
+
25
+ const team = {
26
+ name,
27
+ createdAt: new Date().toISOString(),
28
+ updatedAt: new Date().toISOString(),
29
+ agents: agents.map((a) => ({
30
+ role: a.role,
31
+ scope: a.scope,
32
+ provider: a.provider,
33
+ model: a.model,
34
+ prompt: a.prompt,
35
+ name: a.name,
36
+ })),
37
+ };
38
+
39
+ const path = resolve(this.teamsDir, `${this.sanitizeName(name)}.json`);
40
+ writeFileSync(path, JSON.stringify(team, null, 2));
41
+
42
+ this.activeTeam = name;
43
+ this.autoSave = true;
44
+
45
+ return team;
46
+ }
47
+
48
+ // Load a team — spawns all agents from config
49
+ async load(name) {
50
+ const team = this.get(name);
51
+ if (!team) throw new Error(`Team "${name}" not found`);
52
+
53
+ // Kill all running agents first
54
+ await this.daemon.processes.killAll();
55
+
56
+ // Clear registry of old entries
57
+ const old = this.daemon.registry.getAll();
58
+ for (const a of old) this.daemon.registry.remove(a.id);
59
+
60
+ // Spawn all agents from team config
61
+ const spawned = [];
62
+ for (const config of team.agents) {
63
+ try {
64
+ const agent = await this.daemon.processes.spawn(config);
65
+ spawned.push(agent);
66
+ } catch (err) {
67
+ console.error(` Failed to spawn ${config.name || config.role}:`, err.message);
68
+ }
69
+ }
70
+
71
+ this.activeTeam = name;
72
+ this.autoSave = true;
73
+
74
+ this.daemon.broadcast({
75
+ type: 'team:loaded',
76
+ name,
77
+ agentCount: spawned.length,
78
+ });
79
+
80
+ return { name, agents: spawned };
81
+ }
82
+
83
+ // Get a team definition by name
84
+ get(name) {
85
+ const path = resolve(this.teamsDir, `${this.sanitizeName(name)}.json`);
86
+ if (!existsSync(path)) return null;
87
+ try {
88
+ return JSON.parse(readFileSync(path, 'utf8'));
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ // List all saved teams
95
+ list() {
96
+ if (!existsSync(this.teamsDir)) return [];
97
+
98
+ return readdirSync(this.teamsDir)
99
+ .filter((f) => f.endsWith('.json'))
100
+ .map((f) => {
101
+ try {
102
+ const data = JSON.parse(readFileSync(resolve(this.teamsDir, f), 'utf8'));
103
+ return {
104
+ name: data.name,
105
+ agents: data.agents?.length || 0,
106
+ createdAt: data.createdAt,
107
+ updatedAt: data.updatedAt,
108
+ };
109
+ } catch {
110
+ return null;
111
+ }
112
+ })
113
+ .filter(Boolean);
114
+ }
115
+
116
+ // Delete a team
117
+ delete(name) {
118
+ const path = resolve(this.teamsDir, `${this.sanitizeName(name)}.json`);
119
+ if (!existsSync(path)) throw new Error(`Team "${name}" not found`);
120
+ unlinkSync(path);
121
+ if (this.activeTeam === name) {
122
+ this.activeTeam = null;
123
+ this.autoSave = false;
124
+ }
125
+ return true;
126
+ }
127
+
128
+ // Export team as portable JSON string
129
+ export(name) {
130
+ const team = this.get(name);
131
+ if (!team) throw new Error(`Team "${name}" not found`);
132
+ return JSON.stringify(team, null, 2);
133
+ }
134
+
135
+ // Import team from JSON string
136
+ import(jsonStr) {
137
+ let team;
138
+ try {
139
+ team = JSON.parse(jsonStr);
140
+ } catch {
141
+ throw new Error('Invalid JSON');
142
+ }
143
+
144
+ if (!team.name || !Array.isArray(team.agents)) {
145
+ throw new Error('Invalid team format: needs "name" and "agents" array');
146
+ }
147
+
148
+ validateTeamName(team.name);
149
+
150
+ if (team.agents.length > 20) {
151
+ throw new Error('Too many agents in team (max 20)');
152
+ }
153
+
154
+ // Validate each agent config
155
+ for (const a of team.agents) {
156
+ validateAgentConfig(a);
157
+ }
158
+
159
+ team.updatedAt = new Date().toISOString();
160
+ const path = resolve(this.teamsDir, `${this.sanitizeName(team.name)}.json`);
161
+ writeFileSync(path, JSON.stringify(team, null, 2));
162
+
163
+ return team;
164
+ }
165
+
166
+ // Auto-save: called when agents change while a team is active
167
+ onAgentChange() {
168
+ if (!this.activeTeam || !this.autoSave) return;
169
+
170
+ const agents = this.daemon.registry.getAll().filter(
171
+ (a) => a.status === 'running' || a.status === 'starting'
172
+ );
173
+
174
+ if (agents.length === 0) return;
175
+
176
+ const path = resolve(this.teamsDir, `${this.sanitizeName(this.activeTeam)}.json`);
177
+ if (!existsSync(path)) return;
178
+
179
+ try {
180
+ const team = JSON.parse(readFileSync(path, 'utf8'));
181
+ team.updatedAt = new Date().toISOString();
182
+ team.agents = agents.map((a) => ({
183
+ role: a.role,
184
+ scope: a.scope,
185
+ provider: a.provider,
186
+ model: a.model,
187
+ prompt: a.prompt,
188
+ name: a.name,
189
+ }));
190
+ writeFileSync(path, JSON.stringify(team, null, 2));
191
+ } catch {
192
+ // Non-critical — don't break the flow
193
+ }
194
+ }
195
+
196
+ getActiveTeam() {
197
+ return this.activeTeam;
198
+ }
199
+
200
+ sanitizeName(name) {
201
+ return sanitizeForFilename(name);
202
+ }
203
+ }
@@ -0,0 +1,27 @@
1
+ // GROOVE — Base Terminal Adapter
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ export class TerminalAdapter {
5
+ static name = 'base';
6
+ static displayName = 'Base Terminal';
7
+
8
+ static isAvailable() {
9
+ return false;
10
+ }
11
+
12
+ createPane(options) {
13
+ throw new Error('Terminal adapter must implement createPane()');
14
+ }
15
+
16
+ closePane(paneId) {
17
+ throw new Error('Terminal adapter must implement closePane()');
18
+ }
19
+
20
+ sendKeys(paneId, keys) {
21
+ throw new Error('Terminal adapter must implement sendKeys()');
22
+ }
23
+
24
+ notify(message) {
25
+ // Default: no-op
26
+ }
27
+ }
@@ -0,0 +1,27 @@
1
+ // GROOVE — Generic Terminal Adapter (fallback)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { TerminalAdapter } from './base.js';
5
+
6
+ export class GenericTerminal extends TerminalAdapter {
7
+ static name = 'generic';
8
+ static displayName = 'Generic Terminal';
9
+
10
+ static isAvailable() {
11
+ return true; // Always available as fallback
12
+ }
13
+
14
+ createPane(options) {
15
+ // In generic mode, agents run as background processes
16
+ // managed directly by the ProcessManager
17
+ return { type: 'background', id: options.agentId };
18
+ }
19
+
20
+ closePane(paneId) {
21
+ // Process cleanup handled by ProcessManager
22
+ }
23
+
24
+ sendKeys(paneId, keys) {
25
+ // Not supported in generic mode
26
+ }
27
+ }
@@ -0,0 +1,64 @@
1
+ // GROOVE — tmux Terminal Adapter
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { execFileSync } from 'child_process';
5
+ import { TerminalAdapter } from './base.js';
6
+
7
+ export class TmuxTerminal extends TerminalAdapter {
8
+ static name = 'tmux';
9
+ static displayName = 'tmux';
10
+
11
+ static isAvailable() {
12
+ try {
13
+ execFileSync('which', ['tmux'], { stdio: 'ignore' });
14
+ return !!process.env.TMUX;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ createPane(options) {
21
+ try {
22
+ const output = execFileSync('tmux', [
23
+ 'split-window', '-h', '-d', '-P', '-F', '#{pane_id}',
24
+ options.command || 'bash',
25
+ ], { encoding: 'utf8' }).trim();
26
+
27
+ return { type: 'tmux', paneId: output };
28
+ } catch (err) {
29
+ console.error(' tmux: Failed to create pane:', err.message);
30
+ return { type: 'background', id: options.agentId };
31
+ }
32
+ }
33
+
34
+ closePane(paneId) {
35
+ if (!this.isValidPaneId(paneId)) return;
36
+ try {
37
+ execFileSync('tmux', ['kill-pane', '-t', paneId], { stdio: 'ignore' });
38
+ } catch {
39
+ // Pane may already be closed
40
+ }
41
+ }
42
+
43
+ sendKeys(paneId, keys) {
44
+ if (!this.isValidPaneId(paneId)) return;
45
+ try {
46
+ execFileSync('tmux', ['send-keys', '-t', paneId, keys, 'Enter'], { stdio: 'ignore' });
47
+ } catch {
48
+ // Ignore errors
49
+ }
50
+ }
51
+
52
+ notify(message) {
53
+ try {
54
+ execFileSync('tmux', ['display-message', String(message).slice(0, 200)], { stdio: 'ignore' });
55
+ } catch {
56
+ // tmux not available or not in session
57
+ }
58
+ }
59
+
60
+ isValidPaneId(paneId) {
61
+ // tmux pane IDs are like %0, %1, %12
62
+ return typeof paneId === 'string' && /^%\d+$/.test(paneId);
63
+ }
64
+ }