groove-dev 0.27.14 → 0.27.17

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 (169) hide show
  1. package/README.md +37 -1
  2. package/developerID_application.cer +0 -0
  3. package/node_modules/@groove-dev/daemon/src/api.js +587 -68
  4. package/node_modules/@groove-dev/daemon/src/classifier.js +24 -0
  5. package/node_modules/@groove-dev/daemon/src/credentials.js +12 -2
  6. package/node_modules/@groove-dev/daemon/src/federation/ambassador.js +204 -0
  7. package/node_modules/@groove-dev/daemon/src/federation/connection.js +359 -0
  8. package/node_modules/@groove-dev/daemon/src/federation/contracts.js +112 -0
  9. package/node_modules/@groove-dev/daemon/src/federation/whitelist.js +190 -0
  10. package/node_modules/@groove-dev/daemon/src/federation.js +166 -7
  11. package/node_modules/@groove-dev/daemon/src/index.js +172 -19
  12. package/node_modules/@groove-dev/daemon/src/introducer.js +52 -7
  13. package/node_modules/@groove-dev/daemon/src/journalist.js +46 -1
  14. package/node_modules/@groove-dev/daemon/src/memory.js +36 -16
  15. package/node_modules/@groove-dev/daemon/src/process.js +140 -23
  16. package/node_modules/@groove-dev/daemon/src/providers/base.js +1 -0
  17. package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +1 -0
  18. package/node_modules/@groove-dev/daemon/src/providers/codex.js +124 -28
  19. package/node_modules/@groove-dev/daemon/src/providers/gemini.js +104 -17
  20. package/node_modules/@groove-dev/daemon/src/providers/index.js +17 -0
  21. package/node_modules/@groove-dev/daemon/src/registry.js +10 -1
  22. package/node_modules/@groove-dev/daemon/src/rotator.js +93 -30
  23. package/node_modules/@groove-dev/daemon/src/skills.js +33 -3
  24. package/node_modules/@groove-dev/daemon/src/terminal-pty.js +9 -1
  25. package/node_modules/@groove-dev/daemon/src/tool-executor.js +11 -5
  26. package/node_modules/@groove-dev/daemon/src/toys.js +69 -0
  27. package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +24 -5
  28. package/node_modules/@groove-dev/daemon/templates/toys-catalog.json +242 -0
  29. package/node_modules/@groove-dev/daemon/test/classifier.test.js +98 -0
  30. package/node_modules/@groove-dev/daemon/test/introducer.test.js +72 -1
  31. package/node_modules/@groove-dev/daemon/test/journalist.test.js +117 -0
  32. package/node_modules/@groove-dev/daemon/test/memory.test.js +37 -1
  33. package/node_modules/@groove-dev/daemon/test/rotator.test.js +183 -4
  34. package/node_modules/@groove-dev/gui/dist/assets/index-BglPgjlu.js +8607 -0
  35. package/node_modules/@groove-dev/gui/dist/assets/index-CGcwmmJv.css +1 -0
  36. package/node_modules/@groove-dev/gui/dist/index.html +3 -2
  37. package/node_modules/@groove-dev/gui/index.html +1 -0
  38. package/node_modules/@groove-dev/gui/src/app.css +7 -0
  39. package/node_modules/@groove-dev/gui/src/app.jsx +37 -10
  40. package/node_modules/@groove-dev/gui/src/components/agents/agent-chat.jsx +21 -31
  41. package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +11 -6
  42. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +2 -2
  43. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +42 -1
  44. package/node_modules/@groove-dev/gui/src/components/editor/breadcrumbs.jsx +30 -0
  45. package/node_modules/@groove-dev/gui/src/components/editor/code-editor.jsx +33 -2
  46. package/node_modules/@groove-dev/gui/src/components/editor/editor-status-bar.jsx +26 -0
  47. package/node_modules/@groove-dev/gui/src/components/editor/editor-tabs.jsx +113 -34
  48. package/node_modules/@groove-dev/gui/src/components/editor/goto-line.jsx +35 -0
  49. package/node_modules/@groove-dev/gui/src/components/editor/terminal.jsx +12 -6
  50. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +13 -3
  51. package/node_modules/@groove-dev/gui/src/components/layout/app-shell.jsx +0 -1
  52. package/node_modules/@groove-dev/gui/src/components/layout/breadcrumb-bar.jsx +165 -47
  53. package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +6 -2
  54. package/node_modules/@groove-dev/gui/src/components/layout/terminal-panel.jsx +10 -9
  55. package/node_modules/@groove-dev/gui/src/components/marketplace/repo-import.jsx +9 -1
  56. package/node_modules/@groove-dev/gui/src/components/onboarding/provider-card.jsx +134 -0
  57. package/node_modules/@groove-dev/gui/src/components/onboarding/setup-wizard.jsx +819 -0
  58. package/node_modules/@groove-dev/gui/src/components/pro/pro-gate.jsx +12 -5
  59. package/node_modules/@groove-dev/gui/src/components/pro/upgrade-card.jsx +15 -8
  60. package/node_modules/@groove-dev/gui/src/components/pro/upgrade-modal.jsx +151 -0
  61. package/node_modules/@groove-dev/gui/src/components/settings/federation-activity.jsx +98 -0
  62. package/node_modules/@groove-dev/gui/src/components/settings/federation-panel.jsx +290 -0
  63. package/node_modules/@groove-dev/gui/src/components/settings/federation-peers.jsx +126 -0
  64. package/node_modules/@groove-dev/gui/src/components/settings/federation-wizard.jsx +293 -0
  65. package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +110 -67
  66. package/node_modules/@groove-dev/gui/src/components/settings/remote-server-card.jsx +3 -3
  67. package/node_modules/@groove-dev/gui/src/components/settings/server-detail.jsx +310 -0
  68. package/node_modules/@groove-dev/gui/src/components/settings/server-dialog.jsx +4 -1
  69. package/node_modules/@groove-dev/gui/src/components/settings/server-list.jsx +59 -0
  70. package/node_modules/@groove-dev/gui/src/components/settings/ssh-wizard.jsx +549 -0
  71. package/node_modules/@groove-dev/gui/src/components/toys/toy-card.jsx +78 -0
  72. package/node_modules/@groove-dev/gui/src/components/toys/toy-creator.jsx +144 -0
  73. package/node_modules/@groove-dev/gui/src/components/toys/toy-launcher.jsx +187 -0
  74. package/node_modules/@groove-dev/gui/src/components/ui/toast.jsx +2 -2
  75. package/node_modules/@groove-dev/gui/src/lib/electron.js +15 -0
  76. package/node_modules/@groove-dev/gui/src/lib/format.js +1 -0
  77. package/node_modules/@groove-dev/gui/src/stores/groove.js +373 -58
  78. package/node_modules/@groove-dev/gui/src/views/agents.jsx +148 -42
  79. package/node_modules/@groove-dev/gui/src/views/editor.jsx +92 -2
  80. package/node_modules/@groove-dev/gui/src/views/federation.jsx +37 -0
  81. package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +2 -42
  82. package/node_modules/@groove-dev/gui/src/views/settings.jsx +32 -132
  83. package/node_modules/@groove-dev/gui/src/views/subscription-panel.jsx +327 -0
  84. package/node_modules/@groove-dev/gui/src/views/teams.jsx +3 -3
  85. package/node_modules/@groove-dev/gui/src/views/toys.jsx +162 -0
  86. package/package.json +1 -1
  87. package/packages/daemon/src/api.js +587 -68
  88. package/packages/daemon/src/classifier.js +24 -0
  89. package/packages/daemon/src/credentials.js +12 -2
  90. package/packages/daemon/src/federation/ambassador.js +204 -0
  91. package/packages/daemon/src/federation/connection.js +359 -0
  92. package/packages/daemon/src/federation/contracts.js +112 -0
  93. package/packages/daemon/src/federation/whitelist.js +190 -0
  94. package/packages/daemon/src/federation.js +166 -7
  95. package/packages/daemon/src/index.js +172 -19
  96. package/packages/daemon/src/introducer.js +52 -7
  97. package/packages/daemon/src/journalist.js +46 -1
  98. package/packages/daemon/src/memory.js +36 -16
  99. package/packages/daemon/src/process.js +140 -23
  100. package/packages/daemon/src/providers/base.js +1 -0
  101. package/packages/daemon/src/providers/claude-code.js +1 -0
  102. package/packages/daemon/src/providers/codex.js +124 -28
  103. package/packages/daemon/src/providers/gemini.js +104 -17
  104. package/packages/daemon/src/providers/index.js +17 -0
  105. package/packages/daemon/src/registry.js +10 -1
  106. package/packages/daemon/src/rotator.js +93 -30
  107. package/packages/daemon/src/skills.js +33 -3
  108. package/packages/daemon/src/terminal-pty.js +9 -1
  109. package/packages/daemon/src/tool-executor.js +11 -5
  110. package/packages/daemon/src/toys.js +69 -0
  111. package/packages/daemon/src/tunnel-manager.js +24 -5
  112. package/packages/daemon/templates/toys-catalog.json +242 -0
  113. package/packages/gui/dist/assets/index-BglPgjlu.js +8607 -0
  114. package/packages/gui/dist/assets/index-CGcwmmJv.css +1 -0
  115. package/packages/gui/dist/index.html +3 -2
  116. package/packages/gui/index.html +1 -0
  117. package/packages/gui/src/app.css +7 -0
  118. package/packages/gui/src/app.jsx +37 -10
  119. package/packages/gui/src/components/agents/agent-chat.jsx +21 -31
  120. package/packages/gui/src/components/agents/agent-config.jsx +11 -6
  121. package/packages/gui/src/components/agents/agent-feed.jsx +2 -2
  122. package/packages/gui/src/components/agents/spawn-wizard.jsx +42 -1
  123. package/packages/gui/src/components/editor/breadcrumbs.jsx +30 -0
  124. package/packages/gui/src/components/editor/code-editor.jsx +33 -2
  125. package/packages/gui/src/components/editor/editor-status-bar.jsx +26 -0
  126. package/packages/gui/src/components/editor/editor-tabs.jsx +113 -34
  127. package/packages/gui/src/components/editor/goto-line.jsx +35 -0
  128. package/packages/gui/src/components/editor/terminal.jsx +12 -6
  129. package/packages/gui/src/components/layout/activity-bar.jsx +13 -3
  130. package/packages/gui/src/components/layout/app-shell.jsx +0 -1
  131. package/packages/gui/src/components/layout/breadcrumb-bar.jsx +165 -47
  132. package/packages/gui/src/components/layout/command-palette.jsx +6 -2
  133. package/packages/gui/src/components/layout/terminal-panel.jsx +10 -9
  134. package/packages/gui/src/components/marketplace/repo-import.jsx +9 -1
  135. package/packages/gui/src/components/onboarding/provider-card.jsx +134 -0
  136. package/packages/gui/src/components/onboarding/setup-wizard.jsx +819 -0
  137. package/packages/gui/src/components/pro/pro-gate.jsx +12 -5
  138. package/packages/gui/src/components/pro/upgrade-card.jsx +15 -8
  139. package/packages/gui/src/components/pro/upgrade-modal.jsx +151 -0
  140. package/packages/gui/src/components/settings/federation-activity.jsx +98 -0
  141. package/packages/gui/src/components/settings/federation-panel.jsx +290 -0
  142. package/packages/gui/src/components/settings/federation-peers.jsx +126 -0
  143. package/packages/gui/src/components/settings/federation-wizard.jsx +293 -0
  144. package/packages/gui/src/components/settings/quick-connect.jsx +110 -67
  145. package/packages/gui/src/components/settings/remote-server-card.jsx +3 -3
  146. package/packages/gui/src/components/settings/server-detail.jsx +310 -0
  147. package/packages/gui/src/components/settings/server-dialog.jsx +4 -1
  148. package/packages/gui/src/components/settings/server-list.jsx +59 -0
  149. package/packages/gui/src/components/settings/ssh-wizard.jsx +549 -0
  150. package/packages/gui/src/components/toys/toy-card.jsx +78 -0
  151. package/packages/gui/src/components/toys/toy-creator.jsx +144 -0
  152. package/packages/gui/src/components/toys/toy-launcher.jsx +187 -0
  153. package/packages/gui/src/components/ui/toast.jsx +2 -2
  154. package/packages/gui/src/lib/electron.js +15 -0
  155. package/packages/gui/src/lib/format.js +1 -0
  156. package/packages/gui/src/stores/groove.js +373 -58
  157. package/packages/gui/src/views/agents.jsx +148 -42
  158. package/packages/gui/src/views/editor.jsx +92 -2
  159. package/packages/gui/src/views/federation.jsx +37 -0
  160. package/packages/gui/src/views/marketplace.jsx +2 -42
  161. package/packages/gui/src/views/settings.jsx +32 -132
  162. package/packages/gui/src/views/subscription-panel.jsx +327 -0
  163. package/packages/gui/src/views/teams.jsx +3 -3
  164. package/packages/gui/src/views/toys.jsx +162 -0
  165. package/plans/chat-persistence-refactor.md +154 -0
  166. package/node_modules/@groove-dev/gui/dist/assets/index-BE6lYcd7.css +0 -1
  167. package/node_modules/@groove-dev/gui/dist/assets/index-zdzOLAZM.js +0 -677
  168. package/packages/gui/dist/assets/index-BE6lYcd7.css +0 -1
  169. package/packages/gui/dist/assets/index-zdzOLAZM.js +0 -677
@@ -1,12 +1,29 @@
1
1
  // GROOVE — Provider Registry
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
+ import { execSync } from 'child_process';
4
5
  import { ClaudeCodeProvider } from './claude-code.js';
5
6
  import { CodexProvider } from './codex.js';
6
7
  import { GeminiProvider } from './gemini.js';
7
8
  import { OllamaProvider } from './ollama.js';
8
9
  import { LocalProvider } from './local.js';
9
10
 
11
+ // Electron forks may not inherit the full shell PATH, causing `which` to miss
12
+ // globally-installed CLI tools. Augment PATH with common npm global bin dirs.
13
+ (function augmentPath() {
14
+ const extra = ['/usr/local/bin', '/opt/homebrew/bin'];
15
+ try {
16
+ const npmPrefix = execSync('npm config get prefix 2>/dev/null', { encoding: 'utf8', timeout: 5000 }).trim();
17
+ const npmGlobal = npmPrefix ? `${npmPrefix}/bin` : '';
18
+ if (npmGlobal) extra.push(npmGlobal);
19
+ } catch { /* npm itself may not be in PATH yet */ }
20
+ const home = process.env.HOME || '';
21
+ if (home) extra.push(`${home}/.npm-global/bin`);
22
+ const cur = process.env.PATH || '';
23
+ const toAdd = extra.filter(p => p && !cur.split(':').includes(p));
24
+ if (toAdd.length) process.env.PATH = [...toAdd, cur].join(':');
25
+ })();
26
+
10
27
  const providers = {
11
28
  'claude-code': new ClaudeCodeProvider(),
12
29
  'codex': new CodexProvider(),
@@ -12,9 +12,18 @@ export class Registry extends EventEmitter {
12
12
  }
13
13
 
14
14
  add(config) {
15
+ let name = config.name || `${config.role}-${this.agents.size + 1}`;
16
+ // Dedup: ensure name is unique within the same team
17
+ const teamId = config.teamId || null;
18
+ const existing = this.getAll();
19
+ if (existing.some((a) => a.name === name && a.teamId === teamId)) {
20
+ let suffix = 2;
21
+ while (existing.some((a) => a.name === `${name}-${suffix}` && a.teamId === teamId)) suffix++;
22
+ name = `${name}-${suffix}`;
23
+ }
15
24
  const agent = {
16
25
  id: randomUUID().slice(0, 8),
17
- name: config.name || `${config.role}-${this.agents.size + 1}`,
26
+ name,
18
27
  role: config.role,
19
28
  scope: config.scope || [],
20
29
  provider: config.provider || 'claude-code',
@@ -4,14 +4,23 @@
4
4
  import { EventEmitter } from 'events';
5
5
  import { readFileSync, writeFileSync, existsSync } from 'fs';
6
6
  import { resolve } from 'path';
7
+ import { getProvider } from './providers/index.js';
7
8
 
8
- const DEFAULT_THRESHOLD = 0.75;
9
- const HARD_CEILING = 0.85; // Force rotate at 85%no idle check, prevents compaction
9
+ const DEFAULT_THRESHOLD = 0.65; // For non-self-managing providers (was 0.75)
10
+ const HARD_CEILING = 0.80; // Force rotate (was 0.85)only for non-self-managing
10
11
  const CHECK_INTERVAL = 15_000;
11
12
  const QUALITY_THRESHOLD = 40; // Score below this triggers quality rotation
12
13
  const MIN_EVENTS = 10; // Minimum classifier events before scoring
13
14
  const MIN_AGE_SEC = 120; // Minimum agent age before quality rotation
14
15
  const SCORE_HISTORY_MAX = 40; // ~10 min at 15s intervals
16
+ const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes between rotations per agent
17
+ const TOKEN_CEILING = 5_000_000; // 5M tokens per agent (non-self-managing only)
18
+ const ROLE_MULTIPLIERS = {
19
+ planner: 10,
20
+ fullstack: 4,
21
+ security: 4,
22
+ analyst: 5,
23
+ };
15
24
 
16
25
  export class Rotator extends EventEmitter {
17
26
  constructor(daemon) {
@@ -20,6 +29,7 @@ export class Rotator extends EventEmitter {
20
29
  this.interval = null;
21
30
  this.rotationHistory = [];
22
31
  this.rotating = new Set();
32
+ this.lastRotationTime = new Map(); // agentId -> timestamp of last rotation
23
33
  this.enabled = false;
24
34
  this.liveScores = {};
25
35
  this.scoreHistory = {};
@@ -91,6 +101,17 @@ export class Rotator extends EventEmitter {
91
101
  this.enabled = false;
92
102
  }
93
103
 
104
+ _isOnCooldown(agentId) {
105
+ const lastTime = this.lastRotationTime.get(agentId);
106
+ if (!lastTime) return false;
107
+ return (Date.now() - lastTime) < COOLDOWN_MS;
108
+ }
109
+
110
+ _getTokenCeiling(agent) {
111
+ const multiplier = ROLE_MULTIPLIERS[agent.role] || 1;
112
+ return TOKEN_CEILING * multiplier;
113
+ }
114
+
94
115
  _idleMs(agent) {
95
116
  return agent.lastActivity
96
117
  ? Date.now() - new Date(agent.lastActivity).getTime()
@@ -131,27 +152,49 @@ export class Rotator extends EventEmitter {
131
152
  for (const agent of running) {
132
153
  if (this.rotating.has(agent.id)) continue;
133
154
 
134
- // Hard ceiling force rotate to prevent compaction, even if agent is busy
135
- if (agent.contextUsage >= HARD_CEILING) {
136
- console.log(` Rotator: ${agent.name} at ${Math.round(agent.contextUsage * 100)}% — FORCE rotating (hard ceiling)`);
137
- await this.rotate(agent.id, { reason: 'hard_ceiling' });
138
- continue;
139
- }
155
+ // Determine if provider manages its own context (e.g. Claude Code compacts internally)
156
+ const providerInstance = getProvider(agent.provider);
157
+ const selfManagesContext = providerInstance?.constructor?.managesOwnContext ?? false;
140
158
 
141
- const threshold = this.daemon.adaptive
142
- ? this.daemon.adaptive.getThreshold(agent.provider, agent.role)
143
- : DEFAULT_THRESHOLD;
159
+ if (!selfManagesContext) {
160
+ // Non-Claude: threshold + ceiling + quality rotation
161
+ // These providers fill up linearly and degrade without external rotation
144
162
 
145
- // Context-based rotation (original)
146
- if (agent.contextUsage >= threshold) {
147
- if (this._idleMs(agent) > 10_000) {
148
- console.log(` Rotator: ${agent.name} at ${Math.round(agent.contextUsage * 100)}% — rotating (context)`);
149
- await this.rotate(agent.id, { reason: 'context_threshold' });
163
+ // Hard ceiling — force rotate, no idle check, bypasses cooldown (safety override)
164
+ if (agent.contextUsage >= HARD_CEILING) {
165
+ console.log(` Rotator: ${agent.name} at ${Math.round(agent.contextUsage * 100)}% FORCE rotating (hard ceiling)`);
166
+ await this.rotate(agent.id, { reason: 'hard_ceiling' });
150
167
  continue;
151
168
  }
169
+
170
+ // Token ceiling — force rotate when total tokens exceed ceiling, bypasses cooldown
171
+ const tokenCeiling = this._getTokenCeiling(agent);
172
+ if (agent.tokensUsed >= tokenCeiling) {
173
+ console.log(` Rotator: ${agent.name} at ${(agent.tokensUsed || 0).toLocaleString()} tokens — FORCE rotating (token ceiling ${tokenCeiling.toLocaleString()})`);
174
+ await this.rotate(agent.id, { reason: 'token_ceiling', tokensUsed: agent.tokensUsed, ceiling: tokenCeiling });
175
+ continue;
176
+ }
177
+
178
+ // Cooldown — skip threshold/quality rotation if recently rotated
179
+ if (this._isOnCooldown(agent.id)) continue;
180
+
181
+ // Context threshold — rotate when idle
182
+ const threshold = this.daemon.adaptive
183
+ ? this.daemon.adaptive.getThreshold(agent.provider, agent.role)
184
+ : DEFAULT_THRESHOLD;
185
+ if (agent.contextUsage >= threshold) {
186
+ if (this._idleMs(agent) > 10_000) {
187
+ console.log(` Rotator: ${agent.name} at ${Math.round(agent.contextUsage * 100)}% — rotating (context)`);
188
+ await this.rotate(agent.id, { reason: 'context_threshold' });
189
+ continue;
190
+ }
191
+ }
152
192
  }
153
193
 
154
- // Quality-based rotation detects degradation before tokens are wasted
194
+ // Cooldown — skip quality rotation for self-managing providers if recently rotated
195
+ if (this._isOnCooldown(agent.id)) continue;
196
+
197
+ // All providers: quality-based rotation — detects degradation before tokens are wasted
155
198
  const quality = this.scoreLiveSession(agent);
156
199
  if (quality.hasEnoughData && quality.score < QUALITY_THRESHOLD) {
157
200
  if (this._idleMs(agent) > 10_000) {
@@ -208,12 +251,12 @@ export class Rotator extends EventEmitter {
208
251
  this.daemon.memory.appendHandoffBrief(agent.role, {
209
252
  timestamp: new Date().toISOString(),
210
253
  agentId: agent.id,
211
- newAgentId: null, // filled after respawn completes
254
+ newAgentId: null,
212
255
  reason: options.reason || 'manual',
213
256
  oldTokens: agent.tokensUsed,
214
257
  contextUsage: agent.contextUsage,
215
258
  brief: brief.slice(0, 4000),
216
- });
259
+ }, agent.workingDir);
217
260
  }
218
261
 
219
262
  const record = {
@@ -233,17 +276,28 @@ export class Rotator extends EventEmitter {
233
276
  const routingMode = this.daemon.router.getMode(agentId);
234
277
  const respawnModel = routingMode.mode === 'auto' ? 'auto' : agent.model;
235
278
 
236
- const newAgent = await processes.spawn({
237
- role: agent.role,
238
- scope: agent.scope,
239
- provider: agent.provider,
240
- model: respawnModel,
241
- prompt: brief,
242
- permission: agent.permission || 'full',
243
- workingDir: agent.workingDir,
244
- name: agent.name,
245
- teamId: agent.teamId,
246
- });
279
+ let newAgent;
280
+ try {
281
+ newAgent = await processes.spawn({
282
+ role: agent.role,
283
+ scope: agent.scope,
284
+ provider: agent.provider,
285
+ model: respawnModel,
286
+ prompt: brief,
287
+ permission: agent.permission || 'full',
288
+ workingDir: agent.workingDir,
289
+ name: agent.name,
290
+ teamId: agent.teamId,
291
+ });
292
+ } catch (spawnErr) {
293
+ // Spawn failed — old agent still in registry with 'killed' status.
294
+ // Don't lose it — the user can see and retry.
295
+ console.error(`[Groove] Rotation spawn failed for ${agent.name}: ${spawnErr.message}`);
296
+ throw spawnErr;
297
+ }
298
+
299
+ // Spawn succeeded — safe to remove old agent entry
300
+ registry.remove(agentId);
247
301
 
248
302
  if (agent.tokensUsed > 0) {
249
303
  registry.update(newAgent.id, { tokensUsed: agent.tokensUsed });
@@ -254,6 +308,7 @@ export class Rotator extends EventEmitter {
254
308
 
255
309
  record.newAgentId = newAgent.id;
256
310
  record.newTokens = 0;
311
+ this.lastRotationTime.set(newAgent.id, Date.now());
257
312
  this.rotationHistory.push(record);
258
313
 
259
314
  if (this.rotationHistory.length > 100) {
@@ -358,6 +413,7 @@ export class Rotator extends EventEmitter {
358
413
  const contextRotations = this.rotationHistory.filter((r) => r.reason === 'context_threshold').length;
359
414
  const naturalCompactions = this.rotationHistory.filter((r) => r.reason === 'natural_compaction').length;
360
415
  const hardCeilingRotations = this.rotationHistory.filter((r) => r.reason === 'hard_ceiling').length;
416
+ const tokenCeilingRotations = this.rotationHistory.filter((r) => r.reason === 'token_ceiling').length;
361
417
  return {
362
418
  enabled: this.enabled,
363
419
  totalRotations,
@@ -366,9 +422,16 @@ export class Rotator extends EventEmitter {
366
422
  contextRotations,
367
423
  naturalCompactions,
368
424
  hardCeilingRotations,
425
+ tokenCeilingRotations,
369
426
  rotating: Array.from(this.rotating),
370
427
  liveScores: this.liveScores,
371
428
  scoreHistory: this.scoreHistory,
429
+ defaultThreshold: DEFAULT_THRESHOLD,
430
+ hardCeiling: HARD_CEILING,
431
+ qualityThreshold: QUALITY_THRESHOLD,
432
+ cooldownMs: COOLDOWN_MS,
433
+ tokenCeiling: TOKEN_CEILING,
434
+ roleMultipliers: ROLE_MULTIPLIERS,
372
435
  };
373
436
  }
374
437
  }
@@ -37,6 +37,9 @@ export class SkillStore {
37
37
 
38
38
  // Fetch full registry from live API in background
39
39
  this._refreshRegistry();
40
+
41
+ // Sync subscription cache from stored user on startup
42
+ this._syncSubscriptionCache(this.getUser());
40
43
  }
41
44
 
42
45
  // --- Auth ---
@@ -69,22 +72,45 @@ export class SkillStore {
69
72
  return null; // Invalid token
70
73
  }
71
74
  } catch {
72
- // Can't validate — store anyway, will revalidate later
73
- user = { id: 'unknown' };
75
+ // Can't validate — keep existing stored user so subscription data survives
76
+ const existingUser = this.getUser();
77
+ user = existingUser || { id: 'unknown' };
74
78
  }
75
79
 
76
80
  this.daemon.config.marketplace = { token, user };
77
81
  const { saveConfig } = await import('./firstrun.js');
78
82
  saveConfig(this.daemon.grooveDir, this.daemon.config);
79
83
  this.daemon.audit.log('marketplace.login', { userId: user?.id });
84
+ this._syncSubscriptionCache(user);
80
85
  return user;
81
86
  }
82
87
 
88
+ _syncSubscriptionCache(user) {
89
+ const sub = user?.subscription;
90
+ if (!this.daemon) return;
91
+ if (!sub) {
92
+ console.log(`[Groove:Sub] No subscription data on user — cache unchanged (plan: ${this.daemon.subscriptionCache?.plan || 'unknown'})`);
93
+ return;
94
+ }
95
+ this.daemon.subscriptionCache = {
96
+ plan: sub.plan || 'community',
97
+ status: sub.status || (sub.plan !== 'community' ? 'active' : 'none'),
98
+ active: sub.status === 'active' || (sub.plan && sub.plan !== 'community'),
99
+ features: sub.features || [],
100
+ seats: sub.seats || 1,
101
+ periodEnd: sub.periodEnd || null,
102
+ cancelAtPeriodEnd: sub.cancelAtPeriodEnd || false,
103
+ validatedAt: Date.now(),
104
+ };
105
+ this.daemon.broadcast({ type: 'subscription:updated', data: this.daemon.subscriptionCache });
106
+ }
107
+
83
108
  /** Clear stored auth */
84
109
  async clearAuth() {
85
110
  delete this.daemon.config.marketplace;
86
111
  const { saveConfig } = await import('./firstrun.js');
87
112
  saveConfig(this.daemon.grooveDir, this.daemon.config);
113
+ this.daemon.subscriptionCache = { plan: 'community', status: 'none', features: [], active: false, validatedAt: 0 };
88
114
  this.daemon.audit.log('marketplace.logout');
89
115
  }
90
116
 
@@ -98,7 +124,11 @@ export class SkillStore {
98
124
  headers: { 'Authorization': `Bearer ${token}` },
99
125
  signal: AbortSignal.timeout(8000),
100
126
  });
101
- if (res.ok) return await res.json();
127
+ if (res.ok) {
128
+ const user = await res.json();
129
+ this._syncSubscriptionCache(user);
130
+ return user;
131
+ }
102
132
  } catch { /* offline — assume valid */ return this.getUser(); }
103
133
 
104
134
  // 401 — token expired, clear it
@@ -115,7 +115,7 @@ export class TerminalManager {
115
115
  stdio: ['pipe', 'pipe', 'pipe'],
116
116
  });
117
117
 
118
- const session = { proc, ws, id };
118
+ const session = { proc, ws, id, label: options.label || '' };
119
119
  this.sessions.set(id, session);
120
120
 
121
121
  proc.stdout.on('data', (data) => {
@@ -160,6 +160,14 @@ export class TerminalManager {
160
160
  session.proc.stdin.write(`\x1b]7;${rows};${cols}\x07`);
161
161
  }
162
162
 
163
+ rename(id, label) {
164
+ const session = this.sessions.get(id);
165
+ if (!session) return false;
166
+ if (typeof label !== 'string' || label.length > 100) return false;
167
+ session.label = label;
168
+ return true;
169
+ }
170
+
163
171
  kill(id) {
164
172
  const session = this.sessions.get(id);
165
173
  if (!session) return;
@@ -2,7 +2,7 @@
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
4
  import { readFileSync, writeFileSync, readdirSync, statSync, mkdirSync, existsSync } from 'fs';
5
- import { execSync } from 'child_process';
5
+ import { execSync, execFileSync } from 'child_process';
6
6
  import { resolve, relative, dirname, sep } from 'path';
7
7
  import { minimatch } from 'minimatch';
8
8
 
@@ -242,12 +242,15 @@ export class ToolExecutor {
242
242
  const timeoutMs = Math.min(timeout || 30000, 120000);
243
243
 
244
244
  try {
245
- const output = execSync(command, {
245
+ // Split command safely for shell: false — avoids shell injection
246
+ const parts = command.split(/\s+/);
247
+ const cmd = parts[0];
248
+ const cmdArgs = parts.slice(1);
249
+ const output = execFileSync(cmd, cmdArgs, {
246
250
  cwd: execCwd,
247
- encoding: 'utf8',
248
251
  timeout: timeoutMs,
249
- maxBuffer: 2 * 1024 * 1024, // 2MB
250
- shell: true,
252
+ maxBuffer: 1024 * 1024,
253
+ encoding: 'utf8',
251
254
  env: { ...process.env, GROOVE_AGENT_ID: this.agentId },
252
255
  });
253
256
  // Cap output to prevent context window blowup
@@ -292,6 +295,9 @@ export class ToolExecutor {
292
295
  }
293
296
 
294
297
  searchContent({ pattern, path: searchPath, glob: globFilter }) {
298
+ if (!/^[a-zA-Z0-9_.\-\/\\*?[\]{}()|^$+\s]+$/.test(pattern)) {
299
+ throw new Error('Invalid search pattern');
300
+ }
295
301
  const searchDir = searchPath ? this._resolvePath(searchPath) : this.workingDir;
296
302
 
297
303
  // Prefer ripgrep (faster, respects .gitignore), fall back to grep
@@ -0,0 +1,69 @@
1
+ // GROOVE — Toys Registry & Launch Logic
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { readFileSync } from 'fs';
5
+ import { resolve, dirname } from 'path';
6
+ import { fileURLToPath } from 'url';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const CATALOG_PATH = resolve(__dirname, '../templates/toys-catalog.json');
10
+ const TOY_ID_PATTERN = /^[a-z0-9-]{1,64}$/;
11
+
12
+ export class Toys {
13
+ constructor(daemon) {
14
+ this.daemon = daemon;
15
+ this.catalog = JSON.parse(readFileSync(CATALOG_PATH, 'utf8'));
16
+ }
17
+
18
+ list(category) {
19
+ if (!category) return this.catalog;
20
+ return this.catalog.filter((t) => t.category === category);
21
+ }
22
+
23
+ get(id) {
24
+ return this.catalog.find((t) => t.id === id) || null;
25
+ }
26
+
27
+ async launch(id, { apiKey, starterPrompt } = {}) {
28
+ if (!id || typeof id !== 'string' || !TOY_ID_PATTERN.test(id)) {
29
+ throw new Error('Invalid toy id');
30
+ }
31
+ if (apiKey !== undefined && apiKey !== null && typeof apiKey !== 'string') {
32
+ throw new Error('apiKey must be a string');
33
+ }
34
+ if (starterPrompt !== undefined && starterPrompt !== null && typeof starterPrompt !== 'string') {
35
+ throw new Error('starterPrompt must be a string');
36
+ }
37
+
38
+ const toy = this.get(id);
39
+ if (!toy) throw new Error(`Toy not found: ${id}`);
40
+
41
+ if (apiKey) {
42
+ this.daemon.credentials.setKey('toy:' + id, apiKey);
43
+ }
44
+
45
+ const team = this.daemon.teams.create('Toy: ' + toy.name);
46
+
47
+ const plannerPrompt =
48
+ 'You are exploring the ' + toy.name + ' API.\n'
49
+ + 'Documentation: ' + toy.docsUrl + '\n'
50
+ + 'Base URL: ' + toy.baseUrl + '\n'
51
+ + (apiKey
52
+ ? 'API Key: ' + apiKey + ' (auth type: ' + toy.authType + ', key goes in: ' + toy.keyHeader + ')\n'
53
+ : 'This API requires no authentication.\n')
54
+ + 'Your first task: Use WebFetch to read the API documentation page at ' + toy.docsUrl + '. Study all available endpoints, data structures, parameters, and rate limits.\n'
55
+ + 'Then present a clear summary of what this API offers and ask the user what they would like to build.\n'
56
+ + (starterPrompt ? 'The user is interested in: ' + starterPrompt + '\n' : '')
57
+ + 'Suggest these project ideas: ' + toy.starterPrompts.join(', ');
58
+
59
+ const agent = await this.daemon.processes.spawn({
60
+ role: 'planner',
61
+ name: 'Toy-' + toy.name.replace(/\s+/g, '-'),
62
+ provider: 'claude-code',
63
+ prompt: plannerPrompt,
64
+ teamId: team.id,
65
+ });
66
+
67
+ return { team, agent, toyId: id };
68
+ }
69
+ }
@@ -56,9 +56,21 @@ export class TunnelManager {
56
56
  );
57
57
  }
58
58
 
59
+ async init() {
60
+ for (const [id, config] of this.saved) {
61
+ if (config.autoConnect) {
62
+ try {
63
+ await this.connect(id);
64
+ } catch (err) {
65
+ this.daemon.broadcast({ type: 'tunnel.error', data: { id, error: err.message } });
66
+ }
67
+ }
68
+ }
69
+ }
70
+
59
71
  getSaved() {
60
72
  return Array.from(this.saved.values()).map(s => ({
61
- ...s,
73
+ ...this._sanitize(s),
62
74
  active: this.active.has(s.id),
63
75
  ...(this.active.get(s.id) || {}),
64
76
  }));
@@ -149,9 +161,9 @@ export class TunnelManager {
149
161
  return merged;
150
162
  }
151
163
 
152
- delete(id) {
164
+ async delete(id) {
153
165
  if (!this.saved.has(id)) throw new Error(`Remote ${id} not found`);
154
- if (this.active.has(id)) this.disconnect(id);
166
+ if (this.active.has(id)) await this.disconnect(id);
155
167
  const name = this.saved.get(id).name;
156
168
  this.saved.delete(id);
157
169
  this._save();
@@ -419,17 +431,24 @@ export class TunnelManager {
419
431
  return { installed: verify.grooveInstalled, daemonRunning: verify.daemonRunning };
420
432
  }
421
433
 
434
+ _sanitize(entry) {
435
+ if (!entry) return entry;
436
+ const { sshKeyPath, ...safe } = entry;
437
+ safe.sshKeyDisplay = sshKeyPath ? sshKeyPath.split('/').pop() : null;
438
+ return safe;
439
+ }
440
+
422
441
  getStatus(id) {
423
442
  const saved = this.saved.get(id);
424
443
  if (!saved) return null;
425
444
  const active = this.active.get(id);
426
- return { ...saved, active: !!active, ...(active || {}) };
445
+ return { ...this._sanitize(saved), active: !!active, ...(active || {}) };
427
446
  }
428
447
 
429
448
  getActive() {
430
449
  return Array.from(this.active.entries()).map(([id, conn]) => ({
431
450
  ...conn,
432
- ...(this.saved.get(id) || {}),
451
+ ...this._sanitize(this.saved.get(id) || {}),
433
452
  id,
434
453
  }));
435
454
  }