groove-dev 0.27.15 → 0.27.18

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 (172) hide show
  1. package/CLAUDE.md +0 -10
  2. package/README.md +37 -1
  3. package/developerID_application.cer +0 -0
  4. package/node_modules/@groove-dev/daemon/src/api.js +586 -67
  5. package/node_modules/@groove-dev/daemon/src/classifier.js +24 -0
  6. package/node_modules/@groove-dev/daemon/src/credentials.js +12 -2
  7. package/node_modules/@groove-dev/daemon/src/federation/ambassador.js +204 -0
  8. package/node_modules/@groove-dev/daemon/src/federation/connection.js +359 -0
  9. package/node_modules/@groove-dev/daemon/src/federation/contracts.js +112 -0
  10. package/node_modules/@groove-dev/daemon/src/federation/whitelist.js +190 -0
  11. package/node_modules/@groove-dev/daemon/src/federation.js +166 -7
  12. package/node_modules/@groove-dev/daemon/src/index.js +172 -19
  13. package/node_modules/@groove-dev/daemon/src/introducer.js +52 -7
  14. package/node_modules/@groove-dev/daemon/src/journalist.js +46 -1
  15. package/node_modules/@groove-dev/daemon/src/memory.js +36 -16
  16. package/node_modules/@groove-dev/daemon/src/process.js +140 -23
  17. package/node_modules/@groove-dev/daemon/src/providers/base.js +1 -0
  18. package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +14 -0
  19. package/node_modules/@groove-dev/daemon/src/providers/codex.js +124 -28
  20. package/node_modules/@groove-dev/daemon/src/providers/gemini.js +104 -17
  21. package/node_modules/@groove-dev/daemon/src/providers/index.js +17 -0
  22. package/node_modules/@groove-dev/daemon/src/registry.js +10 -1
  23. package/node_modules/@groove-dev/daemon/src/rotator.js +93 -30
  24. package/node_modules/@groove-dev/daemon/src/skills.js +33 -3
  25. package/node_modules/@groove-dev/daemon/src/terminal-pty.js +9 -1
  26. package/node_modules/@groove-dev/daemon/src/tool-executor.js +11 -5
  27. package/node_modules/@groove-dev/daemon/src/toys.js +69 -0
  28. package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +24 -5
  29. package/node_modules/@groove-dev/daemon/templates/toys-catalog.json +242 -0
  30. package/node_modules/@groove-dev/daemon/test/classifier.test.js +98 -0
  31. package/node_modules/@groove-dev/daemon/test/introducer.test.js +72 -1
  32. package/node_modules/@groove-dev/daemon/test/journalist.test.js +117 -0
  33. package/node_modules/@groove-dev/daemon/test/memory.test.js +37 -1
  34. package/node_modules/@groove-dev/daemon/test/rotator.test.js +183 -4
  35. package/node_modules/@groove-dev/gui/dist/assets/index-Bg6_D2xK.css +1 -0
  36. package/node_modules/@groove-dev/gui/dist/assets/index-D3rvwTHD.js +8607 -0
  37. package/node_modules/@groove-dev/gui/dist/index.html +3 -2
  38. package/node_modules/@groove-dev/gui/index.html +1 -0
  39. package/node_modules/@groove-dev/gui/src/app.css +7 -0
  40. package/node_modules/@groove-dev/gui/src/app.jsx +37 -10
  41. package/node_modules/@groove-dev/gui/src/components/agents/agent-chat.jsx +21 -31
  42. package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +11 -6
  43. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +2 -2
  44. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +42 -1
  45. package/node_modules/@groove-dev/gui/src/components/editor/breadcrumbs.jsx +30 -0
  46. package/node_modules/@groove-dev/gui/src/components/editor/code-editor.jsx +33 -2
  47. package/node_modules/@groove-dev/gui/src/components/editor/editor-status-bar.jsx +26 -0
  48. package/node_modules/@groove-dev/gui/src/components/editor/editor-tabs.jsx +113 -34
  49. package/node_modules/@groove-dev/gui/src/components/editor/goto-line.jsx +35 -0
  50. package/node_modules/@groove-dev/gui/src/components/editor/terminal.jsx +12 -6
  51. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +15 -3
  52. package/node_modules/@groove-dev/gui/src/components/layout/app-shell.jsx +0 -1
  53. package/node_modules/@groove-dev/gui/src/components/layout/breadcrumb-bar.jsx +165 -47
  54. package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +6 -2
  55. package/node_modules/@groove-dev/gui/src/components/layout/status-bar.jsx +11 -1
  56. package/node_modules/@groove-dev/gui/src/components/layout/terminal-panel.jsx +10 -9
  57. package/node_modules/@groove-dev/gui/src/components/marketplace/repo-import.jsx +9 -1
  58. package/node_modules/@groove-dev/gui/src/components/onboarding/provider-card.jsx +134 -0
  59. package/node_modules/@groove-dev/gui/src/components/onboarding/setup-wizard.jsx +819 -0
  60. package/node_modules/@groove-dev/gui/src/components/pro/pro-gate.jsx +12 -5
  61. package/node_modules/@groove-dev/gui/src/components/pro/upgrade-card.jsx +15 -8
  62. package/node_modules/@groove-dev/gui/src/components/pro/upgrade-modal.jsx +151 -0
  63. package/node_modules/@groove-dev/gui/src/components/settings/federation-activity.jsx +98 -0
  64. package/node_modules/@groove-dev/gui/src/components/settings/federation-panel.jsx +290 -0
  65. package/node_modules/@groove-dev/gui/src/components/settings/federation-peers.jsx +126 -0
  66. package/node_modules/@groove-dev/gui/src/components/settings/federation-wizard.jsx +293 -0
  67. package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +110 -67
  68. package/node_modules/@groove-dev/gui/src/components/settings/remote-server-card.jsx +3 -3
  69. package/node_modules/@groove-dev/gui/src/components/settings/server-detail.jsx +310 -0
  70. package/node_modules/@groove-dev/gui/src/components/settings/server-dialog.jsx +4 -1
  71. package/node_modules/@groove-dev/gui/src/components/settings/server-list.jsx +59 -0
  72. package/node_modules/@groove-dev/gui/src/components/settings/ssh-wizard.jsx +549 -0
  73. package/node_modules/@groove-dev/gui/src/components/toys/toy-card.jsx +78 -0
  74. package/node_modules/@groove-dev/gui/src/components/toys/toy-creator.jsx +144 -0
  75. package/node_modules/@groove-dev/gui/src/components/toys/toy-launcher.jsx +187 -0
  76. package/node_modules/@groove-dev/gui/src/components/ui/toast.jsx +2 -2
  77. package/node_modules/@groove-dev/gui/src/lib/electron.js +15 -0
  78. package/node_modules/@groove-dev/gui/src/lib/format.js +1 -0
  79. package/node_modules/@groove-dev/gui/src/stores/groove.js +388 -63
  80. package/node_modules/@groove-dev/gui/src/views/agents.jsx +148 -42
  81. package/node_modules/@groove-dev/gui/src/views/editor.jsx +92 -2
  82. package/node_modules/@groove-dev/gui/src/views/federation.jsx +37 -0
  83. package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +2 -42
  84. package/node_modules/@groove-dev/gui/src/views/settings.jsx +35 -134
  85. package/node_modules/@groove-dev/gui/src/views/subscription-panel.jsx +327 -0
  86. package/node_modules/@groove-dev/gui/src/views/teams.jsx +3 -3
  87. package/node_modules/@groove-dev/gui/src/views/toys.jsx +162 -0
  88. package/package.json +1 -1
  89. package/packages/daemon/src/api.js +586 -67
  90. package/packages/daemon/src/classifier.js +24 -0
  91. package/packages/daemon/src/credentials.js +12 -2
  92. package/packages/daemon/src/federation/ambassador.js +204 -0
  93. package/packages/daemon/src/federation/connection.js +359 -0
  94. package/packages/daemon/src/federation/contracts.js +112 -0
  95. package/packages/daemon/src/federation/whitelist.js +190 -0
  96. package/packages/daemon/src/federation.js +166 -7
  97. package/packages/daemon/src/index.js +172 -19
  98. package/packages/daemon/src/introducer.js +52 -7
  99. package/packages/daemon/src/journalist.js +46 -1
  100. package/packages/daemon/src/memory.js +36 -16
  101. package/packages/daemon/src/process.js +140 -23
  102. package/packages/daemon/src/providers/base.js +1 -0
  103. package/packages/daemon/src/providers/claude-code.js +14 -0
  104. package/packages/daemon/src/providers/codex.js +124 -28
  105. package/packages/daemon/src/providers/gemini.js +104 -17
  106. package/packages/daemon/src/providers/index.js +17 -0
  107. package/packages/daemon/src/registry.js +10 -1
  108. package/packages/daemon/src/rotator.js +93 -30
  109. package/packages/daemon/src/skills.js +33 -3
  110. package/packages/daemon/src/terminal-pty.js +9 -1
  111. package/packages/daemon/src/tool-executor.js +11 -5
  112. package/packages/daemon/src/toys.js +69 -0
  113. package/packages/daemon/src/tunnel-manager.js +24 -5
  114. package/packages/daemon/templates/toys-catalog.json +242 -0
  115. package/packages/gui/dist/assets/index-Bg6_D2xK.css +1 -0
  116. package/packages/gui/dist/assets/index-D3rvwTHD.js +8607 -0
  117. package/packages/gui/dist/index.html +3 -2
  118. package/packages/gui/index.html +1 -0
  119. package/packages/gui/src/app.css +7 -0
  120. package/packages/gui/src/app.jsx +37 -10
  121. package/packages/gui/src/components/agents/agent-chat.jsx +21 -31
  122. package/packages/gui/src/components/agents/agent-config.jsx +11 -6
  123. package/packages/gui/src/components/agents/agent-feed.jsx +2 -2
  124. package/packages/gui/src/components/agents/spawn-wizard.jsx +42 -1
  125. package/packages/gui/src/components/editor/breadcrumbs.jsx +30 -0
  126. package/packages/gui/src/components/editor/code-editor.jsx +33 -2
  127. package/packages/gui/src/components/editor/editor-status-bar.jsx +26 -0
  128. package/packages/gui/src/components/editor/editor-tabs.jsx +113 -34
  129. package/packages/gui/src/components/editor/goto-line.jsx +35 -0
  130. package/packages/gui/src/components/editor/terminal.jsx +12 -6
  131. package/packages/gui/src/components/layout/activity-bar.jsx +15 -3
  132. package/packages/gui/src/components/layout/app-shell.jsx +0 -1
  133. package/packages/gui/src/components/layout/breadcrumb-bar.jsx +165 -47
  134. package/packages/gui/src/components/layout/command-palette.jsx +6 -2
  135. package/packages/gui/src/components/layout/status-bar.jsx +11 -1
  136. package/packages/gui/src/components/layout/terminal-panel.jsx +10 -9
  137. package/packages/gui/src/components/marketplace/repo-import.jsx +9 -1
  138. package/packages/gui/src/components/onboarding/provider-card.jsx +134 -0
  139. package/packages/gui/src/components/onboarding/setup-wizard.jsx +819 -0
  140. package/packages/gui/src/components/pro/pro-gate.jsx +12 -5
  141. package/packages/gui/src/components/pro/upgrade-card.jsx +15 -8
  142. package/packages/gui/src/components/pro/upgrade-modal.jsx +151 -0
  143. package/packages/gui/src/components/settings/federation-activity.jsx +98 -0
  144. package/packages/gui/src/components/settings/federation-panel.jsx +290 -0
  145. package/packages/gui/src/components/settings/federation-peers.jsx +126 -0
  146. package/packages/gui/src/components/settings/federation-wizard.jsx +293 -0
  147. package/packages/gui/src/components/settings/quick-connect.jsx +110 -67
  148. package/packages/gui/src/components/settings/remote-server-card.jsx +3 -3
  149. package/packages/gui/src/components/settings/server-detail.jsx +310 -0
  150. package/packages/gui/src/components/settings/server-dialog.jsx +4 -1
  151. package/packages/gui/src/components/settings/server-list.jsx +59 -0
  152. package/packages/gui/src/components/settings/ssh-wizard.jsx +549 -0
  153. package/packages/gui/src/components/toys/toy-card.jsx +78 -0
  154. package/packages/gui/src/components/toys/toy-creator.jsx +144 -0
  155. package/packages/gui/src/components/toys/toy-launcher.jsx +187 -0
  156. package/packages/gui/src/components/ui/toast.jsx +2 -2
  157. package/packages/gui/src/lib/electron.js +15 -0
  158. package/packages/gui/src/lib/format.js +1 -0
  159. package/packages/gui/src/stores/groove.js +388 -63
  160. package/packages/gui/src/views/agents.jsx +148 -42
  161. package/packages/gui/src/views/editor.jsx +92 -2
  162. package/packages/gui/src/views/federation.jsx +37 -0
  163. package/packages/gui/src/views/marketplace.jsx +2 -42
  164. package/packages/gui/src/views/settings.jsx +35 -134
  165. package/packages/gui/src/views/subscription-panel.jsx +327 -0
  166. package/packages/gui/src/views/teams.jsx +3 -3
  167. package/packages/gui/src/views/toys.jsx +162 -0
  168. package/plans/chat-persistence-refactor.md +154 -0
  169. package/node_modules/@groove-dev/gui/dist/assets/index-BE6lYcd7.css +0 -1
  170. package/node_modules/@groove-dev/gui/dist/assets/index-zdzOLAZM.js +0 -677
  171. package/packages/gui/dist/assets/index-BE6lYcd7.css +0 -1
  172. package/packages/gui/dist/assets/index-zdzOLAZM.js +0 -677
@@ -38,6 +38,7 @@ export class TaskClassifier {
38
38
  constructor() {
39
39
  this.windowSize = 200; // Large enough for quality signal extraction across tool calls
40
40
  this.agentWindows = {}; // for degradation detection and adaptive scoring
41
+ this._lastBroadcast = {};
41
42
  }
42
43
 
43
44
  addEvent(agentId, event) {
@@ -148,7 +149,30 @@ export class TaskClassifier {
148
149
  return { model: fallback, tier, reason: `No ${tier} model available, using ${fallback?.tier || 'default'}` };
149
150
  }
150
151
 
152
+ // Returns agents with significant classification changes since last poll.
153
+ // Called by the daemon's 30s broadcast timer — keeps classification
154
+ // completely decoupled from the stdout hot path.
155
+ getUpdates() {
156
+ const updates = [];
157
+ for (const [agentId, events] of Object.entries(this.agentWindows)) {
158
+ if (events.length < 40) continue; // Not enough data
159
+
160
+ const tier = this.classify(agentId);
161
+ const eventCount = events.length;
162
+ const lastBroadcast = this._lastBroadcast[agentId];
163
+
164
+ // Only report if classification changed or this is the first report
165
+ if (!lastBroadcast || lastBroadcast.tier !== tier ||
166
+ Math.abs(lastBroadcast.eventCount - eventCount) >= 20) {
167
+ updates.push({ agentId, tier, eventCount });
168
+ this._lastBroadcast[agentId] = { tier, eventCount };
169
+ }
170
+ }
171
+ return updates;
172
+ }
173
+
151
174
  clearAgent(agentId) {
152
175
  delete this.agentWindows[agentId];
176
+ delete this._lastBroadcast[agentId];
153
177
  }
154
178
  }
@@ -11,6 +11,7 @@ const SALT_PREFIX = 'groove-v1';
11
11
 
12
12
  export class CredentialStore {
13
13
  constructor(grooveDir) {
14
+ this.grooveDir = grooveDir;
14
15
  this.path = resolve(grooveDir, 'credentials.json');
15
16
  this.data = {};
16
17
  this.encryptionKey = this.deriveKey();
@@ -21,7 +22,15 @@ export class CredentialStore {
21
22
  // Not unbreakable, but much better than base64 — credentials file is
22
23
  // meaningless if copied to another machine or read without this process.
23
24
  deriveKey() {
24
- const machineId = `${SALT_PREFIX}:${homedir()}:${hostname()}`;
25
+ const seedPath = resolve(this.grooveDir, '.credential-seed');
26
+ let seed = '';
27
+ try {
28
+ seed = readFileSync(seedPath, 'utf8');
29
+ } catch {
30
+ seed = randomBytes(32).toString('hex');
31
+ writeFileSync(seedPath, seed, { mode: 0o600 });
32
+ }
33
+ const machineId = `${SALT_PREFIX}:${seed}:${homedir()}:${hostname()}`;
25
34
  return scryptSync(machineId, 'groove-credential-salt', 32);
26
35
  }
27
36
 
@@ -101,7 +110,8 @@ export class CredentialStore {
101
110
  decrypt(encoded) {
102
111
  const parts = encoded.split(':');
103
112
  if (parts.length !== 3) {
104
- // Legacy base64 format — migrate on read
113
+ // Legacy base64 format — migration path: decoded plaintext will be
114
+ // re-encrypted with AES-256-GCM on the next setKey call
105
115
  return Buffer.from(encoded, 'base64').toString('utf8');
106
116
  }
107
117
  const [ivHex, authTagHex, ciphertext] = parts;
@@ -0,0 +1,204 @@
1
+ // GROOVE — Federation Ambassador Agent Coordination
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { EventEmitter } from 'events';
5
+ import { validateContract } from './contracts.js';
6
+
7
+ const MAX_POUCH_LOG = 200;
8
+
9
+ export class AmbassadorManager extends EventEmitter {
10
+ constructor(federation) {
11
+ super();
12
+ this.federation = federation;
13
+ this.daemon = federation.daemon;
14
+ this.ambassadors = new Map(); // peerId -> agentId
15
+ this.taskQueue = new Map(); // peerId -> [{ contract, from, receivedAt }]
16
+ this.pouchLog = []; // recent pouch messages for GUI
17
+ }
18
+
19
+ getAmbassadorForPeer(peerId) {
20
+ const agentId = this.ambassadors.get(peerId);
21
+ if (!agentId) return null;
22
+ const agent = this.daemon.registry.get(agentId);
23
+ if (!agent || agent.status === 'killed' || agent.status === 'completed') {
24
+ this.ambassadors.delete(peerId);
25
+ return null;
26
+ }
27
+ return agent;
28
+ }
29
+
30
+ hasAmbassadorForPeer(peerId) {
31
+ return !!this.getAmbassadorForPeer(peerId);
32
+ }
33
+
34
+ registerAmbassador(peerId, agentId) {
35
+ if (this.hasAmbassadorForPeer(peerId)) {
36
+ throw new Error(`Ambassador already exists for peer ${peerId}`);
37
+ }
38
+ this.ambassadors.set(peerId, agentId);
39
+
40
+ const queued = this.taskQueue.get(peerId) || [];
41
+ if (queued.length > 0) {
42
+ for (const item of queued) {
43
+ this._deliverToAmbassador(agentId, item);
44
+ }
45
+ this.taskQueue.delete(peerId);
46
+ }
47
+
48
+ this.daemon.audit.log('federation.ambassador.register', { peerId, agentId });
49
+ this.emit('registered', { peerId, agentId });
50
+ }
51
+
52
+ unregisterAmbassador(peerId) {
53
+ const agentId = this.ambassadors.get(peerId);
54
+ this.ambassadors.delete(peerId);
55
+ if (agentId) {
56
+ this.daemon.audit.log('federation.ambassador.unregister', { peerId, agentId });
57
+ this.emit('unregistered', { peerId, agentId });
58
+ }
59
+ }
60
+
61
+ receivePouch(senderId, contract, signature) {
62
+ const verified = this.federation.receiveContract(senderId, contract, signature);
63
+ if (!verified.verified) {
64
+ throw new Error('Pouch signature verification failed');
65
+ }
66
+
67
+ const validation = validateContract(verified.contract);
68
+ if (!validation.valid) {
69
+ throw new Error(`Invalid pouch contract: ${validation.error}`);
70
+ }
71
+
72
+ this._logPouch('inbound', senderId, verified.contract);
73
+
74
+ const peerEntry = this._findPeerBySenderId(senderId);
75
+ const peerId = peerEntry?.id || senderId;
76
+
77
+ const ambassador = this.getAmbassadorForPeer(peerId);
78
+ if (ambassador) {
79
+ this._deliverToAmbassador(ambassador.id, {
80
+ contract: verified.contract,
81
+ from: senderId,
82
+ receivedAt: new Date().toISOString(),
83
+ });
84
+ } else {
85
+ if (!this.taskQueue.has(peerId)) {
86
+ this.taskQueue.set(peerId, []);
87
+ }
88
+ this.taskQueue.get(peerId).push({
89
+ contract: verified.contract,
90
+ from: senderId,
91
+ receivedAt: new Date().toISOString(),
92
+ });
93
+ }
94
+
95
+ this.emit('pouch-received', { from: senderId, type: verified.contract.type });
96
+ return { received: true, queued: !ambassador };
97
+ }
98
+
99
+ async sendPouch(peerId, contract) {
100
+ const validation = validateContract(contract);
101
+ if (!validation.valid) {
102
+ throw new Error(`Invalid outbound contract: ${validation.error}`);
103
+ }
104
+
105
+ const envelope = this.federation.sign({
106
+ ...contract,
107
+ from: this.federation._daemonId(),
108
+ });
109
+
110
+ const sent = this.federation.connections.sendTo(peerId, {
111
+ type: 'pouch',
112
+ senderId: this.federation._daemonId(),
113
+ payload: envelope.payload,
114
+ signature: envelope.signature,
115
+ });
116
+
117
+ if (!sent) {
118
+ const peer = this.federation.peers.get(peerId);
119
+ if (!peer) throw new Error(`Unknown peer: ${peerId}`);
120
+
121
+ const url = `http://${peer.host}:${peer.port}/api/federation/pouch`;
122
+ const res = await fetch(url, {
123
+ method: 'POST',
124
+ headers: { 'Content-Type': 'application/json' },
125
+ body: JSON.stringify({
126
+ senderId: this.federation._daemonId(),
127
+ payload: envelope.payload,
128
+ signature: envelope.signature,
129
+ }),
130
+ });
131
+
132
+ if (!res.ok) {
133
+ const err = await res.json().catch(() => ({}));
134
+ throw new Error(err.error || `Pouch delivery failed: HTTP ${res.status}`);
135
+ }
136
+ }
137
+
138
+ this._logPouch('outbound', peerId, contract);
139
+ this.daemon.audit.log('federation.pouch.send', { peerId, type: contract.type });
140
+ this.emit('pouch-sent', { to: peerId, type: contract.type });
141
+ return { sent: true };
142
+ }
143
+
144
+ _deliverToAmbassador(agentId, item) {
145
+ this.daemon.broadcast({
146
+ type: 'federation:pouch',
147
+ data: {
148
+ agentId,
149
+ from: item.from,
150
+ contract: item.contract,
151
+ receivedAt: item.receivedAt,
152
+ },
153
+ });
154
+ this.emit('delivered', { agentId, from: item.from, type: item.contract.type });
155
+ }
156
+
157
+ _findPeerBySenderId(senderId) {
158
+ for (const peer of this.federation.peers.values()) {
159
+ if (peer.id === senderId) return peer;
160
+ }
161
+ return null;
162
+ }
163
+
164
+ _logPouch(direction, peer, contract) {
165
+ this.pouchLog.push({
166
+ direction,
167
+ peer,
168
+ type: contract.type,
169
+ taskId: contract.spec?.taskId || null,
170
+ timestamp: new Date().toISOString(),
171
+ });
172
+ if (this.pouchLog.length > MAX_POUCH_LOG) {
173
+ this.pouchLog = this.pouchLog.slice(-MAX_POUCH_LOG);
174
+ }
175
+ this.daemon.broadcast({
176
+ type: 'federation:pouch-log',
177
+ data: this.pouchLog.slice(-1)[0],
178
+ });
179
+ }
180
+
181
+ getPouchLog(limit = 50) {
182
+ return this.pouchLog.slice(-limit);
183
+ }
184
+
185
+ getStatus() {
186
+ const ambassadors = [];
187
+ for (const [peerId, agentId] of this.ambassadors) {
188
+ const agent = this.daemon.registry.get(agentId);
189
+ ambassadors.push({
190
+ peerId,
191
+ agentId,
192
+ agentStatus: agent?.status || 'unknown',
193
+ queuedTasks: (this.taskQueue.get(peerId) || []).length,
194
+ });
195
+ }
196
+ return { ambassadors, totalQueued: Array.from(this.taskQueue.values()).reduce((s, q) => s + q.length, 0) };
197
+ }
198
+
199
+ destroy() {
200
+ this.ambassadors.clear();
201
+ this.taskQueue.clear();
202
+ this.removeAllListeners();
203
+ }
204
+ }
@@ -0,0 +1,359 @@
1
+ // GROOVE — Federation WebSocket Connection Manager
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { EventEmitter } from 'events';
5
+ import WebSocket from 'ws';
6
+
7
+ const HEARTBEAT_INTERVAL = 30_000;
8
+ const INITIAL_RECONNECT_DELAY = 2_000;
9
+ const MAX_RECONNECT_DELAY = 60_000;
10
+ const KNOCK_TIMEOUT = 10_000;
11
+
12
+ const STATES = {
13
+ DISCONNECTED: 'disconnected',
14
+ WHITELISTED: 'whitelisted',
15
+ MUTUAL: 'mutual',
16
+ KNOCKING: 'knocking',
17
+ CONNECTED: 'connected',
18
+ };
19
+
20
+ class PeerConnection extends EventEmitter {
21
+ constructor(manager, ip, port, remoteDaemonId) {
22
+ super();
23
+ this.manager = manager;
24
+ this.ip = ip;
25
+ this.port = port;
26
+ this.remoteDaemonId = remoteDaemonId;
27
+ this.state = STATES.DISCONNECTED;
28
+ this.ws = null;
29
+ this._heartbeatTimer = null;
30
+ this._reconnectTimer = null;
31
+ this._reconnectDelay = INITIAL_RECONNECT_DELAY;
32
+ this._knockTimeout = null;
33
+ this._destroyed = false;
34
+ }
35
+
36
+ get peerId() {
37
+ return this.remoteDaemonId || `${this.ip}:${this.port}`;
38
+ }
39
+
40
+ _setState(newState) {
41
+ const old = this.state;
42
+ if (old === newState) return;
43
+ this.state = newState;
44
+ this.emit('state-change', { ip: this.ip, state: newState, oldState: old, newState, peerId: this.peerId });
45
+ }
46
+
47
+ async initiateKnock() {
48
+ if (this._destroyed) return;
49
+ this._setState(STATES.KNOCKING);
50
+
51
+ const federation = this.manager.federation;
52
+ const challenge = { type: 'knock', daemonId: federation._daemonId() };
53
+ const envelope = federation.sign(challenge);
54
+
55
+ try {
56
+ const controller = new AbortController();
57
+ this._knockTimeout = setTimeout(() => controller.abort(), KNOCK_TIMEOUT);
58
+
59
+ const url = `http://${this.ip}:${this.port}/api/federation/knock`;
60
+ const res = await fetch(url, {
61
+ method: 'POST',
62
+ headers: { 'Content-Type': 'application/json' },
63
+ body: JSON.stringify({
64
+ senderId: federation._daemonId(),
65
+ publicKey: federation.getPublicKeyPem(),
66
+ payload: envelope.payload,
67
+ signature: envelope.signature,
68
+ }),
69
+ signal: controller.signal,
70
+ });
71
+
72
+ clearTimeout(this._knockTimeout);
73
+ this._knockTimeout = null;
74
+
75
+ if (!res.ok) {
76
+ this._setState(STATES.MUTUAL);
77
+ this._scheduleReconnect();
78
+ return;
79
+ }
80
+
81
+ const data = await res.json();
82
+ if (!data.accepted) {
83
+ this._setState(STATES.MUTUAL);
84
+ this._scheduleReconnect();
85
+ return;
86
+ }
87
+
88
+ if (data.peerId) this.remoteDaemonId = data.peerId;
89
+ if (data.publicKey) {
90
+ this._ensurePeerStored(data);
91
+ }
92
+
93
+ this._openWebSocket();
94
+ } catch {
95
+ clearTimeout(this._knockTimeout);
96
+ this._knockTimeout = null;
97
+ this._setState(STATES.MUTUAL);
98
+ this._scheduleReconnect();
99
+ }
100
+ }
101
+
102
+ _ensurePeerStored(data) {
103
+ const federation = this.manager.federation;
104
+ if (data.peerId && data.publicKey && !federation.peers.has(data.peerId)) {
105
+ federation._savePeer({
106
+ id: data.peerId,
107
+ name: data.peerName || data.peerId,
108
+ host: this.ip,
109
+ port: this.port,
110
+ publicKey: data.publicKey,
111
+ pairedAt: new Date().toISOString(),
112
+ });
113
+ }
114
+ }
115
+
116
+ _openWebSocket() {
117
+ if (this._destroyed) return;
118
+
119
+ const federation = this.manager.federation;
120
+ const url = `ws://${this.ip}:${this.port}/ws/federation`;
121
+
122
+ try {
123
+ this.ws = new WebSocket(url, {
124
+ headers: {
125
+ 'X-Groove-DaemonId': federation._daemonId(),
126
+ 'X-Groove-Signature': this._makeAuthHeader(),
127
+ },
128
+ handshakeTimeout: 10_000,
129
+ });
130
+ } catch {
131
+ this._setState(STATES.MUTUAL);
132
+ this._scheduleReconnect();
133
+ return;
134
+ }
135
+
136
+ this.ws.on('open', () => {
137
+ this._reconnectDelay = INITIAL_RECONNECT_DELAY;
138
+ this._setState(STATES.CONNECTED);
139
+ this._startHeartbeat();
140
+ this.emit('connected', { ip: this.ip, peerId: this.peerId });
141
+ });
142
+
143
+ this.ws.on('message', (raw) => {
144
+ try {
145
+ const msg = JSON.parse(raw);
146
+ if (msg.type === 'pong') return;
147
+ this.emit('message', msg);
148
+ } catch { /* ignore malformed */ }
149
+ });
150
+
151
+ this.ws.on('close', () => {
152
+ this._cleanup();
153
+ if (!this._destroyed) {
154
+ this._setState(STATES.MUTUAL);
155
+ this.emit('disconnected', { ip: this.ip, peerId: this.peerId });
156
+ this._scheduleReconnect();
157
+ }
158
+ });
159
+
160
+ this.ws.on('error', () => {
161
+ // close event will fire after error
162
+ });
163
+ }
164
+
165
+ _makeAuthHeader() {
166
+ const federation = this.manager.federation;
167
+ const envelope = federation.sign({ type: 'ws-auth', daemonId: federation._daemonId() });
168
+ return Buffer.from(JSON.stringify(envelope)).toString('base64');
169
+ }
170
+
171
+ send(message) {
172
+ if (this.state !== STATES.CONNECTED || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
173
+ return false;
174
+ }
175
+ try {
176
+ this.ws.send(JSON.stringify(message));
177
+ return true;
178
+ } catch {
179
+ return false;
180
+ }
181
+ }
182
+
183
+ _startHeartbeat() {
184
+ this._stopHeartbeat();
185
+ this._heartbeatTimer = setInterval(() => {
186
+ if (this.ws?.readyState === WebSocket.OPEN) {
187
+ this.ws.send(JSON.stringify({ type: 'ping', ts: Date.now() }));
188
+ }
189
+ }, HEARTBEAT_INTERVAL);
190
+ }
191
+
192
+ _stopHeartbeat() {
193
+ if (this._heartbeatTimer) {
194
+ clearInterval(this._heartbeatTimer);
195
+ this._heartbeatTimer = null;
196
+ }
197
+ }
198
+
199
+ _scheduleReconnect() {
200
+ if (this._destroyed || this._reconnectTimer) return;
201
+ this._reconnectTimer = setTimeout(() => {
202
+ this._reconnectTimer = null;
203
+ if (!this._destroyed && this.state === STATES.MUTUAL) {
204
+ this.initiateKnock();
205
+ }
206
+ }, this._reconnectDelay);
207
+ this._reconnectDelay = Math.min(this._reconnectDelay * 2, MAX_RECONNECT_DELAY);
208
+ }
209
+
210
+ _cleanup() {
211
+ this._stopHeartbeat();
212
+ if (this.ws) {
213
+ try { this.ws.terminate(); } catch { /* */ }
214
+ this.ws = null;
215
+ }
216
+ }
217
+
218
+ destroy() {
219
+ this._destroyed = true;
220
+ clearTimeout(this._reconnectTimer);
221
+ clearTimeout(this._knockTimeout);
222
+ this._reconnectTimer = null;
223
+ this._knockTimeout = null;
224
+ this._cleanup();
225
+ this._setState(STATES.DISCONNECTED);
226
+ this.removeAllListeners();
227
+ }
228
+ }
229
+
230
+ export class ConnectionManager extends EventEmitter {
231
+ constructor(federation) {
232
+ super();
233
+ this.federation = federation;
234
+ this.daemon = federation.daemon;
235
+ this.connections = new Map(); // ip -> PeerConnection
236
+ this.inbound = new Map(); // daemonId -> ws (incoming connections from peers)
237
+ }
238
+
239
+ onMutual(ip, port, remoteDaemonId) {
240
+ if (this.connections.has(ip)) {
241
+ const existing = this.connections.get(ip);
242
+ if (existing.state === STATES.CONNECTED) return;
243
+ if (existing.state === STATES.KNOCKING) return;
244
+ }
245
+
246
+ const conn = this._getOrCreate(ip, port, remoteDaemonId);
247
+ conn.initiateKnock();
248
+ }
249
+
250
+ _getOrCreate(ip, port, remoteDaemonId) {
251
+ if (this.connections.has(ip)) {
252
+ const existing = this.connections.get(ip);
253
+ if (remoteDaemonId) existing.remoteDaemonId = remoteDaemonId;
254
+ return existing;
255
+ }
256
+
257
+ const conn = new PeerConnection(this, ip, port, remoteDaemonId);
258
+
259
+ conn.on('state-change', (info) => {
260
+ this.emit('state-change', info);
261
+ this.daemon.broadcast({ type: 'federation:connection', data: info });
262
+ });
263
+
264
+ conn.on('connected', (info) => {
265
+ this.federation.whitelist.setConnected(ip);
266
+ this.emit('connected', info);
267
+ this.daemon.audit.log('federation.connected', { ip, peerId: info.peerId });
268
+ });
269
+
270
+ conn.on('disconnected', (info) => {
271
+ this.federation.whitelist.setDisconnected(ip);
272
+ this.emit('disconnected', info);
273
+ });
274
+
275
+ conn.on('message', (msg) => {
276
+ this.emit('message', { ip, peerId: conn.peerId, message: msg });
277
+ });
278
+
279
+ this.connections.set(ip, conn);
280
+ return conn;
281
+ }
282
+
283
+ handleInboundConnection(ws, daemonId) {
284
+ this.inbound.set(daemonId, ws);
285
+ this.emit('inbound-connected', { daemonId });
286
+ this.daemon.audit.log('federation.inbound', { daemonId });
287
+
288
+ ws.on('message', (raw) => {
289
+ try {
290
+ const msg = JSON.parse(raw);
291
+ if (msg.type === 'ping') {
292
+ ws.send(JSON.stringify({ type: 'pong', ts: Date.now() }));
293
+ return;
294
+ }
295
+ this.emit('message', { daemonId, message: msg, inbound: true });
296
+ } catch { /* ignore */ }
297
+ });
298
+
299
+ ws.on('close', () => {
300
+ this.inbound.delete(daemonId);
301
+ this.emit('inbound-disconnected', { daemonId });
302
+ });
303
+ }
304
+
305
+ sendTo(peerIdentifier, message) {
306
+ // Try outbound first (by IP)
307
+ for (const [ip, conn] of this.connections) {
308
+ if (ip === peerIdentifier || conn.remoteDaemonId === peerIdentifier || conn.peerId === peerIdentifier) {
309
+ if (conn.send(message)) return true;
310
+ }
311
+ }
312
+ // Try inbound (by daemonId)
313
+ const inboundWs = this.inbound.get(peerIdentifier);
314
+ if (inboundWs?.readyState === WebSocket.OPEN) {
315
+ try {
316
+ inboundWs.send(JSON.stringify(message));
317
+ return true;
318
+ } catch { /* */ }
319
+ }
320
+ return false;
321
+ }
322
+
323
+ getStatus() {
324
+ const connections = [];
325
+ for (const [ip, conn] of this.connections) {
326
+ connections.push({
327
+ ip,
328
+ port: conn.port,
329
+ peerId: conn.peerId,
330
+ remoteDaemonId: conn.remoteDaemonId,
331
+ state: conn.state,
332
+ direction: 'outbound',
333
+ });
334
+ }
335
+ for (const [daemonId] of this.inbound) {
336
+ if (!connections.some(c => c.remoteDaemonId === daemonId)) {
337
+ connections.push({
338
+ peerId: daemonId,
339
+ remoteDaemonId: daemonId,
340
+ state: 'connected',
341
+ direction: 'inbound',
342
+ });
343
+ }
344
+ }
345
+ return connections;
346
+ }
347
+
348
+ destroy() {
349
+ for (const conn of this.connections.values()) {
350
+ conn.destroy();
351
+ }
352
+ this.connections.clear();
353
+ for (const ws of this.inbound.values()) {
354
+ try { ws.terminate(); } catch { /* */ }
355
+ }
356
+ this.inbound.clear();
357
+ this.removeAllListeners();
358
+ }
359
+ }