groove-dev 0.27.181 → 0.27.183

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 (63) 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/scripts/repair-tokens.mjs +78 -0
  4. package/node_modules/@groove-dev/daemon/src/api.js +1 -1
  5. package/node_modules/@groove-dev/daemon/src/deliver.js +130 -0
  6. package/node_modules/@groove-dev/daemon/src/index.js +8 -3
  7. package/node_modules/@groove-dev/daemon/src/innerchat.js +213 -62
  8. package/node_modules/@groove-dev/daemon/src/journalist.js +15 -8
  9. package/node_modules/@groove-dev/daemon/src/process.js +59 -34
  10. package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +9 -1
  11. package/node_modules/@groove-dev/daemon/src/registry.js +5 -1
  12. package/node_modules/@groove-dev/daemon/src/rename.js +72 -0
  13. package/node_modules/@groove-dev/daemon/src/rotator.js +62 -5
  14. package/node_modules/@groove-dev/daemon/src/routes/agents.js +22 -100
  15. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +12 -9
  16. package/node_modules/@groove-dev/daemon/src/teams.js +7 -1
  17. package/node_modules/@groove-dev/daemon/src/tokentracker.js +46 -6
  18. package/node_modules/@groove-dev/daemon/test/claude-code-provider.test.js +52 -0
  19. package/node_modules/@groove-dev/daemon/test/innerchat.test.js +197 -111
  20. package/node_modules/@groove-dev/daemon/test/journalist.test.js +4 -2
  21. package/node_modules/@groove-dev/daemon/test/rename.test.js +108 -0
  22. package/node_modules/@groove-dev/daemon/test/rotator.test.js +131 -0
  23. package/node_modules/@groove-dev/daemon/test/tokentracker.test.js +4 -0
  24. package/node_modules/@groove-dev/gui/dist/assets/{index-DTFtRtkx.css → index-CiOy7wVS.css} +1 -1
  25. package/node_modules/@groove-dev/gui/dist/assets/{index-CTer01Vg.js → index-DPjGBQ5X.js} +225 -225
  26. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  27. package/node_modules/@groove-dev/gui/package.json +1 -1
  28. package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +3 -68
  29. package/node_modules/@groove-dev/gui/src/components/agents/innerchat-relay.jsx +145 -0
  30. package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +3 -1
  31. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-pane.jsx +15 -1
  32. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +33 -1
  33. package/node_modules/@groove-dev/gui/src/stores/groove.js +29 -44
  34. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +27 -4
  35. package/package.json +1 -1
  36. package/packages/cli/package.json +1 -1
  37. package/packages/daemon/package.json +1 -1
  38. package/packages/daemon/scripts/repair-tokens.mjs +78 -0
  39. package/packages/daemon/src/api.js +1 -1
  40. package/packages/daemon/src/deliver.js +130 -0
  41. package/packages/daemon/src/index.js +8 -3
  42. package/packages/daemon/src/innerchat.js +213 -62
  43. package/packages/daemon/src/journalist.js +15 -8
  44. package/packages/daemon/src/process.js +59 -34
  45. package/packages/daemon/src/providers/claude-code.js +9 -1
  46. package/packages/daemon/src/registry.js +5 -1
  47. package/packages/daemon/src/rename.js +72 -0
  48. package/packages/daemon/src/rotator.js +62 -5
  49. package/packages/daemon/src/routes/agents.js +22 -100
  50. package/packages/daemon/src/routes/innerchat.js +12 -9
  51. package/packages/daemon/src/teams.js +7 -1
  52. package/packages/daemon/src/tokentracker.js +46 -6
  53. package/packages/gui/dist/assets/{index-DTFtRtkx.css → index-CiOy7wVS.css} +1 -1
  54. package/packages/gui/dist/assets/{index-CTer01Vg.js → index-DPjGBQ5X.js} +225 -225
  55. package/packages/gui/dist/index.html +2 -2
  56. package/packages/gui/package.json +1 -1
  57. package/packages/gui/src/components/agents/agent-panel.jsx +3 -68
  58. package/packages/gui/src/components/agents/innerchat-relay.jsx +145 -0
  59. package/packages/gui/src/components/chat/chat-messages.jsx +3 -1
  60. package/packages/gui/src/components/fleet/fleet-pane.jsx +15 -1
  61. package/packages/gui/src/components/fleet/fleet-sidebar.jsx +33 -1
  62. package/packages/gui/src/stores/groove.js +29 -44
  63. package/packages/gui/src/stores/slices/agents-slice.js +27 -4
@@ -320,10 +320,19 @@ const PERMISSION_PROMPTS = {
320
320
  supervised: null, // Maps to auto (supervised removed — too expensive)
321
321
  };
322
322
 
323
- function sanitizeFilename(name) {
323
+ export 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
@@ -1146,7 +1155,7 @@ For normal file edits within your scope, proceed without review.
1146
1155
  // Set up log capture (shared between CLI and agent loop paths)
1147
1156
  const logDir = resolve(this.daemon.grooveDir, 'logs');
1148
1157
  mkdirSync(logDir, { recursive: true });
1149
- const logPath = resolve(logDir, `${sanitizeFilename(agent.name)}.log`);
1158
+ const logPath = agentLogPath(this.daemon.grooveDir, agent);
1150
1159
  const logStream = createWriteStream(logPath, { flags: 'a', mode: 0o600 });
1151
1160
 
1152
1161
  // Inject API key from credential store for agent-loop providers
@@ -1567,38 +1576,54 @@ For normal file edits within your scope, proceed without review.
1567
1576
 
1568
1577
  // Session result data (cost, duration, turns)
1569
1578
  if (output.type === 'result') {
1570
- // Phantom-interrupt abort: on --resume with an orphaned background shell
1571
- // task, the CLI injects a synthetic "[Request interrupted by user]" that
1572
- // aborts the turn before the model is ever called. Fingerprint is an
1573
- // error result that never reached the API (apiDurationMs === 0, so zero
1574
- // cost). Retrying is free and succeeds ~94% of the time — treating it as
1575
- // a normal result is what made user messages vanish silently.
1576
- if (output.isError && output.apiDurationMs === 0) {
1577
- if (this._retryDroppedTurn(agentId, agent, output)) return;
1578
- }
1579
- // A turn completed for real the user's message is safely delivered.
1580
- this._pendingUserMessage.delete(agentId);
1581
-
1582
- tokens.recordResult(agentId, {
1583
- costUsd: output.cost, durationMs: output.duration, turns: output.turns,
1584
- });
1585
- if (output.cost) updates.costUsd = (agent.costUsd || 0) + output.cost;
1586
- if (output.duration) updates.durationMs = output.duration;
1587
- if (output.turns) updates.turns = output.turns;
1588
-
1589
- // Claude Code sometimes hangs after emitting the result event — the
1590
- // process stays alive instead of exiting. Record that the result
1591
- // arrived so exit handlers know this was a successful completion even
1592
- // if we have to SIGTERM the process. After a 5s grace period, force-
1593
- // kill any process that hasn't exited on its own.
1594
- this._resultReceived.add(agentId);
1595
- const handle = this.handles.get(agentId);
1596
- if (handle?.proc && typeof handle.proc.kill === 'function') {
1597
- setTimeout(() => {
1598
- if (this.handles.has(agentId) && this._resultReceived.has(agentId)) {
1599
- try { handle.proc.kill('SIGTERM'); } catch {}
1600
- }
1601
- }, 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
+ }
1602
1627
  }
1603
1628
  }
1604
1629
 
@@ -275,7 +275,15 @@ export class ClaudeCodeProvider extends Provider {
275
275
 
276
276
  // Merge events: prefer content-bearing events (activity/result) over usage/session.
277
277
  // Accumulate token counts across all events in this chunk.
278
- 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];
279
287
  const merged = { ...content };
280
288
 
281
289
  let totalTokens = 0;
@@ -79,12 +79,16 @@ export class Registry extends EventEmitter {
79
79
  return Array.from(this.agents.values());
80
80
  }
81
81
 
82
- update(id, updates) {
82
+ update(id, updates, { allowRename = false } = {}) {
83
83
  const agent = this.agents.get(id);
84
84
  if (!agent) return null;
85
85
 
86
86
  // Only allow known fields to prevent prototype pollution
87
87
  for (const key of Object.keys(updates)) {
88
+ // Logs, personalities and agent-files are keyed by name — renaming
89
+ // without migrating them orphans the lot, and the GC then deletes it.
90
+ // renameAgent() (rename.js) does the migration and opts in here.
91
+ if (key === 'name' && !allowRename) continue;
88
92
  if (SAFE_FIELDS.has(key)) {
89
93
  agent[key] = updates[key];
90
94
  }
@@ -0,0 +1,72 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ import { resolve } from 'path';
4
+ import { existsSync, renameSync } from 'fs';
5
+ import { sanitizeFilename } from './process.js';
6
+
7
+ /**
8
+ * Rename an agent, migrating everything keyed by its name.
9
+ *
10
+ * Agent logs, personalities and scratch files are keyed by NAME rather than id
11
+ * on purpose — rotation mints a new id, and name-keying is what carries an
12
+ * agent's history across it (see agentLogPath in process.js). The cost is that
13
+ * a bare rename orphans all of it, and the log GC then deletes the orphan. So
14
+ * a rename has to move those artifacts itself.
15
+ *
16
+ * Note the live process keeps the old name in its env (GROOVE_AGENT_NAME) and
17
+ * system prompt until it next respawns — the daemon-side view is what changes.
18
+ */
19
+ export function renameAgent(daemon, agentId, newName) {
20
+ const agent = daemon.registry.get(agentId);
21
+ if (!agent) throw new Error('Agent not found');
22
+
23
+ const trimmed = String(newName || '').trim();
24
+ if (!trimmed) throw new Error('name is required');
25
+
26
+ // The name becomes a path segment (agent-files/<name>, personalities/<name>.md),
27
+ // so anything with a separator or a dot-segment would escape the directory.
28
+ if (!/^[A-Za-z0-9._-]+$/.test(trimmed) || /^\.+$/.test(trimmed)) {
29
+ throw new Error('name may only contain letters, numbers, dots, dashes and underscores');
30
+ }
31
+ if (trimmed.length > 64) throw new Error('name must be 64 characters or fewer');
32
+
33
+ if (trimmed === agent.name) return agent;
34
+
35
+ // Two agents sharing a name means two agents sharing one log file.
36
+ const collision = daemon.registry.getAll()
37
+ .some((a) => a.id !== agentId && a.name === trimmed);
38
+ if (collision) throw new Error(`An agent named ${trimmed} already exists`);
39
+
40
+ const oldName = agent.name;
41
+ const { grooveDir, projectDir } = daemon;
42
+
43
+ const moves = [
44
+ // Raw log — the one whose loss breaks chat resume and synthesis.
45
+ [resolve(grooveDir, 'logs', `${sanitizeFilename(oldName)}.log`),
46
+ resolve(grooveDir, 'logs', `${sanitizeFilename(trimmed)}.log`)],
47
+ [resolve(grooveDir, 'personalities', `${oldName}.md`),
48
+ resolve(grooveDir, 'personalities', `${trimmed}.md`)],
49
+ [resolve(projectDir, 'agent-files', oldName),
50
+ resolve(projectDir, 'agent-files', trimmed)],
51
+ ];
52
+
53
+ const moved = [];
54
+ try {
55
+ for (const [from, to] of moves) {
56
+ if (!existsSync(from) || existsSync(to)) continue;
57
+ renameSync(from, to);
58
+ moved.push([from, to]);
59
+ }
60
+ } catch (err) {
61
+ // Roll back so a partial migration can't leave artifacts split across
62
+ // two names — that's the state the GC would then eat.
63
+ for (const [from, to] of moved.reverse()) {
64
+ try { renameSync(to, from); } catch { /* best effort */ }
65
+ }
66
+ throw new Error(`Rename failed while migrating files: ${err.message}`);
67
+ }
68
+
69
+ const updated = daemon.registry.update(agentId, { name: trimmed }, { allowRename: true });
70
+ daemon.audit.log('agent.rename', { id: agentId, from: oldName, to: trimmed });
71
+ return updated;
72
+ }
@@ -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
  }
@@ -5,6 +5,8 @@ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSy
5
5
  import { validateAgentConfig, validateReasoningEffort, validateVerbosity } from '../validate.js';
6
6
  import { ROLE_INTEGRATIONS, wrapWithRoleReminder } from '../process.js';
7
7
  import { getProvider } from '../providers/index.js';
8
+ import { deliverInstruction } from '../deliver.js';
9
+ import { renameAgent } from '../rename.js';
8
10
 
9
11
  export function registerAgentRoutes(app, daemon) {
10
12
  // List all agents
@@ -56,9 +58,21 @@ export function registerAgentRoutes(app, daemon) {
56
58
 
57
59
  // Update agent
58
60
  app.patch('/api/agents/:id', (req, res) => {
59
- const agent = daemon.registry.update(req.params.id, req.body);
60
- if (!agent) return res.status(404).json({ error: 'Agent not found' });
61
- res.json(agent);
61
+ try {
62
+ const { name, ...rest } = req.body || {};
63
+
64
+ // Renames migrate name-keyed files, so they can't go through the plain
65
+ // field update — registry.update ignores `name` without the opt-in.
66
+ let agent = daemon.registry.get(req.params.id);
67
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
68
+
69
+ if (Object.keys(rest).length) agent = daemon.registry.update(req.params.id, rest);
70
+ if (typeof name === 'string') agent = renameAgent(daemon, req.params.id, name);
71
+
72
+ res.json(agent);
73
+ } catch (err) {
74
+ res.status(400).json({ error: err.message });
75
+ }
62
76
  });
63
77
 
64
78
  // Kill an agent (add ?purge=true to also remove from registry)
@@ -426,104 +440,12 @@ export function registerAgentRoutes(app, daemon) {
426
440
  }
427
441
  }
428
442
 
429
- // Record user feedback so the journalist can include it in future agent context
430
- if (daemon.journalist) daemon.journalist.recordUserFeedback(agent, finalMessage);
431
- if (daemon.rotator) daemon.rotator.recordUserMessage(req.params.id);
432
-
433
- // Agent loop path — send message directly to the running loop
434
- const wrappedMessage = wrapWithRoleReminder(agent.role, finalMessage);
435
- if (daemon.processes.hasAgentLoop(req.params.id)) {
436
- const sent = await daemon.processes.sendMessage(req.params.id, wrappedMessage);
437
- if (sent) {
438
- daemon.audit.log('agent.chat', { id: req.params.id });
439
- return res.json({ id: agent.id, status: 'message_sent' });
440
- }
441
- // Loop exists but not running — fall through to resume/rotate
442
- }
443
-
444
- // One-shot providers (groove-network): kill any running instance and
445
- // respawn with the user's message as --prompt. No handoff brief, no
446
- // session resume, no message queue — each chat message is a fresh spawn.
447
- const provider = getProvider(agent.provider);
448
- if (provider?.constructor?.isOneShot) {
449
- const oldConfig = { ...agent };
450
- if (daemon.processes.isRunning(req.params.id)) {
451
- await daemon.processes.kill(req.params.id);
452
- }
453
- daemon.registry.remove(req.params.id, { silent: true });
454
- daemon.locks.release(req.params.id);
455
-
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
- }
473
- daemon.audit.log('agent.instruct', { id: req.params.id, newId: newAgent.id, resumed: false });
474
- return res.json(newAgent);
475
- }
476
-
477
- // Non-interactive CLI providers (e.g. Gemini): respawn with the new
478
- // message as the prompt, preserving original introContext. These providers
479
- // run one prompt per spawn and cannot resume sessions.
480
- if (provider?.constructor?.nonInteractive && !daemon.processes.isRunning(req.params.id)) {
481
- const oldConfig = { ...agent };
482
- daemon.registry.remove(req.params.id, { silent: true });
483
- daemon.locks.release(req.params.id);
484
-
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
- }
503
- daemon.audit.log('agent.instruct', { id: req.params.id, newId: newAgent.id, resumed: false });
504
- return res.json(newAgent);
505
- }
506
-
507
- // Running CLI agent (no loop) — queue the message for delivery after
508
- // the current task completes instead of killing and respawning.
509
- if (daemon.processes.isRunning(req.params.id)) {
510
- daemon.processes.queueMessage(req.params.id, wrappedMessage);
511
- daemon.audit.log('agent.chat.queued', { id: req.params.id });
512
- return res.json({ id: agent.id, status: 'message_queued' });
513
- }
443
+ const result = await deliverInstruction(daemon, req.params.id, finalMessage);
514
444
 
515
- // CLI agent path session resume or rotation.
516
- // Force rotation (fresh session + handoff brief) past the resume ceiling:
517
- // reviving a >5M-token claude session has crashed the CLI mid-HTTP-parse
518
- // (V8 fatal in JsonStringifier) the rotator's handoff brief sidesteps that.
519
- const SESSION_RESUME_CEILING = 5_000_000;
520
- const resumed = !!agent.sessionId && (agent.tokensUsed || 0) < SESSION_RESUME_CEILING;
521
- const newAgent = resumed
522
- ? await daemon.processes.resume(req.params.id, wrappedMessage)
523
- : await daemon.rotator.rotate(req.params.id, { additionalPrompt: wrappedMessage });
524
-
525
- daemon.audit.log('agent.instruct', { id: req.params.id, newId: newAgent.id, resumed });
526
- res.json(newAgent);
445
+ // Respawn paths return the fresh agent record; in-place delivery just
446
+ // acknowledges with a status the GUI uses to show queued vs sent.
447
+ if (result.agent && result.agentId !== req.params.id) return res.json(result.agent);
448
+ res.json({ id: agent.id, status: result.status });
527
449
  } catch (err) {
528
450
  res.status(400).json({ error: err.message });
529
451
  }
@@ -1,30 +1,33 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
 
3
3
  export function registerInnerChatRoutes(app, daemon) {
4
+ // Relay a message from one agent to another. Opens a thread, or continues
5
+ // an existing one when threadId is supplied.
4
6
  app.post('/api/innerchat/send', async (req, res) => {
5
7
  try {
6
- const { from, to, message } = req.body;
8
+ const { from, to, message, threadId } = req.body;
7
9
  if (!from || typeof from !== 'string') return res.status(400).json({ error: 'from (agent ID) is required' });
8
10
  if (!to || typeof to !== 'string') return res.status(400).json({ error: 'to (agent ID) is required' });
9
11
  if (!message || typeof message !== 'string' || !message.trim()) return res.status(400).json({ error: 'message is required' });
10
12
  if (from === to) return res.status(400).json({ error: 'cannot send a message to yourself' });
13
+ if (threadId && typeof threadId !== 'string') return res.status(400).json({ error: 'threadId must be a string' });
11
14
 
12
- const msg = await daemon.innerchat.send(from, to, message.trim());
13
- res.json(msg);
15
+ const result = await daemon.innerchat.send(from, to, message.trim(), threadId || null);
16
+ res.json(result);
14
17
  } catch (err) {
15
18
  res.status(400).json({ error: err.message });
16
19
  }
17
20
  });
18
21
 
19
- app.get('/api/innerchat/messages', (req, res) => {
22
+ app.get('/api/innerchat/threads', (req, res) => {
20
23
  const { agentId } = req.query;
21
- res.json({ messages: daemon.innerchat.getMessages(agentId || null) });
24
+ res.json({ threads: daemon.innerchat.getThreads(agentId || null) });
22
25
  });
23
26
 
24
- app.get('/api/innerchat/messages/:id', (req, res) => {
25
- const msg = daemon.innerchat.getMessage(req.params.id);
26
- if (!msg) return res.status(404).json({ error: 'Message not found' });
27
- res.json(msg);
27
+ app.get('/api/innerchat/threads/:id', (req, res) => {
28
+ const thread = daemon.innerchat.getThread(req.params.id);
29
+ if (!thread) return res.status(404).json({ error: 'Thread not found' });
30
+ res.json(thread);
28
31
  });
29
32
 
30
33
  app.get('/api/innerchat/pending/:agentId', (req, res) => {
@@ -148,11 +148,17 @@ export class Teams {
148
148
  renameSync(oldWorkingDir, newWorkingDir);
149
149
  team.workingDir = newWorkingDir;
150
150
 
151
- // Update all agents in this team with the new working directory
151
+ // Repoint every agent under the old tree, not just those sitting at
152
+ // its root — an agent in a subdirectory would otherwise keep a path
153
+ // that no longer exists.
152
154
  const agents = this.daemon.registry.getAll().filter((a) => a.teamId === id);
153
155
  for (const agent of agents) {
156
+ if (!agent.workingDir) continue;
154
157
  if (agent.workingDir === oldWorkingDir) {
155
158
  this.daemon.registry.update(agent.id, { workingDir: newWorkingDir });
159
+ } else if (agent.workingDir.startsWith(`${oldWorkingDir}/`)) {
160
+ const suffix = agent.workingDir.slice(oldWorkingDir.length);
161
+ this.daemon.registry.update(agent.id, { workingDir: `${newWorkingDir}${suffix}` });
156
162
  }
157
163
  }
158
164
  } catch (err) {