groove-dev 0.17.8 → 0.18.2

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 (131) hide show
  1. package/node_modules/@groove-dev/cli/package.json +4 -3
  2. package/node_modules/@groove-dev/daemon/google-oauth.json +5 -0
  3. package/node_modules/@groove-dev/daemon/integrations-registry.json +0 -40
  4. package/node_modules/@groove-dev/daemon/package.json +4 -3
  5. package/node_modules/@groove-dev/daemon/src/api.js +212 -21
  6. package/node_modules/@groove-dev/daemon/src/index.js +68 -1
  7. package/node_modules/@groove-dev/daemon/src/integrations.js +59 -20
  8. package/node_modules/@groove-dev/daemon/src/process.js +83 -11
  9. package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +4 -0
  10. package/node_modules/@groove-dev/daemon/src/registry.js +1 -1
  11. package/node_modules/@groove-dev/gui/.groove/audit.log +1 -0
  12. package/node_modules/@groove-dev/gui/.groove/codebase-index.json +64 -0
  13. package/node_modules/@groove-dev/gui/.groove/config.json +10 -0
  14. package/node_modules/@groove-dev/gui/.groove/coordination.md +5 -0
  15. package/node_modules/@groove-dev/gui/.groove/credentials.json +6 -0
  16. package/node_modules/@groove-dev/gui/.groove/daemon.port +1 -0
  17. package/node_modules/@groove-dev/gui/.groove/federation/identity.key +3 -0
  18. package/node_modules/@groove-dev/gui/.groove/federation/identity.pub +3 -0
  19. package/node_modules/@groove-dev/gui/.groove/integrations/package.json +6 -0
  20. package/node_modules/@groove-dev/gui/.groove/state.json +3 -0
  21. package/node_modules/@groove-dev/gui/dist/assets/index-x5suAiK7.js +182 -0
  22. package/node_modules/@groove-dev/gui/dist/index.html +1 -1
  23. package/node_modules/@groove-dev/gui/package.json +5 -4
  24. package/node_modules/@groove-dev/gui/src/App.jsx +149 -76
  25. package/node_modules/@groove-dev/gui/src/components/AgentActions.jsx +130 -1
  26. package/node_modules/@groove-dev/gui/src/components/AgentChat.jsx +47 -7
  27. package/node_modules/@groove-dev/gui/src/components/AgentNode.jsx +13 -83
  28. package/node_modules/@groove-dev/gui/src/components/SpawnPanel.jsx +918 -580
  29. package/node_modules/@groove-dev/gui/src/stores/groove.js +31 -2
  30. package/node_modules/@groove-dev/gui/src/views/AgentTree.jsx +133 -67
  31. package/node_modules/@groove-dev/gui/src/views/FileEditor.jsx +85 -1
  32. package/node_modules/@groove-dev/gui/src/views/IntegrationsStore.jsx +121 -44
  33. package/package.json +1 -2
  34. package/packages/cli/package.json +4 -3
  35. package/packages/daemon/integrations-registry.json +0 -40
  36. package/packages/daemon/package.json +4 -3
  37. package/packages/daemon/src/api.js +212 -21
  38. package/packages/daemon/src/index.js +68 -1
  39. package/packages/daemon/src/integrations.js +59 -20
  40. package/packages/daemon/src/process.js +83 -11
  41. package/packages/daemon/src/providers/claude-code.js +4 -0
  42. package/packages/daemon/src/registry.js +1 -1
  43. package/packages/gui/dist/assets/index-x5suAiK7.js +182 -0
  44. package/packages/gui/dist/index.html +1 -1
  45. package/packages/gui/node_modules/.vite/deps/@codemirror_autocomplete.js +68 -0
  46. package/packages/gui/node_modules/.vite/deps/@codemirror_autocomplete.js.map +7 -0
  47. package/packages/gui/node_modules/.vite/deps/@codemirror_commands.js +1420 -0
  48. package/packages/gui/node_modules/.vite/deps/@codemirror_commands.js.map +7 -0
  49. package/packages/gui/node_modules/.vite/deps/@codemirror_lang-css.js +17 -0
  50. package/packages/gui/node_modules/.vite/deps/@codemirror_lang-css.js.map +7 -0
  51. package/packages/gui/node_modules/.vite/deps/@codemirror_lang-html.js +22 -0
  52. package/packages/gui/node_modules/.vite/deps/@codemirror_lang-html.js.map +7 -0
  53. package/packages/gui/node_modules/.vite/deps/@codemirror_lang-javascript.js +34 -0
  54. package/packages/gui/node_modules/.vite/deps/@codemirror_lang-javascript.js.map +7 -0
  55. package/packages/gui/node_modules/.vite/deps/@codemirror_lang-json.js +101 -0
  56. package/packages/gui/node_modules/.vite/deps/@codemirror_lang-json.js.map +7 -0
  57. package/packages/gui/node_modules/.vite/deps/@codemirror_lang-markdown.js +2534 -0
  58. package/packages/gui/node_modules/.vite/deps/@codemirror_lang-markdown.js.map +7 -0
  59. package/packages/gui/node_modules/.vite/deps/@codemirror_lang-python.js +789 -0
  60. package/packages/gui/node_modules/.vite/deps/@codemirror_lang-python.js.map +7 -0
  61. package/packages/gui/node_modules/.vite/deps/@codemirror_language.js +115 -0
  62. package/packages/gui/node_modules/.vite/deps/@codemirror_language.js.map +7 -0
  63. package/packages/gui/node_modules/.vite/deps/@codemirror_search.js +1136 -0
  64. package/packages/gui/node_modules/.vite/deps/@codemirror_search.js.map +7 -0
  65. package/packages/gui/node_modules/.vite/deps/@codemirror_state.js +63 -0
  66. package/packages/gui/node_modules/.vite/deps/@codemirror_state.js.map +7 -0
  67. package/packages/gui/node_modules/.vite/deps/@codemirror_theme-one-dark.js +179 -0
  68. package/packages/gui/node_modules/.vite/deps/@codemirror_theme-one-dark.js.map +7 -0
  69. package/packages/gui/node_modules/.vite/deps/@codemirror_view.js +104 -0
  70. package/packages/gui/node_modules/.vite/deps/@codemirror_view.js.map +7 -0
  71. package/packages/gui/node_modules/.vite/deps/@xterm_addon-fit.js +46 -0
  72. package/packages/gui/node_modules/.vite/deps/@xterm_addon-fit.js.map +7 -0
  73. package/packages/gui/node_modules/.vite/deps/@xterm_addon-web-links.js +121 -0
  74. package/packages/gui/node_modules/.vite/deps/@xterm_addon-web-links.js.map +7 -0
  75. package/packages/gui/node_modules/.vite/deps/@xterm_xterm.js +9237 -0
  76. package/packages/gui/node_modules/.vite/deps/@xterm_xterm.js.map +7 -0
  77. package/packages/gui/node_modules/.vite/deps/@xyflow_react.js +9934 -0
  78. package/packages/gui/node_modules/.vite/deps/@xyflow_react.js.map +7 -0
  79. package/packages/gui/node_modules/.vite/deps/_metadata.json +184 -0
  80. package/packages/gui/node_modules/.vite/deps/chunk-3EE34IFC.js +5169 -0
  81. package/packages/gui/node_modules/.vite/deps/chunk-3EE34IFC.js.map +7 -0
  82. package/packages/gui/node_modules/.vite/deps/chunk-3IB5EUP7.js +2000 -0
  83. package/packages/gui/node_modules/.vite/deps/chunk-3IB5EUP7.js.map +7 -0
  84. package/packages/gui/node_modules/.vite/deps/chunk-3LBP22MX.js +1115 -0
  85. package/packages/gui/node_modules/.vite/deps/chunk-3LBP22MX.js.map +7 -0
  86. package/packages/gui/node_modules/.vite/deps/chunk-3Q7HT7ZF.js +701 -0
  87. package/packages/gui/node_modules/.vite/deps/chunk-3Q7HT7ZF.js.map +7 -0
  88. package/packages/gui/node_modules/.vite/deps/chunk-44CLUOQE.js +1776 -0
  89. package/packages/gui/node_modules/.vite/deps/chunk-44CLUOQE.js.map +7 -0
  90. package/packages/gui/node_modules/.vite/deps/chunk-5RZAEUNX.js +280 -0
  91. package/packages/gui/node_modules/.vite/deps/chunk-5RZAEUNX.js.map +7 -0
  92. package/packages/gui/node_modules/.vite/deps/chunk-5WRI5ZAA.js +30 -0
  93. package/packages/gui/node_modules/.vite/deps/chunk-5WRI5ZAA.js.map +7 -0
  94. package/packages/gui/node_modules/.vite/deps/chunk-7FYDPZIO.js +1004 -0
  95. package/packages/gui/node_modules/.vite/deps/chunk-7FYDPZIO.js.map +7 -0
  96. package/packages/gui/node_modules/.vite/deps/chunk-BX6POZPY.js +292 -0
  97. package/packages/gui/node_modules/.vite/deps/chunk-BX6POZPY.js.map +7 -0
  98. package/packages/gui/node_modules/.vite/deps/chunk-HVFOBSCQ.js +1062 -0
  99. package/packages/gui/node_modules/.vite/deps/chunk-HVFOBSCQ.js.map +7 -0
  100. package/packages/gui/node_modules/.vite/deps/chunk-RE2FU7ZU.js +10985 -0
  101. package/packages/gui/node_modules/.vite/deps/chunk-RE2FU7ZU.js.map +7 -0
  102. package/packages/gui/node_modules/.vite/deps/chunk-YYJMNVCJ.js +3459 -0
  103. package/packages/gui/node_modules/.vite/deps/chunk-YYJMNVCJ.js.map +7 -0
  104. package/packages/gui/node_modules/.vite/deps/package.json +3 -0
  105. package/packages/gui/node_modules/.vite/deps/react-dom.js +6 -0
  106. package/packages/gui/node_modules/.vite/deps/react-dom.js.map +7 -0
  107. package/packages/gui/node_modules/.vite/deps/react-dom_client.js +20217 -0
  108. package/packages/gui/node_modules/.vite/deps/react-dom_client.js.map +7 -0
  109. package/packages/gui/node_modules/.vite/deps/react.js +5 -0
  110. package/packages/gui/node_modules/.vite/deps/react.js.map +7 -0
  111. package/packages/gui/node_modules/.vite/deps/react_jsx-dev-runtime.js +278 -0
  112. package/packages/gui/node_modules/.vite/deps/react_jsx-dev-runtime.js.map +7 -0
  113. package/packages/gui/node_modules/.vite/deps/react_jsx-runtime.js +6 -0
  114. package/packages/gui/node_modules/.vite/deps/react_jsx-runtime.js.map +7 -0
  115. package/packages/gui/node_modules/.vite/deps/zustand.js +56 -0
  116. package/packages/gui/node_modules/.vite/deps/zustand.js.map +7 -0
  117. package/packages/gui/package.json +5 -4
  118. package/packages/gui/src/App.jsx +149 -76
  119. package/packages/gui/src/components/AgentActions.jsx +130 -1
  120. package/packages/gui/src/components/AgentChat.jsx +47 -7
  121. package/packages/gui/src/components/AgentNode.jsx +13 -83
  122. package/packages/gui/src/components/SpawnPanel.jsx +918 -580
  123. package/packages/gui/src/stores/groove.js +31 -2
  124. package/packages/gui/src/views/AgentTree.jsx +133 -67
  125. package/packages/gui/src/views/FileEditor.jsx +85 -1
  126. package/packages/gui/src/views/IntegrationsStore.jsx +121 -44
  127. package/docs/FILE-EDITOR-PLAN.md +0 -253
  128. package/docs/GUI_DESIGN_SPEC.md +0 -402
  129. package/docs/SKILLS-API-SPEC.md +0 -277
  130. package/node_modules/@groove-dev/gui/dist/assets/index-D5dtDQf0.js +0 -156
  131. package/packages/gui/dist/assets/index-D5dtDQf0.js +0 -156
@@ -5,8 +5,9 @@ import express from 'express';
5
5
  import { resolve, dirname } from 'path';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, unlinkSync, renameSync, rmSync, createReadStream } from 'fs';
8
+ import { spawn as cpSpawn } from 'child_process';
8
9
  import { lookup as mimeLookup } from './mimetypes.js';
9
- import { listProviders } from './providers/index.js';
10
+ import { listProviders, getProvider } from './providers/index.js';
10
11
  import { validateAgentConfig } from './validate.js';
11
12
 
12
13
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -351,6 +352,67 @@ export function createApi(app, daemon) {
351
352
  });
352
353
  });
353
354
 
355
+ // Plan chat — direct API (fast, sub-second) when API key available, CLI fallback otherwise
356
+ const PLAN_SYSTEM = `You are the planning assistant built into Groove's spawn panel. The user is configuring an AI agent right now — they're looking at a form with role selection, file scope, skills, integrations, effort level, and a task prompt. Your conversation will be synthesized into the agent's task prompt when they click "Generate Prompt."
357
+
358
+ Your job: help them think through what the agent should do, then craft a clear plan. Be direct and practical. Don't ask how they'll feed input to agents or what tools to use — they're already inside Groove doing it. Focus on the TASK itself.
359
+
360
+ What you know about the system:
361
+ - The user is in the spawn panel, configuring an agent before launching it
362
+ - The left panel has: role picker, directory, permissions, effort, integrations, skills, schedule
363
+ - When done planning, "Generate Prompt" synthesizes this chat into the agent's task prompt
364
+ - Agents are Claude Code instances with full terminal/file access in the specified directory
365
+ - Agents can read/write files, run commands, use MCP integrations (Slack, GitHub, etc.)
366
+ - The journalist system prevents cold starts during context rotation — agents don't lose context
367
+
368
+ Keep responses concise. Help them think, don't lecture them about the system they built.`;
369
+
370
+ app.post('/api/journalist/query', async (req, res) => {
371
+ try {
372
+ const { prompt } = req.body || {};
373
+ if (!prompt) return res.status(400).json({ error: 'prompt is required' });
374
+
375
+ // Fast path: direct Anthropic API call (sub-second)
376
+ const apiKey = daemon.credentials.getKey('anthropic-api');
377
+ if (apiKey) {
378
+ const apiRes = await fetch('https://api.anthropic.com/v1/messages', {
379
+ method: 'POST',
380
+ headers: {
381
+ 'Content-Type': 'application/json',
382
+ 'x-api-key': apiKey,
383
+ 'anthropic-version': '2023-06-01',
384
+ },
385
+ body: JSON.stringify({
386
+ model: 'claude-haiku-4-5-20251001',
387
+ max_tokens: 1024,
388
+ system: PLAN_SYSTEM,
389
+ messages: [{ role: 'user', content: prompt }],
390
+ }),
391
+ });
392
+ const data = await apiRes.json();
393
+ if (data.content?.[0]?.text) {
394
+ return res.json({ response: data.content[0].text, mode: 'fast' });
395
+ }
396
+ if (data.error) {
397
+ return res.status(400).json({ error: data.error.message || 'API error' });
398
+ }
399
+ }
400
+
401
+ // Slow path: CLI fallback for subscription auth (~10s)
402
+ const fullPrompt = `${PLAN_SYSTEM}\n\n${prompt}`;
403
+ const response = await daemon.journalist.callHeadless(fullPrompt);
404
+ res.json({ response, mode: 'cli' });
405
+ } catch (err) {
406
+ res.status(500).json({ error: err.message });
407
+ }
408
+ });
409
+
410
+ // Check if Anthropic API key is configured
411
+ app.get('/api/anthropic-key/status', (req, res) => {
412
+ const hasKey = !!daemon.credentials.getKey('anthropic-api');
413
+ res.json({ configured: hasKey });
414
+ });
415
+
354
416
  // Trigger journalist cycle manually
355
417
  app.post('/api/journalist/cycle', async (req, res) => {
356
418
  try {
@@ -507,11 +569,13 @@ export function createApi(app, daemon) {
507
569
  // Parameterized :id routes (after specific routes above)
508
570
 
509
571
  app.post('/api/integrations/:id/authenticate', (req, res) => {
572
+ console.log(`[Groove:API] POST /api/integrations/${req.params.id}/authenticate`);
510
573
  try {
511
574
  const handle = daemon.integrations.authenticate(req.params.id);
575
+ console.log(`[Groove:API] Authenticate started, PID: ${handle.pid}`);
512
576
  res.json({ ok: true, pid: handle.pid });
513
- // Auto-cleanup tracked by the handle timeout
514
577
  } catch (err) {
578
+ console.log(`[Groove:API] Authenticate error: ${err.message}`);
515
579
  res.status(400).json({ error: err.message });
516
580
  }
517
581
  });
@@ -720,6 +784,34 @@ export function createApi(app, daemon) {
720
784
  }
721
785
  });
722
786
 
787
+ // Browse absolute paths (for directory picker in agent config)
788
+ // Dirs only, localhost-only, no file content exposed
789
+ app.get('/api/browse-system', (req, res) => {
790
+ const absPath = req.query.path || process.env.HOME || '/';
791
+ if (absPath.includes('\0')) return res.status(400).json({ error: 'Invalid path' });
792
+ if (!existsSync(absPath)) return res.status(404).json({ error: 'Not found' });
793
+
794
+ try {
795
+ const entries = readdirSync(absPath, { withFileTypes: true })
796
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules')
797
+ .sort((a, b) => a.name.localeCompare(b.name))
798
+ .map((e) => {
799
+ const full = resolve(absPath, e.name);
800
+ let hasChildren = false;
801
+ try {
802
+ hasChildren = readdirSync(full, { withFileTypes: true })
803
+ .some((c) => c.isDirectory() && !c.name.startsWith('.') && c.name !== 'node_modules');
804
+ } catch { /* unreadable */ }
805
+ return { name: e.name, path: full, hasChildren };
806
+ });
807
+
808
+ const parent = absPath === '/' ? null : resolve(absPath, '..');
809
+ res.json({ current: absPath, parent, dirs: entries });
810
+ } catch (err) {
811
+ res.status(500).json({ error: err.message });
812
+ }
813
+ });
814
+
723
815
  // --- File Editor API ---
724
816
 
725
817
  const LANG_MAP = {
@@ -740,6 +832,11 @@ export function createApi(app, daemon) {
740
832
 
741
833
  const IGNORED_NAMES = new Set(['.git', 'node_modules', '.DS_Store', '.groove', '__pycache__', '.next', '.cache', 'dist', 'coverage']);
742
834
 
835
+ // Editor root directory — defaults to projectDir but can be changed at runtime
836
+ let editorRootDir = daemon.projectDir;
837
+
838
+ function getEditorRoot() { return editorRootDir; }
839
+
743
840
  function validateFilePath(relPath, projectDir) {
744
841
  if (!relPath || typeof relPath !== 'string') return { error: 'path is required' };
745
842
  if (relPath.startsWith('/') || relPath.includes('..') || relPath.includes('\0')) {
@@ -750,6 +847,27 @@ export function createApi(app, daemon) {
750
847
  return { fullPath };
751
848
  }
752
849
 
850
+ // Get/set the editor working directory
851
+ app.get('/api/files/root', (req, res) => {
852
+ res.json({ root: editorRootDir });
853
+ });
854
+
855
+ app.post('/api/files/root', (req, res) => {
856
+ const { root } = req.body || {};
857
+ if (!root || typeof root !== 'string') return res.status(400).json({ error: 'root path is required' });
858
+ // Must be absolute and exist
859
+ if (!root.startsWith('/')) return res.status(400).json({ error: 'root must be an absolute path' });
860
+ if (root.includes('\0') || root.includes('..')) return res.status(400).json({ error: 'Invalid path' });
861
+ if (!existsSync(root)) return res.status(404).json({ error: 'Directory not found' });
862
+ try {
863
+ const stat = statSync(root);
864
+ if (!stat.isDirectory()) return res.status(400).json({ error: 'Path is not a directory' });
865
+ } catch { return res.status(400).json({ error: 'Cannot access directory' }); }
866
+ editorRootDir = root;
867
+ daemon.audit.log('editor.root.set', { root });
868
+ res.json({ ok: true, root: editorRootDir });
869
+ });
870
+
753
871
  // File tree — returns dirs + files for a given path
754
872
  app.get('/api/files/tree', (req, res) => {
755
873
  const relPath = req.query.path || '';
@@ -759,8 +877,9 @@ export function createApi(app, daemon) {
759
877
  return res.status(400).json({ error: 'Invalid path' });
760
878
  }
761
879
 
762
- const fullPath = relPath ? resolve(daemon.projectDir, relPath) : daemon.projectDir;
763
- if (!fullPath.startsWith(daemon.projectDir)) {
880
+ const rootDir = getEditorRoot();
881
+ const fullPath = relPath ? resolve(rootDir, relPath) : rootDir;
882
+ if (!fullPath.startsWith(rootDir)) {
764
883
  return res.status(400).json({ error: 'Path outside project' });
765
884
  }
766
885
  if (!existsSync(fullPath)) {
@@ -810,7 +929,7 @@ export function createApi(app, daemon) {
810
929
 
811
930
  // Read file contents
812
931
  app.get('/api/files/read', (req, res) => {
813
- const result = validateFilePath(req.query.path, daemon.projectDir);
932
+ const result = validateFilePath(req.query.path, getEditorRoot());
814
933
  if (result.error) return res.status(400).json({ error: result.error });
815
934
 
816
935
  if (!existsSync(result.fullPath)) {
@@ -846,7 +965,7 @@ export function createApi(app, daemon) {
846
965
  // Write file contents
847
966
  app.post('/api/files/write', (req, res) => {
848
967
  const { path: relPath, content } = req.body;
849
- const result = validateFilePath(relPath, daemon.projectDir);
968
+ const result = validateFilePath(relPath, getEditorRoot());
850
969
  if (result.error) return res.status(400).json({ error: result.error });
851
970
 
852
971
  if (typeof content !== 'string') {
@@ -868,7 +987,7 @@ export function createApi(app, daemon) {
868
987
  // Create a new file
869
988
  app.post('/api/files/create', (req, res) => {
870
989
  const { path: relPath, content = '' } = req.body;
871
- const result = validateFilePath(relPath, daemon.projectDir);
990
+ const result = validateFilePath(relPath, getEditorRoot());
872
991
  if (result.error) return res.status(400).json({ error: result.error });
873
992
 
874
993
  if (existsSync(result.fullPath)) {
@@ -893,7 +1012,7 @@ export function createApi(app, daemon) {
893
1012
  // Create a new directory
894
1013
  app.post('/api/files/mkdir', (req, res) => {
895
1014
  const { path: relPath } = req.body;
896
- const result = validateFilePath(relPath, daemon.projectDir);
1015
+ const result = validateFilePath(relPath, getEditorRoot());
897
1016
  if (result.error) return res.status(400).json({ error: result.error });
898
1017
 
899
1018
  if (existsSync(result.fullPath)) {
@@ -912,7 +1031,7 @@ export function createApi(app, daemon) {
912
1031
  // Delete a file or directory
913
1032
  app.delete('/api/files/delete', (req, res) => {
914
1033
  const relPath = req.query.path || req.body?.path;
915
- const result = validateFilePath(relPath, daemon.projectDir);
1034
+ const result = validateFilePath(relPath, getEditorRoot());
916
1035
  if (result.error) return res.status(400).json({ error: result.error });
917
1036
 
918
1037
  if (!existsSync(result.fullPath)) {
@@ -936,9 +1055,9 @@ export function createApi(app, daemon) {
936
1055
  // Rename / move a file or directory
937
1056
  app.post('/api/files/rename', (req, res) => {
938
1057
  const { oldPath, newPath } = req.body;
939
- const oldResult = validateFilePath(oldPath, daemon.projectDir);
1058
+ const oldResult = validateFilePath(oldPath, getEditorRoot());
940
1059
  if (oldResult.error) return res.status(400).json({ error: oldResult.error });
941
- const newResult = validateFilePath(newPath, daemon.projectDir);
1060
+ const newResult = validateFilePath(newPath, getEditorRoot());
942
1061
  if (newResult.error) return res.status(400).json({ error: newResult.error });
943
1062
 
944
1063
  if (!existsSync(oldResult.fullPath)) {
@@ -962,7 +1081,7 @@ export function createApi(app, daemon) {
962
1081
 
963
1082
  // Serve raw file (images, video, etc.)
964
1083
  app.get('/api/files/raw', (req, res) => {
965
- const result = validateFilePath(req.query.path, daemon.projectDir);
1084
+ const result = validateFilePath(req.query.path, getEditorRoot());
966
1085
  if (result.error) return res.status(400).json({ error: result.error });
967
1086
 
968
1087
  if (!existsSync(result.fullPath)) {
@@ -1033,9 +1152,25 @@ export function createApi(app, daemon) {
1033
1152
 
1034
1153
  // --- Recommended Team (from planner) ---
1035
1154
 
1155
+ // Find recommended-team.json — check all agent working dirs, then daemon's grooveDir
1156
+ function findRecommendedTeam() {
1157
+ // Check agent working dirs first (planner may have written there)
1158
+ const agents = daemon.registry.getAll();
1159
+ for (const agent of agents) {
1160
+ if (agent.workingDir) {
1161
+ const p = resolve(agent.workingDir, '.groove', 'recommended-team.json');
1162
+ if (existsSync(p)) return p;
1163
+ }
1164
+ }
1165
+ // Fallback to daemon's .groove dir
1166
+ const p = resolve(daemon.grooveDir, 'recommended-team.json');
1167
+ if (existsSync(p)) return p;
1168
+ return null;
1169
+ }
1170
+
1036
1171
  app.get('/api/recommended-team', (req, res) => {
1037
- const teamPath = resolve(daemon.grooveDir, 'recommended-team.json');
1038
- if (!existsSync(teamPath)) {
1172
+ const teamPath = findRecommendedTeam();
1173
+ if (!teamPath) {
1039
1174
  return res.json({ exists: false, agents: [] });
1040
1175
  }
1041
1176
  try {
@@ -1047,8 +1182,8 @@ export function createApi(app, daemon) {
1047
1182
  });
1048
1183
 
1049
1184
  app.post('/api/recommended-team/launch', async (req, res) => {
1050
- const teamPath = resolve(daemon.grooveDir, 'recommended-team.json');
1051
- if (!existsSync(teamPath)) {
1185
+ const teamPath = findRecommendedTeam();
1186
+ if (!teamPath) {
1052
1187
  return res.status(404).json({ error: 'No recommended team found. Run a planner first.' });
1053
1188
  }
1054
1189
  try {
@@ -1057,8 +1192,24 @@ export function createApi(app, daemon) {
1057
1192
  return res.status(400).json({ error: 'Recommended team is empty' });
1058
1193
  }
1059
1194
 
1195
+ const defaultDir = daemon.config?.defaultWorkingDir || undefined;
1196
+
1197
+ // Separate phase 1 (builders) and phase 2 (QC/finisher)
1198
+ const phase1 = agents.filter((a) => !a.phase || a.phase === 1);
1199
+ let phase2 = agents.filter((a) => a.phase === 2);
1200
+
1201
+ // Safety net: if planner forgot the QC agent, auto-add one
1202
+ if (phase2.length === 0 && phase1.length >= 2) {
1203
+ phase2 = [{
1204
+ role: 'fullstack', phase: 2, scope: [],
1205
+ prompt: 'QC Senior Dev: All builder agents have completed. Audit their changes for correctness, fix any issues, run tests, build the project, commit all changes, and launch. Output the localhost URL where the app can be accessed.',
1206
+ }];
1207
+ }
1208
+
1209
+ // Spawn phase 1 agents immediately
1060
1210
  const spawned = [];
1061
- for (const config of agents) {
1211
+ const phase1Ids = [];
1212
+ for (const config of phase1) {
1062
1213
  const validated = validateAgentConfig({
1063
1214
  role: config.role,
1064
1215
  scope: config.scope || [],
@@ -1066,19 +1217,59 @@ export function createApi(app, daemon) {
1066
1217
  provider: config.provider || 'claude-code',
1067
1218
  model: config.model || 'auto',
1068
1219
  permission: config.permission || 'auto',
1069
- workingDir: config.workingDir || undefined,
1220
+ workingDir: config.workingDir || defaultDir,
1221
+ name: config.name || undefined,
1070
1222
  });
1071
1223
  const agent = await daemon.processes.spawn(validated);
1072
1224
  spawned.push({ id: agent.id, name: agent.name, role: agent.role });
1225
+ phase1Ids.push(agent.id);
1226
+ }
1227
+
1228
+ // If there are phase 2 agents, register them for auto-spawn on phase 1 completion
1229
+ if (phase2.length > 0 && phase1Ids.length > 0) {
1230
+ daemon._pendingPhase2 = daemon._pendingPhase2 || [];
1231
+ daemon._pendingPhase2.push({
1232
+ waitFor: phase1Ids,
1233
+ agents: phase2.map((c) => ({
1234
+ role: c.role, scope: c.scope || [], prompt: c.prompt || '',
1235
+ provider: c.provider || 'claude-code', model: c.model || 'auto',
1236
+ permission: c.permission || 'auto',
1237
+ workingDir: c.workingDir || defaultDir,
1238
+ name: c.name || undefined,
1239
+ })),
1240
+ });
1073
1241
  }
1074
1242
 
1075
- daemon.audit.log('team.launch', { count: spawned.length, agents: spawned.map((a) => a.role) });
1076
- res.json({ launched: spawned.length, agents: spawned });
1243
+ daemon.audit.log('team.launch', {
1244
+ phase1: spawned.length, phase2Pending: phase2.length,
1245
+ agents: spawned.map((a) => a.role),
1246
+ });
1247
+ res.json({ launched: spawned.length, phase2Pending: phase2.length, agents: spawned });
1077
1248
  } catch (err) {
1078
1249
  res.status(500).json({ error: err.message });
1079
1250
  }
1080
1251
  });
1081
1252
 
1253
+ // Clean up stale artifacts (old plans, recommended teams, etc.)
1254
+ app.post('/api/cleanup', (req, res) => {
1255
+ let cleaned = 0;
1256
+ // Clean recommended-team.json from all known locations
1257
+ const locations = [resolve(daemon.grooveDir, 'recommended-team.json')];
1258
+ for (const agent of daemon.registry.getAll()) {
1259
+ if (agent.workingDir) {
1260
+ locations.push(resolve(agent.workingDir, '.groove', 'recommended-team.json'));
1261
+ }
1262
+ }
1263
+ const defaultDir = daemon.config?.defaultWorkingDir;
1264
+ if (defaultDir) locations.push(resolve(defaultDir, '.groove', 'recommended-team.json'));
1265
+
1266
+ for (const p of locations) {
1267
+ if (existsSync(p)) { try { unlinkSync(p); cleaned++; } catch { /* */ } }
1268
+ }
1269
+ daemon.audit.log('cleanup', { cleaned });
1270
+ res.json({ ok: true, cleaned });
1271
+ });
1272
+
1082
1273
  // --- Command Center Dashboard ---
1083
1274
 
1084
1275
  app.get('/api/dashboard', (req, res) => {
@@ -1259,7 +1450,7 @@ export function createApi(app, daemon) {
1259
1450
  app.patch('/api/config', async (req, res) => {
1260
1451
  const ALLOWED_KEYS = [
1261
1452
  'port', 'journalistInterval', 'rotationThreshold', 'autoRotation',
1262
- 'qcThreshold', 'maxAgents', 'defaultProvider',
1453
+ 'qcThreshold', 'maxAgents', 'defaultProvider', 'defaultWorkingDir',
1263
1454
  ];
1264
1455
  for (const key of Object.keys(req.body)) {
1265
1456
  if (!ALLOWED_KEYS.includes(key)) {
@@ -5,7 +5,7 @@ import { createServer as createHttpServer } from 'http';
5
5
  import { createServer as createNetServer } from 'net';
6
6
  import { execFileSync } from 'child_process';
7
7
  import { resolve } from 'path';
8
- import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
8
+ import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
9
9
  import express from 'express';
10
10
  import { WebSocketServer } from 'ws';
11
11
  import { Registry } from './registry.js';
@@ -247,6 +247,15 @@ export class Daemon {
247
247
  tester.listen(port, bindHost);
248
248
  }).catch(() => false);
249
249
 
250
+ if (!(await checkPort(this.port))) {
251
+ // Wait for port release (e.g., after groove stop)
252
+ let retries = 5;
253
+ while (retries > 0 && !(await checkPort(this.port))) {
254
+ await new Promise((r) => setTimeout(r, 1000));
255
+ retries--;
256
+ }
257
+ }
258
+
250
259
  if (!(await checkPort(this.port))) {
251
260
  const originalPort = this.port;
252
261
  // Try next 10 ports
@@ -281,6 +290,7 @@ export class Daemon {
281
290
  this.journalist.start();
282
291
  this.rotator.start();
283
292
  this.scheduler.start();
293
+ this._startGarbageCollector();
284
294
 
285
295
  // Scan codebase for workspace/structure awareness
286
296
  this.indexer.scan();
@@ -290,6 +300,62 @@ export class Daemon {
290
300
  });
291
301
  }
292
302
 
303
+ _startGarbageCollector() {
304
+ // Run once on startup, then every 24 hours
305
+ this._gc();
306
+ this._gcInterval = setInterval(() => this._gc(), 24 * 60 * 60 * 1000);
307
+ }
308
+
309
+ _gc() {
310
+ const { grooveDir } = this;
311
+ let cleaned = 0;
312
+
313
+ try {
314
+ // 1. Clean old log files (>7 days, agent no longer exists)
315
+ const logsDir = resolve(grooveDir, 'logs');
316
+ if (existsSync(logsDir)) {
317
+ const now = Date.now();
318
+ const sevenDays = 7 * 24 * 60 * 60 * 1000;
319
+ for (const file of readdirSync(logsDir)) {
320
+ const p = resolve(logsDir, file);
321
+ try {
322
+ const age = now - statSync(p).mtimeMs;
323
+ if (age > sevenDays) { unlinkSync(p); cleaned++; }
324
+ } catch { /* skip */ }
325
+ }
326
+ }
327
+
328
+ // 2. Clean stale recommended-team.json from daemon dir (not working dirs — those are user-managed)
329
+ // Only clean if no planner agent is currently running
330
+ const hasPlanner = this.registry.getAll().some((a) => a.role === 'planner' && (a.status === 'running' || a.status === 'starting'));
331
+ if (!hasPlanner) {
332
+ const teamFile = resolve(grooveDir, 'recommended-team.json');
333
+ if (existsSync(teamFile)) {
334
+ try {
335
+ const age = Date.now() - statSync(teamFile).mtimeMs;
336
+ if (age > 24 * 60 * 60 * 1000) { unlinkSync(teamFile); cleaned++; } // >24h old
337
+ } catch { /* skip */ }
338
+ }
339
+ }
340
+
341
+ // 3. Prune audit log (keep last 1000 lines)
342
+ const auditFile = resolve(grooveDir, 'audit.log');
343
+ if (existsSync(auditFile)) {
344
+ try {
345
+ const lines = readFileSync(auditFile, 'utf8').split('\n');
346
+ if (lines.length > 1000) {
347
+ writeFileSync(auditFile, lines.slice(-1000).join('\n'));
348
+ cleaned++;
349
+ }
350
+ } catch { /* skip */ }
351
+ }
352
+
353
+ if (cleaned > 0) {
354
+ this.audit.log('gc.run', { cleaned });
355
+ }
356
+ } catch { /* gc should never crash the daemon */ }
357
+ }
358
+
293
359
  async stop() {
294
360
  // Persist state before shutdown
295
361
  this.state.set('agents', this.registry.getAll());
@@ -299,6 +365,7 @@ export class Daemon {
299
365
  this.journalist.stop();
300
366
  this.rotator.stop();
301
367
  this.scheduler.stop();
368
+ if (this._gcInterval) clearInterval(this._gcInterval);
302
369
 
303
370
  // Clean up file watchers and terminal sessions
304
371
  this.fileWatcher.unwatchAll();
@@ -380,10 +380,8 @@ export class IntegrationStore {
380
380
  if (!entry) throw new Error(`Integration not found: ${integrationId}`);
381
381
  if (entry.authType !== 'oauth-google') throw new Error('Integration does not use OAuth');
382
382
 
383
- // Check if user has provided their own Google OAuth client (stored globally)
384
- const clientId = this.getCredential('google-oauth', 'GOOGLE_CLIENT_ID');
385
- const clientSecret = this.getCredential('google-oauth', 'GOOGLE_CLIENT_SECRET');
386
- if (!clientId || !clientSecret) {
383
+ const creds = this._getGoogleOAuthCredentials();
384
+ if (!creds) {
387
385
  throw new Error('Google OAuth not configured. Set up your Google Cloud project first.');
388
386
  }
389
387
 
@@ -392,7 +390,7 @@ export class IntegrationStore {
392
390
  const scopes = entry.oauthScopes || [];
393
391
 
394
392
  const params = new URLSearchParams({
395
- client_id: clientId,
393
+ client_id: creds.clientId,
396
394
  redirect_uri: redirectUri,
397
395
  response_type: 'code',
398
396
  scope: scopes.join(' '),
@@ -408,11 +406,11 @@ export class IntegrationStore {
408
406
  * Handle OAuth callback — exchange code for tokens.
409
407
  */
410
408
  async handleOAuthCallback(code, integrationId) {
411
- const clientId = this.getCredential('google-oauth', 'GOOGLE_CLIENT_ID');
412
- const clientSecret = this.getCredential('google-oauth', 'GOOGLE_CLIENT_SECRET');
413
- if (!clientId || !clientSecret) {
409
+ const creds = this._getGoogleOAuthCredentials();
410
+ if (!creds) {
414
411
  throw new Error('Google OAuth credentials not found');
415
412
  }
413
+ const { clientId, clientSecret } = creds;
416
414
 
417
415
  const port = this.daemon.port || 31415;
418
416
  const redirectUri = `http://localhost:${port}/api/integrations/oauth/callback`;
@@ -451,10 +449,32 @@ export class IntegrationStore {
451
449
  /**
452
450
  * Check if Google OAuth is configured (user has set up their Cloud project).
453
451
  */
452
+ /**
453
+ * Get Google OAuth credentials from user config OR bundled defaults.
454
+ * User-configured credentials take priority over bundled defaults.
455
+ */
456
+ _getGoogleOAuthCredentials() {
457
+ // 1. Check user-configured credentials (encrypted store)
458
+ let clientId = this.getCredential('google-oauth', 'GOOGLE_CLIENT_ID');
459
+ let clientSecret = this.getCredential('google-oauth', 'GOOGLE_CLIENT_SECRET');
460
+ if (clientId && clientSecret) return { clientId, clientSecret, source: 'user' };
461
+
462
+ // 2. Check bundled defaults (shipped with Groove)
463
+ try {
464
+ const defaultsPath = resolve(__dirname, '../google-oauth.json');
465
+ if (existsSync(defaultsPath)) {
466
+ const defaults = JSON.parse(readFileSync(defaultsPath, 'utf8'));
467
+ if (defaults.client_id && defaults.client_secret) {
468
+ return { clientId: defaults.client_id, clientSecret: defaults.client_secret, source: 'bundled' };
469
+ }
470
+ }
471
+ } catch { /* no bundled defaults */ }
472
+
473
+ return null;
474
+ }
475
+
454
476
  isGoogleOAuthConfigured() {
455
- const clientId = this.getCredential('google-oauth', 'GOOGLE_CLIENT_ID');
456
- const clientSecret = this.getCredential('google-oauth', 'GOOGLE_CLIENT_SECRET');
457
- return !!(clientId && clientSecret);
477
+ return !!this._getGoogleOAuthCredentials();
458
478
  }
459
479
 
460
480
  /**
@@ -467,14 +487,18 @@ export class IntegrationStore {
467
487
  const entry = this.registry.find((s) => s.id === integrationId);
468
488
  if (!entry) throw new Error(`Integration not found: ${integrationId}`);
469
489
 
490
+ console.log(`[Groove:Integrations] authenticate(${integrationId}) — authType: ${entry.authType}`);
491
+
470
492
  // For google-autoauth integrations, write the gcp-oauth.keys.json file
471
493
  // that the MCP server expects before it can start the OAuth browser flow
472
494
  if (entry.authType === 'google-autoauth') {
495
+ console.log(`[Groove:Integrations] Writing gcp-oauth.keys.json for ${integrationId}`);
473
496
  this._writeGoogleOAuthKeys(entry);
474
497
  }
475
498
 
476
499
  const command = entry.command || 'npx';
477
500
  const args = entry.args || ['-y', entry.npmPackage];
501
+ console.log(`[Groove:Integrations] Spawning: ${command} ${args.join(' ')}`);
478
502
 
479
503
  // Build env with any configured credentials
480
504
  const env = {};
@@ -491,6 +515,8 @@ export class IntegrationStore {
491
515
  detached: false,
492
516
  });
493
517
 
518
+ console.log(`[Groove:Integrations] Process spawned, PID: ${proc.pid}`);
519
+
494
520
  // Send MCP handshake to initialize the server — this triggers auth
495
521
  const initMsg = JSON.stringify({
496
522
  jsonrpc: '2.0', id: 1, method: 'initialize',
@@ -507,28 +533,38 @@ export class IntegrationStore {
507
533
  jsonrpc: '2.0', method: 'notifications/initialized',
508
534
  });
509
535
 
510
- // Wait a moment for npx to download + start, then send handshake
511
536
  proc.stdout.on('data', (chunk) => {
512
537
  const text = chunk.toString();
538
+ console.log(`[Groove:Integrations] MCP stdout: ${text.slice(0, 200)}`);
513
539
  // After initialize response, send initialized notification + tools/list
514
540
  if (text.includes('"id":1') || text.includes('"id": 1')) {
541
+ console.log('[Groove:Integrations] Got initialize response, sending initialized + tools/list');
515
542
  proc.stdin.write(initializedNotif + '\n');
516
543
  setTimeout(() => proc.stdin.write(listToolsMsg + '\n'), 500);
517
544
  }
518
545
  });
519
546
 
547
+ proc.on('error', (err) => {
548
+ console.log(`[Groove:Integrations] Process error: ${err.message}`);
549
+ });
550
+
551
+ proc.on('exit', (code, signal) => {
552
+ console.log(`[Groove:Integrations] Process exited: code=${code} signal=${signal}`);
553
+ clearTimeout(timeout);
554
+ });
555
+
520
556
  // Send initialize after a brief delay for npx startup
521
557
  setTimeout(() => {
522
- try { proc.stdin.write(initMsg + '\n'); } catch { /* process may have exited */ }
558
+ console.log('[Groove:Integrations] Sending MCP initialize message');
559
+ try { proc.stdin.write(initMsg + '\n'); } catch (e) { console.log('[Groove:Integrations] stdin write failed:', e.message); }
523
560
  }, 3000);
524
561
 
525
- // Auto-kill after 2 minutes (auth should complete well before that)
562
+ // Auto-kill after 2 minutes
526
563
  const timeout = setTimeout(() => {
564
+ console.log('[Groove:Integrations] Auth timeout — killing process');
527
565
  try { proc.kill('SIGTERM'); } catch { /* ignore */ }
528
566
  }, 120_000);
529
567
 
530
- proc.on('exit', () => clearTimeout(timeout));
531
-
532
568
  this.daemon.audit.log('integration.authenticate', { id: integrationId });
533
569
 
534
570
  return {
@@ -543,11 +579,11 @@ export class IntegrationStore {
543
579
  * before they can open the browser for user consent.
544
580
  */
545
581
  _writeGoogleOAuthKeys(entry) {
546
- const clientId = this.getCredential('google-oauth', 'GOOGLE_CLIENT_ID');
547
- const clientSecret = this.getCredential('google-oauth', 'GOOGLE_CLIENT_SECRET');
548
- if (!clientId || !clientSecret) {
549
- throw new Error('Google OAuth not configured. Click "Sign in with Google" to set up your Google Cloud credentials first.');
582
+ const creds = this._getGoogleOAuthCredentials();
583
+ if (!creds) {
584
+ throw new Error('Google OAuth not configured. Set up your Google Cloud credentials first.');
550
585
  }
586
+ const { clientId, clientSecret } = creds;
551
587
 
552
588
  const keysContent = JSON.stringify({
553
589
  installed: {
@@ -567,6 +603,9 @@ export class IntegrationStore {
567
603
  mkdirSync(dirPath, { recursive: true });
568
604
  const keysPath = resolve(dirPath, 'gcp-oauth.keys.json');
569
605
  writeFileSync(keysPath, keysContent, { mode: 0o600 });
606
+ console.log(`[Groove:Integrations] Wrote OAuth keys to: ${keysPath}`);
607
+ } else {
608
+ console.log(`[Groove:Integrations] WARNING: No oauthKeysDir for ${entry.id}`);
570
609
  }
571
610
  }
572
611