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
@@ -0,0 +1 @@
1
+ x
@@ -0,0 +1 @@
1
+ x
@@ -0,0 +1 @@
1
+ x
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.184",
3
+ "version": "0.27.186",
4
4
  "description": "GROOVE CLI — manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.184",
3
+ "version": "0.27.186",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -28,6 +28,7 @@ import { registerFileRoutes, resetEditorRoot } from './routes/files.js';
28
28
  import { registerNetworkRoutes } from './routes/network.js';
29
29
  import { registerScheduleRoutes } from './routes/schedules.js';
30
30
  import { registerInnerChatRoutes } from './routes/innerchat.js';
31
+ import { registerWatchRoutes } from './routes/watch.js';
31
32
  import { registerAutoAgentRoutes } from './routes/auto-agents.js';
32
33
 
33
34
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -178,6 +179,7 @@ export function createApi(app, daemon) {
178
179
  registerNetworkRoutes(app, daemon);
179
180
  registerScheduleRoutes(app, daemon);
180
181
  registerInnerChatRoutes(app, daemon);
182
+ registerWatchRoutes(app, daemon);
181
183
  registerAutoAgentRoutes(app, daemon);
182
184
 
183
185
 
@@ -1439,6 +1441,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
1439
1441
  'qcThreshold', 'maxAgents', 'defaultProvider', 'defaultWorkingDir',
1440
1442
  'onboardingDismissed', 'defaultModel', 'defaultChatProvider', 'defaultChatModel',
1441
1443
  'dataSharingDismissed', 'replayCeiling', 'velocityCeiling', 'journalistModelTier',
1444
+ 'resumeBudgetChars',
1442
1445
  ];
1443
1446
  for (const key of Object.keys(req.body)) {
1444
1447
  if (!ALLOWED_KEYS.includes(key)) {
@@ -47,6 +47,7 @@ import { RepoImporter } from './repo-import.js';
47
47
  import { ConversationManager } from './conversations.js';
48
48
  import { Toys } from './toys.js';
49
49
  import { InnerChat } from './innerchat.js';
50
+ import { Watcher } from './watcher.js';
50
51
  import { AutoState } from './autostate.js';
51
52
  import { Orchestrator } from './orchestrator.js';
52
53
  import { TrajectoryCapture, ConsentManager } from '../../../moe-training/client/index.js';
@@ -162,6 +163,7 @@ export class Daemon {
162
163
  this.modelLab = new ModelLab(this);
163
164
  this.toys = new Toys(this);
164
165
  this.innerchat = new InnerChat(this);
166
+ this.watcher = new Watcher(this);
165
167
  this.autoState = new AutoState(this.grooveDir);
166
168
  this.orchestrator = new Orchestrator(this);
167
169
  this.trajectoryCapture = null;
@@ -623,6 +625,19 @@ export class Daemon {
623
625
  this.federation.initialize();
624
626
  this._startGarbageCollector();
625
627
 
628
+ // Regenerate the on-disk registry files once on boot. They otherwise
629
+ // only refresh on a registry change, so a daemon upgraded with new
630
+ // agent-facing docs (InnerChat, Watch) would serve stale AGENTS_REGISTRY.md
631
+ // to already-running agents until something happened to change state.
632
+ if (this.registry.getAll().length > 0) {
633
+ try {
634
+ this.introducer.writeRegistryFile(this.projectDir);
635
+ this.introducer.injectGrooveSection(this.projectDir);
636
+ } catch (err) {
637
+ console.error('[startup] Failed to refresh registry files:', err.message);
638
+ }
639
+ }
640
+
626
641
  // Restore auth token from stored config so subscription polling works after restart
627
642
  const storedToken = this.skills.getToken();
628
643
  if (storedToken) {
@@ -847,6 +862,8 @@ export class Daemon {
847
862
  this.journalist.stop();
848
863
  this.rotator.stop();
849
864
  this.scheduler.stop();
865
+ this.watcher.stop();
866
+ this.innerchat.stop();
850
867
  this.orchestrator.stop();
851
868
  this.timeline.stop();
852
869
  if (this._gcInterval) clearInterval(this._gcInterval);
@@ -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) {