groove-dev 0.27.197 → 0.27.198

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 (67) 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/node_modules/@groove-dev/cli/package.json +1 -1
  5. package/node_modules/@groove-dev/daemon/package.json +1 -1
  6. package/node_modules/@groove-dev/daemon/src/api.js +2 -0
  7. package/node_modules/@groove-dev/daemon/src/axom-connector.js +340 -0
  8. package/node_modules/@groove-dev/daemon/src/axom-install.js +140 -0
  9. package/node_modules/@groove-dev/daemon/src/axom-server.js +229 -0
  10. package/node_modules/@groove-dev/daemon/src/federation.js +6 -0
  11. package/node_modules/@groove-dev/daemon/src/index.js +11 -0
  12. package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +8 -1
  13. package/node_modules/@groove-dev/daemon/src/innerchat-relay.js +89 -0
  14. package/node_modules/@groove-dev/daemon/src/innerchat.js +261 -1
  15. package/node_modules/@groove-dev/daemon/src/introducer.js +1 -1
  16. package/node_modules/@groove-dev/daemon/src/network-guard.js +56 -0
  17. package/node_modules/@groove-dev/daemon/src/process.js +1 -1
  18. package/node_modules/@groove-dev/daemon/src/providers/axom.js +66 -0
  19. package/node_modules/@groove-dev/daemon/src/providers/index.js +2 -0
  20. package/node_modules/@groove-dev/daemon/src/routes/axom.js +196 -0
  21. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +152 -20
  22. package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +412 -0
  23. package/node_modules/@groove-dev/daemon/test/axom-server.test.js +187 -0
  24. package/node_modules/@groove-dev/daemon/test/innerchat-relay.test.js +251 -0
  25. package/node_modules/@groove-dev/gui/dist/assets/index-RbtaI6l7.css +1 -0
  26. package/node_modules/@groove-dev/gui/dist/assets/{index-Da2nbd6M.js → index-b9dKN6cq.js} +259 -228
  27. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  28. package/node_modules/@groove-dev/gui/package.json +1 -1
  29. package/node_modules/@groove-dev/gui/src/App.jsx +2 -0
  30. package/node_modules/@groove-dev/gui/src/app.css +53 -0
  31. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -1
  32. package/node_modules/@groove-dev/gui/src/stores/groove.js +19 -0
  33. package/node_modules/@groove-dev/gui/src/stores/slices/axom-slice.js +197 -0
  34. package/node_modules/@groove-dev/gui/src/views/axom.jsx +1060 -0
  35. package/node_modules/@groove-dev/gui/src/views/settings.jsx +178 -3
  36. package/package.json +2 -2
  37. package/packages/cli/package.json +1 -1
  38. package/packages/daemon/package.json +1 -1
  39. package/packages/daemon/src/api.js +2 -0
  40. package/packages/daemon/src/axom-connector.js +340 -0
  41. package/packages/daemon/src/axom-install.js +140 -0
  42. package/packages/daemon/src/axom-server.js +229 -0
  43. package/packages/daemon/src/federation.js +6 -0
  44. package/packages/daemon/src/index.js +11 -0
  45. package/packages/daemon/src/innerchat-docs.js +8 -1
  46. package/packages/daemon/src/innerchat-relay.js +89 -0
  47. package/packages/daemon/src/innerchat.js +261 -1
  48. package/packages/daemon/src/introducer.js +1 -1
  49. package/packages/daemon/src/network-guard.js +56 -0
  50. package/packages/daemon/src/process.js +1 -1
  51. package/packages/daemon/src/providers/axom.js +66 -0
  52. package/packages/daemon/src/providers/index.js +2 -0
  53. package/packages/daemon/src/routes/axom.js +196 -0
  54. package/packages/daemon/src/routes/innerchat.js +152 -20
  55. package/packages/gui/dist/assets/index-RbtaI6l7.css +1 -0
  56. package/packages/gui/dist/assets/{index-Da2nbd6M.js → index-b9dKN6cq.js} +259 -228
  57. package/packages/gui/dist/index.html +2 -2
  58. package/packages/gui/package.json +1 -1
  59. package/packages/gui/src/App.jsx +2 -0
  60. package/packages/gui/src/app.css +53 -0
  61. package/packages/gui/src/components/layout/activity-bar.jsx +2 -1
  62. package/packages/gui/src/stores/groove.js +19 -0
  63. package/packages/gui/src/stores/slices/axom-slice.js +197 -0
  64. package/packages/gui/src/views/axom.jsx +1060 -0
  65. package/packages/gui/src/views/settings.jsx +178 -3
  66. package/node_modules/@groove-dev/gui/dist/assets/index-zmrIbwNm.css +0 -1
  67. package/packages/gui/dist/assets/index-zmrIbwNm.css +0 -1
@@ -1,6 +1,8 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
 
3
3
  import { MAX_EXCHANGES } from '../innerchat.js';
4
+ import { validatePeer, parsePeerRef } from '../innerchat-relay.js';
5
+ import { saveConfig } from '../firstrun.js';
4
6
 
5
7
  // Agents know each other by name, not id. Exact matches win first so
6
8
  // `fullstack-1` never resolves to `fullstack-14`; only if nothing matches
@@ -22,14 +24,16 @@ function resolveAgent(daemon, ref) {
22
24
  return partial.length === 1 ? partial[0] : null;
23
25
  }
24
26
 
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
-
27
+ // The calling agent is always local. Writes a 404 and returns null on miss.
28
+ function resolveFrom(daemon, from, res) {
30
29
  const fromAgent = resolveAgent(daemon, from);
31
30
  if (!fromAgent) { res.status(404).json({ error: `Unknown calling agent: ${from}` }); return null; }
31
+ return fromAgent;
32
+ }
32
33
 
34
+ // Resolve a LOCAL to/message pair, or write the appropriate 400/404 and return
35
+ // null. Used only when `to` is a plain (non-peer) name.
36
+ function resolveLocalTo(daemon, fromAgent, to, message, res) {
33
37
  const toAgent = resolveAgent(daemon, to);
34
38
  if (!toAgent) {
35
39
  const others = daemon.registry.getAll().filter((a) => a.id !== fromAgent.id);
@@ -42,32 +46,63 @@ function resolveParties(daemon, req, res) {
42
46
  }
43
47
  return null;
44
48
  }
45
-
46
49
  if (!message || typeof message !== 'string' || !message.trim()) {
47
50
  res.status(400).json({ error: 'message is required' }); return null;
48
51
  }
49
- return { fromAgent, toAgent, message: message.trim() };
52
+ return { toAgent, message: message.trim() };
53
+ }
54
+
55
+ // Confirm a relay request is from a configured, signature-verified peer.
56
+ // Returns the peer record, or writes a 403 and returns null.
57
+ function verifyRelayCaller(daemon, body, res) {
58
+ const { payload, signature } = body || {};
59
+ if (!payload || typeof payload !== 'object' || !signature) {
60
+ res.status(400).json({ error: 'signed relay envelope required ({ payload, signature })' });
61
+ return null;
62
+ }
63
+ const fromDaemonId = payload.fromDaemonId;
64
+ const peer = daemon.innerchat._peerByDaemonId(fromDaemonId);
65
+ if (!peer) {
66
+ res.status(403).json({ error: `Unknown daemon "${String(fromDaemonId).slice(0, 16)}" — not a configured InnerChat peer.` });
67
+ return null;
68
+ }
69
+ if (!daemon.federation.verify(fromDaemonId, payload, signature)) {
70
+ res.status(403).json({ error: 'Signature verification failed.' });
71
+ return null;
72
+ }
73
+ return peer;
50
74
  }
51
75
 
52
76
  export function registerInnerChatRoutes(app, daemon) {
53
77
  /**
54
78
  * Ask another agent a question and BLOCK until it answers.
55
79
  *
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.
80
+ * `to` may be a local name or `name@peer`. A peer target is relayed to the
81
+ * configured peer daemon; the reply rides the still-open response back.
60
82
  */
61
83
  app.post('/api/innerchat/ask', async (req, res) => {
62
84
  try {
63
- const parties = resolveParties(daemon, req, res);
64
- if (!parties) return;
85
+ const { from, to, message } = req.body || {};
86
+ const fromAgent = resolveFrom(daemon, from, res);
87
+ if (!fromAgent) return;
65
88
 
66
89
  // Held open until the target answers — see the class doc in innerchat.js.
67
90
  req.setTimeout(0);
68
91
  res.setTimeout(0);
69
92
 
70
- const result = await daemon.innerchat.ask(parties.fromAgent.id, parties.toAgent.id, parties.message, {
93
+ if (daemon.innerchat.isRemoteRef(to)) {
94
+ const { name, alias } = parsePeerRef(to);
95
+ if (!message || !String(message).trim()) return res.status(400).json({ error: 'message is required' });
96
+ const result = await daemon.innerchat.askRemote(fromAgent, name, alias, String(message).trim(), {
97
+ timeoutMs: req.body?.timeoutMs,
98
+ });
99
+ return res.json({ from: `${name}@${alias}`, reply: result.reply, threadId: result.threadId, remote: true });
100
+ }
101
+
102
+ const parties = resolveLocalTo(daemon, fromAgent, to, message, res);
103
+ if (!parties) return;
104
+
105
+ const result = await daemon.innerchat.ask(fromAgent.id, parties.toAgent.id, parties.message, {
71
106
  timeoutMs: req.body?.timeoutMs,
72
107
  });
73
108
 
@@ -86,17 +121,34 @@ export function registerInnerChatRoutes(app, daemon) {
86
121
  });
87
122
 
88
123
  /**
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.
124
+ * Send a message WITHOUT blocking. `to` may be local or `name@peer`. For a
125
+ * peer target the reply (if any) is queued on the peer and picked up by this
126
+ * daemon's outbox poll, then routed back to the sender.
93
127
  */
94
128
  app.post('/api/innerchat/tell', async (req, res) => {
95
129
  try {
96
- const parties = resolveParties(daemon, req, res);
130
+ const { from, to, message } = req.body || {};
131
+ const fromAgent = resolveFrom(daemon, from, res);
132
+ if (!fromAgent) return;
133
+
134
+ if (daemon.innerchat.isRemoteRef(to)) {
135
+ const { name, alias } = parsePeerRef(to);
136
+ if (!message || !String(message).trim()) return res.status(400).json({ error: 'message is required' });
137
+ const result = await daemon.innerchat.tellRemote(fromAgent, name, alias, String(message).trim());
138
+ return res.json({
139
+ ok: true,
140
+ to: `${name}@${alias}`,
141
+ delivered: result.delivered,
142
+ threadId: result.threadId,
143
+ remote: true,
144
+ note: `Message relayed to ${name}@${alias}. Its reply, if any, will be routed back to you — you can end your turn.`,
145
+ });
146
+ }
147
+
148
+ const parties = resolveLocalTo(daemon, fromAgent, to, message, res);
97
149
  if (!parties) return;
98
150
 
99
- const result = await daemon.innerchat.tell(parties.fromAgent.id, parties.toAgent.id, parties.message, {
151
+ const result = await daemon.innerchat.tell(fromAgent.id, parties.toAgent.id, parties.message, {
100
152
  threadId: req.body?.threadId,
101
153
  });
102
154
 
@@ -115,6 +167,86 @@ export function registerInnerChatRoutes(app, daemon) {
115
167
  }
116
168
  });
117
169
 
170
+ // ── Cross-daemon relay endpoints (peer daemon → this daemon) ──
171
+
172
+ /**
173
+ * Receive a relayed ask/tell from a peer daemon. Signature-verified and
174
+ * restricted to configured peers. Resolves the target locally and delivers
175
+ * with a guest identity. For 'ask' the response is held open until the local
176
+ * agent answers.
177
+ */
178
+ app.post('/api/innerchat/relay', async (req, res) => {
179
+ const peer = verifyRelayCaller(daemon, req.body, res);
180
+ if (!peer) return;
181
+ const p = req.body.payload;
182
+ if (p.type !== 'innerchat.relay' || (p.kind !== 'ask' && p.kind !== 'tell')) {
183
+ return res.status(400).json({ error: 'invalid relay payload' });
184
+ }
185
+ try {
186
+ req.setTimeout(0);
187
+ res.setTimeout(0);
188
+ const result = await daemon.innerchat.receiveRelay({
189
+ fromName: p.fromName,
190
+ fromRole: p.fromRole,
191
+ fromDaemonId: p.fromDaemonId,
192
+ peerAlias: peer.alias,
193
+ toName: p.toName,
194
+ message: p.message,
195
+ kind: p.kind,
196
+ threadId: p.threadId,
197
+ timeoutMs: p.timeoutMs,
198
+ });
199
+ res.json(result);
200
+ } catch (err) {
201
+ const body = { error: err.message };
202
+ if (err.availableAgents) body.availableAgents = err.availableAgents;
203
+ res.status(404).json(body);
204
+ }
205
+ });
206
+
207
+ /**
208
+ * Drain this daemon's outbox of async (tell) replies queued for the calling
209
+ * peer. Signature-verified; returns and removes only that peer's entries.
210
+ */
211
+ app.post('/api/innerchat/relay/outbox', (req, res) => {
212
+ const peer = verifyRelayCaller(daemon, req.body, res);
213
+ if (!peer) return;
214
+ if (req.body.payload.type !== 'innerchat.outbox') {
215
+ return res.status(400).json({ error: 'invalid outbox payload' });
216
+ }
217
+ res.json({ entries: daemon.innerchat.drainOutbox(req.body.payload.fromDaemonId) });
218
+ });
219
+
220
+ // ── Peer configuration (alias ↔ url ↔ daemonId) ──────────────
221
+
222
+ app.get('/api/innerchat/peers', (req, res) => {
223
+ res.json({ peers: daemon.innerchat._peers() });
224
+ });
225
+
226
+ app.post('/api/innerchat/peers', (req, res) => {
227
+ const entry = { alias: req.body?.alias, url: req.body?.url, daemonId: req.body?.daemonId };
228
+ const err = validatePeer(entry);
229
+ if (err) return res.status(400).json({ error: err });
230
+ entry.url = entry.url.replace(/\/+$/, '');
231
+
232
+ const peers = Array.isArray(daemon.config.innerchatPeers) ? daemon.config.innerchatPeers : [];
233
+ const next = peers.filter((p) => p.alias !== entry.alias);
234
+ next.push(entry);
235
+ daemon.config.innerchatPeers = next;
236
+ saveConfig(daemon.grooveDir, daemon.config);
237
+ daemon.audit.log('innerchat.peer.set', { alias: entry.alias, url: entry.url });
238
+ res.json({ peers: next });
239
+ });
240
+
241
+ app.delete('/api/innerchat/peers/:alias', (req, res) => {
242
+ const peers = Array.isArray(daemon.config.innerchatPeers) ? daemon.config.innerchatPeers : [];
243
+ const next = peers.filter((p) => p.alias !== req.params.alias);
244
+ daemon.config.innerchatPeers = next;
245
+ saveConfig(daemon.grooveDir, daemon.config);
246
+ daemon.audit.log('innerchat.peer.remove', { alias: req.params.alias });
247
+ res.json({ peers: next });
248
+ });
249
+
118
250
  app.get('/api/innerchat/threads', (req, res) => {
119
251
  const { agentId } = req.query;
120
252
  res.json({ threads: daemon.innerchat.getThreads(agentId || null) });
@@ -0,0 +1,412 @@
1
+ // GROOVE — Axom Connector Tests
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+ //
4
+ // Runs the connector against a mock bridge speaking the Axom provider
5
+ // protocol (GROOVE ⇄ Axom Integration Contract v0). The mock is contract-
6
+ // faithful: /about with kinds, /sessions, per-session WS with ?since replay,
7
+ // interrupt with 2000-char truncation, sticky idempotent stop.
8
+
9
+ import { describe, it, beforeEach, afterEach } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { createServer } from 'http';
12
+ import { WebSocketServer } from 'ws';
13
+ import { AxomConnector, KNOWN_KINDS, validateEndpoint } from '../src/axom-connector.js';
14
+
15
+ function envelope(seq, kind, payload = {}, session = 's-test0001') {
16
+ return {
17
+ id: `ev-${String(seq).padStart(6, '0')}`,
18
+ ts: 1753000000 + seq,
19
+ session,
20
+ firing_id: null,
21
+ step: null,
22
+ kind,
23
+ payload,
24
+ };
25
+ }
26
+
27
+ // Contract-faithful mock of `axom serve`'s bridge surface.
28
+ class MockBridge {
29
+ constructor({ kinds = [...KNOWN_KINDS], sessions } = {}) {
30
+ this.kinds = kinds;
31
+ this.sessions = sessions || { 's-test0001': { started: 1753000000, live: true, events: [] } };
32
+ this.interrupts = [];
33
+ this.stops = [];
34
+ this.sinceSeen = []; // ?since values observed on WS connects
35
+ this.sockets = new Set();
36
+ }
37
+
38
+ async start(port = 0) {
39
+ this.server = createServer((req, res) => this._http(req, res));
40
+ this.wss = new WebSocketServer({ noServer: true });
41
+ this.server.on('upgrade', (req, socket, head) => {
42
+ const url = new URL(req.url, 'http://localhost');
43
+ const match = url.pathname.match(/^\/ws\/session\/([^/]+)$/);
44
+ const session = match && this.sessions[decodeURIComponent(match[1])];
45
+ if (!session) { socket.destroy(); return; }
46
+ this.wss.handleUpgrade(req, socket, head, (ws) => {
47
+ this.sockets.add(ws);
48
+ ws.on('close', () => this.sockets.delete(ws));
49
+ const since = url.searchParams.get('since');
50
+ this.sinceSeen.push(since);
51
+ // Ring replay: everything after `since`, then live.
52
+ const from = since ? parseInt(since.slice(3), 10) : 0;
53
+ for (const e of session.events) {
54
+ if (parseInt(e.id.slice(3), 10) > from) ws.send(JSON.stringify(e));
55
+ }
56
+ session.socket = ws;
57
+ });
58
+ });
59
+ await new Promise((resolve, reject) => {
60
+ this.server.once('error', reject);
61
+ this.server.listen(port, '127.0.0.1', resolve);
62
+ });
63
+ this.url = `http://127.0.0.1:${this.server.address().port}`;
64
+ }
65
+
66
+ emit(sessionId, env) {
67
+ const session = this.sessions[sessionId];
68
+ session.events.push(env);
69
+ if (session.socket && session.socket.readyState === 1) {
70
+ session.socket.send(JSON.stringify(env));
71
+ }
72
+ }
73
+
74
+ _http(req, res) {
75
+ const url = new URL(req.url, 'http://localhost');
76
+ const json = (code, body) => {
77
+ res.writeHead(code, { 'Content-Type': 'application/json' });
78
+ res.end(JSON.stringify(body));
79
+ };
80
+ if (req.method === 'GET' && url.pathname === '/about') {
81
+ return json(200, {
82
+ name: 'axom', version: '0.1.0', family: 'phi4mini-s1',
83
+ record: { benchmark: 'HumanEval+', 'pass@1': '73.78%' },
84
+ chassis: { model: 'phi4mini_stock_q8.gguf', loaded: true },
85
+ leaves: [{ name: 'chat', loaded: true }, { name: 'agentic', loaded: true }],
86
+ narrator: 'template',
87
+ kinds: this.kinds,
88
+ });
89
+ }
90
+ if (req.method === 'GET' && url.pathname === '/sessions') {
91
+ return json(200, Object.entries(this.sessions).map(([id, s]) => ({
92
+ session: id, started: s.started, live: s.live,
93
+ })));
94
+ }
95
+ const interrupt = url.pathname.match(/^\/session\/([^/]+)\/interrupt$/);
96
+ const stop = url.pathname.match(/^\/session\/([^/]+)\/stop$/);
97
+ const message = url.pathname.match(/^\/session\/([^/]+)\/message$/);
98
+ if (req.method === 'POST' && (interrupt || stop || message)) {
99
+ let body = '';
100
+ req.on('data', (c) => { body += c; });
101
+ req.on('end', () => {
102
+ if (interrupt) {
103
+ const { text } = JSON.parse(body || '{}');
104
+ this.interrupts.push({ session: interrupt[1], text });
105
+ return json(200, { id: `int-${this.interrupts.length}`, truncated: text.length > 2000 });
106
+ }
107
+ if (message) {
108
+ // §12 semantics: caller-chosen id, first message creates; one turn
109
+ // at a time; hard max with 413, never truncation.
110
+ const id = decodeURIComponent(message[1]);
111
+ const { text } = JSON.parse(body || '{}');
112
+ if (text.length > 32768) return json(413, { error: 'too_long', max: 32768 });
113
+ let session = this.sessions[id];
114
+ if (!session) { session = this.sessions[id] = { started: Date.now() / 1000, live: false, events: [] }; }
115
+ if (session.live) return json(409, { error: 'busy' });
116
+ session.live = true;
117
+ return json(202, { session: id, accepted: true });
118
+ }
119
+ this.stops.push({ session: stop[1] });
120
+ return json(200, { ok: true });
121
+ });
122
+ return;
123
+ }
124
+ json(404, { error: 'not found' });
125
+ }
126
+
127
+ async close() {
128
+ for (const ws of this.sockets) ws.terminate();
129
+ this.wss?.close();
130
+ this.server.closeAllConnections?.();
131
+ await new Promise((r) => this.server.close(r));
132
+ }
133
+ }
134
+
135
+ function fakeDaemon() {
136
+ const broadcasts = [];
137
+ return {
138
+ broadcasts,
139
+ config: { axom: { endpoints: [] } },
140
+ broadcast: (m) => broadcasts.push(m),
141
+ audit: { log() {} },
142
+ };
143
+ }
144
+
145
+ async function waitFor(predicate, ms = 3000) {
146
+ const deadline = Date.now() + ms;
147
+ while (Date.now() < deadline) {
148
+ if (predicate()) return;
149
+ await new Promise((r) => setTimeout(r, 15));
150
+ }
151
+ throw new Error('waitFor timed out');
152
+ }
153
+
154
+ describe('AxomConnector', () => {
155
+ let bridge, daemon, connector;
156
+
157
+ beforeEach(async () => {
158
+ bridge = new MockBridge();
159
+ await bridge.start();
160
+ daemon = fakeDaemon();
161
+ connector = new AxomConnector(daemon, { backoffBaseMs: 50, sessionPollMs: 100 });
162
+ });
163
+
164
+ afterEach(async () => {
165
+ connector.destroy();
166
+ await bridge.close();
167
+ });
168
+
169
+ function connect(name = 'local') {
170
+ connector.configure([{ name, url: bridge.url }]);
171
+ }
172
+
173
+ it('handshakes via /about and reports a clean schema match', async () => {
174
+ connect();
175
+ await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
176
+ const ep = connector.status().endpoints[0];
177
+ assert.equal(ep.about.family, 'phi4mini-s1');
178
+ assert.equal(ep.about.narrator, 'template');
179
+ assert.deepEqual(ep.drift, { novel: [], missing: [] });
180
+ });
181
+
182
+ it('surfaces schema drift in both directions without dropping anything', async () => {
183
+ await bridge.close();
184
+ bridge = new MockBridge({ kinds: [...KNOWN_KINDS.filter((k) => k !== 'leaf_swap'), 'brand_new_kind'] });
185
+ await bridge.start();
186
+ connect();
187
+ await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
188
+ const ep = connector.status().endpoints[0];
189
+ assert.deepEqual(ep.drift.novel, ['brand_new_kind']);
190
+ assert.deepEqual(ep.drift.missing, ['leaf_swap']);
191
+ assert.equal(ep.status, 'connected'); // drift is surfaced, never fatal
192
+ });
193
+
194
+ it('discovers live sessions and passes envelopes through verbatim', async () => {
195
+ connect();
196
+ await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
197
+ const env = envelope(1, 'pipeline_start', { firing_id: 'f-1' });
198
+ bridge.emit('s-test0001', env);
199
+ await waitFor(() => daemon.broadcasts.some((b) => b.type === 'axom:event'));
200
+ const msg = daemon.broadcasts.find((b) => b.type === 'axom:event');
201
+ assert.equal(msg.endpoint, 'local');
202
+ assert.equal(msg.session, 's-test0001');
203
+ assert.deepEqual(msg.envelope, env); // byte-faithful passthrough
204
+ });
205
+
206
+ it('tolerates unknown kinds — passed through and counted, never dropped', async () => {
207
+ connect();
208
+ await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
209
+ bridge.emit('s-test0001', envelope(1, 'kind_from_the_future', { x: 1 }));
210
+ await waitFor(() => daemon.broadcasts.some((b) => b.type === 'axom:event'));
211
+ assert.equal(daemon.broadcasts.find((b) => b.type === 'axom:event').envelope.kind, 'kind_from_the_future');
212
+ const s = connector.status().endpoints[0].sessions[0];
213
+ assert.equal(s.unknownKinds.kind_from_the_future, 1);
214
+ });
215
+
216
+ it('resumes with ?since after reconnect and dedups the ring replay', async () => {
217
+ connect();
218
+ await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
219
+ bridge.emit('s-test0001', envelope(1, 'pipeline_start'));
220
+ bridge.emit('s-test0001', envelope(2, 'thought', { text: 'hm' }));
221
+ await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 2);
222
+
223
+ // Kill the socket server-side; connector should reconnect with since=ev-000002.
224
+ bridge.sessions['s-test0001'].socket.terminate();
225
+ await waitFor(() => bridge.sinceSeen.length === 2);
226
+ assert.equal(bridge.sinceSeen[1], 'ev-000002');
227
+
228
+ // Replayed history must not re-broadcast; a genuinely new event must.
229
+ bridge.emit('s-test0001', envelope(3, 'resolution'));
230
+ await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 3);
231
+ const seqs = daemon.broadcasts.filter((b) => b.type === 'axom:event').map((b) => b.envelope.id);
232
+ assert.deepEqual(seqs, ['ev-000001', 'ev-000002', 'ev-000003']);
233
+ });
234
+
235
+ it('proxies interrupt verbatim including the runtime truncation flag', async () => {
236
+ connect();
237
+ await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
238
+ const short = await connector.interrupt('local', 's-test0001', 'skip the auth module');
239
+ assert.deepEqual(short, { id: 'int-1', truncated: false });
240
+ const long = await connector.interrupt('local', 's-test0001', 'x'.repeat(2500));
241
+ assert.equal(long.truncated, true); // the runtime is the truncation authority
242
+ assert.equal(bridge.interrupts.length, 2);
243
+ });
244
+
245
+ it('proxies stop and resolves the endpoint when unambiguous', async () => {
246
+ connect();
247
+ await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
248
+ // Omitted endpoint name resolves with exactly one endpoint configured.
249
+ const result = await connector.stop(undefined, 's-test0001');
250
+ assert.deepEqual(result, { ok: true });
251
+ assert.deepEqual(bridge.stops, [{ session: 's-test0001' }]);
252
+ });
253
+
254
+ it('counts ring overflow instead of dropping silently', async () => {
255
+ connector.destroy();
256
+ connector = new AxomConnector(daemon, { ringSize: 2, backoffBaseMs: 50, sessionPollMs: 100 });
257
+ connect();
258
+ await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
259
+ for (let i = 1; i <= 5; i++) bridge.emit('s-test0001', envelope(i, 'thought'));
260
+ await waitFor(() => connector.status().endpoints[0].sessions[0].overflow === 3);
261
+ const s = connector.status().endpoints[0].sessions[0];
262
+ assert.equal(s.buffered, 2);
263
+ assert.equal(s.overflow, 3);
264
+ // All five still broadcast — the ring bounds memory, not delivery.
265
+ assert.equal(daemon.broadcasts.filter((b) => b.type === 'axom:event').length, 5);
266
+ });
267
+
268
+ it('serves buffered events with a since cursor for GUI backfill', async () => {
269
+ connect();
270
+ await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
271
+ for (let i = 1; i <= 3; i++) bridge.emit('s-test0001', envelope(i, 'thought'));
272
+ await waitFor(() => connector.status().endpoints[0].sessions[0].buffered === 3);
273
+ const all = connector.events('local', 's-test0001');
274
+ assert.equal(all.events.length, 3);
275
+ const tail = connector.events('local', 's-test0001', 2);
276
+ assert.deepEqual(tail.events.map((e) => e.id), ['ev-000003']);
277
+ });
278
+
279
+ it('isolates concurrent sessions — streams, backfill cursors, and rings never cross', async () => {
280
+ connector.destroy();
281
+ await bridge.close();
282
+ bridge = new MockBridge({
283
+ sessions: {
284
+ 's-alpha': { started: 1, live: true, events: [] },
285
+ 's-beta': { started: 2, live: true, events: [] },
286
+ },
287
+ });
288
+ await bridge.start();
289
+ connector = new AxomConnector(daemon, { ringSize: 2, backoffBaseMs: 50, sessionPollMs: 100 });
290
+ connect();
291
+ await waitFor(() => {
292
+ const sessions = connector.status().endpoints[0]?.sessions || [];
293
+ return sessions.length === 2 && sessions.every((s) => s.watching);
294
+ });
295
+
296
+ bridge.emit('s-alpha', envelope(1, 'pipeline_start', {}, 's-alpha'));
297
+ bridge.emit('s-beta', envelope(1, 'thought', { text: 'b' }, 's-beta'));
298
+ for (let i = 2; i <= 4; i++) bridge.emit('s-alpha', envelope(i, 'thought', {}, 's-alpha'));
299
+ await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 5);
300
+
301
+ // Broadcast routing: every envelope tagged with its own session, only.
302
+ for (const b of daemon.broadcasts.filter((x) => x.type === 'axom:event')) {
303
+ assert.equal(b.envelope.session, b.session);
304
+ }
305
+ // Backfill cursors never leak across sessions.
306
+ const alpha = connector.events('local', 's-alpha');
307
+ const beta = connector.events('local', 's-beta');
308
+ assert.ok(alpha.events.every((e) => e.session === 's-alpha'));
309
+ assert.deepEqual(beta.events.map((e) => e.session), ['s-beta']);
310
+ // Rings overflow independently: alpha (4 events, ring 2) overflowed, beta did not.
311
+ assert.equal(alpha.overflow, 2);
312
+ assert.equal(beta.overflow, 0);
313
+ });
314
+
315
+ it('passes a second stop through cleanly — sticky idempotence is preserved end to end', async () => {
316
+ connect();
317
+ await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
318
+ const first = await connector.stop('local', 's-test0001');
319
+ const second = await connector.stop('local', 's-test0001');
320
+ // The runtime's stop is sticky-idempotent; the proxy must neither error,
321
+ // dedupe, nor add semantics — both calls land, both return {ok}.
322
+ assert.deepEqual(first, { ok: true });
323
+ assert.deepEqual(second, { ok: true });
324
+ assert.equal(bridge.stops.length, 2);
325
+ });
326
+
327
+ it('survives a WS drop mid-flow with no gap and no duplicate when the producer never paused', async () => {
328
+ connect();
329
+ await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
330
+ for (let i = 1; i <= 3; i++) bridge.emit('s-test0001', envelope(i, 'thought'));
331
+ await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 3);
332
+
333
+ // Drop the socket while the producer keeps firing into the runtime ring.
334
+ bridge.sessions['s-test0001'].socket.terminate();
335
+ for (let i = 4; i <= 6; i++) bridge.emit('s-test0001', envelope(i, 'thought'));
336
+
337
+ // Reconnect replays 4-6 from the ring via ?since, then goes live for 7-8.
338
+ await waitFor(() => bridge.sinceSeen.length === 2);
339
+ bridge.emit('s-test0001', envelope(7, 'thought'));
340
+ bridge.emit('s-test0001', envelope(8, 'resolution'));
341
+ await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 8);
342
+ const ids = daemon.broadcasts.filter((b) => b.type === 'axom:event').map((b) => b.envelope.id);
343
+ assert.deepEqual(ids, Array.from({ length: 8 }, (_, i) => `ev-${String(i + 1).padStart(6, '0')}`));
344
+ });
345
+
346
+ it('message creates a caller-chosen session (202) and the connector attaches to it', async () => {
347
+ connect();
348
+ await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
349
+ const result = await connector.message('local', 's-mine-0001', 'hello axom');
350
+ assert.equal(result.status, 202);
351
+ assert.deepEqual(result.body, { session: 's-mine-0001', accepted: true });
352
+ // The post-202 poll discovers and watches the new session.
353
+ await waitFor(() => {
354
+ const s = connector.status().endpoints[0].sessions.find((x) => x.session === 's-mine-0001');
355
+ return s && s.watching;
356
+ });
357
+ });
358
+
359
+ it('mirrors §12 contract statuses verbatim — 409 busy, 413 too_long', async () => {
360
+ connect();
361
+ await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
362
+ await connector.message('local', 's-busy', 'first turn');
363
+ const busy = await connector.message('local', 's-busy', 'second turn');
364
+ assert.equal(busy.status, 409);
365
+ assert.equal(busy.body.error, 'busy');
366
+ const tooLong = await connector.message('local', 's-long', 'x'.repeat(40000));
367
+ assert.equal(tooLong.status, 413);
368
+ assert.equal(tooLong.body.max, 32768);
369
+ });
370
+
371
+ it('stays attached to a session between turns (live:false still watched)', async () => {
372
+ connect();
373
+ await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
374
+ bridge.sessions['s-test0001'].live = false;
375
+ // Force a poll cycle; the watch must not drop on live:false (§12: events
376
+ // for the next turn can start at any moment).
377
+ await new Promise((r) => setTimeout(r, 250));
378
+ const s = connector.status().endpoints[0].sessions.find((x) => x.session === 's-test0001');
379
+ assert.equal(s.live, false);
380
+ assert.equal(s.watching, true);
381
+ });
382
+
383
+ it('reports an unreachable endpoint honestly and recovers by retry', async () => {
384
+ const deadUrl = bridge.url;
385
+ const port = Number(new URL(deadUrl).port);
386
+ await bridge.close();
387
+ connector.configure([{ name: 'local', url: deadUrl }]);
388
+ await waitFor(() => connector.status().endpoints[0]?.status === 'error');
389
+ assert.ok(connector.status().endpoints[0].error);
390
+
391
+ // Bring a bridge back on the same port; backoff retry should reconnect.
392
+ bridge = new MockBridge();
393
+ await bridge.start(port);
394
+ await waitFor(() => connector.status().endpoints[0]?.status === 'connected', 5000);
395
+ });
396
+ });
397
+
398
+ describe('validateEndpoint', () => {
399
+ it('accepts a clean http endpoint', () => {
400
+ assert.equal(validateEndpoint({ name: 'local', url: 'http://127.0.0.1:8737' }), null);
401
+ });
402
+
403
+ it('rejects bad names, schemes, and embedded credentials', () => {
404
+ assert.ok(validateEndpoint({ name: 'bad name!', url: 'http://127.0.0.1:8737' }));
405
+ assert.ok(validateEndpoint({ name: 'x', url: 'ftp://127.0.0.1' }));
406
+ assert.ok(validateEndpoint({ name: 'x', url: 'javascript:alert(1)' }));
407
+ assert.ok(validateEndpoint({ name: 'x', url: 'http://user:pass@host:8737' }));
408
+ assert.ok(validateEndpoint({ name: 'x', url: 'not a url' }));
409
+ assert.ok(validateEndpoint({ name: 'x' }));
410
+ assert.ok(validateEndpoint(null));
411
+ });
412
+ });