groove-dev 0.27.197 → 0.27.199

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 (76) hide show
  1. package/axom-integration/Screenshot_2026-07-25_at_5.25.30_PM.png +0 -0
  2. package/axom-integration/Screenshot_2026-07-25_at_5.33.26_PM.png +0 -0
  3. package/axom-integration/Screenshot_2026-07-25_at_6.11.53_PM.png +0 -0
  4. package/axom-integration/Screenshot_2026-07-25_at_6.44.56_PM.png +0 -0
  5. package/axom-integration/Screenshot_2026-07-25_at_6.45.35_PM.png +0 -0
  6. package/node_modules/@groove-dev/cli/package.json +1 -1
  7. package/node_modules/@groove-dev/daemon/package.json +1 -1
  8. package/node_modules/@groove-dev/daemon/src/api.js +2 -0
  9. package/node_modules/@groove-dev/daemon/src/axom-connector.js +368 -0
  10. package/node_modules/@groove-dev/daemon/src/axom-install.js +162 -0
  11. package/node_modules/@groove-dev/daemon/src/axom-remote.js +230 -0
  12. package/node_modules/@groove-dev/daemon/src/axom-server.js +249 -0
  13. package/node_modules/@groove-dev/daemon/src/federation.js +6 -0
  14. package/node_modules/@groove-dev/daemon/src/index.js +14 -0
  15. package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +8 -1
  16. package/node_modules/@groove-dev/daemon/src/innerchat-relay.js +89 -0
  17. package/node_modules/@groove-dev/daemon/src/innerchat.js +261 -1
  18. package/node_modules/@groove-dev/daemon/src/introducer.js +1 -1
  19. package/node_modules/@groove-dev/daemon/src/network-guard.js +56 -0
  20. package/node_modules/@groove-dev/daemon/src/process.js +1 -1
  21. package/node_modules/@groove-dev/daemon/src/providers/axom.js +66 -0
  22. package/node_modules/@groove-dev/daemon/src/providers/index.js +2 -0
  23. package/node_modules/@groove-dev/daemon/src/routes/axom.js +267 -0
  24. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +334 -20
  25. package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +455 -0
  26. package/node_modules/@groove-dev/daemon/test/axom-remote.test.js +115 -0
  27. package/node_modules/@groove-dev/daemon/test/axom-server.test.js +228 -0
  28. package/node_modules/@groove-dev/daemon/test/innerchat-relay.test.js +251 -0
  29. package/node_modules/@groove-dev/gui/dist/assets/index-BbA-X4CE.js +1084 -0
  30. package/node_modules/@groove-dev/gui/dist/assets/index-BbE3qX83.css +1 -0
  31. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  32. package/node_modules/@groove-dev/gui/package.json +1 -1
  33. package/node_modules/@groove-dev/gui/src/App.jsx +2 -0
  34. package/node_modules/@groove-dev/gui/src/app.css +53 -0
  35. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +3 -1
  36. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -1
  37. package/node_modules/@groove-dev/gui/src/stores/groove.js +22 -0
  38. package/node_modules/@groove-dev/gui/src/stores/slices/axom-slice.js +279 -0
  39. package/node_modules/@groove-dev/gui/src/views/axom.jsx +1897 -0
  40. package/node_modules/@groove-dev/gui/src/views/settings.jsx +232 -4
  41. package/package.json +2 -2
  42. package/packages/cli/package.json +1 -1
  43. package/packages/daemon/package.json +1 -1
  44. package/packages/daemon/src/api.js +2 -0
  45. package/packages/daemon/src/axom-connector.js +368 -0
  46. package/packages/daemon/src/axom-install.js +162 -0
  47. package/packages/daemon/src/axom-remote.js +230 -0
  48. package/packages/daemon/src/axom-server.js +249 -0
  49. package/packages/daemon/src/federation.js +6 -0
  50. package/packages/daemon/src/index.js +14 -0
  51. package/packages/daemon/src/innerchat-docs.js +8 -1
  52. package/packages/daemon/src/innerchat-relay.js +89 -0
  53. package/packages/daemon/src/innerchat.js +261 -1
  54. package/packages/daemon/src/introducer.js +1 -1
  55. package/packages/daemon/src/network-guard.js +56 -0
  56. package/packages/daemon/src/process.js +1 -1
  57. package/packages/daemon/src/providers/axom.js +66 -0
  58. package/packages/daemon/src/providers/index.js +2 -0
  59. package/packages/daemon/src/routes/axom.js +267 -0
  60. package/packages/daemon/src/routes/innerchat.js +334 -20
  61. package/packages/gui/dist/assets/index-BbA-X4CE.js +1084 -0
  62. package/packages/gui/dist/assets/index-BbE3qX83.css +1 -0
  63. package/packages/gui/dist/index.html +2 -2
  64. package/packages/gui/package.json +1 -1
  65. package/packages/gui/src/App.jsx +2 -0
  66. package/packages/gui/src/app.css +53 -0
  67. package/packages/gui/src/components/agents/agent-feed.jsx +3 -1
  68. package/packages/gui/src/components/layout/activity-bar.jsx +2 -1
  69. package/packages/gui/src/stores/groove.js +22 -0
  70. package/packages/gui/src/stores/slices/axom-slice.js +279 -0
  71. package/packages/gui/src/views/axom.jsx +1897 -0
  72. package/packages/gui/src/views/settings.jsx +232 -4
  73. package/node_modules/@groove-dev/gui/dist/assets/index-Da2nbd6M.js +0 -1043
  74. package/node_modules/@groove-dev/gui/dist/assets/index-zmrIbwNm.css +0 -1
  75. package/packages/gui/dist/assets/index-Da2nbd6M.js +0 -1043
  76. package/packages/gui/dist/assets/index-zmrIbwNm.css +0 -1
@@ -0,0 +1,267 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ import os from 'os';
4
+ import { saveConfig } from '../firstrun.js';
5
+ import { validateEndpoint, AXOM_DEFAULT_PORT } from '../axom-connector.js';
6
+ import { hardwareReport } from '../axom-server.js';
7
+ import { validateRemote } from '../axom-remote.js';
8
+
9
+ // Interrupt text is capped runtime-side at 2000 chars (contract §2, with a
10
+ // `truncated` flag in the response); we allow headroom and let the runtime be
11
+ // the authority on truncation — its flag is the honest signal, not ours.
12
+ const MAX_INTERRUPT_CHARS = 8000;
13
+
14
+ export function registerAxomRoutes(app, daemon) {
15
+ app.get('/api/axom/status', (req, res) => {
16
+ res.json(daemon.axom.status());
17
+ });
18
+
19
+ app.get('/api/axom/sessions', (req, res) => {
20
+ const sessions = daemon.axom.status().endpoints.flatMap((ep) =>
21
+ ep.sessions.map((s) => ({ endpoint: ep.name, ...s })));
22
+ res.json(sessions);
23
+ });
24
+
25
+ // In-daemon ring backfill for the GUI (initial load / scrubber). `since` is
26
+ // an envelope id ("ev-000123"); events after it are returned.
27
+ app.get('/api/axom/sessions/:id/events', (req, res) => {
28
+ try {
29
+ const sinceSeq = /^ev-\d+$/.test(req.query.since || '')
30
+ ? parseInt(req.query.since.slice(3), 10) : 0;
31
+ res.json(daemon.axom.events(req.query.endpoint, req.params.id, sinceSeq));
32
+ } catch (err) {
33
+ res.status(404).json({ error: err.message });
34
+ }
35
+ });
36
+
37
+ // §12: message starts a turn. Contract statuses (202/409/413) mirror
38
+ // through verbatim — the GUI reads them, we don't reinterpret.
39
+ app.post('/api/axom/sessions/:id/message', async (req, res) => {
40
+ try {
41
+ const { text, endpoint, clientRef } = req.body || {};
42
+ if (!text || typeof text !== 'string' || !text.trim()) {
43
+ return res.status(400).json({ error: 'text is required' });
44
+ }
45
+ if (clientRef !== undefined && (typeof clientRef !== 'string' || clientRef.length > 64)) {
46
+ return res.status(400).json({ error: 'clientRef must be a string of at most 64 chars' });
47
+ }
48
+ const result = await daemon.axom.message(endpoint, req.params.id, text, clientRef);
49
+ daemon.audit.log('axom.message', { session: req.params.id, chars: text.length, status: result.status });
50
+ res.status(result.status).json(result.body);
51
+ } catch (err) {
52
+ res.status(502).json({ error: err.message });
53
+ }
54
+ });
55
+
56
+ app.post('/api/axom/sessions/:id/interrupt', async (req, res) => {
57
+ try {
58
+ const { text, endpoint } = req.body || {};
59
+ if (!text || typeof text !== 'string' || !text.trim()) {
60
+ return res.status(400).json({ error: 'text is required' });
61
+ }
62
+ if (text.length > MAX_INTERRUPT_CHARS) {
63
+ return res.status(400).json({ error: `text exceeds ${MAX_INTERRUPT_CHARS} chars` });
64
+ }
65
+ const result = await daemon.axom.interrupt(endpoint, req.params.id, text);
66
+ daemon.audit.log('axom.interrupt', { session: req.params.id, chars: text.length });
67
+ res.json(result); // verbatim {id, truncated} from the runtime
68
+ } catch (err) {
69
+ res.status(502).json({ error: err.message });
70
+ }
71
+ });
72
+
73
+ app.post('/api/axom/sessions/:id/stop', async (req, res) => {
74
+ try {
75
+ const result = await daemon.axom.stop(req.body?.endpoint, req.params.id);
76
+ daemon.audit.log('axom.stop', { session: req.params.id });
77
+ res.json(result);
78
+ } catch (err) {
79
+ res.status(502).json({ error: err.message });
80
+ }
81
+ });
82
+
83
+ // Machine readiness for local inference — powers the welcome page's
84
+ // "can this device run Axom?" verdict.
85
+ app.get('/api/axom/hardware', (req, res) => {
86
+ res.json(hardwareReport(daemon.grooveDir));
87
+ });
88
+
89
+ // This GROOVE's own Axom endpoint — what another instance would paste.
90
+ // serve binds 127.0.0.1 (§6), so cross-machine reach is the user's own
91
+ // channel; we hand them the exact tunnel one-liner rather than pretending
92
+ // the URL travels on its own.
93
+ app.get('/api/axom/my-endpoint', (req, res) => {
94
+ const running = daemon.axomServer.list().find((i) => i.status === 'running');
95
+ const port = running?.port || AXOM_DEFAULT_PORT;
96
+ const host = os.hostname();
97
+ const user = os.userInfo().username;
98
+ res.json({
99
+ url: `http://127.0.0.1:${port}`,
100
+ port,
101
+ host,
102
+ running: !!running,
103
+ instanceId: running?.id || null,
104
+ tunnelCommand: `ssh -N -L ${port}:localhost:${port} ${user}@${host}`,
105
+ });
106
+ });
107
+
108
+ // §14: shut down the runtime itself. Works for any endpoint that supports
109
+ // the verb — including remote ones, which GROOVE could never reach with a
110
+ // signal. A 404 means the runtime predates §14; the GUI must say so rather
111
+ // than claim the runtime is gone.
112
+ app.post('/api/axom/shutdown', async (req, res) => {
113
+ try {
114
+ const result = await daemon.axom.shutdown(req.body?.endpoint, { force: !!req.body?.force });
115
+ daemon.audit.log('axom.shutdown', { endpoint: req.body?.endpoint || null, status: result.status });
116
+ res.status(result.status === 404 ? 501 : result.status)
117
+ .json(result.status === 404
118
+ ? { error: 'this runtime does not support remote shutdown (predates contract §14)' }
119
+ : result.body);
120
+ } catch (err) {
121
+ res.status(502).json({ error: err.message });
122
+ }
123
+ });
124
+
125
+ // ── Remote runtime control over SSH (manual only, never automatic) ──────
126
+
127
+ app.get('/api/axom/remote', async (req, res) => {
128
+ res.json(await daemon.axomRemote.status());
129
+ });
130
+
131
+ app.patch('/api/axom/remote', (req, res) => {
132
+ const remote = req.body || {};
133
+ if (remote.clear === true) {
134
+ delete daemon.config.axom?.remote;
135
+ saveConfig(daemon.grooveDir, daemon.config);
136
+ return res.json({ configured: false });
137
+ }
138
+ const problem = validateRemote(remote);
139
+ if (problem) return res.status(400).json({ error: problem });
140
+ const { host, user, sshPort, port, command, logPath } = remote;
141
+ daemon.config.axom = {
142
+ ...(daemon.config.axom || {}),
143
+ remote: { host, user, sshPort, port, command, logPath },
144
+ };
145
+ saveConfig(daemon.grooveDir, daemon.config);
146
+ daemon.audit.log('axom.remote.config', { host, user });
147
+ res.json(daemon.config.axom.remote);
148
+ });
149
+
150
+ // Reachability only — opens/heals the port-forward. Never starts a runtime.
151
+ app.post('/api/axom/remote/tunnel', async (req, res) => {
152
+ try {
153
+ res.json(await daemon.axomRemote.ensureTunnel());
154
+ } catch (err) {
155
+ res.status(502).json({ error: err.message });
156
+ }
157
+ });
158
+
159
+ app.post('/api/axom/remote/start', async (req, res) => {
160
+ try {
161
+ res.json(await daemon.axomRemote.start());
162
+ } catch (err) {
163
+ res.status(502).json({ error: err.message });
164
+ }
165
+ });
166
+
167
+ app.post('/api/axom/remote/stop', async (req, res) => {
168
+ try {
169
+ res.json(await daemon.axomRemote.stop({ force: !!req.body?.force }));
170
+ } catch (err) {
171
+ res.status(502).json({ error: err.message });
172
+ }
173
+ });
174
+
175
+ // ── Managed local instances (contract §11) ──────────────────────────────
176
+
177
+ app.get('/api/axom/instances', (req, res) => {
178
+ res.json(daemon.axomServer.list());
179
+ });
180
+
181
+ app.post('/api/axom/instances', async (req, res) => {
182
+ try {
183
+ const instance = await daemon.axomServer.start(req.body?.id || 'default');
184
+ res.json(instance);
185
+ } catch (err) {
186
+ res.status(500).json({ error: err.message });
187
+ }
188
+ });
189
+
190
+ app.delete('/api/axom/instances/:id', async (req, res) => {
191
+ try {
192
+ await daemon.axomServer.stop(req.params.id);
193
+ res.json({ ok: true });
194
+ } catch (err) {
195
+ res.status(400).json({ error: err.message });
196
+ }
197
+ });
198
+
199
+ // ── Manifest-driven install (contract §11) ──────────────────────────────
200
+
201
+ app.get('/api/axom/install', (req, res) => {
202
+ res.json(daemon.axomInstaller.getStatus());
203
+ });
204
+
205
+ app.post('/api/axom/install', (req, res) => {
206
+ // Long-running — kick off and let the axom:install:progress broadcasts
207
+ // carry the story; the GUI polls GET /api/axom/install as backstop.
208
+ daemon.axomInstaller.install(req.body?.manifestUrl).catch(() => { /* status carries the error */ });
209
+ res.json({ started: true });
210
+ });
211
+
212
+ app.get('/api/axom/config', (req, res) => {
213
+ res.json({ endpoints: daemon.config.axom?.endpoints || [] });
214
+ });
215
+
216
+ app.patch('/api/axom/config', (req, res) => {
217
+ const { endpoints, command, mock, manifestUrl, modelDir } = req.body || {};
218
+
219
+ // Runtime settings (contract §11): serve binary path, mock mode,
220
+ // install-manifest URL, model dir. Any subset may be patched alone.
221
+ const settings = {};
222
+ if (command !== undefined) {
223
+ if (command !== null && (typeof command !== 'string' || !command.trim())) {
224
+ return res.status(400).json({ error: 'command must be a non-empty string or null' });
225
+ }
226
+ settings.command = command;
227
+ }
228
+ if (mock !== undefined) settings.mock = !!mock;
229
+ if (manifestUrl !== undefined) {
230
+ if (manifestUrl !== null && !/^https?:\/\//.test(manifestUrl)) {
231
+ return res.status(400).json({ error: 'manifestUrl must be http(s) or null' });
232
+ }
233
+ settings.manifestUrl = manifestUrl;
234
+ }
235
+ if (modelDir !== undefined) settings.modelDir = modelDir;
236
+ if (Object.keys(settings).length > 0) {
237
+ daemon.config.axom = { ...(daemon.config.axom || {}), ...settings };
238
+ if (endpoints === undefined) {
239
+ saveConfig(daemon.grooveDir, daemon.config);
240
+ daemon.audit.log('axom.config', { keys: Object.keys(settings) });
241
+ return res.json({ ...daemon.config.axom });
242
+ }
243
+ }
244
+
245
+ if (!Array.isArray(endpoints)) {
246
+ return res.status(400).json({ error: 'endpoints array is required' });
247
+ }
248
+ if (endpoints.length > 16) {
249
+ return res.status(400).json({ error: 'too many endpoints (max 16)' });
250
+ }
251
+ const seen = new Set();
252
+ for (const entry of endpoints) {
253
+ const problem = validateEndpoint(entry);
254
+ if (problem) return res.status(400).json({ error: problem });
255
+ if (seen.has(entry.name)) {
256
+ return res.status(400).json({ error: `duplicate endpoint name: ${entry.name}` });
257
+ }
258
+ seen.add(entry.name);
259
+ }
260
+ const cleaned = endpoints.map(({ name, url }) => ({ name, url: url.replace(/\/+$/, '') }));
261
+ daemon.config.axom = { ...(daemon.config.axom || {}), endpoints: cleaned };
262
+ saveConfig(daemon.grooveDir, daemon.config);
263
+ daemon.axom.configure(cleaned);
264
+ daemon.audit.log('axom.config', { endpoints: cleaned.map((e) => e.name) });
265
+ res.json({ endpoints: cleaned });
266
+ });
267
+ }
@@ -1,6 +1,11 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
 
3
3
  import { MAX_EXCHANGES } from '../innerchat.js';
4
+ import { readFileSync } from 'fs';
5
+ import { validatePeer, parsePeerRef } from '../innerchat-relay.js';
6
+ import { saveConfig } from '../firstrun.js';
7
+
8
+ const pkgVersion = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
4
9
 
5
10
  // Agents know each other by name, not id. Exact matches win first so
6
11
  // `fullstack-1` never resolves to `fullstack-14`; only if nothing matches
@@ -22,14 +27,16 @@ function resolveAgent(daemon, ref) {
22
27
  return partial.length === 1 ? partial[0] : null;
23
28
  }
24
29
 
25
- // Resolve the from/to pair from a request body, or write the appropriate
26
- // 400/404 and return null so the caller bails.
27
- function resolveParties(daemon, req, res) {
28
- const { from, to, message } = req.body || {};
29
-
30
+ // The calling agent is always local. Writes a 404 and returns null on miss.
31
+ function resolveFrom(daemon, from, res) {
30
32
  const fromAgent = resolveAgent(daemon, from);
31
33
  if (!fromAgent) { res.status(404).json({ error: `Unknown calling agent: ${from}` }); return null; }
34
+ return fromAgent;
35
+ }
32
36
 
37
+ // Resolve a LOCAL to/message pair, or write the appropriate 400/404 and return
38
+ // null. Used only when `to` is a plain (non-peer) name.
39
+ function resolveLocalTo(daemon, fromAgent, to, message, res) {
33
40
  const toAgent = resolveAgent(daemon, to);
34
41
  if (!toAgent) {
35
42
  const others = daemon.registry.getAll().filter((a) => a.id !== fromAgent.id);
@@ -42,32 +49,157 @@ function resolveParties(daemon, req, res) {
42
49
  }
43
50
  return null;
44
51
  }
45
-
46
52
  if (!message || typeof message !== 'string' || !message.trim()) {
47
53
  res.status(400).json({ error: 'message is required' }); return null;
48
54
  }
49
- return { fromAgent, toAgent, message: message.trim() };
55
+ return { toAgent, message: message.trim() };
56
+ }
57
+
58
+ // Confirm a relay request is from a configured, signature-verified peer.
59
+ // Returns the peer record, or writes a 403 and returns null.
60
+ function verifyRelayCaller(daemon, body, res) {
61
+ const { payload, signature } = body || {};
62
+ if (!payload || typeof payload !== 'object' || !signature) {
63
+ res.status(400).json({ error: 'signed relay envelope required ({ payload, signature })' });
64
+ return null;
65
+ }
66
+ const fromDaemonId = payload.fromDaemonId;
67
+ const peer = daemon.innerchat._peerByDaemonId(fromDaemonId);
68
+ if (!peer) {
69
+ res.status(403).json({ error: `Unknown daemon "${String(fromDaemonId).slice(0, 16)}" — not a configured InnerChat peer.` });
70
+ return null;
71
+ }
72
+ if (!daemon.federation.verify(fromDaemonId, payload, signature)) {
73
+ res.status(403).json({ error: 'Signature verification failed.' });
74
+ return null;
75
+ }
76
+ return peer;
77
+ }
78
+
79
+ // A peer relay is accepted only when the sender is BOTH a configured peer
80
+ // (in innerchatPeers — the human's decision) AND signature-verifies against a
81
+ // stored federation public key. Those are independent gates, which is what
82
+ // makes automatic key exchange safe: holding someone's public key grants
83
+ // nothing on its own. So adding a peer can fetch and store its key without a
84
+ // separate manual pairing step.
85
+ async function exchangeKeys(daemon, url, alias) {
86
+ const base = url.replace(/\/+$/, '');
87
+ const controller = new AbortController();
88
+ const timer = setTimeout(() => controller.abort(), 8000);
89
+ try {
90
+ const res = await fetch(`${base}/api/innerchat/identity`, { signal: controller.signal });
91
+ if (!res.ok) throw new Error(`peer returned HTTP ${res.status}`);
92
+ const id = await res.json();
93
+ if (!id?.daemonId || !id?.publicKey) throw new Error('peer did not return an identity');
94
+
95
+ // Store their key so we can verify what they send us.
96
+ daemon.federation._savePeer({
97
+ id: id.daemonId,
98
+ name: alias,
99
+ host: new URL(base).hostname,
100
+ port: Number(new URL(base).port) || 80,
101
+ publicKey: id.publicKey,
102
+ pairedAt: new Date().toISOString(),
103
+ });
104
+
105
+ // Push ours so they can verify what WE send — this is the half that
106
+ // otherwise required a manual step on the other machine.
107
+ let pushed = false;
108
+ try {
109
+ const push = await fetch(`${base}/api/innerchat/identity`, {
110
+ method: 'POST',
111
+ headers: { 'Content-Type': 'application/json' },
112
+ body: JSON.stringify({
113
+ daemonId: daemon.federation.getDaemonId(),
114
+ publicKey: daemon.federation.getPublicKeyPem(),
115
+ name: daemon.config?.daemonName || 'groove',
116
+ }),
117
+ signal: controller.signal,
118
+ });
119
+ pushed = push.ok;
120
+ } catch { /* reachable for GET but not POST — report partial */ }
121
+
122
+ return { ok: true, peerDaemonId: id.daemonId, peerVersion: id.version, pushed };
123
+ } finally {
124
+ clearTimeout(timer);
125
+ }
50
126
  }
51
127
 
52
128
  export function registerInnerChatRoutes(app, daemon) {
129
+ /**
130
+ * This daemon's federation identity. Read-only and non-secret (a public key
131
+ * plus an id); the peer needs it to verify signed relays from us.
132
+ */
133
+ app.get('/api/innerchat/identity', (req, res) => {
134
+ res.json({
135
+ daemonId: daemon.federation.getDaemonId(),
136
+ publicKey: daemon.federation.getPublicKeyPem(),
137
+ version: pkgVersion,
138
+ port: daemon.port,
139
+ agents: daemon.registry.getAll().map((a) => a.name),
140
+ });
141
+ });
142
+
143
+ /**
144
+ * Accept a peer's public key so we can verify its signed relays. Storing a
145
+ * key authorizes nothing by itself — a relay ALSO requires that daemon to be
146
+ * listed in innerchatPeers, which only the user can do. That second gate is
147
+ * what keeps this endpoint safe to accept without a pairing dance.
148
+ */
149
+ app.post('/api/innerchat/identity', (req, res) => {
150
+ const { daemonId, publicKey, name } = req.body || {};
151
+ if (!daemonId || !/^[a-f0-9]{6,64}$/.test(String(daemonId))) {
152
+ return res.status(400).json({ error: 'valid daemonId required' });
153
+ }
154
+ if (!publicKey || typeof publicKey !== 'string' || !publicKey.includes('PUBLIC KEY')) {
155
+ return res.status(400).json({ error: 'publicKey (PEM) required' });
156
+ }
157
+ daemon.federation._savePeer({
158
+ id: daemonId,
159
+ name: typeof name === 'string' && name.trim() ? name.trim().slice(0, 40) : daemonId,
160
+ host: (req.ip || '').replace('::ffff:', '') || '127.0.0.1',
161
+ port: 0,
162
+ publicKey,
163
+ pairedAt: new Date().toISOString(),
164
+ });
165
+ daemon.audit.log('innerchat.key.received', { daemonId });
166
+ res.json({
167
+ ok: true,
168
+ // Hand back ours so a single call completes the exchange either way.
169
+ daemonId: daemon.federation.getDaemonId(),
170
+ publicKey: daemon.federation.getPublicKeyPem(),
171
+ });
172
+ });
173
+
53
174
  /**
54
175
  * Ask another agent a question and BLOCK until it answers.
55
176
  *
56
- * This request is held open deliberately the calling agent is waiting on
57
- * it, and the response body is the other agent's reply. Best for tight
58
- * interface negotiation, where the finite exchange budget keeps both sides
59
- * writing decision-dense messages.
177
+ * `to` may be a local name or `name@peer`. A peer target is relayed to the
178
+ * configured peer daemon; the reply rides the still-open response back.
60
179
  */
61
180
  app.post('/api/innerchat/ask', async (req, res) => {
62
181
  try {
63
- const parties = resolveParties(daemon, req, res);
64
- if (!parties) return;
182
+ const { from, to, message } = req.body || {};
183
+ const fromAgent = resolveFrom(daemon, from, res);
184
+ if (!fromAgent) return;
65
185
 
66
186
  // Held open until the target answers — see the class doc in innerchat.js.
67
187
  req.setTimeout(0);
68
188
  res.setTimeout(0);
69
189
 
70
- const result = await daemon.innerchat.ask(parties.fromAgent.id, parties.toAgent.id, parties.message, {
190
+ if (daemon.innerchat.isRemoteRef(to)) {
191
+ const { name, alias } = parsePeerRef(to);
192
+ if (!message || !String(message).trim()) return res.status(400).json({ error: 'message is required' });
193
+ const result = await daemon.innerchat.askRemote(fromAgent, name, alias, String(message).trim(), {
194
+ timeoutMs: req.body?.timeoutMs,
195
+ });
196
+ return res.json({ from: `${name}@${alias}`, reply: result.reply, threadId: result.threadId, remote: true });
197
+ }
198
+
199
+ const parties = resolveLocalTo(daemon, fromAgent, to, message, res);
200
+ if (!parties) return;
201
+
202
+ const result = await daemon.innerchat.ask(fromAgent.id, parties.toAgent.id, parties.message, {
71
203
  timeoutMs: req.body?.timeoutMs,
72
204
  });
73
205
 
@@ -86,17 +218,34 @@ export function registerInnerChatRoutes(app, daemon) {
86
218
  });
87
219
 
88
220
  /**
89
- * Send a message WITHOUT blocking returns as soon as it's delivered. If the
90
- * target replies, the reply is routed back to the sender asynchronously
91
- * (resuming it if its turn ended). Best for handing off to a heads-down agent
92
- * where waiting out a timeout would waste the turn.
221
+ * Send a message WITHOUT blocking. `to` may be local or `name@peer`. For a
222
+ * peer target the reply (if any) is queued on the peer and picked up by this
223
+ * daemon's outbox poll, then routed back to the sender.
93
224
  */
94
225
  app.post('/api/innerchat/tell', async (req, res) => {
95
226
  try {
96
- const parties = resolveParties(daemon, req, res);
227
+ const { from, to, message } = req.body || {};
228
+ const fromAgent = resolveFrom(daemon, from, res);
229
+ if (!fromAgent) return;
230
+
231
+ if (daemon.innerchat.isRemoteRef(to)) {
232
+ const { name, alias } = parsePeerRef(to);
233
+ if (!message || !String(message).trim()) return res.status(400).json({ error: 'message is required' });
234
+ const result = await daemon.innerchat.tellRemote(fromAgent, name, alias, String(message).trim());
235
+ return res.json({
236
+ ok: true,
237
+ to: `${name}@${alias}`,
238
+ delivered: result.delivered,
239
+ threadId: result.threadId,
240
+ remote: true,
241
+ note: `Message relayed to ${name}@${alias}. Its reply, if any, will be routed back to you — you can end your turn.`,
242
+ });
243
+ }
244
+
245
+ const parties = resolveLocalTo(daemon, fromAgent, to, message, res);
97
246
  if (!parties) return;
98
247
 
99
- const result = await daemon.innerchat.tell(parties.fromAgent.id, parties.toAgent.id, parties.message, {
248
+ const result = await daemon.innerchat.tell(fromAgent.id, parties.toAgent.id, parties.message, {
100
249
  threadId: req.body?.threadId,
101
250
  });
102
251
 
@@ -115,6 +264,171 @@ export function registerInnerChatRoutes(app, daemon) {
115
264
  }
116
265
  });
117
266
 
267
+ // ── Cross-daemon relay endpoints (peer daemon → this daemon) ──
268
+
269
+ /**
270
+ * Receive a relayed ask/tell from a peer daemon. Signature-verified and
271
+ * restricted to configured peers. Resolves the target locally and delivers
272
+ * with a guest identity. For 'ask' the response is held open until the local
273
+ * agent answers.
274
+ */
275
+ app.post('/api/innerchat/relay', async (req, res) => {
276
+ const peer = verifyRelayCaller(daemon, req.body, res);
277
+ if (!peer) return;
278
+ const p = req.body.payload;
279
+ if (p.type !== 'innerchat.relay' || (p.kind !== 'ask' && p.kind !== 'tell')) {
280
+ return res.status(400).json({ error: 'invalid relay payload' });
281
+ }
282
+ try {
283
+ req.setTimeout(0);
284
+ res.setTimeout(0);
285
+ const result = await daemon.innerchat.receiveRelay({
286
+ fromName: p.fromName,
287
+ fromRole: p.fromRole,
288
+ fromDaemonId: p.fromDaemonId,
289
+ peerAlias: peer.alias,
290
+ toName: p.toName,
291
+ message: p.message,
292
+ kind: p.kind,
293
+ threadId: p.threadId,
294
+ timeoutMs: p.timeoutMs,
295
+ });
296
+ res.json(result);
297
+ } catch (err) {
298
+ const body = { error: err.message };
299
+ if (err.availableAgents) body.availableAgents = err.availableAgents;
300
+ res.status(404).json(body);
301
+ }
302
+ });
303
+
304
+ /**
305
+ * Drain this daemon's outbox of async (tell) replies queued for the calling
306
+ * peer. Signature-verified; returns and removes only that peer's entries.
307
+ */
308
+ app.post('/api/innerchat/relay/outbox', (req, res) => {
309
+ const peer = verifyRelayCaller(daemon, req.body, res);
310
+ if (!peer) return;
311
+ if (req.body.payload.type !== 'innerchat.outbox') {
312
+ return res.status(400).json({ error: 'invalid outbox payload' });
313
+ }
314
+ res.json({ entries: daemon.innerchat.drainOutbox(req.body.payload.fromDaemonId) });
315
+ });
316
+
317
+ // ── Peer configuration (alias ↔ url ↔ daemonId) ──────────────
318
+
319
+ app.get('/api/innerchat/peers', (req, res) => {
320
+ res.json({ peers: daemon.innerchat._peers() });
321
+ });
322
+
323
+ /**
324
+ * Add a peer. The daemonId is discovered from the peer itself rather than
325
+ * typed, and public keys are exchanged automatically, so the user only has
326
+ * to supply a name and a reachable URL.
327
+ */
328
+ app.post('/api/innerchat/peers', async (req, res) => {
329
+ const alias = req.body?.alias;
330
+ const rawUrl = req.body?.url;
331
+ if (!alias || typeof alias !== 'string' || !/^[a-zA-Z0-9_-]{1,40}$/.test(alias)) {
332
+ return res.status(400).json({ error: 'peer name must be 1-40 chars (letters, digits, dash, underscore)' });
333
+ }
334
+ if (!rawUrl || typeof rawUrl !== 'string') return res.status(400).json({ error: 'peer url is required' });
335
+
336
+ let url;
337
+ try {
338
+ const parsed = new URL(rawUrl.trim());
339
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
340
+ return res.status(400).json({ error: 'peer url must be http(s)' });
341
+ }
342
+ if (parsed.username || parsed.password) return res.status(400).json({ error: 'credentials in the url are not allowed' });
343
+ url = rawUrl.trim().replace(/\/+$/, '');
344
+ } catch { return res.status(400).json({ error: `invalid url: ${rawUrl}` }); }
345
+
346
+ // Reach the peer, learn its id, and trade keys. This is the step that used
347
+ // to be a manual federation pairing.
348
+ let exchange;
349
+ try {
350
+ exchange = await exchangeKeys(daemon, url, alias);
351
+ } catch (err) {
352
+ return res.status(502).json({
353
+ error: `Could not reach a Groove daemon at ${url} — ${err.message}. `
354
+ + 'Check the URL (for a tunnelled peer this is the forwarded local port, not 31415) and that the peer daemon is running the current version.',
355
+ });
356
+ }
357
+
358
+ if (exchange.peerDaemonId === daemon.federation.getDaemonId()) {
359
+ return res.status(400).json({
360
+ error: `That URL points at THIS daemon, not a peer. For a tunnelled machine use the forwarded local port (e.g. http://127.0.0.1:31416), not ${url}.`,
361
+ });
362
+ }
363
+
364
+ const entry = { alias, url, daemonId: exchange.peerDaemonId };
365
+ const check = validatePeer(entry);
366
+ if (check) return res.status(400).json({ error: check });
367
+
368
+ const peers = Array.isArray(daemon.config.innerchatPeers) ? daemon.config.innerchatPeers : [];
369
+ const next = peers.filter((p) => p.alias !== alias);
370
+ next.push(entry);
371
+ daemon.config.innerchatPeers = next;
372
+ saveConfig(daemon.grooveDir, daemon.config);
373
+ daemon.audit.log('innerchat.peer.set', { alias, url, daemonId: entry.daemonId });
374
+ // Registry files carry the @peer instructions + alias list — refresh now so
375
+ // running agents see the new peer without waiting for a spawn or restart.
376
+ try { daemon.introducer?.writeRegistryFile?.(daemon.projectDir); } catch { /* best effort */ }
377
+
378
+ res.json({
379
+ peers: next,
380
+ exchanged: true,
381
+ keyPushed: exchange.pushed,
382
+ peerDaemonId: exchange.peerDaemonId,
383
+ note: exchange.pushed
384
+ ? `Keys exchanged with ${alias}. Add this machine as a peer there too if you want its agents to start conversations.`
385
+ : `Stored ${alias}'s key, but could not send ours — ${alias} may be running an older version. Update it, then re-add.`,
386
+ });
387
+ });
388
+
389
+ /**
390
+ * Verify a configured peer end to end: reachable, identity matches what we
391
+ * stored, and keys are present on both sides.
392
+ */
393
+ app.get('/api/innerchat/peers/:alias/test', async (req, res) => {
394
+ const peer = daemon.innerchat._peerByAlias(req.params.alias);
395
+ if (!peer) return res.status(404).json({ error: `No peer named "${req.params.alias}"` });
396
+ const controller = new AbortController();
397
+ const timer = setTimeout(() => controller.abort(), 8000);
398
+ try {
399
+ const r = await fetch(`${peer.url}/api/innerchat/identity`, { signal: controller.signal });
400
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
401
+ const id = await r.json();
402
+ const idMatches = id.daemonId === peer.daemonId;
403
+ const weHaveTheirKey = daemon.federation.peers.has(peer.daemonId);
404
+ res.json({
405
+ ok: idMatches && weHaveTheirKey,
406
+ reachable: true,
407
+ idMatches,
408
+ weHaveTheirKey,
409
+ peerDaemonId: id.daemonId,
410
+ peerVersion: id.version,
411
+ agents: Array.isArray(id.agents) ? id.agents : [],
412
+ error: !idMatches
413
+ ? `The daemon at ${peer.url} reports id ${id.daemonId}, but this peer is configured as ${peer.daemonId}. Re-add the peer.`
414
+ : !weHaveTheirKey ? 'Missing this peer\'s public key — re-add the peer to exchange keys.' : null,
415
+ });
416
+ } catch (err) {
417
+ res.json({ ok: false, reachable: false, error: `Could not reach ${peer.url} — ${err.message}` });
418
+ } finally {
419
+ clearTimeout(timer);
420
+ }
421
+ });
422
+
423
+ app.delete('/api/innerchat/peers/:alias', (req, res) => {
424
+ const peers = Array.isArray(daemon.config.innerchatPeers) ? daemon.config.innerchatPeers : [];
425
+ const next = peers.filter((p) => p.alias !== req.params.alias);
426
+ daemon.config.innerchatPeers = next;
427
+ saveConfig(daemon.grooveDir, daemon.config);
428
+ daemon.audit.log('innerchat.peer.remove', { alias: req.params.alias });
429
+ res.json({ peers: next });
430
+ });
431
+
118
432
  app.get('/api/innerchat/threads', (req, res) => {
119
433
  const { agentId } = req.query;
120
434
  res.json({ threads: daemon.innerchat.getThreads(agentId || null) });