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.
- package/axom-integration/Screenshot_2026-07-25_at_5.25.30_PM.png +0 -0
- package/axom-integration/Screenshot_2026-07-25_at_5.33.26_PM.png +0 -0
- package/axom-integration/Screenshot_2026-07-25_at_6.11.53_PM.png +0 -0
- package/axom-integration/Screenshot_2026-07-25_at_6.44.56_PM.png +0 -0
- package/axom-integration/Screenshot_2026-07-25_at_6.45.35_PM.png +0 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +2 -0
- package/node_modules/@groove-dev/daemon/src/axom-connector.js +368 -0
- package/node_modules/@groove-dev/daemon/src/axom-install.js +162 -0
- package/node_modules/@groove-dev/daemon/src/axom-remote.js +230 -0
- package/node_modules/@groove-dev/daemon/src/axom-server.js +249 -0
- package/node_modules/@groove-dev/daemon/src/federation.js +6 -0
- package/node_modules/@groove-dev/daemon/src/index.js +14 -0
- package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +8 -1
- package/node_modules/@groove-dev/daemon/src/innerchat-relay.js +89 -0
- package/node_modules/@groove-dev/daemon/src/innerchat.js +261 -1
- package/node_modules/@groove-dev/daemon/src/introducer.js +1 -1
- package/node_modules/@groove-dev/daemon/src/network-guard.js +56 -0
- package/node_modules/@groove-dev/daemon/src/process.js +1 -1
- package/node_modules/@groove-dev/daemon/src/providers/axom.js +66 -0
- package/node_modules/@groove-dev/daemon/src/providers/index.js +2 -0
- package/node_modules/@groove-dev/daemon/src/routes/axom.js +267 -0
- package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +334 -20
- package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +455 -0
- package/node_modules/@groove-dev/daemon/test/axom-remote.test.js +115 -0
- package/node_modules/@groove-dev/daemon/test/axom-server.test.js +228 -0
- package/node_modules/@groove-dev/daemon/test/innerchat-relay.test.js +251 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-BbA-X4CE.js +1084 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-BbE3qX83.css +1 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/App.jsx +2 -0
- package/node_modules/@groove-dev/gui/src/app.css +53 -0
- package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +3 -1
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +22 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/axom-slice.js +279 -0
- package/node_modules/@groove-dev/gui/src/views/axom.jsx +1897 -0
- package/node_modules/@groove-dev/gui/src/views/settings.jsx +232 -4
- package/package.json +2 -2
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +2 -0
- package/packages/daemon/src/axom-connector.js +368 -0
- package/packages/daemon/src/axom-install.js +162 -0
- package/packages/daemon/src/axom-remote.js +230 -0
- package/packages/daemon/src/axom-server.js +249 -0
- package/packages/daemon/src/federation.js +6 -0
- package/packages/daemon/src/index.js +14 -0
- package/packages/daemon/src/innerchat-docs.js +8 -1
- package/packages/daemon/src/innerchat-relay.js +89 -0
- package/packages/daemon/src/innerchat.js +261 -1
- package/packages/daemon/src/introducer.js +1 -1
- package/packages/daemon/src/network-guard.js +56 -0
- package/packages/daemon/src/process.js +1 -1
- package/packages/daemon/src/providers/axom.js +66 -0
- package/packages/daemon/src/providers/index.js +2 -0
- package/packages/daemon/src/routes/axom.js +267 -0
- package/packages/daemon/src/routes/innerchat.js +334 -20
- package/packages/gui/dist/assets/index-BbA-X4CE.js +1084 -0
- package/packages/gui/dist/assets/index-BbE3qX83.css +1 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/App.jsx +2 -0
- package/packages/gui/src/app.css +53 -0
- package/packages/gui/src/components/agents/agent-feed.jsx +3 -1
- package/packages/gui/src/components/layout/activity-bar.jsx +2 -1
- package/packages/gui/src/stores/groove.js +22 -0
- package/packages/gui/src/stores/slices/axom-slice.js +279 -0
- package/packages/gui/src/views/axom.jsx +1897 -0
- package/packages/gui/src/views/settings.jsx +232 -4
- package/node_modules/@groove-dev/gui/dist/assets/index-Da2nbd6M.js +0 -1043
- package/node_modules/@groove-dev/gui/dist/assets/index-zmrIbwNm.css +0 -1
- package/packages/gui/dist/assets/index-Da2nbd6M.js +0 -1043
- 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 };
|