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
@@ -52,6 +52,15 @@ const KEY_PLACEHOLDERS = {
52
52
  grok: 'xai-...',
53
53
  };
54
54
 
55
+ // installCommand is a string for most providers, but some (Ollama) return
56
+ // {command, alt, platform} — normalize so an object can never reach JSX as a
57
+ // child (React #31 white-screens the whole tab).
58
+ function installCommandText(installCommand) {
59
+ if (!installCommand) return null;
60
+ if (typeof installCommand === 'string') return installCommand;
61
+ return installCommand.command || null;
62
+ }
63
+
55
64
  function ProviderCard({ provider, onKeyChange }) {
56
65
  const [settingKey, setSettingKey] = useState(false);
57
66
  const [keyInput, setKeyInput] = useState('');
@@ -182,15 +191,15 @@ function ProviderCard({ provider, onKeyChange }) {
182
191
  </Button>
183
192
 
184
193
  {/* Manual install command */}
185
- {provider.installCommand && (
194
+ {installCommandText(provider.installCommand) && (
186
195
  <div className="space-y-1">
187
196
  <p className="text-2xs text-text-4 font-sans">Or install manually in your terminal:</p>
188
197
  <div className="flex items-center gap-1">
189
198
  <code className="flex-1 px-2 py-1.5 bg-surface-0 border border-border-subtle rounded text-2xs font-mono text-text-2 select-all">
190
- {provider.installCommand}
199
+ {installCommandText(provider.installCommand)}
191
200
  </code>
192
201
  <button
193
- onClick={() => { navigator.clipboard.writeText(provider.installCommand); addToast('success', 'Copied'); }}
202
+ onClick={() => { navigator.clipboard.writeText(installCommandText(provider.installCommand)); addToast('success', 'Copied'); }}
194
203
  className="p-1.5 text-text-4 hover:text-text-2 cursor-pointer"
195
204
  >
196
205
  <Copy size={10} />
@@ -1491,6 +1500,169 @@ function TrainingDataSection() {
1491
1500
  );
1492
1501
  }
1493
1502
 
1503
+ /* ── InnerChat Peers Section ──────────────────────────────── */
1504
+
1505
+ // Cross-daemon InnerChat: agents on this daemon can reach agents on a peer
1506
+ // daemon as `name@alias`. A peer is another Groove daemon you can already
1507
+ // reach (a tunnel-forwarded localhost port, a Tailscale address). Trust is
1508
+ // mutual — both machines must list each other, and signatures ride the
1509
+ // federation keypair — so this panel also surfaces THIS daemon's id to hand
1510
+ // to the other side.
1511
+ function InnerChatPeersSection() {
1512
+ const addToast = useGrooveStore((s) => s.addToast);
1513
+ const [peers, setPeers] = useState([]);
1514
+ const [ownId, setOwnId] = useState(null);
1515
+ const [loading, setLoading] = useState(true);
1516
+ const [adding, setAdding] = useState(false);
1517
+ const [form, setForm] = useState({ alias: '', url: '', daemonId: '' });
1518
+ const [busy, setBusy] = useState(false);
1519
+
1520
+ const load = () => {
1521
+ api.get('/innerchat/peers')
1522
+ .then((d) => setPeers(Array.isArray(d?.peers) ? d.peers : []))
1523
+ .catch(() => {})
1524
+ .finally(() => setLoading(false));
1525
+ api.get('/federation').then((d) => setOwnId(d?.id || null)).catch(() => {});
1526
+ };
1527
+ useEffect(() => { load(); }, []);
1528
+
1529
+ const canSubmit = form.alias.trim() && form.url.trim() && form.daemonId.trim();
1530
+
1531
+ async function addPeer() {
1532
+ if (!canSubmit) return;
1533
+ setBusy(true);
1534
+ try {
1535
+ const d = await api.post('/innerchat/peers', {
1536
+ alias: form.alias.trim(),
1537
+ url: form.url.trim(),
1538
+ daemonId: form.daemonId.trim().toLowerCase(),
1539
+ });
1540
+ setPeers(Array.isArray(d?.peers) ? d.peers : []);
1541
+ setForm({ alias: '', url: '', daemonId: '' });
1542
+ setAdding(false);
1543
+ addToast('success', `Peer "${form.alias.trim()}" added`);
1544
+ } catch (err) {
1545
+ addToast('error', 'Could not add peer', err.message);
1546
+ } finally {
1547
+ setBusy(false);
1548
+ }
1549
+ }
1550
+
1551
+ async function removePeer(alias) {
1552
+ try {
1553
+ const d = await api.delete(`/innerchat/peers/${encodeURIComponent(alias)}`);
1554
+ setPeers(Array.isArray(d?.peers) ? d.peers : []);
1555
+ addToast('info', `Removed peer "${alias}"`);
1556
+ } catch (err) {
1557
+ addToast('error', 'Remove failed', err.message);
1558
+ }
1559
+ }
1560
+
1561
+ return (
1562
+ <div>
1563
+ <div className="flex items-center gap-2 mb-2.5 px-0.5">
1564
+ <span className="text-2xs font-semibold text-text-3 font-sans uppercase tracking-wider">InnerChat Peers</span>
1565
+ <div className="flex-1 h-px bg-border-subtle" />
1566
+ <span className="text-2xs text-text-4 font-sans">
1567
+ {peers.length} peer{peers.length !== 1 ? 's' : ''} · address as <code className="text-text-3">name@alias</code>
1568
+ </span>
1569
+ </div>
1570
+
1571
+ <div className="rounded-lg border border-border-subtle bg-surface-1 px-4 py-3.5 flex flex-col gap-3">
1572
+ {/* This daemon's identity — to hand to the peer machine */}
1573
+ <div className="flex items-start gap-2.5">
1574
+ <div className="w-6 h-6 rounded bg-indigo/10 flex items-center justify-center flex-shrink-0 mt-0.5">
1575
+ <Share2 size={12} className="text-indigo" />
1576
+ </div>
1577
+ <div className="flex-1 min-w-0">
1578
+ <div className="text-[13px] font-medium text-text-0 font-sans leading-tight">Cross-daemon agent chat</div>
1579
+ <div className="text-2xs text-text-4 font-sans leading-relaxed mt-0.5">
1580
+ Agents reach a peer's agents as <code className="text-text-3">name@alias</code>. Add the peer on both
1581
+ machines — each lists the other's URL and daemon id. This daemon's id:
1582
+ </div>
1583
+ <div className="flex items-center gap-1.5 mt-1.5">
1584
+ <code className="h-7 px-2 flex items-center bg-surface-0 border border-border-subtle rounded-md text-2xs font-mono text-text-2 truncate min-w-0">
1585
+ {ownId || '—'}
1586
+ </code>
1587
+ {ownId && (
1588
+ <Button variant="secondary" size="sm" onClick={() => { navigator.clipboard.writeText(ownId); addToast('success', 'Copied daemon id'); }} className="h-7 px-2 flex-shrink-0">
1589
+ <Copy size={11} />
1590
+ </Button>
1591
+ )}
1592
+ </div>
1593
+ </div>
1594
+ </div>
1595
+
1596
+ {/* Peer list */}
1597
+ {loading ? (
1598
+ <Skeleton className="h-10 rounded-md" />
1599
+ ) : peers.length > 0 ? (
1600
+ <div className="flex flex-col gap-1.5">
1601
+ {peers.map((p) => (
1602
+ <div key={p.alias} className="flex items-center gap-2.5 px-2.5 py-2 rounded-md bg-surface-0 border border-border-subtle">
1603
+ <MessageCircle size={12} className="text-indigo flex-shrink-0" />
1604
+ <Badge variant="secondary" className="font-mono flex-shrink-0">@{p.alias}</Badge>
1605
+ <code className="text-2xs font-mono text-text-2 truncate min-w-0 flex-1">{p.url}</code>
1606
+ <code className="text-2xs font-mono text-text-4 flex-shrink-0 hidden sm:inline" title={p.daemonId}>{String(p.daemonId).slice(0, 8)}…</code>
1607
+ <button
1608
+ onClick={() => removePeer(p.alias)}
1609
+ className="text-text-4 hover:text-danger transition-colors flex-shrink-0 cursor-pointer"
1610
+ title={`Remove ${p.alias}`}
1611
+ >
1612
+ <Trash2 size={12} />
1613
+ </button>
1614
+ </div>
1615
+ ))}
1616
+ </div>
1617
+ ) : (
1618
+ <div className="text-2xs text-text-4 font-sans px-1 py-1">No peers configured — add one to let agents talk across machines.</div>
1619
+ )}
1620
+
1621
+ {/* Add form */}
1622
+ {adding ? (
1623
+ <div className="flex flex-col gap-2 pt-1 border-t border-border-subtle">
1624
+ <div className="grid grid-cols-[1fr_2fr] gap-2">
1625
+ <input
1626
+ value={form.alias}
1627
+ onChange={(e) => setForm((f) => ({ ...f, alias: e.target.value }))}
1628
+ placeholder="alias (e.g. spark)"
1629
+ className="h-8 px-2.5 text-xs bg-surface-0 border border-border-subtle rounded-md text-text-0 font-mono placeholder:text-text-4 focus:outline-none focus:ring-1 focus:ring-accent"
1630
+ />
1631
+ <input
1632
+ value={form.url}
1633
+ onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
1634
+ placeholder="http://localhost:62686 (tunnel / Tailscale URL)"
1635
+ className="h-8 px-2.5 text-xs bg-surface-0 border border-border-subtle rounded-md text-text-0 font-mono placeholder:text-text-4 focus:outline-none focus:ring-1 focus:ring-accent"
1636
+ />
1637
+ </div>
1638
+ <input
1639
+ value={form.daemonId}
1640
+ onChange={(e) => setForm((f) => ({ ...f, daemonId: e.target.value }))}
1641
+ placeholder="peer daemon id (from the other machine's Settings)"
1642
+ className="h-8 px-2.5 text-xs bg-surface-0 border border-border-subtle rounded-md text-text-0 font-mono placeholder:text-text-4 focus:outline-none focus:ring-1 focus:ring-accent"
1643
+ />
1644
+ <div className="flex items-center justify-end gap-2">
1645
+ <Button variant="ghost" size="sm" onClick={() => { setAdding(false); setForm({ alias: '', url: '', daemonId: '' }); }} disabled={busy}>Cancel</Button>
1646
+ <Button variant="primary" size="sm" onClick={addPeer} disabled={!canSubmit || busy}>
1647
+ {busy ? <Loader2 size={12} className="animate-spin" /> : <Check size={12} />}
1648
+ Add Peer
1649
+ </Button>
1650
+ </div>
1651
+ </div>
1652
+ ) : (
1653
+ <button
1654
+ onClick={() => setAdding(true)}
1655
+ className="flex items-center justify-center gap-1.5 h-8 rounded-md border border-dashed border-border-subtle text-2xs font-sans text-text-3 hover:text-accent hover:border-accent/40 transition-colors cursor-pointer"
1656
+ >
1657
+ <Plus size={12} />
1658
+ Add Peer
1659
+ </button>
1660
+ )}
1661
+ </div>
1662
+ </div>
1663
+ );
1664
+ }
1665
+
1494
1666
  /* ── Early Access Section ─────────────────────────────────── */
1495
1667
 
1496
1668
  function EarlyAccessSection() {
@@ -1791,6 +1963,9 @@ export default function SettingsView() {
1791
1963
  </div>
1792
1964
  )}
1793
1965
 
1966
+ {/* ═══════ INNERCHAT PEERS ═══════ */}
1967
+ <InnerChatPeersSection />
1968
+
1794
1969
  {/* ═══════ EARLY ACCESS ═══════ */}
1795
1970
  <EarlyAccessSection />
1796
1971
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.27.197",
3
+ "version": "0.27.198",
4
4
  "description": "Open-source agent orchestration layer — the AI company OS. Local model agent engine (GGUF/Ollama/llama-server), HuggingFace model browser, MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama, any local model.",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
@@ -58,7 +58,7 @@
58
58
  "start:desktop": "npm run start -w packages/desktop",
59
59
  "build:desktop": "npm run build -w packages/desktop",
60
60
  "dist:desktop": "npm run build && npm run dist -w packages/desktop",
61
- "test": "node --test packages/daemon/test/*.test.js",
61
+ "test": "node --test-force-exit --test packages/daemon/test/*.test.js",
62
62
  "prepublishOnly": "npm run build"
63
63
  },
64
64
  "bundleDependencies": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.197",
3
+ "version": "0.27.198",
4
4
  "description": "GROOVE CLI — manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.197",
3
+ "version": "0.27.198",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -31,6 +31,7 @@ import { registerInnerChatRoutes } from './routes/innerchat.js';
31
31
  import { registerChatHistoryRoutes } from './routes/chat-history.js';
32
32
  import { registerWatchRoutes } from './routes/watch.js';
33
33
  import { registerAutoAgentRoutes } from './routes/auto-agents.js';
34
+ import { registerAxomRoutes } from './routes/axom.js';
34
35
 
35
36
  const __dirname = dirname(fileURLToPath(import.meta.url));
36
37
  const pkgVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
@@ -183,6 +184,7 @@ export function createApi(app, daemon) {
183
184
  registerChatHistoryRoutes(app, daemon);
184
185
  registerWatchRoutes(app, daemon);
185
186
  registerAutoAgentRoutes(app, daemon);
187
+ registerAxomRoutes(app, daemon);
186
188
 
187
189
 
188
190
  // Token usage
@@ -0,0 +1,340 @@
1
+ // GROOVE — Axom Connector
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+ //
4
+ // Consumes the Axom provider protocol (GROOVE ⇄ Axom Integration Contract v0,
5
+ // Axom-Labs/Axom-Private docs/GROOVE_INTEGRATION.md):
6
+ //
7
+ // GET /about identity + KINDS schema handshake
8
+ // GET /sessions [{session, started, live}]
9
+ // WS /ws/session/{id}?since=<ev-id> envelope stream, verbatim
10
+ // POST /session/{id}/interrupt {text} -> {id, truncated}
11
+ // POST /session/{id}/stop {} -> {ok}
12
+ //
13
+ // The envelope schema is additive-only: unknown kinds are surfaced and passed
14
+ // through, never dropped. GROOVE consumes the protocol; it never imports Axom
15
+ // code. Host-agnostic by contract §5 — an endpoint is a URL, nothing here may
16
+ // assume where the runtime lives.
17
+
18
+ import { WebSocket } from 'ws';
19
+
20
+ export const AXOM_DEFAULT_PORT = 8737;
21
+
22
+ // The frozen KINDS enumeration from Axom's events.py, mirrored for the /about
23
+ // handshake. Drift (either direction) is surfaced on the endpoint status —
24
+ // new kinds still flow through; this list is for visibility, not filtering.
25
+ export const KNOWN_KINDS = Object.freeze([
26
+ 'pipeline_start', 'firing_start', 'step_start', 'firing_end', 'pipeline_done',
27
+ 'thought', 'text', 'action', 'observation', 'resolution',
28
+ 'delegate', 'yield', 'interrupt', 'interrupt_ack',
29
+ 'trace_other', 'tool_start', 'tool_end', 'recall_end',
30
+ 'swarm_start', 'swarm_weights', 'swarm_agent_done', 'swarm_agent_error',
31
+ 'swarm_memory_fused', 'swarm_fused', 'swarm_facts_extracted', 'swarm_done',
32
+ 'leaf_swap', 'stop_requested', 'stop_effected', 'candidate_banked',
33
+ 'narration', 'narration_dropped',
34
+ 'candidate_arrived', 'evidence_scored', 'champion_changed',
35
+ 'confidence_updated', 'verifier_verdict',
36
+ ]);
37
+
38
+ const RING_SIZE = 4096;
39
+ const SESSION_POLL_MS = 15000;
40
+ const BACKOFF_BASE_MS = 1000;
41
+ const BACKOFF_MAX_MS = 30000;
42
+ const FETCH_TIMEOUT_MS = 5000;
43
+
44
+ export function validateEndpoint(entry) {
45
+ if (!entry || typeof entry !== 'object') return 'endpoint must be an object';
46
+ const { name, url } = entry;
47
+ if (!name || typeof name !== 'string' || !/^[a-zA-Z0-9_-]{1,40}$/.test(name)) {
48
+ return 'endpoint name must be 1-40 chars (letters, digits, dash, underscore)';
49
+ }
50
+ if (!url || typeof url !== 'string') return 'endpoint url is required';
51
+ let parsed;
52
+ try { parsed = new URL(url); } catch { return `invalid url: ${url}`; }
53
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
54
+ return 'endpoint url must be http(s)';
55
+ }
56
+ if (parsed.username || parsed.password) return 'credentials in the url are not allowed';
57
+ return null;
58
+ }
59
+
60
+ function seqOf(eventId) {
61
+ if (typeof eventId !== 'string' || !eventId.startsWith('ev-')) return null;
62
+ const n = parseInt(eventId.slice(3), 10);
63
+ return Number.isFinite(n) ? n : null;
64
+ }
65
+
66
+ export class AxomConnector {
67
+ constructor(daemon, opts = {}) {
68
+ this.daemon = daemon;
69
+ this.ringSize = opts.ringSize || RING_SIZE;
70
+ this.backoffBaseMs = opts.backoffBaseMs || BACKOFF_BASE_MS;
71
+ this.sessionPollMs = opts.sessionPollMs || SESSION_POLL_MS;
72
+ this.endpoints = new Map(); // name -> endpoint state
73
+ this.destroyed = false;
74
+ }
75
+
76
+ start() {
77
+ const configured = this.daemon.config?.axom?.endpoints || [];
78
+ this.configure(configured);
79
+ }
80
+
81
+ // Reconcile live connections against a validated endpoint list. Persistence
82
+ // is the caller's job (routes save config); this manages connections only.
83
+ configure(entries) {
84
+ const keep = new Set();
85
+ for (const entry of entries) {
86
+ if (validateEndpoint(entry)) continue; // routes validate; skip defensively
87
+ keep.add(entry.name);
88
+ const existing = this.endpoints.get(entry.name);
89
+ if (existing && existing.url === entry.url.replace(/\/+$/, '')) continue;
90
+ if (existing) this._teardownEndpoint(existing);
91
+ this._connectEndpoint(entry);
92
+ }
93
+ for (const [name, ep] of this.endpoints) {
94
+ if (!keep.has(name)) {
95
+ this._teardownEndpoint(ep);
96
+ this.endpoints.delete(name);
97
+ }
98
+ }
99
+ }
100
+
101
+ _connectEndpoint({ name, url }) {
102
+ const ep = {
103
+ name,
104
+ url: url.replace(/\/+$/, ''),
105
+ status: 'connecting', // connecting | connected | error
106
+ error: null,
107
+ about: null,
108
+ drift: null, // {novel: [], missing: []} vs KNOWN_KINDS
109
+ sessions: new Map(), // sessionId -> session state
110
+ pollTimer: null,
111
+ backoffMs: this.backoffBaseMs,
112
+ };
113
+ this.endpoints.set(name, ep);
114
+ this._handshake(ep);
115
+ return ep;
116
+ }
117
+
118
+ async _fetch(ep, path, options = {}) {
119
+ const controller = new AbortController();
120
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
121
+ try {
122
+ const res = await fetch(`${ep.url}${path}`, { ...options, signal: controller.signal });
123
+ if (!res.ok) throw new Error(`HTTP ${res.status} from ${path}`);
124
+ return await res.json();
125
+ } finally {
126
+ clearTimeout(timer);
127
+ }
128
+ }
129
+
130
+ async _handshake(ep) {
131
+ if (this.destroyed) return;
132
+ try {
133
+ const about = await this._fetch(ep, '/about');
134
+ ep.about = about;
135
+ // Mechanical schema handshake (contract §6): compare, surface, never drop.
136
+ const remote = Array.isArray(about.kinds) ? about.kinds : [];
137
+ ep.drift = {
138
+ novel: remote.filter((k) => !KNOWN_KINDS.includes(k)),
139
+ missing: remote.length ? KNOWN_KINDS.filter((k) => !remote.includes(k)) : [],
140
+ };
141
+ ep.status = 'connected';
142
+ ep.error = null;
143
+ ep.backoffMs = this.backoffBaseMs;
144
+ this._broadcastStatus();
145
+ await this._pollSessions(ep);
146
+ ep.pollTimer = setInterval(() => {
147
+ this._pollSessions(ep).catch(() => this._endpointLost(ep));
148
+ }, this.sessionPollMs);
149
+ } catch (err) {
150
+ ep.status = 'error';
151
+ ep.error = err.message;
152
+ this._broadcastStatus();
153
+ this._scheduleEndpointRetry(ep);
154
+ }
155
+ }
156
+
157
+ _endpointLost(ep) {
158
+ if (this.destroyed || ep.status === 'error') return;
159
+ ep.status = 'error';
160
+ ep.error = 'endpoint unreachable';
161
+ if (ep.pollTimer) { clearInterval(ep.pollTimer); ep.pollTimer = null; }
162
+ this._broadcastStatus();
163
+ this._scheduleEndpointRetry(ep);
164
+ }
165
+
166
+ _scheduleEndpointRetry(ep) {
167
+ if (this.destroyed) return;
168
+ ep.retryTimer = setTimeout(() => {
169
+ ep.backoffMs = Math.min(ep.backoffMs * 2, BACKOFF_MAX_MS);
170
+ this._handshake(ep);
171
+ }, ep.backoffMs);
172
+ }
173
+
174
+ async _pollSessions(ep) {
175
+ const list = await this._fetch(ep, '/sessions');
176
+ if (!Array.isArray(list)) return;
177
+ for (const info of list) {
178
+ const id = info.session;
179
+ if (!id || typeof id !== 'string') continue;
180
+ let s = ep.sessions.get(id);
181
+ if (!s) {
182
+ s = {
183
+ id,
184
+ started: info.started ?? null,
185
+ live: !!info.live,
186
+ ws: null,
187
+ lastSeq: 0,
188
+ ring: [],
189
+ overflow: 0,
190
+ unknownKinds: {},
191
+ reconnectTimer: null,
192
+ };
193
+ ep.sessions.set(id, s);
194
+ }
195
+ // §12: `live` means a turn is in flight; sessions persist between turns
196
+ // and events can start at any moment — stay attached regardless.
197
+ s.live = !!info.live;
198
+ if (!s.ws) this._watchSession(ep, s);
199
+ }
200
+ }
201
+
202
+ _watchSession(ep, s) {
203
+ if (this.destroyed) return;
204
+ const since = s.lastSeq > 0 ? `?since=ev-${String(s.lastSeq).padStart(6, '0')}` : '';
205
+ const wsUrl = `${ep.url.replace(/^http/, 'ws')}/ws/session/${encodeURIComponent(s.id)}${since}`;
206
+ const ws = new WebSocket(wsUrl);
207
+ s.ws = ws;
208
+
209
+ ws.on('message', (data) => {
210
+ let envelope;
211
+ try { envelope = JSON.parse(data.toString()); } catch { return; }
212
+ const seq = seqOf(envelope.id);
213
+ // Dedup on ring-buffer replay after reconnect — ids are monotonic.
214
+ if (seq !== null && seq <= s.lastSeq) return;
215
+ if (seq !== null) s.lastSeq = seq;
216
+ if (s.ring.length >= this.ringSize) {
217
+ s.ring.shift();
218
+ // The ring bounds memory, not delivery: overflow is counted, never
219
+ // silent, and every event still broadcasts — mirrors the runtime.
220
+ s.overflow += 1;
221
+ }
222
+ s.ring.push(envelope);
223
+ if (envelope.kind && !KNOWN_KINDS.includes(envelope.kind)) {
224
+ s.unknownKinds[envelope.kind] = (s.unknownKinds[envelope.kind] || 0) + 1;
225
+ }
226
+ // Verbatim passthrough — the envelope is the contract, GROOVE adds
227
+ // routing metadata around it, never inside it.
228
+ this.daemon.broadcast({ type: 'axom:event', endpoint: ep.name, session: s.id, envelope });
229
+ });
230
+
231
+ const onGone = () => {
232
+ if (s.ws !== ws) return;
233
+ s.ws = null;
234
+ // Identity check, not name check: after configure() replaces an
235
+ // endpoint, a late close from the old socket must not re-arm a
236
+ // reconnect loop on the orphaned object — destroy() could never reach
237
+ // it (this exact leak wedged a test worker into an infinite loop).
238
+ if (this.destroyed || this.endpoints.get(ep.name) !== ep) return;
239
+ s.reconnectTimer = setTimeout(() => this._watchSession(ep, s), ep.backoffMs);
240
+ };
241
+ ws.on('close', onGone);
242
+ ws.on('error', onGone);
243
+ }
244
+
245
+ // §12 message verb: starts a turn (interrupt steers, stop halts). Status is
246
+ // part of the contract (202 accepted / 409 busy / 413 too_long) so this
247
+ // returns {status, body} verbatim instead of throwing on non-2xx.
248
+ async message(endpointName, sessionId, text) {
249
+ const ep = this._requireEndpoint(endpointName);
250
+ const res = await fetch(`${ep.url}/session/${encodeURIComponent(sessionId)}/message`, {
251
+ method: 'POST',
252
+ headers: { 'Content-Type': 'application/json' },
253
+ body: JSON.stringify({ text }),
254
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
255
+ });
256
+ const body = await res.json().catch(() => ({}));
257
+ // First message creates the session (caller-chosen id) — poll now so the
258
+ // WS attaches before the turn's first events age out of nothing.
259
+ if (res.status === 202) this._pollSessions(ep).catch(() => {});
260
+ return { status: res.status, body };
261
+ }
262
+
263
+ async interrupt(endpointName, sessionId, text) {
264
+ const ep = this._requireEndpoint(endpointName);
265
+ return this._fetch(ep, `/session/${encodeURIComponent(sessionId)}/interrupt`, {
266
+ method: 'POST',
267
+ headers: { 'Content-Type': 'application/json' },
268
+ body: JSON.stringify({ text }),
269
+ });
270
+ }
271
+
272
+ async stop(endpointName, sessionId) {
273
+ const ep = this._requireEndpoint(endpointName);
274
+ return this._fetch(ep, `/session/${encodeURIComponent(sessionId)}/stop`, {
275
+ method: 'POST',
276
+ headers: { 'Content-Type': 'application/json' },
277
+ body: JSON.stringify({}),
278
+ });
279
+ }
280
+
281
+ _requireEndpoint(name) {
282
+ // Single-endpoint convenience: an omitted name resolves iff unambiguous.
283
+ if (!name && this.endpoints.size === 1) return this.endpoints.values().next().value;
284
+ const ep = this.endpoints.get(name);
285
+ if (!ep) throw new Error(`No Axom endpoint named "${name}"`);
286
+ return ep;
287
+ }
288
+
289
+ events(endpointName, sessionId, sinceSeq = 0) {
290
+ const ep = this._requireEndpoint(endpointName);
291
+ const s = ep.sessions.get(sessionId);
292
+ if (!s) throw new Error(`No session "${sessionId}" on endpoint "${ep.name}"`);
293
+ const events = sinceSeq > 0 ? s.ring.filter((e) => (seqOf(e.id) || 0) > sinceSeq) : s.ring;
294
+ return { session: s.id, overflow: s.overflow, events };
295
+ }
296
+
297
+ status() {
298
+ return {
299
+ endpoints: [...this.endpoints.values()].map((ep) => ({
300
+ name: ep.name,
301
+ url: ep.url,
302
+ status: ep.status,
303
+ error: ep.error,
304
+ about: ep.about,
305
+ drift: ep.drift,
306
+ sessions: [...ep.sessions.values()].map((s) => ({
307
+ session: s.id,
308
+ started: s.started,
309
+ live: s.live,
310
+ watching: !!s.ws,
311
+ lastEventId: s.lastSeq > 0 ? `ev-${String(s.lastSeq).padStart(6, '0')}` : null,
312
+ buffered: s.ring.length,
313
+ overflow: s.overflow,
314
+ unknownKinds: s.unknownKinds,
315
+ })),
316
+ })),
317
+ };
318
+ }
319
+
320
+ _broadcastStatus() {
321
+ this.daemon.broadcast({ type: 'axom:status', data: this.status() });
322
+ }
323
+
324
+ _teardownEndpoint(ep) {
325
+ if (ep.pollTimer) clearInterval(ep.pollTimer);
326
+ if (ep.retryTimer) clearTimeout(ep.retryTimer);
327
+ for (const s of ep.sessions.values()) {
328
+ if (s.reconnectTimer) clearTimeout(s.reconnectTimer);
329
+ // terminate, not close: close() on a CONNECTING socket defers teardown
330
+ // until the connect completes — a live handle that can outlast us.
331
+ if (s.ws) { try { s.ws.terminate(); } catch { /* already gone */ } s.ws = null; }
332
+ }
333
+ }
334
+
335
+ destroy() {
336
+ this.destroyed = true;
337
+ for (const ep of this.endpoints.values()) this._teardownEndpoint(ep);
338
+ this.endpoints.clear();
339
+ }
340
+ }