groove-dev 0.27.184 → 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 (48) 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 +60 -5
  9. package/node_modules/@groove-dev/daemon/src/innerchat.js +167 -54
  10. package/node_modules/@groove-dev/daemon/src/introducer.js +3 -2
  11. package/node_modules/@groove-dev/daemon/src/process.js +19 -10
  12. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +80 -28
  13. package/node_modules/@groove-dev/daemon/src/routes/watch.js +42 -0
  14. package/node_modules/@groove-dev/daemon/src/watcher.js +258 -0
  15. package/node_modules/@groove-dev/daemon/test/innerchat.test.js +71 -1
  16. package/node_modules/@groove-dev/daemon/test/watcher.test.js +158 -0
  17. package/node_modules/@groove-dev/gui/dist/assets/index-CU8L_r5f.css +1 -0
  18. package/node_modules/@groove-dev/gui/dist/assets/{index-BpOyN6Zf.js → index-DEZwM4Hb.js} +227 -222
  19. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  20. package/node_modules/@groove-dev/gui/package.json +1 -1
  21. package/node_modules/@groove-dev/gui/src/app.css +1 -0
  22. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +60 -4
  23. package/node_modules/@groove-dev/gui/src/stores/groove.js +30 -16
  24. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +6 -0
  25. package/node_modules/@groove-dev/gui/src/views/memory.jsx +56 -31
  26. package/package.json +1 -1
  27. package/packages/cli/package.json +1 -1
  28. package/packages/daemon/package.json +1 -1
  29. package/packages/daemon/src/api.js +2 -0
  30. package/packages/daemon/src/index.js +17 -0
  31. package/packages/daemon/src/innerchat-docs.js +60 -5
  32. package/packages/daemon/src/innerchat.js +167 -54
  33. package/packages/daemon/src/introducer.js +3 -2
  34. package/packages/daemon/src/process.js +19 -10
  35. package/packages/daemon/src/routes/innerchat.js +80 -28
  36. package/packages/daemon/src/routes/watch.js +42 -0
  37. package/packages/daemon/src/watcher.js +258 -0
  38. package/packages/gui/dist/assets/index-CU8L_r5f.css +1 -0
  39. package/packages/gui/dist/assets/{index-BpOyN6Zf.js → index-DEZwM4Hb.js} +227 -222
  40. package/packages/gui/dist/index.html +2 -2
  41. package/packages/gui/package.json +1 -1
  42. package/packages/gui/src/app.css +1 -0
  43. package/packages/gui/src/components/agents/agent-feed.jsx +60 -4
  44. package/packages/gui/src/stores/groove.js +30 -16
  45. package/packages/gui/src/stores/slices/agents-slice.js +6 -0
  46. package/packages/gui/src/views/memory.jsx +56 -31
  47. package/node_modules/@groove-dev/gui/dist/assets/index-DiXB7yry.css +0 -1
  48. package/packages/gui/dist/assets/index-DiXB7yry.css +0 -1
@@ -65,81 +65,127 @@ export class InnerChat {
65
65
  throw new Error(`${toAgent.name} is already answering another agent — try again shortly`);
66
66
  }
67
67
 
68
- const thread = opts.threadId ? this.threads.get(opts.threadId) : this._findThread(fromAgentId, toAgentId);
69
- const t = thread || this._createThread(fromAgent, toAgent);
68
+ const t = this._openThread(fromAgent, toAgent, opts.threadId);
69
+ this._checkExchangeCap(t, toAgent);
70
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
- );
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`);
77
99
  }
78
100
 
79
- const timeoutMs = Math.min(Number(opts.timeoutMs) || DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
101
+ const t = this._openThread(fromAgent, toAgent, opts.threadId);
102
+ this._checkExchangeCap(t, toAgent);
80
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) {
81
123
  const turn = {
82
124
  id: randomUUID().slice(0, 12),
83
125
  from: peer(fromAgent),
84
126
  to: peer(toAgent),
85
- text: message.trim(),
86
- kind: 'ask',
127
+ text: message,
128
+ kind,
87
129
  status: 'sending',
88
130
  timestamp: Date.now(),
89
131
  };
90
- t.turns.push(turn);
132
+ thread.turns.push(turn);
91
133
 
92
- const wrapped = this._wrap(t, fromAgent, toAgent, message.trim());
134
+ const wrapped = this._wrap(thread, fromAgent, toAgent, message, kind);
93
135
 
94
136
  let result;
95
137
  try {
96
- result = await deliverInstruction(this.daemon, toAgentId, wrapped, { recordFeedback: false });
138
+ result = await deliverInstruction(this.daemon, toAgent.id, wrapped, { recordFeedback: false });
97
139
  } catch (err) {
98
140
  turn.status = 'failed';
99
141
  turn.error = err.message;
100
- t.status = 'failed';
101
- this._broadcast(t, turn);
142
+ thread.status = 'failed';
143
+ this._broadcast(thread, turn);
102
144
  throw new Error(`Could not reach ${toAgent.name}: ${err.message}`);
103
145
  }
104
146
 
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.
147
+ // A stopped target gets resumed or rotated, minting a new agent id.
107
148
  const targetId = result.agentId;
108
- if (targetId !== toAgentId) {
109
- this._remapParticipant(t, toAgentId, targetId);
149
+ if (targetId !== toAgent.id) {
150
+ this._remapParticipant(thread, toAgent.id, targetId);
110
151
  turn.to.id = targetId;
111
152
  }
112
153
 
113
154
  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 });
155
+ thread.status = 'awaiting_reply';
156
+ thread.updatedAt = Date.now();
157
+ this._broadcast(thread, turn);
118
158
 
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;
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
+ }
122
163
 
123
- const reply = await this._awaitReply(t, fromAgentId, targetId, { skipResults, timeoutMs });
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
+ }
124
168
 
125
- return {
126
- reply,
127
- threadId: t.id,
128
- exchanges: exchanges + 1,
129
- remaining: MAX_EXCHANGES - (exchanges + 1),
130
- };
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
+ }
131
177
  }
132
178
 
133
179
  _awaitReply(thread, askerId, targetId, { skipResults, timeoutMs }) {
134
180
  return new Promise((resolve, reject) => {
135
181
  const settle = (fn, value) => {
136
- clearTimeout(record.timer);
137
- this.awaiting.delete(targetId);
182
+ this._clearAwait(targetId);
138
183
  this.blockedOn.delete(askerId);
139
184
  fn(value);
140
185
  };
141
186
 
142
187
  const record = {
188
+ mode: 'block',
143
189
  threadId: thread.id,
144
190
  askerId,
145
191
  skipResults,
@@ -150,7 +196,7 @@ export class InnerChat {
150
196
  thread.updatedAt = Date.now();
151
197
  record.reject(new Error(
152
198
  `No answer within ${Math.round(timeoutMs / 1000)}s. The agent may still be working — `
153
- + 'proceed on your own judgement or try again.',
199
+ + 'proceed on your own judgement, or use a non-blocking send (tell) next time.',
154
200
  ));
155
201
  }, timeoutMs),
156
202
  };
@@ -161,7 +207,7 @@ export class InnerChat {
161
207
  }
162
208
 
163
209
  /**
164
- * Watch agent output for the answer to an outstanding question.
210
+ * Watch agent output for the reply to an outstanding message.
165
211
  * Called from the process manager's output handler.
166
212
  */
167
213
  onAgentOutput(agentId, output) {
@@ -170,7 +216,7 @@ export class InnerChat {
170
216
  if (output.type !== 'result') return;
171
217
 
172
218
  const thread = this.threads.get(pending.threadId);
173
- if (!thread) { pending.reject(new Error('Conversation was lost')); return; }
219
+ if (!thread) { this._settlePending(agentId, pending, null, new Error('Conversation was lost')); return; }
174
220
 
175
221
  const text = extractText(output.data);
176
222
  if (!text) return;
@@ -182,7 +228,7 @@ export class InnerChat {
182
228
  }
183
229
 
184
230
  const responder = this.daemon.registry.get(agentId);
185
- thread.turns.push({
231
+ const answerTurn = {
186
232
  id: randomUUID().slice(0, 12),
187
233
  from: responder ? peer(responder) : { id: agentId, name: agentId, role: 'agent' },
188
234
  to: this._otherParticipant(thread, agentId),
@@ -190,27 +236,67 @@ export class InnerChat {
190
236
  kind: 'answer',
191
237
  status: 'delivered',
192
238
  timestamp: Date.now(),
193
- });
239
+ };
240
+ thread.turns.push(answerTurn);
194
241
  thread.status = 'idle';
195
242
  thread.updatedAt = Date.now();
196
-
197
- this._broadcast(thread, thread.turns.at(-1));
243
+ this._broadcast(thread, answerTurn);
198
244
  this.daemon.audit.log('innerchat.answer', { thread: thread.id, from: agentId });
199
245
 
200
- pending.resolve(text);
246
+ if (pending.mode === 'async') {
247
+ this._clearAwait(agentId);
248
+ this._forwardAsyncReply(pending, answerTurn, text);
249
+ } else {
250
+ pending.resolve(text);
251
+ }
252
+ }
253
+
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})]`,
263
+ '',
264
+ text,
265
+ '',
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.',
268
+ ].join('\n');
269
+
270
+ try {
271
+ await deliverInstruction(this.daemon, sender.id, msg, { recordFeedback: false });
272
+ } catch (err) {
273
+ this.daemon.audit.log('innerchat.forward_failed', { to: sender.id, error: err.message });
274
+ }
201
275
  }
202
276
 
203
277
  /**
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.
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.
206
280
  */
207
281
  onAgentGone(agentId, reason = 'stopped') {
208
282
  const pending = this.awaiting.get(agentId);
209
283
  if (!pending) return;
284
+ if (pending.mode === 'async') { this._clearAwait(agentId); return; }
210
285
  const agent = this.daemon.registry.get(agentId);
211
286
  pending.reject(new Error(`${agent?.name || agentId} ${reason} before answering`));
212
287
  }
213
288
 
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
+
214
300
  // ── Threads ─────────────────────────────────────────────────
215
301
 
216
302
  _createThread(fromAgent, toAgent) {
@@ -233,10 +319,15 @@ export class InnerChat {
233
319
  }
234
320
 
235
321
  // 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) {
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]`;
238
329
  const prior = thread.turns.slice(0, -1).slice(-CONTEXT_TURNS);
239
- const lines = [`[InnerChat — ${fromAgent.name} (${fromAgent.role}) is asking you a question]`, ''];
330
+ const lines = [header, ''];
240
331
 
241
332
  if (prior.length) {
242
333
  lines.push('Earlier in this conversation:');
@@ -245,11 +336,19 @@ export class InnerChat {
245
336
  }
246
337
 
247
338
  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
- );
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
+ }
253
352
  return lines.join('\n');
254
353
  }
255
354
 
@@ -286,6 +385,20 @@ export class InnerChat {
286
385
  const pending = this.awaiting.get(agentId);
287
386
  return pending ? this.threads.get(pending.threadId) : null;
288
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
+ }
289
402
  }
290
403
 
291
404
  function peer(agent) {
@@ -4,7 +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
+ import { innerChatInstructions, watchInstructions } from './innerchat-docs.js';
8
8
 
9
9
  const GROOVE_SECTION_START = '<!-- GROOVE:START -->';
10
10
  const GROOVE_SECTION_END = '<!-- GROOVE:END -->';
@@ -560,7 +560,8 @@ export class Introducer {
560
560
  // Written into every AGENTS_REGISTRY.md so the capability survives context
561
561
  // compaction — the spawn prompt alone can scroll out of a long session.
562
562
  _innerChatSection() {
563
- return innerChatInstructions(this.daemon.port || 31415);
563
+ const port = this.daemon.port || 31415;
564
+ return [...innerChatInstructions(port), '', ...watchInstructions(port)];
564
565
  }
565
566
 
566
567
  writeRegistryFile(projectDir) {
@@ -10,7 +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
+ import { innerChatInstructions, watchInstructions } from './innerchat-docs.js';
14
14
 
15
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
16
  const SLIDES_ENGINE_SRC = resolve(__dirname, '../templates/groove-slides.cjs');
@@ -562,12 +562,17 @@ export class ProcessManager {
562
562
  const pending = this.consumePendingMessage(agent.id);
563
563
  if (pending) {
564
564
  const agentData = registry.get(agent.id);
565
- if (agentData?.sessionId) {
566
- this.resume(agent.id, pending.message).catch((err) => {
567
- console.error(`[Groove] Auto-resume with queued message failed for ${agent.name}: ${err.message}`);
568
- });
569
- return;
570
- }
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;
571
576
  }
572
577
  }
573
578
 
@@ -1165,11 +1170,15 @@ For normal file edits within your scope, proceed without review.
1165
1170
  // sandboxed providers (Codex) can't reach it.
1166
1171
  if (!sandboxedProviders.includes(providerName) && !isOneShotProvider) {
1167
1172
  const port = this.daemon.port || 31415;
1168
- const innerChatPrompt = innerChatInstructions(port, agent.name).join('\n') + '\n\n';
1173
+ const capabilities = [
1174
+ ...innerChatInstructions(port, agent.name),
1175
+ '',
1176
+ ...watchInstructions(port, agent.name),
1177
+ ].join('\n') + '\n\n';
1169
1178
  if (spawnConfig.prompt.startsWith('# Handoff Brief')) {
1170
- spawnConfig.prompt += '\n\n' + innerChatPrompt.trim();
1179
+ spawnConfig.prompt += '\n\n' + capabilities.trim();
1171
1180
  } else {
1172
- spawnConfig.prompt = innerChatPrompt + spawnConfig.prompt;
1181
+ spawnConfig.prompt = capabilities + spawnConfig.prompt;
1173
1182
  }
1174
1183
  }
1175
1184
 
@@ -2,15 +2,51 @@
2
2
 
3
3
  import { MAX_EXCHANGES } from '../innerchat.js';
4
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`.
5
+ // Agents know each other by name, not id. Exact matches win first so
6
+ // `fullstack-1` never resolves to `fullstack-14`; only if nothing matches
7
+ // exactly do we accept a single unambiguous partial, since agents routinely
8
+ // half-remember a teammate's name. An ambiguous partial resolves to nothing
9
+ // and the caller gets the candidate list instead of a wrong recipient.
7
10
  function resolveAgent(daemon, ref) {
8
11
  if (!ref || typeof ref !== 'string') return null;
9
12
  const all = daemon.registry.getAll();
10
- return all.find((a) => a.id === ref)
13
+ const needle = ref.trim().toLowerCase();
14
+
15
+ const exact = all.find((a) => a.id === ref)
11
16
  || all.find((a) => a.name === ref)
12
- || all.find((a) => a.name.toLowerCase() === ref.toLowerCase())
13
- || null;
17
+ || all.find((a) => a.name.toLowerCase() === needle);
18
+ if (exact) return exact;
19
+
20
+ const partial = all.filter((a) => a.name.toLowerCase().includes(needle)
21
+ || needle.includes(a.name.toLowerCase()));
22
+ return partial.length === 1 ? partial[0] : null;
23
+ }
24
+
25
+ // Resolve the from/to pair from a request body, or write the appropriate
26
+ // 400/404 and return null so the caller bails.
27
+ function resolveParties(daemon, req, res) {
28
+ const { from, to, message } = req.body || {};
29
+
30
+ const fromAgent = resolveAgent(daemon, from);
31
+ if (!fromAgent) { res.status(404).json({ error: `Unknown calling agent: ${from}` }); return null; }
32
+
33
+ const toAgent = resolveAgent(daemon, to);
34
+ if (!toAgent) {
35
+ const others = daemon.registry.getAll().filter((a) => a.id !== fromAgent.id);
36
+ const needle = String(to || '').trim().toLowerCase();
37
+ const close = others.filter((a) => a.name.toLowerCase().includes(needle)).map((a) => a.name);
38
+ if (close.length > 1) {
39
+ res.status(404).json({ error: `"${to}" matches more than one agent — use the full name.`, didYouMean: close });
40
+ } else {
41
+ res.status(404).json({ error: `No agent named "${to}".`, availableAgents: others.map((a) => a.name) });
42
+ }
43
+ return null;
44
+ }
45
+
46
+ if (!message || typeof message !== 'string' || !message.trim()) {
47
+ res.status(400).json({ error: 'message is required' }); return null;
48
+ }
49
+ return { fromAgent, toAgent, message: message.trim() };
14
50
  }
15
51
 
16
52
  export function registerInnerChatRoutes(app, daemon) {
@@ -18,39 +54,25 @@ export function registerInnerChatRoutes(app, daemon) {
18
54
  * Ask another agent a question and BLOCK until it answers.
19
55
  *
20
56
  * 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.
57
+ * it, and the response body is the other agent's reply. Best for tight
58
+ * interface negotiation, where the finite exchange budget keeps both sides
59
+ * writing decision-dense messages.
23
60
  */
24
61
  app.post('/api/innerchat/ask', async (req, res) => {
25
62
  try {
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
- }
63
+ const parties = resolveParties(daemon, req, res);
64
+ if (!parties) return;
45
65
 
46
66
  // Held open until the target answers — see the class doc in innerchat.js.
47
67
  req.setTimeout(0);
48
68
  res.setTimeout(0);
49
69
 
50
- const result = await daemon.innerchat.ask(fromAgent.id, toAgent.id, message.trim(), { timeoutMs });
70
+ const result = await daemon.innerchat.ask(parties.fromAgent.id, parties.toAgent.id, parties.message, {
71
+ timeoutMs: req.body?.timeoutMs,
72
+ });
51
73
 
52
74
  res.json({
53
- from: toAgent.name,
75
+ from: parties.toAgent.name,
54
76
  reply: result.reply,
55
77
  threadId: result.threadId,
56
78
  exchangesUsed: result.exchanges,
@@ -63,6 +85,36 @@ export function registerInnerChatRoutes(app, daemon) {
63
85
  }
64
86
  });
65
87
 
88
+ /**
89
+ * Send a message WITHOUT blocking — returns as soon as it's delivered. If the
90
+ * target replies, the reply is routed back to the sender asynchronously
91
+ * (resuming it if its turn ended). Best for handing off to a heads-down agent
92
+ * where waiting out a timeout would waste the turn.
93
+ */
94
+ app.post('/api/innerchat/tell', async (req, res) => {
95
+ try {
96
+ const parties = resolveParties(daemon, req, res);
97
+ if (!parties) return;
98
+
99
+ const result = await daemon.innerchat.tell(parties.fromAgent.id, parties.toAgent.id, parties.message, {
100
+ threadId: req.body?.threadId,
101
+ });
102
+
103
+ res.json({
104
+ ok: true,
105
+ to: parties.toAgent.name,
106
+ delivered: result.delivered,
107
+ threadId: result.threadId,
108
+ exchangesUsed: result.exchanges,
109
+ exchangesRemaining: result.remaining,
110
+ maxExchanges: MAX_EXCHANGES,
111
+ note: `Message delivered. ${parties.toAgent.name}'s reply, if any, will be routed back to you — you can end your turn.`,
112
+ });
113
+ } catch (err) {
114
+ res.status(409).json({ error: err.message });
115
+ }
116
+ });
117
+
66
118
  app.get('/api/innerchat/threads', (req, res) => {
67
119
  const { agentId } = req.query;
68
120
  res.json({ threads: daemon.innerchat.getThreads(agentId || null) });
@@ -0,0 +1,42 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ // Agents identify themselves by name; resolve to the live record.
4
+ function resolveAgent(daemon, ref) {
5
+ if (!ref || typeof ref !== 'string') return null;
6
+ const all = daemon.registry.getAll();
7
+ return all.find((a) => a.id === ref)
8
+ || all.find((a) => a.name === ref)
9
+ || all.find((a) => a.name.toLowerCase() === ref.trim().toLowerCase())
10
+ || null;
11
+ }
12
+
13
+ export function registerWatchRoutes(app, daemon) {
14
+ // Register a watch and return immediately — the agent's turn ends, and the
15
+ // wake comes later when the watched thing finishes. This does NOT block.
16
+ app.post('/api/watch', (req, res) => {
17
+ try {
18
+ const { agent, command, until, label, timeoutMs, intervalMs } = req.body || {};
19
+ const who = resolveAgent(daemon, agent);
20
+ if (!who) return res.status(404).json({ error: `Unknown agent: ${agent}` });
21
+
22
+ const watch = daemon.watcher.create(who.id, { command, until, label, timeoutMs, intervalMs });
23
+ res.json({
24
+ ok: true,
25
+ watchId: watch.id,
26
+ message: `Watching "${watch.label}". You'll be resumed with the result when it ${watch.mode === 'command' ? 'finishes' : 'condition is met'}. You can end your turn now.`,
27
+ });
28
+ } catch (err) {
29
+ res.status(400).json({ error: err.message });
30
+ }
31
+ });
32
+
33
+ app.get('/api/watch', (req, res) => {
34
+ res.json({ watches: daemon.watcher.list(req.query.agentId || null) });
35
+ });
36
+
37
+ app.delete('/api/watch/:id', (req, res) => {
38
+ const ok = daemon.watcher.cancel(req.params.id);
39
+ if (!ok) return res.status(404).json({ error: 'Watch not found' });
40
+ res.json({ ok: true });
41
+ });
42
+ }