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
@@ -40,6 +40,7 @@ import { TunnelManager } from './tunnel-manager.js';
40
40
  import { ModelManager } from './model-manager.js';
41
41
  import { LlamaServerManager } from './llama-server.js';
42
42
  import { RepoImporter } from './repo-import.js';
43
+ import { Toys } from './toys.js';
43
44
  import { isFirstRun, runFirstTimeSetup, loadConfig, saveConfig, printWelcome } from './firstrun.js';
44
45
 
45
46
  const DEFAULT_PORT = 31415;
@@ -141,26 +142,48 @@ export class Daemon {
141
142
  this.mcpManager = new McpManager(this);
142
143
  this.tunnelManager = new TunnelManager(this);
143
144
  this.repoImporter = new RepoImporter(this);
145
+ this.toys = new Toys(this);
146
+
147
+ // Subscription state (populated by Electron IPC or direct auth)
148
+ this.authToken = null;
149
+ this.subscriptionCache = { plan: 'community', status: 'none', features: [], active: false, validatedAt: 0 };
144
150
 
145
151
  // HTTP + WebSocket server
146
152
  this.app = express();
147
153
  this.server = createHttpServer(this.app);
148
- this.wss = new WebSocketServer({
149
- server: this.server,
150
- maxPayload: 1024 * 1024, // 1MB max message
151
- verifyClient: ({ req }) => {
152
- const origin = req.headers.origin;
153
- // Allow: no origin (CLI/native clients)
154
- if (!origin) return true;
155
- try {
156
- const url = new URL(origin);
157
- // Allow any localhost origin (any port — tunnels change the port)
158
- if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') return true;
159
- // Allow the bound interface (for Tailscale/LAN access)
160
- if (this.host !== DEFAULT_HOST && url.hostname === this.host) return true;
161
- } catch { /* invalid origin */ }
162
- return false;
163
- },
154
+
155
+ const verifyOrigin = (req) => {
156
+ const origin = req.headers.origin;
157
+ if (!origin) return true;
158
+ try {
159
+ const url = new URL(origin);
160
+ if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') return true;
161
+ if (this.host !== DEFAULT_HOST && url.hostname === this.host) return true;
162
+ } catch { /* invalid origin */ }
163
+ return false;
164
+ };
165
+
166
+ this.wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
167
+ this.federationWss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
168
+
169
+ this.server.on('upgrade', (req, socket, head) => {
170
+ if (!verifyOrigin(req) && !req.url?.startsWith('/ws/federation')) {
171
+ socket.destroy();
172
+ return;
173
+ }
174
+ if (req.url?.startsWith('/ws/federation')) {
175
+ const daemonId = req.headers['x-groove-daemonid'];
176
+ const signatureHeader = req.headers['x-groove-signature'] || '';
177
+ const callerIp = req.socket?.remoteAddress?.replace('::ffff:', '') || '';
178
+ if (!daemonId) { socket.destroy(); return; }
179
+ this.federationWss.handleUpgrade(req, socket, head, (ws) => {
180
+ this.federation.handleWsUpgrade(ws, daemonId, callerIp, signatureHeader);
181
+ });
182
+ } else {
183
+ this.wss.handleUpgrade(req, socket, head, (ws) => {
184
+ this.wss.emit('connection', ws, req);
185
+ });
186
+ }
164
187
  });
165
188
 
166
189
  // Wire up API routes
@@ -203,18 +226,38 @@ export class Daemon {
203
226
  ws.on('message', (raw) => {
204
227
  try {
205
228
  const msg = JSON.parse(raw);
229
+
230
+ // Validate message type against whitelist
231
+ const VALID_WS_TYPES = new Set([
232
+ 'terminal:spawn', 'terminal:resize', 'terminal:input', 'terminal:close', 'terminal:kill', 'terminal:rename',
233
+ 'editor:watch', 'editor:unwatch', 'editor:save',
234
+ 'ping'
235
+ ]);
236
+ if (!msg || typeof msg !== 'object' || !VALID_WS_TYPES.has(msg.type)) return;
237
+ if (Object.hasOwn(msg, '__proto__') || Object.hasOwn(msg, 'constructor')) return;
238
+
206
239
  switch (msg.type) {
207
240
  // File editor
208
241
  case 'editor:watch':
209
- if (msg.path) { this.fileWatcher.watch(msg.path); watchedFiles.add(msg.path); }
242
+ if (msg.path && typeof msg.path === 'string' && !msg.path.includes('..')) {
243
+ this.fileWatcher.watch(msg.path); watchedFiles.add(msg.path);
244
+ }
210
245
  break;
211
246
  case 'editor:unwatch':
212
247
  if (msg.path) { this.fileWatcher.unwatch(msg.path); watchedFiles.delete(msg.path); }
213
248
  break;
214
249
  // Terminal
215
250
  case 'terminal:spawn': {
216
- const id = this.terminalManager.spawn(ws, { cwd: msg.cwd, cols: msg.cols, rows: msg.rows });
217
- ws.send(JSON.stringify({ type: 'terminal:spawned', id }));
251
+ if (msg.cwd !== undefined && (typeof msg.cwd !== 'string' || msg.cwd.includes('..'))) break;
252
+ if (msg.cols !== undefined && (typeof msg.cols !== 'number' || msg.cols < 1 || msg.cols > 500)) break;
253
+ if (msg.rows !== undefined && (typeof msg.rows !== 'number' || msg.rows < 1 || msg.rows > 200)) break;
254
+ try {
255
+ const id = this.terminalManager.spawn(ws, { cwd: msg.cwd, cols: msg.cols, rows: msg.rows });
256
+ ws.send(JSON.stringify({ type: 'terminal:spawned', id }));
257
+ } catch (err) {
258
+ console.error('[terminal] spawn error:', err);
259
+ ws.send(JSON.stringify({ type: 'terminal:error', message: err.message }));
260
+ }
218
261
  break;
219
262
  }
220
263
  case 'terminal:input':
@@ -226,6 +269,13 @@ export class Daemon {
226
269
  case 'terminal:kill':
227
270
  if (msg.id) this.terminalManager.kill(msg.id);
228
271
  break;
272
+ case 'terminal:rename':
273
+ if (msg.id && typeof msg.label === 'string') {
274
+ if (this.terminalManager.rename(msg.id, msg.label)) {
275
+ this.broadcast({ type: 'terminal:renamed', id: msg.id, label: msg.label });
276
+ }
277
+ }
278
+ break;
229
279
  }
230
280
  } catch { /* ignore malformed messages */ }
231
281
  });
@@ -248,6 +298,7 @@ export class Daemon {
248
298
  }
249
299
 
250
300
  broadcast(message) {
301
+ if (!this.wss) return;
251
302
  const payload = JSON.stringify(message);
252
303
  for (const client of this.wss.clients) {
253
304
  if (client.readyState === 1) {
@@ -256,6 +307,66 @@ export class Daemon {
256
307
  }
257
308
  }
258
309
 
310
+ async setAuthToken(token) {
311
+ this.authToken = token;
312
+ if (token) {
313
+ await this._pollSubscription();
314
+ // Fallback: if external API failed, try syncing from stored user data
315
+ if (!this.subscriptionCache?.active) {
316
+ this.skills?._syncSubscriptionCache(this.skills?.getUser());
317
+ }
318
+ } else {
319
+ this.subscriptionCache = { plan: 'community', status: 'none', features: [], active: false, validatedAt: Date.now() };
320
+ this.broadcast({ type: 'subscription:updated', data: this.subscriptionCache });
321
+ }
322
+ }
323
+
324
+ async _pollSubscription() {
325
+ if (!this.authToken) return;
326
+ const API_BASE = 'https://docs.groovedev.ai/api/v1';
327
+ const delays = [0, 5000, 15000, 30000];
328
+ for (let attempt = 0; attempt < delays.length; attempt++) {
329
+ if (attempt > 0) await new Promise((r) => setTimeout(r, delays[attempt]));
330
+ try {
331
+ const resp = await fetch(`${API_BASE}/subscription/status`, {
332
+ headers: { 'Authorization': `Bearer ${this.authToken}` },
333
+ signal: AbortSignal.timeout(10000),
334
+ });
335
+ if (resp.status === 401) {
336
+ this.subscriptionCache = { plan: 'community', status: 'none', features: [], active: false, validatedAt: Date.now() };
337
+ this.broadcast({ type: 'subscription:updated', data: this.subscriptionCache });
338
+ this.broadcast({ type: 'auth:expired' });
339
+ return;
340
+ }
341
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
342
+ const data = await resp.json();
343
+ this.subscriptionCache = {
344
+ plan: data.plan || 'community',
345
+ status: data.status || 'none',
346
+ features: data.features || [],
347
+ active: data.status === 'active' || data.status === 'trialing',
348
+ seats: data.seats || 1,
349
+ periodEnd: data.periodEnd || null,
350
+ cancelAtPeriodEnd: data.cancelAtPeriodEnd || false,
351
+ validatedAt: Date.now(),
352
+ };
353
+ this.broadcast({ type: 'subscription:updated', data: this.subscriptionCache });
354
+ return;
355
+ } catch (err) {
356
+ if (attempt < delays.length - 1) {
357
+ console.log(`[Groove:Subscription] Attempt ${attempt + 1} failed, retrying in ${delays[attempt + 1] / 1000}s...`);
358
+ continue;
359
+ }
360
+ if (this.subscriptionCache?.validatedAt && (Date.now() - this.subscriptionCache.validatedAt < 72 * 3600 * 1000)) {
361
+ console.log('[Groove:Subscription] External API unreachable, keeping cached subscription');
362
+ return;
363
+ }
364
+ this.subscriptionCache = { plan: 'community', status: 'none', features: [], active: false, validatedAt: 0 };
365
+ this.broadcast({ type: 'subscription:updated', data: this.subscriptionCache });
366
+ }
367
+ }
368
+ }
369
+
259
370
  async start() {
260
371
  // Kill any existing daemon on our port
261
372
  if (existsSync(this.pidFile)) {
@@ -317,6 +428,8 @@ export class Daemon {
317
428
 
318
429
  return new Promise((resolvePromise) => {
319
430
  this.server.listen(this.port, this.host, () => {
431
+ // Read back actual port (critical for port 0 / dynamic allocation)
432
+ this.port = this.server.address().port;
320
433
  writeFileSync(this.pidFile, String(process.pid));
321
434
  // Write actual port and host so CLI can find us
322
435
  writeFileSync(resolve(this.grooveDir, 'daemon.port'), String(this.port));
@@ -330,8 +443,34 @@ export class Daemon {
330
443
  this.scheduler.start();
331
444
  this.timeline.start();
332
445
  this.gateways.start();
446
+ this.federation.initialize();
333
447
  this._startGarbageCollector();
334
448
 
449
+ // Restore auth token from stored config so subscription polling works after restart
450
+ const storedToken = this.skills.getToken();
451
+ if (storedToken) {
452
+ this.authToken = storedToken;
453
+ this._pollSubscription().catch(() => {});
454
+ }
455
+
456
+ // Re-validate subscription every 30 minutes
457
+ this._subscriptionPollInterval = setInterval(() => {
458
+ this._pollSubscription().catch(() => {});
459
+ }, 30 * 60 * 1000);
460
+
461
+ // Classifier broadcasting — decoupled from stdout handler
462
+ // Runs every 30s, checks for classification changes, broadcasts to GUI
463
+ this._classifierInterval = setInterval(() => {
464
+ try {
465
+ const updates = this.classifier.getUpdates();
466
+ for (const update of updates) {
467
+ this.broadcast({ type: 'classifier:update', data: update });
468
+ }
469
+ } catch {
470
+ // Never let classifier broadcasting break the daemon
471
+ }
472
+ }, 30_000);
473
+
335
474
  // Scan codebase for workspace/structure awareness
336
475
  this.indexer.scan();
337
476
 
@@ -353,6 +492,9 @@ export class Daemon {
353
492
  this.tokens.setProjectStats(stats.totalFiles, stats.totalDirs);
354
493
  }
355
494
 
495
+ // Auto-connect saved tunnels that have autoConnect enabled
496
+ this.tunnelManager.init();
497
+
356
498
  resolvePromise(this);
357
499
  });
358
500
  });
@@ -362,6 +504,11 @@ export class Daemon {
362
504
  // Run once on startup, then every 24 hours
363
505
  this._gc();
364
506
  this._gcInterval = setInterval(() => this._gc(), 24 * 60 * 60 * 1000);
507
+
508
+ // Periodic state save — crash protection (every 30s)
509
+ this._stateSaveInterval = setInterval(() => {
510
+ try { this.state.set('agents', this.registry.getAll()); this.state.save(); } catch {}
511
+ }, 30000);
365
512
  }
366
513
 
367
514
  _gc() {
@@ -449,11 +596,17 @@ export class Daemon {
449
596
  this.scheduler.stop();
450
597
  this.timeline.stop();
451
598
  if (this._gcInterval) clearInterval(this._gcInterval);
599
+ if (this._stateSaveInterval) clearInterval(this._stateSaveInterval);
600
+ if (this._classifierInterval) clearInterval(this._classifierInterval);
601
+ if (this._subscriptionPollInterval) clearInterval(this._subscriptionPollInterval);
452
602
 
453
603
  // Clean up file watchers and terminal sessions
454
604
  this.fileWatcher.unwatchAll();
455
605
  this.terminalManager.killAll();
456
606
 
607
+ // Clean up federation (whitelist probing, connections, ambassadors)
608
+ this.federation.destroy();
609
+
457
610
  // Disconnect all SSH tunnels
458
611
  this.tunnelManager.shutdown();
459
612
 
@@ -14,7 +14,7 @@ export class Introducer {
14
14
  }
15
15
 
16
16
  generateContext(newAgent, options = {}) {
17
- const { taskNegotiation } = options;
17
+ const { taskNegotiation, hasTask } = options;
18
18
  const agents = this.daemon.registry.getAll();
19
19
  // Only include ACTIVE agents — not completed/killed ones from previous sessions
20
20
  // Completed agents' work is captured in the journalist's project map, not here
@@ -178,11 +178,11 @@ export class Introducer {
178
178
  lines.push(`If you see files that seem unrelated to your task, leave them alone — they belong to another project or agent.`);
179
179
 
180
180
  // Memory containment — prevent agents from reading/writing auto-memory
181
- // which can contain stale context from unrelated sessions in the same dir
181
+ // GROOVE manages project memory automatically via Layer 7
182
182
  lines.push('');
183
183
  lines.push(`## Memory Policy`);
184
184
  lines.push('');
185
- lines.push(`Ignore auto-memory. Do NOT read or write MEMORY.md or any files in the auto-memory directory.`);
185
+ lines.push(`GROOVE manages project memory automatically. Do not read or write MEMORY.md or .groove/memory/ files directly.`);
186
186
  lines.push(`GROOVE provides all your project context through handoff briefs, AGENTS_REGISTRY.md, and GROOVE_PROJECT_MAP.md.`);
187
187
  lines.push(`Do NOT save memories — your state is managed by GROOVE's rotation and handoff system.`);
188
188
 
@@ -312,15 +312,24 @@ export class Introducer {
312
312
  lines.push('To clone a NEW GitHub repo, use: `POST http://localhost:31415/api/repos/import` with `{ "repoUrl": "...", "targetPath": "~/Projects/name", "createTeam": true }`. Do NOT run `git clone` directly.');
313
313
 
314
314
  // Surface stored API keys so agents know what's available in their environment
315
+ // Only inject provider API keys (codex, gemini, ollama) and integration credentials
316
+ // that are relevant to this agent's attached integrations — skip OAuth boilerplate
315
317
  const KEY_MAP = { codex: 'OPENAI_API_KEY', gemini: 'GEMINI_API_KEY', ollama: 'OLLAMA_API_KEY' };
318
+ const agentIntegrations = new Set(newAgent.integrations || []);
316
319
  try {
317
320
  const credProviders = this.daemon.credentials?.listProviders() || [];
318
- if (credProviders.length > 0) {
321
+ const relevant = credProviders.filter((cp) => {
322
+ if (KEY_MAP[cp.provider]) return true;
323
+ if (!cp.provider.startsWith('integration:')) return true;
324
+ const parts = cp.provider.split(':');
325
+ return parts.length >= 2 && agentIntegrations.has(parts[1]);
326
+ });
327
+ if (relevant.length > 0) {
319
328
  lines.push('');
320
329
  lines.push('## Available API Keys');
321
330
  lines.push('');
322
331
  lines.push('GROOVE has API keys stored and injected into your environment. Do NOT ask the user for these:');
323
- for (const cp of credProviders) {
332
+ for (const cp of relevant) {
324
333
  const envVar = KEY_MAP[cp.provider];
325
334
  if (envVar) {
326
335
  lines.push(`- **${cp.provider}**: available as \`${envVar}\` in your environment`);
@@ -333,7 +342,43 @@ export class Introducer {
333
342
  }
334
343
  } catch { /* credentials not available */ }
335
344
 
336
- return lines.join('\n');
345
+ // --- Layer 7: Project Memory (injected at end, bounded) ---
346
+ let memorySection = '';
347
+ try {
348
+ if (this.daemon.memory) {
349
+ const parts = [];
350
+
351
+ const constraints = this.daemon.memory.getConstraintsMarkdown(2000);
352
+ if (constraints) {
353
+ parts.push(`### Constraints (read carefully)\n${constraints}`);
354
+ }
355
+
356
+ if (hasTask) {
357
+ const discoveries = this.daemon.memory.getDiscoveriesMarkdown(newAgent.role, 15, 1000);
358
+ if (discoveries) {
359
+ parts.push(`### Known Fixes for ${newAgent.role} Role\n${discoveries}`);
360
+ }
361
+
362
+ const handoffs = this.daemon.memory.getRecentHandoffMarkdown(newAgent.role, 2, 1000, newAgent.workingDir);
363
+ if (handoffs) {
364
+ parts.push(`### Recent Handoff History\n${handoffs}`);
365
+ }
366
+ }
367
+
368
+ if (parts.length > 0) {
369
+ memorySection = `\n## Project Memory (auto-generated)\n\n${parts.join('\n\n')}\n`;
370
+ // Hard budget: 4K chars total
371
+ if (memorySection.length > 4000) {
372
+ memorySection = memorySection.slice(0, 3997) + '...';
373
+ }
374
+ }
375
+ }
376
+ } catch {
377
+ // Memory injection must never break agent spawn
378
+ memorySection = '';
379
+ }
380
+
381
+ return lines.join('\n') + memorySection;
337
382
  }
338
383
 
339
384
  loadArchitectureDoc() {
@@ -416,7 +461,7 @@ export class Introducer {
416
461
  '',
417
462
  `See AGENTS_REGISTRY.md for full agent state.`,
418
463
  '',
419
- `**Memory policy:** Ignore auto-memory. Do not read or write MEMORY.md. GROOVE manages all context.`,
464
+ `**Memory policy:** GROOVE manages project memory automatically. Do not read or write MEMORY.md or .groove/memory/ files directly.`,
420
465
  '',
421
466
  GROOVE_SECTION_END,
422
467
  ].filter(Boolean).join('\n');
@@ -92,6 +92,10 @@ export class Journalist {
92
92
  this.writeDecisionsLog(synthesis.decisions);
93
93
  this.writeAgentSessionLogs(activeAgents, filteredLogs);
94
94
 
95
+ // Post-synthesis: extract Layer 7 memory from agent logs
96
+ this._extractDiscoveries(filteredLogs);
97
+ this._extractConstraints(filteredLogs);
98
+
95
99
  this.lastSynthesis = synthesis;
96
100
  this.history.push({
97
101
  cycle: this.cycleCount,
@@ -733,7 +737,7 @@ export class Journalist {
733
737
  // Pull recent rotation history from persistent memory (Layer 7).
734
738
  // Gives the new agent causal continuity: what the last 3 agents struggled
735
739
  // with, decided, and solved — not just what the current session did.
736
- const recentChain = this.daemon.memory?.getRecentHandoffMarkdown(agent.role, 3, 3000) || '';
740
+ const recentChain = this.daemon.memory?.getRecentHandoffMarkdown(agent.role, 3, 3000, agent.workingDir) || '';
737
741
 
738
742
  // Pull the user's recent messages to this agent so the new instance
739
743
  // can continue the conversation naturally instead of restarting it.
@@ -967,6 +971,47 @@ export class Journalist {
967
971
  return all.sort((a, b) => a.timestamp.localeCompare(b.timestamp)).slice(-30);
968
972
  }
969
973
 
974
+ // --- Layer 7 Memory Extraction ---
975
+
976
+ _extractDiscoveries(filteredLogs) {
977
+ if (!this.daemon.memory) return;
978
+
979
+ try {
980
+ for (const [agentId, data] of Object.entries(filteredLogs)) {
981
+ const { agent, entries } = data;
982
+ if (!entries || entries.length === 0) continue;
983
+
984
+ for (let i = 0; i < entries.length - 1; i++) {
985
+ const entry = entries[i];
986
+ if (entry.type !== 'error') continue;
987
+
988
+ const trigger = (entry.text || '').trim();
989
+ if (trigger.length < 20) continue;
990
+
991
+ for (let j = i + 1; j < Math.min(i + 6, entries.length); j++) {
992
+ const next = entries[j];
993
+ if (next.type !== 'tool' || (next.tool !== 'Edit' && next.tool !== 'Write')) continue;
994
+ const fix = (next.input || '').trim();
995
+ if (fix.length < 10) continue;
996
+
997
+ this.daemon.memory.addDiscovery({
998
+ agentId: agent.id,
999
+ role: agent.role,
1000
+ trigger: trigger.slice(0, 300),
1001
+ fix: fix.slice(0, 500),
1002
+ outcome: 'success',
1003
+ });
1004
+ break;
1005
+ }
1006
+ }
1007
+ }
1008
+ } catch { /* Memory extraction must never break journalist cycle */ }
1009
+ }
1010
+
1011
+ _extractConstraints(filteredLogs) {
1012
+ return;
1013
+ }
1014
+
970
1015
  // --- Accessors ---
971
1016
 
972
1017
  getLastSynthesis() {
@@ -10,7 +10,7 @@
10
10
  // Read by the introducer on every spawn so agent #50 knows what agent #1 learned.
11
11
 
12
12
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, appendFileSync, statSync } from 'fs';
13
- import { resolve } from 'path';
13
+ import { resolve, relative } from 'path';
14
14
  import { createHash } from 'crypto';
15
15
 
16
16
  const MAX_CONSTRAINTS = 50;
@@ -34,6 +34,7 @@ function truncate(text, max) {
34
34
  export class MemoryStore {
35
35
  constructor(grooveDir) {
36
36
  this.memDir = resolve(grooveDir, 'memory');
37
+ this.projectDir = resolve(grooveDir, '..');
37
38
  this.constraintsPath = resolve(this.memDir, 'project-constraints.md');
38
39
  this.handoffDir = resolve(this.memDir, 'handoff-chain');
39
40
  this.discoveriesPath = resolve(this.memDir, 'agent-discoveries.jsonl');
@@ -133,19 +134,29 @@ export class MemoryStore {
133
134
 
134
135
  // --- Handoff Chain ---
135
136
 
136
- _chainPath(role) {
137
+ _workspaceSlug(workingDir) {
138
+ if (!workingDir) return '';
139
+ const rel = relative(this.projectDir, workingDir);
140
+ if (!rel || rel === '.' || rel.startsWith('..')) return '';
141
+ return safeName(rel);
142
+ }
143
+
144
+ _chainPath(role, workingDir) {
145
+ const slug = this._workspaceSlug(workingDir);
146
+ if (slug) {
147
+ const dir = resolve(this.handoffDir, slug);
148
+ mkdirSync(dir, { recursive: true });
149
+ return resolve(dir, `${safeName(role)}.md`);
150
+ }
137
151
  return resolve(this.handoffDir, `${safeName(role)}.md`);
138
152
  }
139
153
 
140
- getHandoffChain(role) {
141
- const path = this._chainPath(role);
154
+ getHandoffChain(role, workingDir) {
155
+ const path = this._chainPath(role, workingDir);
142
156
  if (!existsSync(path)) return [];
143
157
  try {
144
158
  const content = readFileSync(path, 'utf8');
145
159
  const entries = [];
146
- // Parse entries — each starts with "## Rotation N —"
147
- // Body includes the header + all content up to (but not including) the
148
- // trailing --- separator and the next entry.
149
160
  const blocks = content.split(/\n(?=## Rotation )/);
150
161
  for (const block of blocks) {
151
162
  const headerMatch = block.match(/^## Rotation (\d+) —/);
@@ -162,9 +173,9 @@ export class MemoryStore {
162
173
  }
163
174
  }
164
175
 
165
- appendHandoffBrief(role, entry) {
176
+ appendHandoffBrief(role, entry, workingDir) {
166
177
  if (!role || !entry) return false;
167
- const chain = this.getHandoffChain(role);
178
+ const chain = this.getHandoffChain(role, workingDir);
168
179
  const nextN = (chain[0]?.rotationN || 0) + 1;
169
180
 
170
181
  const block = [
@@ -178,7 +189,6 @@ export class MemoryStore {
178
189
  '',
179
190
  ].filter(Boolean).join('\n');
180
191
 
181
- // Prepend new entry (newest first), keep last N
182
192
  const newChain = [{ rotationN: nextN, body: block }, ...chain].slice(0, MAX_HANDOFF_ROTATIONS);
183
193
 
184
194
  const lines = [
@@ -193,25 +203,27 @@ export class MemoryStore {
193
203
  }
194
204
 
195
205
  try {
196
- writeFileSync(this._chainPath(role), lines.join('\n'));
206
+ writeFileSync(this._chainPath(role, workingDir), lines.join('\n'));
197
207
  return true;
198
208
  } catch {
199
209
  return false;
200
210
  }
201
211
  }
202
212
 
203
- getRecentHandoffMarkdown(role, count = 3, maxChars = 4000) {
204
- const chain = this.getHandoffChain(role);
213
+ getRecentHandoffMarkdown(role, count = 3, maxChars = 4000, workingDir) {
214
+ const chain = this.getHandoffChain(role, workingDir);
205
215
  if (chain.length === 0) return '';
206
216
  const recent = chain.slice(0, count);
207
217
  const out = recent.map((e) => e.body || '').join('\n\n---\n\n');
208
218
  return truncate(out, maxChars);
209
219
  }
210
220
 
211
- listHandoffRoles() {
212
- if (!existsSync(this.handoffDir)) return [];
221
+ listHandoffRoles(workingDir) {
222
+ const slug = this._workspaceSlug(workingDir);
223
+ const dir = slug ? resolve(this.handoffDir, slug) : this.handoffDir;
224
+ if (!existsSync(dir)) return [];
213
225
  try {
214
- return readdirSync(this.handoffDir)
226
+ return readdirSync(dir)
215
227
  .filter((f) => f.endsWith('.md'))
216
228
  .map((f) => f.replace(/\.md$/, ''));
217
229
  } catch {
@@ -225,6 +237,14 @@ export class MemoryStore {
225
237
  if (!trigger || !fix) return { added: false, error: 'trigger and fix required' };
226
238
  if (outcome !== 'success') return { added: false, reason: 'only successes stored' };
227
239
 
240
+ const fileExtPattern = /[\w./-]+\.(?:js|ts|json|md|jsx|tsx|css|mjs|cjs)\b/g;
241
+ const triggerTokens = new Set((String(trigger).match(fileExtPattern) || []).map(t => t.toLowerCase()));
242
+ const fixTokens = new Set((String(fix).match(fileExtPattern) || []).map(t => t.toLowerCase()));
243
+ const hasOverlap = [...triggerTokens].some(t => fixTokens.has(t));
244
+ if (triggerTokens.size > 0 && fixTokens.size > 0 && !hasOverlap) {
245
+ return { added: false, reason: 'trigger and fix are unrelated' };
246
+ }
247
+
228
248
  const entry = {
229
249
  ts: new Date().toISOString(),
230
250
  agentId: agentId || null,