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
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { randomUUID } from 'crypto';
4
4
  import { deliverInstruction } from './deliver.js';
5
+ import { RelayClient, parsePeerRef } from './innerchat-relay.js';
5
6
 
6
7
  // An unanswered ask holds an HTTP request open and blocks the calling agent,
7
8
  // so it has to be bounded.
@@ -16,6 +17,12 @@ const MAX_EXCHANGES = 12;
16
17
  const CONTEXT_TURNS = 4;
17
18
  const MAX_TURN_CHARS = 1200;
18
19
 
20
+ // Sender-side outbox polling for async (tell) replies from a peer. Bounded: we
21
+ // poll a peer only after we've sent it a tell, and stop after a run of empty
22
+ // polls — a heads-down agent gets a generous window to finish, then we let go.
23
+ const OUTBOX_POLL_MS = 3000;
24
+ const OUTBOX_IDLE_STOP = 10; // ~30s of silence ends the poll
25
+
19
26
  /**
20
27
  * Agent-to-agent consultation.
21
28
  *
@@ -29,13 +36,40 @@ const MAX_TURN_CHARS = 1200;
29
36
  * a question reaches its target whether it's mid-task, idle, or stopped.
30
37
  */
31
38
  export class InnerChat {
32
- constructor(daemon) {
39
+ constructor(daemon, opts = {}) {
33
40
  this.daemon = daemon;
34
41
  this.threads = new Map();
35
42
  // agentId being asked -> pending record (resolve/reject/timer/threadId)
36
43
  this.awaiting = new Map();
37
44
  // asker id -> id of the agent it is currently blocked on
38
45
  this.blockedOn = new Map();
46
+
47
+ // Cross-daemon relay (spec: plans/cross-daemon-innerchat-spec.md).
48
+ this.relay = opts.relay || new RelayClient();
49
+ // Replies to async (tell) relays, queued here on the answering daemon until
50
+ // the sending daemon polls its outbox. Entry: { id, forDaemonId, toName,
51
+ // fromName, fromDaemonId, text, threadId, ts }.
52
+ this.relayOutbox = [];
53
+ // alias -> { timer, idle, busy } for active sender-side outbox pollers.
54
+ this._outboxPollers = new Map();
55
+ }
56
+
57
+ // Configured peers: [{ alias, url, daemonId }]. Empty by default — cross-daemon
58
+ // routing is off until the user adds a peer on both machines.
59
+ _peers() {
60
+ return Array.isArray(this.daemon.config?.innerchatPeers) ? this.daemon.config.innerchatPeers : [];
61
+ }
62
+
63
+ _peerByAlias(alias) {
64
+ return this._peers().find((p) => p.alias === alias) || null;
65
+ }
66
+
67
+ _peerByDaemonId(daemonId) {
68
+ return this._peers().find((p) => p.daemonId === daemonId) || null;
69
+ }
70
+
71
+ _daemonId() {
72
+ return this.daemon.federation.getDaemonId();
39
73
  }
40
74
 
41
75
  /**
@@ -252,6 +286,9 @@ export class InnerChat {
252
286
  if (pending.mode === 'async') {
253
287
  this._clearAwait(agentId);
254
288
  this._forwardAsyncReply(pending, answerTurn, text);
289
+ } else if (pending.mode === 'relay-async') {
290
+ this._clearAwait(agentId);
291
+ this._enqueueOutbox(pending, answerTurn, text);
255
292
  } else {
256
293
  pending.resolve(text);
257
294
  }
@@ -392,6 +429,228 @@ export class InnerChat {
392
429
  return pending ? this.threads.get(pending.threadId) : null;
393
430
  }
394
431
 
432
+ // ── Cross-daemon relay ──────────────────────────────────────
433
+ //
434
+ // A `name@alias` target routes here. The sending side (askRemote/tellRemote)
435
+ // signs the request with this daemon's federation key and POSTs it to the
436
+ // peer; the answering side (receiveRelay) verifies the signer, resolves the
437
+ // name in its OWN registry, and delivers through the normal InnerChat pipe
438
+ // with a guest identity so caps, threads, and audit all work unchanged.
439
+
440
+ // True when `to` names a peer. Lets the route layer branch cheaply.
441
+ isRemoteRef(to) {
442
+ const parsed = parsePeerRef(to);
443
+ return !!(parsed && parsed.alias);
444
+ }
445
+
446
+ // Ask an agent on a peer daemon and BLOCK for its reply (relayed over the
447
+ // still-open HTTP response — one-way transport suffices for ask/reply).
448
+ async askRemote(fromAgent, toName, alias, message, opts = {}) {
449
+ const peer = this._requirePeer(alias);
450
+ const envelope = this._signRelay('ask', fromAgent, toName, message);
451
+ const { ok, status, json } = await this._sendRelay(peer, envelope, opts.timeoutMs);
452
+ if (!ok) throw new Error(json?.error || `Peer "${alias}" refused the relay (HTTP ${status})`);
453
+ this.daemon.audit.log('innerchat.relay.ask', { peer: alias, from: fromAgent.name, to: toName });
454
+ return { reply: json.reply, remote: true, peer: alias, threadId: json.threadId };
455
+ }
456
+
457
+ // Send to an agent on a peer daemon WITHOUT blocking. The reply, if any, is
458
+ // queued in the peer's outbox and picked up by our poller, then delivered
459
+ // back to the local sender — keeping the one-way topology sufficient.
460
+ async tellRemote(fromAgent, toName, alias, message) {
461
+ const peer = this._requirePeer(alias);
462
+ const envelope = this._signRelay('tell', fromAgent, toName, message);
463
+ const { ok, status, json } = await this._sendRelay(peer, envelope);
464
+ if (!ok) throw new Error(json?.error || `Peer "${alias}" refused the relay (HTTP ${status})`);
465
+ this.daemon.audit.log('innerchat.relay.tell', { peer: alias, from: fromAgent.name, to: toName });
466
+ this._ensureOutboxPoll(peer);
467
+ return { delivered: true, remote: true, peer: alias, threadId: json.threadId };
468
+ }
469
+
470
+ _requirePeer(alias) {
471
+ const peer = this._peerByAlias(alias);
472
+ if (!peer) {
473
+ const aliases = this._peers().map((p) => p.alias);
474
+ throw new Error(
475
+ `No configured InnerChat peer "${alias}".`
476
+ + (aliases.length ? ` Known peers: ${aliases.join(', ')}.` : ' No peers are configured.'),
477
+ );
478
+ }
479
+ return peer;
480
+ }
481
+
482
+ _signRelay(kind, fromAgent, toName, message) {
483
+ const signed = this.daemon.federation.sign({
484
+ v: 1,
485
+ type: 'innerchat.relay',
486
+ kind,
487
+ fromName: fromAgent.name,
488
+ fromRole: fromAgent.role,
489
+ fromDaemonId: this._daemonId(),
490
+ toName,
491
+ message,
492
+ });
493
+ return { payload: signed.payload, signature: signed.signature };
494
+ }
495
+
496
+ _sendRelay(peer, envelope, timeoutMs) {
497
+ return this.relay.sendRelay(peer.url, envelope, timeoutMs);
498
+ }
499
+
500
+ /**
501
+ * Receive a relayed ask/tell from a peer daemon. The route layer has already
502
+ * verified the signature and confirmed the sender is a configured peer, and
503
+ * passes the peer's alias for display. Resolves the target in the LOCAL
504
+ * registry and delivers with a guest sender identity.
505
+ *
506
+ * For 'ask' this resolves with the local agent's reply text (relayed back on
507
+ * the open response). For 'tell' it resolves once delivered; the reply is
508
+ * later queued to the outbox for the sending daemon.
509
+ */
510
+ async receiveRelay({ fromName, fromRole, fromDaemonId, peerAlias, toName, message, kind, threadId, timeoutMs }) {
511
+ // One hop only: a `name@peer` target here would be a second relay. Refuse
512
+ // before resolving, so it reads as "transitive" not "no such agent".
513
+ if (String(toName).includes('@')) throw new Error('Transitive relay refused — InnerChat is one hop only.');
514
+ const toAgent = this._resolveLocal(toName);
515
+ if (!toAgent) {
516
+ const names = this.daemon.registry.getAll().map((a) => a.name);
517
+ const err = new Error(`No agent named "${toName}" on this daemon.`);
518
+ err.availableAgents = names;
519
+ throw err;
520
+ }
521
+ if (this.awaiting.has(toAgent.id)) {
522
+ throw new Error(`${toAgent.name} is already answering another message — try again shortly.`);
523
+ }
524
+ if (!message || !message.trim()) throw new Error('message is required');
525
+
526
+ const guest = {
527
+ id: `guest:${fromDaemonId}:${fromName}`,
528
+ name: peerAlias ? `${fromName}@${peerAlias}` : fromName,
529
+ role: fromRole || 'remote-agent',
530
+ remote: { daemonId: fromDaemonId, fromName },
531
+ };
532
+
533
+ const t = this._openThread(guest, toAgent, threadId);
534
+ this._checkExchangeCap(t, toAgent);
535
+
536
+ const { targetId, skipResults } = await this._deliver(t, guest, toAgent, message.trim(), kind);
537
+
538
+ if (kind === 'tell') {
539
+ this.awaiting.set(targetId, {
540
+ mode: 'relay-async',
541
+ threadId: t.id,
542
+ remote: { forDaemonId: fromDaemonId, fromName },
543
+ skipResults,
544
+ timer: setTimeout(() => this._clearAwait(targetId), MAX_TIMEOUT_MS),
545
+ });
546
+ return { delivered: true, threadId: t.id };
547
+ }
548
+
549
+ const reply = await this._awaitReply(t, guest.id, targetId, {
550
+ skipResults,
551
+ timeoutMs: Math.min(Number(timeoutMs) || DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS),
552
+ });
553
+ return { reply, threadId: t.id };
554
+ }
555
+
556
+ // Local-only resolution mirroring the route resolver: exact id/name first,
557
+ // then a single unambiguous partial. Never reaches across daemons (one hop).
558
+ _resolveLocal(ref) {
559
+ const all = this.daemon.registry.getAll();
560
+ const needle = String(ref || '').trim().toLowerCase();
561
+ const exact = all.find((a) => a.id === ref)
562
+ || all.find((a) => a.name === ref)
563
+ || all.find((a) => a.name.toLowerCase() === needle);
564
+ if (exact) return exact;
565
+ const partial = all.filter((a) => a.name.toLowerCase() === needle
566
+ || a.name.toLowerCase().includes(needle));
567
+ return partial.length === 1 ? partial[0] : null;
568
+ }
569
+
570
+ // ── Outbox (async relay replies) ────────────────────────────
571
+
572
+ _enqueueOutbox(pending, answerTurn, text) {
573
+ this.relayOutbox.push({
574
+ id: randomUUID().slice(0, 12),
575
+ forDaemonId: pending.remote.forDaemonId,
576
+ toName: pending.remote.fromName, // the remote agent that asked
577
+ fromName: answerTurn.from.name, // the local agent that answered
578
+ fromDaemonId: this._daemonId(),
579
+ text,
580
+ threadId: pending.threadId,
581
+ ts: Date.now(),
582
+ });
583
+ this.daemon.audit.log('innerchat.relay.queued', { for: pending.remote.forDaemonId, from: answerTurn.from.name });
584
+ }
585
+
586
+ // Drain and remove outbox entries destined for a given daemon (at-least-once:
587
+ // removed on read). Called by the relay/outbox route after verifying the caller.
588
+ drainOutbox(forDaemonId) {
589
+ const mine = this.relayOutbox.filter((e) => e.forDaemonId === forDaemonId);
590
+ if (mine.length) this.relayOutbox = this.relayOutbox.filter((e) => e.forDaemonId !== forDaemonId);
591
+ return mine;
592
+ }
593
+
594
+ // Sender-side: poll a peer for queued replies until they stop arriving.
595
+ _ensureOutboxPoll(peer) {
596
+ const existing = this._outboxPollers.get(peer.alias);
597
+ if (existing) { existing.idle = 0; return; }
598
+
599
+ const state = { idle: 0, busy: false, timer: null };
600
+ const tick = async () => {
601
+ if (state.busy) return;
602
+ state.busy = true;
603
+ try {
604
+ const entries = await this._pollOutbox(peer);
605
+ if (entries.length) {
606
+ state.idle = 0;
607
+ for (const e of entries) await this._deliverRemoteReply(peer, e);
608
+ } else {
609
+ state.idle += 1;
610
+ }
611
+ } catch { state.idle += 1; }
612
+ finally { state.busy = false; }
613
+ if (state.idle >= OUTBOX_IDLE_STOP) this._stopOutboxPoll(peer.alias);
614
+ };
615
+ state.timer = setInterval(tick, OUTBOX_POLL_MS);
616
+ if (state.timer.unref) state.timer.unref();
617
+ this._outboxPollers.set(peer.alias, state);
618
+ }
619
+
620
+ async _pollOutbox(peer) {
621
+ const signed = this.daemon.federation.sign({ v: 1, type: 'innerchat.outbox', fromDaemonId: this._daemonId() });
622
+ const { json } = await this.relay.fetchOutbox(peer.url, { payload: signed.payload, signature: signed.signature }, 5000);
623
+ return Array.isArray(json?.entries) ? json.entries : [];
624
+ }
625
+
626
+ _stopOutboxPoll(alias) {
627
+ const state = this._outboxPollers.get(alias);
628
+ if (state?.timer) clearInterval(state.timer);
629
+ this._outboxPollers.delete(alias);
630
+ }
631
+
632
+ // Deliver a peer's async reply to the local agent that sent the original tell.
633
+ async _deliverRemoteReply(peer, entry) {
634
+ const sender = this._resolveLocal(entry.toName);
635
+ if (!sender) {
636
+ this.daemon.audit.log('innerchat.relay.reply_undeliverable', { to: entry.toName, peer: peer.alias });
637
+ return;
638
+ }
639
+ const msg = [
640
+ `[InnerChat reply from ${entry.fromName}@${peer.alias}]`,
641
+ '',
642
+ entry.text,
643
+ '',
644
+ 'This is a reply to a message you sent earlier to a peer agent (you were not '
645
+ + 'blocking on it). Fold it into your work, or reply again to continue.',
646
+ ].join('\n');
647
+ try {
648
+ await deliverInstruction(this.daemon, sender.id, msg, { recordFeedback: false });
649
+ } catch (err) {
650
+ this.daemon.audit.log('innerchat.relay.reply_failed', { to: sender.id, error: err.message });
651
+ }
652
+ }
653
+
395
654
  // Clear all outstanding timers so a shutdown (or a test) doesn't hang on
396
655
  // pending ask/tell timeouts. Blocking asks are rejected so their HTTP
397
656
  // requests don't hang either.
@@ -404,6 +663,7 @@ export class InnerChat {
404
663
  this.awaiting.delete(targetId);
405
664
  }
406
665
  this.blockedOn.clear();
666
+ for (const alias of [...this._outboxPollers.keys()]) this._stopOutboxPoll(alias);
407
667
  }
408
668
  }
409
669
 
@@ -561,7 +561,7 @@ export class Introducer {
561
561
  // compaction — the spawn prompt alone can scroll out of a long session.
562
562
  _innerChatSection() {
563
563
  const port = this.daemon.port || 31415;
564
- return [...innerChatInstructions(port), '', ...watchInstructions(port), '', ...logFileInstructions()];
564
+ return [...innerChatInstructions(port, 'YOUR_NAME', this.daemon.config?.innerchatPeers), '', ...watchInstructions(port), '', ...logFileInstructions()];
565
565
  }
566
566
 
567
567
  writeRegistryFile(projectDir) {
@@ -0,0 +1,56 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ import { execFileSync } from 'child_process';
4
+
5
+ // Fingerprint the current network by its default gateway's IP + MAC address.
6
+ // The MAC is the strong part: it's unique to your router and a passive sniffer
7
+ // on a coffee-shop LAN can't make their gateway look like your home router.
8
+ // LAN peering only activates when this fingerprint matches the one recorded
9
+ // when you enabled it at home — so at a hotel/cafe the peer listener never
10
+ // opens in the first place.
11
+
12
+ function defaultGatewayIp() {
13
+ try {
14
+ if (process.platform === 'darwin') {
15
+ const out = execFileSync('route', ['-n', 'get', 'default'], { encoding: 'utf8', timeout: 3000 });
16
+ const m = out.match(/gateway:\s*([\d.]+)/);
17
+ return m ? m[1] : null;
18
+ }
19
+ // linux
20
+ const out = execFileSync('ip', ['route'], { encoding: 'utf8', timeout: 3000 });
21
+ const m = out.match(/default via ([\d.]+)/);
22
+ return m ? m[1] : null;
23
+ } catch { return null; }
24
+ }
25
+
26
+ function macForIp(ip) {
27
+ if (!ip) return null;
28
+ try {
29
+ const arp = process.platform === 'darwin' ? ['-n', ip] : ['-n', ip];
30
+ const out = execFileSync('arp', arp, { encoding: 'utf8', timeout: 3000 });
31
+ // e.g. "? (10.0.0.1) at 5c:7d:7d:2d:11:e0 on en0 …"
32
+ const m = out.match(/([0-9a-f]{1,2}(:[0-9a-f]{1,2}){5})/i);
33
+ return m ? normalizeMac(m[1]) : null;
34
+ } catch { return null; }
35
+ }
36
+
37
+ function normalizeMac(mac) {
38
+ // ARP can print "5:7d:..." (no leading zero) — pad each octet so equality is reliable.
39
+ return mac.toLowerCase().split(':').map((o) => o.padStart(2, '0')).join(':');
40
+ }
41
+
42
+ // { gatewayIp, gatewayMac } for the network we're on right now, or nulls.
43
+ export function currentNetworkFingerprint() {
44
+ const gatewayIp = defaultGatewayIp();
45
+ const gatewayMac = macForIp(gatewayIp);
46
+ return { gatewayIp, gatewayMac };
47
+ }
48
+
49
+ // Are we on the trusted network? Requires a MAC match — an IP-only match is not
50
+ // enough (many LANs use 192.168.1.1). If no fingerprint was ever recorded, we
51
+ // are NOT on a trusted network (fail closed).
52
+ export function onTrustedNetwork(trusted) {
53
+ if (!trusted || !trusted.gatewayMac) return false;
54
+ const now = currentNetworkFingerprint();
55
+ return !!now.gatewayMac && now.gatewayMac === normalizeMac(trusted.gatewayMac);
56
+ }
@@ -1196,7 +1196,7 @@ For normal file edits within your scope, proceed without review.
1196
1196
  if (!sandboxedProviders.includes(providerName) && !isOneShotProvider) {
1197
1197
  const port = this.daemon.port || 31415;
1198
1198
  const capabilities = [
1199
- ...innerChatInstructions(port, agent.name),
1199
+ ...innerChatInstructions(port, agent.name, this.daemon.config?.innerchatPeers),
1200
1200
  '',
1201
1201
  ...watchInstructions(port, agent.name),
1202
1202
  '',
@@ -0,0 +1,66 @@
1
+ // GROOVE — Axom Provider (Endpoint Runtime)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+ //
4
+ // Axom is an ENDPOINT provider, not a CLI provider: the runtime is a daemon
5
+ // (`axom serve`, default port 8737) that GROOVE connects to over the Axom
6
+ // provider protocol. Sessions stream envelope events; the AxomConnector
7
+ // (src/axom-connector.js) owns the live connection. v0 has no session-create
8
+ // verb, so agents are not spawnable — the Axom tab is the surface.
9
+
10
+ import { Provider } from './base.js';
11
+ import { AXOM_DEFAULT_PORT } from '../axom-connector.js';
12
+
13
+ let _daemonRef = null;
14
+ export function bindDaemon(daemon) { _daemonRef = daemon; }
15
+
16
+ export class AxomProvider extends Provider {
17
+ static name = 'axom';
18
+ static displayName = 'Axom';
19
+ static command = 'axom';
20
+ static authType = 'endpoint'; // configured URL, no key — auth is a v1 protocol item
21
+ static managesOwnContext = true; // memory ledger + rotation live in the runtime
22
+ static singleTask = false;
23
+ static models = []; // the live roster comes from GET /about, not a static list
24
+
25
+ static isInstalled() {
26
+ return (_daemonRef?.config?.axom?.endpoints || []).length > 0;
27
+ }
28
+
29
+ // Settings renders this verbatim as text (string contract — Ollama's object
30
+ // shape only survives because its render branch never fires when installed).
31
+ static installCommand() {
32
+ return 'axom serve';
33
+ }
34
+
35
+ static async isServerRunning(url = `http://127.0.0.1:${AXOM_DEFAULT_PORT}`) {
36
+ try {
37
+ const controller = new AbortController();
38
+ const timeout = setTimeout(() => controller.abort(), 3000);
39
+ const res = await fetch(`${url.replace(/\/+$/, '')}/about`, { signal: controller.signal });
40
+ clearTimeout(timeout);
41
+ return res.ok;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ buildSpawnCommand() {
48
+ throw new Error('Axom sessions are not spawnable in v0 — configure an endpoint and use the Axom tab');
49
+ }
50
+
51
+ buildHeadlessCommand() {
52
+ throw new Error('Axom has no headless command — it is an endpoint runtime, not a CLI');
53
+ }
54
+
55
+ static setupGuide() {
56
+ return {
57
+ installSteps: [
58
+ 'Install the Axom runtime on the machine that will run it (localhost is the default topology)',
59
+ `Start it with: axom serve (binds 127.0.0.1:${AXOM_DEFAULT_PORT})`,
60
+ 'In GROOVE, open the Axom tab and add the endpoint URL',
61
+ ],
62
+ authMethods: [],
63
+ authInstructions: {},
64
+ };
65
+ }
66
+ }
@@ -11,6 +11,7 @@ import { GrokProvider } from './grok.js';
11
11
  import { NanaBananaProvider } from './nano-banana.js';
12
12
  import { LocalProvider } from './local.js';
13
13
  import { GrooveNetworkProvider } from './groove-network.js';
14
+ import { AxomProvider } from './axom.js';
14
15
 
15
16
  // Electron forks may not inherit the full shell PATH, causing `which` to miss
16
17
  // globally-installed CLI tools. Augment PATH with common npm global bin dirs.
@@ -90,6 +91,7 @@ const providers = {
90
91
  'nano-banana': new NanaBananaProvider(),
91
92
  'local': new LocalProvider(),
92
93
  'groove-network': new GrooveNetworkProvider(),
94
+ 'axom': new AxomProvider(),
93
95
  };
94
96
 
95
97
  const installCache = new Map();
@@ -0,0 +1,196 @@
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
+
8
+ // Interrupt text is capped runtime-side at 2000 chars (contract §2, with a
9
+ // `truncated` flag in the response); we allow headroom and let the runtime be
10
+ // the authority on truncation — its flag is the honest signal, not ours.
11
+ const MAX_INTERRUPT_CHARS = 8000;
12
+
13
+ export function registerAxomRoutes(app, daemon) {
14
+ app.get('/api/axom/status', (req, res) => {
15
+ res.json(daemon.axom.status());
16
+ });
17
+
18
+ app.get('/api/axom/sessions', (req, res) => {
19
+ const sessions = daemon.axom.status().endpoints.flatMap((ep) =>
20
+ ep.sessions.map((s) => ({ endpoint: ep.name, ...s })));
21
+ res.json(sessions);
22
+ });
23
+
24
+ // In-daemon ring backfill for the GUI (initial load / scrubber). `since` is
25
+ // an envelope id ("ev-000123"); events after it are returned.
26
+ app.get('/api/axom/sessions/:id/events', (req, res) => {
27
+ try {
28
+ const sinceSeq = /^ev-\d+$/.test(req.query.since || '')
29
+ ? parseInt(req.query.since.slice(3), 10) : 0;
30
+ res.json(daemon.axom.events(req.query.endpoint, req.params.id, sinceSeq));
31
+ } catch (err) {
32
+ res.status(404).json({ error: err.message });
33
+ }
34
+ });
35
+
36
+ // §12: message starts a turn. Contract statuses (202/409/413) mirror
37
+ // through verbatim — the GUI reads them, we don't reinterpret.
38
+ app.post('/api/axom/sessions/:id/message', async (req, res) => {
39
+ try {
40
+ const { text, endpoint } = req.body || {};
41
+ if (!text || typeof text !== 'string' || !text.trim()) {
42
+ return res.status(400).json({ error: 'text is required' });
43
+ }
44
+ const result = await daemon.axom.message(endpoint, req.params.id, text);
45
+ daemon.audit.log('axom.message', { session: req.params.id, chars: text.length, status: result.status });
46
+ res.status(result.status).json(result.body);
47
+ } catch (err) {
48
+ res.status(502).json({ error: err.message });
49
+ }
50
+ });
51
+
52
+ app.post('/api/axom/sessions/:id/interrupt', async (req, res) => {
53
+ try {
54
+ const { text, endpoint } = req.body || {};
55
+ if (!text || typeof text !== 'string' || !text.trim()) {
56
+ return res.status(400).json({ error: 'text is required' });
57
+ }
58
+ if (text.length > MAX_INTERRUPT_CHARS) {
59
+ return res.status(400).json({ error: `text exceeds ${MAX_INTERRUPT_CHARS} chars` });
60
+ }
61
+ const result = await daemon.axom.interrupt(endpoint, req.params.id, text);
62
+ daemon.audit.log('axom.interrupt', { session: req.params.id, chars: text.length });
63
+ res.json(result); // verbatim {id, truncated} from the runtime
64
+ } catch (err) {
65
+ res.status(502).json({ error: err.message });
66
+ }
67
+ });
68
+
69
+ app.post('/api/axom/sessions/:id/stop', async (req, res) => {
70
+ try {
71
+ const result = await daemon.axom.stop(req.body?.endpoint, req.params.id);
72
+ daemon.audit.log('axom.stop', { session: req.params.id });
73
+ res.json(result);
74
+ } catch (err) {
75
+ res.status(502).json({ error: err.message });
76
+ }
77
+ });
78
+
79
+ // Machine readiness for local inference — powers the welcome page's
80
+ // "can this device run Axom?" verdict.
81
+ app.get('/api/axom/hardware', (req, res) => {
82
+ res.json(hardwareReport(daemon.grooveDir));
83
+ });
84
+
85
+ // This GROOVE's own Axom endpoint — what another instance would paste.
86
+ // serve binds 127.0.0.1 (§6), so cross-machine reach is the user's own
87
+ // channel; we hand them the exact tunnel one-liner rather than pretending
88
+ // the URL travels on its own.
89
+ app.get('/api/axom/my-endpoint', (req, res) => {
90
+ const running = daemon.axomServer.list().find((i) => i.status === 'running');
91
+ const port = running?.port || AXOM_DEFAULT_PORT;
92
+ const host = os.hostname();
93
+ const user = os.userInfo().username;
94
+ res.json({
95
+ url: `http://127.0.0.1:${port}`,
96
+ port,
97
+ host,
98
+ running: !!running,
99
+ instanceId: running?.id || null,
100
+ tunnelCommand: `ssh -N -L ${port}:localhost:${port} ${user}@${host}`,
101
+ });
102
+ });
103
+
104
+ // ── Managed local instances (contract §11) ──────────────────────────────
105
+
106
+ app.get('/api/axom/instances', (req, res) => {
107
+ res.json(daemon.axomServer.list());
108
+ });
109
+
110
+ app.post('/api/axom/instances', async (req, res) => {
111
+ try {
112
+ const instance = await daemon.axomServer.start(req.body?.id || 'default');
113
+ res.json(instance);
114
+ } catch (err) {
115
+ res.status(500).json({ error: err.message });
116
+ }
117
+ });
118
+
119
+ app.delete('/api/axom/instances/:id', async (req, res) => {
120
+ try {
121
+ await daemon.axomServer.stop(req.params.id);
122
+ res.json({ ok: true });
123
+ } catch (err) {
124
+ res.status(400).json({ error: err.message });
125
+ }
126
+ });
127
+
128
+ // ── Manifest-driven install (contract §11) ──────────────────────────────
129
+
130
+ app.get('/api/axom/install', (req, res) => {
131
+ res.json(daemon.axomInstaller.getStatus());
132
+ });
133
+
134
+ app.post('/api/axom/install', (req, res) => {
135
+ // Long-running — kick off and let the axom:install:progress broadcasts
136
+ // carry the story; the GUI polls GET /api/axom/install as backstop.
137
+ daemon.axomInstaller.install(req.body?.manifestUrl).catch(() => { /* status carries the error */ });
138
+ res.json({ started: true });
139
+ });
140
+
141
+ app.get('/api/axom/config', (req, res) => {
142
+ res.json({ endpoints: daemon.config.axom?.endpoints || [] });
143
+ });
144
+
145
+ app.patch('/api/axom/config', (req, res) => {
146
+ const { endpoints, command, mock, manifestUrl, modelDir } = req.body || {};
147
+
148
+ // Runtime settings (contract §11): serve binary path, mock mode,
149
+ // install-manifest URL, model dir. Any subset may be patched alone.
150
+ const settings = {};
151
+ if (command !== undefined) {
152
+ if (command !== null && (typeof command !== 'string' || !command.trim())) {
153
+ return res.status(400).json({ error: 'command must be a non-empty string or null' });
154
+ }
155
+ settings.command = command;
156
+ }
157
+ if (mock !== undefined) settings.mock = !!mock;
158
+ if (manifestUrl !== undefined) {
159
+ if (manifestUrl !== null && !/^https?:\/\//.test(manifestUrl)) {
160
+ return res.status(400).json({ error: 'manifestUrl must be http(s) or null' });
161
+ }
162
+ settings.manifestUrl = manifestUrl;
163
+ }
164
+ if (modelDir !== undefined) settings.modelDir = modelDir;
165
+ if (Object.keys(settings).length > 0) {
166
+ daemon.config.axom = { ...(daemon.config.axom || {}), ...settings };
167
+ if (endpoints === undefined) {
168
+ saveConfig(daemon.grooveDir, daemon.config);
169
+ daemon.audit.log('axom.config', { keys: Object.keys(settings) });
170
+ return res.json({ ...daemon.config.axom });
171
+ }
172
+ }
173
+
174
+ if (!Array.isArray(endpoints)) {
175
+ return res.status(400).json({ error: 'endpoints array is required' });
176
+ }
177
+ if (endpoints.length > 16) {
178
+ return res.status(400).json({ error: 'too many endpoints (max 16)' });
179
+ }
180
+ const seen = new Set();
181
+ for (const entry of endpoints) {
182
+ const problem = validateEndpoint(entry);
183
+ if (problem) return res.status(400).json({ error: problem });
184
+ if (seen.has(entry.name)) {
185
+ return res.status(400).json({ error: `duplicate endpoint name: ${entry.name}` });
186
+ }
187
+ seen.add(entry.name);
188
+ }
189
+ const cleaned = endpoints.map(({ name, url }) => ({ name, url: url.replace(/\/+$/, '') }));
190
+ daemon.config.axom = { ...(daemon.config.axom || {}), endpoints: cleaned };
191
+ saveConfig(daemon.grooveDir, daemon.config);
192
+ daemon.axom.configure(cleaned);
193
+ daemon.audit.log('axom.config', { endpoints: cleaned.map((e) => e.name) });
194
+ res.json({ endpoints: cleaned });
195
+ });
196
+ }