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
@@ -0,0 +1,140 @@
1
+ // GROOVE — Axom Installer (manifest-driven)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+ //
4
+ // Installs the Axom runtime + models against an install manifest per contract
5
+ // §11. The GUI (and this module) NEVER hardcodes a source — dev vs launch is
6
+ // purely a manifest swap. Models are sha256-verified before anything is
7
+ // configured; a failed verify configures nothing.
8
+ //
9
+ // Manifest shape (§11):
10
+ // {runtime: {kind: "pip"|"wheel-url"|"tarball", ref, sha256},
11
+ // models: [{file, url, sha256, bytes}], min_version}
12
+
13
+ import { execFile } from 'child_process';
14
+ import { createHash } from 'crypto';
15
+ import { createWriteStream } from 'fs';
16
+ import { mkdir, rename, rm } from 'fs/promises';
17
+ import { join } from 'path';
18
+ import os from 'os';
19
+ import { pipeline } from 'stream/promises';
20
+ import { Transform } from 'stream';
21
+ import { AXOM_REQUIREMENTS } from './axom-server.js';
22
+
23
+ export class AxomInstaller {
24
+ constructor(daemon, opts = {}) {
25
+ this.daemon = daemon;
26
+ this.pipCommand = opts.pipCommand || ['python3', '-m', 'pip'];
27
+ this.totalRamGbOverride = opts.totalRamGb; // tests inject; prod reads os
28
+ this.status = { phase: 'idle', file: null, receivedBytes: 0, totalBytes: 0, error: null };
29
+ this._running = false;
30
+ }
31
+
32
+ getStatus() {
33
+ return { ...this.status };
34
+ }
35
+
36
+ _update(patch) {
37
+ this.status = { ...this.status, ...patch };
38
+ this.daemon.broadcast({ type: 'axom:install:progress', data: this.getStatus() });
39
+ }
40
+
41
+ async install(manifestUrl) {
42
+ const url = manifestUrl || this.daemon.config?.axom?.manifestUrl;
43
+ if (!url) throw new Error('no install manifest configured (axom.manifestUrl)');
44
+ if (this._running) throw new Error('an install is already running');
45
+ // Same hardware floor as the instance manager — never download 4GB of
46
+ // weights onto a machine that can't safely run them. Manifest min_ram_gb
47
+ // (when present) is the authority; the static floor is the fallback.
48
+ if (!this.daemon.config?.axom?.allowUnderspec) {
49
+ const totalRamGb = this.totalRamGbOverride ?? os.totalmem() / 2 ** 30;
50
+ if (totalRamGb < AXOM_REQUIREMENTS.minRamGb) {
51
+ throw new Error(
52
+ `This machine has ${Math.round(totalRamGb)}GB RAM — below Axom's ${AXOM_REQUIREMENTS.minRamGb}GB floor for local inference. `
53
+ + 'Connect to a remote Axom endpoint instead.',
54
+ );
55
+ }
56
+ }
57
+ this._running = true;
58
+ try {
59
+ this._update({ phase: 'manifest', file: null, receivedBytes: 0, totalBytes: 0, error: null });
60
+ const res = await fetch(url, { signal: AbortSignal.timeout(15000) });
61
+ if (!res.ok) throw new Error(`manifest fetch failed: HTTP ${res.status}`);
62
+ const manifest = await res.json();
63
+ if (!Array.isArray(manifest.models)) throw new Error('manifest has no models list');
64
+
65
+ if (manifest.runtime) await this._installRuntime(manifest.runtime);
66
+
67
+ const modelDir = this.daemon.axomServer?._modelDir()
68
+ || join(this.daemon.grooveDir, 'axom', 'models');
69
+ await mkdir(modelDir, { recursive: true });
70
+ for (const model of manifest.models) {
71
+ await this._downloadVerified(model, modelDir);
72
+ }
73
+
74
+ this._update({ phase: 'done', file: null });
75
+ this.daemon.audit.log('axom.install.complete', { models: manifest.models.length });
76
+ return { ok: true, models: manifest.models.length };
77
+ } catch (err) {
78
+ this._update({ phase: 'error', error: err.message });
79
+ throw err;
80
+ } finally {
81
+ this._running = false;
82
+ }
83
+ }
84
+
85
+ async _installRuntime(runtime) {
86
+ this._update({ phase: 'runtime', file: runtime.ref || null });
87
+ if (runtime.kind === 'pip') {
88
+ const [cmd, ...base] = this.pipCommand;
89
+ await new Promise((resolve, reject) => {
90
+ execFile(cmd, [...base, 'install', runtime.ref], { timeout: 600000 }, (err, _out, stderr) => {
91
+ if (err) reject(new Error(`pip install failed: ${stderr?.slice(-400) || err.message}`));
92
+ else resolve();
93
+ });
94
+ });
95
+ return;
96
+ }
97
+ // Honest v0 boundary — no silent no-op for kinds we don't handle yet.
98
+ throw new Error(`runtime kind "${runtime.kind}" is not supported by this GROOVE version`);
99
+ }
100
+
101
+ async _downloadVerified({ file, url, sha256, bytes }, modelDir) {
102
+ if (!file || !/^[a-zA-Z0-9._-]+$/.test(file)) throw new Error(`unsafe model filename: ${file}`);
103
+ if (!sha256) throw new Error(`manifest entry ${file} has no sha256 — refusing unverifiable download`);
104
+ const finalPath = join(modelDir, file);
105
+ const tmpPath = `${finalPath}.part`;
106
+ this._update({ phase: 'models', file, receivedBytes: 0, totalBytes: bytes || 0 });
107
+
108
+ const res = await fetch(url);
109
+ if (!res.ok || !res.body) throw new Error(`download failed for ${file}: HTTP ${res.status}`);
110
+
111
+ const hash = createHash('sha256');
112
+ let received = 0;
113
+ let lastBroadcast = 0;
114
+ const meter = new Transform({
115
+ transform: (chunk, _enc, cb) => {
116
+ hash.update(chunk);
117
+ received += chunk.length;
118
+ const now = Date.now();
119
+ if (now - lastBroadcast > 500) { // progress, throttled
120
+ lastBroadcast = now;
121
+ this._update({ receivedBytes: received });
122
+ }
123
+ cb(null, chunk);
124
+ },
125
+ });
126
+
127
+ try {
128
+ await pipeline(res.body, meter, createWriteStream(tmpPath));
129
+ const digest = hash.digest('hex');
130
+ if (digest !== sha256.toLowerCase()) {
131
+ throw new Error(`sha256 mismatch for ${file}: expected ${sha256}, got ${digest}`);
132
+ }
133
+ await rename(tmpPath, finalPath);
134
+ this._update({ receivedBytes: received });
135
+ } catch (err) {
136
+ await rm(tmpPath, { force: true });
137
+ throw err;
138
+ }
139
+ }
140
+ }
@@ -0,0 +1,229 @@
1
+ // GROOVE — Axom Server Manager (per-session local instances)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+ //
4
+ // Manages local `axom serve` processes per contract §11: one instance per
5
+ // GROOVE session that wants its own Axom, each with its own port and its own
6
+ // SOVEREIGN --data-dir (ledgers never shared — that isolation is the product,
7
+ // not an implementation detail). Same lifecycle family as LlamaServerManager.
8
+ // The serve binary refuses a locked data-dir; we surface that error verbatim
9
+ // rather than retrying around it.
10
+
11
+ import { spawn } from 'child_process';
12
+ import { mkdirSync, statfsSync } from 'fs';
13
+ import { join } from 'path';
14
+ import os from 'os';
15
+ import { AXOM_DEFAULT_PORT } from './axom-connector.js';
16
+
17
+ const START_TIMEOUT_MS = 60000; // model load can be slow on first start
18
+
19
+ // Local-inference floor, learned 2026-07-25: an 8GB Mac loaded the 4GB Q8
20
+ // chassis fine, then llama_decode failed at the Metal working-set ceiling and
21
+ // memory pressure took the whole machine down. Refusing is a feature. The
22
+ // install manifest's min_ram_gb overrides these when present.
23
+ export const AXOM_REQUIREMENTS = {
24
+ minRamGb: 12,
25
+ recommendedRamGb: 16,
26
+ minDiskGb: 6,
27
+ downloadGb: 4.4,
28
+ };
29
+
30
+ export function hardwareReport(dir = os.homedir(), requirements = AXOM_REQUIREMENTS) {
31
+ const totalRamGb = os.totalmem() / 2 ** 30;
32
+ let freeDiskGb = null;
33
+ try {
34
+ const s = statfsSync(dir);
35
+ freeDiskGb = (s.bavail * s.bsize) / 2 ** 30;
36
+ } catch { /* stat unavailable — report null, never guess */ }
37
+ const appleSilicon = process.platform === 'darwin' && process.arch === 'arm64';
38
+ const verdict = totalRamGb < requirements.minRamGb ? 'insufficient'
39
+ : totalRamGb < requirements.recommendedRamGb ? 'marginal' : 'ready';
40
+ return {
41
+ totalRamGb: Math.round(totalRamGb * 10) / 10,
42
+ cpuCores: os.cpus().length,
43
+ arch: process.arch,
44
+ platform: process.platform,
45
+ appleSilicon,
46
+ gpu: appleSilicon ? 'Metal (unified memory)' : null, // CUDA detection: v1
47
+ freeDiskGb: freeDiskGb === null ? null : Math.round(freeDiskGb * 10) / 10,
48
+ diskOk: freeDiskGb === null ? null : freeDiskGb >= requirements.minDiskGb,
49
+ verdict,
50
+ requirements,
51
+ };
52
+ }
53
+
54
+ export class AxomServerManager {
55
+ constructor(daemon, opts = {}) {
56
+ this.daemon = daemon;
57
+ this.command = opts.command || null; // resolved lazily from config
58
+ this.totalRamGbOverride = opts.totalRamGb; // tests inject; prod reads os
59
+ this.instances = new Map(); // id -> {id, proc, port, dataDir, status, startedAt, error}
60
+ }
61
+
62
+ _command() {
63
+ return this.command || this.daemon.config?.axom?.command || 'axom';
64
+ }
65
+
66
+ _modelDir() {
67
+ return this.daemon.config?.axom?.modelDir
68
+ || join(this.daemon.grooveDir, 'axom', 'models');
69
+ }
70
+
71
+ _allocatePort() {
72
+ const used = new Set([...this.instances.values()].map((i) => i.port));
73
+ for (const ep of this.daemon.axom?.endpoints?.values?.() || []) {
74
+ try { used.add(Number(new URL(ep.url).port)); } catch { /* non-numeric */ }
75
+ }
76
+ let port = AXOM_DEFAULT_PORT;
77
+ while (used.has(port)) port += 1;
78
+ return port;
79
+ }
80
+
81
+ list() {
82
+ return [...this.instances.values()].map(({ proc, ...rest }) => rest);
83
+ }
84
+
85
+ // Start a local instance. `id` doubles as the data-dir name, so the same id
86
+ // across restarts resumes the same sovereign memory.
87
+ async start(id = 'default') {
88
+ if (!/^[a-zA-Z0-9_-]{1,40}$/.test(id)) throw new Error('invalid instance id');
89
+ const existing = this.instances.get(id);
90
+ if (existing && existing.status === 'running') return this.list().find((i) => i.id === id);
91
+
92
+ // Hardware floor — refuse under-spec local inference instead of letting
93
+ // it take the host down. Mock mode is weightless and exempt.
94
+ if (!this.daemon.config?.axom?.mock && !this.daemon.config?.axom?.allowUnderspec) {
95
+ const totalRamGb = this.totalRamGbOverride ?? os.totalmem() / 2 ** 30;
96
+ if (totalRamGb < AXOM_REQUIREMENTS.minRamGb) {
97
+ throw new Error(
98
+ `This machine has ${Math.round(totalRamGb)}GB RAM — below Axom's ${AXOM_REQUIREMENTS.minRamGb}GB floor for local inference. `
99
+ + 'Connect to a remote Axom endpoint instead (axom.allowUnderspec overrides at your own risk).',
100
+ );
101
+ }
102
+ }
103
+
104
+ const port = this._allocatePort();
105
+ const dataDir = join(this.daemon.grooveDir, 'axom', 'instances', id);
106
+ mkdirSync(dataDir, { recursive: true });
107
+
108
+ const args = [
109
+ 'serve',
110
+ '--host', '127.0.0.1',
111
+ '--port', String(port),
112
+ '--data-dir', dataDir,
113
+ '--model-dir', this._modelDir(),
114
+ ];
115
+ // config.axom.mock: run the scripted engine (§11 addendum) — the
116
+ // no-weights bring-up mode. /about reports family "mock" so the GUI can
117
+ // never mistake it for the real thing.
118
+ if (this.daemon.config?.axom?.mock) args.push('--mock');
119
+ const instance = {
120
+ id, port, dataDir, status: 'starting', startedAt: Date.now(), error: null, proc: null,
121
+ };
122
+ this.instances.set(id, instance);
123
+ this._broadcast();
124
+
125
+ let proc;
126
+ try {
127
+ proc = spawn(this._command(), args, { stdio: ['ignore', 'pipe', 'pipe'] });
128
+ } catch (err) {
129
+ instance.status = 'error';
130
+ instance.error = err.message;
131
+ this._broadcast();
132
+ throw err;
133
+ }
134
+ instance.proc = proc;
135
+ let stderrTail = '';
136
+ proc.stderr?.on('data', (d) => { stderrTail = (stderrTail + d.toString()).slice(-2000); });
137
+ proc.on('error', (err) => {
138
+ instance.status = 'error';
139
+ instance.error = err.code === 'ENOENT'
140
+ ? `"${this._command()}" not found — install the Axom runtime first`
141
+ : err.message;
142
+ this._broadcast();
143
+ });
144
+ proc.on('exit', (code, signal) => {
145
+ const wasRunning = instance.status === 'running';
146
+ instance.status = instance.status === 'stopping' ? 'stopped' : 'error';
147
+ if (instance.status === 'error') {
148
+ // The lockfile refusal (§11: two instances on one data-dir) lands
149
+ // here — surface the runtime's own words, don't paraphrase.
150
+ instance.error = instance.error || stderrTail.trim().split('\n').pop() || `exited (code ${code}, signal ${signal})`;
151
+ }
152
+ instance.proc = null;
153
+ if (wasRunning) this._deregisterEndpoint(id);
154
+ this._broadcast();
155
+ this.daemon.audit.log('axom.instance.exit', { id, code, signal });
156
+ });
157
+
158
+ await this._waitForAbout(port, instance);
159
+ if (instance.status !== 'error') {
160
+ instance.status = 'running';
161
+ this._registerEndpoint(id, port);
162
+ this.daemon.audit.log('axom.instance.start', { id, port, dataDir });
163
+ }
164
+ this._broadcast();
165
+ if (instance.status === 'error') throw new Error(instance.error || 'instance failed to start');
166
+ return this.list().find((i) => i.id === id);
167
+ }
168
+
169
+ async stop(id) {
170
+ const instance = this.instances.get(id);
171
+ if (!instance) throw new Error(`no instance "${id}"`);
172
+ instance.status = 'stopping';
173
+ this._broadcast();
174
+ this._deregisterEndpoint(id);
175
+ if (instance.proc) {
176
+ instance.proc.kill('SIGTERM');
177
+ await new Promise((resolve) => {
178
+ const t = setTimeout(() => { instance.proc?.kill('SIGKILL'); resolve(); }, 5000);
179
+ instance.proc.once('exit', () => { clearTimeout(t); resolve(); });
180
+ });
181
+ } else {
182
+ instance.status = 'stopped';
183
+ }
184
+ this._broadcast();
185
+ this.daemon.audit.log('axom.instance.stop', { id });
186
+ }
187
+
188
+ async _waitForAbout(port, instance) {
189
+ const deadline = Date.now() + START_TIMEOUT_MS;
190
+ while (Date.now() < deadline) {
191
+ if (instance.status === 'error') return;
192
+ try {
193
+ const res = await fetch(`http://127.0.0.1:${port}/about`, { signal: AbortSignal.timeout(2000) });
194
+ if (res.ok) return;
195
+ } catch { /* not up yet */ }
196
+ await new Promise((r) => setTimeout(r, 500));
197
+ }
198
+ instance.status = 'error';
199
+ instance.error = instance.error || `no /about on port ${port} within ${START_TIMEOUT_MS / 1000}s`;
200
+ instance.proc?.kill('SIGKILL');
201
+ }
202
+
203
+ // Managed instances surface through the same connector as any endpoint —
204
+ // one socket, every tier (contract §5).
205
+ _registerEndpoint(id, port) {
206
+ const name = `local-${id}`;
207
+ const entries = (this.daemon.config.axom?.endpoints || []).filter((e) => e.name !== name);
208
+ entries.push({ name, url: `http://127.0.0.1:${port}`, managed: true });
209
+ this.daemon.config.axom = { ...(this.daemon.config.axom || {}), endpoints: entries };
210
+ this.daemon.axom.configure(entries);
211
+ }
212
+
213
+ _deregisterEndpoint(id) {
214
+ const name = `local-${id}`;
215
+ const entries = (this.daemon.config.axom?.endpoints || []).filter((e) => e.name !== name);
216
+ this.daemon.config.axom = { ...(this.daemon.config.axom || {}), endpoints: entries };
217
+ this.daemon.axom.configure(entries);
218
+ }
219
+
220
+ _broadcast() {
221
+ this.daemon.broadcast({ type: 'axom:instances', data: this.list() });
222
+ }
223
+
224
+ async destroy() {
225
+ for (const id of [...this.instances.keys()]) {
226
+ try { await this.stop(id); } catch { /* shutting down */ }
227
+ }
228
+ }
229
+ }
@@ -355,6 +355,12 @@ export class Federation {
355
355
  };
356
356
  }
357
357
 
358
+ // Public accessor for this daemon's stable id (used by cross-daemon InnerChat
359
+ // relay to identify and verify the sender). Same value as _daemonId().
360
+ getDaemonId() {
361
+ return this._daemonId();
362
+ }
363
+
358
364
  _daemonId() {
359
365
  // Stable ID derived from keypair — fingerprint of public key
360
366
  const idPath = resolve(this.fedDir, 'daemon.id');
@@ -53,6 +53,10 @@ import { Orchestrator } from './orchestrator.js';
53
53
  import { TrajectoryCapture, ConsentManager } from '../../../moe-training/client/index.js';
54
54
  import { isFirstRun, runFirstTimeSetup, loadConfig, saveConfig, printWelcome } from './firstrun.js';
55
55
  import { bindDaemon as bindGrooveNetworkDaemon } from './providers/groove-network.js';
56
+ import { bindDaemon as bindAxomDaemon } from './providers/axom.js';
57
+ import { AxomConnector } from './axom-connector.js';
58
+ import { AxomServerManager } from './axom-server.js';
59
+ import { AxomInstaller } from './axom-install.js';
56
60
  import { setProviderPaths } from './providers/index.js';
57
61
 
58
62
  const DEFAULT_PORT = 31415;
@@ -166,6 +170,9 @@ export class Daemon {
166
170
  this.watcher = new Watcher(this);
167
171
  this.autoState = new AutoState(this.grooveDir);
168
172
  this.orchestrator = new Orchestrator(this);
173
+ this.axom = new AxomConnector(this);
174
+ this.axomServer = new AxomServerManager(this);
175
+ this.axomInstaller = new AxomInstaller(this);
169
176
  this.trajectoryCapture = null;
170
177
 
171
178
  // Hook teams.delete to clean up agent-loop session files
@@ -202,6 +209,7 @@ export class Daemon {
202
209
  // Give the groove-network provider a handle to the daemon so it can read
203
210
  // networkBeta config at spawn time without a circular import.
204
211
  bindGrooveNetworkDaemon(this);
212
+ bindAxomDaemon(this);
205
213
 
206
214
  // HTTP + WebSocket server
207
215
  this.app = express();
@@ -626,6 +634,7 @@ export class Daemon {
626
634
  this.orchestrator.start();
627
635
  this.timeline.start();
628
636
  this.gateways.start();
637
+ this.axom.start();
629
638
  this.federation.initialize();
630
639
  this._startGarbageCollector();
631
640
 
@@ -877,6 +886,8 @@ export class Daemon {
877
886
  this.chatStore.stop();
878
887
  this.orchestrator.stop();
879
888
  this.timeline.stop();
889
+ await this.axomServer.destroy();
890
+ this.axom.destroy();
880
891
  if (this._gcInterval) clearInterval(this._gcInterval);
881
892
  if (this._stateSaveInterval) clearInterval(this._stateSaveInterval);
882
893
  if (this._classifierInterval) clearInterval(this._classifierInterval);
@@ -9,7 +9,13 @@
9
9
  //
10
10
  // No imports on purpose — process.js and introducer.js both pull from here.
11
11
 
12
- export function innerChatInstructions(port = 31415, agentName = 'YOUR_NAME') {
12
+ export function innerChatInstructions(port = 31415, agentName = 'YOUR_NAME', peers = []) {
13
+ const aliases = Array.isArray(peers) ? peers.map((p) => p.alias).filter(Boolean) : [];
14
+ const peerRule = aliases.length
15
+ ? `- Agents on peer machines are addressed as \`name@peer\` (configured peers: ${aliases.join(', ')}). `
16
+ + 'A plain name is always local; `name@peer` routes the same ask/tell to that daemon and relays the reply back.'
17
+ : '- To reach an agent on another machine, address it as `name@peer` (a peer must be configured first with '
18
+ + '`POST /api/innerchat/peers`); a plain name is always local.';
13
19
  return [
14
20
  '## Consulting Other Agents (InnerChat)',
15
21
  '',
@@ -56,6 +62,7 @@ export function innerChatInstructions(port = 31415, agentName = 'YOUR_NAME') {
56
62
  '- Only start a conversation when the user asks you to. Never consult other agents on your own initiative.',
57
63
  `- \`from\` must be your own agent name${agentName === 'YOUR_NAME' ? ' (it is in your $GROOVE_AGENT_NAME environment variable)' : ''}.`,
58
64
  '- Agent names are listed in `AGENTS_REGISTRY.md`, including agents on other teams.',
65
+ peerRule,
59
66
  '- Use `ask` when you need the reply now; use `tell` to hand off or notify without waiting.',
60
67
  '- One message per call. With `ask`, wait for the answer before deciding what to ask next.',
61
68
  '- Ground truth lives in files (specs, code, run output), not in chat — use InnerChat to',
@@ -0,0 +1,89 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ //
3
+ // Cross-daemon InnerChat relay transport (spec: plans/cross-daemon-innerchat-spec.md).
4
+ //
5
+ // An agent addresses a peer's agent as `name@alias`. A plain `name` stays purely
6
+ // local (zero behavior change); `name@alias` routes to the daemon configured
7
+ // under that alias. Transport is whatever the user can already reach — a
8
+ // tunnel-forwarded localhost port today, a Tailscale address tomorrow — so this
9
+ // module never assumes the topology: a peer is just a URL. The daemon stays
10
+ // bound to loopback; no new listener is opened.
11
+
12
+ const RELAY_FETCH_TIMEOUT_MS = 30000; // ask holds the peer's request open
13
+
14
+ // Peer config entry: { alias, url, daemonId }. Reuses the Axom connector's
15
+ // endpoint rules for the URL (http(s), no embedded credentials) and pins the
16
+ // peer's federation daemon id so replies/signatures resolve to one identity.
17
+ export function validatePeer(entry) {
18
+ if (!entry || typeof entry !== 'object') return 'peer must be an object';
19
+ const { alias, url, daemonId } = entry;
20
+ if (!alias || typeof alias !== 'string' || !/^[a-zA-Z0-9_-]{1,40}$/.test(alias)) {
21
+ return 'peer alias must be 1-40 chars (letters, digits, dash, underscore)';
22
+ }
23
+ if (!url || typeof url !== 'string') return 'peer url is required';
24
+ let parsed;
25
+ try { parsed = new URL(url); } catch { return `invalid url: ${url}`; }
26
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
27
+ return 'peer url must be http(s)';
28
+ }
29
+ if (parsed.username || parsed.password) return 'credentials in the url are not allowed';
30
+ if (!daemonId || typeof daemonId !== 'string' || !/^[a-f0-9]{6,64}$/.test(daemonId)) {
31
+ return 'peer daemonId must be lowercase hex (the peer daemon\'s federation id)';
32
+ }
33
+ return null;
34
+ }
35
+
36
+ // Split an InnerChat address. `name` → local (peer null). `name@alias` → remote.
37
+ // Agent names never contain '@', so it reliably marks a peer reference. Returns
38
+ // null on a malformed reference (empty name or empty/multi alias).
39
+ export function parsePeerRef(ref) {
40
+ if (!ref || typeof ref !== 'string' || !ref.includes('@')) return { name: ref, alias: null };
41
+ const parts = ref.split('@');
42
+ if (parts.length !== 2) return null; // transitive/garbled — one hop only
43
+ const [name, alias] = parts.map((s) => s.trim());
44
+ if (!name || !alias) return null;
45
+ return { name, alias };
46
+ }
47
+
48
+ // Thin HTTP client for the relay. Kept injectable so tests can drive the relay
49
+ // logic without binding two real servers.
50
+ export class RelayClient {
51
+ constructor({ fetchImpl = fetch, timeoutMs = RELAY_FETCH_TIMEOUT_MS } = {}) {
52
+ this.fetchImpl = fetchImpl;
53
+ this.timeoutMs = timeoutMs;
54
+ }
55
+
56
+ async _post(url, body, timeoutMs) {
57
+ const controller = new AbortController();
58
+ const timer = setTimeout(() => controller.abort(), timeoutMs || this.timeoutMs);
59
+ try {
60
+ const res = await this.fetchImpl(url, {
61
+ method: 'POST',
62
+ headers: { 'Content-Type': 'application/json' },
63
+ body: JSON.stringify(body),
64
+ signal: controller.signal,
65
+ });
66
+ const json = await res.json().catch(() => ({}));
67
+ return { ok: res.ok, status: res.status, json };
68
+ } finally {
69
+ clearTimeout(timer);
70
+ }
71
+ }
72
+
73
+ // POST a signed relay envelope to a peer. `{ payload, signature }`.
74
+ sendRelay(peerUrl, envelope, timeoutMs) {
75
+ return this._post(`${trimUrl(peerUrl)}/api/innerchat/relay`, envelope, timeoutMs);
76
+ }
77
+
78
+ // Drain replies queued on a peer for us (async `tell` answers). Signed so the
79
+ // peer can confirm which daemon is asking and hand back only its entries.
80
+ fetchOutbox(peerUrl, envelope, timeoutMs) {
81
+ return this._post(`${trimUrl(peerUrl)}/api/innerchat/relay/outbox`, envelope, timeoutMs);
82
+ }
83
+ }
84
+
85
+ function trimUrl(url) {
86
+ return url.replace(/\/+$/, '');
87
+ }
88
+
89
+ export { RELAY_FETCH_TIMEOUT_MS };