groove-dev 0.27.60 → 0.27.62

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 +167 -60
  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-Dvum7uoe.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 +52 -12
  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 +167 -60
  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-Dvum7uoe.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 +52 -12
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.60",
3
+ "version": "0.27.62",
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.62",
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, relative } 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
- import { spawn, execFile } from 'child_process';
9
- import { createHash } from 'crypto';
8
+ import { spawn, execFile, execFileSync } from 'child_process';
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
 
@@ -2908,6 +2914,18 @@ Keep responses concise. Help them think, don't lecture them about the system the
2908
2914
  console.log(`[Groove] Project directory: ${projectWorkingDir}`);
2909
2915
  }
2910
2916
 
2917
+ function normalizeScope(patterns, baseDir) {
2918
+ if (!patterns || !Array.isArray(patterns)) return patterns;
2919
+ return patterns.map((p) => {
2920
+ if (typeof p === 'string' && p.startsWith('/')) {
2921
+ const rel = relative(baseDir, p);
2922
+ if (!rel.startsWith('..')) return rel;
2923
+ return p.slice(1);
2924
+ }
2925
+ return p;
2926
+ });
2927
+ }
2928
+
2911
2929
  // Separate phase 1 (builders) and phase 2 (QC/finisher)
2912
2930
  const phase1 = agentConfigs.filter((a) => !a.phase || a.phase === 1);
2913
2931
  let phase2 = agentConfigs.filter((a) => a.phase === 2);
@@ -2967,7 +2985,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
2967
2985
  // Spawn fresh with the same name/team but new prompt + full context
2968
2986
  const validated = validateAgentConfig({
2969
2987
  role: existing.role,
2970
- scope: config.scope || existing.scope || [],
2988
+ scope: normalizeScope(config.scope || existing.scope || [], existing.workingDir || projectWorkingDir),
2971
2989
  prompt,
2972
2990
  provider: config.provider || existing.provider || undefined,
2973
2991
  model: config.model || existing.model || daemon.config?.defaultModel || 'auto',
@@ -2989,7 +3007,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
2989
3007
  try {
2990
3008
  const validated = validateAgentConfig({
2991
3009
  role: config.role,
2992
- scope: config.scope || [],
3010
+ scope: normalizeScope(config.scope || [], config.workingDir || projectWorkingDir),
2993
3011
  prompt,
2994
3012
  provider: config.provider || undefined,
2995
3013
  model: config.model || daemon.config?.defaultModel || 'auto',
@@ -3009,6 +3027,10 @@ Keep responses concise. Help them think, don't lecture them about the system the
3009
3027
  }
3010
3028
  }
3011
3029
 
3030
+ if (failed.length > 0) {
3031
+ console.warn(`[Groove] Team launch had ${failed.length} failure(s):`, failed.map((f) => `${f.role}: ${f.error}`).join(', '));
3032
+ }
3033
+
3012
3034
  // Phase 2 agents also scoped to projectWorkingDir
3013
3035
  if (phase2.length > 0 && phase1Ids.length > 0) {
3014
3036
  // Dedup: if a running idle fullstack already exists in this team,
@@ -3474,7 +3496,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
3474
3496
  // Resolve shell shortcuts — GUI sends ~/... and ./...
3475
3497
  let resolvedPath = targetPath;
3476
3498
  if (resolvedPath.startsWith('~/') || resolvedPath === '~') {
3477
- resolvedPath = resolve(process.env.HOME || '/tmp', resolvedPath.slice(2));
3499
+ resolvedPath = resolve(homedir(), resolvedPath.slice(2));
3478
3500
  } else if (!resolvedPath.startsWith('/')) {
3479
3501
  resolvedPath = resolve(daemon.projectDir, resolvedPath);
3480
3502
  }
@@ -3948,15 +3970,14 @@ Keep responses concise. Help them think, don't lecture them about the system the
3948
3970
  const BETA_RATE_WINDOW_MS = 60_000;
3949
3971
 
3950
3972
  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');
3973
+ const idFile = join(daemon.grooveDir, '.machine-id');
3974
+ try {
3975
+ const existing = readFileSync(idFile, 'utf8').trim();
3976
+ if (existing.length >= 32) return existing;
3977
+ } catch {}
3978
+ const id = createHash('sha256').update(`${hostname()}|${randomUUID()}`).digest('hex');
3979
+ try { writeFileSync(idFile, id, { mode: 0o600 }); } catch {}
3980
+ return id;
3960
3981
  }
3961
3982
 
3962
3983
  async function validateCodeWithServer(code) {
@@ -4103,11 +4124,9 @@ Keep responses concise. Help them think, don't lecture them about the system the
4103
4124
 
4104
4125
  app.post('/api/beta/deactivate', async (req, res) => {
4105
4126
  // 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 */ }
4127
+ if (daemon.networkNode?.proc && !daemon.networkNode.proc.killed) {
4128
+ safeKill(daemon.networkNode.proc);
4129
+ }
4111
4130
  daemon.networkNode = {
4112
4131
  active: false, status: 'stopped', pid: null, proc: null,
4113
4132
  nodeId: null, layers: null, model: null, sessions: 0,
@@ -4220,15 +4239,15 @@ Keep responses concise. Help them think, don't lecture them about the system the
4220
4239
  // Resolve deploy path (handles ~ and defaults to ~/Desktop/groove-deploy)
4221
4240
  let deployPath = cfg.deployPath || null;
4222
4241
  if (!deployPath) {
4223
- deployPath = resolve(process.env.HOME || '', 'Desktop/groove-deploy');
4242
+ deployPath = resolve(homedir(), 'Desktop', 'groove-deploy');
4224
4243
  } else if (deployPath.startsWith('~/')) {
4225
- deployPath = resolve(process.env.HOME || '', deployPath.slice(2));
4244
+ deployPath = resolve(homedir(), deployPath.slice(2));
4226
4245
  }
4227
4246
 
4228
4247
  if (!existsSync(deployPath)) {
4229
4248
  return res.status(400).json({ error: `Deploy path not found: ${deployPath}` });
4230
4249
  }
4231
- if (!isInsideGrooveHome(deployPath) && !deployPath.startsWith(resolve(process.env.HOME || '', 'Desktop'))) {
4250
+ if (!isInsideGrooveHome(deployPath) && !deployPath.startsWith(resolve(homedir(), 'Desktop'))) {
4232
4251
  return res.status(400).json({ error: 'Deploy path outside allowed directories' });
4233
4252
  }
4234
4253
 
@@ -4245,7 +4264,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
4245
4264
 
4246
4265
  let proc;
4247
4266
  try {
4248
- proc = spawn(join(deployPath, 'venv', 'bin', 'python3'), args, {
4267
+ proc = spawn(venvPython(deployPath), args, {
4249
4268
  cwd: deployPath,
4250
4269
  env: { ...process.env, PYTHONUNBUFFERED: '1' },
4251
4270
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -4368,11 +4387,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
4368
4387
  if (!node?.active || !node.proc) {
4369
4388
  return res.status(409).json({ error: 'Node not running' });
4370
4389
  }
4371
- try {
4372
- node.proc.kill('SIGINT');
4373
- } catch (err) {
4374
- return res.status(500).json({ error: `Failed to stop node: ${err.message}` });
4375
- }
4390
+ safeKill(node.proc);
4376
4391
  daemon.networkNode.status = 'stopping';
4377
4392
  pushNodeEvent('stopping', { pid: node.pid });
4378
4393
  broadcastNodeStatus();
@@ -4610,9 +4625,65 @@ Keep responses concise. Help them think, don't lecture them about the system the
4610
4625
 
4611
4626
  // --- Network package install/uninstall ---
4612
4627
 
4628
+ const IS_WIN = process.platform === 'win32';
4613
4629
  const NETWORK_REPO_URL = 'https://github.com/grooveai-dev/groove-network.git';
4614
4630
  const NETWORK_VERSION = 'v0.2.0';
4615
4631
 
4632
+ function venvPython(base) {
4633
+ return IS_WIN
4634
+ ? join(base, 'venv', 'Scripts', 'python.exe')
4635
+ : join(base, 'venv', 'bin', 'python3');
4636
+ }
4637
+
4638
+ let _cachedGitBash = undefined;
4639
+ function findGitBash() {
4640
+ if (_cachedGitBash !== undefined) return _cachedGitBash;
4641
+ try {
4642
+ const gitPath = execFileSync('where', ['git'], { timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] })
4643
+ .toString().trim().split('\n')[0].trim();
4644
+ // git.exe is typically at <Git>\cmd\git.exe — navigate up to Git root
4645
+ const gitDir = dirname(dirname(gitPath));
4646
+ const candidate = join(gitDir, 'bin', 'bash.exe');
4647
+ if (existsSync(candidate)) { _cachedGitBash = candidate; return _cachedGitBash; }
4648
+ } catch { /* where failed — try common paths */ }
4649
+ const fallbacks = [
4650
+ 'C:\\Program Files\\Git\\bin\\bash.exe',
4651
+ 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
4652
+ ];
4653
+ for (const p of fallbacks) {
4654
+ if (existsSync(p)) { _cachedGitBash = p; return _cachedGitBash; }
4655
+ }
4656
+ _cachedGitBash = null;
4657
+ return null;
4658
+ }
4659
+
4660
+ function spawnSetupSh(cwd) {
4661
+ if (IS_WIN) {
4662
+ const bashPath = findGitBash();
4663
+ if (!bashPath) {
4664
+ const err = new Error('Could not find bash. Ensure Git for Windows is installed from https://git-scm.com');
4665
+ err.code = 'BASH_NOT_FOUND';
4666
+ throw err;
4667
+ }
4668
+ return spawn(bashPath, ['setup.sh', '--json'], {
4669
+ cwd,
4670
+ stdio: ['ignore', 'pipe', 'pipe'],
4671
+ env: { ...process.env, PYTHONUNBUFFERED: '1' },
4672
+ });
4673
+ }
4674
+ return spawn('bash', ['setup.sh', '--json'], {
4675
+ cwd,
4676
+ stdio: ['ignore', 'pipe', 'pipe'],
4677
+ env: { ...process.env, PYTHONUNBUFFERED: '1' },
4678
+ });
4679
+ }
4680
+
4681
+ function safeKill(proc, signal = 'SIGINT') {
4682
+ try {
4683
+ if (IS_WIN) { proc.kill(); } else { proc.kill(signal); }
4684
+ } catch { /* ignore */ }
4685
+ }
4686
+
4616
4687
  function networkRoot() {
4617
4688
  return resolve(homedir(), '.groove', 'network');
4618
4689
  }
@@ -4636,12 +4707,12 @@ Keep responses concise. Help them think, don't lecture them about the system the
4636
4707
  // Defensive: only permit fs ops on paths that resolve inside ~/.groove/.
4637
4708
  // Uses realpathSync when the path exists to defeat symlink escapes.
4638
4709
  function isInsideGrooveHome(target) {
4639
- const home = resolve(homedir(), '.groove') + '/';
4710
+ const home = resolve(homedir(), '.groove') + sep;
4640
4711
  const resolved = resolve(target);
4641
4712
  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;
4713
+ try { full = existsSync(resolved) ? realpathSync(resolved) + sep : resolved + sep; }
4714
+ catch { full = resolved + sep; }
4715
+ const realHome = existsSync(home.slice(0, -1)) ? realpathSync(home.slice(0, -1)) + sep : home;
4645
4716
  return full.startsWith(realHome);
4646
4717
  }
4647
4718
 
@@ -4701,7 +4772,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
4701
4772
  };
4702
4773
 
4703
4774
  try {
4704
- const pat = daemon.credentials?.getKey?.('github-pat') || null;
4775
+ const pat = daemon.credentials?.getKey?.('github') || daemon.credentials?.getKey?.('github-pat') || null;
4705
4776
 
4706
4777
  let installVersion;
4707
4778
  try {
@@ -4712,6 +4783,14 @@ Keep responses concise. Help them think, don't lecture them about the system the
4712
4783
 
4713
4784
  broadcastInstallProgress('cloning', `Cloning network package ${installVersion}...`, 0);
4714
4785
 
4786
+ // Pre-flight: verify git is installed before attempting clone.
4787
+ const gitInstalled = await new Promise((resolveGit) => {
4788
+ execFile('git', ['--version'], { timeout: 5000 }, (err) => resolveGit(!err));
4789
+ });
4790
+ if (!gitInstalled) {
4791
+ return fail('Git is not installed. Install Git from https://git-scm.com and restart Groove.');
4792
+ }
4793
+
4715
4794
  const cloneArgs = ['clone', '--branch', installVersion, '--depth', '1', NETWORK_REPO_URL, installPath];
4716
4795
  const cloneEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
4717
4796
  if (pat) {
@@ -4741,18 +4820,30 @@ Keep responses concise. Help them think, don't lecture them about the system the
4741
4820
  });
4742
4821
 
4743
4822
  if (cloneCode.code !== 0) {
4744
- const hint = stripCredentials(cloneErr.trim().split('\n').slice(-1)[0] || 'git clone failed');
4823
+ let hint;
4824
+ const errMsg = cloneCode.err || '';
4825
+ const lastLine = cloneErr.trim().split('\n').slice(-1)[0] || '';
4826
+ if (errMsg.includes('ENOENT')) {
4827
+ hint = 'Git is not installed. Install Git from https://git-scm.com and restart Groove.';
4828
+ } else if (/Authentication failed|could not read Username/i.test(cloneErr)) {
4829
+ hint = 'Authentication failed — run "groove set-key github-pat <token>" to set a GitHub PAT.';
4830
+ } else if (/not found/i.test(cloneErr)) {
4831
+ hint = `Repository or tag not found (${installVersion}). Check NETWORK_REPO_URL and tag.`;
4832
+ } else {
4833
+ hint = stripCredentials(lastLine || errMsg || 'git clone failed');
4834
+ }
4745
4835
  return fail(`Clone failed: ${hint}`);
4746
4836
  }
4747
4837
 
4748
4838
  broadcastInstallProgress('cloned', 'Repository cloned', 10);
4749
4839
 
4750
4840
  // 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
- });
4841
+ let setup;
4842
+ try {
4843
+ setup = spawnSetupSh(installPath);
4844
+ } catch (spawnErr) {
4845
+ return fail(`Setup failed: ${spawnErr.message}`);
4846
+ }
4756
4847
 
4757
4848
  daemon.networkInstall.proc = setup;
4758
4849
 
@@ -4786,7 +4877,12 @@ Keep responses concise. Help them think, don't lecture them about the system the
4786
4877
  });
4787
4878
 
4788
4879
  if (setupResult.code !== 0) {
4789
- const hint = stderrBuf.trim().split('\n').slice(-1)[0] || `setup.sh exited ${setupResult.code}`;
4880
+ let hint;
4881
+ if (setupResult.code === -1 || setupResult.err?.includes('ENOENT')) {
4882
+ hint = 'bash not found — ensure Git for Windows is installed from https://git-scm.com';
4883
+ } else {
4884
+ hint = stderrBuf.trim().split('\n').slice(-1)[0] || `setup.sh exited ${setupResult.code}`;
4885
+ }
4790
4886
  return fail(`Setup failed: ${hint}`);
4791
4887
  }
4792
4888
 
@@ -4816,7 +4912,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
4816
4912
  try {
4817
4913
  const node = daemon.networkNode;
4818
4914
  if (node?.active && node.proc && !node.proc.killed) {
4819
- try { node.proc.kill('SIGINT'); } catch { /* ignore */ }
4915
+ safeKill(node.proc);
4820
4916
  daemon.networkNode.status = 'stopping';
4821
4917
  pushNodeEvent('stopping', { pid: node.pid, reason: 'uninstall' });
4822
4918
  broadcastNodeStatus();
@@ -4860,17 +4956,22 @@ Keep responses concise. Help them think, don't lecture them about the system the
4860
4956
  // surface that. Uses spawn with array args — no shell interpolation.
4861
4957
  function fetchLatestNetworkTag() {
4862
4958
  return new Promise((resolvePromise) => {
4959
+ const tagEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
4960
+ const tagPat = daemon.credentials?.getKey?.('github') || daemon.credentials?.getKey?.('github-pat') || null;
4961
+ if (tagPat) {
4962
+ tagEnv.GIT_CONFIG_COUNT = '1';
4963
+ tagEnv.GIT_CONFIG_KEY_0 = 'http.extraHeader';
4964
+ tagEnv.GIT_CONFIG_VALUE_0 = `Authorization: token ${tagPat}`;
4965
+ }
4863
4966
  const proc = spawn('git', ['ls-remote', '--tags', NETWORK_REPO_URL], {
4864
4967
  stdio: ['ignore', 'pipe', 'pipe'],
4865
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
4968
+ env: tagEnv,
4866
4969
  });
4867
4970
  let stdout = '';
4868
4971
  let stderr = '';
4869
4972
  proc.stdout.on('data', (c) => { stdout += c.toString(); });
4870
4973
  proc.stderr.on('data', (c) => { stderr += c.toString(); });
4871
- const timeout = setTimeout(() => {
4872
- try { proc.kill('SIGTERM'); } catch { /* ignore */ }
4873
- }, 10_000);
4974
+ const timeout = setTimeout(() => { safeKill(proc, 'SIGTERM'); }, 10_000);
4874
4975
  proc.on('error', () => { clearTimeout(timeout); resolvePromise(null); });
4875
4976
  proc.on('close', (code) => {
4876
4977
  clearTimeout(timeout);
@@ -4957,7 +5058,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
4957
5058
  try {
4958
5059
  const node = daemon.networkNode;
4959
5060
  if (node?.active && node.proc && !node.proc.killed) {
4960
- try { node.proc.kill('SIGINT'); } catch { /* ignore */ }
5061
+ safeKill(node.proc);
4961
5062
  daemon.networkNode.status = 'stopping';
4962
5063
  pushNodeEvent('stopping', { pid: node.pid, reason: 'update' });
4963
5064
  broadcastNodeStatus();
@@ -5002,11 +5103,12 @@ Keep responses concise. Help them think, don't lecture them about the system the
5002
5103
 
5003
5104
  broadcastUpdateProgress('deps', 'Updating dependencies...', 30);
5004
5105
 
5005
- const setup = spawn('bash', ['setup.sh', '--json'], {
5006
- cwd: installPath,
5007
- stdio: ['ignore', 'pipe', 'pipe'],
5008
- env: { ...process.env, PYTHONUNBUFFERED: '1' },
5009
- });
5106
+ let setup;
5107
+ try {
5108
+ setup = spawnSetupSh(installPath);
5109
+ } catch (spawnErr) {
5110
+ return fail(`Setup failed: ${spawnErr.message}`);
5111
+ }
5010
5112
 
5011
5113
  daemon.networkInstall.proc = setup;
5012
5114
 
@@ -5037,7 +5139,12 @@ Keep responses concise. Help them think, don't lecture them about the system the
5037
5139
  });
5038
5140
 
5039
5141
  if (setupResult.code !== 0) {
5040
- const hint = stderrBuf.trim().split('\n').slice(-1)[0] || `setup.sh exited ${setupResult.code}`;
5142
+ let hint;
5143
+ if (setupResult.code === -1 || setupResult.err?.includes('ENOENT')) {
5144
+ hint = 'bash not found — ensure Git for Windows is installed from https://git-scm.com';
5145
+ } else {
5146
+ hint = stderrBuf.trim().split('\n').slice(-1)[0] || `setup.sh exited ${setupResult.code}`;
5147
+ }
5041
5148
  return fail(`Setup failed: ${hint}`);
5042
5149
  }
5043
5150
 
@@ -200,6 +200,17 @@ export class ConversationManager {
200
200
  this.daemon.broadcast({ type: 'conversation:updated', data: conv });
201
201
  }
202
202
 
203
+ updateModel(id, provider, model) {
204
+ const conv = this.conversations.get(id);
205
+ if (!conv) throw new Error('Conversation not found');
206
+ conv.provider = provider;
207
+ conv.model = model;
208
+ conv.updatedAt = new Date().toISOString();
209
+ this._save();
210
+ this.daemon.broadcast({ type: 'conversation:updated', data: conv });
211
+ return conv;
212
+ }
213
+
203
214
  async setMode(id, mode) {
204
215
  const conv = this.conversations.get(id);
205
216
  if (!conv) throw new Error('Conversation not found');
@@ -259,35 +270,90 @@ export class ConversationManager {
259
270
 
260
271
  _killStreamingProcess(conversationId) {
261
272
  const procs = this._getStreamingProcesses();
262
- const proc = procs.get(conversationId);
263
- if (proc && !proc.killed) {
264
- proc.kill();
273
+ const handle = procs.get(conversationId);
274
+ if (!handle) return;
275
+ if (handle.abort) {
276
+ handle.abort();
277
+ } else if (handle.kill && !handle.killed) {
278
+ handle.kill();
265
279
  }
266
280
  procs.delete(conversationId);
267
281
  }
268
282
 
283
+ _getApiKey(providerName) {
284
+ const envMap = {
285
+ 'claude-code': 'ANTHROPIC_API_KEY',
286
+ 'codex': 'OPENAI_API_KEY',
287
+ 'gemini': 'GEMINI_API_KEY',
288
+ };
289
+ const envVar = envMap[providerName];
290
+ if (envVar && process.env[envVar]) return process.env[envVar];
291
+ try {
292
+ return this.daemon.credentials?.getKey(providerName) || null;
293
+ } catch { return null; }
294
+ }
295
+
269
296
  async sendMessage(id, message, history) {
270
297
  const conv = this.conversations.get(id);
271
298
  if (!conv) throw new Error('Conversation not found');
272
299
  if (conv.mode !== 'api') throw new Error('sendMessage only works in API mode');
273
300
 
274
- // Kill any previous streaming process for this conversation
275
301
  this._killStreamingProcess(id);
276
302
 
277
- const prompt = this._buildHistoryPrompt(history, message);
278
-
279
- // Resolve the provider for this conversation
280
303
  let provider = getProvider(conv.provider);
281
304
  let modelId = conv.model;
305
+ let providerName = conv.provider;
282
306
 
283
307
  if (!provider || !isProviderInstalled(conv.provider)) {
284
308
  const priority = ['claude-code', 'gemini', 'codex', 'ollama'];
285
309
  const fallbackId = priority.find((p) => isProviderInstalled(p));
286
310
  if (!fallbackId) throw new Error('No provider available for chat');
287
311
  provider = getProvider(fallbackId);
312
+ providerName = fallbackId;
288
313
  modelId = null;
289
314
  }
290
315
 
316
+ // Build messages array for direct API call
317
+ const messages = (history || []).map((m) => ({
318
+ role: m.from === 'user' ? 'user' : 'assistant',
319
+ content: m.text,
320
+ }));
321
+ messages.push({ role: 'user', content: message });
322
+
323
+ const apiKey = this._getApiKey(providerName);
324
+
325
+ // Try direct API streaming first (sub-second latency)
326
+ const controller = provider.streamChat(
327
+ messages, modelId, apiKey,
328
+ (text) => {
329
+ this.daemon.broadcast({
330
+ type: 'conversation:chunk',
331
+ data: { conversationId: id, text },
332
+ });
333
+ },
334
+ () => {
335
+ this._getStreamingProcesses().delete(id);
336
+ this.daemon.broadcast({
337
+ type: 'conversation:complete',
338
+ data: { conversationId: id },
339
+ });
340
+ },
341
+ (err) => {
342
+ this._getStreamingProcesses().delete(id);
343
+ this.daemon.broadcast({
344
+ type: 'conversation:error',
345
+ data: { conversationId: id, error: err.message },
346
+ });
347
+ },
348
+ );
349
+
350
+ if (controller) {
351
+ this._getStreamingProcesses().set(id, controller);
352
+ return;
353
+ }
354
+
355
+ // Fallback: headless CLI spawn (for providers without streamChat or missing API key)
356
+ const prompt = this._buildHistoryPrompt(history, message);
291
357
  const headlessCmd = provider.buildHeadlessCommand(prompt, modelId);
292
358
  const { command, args, env, stdin: stdinData, cwd } = headlessCmd;
293
359
 
@@ -305,23 +371,15 @@ export class ConversationManager {
305
371
  proc.stdin.end();
306
372
  }
307
373
 
308
- let fullOutput = '';
309
-
310
374
  proc.stdout.on('data', (data) => {
311
375
  const text = data.toString();
312
- fullOutput += text;
313
-
314
- // Parse provider output for streaming chunks
315
376
  const lines = text.split('\n');
316
377
  for (const line of lines) {
317
378
  const trimmed = line.trim();
318
379
  if (!trimmed) continue;
319
380
 
320
- // Try to parse as JSON (stream-json format)
321
381
  try {
322
382
  const json = JSON.parse(trimmed);
323
-
324
- // Claude Code stream-json: assistant message content
325
383
  if (json.type === 'assistant' && json.message?.content) {
326
384
  for (const block of json.message.content) {
327
385
  if (block.type === 'text' && block.text) {
@@ -333,8 +391,6 @@ export class ConversationManager {
333
391
  }
334
392
  continue;
335
393
  }
336
-
337
- // Claude Code stream-json: content_block_delta
338
394
  if (json.type === 'content_block_delta' && json.delta?.text) {
339
395
  this.daemon.broadcast({
340
396
  type: 'conversation:chunk',
@@ -342,14 +398,7 @@ export class ConversationManager {
342
398
  });
343
399
  continue;
344
400
  }
345
-
346
- // Claude Code stream-json: result block — skip broadcasting since
347
- // the content was already streamed via assistant/content_block_delta
348
- if (json.type === 'result' && json.result) {
349
- continue;
350
- }
351
-
352
- // Groove Network: token events
401
+ if (json.type === 'result' && json.result) continue;
353
402
  if (json.type === 'token' && json.text != null) {
354
403
  this.daemon.broadcast({
355
404
  type: 'conversation:chunk',
@@ -357,8 +406,6 @@ export class ConversationManager {
357
406
  });
358
407
  continue;
359
408
  }
360
-
361
- // Groove Network: done/complete/result
362
409
  if ((json.type === 'done' || json.type === 'complete' || json.type === 'result') && json.text) {
363
410
  this.daemon.broadcast({
364
411
  type: 'conversation:chunk',
@@ -366,8 +413,6 @@ export class ConversationManager {
366
413
  });
367
414
  continue;
368
415
  }
369
-
370
- // Gemini / Codex: content text
371
416
  if (json.content?.[0]?.text) {
372
417
  this.daemon.broadcast({
373
418
  type: 'conversation:chunk',
@@ -375,9 +420,8 @@ export class ConversationManager {
375
420
  });
376
421
  continue;
377
422
  }
378
- } catch { /* not JSON — treat as raw text */ }
423
+ } catch { /* not JSON */ }
379
424
 
380
- // Non-JSON output: broadcast raw text (some providers output plain text)
381
425
  if (!trimmed.startsWith('{')) {
382
426
  this.daemon.broadcast({
383
427
  type: 'conversation:chunk',
@@ -884,6 +884,7 @@ export class Journalist {
884
884
  recentChain ? `## Rotation History\n\n${recentChain}\n` : '',
885
885
  agent.prompt ? `## Original Task\n\n${agent.prompt}\n` : '',
886
886
  ``,
887
+ agent.role === 'planner' ? 'CRITICAL: You are a PLANNING ONLY agent. Do NOT implement code. Route all work to your team via .groove/recommended-team.json.\n' : '',
887
888
  `Continue seamlessly — finish what was in progress and deliver the output. Do not announce rotation or greet the user.`,
888
889
  ].filter(Boolean).join('\n');
889
890
  }