groove-dev 0.27.182 → 0.27.184
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-docs.js +41 -0
- package/node_modules/@groove-dev/daemon/src/innerchat.js +267 -60
- package/node_modules/@groove-dev/daemon/src/introducer.js +30 -1
- package/node_modules/@groove-dev/daemon/src/process.js +22 -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 +63 -16
- package/node_modules/@groove-dev/daemon/src/routes/teams.js +11 -0
- package/node_modules/@groove-dev/daemon/src/teams.js +35 -1
- package/node_modules/@groove-dev/daemon/test/innerchat.test.js +193 -134
- package/node_modules/@groove-dev/daemon/test/rename.test.js +108 -0
- package/node_modules/@groove-dev/daemon/test/teams.test.js +53 -0
- package/node_modules/@groove-dev/gui/dist/assets/{index-CTer01Vg.js → index-BpOyN6Zf.js} +227 -227
- package/node_modules/@groove-dev/gui/dist/assets/index-DiXB7yry.css +1 -0
- 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 +2 -95
- package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +3 -1
- package/node_modules/@groove-dev/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
- package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +56 -6
- package/node_modules/@groove-dev/gui/src/stores/groove.js +35 -44
- package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +27 -4
- package/node_modules/@groove-dev/gui/src/stores/slices/teams-slice.js +22 -0
- 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-docs.js +41 -0
- package/packages/daemon/src/innerchat.js +267 -60
- package/packages/daemon/src/introducer.js +30 -1
- package/packages/daemon/src/process.js +22 -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 +63 -16
- package/packages/daemon/src/routes/teams.js +11 -0
- package/packages/daemon/src/teams.js +35 -1
- package/packages/gui/dist/assets/{index-CTer01Vg.js → index-BpOyN6Zf.js} +227 -227
- package/packages/gui/dist/assets/index-DiXB7yry.css +1 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/agents/agent-panel.jsx +2 -95
- package/packages/gui/src/components/chat/chat-messages.jsx +3 -1
- package/packages/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
- package/packages/gui/src/components/fleet/fleet-sidebar.jsx +56 -6
- package/packages/gui/src/stores/groove.js +35 -44
- package/packages/gui/src/stores/slices/agents-slice.js +27 -4
- package/packages/gui/src/stores/slices/teams-slice.js +22 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-DTFtRtkx.css +0 -1
- package/packages/gui/dist/assets/index-DTFtRtkx.css +0 -1
|
@@ -1,100 +1,307 @@
|
|
|
1
1
|
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
2
|
|
|
3
3
|
import { randomUUID } from 'crypto';
|
|
4
|
+
import { deliverInstruction } from './deliver.js';
|
|
4
5
|
|
|
6
|
+
// An unanswered ask holds an HTTP request open and blocks the calling agent,
|
|
7
|
+
// so it has to be bounded.
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
9
|
+
const MAX_TIMEOUT_MS = 15 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
// Ceiling on autonomous back-and-forth. Two agents that can call each other
|
|
12
|
+
// freely will happily talk until the budget is gone; past this they're told to
|
|
13
|
+
// wrap up and report to the user.
|
|
14
|
+
const MAX_EXCHANGES = 12;
|
|
15
|
+
|
|
16
|
+
const CONTEXT_TURNS = 4;
|
|
17
|
+
const MAX_TURN_CHARS = 1200;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Agent-to-agent consultation.
|
|
21
|
+
*
|
|
22
|
+
* An agent asks another agent a question and BLOCKS until it answers — the
|
|
23
|
+
* daemon holds the caller's HTTP request open, delivers the question, waits
|
|
24
|
+
* for the target's next result, and returns it as the response body. That
|
|
25
|
+
* makes a normal request/response loop available to the calling agent, so two
|
|
26
|
+
* agents can iterate to consensus without a human relaying between them.
|
|
27
|
+
*
|
|
28
|
+
* Delivery goes through deliverInstruction — the same pipe as user chat — so
|
|
29
|
+
* a question reaches its target whether it's mid-task, idle, or stopped.
|
|
30
|
+
*/
|
|
5
31
|
export class InnerChat {
|
|
6
32
|
constructor(daemon) {
|
|
7
33
|
this.daemon = daemon;
|
|
8
|
-
this.
|
|
9
|
-
|
|
34
|
+
this.threads = new Map();
|
|
35
|
+
// agentId being asked -> pending record (resolve/reject/timer/threadId)
|
|
36
|
+
this.awaiting = new Map();
|
|
37
|
+
// asker id -> id of the agent it is currently blocked on
|
|
38
|
+
this.blockedOn = new Map();
|
|
10
39
|
}
|
|
11
40
|
|
|
12
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Ask another agent a question and wait for its answer.
|
|
43
|
+
*
|
|
44
|
+
* Resolves with { reply, threadId, exchanges, remaining }. Rejects when the
|
|
45
|
+
* target can't be reached, the exchange cap is hit, the call would deadlock,
|
|
46
|
+
* or the target doesn't answer inside the timeout.
|
|
47
|
+
*/
|
|
48
|
+
async ask(fromAgentId, toAgentId, message, opts = {}) {
|
|
13
49
|
const fromAgent = this.daemon.registry.get(fromAgentId);
|
|
14
50
|
const toAgent = this.daemon.registry.get(toAgentId);
|
|
15
|
-
if (!fromAgent) throw new Error(`
|
|
51
|
+
if (!fromAgent) throw new Error(`Calling agent ${fromAgentId} not found`);
|
|
16
52
|
if (!toAgent) throw new Error(`Target agent ${toAgentId} not found`);
|
|
53
|
+
if (fromAgentId === toAgentId) throw new Error('An agent cannot ask itself');
|
|
54
|
+
if (!message || !message.trim()) throw new Error('message is required');
|
|
55
|
+
|
|
56
|
+
// Deadlock guard: the target is already blocked waiting on us, so it can
|
|
57
|
+
// never get far enough to read this. Tell the caller to just answer.
|
|
58
|
+
if (this.blockedOn.get(toAgentId) === fromAgentId) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`${toAgent.name} is currently waiting for YOUR answer — it cannot reply to a new `
|
|
61
|
+
+ 'question until you respond. Answer its question first; your reply is what unblocks it.',
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (this.awaiting.has(toAgentId)) {
|
|
65
|
+
throw new Error(`${toAgent.name} is already answering another agent — try again shortly`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const thread = opts.threadId ? this.threads.get(opts.threadId) : this._findThread(fromAgentId, toAgentId);
|
|
69
|
+
const t = thread || this._createThread(fromAgent, toAgent);
|
|
17
70
|
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
71
|
+
const exchanges = t.turns.filter((x) => x.kind === 'ask').length;
|
|
72
|
+
if (exchanges >= MAX_EXCHANGES) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`This conversation has reached its ${MAX_EXCHANGES}-exchange limit. Stop consulting `
|
|
75
|
+
+ `${toAgent.name} and report your conclusion to the user.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const timeoutMs = Math.min(Number(opts.timeoutMs) || DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
|
|
80
|
+
|
|
81
|
+
const turn = {
|
|
82
|
+
id: randomUUID().slice(0, 12),
|
|
83
|
+
from: peer(fromAgent),
|
|
84
|
+
to: peer(toAgent),
|
|
85
|
+
text: message.trim(),
|
|
86
|
+
kind: 'ask',
|
|
87
|
+
status: 'sending',
|
|
26
88
|
timestamp: Date.now(),
|
|
27
|
-
respondedAt: null,
|
|
28
89
|
};
|
|
90
|
+
t.turns.push(turn);
|
|
29
91
|
|
|
30
|
-
this.
|
|
31
|
-
this.pendingResponses.set(toAgentId, id);
|
|
92
|
+
const wrapped = this._wrap(t, fromAgent, toAgent, message.trim());
|
|
32
93
|
|
|
33
|
-
|
|
94
|
+
let result;
|
|
95
|
+
try {
|
|
96
|
+
result = await deliverInstruction(this.daemon, toAgentId, wrapped, { recordFeedback: false });
|
|
97
|
+
} catch (err) {
|
|
98
|
+
turn.status = 'failed';
|
|
99
|
+
turn.error = err.message;
|
|
100
|
+
t.status = 'failed';
|
|
101
|
+
this._broadcast(t, turn);
|
|
102
|
+
throw new Error(`Could not reach ${toAgent.name}: ${err.message}`);
|
|
103
|
+
}
|
|
34
104
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
deliveryStatus = 'queued';
|
|
42
|
-
} else {
|
|
43
|
-
throw new Error(`Target agent ${toAgent.name} is not running`);
|
|
105
|
+
// A stopped target gets resumed or rotated, which mints a new agent id.
|
|
106
|
+
// Re-key onto it or the answer will never be matched.
|
|
107
|
+
const targetId = result.agentId;
|
|
108
|
+
if (targetId !== toAgentId) {
|
|
109
|
+
this._remapParticipant(t, toAgentId, targetId);
|
|
110
|
+
turn.to.id = targetId;
|
|
44
111
|
}
|
|
45
112
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
113
|
+
turn.status = result.status;
|
|
114
|
+
t.status = 'awaiting_reply';
|
|
115
|
+
t.updatedAt = Date.now();
|
|
116
|
+
this._broadcast(t, turn);
|
|
117
|
+
this.daemon.audit.log('innerchat.ask', { thread: t.id, from: fromAgentId, to: targetId });
|
|
118
|
+
|
|
119
|
+
// A queued question sits behind whatever the target is already doing, so
|
|
120
|
+
// the next result belongs to that prior task, not to us — skip it.
|
|
121
|
+
const skipResults = result.status === 'message_queued' ? 1 : 0;
|
|
122
|
+
|
|
123
|
+
const reply = await this._awaitReply(t, fromAgentId, targetId, { skipResults, timeoutMs });
|
|
49
124
|
|
|
50
|
-
return
|
|
125
|
+
return {
|
|
126
|
+
reply,
|
|
127
|
+
threadId: t.id,
|
|
128
|
+
exchanges: exchanges + 1,
|
|
129
|
+
remaining: MAX_EXCHANGES - (exchanges + 1),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
_awaitReply(thread, askerId, targetId, { skipResults, timeoutMs }) {
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
const settle = (fn, value) => {
|
|
136
|
+
clearTimeout(record.timer);
|
|
137
|
+
this.awaiting.delete(targetId);
|
|
138
|
+
this.blockedOn.delete(askerId);
|
|
139
|
+
fn(value);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const record = {
|
|
143
|
+
threadId: thread.id,
|
|
144
|
+
askerId,
|
|
145
|
+
skipResults,
|
|
146
|
+
resolve: (text) => settle(resolve, text),
|
|
147
|
+
reject: (err) => settle(reject, err),
|
|
148
|
+
timer: setTimeout(() => {
|
|
149
|
+
thread.status = 'timeout';
|
|
150
|
+
thread.updatedAt = Date.now();
|
|
151
|
+
record.reject(new Error(
|
|
152
|
+
`No answer within ${Math.round(timeoutMs / 1000)}s. The agent may still be working — `
|
|
153
|
+
+ 'proceed on your own judgement or try again.',
|
|
154
|
+
));
|
|
155
|
+
}, timeoutMs),
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
this.awaiting.set(targetId, record);
|
|
159
|
+
this.blockedOn.set(askerId, targetId);
|
|
160
|
+
});
|
|
51
161
|
}
|
|
52
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Watch agent output for the answer to an outstanding question.
|
|
165
|
+
* Called from the process manager's output handler.
|
|
166
|
+
*/
|
|
53
167
|
onAgentOutput(agentId, output) {
|
|
54
|
-
const
|
|
55
|
-
if (!
|
|
168
|
+
const pending = this.awaiting.get(agentId);
|
|
169
|
+
if (!pending) return;
|
|
56
170
|
if (output.type !== 'result') return;
|
|
57
171
|
|
|
58
|
-
const
|
|
59
|
-
if (!
|
|
172
|
+
const thread = this.threads.get(pending.threadId);
|
|
173
|
+
if (!thread) { pending.reject(new Error('Conversation was lost')); return; }
|
|
174
|
+
|
|
175
|
+
const text = extractText(output.data);
|
|
176
|
+
if (!text) return;
|
|
177
|
+
|
|
178
|
+
// Burn off results belonging to work already underway when we queued.
|
|
179
|
+
if (pending.skipResults > 0) {
|
|
180
|
+
pending.skipResults -= 1;
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const responder = this.daemon.registry.get(agentId);
|
|
185
|
+
thread.turns.push({
|
|
186
|
+
id: randomUUID().slice(0, 12),
|
|
187
|
+
from: responder ? peer(responder) : { id: agentId, name: agentId, role: 'agent' },
|
|
188
|
+
to: this._otherParticipant(thread, agentId),
|
|
189
|
+
text,
|
|
190
|
+
kind: 'answer',
|
|
191
|
+
status: 'delivered',
|
|
192
|
+
timestamp: Date.now(),
|
|
193
|
+
});
|
|
194
|
+
thread.status = 'idle';
|
|
195
|
+
thread.updatedAt = Date.now();
|
|
196
|
+
|
|
197
|
+
this._broadcast(thread, thread.turns.at(-1));
|
|
198
|
+
this.daemon.audit.log('innerchat.answer', { thread: thread.id, from: agentId });
|
|
199
|
+
|
|
200
|
+
pending.resolve(text);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* An agent that dies mid-question would otherwise leave its asker blocked
|
|
205
|
+
* until the timeout. Called when an agent crashes or is killed.
|
|
206
|
+
*/
|
|
207
|
+
onAgentGone(agentId, reason = 'stopped') {
|
|
208
|
+
const pending = this.awaiting.get(agentId);
|
|
209
|
+
if (!pending) return;
|
|
210
|
+
const agent = this.daemon.registry.get(agentId);
|
|
211
|
+
pending.reject(new Error(`${agent?.name || agentId} ${reason} before answering`));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Threads ─────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
_createThread(fromAgent, toAgent) {
|
|
217
|
+
const thread = {
|
|
218
|
+
id: randomUUID().slice(0, 12),
|
|
219
|
+
participants: [peer(fromAgent), peer(toAgent)],
|
|
220
|
+
turns: [],
|
|
221
|
+
status: 'idle',
|
|
222
|
+
createdAt: Date.now(),
|
|
223
|
+
updatedAt: Date.now(),
|
|
224
|
+
};
|
|
225
|
+
this.threads.set(thread.id, thread);
|
|
226
|
+
return thread;
|
|
227
|
+
}
|
|
60
228
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
229
|
+
// Consecutive asks between the same pair continue one conversation, so the
|
|
230
|
+
// exchange cap actually bounds a back-and-forth rather than resetting.
|
|
231
|
+
_findThread(a, b) {
|
|
232
|
+
return this.getThreads(a).find((t) => t.participants.some((p) => p.id === b)) || null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// The target sees a direct message from the other agent, with enough prior
|
|
236
|
+
// turns to follow a continuing conversation.
|
|
237
|
+
_wrap(thread, fromAgent, toAgent, message) {
|
|
238
|
+
const prior = thread.turns.slice(0, -1).slice(-CONTEXT_TURNS);
|
|
239
|
+
const lines = [`[InnerChat — ${fromAgent.name} (${fromAgent.role}) is asking you a question]`, ''];
|
|
240
|
+
|
|
241
|
+
if (prior.length) {
|
|
242
|
+
lines.push('Earlier in this conversation:');
|
|
243
|
+
for (const t of prior) lines.push(` ${t.from.name}: ${truncate(t.text, MAX_TURN_CHARS)}`);
|
|
244
|
+
lines.push('');
|
|
66
245
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
246
|
+
|
|
247
|
+
lines.push(message, '');
|
|
248
|
+
lines.push(
|
|
249
|
+
`${fromAgent.name} is BLOCKED waiting on your answer — it cannot continue until you `
|
|
250
|
+
+ 'respond. Answer directly and concisely in your next message; that message is sent '
|
|
251
|
+
+ 'back to it verbatim. Do not address the user, and do not start unrelated work first.',
|
|
252
|
+
);
|
|
253
|
+
return lines.join('\n');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
_otherParticipant(thread, agentId) {
|
|
257
|
+
return thread.participants.find((p) => p.id !== agentId) || thread.participants[0];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
_remapParticipant(thread, oldId, newId) {
|
|
261
|
+
for (const p of thread.participants) if (p.id === oldId) p.id = newId;
|
|
262
|
+
const pending = this.awaiting.get(oldId);
|
|
263
|
+
if (pending) {
|
|
264
|
+
this.awaiting.delete(oldId);
|
|
265
|
+
this.awaiting.set(newId, pending);
|
|
80
266
|
}
|
|
267
|
+
}
|
|
81
268
|
|
|
82
|
-
|
|
83
|
-
this.daemon.
|
|
269
|
+
_broadcast(thread, turn) {
|
|
270
|
+
this.daemon.broadcast({ type: 'innerchat:turn', data: { thread, turn } });
|
|
84
271
|
}
|
|
85
272
|
|
|
86
|
-
|
|
87
|
-
|
|
273
|
+
// ── Queries ─────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
getThreads(agentId = null) {
|
|
276
|
+
const all = Array.from(this.threads.values()).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
88
277
|
if (!agentId) return all;
|
|
89
|
-
return all.filter(
|
|
278
|
+
return all.filter((t) => t.participants.some((p) => p.id === agentId));
|
|
90
279
|
}
|
|
91
280
|
|
|
92
|
-
|
|
93
|
-
return this.
|
|
281
|
+
getThread(id) {
|
|
282
|
+
return this.threads.get(id) || null;
|
|
94
283
|
}
|
|
95
284
|
|
|
96
285
|
getPending(agentId) {
|
|
97
|
-
const
|
|
98
|
-
return
|
|
286
|
+
const pending = this.awaiting.get(agentId);
|
|
287
|
+
return pending ? this.threads.get(pending.threadId) : null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function peer(agent) {
|
|
292
|
+
return { id: agent.id, name: agent.name, role: agent.role };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function truncate(text, max) {
|
|
296
|
+
return text.length <= max ? text : `${text.slice(0, max)}… [truncated]`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function extractText(data) {
|
|
300
|
+
if (typeof data === 'string') return data.trim();
|
|
301
|
+
if (Array.isArray(data)) {
|
|
302
|
+
return data.filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim();
|
|
99
303
|
}
|
|
304
|
+
return '';
|
|
100
305
|
}
|
|
306
|
+
|
|
307
|
+
export { MAX_EXCHANGES, DEFAULT_TIMEOUT_MS };
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { writeFileSync, readFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
5
5
|
import { resolve, dirname, basename } from 'path';
|
|
6
6
|
import { escapeMd } from './validate.js';
|
|
7
|
+
import { innerChatInstructions } from './innerchat-docs.js';
|
|
7
8
|
|
|
8
9
|
const GROOVE_SECTION_START = '<!-- GROOVE:START -->';
|
|
9
10
|
const GROOVE_SECTION_END = '<!-- GROOVE:END -->';
|
|
@@ -556,6 +557,12 @@ export class Introducer {
|
|
|
556
557
|
return null;
|
|
557
558
|
}
|
|
558
559
|
|
|
560
|
+
// Written into every AGENTS_REGISTRY.md so the capability survives context
|
|
561
|
+
// compaction — the spawn prompt alone can scroll out of a long session.
|
|
562
|
+
_innerChatSection() {
|
|
563
|
+
return innerChatInstructions(this.daemon.port || 31415);
|
|
564
|
+
}
|
|
565
|
+
|
|
559
566
|
writeRegistryFile(projectDir) {
|
|
560
567
|
const agents = this.daemon.registry.getAll();
|
|
561
568
|
|
|
@@ -596,6 +603,27 @@ export class Introducer {
|
|
|
596
603
|
lines.push(`| ${escapeMd(a.id)} | ${escapeMd(a.name)} | ${escapeMd(a.role)} | ${escapeMd(a.provider)} | ${agentDir} | ${scope} | ${escapeMd(a.status)} |`);
|
|
597
604
|
}
|
|
598
605
|
|
|
606
|
+
// Agents on other teams are reachable via InnerChat, so they have to be
|
|
607
|
+
// discoverable here — otherwise cross-team consultation means guessing
|
|
608
|
+
// names. Kept to a separate, minimal table so the team's own roster
|
|
609
|
+
// stays the primary listing.
|
|
610
|
+
const others = agents.filter((a) => (a.teamId || '_default') !== teamId);
|
|
611
|
+
if (others.length > 0) {
|
|
612
|
+
lines.push('');
|
|
613
|
+
lines.push(`## Other Teams`);
|
|
614
|
+
lines.push('');
|
|
615
|
+
lines.push(`Reachable via InnerChat (see below). Not in your scope — do not edit their files.`);
|
|
616
|
+
lines.push('');
|
|
617
|
+
lines.push(`| Name | Role | Team | Status |`);
|
|
618
|
+
lines.push(`|------|------|------|--------|`);
|
|
619
|
+
for (const a of others) {
|
|
620
|
+
const otherTeam = this.daemon.teams?.get(a.teamId)?.name || '-';
|
|
621
|
+
lines.push(`| ${escapeMd(a.name)} | ${escapeMd(a.role)} | ${escapeMd(otherTeam)} | ${escapeMd(a.status)} |`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
lines.push('');
|
|
626
|
+
lines.push(...this._innerChatSection());
|
|
599
627
|
lines.push('');
|
|
600
628
|
lines.push(`*Updated: ${new Date().toISOString()}*`);
|
|
601
629
|
|
|
@@ -632,7 +660,8 @@ export class Introducer {
|
|
|
632
660
|
running.length > 0 ? '|------|------|-------|' : '',
|
|
633
661
|
...running.map((a) => `| ${a.name} | ${a.role} | ${a.scope?.join(', ') || '-'} |`),
|
|
634
662
|
'',
|
|
635
|
-
`See AGENTS_REGISTRY.md for full agent state
|
|
663
|
+
`See AGENTS_REGISTRY.md for full agent state, the names of agents on other teams,`,
|
|
664
|
+
`and how to consult them directly (InnerChat).`,
|
|
636
665
|
'',
|
|
637
666
|
`**Memory policy:** GROOVE manages project memory automatically. Do not read or write MEMORY.md or .groove/memory/ files directly.`,
|
|
638
667
|
'',
|
|
@@ -10,6 +10,7 @@ import { LocalProvider } from './providers/local.js';
|
|
|
10
10
|
import { OllamaProvider } from './providers/ollama.js';
|
|
11
11
|
import { AgentLoop } from './agent-loop.js';
|
|
12
12
|
import { validateAgentConfig } from './validate.js';
|
|
13
|
+
import { innerChatInstructions } from './innerchat-docs.js';
|
|
13
14
|
|
|
14
15
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
16
|
const SLIDES_ENGINE_SRC = resolve(__dirname, '../templates/groove-slides.cjs');
|
|
@@ -320,7 +321,7 @@ const PERMISSION_PROMPTS = {
|
|
|
320
321
|
supervised: null, // Maps to auto (supervised removed — too expensive)
|
|
321
322
|
};
|
|
322
323
|
|
|
323
|
-
function sanitizeFilename(name) {
|
|
324
|
+
export function sanitizeFilename(name) {
|
|
324
325
|
return String(name).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
|
|
325
326
|
}
|
|
326
327
|
|
|
@@ -506,6 +507,13 @@ export class ProcessManager {
|
|
|
506
507
|
|
|
507
508
|
registry.update(agent.id, { status: finalStatus, pid: null });
|
|
508
509
|
|
|
510
|
+
// An agent that dies mid-question leaves its asker blocked on an open HTTP
|
|
511
|
+
// request — fail it now rather than making it wait out the timeout.
|
|
512
|
+
// 'completed' is normal: the answer arrives as a result before this fires.
|
|
513
|
+
if (this.daemon.innerchat && finalStatus !== 'completed') {
|
|
514
|
+
this.daemon.innerchat.onAgentGone(agent.id, finalStatus);
|
|
515
|
+
}
|
|
516
|
+
|
|
509
517
|
if (this.daemon.timeline) {
|
|
510
518
|
const agentData = registry.get(agent.id);
|
|
511
519
|
this.daemon.timeline.recordEvent(finalStatus === 'completed' ? 'complete' : finalStatus === 'crashed' ? 'crash' : 'kill', {
|
|
@@ -1152,6 +1160,19 @@ For normal file edits within your scope, proceed without review.
|
|
|
1152
1160
|
}
|
|
1153
1161
|
}
|
|
1154
1162
|
|
|
1163
|
+
// InnerChat — let the agent consult other agents directly.
|
|
1164
|
+
// Same constraint as PM review: the agent calls back over HTTP, so
|
|
1165
|
+
// sandboxed providers (Codex) can't reach it.
|
|
1166
|
+
if (!sandboxedProviders.includes(providerName) && !isOneShotProvider) {
|
|
1167
|
+
const port = this.daemon.port || 31415;
|
|
1168
|
+
const innerChatPrompt = innerChatInstructions(port, agent.name).join('\n') + '\n\n';
|
|
1169
|
+
if (spawnConfig.prompt.startsWith('# Handoff Brief')) {
|
|
1170
|
+
spawnConfig.prompt += '\n\n' + innerChatPrompt.trim();
|
|
1171
|
+
} else {
|
|
1172
|
+
spawnConfig.prompt = innerChatPrompt + spawnConfig.prompt;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1155
1176
|
// Set up log capture (shared between CLI and agent loop paths)
|
|
1156
1177
|
const logDir = resolve(this.daemon.grooveDir, 'logs');
|
|
1157
1178
|
mkdirSync(logDir, { recursive: true });
|
|
@@ -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
|
+
}
|