groove-dev 0.20.0 → 0.21.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 (40) hide show
  1. package/node_modules/@groove-dev/daemon/package.json +4 -0
  2. package/node_modules/@groove-dev/daemon/src/api.js +86 -0
  3. package/node_modules/@groove-dev/daemon/src/gateways/base.js +87 -0
  4. package/node_modules/@groove-dev/daemon/src/gateways/discord.js +220 -0
  5. package/node_modules/@groove-dev/daemon/src/gateways/formatter.js +201 -0
  6. package/node_modules/@groove-dev/daemon/src/gateways/manager.js +695 -0
  7. package/node_modules/@groove-dev/daemon/src/gateways/slack.js +165 -0
  8. package/node_modules/@groove-dev/daemon/src/gateways/telegram.js +265 -0
  9. package/node_modules/@groove-dev/daemon/src/index.js +4 -0
  10. package/node_modules/@groove-dev/daemon/src/validate.js +55 -0
  11. package/node_modules/@groove-dev/gui/.groove/codebase-index.json +1 -1
  12. package/node_modules/@groove-dev/gui/.groove/daemon.host +1 -0
  13. package/node_modules/@groove-dev/gui/.groove/daemon.pid +1 -0
  14. package/node_modules/@groove-dev/gui/.groove/timeline.json +2944 -0
  15. package/node_modules/@groove-dev/gui/AGENTS_REGISTRY.md +9 -0
  16. package/node_modules/@groove-dev/gui/dist/assets/index-CNqM3_F2.js +552 -0
  17. package/node_modules/@groove-dev/gui/dist/assets/index-ChDhUvQR.css +1 -0
  18. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  19. package/node_modules/@groove-dev/gui/src/stores/groove.js +7 -0
  20. package/node_modules/@groove-dev/gui/src/views/settings.jsx +353 -3
  21. package/package.json +1 -1
  22. package/packages/daemon/package.json +4 -0
  23. package/packages/daemon/src/api.js +86 -0
  24. package/packages/daemon/src/gateways/base.js +87 -0
  25. package/packages/daemon/src/gateways/discord.js +220 -0
  26. package/packages/daemon/src/gateways/formatter.js +201 -0
  27. package/packages/daemon/src/gateways/manager.js +695 -0
  28. package/packages/daemon/src/gateways/slack.js +165 -0
  29. package/packages/daemon/src/gateways/telegram.js +265 -0
  30. package/packages/daemon/src/index.js +4 -0
  31. package/packages/daemon/src/validate.js +55 -0
  32. package/packages/gui/dist/assets/index-CNqM3_F2.js +552 -0
  33. package/packages/gui/dist/assets/index-ChDhUvQR.css +1 -0
  34. package/packages/gui/dist/index.html +2 -2
  35. package/packages/gui/src/stores/groove.js +7 -0
  36. package/packages/gui/src/views/settings.jsx +353 -3
  37. package/node_modules/@groove-dev/gui/dist/assets/index-B8ZmjJeV.css +0 -1
  38. package/node_modules/@groove-dev/gui/dist/assets/index-DKov-d0e.js +0 -537
  39. package/packages/gui/dist/assets/index-B8ZmjJeV.css +0 -1
  40. package/packages/gui/dist/assets/index-DKov-d0e.js +0 -537
@@ -15,5 +15,9 @@
15
15
  "express": "^4.21.0",
16
16
  "minimatch": "^10.0.0"
17
17
  },
18
+ "optionalDependencies": {
19
+ "discord.js": "^14.0.0",
20
+ "@slack/bolt": "^4.0.0"
21
+ },
18
22
  "private": true
19
23
  }
@@ -838,6 +838,92 @@ Keep responses concise. Help them think, don't lecture them about the system the
838
838
  res.json({ id: agent.id, integrations });
839
839
  });
840
840
 
841
+ // --- Gateways (Telegram, Discord, Slack) ---
842
+
843
+ app.get('/api/gateways', (req, res) => {
844
+ res.json(daemon.gateways.list());
845
+ });
846
+
847
+ app.post('/api/gateways', async (req, res) => {
848
+ try {
849
+ const result = await daemon.gateways.create(req.body || {});
850
+ res.json(result);
851
+ } catch (err) {
852
+ res.status(400).json({ error: err.message });
853
+ }
854
+ });
855
+
856
+ app.get('/api/gateways/:id', (req, res) => {
857
+ const gw = daemon.gateways.get(req.params.id);
858
+ if (!gw) return res.status(404).json({ error: 'Gateway not found' });
859
+ res.json(gw);
860
+ });
861
+
862
+ app.patch('/api/gateways/:id', async (req, res) => {
863
+ try {
864
+ const result = await daemon.gateways.update(req.params.id, req.body || {});
865
+ res.json(result);
866
+ } catch (err) {
867
+ res.status(400).json({ error: err.message });
868
+ }
869
+ });
870
+
871
+ app.delete('/api/gateways/:id', async (req, res) => {
872
+ try {
873
+ await daemon.gateways.delete(req.params.id);
874
+ res.json({ ok: true });
875
+ } catch (err) {
876
+ res.status(400).json({ error: err.message });
877
+ }
878
+ });
879
+
880
+ app.post('/api/gateways/:id/test', async (req, res) => {
881
+ try {
882
+ const result = await daemon.gateways.test(req.params.id);
883
+ res.json(result);
884
+ } catch (err) {
885
+ res.status(400).json({ error: err.message });
886
+ }
887
+ });
888
+
889
+ app.post('/api/gateways/:id/connect', async (req, res) => {
890
+ try {
891
+ const result = await daemon.gateways.connect(req.params.id);
892
+ res.json(result);
893
+ } catch (err) {
894
+ res.status(400).json({ error: err.message });
895
+ }
896
+ });
897
+
898
+ app.post('/api/gateways/:id/disconnect', async (req, res) => {
899
+ try {
900
+ const result = await daemon.gateways.disconnect(req.params.id);
901
+ res.json(result);
902
+ } catch (err) {
903
+ res.status(400).json({ error: err.message });
904
+ }
905
+ });
906
+
907
+ app.post('/api/gateways/:id/credentials', (req, res) => {
908
+ try {
909
+ const { key, value } = req.body || {};
910
+ if (!key || !value) return res.status(400).json({ error: 'key and value are required' });
911
+ daemon.gateways.setCredential(req.params.id, key, value);
912
+ res.json({ ok: true });
913
+ } catch (err) {
914
+ res.status(400).json({ error: err.message });
915
+ }
916
+ });
917
+
918
+ app.delete('/api/gateways/:id/credentials/:key', (req, res) => {
919
+ try {
920
+ daemon.gateways.deleteCredential(req.params.id, req.params.key);
921
+ res.json({ ok: true });
922
+ } catch (err) {
923
+ res.status(400).json({ error: err.message });
924
+ }
925
+ });
926
+
841
927
  // --- Schedules ---
842
928
 
843
929
  app.get('/api/schedules', (req, res) => {
@@ -0,0 +1,87 @@
1
+ // GROOVE — Base Gateway Interface
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ export class BaseGateway {
5
+ static type = 'base';
6
+ static displayName = 'Base Gateway';
7
+ static description = '';
8
+ static credentialKeys = []; // [{ key, label, required, help }]
9
+
10
+ constructor(daemon, config) {
11
+ this.daemon = daemon;
12
+ this.config = config; // { id, type, enabled, chatId, allowedUsers, notifications }
13
+ this.connected = false;
14
+ }
15
+
16
+ /**
17
+ * Connect to the messaging platform. Called by GatewayManager.start().
18
+ */
19
+ async connect() {
20
+ throw new Error('Gateway must implement connect()');
21
+ }
22
+
23
+ /**
24
+ * Gracefully disconnect. Called by GatewayManager.stop().
25
+ */
26
+ async disconnect() {
27
+ throw new Error('Gateway must implement disconnect()');
28
+ }
29
+
30
+ /**
31
+ * Send a message to the configured chat/channel.
32
+ * @param {string} text — formatted message text
33
+ * @param {object} [options] — platform-specific options (replyMarkup, embeds, blocks, etc.)
34
+ */
35
+ async send(text, options) {
36
+ throw new Error('Gateway must implement send()');
37
+ }
38
+
39
+ /**
40
+ * Process an inbound command from chat. Checks authorization, then delegates
41
+ * to GatewayManager.routeCommand() for actual execution.
42
+ * @param {string} command — command name (without leading /)
43
+ * @param {string[]} args — command arguments
44
+ * @param {string} userId — platform-specific user ID
45
+ * @returns {{ text: string, options?: object } | null}
46
+ */
47
+ async handleCommand(command, args, userId) {
48
+ if (!this._isAuthorized(userId)) {
49
+ return { text: 'Unauthorized. Your user ID is not in the gateway allowlist.' };
50
+ }
51
+ return this.daemon.gateways.routeCommand(this, command, args, userId);
52
+ }
53
+
54
+ /**
55
+ * Check if a user is authorized to send commands.
56
+ * Empty allowlist = open access (for personal bots).
57
+ */
58
+ _isAuthorized(userId) {
59
+ const allow = this.config.allowedUsers || [];
60
+ if (allow.length === 0) return true;
61
+ return allow.includes(String(userId));
62
+ }
63
+
64
+ /**
65
+ * Get the credential value for this gateway from CredentialStore.
66
+ */
67
+ _getCredential(key) {
68
+ return this.daemon.credentials.getKey(`gateway:${this.config.id}:${key}`);
69
+ }
70
+
71
+ /**
72
+ * Return current gateway status for API responses.
73
+ */
74
+ getStatus() {
75
+ return {
76
+ id: this.config.id,
77
+ type: this.constructor.type,
78
+ displayName: this.constructor.displayName,
79
+ connected: this.connected,
80
+ enabled: this.config.enabled,
81
+ chatId: this.config.chatId || null,
82
+ notifications: this.config.notifications || { preset: 'critical' },
83
+ commandPermission: this.config.commandPermission || 'full',
84
+ allowedUsers: (this.config.allowedUsers || []).length,
85
+ };
86
+ }
87
+ }
@@ -0,0 +1,220 @@
1
+ // GROOVE — Discord Gateway (discord.js)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { BaseGateway } from './base.js';
5
+ import { truncate, statusEmoji, formatTokens, formatDuration } from './formatter.js';
6
+
7
+ // Embed colors (decimal)
8
+ const COLORS = {
9
+ success: 0x2ecc71,
10
+ danger: 0xe74c3c,
11
+ warning: 0xf39c12,
12
+ info: 0x3498db,
13
+ accent: 0x5865f2, // Discord blurple
14
+ };
15
+
16
+ export class DiscordGateway extends BaseGateway {
17
+ static type = 'discord';
18
+ static displayName = 'Discord';
19
+ static description = 'Discord bot for notifications and agent commands';
20
+ static credentialKeys = [
21
+ { key: 'bot_token', label: 'Bot Token', required: true, help: 'Discord Developer Portal \u2192 Bot \u2192 Token' },
22
+ ];
23
+
24
+ constructor(daemon, config) {
25
+ super(daemon, config);
26
+ this.client = null;
27
+ this.channel = null;
28
+ this._djs = null; // cached discord.js module
29
+ }
30
+
31
+ async connect() {
32
+ const token = this._getCredential('bot_token');
33
+ if (!token) throw new Error('Discord bot token not configured');
34
+
35
+ try {
36
+ this._djs = await import('discord.js');
37
+ } catch {
38
+ throw new Error('Discord gateway requires discord.js. Install with: npm i discord.js');
39
+ }
40
+
41
+ const { Client, GatewayIntentBits } = this._djs;
42
+
43
+ this.client = new Client({
44
+ intents: [
45
+ GatewayIntentBits.Guilds,
46
+ GatewayIntentBits.GuildMessages,
47
+ GatewayIntentBits.MessageContent,
48
+ ],
49
+ });
50
+
51
+ // Handle incoming messages (commands)
52
+ this.client.on('messageCreate', async (msg) => {
53
+ if (msg.author.bot) return;
54
+ if (!msg.content.startsWith('/')) return;
55
+
56
+ const userId = msg.author.id;
57
+ const [command, ...args] = msg.content.slice(1).split(/\s+/);
58
+
59
+ // Auto-capture channelId
60
+ if (!this.config.chatId) {
61
+ this.config.chatId = msg.channel.id;
62
+ this.daemon.gateways._save(this.config.id);
63
+ }
64
+
65
+ const response = await this.handleCommand(command, args, userId);
66
+ if (response) {
67
+ await msg.channel.send(this._buildReply(response));
68
+ }
69
+ });
70
+
71
+ // Handle button interactions (approve/reject)
72
+ this.client.on('interactionCreate', async (interaction) => {
73
+ if (!interaction.isButton()) return;
74
+
75
+ const userId = interaction.user.id;
76
+ if (!this._isAuthorized(userId)) {
77
+ await interaction.reply({ content: 'Unauthorized.', ephemeral: true });
78
+ return;
79
+ }
80
+
81
+ const [action, ...rest] = interaction.customId.split(':');
82
+ const approvalId = rest.join(':');
83
+ if (!approvalId) {
84
+ await interaction.reply({ content: 'Invalid action.', ephemeral: true });
85
+ return;
86
+ }
87
+
88
+ try {
89
+ let responseText;
90
+ if (action === 'approve') {
91
+ this.daemon.supervisor.approve(approvalId);
92
+ responseText = `\u2705 Approved: ${approvalId}`;
93
+ } else if (action === 'reject') {
94
+ this.daemon.supervisor.reject(approvalId);
95
+ responseText = `\u274c Rejected: ${approvalId}`;
96
+ } else {
97
+ await interaction.reply({ content: 'Unknown action.', ephemeral: true });
98
+ return;
99
+ }
100
+
101
+ // Update the original message — remove buttons, add resolution
102
+ await interaction.update({
103
+ components: [],
104
+ embeds: [
105
+ ...(interaction.message.embeds || []),
106
+ this._embed({ description: responseText, color: action === 'approve' ? COLORS.success : COLORS.danger }),
107
+ ],
108
+ });
109
+ } catch (err) {
110
+ await interaction.reply({ content: `Error: ${err.message}`, ephemeral: true }).catch(() => {});
111
+ }
112
+ });
113
+
114
+ await this.client.login(token);
115
+
116
+ // Resolve target channel
117
+ if (this.config.chatId) {
118
+ try {
119
+ this.channel = await this.client.channels.fetch(this.config.chatId);
120
+ } catch { /* channel will be set on first message */ }
121
+ }
122
+
123
+ this.connected = true;
124
+ console.log(`[Groove:Discord] Connected as ${this.client.user.tag}`);
125
+ }
126
+
127
+ async disconnect() {
128
+ if (this.client) {
129
+ this.client.destroy();
130
+ this.client = null;
131
+ }
132
+ this.channel = null;
133
+ this.connected = false;
134
+ this._djs = null;
135
+ console.log('[Groove:Discord] Disconnected');
136
+ }
137
+
138
+ async send(text, options = {}) {
139
+ const ch = await this._resolveChannel();
140
+ if (!ch) return;
141
+
142
+ const payload = {};
143
+
144
+ // Build embed for rich notifications
145
+ if (options.approvalId) {
146
+ // Approval request — embed with action buttons
147
+ payload.embeds = [this._embed({
148
+ title: '\ud83d\udea8 Approval Required',
149
+ description: text,
150
+ color: COLORS.warning,
151
+ })];
152
+ payload.components = [this._approvalButtons(options.approvalId)];
153
+ } else if (options.embed) {
154
+ payload.embeds = [options.embed];
155
+ } else {
156
+ // Plain text — truncate to Discord's 2000 char limit
157
+ payload.content = truncate(text, 2000);
158
+ }
159
+
160
+ await ch.send(payload);
161
+ }
162
+
163
+ getStatus() {
164
+ return {
165
+ ...super.getStatus(),
166
+ botTag: this.client?.user?.tag || null,
167
+ };
168
+ }
169
+
170
+ // -------------------------------------------------------------------
171
+ // Discord-Specific Helpers
172
+ // -------------------------------------------------------------------
173
+
174
+ async _resolveChannel() {
175
+ if (!this.channel && this.config.chatId && this.client) {
176
+ try {
177
+ this.channel = await this.client.channels.fetch(this.config.chatId);
178
+ } catch { return null; }
179
+ }
180
+ return this.channel;
181
+ }
182
+
183
+ /**
184
+ * Build a discord.js EmbedBuilder-compatible plain object.
185
+ */
186
+ _embed({ title, description, color, fields, footer }) {
187
+ const embed = { color: color || COLORS.accent };
188
+ if (title) embed.title = title;
189
+ if (description) embed.description = truncate(description, 4096);
190
+ if (fields) embed.fields = fields;
191
+ if (footer) embed.footer = { text: footer };
192
+ embed.timestamp = new Date().toISOString();
193
+ return embed;
194
+ }
195
+
196
+ /**
197
+ * Build an ActionRow with Approve/Reject buttons.
198
+ */
199
+ _approvalButtons(approvalId) {
200
+ if (!this._djs) return {};
201
+ const { ActionRowBuilder, ButtonBuilder, ButtonStyle } = this._djs;
202
+ return new ActionRowBuilder().addComponents(
203
+ new ButtonBuilder().setCustomId(`approve:${approvalId}`).setLabel('Approve').setStyle(ButtonStyle.Success).setEmoji('\u2705'),
204
+ new ButtonBuilder().setCustomId(`reject:${approvalId}`).setLabel('Reject').setStyle(ButtonStyle.Danger).setEmoji('\u274c'),
205
+ );
206
+ }
207
+
208
+ /**
209
+ * Build a reply payload from a command response.
210
+ */
211
+ _buildReply(response) {
212
+ if (!response) return {};
213
+ // Wrap command responses in a code block for readability
214
+ const text = response.text || '';
215
+ if (text.length > 1900) {
216
+ return { content: '```\n' + truncate(text, 1900) + '\n```' };
217
+ }
218
+ return { content: '```\n' + text + '\n```' };
219
+ }
220
+ }
@@ -0,0 +1,201 @@
1
+ // GROOVE — Gateway Message Formatter
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ /**
5
+ * Truncate text to a maximum length, adding ellipsis if needed.
6
+ */
7
+ export function truncate(text, max = 2000) {
8
+ if (!text || text.length <= max) return text || '';
9
+ return text.slice(0, max - 3) + '...';
10
+ }
11
+
12
+ /**
13
+ * Format a duration in ms to human-readable string.
14
+ */
15
+ export function formatDuration(ms) {
16
+ if (!ms || ms < 0) return '0s';
17
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
18
+ if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
19
+ const h = Math.floor(ms / 3_600_000);
20
+ const m = Math.round((ms % 3_600_000) / 60_000);
21
+ return m > 0 ? `${h}h ${m}m` : `${h}h`;
22
+ }
23
+
24
+ /**
25
+ * Format token count with K/M suffix.
26
+ */
27
+ export function formatTokens(n) {
28
+ if (!n || n < 0) return '0';
29
+ if (n < 1000) return String(n);
30
+ if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
31
+ return `${(n / 1_000_000).toFixed(2)}M`;
32
+ }
33
+
34
+ /**
35
+ * Format USD cost.
36
+ */
37
+ export function formatCost(usd) {
38
+ if (!usd || usd < 0) return '$0.00';
39
+ if (usd < 0.01) return `$${usd.toFixed(4)}`;
40
+ return `$${usd.toFixed(2)}`;
41
+ }
42
+
43
+ const STATUS_EMOJI = {
44
+ running: '\u{1f7e2}', // green circle
45
+ completed: '\u2705', // check mark
46
+ crashed: '\u{1f534}', // red circle
47
+ killed: '\u26d4', // no entry
48
+ starting: '\u{1f7e1}', // yellow circle
49
+ stopped: '\u26ab', // black circle
50
+ };
51
+
52
+ /**
53
+ * Get emoji for agent status.
54
+ */
55
+ export function statusEmoji(status) {
56
+ return STATUS_EMOJI[status] || '\u2753'; // question mark fallback
57
+ }
58
+
59
+ /**
60
+ * Convert a daemon broadcast event into a human-readable one-liner summary.
61
+ * Returns null if the event shouldn't generate a notification.
62
+ */
63
+ export function eventToSummary(event) {
64
+ switch (event.type) {
65
+ case 'agent:exit': {
66
+ const status = event.status || 'unknown';
67
+ const emoji = statusEmoji(status);
68
+ const id = event.agentId ? ` (${event.agentId})` : '';
69
+ return `${emoji} Agent${id} ${status}`;
70
+ }
71
+
72
+ case 'approval:request': {
73
+ const d = event.data || {};
74
+ const name = d.agentName || d.agentId || 'unknown';
75
+ const desc = d.action?.description || 'action pending';
76
+ return `\u{1f6a8} Approval needed — ${name}: ${truncate(desc, 200)}`;
77
+ }
78
+
79
+ case 'approval:resolved': {
80
+ const d = event.data || {};
81
+ const status = d.status || 'resolved';
82
+ const name = d.agentName || d.agentId || 'unknown';
83
+ return `${status === 'approved' ? '\u2705' : '\u274c'} Approval ${status} — ${name}`;
84
+ }
85
+
86
+ case 'conflict:detected': {
87
+ const d = event.data || event;
88
+ return `\u26a0\ufe0f Scope conflict: ${d.agentName || 'agent'} tried to modify ${d.filePath || 'file'} (owned by ${d.ownerName || 'another agent'})`;
89
+ }
90
+
91
+ case 'rotation:start':
92
+ return `\u{1f504} Rotating ${event.agentName || 'agent'}...`;
93
+
94
+ case 'rotation:complete': {
95
+ const saved = event.tokensSaved ? ` (saved ${formatTokens(event.tokensSaved)} tokens)` : '';
96
+ return `\u{1f504} Rotated ${event.agentName || 'agent'}${saved}`;
97
+ }
98
+
99
+ case 'rotation:failed':
100
+ return `\u274c Rotation failed: ${truncate(event.error, 200)}`;
101
+
102
+ case 'phase2:spawned':
103
+ return `\u{1f195} QC agent spawned: ${event.name || 'qc'}`;
104
+
105
+ case 'schedule:execute':
106
+ return `\u23f0 Scheduled agent spawned (schedule: ${event.scheduleId || 'unknown'})`;
107
+
108
+ case 'qc:activated':
109
+ return `\u{1f6e1}\ufe0f QC activated — ${event.agentCount || '4+'} agents running`;
110
+
111
+ case 'journalist:cycle': {
112
+ const d = event.data || {};
113
+ const summary = d.lastSynthesis || d.summary;
114
+ if (summary) return `\u{1f4f0} Journalist: ${truncate(summary, 500)}`;
115
+ return `\u{1f4f0} Journalist cycle #${d.cycleCount || d.cycle || '?'}`;
116
+ }
117
+
118
+ case 'team:created':
119
+ return `\u{1f4c1} Team created: ${event.team?.name || 'unnamed'}`;
120
+
121
+ case 'team:deleted':
122
+ return `\u{1f5d1}\ufe0f Team deleted (agents moved to default)`;
123
+
124
+ default:
125
+ return null;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Format an agent list for display in chat.
131
+ * Returns a multi-line string.
132
+ */
133
+ export function agentListText(agents) {
134
+ if (!agents || agents.length === 0) return 'No agents running.';
135
+
136
+ const lines = agents.map((a) => {
137
+ const emoji = statusEmoji(a.status);
138
+ const tokens = a.tokensUsed ? ` | ${formatTokens(a.tokensUsed)} tokens` : '';
139
+ const ctx = a.contextUsage ? ` | ctx ${Math.round(a.contextUsage * 100)}%` : '';
140
+ return `${emoji} ${a.name || a.id} (${a.role})${tokens}${ctx}`;
141
+ });
142
+
143
+ return lines.join('\n');
144
+ }
145
+
146
+ /**
147
+ * Format a daemon status summary for chat.
148
+ */
149
+ export function statusText(agents, uptime) {
150
+ const running = agents.filter((a) => a.status === 'running' || a.status === 'starting');
151
+ const completed = agents.filter((a) => a.status === 'completed');
152
+ const crashed = agents.filter((a) => a.status === 'crashed');
153
+
154
+ const lines = [
155
+ `Groove Daemon — ${formatDuration(uptime)} uptime`,
156
+ `Agents: ${running.length} running, ${completed.length} completed, ${crashed.length} crashed`,
157
+ ];
158
+
159
+ if (running.length > 0) {
160
+ lines.push('');
161
+ lines.push('Active:');
162
+ for (const a of running) {
163
+ const tokens = a.tokensUsed ? ` | ${formatTokens(a.tokensUsed)}` : '';
164
+ lines.push(` ${statusEmoji(a.status)} ${a.name || a.id} (${a.role})${tokens}`);
165
+ }
166
+ }
167
+
168
+ return lines.join('\n');
169
+ }
170
+
171
+ /**
172
+ * Format a list of approvals for chat.
173
+ */
174
+ export function approvalsText(approvals) {
175
+ if (!approvals || approvals.length === 0) return 'No pending approvals.';
176
+
177
+ return approvals.map((a) => {
178
+ const desc = a.action?.description || 'action pending';
179
+ return `\u{1f6a8} [${a.id}] ${a.agentName || a.agentId}: ${truncate(desc, 150)}`;
180
+ }).join('\n');
181
+ }
182
+
183
+ /**
184
+ * Format a list of teams for chat.
185
+ */
186
+ export function teamsText(teams) {
187
+ if (!teams || teams.length === 0) return 'No teams.';
188
+ return teams.map((t) => `\u{1f4c1} ${t.name}${t.isDefault ? ' (default)' : ''} — ${t.agentCount || 0} agents`).join('\n');
189
+ }
190
+
191
+ /**
192
+ * Format a list of schedules for chat.
193
+ */
194
+ export function schedulesText(schedules) {
195
+ if (!schedules || schedules.length === 0) return 'No schedules.';
196
+ return schedules.map((s) => {
197
+ const status = s.enabled ? '\u2705' : '\u26ab';
198
+ const running = s.isRunning ? ' (running)' : '';
199
+ return `${status} ${s.name} — ${s.cronDescription || s.cron}${running}`;
200
+ }).join('\n');
201
+ }