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
@@ -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
 
@@ -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
  }
@@ -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
  }
@@ -10,6 +10,28 @@ import { Provider } from './base.js';
10
10
 
11
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
12
 
13
+ async function parseSSEStream(response, onEvent) {
14
+ const reader = response.body.getReader();
15
+ const decoder = new TextDecoder();
16
+ let buffer = '';
17
+ let gotDone = false;
18
+ while (true) {
19
+ const { done, value } = await reader.read();
20
+ if (done) break;
21
+ buffer += decoder.decode(value, { stream: true });
22
+ const lines = buffer.split('\n');
23
+ buffer = lines.pop();
24
+ for (const line of lines) {
25
+ if (line.startsWith('data: ')) {
26
+ const data = line.slice(6);
27
+ if (data === '[DONE]') { onEvent({ done: true }); gotDone = true; return; }
28
+ try { onEvent(JSON.parse(data)); } catch { /* skip malformed */ }
29
+ }
30
+ }
31
+ }
32
+ if (!gotDone) onEvent({ done: true });
33
+ }
34
+
13
35
  export class ClaudeCodeProvider extends Provider {
14
36
  static name = 'claude-code';
15
37
  static displayName = 'Claude Code';
@@ -254,6 +276,47 @@ export class ClaudeCodeProvider extends Provider {
254
276
  return merged;
255
277
  }
256
278
 
279
+ streamChat(messages, model, apiKey, onChunk, onDone, onError) {
280
+ if (!apiKey) return null;
281
+ const controller = new AbortController();
282
+ let finished = false;
283
+ const finish = () => { if (!finished) { finished = true; onDone(); } };
284
+ const body = JSON.stringify({
285
+ model: model || 'claude-sonnet-4-6',
286
+ messages,
287
+ max_tokens: 8192,
288
+ stream: true,
289
+ });
290
+ fetch('https://api.anthropic.com/v1/messages', {
291
+ method: 'POST',
292
+ headers: {
293
+ 'x-api-key': apiKey,
294
+ 'anthropic-version': '2023-06-01',
295
+ 'content-type': 'application/json',
296
+ },
297
+ body,
298
+ signal: controller.signal,
299
+ }).then((res) => {
300
+ if (!res.ok) {
301
+ return res.text().then((t) => { throw new Error(`Anthropic API ${res.status}: ${t.slice(0, 200)}`); });
302
+ }
303
+ return parseSSEStream(res, (event) => {
304
+ if (event.done) { finish(); return; }
305
+ if (event.type === 'content_block_delta' && event.delta?.text) {
306
+ onChunk(event.delta.text);
307
+ } else if (event.type === 'message_stop') {
308
+ finish();
309
+ }
310
+ });
311
+ }).then(() => {
312
+ finish();
313
+ }).catch((err) => {
314
+ if (err.name === 'AbortError') return;
315
+ onError(err);
316
+ });
317
+ return controller;
318
+ }
319
+
257
320
  static getAuthStatus() {
258
321
  try {
259
322
  const out = execSync('claude auth status --json', { encoding: 'utf8', timeout: 10_000, stdio: ['pipe', 'pipe', 'pipe'] });