groove-dev 0.27.198 → 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 (44) hide show
  1. package/axom-integration/Screenshot_2026-07-25_at_6.44.56_PM.png +0 -0
  2. package/axom-integration/Screenshot_2026-07-25_at_6.45.35_PM.png +0 -0
  3. package/node_modules/@groove-dev/cli/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/package.json +1 -1
  5. package/node_modules/@groove-dev/daemon/src/axom-connector.js +30 -2
  6. package/node_modules/@groove-dev/daemon/src/axom-install.js +24 -2
  7. package/node_modules/@groove-dev/daemon/src/axom-remote.js +230 -0
  8. package/node_modules/@groove-dev/daemon/src/axom-server.js +22 -2
  9. package/node_modules/@groove-dev/daemon/src/index.js +3 -0
  10. package/node_modules/@groove-dev/daemon/src/routes/axom.js +73 -2
  11. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +190 -8
  12. package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +44 -1
  13. package/node_modules/@groove-dev/daemon/test/axom-remote.test.js +115 -0
  14. package/node_modules/@groove-dev/daemon/test/axom-server.test.js +50 -9
  15. package/node_modules/@groove-dev/gui/dist/assets/{index-b9dKN6cq.js → index-BbA-X4CE.js} +243 -233
  16. package/node_modules/@groove-dev/gui/dist/assets/index-BbE3qX83.css +1 -0
  17. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  18. package/node_modules/@groove-dev/gui/package.json +1 -1
  19. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +3 -1
  20. package/node_modules/@groove-dev/gui/src/stores/groove.js +4 -1
  21. package/node_modules/@groove-dev/gui/src/stores/slices/axom-slice.js +83 -1
  22. package/node_modules/@groove-dev/gui/src/views/axom.jsx +1147 -310
  23. package/node_modules/@groove-dev/gui/src/views/settings.jsx +123 -70
  24. package/package.json +1 -1
  25. package/packages/cli/package.json +1 -1
  26. package/packages/daemon/package.json +1 -1
  27. package/packages/daemon/src/axom-connector.js +30 -2
  28. package/packages/daemon/src/axom-install.js +24 -2
  29. package/packages/daemon/src/axom-remote.js +230 -0
  30. package/packages/daemon/src/axom-server.js +22 -2
  31. package/packages/daemon/src/index.js +3 -0
  32. package/packages/daemon/src/routes/axom.js +73 -2
  33. package/packages/daemon/src/routes/innerchat.js +190 -8
  34. package/packages/gui/dist/assets/{index-b9dKN6cq.js → index-BbA-X4CE.js} +243 -233
  35. package/packages/gui/dist/assets/index-BbE3qX83.css +1 -0
  36. package/packages/gui/dist/index.html +2 -2
  37. package/packages/gui/package.json +1 -1
  38. package/packages/gui/src/components/agents/agent-feed.jsx +3 -1
  39. package/packages/gui/src/stores/groove.js +4 -1
  40. package/packages/gui/src/stores/slices/axom-slice.js +83 -1
  41. package/packages/gui/src/views/axom.jsx +1147 -310
  42. package/packages/gui/src/views/settings.jsx +123 -70
  43. package/node_modules/@groove-dev/gui/dist/assets/index-RbtaI6l7.css +0 -1
  44. package/packages/gui/dist/assets/index-RbtaI6l7.css +0 -1
@@ -21,7 +21,7 @@ import {
21
21
  ShieldCheck, Settings, Lock, Database, Shield,
22
22
  Newspaper, Radio, Send, MessageSquare, MessageCircle,
23
23
  Plus, Trash2, Plug, PlugZap, TestTube, X, HelpCircle, ExternalLink,
24
- Sparkles, Share2, Gift,
24
+ Sparkles, Share2, Gift, AlertCircle,
25
25
  } from 'lucide-react';
26
26
 
27
27
  /* ── Toggle ────────────────────────────────────────────────── */
@@ -1502,52 +1502,61 @@ function TrainingDataSection() {
1502
1502
 
1503
1503
  /* ── InnerChat Peers Section ──────────────────────────────── */
1504
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 keypairso this panel also surfaces THIS daemon's id to hand
1510
- // to the other side.
1505
+ // Cross-daemon InnerChat: agents here reach a peer's agents as `name@alias`.
1506
+ // A peer is another Groove daemon at any URL you can already reach (a
1507
+ // tunnel-forwarded local port, a Tailscale address). Adding one discovers its
1508
+ // id and trades public keys automatically, so the user supplies only a name
1509
+ // and a URL everything else this panel shows rather than asks for.
1511
1510
  function InnerChatPeersSection() {
1512
1511
  const addToast = useGrooveStore((s) => s.addToast);
1513
1512
  const [peers, setPeers] = useState([]);
1514
- const [ownId, setOwnId] = useState(null);
1513
+ const [identity, setIdentity] = useState(null);
1515
1514
  const [loading, setLoading] = useState(true);
1516
1515
  const [adding, setAdding] = useState(false);
1517
- const [form, setForm] = useState({ alias: '', url: '', daemonId: '' });
1516
+ const [form, setForm] = useState({ alias: '', url: '' });
1518
1517
  const [busy, setBusy] = useState(false);
1518
+ const [tests, setTests] = useState({});
1519
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(); }, []);
1520
+ const loadPeers = () => api.get('/innerchat/peers')
1521
+ .then((d) => setPeers(Array.isArray(d?.peers) ? d.peers : []))
1522
+ .catch(() => {});
1528
1523
 
1529
- const canSubmit = form.alias.trim() && form.url.trim() && form.daemonId.trim();
1524
+ useEffect(() => {
1525
+ Promise.all([
1526
+ loadPeers(),
1527
+ api.get('/innerchat/identity').then(setIdentity).catch(() => {}),
1528
+ ]).finally(() => setLoading(false));
1529
+ }, []);
1530
+
1531
+ const canSubmit = form.alias.trim() && form.url.trim();
1530
1532
 
1531
1533
  async function addPeer() {
1532
1534
  if (!canSubmit) return;
1533
1535
  setBusy(true);
1534
1536
  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
- });
1537
+ const d = await api.post('/innerchat/peers', { alias: form.alias.trim(), url: form.url.trim() });
1540
1538
  setPeers(Array.isArray(d?.peers) ? d.peers : []);
1541
- setForm({ alias: '', url: '', daemonId: '' });
1539
+ setForm({ alias: '', url: '' });
1542
1540
  setAdding(false);
1543
- addToast('success', `Peer "${form.alias.trim()}" added`);
1541
+ addToast(d.keyPushed ? 'success' : 'warning', `Connected to ${form.alias.trim()}`, d.note);
1542
+ testPeer(form.alias.trim());
1544
1543
  } catch (err) {
1545
- addToast('error', 'Could not add peer', err.message);
1544
+ addToast('error', 'Could not connect', err.message);
1546
1545
  } finally {
1547
1546
  setBusy(false);
1548
1547
  }
1549
1548
  }
1550
1549
 
1550
+ async function testPeer(alias) {
1551
+ setTests((t) => ({ ...t, [alias]: { testing: true } }));
1552
+ try {
1553
+ const r = await api.get(`/innerchat/peers/${encodeURIComponent(alias)}/test`);
1554
+ setTests((t) => ({ ...t, [alias]: r }));
1555
+ } catch (err) {
1556
+ setTests((t) => ({ ...t, [alias]: { ok: false, error: err.message } }));
1557
+ }
1558
+ }
1559
+
1551
1560
  async function removePeer(alias) {
1552
1561
  try {
1553
1562
  const d = await api.delete(`/innerchat/peers/${encodeURIComponent(alias)}`);
@@ -1564,88 +1573,133 @@ function InnerChatPeersSection() {
1564
1573
  <span className="text-2xs font-semibold text-text-3 font-sans uppercase tracking-wider">InnerChat Peers</span>
1565
1574
  <div className="flex-1 h-px bg-border-subtle" />
1566
1575
  <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>
1576
+ {peers.length} peer{peers.length !== 1 ? 's' : ''} · agents address them as <code className="text-text-3">name@peer</code>
1568
1577
  </span>
1569
1578
  </div>
1570
1579
 
1571
1580
  <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
1581
  <div className="flex items-start gap-2.5">
1574
1582
  <div className="w-6 h-6 rounded bg-indigo/10 flex items-center justify-center flex-shrink-0 mt-0.5">
1575
1583
  <Share2 size={12} className="text-indigo" />
1576
1584
  </div>
1577
1585
  <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>
1586
+ <div className="text-[13px] font-medium text-text-0 font-sans leading-tight">Let agents talk across machines</div>
1579
1587
  <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
- )}
1588
+ Add another Groove machine below and your agents can consult its agents directly as{' '}
1589
+ <code className="text-text-3">name@peer</code>. Keys are exchanged for you nothing else to set up.
1592
1590
  </div>
1593
1591
  </div>
1594
1592
  </div>
1595
1593
 
1594
+ {/* This machine — what the OTHER side needs */}
1595
+ <div className="rounded-md bg-surface-0 border border-border-subtle px-3 py-2.5 flex flex-col gap-2">
1596
+ <div className="text-2xs font-semibold text-text-3 font-sans uppercase tracking-wider">This machine</div>
1597
+ <div className="flex items-center gap-2">
1598
+ <span className="text-2xs text-text-4 font-sans w-16 flex-shrink-0">Daemon ID</span>
1599
+ <code className="flex-1 text-2xs font-mono text-text-2 truncate min-w-0">{identity?.daemonId || '—'}</code>
1600
+ {identity?.daemonId && (
1601
+ <button
1602
+ onClick={() => { navigator.clipboard.writeText(identity.daemonId); addToast('success', 'Copied'); }}
1603
+ className="text-text-4 hover:text-accent transition-colors flex-shrink-0 cursor-pointer"
1604
+ title="Copy daemon id"
1605
+ >
1606
+ <Copy size={11} />
1607
+ </button>
1608
+ )}
1609
+ </div>
1610
+ <div className="flex items-start gap-2">
1611
+ <span className="text-2xs text-text-4 font-sans w-16 flex-shrink-0 pt-px">Reach me</span>
1612
+ <span className="flex-1 text-2xs text-text-3 font-sans leading-relaxed min-w-0">
1613
+ On this machine: <code className="text-text-2">http://127.0.0.1:{identity?.port || 31415}</code>.
1614
+ From another machine, use whatever address forwards here — an SSH tunnel&apos;s local port on that side,
1615
+ or this host&apos;s Tailscale address.
1616
+ </span>
1617
+ </div>
1618
+ </div>
1619
+
1596
1620
  {/* Peer list */}
1597
1621
  {loading ? (
1598
1622
  <Skeleton className="h-10 rounded-md" />
1599
1623
  ) : peers.length > 0 ? (
1600
1624
  <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
- ))}
1625
+ {peers.map((p) => {
1626
+ const t = tests[p.alias];
1627
+ return (
1628
+ <div key={p.alias} className="rounded-md bg-surface-0 border border-border-subtle px-2.5 py-2 flex flex-col gap-1.5">
1629
+ <div className="flex items-center gap-2.5">
1630
+ <MessageCircle size={12} className="text-indigo flex-shrink-0" />
1631
+ <Badge variant="default" className="font-mono flex-shrink-0 normal-case">@{p.alias}</Badge>
1632
+ <code className="text-2xs font-mono text-text-2 truncate min-w-0 flex-1">{p.url}</code>
1633
+ {t?.testing ? (
1634
+ <Loader2 size={11} className="animate-spin text-text-3 flex-shrink-0" />
1635
+ ) : t ? (
1636
+ <Badge variant={t.ok ? 'success' : 'danger'} className="flex-shrink-0">
1637
+ {t.ok ? <Check size={9} /> : <AlertCircle size={9} />}
1638
+ {t.ok ? 'Connected' : 'Problem'}
1639
+ </Badge>
1640
+ ) : null}
1641
+ <button
1642
+ onClick={() => testPeer(p.alias)}
1643
+ className="text-text-4 hover:text-accent transition-colors flex-shrink-0 cursor-pointer"
1644
+ title="Test connection"
1645
+ >
1646
+ <TestTube size={12} />
1647
+ </button>
1648
+ <button
1649
+ onClick={() => removePeer(p.alias)}
1650
+ className="text-text-4 hover:text-danger transition-colors flex-shrink-0 cursor-pointer"
1651
+ title={`Remove ${p.alias}`}
1652
+ >
1653
+ <Trash2 size={12} />
1654
+ </button>
1655
+ </div>
1656
+ {t && !t.testing && (
1657
+ <div className="text-2xs font-sans pl-6 leading-relaxed">
1658
+ {t.ok ? (
1659
+ <span className="text-text-4">
1660
+ v{t.peerVersion} · {t.agents?.length || 0} agent{t.agents?.length !== 1 ? 's' : ''}
1661
+ {t.agents?.length ? <> — try <code className="text-text-3">{t.agents[0]}@{p.alias}</code></> : null}
1662
+ </span>
1663
+ ) : (
1664
+ <span className="text-danger">{t.error}</span>
1665
+ )}
1666
+ </div>
1667
+ )}
1668
+ </div>
1669
+ );
1670
+ })}
1616
1671
  </div>
1617
1672
  ) : (
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>
1673
+ <div className="text-2xs text-text-4 font-sans px-1 py-1">No peers yet — add one to let agents talk across machines.</div>
1619
1674
  )}
1620
1675
 
1621
- {/* Add form */}
1676
+ {/* Add form — name + URL only; id and keys are handled for you */}
1622
1677
  {adding ? (
1623
1678
  <div className="flex flex-col gap-2 pt-1 border-t border-border-subtle">
1624
1679
  <div className="grid grid-cols-[1fr_2fr] gap-2">
1625
1680
  <input
1626
1681
  value={form.alias}
1627
1682
  onChange={(e) => setForm((f) => ({ ...f, alias: e.target.value }))}
1628
- placeholder="alias (e.g. spark)"
1683
+ placeholder="name (e.g. spark)"
1629
1684
  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
1685
  />
1631
1686
  <input
1632
1687
  value={form.url}
1633
1688
  onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
1634
- placeholder="http://localhost:62686 (tunnel / Tailscale URL)"
1689
+ onKeyDown={(e) => { if (e.key === 'Enter' && canSubmit && !busy) addPeer(); }}
1690
+ placeholder="http://127.0.0.1:31416"
1635
1691
  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
1692
  />
1637
1693
  </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
- />
1694
+ <div className="text-2xs text-text-4 font-sans px-0.5 leading-relaxed">
1695
+ The URL is where that machine is reachable <em>from here</em> — if you connect over an SSH tunnel that is
1696
+ the forwarded local port (not 31415, which is this machine).
1697
+ </div>
1644
1698
  <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>
1699
+ <Button variant="ghost" size="sm" onClick={() => { setAdding(false); setForm({ alias: '', url: '' }); }} disabled={busy}>Cancel</Button>
1646
1700
  <Button variant="primary" size="sm" onClick={addPeer} disabled={!canSubmit || busy}>
1647
1701
  {busy ? <Loader2 size={12} className="animate-spin" /> : <Check size={12} />}
1648
- Add Peer
1702
+ Connect
1649
1703
  </Button>
1650
1704
  </div>
1651
1705
  </div>
@@ -1655,14 +1709,13 @@ function InnerChatPeersSection() {
1655
1709
  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
1710
  >
1657
1711
  <Plus size={12} />
1658
- Add Peer
1712
+ Add Peer Machine
1659
1713
  </button>
1660
1714
  )}
1661
1715
  </div>
1662
1716
  </div>
1663
1717
  );
1664
1718
  }
1665
-
1666
1719
  /* ── Early Access Section ─────────────────────────────────── */
1667
1720
 
1668
1721
  function EarlyAccessSection() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.27.198",
3
+ "version": "0.27.199",
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)",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.198",
3
+ "version": "0.27.199",
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.198",
3
+ "version": "0.27.199",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -245,12 +245,14 @@ export class AxomConnector {
245
245
  // §12 message verb: starts a turn (interrupt steers, stop halts). Status is
246
246
  // part of the contract (202 accepted / 409 busy / 413 too_long) so this
247
247
  // returns {status, body} verbatim instead of throwing on non-2xx.
248
- async message(endpointName, sessionId, text) {
248
+ async message(endpointName, sessionId, text, clientRef) {
249
249
  const ep = this._requireEndpoint(endpointName);
250
250
  const res = await fetch(`${ep.url}/session/${encodeURIComponent(sessionId)}/message`, {
251
251
  method: 'POST',
252
252
  headers: { 'Content-Type': 'application/json' },
253
- body: JSON.stringify({ text }),
253
+ // §15: opaque, echoed back in pipeline_start. Omitted when absent so
254
+ // pre-§15 runtimes see exactly today's payload.
255
+ body: JSON.stringify(clientRef ? { text, client_ref: clientRef } : { text }),
254
256
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
255
257
  });
256
258
  const body = await res.json().catch(() => ({}));
@@ -260,6 +262,21 @@ export class AxomConnector {
260
262
  return { status: res.status, body };
261
263
  }
262
264
 
265
+ // §14: ends the RUNTIME (distinct from stop, which halts a turn). 409 when
266
+ // a turn is in flight unless forced; statuses pass through so the GUI can
267
+ // say what happened rather than guess.
268
+ async shutdown(endpointName, { force = false } = {}) {
269
+ const ep = this._requireEndpoint(endpointName);
270
+ const res = await fetch(`${ep.url}/shutdown`, {
271
+ method: 'POST',
272
+ headers: { 'Content-Type': 'application/json' },
273
+ body: JSON.stringify({ force }),
274
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
275
+ });
276
+ const body = await res.json().catch(() => ({}));
277
+ return { status: res.status, body };
278
+ }
279
+
263
280
  async interrupt(endpointName, sessionId, text) {
264
281
  const ep = this._requireEndpoint(endpointName);
265
282
  return this._fetch(ep, `/session/${encodeURIComponent(sessionId)}/interrupt`, {
@@ -295,6 +312,14 @@ export class AxomConnector {
295
312
  }
296
313
 
297
314
  status() {
315
+ // A runtime we spawned can be shut down; one we merely connected to
316
+ // cannot (no signal reaches another machine's process). The GUI needs
317
+ // this to avoid offering a kill switch it can't honor.
318
+ const managed = new Set(
319
+ (this.daemon.axomServer?.list?.() || [])
320
+ .filter((i) => i.status === 'running')
321
+ .map((i) => `http://127.0.0.1:${i.port}`),
322
+ );
298
323
  return {
299
324
  endpoints: [...this.endpoints.values()].map((ep) => ({
300
325
  name: ep.name,
@@ -303,6 +328,9 @@ export class AxomConnector {
303
328
  error: ep.error,
304
329
  about: ep.about,
305
330
  drift: ep.drift,
331
+ managed: managed.has(ep.url),
332
+ instanceId: (this.daemon.axomServer?.list?.() || [])
333
+ .find((i) => i.status === 'running' && `http://127.0.0.1:${i.port}` === ep.url)?.id || null,
306
334
  sessions: [...ep.sessions.values()].map((s) => ({
307
335
  session: s.id,
308
336
  started: s.started,
@@ -29,8 +29,17 @@ export class AxomInstaller {
29
29
  this._running = false;
30
30
  }
31
31
 
32
+ // Availability is a first-class answer, not an error string. A GROOVE build
33
+ // with no manifest configured cannot install Axom at all — the UI must say
34
+ // so up front rather than offering a button that fails on click.
32
35
  getStatus() {
33
- return { ...this.status };
36
+ const manifestUrl = this.daemon.config?.axom?.manifestUrl || null;
37
+ return {
38
+ ...this.status,
39
+ available: !!manifestUrl,
40
+ manifestUrl,
41
+ unavailableReason: manifestUrl ? null : 'Coming soon',
42
+ };
34
43
  }
35
44
 
36
45
  _update(patch) {
@@ -39,8 +48,12 @@ export class AxomInstaller {
39
48
  }
40
49
 
41
50
  async install(manifestUrl) {
51
+ // Gate, don't explain: a build with no configured distribution simply
52
+ // cannot install. The UI shows "Coming soon" and never offers the action;
53
+ // this is the server-side half of the same gate (a hand-crafted POST
54
+ // can't bypass it either).
42
55
  const url = manifestUrl || this.daemon.config?.axom?.manifestUrl;
43
- if (!url) throw new Error('no install manifest configured (axom.manifestUrl)');
56
+ if (!url) throw new Error('Coming soon Axom is not yet available for local install.');
44
57
  if (this._running) throw new Error('an install is already running');
45
58
  // Same hardware floor as the instance manager — never download 4GB of
46
59
  // weights onto a machine that can't safely run them. Manifest min_ram_gb
@@ -58,6 +71,12 @@ export class AxomInstaller {
58
71
  try {
59
72
  this._update({ phase: 'manifest', file: null, receivedBytes: 0, totalBytes: 0, error: null });
60
73
  const res = await fetch(url, { signal: AbortSignal.timeout(15000) });
74
+ if (res.status === 401 || res.status === 403) {
75
+ throw new Error('This Axom build is private — your account does not have access to the distribution. Connect to an Axom running elsewhere instead.');
76
+ }
77
+ if (res.status === 404) {
78
+ throw new Error(`No Axom distribution found at ${url} — the configured manifest URL is wrong or the release was removed.`);
79
+ }
61
80
  if (!res.ok) throw new Error(`manifest fetch failed: HTTP ${res.status}`);
62
81
  const manifest = await res.json();
63
82
  if (!Array.isArray(manifest.models)) throw new Error('manifest has no models list');
@@ -106,6 +125,9 @@ export class AxomInstaller {
106
125
  this._update({ phase: 'models', file, receivedBytes: 0, totalBytes: bytes || 0 });
107
126
 
108
127
  const res = await fetch(url);
128
+ if (res.status === 401 || res.status === 403) {
129
+ throw new Error(`${file} is in a private repository your account can't read — the model weights are gated. Nothing was installed.`);
130
+ }
109
131
  if (!res.ok || !res.body) throw new Error(`download failed for ${file}: HTTP ${res.status}`);
110
132
 
111
133
  const hash = createHash('sha256');