groove-dev 0.27.60 → 0.27.61

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 (45) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/src/api.js +69 -52
  4. package/node_modules/@groove-dev/daemon/src/conversations.js +75 -31
  5. package/node_modules/@groove-dev/daemon/src/journalist.js +1 -0
  6. package/node_modules/@groove-dev/daemon/src/process.js +17 -7
  7. package/node_modules/@groove-dev/daemon/src/providers/base.js +4 -0
  8. package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +63 -0
  9. package/node_modules/@groove-dev/daemon/src/providers/codex.js +55 -0
  10. package/node_modules/@groove-dev/daemon/src/providers/gemini.js +53 -0
  11. package/node_modules/@groove-dev/daemon/src/providers/local.js +44 -0
  12. package/node_modules/@groove-dev/daemon/src/providers/ollama.js +44 -0
  13. package/node_modules/@groove-dev/daemon/src/rotator.js +4 -0
  14. package/node_modules/@groove-dev/gui/dist/assets/{index-DD6taBMp.css → index-B3AqeyS4.css} +1 -1
  15. package/node_modules/@groove-dev/gui/dist/assets/{index-DcnRqlqB.js → index-DWao9glo.js} +178 -178
  16. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  17. package/node_modules/@groove-dev/gui/package.json +1 -1
  18. package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +3 -2
  19. package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +1 -1
  20. package/node_modules/@groove-dev/gui/src/components/layout/status-bar.jsx +13 -7
  21. package/node_modules/@groove-dev/gui/src/components/ui/update-modal.jsx +70 -0
  22. package/node_modules/@groove-dev/gui/src/stores/groove.js +27 -6
  23. package/package.json +1 -1
  24. package/packages/cli/package.json +1 -1
  25. package/packages/daemon/package.json +1 -1
  26. package/packages/daemon/src/api.js +69 -52
  27. package/packages/daemon/src/conversations.js +75 -31
  28. package/packages/daemon/src/journalist.js +1 -0
  29. package/packages/daemon/src/process.js +17 -7
  30. package/packages/daemon/src/providers/base.js +4 -0
  31. package/packages/daemon/src/providers/claude-code.js +63 -0
  32. package/packages/daemon/src/providers/codex.js +55 -0
  33. package/packages/daemon/src/providers/gemini.js +53 -0
  34. package/packages/daemon/src/providers/local.js +44 -0
  35. package/packages/daemon/src/providers/ollama.js +44 -0
  36. package/packages/daemon/src/rotator.js +4 -0
  37. package/packages/gui/dist/assets/{index-DD6taBMp.css → index-B3AqeyS4.css} +1 -1
  38. package/packages/gui/dist/assets/{index-DcnRqlqB.js → index-DWao9glo.js} +178 -178
  39. package/packages/gui/dist/index.html +2 -2
  40. package/packages/gui/package.json +1 -1
  41. package/packages/gui/src/components/chat/chat-view.jsx +3 -2
  42. package/packages/gui/src/components/chat/model-picker.jsx +1 -1
  43. package/packages/gui/src/components/layout/status-bar.jsx +13 -7
  44. package/packages/gui/src/components/ui/update-modal.jsx +70 -0
  45. package/packages/gui/src/stores/groove.js +27 -6
@@ -6,12 +6,12 @@
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <link rel="icon" type="image/png" href="/favicon.png" />
8
8
  <title>Groove GUI</title>
9
- <script type="module" crossorigin src="/assets/index-DcnRqlqB.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-DWao9glo.js"></script>
10
10
  <link rel="modulepreload" crossorigin href="/assets/vendor-C0HXlhrU.js">
11
11
  <link rel="modulepreload" crossorigin href="/assets/reactflow-BQPfi37R.js">
12
12
  <link rel="modulepreload" crossorigin href="/assets/codemirror-BBL3i_JW.js">
13
13
  <link rel="modulepreload" crossorigin href="/assets/xterm--7_ns2zW.js">
14
- <link rel="stylesheet" crossorigin href="/assets/index-DD6taBMp.css">
14
+ <link rel="stylesheet" crossorigin href="/assets/index-B3AqeyS4.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.60",
3
+ "version": "0.27.61",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -38,6 +38,7 @@ export function ChatView() {
38
38
  const stopAgent = useGrooveStore((s) => s.stopAgent);
39
39
  const stopChatStreaming = useGrooveStore((s) => s.stopChatStreaming);
40
40
  const setConversationMode = useGrooveStore((s) => s.setConversationMode);
41
+ const setConversationModel = useGrooveStore((s) => s.setConversationModel);
41
42
 
42
43
  const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
43
44
 
@@ -74,11 +75,11 @@ export function ChatView() {
74
75
 
75
76
  const handleModelChange = useCallback(async (selection) => {
76
77
  if (activeConversationId) {
77
- // TODO: Update conversation model via API
78
+ await setConversationModel(activeConversationId, selection.provider, selection.model);
78
79
  } else {
79
80
  await handleNewChat(selection.provider, selection.model);
80
81
  }
81
- }, [activeConversationId, handleNewChat]);
82
+ }, [activeConversationId, setConversationModel, handleNewChat]);
82
83
 
83
84
  const currentModel = activeConversation
84
85
  ? { provider: activeConversation.provider, model: activeConversation.model }
@@ -83,7 +83,7 @@ export function ModelPicker({ value, onChange, disabled }) {
83
83
  </button>
84
84
 
85
85
  {open && (
86
- <div className="absolute top-full left-0 mt-1 w-72 max-h-80 overflow-y-auto rounded-lg border border-border bg-surface-1 shadow-xl z-50">
86
+ <div className="absolute top-full right-0 mt-1 w-72 max-h-80 overflow-y-auto rounded-lg border border-border bg-surface-1 shadow-xl z-50">
87
87
  {providers.length === 0 && (
88
88
  <div className="px-4 py-6 text-center text-xs text-text-3 font-sans">No providers available</div>
89
89
  )}
@@ -2,9 +2,11 @@
2
2
  import { Terminal, BookOpen, Radio, Plug, Globe, ArrowUpCircle, X, Unplug } from 'lucide-react';
3
3
  import { cn } from '../../lib/cn';
4
4
  import { StatusDot } from '../ui/status-dot';
5
+ import { Badge } from '../ui/badge';
5
6
  import { fmtUptime } from '../../lib/format';
6
7
  import { useGrooveStore } from '../../stores/groove';
7
8
  import { isElectron, openExternal } from '../../lib/electron';
9
+ import { UpdateModal } from '../ui/update-modal';
8
10
 
9
11
  export function StatusBar({
10
12
  connected,
@@ -18,7 +20,8 @@ export function StatusBar({
18
20
  const tunneled = useGrooveStore((s) => s.tunneled);
19
21
  const version = useGrooveStore((s) => s.version);
20
22
  const updateReady = useGrooveStore((s) => s.updateReady);
21
- const installUpdate = useGrooveStore((s) => s.installUpdate);
23
+ const updateProgress = useGrooveStore((s) => s.updateProgress);
24
+ const setUpdateModalOpen = useGrooveStore((s) => s.setUpdateModalOpen);
22
25
  const subscription = useGrooveStore((s) => s.subscription);
23
26
  const navigate = useGrooveStore((s) => s.setActiveView);
24
27
  const activeTunnel = savedTunnels.find((t) => t.active);
@@ -110,14 +113,16 @@ export function StatusBar({
110
113
  <div className="flex-1" />
111
114
 
112
115
  {/* Right: version + docs + terminal toggle */}
113
- {updateReady ? (
116
+ {updateReady || updateProgress ? (
114
117
  <button
115
- onClick={installUpdate}
116
- className="flex items-center gap-1.5 px-2 h-full text-success hover:bg-success/10 transition-colors cursor-pointer"
117
- title={`Update to v${updateReady}`}
118
+ onClick={() => setUpdateModalOpen(true)}
119
+ className="flex items-center gap-1 px-2 h-full cursor-pointer"
120
+ title={updateReady ? `Update to v${updateReady}` : 'Downloading update\u2026'}
118
121
  >
119
- <ArrowUpCircle size={12} />
120
- <span>v{updateReady}</span>
122
+ <Badge variant="warning" className="cursor-pointer">
123
+ <ArrowUpCircle size={10} />
124
+ {updateReady ? 'Update Available' : 'Downloading\u2026'}
125
+ </Badge>
121
126
  </button>
122
127
  ) : version ? (
123
128
  <span className="text-text-4 px-2">v{version}</span>
@@ -146,6 +151,7 @@ export function StatusBar({
146
151
  <span>Terminal</span>
147
152
  <kbd className="font-mono text-text-4 ml-0.5">Cmd+J</kbd>
148
153
  </button>
154
+ <UpdateModal />
149
155
  </footer>
150
156
  );
151
157
  }
@@ -0,0 +1,70 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { ArrowUpCircle, Loader2 } from 'lucide-react';
3
+ import { Dialog, DialogContent } from './dialog';
4
+ import { Button } from './button';
5
+ import { useGrooveStore } from '../../stores/groove';
6
+
7
+ export function UpdateModal() {
8
+ const open = useGrooveStore((s) => s.updateModalOpen);
9
+ const setOpen = useGrooveStore((s) => s.setUpdateModalOpen);
10
+ const version = useGrooveStore((s) => s.version);
11
+ const updateReady = useGrooveStore((s) => s.updateReady);
12
+ const updateProgress = useGrooveStore((s) => s.updateProgress);
13
+ const installUpdate = useGrooveStore((s) => s.installUpdate);
14
+
15
+ const downloading = updateProgress && !updateReady;
16
+ const percent = downloading ? Math.max(0, Math.min(100, updateProgress.percent || 0)) : 100;
17
+
18
+ return (
19
+ <Dialog open={open} onOpenChange={setOpen}>
20
+ <DialogContent title="Update Available" description="Desktop app update">
21
+ <div className="px-5 py-4 flex flex-col gap-3">
22
+ <div className="flex items-center gap-3">
23
+ <div className="flex items-center justify-center w-10 h-10 rounded-lg bg-accent/12">
24
+ <ArrowUpCircle size={20} className="text-accent" />
25
+ </div>
26
+ <div>
27
+ <p className="text-sm text-text-1 font-sans font-medium">
28
+ {downloading ? 'Downloading update\u2026' : `Ready to update`}
29
+ </p>
30
+ <p className="text-xs text-text-3 font-sans mt-0.5">
31
+ {version && <span className="font-mono">{version}</span>}
32
+ {version && updateReady && ' \u2192 '}
33
+ {updateReady && <span className="font-mono text-accent">{updateReady}</span>}
34
+ </p>
35
+ </div>
36
+ </div>
37
+ {downloading && (
38
+ <div className="flex items-center gap-2 mt-1">
39
+ <Loader2 size={12} className="animate-spin text-accent flex-shrink-0" />
40
+ <div className="flex-1 h-1.5 rounded-full bg-surface-3 overflow-hidden">
41
+ <div
42
+ className="h-full rounded-full bg-accent transition-all duration-500 ease-out"
43
+ style={{ width: `${percent}%` }}
44
+ />
45
+ </div>
46
+ <span className="text-2xs font-mono text-text-3 tabular-nums">{percent}%</span>
47
+ </div>
48
+ )}
49
+ {!downloading && (
50
+ <p className="text-xs text-text-3 font-sans leading-relaxed">
51
+ The app will restart to apply the update. Your work is saved automatically.
52
+ </p>
53
+ )}
54
+ </div>
55
+ <div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-border-subtle bg-surface-0">
56
+ <Button variant="ghost" size="sm" onClick={() => setOpen(false)}>Later</Button>
57
+ <Button
58
+ variant="primary"
59
+ size="sm"
60
+ disabled={downloading}
61
+ onClick={() => { installUpdate(); setOpen(false); }}
62
+ >
63
+ <ArrowUpCircle size={12} />
64
+ Update &amp; Restart
65
+ </Button>
66
+ </div>
67
+ </DialogContent>
68
+ </Dialog>
69
+ );
70
+ }
@@ -123,6 +123,8 @@ export const useGrooveStore = create((set, get) => ({
123
123
  // ── Version / Auto-Update ──────────────────────────────────
124
124
  version: null,
125
125
  updateReady: null,
126
+ updateProgress: null,
127
+ updateModalOpen: false,
126
128
 
127
129
  // ── Toasts ────────────────────────────────────────────────
128
130
  toasts: [],
@@ -184,10 +186,14 @@ export const useGrooveStore = create((set, get) => ({
184
186
  if (data) set({ subscription: { ...get().subscription, ...data } });
185
187
  });
186
188
  }
189
+ if (window.groove?.update?.onUpdateProgress) {
190
+ window.groove.update.onUpdateProgress((data) => {
191
+ set({ updateProgress: data });
192
+ });
193
+ }
187
194
  if (window.groove?.update?.onUpdateDownloaded) {
188
195
  window.groove.update.onUpdateDownloaded((data) => {
189
- set({ updateReady: data.version });
190
- get().addToast('info', 'Update available', `v${data.version} downloaded — restart to apply`);
196
+ set({ updateReady: data.version, updateModalOpen: true, updateProgress: null });
191
197
  });
192
198
  }
193
199
  };
@@ -861,7 +867,6 @@ export const useGrooveStore = create((set, get) => ({
861
867
  arr.push({ from: 'assistant', text, timestamp: Date.now() });
862
868
  }
863
869
  msgs[conversationId] = arr.slice(-200);
864
- persistJSON('groove:conversationMessages', msgs);
865
870
  return { conversationMessages: msgs, streamingConversationId: conversationId };
866
871
  });
867
872
  break;
@@ -869,10 +874,10 @@ export const useGrooveStore = create((set, get) => ({
869
874
 
870
875
  case 'conversation:complete': {
871
876
  const { conversationId } = msg.data || msg;
872
- if (conversationId) {
877
+ if (conversationId && get().streamingConversationId === conversationId) {
873
878
  set({ sendingMessage: false, streamingConversationId: null });
874
- persistJSON('groove:conversationMessages', get().conversationMessages);
875
879
  }
880
+ if (conversationId) persistJSON('groove:conversationMessages', get().conversationMessages);
876
881
  break;
877
882
  }
878
883
 
@@ -884,7 +889,8 @@ export const useGrooveStore = create((set, get) => ({
884
889
  if (!msgs[conversationId]) msgs[conversationId] = [];
885
890
  msgs[conversationId] = [...msgs[conversationId], { from: 'system', text: `Error: ${error || 'Unknown error'}`, timestamp: Date.now() }];
886
891
  persistJSON('groove:conversationMessages', msgs);
887
- return { conversationMessages: msgs, sendingMessage: false, streamingConversationId: null };
892
+ const isActive = s.streamingConversationId === conversationId;
893
+ return { conversationMessages: msgs, sendingMessage: isActive ? false : s.sendingMessage, streamingConversationId: isActive ? null : s.streamingConversationId };
888
894
  });
889
895
  }
890
896
  break;
@@ -1071,6 +1077,12 @@ export const useGrooveStore = create((set, get) => ({
1071
1077
  installUpdate() {
1072
1078
  window.groove?.update?.installUpdate();
1073
1079
  },
1080
+ setUpdateModalOpen(open) {
1081
+ set({ updateModalOpen: open });
1082
+ },
1083
+ checkForUpdate() {
1084
+ window.groove?.update?.checkForUpdate();
1085
+ },
1074
1086
 
1075
1087
  // ── Marketplace Auth ────────────────────────────────────────
1076
1088
 
@@ -1718,6 +1730,15 @@ export const useGrooveStore = create((set, get) => ({
1718
1730
  }
1719
1731
  },
1720
1732
 
1733
+ async setConversationModel(id, provider, model) {
1734
+ try {
1735
+ const conv = await api.patch(`/conversations/${encodeURIComponent(id)}`, { provider, model });
1736
+ set((s) => ({ conversations: s.conversations.map((c) => c.id === id ? { ...c, ...conv } : c) }));
1737
+ } catch (err) {
1738
+ get().addToast('error', 'Model change failed', err.message);
1739
+ }
1740
+ },
1741
+
1721
1742
  async stopChatStreaming(conversationId) {
1722
1743
  try {
1723
1744
  await api.post(`/conversations/${encodeURIComponent(conversationId)}/stop`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.27.60",
3
+ "version": "0.27.61",
4
4
  "description": "Open-source agent orchestration layer — the AI company OS. Local model agent engine (GGUF/Ollama/llama-server), HuggingFace model browser, MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama, any local model.",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.60",
3
+ "version": "0.27.61",
4
4
  "description": "GROOVE CLI — manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.60",
3
+ "version": "0.27.61",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -2,11 +2,11 @@
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
4
  import express from 'express';
5
- import { resolve, dirname, join } from 'path';
5
+ import { resolve, dirname, join, sep } from 'path';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, unlinkSync, renameSync, rmSync, createReadStream, copyFileSync, realpathSync } from 'fs';
8
8
  import { spawn, execFile } from 'child_process';
9
- import { createHash } from 'crypto';
9
+ import { createHash, randomUUID } from 'crypto';
10
10
  import { hostname, networkInterfaces, homedir } from 'os';
11
11
  import { lookup as mimeLookup } from './mimetypes.js';
12
12
  import { listProviders, getProvider } from './providers/index.js';
@@ -14,7 +14,7 @@ import { OllamaProvider } from './providers/ollama.js';
14
14
  import { ClaudeCodeProvider } from './providers/claude-code.js';
15
15
  import { supportsSignalFlag, compareSemver, parseSemver } from './providers/groove-network.js';
16
16
  import { validateAgentConfig } from './validate.js';
17
- import { ROLE_INTEGRATIONS } from './process.js';
17
+ import { ROLE_INTEGRATIONS, wrapWithRoleReminder } from './process.js';
18
18
 
19
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
20
  const pkgVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
@@ -846,13 +846,18 @@ export function createApi(app, daemon) {
846
846
  if (req.body.title !== undefined) daemon.conversations.rename(req.params.id, req.body.title);
847
847
  if (req.body.pinned !== undefined) daemon.conversations.pin(req.params.id, req.body.pinned);
848
848
  if (req.body.archived !== undefined) daemon.conversations.archive(req.params.id, req.body.archived);
849
+ if (req.body.model !== undefined || req.body.provider !== undefined) {
850
+ const newProvider = req.body.provider || conv.provider;
851
+ const newModel = req.body.model || conv.model;
852
+ daemon.conversations.updateModel(req.params.id, newProvider, newModel);
853
+ }
849
854
  if (req.body.mode !== undefined) {
850
855
  if (req.body.mode !== 'api' && req.body.mode !== 'agent') {
851
856
  return res.status(400).json({ error: 'mode must be "api" or "agent"' });
852
857
  }
853
858
  await daemon.conversations.setMode(req.params.id, req.body.mode);
854
859
  }
855
- daemon.audit.log('conversation.update', { id: req.params.id, mode: req.body.mode });
860
+ daemon.audit.log('conversation.update', { id: req.params.id, provider: req.body.provider, model: req.body.model, mode: req.body.mode });
856
861
  res.json(daemon.conversations.get(req.params.id));
857
862
  } catch (err) {
858
863
  res.status(400).json({ error: err.message });
@@ -1105,8 +1110,9 @@ export function createApi(app, daemon) {
1105
1110
  if (daemon.journalist) daemon.journalist.recordUserFeedback(agent, message.trim());
1106
1111
 
1107
1112
  // Agent loop path — send message directly to the running loop
1113
+ const wrappedMessage = wrapWithRoleReminder(agent.role, message.trim());
1108
1114
  if (daemon.processes.hasAgentLoop(req.params.id)) {
1109
- const sent = await daemon.processes.sendMessage(req.params.id, message.trim());
1115
+ const sent = await daemon.processes.sendMessage(req.params.id, wrappedMessage);
1110
1116
  if (sent) {
1111
1117
  daemon.audit.log('agent.chat', { id: req.params.id });
1112
1118
  return res.json({ id: agent.id, status: 'message_sent' });
@@ -1144,7 +1150,7 @@ export function createApi(app, daemon) {
1144
1150
  // Running CLI agent (no loop) — queue the message for delivery after
1145
1151
  // the current task completes instead of killing and respawning.
1146
1152
  if (daemon.processes.isRunning(req.params.id)) {
1147
- daemon.processes.queueMessage(req.params.id, message.trim());
1153
+ daemon.processes.queueMessage(req.params.id, wrappedMessage);
1148
1154
  daemon.audit.log('agent.chat.queued', { id: req.params.id });
1149
1155
  return res.json({ id: agent.id, status: 'message_queued' });
1150
1156
  }
@@ -1156,8 +1162,8 @@ export function createApi(app, daemon) {
1156
1162
  const SESSION_RESUME_CEILING = 5_000_000;
1157
1163
  const resumed = !!agent.sessionId && (agent.tokensUsed || 0) < SESSION_RESUME_CEILING;
1158
1164
  const newAgent = resumed
1159
- ? await daemon.processes.resume(req.params.id, message.trim())
1160
- : await daemon.rotator.rotate(req.params.id, { additionalPrompt: message.trim() });
1165
+ ? await daemon.processes.resume(req.params.id, wrappedMessage)
1166
+ : await daemon.rotator.rotate(req.params.id, { additionalPrompt: wrappedMessage });
1161
1167
 
1162
1168
  daemon.audit.log('agent.instruct', { id: req.params.id, newId: newAgent.id, resumed });
1163
1169
  res.json(newAgent);
@@ -2340,7 +2346,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
2340
2346
  // Browse absolute paths (for directory picker in agent config)
2341
2347
  // Dirs only, localhost-only, no file content exposed
2342
2348
  app.get('/api/browse-system', (req, res) => {
2343
- const absPath = req.query.path || process.env.HOME || '/';
2349
+ const absPath = req.query.path || homedir();
2344
2350
  if (absPath.includes('\0')) return res.status(400).json({ error: 'Invalid path' });
2345
2351
  if (!existsSync(absPath)) return res.status(404).json({ error: 'Not found' });
2346
2352
 
@@ -3474,7 +3480,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
3474
3480
  // Resolve shell shortcuts — GUI sends ~/... and ./...
3475
3481
  let resolvedPath = targetPath;
3476
3482
  if (resolvedPath.startsWith('~/') || resolvedPath === '~') {
3477
- resolvedPath = resolve(process.env.HOME || '/tmp', resolvedPath.slice(2));
3483
+ resolvedPath = resolve(homedir(), resolvedPath.slice(2));
3478
3484
  } else if (!resolvedPath.startsWith('/')) {
3479
3485
  resolvedPath = resolve(daemon.projectDir, resolvedPath);
3480
3486
  }
@@ -3948,15 +3954,14 @@ Keep responses concise. Help them think, don't lecture them about the system the
3948
3954
  const BETA_RATE_WINDOW_MS = 60_000;
3949
3955
 
3950
3956
  function getMachineId() {
3951
- const nets = networkInterfaces();
3952
- const macs = [];
3953
- for (const name of Object.keys(nets)) {
3954
- for (const iface of nets[name] || []) {
3955
- if (iface.mac && iface.mac !== '00:00:00:00:00:00') macs.push(iface.mac);
3956
- }
3957
- }
3958
- macs.sort();
3959
- return createHash('sha256').update(`${hostname()}|${macs.join(',')}`).digest('hex');
3957
+ const idFile = join(daemon.grooveDir, '.machine-id');
3958
+ try {
3959
+ const existing = readFileSync(idFile, 'utf8').trim();
3960
+ if (existing.length >= 32) return existing;
3961
+ } catch {}
3962
+ const id = createHash('sha256').update(`${hostname()}|${randomUUID()}`).digest('hex');
3963
+ try { writeFileSync(idFile, id, { mode: 0o600 }); } catch {}
3964
+ return id;
3960
3965
  }
3961
3966
 
3962
3967
  async function validateCodeWithServer(code) {
@@ -4103,11 +4108,9 @@ Keep responses concise. Help them think, don't lecture them about the system the
4103
4108
 
4104
4109
  app.post('/api/beta/deactivate', async (req, res) => {
4105
4110
  // Stop the node if it's running before locking the feature away.
4106
- try {
4107
- if (daemon.networkNode?.proc && !daemon.networkNode.proc.killed) {
4108
- daemon.networkNode.proc.kill('SIGINT');
4109
- }
4110
- } catch { /* ignore */ }
4111
+ if (daemon.networkNode?.proc && !daemon.networkNode.proc.killed) {
4112
+ safeKill(daemon.networkNode.proc);
4113
+ }
4111
4114
  daemon.networkNode = {
4112
4115
  active: false, status: 'stopped', pid: null, proc: null,
4113
4116
  nodeId: null, layers: null, model: null, sessions: 0,
@@ -4220,15 +4223,15 @@ Keep responses concise. Help them think, don't lecture them about the system the
4220
4223
  // Resolve deploy path (handles ~ and defaults to ~/Desktop/groove-deploy)
4221
4224
  let deployPath = cfg.deployPath || null;
4222
4225
  if (!deployPath) {
4223
- deployPath = resolve(process.env.HOME || '', 'Desktop/groove-deploy');
4226
+ deployPath = resolve(homedir(), 'Desktop', 'groove-deploy');
4224
4227
  } else if (deployPath.startsWith('~/')) {
4225
- deployPath = resolve(process.env.HOME || '', deployPath.slice(2));
4228
+ deployPath = resolve(homedir(), deployPath.slice(2));
4226
4229
  }
4227
4230
 
4228
4231
  if (!existsSync(deployPath)) {
4229
4232
  return res.status(400).json({ error: `Deploy path not found: ${deployPath}` });
4230
4233
  }
4231
- if (!isInsideGrooveHome(deployPath) && !deployPath.startsWith(resolve(process.env.HOME || '', 'Desktop'))) {
4234
+ if (!isInsideGrooveHome(deployPath) && !deployPath.startsWith(resolve(homedir(), 'Desktop'))) {
4232
4235
  return res.status(400).json({ error: 'Deploy path outside allowed directories' });
4233
4236
  }
4234
4237
 
@@ -4245,7 +4248,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
4245
4248
 
4246
4249
  let proc;
4247
4250
  try {
4248
- proc = spawn(join(deployPath, 'venv', 'bin', 'python3'), args, {
4251
+ proc = spawn(venvPython(deployPath), args, {
4249
4252
  cwd: deployPath,
4250
4253
  env: { ...process.env, PYTHONUNBUFFERED: '1' },
4251
4254
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -4368,11 +4371,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
4368
4371
  if (!node?.active || !node.proc) {
4369
4372
  return res.status(409).json({ error: 'Node not running' });
4370
4373
  }
4371
- try {
4372
- node.proc.kill('SIGINT');
4373
- } catch (err) {
4374
- return res.status(500).json({ error: `Failed to stop node: ${err.message}` });
4375
- }
4374
+ safeKill(node.proc);
4376
4375
  daemon.networkNode.status = 'stopping';
4377
4376
  pushNodeEvent('stopping', { pid: node.pid });
4378
4377
  broadcastNodeStatus();
@@ -4610,9 +4609,37 @@ Keep responses concise. Help them think, don't lecture them about the system the
4610
4609
 
4611
4610
  // --- Network package install/uninstall ---
4612
4611
 
4612
+ const IS_WIN = process.platform === 'win32';
4613
4613
  const NETWORK_REPO_URL = 'https://github.com/grooveai-dev/groove-network.git';
4614
4614
  const NETWORK_VERSION = 'v0.2.0';
4615
4615
 
4616
+ function venvPython(base) {
4617
+ return IS_WIN
4618
+ ? join(base, 'venv', 'Scripts', 'python.exe')
4619
+ : join(base, 'venv', 'bin', 'python3');
4620
+ }
4621
+
4622
+ function spawnSetupSh(cwd) {
4623
+ if (IS_WIN) {
4624
+ return spawn('cmd.exe', ['/c', 'bash setup.sh --json'], {
4625
+ cwd,
4626
+ stdio: ['ignore', 'pipe', 'pipe'],
4627
+ env: { ...process.env, PYTHONUNBUFFERED: '1' },
4628
+ });
4629
+ }
4630
+ return spawn('bash', ['setup.sh', '--json'], {
4631
+ cwd,
4632
+ stdio: ['ignore', 'pipe', 'pipe'],
4633
+ env: { ...process.env, PYTHONUNBUFFERED: '1' },
4634
+ });
4635
+ }
4636
+
4637
+ function safeKill(proc, signal = 'SIGINT') {
4638
+ try {
4639
+ if (IS_WIN) { proc.kill(); } else { proc.kill(signal); }
4640
+ } catch { /* ignore */ }
4641
+ }
4642
+
4616
4643
  function networkRoot() {
4617
4644
  return resolve(homedir(), '.groove', 'network');
4618
4645
  }
@@ -4636,12 +4663,12 @@ Keep responses concise. Help them think, don't lecture them about the system the
4636
4663
  // Defensive: only permit fs ops on paths that resolve inside ~/.groove/.
4637
4664
  // Uses realpathSync when the path exists to defeat symlink escapes.
4638
4665
  function isInsideGrooveHome(target) {
4639
- const home = resolve(homedir(), '.groove') + '/';
4666
+ const home = resolve(homedir(), '.groove') + sep;
4640
4667
  const resolved = resolve(target);
4641
4668
  let full;
4642
- try { full = existsSync(resolved) ? realpathSync(resolved) + '/' : resolved + '/'; }
4643
- catch { full = resolved + '/'; }
4644
- const realHome = existsSync(home.slice(0, -1)) ? realpathSync(home.slice(0, -1)) + '/' : home;
4669
+ try { full = existsSync(resolved) ? realpathSync(resolved) + sep : resolved + sep; }
4670
+ catch { full = resolved + sep; }
4671
+ const realHome = existsSync(home.slice(0, -1)) ? realpathSync(home.slice(0, -1)) + sep : home;
4645
4672
  return full.startsWith(realHome);
4646
4673
  }
4647
4674
 
@@ -4748,11 +4775,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
4748
4775
  broadcastInstallProgress('cloned', 'Repository cloned', 10);
4749
4776
 
4750
4777
  // Run setup.sh --json from the install directory
4751
- const setup = spawn('bash', ['setup.sh', '--json'], {
4752
- cwd: installPath,
4753
- stdio: ['ignore', 'pipe', 'pipe'],
4754
- env: { ...process.env, PYTHONUNBUFFERED: '1' },
4755
- });
4778
+ const setup = spawnSetupSh(installPath);
4756
4779
 
4757
4780
  daemon.networkInstall.proc = setup;
4758
4781
 
@@ -4816,7 +4839,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
4816
4839
  try {
4817
4840
  const node = daemon.networkNode;
4818
4841
  if (node?.active && node.proc && !node.proc.killed) {
4819
- try { node.proc.kill('SIGINT'); } catch { /* ignore */ }
4842
+ safeKill(node.proc);
4820
4843
  daemon.networkNode.status = 'stopping';
4821
4844
  pushNodeEvent('stopping', { pid: node.pid, reason: 'uninstall' });
4822
4845
  broadcastNodeStatus();
@@ -4868,9 +4891,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
4868
4891
  let stderr = '';
4869
4892
  proc.stdout.on('data', (c) => { stdout += c.toString(); });
4870
4893
  proc.stderr.on('data', (c) => { stderr += c.toString(); });
4871
- const timeout = setTimeout(() => {
4872
- try { proc.kill('SIGTERM'); } catch { /* ignore */ }
4873
- }, 10_000);
4894
+ const timeout = setTimeout(() => { safeKill(proc, 'SIGTERM'); }, 10_000);
4874
4895
  proc.on('error', () => { clearTimeout(timeout); resolvePromise(null); });
4875
4896
  proc.on('close', (code) => {
4876
4897
  clearTimeout(timeout);
@@ -4957,7 +4978,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
4957
4978
  try {
4958
4979
  const node = daemon.networkNode;
4959
4980
  if (node?.active && node.proc && !node.proc.killed) {
4960
- try { node.proc.kill('SIGINT'); } catch { /* ignore */ }
4981
+ safeKill(node.proc);
4961
4982
  daemon.networkNode.status = 'stopping';
4962
4983
  pushNodeEvent('stopping', { pid: node.pid, reason: 'update' });
4963
4984
  broadcastNodeStatus();
@@ -5002,11 +5023,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
5002
5023
 
5003
5024
  broadcastUpdateProgress('deps', 'Updating dependencies...', 30);
5004
5025
 
5005
- const setup = spawn('bash', ['setup.sh', '--json'], {
5006
- cwd: installPath,
5007
- stdio: ['ignore', 'pipe', 'pipe'],
5008
- env: { ...process.env, PYTHONUNBUFFERED: '1' },
5009
- });
5026
+ const setup = spawnSetupSh(installPath);
5010
5027
 
5011
5028
  daemon.networkInstall.proc = setup;
5012
5029