groove-dev 0.27.180 → 0.27.182

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 (28) hide show
  1. package/CLAUDE.md +7 -0
  2. package/node_modules/@groove-dev/cli/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/scripts/repair-tokens.mjs +78 -0
  5. package/node_modules/@groove-dev/daemon/src/api.js +1 -1
  6. package/node_modules/@groove-dev/daemon/src/index.js +1 -0
  7. package/node_modules/@groove-dev/daemon/src/journalist.js +15 -8
  8. package/node_modules/@groove-dev/daemon/src/process.js +134 -21
  9. package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +16 -1
  10. package/node_modules/@groove-dev/daemon/src/rotator.js +62 -5
  11. package/node_modules/@groove-dev/daemon/src/tokentracker.js +46 -6
  12. package/node_modules/@groove-dev/daemon/test/claude-code-provider.test.js +128 -0
  13. package/node_modules/@groove-dev/daemon/test/journalist.test.js +4 -2
  14. package/node_modules/@groove-dev/daemon/test/rotator.test.js +131 -0
  15. package/node_modules/@groove-dev/daemon/test/tokentracker.test.js +4 -0
  16. package/node_modules/@groove-dev/gui/package.json +1 -1
  17. package/package.json +1 -1
  18. package/packages/cli/package.json +1 -1
  19. package/packages/daemon/package.json +1 -1
  20. package/packages/daemon/scripts/repair-tokens.mjs +78 -0
  21. package/packages/daemon/src/api.js +1 -1
  22. package/packages/daemon/src/index.js +1 -0
  23. package/packages/daemon/src/journalist.js +15 -8
  24. package/packages/daemon/src/process.js +134 -21
  25. package/packages/daemon/src/providers/claude-code.js +16 -1
  26. package/packages/daemon/src/rotator.js +62 -5
  27. package/packages/daemon/src/tokentracker.js +46 -6
  28. package/packages/gui/package.json +1 -1
package/CLAUDE.md CHANGED
@@ -295,3 +295,10 @@ Audit-driven release. Multi-agent orchestration system with 7 coordination layer
295
295
  - Dashboard: routing donut, cache panel, context health gauges
296
296
  - Monitor/QC agent mode (stay active, loop)
297
297
  - Distribution: demo video, HN launch, Twitter content
298
+
299
+ <!-- GROOVE:START -->
300
+ ## GROOVE Orchestration (auto-injected)
301
+ Active agents: 0
302
+ See AGENTS_REGISTRY.md for full agent state.
303
+ **Memory policy:** GROOVE manages project memory automatically. Do not read or write MEMORY.md or .groove/memory/ files directly.
304
+ <!-- GROOVE:END -->
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.180",
3
+ "version": "0.27.182",
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.180",
3
+ "version": "0.27.182",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+ //
4
+ // One-off ledger repair for ~/.groove/tokens.json (2026-07 efficiency audit).
5
+ //
6
+ // Entry c3789b6f recorded $20,536.57 from a duplicate-ingestion loop (4,631
7
+ // session records in 380s, payloads duplicated up to 10×). The ingestion bug
8
+ // is fixed in claude-code.js, but the poisoned data still dominates every cost
9
+ // dashboard: recorded all-time spend ~$24K vs ~$3.5K real. This script:
10
+ // 1. Sets the poisoned entry's totalCostUsd to its audited real value (~$325)
11
+ // 2. Truncates every entry's sessions array to the last 500 records
12
+ // (matches SESSION_CAP in tokentracker.js; aggregates are untouched)
13
+ // 3. Writes a timestamped .bak of the original file first
14
+ //
15
+ // MUST run with the daemon STOPPED — the daemon holds the ledger in memory and
16
+ // rewrites the file, so a live edit gets clobbered. The script checks and aborts.
17
+ //
18
+ // Usage: node repair-tokens.mjs [path-to-tokens.json] [--entry c3789b6f] [--cost 325]
19
+
20
+ import { readFileSync, writeFileSync, copyFileSync, existsSync } from 'fs';
21
+ import { resolve } from 'path';
22
+ import { homedir } from 'os';
23
+
24
+ const args = process.argv.slice(2);
25
+ const flag = (name, dflt) => {
26
+ const i = args.indexOf(`--${name}`);
27
+ return i !== -1 && args[i + 1] ? args[i + 1] : dflt;
28
+ };
29
+ const tokensPath = args[0] && !args[0].startsWith('--')
30
+ ? resolve(args[0])
31
+ : resolve(homedir(), '.groove', 'tokens.json');
32
+ const entryId = flag('entry', 'c3789b6f');
33
+ const correctedCost = Number(flag('cost', '325'));
34
+ const SESSION_CAP = 500;
35
+
36
+ // Refuse to edit under a live daemon
37
+ try {
38
+ const res = await fetch('http://127.0.0.1:31415/api/health', { signal: AbortSignal.timeout(1500) });
39
+ if (res.ok) {
40
+ console.error('ABORT: GROOVE daemon is running on :31415. Stop it first (groove stop) — it rewrites tokens.json and will clobber this repair.');
41
+ process.exit(1);
42
+ }
43
+ } catch { /* no daemon — safe to proceed */ }
44
+
45
+ if (!existsSync(tokensPath)) {
46
+ console.error(`ABORT: ${tokensPath} not found. Pass the path explicitly: node repair-tokens.mjs /path/to/tokens.json`);
47
+ process.exit(1);
48
+ }
49
+
50
+ const backupPath = `${tokensPath}.${new Date().toISOString().replace(/[:.]/g, '-')}.bak`;
51
+ copyFileSync(tokensPath, backupPath);
52
+ console.log(`Backup written: ${backupPath}`);
53
+
54
+ const data = JSON.parse(readFileSync(tokensPath, 'utf8'));
55
+ const usage = data.usage || data;
56
+
57
+ const target = usage[entryId];
58
+ if (target) {
59
+ const before = target.totalCostUsd;
60
+ target.totalCostUsd = correctedCost;
61
+ target.note = `totalCostUsd corrected ${before} -> ${correctedCost} on ${new Date().toISOString().slice(0, 10)} (duplicate-ingestion inflation, see 2026-07 efficiency audit)`;
62
+ console.log(`Entry ${entryId}: totalCostUsd ${before} -> ${correctedCost}`);
63
+ } else {
64
+ console.warn(`Entry ${entryId} not found — skipping cost correction.`);
65
+ }
66
+
67
+ let trimmed = 0;
68
+ for (const entry of Object.values(usage)) {
69
+ if (Array.isArray(entry?.sessions) && entry.sessions.length > SESSION_CAP) {
70
+ trimmed += entry.sessions.length - SESSION_CAP;
71
+ entry.sessions.splice(0, entry.sessions.length - SESSION_CAP);
72
+ }
73
+ }
74
+ console.log(`Trimmed ${trimmed} session records across all entries (cap ${SESSION_CAP}).`);
75
+
76
+ writeFileSync(tokensPath, JSON.stringify(data, null, 2));
77
+ const sizeMb = (Buffer.byteLength(JSON.stringify(data)) / 1024 / 1024).toFixed(1);
78
+ console.log(`Repaired ledger written: ${tokensPath} (${sizeMb}MB)`);
@@ -1438,7 +1438,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
1438
1438
  'port', 'journalistInterval', 'rotationThreshold', 'autoRotation',
1439
1439
  'qcThreshold', 'maxAgents', 'defaultProvider', 'defaultWorkingDir',
1440
1440
  'onboardingDismissed', 'defaultModel', 'defaultChatProvider', 'defaultChatModel',
1441
- 'dataSharingDismissed',
1441
+ 'dataSharingDismissed', 'replayCeiling', 'velocityCeiling', 'journalistModelTier',
1442
1442
  ];
1443
1443
  for (const key of Object.keys(req.body)) {
1444
1444
  if (!ALLOWED_KEYS.includes(key)) {
@@ -836,6 +836,7 @@ export class Daemon {
836
836
  // Persist state before shutdown
837
837
  this.state.set('agents', this.registry.getAll());
838
838
  await this.state.save();
839
+ try { this.tokens.flush(); } catch { /* best-effort */ }
839
840
 
840
841
  // Stop background services
841
842
  await this.gateways.stop();
@@ -5,6 +5,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'fs
5
5
  import { resolve } from 'path';
6
6
  import { execFile, spawn as cpSpawn } from 'child_process';
7
7
  import { getProvider, getInstalledProviders, resolveProviderCommand } from './providers/index.js';
8
+ import { agentLogPath } from './process.js';
8
9
 
9
10
  const DEFAULT_INTERVAL = 300_000; // 5 minutes (safety-net fallback; event-driven triggers handle the normal case)
10
11
  const MAX_LOG_CHARS = 100_000; // ~25k tokens budget for synthesis input (captures 80-90% of recent activity)
@@ -168,7 +169,7 @@ export class Journalist {
168
169
 
169
170
  hasNewActivity(agents) {
170
171
  for (const agent of agents) {
171
- const logPath = resolve(this.daemon.grooveDir, 'logs', `${agent.id}.log`);
172
+ const logPath = agentLogPath(this.daemon.grooveDir, agent);
172
173
  if (!existsSync(logPath)) continue;
173
174
  try {
174
175
  const size = statSync(logPath).size;
@@ -182,7 +183,7 @@ export class Journalist {
182
183
  const result = {};
183
184
 
184
185
  for (const agent of agents) {
185
- const logPath = resolve(this.daemon.grooveDir, 'logs', `${agent.id}.log`);
186
+ const logPath = agentLogPath(this.daemon.grooveDir, agent);
186
187
  if (!existsSync(logPath)) {
187
188
  result[agent.id] = { agent, entries: [], explorationEntries: [] };
188
189
  continue;
@@ -574,9 +575,15 @@ export class Journalist {
574
575
  const provider = getProvider(providerId);
575
576
  if (!provider) continue;
576
577
 
577
- const selectedModel = provider.constructor.models?.find((m) => m.tier === 'medium')
578
- || provider.constructor.models?.find((m) => m.tier === 'light')
579
- || provider.constructor.models?.[0];
578
+ // Digests are light-tier work (Haiku-shaped): default internal overhead to
579
+ // the cheapest tier, escalate via config only on evidence of quality issues.
580
+ // On Claude, medium→light cuts the journalist's marginal cost ~90%.
581
+ const models = provider.constructor.models || [];
582
+ const preferredTier = this.daemon.config?.journalistModelTier || 'light';
583
+ const selectedModel = models.find((m) => m.tier === preferredTier)
584
+ || models.find((m) => m.tier === 'light')
585
+ || models.find((m) => m.tier === 'medium')
586
+ || models[0];
580
587
  const modelId = selectedModel?.id || null;
581
588
 
582
589
  // Try CLI headless command first
@@ -1055,7 +1062,7 @@ export class Journalist {
1055
1062
  * Budget: keeps recent turns verbatim, summarizes oldest if over maxChars.
1056
1063
  */
1057
1064
  extractConversationThread(agent, { maxChars = 60000 } = {}) {
1058
- const logPath = resolve(this.daemon.grooveDir, 'logs', `${agent.id}.log`);
1065
+ const logPath = agentLogPath(this.daemon.grooveDir, agent);
1059
1066
  if (!existsSync(logPath)) return null;
1060
1067
 
1061
1068
  let content;
@@ -1297,7 +1304,7 @@ export class Journalist {
1297
1304
  * Used by the Introducer to tell new agents what their teammates built.
1298
1305
  */
1299
1306
  getAgentFiles(agent) {
1300
- const logPath = resolve(this.daemon.grooveDir, 'logs', `${agent.id}.log`);
1307
+ const logPath = agentLogPath(this.daemon.grooveDir, agent);
1301
1308
  if (!existsSync(logPath)) return [];
1302
1309
 
1303
1310
  try {
@@ -1335,7 +1342,7 @@ export class Journalist {
1335
1342
  * Used to capture planner conclusions, build summaries, etc.
1336
1343
  */
1337
1344
  getAgentResult(agent) {
1338
- const logPath = resolve(this.daemon.grooveDir, 'logs', `${agent.id}.log`);
1345
+ const logPath = agentLogPath(this.daemon.grooveDir, agent);
1339
1346
  if (!existsSync(logPath)) return '';
1340
1347
 
1341
1348
  try {
@@ -324,6 +324,15 @@ function sanitizeFilename(name) {
324
324
  return String(name).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
325
325
  }
326
326
 
327
+ // Agent log files are keyed by sanitized agent NAME, not id — a rotated agent
328
+ // keeps its name and appends to the same file, preserving conversation history
329
+ // across rotations. Every reader must resolve paths through this helper: an
330
+ // id-based path silently misses the file and downstream consumers (handoff
331
+ // briefs, conversation resume, synthesis) fall back to empty context.
332
+ export function agentLogPath(grooveDir, agent) {
333
+ return resolve(grooveDir, 'logs', `${sanitizeFilename(agent.name)}.log`);
334
+ }
335
+
327
336
  // Apply Claude Code billing mode to a spawn env, in place.
328
337
  // The `claude` CLI bills to usage credits when ANTHROPIC_API_KEY is present and
329
338
  // to the OAuth subscription otherwise. 'subscription' (default) strips any
@@ -363,6 +372,14 @@ export class ProcessManager {
363
372
  this._stalledAgents = new Set(); // agentIds already flagged as stalled (avoids duplicate broadcasts)
364
373
  this._exitHandled = new Set();
365
374
  this._resultReceived = new Set();
375
+ // In-flight user messages, kept until a turn actually completes so a
376
+ // dropped turn can be re-issued instead of losing the user's message.
377
+ // agentId -> { message, attempts }
378
+ this._pendingUserMessage = new Map();
379
+ // Retry count carried across the agent-id change that resume() performs.
380
+ // Kept separate so teardown of the old agent can't reset it to 0 and
381
+ // turn a bounded retry into an infinite respawn loop. oldAgentId -> attempts
382
+ this._retryAttemptCarry = new Map();
366
383
  this._truncationFlagged = new Set(); // agentIds that have had any truncation in their session
367
384
  this._lastAssistantBlocks = new Map(); // agentId -> last assistant content blocks (for abandoned tool_use detection)
368
385
  this._previousCacheReadTokens = new Map(); // agentId -> previous turn's cacheReadTokens
@@ -1138,7 +1155,7 @@ For normal file edits within your scope, proceed without review.
1138
1155
  // Set up log capture (shared between CLI and agent loop paths)
1139
1156
  const logDir = resolve(this.daemon.grooveDir, 'logs');
1140
1157
  mkdirSync(logDir, { recursive: true });
1141
- const logPath = resolve(logDir, `${sanitizeFilename(agent.name)}.log`);
1158
+ const logPath = agentLogPath(this.daemon.grooveDir, agent);
1142
1159
  const logStream = createWriteStream(logPath, { flags: 'a', mode: 0o600 });
1143
1160
 
1144
1161
  // Inject API key from credential store for agent-loop providers
@@ -1559,26 +1576,54 @@ For normal file edits within your scope, proceed without review.
1559
1576
 
1560
1577
  // Session result data (cost, duration, turns)
1561
1578
  if (output.type === 'result') {
1562
- tokens.recordResult(agentId, {
1563
- costUsd: output.cost, durationMs: output.duration, turns: output.turns,
1564
- });
1565
- if (output.cost) updates.costUsd = (agent.costUsd || 0) + output.cost;
1566
- if (output.duration) updates.durationMs = output.duration;
1567
- if (output.turns) updates.turns = output.turns;
1568
-
1569
- // Claude Code sometimes hangs after emitting the result event the
1570
- // process stays alive instead of exiting. Record that the result
1571
- // arrived so exit handlers know this was a successful completion even
1572
- // if we have to SIGTERM the process. After a 5s grace period, force-
1573
- // kill any process that hasn't exited on its own.
1574
- this._resultReceived.add(agentId);
1575
- const handle = this.handles.get(agentId);
1576
- if (handle?.proc && typeof handle.proc.kill === 'function') {
1577
- setTimeout(() => {
1578
- if (this.handles.has(agentId) && this._resultReceived.has(agentId)) {
1579
- try { handle.proc.kill('SIGTERM'); } catch {}
1580
- }
1581
- }, 5_000);
1579
+ // Did this result represent a turn that actually ran? CLI >= 2.1.212
1580
+ // emits a 0-turn no-op `result` during --resume reconciliation, before
1581
+ // the real turn starts. Both it and an aborted turn report
1582
+ // duration_api_ms === 0 the model was never called. Providers that
1583
+ // don't report an API duration (codex, gemini, ollama) are unaffected.
1584
+ const reachedModel = output.apiDurationMs === undefined || output.apiDurationMs > 0;
1585
+
1586
+ // Turn aborted before the model was called re-deliver the message.
1587
+ // Deliberately does NOT return: session id persistence and the GUI
1588
+ // broadcast below still need to run, and the retry is scheduled async.
1589
+ if (!reachedModel && output.isError) {
1590
+ this._retryDroppedTurn(agentId, agent, output);
1591
+ }
1592
+
1593
+ // Everything below is completion bookkeeping and must only run for a
1594
+ // turn that actually executed. Running it for the resume handshake is
1595
+ // what killed the process mid-turn.
1596
+ if (reachedModel) {
1597
+ // The user's message is safely delivered.
1598
+ this._pendingUserMessage.delete(agentId);
1599
+
1600
+ tokens.recordResult(agentId, {
1601
+ costUsd: output.cost, durationMs: output.duration, turns: output.turns,
1602
+ });
1603
+ if (output.cost) updates.costUsd = (agent.costUsd || 0) + output.cost;
1604
+ if (output.duration) updates.durationMs = output.duration;
1605
+ if (output.turns) updates.turns = output.turns;
1606
+
1607
+ // Claude Code sometimes hangs after emitting the result event — the
1608
+ // process stays alive instead of exiting. Record that the result
1609
+ // arrived so exit handlers know this was a successful completion even
1610
+ // if we have to SIGTERM the process. After a 5s grace period, force-
1611
+ // kill any process that hasn't exited on its own.
1612
+ //
1613
+ // MUST stay behind the reachedModel guard. CLI >= 2.1.212 emits a
1614
+ // 0-turn no-op result ~180ms into --resume; arming this on that result
1615
+ // SIGTERMs the process ~5.2s later, mid-turn, before a token is
1616
+ // produced (fable TTFT observed at 34s). The CLI records the signal as
1617
+ // "[Request interrupted by user]" and the user's message is destroyed.
1618
+ this._resultReceived.add(agentId);
1619
+ const handle = this.handles.get(agentId);
1620
+ if (handle?.proc && typeof handle.proc.kill === 'function') {
1621
+ setTimeout(() => {
1622
+ if (this.handles.has(agentId) && this._resultReceived.has(agentId)) {
1623
+ try { handle.proc.kill('SIGTERM'); } catch {}
1624
+ }
1625
+ }, 5_000);
1626
+ }
1582
1627
  }
1583
1628
  }
1584
1629
 
@@ -2510,6 +2555,59 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
2510
2555
  * spawns fresh with the full conversation thread instead of --resume.
2511
2556
  * This avoids degraded context from internal compaction.
2512
2557
  */
2558
+ /**
2559
+ * Re-issue a user message whose turn was aborted before reaching the model.
2560
+ *
2561
+ * Returns true if a retry was scheduled (caller should skip normal result
2562
+ * handling), false if the turn should be treated as a real result.
2563
+ */
2564
+ _retryDroppedTurn(agentId, agent, output) {
2565
+ const pending = this._pendingUserMessage.get(agentId);
2566
+ if (!pending) return false; // nothing to re-issue — let it fall through
2567
+
2568
+ const MAX_RETRIES = 2;
2569
+ if (pending.attempts >= MAX_RETRIES) {
2570
+ this._pendingUserMessage.delete(agentId);
2571
+ this.daemon.broadcast({
2572
+ type: 'agent:output',
2573
+ agentId,
2574
+ data: {
2575
+ type: 'activity',
2576
+ subtype: 'error',
2577
+ data: `Message could not be delivered after ${MAX_RETRIES + 1} attempts `
2578
+ + `(${output.terminalReason || output.subtype || 'turn aborted'}). Please re-send.`,
2579
+ },
2580
+ });
2581
+ return false;
2582
+ }
2583
+
2584
+ pending.attempts += 1;
2585
+ this._retryAttemptCarry.set(agentId, pending.attempts);
2586
+ this.daemon.broadcast({
2587
+ type: 'agent:output',
2588
+ agentId,
2589
+ data: {
2590
+ type: 'activity',
2591
+ subtype: 'info',
2592
+ data: `Turn aborted before reaching the model — retrying (${pending.attempts}/${MAX_RETRIES})…`,
2593
+ },
2594
+ });
2595
+
2596
+ // Let the aborted process finish tearing down before re-spawning; resume()
2597
+ // kills the current handle and re-registers under a new agent id.
2598
+ setTimeout(() => {
2599
+ this.resume(agentId, pending.message).catch((err) => {
2600
+ this.daemon.broadcast({
2601
+ type: 'agent:output',
2602
+ agentId,
2603
+ data: { type: 'activity', subtype: 'error', data: `Retry failed: ${err.message}` },
2604
+ });
2605
+ });
2606
+ }, 500);
2607
+
2608
+ return true;
2609
+ }
2610
+
2513
2611
  async resume(agentId, message) {
2514
2612
  const { registry, locks } = this.daemon;
2515
2613
  const agent = registry.get(agentId);
@@ -2577,8 +2675,19 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
2577
2675
  workingDir: config.workingDir || this.daemon.config?.defaultWorkingDir || undefined,
2578
2676
  name: config.name,
2579
2677
  teamId: config.teamId,
2678
+ authMode: config.authMode,
2580
2679
  });
2581
2680
 
2681
+ // Hold the message until a turn actually completes, so a turn aborted
2682
+ // before reaching the model can be re-issued rather than lost. Carries the
2683
+ // attempt count forward from the agent id we're replacing.
2684
+ if (message) {
2685
+ const carried = this._retryAttemptCarry.get(agentId);
2686
+ this._retryAttemptCarry.delete(agentId);
2687
+ this._pendingUserMessage.delete(agentId);
2688
+ this._pendingUserMessage.set(newAgent.id, { message, attempts: carried || 0 });
2689
+ }
2690
+
2582
2691
  // Carry cumulative tokens
2583
2692
  if (config.tokensUsed > 0) {
2584
2693
  registry.update(newAgent.id, { tokensUsed: config.tokensUsed });
@@ -2959,6 +3068,10 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
2959
3068
  if (throttle?.timer) clearTimeout(throttle.timer);
2960
3069
  this._streamThrottle.delete(agentId);
2961
3070
  this.pendingMessages.delete(agentId);
3071
+ // Safe during a retry: resume() re-registers the message under the new
3072
+ // agent id from its own `message` argument, and the attempt count lives in
3073
+ // _retryAttemptCarry (which kill() must not touch).
3074
+ this._pendingUserMessage.delete(agentId);
2962
3075
 
2963
3076
  // Unregister ambassador if this agent was one
2964
3077
  const agent = this.daemon.registry.get(agentId);
@@ -257,6 +257,13 @@ export class ClaudeCodeProvider extends Provider {
257
257
  cost: data.total_cost_usd,
258
258
  duration: data.duration_ms,
259
259
  turns: data.num_turns,
260
+ // Error/abort signals. Without these an `error_during_execution`
261
+ // result looks identical to a successful one and the user's
262
+ // message is silently dropped.
263
+ subtype: data.subtype,
264
+ isError: data.is_error === true || data.subtype === 'error_during_execution',
265
+ apiDurationMs: data.duration_api_ms,
266
+ terminalReason: data.terminal_reason,
260
267
  });
261
268
  }
262
269
  } catch {
@@ -268,7 +275,15 @@ export class ClaudeCodeProvider extends Provider {
268
275
 
269
276
  // Merge events: prefer content-bearing events (activity/result) over usage/session.
270
277
  // Accumulate token counts across all events in this chunk.
271
- let content = events.find((e) => e.type === 'result') || events.find((e) => e.type === 'activity') || events[events.length - 1];
278
+ // Prefer an error result over a success one when a single chunk carries
279
+ // both. During --resume the CLI emits a 0-turn no-op success result
280
+ // immediately before the real turn's result; taking the first would hide a
281
+ // subsequent abort and the failure would look like a clean completion.
282
+ const resultEvents = events.filter((e) => e.type === 'result');
283
+ let content = resultEvents.find((e) => e.isError)
284
+ || resultEvents[0]
285
+ || events.find((e) => e.type === 'activity')
286
+ || events[events.length - 1];
272
287
  const merged = { ...content };
273
288
 
274
289
  let totalTokens = 0;
@@ -18,6 +18,9 @@ const SCORE_HISTORY_MAX = 40; // ~10 min at 15s intervals
18
18
  const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes between rotations per agent
19
19
  const QUALITY_COOLDOWN_MS = 2 * 60 * 1000; // 2 minutes for quality degradation rotations
20
20
  const TOKEN_CEILING = 5_000_000; // 5M tokens per agent (non-self-managing only)
21
+ const REPLAY_CEILING = 0.50; // Self-managing providers: rotate idle agents above this context usage
22
+ const VELOCITY_CEILING = 250_000; // New (non-cache) tokens per window before force rotation
23
+ const VELOCITY_WINDOW_MS = 5 * 60_000;
21
24
  const ROLE_MULTIPLIERS = {
22
25
  planner: 2,
23
26
  fullstack: 4,
@@ -201,13 +204,34 @@ export class Rotator extends EventEmitter {
201
204
  const singleTask = providerInstance?.constructor?.singleTask ?? false;
202
205
  if (singleTask) continue;
203
206
 
204
- // Skip agents idle for over 60s — no point scoring them every 15s
205
- const idleMs = this._idleMs(agent);
206
- if (idleMs > 60_000 && agent.contextUsage < HARD_CEILING) continue;
207
-
208
207
  // Determine if provider manages its own context (e.g. Claude Code compacts internally)
209
208
  const selfManagesContext = providerInstance?.constructor?.managesOwnContext ?? false;
210
209
 
210
+ // Velocity safety — new-token burn rate, all provider types. tokensUsed
211
+ // excludes cache reads, so this measures real generation. Caps worst-case
212
+ // runaway burn; bypasses cooldown (a runaway must be stopped, not waited out).
213
+ const velCeiling = this.daemon.config?.velocityCeiling ?? VELOCITY_CEILING;
214
+ const velocity = this.daemon.tokens?.getVelocity?.(agent.id, VELOCITY_WINDOW_MS) ?? 0;
215
+ if (velCeiling > 0 && velocity >= velCeiling) {
216
+ console.warn(` Rotator: ${agent.name} burned ${velCeiling.toLocaleString()}+ new tokens in 5min — FORCE rotating (velocity)`);
217
+ await this.rotate(agent.id, { reason: 'velocity_ceiling' });
218
+ continue;
219
+ }
220
+
221
+ // Replay ceiling — self-managing providers only. Internal compaction keeps
222
+ // sessions functional but lets context ride at 60-85% indefinitely, and every
223
+ // subsequent message replays that much context on EVERY API call in the agent
224
+ // loop. A handoff-brief rotation costs ~10K tokens once; riding high costs
225
+ // 150-300K per call. Computed here because the idle-skip guard below must
226
+ // not hide the exact agents this targets (idle, high-context).
227
+ const replayCeiling = this.daemon.config?.replayCeiling ?? REPLAY_CEILING;
228
+ const overReplayCeiling = selfManagesContext && replayCeiling > 0
229
+ && agent.contextUsage >= replayCeiling;
230
+
231
+ // Skip agents idle for over 60s — no point scoring them every 15s
232
+ const idleMs = this._idleMs(agent);
233
+ if (idleMs > 60_000 && agent.contextUsage < HARD_CEILING && !overReplayCeiling) continue;
234
+
211
235
  if (!selfManagesContext) {
212
236
  // Non-Claude: threshold + ceiling + quality rotation
213
237
  // These providers fill up linearly and degrade without external rotation
@@ -260,6 +284,13 @@ export class Rotator extends EventEmitter {
260
284
  continue;
261
285
  }
262
286
  }
287
+ } else if (overReplayCeiling && !this._isOnCooldown(agent.id) && idleMs > 10_000) {
288
+ // Rotate while idle only — never mid-task — and respect the cooldown.
289
+ // Continuity comes from the conversation-thread resume; rotate() blocks
290
+ // the rotation entirely if the thread can't be extracted for a chat agent.
291
+ console.log(` Rotator: ${agent.name} at ${Math.round(agent.contextUsage * 100)}% — rotating (replay ceiling)`);
292
+ await this.rotate(agent.id, { reason: 'replay_ceiling' });
293
+ continue;
263
294
  }
264
295
 
265
296
  // --- Change 4: Truncation-triggered rotation ---
@@ -409,6 +440,7 @@ export class Rotator extends EventEmitter {
409
440
  // empty or the thread is too short to be useful.
410
441
  let brief;
411
442
  let usedConversationThread = false;
443
+ const isAutoRotation = options.reason && options.reason !== 'manual';
412
444
  if (typeof journalist.buildConversationResumePrompt === 'function') {
413
445
  const conversationPrompt = journalist.buildConversationResumePrompt(
414
446
  agent,
@@ -421,6 +453,22 @@ export class Rotator extends EventEmitter {
421
453
  }
422
454
  }
423
455
  if (!usedConversationThread) {
456
+ // Chat-continuity protection: an agent with a live user conversation must
457
+ // carry that dialogue into the fresh session. If the thread can't be
458
+ // extracted, a boilerplate brief would wipe the agent's memory of the
459
+ // conversation — skip the auto-rotation instead (context stays intact,
460
+ // worst case is status quo). Manual rotations proceed; the user accepts the risk.
461
+ if (isAutoRotation && this.userMessageTimes.has(agentId)) {
462
+ console.warn(` Rotator: ${agent.name} has chat history but no extractable conversation thread — skipping rotation`);
463
+ this.rotating.delete(agentId);
464
+ this.daemon.broadcast({
465
+ type: 'rotation:blocked',
466
+ agentId,
467
+ agentName: agent.name,
468
+ reason: 'no_conversation_thread',
469
+ });
470
+ return null;
471
+ }
424
472
  brief = await journalist.generateHandoffBrief(agent, {
425
473
  reason: options.reason,
426
474
  qualityScore: options.qualityScore,
@@ -435,11 +483,14 @@ export class Rotator extends EventEmitter {
435
483
  // with too little meaningful content, the new agent will arrive blind.
436
484
  // Block it rather than spawning an agent that can't continue the work.
437
485
  // Manual rotations (user-requested) are never blocked — the user accepts the risk.
438
- const isAutoRotation = options.reason && options.reason !== 'manual';
439
486
  if (isAutoRotation && !usedConversationThread) {
440
487
  const briefContent = brief
441
488
  .replace(/^#.*$/gm, '') // strip markdown headers
442
489
  .replace(/^[-*].*role:.*$/gim, '') // strip metadata lines
490
+ // Strip the static Instructions boilerplate generateHandoffBrief always
491
+ // appends (~400 chars) — padding must not defeat the content threshold
492
+ .replace(/^- (Do NOT|If the task).*$/gm, '')
493
+ .replace(/^Continue and finish the in-progress task.*$/gm, '')
443
494
  .replace(/^\s*$/gm, '') // strip blank lines
444
495
  .trim();
445
496
  if (briefContent.length < 200) {
@@ -716,6 +767,8 @@ export class Rotator extends EventEmitter {
716
767
  const hardCeilingRotations = this.rotationHistory.filter((r) => r.reason === 'hard_ceiling').length;
717
768
  const tokenCeilingRotations = this.rotationHistory.filter((r) => r.reason === 'token_ceiling').length;
718
769
  const estimatedCeilingRotations = this.rotationHistory.filter((r) => r.reason === 'estimated_context_ceiling').length;
770
+ const replayCeilingRotations = this.rotationHistory.filter((r) => r.reason === 'replay_ceiling').length;
771
+ const velocityCeilingRotations = this.rotationHistory.filter((r) => r.reason === 'velocity_ceiling').length;
719
772
  return {
720
773
  enabled: this.enabled,
721
774
  totalRotations,
@@ -726,6 +779,8 @@ export class Rotator extends EventEmitter {
726
779
  hardCeilingRotations,
727
780
  tokenCeilingRotations,
728
781
  estimatedCeilingRotations,
782
+ replayCeilingRotations,
783
+ velocityCeilingRotations,
729
784
  rotating: Array.from(this.rotating),
730
785
  liveScores: this.liveScores,
731
786
  scoreHistory: this.scoreHistory,
@@ -734,6 +789,8 @@ export class Rotator extends EventEmitter {
734
789
  qualityThreshold: QUALITY_THRESHOLD,
735
790
  cooldownMs: COOLDOWN_MS,
736
791
  tokenCeiling: TOKEN_CEILING,
792
+ replayCeiling: this.daemon.config?.replayCeiling ?? REPLAY_CEILING,
793
+ velocityCeiling: this.daemon.config?.velocityCeiling ?? VELOCITY_CEILING,
737
794
  roleMultipliers: ROLE_MULTIPLIERS,
738
795
  };
739
796
  }