groove-dev 0.27.183 → 0.27.185

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.watch-test-flag-1784767927904 +1 -0
  2. package/.watch-test-flag-1784768055729 +1 -0
  3. package/.watch-test-flag-1784768066364 +1 -0
  4. package/node_modules/@groove-dev/cli/package.json +1 -1
  5. package/node_modules/@groove-dev/daemon/package.json +1 -1
  6. package/node_modules/@groove-dev/daemon/src/api.js +2 -0
  7. package/node_modules/@groove-dev/daemon/src/index.js +17 -0
  8. package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +96 -0
  9. package/node_modules/@groove-dev/daemon/src/innerchat.js +265 -96
  10. package/node_modules/@groove-dev/daemon/src/introducer.js +31 -1
  11. package/node_modules/@groove-dev/daemon/src/process.js +36 -6
  12. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +109 -13
  13. package/node_modules/@groove-dev/daemon/src/routes/teams.js +11 -0
  14. package/node_modules/@groove-dev/daemon/src/routes/watch.js +42 -0
  15. package/node_modules/@groove-dev/daemon/src/teams.js +28 -0
  16. package/node_modules/@groove-dev/daemon/src/watcher.js +258 -0
  17. package/node_modules/@groove-dev/daemon/test/innerchat.test.js +222 -179
  18. package/node_modules/@groove-dev/daemon/test/teams.test.js +53 -0
  19. package/node_modules/@groove-dev/daemon/test/watcher.test.js +158 -0
  20. package/node_modules/@groove-dev/gui/dist/assets/index-CU8L_r5f.css +1 -0
  21. package/{packages/gui/dist/assets/index-DPjGBQ5X.js → node_modules/@groove-dev/gui/dist/assets/index-DEZwM4Hb.js} +233 -228
  22. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  23. package/node_modules/@groove-dev/gui/package.json +1 -1
  24. package/node_modules/@groove-dev/gui/src/app.css +1 -0
  25. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +60 -4
  26. package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +1 -29
  27. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
  28. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-pane.jsx +1 -15
  29. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +29 -11
  30. package/node_modules/@groove-dev/gui/src/stores/groove.js +36 -16
  31. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +6 -0
  32. package/node_modules/@groove-dev/gui/src/stores/slices/teams-slice.js +22 -0
  33. package/node_modules/@groove-dev/gui/src/views/memory.jsx +56 -31
  34. package/package.json +1 -1
  35. package/packages/cli/package.json +1 -1
  36. package/packages/daemon/package.json +1 -1
  37. package/packages/daemon/src/api.js +2 -0
  38. package/packages/daemon/src/index.js +17 -0
  39. package/packages/daemon/src/innerchat-docs.js +96 -0
  40. package/packages/daemon/src/innerchat.js +265 -96
  41. package/packages/daemon/src/introducer.js +31 -1
  42. package/packages/daemon/src/process.js +36 -6
  43. package/packages/daemon/src/routes/innerchat.js +109 -13
  44. package/packages/daemon/src/routes/teams.js +11 -0
  45. package/packages/daemon/src/routes/watch.js +42 -0
  46. package/packages/daemon/src/teams.js +28 -0
  47. package/packages/daemon/src/watcher.js +258 -0
  48. package/packages/gui/dist/assets/index-CU8L_r5f.css +1 -0
  49. package/{node_modules/@groove-dev/gui/dist/assets/index-DPjGBQ5X.js → packages/gui/dist/assets/index-DEZwM4Hb.js} +233 -228
  50. package/packages/gui/dist/index.html +2 -2
  51. package/packages/gui/package.json +1 -1
  52. package/packages/gui/src/app.css +1 -0
  53. package/packages/gui/src/components/agents/agent-feed.jsx +60 -4
  54. package/packages/gui/src/components/agents/agent-panel.jsx +1 -29
  55. package/packages/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
  56. package/packages/gui/src/components/fleet/fleet-pane.jsx +1 -15
  57. package/packages/gui/src/components/fleet/fleet-sidebar.jsx +29 -11
  58. package/packages/gui/src/stores/groove.js +36 -16
  59. package/packages/gui/src/stores/slices/agents-slice.js +6 -0
  60. package/packages/gui/src/stores/slices/teams-slice.js +22 -0
  61. package/packages/gui/src/views/memory.jsx +56 -31
  62. package/node_modules/@groove-dev/gui/dist/assets/index-CiOy7wVS.css +0 -1
  63. package/node_modules/@groove-dev/gui/src/components/agents/innerchat-relay.jsx +0 -145
  64. package/packages/gui/dist/assets/index-CiOy7wVS.css +0 -1
  65. package/packages/gui/src/components/agents/innerchat-relay.jsx +0 -145
@@ -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
 
@@ -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);
@@ -0,0 +1,96 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ // The agent-facing InnerChat instructions, in one place.
4
+ //
5
+ // These are injected into a new agent's spawn prompt AND written into every
6
+ // AGENTS_REGISTRY.md. Both are needed: the prompt reaches the agent on turn
7
+ // one, and the file survives context compaction — an agent that has forgotten
8
+ // the prompt can re-read the registry it already knows to consult.
9
+ //
10
+ // No imports on purpose — process.js and introducer.js both pull from here.
11
+
12
+ export function innerChatInstructions(port = 31415, agentName = 'YOUR_NAME') {
13
+ return [
14
+ '## Consulting Other Agents (InnerChat)',
15
+ '',
16
+ 'Other GROOVE agents may be working alongside you, including on other teams.',
17
+ 'When the user asks you to reach out to, coordinate with, or get input from',
18
+ 'another agent, use this — it sends them a message and waits for their reply:',
19
+ '',
20
+ '> **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
+ '',
27
+ '```bash',
28
+ `curl -s http://localhost:${port}/api/innerchat/ask -X POST -H 'Content-Type: application/json' \\`,
29
+ ` -d '{"from":"${agentName}","to":"AGENT_NAME","message":"YOUR_QUESTION"}'`,
30
+ '```',
31
+ '',
32
+ 'This BLOCKS until they answer, then returns:',
33
+ '`{"from":"...","reply":"...","exchangesUsed":N,"exchangesRemaining":N}`',
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
+ '',
41
+ 'Call it again to continue — prior turns are included automatically, so just say',
42
+ 'the next thing. Keep going until you reach a conclusion together, then report',
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
+ '```',
54
+ '',
55
+ '**Rules:**',
56
+ '- Only start a conversation when the user asks you to. Never consult other agents on your own initiative.',
57
+ `- \`from\` must be your own agent name${agentName === 'YOUR_NAME' ? ' (it is in your $GROOVE_AGENT_NAME environment variable)' : ''}.`,
58
+ '- Agent names are listed in `AGENTS_REGISTRY.md`, including agents on other teams.',
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.',
63
+ '- Read any error you get back — it tells you what to do (unknown name, cap reached, or they are waiting on you).',
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.',
95
+ ];
96
+ }
@@ -3,94 +3,212 @@
3
3
  import { randomUUID } from 'crypto';
4
4
  import { deliverInstruction } from './deliver.js';
5
5
 
6
- // How much prior conversation to replay into each relay turn. Enough for the
7
- // recipient to pick up the thread without re-reading the whole exchange.
6
+ // An unanswered ask holds an HTTP request open and blocks the calling agent,
7
+ // so it has to be bounded.
8
+ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
9
+ const MAX_TIMEOUT_MS = 15 * 60 * 1000;
10
+
11
+ // Ceiling on autonomous back-and-forth. Two agents that can call each other
12
+ // freely will happily talk until the budget is gone; past this they're told to
13
+ // wrap up and report to the user.
14
+ const MAX_EXCHANGES = 12;
15
+
8
16
  const CONTEXT_TURNS = 4;
9
17
  const MAX_TURN_CHARS = 1200;
10
18
 
11
19
  /**
12
- * Agent-to-agent relay, user-initiated.
20
+ * Agent-to-agent consultation.
13
21
  *
14
- * The A→B hop is always driven by a human clicking Relay. The B→A hop is
15
- * automatic: B's answer is fed straight back into A so A can process it and
16
- * report to the user, who then decides whether to run another turn.
22
+ * An agent asks another agent a question and BLOCKS until it answers the
23
+ * daemon holds the caller's HTTP request open, delivers the question, waits
24
+ * for the target's next result, and returns it as the response body. That
25
+ * makes a normal request/response loop available to the calling agent, so two
26
+ * agents can iterate to consensus without a human relaying between them.
17
27
  *
18
28
  * Delivery goes through deliverInstruction — the same pipe as user chat — so
19
- * a relay reaches its target whether it's mid-task, idle, or stopped.
29
+ * a question reaches its target whether it's mid-task, idle, or stopped.
20
30
  */
21
31
  export class InnerChat {
22
32
  constructor(daemon) {
23
33
  this.daemon = daemon;
24
34
  this.threads = new Map();
25
- // agentId -> { threadId, sentAt } — which agent we're awaiting a reply from.
35
+ // agentId being asked -> pending record (resolve/reject/timer/threadId)
26
36
  this.awaiting = new Map();
37
+ // asker id -> id of the agent it is currently blocked on
38
+ this.blockedOn = new Map();
27
39
  }
28
40
 
29
41
  /**
30
- * Relay a message from one agent to another, opening a thread or continuing
31
- * an existing one.
42
+ * Ask another agent a question and wait for its answer.
43
+ *
44
+ * Resolves with { reply, threadId, exchanges, remaining }. Rejects when the
45
+ * target can't be reached, the exchange cap is hit, the call would deadlock,
46
+ * or the target doesn't answer inside the timeout.
32
47
  */
33
- async send(fromAgentId, toAgentId, message, threadId = null) {
48
+ async ask(fromAgentId, toAgentId, message, opts = {}) {
34
49
  const fromAgent = this.daemon.registry.get(fromAgentId);
35
50
  const toAgent = this.daemon.registry.get(toAgentId);
36
- if (!fromAgent) throw new Error(`Sender agent ${fromAgentId} not found`);
51
+ if (!fromAgent) throw new Error(`Calling agent ${fromAgentId} not found`);
37
52
  if (!toAgent) throw new Error(`Target agent ${toAgentId} not found`);
38
- if (fromAgentId === toAgentId) throw new Error('Cannot relay an agent to itself');
53
+ if (fromAgentId === toAgentId) throw new Error('An agent cannot ask itself');
39
54
  if (!message || !message.trim()) throw new Error('message is required');
40
55
 
41
- const thread = threadId ? this.threads.get(threadId) : null;
42
- if (threadId && !thread) throw new Error(`Thread ${threadId} not found`);
56
+ // Deadlock guard: the target is already blocked waiting on us, so it can
57
+ // never get far enough to read this. Tell the caller to just answer.
58
+ if (this.blockedOn.get(toAgentId) === fromAgentId) {
59
+ throw new Error(
60
+ `${toAgent.name} is currently waiting for YOUR answer — it cannot reply to a new `
61
+ + 'question until you respond. Answer its question first; your reply is what unblocks it.',
62
+ );
63
+ }
64
+ if (this.awaiting.has(toAgentId)) {
65
+ throw new Error(`${toAgent.name} is already answering another agent — try again shortly`);
66
+ }
67
+
68
+ const t = this._openThread(fromAgent, toAgent, opts.threadId);
69
+ this._checkExchangeCap(t, toAgent);
43
70
 
44
- const t = thread || this._createThread(fromAgent, toAgent);
71
+ const timeoutMs = Math.min(Number(opts.timeoutMs) || DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
72
+
73
+ const { turn, targetId, skipResults } = await this._deliver(t, fromAgent, toAgent, message.trim(), 'ask');
74
+ this.daemon.audit.log('innerchat.ask', { thread: t.id, from: fromAgentId, to: targetId });
75
+
76
+ const reply = await this._awaitReply(t, fromAgentId, targetId, { skipResults, timeoutMs });
77
+ const exchanges = t.turns.filter((x) => x.kind === 'ask' || x.kind === 'tell').length;
78
+
79
+ return { reply, threadId: t.id, exchanges, remaining: MAX_EXCHANGES - exchanges };
80
+ }
81
+
82
+ /**
83
+ * Send a message to another agent WITHOUT blocking. Returns as soon as it's
84
+ * delivered. If the target later replies, the reply is routed back to the
85
+ * sender asynchronously — resuming the sender if its turn has ended.
86
+ *
87
+ * This is the fire-and-forget counterpart to ask(): use it to hand off to an
88
+ * agent that's heads-down, where waiting out a timeout would waste the turn.
89
+ */
90
+ async tell(fromAgentId, toAgentId, message, opts = {}) {
91
+ const fromAgent = this.daemon.registry.get(fromAgentId);
92
+ const toAgent = this.daemon.registry.get(toAgentId);
93
+ if (!fromAgent) throw new Error(`Calling agent ${fromAgentId} not found`);
94
+ if (!toAgent) throw new Error(`Target agent ${toAgentId} not found`);
95
+ if (fromAgentId === toAgentId) throw new Error('An agent cannot message itself');
96
+ if (!message || !message.trim()) throw new Error('message is required');
97
+ if (this.awaiting.has(toAgentId)) {
98
+ throw new Error(`${toAgent.name} already has an unanswered message — wait for its reply before sending another`);
99
+ }
100
+
101
+ const t = this._openThread(fromAgent, toAgent, opts.threadId);
102
+ this._checkExchangeCap(t, toAgent);
103
+
104
+ const { turn, targetId, skipResults } = await this._deliver(t, fromAgent, toAgent, message.trim(), 'tell');
105
+ this.daemon.audit.log('innerchat.tell', { thread: t.id, from: fromAgentId, to: targetId });
106
+
107
+ // Register async capture: the reply (if any) is forwarded, not awaited.
108
+ this.awaiting.set(targetId, {
109
+ mode: 'async',
110
+ threadId: t.id,
111
+ senderId: fromAgentId,
112
+ skipResults,
113
+ timer: setTimeout(() => this._clearAwait(targetId), MAX_TIMEOUT_MS),
114
+ });
115
+
116
+ const exchanges = t.turns.filter((x) => x.kind === 'ask' || x.kind === 'tell').length;
117
+ return { delivered: true, threadId: t.id, exchanges, remaining: MAX_EXCHANGES - exchanges };
118
+ }
119
+
120
+ // Shared send: push a turn, deliver it, and remap the target id if delivery
121
+ // resumed/rotated the agent. Returns the queued-skip count for reply capture.
122
+ async _deliver(thread, fromAgent, toAgent, message, kind) {
45
123
  const turn = {
46
124
  id: randomUUID().slice(0, 12),
47
125
  from: peer(fromAgent),
48
126
  to: peer(toAgent),
49
- text: message.trim(),
50
- kind: 'relay',
127
+ text: message,
128
+ kind,
51
129
  status: 'sending',
52
130
  timestamp: Date.now(),
53
131
  };
54
- t.turns.push(turn);
132
+ thread.turns.push(turn);
55
133
 
56
- const wrapped = this._wrap(t, fromAgent, toAgent, message.trim());
134
+ const wrapped = this._wrap(thread, fromAgent, toAgent, message, kind);
57
135
 
58
136
  let result;
59
137
  try {
60
- result = await deliverInstruction(this.daemon, toAgentId, wrapped, { recordFeedback: false });
138
+ result = await deliverInstruction(this.daemon, toAgent.id, wrapped, { recordFeedback: false });
61
139
  } catch (err) {
62
140
  turn.status = 'failed';
63
141
  turn.error = err.message;
64
- t.status = 'failed';
65
- this.daemon.broadcast({ type: 'innerchat:turn', data: { thread: t, turn } });
66
- throw err;
142
+ thread.status = 'failed';
143
+ this._broadcast(thread, turn);
144
+ throw new Error(`Could not reach ${toAgent.name}: ${err.message}`);
67
145
  }
68
146
 
69
- // A stopped target gets resumed or rotated, which mints a new agent id.
70
- // Re-key everything onto it or the reply will never be captured.
71
- if (result.agentId !== toAgentId) {
72
- this._remapParticipant(t, toAgentId, result.agentId);
73
- turn.to.id = result.agentId;
147
+ // A stopped target gets resumed or rotated, minting a new agent id.
148
+ const targetId = result.agentId;
149
+ if (targetId !== toAgent.id) {
150
+ this._remapParticipant(thread, toAgent.id, targetId);
151
+ turn.to.id = targetId;
74
152
  }
75
153
 
76
154
  turn.status = result.status;
77
- t.status = 'awaiting_reply';
78
- t.updatedAt = Date.now();
155
+ thread.status = 'awaiting_reply';
156
+ thread.updatedAt = Date.now();
157
+ this._broadcast(thread, turn);
158
+
159
+ // A queued message sits behind the target's current work, so the next
160
+ // result belongs to that prior task, not to us — skip it.
161
+ return { turn, targetId, skipResults: result.status === 'message_queued' ? 1 : 0 };
162
+ }
79
163
 
80
- // A queued relay sits behind whatever the agent is already doing, so the
81
- // next result belongs to that prior task, not to us — skip it.
82
- const skipResults = result.status === 'message_queued' ? 1 : 0;
83
- this.awaiting.set(turn.to.id, { threadId: t.id, sentAt: Date.now(), skipResults });
164
+ _openThread(fromAgent, toAgent, threadId) {
165
+ const thread = threadId ? this.threads.get(threadId) : this._findThread(fromAgent.id, toAgent.id);
166
+ return thread || this._createThread(fromAgent, toAgent);
167
+ }
84
168
 
85
- this.daemon.broadcast({ type: 'innerchat:turn', data: { thread: t, turn } });
86
- this.daemon.audit.log('innerchat.send', { thread: t.id, from: fromAgentId, to: turn.to.id });
169
+ _checkExchangeCap(thread, toAgent) {
170
+ const used = thread.turns.filter((x) => x.kind === 'ask' || x.kind === 'tell').length;
171
+ if (used >= MAX_EXCHANGES) {
172
+ throw new Error(
173
+ `This conversation has reached its ${MAX_EXCHANGES}-exchange limit. Stop consulting `
174
+ + `${toAgent.name} and report your conclusion to the user.`,
175
+ );
176
+ }
177
+ }
87
178
 
88
- return { thread: t, turn };
179
+ _awaitReply(thread, askerId, targetId, { skipResults, timeoutMs }) {
180
+ return new Promise((resolve, reject) => {
181
+ const settle = (fn, value) => {
182
+ this._clearAwait(targetId);
183
+ this.blockedOn.delete(askerId);
184
+ fn(value);
185
+ };
186
+
187
+ const record = {
188
+ mode: 'block',
189
+ threadId: thread.id,
190
+ askerId,
191
+ skipResults,
192
+ resolve: (text) => settle(resolve, text),
193
+ reject: (err) => settle(reject, err),
194
+ timer: setTimeout(() => {
195
+ thread.status = 'timeout';
196
+ thread.updatedAt = Date.now();
197
+ record.reject(new Error(
198
+ `No answer within ${Math.round(timeoutMs / 1000)}s. The agent may still be working — `
199
+ + 'proceed on your own judgement, or use a non-blocking send (tell) next time.',
200
+ ));
201
+ }, timeoutMs),
202
+ };
203
+
204
+ this.awaiting.set(targetId, record);
205
+ this.blockedOn.set(askerId, targetId);
206
+ });
89
207
  }
90
208
 
91
209
  /**
92
- * Watch agent output for the reply to an outstanding relay, then forward it
93
- * back to the agent that asked.
210
+ * Watch agent output for the reply to an outstanding message.
211
+ * Called from the process manager's output handler.
94
212
  */
95
213
  onAgentOutput(agentId, output) {
96
214
  const pending = this.awaiting.get(agentId);
@@ -98,72 +216,88 @@ export class InnerChat {
98
216
  if (output.type !== 'result') return;
99
217
 
100
218
  const thread = this.threads.get(pending.threadId);
101
- if (!thread) { this.awaiting.delete(agentId); return; }
219
+ if (!thread) { this._settlePending(agentId, pending, null, new Error('Conversation was lost')); return; }
102
220
 
103
- const responseText = extractText(output.data);
104
- if (!responseText) return;
221
+ const text = extractText(output.data);
222
+ if (!text) return;
105
223
 
106
- // Burn off results belonging to work that was already underway when the
107
- // relay was queued behind it.
224
+ // Burn off results belonging to work already underway when we queued.
108
225
  if (pending.skipResults > 0) {
109
226
  pending.skipResults -= 1;
110
227
  return;
111
228
  }
112
229
 
113
- // Claim the reply before the async forward so a second result arriving
114
- // mid-flight can't be captured as a duplicate.
115
- this.awaiting.delete(agentId);
116
-
117
230
  const responder = this.daemon.registry.get(agentId);
118
- const asker = this._otherParticipant(thread, agentId);
119
-
120
- const turn = {
231
+ const answerTurn = {
121
232
  id: randomUUID().slice(0, 12),
122
233
  from: responder ? peer(responder) : { id: agentId, name: agentId, role: 'agent' },
123
- to: asker,
124
- text: responseText,
125
- kind: 'reply',
126
- status: 'forwarding',
234
+ to: this._otherParticipant(thread, agentId),
235
+ text,
236
+ kind: 'answer',
237
+ status: 'delivered',
127
238
  timestamp: Date.now(),
128
239
  };
129
- thread.turns.push(turn);
130
- thread.status = 'forwarding';
240
+ thread.turns.push(answerTurn);
241
+ thread.status = 'idle';
131
242
  thread.updatedAt = Date.now();
132
-
133
- this._forwardReply(thread, turn, asker.id, responseText);
243
+ this._broadcast(thread, answerTurn);
244
+ this.daemon.audit.log('innerchat.answer', { thread: thread.id, from: agentId });
245
+
246
+ if (pending.mode === 'async') {
247
+ this._clearAwait(agentId);
248
+ this._forwardAsyncReply(pending, answerTurn, text);
249
+ } else {
250
+ pending.resolve(text);
251
+ }
134
252
  }
135
253
 
136
- async _forwardReply(thread, turn, askerId, responseText) {
137
- const from = turn.from;
138
- const relay = [
139
- `[InnerChat reply from ${from.name} (${from.role})]`,
254
+ // Deliver an async (tell) reply back to the original sender, resuming it if
255
+ // its turn has ended. The sender is not blocked, so this just wakes them.
256
+ async _forwardAsyncReply(pending, answerTurn, text) {
257
+ const sender = this.daemon.registry.get(pending.senderId)
258
+ || this.daemon.registry.getAll().find((a) => a.name === answerTurn.to.name);
259
+ if (!sender) return; // sender is gone — nothing to route back to
260
+
261
+ const msg = [
262
+ `[InnerChat reply from ${answerTurn.from.name} (${answerTurn.from.role})]`,
140
263
  '',
141
- responseText,
264
+ text,
142
265
  '',
143
- `This is the answer to what you relayed to ${from.name}. Process it and report back — `
144
- + 'the user will decide whether to send another turn.',
266
+ 'This is a reply to a message you sent earlier (you were not blocking on it). '
267
+ + 'Fold it into your work, or reply again to continue.',
145
268
  ].join('\n');
146
269
 
147
270
  try {
148
- const result = await deliverInstruction(this.daemon, askerId, relay, { recordFeedback: false });
149
- if (result.agentId !== askerId) {
150
- this._remapParticipant(thread, askerId, result.agentId);
151
- turn.to.id = result.agentId;
152
- }
153
- turn.status = result.status;
154
- thread.status = 'idle';
271
+ await deliverInstruction(this.daemon, sender.id, msg, { recordFeedback: false });
155
272
  } catch (err) {
156
- turn.status = 'failed';
157
- turn.error = err.message;
158
- thread.status = 'failed';
273
+ this.daemon.audit.log('innerchat.forward_failed', { to: sender.id, error: err.message });
159
274
  }
275
+ }
160
276
 
161
- thread.updatedAt = Date.now();
162
- this.daemon.broadcast({ type: 'innerchat:turn', data: { thread, turn } });
163
- this.daemon.audit.log('innerchat.reply', { thread: thread.id, from: turn.from.id, to: turn.to.id });
277
+ /**
278
+ * An agent that dies mid-question would otherwise leave a blocking asker
279
+ * stuck until timeout. Async senders aren't waiting, so just clear those.
280
+ */
281
+ onAgentGone(agentId, reason = 'stopped') {
282
+ const pending = this.awaiting.get(agentId);
283
+ if (!pending) return;
284
+ if (pending.mode === 'async') { this._clearAwait(agentId); return; }
285
+ const agent = this.daemon.registry.get(agentId);
286
+ pending.reject(new Error(`${agent?.name || agentId} ${reason} before answering`));
164
287
  }
165
288
 
166
- // ── Thread helpers ──────────────────────────────────────────
289
+ _clearAwait(targetId) {
290
+ const pending = this.awaiting.get(targetId);
291
+ if (pending?.timer) clearTimeout(pending.timer);
292
+ this.awaiting.delete(targetId);
293
+ }
294
+
295
+ _settlePending(agentId, pending, value, err) {
296
+ if (pending.mode === 'async') { this._clearAwait(agentId); return; }
297
+ if (err) pending.reject(err); else pending.resolve(value);
298
+ }
299
+
300
+ // ── Threads ─────────────────────────────────────────────────
167
301
 
168
302
  _createThread(fromAgent, toAgent) {
169
303
  const thread = {
@@ -178,25 +312,43 @@ export class InnerChat {
178
312
  return thread;
179
313
  }
180
314
 
181
- // Rebuild the message the target sees. It reads as a direct message from the
182
- // other agent, with enough prior turns that a continuing thread makes sense.
183
- _wrap(thread, fromAgent, toAgent, message) {
315
+ // Consecutive asks between the same pair continue one conversation, so the
316
+ // exchange cap actually bounds a back-and-forth rather than resetting.
317
+ _findThread(a, b) {
318
+ return this.getThreads(a).find((t) => t.participants.some((p) => p.id === b)) || null;
319
+ }
320
+
321
+ // The target sees a direct message from the other agent, with enough prior
322
+ // turns to follow a continuing conversation. The closing instruction differs
323
+ // by kind: an ask blocks the sender (answer now), a tell does not (answer
324
+ // when you reach a good stopping point).
325
+ _wrap(thread, fromAgent, toAgent, message, kind = 'ask') {
326
+ const header = kind === 'tell'
327
+ ? `[InnerChat — ${fromAgent.name} (${fromAgent.role}) sent you a message]`
328
+ : `[InnerChat — ${fromAgent.name} (${fromAgent.role}) is asking you a question]`;
184
329
  const prior = thread.turns.slice(0, -1).slice(-CONTEXT_TURNS);
185
- const lines = [`[InnerChat from ${fromAgent.name} (${fromAgent.role})]`, ''];
330
+ const lines = [header, ''];
186
331
 
187
332
  if (prior.length) {
188
333
  lines.push('Earlier in this conversation:');
189
- for (const t of prior) {
190
- lines.push(` ${t.from.name}: ${truncate(t.text, MAX_TURN_CHARS)}`);
191
- }
334
+ for (const t of prior) lines.push(` ${t.from.name}: ${truncate(t.text, MAX_TURN_CHARS)}`);
192
335
  lines.push('');
193
336
  }
194
337
 
195
338
  lines.push(message, '');
196
- lines.push(
197
- `Reply normally — your response is relayed straight back to ${fromAgent.name}. `
198
- + 'Answer them directly; do not address the user.',
199
- );
339
+ if (kind === 'tell') {
340
+ lines.push(
341
+ `${fromAgent.name} is NOT blocked on this finish what you're doing first if you're `
342
+ + 'mid-task. When you reach a good stopping point, answer directly in a message; your '
343
+ + `reply is relayed back to ${fromAgent.name}. If no reply is needed, just carry on.`,
344
+ );
345
+ } else {
346
+ lines.push(
347
+ `${fromAgent.name} is BLOCKED waiting on your answer — it cannot continue until you `
348
+ + 'respond. Answer directly and concisely in your next message; that message is sent '
349
+ + 'back to it verbatim. Do not address the user, and do not start unrelated work first.',
350
+ );
351
+ }
200
352
  return lines.join('\n');
201
353
  }
202
354
 
@@ -205,10 +357,7 @@ export class InnerChat {
205
357
  }
206
358
 
207
359
  _remapParticipant(thread, oldId, newId) {
208
- for (const p of thread.participants) {
209
- if (p.id === oldId) p.id = newId;
210
- }
211
- // Any relay still awaiting a reply from the old id must follow it forward.
360
+ for (const p of thread.participants) if (p.id === oldId) p.id = newId;
212
361
  const pending = this.awaiting.get(oldId);
213
362
  if (pending) {
214
363
  this.awaiting.delete(oldId);
@@ -216,6 +365,10 @@ export class InnerChat {
216
365
  }
217
366
  }
218
367
 
368
+ _broadcast(thread, turn) {
369
+ this.daemon.broadcast({ type: 'innerchat:turn', data: { thread, turn } });
370
+ }
371
+
219
372
  // ── Queries ─────────────────────────────────────────────────
220
373
 
221
374
  getThreads(agentId = null) {
@@ -232,6 +385,20 @@ export class InnerChat {
232
385
  const pending = this.awaiting.get(agentId);
233
386
  return pending ? this.threads.get(pending.threadId) : null;
234
387
  }
388
+
389
+ // Clear all outstanding timers so a shutdown (or a test) doesn't hang on
390
+ // pending ask/tell timeouts. Blocking asks are rejected so their HTTP
391
+ // requests don't hang either.
392
+ stop() {
393
+ for (const [targetId, pending] of this.awaiting) {
394
+ if (pending.timer) clearTimeout(pending.timer);
395
+ if (pending.mode === 'block') {
396
+ try { pending.reject(new Error('Daemon shutting down')); } catch { /* already settled */ }
397
+ }
398
+ this.awaiting.delete(targetId);
399
+ }
400
+ this.blockedOn.clear();
401
+ }
235
402
  }
236
403
 
237
404
  function peer(agent) {
@@ -249,3 +416,5 @@ function extractText(data) {
249
416
  }
250
417
  return '';
251
418
  }
419
+
420
+ export { MAX_EXCHANGES, DEFAULT_TIMEOUT_MS };