groove-dev 0.27.15 → 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 (170) hide show
  1. package/CLAUDE.md +0 -10
  2. package/README.md +37 -1
  3. package/developerID_application.cer +0 -0
  4. package/node_modules/@groove-dev/daemon/src/api.js +586 -67
  5. package/node_modules/@groove-dev/daemon/src/classifier.js +24 -0
  6. package/node_modules/@groove-dev/daemon/src/credentials.js +12 -2
  7. package/node_modules/@groove-dev/daemon/src/federation/ambassador.js +204 -0
  8. package/node_modules/@groove-dev/daemon/src/federation/connection.js +359 -0
  9. package/node_modules/@groove-dev/daemon/src/federation/contracts.js +112 -0
  10. package/node_modules/@groove-dev/daemon/src/federation/whitelist.js +190 -0
  11. package/node_modules/@groove-dev/daemon/src/federation.js +166 -7
  12. package/node_modules/@groove-dev/daemon/src/index.js +172 -19
  13. package/node_modules/@groove-dev/daemon/src/introducer.js +52 -7
  14. package/node_modules/@groove-dev/daemon/src/journalist.js +46 -1
  15. package/node_modules/@groove-dev/daemon/src/memory.js +36 -16
  16. package/node_modules/@groove-dev/daemon/src/process.js +140 -23
  17. package/node_modules/@groove-dev/daemon/src/providers/base.js +1 -0
  18. package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +1 -0
  19. package/node_modules/@groove-dev/daemon/src/providers/codex.js +124 -28
  20. package/node_modules/@groove-dev/daemon/src/providers/gemini.js +104 -17
  21. package/node_modules/@groove-dev/daemon/src/providers/index.js +17 -0
  22. package/node_modules/@groove-dev/daemon/src/registry.js +10 -1
  23. package/node_modules/@groove-dev/daemon/src/rotator.js +93 -30
  24. package/node_modules/@groove-dev/daemon/src/skills.js +33 -3
  25. package/node_modules/@groove-dev/daemon/src/terminal-pty.js +9 -1
  26. package/node_modules/@groove-dev/daemon/src/tool-executor.js +11 -5
  27. package/node_modules/@groove-dev/daemon/src/toys.js +69 -0
  28. package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +24 -5
  29. package/node_modules/@groove-dev/daemon/templates/toys-catalog.json +242 -0
  30. package/node_modules/@groove-dev/daemon/test/classifier.test.js +98 -0
  31. package/node_modules/@groove-dev/daemon/test/introducer.test.js +72 -1
  32. package/node_modules/@groove-dev/daemon/test/journalist.test.js +117 -0
  33. package/node_modules/@groove-dev/daemon/test/memory.test.js +37 -1
  34. package/node_modules/@groove-dev/daemon/test/rotator.test.js +183 -4
  35. package/node_modules/@groove-dev/gui/dist/assets/index-BglPgjlu.js +8607 -0
  36. package/node_modules/@groove-dev/gui/dist/assets/index-CGcwmmJv.css +1 -0
  37. package/node_modules/@groove-dev/gui/dist/index.html +3 -2
  38. package/node_modules/@groove-dev/gui/index.html +1 -0
  39. package/node_modules/@groove-dev/gui/src/app.css +7 -0
  40. package/node_modules/@groove-dev/gui/src/app.jsx +37 -10
  41. package/node_modules/@groove-dev/gui/src/components/agents/agent-chat.jsx +21 -31
  42. package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +11 -6
  43. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +2 -2
  44. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +42 -1
  45. package/node_modules/@groove-dev/gui/src/components/editor/breadcrumbs.jsx +30 -0
  46. package/node_modules/@groove-dev/gui/src/components/editor/code-editor.jsx +33 -2
  47. package/node_modules/@groove-dev/gui/src/components/editor/editor-status-bar.jsx +26 -0
  48. package/node_modules/@groove-dev/gui/src/components/editor/editor-tabs.jsx +113 -34
  49. package/node_modules/@groove-dev/gui/src/components/editor/goto-line.jsx +35 -0
  50. package/node_modules/@groove-dev/gui/src/components/editor/terminal.jsx +12 -6
  51. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +13 -3
  52. package/node_modules/@groove-dev/gui/src/components/layout/app-shell.jsx +0 -1
  53. package/node_modules/@groove-dev/gui/src/components/layout/breadcrumb-bar.jsx +165 -47
  54. package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +6 -2
  55. package/node_modules/@groove-dev/gui/src/components/layout/terminal-panel.jsx +10 -9
  56. package/node_modules/@groove-dev/gui/src/components/marketplace/repo-import.jsx +9 -1
  57. package/node_modules/@groove-dev/gui/src/components/onboarding/provider-card.jsx +134 -0
  58. package/node_modules/@groove-dev/gui/src/components/onboarding/setup-wizard.jsx +819 -0
  59. package/node_modules/@groove-dev/gui/src/components/pro/pro-gate.jsx +12 -5
  60. package/node_modules/@groove-dev/gui/src/components/pro/upgrade-card.jsx +15 -8
  61. package/node_modules/@groove-dev/gui/src/components/pro/upgrade-modal.jsx +151 -0
  62. package/node_modules/@groove-dev/gui/src/components/settings/federation-activity.jsx +98 -0
  63. package/node_modules/@groove-dev/gui/src/components/settings/federation-panel.jsx +290 -0
  64. package/node_modules/@groove-dev/gui/src/components/settings/federation-peers.jsx +126 -0
  65. package/node_modules/@groove-dev/gui/src/components/settings/federation-wizard.jsx +293 -0
  66. package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +110 -67
  67. package/node_modules/@groove-dev/gui/src/components/settings/remote-server-card.jsx +3 -3
  68. package/node_modules/@groove-dev/gui/src/components/settings/server-detail.jsx +310 -0
  69. package/node_modules/@groove-dev/gui/src/components/settings/server-dialog.jsx +4 -1
  70. package/node_modules/@groove-dev/gui/src/components/settings/server-list.jsx +59 -0
  71. package/node_modules/@groove-dev/gui/src/components/settings/ssh-wizard.jsx +549 -0
  72. package/node_modules/@groove-dev/gui/src/components/toys/toy-card.jsx +78 -0
  73. package/node_modules/@groove-dev/gui/src/components/toys/toy-creator.jsx +144 -0
  74. package/node_modules/@groove-dev/gui/src/components/toys/toy-launcher.jsx +187 -0
  75. package/node_modules/@groove-dev/gui/src/components/ui/toast.jsx +2 -2
  76. package/node_modules/@groove-dev/gui/src/lib/electron.js +15 -0
  77. package/node_modules/@groove-dev/gui/src/lib/format.js +1 -0
  78. package/node_modules/@groove-dev/gui/src/stores/groove.js +373 -58
  79. package/node_modules/@groove-dev/gui/src/views/agents.jsx +148 -42
  80. package/node_modules/@groove-dev/gui/src/views/editor.jsx +92 -2
  81. package/node_modules/@groove-dev/gui/src/views/federation.jsx +37 -0
  82. package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +2 -42
  83. package/node_modules/@groove-dev/gui/src/views/settings.jsx +32 -132
  84. package/node_modules/@groove-dev/gui/src/views/subscription-panel.jsx +327 -0
  85. package/node_modules/@groove-dev/gui/src/views/teams.jsx +3 -3
  86. package/node_modules/@groove-dev/gui/src/views/toys.jsx +162 -0
  87. package/package.json +1 -1
  88. package/packages/daemon/src/api.js +586 -67
  89. package/packages/daemon/src/classifier.js +24 -0
  90. package/packages/daemon/src/credentials.js +12 -2
  91. package/packages/daemon/src/federation/ambassador.js +204 -0
  92. package/packages/daemon/src/federation/connection.js +359 -0
  93. package/packages/daemon/src/federation/contracts.js +112 -0
  94. package/packages/daemon/src/federation/whitelist.js +190 -0
  95. package/packages/daemon/src/federation.js +166 -7
  96. package/packages/daemon/src/index.js +172 -19
  97. package/packages/daemon/src/introducer.js +52 -7
  98. package/packages/daemon/src/journalist.js +46 -1
  99. package/packages/daemon/src/memory.js +36 -16
  100. package/packages/daemon/src/process.js +140 -23
  101. package/packages/daemon/src/providers/base.js +1 -0
  102. package/packages/daemon/src/providers/claude-code.js +1 -0
  103. package/packages/daemon/src/providers/codex.js +124 -28
  104. package/packages/daemon/src/providers/gemini.js +104 -17
  105. package/packages/daemon/src/providers/index.js +17 -0
  106. package/packages/daemon/src/registry.js +10 -1
  107. package/packages/daemon/src/rotator.js +93 -30
  108. package/packages/daemon/src/skills.js +33 -3
  109. package/packages/daemon/src/terminal-pty.js +9 -1
  110. package/packages/daemon/src/tool-executor.js +11 -5
  111. package/packages/daemon/src/toys.js +69 -0
  112. package/packages/daemon/src/tunnel-manager.js +24 -5
  113. package/packages/daemon/templates/toys-catalog.json +242 -0
  114. package/packages/gui/dist/assets/index-BglPgjlu.js +8607 -0
  115. package/packages/gui/dist/assets/index-CGcwmmJv.css +1 -0
  116. package/packages/gui/dist/index.html +3 -2
  117. package/packages/gui/index.html +1 -0
  118. package/packages/gui/src/app.css +7 -0
  119. package/packages/gui/src/app.jsx +37 -10
  120. package/packages/gui/src/components/agents/agent-chat.jsx +21 -31
  121. package/packages/gui/src/components/agents/agent-config.jsx +11 -6
  122. package/packages/gui/src/components/agents/agent-feed.jsx +2 -2
  123. package/packages/gui/src/components/agents/spawn-wizard.jsx +42 -1
  124. package/packages/gui/src/components/editor/breadcrumbs.jsx +30 -0
  125. package/packages/gui/src/components/editor/code-editor.jsx +33 -2
  126. package/packages/gui/src/components/editor/editor-status-bar.jsx +26 -0
  127. package/packages/gui/src/components/editor/editor-tabs.jsx +113 -34
  128. package/packages/gui/src/components/editor/goto-line.jsx +35 -0
  129. package/packages/gui/src/components/editor/terminal.jsx +12 -6
  130. package/packages/gui/src/components/layout/activity-bar.jsx +13 -3
  131. package/packages/gui/src/components/layout/app-shell.jsx +0 -1
  132. package/packages/gui/src/components/layout/breadcrumb-bar.jsx +165 -47
  133. package/packages/gui/src/components/layout/command-palette.jsx +6 -2
  134. package/packages/gui/src/components/layout/terminal-panel.jsx +10 -9
  135. package/packages/gui/src/components/marketplace/repo-import.jsx +9 -1
  136. package/packages/gui/src/components/onboarding/provider-card.jsx +134 -0
  137. package/packages/gui/src/components/onboarding/setup-wizard.jsx +819 -0
  138. package/packages/gui/src/components/pro/pro-gate.jsx +12 -5
  139. package/packages/gui/src/components/pro/upgrade-card.jsx +15 -8
  140. package/packages/gui/src/components/pro/upgrade-modal.jsx +151 -0
  141. package/packages/gui/src/components/settings/federation-activity.jsx +98 -0
  142. package/packages/gui/src/components/settings/federation-panel.jsx +290 -0
  143. package/packages/gui/src/components/settings/federation-peers.jsx +126 -0
  144. package/packages/gui/src/components/settings/federation-wizard.jsx +293 -0
  145. package/packages/gui/src/components/settings/quick-connect.jsx +110 -67
  146. package/packages/gui/src/components/settings/remote-server-card.jsx +3 -3
  147. package/packages/gui/src/components/settings/server-detail.jsx +310 -0
  148. package/packages/gui/src/components/settings/server-dialog.jsx +4 -1
  149. package/packages/gui/src/components/settings/server-list.jsx +59 -0
  150. package/packages/gui/src/components/settings/ssh-wizard.jsx +549 -0
  151. package/packages/gui/src/components/toys/toy-card.jsx +78 -0
  152. package/packages/gui/src/components/toys/toy-creator.jsx +144 -0
  153. package/packages/gui/src/components/toys/toy-launcher.jsx +187 -0
  154. package/packages/gui/src/components/ui/toast.jsx +2 -2
  155. package/packages/gui/src/lib/electron.js +15 -0
  156. package/packages/gui/src/lib/format.js +1 -0
  157. package/packages/gui/src/stores/groove.js +373 -58
  158. package/packages/gui/src/views/agents.jsx +148 -42
  159. package/packages/gui/src/views/editor.jsx +92 -2
  160. package/packages/gui/src/views/federation.jsx +37 -0
  161. package/packages/gui/src/views/marketplace.jsx +2 -42
  162. package/packages/gui/src/views/settings.jsx +32 -132
  163. package/packages/gui/src/views/subscription-panel.jsx +327 -0
  164. package/packages/gui/src/views/teams.jsx +3 -3
  165. package/packages/gui/src/views/toys.jsx +162 -0
  166. package/plans/chat-persistence-refactor.md +154 -0
  167. package/node_modules/@groove-dev/gui/dist/assets/index-BE6lYcd7.css +0 -1
  168. package/node_modules/@groove-dev/gui/dist/assets/index-zdzOLAZM.js +0 -677
  169. package/packages/gui/dist/assets/index-BE6lYcd7.css +0 -1
  170. package/packages/gui/dist/assets/index-zdzOLAZM.js +0 -677
@@ -4,7 +4,8 @@
4
4
  import express from 'express';
5
5
  import { resolve, dirname } from 'path';
6
6
  import { fileURLToPath } from 'url';
7
- import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, unlinkSync, renameSync, rmSync, createReadStream, copyFileSync } from 'fs';
7
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, unlinkSync, renameSync, rmSync, createReadStream, copyFileSync, realpathSync } from 'fs';
8
+ import { spawn, execFile } from 'child_process';
8
9
  import { lookup as mimeLookup } from './mimetypes.js';
9
10
  import { listProviders, getProvider } from './providers/index.js';
10
11
  import { OllamaProvider } from './providers/ollama.js';
@@ -13,25 +14,22 @@ import { validateAgentConfig } from './validate.js';
13
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
15
  const isPro = process.env.GROOVE_EDITION === 'pro';
15
16
 
16
- let _subscriptionCache = { active: true, checkedAt: 0 };
17
+ let _daemon = null;
17
18
 
18
19
  function proOnly(req, res, next) {
19
- if (!isPro) {
20
- return res.status(403).json({
21
- error: 'Pro feature',
22
- edition: 'community',
23
- upgrade: 'https://groovedev.ai/pro',
24
- });
25
- }
26
- if (!_subscriptionCache.active) {
27
- return res.status(403).json({
28
- error: 'Pro subscription required',
29
- edition: 'pro',
30
- subscriptionActive: false,
31
- upgrade: 'https://groovedev.ai/pro',
32
- });
33
- }
34
- next();
20
+ const sub = _daemon?.subscriptionCache || {};
21
+ if (isPro || sub.active) return next();
22
+ return res.status(403).json({
23
+ error: 'Pro subscription required',
24
+ edition: 'community',
25
+ plan: sub.plan || 'community',
26
+ subscriptionActive: false,
27
+ upgrade: 'https://groovedev.ai/pro',
28
+ });
29
+ }
30
+
31
+ function hasFeature(name) {
32
+ return (_daemon?.subscriptionCache?.features || []).includes(name);
35
33
  }
36
34
 
37
35
  async function _executeApprovalRetry(daemon, approval) {
@@ -64,6 +62,8 @@ async function _executeApprovalRetry(daemon, approval) {
64
62
  }
65
63
 
66
64
  export function createApi(app, daemon) {
65
+ _daemon = daemon;
66
+
67
67
  // CORS — restrict to localhost + bound interface origins
68
68
  app.use((req, res, next) => {
69
69
  const origin = req.headers.origin;
@@ -88,6 +88,15 @@ export function createApi(app, daemon) {
88
88
  next();
89
89
  });
90
90
 
91
+ // Security headers
92
+ app.use((req, res, next) => {
93
+ res.setHeader('X-Content-Type-Options', 'nosniff');
94
+ res.setHeader('X-Frame-Options', 'DENY');
95
+ res.setHeader('X-XSS-Protection', '0');
96
+ res.setHeader('Content-Security-Policy', "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self' ws://localhost:* ws://127.0.0.1:* http://localhost:* http://127.0.0.1:*; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'");
97
+ next();
98
+ });
99
+
91
100
  app.use(express.json({ limit: '6mb' }));
92
101
 
93
102
  // Health check
@@ -143,22 +152,28 @@ export function createApi(app, daemon) {
143
152
  await daemon.processes.kill(req.params.id);
144
153
  }
145
154
 
146
- // Purge from registry if requested or if agent is dead
147
- if (req.query.purge === 'true' || !isAlive) {
155
+ // Only purge from registry when explicitly requested.
156
+ // Killed/completed agents stay visible so the user can review output.
157
+ const purge = req.query.purge === 'true';
158
+ if (purge) {
148
159
  daemon.registry.remove(req.params.id);
149
160
  }
150
161
 
151
- daemon.audit.log('agent.kill', { id: agent.id, role: agent.role, purged: req.query.purge === 'true' || !isAlive });
152
- res.json({ ok: true, purged: req.query.purge === 'true' || !isAlive });
162
+ daemon.audit.log('agent.kill', { id: agent.id, role: agent.role, purged: purge });
163
+ res.json({ ok: true, purged: purge });
153
164
  } catch (err) {
154
165
  res.status(400).json({ error: err.message });
155
166
  }
156
167
  });
157
168
 
158
- // Kill all agents
169
+ // Kill all agents and purge registry (used by groove nuke)
159
170
  app.delete('/api/agents', async (req, res) => {
160
171
  const count = daemon.processes.getRunningCount();
161
172
  await daemon.processes.killAll();
173
+ // Purge all agents from registry — kill() no longer does this automatically
174
+ for (const agent of daemon.registry.getAll()) {
175
+ daemon.registry.remove(agent.id);
176
+ }
162
177
  daemon.audit.log('agent.kill_all', { count });
163
178
  res.json({ ok: true });
164
179
  });
@@ -227,11 +242,12 @@ export function createApi(app, daemon) {
227
242
  res.json({ removed });
228
243
  });
229
244
 
230
- // Handoff chains (per role)
245
+ // Handoff chains (per role, optionally scoped by workspace)
231
246
  app.get('/api/memory/handoff-chain/:role', (req, res) => {
232
247
  res.json({
233
248
  role: req.params.role,
234
- entries: daemon.memory.getHandoffChain(req.params.role),
249
+ workspace: req.query.workspace || null,
250
+ entries: daemon.memory.getHandoffChain(req.params.role, req.query.workspace),
235
251
  });
236
252
  });
237
253
 
@@ -239,12 +255,13 @@ export function createApi(app, daemon) {
239
255
  const count = Math.min(parseInt(req.query.count) || 3, 10);
240
256
  res.json({
241
257
  role: req.params.role,
242
- markdown: daemon.memory.getRecentHandoffMarkdown(req.params.role, count, 10_000),
258
+ workspace: req.query.workspace || null,
259
+ markdown: daemon.memory.getRecentHandoffMarkdown(req.params.role, count, 10_000, req.query.workspace),
243
260
  });
244
261
  });
245
262
 
246
263
  app.get('/api/memory/handoff-chain', (req, res) => {
247
- res.json({ roles: daemon.memory.listHandoffRoles() });
264
+ res.json({ roles: daemon.memory.listHandoffRoles(req.query.workspace) });
248
265
  });
249
266
 
250
267
  // Discoveries (error → fix pairs)
@@ -546,11 +563,22 @@ export function createApi(app, daemon) {
546
563
 
547
564
  // Edition
548
565
  app.get('/api/edition', (req, res) => {
549
- res.json({ edition: isPro ? 'pro' : 'community' });
566
+ const sub = daemon.subscriptionCache || {};
567
+ res.json({
568
+ edition: (isPro || sub.active) ? 'pro' : 'community',
569
+ plan: sub.plan || 'community',
570
+ subscriptionActive: sub.active || false,
571
+ features: sub.features || [],
572
+ seats: sub.seats || 1,
573
+ periodEnd: sub.periodEnd || null,
574
+ cancelAtPeriodEnd: sub.cancelAtPeriodEnd || false,
575
+ status: sub.status || 'none',
576
+ });
550
577
  });
551
578
 
552
579
  // Daemon status
553
580
  app.get('/api/status', (req, res) => {
581
+ const sub = daemon.subscriptionCache || {};
554
582
  res.json({
555
583
  pid: process.pid,
556
584
  uptime: process.uptime(),
@@ -559,7 +587,7 @@ export function createApi(app, daemon) {
559
587
  host: daemon.host,
560
588
  port: daemon.port,
561
589
  projectDir: daemon.projectDir,
562
- edition: isPro ? 'pro' : 'community',
590
+ edition: (isPro || sub.active) ? 'pro' : 'community',
563
591
  });
564
592
  });
565
593
 
@@ -739,8 +767,12 @@ export function createApi(app, daemon) {
739
767
  // Loop exists but not running — fall through to resume/rotate
740
768
  }
741
769
 
742
- // CLI agent path — session resume or rotation
743
- const resumed = !!agent.sessionId;
770
+ // CLI agent path — session resume or rotation.
771
+ // Force rotation (fresh session + handoff brief) past the resume ceiling:
772
+ // reviving a >5M-token claude session has crashed the CLI mid-HTTP-parse
773
+ // (V8 fatal in JsonStringifier) — the rotator's handoff brief sidesteps that.
774
+ const SESSION_RESUME_CEILING = 5_000_000;
775
+ const resumed = !!agent.sessionId && (agent.tokensUsed || 0) < SESSION_RESUME_CEILING;
744
776
  const newAgent = resumed
745
777
  ? await daemon.processes.resume(req.params.id, message.trim())
746
778
  : await daemon.rotator.rotate(req.params.id, { additionalPrompt: message.trim() });
@@ -799,8 +831,8 @@ export function createApi(app, daemon) {
799
831
  const { filename, content } = req.body;
800
832
  if (!filename || !content) return res.status(400).json({ error: 'filename and content required' });
801
833
 
802
- // Sanitize filename — no path traversal
803
- const safeName = String(filename).replace(/[/\\]/g, '_').replace(/\.\./g, '');
834
+ // Sanitize filename — strict allowlist, no path traversal
835
+ const safeName = String(filename).replace(/[^a-zA-Z0-9._-]/g, '_').replace(/^\.+/, '');
804
836
  if (!safeName) return res.status(400).json({ error: 'Invalid filename' });
805
837
 
806
838
  const dir = agent.workingDir || daemon.projectDir;
@@ -1077,6 +1109,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
1077
1109
  }
1078
1110
 
1079
1111
  const user = await daemon.skills.setAuth(token);
1112
+ if (user) await daemon.setAuthToken(token);
1080
1113
  if (!user) {
1081
1114
  return res.send(`<!DOCTYPE html>
1082
1115
  <html><head><meta charset="UTF-8"><title>Groove — Login Failed</title>
@@ -1098,7 +1131,8 @@ Keep responses concise. Help them think, don't lecture them about the system the
1098
1131
  </body></html>`);
1099
1132
  }
1100
1133
 
1101
- const displayName = user?.displayName || user?.id || '';
1134
+ const rawName = user?.displayName || user?.id || '';
1135
+ const displayName = rawName.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
1102
1136
  res.send(`<!DOCTYPE html>
1103
1137
  <html><head><meta charset="UTF-8"><title>Groove — Signed In</title>
1104
1138
  <link rel="icon" href="/favicon.png">
@@ -1111,28 +1145,28 @@ Keep responses concise. Help them think, don't lecture them about the system the
1111
1145
  h2{font-size:16px;font-weight:600;color:#e6e6e6;margin-bottom:6px}
1112
1146
  .user{font-size:13px;color:#33afbc;margin-bottom:16px}
1113
1147
  p{font-size:13px;color:#505862;line-height:1.5}
1114
- .bar{width:120px;height:2px;background:#2c313a;border-radius:1px;margin:20px auto 0;overflow:hidden}
1115
- .bar span{display:block;height:100%;background:#33afbc;border-radius:1px;animation:close 3s linear forwards}
1116
- @keyframes close{from{width:100%}to{width:0%}}
1148
+ .btn{display:inline-block;margin-top:20px;padding:8px 20px;font-size:13px;font-weight:500;color:#e6e6e6;background:#2c313a;border:1px solid #3a3f48;border-radius:999px;cursor:pointer;transition:background 0.15s}
1149
+ .btn:hover{background:#33afbc;border-color:#33afbc;color:#1a1e25}
1117
1150
  </style>
1118
1151
  </head><body>
1119
1152
  <div class="card">
1120
1153
  <div class="logo"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></div>
1121
1154
  <h2>Connected to Groove</h2>
1122
1155
  ${displayName ? `<div class="user">${displayName}</div>` : ''}
1123
- <p>This tab will close automatically.</p>
1124
- <div class="bar"><span></span></div>
1156
+ <p id="msg">You can close this tab and return to Groove.</p>
1157
+ <button class="btn" onclick="window.close()">Close tab</button>
1125
1158
  </div>
1126
- <script>setTimeout(()=>window.close(),3000)</script>
1159
+ <script>setTimeout(()=>{try{window.close()}catch(e){}setTimeout(()=>{document.getElementById('msg').textContent='Return to the Groove app to continue.'},500)},3000)</script>
1127
1160
  </body></html>`);
1128
1161
  });
1129
1162
 
1130
- // Auth status — returns current user or { authenticated: false }
1163
+ // Auth status — returns current user + subscription or { authenticated: false }
1131
1164
  app.get('/api/auth/status', async (req, res) => {
1132
1165
  const user = daemon.skills.getUser();
1133
1166
  const token = daemon.skills.getToken();
1134
1167
  if (!user || !token) return res.json({ authenticated: false });
1135
- res.json({ authenticated: true, user });
1168
+ const sub = daemon.subscriptionCache || {};
1169
+ res.json({ authenticated: true, user, subscription: sub });
1136
1170
  });
1137
1171
 
1138
1172
  // Validate stored token (hits remote API)
@@ -1177,6 +1211,80 @@ Keep responses concise. Help them think, don't lecture them about the system the
1177
1211
  }
1178
1212
  });
1179
1213
 
1214
+ // --- Subscription ---
1215
+
1216
+ const SUB_API = 'https://docs.groovedev.ai/api/v1';
1217
+
1218
+ app.get('/api/subscription/plans', async (req, res) => {
1219
+ try {
1220
+ const resp = await fetch(`${SUB_API}/subscription/plans`);
1221
+ const data = await resp.json();
1222
+ res.json(data);
1223
+ } catch (err) {
1224
+ res.status(502).json({ error: 'Failed to fetch plans', message: err.message });
1225
+ }
1226
+ });
1227
+
1228
+ app.get('/api/subscription/status', (req, res) => {
1229
+ const sub = daemon.subscriptionCache || {};
1230
+ res.json(sub);
1231
+ });
1232
+
1233
+ app.post('/api/subscription/checkout', async (req, res) => {
1234
+ if (!daemon.authToken) return res.status(401).json({ error: 'Not authenticated' });
1235
+ const { priceId } = req.body;
1236
+ if (!priceId || typeof priceId !== 'string') return res.status(400).json({ error: 'priceId required' });
1237
+ try {
1238
+ const resp = await fetch(`${SUB_API}/subscription/checkout`, {
1239
+ method: 'POST',
1240
+ headers: { 'Authorization': `Bearer ${daemon.authToken}`, 'Content-Type': 'application/json' },
1241
+ body: JSON.stringify({ priceId }),
1242
+ });
1243
+ const data = await resp.json();
1244
+ if (!resp.ok) return res.status(resp.status).json(data);
1245
+ res.json(data);
1246
+ } catch (err) {
1247
+ res.status(502).json({ error: 'Checkout failed', message: err.message });
1248
+ }
1249
+ });
1250
+
1251
+ app.post('/api/subscription/portal', async (req, res) => {
1252
+ if (!daemon.authToken) return res.status(401).json({ error: 'Not authenticated' });
1253
+ try {
1254
+ const resp = await fetch(`${SUB_API}/subscription/portal`, {
1255
+ method: 'POST',
1256
+ headers: { 'Authorization': `Bearer ${daemon.authToken}`, 'Content-Type': 'application/json' },
1257
+ body: '{}',
1258
+ });
1259
+ const data = await resp.json();
1260
+ if (!resp.ok) return res.status(resp.status).json(data);
1261
+ res.json(data);
1262
+ } catch (err) {
1263
+ res.status(502).json({ error: 'Portal failed', message: err.message });
1264
+ }
1265
+ });
1266
+
1267
+ app.patch('/api/subscription', async (req, res) => {
1268
+ if (!daemon.authToken) return res.status(401).json({ error: 'Not authenticated' });
1269
+ const { seats } = req.body;
1270
+ if (!seats || typeof seats !== 'number' || seats < 1 || seats > 999 || !Number.isInteger(seats)) {
1271
+ return res.status(400).json({ error: 'seats must be integer 1-999' });
1272
+ }
1273
+ try {
1274
+ const resp = await fetch(`${SUB_API}/subscription`, {
1275
+ method: 'PATCH',
1276
+ headers: { 'Authorization': `Bearer ${daemon.authToken}`, 'Content-Type': 'application/json' },
1277
+ body: JSON.stringify({ seats }),
1278
+ });
1279
+ const data = await resp.json();
1280
+ if (!resp.ok) return res.status(resp.status).json(data);
1281
+ daemon._pollSubscription();
1282
+ res.json(data);
1283
+ } catch (err) {
1284
+ res.status(502).json({ error: 'Seat update failed', message: err.message });
1285
+ }
1286
+ });
1287
+
1180
1288
  // --- Skills Marketplace ---
1181
1289
 
1182
1290
  app.get('/api/skills/registry', async (req, res) => {
@@ -1900,6 +2008,16 @@ Keep responses concise. Help them think, don't lecture them about the system the
1900
2008
  }
1901
2009
  const fullPath = resolve(projectDir, relPath);
1902
2010
  if (!fullPath.startsWith(projectDir)) return { error: 'Path outside project' };
2011
+ // Symlink resolution — ensure real path is also within project
2012
+ try {
2013
+ const realPath = realpathSync(fullPath);
2014
+ const realBase = realpathSync(projectDir);
2015
+ if (!realPath.startsWith(realBase)) {
2016
+ return { error: 'Path outside project (symlink)' };
2017
+ }
2018
+ } catch {
2019
+ // File may not exist yet (for writes) — path prefix check is sufficient
2020
+ }
1903
2021
  return { fullPath };
1904
2022
  }
1905
2023
 
@@ -2160,6 +2278,88 @@ Keep responses concise. Help them think, don't lecture them about the system the
2160
2278
  }
2161
2279
  });
2162
2280
 
2281
+ // Git status — returns modified/added/deleted/untracked files
2282
+ app.get('/api/files/git-status', (req, res) => {
2283
+ const rootDir = getEditorRoot();
2284
+ if (!rootDir) return res.status(400).json({ error: 'Editor root not set' });
2285
+
2286
+ execFile('git', ['status', '--porcelain'], { cwd: rootDir, timeout: 10000 }, (err, stdout) => {
2287
+ if (err) {
2288
+ // Not a git repo or git not installed — return empty
2289
+ return res.json({ entries: [] });
2290
+ }
2291
+ const STATUS_MAP = { 'M': 'M', 'A': 'A', '?': '?', 'D': 'D', 'R': 'R', 'U': 'U' };
2292
+ const entries = [];
2293
+ for (const line of stdout.split('\n')) {
2294
+ if (!line.trim()) continue;
2295
+ const code = line[0] === ' ' ? line[1] : line[0];
2296
+ const filePath = line.slice(3).trim();
2297
+ if (!filePath) continue;
2298
+ entries.push({ path: filePath, status: STATUS_MAP[code] || code });
2299
+ }
2300
+ res.json({ entries });
2301
+ });
2302
+ });
2303
+
2304
+ // Git branch — returns the current branch name
2305
+ app.get('/api/files/git-branch', (req, res) => {
2306
+ const rootDir = getEditorRoot();
2307
+ if (!rootDir) return res.status(400).json({ error: 'Editor root not set' });
2308
+
2309
+ execFile('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: rootDir, timeout: 5000 }, (err, stdout) => {
2310
+ if (err) {
2311
+ return res.json({ branch: null });
2312
+ }
2313
+ res.json({ branch: stdout.trim() });
2314
+ });
2315
+ });
2316
+
2317
+ // File search — fuzzy filename matching for quick-open (Ctrl+P)
2318
+ app.get('/api/files/search', (req, res) => {
2319
+ const query = req.query.q;
2320
+ if (!query || typeof query !== 'string') return res.status(400).json({ error: 'q parameter is required' });
2321
+ if (query.length > 200) return res.status(400).json({ error: 'Query too long' });
2322
+
2323
+ const maxResults = Math.min(parseInt(req.query.maxResults, 10) || 50, 200);
2324
+ const rootDir = getEditorRoot();
2325
+ if (!rootDir) return res.status(400).json({ error: 'Editor root not set' });
2326
+
2327
+ const lowerQuery = query.toLowerCase();
2328
+ const results = [];
2329
+
2330
+ function fuzzyMatch(name) {
2331
+ const lower = name.toLowerCase();
2332
+ let qi = 0;
2333
+ for (let i = 0; i < lower.length && qi < lowerQuery.length; i++) {
2334
+ if (lower[i] === lowerQuery[qi]) qi++;
2335
+ }
2336
+ return qi === lowerQuery.length;
2337
+ }
2338
+
2339
+ function walk(dir, rel) {
2340
+ if (results.length >= maxResults) return;
2341
+ let entries;
2342
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
2343
+ for (const entry of entries) {
2344
+ if (results.length >= maxResults) return;
2345
+ if (IGNORED_NAMES.has(entry.name) || entry.name.startsWith('.')) continue;
2346
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
2347
+ if (entry.isDirectory()) {
2348
+ walk(resolve(dir, entry.name), childRel);
2349
+ } else if (entry.isFile() && fuzzyMatch(entry.name)) {
2350
+ results.push({ path: childRel, name: entry.name });
2351
+ }
2352
+ }
2353
+ }
2354
+
2355
+ try {
2356
+ walk(rootDir, '');
2357
+ res.json({ files: results });
2358
+ } catch (err) {
2359
+ res.status(500).json({ error: err.message });
2360
+ }
2361
+ });
2362
+
2163
2363
  // --- Codebase Indexer ---
2164
2364
 
2165
2365
  app.get('/api/indexer', (req, res) => {
@@ -2208,19 +2408,27 @@ Keep responses concise. Help them think, don't lecture them about the system the
2208
2408
 
2209
2409
  // --- Recommended Team (from planner) ---
2210
2410
 
2211
- // Find recommended-team.json — check all agent working dirs, then daemon's grooveDir
2411
+ // Find recommended-team.json — check planner agents first (they write the file),
2412
+ // sorted by most recent activity so the latest planner's team wins.
2212
2413
  function findRecommendedTeam() {
2213
- // Check agent working dirs first (planner may have written there)
2214
2414
  const agents = daemon.registry.getAll();
2215
- for (const agent of agents) {
2216
- if (agent.workingDir) {
2217
- const p = resolve(agent.workingDir, '.groove', 'recommended-team.json');
2218
- if (existsSync(p)) return { path: p, teamId: agent.teamId || null, agentId: agent.id || null };
2219
- }
2415
+ const planners = agents
2416
+ .filter((a) => a.role === 'planner' && a.workingDir)
2417
+ .sort((a, b) => (b.lastActivity || b.spawnedAt || '').localeCompare(a.lastActivity || a.spawnedAt || ''));
2418
+
2419
+ // Check planner workingDirs first — most recently active planner wins
2420
+ for (const planner of planners) {
2421
+ const p = resolve(planner.workingDir, '.groove', 'recommended-team.json');
2422
+ if (existsSync(p)) return { path: p, teamId: planner.teamId || null, agentId: planner.id || null };
2220
2423
  }
2221
- // Fallback to daemon's .groove dir
2424
+
2425
+ // Fallback to daemon's .groove dir — try to attribute to most recent planner
2222
2426
  const p = resolve(daemon.grooveDir, 'recommended-team.json');
2223
- if (existsSync(p)) return { path: p, teamId: null, agentId: null };
2427
+ if (existsSync(p)) {
2428
+ const fallbackTeamId = planners[0]?.teamId || null;
2429
+ const fallbackAgentId = planners[0]?.id || null;
2430
+ return { path: p, teamId: fallbackTeamId, agentId: fallbackAgentId };
2431
+ }
2224
2432
  return null;
2225
2433
  }
2226
2434
 
@@ -2252,6 +2460,9 @@ Keep responses concise. Help them think, don't lecture them about the system the
2252
2460
  try {
2253
2461
  const raw = JSON.parse(readFileSync(found.path, 'utf8'));
2254
2462
 
2463
+ // Delete immediately after reading to prevent duplicate launches from poll races
2464
+ try { unlinkSync(found.path); } catch { /* already gone */ }
2465
+
2255
2466
  // Support both old format (bare array) and new format ({ projectDir, agents })
2256
2467
  let agentConfigs;
2257
2468
  let projectDir = null;
@@ -2268,7 +2479,9 @@ Keep responses concise. Help them think, don't lecture them about the system the
2268
2479
  return res.status(400).json({ error: 'Recommended team is empty' });
2269
2480
  }
2270
2481
 
2271
- const baseDir = daemon.config?.defaultWorkingDir || daemon.projectDir;
2482
+ // Resolve base directory from the planner that wrote the file, not the daemon root
2483
+ const plannerAgent = found.agentId ? daemon.registry.get(found.agentId) : null;
2484
+ const baseDir = plannerAgent?.workingDir || daemon.config?.defaultWorkingDir || daemon.projectDir;
2272
2485
 
2273
2486
  // Use the planner's teamId so launched agents join the correct team.
2274
2487
  // Priority: explicit from frontend > agent that wrote the file > most recent planner > default
@@ -2304,6 +2517,13 @@ Keep responses concise. Help them think, don't lecture them about the system the
2304
2517
  }];
2305
2518
  }
2306
2519
 
2520
+ // Reset handoff cycle counters for this team so a fresh launch starts clean
2521
+ if (daemon._handoffCounts) {
2522
+ for (const key of [...daemon._handoffCounts.keys()]) {
2523
+ if (key.startsWith(`${defaultTeamId}:`)) daemon._handoffCounts.delete(key);
2524
+ }
2525
+ }
2526
+
2307
2527
  // Spawn phase 1 agents — reuse idle team members with matching roles when possible
2308
2528
  const spawned = [];
2309
2529
  const reused = [];
@@ -2592,7 +2812,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
2592
2812
 
2593
2813
  // --- Federation ---
2594
2814
 
2595
- // Federation status
2815
+ // Federation status (v1 — includes whitelist, connections, ambassadors)
2596
2816
  app.get('/api/federation', proOnly, (req, res) => {
2597
2817
  res.json(daemon.federation.getStatus());
2598
2818
  });
@@ -2602,12 +2822,22 @@ Keep responses concise. Help them think, don't lecture them about the system the
2602
2822
  res.json(daemon.federation.getPeers());
2603
2823
  });
2604
2824
 
2605
- // Initiate pairing (local CLI calls this with the remote URL)
2825
+ // Unpair a peer
2826
+ app.delete('/api/federation/peers/:id', proOnly, (req, res) => {
2827
+ try {
2828
+ daemon.federation.unpair(req.params.id);
2829
+ res.json({ ok: true });
2830
+ } catch (err) {
2831
+ res.status(400).json({ error: err.message });
2832
+ }
2833
+ });
2834
+
2835
+ // Initiate pairing with a remote daemon
2606
2836
  app.post('/api/federation/initiate', proOnly, async (req, res) => {
2607
2837
  try {
2608
2838
  const { remoteUrl } = req.body;
2609
2839
  if (!remoteUrl || typeof remoteUrl !== 'string') {
2610
- return res.status(400).json({ error: 'remoteUrl is required' });
2840
+ return res.status(400).json({ error: 'remoteUrl is required (string)' });
2611
2841
  }
2612
2842
  const result = await daemon.federation.initiatePairing(remoteUrl);
2613
2843
  res.json(result);
@@ -2616,29 +2846,128 @@ Keep responses concise. Help them think, don't lecture them about the system the
2616
2846
  }
2617
2847
  });
2618
2848
 
2619
- // Accept pairing (remote daemon calls this during key exchange)
2620
- app.post('/api/federation/pair', proOnly, (req, res) => {
2849
+ // --- Federation v1: Whitelist ---
2850
+
2851
+ app.get('/api/federation/whitelist', proOnly, (req, res) => {
2852
+ res.json(daemon.federation.whitelist?.list() || []);
2853
+ });
2854
+
2855
+ app.post('/api/federation/whitelist', proOnly, (req, res) => {
2621
2856
  try {
2622
- const result = daemon.federation.acceptPairing(req.body);
2623
- res.json(result);
2857
+ const { ip, port, name } = req.body;
2858
+ if (!ip || typeof ip !== 'string') {
2859
+ return res.status(400).json({ error: 'ip is required (string)' });
2860
+ }
2861
+ const entry = daemon.federation.whitelist.add(ip, port, name);
2862
+ daemon.broadcast({ type: 'federation:whitelist', data: daemon.federation.whitelist.list() });
2863
+ res.json(entry);
2624
2864
  } catch (err) {
2625
2865
  res.status(400).json({ error: err.message });
2626
2866
  }
2627
2867
  });
2628
2868
 
2629
- // Unpair a peer
2630
- app.delete('/api/federation/peers/:id', proOnly, (req, res) => {
2869
+ app.delete('/api/federation/whitelist/:ip', proOnly, (req, res) => {
2631
2870
  try {
2632
- daemon.federation.unpair(req.params.id);
2871
+ daemon.federation.whitelist.remove(req.params.ip);
2872
+ daemon.broadcast({ type: 'federation:whitelist', data: daemon.federation.whitelist.list() });
2633
2873
  res.json({ ok: true });
2634
2874
  } catch (err) {
2635
2875
  res.status(400).json({ error: err.message });
2636
2876
  }
2637
2877
  });
2638
2878
 
2639
- // Receive a signed contract from a peer
2640
- app.post('/api/federation/contract', proOnly, (req, res) => {
2879
+ // Probe endpoint remote daemons hit this to check if they are whitelisted
2880
+ app.get('/api/federation/whitelist-check', (req, res) => {
2881
+ const ip = req.ip?.replace('::ffff:', '') || req.socket?.remoteAddress?.replace('::ffff:', '') || '';
2882
+ const whitelisted = daemon.federation.isWhitelisted(ip);
2883
+ res.json({
2884
+ whitelisted,
2885
+ ...(whitelisted ? { daemonId: daemon.federation._daemonId() } : {}),
2886
+ });
2887
+ });
2888
+
2889
+ // --- Federation v1: Knock ---
2890
+
2891
+ app.post('/api/federation/knock', (req, res) => {
2641
2892
  try {
2893
+ const callerIp = req.ip?.replace('::ffff:', '') || req.socket?.remoteAddress?.replace('::ffff:', '') || '';
2894
+ const { senderId, publicKey, payload, signature } = req.body;
2895
+ if (!senderId || !publicKey || !payload || !signature) {
2896
+ return res.status(400).json({ error: 'senderId, publicKey, payload, and signature are required' });
2897
+ }
2898
+ const result = daemon.federation.handleKnock(senderId, publicKey, payload, signature, callerIp);
2899
+ res.json(result);
2900
+ } catch (err) {
2901
+ res.status(403).json({ error: err.message });
2902
+ }
2903
+ });
2904
+
2905
+ // --- Federation v1: Connections ---
2906
+
2907
+ app.get('/api/federation/connections', proOnly, (req, res) => {
2908
+ res.json(daemon.federation.connections?.getStatus() || []);
2909
+ });
2910
+
2911
+ // --- Federation v1: Diplomatic Pouch ---
2912
+
2913
+ app.post('/api/federation/pouch', (req, res) => {
2914
+ try {
2915
+ const callerIp = req.ip?.replace('::ffff:', '') || req.socket?.remoteAddress?.replace('::ffff:', '') || '';
2916
+ if (!callerIp || !daemon.federation.isWhitelisted(callerIp)) {
2917
+ return res.status(403).json({ error: 'Caller IP not whitelisted' });
2918
+ }
2919
+ const { senderId, payload, signature } = req.body;
2920
+ if (!senderId || !payload || !signature) {
2921
+ return res.status(400).json({ error: 'senderId, payload, and signature are required' });
2922
+ }
2923
+ const result = daemon.federation.ambassadors.receivePouch(senderId, payload, signature);
2924
+ res.json(result);
2925
+ } catch (err) {
2926
+ res.status(403).json({ error: err.message });
2927
+ }
2928
+ });
2929
+
2930
+ app.get('/api/federation/pouch/log', proOnly, (req, res) => {
2931
+ const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
2932
+ res.json(daemon.federation.ambassadors?.getPouchLog(limit) || []);
2933
+ });
2934
+
2935
+ // Send a pouch message to a peer (local agents/GUI call this)
2936
+ app.post('/api/federation/pouch/send', proOnly, async (req, res) => {
2937
+ try {
2938
+ const { peerId, contract } = req.body;
2939
+ if (!peerId || !contract) {
2940
+ return res.status(400).json({ error: 'peerId and contract are required' });
2941
+ }
2942
+ const result = await daemon.federation.ambassadors.sendPouch(peerId, contract);
2943
+ res.json(result);
2944
+ } catch (err) {
2945
+ res.status(400).json({ error: err.message });
2946
+ }
2947
+ });
2948
+
2949
+ // Accept incoming pairing request from a remote daemon
2950
+ app.post('/api/federation/pair', (req, res) => {
2951
+ try {
2952
+ const callerIp = req.ip?.replace('::ffff:', '') || req.socket?.remoteAddress?.replace('::ffff:', '') || '';
2953
+ const { id, name, port, publicKey } = req.body;
2954
+ if (!id || !publicKey) {
2955
+ return res.status(400).json({ error: 'id and publicKey are required' });
2956
+ }
2957
+ const result = daemon.federation.acceptPairing({ id, name, port, publicKey }, callerIp);
2958
+ res.json(result);
2959
+ } catch (err) {
2960
+ res.status(403).json({ error: err.message });
2961
+ }
2962
+ });
2963
+
2964
+ // Legacy contract endpoints (kept for backward compat)
2965
+ app.post('/api/federation/contract', (req, res) => {
2966
+ try {
2967
+ const callerIp = req.ip?.replace('::ffff:', '') || req.socket?.remoteAddress?.replace('::ffff:', '') || '';
2968
+ if (!callerIp || !daemon.federation.isWhitelisted(callerIp)) {
2969
+ return res.status(403).json({ error: 'Caller IP not whitelisted' });
2970
+ }
2642
2971
  const { senderId, payload, signature } = req.body;
2643
2972
  if (!senderId || !payload || !signature) {
2644
2973
  return res.status(400).json({ error: 'senderId, payload, and signature are required' });
@@ -2650,7 +2979,6 @@ Keep responses concise. Help them think, don't lecture them about the system the
2650
2979
  }
2651
2980
  });
2652
2981
 
2653
- // Send a contract to a peer (local agents call this)
2654
2982
  app.post('/api/federation/contract/send', proOnly, async (req, res) => {
2655
2983
  try {
2656
2984
  const { peerId, contract } = req.body;
@@ -2863,9 +3191,9 @@ Keep responses concise. Help them think, don't lecture them about the system the
2863
3191
  }
2864
3192
  });
2865
3193
 
2866
- app.delete('/api/tunnels/:id', proOnly, (req, res) => {
3194
+ app.delete('/api/tunnels/:id', proOnly, async (req, res) => {
2867
3195
  try {
2868
- daemon.tunnelManager.delete(req.params.id);
3196
+ await daemon.tunnelManager.delete(req.params.id);
2869
3197
  res.json({ ok: true });
2870
3198
  } catch (err) {
2871
3199
  res.status(400).json({ error: err.message });
@@ -2925,6 +3253,169 @@ Keep responses concise. Help them think, don't lecture them about the system the
2925
3253
  res.json(s);
2926
3254
  });
2927
3255
 
3256
+ // --- Onboarding (Electron wizard) ---
3257
+
3258
+ const INSTALLABLE_PROVIDERS = {
3259
+ 'claude-code': '@anthropic-ai/claude-code',
3260
+ 'codex': '@openai/codex',
3261
+ 'gemini': '@google/gemini-cli',
3262
+ };
3263
+
3264
+ app.get('/api/onboarding/status', (req, res) => {
3265
+ const providers = listProviders();
3266
+ const enriched = providers.map((p) => {
3267
+ const hasKey = daemon.credentials.hasKey(p.id);
3268
+ let authStatus = 'not-configured';
3269
+ if (p.authType === 'subscription') {
3270
+ authStatus = p.installed ? 'authenticated' : 'not-configured';
3271
+ } else if (p.authType === 'api-key') {
3272
+ authStatus = hasKey ? 'key-set' : 'not-configured';
3273
+ if (p.authStatus?.authenticated) authStatus = 'authenticated';
3274
+ } else if (p.authType === 'local') {
3275
+ authStatus = p.installed ? 'authenticated' : 'not-configured';
3276
+ }
3277
+ return {
3278
+ id: p.id,
3279
+ displayName: p.name,
3280
+ installed: p.installed,
3281
+ authType: p.authType,
3282
+ authStatus,
3283
+ hasKey,
3284
+ models: p.models,
3285
+ installCommand: p.installCommand,
3286
+ installable: !!INSTALLABLE_PROVIDERS[p.id],
3287
+ };
3288
+ });
3289
+
3290
+ const dismissed = !!(daemon.config.onboardingDismissed);
3291
+ const hasReadyProvider = enriched.some((p) =>
3292
+ p.installed && (p.authStatus === 'authenticated' || p.authStatus === 'key-set'),
3293
+ );
3294
+
3295
+ res.json({
3296
+ complete: dismissed || hasReadyProvider,
3297
+ dismissed,
3298
+ providers: enriched,
3299
+ defaultProvider: daemon.config.defaultProvider || 'claude-code',
3300
+ defaultModel: daemon.config.defaultModel || null,
3301
+ });
3302
+ });
3303
+
3304
+ app.post('/api/onboarding/dismiss', async (req, res) => {
3305
+ daemon.config.onboardingDismissed = true;
3306
+ const { saveConfig } = await import('./firstrun.js');
3307
+ saveConfig(daemon.grooveDir, daemon.config);
3308
+ daemon.audit.log('onboarding.dismiss', {});
3309
+ daemon.broadcast({ type: 'onboarding:dismissed' });
3310
+ res.json({ ok: true });
3311
+ });
3312
+
3313
+ app.post('/api/onboarding/install-provider', (req, res) => {
3314
+ const { provider } = req.body;
3315
+ if (!provider || typeof provider !== 'string') {
3316
+ return res.status(400).json({ error: 'provider is required' });
3317
+ }
3318
+ const pkg = INSTALLABLE_PROVIDERS[provider];
3319
+ if (!pkg) {
3320
+ return res.status(400).json({ error: `Provider '${provider}' is not installable via npm. Valid: ${Object.keys(INSTALLABLE_PROVIDERS).join(', ')}` });
3321
+ }
3322
+
3323
+ res.setHeader('Content-Type', 'application/x-ndjson');
3324
+ res.setHeader('Transfer-Encoding', 'chunked');
3325
+ res.setHeader('Cache-Control', 'no-cache');
3326
+
3327
+ const write = (obj) => {
3328
+ try { res.write(JSON.stringify(obj) + '\n'); } catch { /* client disconnected */ }
3329
+ };
3330
+
3331
+ write({ status: 'installing', output: `Installing ${pkg}...`, progress: 0 });
3332
+
3333
+ const proc = spawn('npm', ['install', '-g', pkg], {
3334
+ stdio: ['ignore', 'pipe', 'pipe'],
3335
+ env: { ...process.env, NODE_ENV: undefined },
3336
+ });
3337
+
3338
+ let output = '';
3339
+ let errOutput = '';
3340
+
3341
+ proc.stdout.on('data', (data) => {
3342
+ output += data.toString();
3343
+ write({ status: 'installing', output: data.toString().trim(), progress: 50 });
3344
+ });
3345
+
3346
+ proc.stderr.on('data', (data) => {
3347
+ errOutput += data.toString();
3348
+ const line = data.toString().trim();
3349
+ if (line) write({ status: 'installing', output: line, progress: 50 });
3350
+ });
3351
+
3352
+ proc.on('close', (code) => {
3353
+ const providerObj = getProvider(provider);
3354
+ const installed = providerObj ? providerObj.constructor.isInstalled() : false;
3355
+
3356
+ if (code === 0 && installed) {
3357
+ write({ status: 'complete', output: `${pkg} installed successfully`, progress: 100, installed: true });
3358
+ daemon.audit.log('onboarding.installProvider', { provider, pkg, success: true });
3359
+ daemon.broadcast({ type: 'onboarding:provider-installed', provider });
3360
+ } else {
3361
+ const reason = code !== 0
3362
+ ? (errOutput || output).slice(-500)
3363
+ : 'Install succeeded but provider binary not found in PATH';
3364
+ write({ status: 'error', output: reason, progress: 100, installed: false });
3365
+ daemon.audit.log('onboarding.installProvider', { provider, pkg, success: false, code });
3366
+ }
3367
+ res.end();
3368
+ });
3369
+
3370
+ proc.on('error', (err) => {
3371
+ write({ status: 'error', output: `Failed to start npm: ${err.message}`, progress: 100, installed: false });
3372
+ res.end();
3373
+ });
3374
+
3375
+ req.on('close', () => {
3376
+ try { proc.kill(); } catch { /* already exited */ }
3377
+ });
3378
+ });
3379
+
3380
+ app.post('/api/onboarding/set-default', async (req, res) => {
3381
+ const { provider, model } = req.body;
3382
+ const validProviders = ['claude-code', 'codex', 'gemini', 'ollama'];
3383
+ if (!provider || !validProviders.includes(provider)) {
3384
+ return res.status(400).json({ error: `Invalid provider. Valid: ${validProviders.join(', ')}` });
3385
+ }
3386
+
3387
+ daemon.config.defaultProvider = provider;
3388
+ if (model && typeof model === 'string' && model.length <= 100) {
3389
+ daemon.config.defaultModel = model.trim();
3390
+ }
3391
+ const { saveConfig } = await import('./firstrun.js');
3392
+ saveConfig(daemon.grooveDir, daemon.config);
3393
+ daemon.audit.log('onboarding.setDefault', { provider, model: model || null });
3394
+ daemon.broadcast({ type: 'onboarding:default-changed', provider, model });
3395
+ res.json({ ok: true });
3396
+ });
3397
+
3398
+ // --- Project Directory ---
3399
+
3400
+ app.post('/api/project-dir', async (req, res) => {
3401
+ const { dir } = req.body;
3402
+ if (!dir || typeof dir !== 'string') return res.status(400).json({ error: 'dir required' });
3403
+ if (/[\0\n\r]/.test(dir)) return res.status(400).json({ error: 'Invalid characters in path' });
3404
+ const { existsSync, statSync } = await import('fs');
3405
+ const { resolve, isAbsolute } = await import('path');
3406
+ const resolved = resolve(dir);
3407
+ if (!isAbsolute(resolved)) return res.status(400).json({ error: 'Path must be absolute' });
3408
+ if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
3409
+ return res.status(400).json({ error: 'Directory does not exist' });
3410
+ }
3411
+ daemon.config.defaultWorkingDir = resolved;
3412
+ const { saveConfig } = await import('./firstrun.js');
3413
+ saveConfig(daemon.grooveDir, daemon.config);
3414
+ daemon.broadcast({ type: 'config:updated', data: { defaultWorkingDir: resolved } });
3415
+ daemon.audit.log('project.dir.change', { dir: resolved });
3416
+ res.json({ ok: true, dir: resolved });
3417
+ });
3418
+
2928
3419
  // --- Config ---
2929
3420
 
2930
3421
  app.get('/api/config', (req, res) => {
@@ -2935,6 +3426,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
2935
3426
  const ALLOWED_KEYS = [
2936
3427
  'port', 'journalistInterval', 'rotationThreshold', 'autoRotation',
2937
3428
  'qcThreshold', 'maxAgents', 'defaultProvider', 'defaultWorkingDir',
3429
+ 'onboardingDismissed', 'defaultModel',
2938
3430
  ];
2939
3431
  for (const key of Object.keys(req.body)) {
2940
3432
  if (!ALLOWED_KEYS.includes(key)) {
@@ -2948,6 +3440,33 @@ Keep responses concise. Help them think, don't lecture them about the system the
2948
3440
  res.json(daemon.config);
2949
3441
  });
2950
3442
 
3443
+ // --- Toys ---
3444
+
3445
+ app.get('/api/toys', (req, res) => {
3446
+ const category = req.query.category;
3447
+ if (category && (typeof category !== 'string' || category.length > 30)) {
3448
+ return res.status(400).json({ error: 'Invalid category' });
3449
+ }
3450
+ res.json(daemon.toys.list(category || undefined));
3451
+ });
3452
+
3453
+ app.get('/api/toys/:id', (req, res) => {
3454
+ const toy = daemon.toys.get(req.params.id);
3455
+ if (!toy) return res.status(404).json({ error: 'Toy not found' });
3456
+ res.json(toy);
3457
+ });
3458
+
3459
+ app.post('/api/toys/:id/launch', async (req, res) => {
3460
+ try {
3461
+ const { apiKey, starterPrompt } = req.body || {};
3462
+ const result = await daemon.toys.launch(req.params.id, { apiKey, starterPrompt });
3463
+ daemon.audit.log('toy.launch', { toyId: req.params.id, teamId: result.team.id });
3464
+ res.status(201).json(result);
3465
+ } catch (err) {
3466
+ res.status(400).json({ error: err.message });
3467
+ }
3468
+ });
3469
+
2951
3470
  // Serve GUI static files (built GUI) — no-cache headers to prevent stale bundles
2952
3471
  const guiPath = process.env.GROOVE_GUI_PATH || resolve(__dirname, '../../gui/dist');
2953
3472
  app.use(express.static(guiPath, { etag: false, maxAge: 0, lastModified: false }));