groove-dev 0.27.60 → 0.27.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +167 -60
- package/node_modules/@groove-dev/daemon/src/conversations.js +75 -31
- 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/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-DD6taBMp.css → index-B3AqeyS4.css} +1 -1
- package/node_modules/@groove-dev/gui/dist/assets/{index-DcnRqlqB.js → index-Dvum7uoe.js} +178 -178
- 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/ui/update-modal.jsx +70 -0
- package/node_modules/@groove-dev/gui/src/stores/groove.js +52 -12
- 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 +167 -60
- package/packages/daemon/src/conversations.js +75 -31
- 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/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-DD6taBMp.css → index-B3AqeyS4.css} +1 -1
- package/packages/gui/dist/assets/{index-DcnRqlqB.js → index-Dvum7uoe.js} +178 -178
- 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/ui/update-modal.jsx +70 -0
- package/packages/gui/src/stores/groove.js +52 -12
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
3
|
|
|
4
4
|
import express from 'express';
|
|
5
|
-
import { resolve, dirname, join } from 'path';
|
|
5
|
+
import { resolve, dirname, join, sep, relative } from 'path';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
7
|
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, unlinkSync, renameSync, rmSync, createReadStream, copyFileSync, realpathSync } from 'fs';
|
|
8
|
-
import { spawn, execFile } from 'child_process';
|
|
9
|
-
import { createHash } from 'crypto';
|
|
8
|
+
import { spawn, execFile, execFileSync } from 'child_process';
|
|
9
|
+
import { createHash, randomUUID } from 'crypto';
|
|
10
10
|
import { hostname, networkInterfaces, homedir } from 'os';
|
|
11
11
|
import { lookup as mimeLookup } from './mimetypes.js';
|
|
12
12
|
import { listProviders, getProvider } from './providers/index.js';
|
|
@@ -14,7 +14,7 @@ import { OllamaProvider } from './providers/ollama.js';
|
|
|
14
14
|
import { ClaudeCodeProvider } from './providers/claude-code.js';
|
|
15
15
|
import { supportsSignalFlag, compareSemver, parseSemver } from './providers/groove-network.js';
|
|
16
16
|
import { validateAgentConfig } from './validate.js';
|
|
17
|
-
import { ROLE_INTEGRATIONS } from './process.js';
|
|
17
|
+
import { ROLE_INTEGRATIONS, wrapWithRoleReminder } from './process.js';
|
|
18
18
|
|
|
19
19
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
20
|
const pkgVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
@@ -846,13 +846,18 @@ export function createApi(app, daemon) {
|
|
|
846
846
|
if (req.body.title !== undefined) daemon.conversations.rename(req.params.id, req.body.title);
|
|
847
847
|
if (req.body.pinned !== undefined) daemon.conversations.pin(req.params.id, req.body.pinned);
|
|
848
848
|
if (req.body.archived !== undefined) daemon.conversations.archive(req.params.id, req.body.archived);
|
|
849
|
+
if (req.body.model !== undefined || req.body.provider !== undefined) {
|
|
850
|
+
const newProvider = req.body.provider || conv.provider;
|
|
851
|
+
const newModel = req.body.model || conv.model;
|
|
852
|
+
daemon.conversations.updateModel(req.params.id, newProvider, newModel);
|
|
853
|
+
}
|
|
849
854
|
if (req.body.mode !== undefined) {
|
|
850
855
|
if (req.body.mode !== 'api' && req.body.mode !== 'agent') {
|
|
851
856
|
return res.status(400).json({ error: 'mode must be "api" or "agent"' });
|
|
852
857
|
}
|
|
853
858
|
await daemon.conversations.setMode(req.params.id, req.body.mode);
|
|
854
859
|
}
|
|
855
|
-
daemon.audit.log('conversation.update', { id: req.params.id, mode: req.body.mode });
|
|
860
|
+
daemon.audit.log('conversation.update', { id: req.params.id, provider: req.body.provider, model: req.body.model, mode: req.body.mode });
|
|
856
861
|
res.json(daemon.conversations.get(req.params.id));
|
|
857
862
|
} catch (err) {
|
|
858
863
|
res.status(400).json({ error: err.message });
|
|
@@ -1105,8 +1110,9 @@ export function createApi(app, daemon) {
|
|
|
1105
1110
|
if (daemon.journalist) daemon.journalist.recordUserFeedback(agent, message.trim());
|
|
1106
1111
|
|
|
1107
1112
|
// Agent loop path — send message directly to the running loop
|
|
1113
|
+
const wrappedMessage = wrapWithRoleReminder(agent.role, message.trim());
|
|
1108
1114
|
if (daemon.processes.hasAgentLoop(req.params.id)) {
|
|
1109
|
-
const sent = await daemon.processes.sendMessage(req.params.id,
|
|
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
|
|
|
@@ -2908,6 +2914,18 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
2908
2914
|
console.log(`[Groove] Project directory: ${projectWorkingDir}`);
|
|
2909
2915
|
}
|
|
2910
2916
|
|
|
2917
|
+
function normalizeScope(patterns, baseDir) {
|
|
2918
|
+
if (!patterns || !Array.isArray(patterns)) return patterns;
|
|
2919
|
+
return patterns.map((p) => {
|
|
2920
|
+
if (typeof p === 'string' && p.startsWith('/')) {
|
|
2921
|
+
const rel = relative(baseDir, p);
|
|
2922
|
+
if (!rel.startsWith('..')) return rel;
|
|
2923
|
+
return p.slice(1);
|
|
2924
|
+
}
|
|
2925
|
+
return p;
|
|
2926
|
+
});
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2911
2929
|
// Separate phase 1 (builders) and phase 2 (QC/finisher)
|
|
2912
2930
|
const phase1 = agentConfigs.filter((a) => !a.phase || a.phase === 1);
|
|
2913
2931
|
let phase2 = agentConfigs.filter((a) => a.phase === 2);
|
|
@@ -2967,7 +2985,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
2967
2985
|
// Spawn fresh with the same name/team but new prompt + full context
|
|
2968
2986
|
const validated = validateAgentConfig({
|
|
2969
2987
|
role: existing.role,
|
|
2970
|
-
scope: config.scope || existing.scope || [],
|
|
2988
|
+
scope: normalizeScope(config.scope || existing.scope || [], existing.workingDir || projectWorkingDir),
|
|
2971
2989
|
prompt,
|
|
2972
2990
|
provider: config.provider || existing.provider || undefined,
|
|
2973
2991
|
model: config.model || existing.model || daemon.config?.defaultModel || 'auto',
|
|
@@ -2989,7 +3007,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
2989
3007
|
try {
|
|
2990
3008
|
const validated = validateAgentConfig({
|
|
2991
3009
|
role: config.role,
|
|
2992
|
-
scope: config.scope || [],
|
|
3010
|
+
scope: normalizeScope(config.scope || [], config.workingDir || projectWorkingDir),
|
|
2993
3011
|
prompt,
|
|
2994
3012
|
provider: config.provider || undefined,
|
|
2995
3013
|
model: config.model || daemon.config?.defaultModel || 'auto',
|
|
@@ -3009,6 +3027,10 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
3009
3027
|
}
|
|
3010
3028
|
}
|
|
3011
3029
|
|
|
3030
|
+
if (failed.length > 0) {
|
|
3031
|
+
console.warn(`[Groove] Team launch had ${failed.length} failure(s):`, failed.map((f) => `${f.role}: ${f.error}`).join(', '));
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3012
3034
|
// Phase 2 agents also scoped to projectWorkingDir
|
|
3013
3035
|
if (phase2.length > 0 && phase1Ids.length > 0) {
|
|
3014
3036
|
// Dedup: if a running idle fullstack already exists in this team,
|
|
@@ -3474,7 +3496,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
3474
3496
|
// Resolve shell shortcuts — GUI sends ~/... and ./...
|
|
3475
3497
|
let resolvedPath = targetPath;
|
|
3476
3498
|
if (resolvedPath.startsWith('~/') || resolvedPath === '~') {
|
|
3477
|
-
resolvedPath = resolve(
|
|
3499
|
+
resolvedPath = resolve(homedir(), resolvedPath.slice(2));
|
|
3478
3500
|
} else if (!resolvedPath.startsWith('/')) {
|
|
3479
3501
|
resolvedPath = resolve(daemon.projectDir, resolvedPath);
|
|
3480
3502
|
}
|
|
@@ -3948,15 +3970,14 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
3948
3970
|
const BETA_RATE_WINDOW_MS = 60_000;
|
|
3949
3971
|
|
|
3950
3972
|
function getMachineId() {
|
|
3951
|
-
const
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
}
|
|
3958
|
-
|
|
3959
|
-
return createHash('sha256').update(`${hostname()}|${macs.join(',')}`).digest('hex');
|
|
3973
|
+
const idFile = join(daemon.grooveDir, '.machine-id');
|
|
3974
|
+
try {
|
|
3975
|
+
const existing = readFileSync(idFile, 'utf8').trim();
|
|
3976
|
+
if (existing.length >= 32) return existing;
|
|
3977
|
+
} catch {}
|
|
3978
|
+
const id = createHash('sha256').update(`${hostname()}|${randomUUID()}`).digest('hex');
|
|
3979
|
+
try { writeFileSync(idFile, id, { mode: 0o600 }); } catch {}
|
|
3980
|
+
return id;
|
|
3960
3981
|
}
|
|
3961
3982
|
|
|
3962
3983
|
async function validateCodeWithServer(code) {
|
|
@@ -4103,11 +4124,9 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4103
4124
|
|
|
4104
4125
|
app.post('/api/beta/deactivate', async (req, res) => {
|
|
4105
4126
|
// Stop the node if it's running before locking the feature away.
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
}
|
|
4110
|
-
} catch { /* ignore */ }
|
|
4127
|
+
if (daemon.networkNode?.proc && !daemon.networkNode.proc.killed) {
|
|
4128
|
+
safeKill(daemon.networkNode.proc);
|
|
4129
|
+
}
|
|
4111
4130
|
daemon.networkNode = {
|
|
4112
4131
|
active: false, status: 'stopped', pid: null, proc: null,
|
|
4113
4132
|
nodeId: null, layers: null, model: null, sessions: 0,
|
|
@@ -4220,15 +4239,15 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4220
4239
|
// Resolve deploy path (handles ~ and defaults to ~/Desktop/groove-deploy)
|
|
4221
4240
|
let deployPath = cfg.deployPath || null;
|
|
4222
4241
|
if (!deployPath) {
|
|
4223
|
-
deployPath = resolve(
|
|
4242
|
+
deployPath = resolve(homedir(), 'Desktop', 'groove-deploy');
|
|
4224
4243
|
} else if (deployPath.startsWith('~/')) {
|
|
4225
|
-
deployPath = resolve(
|
|
4244
|
+
deployPath = resolve(homedir(), deployPath.slice(2));
|
|
4226
4245
|
}
|
|
4227
4246
|
|
|
4228
4247
|
if (!existsSync(deployPath)) {
|
|
4229
4248
|
return res.status(400).json({ error: `Deploy path not found: ${deployPath}` });
|
|
4230
4249
|
}
|
|
4231
|
-
if (!isInsideGrooveHome(deployPath) && !deployPath.startsWith(resolve(
|
|
4250
|
+
if (!isInsideGrooveHome(deployPath) && !deployPath.startsWith(resolve(homedir(), 'Desktop'))) {
|
|
4232
4251
|
return res.status(400).json({ error: 'Deploy path outside allowed directories' });
|
|
4233
4252
|
}
|
|
4234
4253
|
|
|
@@ -4245,7 +4264,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4245
4264
|
|
|
4246
4265
|
let proc;
|
|
4247
4266
|
try {
|
|
4248
|
-
proc = spawn(
|
|
4267
|
+
proc = spawn(venvPython(deployPath), args, {
|
|
4249
4268
|
cwd: deployPath,
|
|
4250
4269
|
env: { ...process.env, PYTHONUNBUFFERED: '1' },
|
|
4251
4270
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -4368,11 +4387,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4368
4387
|
if (!node?.active || !node.proc) {
|
|
4369
4388
|
return res.status(409).json({ error: 'Node not running' });
|
|
4370
4389
|
}
|
|
4371
|
-
|
|
4372
|
-
node.proc.kill('SIGINT');
|
|
4373
|
-
} catch (err) {
|
|
4374
|
-
return res.status(500).json({ error: `Failed to stop node: ${err.message}` });
|
|
4375
|
-
}
|
|
4390
|
+
safeKill(node.proc);
|
|
4376
4391
|
daemon.networkNode.status = 'stopping';
|
|
4377
4392
|
pushNodeEvent('stopping', { pid: node.pid });
|
|
4378
4393
|
broadcastNodeStatus();
|
|
@@ -4610,9 +4625,65 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4610
4625
|
|
|
4611
4626
|
// --- Network package install/uninstall ---
|
|
4612
4627
|
|
|
4628
|
+
const IS_WIN = process.platform === 'win32';
|
|
4613
4629
|
const NETWORK_REPO_URL = 'https://github.com/grooveai-dev/groove-network.git';
|
|
4614
4630
|
const NETWORK_VERSION = 'v0.2.0';
|
|
4615
4631
|
|
|
4632
|
+
function venvPython(base) {
|
|
4633
|
+
return IS_WIN
|
|
4634
|
+
? join(base, 'venv', 'Scripts', 'python.exe')
|
|
4635
|
+
: join(base, 'venv', 'bin', 'python3');
|
|
4636
|
+
}
|
|
4637
|
+
|
|
4638
|
+
let _cachedGitBash = undefined;
|
|
4639
|
+
function findGitBash() {
|
|
4640
|
+
if (_cachedGitBash !== undefined) return _cachedGitBash;
|
|
4641
|
+
try {
|
|
4642
|
+
const gitPath = execFileSync('where', ['git'], { timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] })
|
|
4643
|
+
.toString().trim().split('\n')[0].trim();
|
|
4644
|
+
// git.exe is typically at <Git>\cmd\git.exe — navigate up to Git root
|
|
4645
|
+
const gitDir = dirname(dirname(gitPath));
|
|
4646
|
+
const candidate = join(gitDir, 'bin', 'bash.exe');
|
|
4647
|
+
if (existsSync(candidate)) { _cachedGitBash = candidate; return _cachedGitBash; }
|
|
4648
|
+
} catch { /* where failed — try common paths */ }
|
|
4649
|
+
const fallbacks = [
|
|
4650
|
+
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
4651
|
+
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
4652
|
+
];
|
|
4653
|
+
for (const p of fallbacks) {
|
|
4654
|
+
if (existsSync(p)) { _cachedGitBash = p; return _cachedGitBash; }
|
|
4655
|
+
}
|
|
4656
|
+
_cachedGitBash = null;
|
|
4657
|
+
return null;
|
|
4658
|
+
}
|
|
4659
|
+
|
|
4660
|
+
function spawnSetupSh(cwd) {
|
|
4661
|
+
if (IS_WIN) {
|
|
4662
|
+
const bashPath = findGitBash();
|
|
4663
|
+
if (!bashPath) {
|
|
4664
|
+
const err = new Error('Could not find bash. Ensure Git for Windows is installed from https://git-scm.com');
|
|
4665
|
+
err.code = 'BASH_NOT_FOUND';
|
|
4666
|
+
throw err;
|
|
4667
|
+
}
|
|
4668
|
+
return spawn(bashPath, ['setup.sh', '--json'], {
|
|
4669
|
+
cwd,
|
|
4670
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
4671
|
+
env: { ...process.env, PYTHONUNBUFFERED: '1' },
|
|
4672
|
+
});
|
|
4673
|
+
}
|
|
4674
|
+
return spawn('bash', ['setup.sh', '--json'], {
|
|
4675
|
+
cwd,
|
|
4676
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
4677
|
+
env: { ...process.env, PYTHONUNBUFFERED: '1' },
|
|
4678
|
+
});
|
|
4679
|
+
}
|
|
4680
|
+
|
|
4681
|
+
function safeKill(proc, signal = 'SIGINT') {
|
|
4682
|
+
try {
|
|
4683
|
+
if (IS_WIN) { proc.kill(); } else { proc.kill(signal); }
|
|
4684
|
+
} catch { /* ignore */ }
|
|
4685
|
+
}
|
|
4686
|
+
|
|
4616
4687
|
function networkRoot() {
|
|
4617
4688
|
return resolve(homedir(), '.groove', 'network');
|
|
4618
4689
|
}
|
|
@@ -4636,12 +4707,12 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4636
4707
|
// Defensive: only permit fs ops on paths that resolve inside ~/.groove/.
|
|
4637
4708
|
// Uses realpathSync when the path exists to defeat symlink escapes.
|
|
4638
4709
|
function isInsideGrooveHome(target) {
|
|
4639
|
-
const home = resolve(homedir(), '.groove') +
|
|
4710
|
+
const home = resolve(homedir(), '.groove') + sep;
|
|
4640
4711
|
const resolved = resolve(target);
|
|
4641
4712
|
let full;
|
|
4642
|
-
try { full = existsSync(resolved) ? realpathSync(resolved) +
|
|
4643
|
-
catch { full = resolved +
|
|
4644
|
-
const realHome = existsSync(home.slice(0, -1)) ? realpathSync(home.slice(0, -1)) +
|
|
4713
|
+
try { full = existsSync(resolved) ? realpathSync(resolved) + sep : resolved + sep; }
|
|
4714
|
+
catch { full = resolved + sep; }
|
|
4715
|
+
const realHome = existsSync(home.slice(0, -1)) ? realpathSync(home.slice(0, -1)) + sep : home;
|
|
4645
4716
|
return full.startsWith(realHome);
|
|
4646
4717
|
}
|
|
4647
4718
|
|
|
@@ -4701,7 +4772,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4701
4772
|
};
|
|
4702
4773
|
|
|
4703
4774
|
try {
|
|
4704
|
-
const pat = daemon.credentials?.getKey?.('github-pat') || null;
|
|
4775
|
+
const pat = daemon.credentials?.getKey?.('github') || daemon.credentials?.getKey?.('github-pat') || null;
|
|
4705
4776
|
|
|
4706
4777
|
let installVersion;
|
|
4707
4778
|
try {
|
|
@@ -4712,6 +4783,14 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4712
4783
|
|
|
4713
4784
|
broadcastInstallProgress('cloning', `Cloning network package ${installVersion}...`, 0);
|
|
4714
4785
|
|
|
4786
|
+
// Pre-flight: verify git is installed before attempting clone.
|
|
4787
|
+
const gitInstalled = await new Promise((resolveGit) => {
|
|
4788
|
+
execFile('git', ['--version'], { timeout: 5000 }, (err) => resolveGit(!err));
|
|
4789
|
+
});
|
|
4790
|
+
if (!gitInstalled) {
|
|
4791
|
+
return fail('Git is not installed. Install Git from https://git-scm.com and restart Groove.');
|
|
4792
|
+
}
|
|
4793
|
+
|
|
4715
4794
|
const cloneArgs = ['clone', '--branch', installVersion, '--depth', '1', NETWORK_REPO_URL, installPath];
|
|
4716
4795
|
const cloneEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
|
|
4717
4796
|
if (pat) {
|
|
@@ -4741,18 +4820,30 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4741
4820
|
});
|
|
4742
4821
|
|
|
4743
4822
|
if (cloneCode.code !== 0) {
|
|
4744
|
-
|
|
4823
|
+
let hint;
|
|
4824
|
+
const errMsg = cloneCode.err || '';
|
|
4825
|
+
const lastLine = cloneErr.trim().split('\n').slice(-1)[0] || '';
|
|
4826
|
+
if (errMsg.includes('ENOENT')) {
|
|
4827
|
+
hint = 'Git is not installed. Install Git from https://git-scm.com and restart Groove.';
|
|
4828
|
+
} else if (/Authentication failed|could not read Username/i.test(cloneErr)) {
|
|
4829
|
+
hint = 'Authentication failed — run "groove set-key github-pat <token>" to set a GitHub PAT.';
|
|
4830
|
+
} else if (/not found/i.test(cloneErr)) {
|
|
4831
|
+
hint = `Repository or tag not found (${installVersion}). Check NETWORK_REPO_URL and tag.`;
|
|
4832
|
+
} else {
|
|
4833
|
+
hint = stripCredentials(lastLine || errMsg || 'git clone failed');
|
|
4834
|
+
}
|
|
4745
4835
|
return fail(`Clone failed: ${hint}`);
|
|
4746
4836
|
}
|
|
4747
4837
|
|
|
4748
4838
|
broadcastInstallProgress('cloned', 'Repository cloned', 10);
|
|
4749
4839
|
|
|
4750
4840
|
// Run setup.sh --json from the install directory
|
|
4751
|
-
|
|
4752
|
-
|
|
4753
|
-
|
|
4754
|
-
|
|
4755
|
-
|
|
4841
|
+
let setup;
|
|
4842
|
+
try {
|
|
4843
|
+
setup = spawnSetupSh(installPath);
|
|
4844
|
+
} catch (spawnErr) {
|
|
4845
|
+
return fail(`Setup failed: ${spawnErr.message}`);
|
|
4846
|
+
}
|
|
4756
4847
|
|
|
4757
4848
|
daemon.networkInstall.proc = setup;
|
|
4758
4849
|
|
|
@@ -4786,7 +4877,12 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4786
4877
|
});
|
|
4787
4878
|
|
|
4788
4879
|
if (setupResult.code !== 0) {
|
|
4789
|
-
|
|
4880
|
+
let hint;
|
|
4881
|
+
if (setupResult.code === -1 || setupResult.err?.includes('ENOENT')) {
|
|
4882
|
+
hint = 'bash not found — ensure Git for Windows is installed from https://git-scm.com';
|
|
4883
|
+
} else {
|
|
4884
|
+
hint = stderrBuf.trim().split('\n').slice(-1)[0] || `setup.sh exited ${setupResult.code}`;
|
|
4885
|
+
}
|
|
4790
4886
|
return fail(`Setup failed: ${hint}`);
|
|
4791
4887
|
}
|
|
4792
4888
|
|
|
@@ -4816,7 +4912,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4816
4912
|
try {
|
|
4817
4913
|
const node = daemon.networkNode;
|
|
4818
4914
|
if (node?.active && node.proc && !node.proc.killed) {
|
|
4819
|
-
|
|
4915
|
+
safeKill(node.proc);
|
|
4820
4916
|
daemon.networkNode.status = 'stopping';
|
|
4821
4917
|
pushNodeEvent('stopping', { pid: node.pid, reason: 'uninstall' });
|
|
4822
4918
|
broadcastNodeStatus();
|
|
@@ -4860,17 +4956,22 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4860
4956
|
// surface that. Uses spawn with array args — no shell interpolation.
|
|
4861
4957
|
function fetchLatestNetworkTag() {
|
|
4862
4958
|
return new Promise((resolvePromise) => {
|
|
4959
|
+
const tagEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
|
|
4960
|
+
const tagPat = daemon.credentials?.getKey?.('github') || daemon.credentials?.getKey?.('github-pat') || null;
|
|
4961
|
+
if (tagPat) {
|
|
4962
|
+
tagEnv.GIT_CONFIG_COUNT = '1';
|
|
4963
|
+
tagEnv.GIT_CONFIG_KEY_0 = 'http.extraHeader';
|
|
4964
|
+
tagEnv.GIT_CONFIG_VALUE_0 = `Authorization: token ${tagPat}`;
|
|
4965
|
+
}
|
|
4863
4966
|
const proc = spawn('git', ['ls-remote', '--tags', NETWORK_REPO_URL], {
|
|
4864
4967
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
4865
|
-
env:
|
|
4968
|
+
env: tagEnv,
|
|
4866
4969
|
});
|
|
4867
4970
|
let stdout = '';
|
|
4868
4971
|
let stderr = '';
|
|
4869
4972
|
proc.stdout.on('data', (c) => { stdout += c.toString(); });
|
|
4870
4973
|
proc.stderr.on('data', (c) => { stderr += c.toString(); });
|
|
4871
|
-
const timeout = setTimeout(() => {
|
|
4872
|
-
try { proc.kill('SIGTERM'); } catch { /* ignore */ }
|
|
4873
|
-
}, 10_000);
|
|
4974
|
+
const timeout = setTimeout(() => { safeKill(proc, 'SIGTERM'); }, 10_000);
|
|
4874
4975
|
proc.on('error', () => { clearTimeout(timeout); resolvePromise(null); });
|
|
4875
4976
|
proc.on('close', (code) => {
|
|
4876
4977
|
clearTimeout(timeout);
|
|
@@ -4957,7 +5058,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4957
5058
|
try {
|
|
4958
5059
|
const node = daemon.networkNode;
|
|
4959
5060
|
if (node?.active && node.proc && !node.proc.killed) {
|
|
4960
|
-
|
|
5061
|
+
safeKill(node.proc);
|
|
4961
5062
|
daemon.networkNode.status = 'stopping';
|
|
4962
5063
|
pushNodeEvent('stopping', { pid: node.pid, reason: 'update' });
|
|
4963
5064
|
broadcastNodeStatus();
|
|
@@ -5002,11 +5103,12 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
5002
5103
|
|
|
5003
5104
|
broadcastUpdateProgress('deps', 'Updating dependencies...', 30);
|
|
5004
5105
|
|
|
5005
|
-
|
|
5006
|
-
|
|
5007
|
-
|
|
5008
|
-
|
|
5009
|
-
|
|
5106
|
+
let setup;
|
|
5107
|
+
try {
|
|
5108
|
+
setup = spawnSetupSh(installPath);
|
|
5109
|
+
} catch (spawnErr) {
|
|
5110
|
+
return fail(`Setup failed: ${spawnErr.message}`);
|
|
5111
|
+
}
|
|
5010
5112
|
|
|
5011
5113
|
daemon.networkInstall.proc = setup;
|
|
5012
5114
|
|
|
@@ -5037,7 +5139,12 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
5037
5139
|
});
|
|
5038
5140
|
|
|
5039
5141
|
if (setupResult.code !== 0) {
|
|
5040
|
-
|
|
5142
|
+
let hint;
|
|
5143
|
+
if (setupResult.code === -1 || setupResult.err?.includes('ENOENT')) {
|
|
5144
|
+
hint = 'bash not found — ensure Git for Windows is installed from https://git-scm.com';
|
|
5145
|
+
} else {
|
|
5146
|
+
hint = stderrBuf.trim().split('\n').slice(-1)[0] || `setup.sh exited ${setupResult.code}`;
|
|
5147
|
+
}
|
|
5041
5148
|
return fail(`Setup failed: ${hint}`);
|
|
5042
5149
|
}
|
|
5043
5150
|
|
|
@@ -200,6 +200,17 @@ export class ConversationManager {
|
|
|
200
200
|
this.daemon.broadcast({ type: 'conversation:updated', data: conv });
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
updateModel(id, provider, model) {
|
|
204
|
+
const conv = this.conversations.get(id);
|
|
205
|
+
if (!conv) throw new Error('Conversation not found');
|
|
206
|
+
conv.provider = provider;
|
|
207
|
+
conv.model = model;
|
|
208
|
+
conv.updatedAt = new Date().toISOString();
|
|
209
|
+
this._save();
|
|
210
|
+
this.daemon.broadcast({ type: 'conversation:updated', data: conv });
|
|
211
|
+
return conv;
|
|
212
|
+
}
|
|
213
|
+
|
|
203
214
|
async setMode(id, mode) {
|
|
204
215
|
const conv = this.conversations.get(id);
|
|
205
216
|
if (!conv) throw new Error('Conversation not found');
|
|
@@ -259,35 +270,90 @@ export class ConversationManager {
|
|
|
259
270
|
|
|
260
271
|
_killStreamingProcess(conversationId) {
|
|
261
272
|
const procs = this._getStreamingProcesses();
|
|
262
|
-
const
|
|
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
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
|
|
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
|
}
|