groove-dev 0.8.0

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 (84) hide show
  1. package/CLAUDE.md +197 -0
  2. package/LICENSE +40 -0
  3. package/README.md +115 -0
  4. package/docs/GUI_DESIGN_SPEC.md +402 -0
  5. package/favicon.png +0 -0
  6. package/groove-logo-short.png +0 -0
  7. package/groove-logo.png +0 -0
  8. package/package.json +70 -0
  9. package/packages/cli/bin/groove.js +98 -0
  10. package/packages/cli/package.json +15 -0
  11. package/packages/cli/src/client.js +25 -0
  12. package/packages/cli/src/commands/agents.js +38 -0
  13. package/packages/cli/src/commands/approve.js +50 -0
  14. package/packages/cli/src/commands/config.js +35 -0
  15. package/packages/cli/src/commands/kill.js +15 -0
  16. package/packages/cli/src/commands/nuke.js +19 -0
  17. package/packages/cli/src/commands/providers.js +40 -0
  18. package/packages/cli/src/commands/rotate.js +16 -0
  19. package/packages/cli/src/commands/spawn.js +91 -0
  20. package/packages/cli/src/commands/start.js +31 -0
  21. package/packages/cli/src/commands/status.js +38 -0
  22. package/packages/cli/src/commands/stop.js +15 -0
  23. package/packages/cli/src/commands/team.js +77 -0
  24. package/packages/daemon/package.json +18 -0
  25. package/packages/daemon/src/adaptive.js +237 -0
  26. package/packages/daemon/src/api.js +533 -0
  27. package/packages/daemon/src/classifier.js +126 -0
  28. package/packages/daemon/src/credentials.js +121 -0
  29. package/packages/daemon/src/firstrun.js +93 -0
  30. package/packages/daemon/src/index.js +208 -0
  31. package/packages/daemon/src/introducer.js +238 -0
  32. package/packages/daemon/src/journalist.js +600 -0
  33. package/packages/daemon/src/lockmanager.js +58 -0
  34. package/packages/daemon/src/pm.js +108 -0
  35. package/packages/daemon/src/process.js +361 -0
  36. package/packages/daemon/src/providers/aider.js +72 -0
  37. package/packages/daemon/src/providers/base.js +38 -0
  38. package/packages/daemon/src/providers/claude-code.js +167 -0
  39. package/packages/daemon/src/providers/codex.js +68 -0
  40. package/packages/daemon/src/providers/gemini.js +62 -0
  41. package/packages/daemon/src/providers/index.js +38 -0
  42. package/packages/daemon/src/providers/ollama.js +94 -0
  43. package/packages/daemon/src/registry.js +89 -0
  44. package/packages/daemon/src/rotator.js +185 -0
  45. package/packages/daemon/src/router.js +132 -0
  46. package/packages/daemon/src/state.js +34 -0
  47. package/packages/daemon/src/supervisor.js +178 -0
  48. package/packages/daemon/src/teams.js +203 -0
  49. package/packages/daemon/src/terminal/base.js +27 -0
  50. package/packages/daemon/src/terminal/generic.js +27 -0
  51. package/packages/daemon/src/terminal/tmux.js +64 -0
  52. package/packages/daemon/src/tokentracker.js +124 -0
  53. package/packages/daemon/src/validate.js +122 -0
  54. package/packages/daemon/templates/api-builder.json +18 -0
  55. package/packages/daemon/templates/fullstack.json +18 -0
  56. package/packages/daemon/templates/monorepo.json +24 -0
  57. package/packages/gui/dist/assets/index-BO95Rm1F.js +73 -0
  58. package/packages/gui/dist/assets/index-CPzm9ZE9.css +1 -0
  59. package/packages/gui/dist/favicon.png +0 -0
  60. package/packages/gui/dist/groove-logo-short.png +0 -0
  61. package/packages/gui/dist/groove-logo.png +0 -0
  62. package/packages/gui/dist/index.html +13 -0
  63. package/packages/gui/index.html +12 -0
  64. package/packages/gui/package.json +22 -0
  65. package/packages/gui/public/favicon.png +0 -0
  66. package/packages/gui/public/groove-logo-short.png +0 -0
  67. package/packages/gui/public/groove-logo.png +0 -0
  68. package/packages/gui/src/App.jsx +215 -0
  69. package/packages/gui/src/components/AgentActions.jsx +347 -0
  70. package/packages/gui/src/components/AgentChat.jsx +479 -0
  71. package/packages/gui/src/components/AgentNode.jsx +117 -0
  72. package/packages/gui/src/components/AgentPanel.jsx +115 -0
  73. package/packages/gui/src/components/AgentStats.jsx +333 -0
  74. package/packages/gui/src/components/ApprovalQueue.jsx +156 -0
  75. package/packages/gui/src/components/EmptyState.jsx +100 -0
  76. package/packages/gui/src/components/SpawnPanel.jsx +515 -0
  77. package/packages/gui/src/components/TeamSelector.jsx +162 -0
  78. package/packages/gui/src/main.jsx +9 -0
  79. package/packages/gui/src/stores/groove.js +247 -0
  80. package/packages/gui/src/theme.css +67 -0
  81. package/packages/gui/src/views/AgentTree.jsx +148 -0
  82. package/packages/gui/src/views/CommandCenter.jsx +620 -0
  83. package/packages/gui/src/views/JournalistFeed.jsx +149 -0
  84. package/packages/gui/vite.config.js +19 -0
@@ -0,0 +1,167 @@
1
+ // GROOVE — Claude Code Provider
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { execSync } from 'child_process';
5
+ import { writeFileSync, readFileSync, existsSync } from 'fs';
6
+ import { resolve } from 'path';
7
+ import { Provider } from './base.js';
8
+
9
+ export class ClaudeCodeProvider extends Provider {
10
+ static name = 'claude-code';
11
+ static displayName = 'Claude Code';
12
+ static command = 'claude';
13
+ static authType = 'subscription';
14
+ static models = [
15
+ { id: 'claude-opus-4-6', name: 'Claude Opus 4.6', tier: 'heavy', contextWindow: 1_000_000 },
16
+ { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', tier: 'medium', contextWindow: 200_000 },
17
+ { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', tier: 'light', contextWindow: 200_000 },
18
+ ];
19
+
20
+ static isInstalled() {
21
+ try {
22
+ execSync('which claude', { stdio: 'ignore' });
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ static installCommand() {
30
+ return 'npm i -g @anthropic-ai/claude-code';
31
+ }
32
+
33
+ buildSpawnCommand(agent) {
34
+ // Claude Code interactive mode:
35
+ // claude [options] [prompt]
36
+ //
37
+ // GROOVE spawns claude with:
38
+ // --dangerously-skip-permissions (autonomous operation)
39
+ // --output-format stream-json (structured stdout for parsing)
40
+ // --verbose (richer output for journalist)
41
+ //
42
+ // The initial prompt is passed as a positional argument.
43
+ // GROOVE context is injected via an append-only section in CLAUDE.md.
44
+
45
+ const args = ['--output-format', 'stream-json', '--verbose', '--dangerously-skip-permissions'];
46
+
47
+ if (agent.model) {
48
+ args.push('--model', agent.model);
49
+ }
50
+
51
+ // Pass the initial prompt as positional arg (includes GROOVE context)
52
+ const fullPrompt = this.buildFullPrompt(agent);
53
+ if (fullPrompt) {
54
+ args.push(fullPrompt);
55
+ }
56
+
57
+ return {
58
+ command: 'claude',
59
+ args,
60
+ env: {},
61
+ };
62
+ }
63
+
64
+ buildHeadlessCommand(prompt, model) {
65
+ const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose'];
66
+ if (model) args.push('--model', model);
67
+ return { command: 'claude', args, env: {} };
68
+ }
69
+
70
+ buildFullPrompt(agent) {
71
+ const parts = [];
72
+
73
+ // Inject GROOVE context so the agent knows its role and team
74
+ if (agent.introContext) {
75
+ parts.push(agent.introContext);
76
+ }
77
+
78
+ // User's actual task prompt
79
+ if (agent.prompt) {
80
+ parts.push(`## Your Task\n\n${agent.prompt}`);
81
+ }
82
+
83
+ // Scope awareness
84
+ if (agent.scope && agent.scope.length > 0) {
85
+ parts.push(
86
+ `## Scope Rules\n\nYou MUST only modify files matching these patterns: ${agent.scope.join(', ')}. ` +
87
+ `Do not touch files outside your scope — other agents own them.`
88
+ );
89
+ }
90
+
91
+ return parts.join('\n\n');
92
+ }
93
+
94
+ switchModel(agent, newModel) {
95
+ // Claude Code supports mid-session model switching
96
+ return true;
97
+ }
98
+
99
+ parseOutput(line) {
100
+ // Claude Code stream-json outputs one JSON object per line.
101
+ // Relevant message types:
102
+ // { type: "assistant", message: {...}, session_id: "..." }
103
+ // { type: "result", result: "...", session_id: "..." }
104
+ // { type: "system", message: "..." }
105
+ const lines = line.split('\n').filter(Boolean);
106
+ const events = [];
107
+
108
+ for (const l of lines) {
109
+ try {
110
+ const data = JSON.parse(l);
111
+
112
+ if (data.type === 'assistant') {
113
+ events.push({
114
+ type: 'activity',
115
+ subtype: 'assistant',
116
+ data: data.message?.content || '',
117
+ tokensUsed: data.message?.usage?.output_tokens || 0,
118
+ model: data.message?.model,
119
+ });
120
+ } else if (data.type === 'result') {
121
+ events.push({
122
+ type: 'result',
123
+ data: data.result,
124
+ tokensUsed: data.total_cost_usd ? undefined : 0,
125
+ cost: data.total_cost_usd,
126
+ duration: data.duration_ms,
127
+ turns: data.num_turns,
128
+ });
129
+ } else if (data.type === 'system' && data.subtype === 'usage') {
130
+ // Use actual context window size from model metadata, not hardcoded 200K
131
+ // Opus has 1M, Sonnet/Haiku have 200K — rotation must account for this
132
+ const totalTokens = (data.usage?.cache_read_input_tokens || 0) + (data.usage?.input_tokens || 0);
133
+ const modelId = data.model || events.find((e) => e.model)?.model;
134
+ const modelMeta = ClaudeCodeProvider.models.find((m) => m.id === modelId);
135
+ const contextWindow = modelMeta?.contextWindow || 200_000;
136
+ events.push({
137
+ type: 'usage',
138
+ contextUsage: totalTokens > 0 ? totalTokens / contextWindow : undefined,
139
+ });
140
+ }
141
+ } catch {
142
+ // Not JSON — ignore raw text lines in stream-json mode
143
+ }
144
+ }
145
+
146
+ if (events.length === 0) return null;
147
+
148
+ // Merge events: accumulate tokens across all events in this chunk,
149
+ // but return the most significant event type (usage > result > activity)
150
+ const merged = events[events.length - 1];
151
+ let totalTokens = 0;
152
+ for (const e of events) {
153
+ if (e.tokensUsed > 0) totalTokens += e.tokensUsed;
154
+ }
155
+ if (totalTokens > 0) merged.tokensUsed = totalTokens;
156
+
157
+ return merged;
158
+ }
159
+
160
+ injectContext(agent, contextMarkdown) {
161
+ // For Claude Code, inject context by writing to a .groove/context/<agent>.md file
162
+ // and referencing it. Claude Code auto-reads CLAUDE.md, so we append there.
163
+ // But we don't want to pollute the user's CLAUDE.md permanently — we use
164
+ // the append-only GROOVE section approach.
165
+ return contextMarkdown;
166
+ }
167
+ }
@@ -0,0 +1,68 @@
1
+ // GROOVE — Codex Provider (OpenAI)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { execSync } from 'child_process';
5
+ import { Provider } from './base.js';
6
+
7
+ export class CodexProvider extends Provider {
8
+ static name = 'codex';
9
+ static displayName = 'Codex';
10
+ static command = 'codex';
11
+ static authType = 'api-key';
12
+ static envKey = 'OPENAI_API_KEY';
13
+ static models = [
14
+ { id: 'o3', name: 'o3', tier: 'heavy' },
15
+ { id: 'o4-mini', name: 'o4-mini', tier: 'medium' },
16
+ ];
17
+
18
+ static isInstalled() {
19
+ try {
20
+ execSync('which codex', { stdio: 'ignore' });
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ static installCommand() {
28
+ return 'npm i -g @openai/codex';
29
+ }
30
+
31
+ buildSpawnCommand(agent) {
32
+ const args = [];
33
+
34
+ if (agent.model) args.push('--model', agent.model);
35
+
36
+ // Codex uses full-auto approval mode for autonomous operation
37
+ args.push('--approval-mode', 'full-auto');
38
+
39
+ if (agent.prompt) args.push(agent.prompt);
40
+
41
+ return {
42
+ command: 'codex',
43
+ args,
44
+ env: agent.apiKey ? { OPENAI_API_KEY: agent.apiKey } : {},
45
+ };
46
+ }
47
+
48
+ buildHeadlessCommand(prompt, model) {
49
+ const args = ['exec', prompt];
50
+ if (model) args.push('--model', model);
51
+ return { command: 'codex', args, env: {} };
52
+ }
53
+
54
+ switchModel() {
55
+ return false; // Codex doesn't support mid-session model switch
56
+ }
57
+
58
+ parseOutput(line) {
59
+ // Codex outputs plain text by default
60
+ const trimmed = line.trim();
61
+ if (!trimmed) return null;
62
+
63
+ return {
64
+ type: 'activity',
65
+ data: trimmed,
66
+ };
67
+ }
68
+ }
@@ -0,0 +1,62 @@
1
+ // GROOVE — Gemini CLI Provider (Google)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { execSync } from 'child_process';
5
+ import { Provider } from './base.js';
6
+
7
+ export class GeminiProvider extends Provider {
8
+ static name = 'gemini';
9
+ static displayName = 'Gemini CLI';
10
+ static command = 'gemini';
11
+ static authType = 'api-key';
12
+ static envKey = 'GEMINI_API_KEY';
13
+ static models = [
14
+ { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', tier: 'heavy' },
15
+ { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', tier: 'medium' },
16
+ ];
17
+
18
+ static isInstalled() {
19
+ try {
20
+ execSync('which gemini', { stdio: 'ignore' });
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ static installCommand() {
28
+ return 'npm i -g @anthropic-ai/gemini-cli';
29
+ }
30
+
31
+ buildSpawnCommand(agent) {
32
+ const args = [];
33
+
34
+ if (agent.model) args.push('--model', agent.model);
35
+ if (agent.prompt) args.push(agent.prompt);
36
+
37
+ // Sandbox mode off for full filesystem access
38
+ args.push('--sandbox', 'false');
39
+
40
+ return {
41
+ command: 'gemini',
42
+ args,
43
+ env: agent.apiKey ? { GEMINI_API_KEY: agent.apiKey } : {},
44
+ };
45
+ }
46
+
47
+ buildHeadlessCommand(prompt, model) {
48
+ const args = ['-p', prompt];
49
+ if (model) args.push('--model', model);
50
+ return { command: 'gemini', args, env: {} };
51
+ }
52
+
53
+ switchModel() {
54
+ return false; // Gemini CLI doesn't support mid-session switch
55
+ }
56
+
57
+ parseOutput(line) {
58
+ const trimmed = line.trim();
59
+ if (!trimmed) return null;
60
+ return { type: 'activity', data: trimmed };
61
+ }
62
+ }
@@ -0,0 +1,38 @@
1
+ // GROOVE — Provider Registry
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { ClaudeCodeProvider } from './claude-code.js';
5
+ import { CodexProvider } from './codex.js';
6
+ import { GeminiProvider } from './gemini.js';
7
+ import { AiderProvider } from './aider.js';
8
+ import { OllamaProvider } from './ollama.js';
9
+
10
+ const providers = {
11
+ 'claude-code': new ClaudeCodeProvider(),
12
+ 'codex': new CodexProvider(),
13
+ 'gemini': new GeminiProvider(),
14
+ 'aider': new AiderProvider(),
15
+ 'ollama': new OllamaProvider(),
16
+ };
17
+
18
+ export function getProvider(name) {
19
+ return providers[name] || null;
20
+ }
21
+
22
+ export function listProviders() {
23
+ return Object.entries(providers).map(([key, p]) => ({
24
+ id: key,
25
+ name: p.constructor.displayName,
26
+ installed: p.constructor.isInstalled(),
27
+ authType: p.constructor.authType,
28
+ envKey: p.constructor.envKey || null,
29
+ models: p.constructor.models,
30
+ installCommand: p.constructor.installCommand(),
31
+ canHotSwap: p.switchModel ? p.switchModel() : false,
32
+ hardwareRequirements: p.constructor.hardwareRequirements?.() || null,
33
+ }));
34
+ }
35
+
36
+ export function getInstalledProviders() {
37
+ return listProviders().filter((p) => p.installed);
38
+ }
@@ -0,0 +1,94 @@
1
+ // GROOVE — Ollama Provider (Local Models)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { execSync } from 'child_process';
5
+ import { Provider } from './base.js';
6
+
7
+ export class OllamaProvider extends Provider {
8
+ static name = 'ollama';
9
+ static displayName = 'Ollama (Local)';
10
+ static command = 'ollama';
11
+ static authType = 'local';
12
+ static models = [
13
+ { id: 'qwen2.5-coder:32b', name: 'Qwen 2.5 Coder 32B', tier: 'heavy' },
14
+ { id: 'qwen2.5-coder:7b', name: 'Qwen 2.5 Coder 7B', tier: 'medium' },
15
+ { id: 'codellama:13b', name: 'Code Llama 13B', tier: 'medium' },
16
+ { id: 'deepseek-coder-v2:16b', name: 'DeepSeek Coder V2', tier: 'medium' },
17
+ ];
18
+
19
+ static isInstalled() {
20
+ try {
21
+ execSync('which ollama', { stdio: 'ignore' });
22
+ return true;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ static installCommand() {
29
+ return 'curl -fsSL https://ollama.ai/install.sh | sh';
30
+ }
31
+
32
+ static hardwareRequirements() {
33
+ return {
34
+ minRAM: 8,
35
+ recommendedRAM: 16,
36
+ gpuRecommended: true,
37
+ note: '7B models need ~8GB RAM, 32B models need ~24GB RAM',
38
+ };
39
+ }
40
+
41
+ static getInstalledModels() {
42
+ try {
43
+ const output = execSync('ollama list', { encoding: 'utf8' });
44
+ const lines = output.split('\n').slice(1).filter(Boolean);
45
+ return lines.map((line) => {
46
+ const parts = line.split(/\s+/);
47
+ return { name: parts[0], size: parts[1] };
48
+ });
49
+ } catch {
50
+ return [];
51
+ }
52
+ }
53
+
54
+ buildSpawnCommand(agent) {
55
+ // Ollama works best via Aider as a frontend
56
+ // aider --model ollama/<model> gives a full coding experience
57
+ const model = agent.model || 'qwen2.5-coder:7b';
58
+
59
+ const args = [
60
+ '--model', `ollama/${model}`,
61
+ '--yes-always',
62
+ '--no-auto-commits',
63
+ ];
64
+
65
+ if (agent.prompt) {
66
+ args.push('--message', agent.prompt);
67
+ }
68
+
69
+ return {
70
+ command: 'aider',
71
+ args,
72
+ env: { OLLAMA_API_BASE: 'http://localhost:11434' },
73
+ };
74
+ }
75
+
76
+ buildHeadlessCommand(prompt, model) {
77
+ const m = model || 'qwen2.5-coder:7b';
78
+ return {
79
+ command: 'ollama',
80
+ args: ['run', m, prompt],
81
+ env: {},
82
+ };
83
+ }
84
+
85
+ switchModel() {
86
+ return false; // Needs rotation for model switch
87
+ }
88
+
89
+ parseOutput(line) {
90
+ const trimmed = line.trim();
91
+ if (!trimmed) return null;
92
+ return { type: 'activity', data: trimmed };
93
+ }
94
+ }
@@ -0,0 +1,89 @@
1
+ // GROOVE — Agent Registry
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { EventEmitter } from 'events';
5
+ import { randomUUID } from 'crypto';
6
+
7
+ export class Registry extends EventEmitter {
8
+ constructor(state) {
9
+ super();
10
+ this.state = state;
11
+ this.agents = new Map();
12
+ }
13
+
14
+ add(config) {
15
+ const agent = {
16
+ id: randomUUID().slice(0, 8),
17
+ name: config.name || `${config.role}-${this.agents.size + 1}`,
18
+ role: config.role,
19
+ scope: config.scope || [],
20
+ provider: config.provider || 'claude-code',
21
+ model: config.model || null,
22
+ prompt: config.prompt || '',
23
+ permission: config.permission || 'full',
24
+ workingDir: config.workingDir || process.cwd(),
25
+ status: 'starting',
26
+ pid: null,
27
+ spawnedAt: new Date().toISOString(),
28
+ lastActivity: null,
29
+ tokensUsed: 0,
30
+ contextUsage: 0,
31
+ };
32
+
33
+ this.agents.set(agent.id, agent);
34
+ this.emit('change', this.getAll());
35
+ return agent;
36
+ }
37
+
38
+ get(id) {
39
+ return this.agents.get(id) || null;
40
+ }
41
+
42
+ getAll() {
43
+ return Array.from(this.agents.values());
44
+ }
45
+
46
+ update(id, updates) {
47
+ const agent = this.agents.get(id);
48
+ if (!agent) return null;
49
+
50
+ // Only allow known fields to prevent prototype pollution
51
+ const SAFE_FIELDS = ['status', 'pid', 'tokensUsed', 'contextUsage', 'lastActivity', 'model', 'name', 'routingMode', 'routingReason'];
52
+ for (const key of Object.keys(updates)) {
53
+ if (SAFE_FIELDS.includes(key)) {
54
+ agent[key] = updates[key];
55
+ }
56
+ }
57
+ agent.lastActivity = new Date().toISOString();
58
+ this.emit('change', this.getAll());
59
+ return agent;
60
+ }
61
+
62
+ remove(id) {
63
+ const agent = this.agents.get(id);
64
+ if (!agent) return false;
65
+
66
+ this.agents.delete(id);
67
+ this.emit('change', this.getAll());
68
+ return true;
69
+ }
70
+
71
+ findByRole(role) {
72
+ return this.getAll().filter((a) => a.role === role);
73
+ }
74
+
75
+ findByProvider(provider) {
76
+ return this.getAll().filter((a) => a.provider === provider);
77
+ }
78
+
79
+ restore(agents) {
80
+ for (const agent of agents) {
81
+ agent.status = 'stopped';
82
+ agent.pid = null;
83
+ this.agents.set(agent.id, agent);
84
+ }
85
+ if (agents.length > 0) {
86
+ this.emit('change', this.getAll());
87
+ }
88
+ }
89
+ }
@@ -0,0 +1,185 @@
1
+ // GROOVE — Context Rotation Engine
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { EventEmitter } from 'events';
5
+
6
+ const DEFAULT_THRESHOLD = 0.75; // 75% context usage triggers rotation
7
+ const CHECK_INTERVAL = 15_000; // Check every 15 seconds
8
+
9
+ export class Rotator extends EventEmitter {
10
+ constructor(daemon) {
11
+ super();
12
+ this.daemon = daemon;
13
+ this.interval = null;
14
+ this.rotationHistory = []; // [{ agentId, agentName, oldTokens, timestamp, brief }]
15
+ this.rotating = new Set(); // Agent IDs currently being rotated
16
+ this.enabled = false;
17
+ }
18
+
19
+ start() {
20
+ if (this.interval) return;
21
+ this.enabled = true;
22
+ this.interval = setInterval(() => this.check(), CHECK_INTERVAL);
23
+ console.log(' Rotator started (auto-rotation enabled)');
24
+ }
25
+
26
+ stop() {
27
+ if (this.interval) {
28
+ clearInterval(this.interval);
29
+ this.interval = null;
30
+ }
31
+ this.enabled = false;
32
+ }
33
+
34
+ async check() {
35
+ const agents = this.daemon.registry.getAll();
36
+ const running = agents.filter((a) => a.status === 'running');
37
+
38
+ for (const agent of running) {
39
+ if (this.rotating.has(agent.id)) continue;
40
+
41
+ const threshold = this.daemon.adaptive
42
+ ? this.daemon.adaptive.getThreshold(agent.provider, agent.role)
43
+ : DEFAULT_THRESHOLD;
44
+
45
+ if (agent.contextUsage >= threshold) {
46
+ // Check for natural pause: if agent has been idle for >10s
47
+ const idleMs = agent.lastActivity
48
+ ? Date.now() - new Date(agent.lastActivity).getTime()
49
+ : Infinity;
50
+
51
+ if (idleMs > 10_000) {
52
+ console.log(` Rotator: ${agent.name} at ${Math.round(agent.contextUsage * 100)}% — rotating`);
53
+ await this.rotate(agent.id);
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ async rotate(agentId, options = {}) {
60
+ const { registry, processes, journalist } = this.daemon;
61
+ const agent = registry.get(agentId);
62
+ if (!agent) throw new Error(`Agent ${agentId} not found`);
63
+ if (this.rotating.has(agentId)) throw new Error(`Agent ${agentId} is already rotating`);
64
+
65
+ this.rotating.add(agentId);
66
+
67
+ this.daemon.broadcast({
68
+ type: 'rotation:start',
69
+ agentId,
70
+ agentName: agent.name,
71
+ });
72
+
73
+ try {
74
+ // 1. Record adaptive session so rotation thresholds learn over time
75
+ const classifierEvents = this.daemon.classifier.agentWindows[agentId] || [];
76
+ if (classifierEvents.length > 0) {
77
+ const signals = this.daemon.adaptive.extractSignals(classifierEvents, agent.scope);
78
+ this.daemon.adaptive.recordSession(agent.provider, agent.role, signals);
79
+ }
80
+
81
+ // Clear classifier window for the old agent
82
+ this.daemon.classifier.clearAgent(agentId);
83
+
84
+ // 2. Generate handoff brief from Journalist
85
+ let brief = await journalist.generateHandoffBrief(agent);
86
+
87
+ // Append additional prompt if provided (used by instruct/continue endpoints)
88
+ if (options.additionalPrompt) {
89
+ brief = brief + '\n\n## User Instruction\n\n' + options.additionalPrompt;
90
+ }
91
+
92
+ // 3. Record rotation history
93
+ const record = {
94
+ agentId: agent.id,
95
+ agentName: agent.name,
96
+ role: agent.role,
97
+ provider: agent.provider,
98
+ oldTokens: agent.tokensUsed,
99
+ contextUsage: agent.contextUsage,
100
+ timestamp: new Date().toISOString(),
101
+ };
102
+
103
+ // 4. Kill/clean up the old agent
104
+ // processes.kill handles both alive and dead agents:
105
+ // - alive: sends SIGTERM, waits for exit, removes from registry
106
+ // - dead: just removes from registry and releases locks
107
+ await processes.kill(agentId);
108
+
109
+ // 5. Respawn with handoff brief as the prompt
110
+ // Preserve auto routing mode so the router re-evaluates on respawn
111
+ const routingMode = this.daemon.router.getMode(agentId);
112
+ const respawnModel = routingMode.mode === 'auto' ? 'auto' : agent.model;
113
+
114
+ const newAgent = await processes.spawn({
115
+ role: agent.role,
116
+ scope: agent.scope,
117
+ provider: agent.provider,
118
+ model: respawnModel,
119
+ prompt: brief,
120
+ permission: agent.permission || 'full',
121
+ workingDir: agent.workingDir,
122
+ name: agent.name, // Keep the same name for continuity
123
+ });
124
+
125
+ // Carry cumulative token stats so the dashboard shows lifetime totals
126
+ if (agent.tokensUsed > 0) {
127
+ registry.update(newAgent.id, { tokensUsed: agent.tokensUsed });
128
+ }
129
+
130
+ // Record rotation savings in token tracker
131
+ this.daemon.tokens.recordRotation(agent.id, agent.tokensUsed);
132
+ // Each rotation is a cold-start that the Journalist's handoff brief skips
133
+ this.daemon.tokens.recordColdStartSkipped();
134
+
135
+ record.newAgentId = newAgent.id;
136
+ record.newTokens = 0;
137
+ this.rotationHistory.push(record);
138
+
139
+ // Keep last 100 rotations
140
+ if (this.rotationHistory.length > 100) {
141
+ this.rotationHistory = this.rotationHistory.slice(-100);
142
+ }
143
+
144
+ this.daemon.broadcast({
145
+ type: 'rotation:complete',
146
+ agentId: newAgent.id,
147
+ agentName: newAgent.name,
148
+ oldAgentId: agentId,
149
+ tokensSaved: agent.tokensUsed,
150
+ });
151
+
152
+ this.emit('rotation', record);
153
+
154
+ return newAgent;
155
+ } catch (err) {
156
+ this.daemon.broadcast({
157
+ type: 'rotation:failed',
158
+ agentId,
159
+ error: err.message,
160
+ });
161
+ throw err;
162
+ } finally {
163
+ this.rotating.delete(agentId);
164
+ }
165
+ }
166
+
167
+ isRotating(agentId) {
168
+ return this.rotating.has(agentId);
169
+ }
170
+
171
+ getHistory() {
172
+ return this.rotationHistory;
173
+ }
174
+
175
+ getStats() {
176
+ const totalRotations = this.rotationHistory.length;
177
+ const totalTokensSaved = this.rotationHistory.reduce((sum, r) => sum + r.oldTokens, 0);
178
+ return {
179
+ enabled: this.enabled,
180
+ totalRotations,
181
+ totalTokensSaved,
182
+ rotating: Array.from(this.rotating),
183
+ };
184
+ }
185
+ }