groove-dev 0.27.35 → 0.27.37

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.35",
3
+ "version": "0.27.37",
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.35",
3
+ "version": "0.27.37",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -2206,10 +2206,19 @@ Keep responses concise. Help them think, don't lecture them about the system the
2206
2206
  const entries = [];
2207
2207
 
2208
2208
  // Dirs first (sorted), then files (sorted)
2209
- const dirs = raw.filter((e) => e.isDirectory() && !IGNORED_NAMES.has(e.name) && !e.name.startsWith('.'))
2210
- .sort((a, b) => a.name.localeCompare(b.name));
2211
- const files = raw.filter((e) => e.isFile() && !e.name.startsWith('.'))
2209
+ const isDir = (e) => {
2210
+ if (e.isDirectory()) return true;
2211
+ if (e.isSymbolicLink()) { try { return statSync(resolve(fullPath, e.name)).isDirectory(); } catch { return false; } }
2212
+ return false;
2213
+ };
2214
+ const dirs = raw.filter((e) => isDir(e) && !IGNORED_NAMES.has(e.name) && !e.name.startsWith('.'))
2212
2215
  .sort((a, b) => a.name.localeCompare(b.name));
2216
+ const files = raw.filter((e) => {
2217
+ if (e.name.startsWith('.')) return false;
2218
+ if (e.isFile()) return true;
2219
+ if (e.isSymbolicLink()) { try { return statSync(resolve(fullPath, e.name)).isFile(); } catch { return false; } }
2220
+ return false;
2221
+ }).sort((a, b) => a.name.localeCompare(b.name));
2213
2222
 
2214
2223
  for (const d of dirs) {
2215
2224
  const childPath = relPath ? `${relPath}/${d.name}` : d.name;
@@ -16,6 +16,8 @@ const COMPACTION_DROP_THRESHOLD = 0.25; // 25% drop from peak = natural compacti
16
16
  const COMPACTION_MIN_PEAK = 0.15; // Ignore compaction if peak was below 15%
17
17
  const MAX_BUFFER_SIZE = 1_048_576; // 1MB — discard oldest half if exceeded
18
18
  const STREAM_THROTTLE_MS = 250; // 4 broadcasts/sec per agent
19
+ const STALL_CHECK_INTERVAL_MS = 60_000; // Poll for stuck agents every 60s
20
+ const STALL_THRESHOLD_MS = 5 * 60_000; // 5 min of silence on a live PID = stalled stream
19
21
 
20
22
  // Role-specific prompt prefixes — applied during spawn regardless of entry point
21
23
  // (SpawnPanel, chat continue, CLI, API) for consistency
@@ -297,7 +299,44 @@ export class ProcessManager {
297
299
  this.pendingMessages = new Map(); // agentId -> { message, timestamp }
298
300
  this._streamThrottle = new Map(); // agentId -> { timer, pending }
299
301
  this._rotatingAgents = new Set(); // agentIds currently being rotated (rotator wrote handoff)
302
+ this._stalledAgents = new Set(); // agentIds already flagged as stalled (avoids duplicate broadcasts)
300
303
 
304
+ this._stallWatchdog = setInterval(() => this._checkStalls(), STALL_CHECK_INTERVAL_MS);
305
+ if (this._stallWatchdog.unref) this._stallWatchdog.unref();
306
+ }
307
+
308
+ _checkStalls() {
309
+ const { registry } = this.daemon;
310
+ const now = Date.now();
311
+ for (const agentId of this.handles.keys()) {
312
+ const agent = registry.get(agentId);
313
+ if (!agent || agent.status !== 'running') continue;
314
+ const lastActivity = agent.lastActivity ? new Date(agent.lastActivity).getTime() : now;
315
+ const silentMs = now - lastActivity;
316
+ if (silentMs < STALL_THRESHOLD_MS) {
317
+ if (this._stalledAgents.has(agentId)) {
318
+ this._stalledAgents.delete(agentId);
319
+ registry.update(agentId, { stalled: false });
320
+ }
321
+ continue;
322
+ }
323
+ if (this._stalledAgents.has(agentId)) continue;
324
+ this._stalledAgents.add(agentId);
325
+ registry.update(agentId, { stalled: true, stalledSince: new Date(lastActivity).toISOString() });
326
+ if (this.daemon.timeline) {
327
+ this.daemon.timeline.recordEvent('stall', {
328
+ agentId, agentName: agent.name, role: agent.role, silentMs,
329
+ });
330
+ }
331
+ this.daemon.broadcast({
332
+ type: 'agent:stalled',
333
+ agentId,
334
+ agentName: agent.name,
335
+ silentMs,
336
+ lastActivity: agent.lastActivity,
337
+ });
338
+ console.warn(`[Groove] Agent ${agent.name} (${agentId}) silent for ${Math.round(silentMs / 1000)}s — possible stalled API stream`);
339
+ }
301
340
  }
302
341
 
303
342
  async spawn(config) {
@@ -746,6 +785,7 @@ For normal file edits within your scope, proceed without review.
746
785
  // Clean up per-agent maps to prevent unbounded growth in long sessions
747
786
  this.peakContextUsage.delete(agent.id);
748
787
  this.pendingMessages.delete(agent.id);
788
+ this._stalledAgents.delete(agent.id);
749
789
 
750
790
  // Release file-scope locks so they don't persist after agent death
751
791
  if (this.daemon.locks) this.daemon.locks.release(agent.id);
@@ -917,6 +957,12 @@ For normal file edits within your scope, proceed without review.
917
957
 
918
958
  const updates = { lastActivity: new Date().toISOString() };
919
959
 
960
+ // Clear stall flag — output means the stream is alive again
961
+ if (this._stalledAgents.has(agentId)) {
962
+ this._stalledAgents.delete(agentId);
963
+ updates.stalled = false;
964
+ }
965
+
920
966
  // Token tracking — feed subsystems with full breakdown
921
967
  if (output.tokensUsed !== undefined && output.tokensUsed > 0) {
922
968
  updates.tokensUsed = agent.tokensUsed + output.tokensUsed;
@@ -1398,6 +1444,7 @@ For normal file edits within your scope, proceed without review.
1398
1444
  logStream.write(`[${new Date().toISOString()}] Process exited: code=${code} signal=${signal}\n`);
1399
1445
  logStream.end();
1400
1446
  this.handles.delete(newAgent.id);
1447
+ this._stalledAgents.delete(newAgent.id);
1401
1448
  const finalStatus = signal === 'SIGTERM' || signal === 'SIGKILL' ? 'killed' : code === 0 ? 'completed' : 'crashed';
1402
1449
  registry.update(newAgent.id, { status: finalStatus, pid: null });
1403
1450
  this.daemon.broadcast({ type: 'agent:exit', agentId: newAgent.id, code, signal, status: finalStatus });
@@ -1410,6 +1457,7 @@ For normal file edits within your scope, proceed without review.
1410
1457
  logStream.write(`[error] ${err.message}\n`);
1411
1458
  logStream.end();
1412
1459
  this.handles.delete(newAgent.id);
1460
+ this._stalledAgents.delete(newAgent.id);
1413
1461
  registry.update(newAgent.id, { status: 'crashed', pid: null });
1414
1462
  });
1415
1463
 
@@ -1548,6 +1596,10 @@ For normal file edits within your scope, proceed without review.
1548
1596
  async killAll() {
1549
1597
  const ids = Array.from(this.handles.keys());
1550
1598
  await Promise.all(ids.map((id) => this.kill(id)));
1599
+ if (this._stallWatchdog) {
1600
+ clearInterval(this._stallWatchdog);
1601
+ this._stallWatchdog = null;
1602
+ }
1551
1603
  }
1552
1604
 
1553
1605
  isRunning(agentId) {
@@ -149,19 +149,22 @@ export class ClaudeCodeProvider extends Provider {
149
149
  const cacheReadTokens = usage?.cache_read_input_tokens || 0;
150
150
  const cacheCreationTokens = usage?.cache_creation_input_tokens || 0;
151
151
  const outputTokens = usage?.output_tokens || 0;
152
+ // tokensUsed = new processing tokens only (input + output). Cache reads are
153
+ // the same bytes re-read every turn and must NOT be accumulated — doing so
154
+ // inflated agent.tokensUsed ~50× and created the phantom "freeze at 1M".
155
+ // totalIn still drives contextUsage because cached bytes DO occupy context.
152
156
  const totalIn = inputTokens + cacheReadTokens + cacheCreationTokens;
153
157
  events.push({
154
158
  type: 'activity',
155
159
  subtype: 'assistant',
156
160
  data: data.message?.content || '',
157
- tokensUsed: totalIn + outputTokens,
161
+ tokensUsed: inputTokens + outputTokens,
158
162
  inputTokens,
159
163
  outputTokens,
160
164
  cacheReadTokens,
161
165
  cacheCreationTokens,
162
166
  model: data.message?.model,
163
167
  });
164
- // Compute context usage from assistant message usage
165
168
  if (totalIn > 0) {
166
169
  const modelId = data.message?.model || '';
167
170
  const modelMeta = ClaudeCodeProvider.models.find((m) => modelId.includes(m.id));
@@ -172,21 +175,12 @@ export class ClaudeCodeProvider extends Provider {
172
175
  });
173
176
  }
174
177
  } else if (data.type === 'result') {
175
- // Result has cumulative usage for the full session
176
- const usage = data.usage;
177
- const inputTokens = usage?.input_tokens || 0;
178
- const cacheReadTokens = usage?.cache_read_input_tokens || 0;
179
- const cacheCreationTokens = usage?.cache_creation_input_tokens || 0;
180
- const outputTokens = usage?.output_tokens || 0;
181
- const totalIn = inputTokens + cacheReadTokens + cacheCreationTokens;
178
+ // Result carries cumulative session usage per-turn counts were already
179
+ // accumulated from assistant events, so we do NOT emit tokensUsed here
180
+ // (that was the double-count). Only emit session-level metadata.
182
181
  events.push({
183
182
  type: 'result',
184
183
  data: data.result,
185
- tokensUsed: totalIn + outputTokens,
186
- inputTokens,
187
- outputTokens,
188
- cacheReadTokens,
189
- cacheCreationTokens,
190
184
  cost: data.total_cost_usd,
191
185
  duration: data.duration_ms,
192
186
  turns: data.num_turns,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.35",
3
+ "version": "0.27.37",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.27.35",
3
+ "version": "0.27.37",
4
4
  "description": "Open-source agent orchestration layer — the AI company OS. Local model agent engine (GGUF/Ollama/llama-server), HuggingFace model browser, MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama, any local model.",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.35",
3
+ "version": "0.27.37",
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.35",
3
+ "version": "0.27.37",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -2206,10 +2206,19 @@ Keep responses concise. Help them think, don't lecture them about the system the
2206
2206
  const entries = [];
2207
2207
 
2208
2208
  // Dirs first (sorted), then files (sorted)
2209
- const dirs = raw.filter((e) => e.isDirectory() && !IGNORED_NAMES.has(e.name) && !e.name.startsWith('.'))
2210
- .sort((a, b) => a.name.localeCompare(b.name));
2211
- const files = raw.filter((e) => e.isFile() && !e.name.startsWith('.'))
2209
+ const isDir = (e) => {
2210
+ if (e.isDirectory()) return true;
2211
+ if (e.isSymbolicLink()) { try { return statSync(resolve(fullPath, e.name)).isDirectory(); } catch { return false; } }
2212
+ return false;
2213
+ };
2214
+ const dirs = raw.filter((e) => isDir(e) && !IGNORED_NAMES.has(e.name) && !e.name.startsWith('.'))
2212
2215
  .sort((a, b) => a.name.localeCompare(b.name));
2216
+ const files = raw.filter((e) => {
2217
+ if (e.name.startsWith('.')) return false;
2218
+ if (e.isFile()) return true;
2219
+ if (e.isSymbolicLink()) { try { return statSync(resolve(fullPath, e.name)).isFile(); } catch { return false; } }
2220
+ return false;
2221
+ }).sort((a, b) => a.name.localeCompare(b.name));
2213
2222
 
2214
2223
  for (const d of dirs) {
2215
2224
  const childPath = relPath ? `${relPath}/${d.name}` : d.name;
@@ -16,6 +16,8 @@ const COMPACTION_DROP_THRESHOLD = 0.25; // 25% drop from peak = natural compacti
16
16
  const COMPACTION_MIN_PEAK = 0.15; // Ignore compaction if peak was below 15%
17
17
  const MAX_BUFFER_SIZE = 1_048_576; // 1MB — discard oldest half if exceeded
18
18
  const STREAM_THROTTLE_MS = 250; // 4 broadcasts/sec per agent
19
+ const STALL_CHECK_INTERVAL_MS = 60_000; // Poll for stuck agents every 60s
20
+ const STALL_THRESHOLD_MS = 5 * 60_000; // 5 min of silence on a live PID = stalled stream
19
21
 
20
22
  // Role-specific prompt prefixes — applied during spawn regardless of entry point
21
23
  // (SpawnPanel, chat continue, CLI, API) for consistency
@@ -297,7 +299,44 @@ export class ProcessManager {
297
299
  this.pendingMessages = new Map(); // agentId -> { message, timestamp }
298
300
  this._streamThrottle = new Map(); // agentId -> { timer, pending }
299
301
  this._rotatingAgents = new Set(); // agentIds currently being rotated (rotator wrote handoff)
302
+ this._stalledAgents = new Set(); // agentIds already flagged as stalled (avoids duplicate broadcasts)
300
303
 
304
+ this._stallWatchdog = setInterval(() => this._checkStalls(), STALL_CHECK_INTERVAL_MS);
305
+ if (this._stallWatchdog.unref) this._stallWatchdog.unref();
306
+ }
307
+
308
+ _checkStalls() {
309
+ const { registry } = this.daemon;
310
+ const now = Date.now();
311
+ for (const agentId of this.handles.keys()) {
312
+ const agent = registry.get(agentId);
313
+ if (!agent || agent.status !== 'running') continue;
314
+ const lastActivity = agent.lastActivity ? new Date(agent.lastActivity).getTime() : now;
315
+ const silentMs = now - lastActivity;
316
+ if (silentMs < STALL_THRESHOLD_MS) {
317
+ if (this._stalledAgents.has(agentId)) {
318
+ this._stalledAgents.delete(agentId);
319
+ registry.update(agentId, { stalled: false });
320
+ }
321
+ continue;
322
+ }
323
+ if (this._stalledAgents.has(agentId)) continue;
324
+ this._stalledAgents.add(agentId);
325
+ registry.update(agentId, { stalled: true, stalledSince: new Date(lastActivity).toISOString() });
326
+ if (this.daemon.timeline) {
327
+ this.daemon.timeline.recordEvent('stall', {
328
+ agentId, agentName: agent.name, role: agent.role, silentMs,
329
+ });
330
+ }
331
+ this.daemon.broadcast({
332
+ type: 'agent:stalled',
333
+ agentId,
334
+ agentName: agent.name,
335
+ silentMs,
336
+ lastActivity: agent.lastActivity,
337
+ });
338
+ console.warn(`[Groove] Agent ${agent.name} (${agentId}) silent for ${Math.round(silentMs / 1000)}s — possible stalled API stream`);
339
+ }
301
340
  }
302
341
 
303
342
  async spawn(config) {
@@ -746,6 +785,7 @@ For normal file edits within your scope, proceed without review.
746
785
  // Clean up per-agent maps to prevent unbounded growth in long sessions
747
786
  this.peakContextUsage.delete(agent.id);
748
787
  this.pendingMessages.delete(agent.id);
788
+ this._stalledAgents.delete(agent.id);
749
789
 
750
790
  // Release file-scope locks so they don't persist after agent death
751
791
  if (this.daemon.locks) this.daemon.locks.release(agent.id);
@@ -917,6 +957,12 @@ For normal file edits within your scope, proceed without review.
917
957
 
918
958
  const updates = { lastActivity: new Date().toISOString() };
919
959
 
960
+ // Clear stall flag — output means the stream is alive again
961
+ if (this._stalledAgents.has(agentId)) {
962
+ this._stalledAgents.delete(agentId);
963
+ updates.stalled = false;
964
+ }
965
+
920
966
  // Token tracking — feed subsystems with full breakdown
921
967
  if (output.tokensUsed !== undefined && output.tokensUsed > 0) {
922
968
  updates.tokensUsed = agent.tokensUsed + output.tokensUsed;
@@ -1398,6 +1444,7 @@ For normal file edits within your scope, proceed without review.
1398
1444
  logStream.write(`[${new Date().toISOString()}] Process exited: code=${code} signal=${signal}\n`);
1399
1445
  logStream.end();
1400
1446
  this.handles.delete(newAgent.id);
1447
+ this._stalledAgents.delete(newAgent.id);
1401
1448
  const finalStatus = signal === 'SIGTERM' || signal === 'SIGKILL' ? 'killed' : code === 0 ? 'completed' : 'crashed';
1402
1449
  registry.update(newAgent.id, { status: finalStatus, pid: null });
1403
1450
  this.daemon.broadcast({ type: 'agent:exit', agentId: newAgent.id, code, signal, status: finalStatus });
@@ -1410,6 +1457,7 @@ For normal file edits within your scope, proceed without review.
1410
1457
  logStream.write(`[error] ${err.message}\n`);
1411
1458
  logStream.end();
1412
1459
  this.handles.delete(newAgent.id);
1460
+ this._stalledAgents.delete(newAgent.id);
1413
1461
  registry.update(newAgent.id, { status: 'crashed', pid: null });
1414
1462
  });
1415
1463
 
@@ -1548,6 +1596,10 @@ For normal file edits within your scope, proceed without review.
1548
1596
  async killAll() {
1549
1597
  const ids = Array.from(this.handles.keys());
1550
1598
  await Promise.all(ids.map((id) => this.kill(id)));
1599
+ if (this._stallWatchdog) {
1600
+ clearInterval(this._stallWatchdog);
1601
+ this._stallWatchdog = null;
1602
+ }
1551
1603
  }
1552
1604
 
1553
1605
  isRunning(agentId) {
@@ -149,19 +149,22 @@ export class ClaudeCodeProvider extends Provider {
149
149
  const cacheReadTokens = usage?.cache_read_input_tokens || 0;
150
150
  const cacheCreationTokens = usage?.cache_creation_input_tokens || 0;
151
151
  const outputTokens = usage?.output_tokens || 0;
152
+ // tokensUsed = new processing tokens only (input + output). Cache reads are
153
+ // the same bytes re-read every turn and must NOT be accumulated — doing so
154
+ // inflated agent.tokensUsed ~50× and created the phantom "freeze at 1M".
155
+ // totalIn still drives contextUsage because cached bytes DO occupy context.
152
156
  const totalIn = inputTokens + cacheReadTokens + cacheCreationTokens;
153
157
  events.push({
154
158
  type: 'activity',
155
159
  subtype: 'assistant',
156
160
  data: data.message?.content || '',
157
- tokensUsed: totalIn + outputTokens,
161
+ tokensUsed: inputTokens + outputTokens,
158
162
  inputTokens,
159
163
  outputTokens,
160
164
  cacheReadTokens,
161
165
  cacheCreationTokens,
162
166
  model: data.message?.model,
163
167
  });
164
- // Compute context usage from assistant message usage
165
168
  if (totalIn > 0) {
166
169
  const modelId = data.message?.model || '';
167
170
  const modelMeta = ClaudeCodeProvider.models.find((m) => modelId.includes(m.id));
@@ -172,21 +175,12 @@ export class ClaudeCodeProvider extends Provider {
172
175
  });
173
176
  }
174
177
  } else if (data.type === 'result') {
175
- // Result has cumulative usage for the full session
176
- const usage = data.usage;
177
- const inputTokens = usage?.input_tokens || 0;
178
- const cacheReadTokens = usage?.cache_read_input_tokens || 0;
179
- const cacheCreationTokens = usage?.cache_creation_input_tokens || 0;
180
- const outputTokens = usage?.output_tokens || 0;
181
- const totalIn = inputTokens + cacheReadTokens + cacheCreationTokens;
178
+ // Result carries cumulative session usage per-turn counts were already
179
+ // accumulated from assistant events, so we do NOT emit tokensUsed here
180
+ // (that was the double-count). Only emit session-level metadata.
182
181
  events.push({
183
182
  type: 'result',
184
183
  data: data.result,
185
- tokensUsed: totalIn + outputTokens,
186
- inputTokens,
187
- outputTokens,
188
- cacheReadTokens,
189
- cacheCreationTokens,
190
184
  cost: data.total_cost_usd,
191
185
  duration: data.duration_ms,
192
186
  turns: data.num_turns,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.35",
3
+ "version": "0.27.37",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",