groove-dev 0.27.59 → 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 (68) 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 +73 -56
  4. package/node_modules/@groove-dev/daemon/src/conversations.js +78 -35
  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/groove-network.js +1 -1
  12. package/node_modules/@groove-dev/daemon/src/providers/index.js +16 -1
  13. package/node_modules/@groove-dev/daemon/src/providers/local.js +44 -0
  14. package/node_modules/@groove-dev/daemon/src/providers/ollama.js +44 -0
  15. package/node_modules/@groove-dev/daemon/src/rotator.js +4 -0
  16. package/node_modules/@groove-dev/gui/dist/assets/index-B3AqeyS4.css +1 -0
  17. package/node_modules/@groove-dev/gui/dist/assets/index-DWao9glo.js +8614 -0
  18. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  19. package/node_modules/@groove-dev/gui/package.json +1 -1
  20. package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +3 -2
  21. package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +1 -1
  22. package/node_modules/@groove-dev/gui/src/components/layout/status-bar.jsx +13 -7
  23. package/node_modules/@groove-dev/gui/src/components/network/activity-chart.jsx +245 -0
  24. package/node_modules/@groove-dev/gui/src/components/network/compute-header.jsx +1 -1
  25. package/node_modules/@groove-dev/gui/src/components/network/network-health.jsx +1 -1
  26. package/node_modules/@groove-dev/gui/src/components/network/network-status.jsx +5 -5
  27. package/node_modules/@groove-dev/gui/src/components/network/node-details.jsx +1 -1
  28. package/node_modules/@groove-dev/gui/src/components/ui/update-modal.jsx +70 -0
  29. package/node_modules/@groove-dev/gui/src/stores/groove.js +66 -6
  30. package/node_modules/@groove-dev/gui/src/views/network.jsx +99 -17
  31. package/package.json +1 -1
  32. package/packages/cli/package.json +1 -1
  33. package/packages/daemon/package.json +1 -1
  34. package/packages/daemon/src/api.js +73 -56
  35. package/packages/daemon/src/conversations.js +78 -35
  36. package/packages/daemon/src/journalist.js +1 -0
  37. package/packages/daemon/src/process.js +17 -7
  38. package/packages/daemon/src/providers/base.js +4 -0
  39. package/packages/daemon/src/providers/claude-code.js +63 -0
  40. package/packages/daemon/src/providers/codex.js +55 -0
  41. package/packages/daemon/src/providers/gemini.js +53 -0
  42. package/packages/daemon/src/providers/groove-network.js +1 -1
  43. package/packages/daemon/src/providers/index.js +16 -1
  44. package/packages/daemon/src/providers/local.js +44 -0
  45. package/packages/daemon/src/providers/ollama.js +44 -0
  46. package/packages/daemon/src/rotator.js +4 -0
  47. package/packages/gui/dist/assets/index-B3AqeyS4.css +1 -0
  48. package/packages/gui/dist/assets/index-DWao9glo.js +8614 -0
  49. package/packages/gui/dist/index.html +2 -2
  50. package/packages/gui/package.json +1 -1
  51. package/packages/gui/src/components/chat/chat-view.jsx +3 -2
  52. package/packages/gui/src/components/chat/model-picker.jsx +1 -1
  53. package/packages/gui/src/components/layout/status-bar.jsx +13 -7
  54. package/packages/gui/src/components/network/activity-chart.jsx +245 -0
  55. package/packages/gui/src/components/network/compute-header.jsx +1 -1
  56. package/packages/gui/src/components/network/network-health.jsx +1 -1
  57. package/packages/gui/src/components/network/network-status.jsx +5 -5
  58. package/packages/gui/src/components/network/node-details.jsx +1 -1
  59. package/packages/gui/src/components/ui/update-modal.jsx +70 -0
  60. package/packages/gui/src/stores/groove.js +66 -6
  61. package/packages/gui/src/views/network.jsx +99 -17
  62. package/default/fix-beta-endpoint-deployment.md +0 -68
  63. package/default/groovedev-beta-auth-endpoint.md +0 -166
  64. package/default/security-review-prompt.md +0 -98
  65. package/node_modules/@groove-dev/gui/dist/assets/index-BrfCzrxJ.css +0 -1
  66. package/node_modules/@groove-dev/gui/dist/assets/index-BycOlqLx.js +0 -8614
  67. package/packages/gui/dist/assets/index-BrfCzrxJ.css +0 -1
  68. package/packages/gui/dist/assets/index-BycOlqLx.js +0 -8614
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.59",
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.59",
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,20 +4223,20 @@ 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
 
4235
4238
  const signalFlag = supportsSignalFlag(cfg.version) ? '--signal' : '--relay';
4236
- const model = cfg.model || 'google/gemma-3-4b';
4239
+ const model = cfg.model || 'Qwen/Qwen3-4B';
4237
4240
  const args = [
4238
4241
  '-m', 'src.node.server',
4239
4242
  signalFlag, signal,
@@ -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();
@@ -4484,7 +4483,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
4484
4483
  models,
4485
4484
  compute: data.compute || null,
4486
4485
  coverage: data.covered_layers ?? primaryModel.covered_layers ?? data.coverage ?? 0,
4487
- totalLayers: data.total_layers ?? primaryModel.total_layers ?? data.totalLayers ?? 34,
4486
+ totalLayers: data.total_layers ?? primaryModel.total_layers ?? data.totalLayers ?? 36,
4488
4487
  activeSessions: data.active_sessions ?? data.activeSessions ?? 0,
4489
4488
  totalNodes: data.total_nodes ?? data.totalNodes ?? (Array.isArray(data.nodes) ? data.nodes.length : 0),
4490
4489
  });
@@ -4529,10 +4528,10 @@ Keep responses concise. Help them think, don't lecture them about the system the
4529
4528
  } : null;
4530
4529
  res.json({
4531
4530
  nodes: selfNode,
4532
- models: ['google/gemma-3-4b'],
4531
+ models: ['Qwen/Qwen3-4B'],
4533
4532
  compute: localCompute,
4534
4533
  coverage,
4535
- totalLayers: 34,
4534
+ totalLayers: 36,
4536
4535
  activeSessions: node.sessions || 0,
4537
4536
  totalNodes: selfNode.length,
4538
4537
  });
@@ -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
 
@@ -5,7 +5,7 @@ import { readFileSync, writeFileSync, existsSync } from 'fs';
5
5
  import { resolve } from 'path';
6
6
  import { randomUUID } from 'crypto';
7
7
  import { spawn as cpSpawn } from 'child_process';
8
- import { getProvider, getInstalledProviders } from './providers/index.js';
8
+ import { getProvider, getInstalledProviders, isProviderInstalled } from './providers/index.js';
9
9
 
10
10
  export class ConversationManager {
11
11
  constructor(daemon) {
@@ -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,36 +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
- if (!provider || !provider.constructor.isInstalled()) {
307
+ if (!provider || !isProviderInstalled(conv.provider)) {
284
308
  const priority = ['claude-code', 'gemini', 'codex', 'ollama'];
285
- const installed = getInstalledProviders();
286
- const fallbackId = priority.find((p) => installed.some((i) => i.id === p));
309
+ const fallbackId = priority.find((p) => isProviderInstalled(p));
287
310
  if (!fallbackId) throw new Error('No provider available for chat');
288
311
  provider = getProvider(fallbackId);
312
+ providerName = fallbackId;
289
313
  modelId = null;
290
314
  }
291
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);
292
357
  const headlessCmd = provider.buildHeadlessCommand(prompt, modelId);
293
358
  const { command, args, env, stdin: stdinData, cwd } = headlessCmd;
294
359
 
@@ -306,23 +371,15 @@ export class ConversationManager {
306
371
  proc.stdin.end();
307
372
  }
308
373
 
309
- let fullOutput = '';
310
-
311
374
  proc.stdout.on('data', (data) => {
312
375
  const text = data.toString();
313
- fullOutput += text;
314
-
315
- // Parse provider output for streaming chunks
316
376
  const lines = text.split('\n');
317
377
  for (const line of lines) {
318
378
  const trimmed = line.trim();
319
379
  if (!trimmed) continue;
320
380
 
321
- // Try to parse as JSON (stream-json format)
322
381
  try {
323
382
  const json = JSON.parse(trimmed);
324
-
325
- // Claude Code stream-json: assistant message content
326
383
  if (json.type === 'assistant' && json.message?.content) {
327
384
  for (const block of json.message.content) {
328
385
  if (block.type === 'text' && block.text) {
@@ -334,8 +391,6 @@ export class ConversationManager {
334
391
  }
335
392
  continue;
336
393
  }
337
-
338
- // Claude Code stream-json: content_block_delta
339
394
  if (json.type === 'content_block_delta' && json.delta?.text) {
340
395
  this.daemon.broadcast({
341
396
  type: 'conversation:chunk',
@@ -343,14 +398,7 @@ export class ConversationManager {
343
398
  });
344
399
  continue;
345
400
  }
346
-
347
- // Claude Code stream-json: result block — skip broadcasting since
348
- // the content was already streamed via assistant/content_block_delta
349
- if (json.type === 'result' && json.result) {
350
- continue;
351
- }
352
-
353
- // Groove Network: token events
401
+ if (json.type === 'result' && json.result) continue;
354
402
  if (json.type === 'token' && json.text != null) {
355
403
  this.daemon.broadcast({
356
404
  type: 'conversation:chunk',
@@ -358,8 +406,6 @@ export class ConversationManager {
358
406
  });
359
407
  continue;
360
408
  }
361
-
362
- // Groove Network: done/complete/result
363
409
  if ((json.type === 'done' || json.type === 'complete' || json.type === 'result') && json.text) {
364
410
  this.daemon.broadcast({
365
411
  type: 'conversation:chunk',
@@ -367,8 +413,6 @@ export class ConversationManager {
367
413
  });
368
414
  continue;
369
415
  }
370
-
371
- // Gemini / Codex: content text
372
416
  if (json.content?.[0]?.text) {
373
417
  this.daemon.broadcast({
374
418
  type: 'conversation:chunk',
@@ -376,9 +420,8 @@ export class ConversationManager {
376
420
  });
377
421
  continue;
378
422
  }
379
- } catch { /* not JSON — treat as raw text */ }
423
+ } catch { /* not JSON */ }
380
424
 
381
- // Non-JSON output: broadcast raw text (some providers output plain text)
382
425
  if (!trimmed.startsWith('{')) {
383
426
  this.daemon.broadcast({
384
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
  }
@@ -308,6 +308,13 @@ function sanitizeFilename(name) {
308
308
  return String(name).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
309
309
  }
310
310
 
311
+ export function wrapWithRoleReminder(role, message) {
312
+ if (role === 'planner' && !message.startsWith('ROLE REMINDER:')) {
313
+ return 'ROLE REMINDER: You are a PLANNING ONLY agent. Do NOT write code, edit files, or use Edit/Write/Bash tools. Route this task to your team by writing .groove/recommended-team.json.\n\nUser message: ' + message;
314
+ }
315
+ return message;
316
+ }
317
+
311
318
  export class ProcessManager {
312
319
  constructor(daemon) {
313
320
  this.daemon = daemon;
@@ -568,7 +575,7 @@ Do NOT:
568
575
  - Analyze the codebase proactively
569
576
 
570
577
  DO: Introduce yourself in one sentence and ask the user what they would like you to work on. Then wait.`;
571
- } else if (spawnConfig.prompt.startsWith('# Agent Handoff Brief')) {
578
+ } else if (spawnConfig.prompt.startsWith('# Handoff Brief')) {
572
579
  spawnConfig.prompt += '\n\n## Role Constraints\n\n' + rolePrompt.trim();
573
580
  } else {
574
581
  spawnConfig.prompt = rolePrompt + 'Task: ' + spawnConfig.prompt;
@@ -640,7 +647,7 @@ If response says \`"approved":false\`, adjust your approach based on the reason.
640
647
  For normal file edits within your scope, proceed without review.
641
648
 
642
649
  `;
643
- if (spawnConfig.prompt.startsWith('# Agent Handoff Brief')) {
650
+ if (spawnConfig.prompt.startsWith('# Handoff Brief')) {
644
651
  spawnConfig.prompt += '\n\n' + pmPrompt.trim();
645
652
  } else {
646
653
  spawnConfig.prompt = pmPrompt + spawnConfig.prompt;
@@ -1728,9 +1735,10 @@ For normal file edits within your scope, proceed without review.
1728
1735
  const { loop } = handle;
1729
1736
  if (!loop.running) return false;
1730
1737
 
1731
- // Fire and forget — the loop processes the message asynchronously
1732
- // and emits output events that flow through the normal handler
1733
- loop.sendMessage(message).catch(() => {});
1738
+ const agent = this.daemon.registry.get(agentId);
1739
+ const wrapped = agent ? wrapWithRoleReminder(agent.role, message) : message;
1740
+
1741
+ loop.sendMessage(wrapped).catch(() => {});
1734
1742
  return true;
1735
1743
  }
1736
1744
 
@@ -1743,8 +1751,10 @@ For normal file edits within your scope, proceed without review.
1743
1751
  }
1744
1752
 
1745
1753
  queueMessage(agentId, message) {
1746
- this.pendingMessages.set(agentId, { message, timestamp: Date.now() });
1747
- this.daemon.broadcast({ type: 'agent:message_queued', agentId, message });
1754
+ const agent = this.daemon.registry.get(agentId);
1755
+ const wrapped = agent ? wrapWithRoleReminder(agent.role, message) : message;
1756
+ this.pendingMessages.set(agentId, { message: wrapped, timestamp: Date.now() });
1757
+ this.daemon.broadcast({ type: 'agent:message_queued', agentId, message: wrapped });
1748
1758
  }
1749
1759
 
1750
1760
  consumePendingMessage(agentId) {
@@ -32,4 +32,8 @@ export class Provider {
32
32
  parseOutput(line) {
33
33
  return null;
34
34
  }
35
+
36
+ streamChat(messages, model, apiKey, onChunk, onDone, onError) {
37
+ return null;
38
+ }
35
39
  }