groove-dev 0.27.211 → 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.
- package/CLAUDE.md +0 -8
- package/node_modules/@groove-dev/cli/bin/groove.js +20 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/cli/src/client.js +9 -2
- package/node_modules/@groove-dev/cli/src/commands/ask.js +99 -0
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/deliver.js +101 -1
- package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +25 -7
- package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +89 -3
- package/node_modules/@groove-dev/daemon/test/reach-hint.test.js +100 -0
- package/node_modules/@groove-dev/daemon/test/tunnel-manager.test.js +183 -0
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/package.json +1 -1
- package/packages/cli/bin/groove.js +20 -0
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/client.js +9 -2
- package/packages/cli/src/commands/ask.js +99 -0
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/deliver.js +101 -1
- package/packages/daemon/src/innerchat-docs.js +25 -7
- package/packages/daemon/src/tunnel-manager.js +89 -3
- package/packages/gui/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -295,11 +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, the names of agents on other teams,
|
|
303
|
-
and how to consult them directly (InnerChat).
|
|
304
|
-
**Memory policy:** GROOVE manages project memory automatically. Do not read or write MEMORY.md or .groove/memory/ files directly.
|
|
305
|
-
<!-- GROOVE:END -->
|
|
@@ -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)')
|
|
@@ -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
|
|
37
|
-
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
+
}
|
|
@@ -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
|
|
24
|
+
'another agent, use the `groove` CLI — it is already installed and on your PATH:',
|
|
25
25
|
'',
|
|
26
|
-
'
|
|
27
|
-
'
|
|
28
|
-
'
|
|
29
|
-
'
|
|
30
|
-
'
|
|
31
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// GROOVE — Agent reach hint (InnerChat discoverability)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// Spawn-prompt capabilities decay out of a long session, after which agents
|
|
5
|
+
// deny they can contact anyone. These pin the per-turn hint that replaces it.
|
|
6
|
+
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { agentReachHint } from '../src/deliver.js';
|
|
10
|
+
|
|
11
|
+
function daemonWith(agents, peers = []) {
|
|
12
|
+
return {
|
|
13
|
+
registry: { getAll: () => agents },
|
|
14
|
+
config: { innerchatPeers: peers },
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const me = { name: 'fullstack-3', role: 'fullstack' };
|
|
19
|
+
const roster = [me, { name: 'Integration-Manager', role: 'fullstack' }, { name: 'Axom-UX', role: 'frontend' }];
|
|
20
|
+
|
|
21
|
+
describe('agentReachHint', () => {
|
|
22
|
+
it('always names the CLI verbs, even on an unrelated turn', () => {
|
|
23
|
+
const hint = agentReachHint(daemonWith(roster), me, 'refactor the token parser');
|
|
24
|
+
assert.match(hint, /groove ask/);
|
|
25
|
+
assert.match(hint, /groove who/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('rules out the built-in sub-agent tools that cannot reach GROOVE agents', () => {
|
|
29
|
+
const hint = agentReachHint(daemonWith(roster), me, 'anything');
|
|
30
|
+
assert.match(hint, /CANNOT reach them/i);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('expands to a ready-to-run command naming the agent the user meant', () => {
|
|
34
|
+
const hint = agentReachHint(daemonWith(roster), me, 'ask Integration-Manager about the relay shape');
|
|
35
|
+
assert.match(hint, /groove ask Integration-Manager "your question here"/);
|
|
36
|
+
assert.doesNotMatch(hint, /<name>/, 'should not leave a placeholder to fill in');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('falls back to the roster when intent is clear but the target is not', () => {
|
|
40
|
+
const hint = agentReachHint(daemonWith(roster), me, 'coordinate with the other agents on this');
|
|
41
|
+
assert.match(hint, /groove who/);
|
|
42
|
+
assert.match(hint, /Integration-Manager, Axom-UX/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('does not resolve a target from an ambiguous partial mention', () => {
|
|
46
|
+
const two = [me, { name: 'fullstack-1', role: 'x' }, { name: 'fullstack-2', role: 'x' }];
|
|
47
|
+
const hint = agentReachHint(daemonWith(two), me, 'ask fullstack about it');
|
|
48
|
+
assert.doesNotMatch(hint, /groove ask fullstack-1 "/);
|
|
49
|
+
assert.match(hint, /groove who/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('never suggests messaging yourself', () => {
|
|
53
|
+
const hint = agentReachHint(daemonWith(roster), me, 'ask fullstack-3 what it thinks');
|
|
54
|
+
assert.doesNotMatch(hint, /groove ask fullstack-3 "/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('mentions peer machines and the name@peer form when peers exist', () => {
|
|
58
|
+
const hint = agentReachHint(daemonWith(roster, [{ alias: 'spark' }]), me, 'ask someone about it');
|
|
59
|
+
assert.match(hint, /name@peer/);
|
|
60
|
+
assert.match(hint, /spark/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('stays silent when there is genuinely nobody to talk to', () => {
|
|
64
|
+
assert.equal(agentReachHint(daemonWith([me]), me, 'ask someone'), null);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Naming the feature is an explicit request — the tier that must never rely
|
|
68
|
+
// on heuristics, because it is typically typed AFTER the agent has already
|
|
69
|
+
// claimed the capability does not exist.
|
|
70
|
+
it('re-sends the full reference when the user names InnerChat', () => {
|
|
71
|
+
for (const phrase of ['use innerChat', 'inner chat', 'inner-chat', 'InnerChat please', 'run groove ask']) {
|
|
72
|
+
const hint = agentReachHint({ port: 31415, ...daemonWith(roster) }, me, `${phrase} with the team`);
|
|
73
|
+
assert.match(hint, /Full reference/, `"${phrase}" should trigger the full block`);
|
|
74
|
+
assert.match(hint, /groove ask/);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('tells the agent the feature is real when explicitly asked for it', () => {
|
|
79
|
+
const hint = agentReachHint({ port: 31415, ...daemonWith(roster) }, me, 'use innerchat');
|
|
80
|
+
assert.match(hint, /It exists, it is wired up/);
|
|
81
|
+
assert.match(hint, /Do not tell the user the feature is/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('still resolves the named target alongside the full reference', () => {
|
|
85
|
+
const hint = agentReachHint({ port: 31415, ...daemonWith(roster) }, me, 'use innerchat with Axom-UX');
|
|
86
|
+
assert.match(hint, /groove ask Axom-UX "your question here"/);
|
|
87
|
+
assert.match(hint, /Full reference/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('does not pay for the full reference on an ordinary turn', () => {
|
|
91
|
+
const hint = agentReachHint(daemonWith(roster), me, 'refactor the parser');
|
|
92
|
+
assert.doesNotMatch(hint, /Full reference/);
|
|
93
|
+
assert.ok(hint.length < 400, 'the always-on tier must stay cheap');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('survives a broken registry rather than blocking delivery', () => {
|
|
97
|
+
const broken = { registry: { getAll() { throw new Error('boom'); } } };
|
|
98
|
+
assert.equal(agentReachHint(broken, me, 'ask someone'), null);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// GROOVE — TunnelManager Tests
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// Regression coverage for wake-from-sleep recovery. The failure these guard
|
|
5
|
+
// against: an SSH tunnel cut by a laptop sleep leaves a local listener that
|
|
6
|
+
// still ACCEPTS TCP connections but never forwards them. A TCP-connect probe
|
|
7
|
+
// calls that healthy, so the dead tunnel was handed out forever and the remote
|
|
8
|
+
// GUI loaded a port that hung instead of failing — a black window that only a
|
|
9
|
+
// full app restart cleared.
|
|
10
|
+
|
|
11
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { mkdtempSync, rmSync } from 'fs';
|
|
14
|
+
import { tmpdir } from 'os';
|
|
15
|
+
import { resolve } from 'path';
|
|
16
|
+
import { createServer } from 'net';
|
|
17
|
+
import { TunnelManager } from '../src/tunnel-manager.js';
|
|
18
|
+
|
|
19
|
+
function makeDaemon(grooveDir) {
|
|
20
|
+
const broadcasts = [];
|
|
21
|
+
return {
|
|
22
|
+
broadcasts,
|
|
23
|
+
grooveDir,
|
|
24
|
+
projectDir: process.cwd(),
|
|
25
|
+
audit: { log() {} },
|
|
26
|
+
broadcast(m) { broadcasts.push(m); },
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// A tunnel wedged by sleep: the socket is accepted and then ignored forever.
|
|
31
|
+
function startWedgedListener() {
|
|
32
|
+
const sockets = [];
|
|
33
|
+
const server = createServer((sock) => { sockets.push(sock); });
|
|
34
|
+
return new Promise((res) => {
|
|
35
|
+
server.listen(0, '127.0.0.1', () => {
|
|
36
|
+
res({
|
|
37
|
+
port: server.address().port,
|
|
38
|
+
close: () => { for (const s of sockets) s.destroy(); server.close(); },
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A tunnel that works — answers /api/health like the remote daemon would.
|
|
45
|
+
function startHealthyListener() {
|
|
46
|
+
const server = createServer((sock) => {
|
|
47
|
+
sock.on('data', () => {
|
|
48
|
+
const body = '{"ok":true}';
|
|
49
|
+
sock.end(
|
|
50
|
+
'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n'
|
|
51
|
+
+ `Content-Length: ${body.length}\r\nConnection: close\r\n\r\n${body}`,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
return new Promise((res) => {
|
|
56
|
+
server.listen(0, '127.0.0.1', () => {
|
|
57
|
+
res({ port: server.address().port, close: () => server.close() });
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('TunnelManager — wake-from-sleep recovery', () => {
|
|
63
|
+
let daemon, mgr, grooveDir;
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
grooveDir = mkdtempSync(resolve(tmpdir(), 'groove-tunnel-'));
|
|
67
|
+
daemon = makeDaemon(grooveDir);
|
|
68
|
+
mgr = new TunnelManager(daemon);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
mgr.shutdown();
|
|
73
|
+
try { rmSync(grooveDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('a TCP-accept probe cannot tell a wedged tunnel from a live one', async () => {
|
|
77
|
+
const wedged = await startWedgedListener();
|
|
78
|
+
try {
|
|
79
|
+
// This is what the old code trusted — and why the bug survived.
|
|
80
|
+
assert.equal(await mgr._isPortInUse(wedged.port), true);
|
|
81
|
+
} finally { wedged.close(); }
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('_tunnelResponds rejects a tunnel that accepts but never answers', async () => {
|
|
85
|
+
const wedged = await startWedgedListener();
|
|
86
|
+
try {
|
|
87
|
+
assert.equal(await mgr._tunnelResponds(wedged.port, 1500), false);
|
|
88
|
+
} finally { wedged.close(); }
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('_tunnelResponds accepts a tunnel that actually serves', async () => {
|
|
92
|
+
const live = await startHealthyListener();
|
|
93
|
+
try {
|
|
94
|
+
assert.equal(await mgr._tunnelResponds(live.port, 3000), true);
|
|
95
|
+
} finally { live.close(); }
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('connect() rebuilds instead of handing back a wedged tunnel', async () => {
|
|
99
|
+
const wedged = await startWedgedListener();
|
|
100
|
+
try {
|
|
101
|
+
mgr.saved.set('s19', {
|
|
102
|
+
id: 's19', name: 'S19 Agency', host: 'example.invalid',
|
|
103
|
+
user: 'ops', port: 22, lastConnected: new Date().toISOString(),
|
|
104
|
+
});
|
|
105
|
+
mgr.active.set('s19', {
|
|
106
|
+
pid: 999999, localPort: wedged.port, healthy: true, failCount: 0,
|
|
107
|
+
startedAt: new Date().toISOString(),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Host is unreachable, so the rebuild fails — the point is that it TRIED
|
|
111
|
+
// rather than returning the dead port as if it were usable.
|
|
112
|
+
await assert.rejects(
|
|
113
|
+
() => mgr.connect('s19', { skipTest: false }),
|
|
114
|
+
(err) => !/^$/.test(err.message),
|
|
115
|
+
);
|
|
116
|
+
assert.equal(mgr.active.has('s19'), false, 'the dead tunnel was torn down');
|
|
117
|
+
} finally { wedged.close(); }
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('connect() reuses a tunnel that is genuinely alive', async () => {
|
|
121
|
+
const live = await startHealthyListener();
|
|
122
|
+
try {
|
|
123
|
+
mgr.saved.set('spark', { id: 'spark', name: 'DGX Spark', host: '10.0.0.5', user: 'rok', port: 22 });
|
|
124
|
+
mgr.active.set('spark', {
|
|
125
|
+
pid: 12345, localPort: live.port, healthy: true, failCount: 0,
|
|
126
|
+
startedAt: new Date().toISOString(),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const res = await mgr.connect('spark');
|
|
130
|
+
assert.equal(res.localPort, live.port, 'a working tunnel is reused, not rebuilt');
|
|
131
|
+
assert.equal(mgr.active.has('spark'), true);
|
|
132
|
+
} finally { live.close(); }
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('the health pass reaps a dead tunnel so the next connect starts clean', async () => {
|
|
136
|
+
const wedged = await startWedgedListener();
|
|
137
|
+
try {
|
|
138
|
+
mgr.saved.set('s19', { id: 's19', name: 'S19 Agency', host: 'example.invalid', user: 'ops', port: 22 });
|
|
139
|
+
mgr.active.set('s19', {
|
|
140
|
+
pid: 999999, localPort: wedged.port, healthy: true,
|
|
141
|
+
failCount: 99, // already past the failure limit
|
|
142
|
+
startedAt: new Date().toISOString(),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await mgr._healthCheckAll();
|
|
146
|
+
|
|
147
|
+
assert.equal(mgr.active.has('s19'), false, 'dead tunnel removed from active');
|
|
148
|
+
assert.ok(
|
|
149
|
+
daemon.broadcasts.some((b) => b.type === 'tunnel.unhealthy'),
|
|
150
|
+
'the GUI is told the tunnel went unhealthy',
|
|
151
|
+
);
|
|
152
|
+
} finally { wedged.close(); }
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('treats a long timer gap as a sleep and drops the failure tolerance', async () => {
|
|
156
|
+
const wedged = await startWedgedListener();
|
|
157
|
+
try {
|
|
158
|
+
mgr.saved.set('s19', { id: 's19', name: 'S19 Agency', host: 'example.invalid', user: 'ops', port: 22 });
|
|
159
|
+
mgr.active.set('s19', {
|
|
160
|
+
pid: 999999, localPort: wedged.port, healthy: true, failCount: 0,
|
|
161
|
+
startedAt: new Date().toISOString(),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Last check was 10 minutes ago — the machine was asleep.
|
|
165
|
+
mgr._lastHealthCheck = Date.now() - 10 * 60 * 1000;
|
|
166
|
+
await mgr._healthCheckAll();
|
|
167
|
+
|
|
168
|
+
const conn = mgr.active.get('s19');
|
|
169
|
+
assert.equal(conn?.healthy, false, 'one failure after a sleep gap is enough');
|
|
170
|
+
} finally { wedged.close(); }
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('_waitForPortFree reports a released port', async () => {
|
|
174
|
+
const live = await startHealthyListener();
|
|
175
|
+
assert.equal(await mgr._waitForPortFree(live.port, 600), false, 'still held while listening');
|
|
176
|
+
live.close();
|
|
177
|
+
assert.equal(await mgr._waitForPortFree(live.port, 3000), true, 'free once closed');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('_waitForExit returns true for a pid that does not exist', async () => {
|
|
181
|
+
assert.equal(await mgr._waitForExit(999999, 500), true);
|
|
182
|
+
});
|
|
183
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "groove-dev",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.212",
|
|
4
4
|
"description": "Open-source agent orchestration layer — the AI company OS. Local model agent engine (GGUF/Ollama/llama-server), HuggingFace model browser, MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama, any local model.",
|
|
5
5
|
"license": "FSL-1.1-Apache-2.0",
|
|
6
6
|
"author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
|
|
@@ -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)')
|
|
@@ -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
|
|
37
|
-
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
+
}
|
|
@@ -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
|
|
24
|
+
'another agent, use the `groove` CLI — it is already installed and on your PATH:',
|
|
25
25
|
'',
|
|
26
|
-
'
|
|
27
|
-
'
|
|
28
|
-
'
|
|
29
|
-
'
|
|
30
|
-
'
|
|
31
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|