groove-dev 0.27.210 → 0.27.212

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 (37) hide show
  1. package/node_modules/@groove-dev/cli/bin/groove.js +20 -0
  2. package/node_modules/@groove-dev/cli/package.json +1 -1
  3. package/node_modules/@groove-dev/cli/src/client.js +9 -2
  4. package/node_modules/@groove-dev/cli/src/commands/ask.js +99 -0
  5. package/node_modules/@groove-dev/daemon/package.json +1 -1
  6. package/node_modules/@groove-dev/daemon/src/axom-connector.js +20 -6
  7. package/node_modules/@groove-dev/daemon/src/chatstore.js +57 -13
  8. package/node_modules/@groove-dev/daemon/src/deliver.js +101 -1
  9. package/node_modules/@groove-dev/daemon/src/index.js +2 -0
  10. package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +25 -7
  11. package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +89 -3
  12. package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +41 -0
  13. package/node_modules/@groove-dev/daemon/test/chatstore.test.js +67 -2
  14. package/node_modules/@groove-dev/daemon/test/reach-hint.test.js +100 -0
  15. package/node_modules/@groove-dev/daemon/test/tunnel-manager.test.js +183 -0
  16. package/node_modules/@groove-dev/gui/dist/assets/{index-YHDeARYl.js → index-BP4oE2UL.js} +228 -228
  17. package/node_modules/@groove-dev/gui/dist/assets/index-DPsim83z.css +1 -0
  18. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  19. package/node_modules/@groove-dev/gui/package.json +1 -1
  20. package/package.json +1 -1
  21. package/packages/cli/bin/groove.js +20 -0
  22. package/packages/cli/package.json +1 -1
  23. package/packages/cli/src/client.js +9 -2
  24. package/packages/cli/src/commands/ask.js +99 -0
  25. package/packages/daemon/package.json +1 -1
  26. package/packages/daemon/src/axom-connector.js +20 -6
  27. package/packages/daemon/src/chatstore.js +57 -13
  28. package/packages/daemon/src/deliver.js +101 -1
  29. package/packages/daemon/src/index.js +2 -0
  30. package/packages/daemon/src/innerchat-docs.js +25 -7
  31. package/packages/daemon/src/tunnel-manager.js +89 -3
  32. package/packages/gui/dist/assets/{index-YHDeARYl.js → index-BP4oE2UL.js} +228 -228
  33. package/packages/gui/dist/assets/index-DPsim83z.css +1 -0
  34. package/packages/gui/dist/index.html +2 -2
  35. package/packages/gui/package.json +1 -1
  36. package/node_modules/@groove-dev/gui/dist/assets/index-DdCadtGL.css +0 -1
  37. package/packages/gui/dist/assets/index-DdCadtGL.css +0 -1
@@ -21,6 +21,7 @@ import { disconnect } from '../src/commands/disconnect.js';
21
21
  import { remotes } from '../src/commands/remotes.js';
22
22
  import { audit } from '../src/commands/audit.js';
23
23
  import { federationPair, federationUnpair, federationList, federationStatus } from '../src/commands/federation.js';
24
+ import { ask, tell, who } from '../src/commands/ask.js';
24
25
  import { createRequire } from 'node:module';
25
26
  const require = createRequire(import.meta.url);
26
27
  const { version } = require('../../../package.json');
@@ -74,6 +75,25 @@ program
74
75
  .option('-f, --force', 'Required when agents are still running')
75
76
  .action(nuke);
76
77
 
78
+ // InnerChat — agent-to-agent messaging. Listed high in --help because an
79
+ // agent that has lost the capability from context rediscovers it here.
80
+ program
81
+ .command('ask <agent> <message>')
82
+ .description('Ask another agent a question and wait for their answer')
83
+ .option('--from <name>', 'your agent name (defaults to $GROOVE_AGENT_NAME)')
84
+ .action(ask);
85
+
86
+ program
87
+ .command('tell <agent> <message>')
88
+ .description('Send another agent a message without waiting for a reply')
89
+ .option('--from <name>', 'your agent name (defaults to $GROOVE_AGENT_NAME)')
90
+ .action(tell);
91
+
92
+ program
93
+ .command('who')
94
+ .description('List agents you can message with `groove ask` / `groove tell`')
95
+ .action(who);
96
+
77
97
  program
78
98
  .command('rotate <id>')
79
99
  .description('Rotate an agent (kill + respawn with fresh context)')
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.210",
3
+ "version": "0.27.212",
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",
@@ -33,8 +33,15 @@ export async function apiCall(method, path, body) {
33
33
  const res = await fetch(url, options);
34
34
 
35
35
  if (!res.ok) {
36
- const err = await res.json().catch(() => ({ error: res.statusText }));
37
- throw new Error(err.error || `HTTP ${res.status}`);
36
+ const body = await res.json().catch(() => ({ error: res.statusText }));
37
+ const err = new Error(body.error || `HTTP ${res.status}`);
38
+ // Carry the response body onto the error. The daemon's actionable fields
39
+ // (availableAgents, didYouMean, note) are the whole point of its error
40
+ // messages — dropping them leaves the caller with a dead end.
41
+ err.status = res.status;
42
+ err.body = body;
43
+ Object.assign(err, body);
44
+ throw err;
38
45
  }
39
46
 
40
47
  return res.json();
@@ -0,0 +1,99 @@
1
+ // GROOVE CLI — InnerChat ask/tell/who
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+ //
4
+ // Agent-to-agent messaging as first-class CLI verbs.
5
+ //
6
+ // The HTTP endpoints came first, and agents were taught them via a curl
7
+ // snippet in the spawn prompt. That decays: on a long session the snippet
8
+ // scrolls out of context and the agent no longer knows the capability exists,
9
+ // or half-remembers it and reaches for a built-in tool that cannot see GROOVE
10
+ // agents. A CLI verb survives that — an agent that has forgotten the details
11
+ // can still run `groove --help` or simply guess `groove ask`, and the command
12
+ // itself explains the rest.
13
+
14
+ import chalk from 'chalk';
15
+ import { apiCall } from '../client.js';
16
+
17
+ // The caller is almost always an agent, and the daemon injects its identity
18
+ // into the environment — so `--from` is a manual override, not a requirement.
19
+ function resolveFrom(opts) {
20
+ const from = opts.from || process.env.GROOVE_AGENT_NAME;
21
+ if (!from) {
22
+ console.error(chalk.red(' Could not tell who is asking.'));
23
+ console.error(chalk.dim(' Pass --from <your-agent-name> (GROOVE_AGENT_NAME is unset — are you running outside an agent?)'));
24
+ process.exit(1);
25
+ }
26
+ return from;
27
+ }
28
+
29
+ function reportError(err, to) {
30
+ console.error(chalk.red(' Failed:'), err.message);
31
+ // The daemon returns actionable bodies (unknown name + roster, ambiguous
32
+ // name + candidates, exchange cap). Surface them rather than a bare status.
33
+ if (err.availableAgents?.length) {
34
+ console.error(chalk.dim(` Agents you can reach: ${err.availableAgents.join(', ')}`));
35
+ }
36
+ if (err.didYouMean?.length) {
37
+ console.error(chalk.dim(` "${to}" is ambiguous — did you mean: ${err.didYouMean.join(', ')}?`));
38
+ }
39
+ process.exit(1);
40
+ }
41
+
42
+ export async function ask(to, message, opts = {}) {
43
+ const from = resolveFrom(opts);
44
+ try {
45
+ console.error(chalk.dim(` Asking ${to}… (this blocks until they answer — that is expected)`));
46
+ const res = await apiCall('POST', '/api/innerchat/ask', { from, to, message });
47
+ // The reply goes to stdout alone so it can be piped/read cleanly; the
48
+ // status chatter goes to stderr.
49
+ console.log(res.reply);
50
+ if (res.exchangesRemaining !== undefined) {
51
+ console.error(chalk.dim(` (${res.exchangesRemaining} exchanges left in this conversation)`));
52
+ }
53
+ } catch (err) {
54
+ reportError(err, to);
55
+ }
56
+ }
57
+
58
+ export async function tell(to, message, opts = {}) {
59
+ const from = resolveFrom(opts);
60
+ try {
61
+ const res = await apiCall('POST', '/api/innerchat/tell', { from, to, message });
62
+ console.error(chalk.green(' Delivered.'), chalk.dim(res.note || `${to} will reply later if needed.`));
63
+ } catch (err) {
64
+ reportError(err, to);
65
+ }
66
+ }
67
+
68
+ // Who can I talk to? The question an agent asks first when it has lost the
69
+ // roster from context — and the reason it otherwise starts guessing names.
70
+ export async function who() {
71
+ try {
72
+ const me = process.env.GROOVE_AGENT_NAME;
73
+ const data = await apiCall('GET', '/api/agents');
74
+ const agents = Array.isArray(data) ? data : (data.agents || []);
75
+ const others = agents.filter((a) => a.name !== me);
76
+
77
+ if (!others.length) {
78
+ console.log(chalk.dim(' No other agents are running right now.'));
79
+ } else {
80
+ console.log(chalk.bold(' Agents you can message:'));
81
+ for (const a of others) {
82
+ const status = a.status === 'running' ? chalk.green('●') : chalk.dim('○');
83
+ console.log(` ${status} ${chalk.bold(a.name)} ${chalk.dim(`(${a.role}${a.status !== 'running' ? `, ${a.status}` : ''})`)}`);
84
+ }
85
+ }
86
+
87
+ const peers = await apiCall('GET', '/api/innerchat/peers').catch(() => ({ peers: [] }));
88
+ if (peers.peers?.length) {
89
+ console.log(chalk.bold('\n Peer machines (address as name@peer):'));
90
+ for (const p of peers.peers) console.log(` ${chalk.bold('@' + p.alias)} ${chalk.dim(p.url)}`);
91
+ }
92
+
93
+ console.log(chalk.dim('\n groove ask <name> "<question>" — blocks until they answer'));
94
+ console.log(chalk.dim(' groove tell <name> "<message>" — returns immediately'));
95
+ } catch (err) {
96
+ console.error(chalk.red(' Failed:'), err.message);
97
+ process.exit(1);
98
+ }
99
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.210",
3
+ "version": "0.27.212",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -33,8 +33,16 @@ export const KNOWN_KINDS = Object.freeze([
33
33
  'narration', 'narration_dropped',
34
34
  'candidate_arrived', 'evidence_scored', 'champion_changed',
35
35
  'confidence_updated', 'verifier_verdict',
36
+ 'resolution_delta', 'context_compile',
36
37
  ]);
37
38
 
39
+ // Cosmetic, append-only chunks of an answer still being written. The terminal
40
+ // `resolution` always follows with the authoritative text, so a delta is
41
+ // worthless the moment it lands — keeping them would crowd the ring with
42
+ // history that replay deliberately omits anyway (they are transient runtime
43
+ // side, so a `?since` replay never returns them either).
44
+ const TRANSIENT_KINDS = new Set(['resolution_delta']);
45
+
38
46
  const RING_SIZE = 4096;
39
47
  const SESSION_POLL_MS = 15000;
40
48
  const BACKOFF_BASE_MS = 1000;
@@ -243,13 +251,19 @@ export class AxomConnector {
243
251
  // Dedup on ring-buffer replay after reconnect — ids are monotonic.
244
252
  if (seq !== null && seq <= s.lastSeq) return;
245
253
  if (seq !== null) s.lastSeq = seq;
246
- if (s.ring.length >= this.ringSize) {
247
- s.ring.shift();
248
- // The ring bounds memory, not delivery: overflow is counted, never
249
- // silent, and every event still broadcasts — mirrors the runtime.
250
- s.overflow += 1;
254
+ // Transient kinds broadcast but are never retained: they are superseded
255
+ // by a terminal event, and a backfilled tab must see the same history a
256
+ // reconnecting one does. `lastSeq` still advances above, so dedup and
257
+ // ordering are unaffected.
258
+ if (!TRANSIENT_KINDS.has(envelope.kind)) {
259
+ if (s.ring.length >= this.ringSize) {
260
+ s.ring.shift();
261
+ // The ring bounds memory, not delivery: overflow is counted, never
262
+ // silent, and every event still broadcasts — mirrors the runtime.
263
+ s.overflow += 1;
264
+ }
265
+ s.ring.push(envelope);
251
266
  }
252
- s.ring.push(envelope);
253
267
  if (envelope.kind && !KNOWN_KINDS.includes(envelope.kind)) {
254
268
  s.unknownKinds[envelope.kind] = (s.unknownKinds[envelope.kind] || 0) + 1;
255
269
  }
@@ -141,21 +141,37 @@ export class ChatStore {
141
141
  }
142
142
 
143
143
  /**
144
- * History for the GUI: live agents keyed by their CURRENT id (what the GUI
145
- * looks up by), everything else under its stored name key so an agent
146
- * respawned under the same name picks its history back up.
144
+ * History for the GUI: LIVE agents only, keyed by their current id (what the
145
+ * GUI looks up by). Buckets for agents that no longer exist stay on disk —
146
+ * a respawn under the same name picks them back up — but aren't shipped:
147
+ * they were 97% of a 5.6MB payload, which blew the browser's ~5MB
148
+ * localStorage quota and froze the local cache.
147
149
  */
148
150
  view() {
149
151
  const out = {};
150
- const agents = this.daemon.registry?.getAll?.() || [];
151
- const liveNames = new Map(agents.map((a) => [a.name, a.id]));
152
- for (const [key, msgs] of Object.entries(this.history)) {
153
- if (!Array.isArray(msgs) || !msgs.length) continue;
154
- out[liveNames.get(key) || key] = msgs;
152
+ for (const agent of this.daemon.registry?.getAll?.() || []) {
153
+ const msgs = this.history[agent.name];
154
+ if (Array.isArray(msgs) && msgs.length) out[agent.id] = msgs;
155
155
  }
156
156
  return out;
157
157
  }
158
158
 
159
+ /**
160
+ * Drop the oldest buckets belonging to agents that no longer exist, so a
161
+ * long-lived daemon's store stays bounded. Live agents are never pruned.
162
+ */
163
+ prune(maxDeadBuckets = 200) {
164
+ const liveNames = new Set((this.daemon.registry?.getAll?.() || []).map((a) => a.name));
165
+ const dead = Object.keys(this.history)
166
+ .filter((k) => !liveNames.has(k))
167
+ .map((k) => [k, lastTs(this.history[k])])
168
+ .sort((x, y) => y[1] - x[1]);
169
+ if (dead.length <= maxDeadBuckets) return 0;
170
+ for (const [key] of dead.slice(maxDeadBuckets)) delete this.history[key];
171
+ this._scheduleSave();
172
+ return dead.length - maxDeadBuckets;
173
+ }
174
+
159
175
  getAll() {
160
176
  return this.history;
161
177
  }
@@ -178,18 +194,46 @@ export class ChatStore {
178
194
  }
179
195
  }
180
196
 
181
- // Union of two message arrays, deduped on (timestamp, from, text), time-sorted,
182
- // capped. Exported for tests and the migration path.
197
+ function lastTs(msgs) {
198
+ return (Array.isArray(msgs) && msgs.length && msgs[msgs.length - 1]?.timestamp) || 0;
199
+ }
200
+
201
+ /**
202
+ * Union of two message arrays, time-sorted and capped.
203
+ *
204
+ * Messages carry a stable `id`. A streamed agent reply coalesces client-side
205
+ * into ONE growing message that keeps its id, so an id collision means "same
206
+ * message, later state" — we keep the longer text rather than accumulating a
207
+ * fragment per chunk. Messages without an id (older clients) fall back to a
208
+ * (timestamp, from, text) signature.
209
+ */
183
210
  export function mergeMessages(a, b) {
184
- const seen = new Set();
211
+ const byId = new Map();
212
+ const bySig = new Set();
185
213
  const out = [];
214
+
186
215
  for (const m of [...(a || []), ...(b || [])]) {
187
216
  if (!m || typeof m !== 'object') continue;
217
+
218
+ if (m.id) {
219
+ const prev = byId.get(m.id);
220
+ if (!prev) {
221
+ byId.set(m.id, m);
222
+ out.push(m);
223
+ } else if (String(m.text || '').length > String(prev.text || '').length) {
224
+ // Same message, further along — replace in place.
225
+ out[out.indexOf(prev)] = m;
226
+ byId.set(m.id, m);
227
+ }
228
+ continue;
229
+ }
230
+
188
231
  const sig = `${m.timestamp}:${m.from}:${typeof m.text === 'string' ? m.text.slice(0, 200) : ''}`;
189
- if (seen.has(sig)) continue;
190
- seen.add(sig);
232
+ if (bySig.has(sig)) continue;
233
+ bySig.add(sig);
191
234
  out.push(m);
192
235
  }
236
+
193
237
  out.sort((x, y) => (x.timestamp || 0) - (y.timestamp || 0));
194
238
  return out.slice(-MAX_PER_AGENT);
195
239
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { wrapWithRoleReminder } from './process.js';
4
4
  import { getProvider } from './providers/index.js';
5
+ import { innerChatInstructions } from './innerchat-docs.js';
5
6
 
6
7
  // Reviving a >5M-token claude session has crashed the CLI mid-HTTP-parse
7
8
  // (V8 fatal in JsonStringifier) — past this ceiling the rotator's handoff
@@ -42,7 +43,12 @@ export async function deliverInstruction(daemon, agentId, message, opts = {}) {
42
43
  // and the user's direction, not by how much context has piled up.
43
44
  const clock = daemon.processes.sessionClock?.(agent);
44
45
  const timedMessage = clock ? `${clock}\n\n${finalMessage}` : finalMessage;
45
- const wrappedMessage = wrapWithRoleReminder(agent.role, timedMessage);
46
+ // Spawn-prompt capabilities scroll out of a long session, after which the
47
+ // agent denies it can reach other agents at all. This rides every turn, so
48
+ // it cannot decay — terse by default, expanded when the turn actually asks
49
+ // the agent to contact someone.
50
+ const reach = agentReachHint(daemon, agent, finalMessage);
51
+ const wrappedMessage = wrapWithRoleReminder(agent.role, reach ? `${reach}\n\n${timedMessage}` : timedMessage);
46
52
 
47
53
  // Agent loop path — send straight to the running loop.
48
54
  if (daemon.processes.hasAgentLoop(agentId)) {
@@ -132,3 +138,97 @@ async function respawn(daemon, config) {
132
138
  throw spawnErr;
133
139
  }
134
140
  }
141
+
142
+ // Phrases that mean "go talk to another agent". Deliberately broad: a false
143
+ // positive costs a few tokens of accurate instruction, a false negative costs
144
+ // the user a turn spent watching their agent claim the capability isn't real.
145
+ const CONTACT_INTENT = /\b(ask|message|msg|tell|consult|coordinate|check|sync|reach out|talk|speak|ping|liaise|confer|follow up|loop in)\b/i;
146
+
147
+ // Naming the feature is an EXPLICIT request for it — usually typed after the
148
+ // agent has already failed to find it. That earns the full instructions
149
+ // verbatim, not a hint: no inference, no heuristic that can miss. Matches
150
+ // innerchat / inner chat / inner-chat / InnerChat, and the CLI verbs by name.
151
+ const INNERCHAT_KEYWORD = /\b(inner[\s_-]?chat|groove\s+(?:ask|tell|who))\b/i;
152
+
153
+ /**
154
+ * A capability line appended to every delivered turn.
155
+ *
156
+ * Spawn-time instructions decay — on a long session (or under a model that
157
+ * compacts aggressively) they scroll away, and the agent then insists it has
158
+ * no way to contact anyone, or reaches for a built-in sub-agent tool that
159
+ * cannot see GROOVE agents. Two tiers:
160
+ *
161
+ * - Always: one terse line naming the CLI verbs. ~20 tokens.
162
+ * - When the turn expresses intent to contact someone: the exact command,
163
+ * with the target's real name already filled in.
164
+ */
165
+ export function agentReachHint(daemon, agent, message) {
166
+ let others;
167
+ try {
168
+ others = (daemon.registry.getAll() || []).filter((a) => a.name !== agent.name);
169
+ } catch { return null; }
170
+
171
+ const peers = Array.isArray(daemon.config?.innerchatPeers) ? daemon.config.innerchatPeers : [];
172
+ if (!others.length && !peers.length) return null;
173
+
174
+ const base = '[You can talk to other GROOVE agents: `groove ask <name> "<question>"` waits for their '
175
+ + 'answer, `groove tell <name> "<message>"` does not. `groove who` lists who is reachable. '
176
+ + 'Your own built-in sub-agent/task tools CANNOT reach them.]';
177
+
178
+ const explicit = INNERCHAT_KEYWORD.test(message);
179
+ if (!explicit && !CONTACT_INTENT.test(message)) return base;
180
+
181
+ // Name the agent the user is actually pointing at, so the model has a
182
+ // runnable command rather than a template it has to fill in from memory.
183
+ const lower = message.toLowerCase();
184
+ const named = others.filter((a) => lower.includes(a.name.toLowerCase()));
185
+ const target = named.length === 1 ? named[0] : null;
186
+
187
+ const lines = [
188
+ '[REACHING ANOTHER AGENT — this capability is real and available right now.]',
189
+ ];
190
+ if (explicit) {
191
+ // The user named the feature. Say plainly that it exists, since the usual
192
+ // failure is the agent asserting it doesn't and stopping there.
193
+ lines.push(
194
+ 'The user explicitly asked you to use InnerChat. It exists, it is wired up, and the',
195
+ 'commands below work from your shell right now. Do not tell the user the feature is',
196
+ 'unavailable and do not ask them how to use it — run the command.',
197
+ '',
198
+ );
199
+ }
200
+ if (target) {
201
+ lines.push(
202
+ `To contact ${target.name}, run exactly:`,
203
+ ` groove ask ${target.name} "your question here"`,
204
+ 'That blocks until they answer and prints their reply. Use `groove tell` instead if you '
205
+ + 'do not need the answer before continuing.',
206
+ );
207
+ } else {
208
+ lines.push(
209
+ ' groove who — list who is reachable',
210
+ ' groove ask <name> "<question>" — blocks until they answer, prints the reply',
211
+ ' groove tell <name> "<message>" — returns immediately',
212
+ `Agents reachable now: ${others.map((a) => a.name).join(', ') || '(none)'}`,
213
+ );
214
+ }
215
+ if (peers.length) {
216
+ lines.push(`Agents on peer machines use name@peer (peers: ${peers.map((p) => p.alias).join(', ')}).`);
217
+ }
218
+ lines.push(
219
+ 'Do NOT use your built-in sub-agent/Task/SendMessage tools for this — they cannot see GROOVE '
220
+ + 'agents and will fail. If a name is wrong the command tells you the valid ones; read it and retry.',
221
+ );
222
+
223
+ // Explicit request → re-attach the full reference, so the agent has the
224
+ // complete semantics (blocking vs not, exchange budget, peer addressing)
225
+ // even if the spawn prompt scrolled away long ago.
226
+ if (explicit) {
227
+ lines.push(
228
+ '',
229
+ '--- Full reference (re-sent because you asked for InnerChat by name) ---',
230
+ ...innerChatInstructions(daemon.port || 31415, agent.name, peers),
231
+ );
232
+ }
233
+ return lines.join('\n');
234
+ }
@@ -653,6 +653,8 @@ export class Daemon {
653
653
  try {
654
654
  const moved = this.chatStore.migrate();
655
655
  if (moved) console.log(`[chat] migrated ${moved} id-keyed histories to agent names`);
656
+ const pruned = this.chatStore.prune();
657
+ if (pruned) console.log(`[chat] pruned ${pruned} histories for long-gone agents`);
656
658
  } catch { /* best effort */ }
657
659
 
658
660
  // Regenerate the on-disk registry files once on boot. They otherwise
@@ -21,14 +21,26 @@ export function innerChatInstructions(port = 31415, agentName = 'YOUR_NAME', pee
21
21
  '',
22
22
  'Other GROOVE agents may be working alongside you, including on other teams.',
23
23
  'When the user asks you to reach out to, coordinate with, or get input from',
24
- 'another agent, use this — it sends them a message and waits for their reply:',
24
+ 'another agent, use the `groove` CLI — it is already installed and on your PATH:',
25
25
  '',
26
- '> **Do NOT use your built-in `SendMessage` / Agent tools to reach a GROOVE agent.**',
27
- '> Those only address sub-agents you spawned yourself in this session, so they will',
28
- '> fail with "not reachable" or ask you for an `a…-…` agent ID that does not exist',
29
- '> here. GROOVE agents are separate processes. The curl below is the only way to',
30
- '> reach them. Do not invent a fallback (writing a message into a file, etc.) —',
31
- '> if the curl fails, read the error and report it to the user.',
26
+ '```bash',
27
+ 'groove who # who can I reach right now?',
28
+ 'groove ask <AGENT_NAME> "<QUESTION>" # send + WAIT for their answer',
29
+ 'groove tell <AGENT_NAME> "<MESSAGE>" # send without waiting',
30
+ '```',
31
+ '',
32
+ '`groove ask` prints their reply to stdout. It knows who you are from your',
33
+ 'environment, so there is no id or token to look up. If you get the name wrong,',
34
+ 'the error lists the valid names — read it and retry rather than giving up.',
35
+ '',
36
+ '> **Do NOT use your built-in `SendMessage` / Task / sub-agent tools to reach a**',
37
+ '> **GROOVE agent.** Those only address sub-agents you spawned yourself in this',
38
+ '> session, so they will fail with "not reachable" or ask you for an `a…-…` agent',
39
+ '> ID that does not exist here. GROOVE agents are separate processes. Do not invent',
40
+ '> a fallback (writing a message into a file, etc.) — if the command fails, read the',
41
+ '> error and report it to the user.',
42
+ '',
43
+ 'If the `groove` CLI is somehow unavailable, the same thing over HTTP:',
32
44
  '',
33
45
  '```bash',
34
46
  `curl -s http://localhost:${port}/api/innerchat/ask -X POST -H 'Content-Type: application/json' \\`,
@@ -54,6 +66,12 @@ export function innerChatInstructions(port = 31415, agentName = 'YOUR_NAME', pee
54
66
  'delivered back to you later, waking you if your turn has ended:',
55
67
  '',
56
68
  '```bash',
69
+ 'groove tell <AGENT_NAME> "<MESSAGE>"',
70
+ '```',
71
+ '',
72
+ 'Or over HTTP:',
73
+ '',
74
+ '```bash',
57
75
  `curl -s http://localhost:${port}/api/innerchat/tell -X POST -H 'Content-Type: application/json' \\`,
58
76
  ` -d '{"from":"${agentName}","to":"AGENT_NAME","message":"YOUR_MESSAGE"}'`,
59
77
  '```',
@@ -283,9 +283,19 @@ export class TunnelManager {
283
283
  const config = this.saved.get(id);
284
284
  if (!config) throw new Error(`Remote ${id} not found`);
285
285
 
286
+ // An existing entry is only reusable if the tunnel actually still carries
287
+ // traffic. After a laptop sleep the SSH client can survive with its forward
288
+ // dead: the local port still ACCEPTS connections but never forwards them, so
289
+ // handing this back returns a port that hangs forever instead of failing —
290
+ // which is what left the remote GUI on a black screen. Probe before reusing,
291
+ // and tear it down if it's a corpse.
286
292
  if (this.active.has(id)) {
287
293
  const existing = this.active.get(id);
288
- return { localPort: existing.localPort, pid: existing.pid, name: config.name };
294
+ if (await this._tunnelResponds(existing.localPort)) {
295
+ return { localPort: existing.localPort, pid: existing.pid, name: config.name };
296
+ }
297
+ console.log(`[Groove:Tunnel] ${config.name}: existing tunnel is not responding — rebuilding`);
298
+ await this.disconnect(id);
289
299
  }
290
300
 
291
301
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'testing' } });
@@ -432,11 +442,23 @@ export class TunnelManager {
432
442
  return { localPort, pid: tunnel.pid, name: config.name, url };
433
443
  }
434
444
 
445
+ // Does the tunnel actually serve a request, as opposed to merely holding an
446
+ // open listening socket? A wedged forward passes a TCP connect test but never
447
+ // answers, so only an HTTP round-trip proves it.
448
+ async _tunnelResponds(localPort, timeoutMs = HEALTH_TIMEOUT) {
449
+ try {
450
+ const res = await fetch(`http://localhost:${localPort}/api/health`, {
451
+ signal: AbortSignal.timeout(timeoutMs),
452
+ });
453
+ return res.ok;
454
+ } catch { return false; }
455
+ }
456
+
435
457
  async disconnect(id) {
436
458
  const conn = this.active.get(id);
437
459
  if (!conn) return;
438
460
 
439
- const { pid } = conn;
461
+ const { pid, localPort } = conn;
440
462
  try {
441
463
  const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
442
464
  encoding: 'utf8',
@@ -444,9 +466,20 @@ export class TunnelManager {
444
466
  }).trim();
445
467
  if (cmd.includes('ssh')) {
446
468
  process.kill(pid, 'SIGTERM');
469
+ // An SSH client stuck on a dead TCP session can sit on SIGTERM long
470
+ // enough that the next connect() finds the port still bound. Give it a
471
+ // moment, then stop asking politely — otherwise the leftover listener
472
+ // keeps answering (and hanging) on the port we're about to reuse.
473
+ const gone = await this._waitForExit(pid, 3000);
474
+ if (!gone) {
475
+ try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ }
476
+ await this._waitForExit(pid, 2000);
477
+ }
447
478
  }
448
479
  } catch { /* process already dead */ }
449
480
 
481
+ if (localPort) await this._waitForPortFree(localPort, 3000);
482
+
450
483
  this.active.delete(id);
451
484
 
452
485
  const config = this.saved.get(id);
@@ -873,6 +906,26 @@ export class TunnelManager {
873
906
  }
874
907
 
875
908
  async _healthCheckAll() {
909
+ // Reaping now awaits process death, which can outlast the interval — don't
910
+ // let a second pass start on top of one already tearing a tunnel down.
911
+ if (this._healthRunning) return;
912
+ this._healthRunning = true;
913
+ try { await this._healthCheckPass(); } finally { this._healthRunning = false; }
914
+ }
915
+
916
+ async _healthCheckPass() {
917
+ // Timers don't fire while the machine is asleep, so an interval that should
918
+ // have run every HEALTH_INTERVAL arriving far later means we just woke up.
919
+ // Every tunnel is suspect at that point: verify them now rather than waiting
920
+ // for MAX_FAIL_COUNT normal cycles to notice.
921
+ const now = Date.now();
922
+ const gap = now - (this._lastHealthCheck || now);
923
+ this._lastHealthCheck = now;
924
+ const wokeFromSleep = gap > HEALTH_INTERVAL * 3;
925
+ if (wokeFromSleep && this.active.size > 0) {
926
+ console.log(`[Groove:Tunnel] Detected a ${Math.round(gap / 1000)}s gap (system sleep) — verifying tunnels`);
927
+ }
928
+
876
929
  for (const [id, conn] of this.active) {
877
930
  try {
878
931
  const start = Date.now();
@@ -889,9 +942,23 @@ export class TunnelManager {
889
942
  }
890
943
  } catch {
891
944
  conn.failCount = (conn.failCount || 0) + 1;
892
- if (conn.failCount >= MAX_FAIL_COUNT) {
945
+ // After a sleep gap, one failure is enough — the tunnel was almost
946
+ // certainly cut with the network.
947
+ const limit = wokeFromSleep ? 1 : MAX_FAIL_COUNT;
948
+ if (conn.failCount >= limit) {
893
949
  conn.healthy = false;
894
950
  this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
951
+
952
+ // Reap it. Previously a dead tunnel was only FLAGGED, so it stayed in
953
+ // `active` indefinitely and every later connect() handed back its dead
954
+ // port — the reason reconnecting never helped and only a full restart
955
+ // did. Removing it means the next connect() builds a real tunnel.
956
+ if (conn.failCount >= limit + 1) {
957
+ console.log(`[Groove:Tunnel] Reaping dead tunnel ${id} after ${conn.failCount} failed checks`);
958
+ this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'reaping' } });
959
+ await this.disconnect(id);
960
+ continue;
961
+ }
895
962
  }
896
963
  }
897
964
  this.daemon.broadcast({
@@ -901,6 +968,25 @@ export class TunnelManager {
901
968
  }
902
969
  }
903
970
 
971
+ // Signal 0 only tests for existence — no signal is delivered.
972
+ async _waitForExit(pid, timeoutMs) {
973
+ const deadline = Date.now() + timeoutMs;
974
+ while (Date.now() < deadline) {
975
+ try { process.kill(pid, 0); } catch { return true; } // gone
976
+ await new Promise((r) => setTimeout(r, 150));
977
+ }
978
+ try { process.kill(pid, 0); return false; } catch { return true; }
979
+ }
980
+
981
+ async _waitForPortFree(port, timeoutMs) {
982
+ const deadline = Date.now() + timeoutMs;
983
+ while (Date.now() < deadline) {
984
+ if (!(await this._isPortInUse(port))) return true;
985
+ await new Promise((r) => setTimeout(r, 200));
986
+ }
987
+ return !(await this._isPortInUse(port));
988
+ }
989
+
904
990
  _isPortInUse(port) {
905
991
  return new Promise((resolve) => {
906
992
  const conn = createConnection({ host: '127.0.0.1', port });