groove-dev 0.27.182 → 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 (48) hide show
  1. package/CLAUDE.md +0 -7
  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/src/deliver.js +130 -0
  5. package/node_modules/@groove-dev/daemon/src/index.js +7 -3
  6. package/node_modules/@groove-dev/daemon/src/innerchat.js +213 -62
  7. package/node_modules/@groove-dev/daemon/src/process.js +1 -1
  8. package/node_modules/@groove-dev/daemon/src/registry.js +5 -1
  9. package/node_modules/@groove-dev/daemon/src/rename.js +72 -0
  10. package/node_modules/@groove-dev/daemon/src/routes/agents.js +22 -100
  11. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +12 -9
  12. package/node_modules/@groove-dev/daemon/src/teams.js +7 -1
  13. package/node_modules/@groove-dev/daemon/test/innerchat.test.js +197 -111
  14. package/node_modules/@groove-dev/daemon/test/rename.test.js +108 -0
  15. package/node_modules/@groove-dev/gui/dist/assets/{index-DTFtRtkx.css → index-CiOy7wVS.css} +1 -1
  16. package/node_modules/@groove-dev/gui/dist/assets/{index-CTer01Vg.js → index-DPjGBQ5X.js} +225 -225
  17. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  18. package/node_modules/@groove-dev/gui/package.json +1 -1
  19. package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +3 -68
  20. package/node_modules/@groove-dev/gui/src/components/agents/innerchat-relay.jsx +145 -0
  21. package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +3 -1
  22. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-pane.jsx +15 -1
  23. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +33 -1
  24. package/node_modules/@groove-dev/gui/src/stores/groove.js +29 -44
  25. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +27 -4
  26. package/package.json +1 -1
  27. package/packages/cli/package.json +1 -1
  28. package/packages/daemon/package.json +1 -1
  29. package/packages/daemon/src/deliver.js +130 -0
  30. package/packages/daemon/src/index.js +7 -3
  31. package/packages/daemon/src/innerchat.js +213 -62
  32. package/packages/daemon/src/process.js +1 -1
  33. package/packages/daemon/src/registry.js +5 -1
  34. package/packages/daemon/src/rename.js +72 -0
  35. package/packages/daemon/src/routes/agents.js +22 -100
  36. package/packages/daemon/src/routes/innerchat.js +12 -9
  37. package/packages/daemon/src/teams.js +7 -1
  38. package/packages/gui/dist/assets/{index-DTFtRtkx.css → index-CiOy7wVS.css} +1 -1
  39. package/packages/gui/dist/assets/{index-CTer01Vg.js → index-DPjGBQ5X.js} +225 -225
  40. package/packages/gui/dist/index.html +2 -2
  41. package/packages/gui/package.json +1 -1
  42. package/packages/gui/src/components/agents/agent-panel.jsx +3 -68
  43. package/packages/gui/src/components/agents/innerchat-relay.jsx +145 -0
  44. package/packages/gui/src/components/chat/chat-messages.jsx +3 -1
  45. package/packages/gui/src/components/fleet/fleet-pane.jsx +15 -1
  46. package/packages/gui/src/components/fleet/fleet-sidebar.jsx +33 -1
  47. package/packages/gui/src/stores/groove.js +29 -44
  48. package/packages/gui/src/stores/slices/agents-slice.js +27 -4
package/CLAUDE.md CHANGED
@@ -295,10 +295,3 @@ 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.182",
3
+ "version": "0.27.183",
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.182",
3
+ "version": "0.27.183",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -0,0 +1,130 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ import { wrapWithRoleReminder } from './process.js';
4
+ import { getProvider } from './providers/index.js';
5
+
6
+ // Reviving a >5M-token claude session has crashed the CLI mid-HTTP-parse
7
+ // (V8 fatal in JsonStringifier) — past this ceiling the rotator's handoff
8
+ // brief sidesteps the resume entirely.
9
+ const SESSION_RESUME_CEILING = 5_000_000;
10
+
11
+ /**
12
+ * Deliver a message to an agent, whatever state it's in.
13
+ *
14
+ * This is the single pipe every inbound message travels — user chat from the
15
+ * GUI and InnerChat relays alike. Handles agent-loop delivery, one-shot and
16
+ * non-interactive providers, queueing behind a running task, session resume,
17
+ * and full rotation when the session is too large to revive.
18
+ *
19
+ * Respawn paths mint a NEW agent id. Callers that track the agent afterwards
20
+ * (InnerChat keys response capture on it) must use the returned `agentId`,
21
+ * not the one they passed in.
22
+ *
23
+ * @returns {Promise<{agentId: string, status: string, resumed: boolean, agent: object|null}>}
24
+ */
25
+ export async function deliverInstruction(daemon, agentId, message, opts = {}) {
26
+ const { recordFeedback = true } = opts;
27
+
28
+ const agent = daemon.registry.get(agentId);
29
+ if (!agent) throw new Error('Agent not found');
30
+
31
+ const finalMessage = message.trim();
32
+ if (!finalMessage) throw new Error('message is required');
33
+
34
+ // Journalist/rotator treat this as a human steering signal — InnerChat
35
+ // relays opt out so agent-to-agent chatter doesn't pollute user feedback.
36
+ if (recordFeedback) {
37
+ if (daemon.journalist) daemon.journalist.recordUserFeedback(agent, finalMessage);
38
+ if (daemon.rotator) daemon.rotator.recordUserMessage(agentId);
39
+ }
40
+
41
+ const wrappedMessage = wrapWithRoleReminder(agent.role, finalMessage);
42
+
43
+ // Agent loop path — send straight to the running loop.
44
+ if (daemon.processes.hasAgentLoop(agentId)) {
45
+ const sent = await daemon.processes.sendMessage(agentId, wrappedMessage);
46
+ if (sent) {
47
+ daemon.audit.log('agent.chat', { id: agentId });
48
+ return { agentId, status: 'message_sent', resumed: false, agent };
49
+ }
50
+ // Loop exists but isn't running — fall through to resume/rotate.
51
+ }
52
+
53
+ const provider = getProvider(agent.provider);
54
+
55
+ // One-shot providers (groove-network): kill any running instance and respawn
56
+ // with the message as --prompt. No handoff brief, no resume, no queue.
57
+ if (provider?.constructor?.isOneShot) {
58
+ const oldConfig = { ...agent };
59
+ if (daemon.processes.isRunning(agentId)) {
60
+ await daemon.processes.kill(agentId);
61
+ }
62
+ daemon.registry.remove(agentId, { silent: true });
63
+ daemon.locks.release(agentId);
64
+
65
+ const newAgent = await respawn(daemon, {
66
+ role: oldConfig.role,
67
+ scope: oldConfig.scope,
68
+ provider: oldConfig.provider,
69
+ model: oldConfig.model,
70
+ prompt: finalMessage,
71
+ permission: oldConfig.permission || 'full',
72
+ workingDir: oldConfig.workingDir,
73
+ name: oldConfig.name,
74
+ teamId: oldConfig.teamId,
75
+ });
76
+ daemon.audit.log('agent.instruct', { id: agentId, newId: newAgent.id, resumed: false });
77
+ return { agentId: newAgent.id, status: 'respawned', resumed: false, agent: newAgent };
78
+ }
79
+
80
+ // Non-interactive CLI providers (e.g. Gemini): one prompt per spawn, cannot
81
+ // resume — respawn preserving the original introContext.
82
+ if (provider?.constructor?.nonInteractive && !daemon.processes.isRunning(agentId)) {
83
+ const oldConfig = { ...agent };
84
+ daemon.registry.remove(agentId, { silent: true });
85
+ daemon.locks.release(agentId);
86
+
87
+ const newAgent = await respawn(daemon, {
88
+ role: oldConfig.role,
89
+ scope: oldConfig.scope,
90
+ provider: oldConfig.provider,
91
+ model: oldConfig.model,
92
+ prompt: finalMessage,
93
+ introContext: oldConfig.introContext,
94
+ permission: oldConfig.permission || 'full',
95
+ workingDir: oldConfig.workingDir,
96
+ name: oldConfig.name,
97
+ teamId: oldConfig.teamId,
98
+ });
99
+ daemon.audit.log('agent.instruct', { id: agentId, newId: newAgent.id, resumed: false });
100
+ return { agentId: newAgent.id, status: 'respawned', resumed: false, agent: newAgent };
101
+ }
102
+
103
+ // Running CLI agent (no loop) — queue behind the current task rather than
104
+ // killing and respawning mid-work.
105
+ if (daemon.processes.isRunning(agentId)) {
106
+ daemon.processes.queueMessage(agentId, wrappedMessage);
107
+ daemon.audit.log('agent.chat.queued', { id: agentId });
108
+ return { agentId, status: 'message_queued', resumed: false, agent };
109
+ }
110
+
111
+ // Stopped CLI agent — resume the session, or rotate past the ceiling.
112
+ const resumed = !!agent.sessionId && (agent.tokensUsed || 0) < SESSION_RESUME_CEILING;
113
+ const newAgent = resumed
114
+ ? await daemon.processes.resume(agentId, wrappedMessage)
115
+ : await daemon.rotator.rotate(agentId, { additionalPrompt: wrappedMessage });
116
+
117
+ daemon.audit.log('agent.instruct', { id: agentId, newId: newAgent.id, resumed });
118
+ return { agentId: newAgent.id, status: resumed ? 'resumed' : 'rotated', resumed, agent: newAgent };
119
+ }
120
+
121
+ // Spawn, flushing the registry's pending removals if it fails so a failed
122
+ // respawn doesn't silently erase the agent it was replacing.
123
+ async function respawn(daemon, config) {
124
+ try {
125
+ return await daemon.processes.spawn(config);
126
+ } catch (spawnErr) {
127
+ daemon.registry.flushPendingRemovals();
128
+ throw spawnErr;
129
+ }
130
+ }
@@ -10,7 +10,7 @@ import express from 'express';
10
10
  import { WebSocketServer } from 'ws';
11
11
  import { Registry } from './registry.js';
12
12
  import { createApi } from './api.js';
13
- import { ProcessManager } from './process.js';
13
+ import { ProcessManager, sanitizeFilename } from './process.js';
14
14
  import { StateManager } from './state.js';
15
15
  import { Introducer } from './introducer.js';
16
16
  import { LockManager } from './lockmanager.js';
@@ -750,7 +750,11 @@ export class Daemon {
750
750
  try {
751
751
  // Build set of agent names still in the registry — never remove their logs
752
752
  const allAgents = this.registry.getAll();
753
- const activeNames = new Set(allAgents.map((a) => a.name));
753
+ // Raw logs are keyed by sanitized name; GROOVE_AGENT_LOGS/ subdirectories
754
+ // are keyed by agent id (journalist.js). Comparing either against the
755
+ // wrong set deletes live agents' history.
756
+ const activeNames = new Set(allAgents.map((a) => sanitizeFilename(a.name)));
757
+ const activeIds = new Set(allAgents.map((a) => a.id));
754
758
 
755
759
  // Safety: if registry is empty but log files exist, state may have been
756
760
  // lost (corrupt JSON, partial write). Skip log cleanup to prevent
@@ -778,7 +782,7 @@ export class Daemon {
778
782
  if (existsSync(agentLogsDir)) {
779
783
  for (const dir of readdirSync(agentLogsDir, { withFileTypes: true })) {
780
784
  if (!dir.isDirectory()) continue;
781
- if (activeNames.has(dir.name)) continue;
785
+ if (activeIds.has(dir.name)) continue;
782
786
  try { rmSync(resolve(agentLogsDir, dir.name), { recursive: true }); cleaned++; } catch { /* skip */ }
783
787
  }
784
788
  // Remove GROOVE_AGENT_LOGS/ itself if empty
@@ -1,100 +1,251 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
 
3
3
  import { randomUUID } from 'crypto';
4
-
4
+ import { deliverInstruction } from './deliver.js';
5
+
6
+ // How much prior conversation to replay into each relay turn. Enough for the
7
+ // recipient to pick up the thread without re-reading the whole exchange.
8
+ const CONTEXT_TURNS = 4;
9
+ const MAX_TURN_CHARS = 1200;
10
+
11
+ /**
12
+ * Agent-to-agent relay, user-initiated.
13
+ *
14
+ * The A→B hop is always driven by a human clicking Relay. The B→A hop is
15
+ * automatic: B's answer is fed straight back into A so A can process it and
16
+ * report to the user, who then decides whether to run another turn.
17
+ *
18
+ * Delivery goes through deliverInstruction — the same pipe as user chat — so
19
+ * a relay reaches its target whether it's mid-task, idle, or stopped.
20
+ */
5
21
  export class InnerChat {
6
22
  constructor(daemon) {
7
23
  this.daemon = daemon;
8
- this.messages = new Map();
9
- this.pendingResponses = new Map();
24
+ this.threads = new Map();
25
+ // agentId -> { threadId, sentAt } — which agent we're awaiting a reply from.
26
+ this.awaiting = new Map();
10
27
  }
11
28
 
12
- async send(fromAgentId, toAgentId, message) {
29
+ /**
30
+ * Relay a message from one agent to another, opening a thread or continuing
31
+ * an existing one.
32
+ */
33
+ async send(fromAgentId, toAgentId, message, threadId = null) {
13
34
  const fromAgent = this.daemon.registry.get(fromAgentId);
14
35
  const toAgent = this.daemon.registry.get(toAgentId);
15
36
  if (!fromAgent) throw new Error(`Sender agent ${fromAgentId} not found`);
16
37
  if (!toAgent) throw new Error(`Target agent ${toAgentId} not found`);
17
-
18
- const id = randomUUID().slice(0, 12);
19
- const msg = {
20
- id,
21
- from: { id: fromAgent.id, name: fromAgent.name, role: fromAgent.role },
22
- to: { id: toAgent.id, name: toAgent.name, role: toAgent.role },
23
- message,
24
- response: null,
25
- status: 'sent',
38
+ if (fromAgentId === toAgentId) throw new Error('Cannot relay an agent to itself');
39
+ if (!message || !message.trim()) throw new Error('message is required');
40
+
41
+ const thread = threadId ? this.threads.get(threadId) : null;
42
+ if (threadId && !thread) throw new Error(`Thread ${threadId} not found`);
43
+
44
+ const t = thread || this._createThread(fromAgent, toAgent);
45
+ const turn = {
46
+ id: randomUUID().slice(0, 12),
47
+ from: peer(fromAgent),
48
+ to: peer(toAgent),
49
+ text: message.trim(),
50
+ kind: 'relay',
51
+ status: 'sending',
26
52
  timestamp: Date.now(),
27
- respondedAt: null,
28
53
  };
54
+ t.turns.push(turn);
55
+
56
+ const wrapped = this._wrap(t, fromAgent, toAgent, message.trim());
57
+
58
+ let result;
59
+ try {
60
+ result = await deliverInstruction(this.daemon, toAgentId, wrapped, { recordFeedback: false });
61
+ } catch (err) {
62
+ turn.status = 'failed';
63
+ turn.error = err.message;
64
+ t.status = 'failed';
65
+ this.daemon.broadcast({ type: 'innerchat:turn', data: { thread: t, turn } });
66
+ throw err;
67
+ }
29
68
 
30
- this.messages.set(id, msg);
31
- this.pendingResponses.set(toAgentId, id);
69
+ // A stopped target gets resumed or rotated, which mints a new agent id.
70
+ // Re-key everything onto it or the reply will never be captured.
71
+ if (result.agentId !== toAgentId) {
72
+ this._remapParticipant(t, toAgentId, result.agentId);
73
+ turn.to.id = result.agentId;
74
+ }
32
75
 
33
- const wrapped = `[InnerChat from ${fromAgent.name} (${fromAgent.role})]\n\n${message}\n\nReply normally — your response will be relayed back to ${fromAgent.name}.`;
76
+ turn.status = result.status;
77
+ t.status = 'awaiting_reply';
78
+ t.updatedAt = Date.now();
34
79
 
35
- let deliveryStatus = 'sent';
36
- if (this.daemon.processes.hasAgentLoop(toAgentId)) {
37
- const sent = await this.daemon.processes.sendMessage(toAgentId, wrapped, 'agent');
38
- deliveryStatus = sent ? 'delivered' : 'queued';
39
- } else if (this.daemon.processes.isRunning(toAgentId)) {
40
- this.daemon.processes.queueMessage(toAgentId, wrapped);
41
- deliveryStatus = 'queued';
42
- } else {
43
- throw new Error(`Target agent ${toAgent.name} is not running`);
44
- }
80
+ // A queued relay sits behind whatever the agent is already doing, so the
81
+ // next result belongs to that prior task, not to us — skip it.
82
+ const skipResults = result.status === 'message_queued' ? 1 : 0;
83
+ this.awaiting.set(turn.to.id, { threadId: t.id, sentAt: Date.now(), skipResults });
45
84
 
46
- msg.status = deliveryStatus;
47
- this.daemon.broadcast({ type: 'innerchat:sent', data: msg });
48
- this.daemon.audit.log('innerchat.send', { id, from: fromAgentId, to: toAgentId });
85
+ this.daemon.broadcast({ type: 'innerchat:turn', data: { thread: t, turn } });
86
+ this.daemon.audit.log('innerchat.send', { thread: t.id, from: fromAgentId, to: turn.to.id });
49
87
 
50
- return msg;
88
+ return { thread: t, turn };
51
89
  }
52
90
 
91
+ /**
92
+ * Watch agent output for the reply to an outstanding relay, then forward it
93
+ * back to the agent that asked.
94
+ */
53
95
  onAgentOutput(agentId, output) {
54
- const messageId = this.pendingResponses.get(agentId);
55
- if (!messageId) return;
96
+ const pending = this.awaiting.get(agentId);
97
+ if (!pending) return;
56
98
  if (output.type !== 'result') return;
57
99
 
58
- const msg = this.messages.get(messageId);
59
- if (!msg) return;
100
+ const thread = this.threads.get(pending.threadId);
101
+ if (!thread) { this.awaiting.delete(agentId); return; }
102
+
103
+ const responseText = extractText(output.data);
104
+ if (!responseText) return;
105
+
106
+ // Burn off results belonging to work that was already underway when the
107
+ // relay was queued behind it.
108
+ if (pending.skipResults > 0) {
109
+ pending.skipResults -= 1;
110
+ return;
111
+ }
112
+
113
+ // Claim the reply before the async forward so a second result arriving
114
+ // mid-flight can't be captured as a duplicate.
115
+ this.awaiting.delete(agentId);
60
116
 
61
- let responseText = '';
62
- if (typeof output.data === 'string') {
63
- responseText = output.data;
64
- } else if (Array.isArray(output.data)) {
65
- responseText = output.data.filter(b => b.type === 'text').map(b => b.text).join('\n');
117
+ const responder = this.daemon.registry.get(agentId);
118
+ const asker = this._otherParticipant(thread, agentId);
119
+
120
+ const turn = {
121
+ id: randomUUID().slice(0, 12),
122
+ from: responder ? peer(responder) : { id: agentId, name: agentId, role: 'agent' },
123
+ to: asker,
124
+ text: responseText,
125
+ kind: 'reply',
126
+ status: 'forwarding',
127
+ timestamp: Date.now(),
128
+ };
129
+ thread.turns.push(turn);
130
+ thread.status = 'forwarding';
131
+ thread.updatedAt = Date.now();
132
+
133
+ this._forwardReply(thread, turn, asker.id, responseText);
134
+ }
135
+
136
+ async _forwardReply(thread, turn, askerId, responseText) {
137
+ const from = turn.from;
138
+ const relay = [
139
+ `[InnerChat reply from ${from.name} (${from.role})]`,
140
+ '',
141
+ responseText,
142
+ '',
143
+ `This is the answer to what you relayed to ${from.name}. Process it and report back — `
144
+ + 'the user will decide whether to send another turn.',
145
+ ].join('\n');
146
+
147
+ try {
148
+ const result = await deliverInstruction(this.daemon, askerId, relay, { recordFeedback: false });
149
+ if (result.agentId !== askerId) {
150
+ this._remapParticipant(thread, askerId, result.agentId);
151
+ turn.to.id = result.agentId;
152
+ }
153
+ turn.status = result.status;
154
+ thread.status = 'idle';
155
+ } catch (err) {
156
+ turn.status = 'failed';
157
+ turn.error = err.message;
158
+ thread.status = 'failed';
66
159
  }
67
- if (!responseText.trim()) return;
68
-
69
- msg.response = responseText.trim();
70
- msg.status = 'responded';
71
- msg.respondedAt = Date.now();
72
- this.pendingResponses.delete(agentId);
73
-
74
- const relay = `[InnerChat reply from ${msg.to.name} (${msg.to.role})]\n\n${msg.response}`;
75
- const senderId = msg.from.id;
76
- if (this.daemon.processes.hasAgentLoop(senderId)) {
77
- this.daemon.processes.sendMessage(senderId, relay, 'agent').catch(() => {});
78
- } else if (this.daemon.processes.isRunning(senderId)) {
79
- this.daemon.processes.queueMessage(senderId, relay);
160
+
161
+ thread.updatedAt = Date.now();
162
+ this.daemon.broadcast({ type: 'innerchat:turn', data: { thread, turn } });
163
+ this.daemon.audit.log('innerchat.reply', { thread: thread.id, from: turn.from.id, to: turn.to.id });
164
+ }
165
+
166
+ // ── Thread helpers ──────────────────────────────────────────
167
+
168
+ _createThread(fromAgent, toAgent) {
169
+ const thread = {
170
+ id: randomUUID().slice(0, 12),
171
+ participants: [peer(fromAgent), peer(toAgent)],
172
+ turns: [],
173
+ status: 'idle',
174
+ createdAt: Date.now(),
175
+ updatedAt: Date.now(),
176
+ };
177
+ this.threads.set(thread.id, thread);
178
+ return thread;
179
+ }
180
+
181
+ // Rebuild the message the target sees. It reads as a direct message from the
182
+ // other agent, with enough prior turns that a continuing thread makes sense.
183
+ _wrap(thread, fromAgent, toAgent, message) {
184
+ const prior = thread.turns.slice(0, -1).slice(-CONTEXT_TURNS);
185
+ const lines = [`[InnerChat from ${fromAgent.name} (${fromAgent.role})]`, ''];
186
+
187
+ if (prior.length) {
188
+ lines.push('Earlier in this conversation:');
189
+ for (const t of prior) {
190
+ lines.push(` ${t.from.name}: ${truncate(t.text, MAX_TURN_CHARS)}`);
191
+ }
192
+ lines.push('');
80
193
  }
81
194
 
82
- this.daemon.broadcast({ type: 'innerchat:response', data: msg });
83
- this.daemon.audit.log('innerchat.response', { id: messageId, from: msg.from.id, to: msg.to.id });
195
+ lines.push(message, '');
196
+ lines.push(
197
+ `Reply normally — your response is relayed straight back to ${fromAgent.name}. `
198
+ + 'Answer them directly; do not address the user.',
199
+ );
200
+ return lines.join('\n');
201
+ }
202
+
203
+ _otherParticipant(thread, agentId) {
204
+ return thread.participants.find((p) => p.id !== agentId) || thread.participants[0];
205
+ }
206
+
207
+ _remapParticipant(thread, oldId, newId) {
208
+ for (const p of thread.participants) {
209
+ if (p.id === oldId) p.id = newId;
210
+ }
211
+ // Any relay still awaiting a reply from the old id must follow it forward.
212
+ const pending = this.awaiting.get(oldId);
213
+ if (pending) {
214
+ this.awaiting.delete(oldId);
215
+ this.awaiting.set(newId, pending);
216
+ }
84
217
  }
85
218
 
86
- getMessages(agentId = null) {
87
- const all = Array.from(this.messages.values());
219
+ // ── Queries ─────────────────────────────────────────────────
220
+
221
+ getThreads(agentId = null) {
222
+ const all = Array.from(this.threads.values()).sort((a, b) => b.updatedAt - a.updatedAt);
88
223
  if (!agentId) return all;
89
- return all.filter(m => m.from.id === agentId || m.to.id === agentId);
224
+ return all.filter((t) => t.participants.some((p) => p.id === agentId));
90
225
  }
91
226
 
92
- getMessage(id) {
93
- return this.messages.get(id) || null;
227
+ getThread(id) {
228
+ return this.threads.get(id) || null;
94
229
  }
95
230
 
96
231
  getPending(agentId) {
97
- const messageId = this.pendingResponses.get(agentId);
98
- return messageId ? this.messages.get(messageId) : null;
232
+ const pending = this.awaiting.get(agentId);
233
+ return pending ? this.threads.get(pending.threadId) : null;
234
+ }
235
+ }
236
+
237
+ function peer(agent) {
238
+ return { id: agent.id, name: agent.name, role: agent.role };
239
+ }
240
+
241
+ function truncate(text, max) {
242
+ return text.length <= max ? text : `${text.slice(0, max)}… [truncated]`;
243
+ }
244
+
245
+ function extractText(data) {
246
+ if (typeof data === 'string') return data.trim();
247
+ if (Array.isArray(data)) {
248
+ return data.filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim();
99
249
  }
250
+ return '';
100
251
  }
@@ -320,7 +320,7 @@ 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
 
@@ -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
+ }