groove-dev 0.27.182 → 0.27.184

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 (57) hide show
  1. package/CLAUDE.md +0 -7
  2. package/node_modules/@groove-dev/cli/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/deliver.js +130 -0
  5. package/node_modules/@groove-dev/daemon/src/index.js +7 -3
  6. package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +41 -0
  7. package/node_modules/@groove-dev/daemon/src/innerchat.js +267 -60
  8. package/node_modules/@groove-dev/daemon/src/introducer.js +30 -1
  9. package/node_modules/@groove-dev/daemon/src/process.js +22 -1
  10. package/node_modules/@groove-dev/daemon/src/registry.js +5 -1
  11. package/node_modules/@groove-dev/daemon/src/rename.js +72 -0
  12. package/node_modules/@groove-dev/daemon/src/routes/agents.js +22 -100
  13. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +63 -16
  14. package/node_modules/@groove-dev/daemon/src/routes/teams.js +11 -0
  15. package/node_modules/@groove-dev/daemon/src/teams.js +35 -1
  16. package/node_modules/@groove-dev/daemon/test/innerchat.test.js +193 -134
  17. package/node_modules/@groove-dev/daemon/test/rename.test.js +108 -0
  18. package/node_modules/@groove-dev/daemon/test/teams.test.js +53 -0
  19. package/node_modules/@groove-dev/gui/dist/assets/{index-CTer01Vg.js → index-BpOyN6Zf.js} +227 -227
  20. package/node_modules/@groove-dev/gui/dist/assets/index-DiXB7yry.css +1 -0
  21. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  22. package/node_modules/@groove-dev/gui/package.json +1 -1
  23. package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +2 -95
  24. package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +3 -1
  25. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
  26. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +56 -6
  27. package/node_modules/@groove-dev/gui/src/stores/groove.js +35 -44
  28. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +27 -4
  29. package/node_modules/@groove-dev/gui/src/stores/slices/teams-slice.js +22 -0
  30. package/package.json +1 -1
  31. package/packages/cli/package.json +1 -1
  32. package/packages/daemon/package.json +1 -1
  33. package/packages/daemon/src/deliver.js +130 -0
  34. package/packages/daemon/src/index.js +7 -3
  35. package/packages/daemon/src/innerchat-docs.js +41 -0
  36. package/packages/daemon/src/innerchat.js +267 -60
  37. package/packages/daemon/src/introducer.js +30 -1
  38. package/packages/daemon/src/process.js +22 -1
  39. package/packages/daemon/src/registry.js +5 -1
  40. package/packages/daemon/src/rename.js +72 -0
  41. package/packages/daemon/src/routes/agents.js +22 -100
  42. package/packages/daemon/src/routes/innerchat.js +63 -16
  43. package/packages/daemon/src/routes/teams.js +11 -0
  44. package/packages/daemon/src/teams.js +35 -1
  45. package/packages/gui/dist/assets/{index-CTer01Vg.js → index-BpOyN6Zf.js} +227 -227
  46. package/packages/gui/dist/assets/index-DiXB7yry.css +1 -0
  47. package/packages/gui/dist/index.html +2 -2
  48. package/packages/gui/package.json +1 -1
  49. package/packages/gui/src/components/agents/agent-panel.jsx +2 -95
  50. package/packages/gui/src/components/chat/chat-messages.jsx +3 -1
  51. package/packages/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
  52. package/packages/gui/src/components/fleet/fleet-sidebar.jsx +56 -6
  53. package/packages/gui/src/stores/groove.js +35 -44
  54. package/packages/gui/src/stores/slices/agents-slice.js +27 -4
  55. package/packages/gui/src/stores/slices/teams-slice.js +22 -0
  56. package/node_modules/@groove-dev/gui/dist/assets/index-DTFtRtkx.css +0 -1
  57. package/packages/gui/dist/assets/index-DTFtRtkx.css +0 -1
package/CLAUDE.md CHANGED
@@ -295,10 +295,3 @@ Audit-driven release. Multi-agent orchestration system with 7 coordination layer
295
295
  - Dashboard: routing donut, cache panel, context health gauges
296
296
  - Monitor/QC agent mode (stay active, loop)
297
297
  - Distribution: demo video, HN launch, Twitter content
298
-
299
- <!-- GROOVE:START -->
300
- ## GROOVE Orchestration (auto-injected)
301
- Active agents: 0
302
- See AGENTS_REGISTRY.md for full agent state.
303
- **Memory policy:** GROOVE manages project memory automatically. Do not read or write MEMORY.md or .groove/memory/ files directly.
304
- <!-- GROOVE:END -->
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.182",
3
+ "version": "0.27.184",
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.182",
3
+ "version": "0.27.184",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -0,0 +1,130 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ import { wrapWithRoleReminder } from './process.js';
4
+ import { getProvider } from './providers/index.js';
5
+
6
+ // Reviving a >5M-token claude session has crashed the CLI mid-HTTP-parse
7
+ // (V8 fatal in JsonStringifier) — past this ceiling the rotator's handoff
8
+ // brief sidesteps the resume entirely.
9
+ const SESSION_RESUME_CEILING = 5_000_000;
10
+
11
+ /**
12
+ * Deliver a message to an agent, whatever state it's in.
13
+ *
14
+ * This is the single pipe every inbound message travels — user chat from the
15
+ * GUI and InnerChat relays alike. Handles agent-loop delivery, one-shot and
16
+ * non-interactive providers, queueing behind a running task, session resume,
17
+ * and full rotation when the session is too large to revive.
18
+ *
19
+ * Respawn paths mint a NEW agent id. Callers that track the agent afterwards
20
+ * (InnerChat keys response capture on it) must use the returned `agentId`,
21
+ * not the one they passed in.
22
+ *
23
+ * @returns {Promise<{agentId: string, status: string, resumed: boolean, agent: object|null}>}
24
+ */
25
+ export async function deliverInstruction(daemon, agentId, message, opts = {}) {
26
+ const { recordFeedback = true } = opts;
27
+
28
+ const agent = daemon.registry.get(agentId);
29
+ if (!agent) throw new Error('Agent not found');
30
+
31
+ const finalMessage = message.trim();
32
+ if (!finalMessage) throw new Error('message is required');
33
+
34
+ // Journalist/rotator treat this as a human steering signal — InnerChat
35
+ // relays opt out so agent-to-agent chatter doesn't pollute user feedback.
36
+ if (recordFeedback) {
37
+ if (daemon.journalist) daemon.journalist.recordUserFeedback(agent, finalMessage);
38
+ if (daemon.rotator) daemon.rotator.recordUserMessage(agentId);
39
+ }
40
+
41
+ const wrappedMessage = wrapWithRoleReminder(agent.role, finalMessage);
42
+
43
+ // Agent loop path — send straight to the running loop.
44
+ if (daemon.processes.hasAgentLoop(agentId)) {
45
+ const sent = await daemon.processes.sendMessage(agentId, wrappedMessage);
46
+ if (sent) {
47
+ daemon.audit.log('agent.chat', { id: agentId });
48
+ return { agentId, status: 'message_sent', resumed: false, agent };
49
+ }
50
+ // Loop exists but isn't running — fall through to resume/rotate.
51
+ }
52
+
53
+ const provider = getProvider(agent.provider);
54
+
55
+ // One-shot providers (groove-network): kill any running instance and respawn
56
+ // with the message as --prompt. No handoff brief, no resume, no queue.
57
+ if (provider?.constructor?.isOneShot) {
58
+ const oldConfig = { ...agent };
59
+ if (daemon.processes.isRunning(agentId)) {
60
+ await daemon.processes.kill(agentId);
61
+ }
62
+ daemon.registry.remove(agentId, { silent: true });
63
+ daemon.locks.release(agentId);
64
+
65
+ const newAgent = await respawn(daemon, {
66
+ role: oldConfig.role,
67
+ scope: oldConfig.scope,
68
+ provider: oldConfig.provider,
69
+ model: oldConfig.model,
70
+ prompt: finalMessage,
71
+ permission: oldConfig.permission || 'full',
72
+ workingDir: oldConfig.workingDir,
73
+ name: oldConfig.name,
74
+ teamId: oldConfig.teamId,
75
+ });
76
+ daemon.audit.log('agent.instruct', { id: agentId, newId: newAgent.id, resumed: false });
77
+ return { agentId: newAgent.id, status: 'respawned', resumed: false, agent: newAgent };
78
+ }
79
+
80
+ // Non-interactive CLI providers (e.g. Gemini): one prompt per spawn, cannot
81
+ // resume — respawn preserving the original introContext.
82
+ if (provider?.constructor?.nonInteractive && !daemon.processes.isRunning(agentId)) {
83
+ const oldConfig = { ...agent };
84
+ daemon.registry.remove(agentId, { silent: true });
85
+ daemon.locks.release(agentId);
86
+
87
+ const newAgent = await respawn(daemon, {
88
+ role: oldConfig.role,
89
+ scope: oldConfig.scope,
90
+ provider: oldConfig.provider,
91
+ model: oldConfig.model,
92
+ prompt: finalMessage,
93
+ introContext: oldConfig.introContext,
94
+ permission: oldConfig.permission || 'full',
95
+ workingDir: oldConfig.workingDir,
96
+ name: oldConfig.name,
97
+ teamId: oldConfig.teamId,
98
+ });
99
+ daemon.audit.log('agent.instruct', { id: agentId, newId: newAgent.id, resumed: false });
100
+ return { agentId: newAgent.id, status: 'respawned', resumed: false, agent: newAgent };
101
+ }
102
+
103
+ // Running CLI agent (no loop) — queue behind the current task rather than
104
+ // killing and respawning mid-work.
105
+ if (daemon.processes.isRunning(agentId)) {
106
+ daemon.processes.queueMessage(agentId, wrappedMessage);
107
+ daemon.audit.log('agent.chat.queued', { id: agentId });
108
+ return { agentId, status: 'message_queued', resumed: false, agent };
109
+ }
110
+
111
+ // Stopped CLI agent — resume the session, or rotate past the ceiling.
112
+ const resumed = !!agent.sessionId && (agent.tokensUsed || 0) < SESSION_RESUME_CEILING;
113
+ const newAgent = resumed
114
+ ? await daemon.processes.resume(agentId, wrappedMessage)
115
+ : await daemon.rotator.rotate(agentId, { additionalPrompt: wrappedMessage });
116
+
117
+ daemon.audit.log('agent.instruct', { id: agentId, newId: newAgent.id, resumed });
118
+ return { agentId: newAgent.id, status: resumed ? 'resumed' : 'rotated', resumed, agent: newAgent };
119
+ }
120
+
121
+ // Spawn, flushing the registry's pending removals if it fails so a failed
122
+ // respawn doesn't silently erase the agent it was replacing.
123
+ async function respawn(daemon, config) {
124
+ try {
125
+ return await daemon.processes.spawn(config);
126
+ } catch (spawnErr) {
127
+ daemon.registry.flushPendingRemovals();
128
+ throw spawnErr;
129
+ }
130
+ }
@@ -10,7 +10,7 @@ import express from 'express';
10
10
  import { WebSocketServer } from 'ws';
11
11
  import { Registry } from './registry.js';
12
12
  import { createApi } from './api.js';
13
- import { ProcessManager } from './process.js';
13
+ import { ProcessManager, sanitizeFilename } from './process.js';
14
14
  import { StateManager } from './state.js';
15
15
  import { Introducer } from './introducer.js';
16
16
  import { LockManager } from './lockmanager.js';
@@ -750,7 +750,11 @@ export class Daemon {
750
750
  try {
751
751
  // Build set of agent names still in the registry — never remove their logs
752
752
  const allAgents = this.registry.getAll();
753
- const activeNames = new Set(allAgents.map((a) => a.name));
753
+ // Raw logs are keyed by sanitized name; GROOVE_AGENT_LOGS/ subdirectories
754
+ // are keyed by agent id (journalist.js). Comparing either against the
755
+ // wrong set deletes live agents' history.
756
+ const activeNames = new Set(allAgents.map((a) => sanitizeFilename(a.name)));
757
+ const activeIds = new Set(allAgents.map((a) => a.id));
754
758
 
755
759
  // Safety: if registry is empty but log files exist, state may have been
756
760
  // lost (corrupt JSON, partial write). Skip log cleanup to prevent
@@ -778,7 +782,7 @@ export class Daemon {
778
782
  if (existsSync(agentLogsDir)) {
779
783
  for (const dir of readdirSync(agentLogsDir, { withFileTypes: true })) {
780
784
  if (!dir.isDirectory()) continue;
781
- if (activeNames.has(dir.name)) continue;
785
+ if (activeIds.has(dir.name)) continue;
782
786
  try { rmSync(resolve(agentLogsDir, dir.name), { recursive: true }); cleaned++; } catch { /* skip */ }
783
787
  }
784
788
  // Remove GROOVE_AGENT_LOGS/ itself if empty
@@ -0,0 +1,41 @@
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
+ '```bash',
21
+ `curl -s http://localhost:${port}/api/innerchat/ask -X POST -H 'Content-Type: application/json' \\`,
22
+ ` -d '{"from":"${agentName}","to":"AGENT_NAME","message":"YOUR_QUESTION"}'`,
23
+ '```',
24
+ '',
25
+ 'The command BLOCKS until they answer, then returns:',
26
+ '`{"from":"...","reply":"...","exchangesUsed":N,"exchangesRemaining":N}`',
27
+ '',
28
+ 'Call it again to continue — prior turns are included automatically, so just say',
29
+ 'the next thing. Keep going until you reach a conclusion together, then report',
30
+ 'that conclusion to the user.',
31
+ '',
32
+ '**Rules:**',
33
+ '- Only start a conversation when the user asks you to. Never consult other agents on your own initiative.',
34
+ `- \`from\` must be your own agent name${agentName === 'YOUR_NAME' ? ' (it is in your $GROOVE_AGENT_NAME environment variable)' : ''}.`,
35
+ '- 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.',
37
+ '- 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.',
40
+ ];
41
+ }
@@ -1,100 +1,307 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
 
3
3
  import { randomUUID } from 'crypto';
4
+ import { deliverInstruction } from './deliver.js';
4
5
 
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
+
16
+ const CONTEXT_TURNS = 4;
17
+ const MAX_TURN_CHARS = 1200;
18
+
19
+ /**
20
+ * Agent-to-agent consultation.
21
+ *
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.
27
+ *
28
+ * Delivery goes through deliverInstruction — the same pipe as user chat — so
29
+ * a question reaches its target whether it's mid-task, idle, or stopped.
30
+ */
5
31
  export class InnerChat {
6
32
  constructor(daemon) {
7
33
  this.daemon = daemon;
8
- this.messages = new Map();
9
- this.pendingResponses = new Map();
34
+ this.threads = new Map();
35
+ // agentId being asked -> pending record (resolve/reject/timer/threadId)
36
+ this.awaiting = new Map();
37
+ // asker id -> id of the agent it is currently blocked on
38
+ this.blockedOn = new Map();
10
39
  }
11
40
 
12
- async send(fromAgentId, toAgentId, message) {
41
+ /**
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.
47
+ */
48
+ async ask(fromAgentId, toAgentId, message, opts = {}) {
13
49
  const fromAgent = this.daemon.registry.get(fromAgentId);
14
50
  const toAgent = this.daemon.registry.get(toAgentId);
15
- if (!fromAgent) throw new Error(`Sender agent ${fromAgentId} not found`);
51
+ if (!fromAgent) throw new Error(`Calling agent ${fromAgentId} not found`);
16
52
  if (!toAgent) throw new Error(`Target agent ${toAgentId} not found`);
53
+ if (fromAgentId === toAgentId) throw new Error('An agent cannot ask itself');
54
+ if (!message || !message.trim()) throw new Error('message is required');
55
+
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 thread = opts.threadId ? this.threads.get(opts.threadId) : this._findThread(fromAgentId, toAgentId);
69
+ const t = thread || this._createThread(fromAgent, toAgent);
17
70
 
18
- const id = randomUUID().slice(0, 12);
19
- const msg = {
20
- id,
21
- from: { id: fromAgent.id, name: fromAgent.name, role: fromAgent.role },
22
- to: { id: toAgent.id, name: toAgent.name, role: toAgent.role },
23
- message,
24
- response: null,
25
- status: 'sent',
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
+ );
77
+ }
78
+
79
+ const timeoutMs = Math.min(Number(opts.timeoutMs) || DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
80
+
81
+ const turn = {
82
+ id: randomUUID().slice(0, 12),
83
+ from: peer(fromAgent),
84
+ to: peer(toAgent),
85
+ text: message.trim(),
86
+ kind: 'ask',
87
+ status: 'sending',
26
88
  timestamp: Date.now(),
27
- respondedAt: null,
28
89
  };
90
+ t.turns.push(turn);
29
91
 
30
- this.messages.set(id, msg);
31
- this.pendingResponses.set(toAgentId, id);
92
+ const wrapped = this._wrap(t, fromAgent, toAgent, message.trim());
32
93
 
33
- const wrapped = `[InnerChat from ${fromAgent.name} (${fromAgent.role})]\n\n${message}\n\nReply normally — your response will be relayed back to ${fromAgent.name}.`;
94
+ let result;
95
+ try {
96
+ result = await deliverInstruction(this.daemon, toAgentId, wrapped, { recordFeedback: false });
97
+ } catch (err) {
98
+ turn.status = 'failed';
99
+ turn.error = err.message;
100
+ t.status = 'failed';
101
+ this._broadcast(t, turn);
102
+ throw new Error(`Could not reach ${toAgent.name}: ${err.message}`);
103
+ }
34
104
 
35
- let deliveryStatus = 'sent';
36
- if (this.daemon.processes.hasAgentLoop(toAgentId)) {
37
- const sent = await this.daemon.processes.sendMessage(toAgentId, wrapped, 'agent');
38
- deliveryStatus = sent ? 'delivered' : 'queued';
39
- } else if (this.daemon.processes.isRunning(toAgentId)) {
40
- this.daemon.processes.queueMessage(toAgentId, wrapped);
41
- deliveryStatus = 'queued';
42
- } else {
43
- throw new Error(`Target agent ${toAgent.name} is not running`);
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.
107
+ const targetId = result.agentId;
108
+ if (targetId !== toAgentId) {
109
+ this._remapParticipant(t, toAgentId, targetId);
110
+ turn.to.id = targetId;
44
111
  }
45
112
 
46
- msg.status = deliveryStatus;
47
- this.daemon.broadcast({ type: 'innerchat:sent', data: msg });
48
- this.daemon.audit.log('innerchat.send', { id, from: fromAgentId, to: toAgentId });
113
+ 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 });
118
+
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;
122
+
123
+ const reply = await this._awaitReply(t, fromAgentId, targetId, { skipResults, timeoutMs });
49
124
 
50
- return msg;
125
+ return {
126
+ reply,
127
+ threadId: t.id,
128
+ exchanges: exchanges + 1,
129
+ remaining: MAX_EXCHANGES - (exchanges + 1),
130
+ };
131
+ }
132
+
133
+ _awaitReply(thread, askerId, targetId, { skipResults, timeoutMs }) {
134
+ return new Promise((resolve, reject) => {
135
+ const settle = (fn, value) => {
136
+ clearTimeout(record.timer);
137
+ this.awaiting.delete(targetId);
138
+ this.blockedOn.delete(askerId);
139
+ fn(value);
140
+ };
141
+
142
+ const record = {
143
+ threadId: thread.id,
144
+ askerId,
145
+ skipResults,
146
+ resolve: (text) => settle(resolve, text),
147
+ reject: (err) => settle(reject, err),
148
+ timer: setTimeout(() => {
149
+ thread.status = 'timeout';
150
+ thread.updatedAt = Date.now();
151
+ record.reject(new Error(
152
+ `No answer within ${Math.round(timeoutMs / 1000)}s. The agent may still be working — `
153
+ + 'proceed on your own judgement or try again.',
154
+ ));
155
+ }, timeoutMs),
156
+ };
157
+
158
+ this.awaiting.set(targetId, record);
159
+ this.blockedOn.set(askerId, targetId);
160
+ });
51
161
  }
52
162
 
163
+ /**
164
+ * Watch agent output for the answer to an outstanding question.
165
+ * Called from the process manager's output handler.
166
+ */
53
167
  onAgentOutput(agentId, output) {
54
- const messageId = this.pendingResponses.get(agentId);
55
- if (!messageId) return;
168
+ const pending = this.awaiting.get(agentId);
169
+ if (!pending) return;
56
170
  if (output.type !== 'result') return;
57
171
 
58
- const msg = this.messages.get(messageId);
59
- if (!msg) return;
172
+ const thread = this.threads.get(pending.threadId);
173
+ if (!thread) { pending.reject(new Error('Conversation was lost')); return; }
174
+
175
+ const text = extractText(output.data);
176
+ if (!text) return;
177
+
178
+ // Burn off results belonging to work already underway when we queued.
179
+ if (pending.skipResults > 0) {
180
+ pending.skipResults -= 1;
181
+ return;
182
+ }
183
+
184
+ const responder = this.daemon.registry.get(agentId);
185
+ thread.turns.push({
186
+ id: randomUUID().slice(0, 12),
187
+ from: responder ? peer(responder) : { id: agentId, name: agentId, role: 'agent' },
188
+ to: this._otherParticipant(thread, agentId),
189
+ text,
190
+ kind: 'answer',
191
+ status: 'delivered',
192
+ timestamp: Date.now(),
193
+ });
194
+ thread.status = 'idle';
195
+ thread.updatedAt = Date.now();
196
+
197
+ this._broadcast(thread, thread.turns.at(-1));
198
+ this.daemon.audit.log('innerchat.answer', { thread: thread.id, from: agentId });
199
+
200
+ pending.resolve(text);
201
+ }
202
+
203
+ /**
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.
206
+ */
207
+ onAgentGone(agentId, reason = 'stopped') {
208
+ const pending = this.awaiting.get(agentId);
209
+ if (!pending) return;
210
+ const agent = this.daemon.registry.get(agentId);
211
+ pending.reject(new Error(`${agent?.name || agentId} ${reason} before answering`));
212
+ }
213
+
214
+ // ── Threads ─────────────────────────────────────────────────
215
+
216
+ _createThread(fromAgent, toAgent) {
217
+ const thread = {
218
+ id: randomUUID().slice(0, 12),
219
+ participants: [peer(fromAgent), peer(toAgent)],
220
+ turns: [],
221
+ status: 'idle',
222
+ createdAt: Date.now(),
223
+ updatedAt: Date.now(),
224
+ };
225
+ this.threads.set(thread.id, thread);
226
+ return thread;
227
+ }
60
228
 
61
- let responseText = '';
62
- if (typeof output.data === 'string') {
63
- responseText = output.data;
64
- } else if (Array.isArray(output.data)) {
65
- responseText = output.data.filter(b => b.type === 'text').map(b => b.text).join('\n');
229
+ // Consecutive asks between the same pair continue one conversation, so the
230
+ // exchange cap actually bounds a back-and-forth rather than resetting.
231
+ _findThread(a, b) {
232
+ return this.getThreads(a).find((t) => t.participants.some((p) => p.id === b)) || null;
233
+ }
234
+
235
+ // 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) {
238
+ const prior = thread.turns.slice(0, -1).slice(-CONTEXT_TURNS);
239
+ const lines = [`[InnerChat — ${fromAgent.name} (${fromAgent.role}) is asking you a question]`, ''];
240
+
241
+ if (prior.length) {
242
+ lines.push('Earlier in this conversation:');
243
+ for (const t of prior) lines.push(` ${t.from.name}: ${truncate(t.text, MAX_TURN_CHARS)}`);
244
+ lines.push('');
66
245
  }
67
- if (!responseText.trim()) return;
68
-
69
- msg.response = responseText.trim();
70
- msg.status = 'responded';
71
- msg.respondedAt = Date.now();
72
- this.pendingResponses.delete(agentId);
73
-
74
- const relay = `[InnerChat reply from ${msg.to.name} (${msg.to.role})]\n\n${msg.response}`;
75
- const senderId = msg.from.id;
76
- if (this.daemon.processes.hasAgentLoop(senderId)) {
77
- this.daemon.processes.sendMessage(senderId, relay, 'agent').catch(() => {});
78
- } else if (this.daemon.processes.isRunning(senderId)) {
79
- this.daemon.processes.queueMessage(senderId, relay);
246
+
247
+ 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
+ );
253
+ return lines.join('\n');
254
+ }
255
+
256
+ _otherParticipant(thread, agentId) {
257
+ return thread.participants.find((p) => p.id !== agentId) || thread.participants[0];
258
+ }
259
+
260
+ _remapParticipant(thread, oldId, newId) {
261
+ for (const p of thread.participants) if (p.id === oldId) p.id = newId;
262
+ const pending = this.awaiting.get(oldId);
263
+ if (pending) {
264
+ this.awaiting.delete(oldId);
265
+ this.awaiting.set(newId, pending);
80
266
  }
267
+ }
81
268
 
82
- this.daemon.broadcast({ type: 'innerchat:response', data: msg });
83
- this.daemon.audit.log('innerchat.response', { id: messageId, from: msg.from.id, to: msg.to.id });
269
+ _broadcast(thread, turn) {
270
+ this.daemon.broadcast({ type: 'innerchat:turn', data: { thread, turn } });
84
271
  }
85
272
 
86
- getMessages(agentId = null) {
87
- const all = Array.from(this.messages.values());
273
+ // ── Queries ─────────────────────────────────────────────────
274
+
275
+ getThreads(agentId = null) {
276
+ const all = Array.from(this.threads.values()).sort((a, b) => b.updatedAt - a.updatedAt);
88
277
  if (!agentId) return all;
89
- return all.filter(m => m.from.id === agentId || m.to.id === agentId);
278
+ return all.filter((t) => t.participants.some((p) => p.id === agentId));
90
279
  }
91
280
 
92
- getMessage(id) {
93
- return this.messages.get(id) || null;
281
+ getThread(id) {
282
+ return this.threads.get(id) || null;
94
283
  }
95
284
 
96
285
  getPending(agentId) {
97
- const messageId = this.pendingResponses.get(agentId);
98
- return messageId ? this.messages.get(messageId) : null;
286
+ const pending = this.awaiting.get(agentId);
287
+ return pending ? this.threads.get(pending.threadId) : null;
288
+ }
289
+ }
290
+
291
+ function peer(agent) {
292
+ return { id: agent.id, name: agent.name, role: agent.role };
293
+ }
294
+
295
+ function truncate(text, max) {
296
+ return text.length <= max ? text : `${text.slice(0, max)}… [truncated]`;
297
+ }
298
+
299
+ function extractText(data) {
300
+ if (typeof data === 'string') return data.trim();
301
+ if (Array.isArray(data)) {
302
+ return data.filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim();
99
303
  }
304
+ return '';
100
305
  }
306
+
307
+ export { MAX_EXCHANGES, DEFAULT_TIMEOUT_MS };
@@ -4,6 +4,7 @@
4
4
  import { writeFileSync, readFileSync, existsSync, readdirSync, statSync } from 'fs';
5
5
  import { resolve, dirname, basename } from 'path';
6
6
  import { escapeMd } from './validate.js';
7
+ import { innerChatInstructions } from './innerchat-docs.js';
7
8
 
8
9
  const GROOVE_SECTION_START = '<!-- GROOVE:START -->';
9
10
  const GROOVE_SECTION_END = '<!-- GROOVE:END -->';
@@ -556,6 +557,12 @@ export class Introducer {
556
557
  return null;
557
558
  }
558
559
 
560
+ // Written into every AGENTS_REGISTRY.md so the capability survives context
561
+ // compaction — the spawn prompt alone can scroll out of a long session.
562
+ _innerChatSection() {
563
+ return innerChatInstructions(this.daemon.port || 31415);
564
+ }
565
+
559
566
  writeRegistryFile(projectDir) {
560
567
  const agents = this.daemon.registry.getAll();
561
568
 
@@ -596,6 +603,27 @@ export class Introducer {
596
603
  lines.push(`| ${escapeMd(a.id)} | ${escapeMd(a.name)} | ${escapeMd(a.role)} | ${escapeMd(a.provider)} | ${agentDir} | ${scope} | ${escapeMd(a.status)} |`);
597
604
  }
598
605
 
606
+ // Agents on other teams are reachable via InnerChat, so they have to be
607
+ // discoverable here — otherwise cross-team consultation means guessing
608
+ // names. Kept to a separate, minimal table so the team's own roster
609
+ // stays the primary listing.
610
+ const others = agents.filter((a) => (a.teamId || '_default') !== teamId);
611
+ if (others.length > 0) {
612
+ lines.push('');
613
+ lines.push(`## Other Teams`);
614
+ lines.push('');
615
+ lines.push(`Reachable via InnerChat (see below). Not in your scope — do not edit their files.`);
616
+ lines.push('');
617
+ lines.push(`| Name | Role | Team | Status |`);
618
+ lines.push(`|------|------|------|--------|`);
619
+ for (const a of others) {
620
+ const otherTeam = this.daemon.teams?.get(a.teamId)?.name || '-';
621
+ lines.push(`| ${escapeMd(a.name)} | ${escapeMd(a.role)} | ${escapeMd(otherTeam)} | ${escapeMd(a.status)} |`);
622
+ }
623
+ }
624
+
625
+ lines.push('');
626
+ lines.push(...this._innerChatSection());
599
627
  lines.push('');
600
628
  lines.push(`*Updated: ${new Date().toISOString()}*`);
601
629
 
@@ -632,7 +660,8 @@ export class Introducer {
632
660
  running.length > 0 ? '|------|------|-------|' : '',
633
661
  ...running.map((a) => `| ${a.name} | ${a.role} | ${a.scope?.join(', ') || '-'} |`),
634
662
  '',
635
- `See AGENTS_REGISTRY.md for full agent state.`,
663
+ `See AGENTS_REGISTRY.md for full agent state, the names of agents on other teams,`,
664
+ `and how to consult them directly (InnerChat).`,
636
665
  '',
637
666
  `**Memory policy:** GROOVE manages project memory automatically. Do not read or write MEMORY.md or .groove/memory/ files directly.`,
638
667
  '',