groove-dev 0.27.197 → 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 (76) 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/axom-integration/Screenshot_2026-07-25_at_6.44.56_PM.png +0 -0
  5. package/axom-integration/Screenshot_2026-07-25_at_6.45.35_PM.png +0 -0
  6. package/node_modules/@groove-dev/cli/package.json +1 -1
  7. package/node_modules/@groove-dev/daemon/package.json +1 -1
  8. package/node_modules/@groove-dev/daemon/src/api.js +2 -0
  9. package/node_modules/@groove-dev/daemon/src/axom-connector.js +368 -0
  10. package/node_modules/@groove-dev/daemon/src/axom-install.js +162 -0
  11. package/node_modules/@groove-dev/daemon/src/axom-remote.js +230 -0
  12. package/node_modules/@groove-dev/daemon/src/axom-server.js +249 -0
  13. package/node_modules/@groove-dev/daemon/src/federation.js +6 -0
  14. package/node_modules/@groove-dev/daemon/src/index.js +14 -0
  15. package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +8 -1
  16. package/node_modules/@groove-dev/daemon/src/innerchat-relay.js +89 -0
  17. package/node_modules/@groove-dev/daemon/src/innerchat.js +261 -1
  18. package/node_modules/@groove-dev/daemon/src/introducer.js +1 -1
  19. package/node_modules/@groove-dev/daemon/src/network-guard.js +56 -0
  20. package/node_modules/@groove-dev/daemon/src/process.js +1 -1
  21. package/node_modules/@groove-dev/daemon/src/providers/axom.js +66 -0
  22. package/node_modules/@groove-dev/daemon/src/providers/index.js +2 -0
  23. package/node_modules/@groove-dev/daemon/src/routes/axom.js +267 -0
  24. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +334 -20
  25. package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +455 -0
  26. package/node_modules/@groove-dev/daemon/test/axom-remote.test.js +115 -0
  27. package/node_modules/@groove-dev/daemon/test/axom-server.test.js +228 -0
  28. package/node_modules/@groove-dev/daemon/test/innerchat-relay.test.js +251 -0
  29. package/node_modules/@groove-dev/gui/dist/assets/index-BbA-X4CE.js +1084 -0
  30. package/node_modules/@groove-dev/gui/dist/assets/index-BbE3qX83.css +1 -0
  31. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  32. package/node_modules/@groove-dev/gui/package.json +1 -1
  33. package/node_modules/@groove-dev/gui/src/App.jsx +2 -0
  34. package/node_modules/@groove-dev/gui/src/app.css +53 -0
  35. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +3 -1
  36. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -1
  37. package/node_modules/@groove-dev/gui/src/stores/groove.js +22 -0
  38. package/node_modules/@groove-dev/gui/src/stores/slices/axom-slice.js +279 -0
  39. package/node_modules/@groove-dev/gui/src/views/axom.jsx +1897 -0
  40. package/node_modules/@groove-dev/gui/src/views/settings.jsx +232 -4
  41. package/package.json +2 -2
  42. package/packages/cli/package.json +1 -1
  43. package/packages/daemon/package.json +1 -1
  44. package/packages/daemon/src/api.js +2 -0
  45. package/packages/daemon/src/axom-connector.js +368 -0
  46. package/packages/daemon/src/axom-install.js +162 -0
  47. package/packages/daemon/src/axom-remote.js +230 -0
  48. package/packages/daemon/src/axom-server.js +249 -0
  49. package/packages/daemon/src/federation.js +6 -0
  50. package/packages/daemon/src/index.js +14 -0
  51. package/packages/daemon/src/innerchat-docs.js +8 -1
  52. package/packages/daemon/src/innerchat-relay.js +89 -0
  53. package/packages/daemon/src/innerchat.js +261 -1
  54. package/packages/daemon/src/introducer.js +1 -1
  55. package/packages/daemon/src/network-guard.js +56 -0
  56. package/packages/daemon/src/process.js +1 -1
  57. package/packages/daemon/src/providers/axom.js +66 -0
  58. package/packages/daemon/src/providers/index.js +2 -0
  59. package/packages/daemon/src/routes/axom.js +267 -0
  60. package/packages/daemon/src/routes/innerchat.js +334 -20
  61. package/packages/gui/dist/assets/index-BbA-X4CE.js +1084 -0
  62. package/packages/gui/dist/assets/index-BbE3qX83.css +1 -0
  63. package/packages/gui/dist/index.html +2 -2
  64. package/packages/gui/package.json +1 -1
  65. package/packages/gui/src/App.jsx +2 -0
  66. package/packages/gui/src/app.css +53 -0
  67. package/packages/gui/src/components/agents/agent-feed.jsx +3 -1
  68. package/packages/gui/src/components/layout/activity-bar.jsx +2 -1
  69. package/packages/gui/src/stores/groove.js +22 -0
  70. package/packages/gui/src/stores/slices/axom-slice.js +279 -0
  71. package/packages/gui/src/views/axom.jsx +1897 -0
  72. package/packages/gui/src/views/settings.jsx +232 -4
  73. package/node_modules/@groove-dev/gui/dist/assets/index-Da2nbd6M.js +0 -1043
  74. package/node_modules/@groove-dev/gui/dist/assets/index-zmrIbwNm.css +0 -1
  75. package/packages/gui/dist/assets/index-Da2nbd6M.js +0 -1043
  76. package/packages/gui/dist/assets/index-zmrIbwNm.css +0 -1
@@ -0,0 +1,230 @@
1
+ // GROOVE — Axom Remote Runtime Control (SSH)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+ //
4
+ // Start and stop an `axom serve` on a machine you own, over SSH, from the
5
+ // GROOVE that talks to it. Deliberately MANUAL: nothing here auto-starts, and
6
+ // nothing supervises or restarts a runtime that stops. The user decides when
7
+ // the workhorse is working (Ryan, 2026-07-25: "I will turn it off and on as I
8
+ // need... I just need the control").
9
+ //
10
+ // Only ever runs commands the user configured, on a host the user configured,
11
+ // with their own SSH credentials. GROOVE holds no keys of its own.
12
+
13
+ import { execFile, spawn } from 'child_process';
14
+ import { AXOM_DEFAULT_PORT } from './axom-connector.js';
15
+
16
+ const SSH_OPTS = [
17
+ '-o', 'ConnectTimeout=8',
18
+ '-o', 'StrictHostKeyChecking=accept-new',
19
+ '-o', 'BatchMode=yes',
20
+ ];
21
+
22
+ function validateHost(host) {
23
+ // Hostname or IP only — no user@, no flags, no shell metacharacters. The
24
+ // value reaches execFile as a single argv entry, never a shell string.
25
+ return typeof host === 'string' && /^[a-zA-Z0-9._-]{1,253}$/.test(host);
26
+ }
27
+
28
+ function validateUser(user) {
29
+ return typeof user === 'string' && /^[a-zA-Z0-9._-]{1,32}$/.test(user);
30
+ }
31
+
32
+ export function validateRemote(remote) {
33
+ if (!remote || typeof remote !== 'object') return 'remote config must be an object';
34
+ if (!validateHost(remote.host)) return 'invalid host';
35
+ if (!validateUser(remote.user)) return 'invalid user';
36
+ if (remote.sshPort !== undefined && !(Number.isInteger(remote.sshPort) && remote.sshPort > 0 && remote.sshPort < 65536)) {
37
+ return 'invalid sshPort';
38
+ }
39
+ if (remote.port !== undefined && !(Number.isInteger(remote.port) && remote.port > 0 && remote.port < 65536)) {
40
+ return 'invalid port';
41
+ }
42
+ if (remote.command !== undefined && (typeof remote.command !== 'string' || remote.command.length > 500)) {
43
+ return 'command must be a string of at most 500 chars';
44
+ }
45
+ return null;
46
+ }
47
+
48
+ export class AxomRemote {
49
+ constructor(daemon, opts = {}) {
50
+ this.daemon = daemon;
51
+ this.exec = opts.exec || execFile; // tests inject
52
+ }
53
+
54
+ _config() {
55
+ return this.daemon.config?.axom?.remote || null;
56
+ }
57
+
58
+ _ssh(command, timeoutMs = 30000) {
59
+ const cfg = this._config();
60
+ if (!cfg) return Promise.reject(new Error('no remote Axom host configured'));
61
+ const problem = validateRemote(cfg);
62
+ if (problem) return Promise.reject(new Error(`remote config invalid: ${problem}`));
63
+
64
+ const args = [...SSH_OPTS, '-p', String(cfg.sshPort || 22), `${cfg.user}@${cfg.host}`, command];
65
+ return new Promise((resolve, reject) => {
66
+ this.exec('ssh', args, { encoding: 'utf8', timeout: timeoutMs }, (err, stdout, stderr) => {
67
+ if (err && !stdout) return reject(new Error((stderr || err.message || '').trim().split('\n').pop()));
68
+ resolve({ stdout: String(stdout || ''), stderr: String(stderr || '') });
69
+ });
70
+ });
71
+ }
72
+
73
+ // Is a runtime listening on the remote port right now?
74
+ async status() {
75
+ const cfg = this._config();
76
+ if (!cfg) return { configured: false, running: null };
77
+ const port = cfg.port || AXOM_DEFAULT_PORT;
78
+ try {
79
+ const { stdout } = await this._ssh(
80
+ `curl -sf --max-time 4 http://127.0.0.1:${port}/about >/dev/null 2>&1 && echo UP || echo DOWN`,
81
+ 15000,
82
+ );
83
+ return {
84
+ configured: true,
85
+ host: cfg.host,
86
+ user: cfg.user,
87
+ port,
88
+ running: stdout.includes('UP'),
89
+ error: null,
90
+ };
91
+ } catch (err) {
92
+ // Unreachable host is not "not running" — we genuinely do not know.
93
+ return { configured: true, host: cfg.host, user: cfg.user, port, running: null, error: err.message };
94
+ }
95
+ }
96
+
97
+ async start() {
98
+ const cfg = this._config();
99
+ if (!cfg) throw new Error('no remote Axom host configured');
100
+ const port = cfg.port || AXOM_DEFAULT_PORT;
101
+ const already = await this.status();
102
+ if (already.running) return { started: false, alreadyRunning: true, port };
103
+
104
+ const command = cfg.command
105
+ || `axom serve --port ${port} --data-dir ~/axom-serve/data --model-dir ~/axom-serve/models`;
106
+ // Detached from this SSH session so it survives the connection closing.
107
+ // No supervisor, no restart-on-exit: manual control means manual.
108
+ const log = cfg.logPath || '~/axom-serve/serve.log';
109
+ await this._ssh(`nohup ${command} >> ${log} 2>&1 < /dev/null & disown; echo STARTED`, 30000);
110
+
111
+ // Confirm it actually came up rather than reporting optimism.
112
+ for (let i = 0; i < 30; i++) {
113
+ await new Promise((r) => setTimeout(r, 2000));
114
+ const s = await this.status();
115
+ if (s.running) {
116
+ this.daemon.audit.log('axom.remote.start', { host: cfg.host, port });
117
+ this._broadcast();
118
+ return { started: true, port, logPath: log };
119
+ }
120
+ }
121
+ throw new Error(`started the command but nothing answered on port ${port} within 60s — check ${log} on ${cfg.host}`);
122
+ }
123
+
124
+ async stop({ force = false } = {}) {
125
+ const cfg = this._config();
126
+ if (!cfg) throw new Error('no remote Axom host configured');
127
+ const port = cfg.port || AXOM_DEFAULT_PORT;
128
+
129
+ // Prefer the contract's own verb (§14): it flushes the ledger and
130
+ // releases the lockfile. Fall back to a signal only if the runtime
131
+ // predates the verb.
132
+ try {
133
+ const { stdout } = await this._ssh(
134
+ `curl -s -o /dev/null -w '%{http_code}' --max-time 8 -X POST `
135
+ + `-H 'Content-Type: application/json' -d '{"force":${force ? 'true' : 'false'}}' `
136
+ + `http://127.0.0.1:${port}/shutdown`,
137
+ 20000,
138
+ );
139
+ const code = parseInt(stdout.trim().slice(-3), 10);
140
+ if (code === 202) {
141
+ this.daemon.audit.log('axom.remote.stop', { host: cfg.host, port, via: 'shutdown' });
142
+ this._broadcast();
143
+ return { stopped: true, via: 'shutdown' };
144
+ }
145
+ if (code === 409) return { stopped: false, turnInFlight: true };
146
+ if (code !== 404) throw new Error(`shutdown returned HTTP ${code}`);
147
+ } catch (err) {
148
+ if (!/HTTP 404/.test(err.message)) throw err;
149
+ }
150
+
151
+ // Pre-§14 runtime: signal the process that owns the port. Still the
152
+ // user's own machine, still an explicit action they asked for.
153
+ await this._ssh(`PID=$(lsof -t -i:${port} 2>/dev/null | head -1); [ -n "$PID" ] && kill $PID && echo KILLED || echo NOTFOUND`, 20000);
154
+ this.daemon.audit.log('axom.remote.stop', { host: cfg.host, port, via: 'signal' });
155
+ this._broadcast();
156
+ return { stopped: true, via: 'signal' };
157
+ }
158
+
159
+ // ── Reachability (the port-forward), distinct from runtime lifecycle ──────
160
+ //
161
+ // The runtime is the user's workhorse and is never touched automatically.
162
+ // The TUNNEL is plumbing: it dies on every sleep/wake (ssh's keepalive exits
163
+ // on resume and nothing respawns it), costs the remote machine nothing, and
164
+ // its death looks identical to "Axom is down" from the GUI. So we heal it on
165
+ // demand — but only ever a tunnel GROOVE opened, and only after a health
166
+ // check proves the port is genuinely dead (an unconditional teardown kills
167
+ // slow-but-working tunnels — learned by fullstack-3 the hard way).
168
+
169
+ async _portAnswers(port, timeoutMs = 2500) {
170
+ try {
171
+ const res = await fetch(`http://127.0.0.1:${port}/about`, { signal: AbortSignal.timeout(timeoutMs) });
172
+ return res.ok;
173
+ } catch {
174
+ return false;
175
+ }
176
+ }
177
+
178
+ async ensureTunnel() {
179
+ const cfg = this._config();
180
+ if (!cfg) return { tunneled: false, reason: 'no remote configured' };
181
+ const problem = validateRemote(cfg);
182
+ if (problem) return { tunneled: false, reason: problem };
183
+ const port = cfg.port || AXOM_DEFAULT_PORT;
184
+
185
+ if (await this._portAnswers(port)) return { tunneled: true, healed: false, port };
186
+
187
+ // Port is genuinely dead. If a tunnel process of ours is lingering, drop
188
+ // it before opening another (a stale forward holds the local port and the
189
+ // new ssh would fail with "address already in use").
190
+ if (this._tunnelProc && !this._tunnelProc.killed) {
191
+ try { this._tunnelProc.kill(); } catch { /* already gone */ }
192
+ this._tunnelProc = null;
193
+ }
194
+
195
+ const args = [
196
+ '-N', '-L', `127.0.0.1:${port}:localhost:${port}`,
197
+ ...SSH_OPTS,
198
+ '-o', 'ServerAliveInterval=30',
199
+ '-o', 'ExitOnForwardFailure=yes',
200
+ '-p', String(cfg.sshPort || 22),
201
+ `${cfg.user}@${cfg.host}`,
202
+ ];
203
+ const proc = spawn('ssh', args, { stdio: 'ignore', detached: false });
204
+ this._tunnelProc = proc;
205
+ proc.on('exit', () => { if (this._tunnelProc === proc) this._tunnelProc = null; });
206
+
207
+ for (let i = 0; i < 10; i++) {
208
+ await new Promise((r) => setTimeout(r, 500));
209
+ if (await this._portAnswers(port, 1500)) {
210
+ this.daemon.audit.log('axom.remote.tunnel', { host: cfg.host, port });
211
+ return { tunneled: true, healed: true, port };
212
+ }
213
+ if (proc.exitCode !== null) break;
214
+ }
215
+ // Honest failure: the forward may be up while the runtime is down — say
216
+ // what we know (port silent) rather than asserting a cause.
217
+ return { tunneled: false, healed: false, port, reason: `nothing answers on 127.0.0.1:${port} after opening the forward — the runtime may be stopped` };
218
+ }
219
+
220
+ closeTunnel() {
221
+ if (this._tunnelProc && !this._tunnelProc.killed) {
222
+ try { this._tunnelProc.kill(); } catch { /* already gone */ }
223
+ }
224
+ this._tunnelProc = null;
225
+ }
226
+
227
+ _broadcast() {
228
+ this.status().then((s) => this.daemon.broadcast({ type: 'axom:remote', data: s })).catch(() => {});
229
+ }
230
+ }
@@ -0,0 +1,249 @@
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.portBase = opts.portBase || AXOM_DEFAULT_PORT;
60
+ this.instances = new Map(); // id -> {id, proc, port, dataDir, status, startedAt, error}
61
+ }
62
+
63
+ _command() {
64
+ return this.command || this.daemon.config?.axom?.command || 'axom';
65
+ }
66
+
67
+ _modelDir() {
68
+ return this.daemon.config?.axom?.modelDir
69
+ || join(this.daemon.grooveDir, 'axom', 'models');
70
+ }
71
+
72
+ _allocatePort() {
73
+ const used = new Set([...this.instances.values()].map((i) => i.port));
74
+ for (const ep of this.daemon.axom?.endpoints?.values?.() || []) {
75
+ try { used.add(Number(new URL(ep.url).port)); } catch { /* non-numeric */ }
76
+ }
77
+ let port = this.portBase;
78
+ while (used.has(port)) port += 1;
79
+ return port;
80
+ }
81
+
82
+ list() {
83
+ return [...this.instances.values()].map(({ proc, ...rest }) => rest);
84
+ }
85
+
86
+ // Start a local instance. `id` doubles as the data-dir name, so the same id
87
+ // across restarts resumes the same sovereign memory.
88
+ async start(id = 'default') {
89
+ if (!/^[a-zA-Z0-9_-]{1,40}$/.test(id)) throw new Error('invalid instance id');
90
+ const existing = this.instances.get(id);
91
+ if (existing && existing.status === 'running') return this.list().find((i) => i.id === id);
92
+
93
+ // Hardware floor — refuse under-spec local inference instead of letting
94
+ // it take the host down. Mock mode is weightless and exempt.
95
+ if (!this.daemon.config?.axom?.mock && !this.daemon.config?.axom?.allowUnderspec) {
96
+ const totalRamGb = this.totalRamGbOverride ?? os.totalmem() / 2 ** 30;
97
+ if (totalRamGb < AXOM_REQUIREMENTS.minRamGb) {
98
+ throw new Error(
99
+ `This machine has ${Math.round(totalRamGb)}GB RAM — below Axom's ${AXOM_REQUIREMENTS.minRamGb}GB floor for local inference. `
100
+ + 'Connect to a remote Axom endpoint instead (axom.allowUnderspec overrides at your own risk).',
101
+ );
102
+ }
103
+ }
104
+
105
+ const port = this._allocatePort();
106
+ const dataDir = join(this.daemon.grooveDir, 'axom', 'instances', id);
107
+ mkdirSync(dataDir, { recursive: true });
108
+
109
+ const args = [
110
+ 'serve',
111
+ '--host', '127.0.0.1',
112
+ '--port', String(port),
113
+ '--data-dir', dataDir,
114
+ '--model-dir', this._modelDir(),
115
+ ];
116
+ // config.axom.mock: run the scripted engine (§11 addendum) — the
117
+ // no-weights bring-up mode. /about reports family "mock" so the GUI can
118
+ // never mistake it for the real thing.
119
+ if (this.daemon.config?.axom?.mock) args.push('--mock');
120
+ const instance = {
121
+ id, port, dataDir, status: 'starting', startedAt: Date.now(), error: null, proc: null,
122
+ };
123
+ this.instances.set(id, instance);
124
+ this._broadcast();
125
+
126
+ let proc;
127
+ try {
128
+ proc = spawn(this._command(), args, { stdio: ['ignore', 'pipe', 'pipe'] });
129
+ } catch (err) {
130
+ instance.status = 'error';
131
+ instance.error = err.message;
132
+ this._broadcast();
133
+ throw err;
134
+ }
135
+ instance.proc = proc;
136
+ let stderrTail = '';
137
+ proc.stderr?.on('data', (d) => { stderrTail = (stderrTail + d.toString()).slice(-2000); });
138
+ proc.on('error', (err) => {
139
+ instance.status = 'error';
140
+ instance.error = err.code === 'ENOENT'
141
+ ? `"${this._command()}" not found — install the Axom runtime first`
142
+ : err.message;
143
+ this._broadcast();
144
+ });
145
+ proc.on('exit', (code, signal) => {
146
+ const wasRunning = instance.status === 'running';
147
+ instance.status = instance.status === 'stopping' ? 'stopped' : 'error';
148
+ if (instance.status === 'error') {
149
+ // The lockfile refusal (§11: two instances on one data-dir) lands
150
+ // here — surface the runtime's own words, don't paraphrase.
151
+ instance.error = instance.error || stderrTail.trim().split('\n').pop() || `exited (code ${code}, signal ${signal})`;
152
+ }
153
+ instance.proc = null;
154
+ if (wasRunning) this._deregisterEndpoint(id);
155
+ this._broadcast();
156
+ this.daemon.audit.log('axom.instance.exit', { id, code, signal });
157
+ });
158
+
159
+ await this._waitForAbout(port, instance);
160
+ if (instance.status !== 'error') {
161
+ instance.status = 'running';
162
+ this._registerEndpoint(id, port);
163
+ this.daemon.audit.log('axom.instance.start', { id, port, dataDir });
164
+ }
165
+ this._broadcast();
166
+ if (instance.status === 'error') throw new Error(instance.error || 'instance failed to start');
167
+ return this.list().find((i) => i.id === id);
168
+ }
169
+
170
+ async stop(id) {
171
+ const instance = this.instances.get(id);
172
+ if (!instance) throw new Error(`no instance "${id}"`);
173
+ instance.status = 'stopping';
174
+ this._broadcast();
175
+ this._deregisterEndpoint(id);
176
+ if (instance.proc) {
177
+ instance.proc.kill('SIGTERM');
178
+ await new Promise((resolve) => {
179
+ const t = setTimeout(() => { instance.proc?.kill('SIGKILL'); resolve(); }, 5000);
180
+ instance.proc.once('exit', () => { clearTimeout(t); resolve(); });
181
+ });
182
+ } else {
183
+ instance.status = 'stopped';
184
+ }
185
+ this._broadcast();
186
+ this.daemon.audit.log('axom.instance.stop', { id });
187
+ }
188
+
189
+ async _waitForAbout(port, instance) {
190
+ const deadline = Date.now() + START_TIMEOUT_MS;
191
+ while (Date.now() < deadline) {
192
+ if (instance.status === 'error') return;
193
+ try {
194
+ const res = await fetch(`http://127.0.0.1:${port}/about`, { signal: AbortSignal.timeout(2000) });
195
+ if (res.ok) {
196
+ // Verify the answer is OURS. Another process (or an SSH tunnel to a
197
+ // remote runtime) can already own this port; adopting it would let
198
+ // GROOVE claim — and offer to shut down — a runtime it never
199
+ // launched. §11 gives /about an instance block; when it names a
200
+ // different pid or data-dir, this port is not ours.
201
+ const about = await res.json().catch(() => null);
202
+ const claimed = about?.instance;
203
+ const foreign = claimed
204
+ && ((claimed.pid && instance.proc?.pid && claimed.pid !== instance.proc.pid)
205
+ || (claimed.data_dir && claimed.data_dir !== instance.dataDir));
206
+ if (foreign) {
207
+ instance.status = 'error';
208
+ instance.error = `port ${port} is already served by another Axom runtime `
209
+ + `(${claimed.data_dir || `pid ${claimed.pid}`}) — refusing to adopt it`;
210
+ instance.proc?.kill('SIGKILL');
211
+ return;
212
+ }
213
+ return;
214
+ }
215
+ } catch { /* not up yet */ }
216
+ await new Promise((r) => setTimeout(r, 500));
217
+ }
218
+ instance.status = 'error';
219
+ instance.error = instance.error || `no /about on port ${port} within ${START_TIMEOUT_MS / 1000}s`;
220
+ instance.proc?.kill('SIGKILL');
221
+ }
222
+
223
+ // Managed instances surface through the same connector as any endpoint —
224
+ // one socket, every tier (contract §5).
225
+ _registerEndpoint(id, port) {
226
+ const name = `local-${id}`;
227
+ const entries = (this.daemon.config.axom?.endpoints || []).filter((e) => e.name !== name);
228
+ entries.push({ name, url: `http://127.0.0.1:${port}`, managed: true });
229
+ this.daemon.config.axom = { ...(this.daemon.config.axom || {}), endpoints: entries };
230
+ this.daemon.axom.configure(entries);
231
+ }
232
+
233
+ _deregisterEndpoint(id) {
234
+ const name = `local-${id}`;
235
+ const entries = (this.daemon.config.axom?.endpoints || []).filter((e) => e.name !== name);
236
+ this.daemon.config.axom = { ...(this.daemon.config.axom || {}), endpoints: entries };
237
+ this.daemon.axom.configure(entries);
238
+ }
239
+
240
+ _broadcast() {
241
+ this.daemon.broadcast({ type: 'axom:instances', data: this.list() });
242
+ }
243
+
244
+ async destroy() {
245
+ for (const id of [...this.instances.keys()]) {
246
+ try { await this.stop(id); } catch { /* shutting down */ }
247
+ }
248
+ }
249
+ }
@@ -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,11 @@ 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';
60
+ import { AxomRemote } from './axom-remote.js';
56
61
  import { setProviderPaths } from './providers/index.js';
57
62
 
58
63
  const DEFAULT_PORT = 31415;
@@ -166,6 +171,10 @@ export class Daemon {
166
171
  this.watcher = new Watcher(this);
167
172
  this.autoState = new AutoState(this.grooveDir);
168
173
  this.orchestrator = new Orchestrator(this);
174
+ this.axom = new AxomConnector(this);
175
+ this.axomServer = new AxomServerManager(this);
176
+ this.axomInstaller = new AxomInstaller(this);
177
+ this.axomRemote = new AxomRemote(this);
169
178
  this.trajectoryCapture = null;
170
179
 
171
180
  // Hook teams.delete to clean up agent-loop session files
@@ -202,6 +211,7 @@ export class Daemon {
202
211
  // Give the groove-network provider a handle to the daemon so it can read
203
212
  // networkBeta config at spawn time without a circular import.
204
213
  bindGrooveNetworkDaemon(this);
214
+ bindAxomDaemon(this);
205
215
 
206
216
  // HTTP + WebSocket server
207
217
  this.app = express();
@@ -626,6 +636,7 @@ export class Daemon {
626
636
  this.orchestrator.start();
627
637
  this.timeline.start();
628
638
  this.gateways.start();
639
+ this.axom.start();
629
640
  this.federation.initialize();
630
641
  this._startGarbageCollector();
631
642
 
@@ -877,6 +888,9 @@ export class Daemon {
877
888
  this.chatStore.stop();
878
889
  this.orchestrator.stop();
879
890
  this.timeline.stop();
891
+ this.axomRemote.closeTunnel();
892
+ await this.axomServer.destroy();
893
+ this.axom.destroy();
880
894
  if (this._gcInterval) clearInterval(this._gcInterval);
881
895
  if (this._stateSaveInterval) clearInterval(this._stateSaveInterval);
882
896
  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 };