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.
- package/CLAUDE.md +0 -7
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/deliver.js +130 -0
- package/node_modules/@groove-dev/daemon/src/index.js +7 -3
- package/node_modules/@groove-dev/daemon/src/innerchat.js +213 -62
- package/node_modules/@groove-dev/daemon/src/process.js +1 -1
- package/node_modules/@groove-dev/daemon/src/registry.js +5 -1
- package/node_modules/@groove-dev/daemon/src/rename.js +72 -0
- package/node_modules/@groove-dev/daemon/src/routes/agents.js +22 -100
- package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +12 -9
- package/node_modules/@groove-dev/daemon/src/teams.js +7 -1
- package/node_modules/@groove-dev/daemon/test/innerchat.test.js +197 -111
- package/node_modules/@groove-dev/daemon/test/rename.test.js +108 -0
- package/node_modules/@groove-dev/gui/dist/assets/{index-DTFtRtkx.css → index-CiOy7wVS.css} +1 -1
- package/node_modules/@groove-dev/gui/dist/assets/{index-CTer01Vg.js → index-DPjGBQ5X.js} +225 -225
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +3 -68
- package/node_modules/@groove-dev/gui/src/components/agents/innerchat-relay.jsx +145 -0
- package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +3 -1
- package/node_modules/@groove-dev/gui/src/components/fleet/fleet-pane.jsx +15 -1
- package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +33 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +29 -44
- package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +27 -4
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/deliver.js +130 -0
- package/packages/daemon/src/index.js +7 -3
- package/packages/daemon/src/innerchat.js +213 -62
- package/packages/daemon/src/process.js +1 -1
- package/packages/daemon/src/registry.js +5 -1
- package/packages/daemon/src/rename.js +72 -0
- package/packages/daemon/src/routes/agents.js +22 -100
- package/packages/daemon/src/routes/innerchat.js +12 -9
- package/packages/daemon/src/teams.js +7 -1
- package/packages/gui/dist/assets/{index-DTFtRtkx.css → index-CiOy7wVS.css} +1 -1
- package/packages/gui/dist/assets/{index-CTer01Vg.js → index-DPjGBQ5X.js} +225 -225
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/agents/agent-panel.jsx +3 -68
- package/packages/gui/src/components/agents/innerchat-relay.jsx +145 -0
- package/packages/gui/src/components/chat/chat-messages.jsx +3 -1
- package/packages/gui/src/components/fleet/fleet-pane.jsx +15 -1
- package/packages/gui/src/components/fleet/fleet-sidebar.jsx +33 -1
- package/packages/gui/src/stores/groove.js +29 -44
- package/packages/gui/src/stores/slices/agents-slice.js +27 -4
|
@@ -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
|
-
|
|
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 (
|
|
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.
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
76
|
+
turn.status = result.status;
|
|
77
|
+
t.status = 'awaiting_reply';
|
|
78
|
+
t.updatedAt = Date.now();
|
|
34
79
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
47
|
-
this.daemon.
|
|
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
|
|
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
|
|
55
|
-
if (!
|
|
96
|
+
const pending = this.awaiting.get(agentId);
|
|
97
|
+
if (!pending) return;
|
|
56
98
|
if (output.type !== 'result') return;
|
|
57
99
|
|
|
58
|
-
const
|
|
59
|
-
if (!
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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(
|
|
224
|
+
return all.filter((t) => t.participants.some((p) => p.id === agentId));
|
|
90
225
|
}
|
|
91
226
|
|
|
92
|
-
|
|
93
|
-
return this.
|
|
227
|
+
getThread(id) {
|
|
228
|
+
return this.threads.get(id) || null;
|
|
94
229
|
}
|
|
95
230
|
|
|
96
231
|
getPending(agentId) {
|
|
97
|
-
const
|
|
98
|
-
return
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
516
|
-
//
|
|
517
|
-
|
|
518
|
-
|
|
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
|
|
13
|
-
res.json(
|
|
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/
|
|
22
|
+
app.get('/api/innerchat/threads', (req, res) => {
|
|
20
23
|
const { agentId } = req.query;
|
|
21
|
-
res.json({
|
|
24
|
+
res.json({ threads: daemon.innerchat.getThreads(agentId || null) });
|
|
22
25
|
});
|
|
23
26
|
|
|
24
|
-
app.get('/api/innerchat/
|
|
25
|
-
const
|
|
26
|
-
if (!
|
|
27
|
-
res.json(
|
|
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
|
-
//
|
|
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) {
|