groove-dev 0.27.166 → 0.27.168

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 (34) 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/adaptive.js +37 -10
  4. package/node_modules/@groove-dev/daemon/src/classifier.js +3 -1
  5. package/node_modules/@groove-dev/daemon/src/index.js +10 -9
  6. package/node_modules/@groove-dev/daemon/src/memory.js +9 -1
  7. package/node_modules/@groove-dev/daemon/src/process.js +14 -5
  8. package/node_modules/@groove-dev/daemon/src/registry.js +18 -3
  9. package/node_modules/@groove-dev/daemon/src/rotator.js +81 -10
  10. package/node_modules/@groove-dev/daemon/src/routes/agents.js +38 -25
  11. package/node_modules/@groove-dev/daemon/test/rotator.test.js +1 -1
  12. package/node_modules/@groove-dev/gui/dist/assets/{index-B_kpnfOu.js → index-CSMIQsrG.js} +49 -49
  13. package/node_modules/@groove-dev/gui/dist/index.html +1 -1
  14. package/node_modules/@groove-dev/gui/package.json +1 -1
  15. package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +1 -1
  16. package/node_modules/@groove-dev/gui/src/stores/groove.js +0 -5
  17. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +9 -0
  18. package/package.json +1 -1
  19. package/packages/cli/package.json +1 -1
  20. package/packages/daemon/package.json +1 -1
  21. package/packages/daemon/src/adaptive.js +37 -10
  22. package/packages/daemon/src/classifier.js +3 -1
  23. package/packages/daemon/src/index.js +10 -9
  24. package/packages/daemon/src/memory.js +9 -1
  25. package/packages/daemon/src/process.js +14 -5
  26. package/packages/daemon/src/registry.js +18 -3
  27. package/packages/daemon/src/rotator.js +81 -10
  28. package/packages/daemon/src/routes/agents.js +38 -25
  29. package/packages/gui/dist/assets/{index-B_kpnfOu.js → index-CSMIQsrG.js} +49 -49
  30. package/packages/gui/dist/index.html +1 -1
  31. package/packages/gui/package.json +1 -1
  32. package/packages/gui/src/components/agents/agent-panel.jsx +1 -1
  33. package/packages/gui/src/stores/groove.js +0 -5
  34. package/packages/gui/src/stores/slices/agents-slice.js +9 -0
@@ -6,7 +6,7 @@
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <link rel="icon" type="image/png" href="/favicon.png" />
8
8
  <title>Groove GUI</title>
9
- <script type="module" crossorigin src="/assets/index-B_kpnfOu.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-CSMIQsrG.js"></script>
10
10
  <link rel="modulepreload" crossorigin href="/assets/vendor-26L3JoZv.js">
11
11
  <link rel="modulepreload" crossorigin href="/assets/reactflow-DoBZjiHE.js">
12
12
  <link rel="modulepreload" crossorigin href="/assets/codemirror-BYKpdS2W.js">
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.166",
3
+ "version": "0.27.168",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -88,7 +88,7 @@ export function AgentPanel() {
88
88
  const agentId = detailPanel?.type === 'agent' ? detailPanel.agentId : null;
89
89
  const liveAgent = agentId ? agents.find((a) => a.id === agentId) : null;
90
90
  if (liveAgent) cachedAgentRef.current = liveAgent;
91
- else if (cachedAgentRef.current && cachedAgentRef.current.id !== agentId) cachedAgentRef.current = null;
91
+ else if (cachedAgentRef.current && agentId && cachedAgentRef.current.id !== agentId) cachedAgentRef.current = null;
92
92
  const agent = liveAgent || cachedAgentRef.current;
93
93
  const isAlive = liveAgent?.status === 'running' || liveAgent?.status === 'starting';
94
94
 
@@ -199,11 +199,6 @@ export const useGrooveStore = create((set, get) => ({
199
199
  }
200
200
  for (const id of removed) delete timeline[id];
201
201
  const updates = { agents, tokenTimeline: timeline, hydrated: true };
202
- if (removed.length > 0) {
203
- if (st.detailPanel?.type === 'agent' && removed.includes(st.detailPanel.agentId)) {
204
- updates.detailPanel = null;
205
- }
206
- }
207
202
  set(updates);
208
203
  break;
209
204
  }
@@ -66,6 +66,15 @@ export const createAgentsSlice = (set, get) => ({
66
66
  async killAgent(id, purge = false) {
67
67
  try {
68
68
  await api.delete(`/agents/${encodeURIComponent(id)}?purge=${purge}`);
69
+ const dp = get().detailPanel;
70
+ if (dp?.type === 'agent' && dp.agentId === id) {
71
+ const tid = get().activeTeamId;
72
+ set({ detailPanel: null, teamDetailPanels: { ...get().teamDetailPanels, [tid]: null } });
73
+ }
74
+ const sel = get().fleetSelectedAgents;
75
+ if (sel[0] === id || sel[1] === id) {
76
+ set({ fleetSelectedAgents: [sel[0] === id ? null : sel[0], sel[1] === id ? null : sel[1]] });
77
+ }
69
78
  if (purge) {
70
79
  set((s) => {
71
80
  const chatHistory = { ...s.chatHistory };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.27.166",
3
+ "version": "0.27.168",
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.166",
3
+ "version": "0.27.168",
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.166",
3
+ "version": "0.27.168",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -110,13 +110,43 @@ export class AdaptiveThresholds {
110
110
  const selfManages = options.selfManagesContext ?? false;
111
111
  let score = 70; // Baseline: decent session
112
112
 
113
- // Error rate: -5 per error normally, -2 for self-managing (errors during debugging are expected)
113
+ // --- Error-related penalties ---
114
+ // For self-managing providers, errorCount/errorTrend/repetitions are often
115
+ // correlated: running scripts that produce error output triggers all three.
116
+ // Cap the combined penalty so one root cause can't stack across signals.
114
117
  const errorCount = signals.errorCount || 0;
115
- score -= errorCount * (selfManages ? 2 : 5);
116
-
117
- // Repetitions: 3+ Write/Edit to the same file in a sliding window
118
+ const toolOutputErrors = signals.toolOutputErrors || 0;
118
119
  const repetitions = signals.repetitions || 0;
119
- score -= Math.min(repetitions * 6, 30);
120
+ const errorTrend = signals.errorTrend || 0;
121
+
122
+ let errorPenalty, repetitionPenalty, errorTrendPenalty;
123
+
124
+ if (selfManages) {
125
+ // Tool output errors (is_error on tool results from scripts/tests) get
126
+ // a reduced rate — they're normal workflow, not agent failures
127
+ const agentErrors = errorCount - toolOutputErrors;
128
+ errorPenalty = (Math.max(agentErrors, 0) * 2) + (toolOutputErrors * 1);
129
+ repetitionPenalty = Math.min(repetitions * 6, 30);
130
+ errorTrendPenalty = errorTrend > 0 ? errorTrend * 3 : 0;
131
+
132
+ // Cap: all error-related signals combined max -35 for self-managing
133
+ const totalErrorRelated = errorPenalty + repetitionPenalty + errorTrendPenalty;
134
+ if (totalErrorRelated > 35) {
135
+ const scale = 35 / totalErrorRelated;
136
+ errorPenalty = Math.round(errorPenalty * scale);
137
+ repetitionPenalty = Math.round(repetitionPenalty * scale);
138
+ errorTrendPenalty = Math.round(errorTrendPenalty * scale);
139
+ }
140
+ } else {
141
+ errorPenalty = errorCount * 5;
142
+ repetitionPenalty = Math.min(repetitions * 6, 30);
143
+ errorTrendPenalty = errorTrend > 0 ? errorTrend * 6 : 0;
144
+ }
145
+
146
+ score -= errorPenalty;
147
+ score -= repetitionPenalty;
148
+ score -= errorTrendPenalty;
149
+ if (errorTrend < 0) score += 3; // Errors decreasing = good
120
150
 
121
151
  // Out-of-scope access: each violation costs 10 points
122
152
  const scopeViolations = signals.scopeViolations || 0;
@@ -134,11 +164,6 @@ export class AdaptiveThresholds {
134
164
  const fileChurn = signals.fileChurn || 0;
135
165
  score -= fileChurn * 10;
136
166
 
137
- // Error trend: increasing errors in second half = degradation
138
- const errorTrend = signals.errorTrend || 0;
139
- if (errorTrend > 0) score -= errorTrend * (selfManages ? 3 : 6);
140
- if (errorTrend < 0) score += 3;
141
-
142
167
  // Files written: productivity signal
143
168
  const filesWritten = signals.filesWritten || 0;
144
169
  score += Math.min(filesWritten * 2, 10); // Cap at +10
@@ -182,6 +207,7 @@ export class AdaptiveThresholds {
182
207
  extractSignals(entries, agentScope) {
183
208
  const signals = {
184
209
  errorCount: 0,
210
+ toolOutputErrors: 0, // is_error tool results (script output, test failures — not agent errors)
185
211
  repetitions: 0,
186
212
  scopeViolations: 0,
187
213
  toolCalls: 0,
@@ -208,6 +234,7 @@ export class AdaptiveThresholds {
208
234
 
209
235
  if (entry.type === 'error') {
210
236
  signals.errorCount++;
237
+ if (entry.source === 'tool_output') signals.toolOutputErrors++;
211
238
  }
212
239
 
213
240
  // Track assistant output lengths for decay detection
@@ -63,7 +63,9 @@ export class TaskClassifier {
63
63
  }
64
64
  if (block.type === 'tool_result') {
65
65
  if (block.is_error) {
66
- window.push({ type: 'error', timestamp: Date.now() });
66
+ // Tag source so the quality scorer can distinguish tool output errors
67
+ // (script failures, test output) from agent behavioral errors
68
+ window.push({ type: 'error', source: 'tool_output', timestamp: Date.now() });
67
69
  extracted = true;
68
70
  }
69
71
  }
@@ -312,16 +312,17 @@ export class Daemon {
312
312
 
313
313
  // Single unified registry change listener (broadcast + file I/O + coordination)
314
314
  this.registry.on('change', (delta) => {
315
- if (delta && delta.removed) {
316
- this.broadcast({ type: 'state:delta', data: { changed: [], removed: delta.removed } });
317
- } else if (delta && delta.changed) {
318
- const changedAgents = delta.changed
319
- .map((id) => this.registry.get(id))
320
- .filter(Boolean);
321
- this.broadcast({ type: 'state:delta', data: { changed: enrichAgents(changedAgents), removed: [] } });
322
- } else {
323
- // Fallback: full state broadcast
315
+ if (!delta || (!delta.removed && !delta.changed)) {
324
316
  this.broadcast({ type: 'state', data: enrichAgents(this.registry.getAll()) });
317
+ } else {
318
+ const data = { changed: [], removed: [] };
319
+ if (delta.removed?.length) data.removed = delta.removed;
320
+ if (delta.changed?.length) {
321
+ data.changed = enrichAgents(delta.changed.map((id) => this.registry.get(id)).filter(Boolean));
322
+ }
323
+ if (data.changed.length > 0 || data.removed.length > 0) {
324
+ this.broadcast({ type: 'state:delta', data });
325
+ }
325
326
  }
326
327
  _debouncedRegistryIo();
327
328
  this.teams.onAgentChange();
@@ -206,6 +206,14 @@ export class MemoryStore {
206
206
 
207
207
  const nextN = (chain[0]?.rotationN || 0) + 1;
208
208
 
209
+ // Strip sections that embed previous chain content to prevent recursive
210
+ // nesting — each chain entry should contain only its OWN brief, not copies
211
+ // of all prior briefs. The "Rotation History" and "Known Issues" sections
212
+ // are the primary vectors for this duplication.
213
+ let briefText = entry.brief || '(no brief)';
214
+ briefText = briefText.replace(/## Rotation History[\s\S]*?(?=## [A-Z]|$)/, '').trim();
215
+ briefText = briefText.replace(/## Known Issues & Fixes[\s\S]*?(?=## [A-Z]|$)/, '').trim();
216
+
209
217
  const block = [
210
218
  `## Rotation ${nextN} — ${entry.timestamp || new Date().toISOString()} (${entry.agentId || '?'} → ${entry.newAgentId || '?'})`,
211
219
  `**Reason:** ${entry.reason || 'unknown'}`,
@@ -213,7 +221,7 @@ export class MemoryStore {
213
221
  entry.contextUsage != null ? `**Context at rotation:** ${Math.round(entry.contextUsage * 100)}%` : '',
214
222
  '',
215
223
  '**Brief summary:**',
216
- truncate(entry.brief || '(no brief)', HANDOFF_BRIEF_MAX_CHARS),
224
+ truncate(briefText, HANDOFF_BRIEF_MAX_CHARS),
217
225
  '',
218
226
  ].filter(Boolean).join('\n');
219
227
 
@@ -1975,7 +1975,7 @@ For normal file edits within your scope, proceed without review.
1975
1975
 
1976
1976
  if (existing && (existing.status === 'completed' || existing.status === 'stopped' || existing.status === 'crashed' || existing.status === 'killed')) {
1977
1977
  config.name = existing.name;
1978
- registry.remove(existing.id);
1978
+ registry.remove(existing.id, { silent: true });
1979
1979
  this.daemon.locks.release(existing.id);
1980
1980
  }
1981
1981
 
@@ -2516,11 +2516,13 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
2516
2516
  const config = { ...agent };
2517
2517
  const sessionId = agent.sessionId;
2518
2518
 
2519
- // Stop if running, then remove old entry so we can re-register with same name
2519
+ // Stop if running, then remove old entry so we can re-register with same name.
2520
+ // Silent removal: the add() below will flush both removed+changed in one broadcast,
2521
+ // preventing the GUI from seeing an empty agents list between remove and re-add.
2520
2522
  if (this.handles.has(agentId)) {
2521
2523
  await this.kill(agentId);
2522
2524
  }
2523
- registry.remove(agentId);
2525
+ registry.remove(agentId, { silent: true });
2524
2526
  locks.release(agentId);
2525
2527
 
2526
2528
  // Build resume command
@@ -2648,7 +2650,7 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
2648
2650
  if (this.handles.has(agentId)) {
2649
2651
  await this.kill(agentId);
2650
2652
  }
2651
- registry.remove(agentId);
2653
+ registry.remove(agentId, { silent: true });
2652
2654
  locks.release(agentId);
2653
2655
 
2654
2656
  const newAgent = await this.spawn({
@@ -2698,7 +2700,7 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
2698
2700
  if (this.handles.has(agentId)) {
2699
2701
  await this.kill(agentId);
2700
2702
  }
2701
- registry.remove(agentId);
2703
+ registry.remove(agentId, { silent: true });
2702
2704
  locks.release(agentId);
2703
2705
 
2704
2706
  const newAgent = registry.add({
@@ -3005,6 +3007,10 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
3005
3007
  const agent = this.daemon.registry.get(agentId);
3006
3008
  const wrapped = agent ? wrapWithRoleReminder(agent.role, message) : message;
3007
3009
 
3010
+ if (source === 'user' && this.daemon.rotator) {
3011
+ this.daemon.rotator.recordUserMessage(agentId);
3012
+ }
3013
+
3008
3014
  if (this.daemon.trajectoryCapture) {
3009
3015
  try { this.daemon.trajectoryCapture.onUserMessage(agentId, message, source); } catch (e) { /* fail silent */ }
3010
3016
  }
@@ -3025,6 +3031,9 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
3025
3031
  const agent = this.daemon.registry.get(agentId);
3026
3032
  const wrapped = agent ? wrapWithRoleReminder(agent.role, message) : message;
3027
3033
  this.pendingMessages.set(agentId, { message: wrapped, timestamp: Date.now() });
3034
+ if (this.daemon.rotator) {
3035
+ this.daemon.rotator.recordUserMessage(agentId);
3036
+ }
3028
3037
  this.daemon.broadcast({ type: 'agent:message_queued', agentId, message: wrapped });
3029
3038
  }
3030
3039
 
@@ -12,6 +12,7 @@ export class Registry extends EventEmitter {
12
12
  this.state = state;
13
13
  this.agents = new Map();
14
14
  this._counters = new Map();
15
+ this._pendingRemovals = [];
15
16
  this._initCounters();
16
17
  }
17
18
 
@@ -62,7 +63,10 @@ export class Registry extends EventEmitter {
62
63
  };
63
64
 
64
65
  this.agents.set(agent.id, agent);
65
- this.emit('change', { changed: [agent.id] });
66
+ const removed = this._pendingRemovals.splice(0);
67
+ const delta = { changed: [agent.id] };
68
+ if (removed.length > 0) delta.removed = removed;
69
+ this.emit('change', delta);
66
70
  return agent;
67
71
  }
68
72
 
@@ -89,15 +93,26 @@ export class Registry extends EventEmitter {
89
93
  return agent;
90
94
  }
91
95
 
92
- remove(id) {
96
+ remove(id, { silent = false } = {}) {
93
97
  const agent = this.agents.get(id);
94
98
  if (!agent) return false;
95
99
 
96
100
  this.agents.delete(id);
97
- this.emit('change', { removed: [id] });
101
+ if (silent) {
102
+ this._pendingRemovals.push(id);
103
+ } else {
104
+ this.emit('change', { removed: [id] });
105
+ }
98
106
  return true;
99
107
  }
100
108
 
109
+ flushPendingRemovals() {
110
+ const removed = this._pendingRemovals.splice(0);
111
+ if (removed.length > 0) {
112
+ this.emit('change', { removed });
113
+ }
114
+ }
115
+
101
116
  findByRole(role) {
102
117
  return this.getAll().filter((a) => a.role === role);
103
118
  }
@@ -35,6 +35,7 @@ export class Rotator extends EventEmitter {
35
35
  this.lastRotationTime = new Map(); // agentId -> timestamp of last rotation
36
36
  this._lastContextState = new Map(); // agentId -> { contextUsage, timestamp }
37
37
  this.compactionCounts = new Map(); // agentId -> number of natural compactions
38
+ this.userMessageTimes = new Map(); // agentId -> timestamp of last user message
38
39
  this.enabled = false;
39
40
  this.liveScores = {};
40
41
  this.scoreHistory = {};
@@ -118,6 +119,10 @@ export class Rotator extends EventEmitter {
118
119
  this.enabled = false;
119
120
  }
120
121
 
122
+ recordUserMessage(agentId) {
123
+ this.userMessageTimes.set(agentId, Date.now());
124
+ }
125
+
121
126
  _isOnCooldown(agentId, cooldownMs = COOLDOWN_MS) {
122
127
  const lastTime = this.lastRotationTime.get(agentId);
123
128
  if (!lastTime) return false;
@@ -284,14 +289,13 @@ export class Rotator extends EventEmitter {
284
289
  if (this._isOnCooldown(agent.id, QUALITY_COOLDOWN_MS)) continue;
285
290
 
286
291
  // Effective quality threshold depends on provider type.
287
- // Self-managing providers (Claude Code): threshold = 15. Only rotate on
288
- // truly catastrophic degradation. Normal debugging naturally produces
289
- // errors, retries, and bash repetitions — the scoring model treats these
290
- // as degradation but they're expected behavior during investigation.
291
- // A threshold of 40 (the default) kills debugging sessions at ~8 minutes.
292
+ // Self-managing providers (Claude Code): threshold = 25. Only rotate on
293
+ // severe degradation. Normal debugging naturally produces errors, retries,
294
+ // and bash repetitions — the scoring model treats these as degradation
295
+ // but they're expected behavior during investigation.
292
296
  let effectiveQualityThreshold;
293
297
  if (selfManagesContext) {
294
- effectiveQualityThreshold = 15;
298
+ effectiveQualityThreshold = 25;
295
299
  } else {
296
300
  effectiveQualityThreshold = QUALITY_THRESHOLD;
297
301
  if (compactions >= 3) effectiveQualityThreshold = 55;
@@ -300,10 +304,27 @@ export class Rotator extends EventEmitter {
300
304
  }
301
305
  }
302
306
 
307
+ // Context-awareness guard for self-managing providers: if the provider
308
+ // recently compacted and context is low, it's managing itself — the
309
+ // quality score alone is too unreliable to override that.
310
+ if (selfManagesContext && agent.contextUsage < 0.6) {
311
+ continue;
312
+ }
313
+
314
+ // User-activity guard: if the user sent a message recently, the cost of
315
+ // disrupting the session is much higher than letting it continue. Require
316
+ // catastrophic degradation (score < 5) before rotating.
317
+ const lastUserMsg = this.userMessageTimes.get(agent.id);
318
+ const userActiveRecently = lastUserMsg && (Date.now() - lastUserMsg) < 5 * 60 * 1000;
319
+ if (userActiveRecently) {
320
+ effectiveQualityThreshold = 5;
321
+ }
322
+
303
323
  const quality = this.scoreLiveSession(agent);
304
324
  if (quality.hasEnoughData && quality.score < effectiveQualityThreshold) {
305
325
  // For self-managing providers, effectiveQualityThreshold IS the severe
306
- // threshold (15) — any score below it is catastrophic, rotate immediately.
326
+ // threshold (25, or 5 if user is active) — any score below it is
327
+ // catastrophic, rotate immediately.
307
328
  // For others, severe = < 25, moderate = 25-40/55.
308
329
  const severeThreshold = selfManagesContext ? effectiveQualityThreshold : 25;
309
330
  if (quality.score < severeThreshold) {
@@ -331,12 +352,36 @@ export class Rotator extends EventEmitter {
331
352
  }
332
353
  }
333
354
 
355
+ _detectIdleLoop(agentName) {
356
+ const thirtyMinAgo = Date.now() - 30 * 60 * 1000;
357
+ const recentLowToken = this.rotationHistory.filter(r =>
358
+ r.agentName === agentName &&
359
+ new Date(r.timestamp).getTime() > thirtyMinAgo &&
360
+ (r.oldTokens || 0) < 2000
361
+ );
362
+ return recentLowToken.length >= 3;
363
+ }
364
+
334
365
  async rotate(agentId, options = {}) {
335
366
  const { registry, processes, journalist } = this.daemon;
336
367
  const agent = registry.get(agentId);
337
368
  if (!agent) throw new Error(`Agent ${agentId} not found`);
338
369
  if (this.rotating.has(agentId)) throw new Error(`Agent ${agentId} is already rotating`);
339
370
 
371
+ // Idle rotation loop detection: if this agent name has completed 3+ times
372
+ // in the last 30 minutes with minimal tokens, the system is spinning — each
373
+ // replacement arrives blind, does nothing, and completes. Stop the cycle.
374
+ if (this._detectIdleLoop(agent.name)) {
375
+ console.warn(` Rotator: ${agent.name} is in an idle rotation loop (3+ low-token rotations in 30 min). Skipping respawn — waiting for user input.`);
376
+ this.daemon.broadcast({
377
+ type: 'rotation:blocked',
378
+ agentId,
379
+ agentName: agent.name,
380
+ reason: 'idle_loop_detected',
381
+ });
382
+ return null;
383
+ }
384
+
340
385
  this.rotating.add(agentId);
341
386
 
342
387
  this.daemon.broadcast({
@@ -386,6 +431,30 @@ export class Rotator extends EventEmitter {
386
431
  }
387
432
  }
388
433
 
434
+ // Brief quality validation: if an AUTOMATIC rotation produces a brief
435
+ // with too little meaningful content, the new agent will arrive blind.
436
+ // Block it rather than spawning an agent that can't continue the work.
437
+ // Manual rotations (user-requested) are never blocked — the user accepts the risk.
438
+ const isAutoRotation = options.reason && options.reason !== 'manual';
439
+ if (isAutoRotation && !usedConversationThread) {
440
+ const briefContent = brief
441
+ .replace(/^#.*$/gm, '') // strip markdown headers
442
+ .replace(/^[-*].*role:.*$/gim, '') // strip metadata lines
443
+ .replace(/^\s*$/gm, '') // strip blank lines
444
+ .trim();
445
+ if (briefContent.length < 200) {
446
+ console.warn(` Rotator: ${agent.name} handoff brief has only ${briefContent.length} chars of content — low-confidence handoff, skipping rotation`);
447
+ this.rotating.delete(agentId);
448
+ this.daemon.broadcast({
449
+ type: 'rotation:blocked',
450
+ agentId,
451
+ agentName: agent.name,
452
+ reason: 'low_confidence_handoff',
453
+ });
454
+ return null;
455
+ }
456
+ }
457
+
389
458
  if (agent.role === 'planner' && !brief.includes('PLANNING ONLY')) {
390
459
  brief = 'CRITICAL: You are a PLANNING ONLY agent. Do NOT implement code. Route all work to your team via .groove/recommended-team.json.\n\n' + brief;
391
460
  }
@@ -423,9 +492,10 @@ export class Rotator extends EventEmitter {
423
492
  const respawnModel = routingMode.mode === 'auto' ? 'auto' : agent.model;
424
493
 
425
494
  // Remove old agent BEFORE spawning so registry.add() won't dedup the name
426
- // (appending "-2", "-2-2", etc.). Save config in case we need to re-add on failure.
495
+ // (appending "-2", "-2-2", etc.). Silent removal: the spawn's registry.add()
496
+ // will flush both removed+changed in one broadcast, preventing GUI flicker.
427
497
  const savedConfig = { ...agent };
428
- registry.remove(agentId);
498
+ registry.remove(agentId, { silent: true });
429
499
  this.daemon.locks.release(agentId);
430
500
 
431
501
  let newAgent;
@@ -443,7 +513,8 @@ export class Rotator extends EventEmitter {
443
513
  isRotation: true,
444
514
  });
445
515
  } catch (spawnErr) {
446
- // Spawn failed — re-add old agent so the user can see and retry.
516
+ // Spawn failed — flush the silent removal so GUI sees it, then re-add.
517
+ registry.flushPendingRemovals();
447
518
  registry.add({
448
519
  role: savedConfig.role, scope: savedConfig.scope, provider: savedConfig.provider,
449
520
  model: savedConfig.model, prompt: savedConfig.prompt, permission: savedConfig.permission,
@@ -428,6 +428,7 @@ export function registerAgentRoutes(app, daemon) {
428
428
 
429
429
  // Record user feedback so the journalist can include it in future agent context
430
430
  if (daemon.journalist) daemon.journalist.recordUserFeedback(agent, finalMessage);
431
+ if (daemon.rotator) daemon.rotator.recordUserMessage(req.params.id);
431
432
 
432
433
  // Agent loop path — send message directly to the running loop
433
434
  const wrappedMessage = wrapWithRoleReminder(agent.role, finalMessage);
@@ -449,20 +450,26 @@ export function registerAgentRoutes(app, daemon) {
449
450
  if (daemon.processes.isRunning(req.params.id)) {
450
451
  await daemon.processes.kill(req.params.id);
451
452
  }
452
- daemon.registry.remove(req.params.id);
453
+ daemon.registry.remove(req.params.id, { silent: true });
453
454
  daemon.locks.release(req.params.id);
454
455
 
455
- const newAgent = await daemon.processes.spawn({
456
- role: oldConfig.role,
457
- scope: oldConfig.scope,
458
- provider: oldConfig.provider,
459
- model: oldConfig.model,
460
- prompt: finalMessage,
461
- permission: oldConfig.permission || 'full',
462
- workingDir: oldConfig.workingDir,
463
- name: oldConfig.name,
464
- teamId: oldConfig.teamId,
465
- });
456
+ let newAgent;
457
+ try {
458
+ newAgent = await daemon.processes.spawn({
459
+ role: oldConfig.role,
460
+ scope: oldConfig.scope,
461
+ provider: oldConfig.provider,
462
+ model: oldConfig.model,
463
+ prompt: finalMessage,
464
+ permission: oldConfig.permission || 'full',
465
+ workingDir: oldConfig.workingDir,
466
+ name: oldConfig.name,
467
+ teamId: oldConfig.teamId,
468
+ });
469
+ } catch (spawnErr) {
470
+ daemon.registry.flushPendingRemovals();
471
+ throw spawnErr;
472
+ }
466
473
  daemon.audit.log('agent.instruct', { id: req.params.id, newId: newAgent.id, resumed: false });
467
474
  return res.json(newAgent);
468
475
  }
@@ -472,21 +479,27 @@ export function registerAgentRoutes(app, daemon) {
472
479
  // run one prompt per spawn and cannot resume sessions.
473
480
  if (provider?.constructor?.nonInteractive && !daemon.processes.isRunning(req.params.id)) {
474
481
  const oldConfig = { ...agent };
475
- daemon.registry.remove(req.params.id);
482
+ daemon.registry.remove(req.params.id, { silent: true });
476
483
  daemon.locks.release(req.params.id);
477
484
 
478
- const newAgent = await daemon.processes.spawn({
479
- role: oldConfig.role,
480
- scope: oldConfig.scope,
481
- provider: oldConfig.provider,
482
- model: oldConfig.model,
483
- prompt: finalMessage,
484
- introContext: oldConfig.introContext,
485
- permission: oldConfig.permission || 'full',
486
- workingDir: oldConfig.workingDir,
487
- name: oldConfig.name,
488
- teamId: oldConfig.teamId,
489
- });
485
+ let newAgent;
486
+ try {
487
+ newAgent = await daemon.processes.spawn({
488
+ role: oldConfig.role,
489
+ scope: oldConfig.scope,
490
+ provider: oldConfig.provider,
491
+ model: oldConfig.model,
492
+ prompt: finalMessage,
493
+ introContext: oldConfig.introContext,
494
+ permission: oldConfig.permission || 'full',
495
+ workingDir: oldConfig.workingDir,
496
+ name: oldConfig.name,
497
+ teamId: oldConfig.teamId,
498
+ });
499
+ } catch (spawnErr) {
500
+ daemon.registry.flushPendingRemovals();
501
+ throw spawnErr;
502
+ }
490
503
  daemon.audit.log('agent.instruct', { id: req.params.id, newId: newAgent.id, resumed: false });
491
504
  return res.json(newAgent);
492
505
  }