groove-dev 0.27.183 → 0.27.185

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 (65) hide show
  1. package/.watch-test-flag-1784767927904 +1 -0
  2. package/.watch-test-flag-1784768055729 +1 -0
  3. package/.watch-test-flag-1784768066364 +1 -0
  4. package/node_modules/@groove-dev/cli/package.json +1 -1
  5. package/node_modules/@groove-dev/daemon/package.json +1 -1
  6. package/node_modules/@groove-dev/daemon/src/api.js +2 -0
  7. package/node_modules/@groove-dev/daemon/src/index.js +17 -0
  8. package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +96 -0
  9. package/node_modules/@groove-dev/daemon/src/innerchat.js +265 -96
  10. package/node_modules/@groove-dev/daemon/src/introducer.js +31 -1
  11. package/node_modules/@groove-dev/daemon/src/process.js +36 -6
  12. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +109 -13
  13. package/node_modules/@groove-dev/daemon/src/routes/teams.js +11 -0
  14. package/node_modules/@groove-dev/daemon/src/routes/watch.js +42 -0
  15. package/node_modules/@groove-dev/daemon/src/teams.js +28 -0
  16. package/node_modules/@groove-dev/daemon/src/watcher.js +258 -0
  17. package/node_modules/@groove-dev/daemon/test/innerchat.test.js +222 -179
  18. package/node_modules/@groove-dev/daemon/test/teams.test.js +53 -0
  19. package/node_modules/@groove-dev/daemon/test/watcher.test.js +158 -0
  20. package/node_modules/@groove-dev/gui/dist/assets/index-CU8L_r5f.css +1 -0
  21. package/{packages/gui/dist/assets/index-DPjGBQ5X.js → node_modules/@groove-dev/gui/dist/assets/index-DEZwM4Hb.js} +233 -228
  22. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  23. package/node_modules/@groove-dev/gui/package.json +1 -1
  24. package/node_modules/@groove-dev/gui/src/app.css +1 -0
  25. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +60 -4
  26. package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +1 -29
  27. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
  28. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-pane.jsx +1 -15
  29. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +29 -11
  30. package/node_modules/@groove-dev/gui/src/stores/groove.js +36 -16
  31. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +6 -0
  32. package/node_modules/@groove-dev/gui/src/stores/slices/teams-slice.js +22 -0
  33. package/node_modules/@groove-dev/gui/src/views/memory.jsx +56 -31
  34. package/package.json +1 -1
  35. package/packages/cli/package.json +1 -1
  36. package/packages/daemon/package.json +1 -1
  37. package/packages/daemon/src/api.js +2 -0
  38. package/packages/daemon/src/index.js +17 -0
  39. package/packages/daemon/src/innerchat-docs.js +96 -0
  40. package/packages/daemon/src/innerchat.js +265 -96
  41. package/packages/daemon/src/introducer.js +31 -1
  42. package/packages/daemon/src/process.js +36 -6
  43. package/packages/daemon/src/routes/innerchat.js +109 -13
  44. package/packages/daemon/src/routes/teams.js +11 -0
  45. package/packages/daemon/src/routes/watch.js +42 -0
  46. package/packages/daemon/src/teams.js +28 -0
  47. package/packages/daemon/src/watcher.js +258 -0
  48. package/packages/gui/dist/assets/index-CU8L_r5f.css +1 -0
  49. package/{node_modules/@groove-dev/gui/dist/assets/index-DPjGBQ5X.js → packages/gui/dist/assets/index-DEZwM4Hb.js} +233 -228
  50. package/packages/gui/dist/index.html +2 -2
  51. package/packages/gui/package.json +1 -1
  52. package/packages/gui/src/app.css +1 -0
  53. package/packages/gui/src/components/agents/agent-feed.jsx +60 -4
  54. package/packages/gui/src/components/agents/agent-panel.jsx +1 -29
  55. package/packages/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
  56. package/packages/gui/src/components/fleet/fleet-pane.jsx +1 -15
  57. package/packages/gui/src/components/fleet/fleet-sidebar.jsx +29 -11
  58. package/packages/gui/src/stores/groove.js +36 -16
  59. package/packages/gui/src/stores/slices/agents-slice.js +6 -0
  60. package/packages/gui/src/stores/slices/teams-slice.js +22 -0
  61. package/packages/gui/src/views/memory.jsx +56 -31
  62. package/node_modules/@groove-dev/gui/dist/assets/index-CiOy7wVS.css +0 -1
  63. package/node_modules/@groove-dev/gui/src/components/agents/innerchat-relay.jsx +0 -145
  64. package/packages/gui/dist/assets/index-CiOy7wVS.css +0 -1
  65. package/packages/gui/src/components/agents/innerchat-relay.jsx +0 -145
@@ -3,94 +3,212 @@
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
+ }
67
+
68
+ const t = this._openThread(fromAgent, toAgent, opts.threadId);
69
+ this._checkExchangeCap(t, toAgent);
43
70
 
44
- const t = thread || this._createThread(fromAgent, toAgent);
71
+ const timeoutMs = Math.min(Number(opts.timeoutMs) || DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
72
+
73
+ const { turn, targetId, skipResults } = await this._deliver(t, fromAgent, toAgent, message.trim(), 'ask');
74
+ this.daemon.audit.log('innerchat.ask', { thread: t.id, from: fromAgentId, to: targetId });
75
+
76
+ const reply = await this._awaitReply(t, fromAgentId, targetId, { skipResults, timeoutMs });
77
+ const exchanges = t.turns.filter((x) => x.kind === 'ask' || x.kind === 'tell').length;
78
+
79
+ return { reply, threadId: t.id, exchanges, remaining: MAX_EXCHANGES - exchanges };
80
+ }
81
+
82
+ /**
83
+ * Send a message to another agent WITHOUT blocking. Returns as soon as it's
84
+ * delivered. If the target later replies, the reply is routed back to the
85
+ * sender asynchronously — resuming the sender if its turn has ended.
86
+ *
87
+ * This is the fire-and-forget counterpart to ask(): use it to hand off to an
88
+ * agent that's heads-down, where waiting out a timeout would waste the turn.
89
+ */
90
+ async tell(fromAgentId, toAgentId, message, opts = {}) {
91
+ const fromAgent = this.daemon.registry.get(fromAgentId);
92
+ const toAgent = this.daemon.registry.get(toAgentId);
93
+ if (!fromAgent) throw new Error(`Calling agent ${fromAgentId} not found`);
94
+ if (!toAgent) throw new Error(`Target agent ${toAgentId} not found`);
95
+ if (fromAgentId === toAgentId) throw new Error('An agent cannot message itself');
96
+ if (!message || !message.trim()) throw new Error('message is required');
97
+ if (this.awaiting.has(toAgentId)) {
98
+ throw new Error(`${toAgent.name} already has an unanswered message — wait for its reply before sending another`);
99
+ }
100
+
101
+ const t = this._openThread(fromAgent, toAgent, opts.threadId);
102
+ this._checkExchangeCap(t, toAgent);
103
+
104
+ const { turn, targetId, skipResults } = await this._deliver(t, fromAgent, toAgent, message.trim(), 'tell');
105
+ this.daemon.audit.log('innerchat.tell', { thread: t.id, from: fromAgentId, to: targetId });
106
+
107
+ // Register async capture: the reply (if any) is forwarded, not awaited.
108
+ this.awaiting.set(targetId, {
109
+ mode: 'async',
110
+ threadId: t.id,
111
+ senderId: fromAgentId,
112
+ skipResults,
113
+ timer: setTimeout(() => this._clearAwait(targetId), MAX_TIMEOUT_MS),
114
+ });
115
+
116
+ const exchanges = t.turns.filter((x) => x.kind === 'ask' || x.kind === 'tell').length;
117
+ return { delivered: true, threadId: t.id, exchanges, remaining: MAX_EXCHANGES - exchanges };
118
+ }
119
+
120
+ // Shared send: push a turn, deliver it, and remap the target id if delivery
121
+ // resumed/rotated the agent. Returns the queued-skip count for reply capture.
122
+ async _deliver(thread, fromAgent, toAgent, message, kind) {
45
123
  const turn = {
46
124
  id: randomUUID().slice(0, 12),
47
125
  from: peer(fromAgent),
48
126
  to: peer(toAgent),
49
- text: message.trim(),
50
- kind: 'relay',
127
+ text: message,
128
+ kind,
51
129
  status: 'sending',
52
130
  timestamp: Date.now(),
53
131
  };
54
- t.turns.push(turn);
132
+ thread.turns.push(turn);
55
133
 
56
- const wrapped = this._wrap(t, fromAgent, toAgent, message.trim());
134
+ const wrapped = this._wrap(thread, fromAgent, toAgent, message, kind);
57
135
 
58
136
  let result;
59
137
  try {
60
- result = await deliverInstruction(this.daemon, toAgentId, wrapped, { recordFeedback: false });
138
+ result = await deliverInstruction(this.daemon, toAgent.id, wrapped, { recordFeedback: false });
61
139
  } catch (err) {
62
140
  turn.status = 'failed';
63
141
  turn.error = err.message;
64
- t.status = 'failed';
65
- this.daemon.broadcast({ type: 'innerchat:turn', data: { thread: t, turn } });
66
- throw err;
142
+ thread.status = 'failed';
143
+ this._broadcast(thread, turn);
144
+ throw new Error(`Could not reach ${toAgent.name}: ${err.message}`);
67
145
  }
68
146
 
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;
147
+ // A stopped target gets resumed or rotated, minting a new agent id.
148
+ const targetId = result.agentId;
149
+ if (targetId !== toAgent.id) {
150
+ this._remapParticipant(thread, toAgent.id, targetId);
151
+ turn.to.id = targetId;
74
152
  }
75
153
 
76
154
  turn.status = result.status;
77
- t.status = 'awaiting_reply';
78
- t.updatedAt = Date.now();
155
+ thread.status = 'awaiting_reply';
156
+ thread.updatedAt = Date.now();
157
+ this._broadcast(thread, turn);
158
+
159
+ // A queued message sits behind the target's current work, so the next
160
+ // result belongs to that prior task, not to us — skip it.
161
+ return { turn, targetId, skipResults: result.status === 'message_queued' ? 1 : 0 };
162
+ }
79
163
 
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 });
164
+ _openThread(fromAgent, toAgent, threadId) {
165
+ const thread = threadId ? this.threads.get(threadId) : this._findThread(fromAgent.id, toAgent.id);
166
+ return thread || this._createThread(fromAgent, toAgent);
167
+ }
84
168
 
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 });
169
+ _checkExchangeCap(thread, toAgent) {
170
+ const used = thread.turns.filter((x) => x.kind === 'ask' || x.kind === 'tell').length;
171
+ if (used >= MAX_EXCHANGES) {
172
+ throw new Error(
173
+ `This conversation has reached its ${MAX_EXCHANGES}-exchange limit. Stop consulting `
174
+ + `${toAgent.name} and report your conclusion to the user.`,
175
+ );
176
+ }
177
+ }
87
178
 
88
- return { thread: t, turn };
179
+ _awaitReply(thread, askerId, targetId, { skipResults, timeoutMs }) {
180
+ return new Promise((resolve, reject) => {
181
+ const settle = (fn, value) => {
182
+ this._clearAwait(targetId);
183
+ this.blockedOn.delete(askerId);
184
+ fn(value);
185
+ };
186
+
187
+ const record = {
188
+ mode: 'block',
189
+ threadId: thread.id,
190
+ askerId,
191
+ skipResults,
192
+ resolve: (text) => settle(resolve, text),
193
+ reject: (err) => settle(reject, err),
194
+ timer: setTimeout(() => {
195
+ thread.status = 'timeout';
196
+ thread.updatedAt = Date.now();
197
+ record.reject(new Error(
198
+ `No answer within ${Math.round(timeoutMs / 1000)}s. The agent may still be working — `
199
+ + 'proceed on your own judgement, or use a non-blocking send (tell) next time.',
200
+ ));
201
+ }, timeoutMs),
202
+ };
203
+
204
+ this.awaiting.set(targetId, record);
205
+ this.blockedOn.set(askerId, targetId);
206
+ });
89
207
  }
90
208
 
91
209
  /**
92
- * Watch agent output for the reply to an outstanding relay, then forward it
93
- * back to the agent that asked.
210
+ * Watch agent output for the reply to an outstanding message.
211
+ * Called from the process manager's output handler.
94
212
  */
95
213
  onAgentOutput(agentId, output) {
96
214
  const pending = this.awaiting.get(agentId);
@@ -98,72 +216,88 @@ export class InnerChat {
98
216
  if (output.type !== 'result') return;
99
217
 
100
218
  const thread = this.threads.get(pending.threadId);
101
- if (!thread) { this.awaiting.delete(agentId); return; }
219
+ if (!thread) { this._settlePending(agentId, pending, null, new Error('Conversation was lost')); return; }
102
220
 
103
- const responseText = extractText(output.data);
104
- if (!responseText) return;
221
+ const text = extractText(output.data);
222
+ if (!text) return;
105
223
 
106
- // Burn off results belonging to work that was already underway when the
107
- // relay was queued behind it.
224
+ // Burn off results belonging to work already underway when we queued.
108
225
  if (pending.skipResults > 0) {
109
226
  pending.skipResults -= 1;
110
227
  return;
111
228
  }
112
229
 
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
230
  const responder = this.daemon.registry.get(agentId);
118
- const asker = this._otherParticipant(thread, agentId);
119
-
120
- const turn = {
231
+ const answerTurn = {
121
232
  id: randomUUID().slice(0, 12),
122
233
  from: responder ? peer(responder) : { id: agentId, name: agentId, role: 'agent' },
123
- to: asker,
124
- text: responseText,
125
- kind: 'reply',
126
- status: 'forwarding',
234
+ to: this._otherParticipant(thread, agentId),
235
+ text,
236
+ kind: 'answer',
237
+ status: 'delivered',
127
238
  timestamp: Date.now(),
128
239
  };
129
- thread.turns.push(turn);
130
- thread.status = 'forwarding';
240
+ thread.turns.push(answerTurn);
241
+ thread.status = 'idle';
131
242
  thread.updatedAt = Date.now();
132
-
133
- this._forwardReply(thread, turn, asker.id, responseText);
243
+ this._broadcast(thread, answerTurn);
244
+ this.daemon.audit.log('innerchat.answer', { thread: thread.id, from: agentId });
245
+
246
+ if (pending.mode === 'async') {
247
+ this._clearAwait(agentId);
248
+ this._forwardAsyncReply(pending, answerTurn, text);
249
+ } else {
250
+ pending.resolve(text);
251
+ }
134
252
  }
135
253
 
136
- async _forwardReply(thread, turn, askerId, responseText) {
137
- const from = turn.from;
138
- const relay = [
139
- `[InnerChat reply from ${from.name} (${from.role})]`,
254
+ // Deliver an async (tell) reply back to the original sender, resuming it if
255
+ // its turn has ended. The sender is not blocked, so this just wakes them.
256
+ async _forwardAsyncReply(pending, answerTurn, text) {
257
+ const sender = this.daemon.registry.get(pending.senderId)
258
+ || this.daemon.registry.getAll().find((a) => a.name === answerTurn.to.name);
259
+ if (!sender) return; // sender is gone — nothing to route back to
260
+
261
+ const msg = [
262
+ `[InnerChat reply from ${answerTurn.from.name} (${answerTurn.from.role})]`,
140
263
  '',
141
- responseText,
264
+ text,
142
265
  '',
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.',
266
+ 'This is a reply to a message you sent earlier (you were not blocking on it). '
267
+ + 'Fold it into your work, or reply again to continue.',
145
268
  ].join('\n');
146
269
 
147
270
  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';
271
+ await deliverInstruction(this.daemon, sender.id, msg, { recordFeedback: false });
155
272
  } catch (err) {
156
- turn.status = 'failed';
157
- turn.error = err.message;
158
- thread.status = 'failed';
273
+ this.daemon.audit.log('innerchat.forward_failed', { to: sender.id, error: err.message });
159
274
  }
275
+ }
160
276
 
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 });
277
+ /**
278
+ * An agent that dies mid-question would otherwise leave a blocking asker
279
+ * stuck until timeout. Async senders aren't waiting, so just clear those.
280
+ */
281
+ onAgentGone(agentId, reason = 'stopped') {
282
+ const pending = this.awaiting.get(agentId);
283
+ if (!pending) return;
284
+ if (pending.mode === 'async') { this._clearAwait(agentId); return; }
285
+ const agent = this.daemon.registry.get(agentId);
286
+ pending.reject(new Error(`${agent?.name || agentId} ${reason} before answering`));
164
287
  }
165
288
 
166
- // ── Thread helpers ──────────────────────────────────────────
289
+ _clearAwait(targetId) {
290
+ const pending = this.awaiting.get(targetId);
291
+ if (pending?.timer) clearTimeout(pending.timer);
292
+ this.awaiting.delete(targetId);
293
+ }
294
+
295
+ _settlePending(agentId, pending, value, err) {
296
+ if (pending.mode === 'async') { this._clearAwait(agentId); return; }
297
+ if (err) pending.reject(err); else pending.resolve(value);
298
+ }
299
+
300
+ // ── Threads ─────────────────────────────────────────────────
167
301
 
168
302
  _createThread(fromAgent, toAgent) {
169
303
  const thread = {
@@ -178,25 +312,43 @@ export class InnerChat {
178
312
  return thread;
179
313
  }
180
314
 
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) {
315
+ // Consecutive asks between the same pair continue one conversation, so the
316
+ // exchange cap actually bounds a back-and-forth rather than resetting.
317
+ _findThread(a, b) {
318
+ return this.getThreads(a).find((t) => t.participants.some((p) => p.id === b)) || null;
319
+ }
320
+
321
+ // The target sees a direct message from the other agent, with enough prior
322
+ // turns to follow a continuing conversation. The closing instruction differs
323
+ // by kind: an ask blocks the sender (answer now), a tell does not (answer
324
+ // when you reach a good stopping point).
325
+ _wrap(thread, fromAgent, toAgent, message, kind = 'ask') {
326
+ const header = kind === 'tell'
327
+ ? `[InnerChat — ${fromAgent.name} (${fromAgent.role}) sent you a message]`
328
+ : `[InnerChat — ${fromAgent.name} (${fromAgent.role}) is asking you a question]`;
184
329
  const prior = thread.turns.slice(0, -1).slice(-CONTEXT_TURNS);
185
- const lines = [`[InnerChat from ${fromAgent.name} (${fromAgent.role})]`, ''];
330
+ const lines = [header, ''];
186
331
 
187
332
  if (prior.length) {
188
333
  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
- }
334
+ for (const t of prior) lines.push(` ${t.from.name}: ${truncate(t.text, MAX_TURN_CHARS)}`);
192
335
  lines.push('');
193
336
  }
194
337
 
195
338
  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
- );
339
+ if (kind === 'tell') {
340
+ lines.push(
341
+ `${fromAgent.name} is NOT blocked on this finish what you're doing first if you're `
342
+ + 'mid-task. When you reach a good stopping point, answer directly in a message; your '
343
+ + `reply is relayed back to ${fromAgent.name}. If no reply is needed, just carry on.`,
344
+ );
345
+ } else {
346
+ lines.push(
347
+ `${fromAgent.name} is BLOCKED waiting on your answer — it cannot continue until you `
348
+ + 'respond. Answer directly and concisely in your next message; that message is sent '
349
+ + 'back to it verbatim. Do not address the user, and do not start unrelated work first.',
350
+ );
351
+ }
200
352
  return lines.join('\n');
201
353
  }
202
354
 
@@ -205,10 +357,7 @@ export class InnerChat {
205
357
  }
206
358
 
207
359
  _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.
360
+ for (const p of thread.participants) if (p.id === oldId) p.id = newId;
212
361
  const pending = this.awaiting.get(oldId);
213
362
  if (pending) {
214
363
  this.awaiting.delete(oldId);
@@ -216,6 +365,10 @@ export class InnerChat {
216
365
  }
217
366
  }
218
367
 
368
+ _broadcast(thread, turn) {
369
+ this.daemon.broadcast({ type: 'innerchat:turn', data: { thread, turn } });
370
+ }
371
+
219
372
  // ── Queries ─────────────────────────────────────────────────
220
373
 
221
374
  getThreads(agentId = null) {
@@ -232,6 +385,20 @@ export class InnerChat {
232
385
  const pending = this.awaiting.get(agentId);
233
386
  return pending ? this.threads.get(pending.threadId) : null;
234
387
  }
388
+
389
+ // Clear all outstanding timers so a shutdown (or a test) doesn't hang on
390
+ // pending ask/tell timeouts. Blocking asks are rejected so their HTTP
391
+ // requests don't hang either.
392
+ stop() {
393
+ for (const [targetId, pending] of this.awaiting) {
394
+ if (pending.timer) clearTimeout(pending.timer);
395
+ if (pending.mode === 'block') {
396
+ try { pending.reject(new Error('Daemon shutting down')); } catch { /* already settled */ }
397
+ }
398
+ this.awaiting.delete(targetId);
399
+ }
400
+ this.blockedOn.clear();
401
+ }
235
402
  }
236
403
 
237
404
  function peer(agent) {
@@ -249,3 +416,5 @@ function extractText(data) {
249
416
  }
250
417
  return '';
251
418
  }
419
+
420
+ 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, watchInstructions } 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,13 @@ 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
+ const port = this.daemon.port || 31415;
564
+ return [...innerChatInstructions(port), '', ...watchInstructions(port)];
565
+ }
566
+
559
567
  writeRegistryFile(projectDir) {
560
568
  const agents = this.daemon.registry.getAll();
561
569
 
@@ -596,6 +604,27 @@ export class Introducer {
596
604
  lines.push(`| ${escapeMd(a.id)} | ${escapeMd(a.name)} | ${escapeMd(a.role)} | ${escapeMd(a.provider)} | ${agentDir} | ${scope} | ${escapeMd(a.status)} |`);
597
605
  }
598
606
 
607
+ // Agents on other teams are reachable via InnerChat, so they have to be
608
+ // discoverable here — otherwise cross-team consultation means guessing
609
+ // names. Kept to a separate, minimal table so the team's own roster
610
+ // stays the primary listing.
611
+ const others = agents.filter((a) => (a.teamId || '_default') !== teamId);
612
+ if (others.length > 0) {
613
+ lines.push('');
614
+ lines.push(`## Other Teams`);
615
+ lines.push('');
616
+ lines.push(`Reachable via InnerChat (see below). Not in your scope — do not edit their files.`);
617
+ lines.push('');
618
+ lines.push(`| Name | Role | Team | Status |`);
619
+ lines.push(`|------|------|------|--------|`);
620
+ for (const a of others) {
621
+ const otherTeam = this.daemon.teams?.get(a.teamId)?.name || '-';
622
+ lines.push(`| ${escapeMd(a.name)} | ${escapeMd(a.role)} | ${escapeMd(otherTeam)} | ${escapeMd(a.status)} |`);
623
+ }
624
+ }
625
+
626
+ lines.push('');
627
+ lines.push(...this._innerChatSection());
599
628
  lines.push('');
600
629
  lines.push(`*Updated: ${new Date().toISOString()}*`);
601
630
 
@@ -632,7 +661,8 @@ export class Introducer {
632
661
  running.length > 0 ? '|------|------|-------|' : '',
633
662
  ...running.map((a) => `| ${a.name} | ${a.role} | ${a.scope?.join(', ') || '-'} |`),
634
663
  '',
635
- `See AGENTS_REGISTRY.md for full agent state.`,
664
+ `See AGENTS_REGISTRY.md for full agent state, the names of agents on other teams,`,
665
+ `and how to consult them directly (InnerChat).`,
636
666
  '',
637
667
  `**Memory policy:** GROOVE manages project memory automatically. Do not read or write MEMORY.md or .groove/memory/ files directly.`,
638
668
  '',
@@ -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, watchInstructions } 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', {
@@ -554,12 +562,17 @@ export class ProcessManager {
554
562
  const pending = this.consumePendingMessage(agent.id);
555
563
  if (pending) {
556
564
  const agentData = registry.get(agent.id);
557
- if (agentData?.sessionId) {
558
- this.resume(agent.id, pending.message).catch((err) => {
559
- console.error(`[Groove] Auto-resume with queued message failed for ${agent.name}: ${err.message}`);
560
- });
561
- return;
562
- }
565
+ // Deliver the queued message: resume if there's a session, otherwise
566
+ // rotate (fresh session + handoff brief). Previously a missing sessionId
567
+ // meant the already-consumed message was silently dropped — an
568
+ // InnerChat message to a busy agent would vanish and never fire it.
569
+ const deliver = agentData?.sessionId
570
+ ? this.resume(agent.id, pending.message)
571
+ : this.daemon.rotator.rotate(agent.id, { additionalPrompt: pending.message });
572
+ deliver.catch((err) => {
573
+ console.error(`[Groove] Delivering queued message to ${agent.name} failed: ${err.message}`);
574
+ });
575
+ return;
563
576
  }
564
577
  }
565
578
 
@@ -1152,6 +1165,23 @@ For normal file edits within your scope, proceed without review.
1152
1165
  }
1153
1166
  }
1154
1167
 
1168
+ // InnerChat — let the agent consult other agents directly.
1169
+ // Same constraint as PM review: the agent calls back over HTTP, so
1170
+ // sandboxed providers (Codex) can't reach it.
1171
+ if (!sandboxedProviders.includes(providerName) && !isOneShotProvider) {
1172
+ const port = this.daemon.port || 31415;
1173
+ const capabilities = [
1174
+ ...innerChatInstructions(port, agent.name),
1175
+ '',
1176
+ ...watchInstructions(port, agent.name),
1177
+ ].join('\n') + '\n\n';
1178
+ if (spawnConfig.prompt.startsWith('# Handoff Brief')) {
1179
+ spawnConfig.prompt += '\n\n' + capabilities.trim();
1180
+ } else {
1181
+ spawnConfig.prompt = capabilities + spawnConfig.prompt;
1182
+ }
1183
+ }
1184
+
1155
1185
  // Set up log capture (shared between CLI and agent loop paths)
1156
1186
  const logDir = resolve(this.daemon.grooveDir, 'logs');
1157
1187
  mkdirSync(logDir, { recursive: true });