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.
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +73 -56
- package/node_modules/@groove-dev/daemon/src/conversations.js +78 -35
- package/node_modules/@groove-dev/daemon/src/journalist.js +1 -0
- package/node_modules/@groove-dev/daemon/src/process.js +17 -7
- package/node_modules/@groove-dev/daemon/src/providers/base.js +4 -0
- package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +63 -0
- package/node_modules/@groove-dev/daemon/src/providers/codex.js +55 -0
- package/node_modules/@groove-dev/daemon/src/providers/gemini.js +53 -0
- package/node_modules/@groove-dev/daemon/src/providers/groove-network.js +1 -1
- package/node_modules/@groove-dev/daemon/src/providers/index.js +16 -1
- package/node_modules/@groove-dev/daemon/src/providers/local.js +44 -0
- package/node_modules/@groove-dev/daemon/src/providers/ollama.js +44 -0
- package/node_modules/@groove-dev/daemon/src/rotator.js +4 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-B3AqeyS4.css +1 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-DWao9glo.js +8614 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +3 -2
- package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/layout/status-bar.jsx +13 -7
- package/node_modules/@groove-dev/gui/src/components/network/activity-chart.jsx +245 -0
- package/node_modules/@groove-dev/gui/src/components/network/compute-header.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/network/network-health.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/network/network-status.jsx +5 -5
- package/node_modules/@groove-dev/gui/src/components/network/node-details.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/ui/update-modal.jsx +70 -0
- package/node_modules/@groove-dev/gui/src/stores/groove.js +66 -6
- package/node_modules/@groove-dev/gui/src/views/network.jsx +99 -17
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +73 -56
- package/packages/daemon/src/conversations.js +78 -35
- package/packages/daemon/src/journalist.js +1 -0
- package/packages/daemon/src/process.js +17 -7
- package/packages/daemon/src/providers/base.js +4 -0
- package/packages/daemon/src/providers/claude-code.js +63 -0
- package/packages/daemon/src/providers/codex.js +55 -0
- package/packages/daemon/src/providers/gemini.js +53 -0
- package/packages/daemon/src/providers/groove-network.js +1 -1
- package/packages/daemon/src/providers/index.js +16 -1
- package/packages/daemon/src/providers/local.js +44 -0
- package/packages/daemon/src/providers/ollama.js +44 -0
- package/packages/daemon/src/rotator.js +4 -0
- package/packages/gui/dist/assets/index-B3AqeyS4.css +1 -0
- package/packages/gui/dist/assets/index-DWao9glo.js +8614 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/chat/chat-view.jsx +3 -2
- package/packages/gui/src/components/chat/model-picker.jsx +1 -1
- package/packages/gui/src/components/layout/status-bar.jsx +13 -7
- package/packages/gui/src/components/network/activity-chart.jsx +245 -0
- package/packages/gui/src/components/network/compute-header.jsx +1 -1
- package/packages/gui/src/components/network/network-health.jsx +1 -1
- package/packages/gui/src/components/network/network-status.jsx +5 -5
- package/packages/gui/src/components/network/node-details.jsx +1 -1
- package/packages/gui/src/components/ui/update-modal.jsx +70 -0
- package/packages/gui/src/stores/groove.js +66 -6
- package/packages/gui/src/views/network.jsx +99 -17
- package/default/fix-beta-endpoint-deployment.md +0 -68
- package/default/groovedev-beta-auth-endpoint.md +0 -166
- package/default/security-review-prompt.md +0 -98
- package/node_modules/@groove-dev/gui/dist/assets/index-BrfCzrxJ.css +0 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-BycOlqLx.js +0 -8614
- package/packages/gui/dist/assets/index-BrfCzrxJ.css +0 -1
- package/packages/gui/dist/assets/index-BycOlqLx.js +0 -8614
|
@@ -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,
|
|
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,
|
|
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,
|
|
1160
|
-
: await daemon.rotator.rotate(req.params.id, { additionalPrompt:
|
|
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 ||
|
|
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(
|
|
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
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
}
|
|
3958
|
-
|
|
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
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
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(
|
|
4226
|
+
deployPath = resolve(homedir(), 'Desktop', 'groove-deploy');
|
|
4224
4227
|
} else if (deployPath.startsWith('~/')) {
|
|
4225
|
-
deployPath = resolve(
|
|
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(
|
|
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 || '
|
|
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(
|
|
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
|
-
|
|
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 ??
|
|
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: ['
|
|
4531
|
+
models: ['Qwen/Qwen3-4B'],
|
|
4533
4532
|
compute: localCompute,
|
|
4534
4533
|
coverage,
|
|
4535
|
-
totalLayers:
|
|
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) +
|
|
4643
|
-
catch { full = resolved +
|
|
4644
|
-
const realHome = existsSync(home.slice(0, -1)) ? realpathSync(home.slice(0, -1)) +
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
263
|
-
if (
|
|
264
|
-
|
|
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
|
|
307
|
+
if (!provider || !isProviderInstalled(conv.provider)) {
|
|
284
308
|
const priority = ['claude-code', 'gemini', 'codex', 'ollama'];
|
|
285
|
-
const
|
|
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
|
|
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('#
|
|
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('#
|
|
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
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
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.
|
|
1747
|
-
|
|
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) {
|