groove-dev 0.27.184 → 0.27.186

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 (60) 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 +3 -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/journalist.js +83 -42
  12. package/node_modules/@groove-dev/daemon/src/process.js +19 -10
  13. package/node_modules/@groove-dev/daemon/src/rotator.js +6 -1
  14. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +80 -28
  15. package/node_modules/@groove-dev/daemon/src/routes/watch.js +42 -0
  16. package/node_modules/@groove-dev/daemon/src/watcher.js +258 -0
  17. package/node_modules/@groove-dev/daemon/test/innerchat.test.js +71 -1
  18. package/node_modules/@groove-dev/daemon/test/journalist.test.js +59 -0
  19. package/node_modules/@groove-dev/daemon/test/rotator.test.js +22 -4
  20. package/node_modules/@groove-dev/daemon/test/watcher.test.js +158 -0
  21. package/node_modules/@groove-dev/gui/dist/assets/index-CU8L_r5f.css +1 -0
  22. package/node_modules/@groove-dev/gui/dist/assets/{index-BpOyN6Zf.js → index-DOOaCFRS.js} +227 -222
  23. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  24. package/node_modules/@groove-dev/gui/package.json +1 -1
  25. package/node_modules/@groove-dev/gui/src/app.css +1 -0
  26. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +92 -4
  27. package/node_modules/@groove-dev/gui/src/components/editor/terminal.jsx +25 -0
  28. package/node_modules/@groove-dev/gui/src/lib/logpaths.js +45 -0
  29. package/node_modules/@groove-dev/gui/src/stores/groove.js +30 -16
  30. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +6 -0
  31. package/node_modules/@groove-dev/gui/src/stores/slices/ui-slice.js +11 -0
  32. package/node_modules/@groove-dev/gui/src/views/memory.jsx +56 -31
  33. package/package.json +1 -1
  34. package/packages/cli/package.json +1 -1
  35. package/packages/daemon/package.json +1 -1
  36. package/packages/daemon/src/api.js +3 -0
  37. package/packages/daemon/src/index.js +17 -0
  38. package/packages/daemon/src/innerchat-docs.js +60 -5
  39. package/packages/daemon/src/innerchat.js +167 -54
  40. package/packages/daemon/src/introducer.js +3 -2
  41. package/packages/daemon/src/journalist.js +83 -42
  42. package/packages/daemon/src/process.js +19 -10
  43. package/packages/daemon/src/rotator.js +6 -1
  44. package/packages/daemon/src/routes/innerchat.js +80 -28
  45. package/packages/daemon/src/routes/watch.js +42 -0
  46. package/packages/daemon/src/watcher.js +258 -0
  47. package/packages/gui/dist/assets/index-CU8L_r5f.css +1 -0
  48. package/packages/gui/dist/assets/{index-BpOyN6Zf.js → index-DOOaCFRS.js} +227 -222
  49. package/packages/gui/dist/index.html +2 -2
  50. package/packages/gui/package.json +1 -1
  51. package/packages/gui/src/app.css +1 -0
  52. package/packages/gui/src/components/agents/agent-feed.jsx +92 -4
  53. package/packages/gui/src/components/editor/terminal.jsx +25 -0
  54. package/packages/gui/src/lib/logpaths.js +45 -0
  55. package/packages/gui/src/stores/groove.js +30 -16
  56. package/packages/gui/src/stores/slices/agents-slice.js +6 -0
  57. package/packages/gui/src/stores/slices/ui-slice.js +11 -0
  58. package/packages/gui/src/views/memory.jsx +56 -31
  59. package/node_modules/@groove-dev/gui/dist/assets/index-DiXB7yry.css +0 -1
  60. package/packages/gui/dist/assets/index-DiXB7yry.css +0 -1
@@ -17,25 +17,80 @@ export function innerChatInstructions(port = 31415, agentName = 'YOUR_NAME') {
17
17
  'When the user asks you to reach out to, coordinate with, or get input from',
18
18
  'another agent, use this — it sends them a message and waits for their reply:',
19
19
  '',
20
+ '> **Do NOT use your built-in `SendMessage` / Agent tools to reach a GROOVE agent.**',
21
+ '> Those only address sub-agents you spawned yourself in this session, so they will',
22
+ '> fail with "not reachable" or ask you for an `a…-…` agent ID that does not exist',
23
+ '> here. GROOVE agents are separate processes. The curl below is the only way to',
24
+ '> reach them. Do not invent a fallback (writing a message into a file, etc.) —',
25
+ '> if the curl fails, read the error and report it to the user.',
26
+ '',
20
27
  '```bash',
21
28
  `curl -s http://localhost:${port}/api/innerchat/ask -X POST -H 'Content-Type: application/json' \\`,
22
29
  ` -d '{"from":"${agentName}","to":"AGENT_NAME","message":"YOUR_QUESTION"}'`,
23
30
  '```',
24
31
  '',
25
- 'The command BLOCKS until they answer, then returns:',
32
+ 'This BLOCKS until they answer, then returns:',
26
33
  '`{"from":"...","reply":"...","exchangesUsed":N,"exchangesRemaining":N}`',
27
34
  '',
35
+ '> **Run the curl in the FOREGROUND and let it block.** Do not background it with',
36
+ "> `&`, and never put `&` inside the command — that detaches the curl and throws away",
37
+ "> the reply, so you get no answer and can't confirm delivery. Blocking for a minute or",
38
+ '> two is expected and correct; just wait for it. If you truly cannot wait, use `tell`',
39
+ '> (below) instead — that is the right tool for fire-and-forget, not a backgrounded ask.',
40
+ '',
28
41
  'Call it again to continue — prior turns are included automatically, so just say',
29
42
  'the next thing. Keep going until you reach a conclusion together, then report',
30
- 'that conclusion to the user.',
43
+ 'that conclusion to the user. `ask` is best for tight back-and-forth (negotiating',
44
+ 'an interface, resolving a blocker) where you need the answer before you proceed.',
45
+ '',
46
+ 'If the other agent is heads-down and you do NOT need the answer right now, send it',
47
+ 'without blocking instead — this returns immediately, and their reply (if any) is',
48
+ 'delivered back to you later, waking you if your turn has ended:',
49
+ '',
50
+ '```bash',
51
+ `curl -s http://localhost:${port}/api/innerchat/tell -X POST -H 'Content-Type: application/json' \\`,
52
+ ` -d '{"from":"${agentName}","to":"AGENT_NAME","message":"YOUR_MESSAGE"}'`,
53
+ '```',
31
54
  '',
32
55
  '**Rules:**',
33
56
  '- Only start a conversation when the user asks you to. Never consult other agents on your own initiative.',
34
57
  `- \`from\` must be your own agent name${agentName === 'YOUR_NAME' ? ' (it is in your $GROOVE_AGENT_NAME environment variable)' : ''}.`,
35
58
  '- 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.',
59
+ '- Use `ask` when you need the reply now; use `tell` to hand off or notify without waiting.',
60
+ '- One message per call. With `ask`, wait for the answer before deciding what to ask next.',
61
+ '- Ground truth lives in files (specs, code, run output), not in chat — use InnerChat to',
62
+ ' negotiate and hand off, and verify claims against the actual artifacts.',
37
63
  '- 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.',
64
+ '- `exchangesRemaining` counts down (shared by ask and tell). At 0 the conversation is over: stop and report.',
65
+ '- If another agent asks YOU something (a blocking ask), answer it directly in your next message they are blocked until you do.',
66
+ ];
67
+ }
68
+
69
+ // Wake-on-completion. Same delivery/injection story as InnerChat above.
70
+ export function watchInstructions(port = 31415, agentName = 'YOUR_NAME') {
71
+ const nameHint = agentName === 'YOUR_NAME' ? ' ($GROOVE_AGENT_NAME)' : '';
72
+ return [
73
+ '## Getting Notified When Something Finishes (Watch)',
74
+ '',
75
+ 'Your process ends when your turn ends, so a test suite or long job you kicked',
76
+ 'off has nothing to notify you — telling the user "I\'ll report back when it lands"',
77
+ 'does not work on its own; the session is gone. Instead, set a Watch. The daemon',
78
+ 'outlives your turn and RESUMES you with the result when it finishes:',
79
+ '',
80
+ '```bash',
81
+ `curl -s http://localhost:${port}/api/watch -X POST -H 'Content-Type: application/json' \\`,
82
+ ` -d '{"agent":"${agentName}","label":"test suite","command":"npm test"}'`,
83
+ '```',
84
+ '',
85
+ 'This returns immediately with a `watchId`. **End your turn** — you will be woken',
86
+ 'with the exit code and output tail when the command finishes.',
87
+ '',
88
+ '- Prefer `command` (let the daemon run it) over launching it yourself — that way',
89
+ ' it captures the real exit code and output.',
90
+ '- For something ALREADY running, poll instead with `until` (fires when the check',
91
+ ` exits 0): \`-d '{"agent":"${agentName}","label":"server up","until":"curl -sf localhost:3000/health"}'\`.`,
92
+ `- \`agent\` must be your own name${nameHint}.`,
93
+ '- Watches time out (default 30 min). One clear task per watch; do not watch trivial commands you could just run.',
94
+ '- Do not poll or sleep-loop waiting yourself — set the watch and end the turn. That is the whole point.',
40
95
  ];
41
96
  }
@@ -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,6 +10,12 @@ import { agentLogPath } from './process.js';
10
10
  const DEFAULT_INTERVAL = 300_000; // 5 minutes (safety-net fallback; event-driven triggers handle the normal case)
11
11
  const MAX_LOG_CHARS = 100_000; // ~25k tokens budget for synthesis input (captures 80-90% of recent activity)
12
12
  const DEBOUNCE_MS = 10_000; // requestSynthesis debounce window
13
+ // Conversation carried across a rotation (~37K tokens at 150K chars). This is
14
+ // deliberately generous: a rotation is the one lossy step in an infinite
15
+ // session, and re-reading the recent dialogue once per rotation is far cheaper
16
+ // than the degradation from an agent that forgot its own history. Tune via
17
+ // config resumeBudgetChars (lower for cost-sensitive fleets).
18
+ const DEFAULT_RESUME_BUDGET_CHARS = 150_000;
13
19
 
14
20
  export class Journalist {
15
21
  constructor(daemon) {
@@ -1061,7 +1067,51 @@ export class Journalist {
1061
1067
  *
1062
1068
  * Budget: keeps recent turns verbatim, summarizes oldest if over maxChars.
1063
1069
  */
1064
- extractConversationThread(agent, { maxChars = 60000 } = {}) {
1070
+ extractConversationThread(agent, { maxChars } = {}) {
1071
+ // Budget is configurable (resumeBudgetChars): this is the "how much memory
1072
+ // survives a rotation" dial. Bigger costs more tokens per rotation but
1073
+ // preserves more of the session; a rotation is lossy exactly here.
1074
+ if (!maxChars) maxChars = this.daemon.config?.resumeBudgetChars ?? DEFAULT_RESUME_BUDGET_CHARS;
1075
+ const merged = this._parseConversationTurns(agent);
1076
+ if (!merged || merged.length === 0) return null;
1077
+
1078
+ // Build the thread — keep recent turns verbatim, truncate old ones if over budget
1079
+ let thread = '';
1080
+ const formatted = merged.map((t) => {
1081
+ const label = t.role === 'user' ? 'USER' : 'CLAUDE';
1082
+ return `[${label}]:\n${t.text}`;
1083
+ });
1084
+
1085
+ // Start from the end (most recent) and work backwards to fill budget
1086
+ const recentFirst = [...formatted].reverse();
1087
+ const kept = [];
1088
+ let totalLen = 0;
1089
+
1090
+ for (const entry of recentFirst) {
1091
+ if (totalLen + entry.length > maxChars) {
1092
+ // Truncate this entry to fit remaining budget
1093
+ const remaining = maxChars - totalLen;
1094
+ if (remaining > 200) {
1095
+ kept.push(entry.slice(0, remaining) + '\n[...truncated]');
1096
+ }
1097
+ break;
1098
+ }
1099
+ kept.push(entry);
1100
+ totalLen += entry.length;
1101
+ }
1102
+
1103
+ // Reverse back to chronological order
1104
+ kept.reverse();
1105
+ thread = kept.join('\n\n---\n\n');
1106
+
1107
+ return thread;
1108
+ }
1109
+
1110
+ /**
1111
+ * Parse the full user↔assistant turn list from an agent's stream-json log.
1112
+ * Returns merged turns in chronological order, or null if no log/turns.
1113
+ */
1114
+ _parseConversationTurns(agent) {
1065
1115
  const logPath = agentLogPath(this.daemon.grooveDir, agent);
1066
1116
  if (!existsSync(logPath)) return null;
1067
1117
 
@@ -1121,37 +1171,23 @@ export class Journalist {
1121
1171
  merged.push({ ...turn });
1122
1172
  }
1123
1173
  }
1174
+ return merged;
1175
+ }
1124
1176
 
1125
- // Build the thread — keep recent turns verbatim, truncate old ones if over budget
1126
- let thread = '';
1127
- const formatted = merged.map((t) => {
1128
- const label = t.role === 'user' ? 'USER' : 'CLAUDE';
1129
- return `[${label}]:\n${t.text}`;
1130
- });
1131
-
1132
- // Start from the end (most recent) and work backwards to fill budget
1133
- const recentFirst = [...formatted].reverse();
1134
- const kept = [];
1135
- let totalLen = 0;
1136
-
1137
- for (const entry of recentFirst) {
1138
- if (totalLen + entry.length > maxChars) {
1139
- // Truncate this entry to fit remaining budget
1140
- const remaining = maxChars - totalLen;
1141
- if (remaining > 200) {
1142
- kept.push(entry.slice(0, remaining) + '\n[...truncated]');
1143
- }
1144
- break;
1145
- }
1146
- kept.push(entry);
1147
- totalLen += entry.length;
1148
- }
1149
-
1150
- // Reverse back to chronological order
1151
- kept.reverse();
1152
- thread = kept.join('\n\n---\n\n');
1153
-
1154
- return thread;
1177
+ /**
1178
+ * The agent's ORIGINAL task, from the first substantial user message in the
1179
+ * FULL log — never from the truncated resume window. When weeks of dialogue
1180
+ * exceed the resume budget, the window holds only recent work; anchoring the
1181
+ * task there makes the agent's identity drift onto whatever happened last.
1182
+ * The name-keyed log spans all rotations, so the true origin is always here.
1183
+ */
1184
+ extractOriginalTask(agent) {
1185
+ const merged = this._parseConversationTurns(agent);
1186
+ if (!merged) return '';
1187
+ const first = merged.find((t) => t.role === 'user' && t.text.trim().length >= 10);
1188
+ if (!first) return '';
1189
+ const text = first.text.trim();
1190
+ return text.length > 500 ? text.slice(0, 500) + '...' : text;
1155
1191
  }
1156
1192
 
1157
1193
  /**
@@ -1159,15 +1195,17 @@ export class Journalist {
1159
1195
  * thread so a fresh agent picks up where the previous session left off.
1160
1196
  */
1161
1197
  buildConversationResumePrompt(agent, userMessage, { isRotation = false, reason } = {}) {
1162
- const thread = this.extractConversationThread(agent);
1198
+ const budget = this.daemon.config?.resumeBudgetChars ?? DEFAULT_RESUME_BUDGET_CHARS;
1199
+ const thread = this.extractConversationThread(agent, { maxChars: budget });
1163
1200
  if (!thread) return null;
1164
1201
 
1165
1202
  const constraints = this.daemon.memory?.getConstraintsMarkdown(2000) || '';
1166
1203
  const discoveries = this.daemon.memory?.getDiscoveriesMarkdown(agent.role, 5, 1000, agent.scope, agent.teamId) || '';
1167
1204
 
1168
- // Extract the user's original task from the conversation — the first substantial
1169
- // user message is almost always the task assignment. This anchors the new agent.
1170
- const originalTask = this._extractOriginalTask(thread);
1205
+ // Anchor the agent to its ORIGINAL task from the full log — the truncated
1206
+ // thread's first message may be mid-project (or another agent's task) once
1207
+ // history outgrows the budget. Fall back to the window only if the log parse fails.
1208
+ const originalTask = this.extractOriginalTask(agent) || this._extractOriginalTask(thread);
1171
1209
 
1172
1210
  // Rotation and idle-resume need very different framing. During rotation the agent
1173
1211
  // has no new user message — it must continue the exact in-progress task without
@@ -1181,6 +1219,7 @@ export class Journalist {
1181
1219
  originalTask ? `Your task: ${originalTask}` : '',
1182
1220
  ``,
1183
1221
  `Rules:`,
1222
+ `- NEVER mention the rotation, context refresh, restart, or any session discontinuity to the user — from the user's perspective you are one continuous agent and this refresh must be invisible`,
1184
1223
  `- Continue ONLY the task described in the conversation below`,
1185
1224
  `- Do NOT explore the codebase looking for other things to fix or improve`,
1186
1225
  `- Do NOT start new work that was not part of the original task`,
@@ -1210,14 +1249,16 @@ export class Journalist {
1210
1249
  isIdleResume ? `## New Message From User\n\n${userMessage}` : '',
1211
1250
  ``,
1212
1251
  isRotation
1213
- ? `Continue the in-progress task from the conversation above. Stay focused on that task only. Do not ask the user to repeat anything. If the task was already completed, state that and wait for new instructions.`
1214
- : `Continue seamlessly from the conversation above. You have the full context of what was discussed, what was tried, what worked and what didn't. Do not ask the user to repeat anything.`,
1252
+ ? `Continue the in-progress task from the conversation above. Stay focused on that task only. Do not ask the user to repeat anything, and never reference this context refresh — continue as if the conversation above simply never stopped. If the task was already completed, state that and wait for new instructions.`
1253
+ : `Continue seamlessly from the conversation above. You have the full context of what was discussed, what was tried, what worked and what didn't. Do not ask the user to repeat anything, and never mention the interruption or that you are resuming — to the user this is one continuous conversation.`,
1215
1254
  ].filter(Boolean).join('\n');
1216
1255
 
1217
- // Hard cap at 80K chars (~20K tokens) to leave plenty of room in context window
1218
- if (prompt.length > 80000) {
1256
+ // Cap scales with the configured budget (framing adds constraints/discoveries
1257
+ // on top of the thread) to leave room in the context window
1258
+ const promptCap = budget + 20_000;
1259
+ if (prompt.length > promptCap) {
1219
1260
  // Re-extract with smaller budget and rebuild
1220
- const smallerThread = this.extractConversationThread(agent, { maxChars: 40000 });
1261
+ const smallerThread = this.extractConversationThread(agent, { maxChars: Math.floor(budget * 2 / 3) });
1221
1262
  if (smallerThread) {
1222
1263
  prompt = [
1223
1264
  `# Session Context Resume`,
@@ -1238,8 +1279,8 @@ export class Journalist {
1238
1279
  isIdleResume ? `## New Message From User\n\n${userMessage}` : '',
1239
1280
  ``,
1240
1281
  isRotation
1241
- ? `Continue the in-progress task only. Do not explore or start new work. If done, state that and wait.`
1242
- : `Continue seamlessly. Do not ask the user to repeat anything.`,
1282
+ ? `Continue the in-progress task only. Do not explore or start new work. Never mention this context refresh to the user. If done, state that and wait.`
1283
+ : `Continue seamlessly. Do not ask the user to repeat anything, and never mention the interruption — to the user this is one continuous conversation.`,
1243
1284
  ].filter(Boolean).join('\n');
1244
1285
  }
1245
1286
  }