groove-dev 0.27.183 → 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.
Files changed (45) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +41 -0
  4. package/node_modules/@groove-dev/daemon/src/innerchat.js +147 -91
  5. package/node_modules/@groove-dev/daemon/src/introducer.js +30 -1
  6. package/node_modules/@groove-dev/daemon/src/process.js +21 -0
  7. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +57 -13
  8. package/node_modules/@groove-dev/daemon/src/routes/teams.js +11 -0
  9. package/node_modules/@groove-dev/daemon/src/teams.js +28 -0
  10. package/node_modules/@groove-dev/daemon/test/innerchat.test.js +157 -184
  11. package/node_modules/@groove-dev/daemon/test/teams.test.js +53 -0
  12. package/{packages/gui/dist/assets/index-DPjGBQ5X.js → node_modules/@groove-dev/gui/dist/assets/index-BpOyN6Zf.js} +227 -227
  13. package/node_modules/@groove-dev/gui/dist/assets/index-DiXB7yry.css +1 -0
  14. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  15. package/node_modules/@groove-dev/gui/package.json +1 -1
  16. package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +1 -29
  17. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
  18. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-pane.jsx +1 -15
  19. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +29 -11
  20. package/node_modules/@groove-dev/gui/src/stores/groove.js +6 -0
  21. package/node_modules/@groove-dev/gui/src/stores/slices/teams-slice.js +22 -0
  22. package/package.json +1 -1
  23. package/packages/cli/package.json +1 -1
  24. package/packages/daemon/package.json +1 -1
  25. package/packages/daemon/src/innerchat-docs.js +41 -0
  26. package/packages/daemon/src/innerchat.js +147 -91
  27. package/packages/daemon/src/introducer.js +30 -1
  28. package/packages/daemon/src/process.js +21 -0
  29. package/packages/daemon/src/routes/innerchat.js +57 -13
  30. package/packages/daemon/src/routes/teams.js +11 -0
  31. package/packages/daemon/src/teams.js +28 -0
  32. package/{node_modules/@groove-dev/gui/dist/assets/index-DPjGBQ5X.js → packages/gui/dist/assets/index-BpOyN6Zf.js} +227 -227
  33. package/packages/gui/dist/assets/index-DiXB7yry.css +1 -0
  34. package/packages/gui/dist/index.html +2 -2
  35. package/packages/gui/package.json +1 -1
  36. package/packages/gui/src/components/agents/agent-panel.jsx +1 -29
  37. package/packages/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
  38. package/packages/gui/src/components/fleet/fleet-pane.jsx +1 -15
  39. package/packages/gui/src/components/fleet/fleet-sidebar.jsx +29 -11
  40. package/packages/gui/src/stores/groove.js +6 -0
  41. package/packages/gui/src/stores/slices/teams-slice.js +22 -0
  42. package/node_modules/@groove-dev/gui/dist/assets/index-CiOy7wVS.css +0 -1
  43. package/node_modules/@groove-dev/gui/src/components/agents/innerchat-relay.jsx +0 -145
  44. package/packages/gui/dist/assets/index-CiOy7wVS.css +0 -1
  45. package/packages/gui/src/components/agents/innerchat-relay.jsx +0 -145
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.183",
3
+ "version": "0.27.184",
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.183",
3
+ "version": "0.27.184",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -0,0 +1,41 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ // The agent-facing InnerChat instructions, in one place.
4
+ //
5
+ // These are injected into a new agent's spawn prompt AND written into every
6
+ // AGENTS_REGISTRY.md. Both are needed: the prompt reaches the agent on turn
7
+ // one, and the file survives context compaction — an agent that has forgotten
8
+ // the prompt can re-read the registry it already knows to consult.
9
+ //
10
+ // No imports on purpose — process.js and introducer.js both pull from here.
11
+
12
+ export function innerChatInstructions(port = 31415, agentName = 'YOUR_NAME') {
13
+ return [
14
+ '## Consulting Other Agents (InnerChat)',
15
+ '',
16
+ 'Other GROOVE agents may be working alongside you, including on other teams.',
17
+ 'When the user asks you to reach out to, coordinate with, or get input from',
18
+ 'another agent, use this — it sends them a message and waits for their reply:',
19
+ '',
20
+ '```bash',
21
+ `curl -s http://localhost:${port}/api/innerchat/ask -X POST -H 'Content-Type: application/json' \\`,
22
+ ` -d '{"from":"${agentName}","to":"AGENT_NAME","message":"YOUR_QUESTION"}'`,
23
+ '```',
24
+ '',
25
+ 'The command BLOCKS until they answer, then returns:',
26
+ '`{"from":"...","reply":"...","exchangesUsed":N,"exchangesRemaining":N}`',
27
+ '',
28
+ 'Call it again to continue — prior turns are included automatically, so just say',
29
+ 'the next thing. Keep going until you reach a conclusion together, then report',
30
+ 'that conclusion to the user.',
31
+ '',
32
+ '**Rules:**',
33
+ '- Only start a conversation when the user asks you to. Never consult other agents on your own initiative.',
34
+ `- \`from\` must be your own agent name${agentName === 'YOUR_NAME' ? ' (it is in your $GROOVE_AGENT_NAME environment variable)' : ''}.`,
35
+ '- Agent names are listed in `AGENTS_REGISTRY.md`, including agents on other teams.',
36
+ '- One question per call. Wait for the answer before deciding what to ask next.',
37
+ '- Read any error you get back — it tells you what to do (unknown name, cap reached, or they are waiting on you).',
38
+ '- `exchangesRemaining` counts down. At 0 the conversation is over: stop and report what you have.',
39
+ '- If another agent asks YOU something, answer it directly in your next message. They are blocked until you do.',
40
+ ];
41
+ }
@@ -3,51 +3,87 @@
3
3
  import { randomUUID } from 'crypto';
4
4
  import { deliverInstruction } from './deliver.js';
5
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.
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
+
8
16
  const CONTEXT_TURNS = 4;
9
17
  const MAX_TURN_CHARS = 1200;
10
18
 
11
19
  /**
12
- * Agent-to-agent relay, user-initiated.
20
+ * Agent-to-agent consultation.
13
21
  *
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.
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.
17
27
  *
18
28
  * 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.
29
+ * a question reaches its target whether it's mid-task, idle, or stopped.
20
30
  */
21
31
  export class InnerChat {
22
32
  constructor(daemon) {
23
33
  this.daemon = daemon;
24
34
  this.threads = new Map();
25
- // agentId -> { threadId, sentAt } — which agent we're awaiting a reply from.
35
+ // agentId being asked -> pending record (resolve/reject/timer/threadId)
26
36
  this.awaiting = new Map();
37
+ // asker id -> id of the agent it is currently blocked on
38
+ this.blockedOn = new Map();
27
39
  }
28
40
 
29
41
  /**
30
- * Relay a message from one agent to another, opening a thread or continuing
31
- * an existing one.
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.
32
47
  */
33
- async send(fromAgentId, toAgentId, message, threadId = null) {
48
+ async ask(fromAgentId, toAgentId, message, opts = {}) {
34
49
  const fromAgent = this.daemon.registry.get(fromAgentId);
35
50
  const toAgent = this.daemon.registry.get(toAgentId);
36
- if (!fromAgent) throw new Error(`Sender agent ${fromAgentId} not found`);
51
+ if (!fromAgent) throw new Error(`Calling agent ${fromAgentId} not found`);
37
52
  if (!toAgent) throw new Error(`Target agent ${toAgentId} not found`);
38
- if (fromAgentId === toAgentId) throw new Error('Cannot relay an agent to itself');
53
+ if (fromAgentId === toAgentId) throw new Error('An agent cannot ask itself');
39
54
  if (!message || !message.trim()) throw new Error('message is required');
40
55
 
41
- const thread = threadId ? this.threads.get(threadId) : null;
42
- if (threadId && !thread) throw new Error(`Thread ${threadId} not found`);
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
+ }
43
67
 
68
+ const thread = opts.threadId ? this.threads.get(opts.threadId) : this._findThread(fromAgentId, toAgentId);
44
69
  const t = thread || this._createThread(fromAgent, toAgent);
70
+
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
+
45
81
  const turn = {
46
82
  id: randomUUID().slice(0, 12),
47
83
  from: peer(fromAgent),
48
84
  to: peer(toAgent),
49
85
  text: message.trim(),
50
- kind: 'relay',
86
+ kind: 'ask',
51
87
  status: 'sending',
52
88
  timestamp: Date.now(),
53
89
  };
@@ -62,35 +98,71 @@ export class InnerChat {
62
98
  turn.status = 'failed';
63
99
  turn.error = err.message;
64
100
  t.status = 'failed';
65
- this.daemon.broadcast({ type: 'innerchat:turn', data: { thread: t, turn } });
66
- throw err;
101
+ this._broadcast(t, turn);
102
+ throw new Error(`Could not reach ${toAgent.name}: ${err.message}`);
67
103
  }
68
104
 
69
105
  // 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;
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;
74
111
  }
75
112
 
76
113
  turn.status = result.status;
77
114
  t.status = 'awaiting_reply';
78
115
  t.updatedAt = Date.now();
116
+ this._broadcast(t, turn);
117
+ this.daemon.audit.log('innerchat.ask', { thread: t.id, from: fromAgentId, to: targetId });
79
118
 
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.
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.
82
121
  const skipResults = result.status === 'message_queued' ? 1 : 0;
83
- this.awaiting.set(turn.to.id, { threadId: t.id, sentAt: Date.now(), skipResults });
84
122
 
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 });
123
+ const reply = await this._awaitReply(t, fromAgentId, targetId, { skipResults, timeoutMs });
124
+
125
+ return {
126
+ reply,
127
+ threadId: t.id,
128
+ exchanges: exchanges + 1,
129
+ remaining: MAX_EXCHANGES - (exchanges + 1),
130
+ };
131
+ }
87
132
 
88
- return { thread: t, turn };
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
+ });
89
161
  }
90
162
 
91
163
  /**
92
- * Watch agent output for the reply to an outstanding relay, then forward it
93
- * back to the agent that asked.
164
+ * Watch agent output for the answer to an outstanding question.
165
+ * Called from the process manager's output handler.
94
166
  */
95
167
  onAgentOutput(agentId, output) {
96
168
  const pending = this.awaiting.get(agentId);
@@ -98,72 +170,48 @@ export class InnerChat {
98
170
  if (output.type !== 'result') return;
99
171
 
100
172
  const thread = this.threads.get(pending.threadId);
101
- if (!thread) { this.awaiting.delete(agentId); return; }
173
+ if (!thread) { pending.reject(new Error('Conversation was lost')); return; }
102
174
 
103
- const responseText = extractText(output.data);
104
- if (!responseText) return;
175
+ const text = extractText(output.data);
176
+ if (!text) return;
105
177
 
106
- // Burn off results belonging to work that was already underway when the
107
- // relay was queued behind it.
178
+ // Burn off results belonging to work already underway when we queued.
108
179
  if (pending.skipResults > 0) {
109
180
  pending.skipResults -= 1;
110
181
  return;
111
182
  }
112
183
 
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);
116
-
117
184
  const responder = this.daemon.registry.get(agentId);
118
- const asker = this._otherParticipant(thread, agentId);
119
-
120
- const turn = {
185
+ thread.turns.push({
121
186
  id: randomUUID().slice(0, 12),
122
187
  from: responder ? peer(responder) : { id: agentId, name: agentId, role: 'agent' },
123
- to: asker,
124
- text: responseText,
125
- kind: 'reply',
126
- status: 'forwarding',
188
+ to: this._otherParticipant(thread, agentId),
189
+ text,
190
+ kind: 'answer',
191
+ status: 'delivered',
127
192
  timestamp: Date.now(),
128
- };
129
- thread.turns.push(turn);
130
- thread.status = 'forwarding';
193
+ });
194
+ thread.status = 'idle';
131
195
  thread.updatedAt = Date.now();
132
196
 
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');
197
+ this._broadcast(thread, thread.turns.at(-1));
198
+ this.daemon.audit.log('innerchat.answer', { thread: thread.id, from: agentId });
146
199
 
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';
159
- }
200
+ pending.resolve(text);
201
+ }
160
202
 
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 });
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`));
164
212
  }
165
213
 
166
- // ── Thread helpers ──────────────────────────────────────────
214
+ // ── Threads ─────────────────────────────────────────────────
167
215
 
168
216
  _createThread(fromAgent, toAgent) {
169
217
  const thread = {
@@ -178,24 +226,29 @@ export class InnerChat {
178
226
  return thread;
179
227
  }
180
228
 
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.
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.
183
237
  _wrap(thread, fromAgent, toAgent, message) {
184
238
  const prior = thread.turns.slice(0, -1).slice(-CONTEXT_TURNS);
185
- const lines = [`[InnerChat from ${fromAgent.name} (${fromAgent.role})]`, ''];
239
+ const lines = [`[InnerChat ${fromAgent.name} (${fromAgent.role}) is asking you a question]`, ''];
186
240
 
187
241
  if (prior.length) {
188
242
  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
- }
243
+ for (const t of prior) lines.push(` ${t.from.name}: ${truncate(t.text, MAX_TURN_CHARS)}`);
192
244
  lines.push('');
193
245
  }
194
246
 
195
247
  lines.push(message, '');
196
248
  lines.push(
197
- `Reply normally your response is relayed straight back to ${fromAgent.name}. `
198
- + 'Answer them directly; do not address the user.',
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.',
199
252
  );
200
253
  return lines.join('\n');
201
254
  }
@@ -205,10 +258,7 @@ export class InnerChat {
205
258
  }
206
259
 
207
260
  _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.
261
+ for (const p of thread.participants) if (p.id === oldId) p.id = newId;
212
262
  const pending = this.awaiting.get(oldId);
213
263
  if (pending) {
214
264
  this.awaiting.delete(oldId);
@@ -216,6 +266,10 @@ export class InnerChat {
216
266
  }
217
267
  }
218
268
 
269
+ _broadcast(thread, turn) {
270
+ this.daemon.broadcast({ type: 'innerchat:turn', data: { thread, turn } });
271
+ }
272
+
219
273
  // ── Queries ─────────────────────────────────────────────────
220
274
 
221
275
  getThreads(agentId = null) {
@@ -249,3 +303,5 @@ function extractText(data) {
249
303
  }
250
304
  return '';
251
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');
@@ -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 });
@@ -1,21 +1,65 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
 
3
+ import { MAX_EXCHANGES } from '../innerchat.js';
4
+
5
+ // Agents know each other by name, not id — resolve either, preferring an
6
+ // exact name match so `fullstack-1` never resolves to `fullstack-14`.
7
+ function resolveAgent(daemon, ref) {
8
+ if (!ref || typeof ref !== 'string') return null;
9
+ const all = daemon.registry.getAll();
10
+ return all.find((a) => a.id === ref)
11
+ || all.find((a) => a.name === ref)
12
+ || all.find((a) => a.name.toLowerCase() === ref.toLowerCase())
13
+ || null;
14
+ }
15
+
3
16
  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.
6
- app.post('/api/innerchat/send', async (req, res) => {
17
+ /**
18
+ * Ask another agent a question and BLOCK until it answers.
19
+ *
20
+ * This request is held open deliberately — the calling agent is waiting on
21
+ * it, and the response body is the other agent's reply. That's what lets two
22
+ * agents iterate without a human relaying between them.
23
+ */
24
+ app.post('/api/innerchat/ask', async (req, res) => {
7
25
  try {
8
- const { from, to, message, threadId } = req.body;
9
- if (!from || typeof from !== 'string') return res.status(400).json({ error: 'from (agent ID) is required' });
10
- if (!to || typeof to !== 'string') return res.status(400).json({ error: 'to (agent ID) is required' });
11
- if (!message || typeof message !== 'string' || !message.trim()) return res.status(400).json({ error: 'message is required' });
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' });
14
-
15
- const result = await daemon.innerchat.send(from, to, message.trim(), threadId || null);
16
- res.json(result);
26
+ const { from, to, message, timeoutMs } = req.body || {};
27
+
28
+ const fromAgent = resolveAgent(daemon, from);
29
+ if (!fromAgent) return res.status(404).json({ error: `Unknown calling agent: ${from}` });
30
+
31
+ const toAgent = resolveAgent(daemon, to);
32
+ if (!toAgent) {
33
+ const names = daemon.registry.getAll()
34
+ .filter((a) => a.id !== fromAgent.id)
35
+ .map((a) => a.name);
36
+ return res.status(404).json({
37
+ error: `No agent named "${to}".`,
38
+ availableAgents: names,
39
+ });
40
+ }
41
+
42
+ if (!message || typeof message !== 'string' || !message.trim()) {
43
+ return res.status(400).json({ error: 'message is required' });
44
+ }
45
+
46
+ // Held open until the target answers — see the class doc in innerchat.js.
47
+ req.setTimeout(0);
48
+ res.setTimeout(0);
49
+
50
+ const result = await daemon.innerchat.ask(fromAgent.id, toAgent.id, message.trim(), { timeoutMs });
51
+
52
+ res.json({
53
+ from: toAgent.name,
54
+ reply: result.reply,
55
+ threadId: result.threadId,
56
+ exchangesUsed: result.exchanges,
57
+ exchangesRemaining: result.remaining,
58
+ maxExchanges: MAX_EXCHANGES,
59
+ });
17
60
  } catch (err) {
18
- res.status(400).json({ error: err.message });
61
+ // The agent reads this body — keep it actionable, it's the whole signal.
62
+ res.status(409).json({ error: err.message });
19
63
  }
20
64
  });
21
65
 
@@ -26,6 +26,17 @@ export function registerTeamRoutes(app, daemon) {
26
26
  }
27
27
  });
28
28
 
29
+ // Reorder the sidebar list. Registered before /api/teams/:id so Express
30
+ // doesn't capture "reorder" as a team id.
31
+ app.post('/api/teams/reorder', (req, res) => {
32
+ try {
33
+ const teams = daemon.teams.reorder(req.body?.orderedIds);
34
+ res.json({ teams });
35
+ } catch (err) {
36
+ res.status(400).json({ error: err.message });
37
+ }
38
+ });
39
+
29
40
  app.get('/api/teams/archived', (req, res) => {
30
41
  res.json({ archived: daemon.teams.listArchived() });
31
42
  });
@@ -113,6 +113,34 @@ export class Teams {
113
113
  return team;
114
114
  }
115
115
 
116
+ /**
117
+ * Reorder the team list. Teams persist as a JSON array, so Map insertion
118
+ * order is the display order — rebuilding the Map is the whole operation.
119
+ *
120
+ * Ids missing from `orderedIds` keep their relative order at the end, so a
121
+ * client working from a stale list can't drop teams.
122
+ */
123
+ reorder(orderedIds) {
124
+ if (!Array.isArray(orderedIds)) throw new Error('orderedIds must be an array');
125
+
126
+ const seen = new Set();
127
+ const next = new Map();
128
+ for (const id of orderedIds) {
129
+ const team = this.teams.get(id);
130
+ if (!team || seen.has(id)) continue;
131
+ seen.add(id);
132
+ next.set(id, team);
133
+ }
134
+ for (const [id, team] of this.teams) {
135
+ if (!seen.has(id)) next.set(id, team);
136
+ }
137
+
138
+ this.teams = next;
139
+ this._save();
140
+ this.daemon.broadcast({ type: 'teams:reordered', teams: this.list() });
141
+ return this.list();
142
+ }
143
+
116
144
  get(id) {
117
145
  return this.teams.get(id) || null;
118
146
  }