groove-dev 0.27.198 → 0.27.199

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/axom-integration/Screenshot_2026-07-25_at_6.44.56_PM.png +0 -0
  2. package/axom-integration/Screenshot_2026-07-25_at_6.45.35_PM.png +0 -0
  3. package/node_modules/@groove-dev/cli/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/package.json +1 -1
  5. package/node_modules/@groove-dev/daemon/src/axom-connector.js +30 -2
  6. package/node_modules/@groove-dev/daemon/src/axom-install.js +24 -2
  7. package/node_modules/@groove-dev/daemon/src/axom-remote.js +230 -0
  8. package/node_modules/@groove-dev/daemon/src/axom-server.js +22 -2
  9. package/node_modules/@groove-dev/daemon/src/index.js +3 -0
  10. package/node_modules/@groove-dev/daemon/src/routes/axom.js +73 -2
  11. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +190 -8
  12. package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +44 -1
  13. package/node_modules/@groove-dev/daemon/test/axom-remote.test.js +115 -0
  14. package/node_modules/@groove-dev/daemon/test/axom-server.test.js +50 -9
  15. package/node_modules/@groove-dev/gui/dist/assets/{index-b9dKN6cq.js → index-BbA-X4CE.js} +243 -233
  16. package/node_modules/@groove-dev/gui/dist/assets/index-BbE3qX83.css +1 -0
  17. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  18. package/node_modules/@groove-dev/gui/package.json +1 -1
  19. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +3 -1
  20. package/node_modules/@groove-dev/gui/src/stores/groove.js +4 -1
  21. package/node_modules/@groove-dev/gui/src/stores/slices/axom-slice.js +83 -1
  22. package/node_modules/@groove-dev/gui/src/views/axom.jsx +1147 -310
  23. package/node_modules/@groove-dev/gui/src/views/settings.jsx +123 -70
  24. package/package.json +1 -1
  25. package/packages/cli/package.json +1 -1
  26. package/packages/daemon/package.json +1 -1
  27. package/packages/daemon/src/axom-connector.js +30 -2
  28. package/packages/daemon/src/axom-install.js +24 -2
  29. package/packages/daemon/src/axom-remote.js +230 -0
  30. package/packages/daemon/src/axom-server.js +22 -2
  31. package/packages/daemon/src/index.js +3 -0
  32. package/packages/daemon/src/routes/axom.js +73 -2
  33. package/packages/daemon/src/routes/innerchat.js +190 -8
  34. package/packages/gui/dist/assets/{index-b9dKN6cq.js → index-BbA-X4CE.js} +243 -233
  35. package/packages/gui/dist/assets/index-BbE3qX83.css +1 -0
  36. package/packages/gui/dist/index.html +2 -2
  37. package/packages/gui/package.json +1 -1
  38. package/packages/gui/src/components/agents/agent-feed.jsx +3 -1
  39. package/packages/gui/src/stores/groove.js +4 -1
  40. package/packages/gui/src/stores/slices/axom-slice.js +83 -1
  41. package/packages/gui/src/views/axom.jsx +1147 -310
  42. package/packages/gui/src/views/settings.jsx +123 -70
  43. package/node_modules/@groove-dev/gui/dist/assets/index-RbtaI6l7.css +0 -1
  44. package/packages/gui/dist/assets/index-RbtaI6l7.css +0 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.198",
3
+ "version": "0.27.199",
4
4
  "description": "GROOVE CLI — manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.198",
3
+ "version": "0.27.199",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -245,12 +245,14 @@ export class AxomConnector {
245
245
  // §12 message verb: starts a turn (interrupt steers, stop halts). Status is
246
246
  // part of the contract (202 accepted / 409 busy / 413 too_long) so this
247
247
  // returns {status, body} verbatim instead of throwing on non-2xx.
248
- async message(endpointName, sessionId, text) {
248
+ async message(endpointName, sessionId, text, clientRef) {
249
249
  const ep = this._requireEndpoint(endpointName);
250
250
  const res = await fetch(`${ep.url}/session/${encodeURIComponent(sessionId)}/message`, {
251
251
  method: 'POST',
252
252
  headers: { 'Content-Type': 'application/json' },
253
- body: JSON.stringify({ text }),
253
+ // §15: opaque, echoed back in pipeline_start. Omitted when absent so
254
+ // pre-§15 runtimes see exactly today's payload.
255
+ body: JSON.stringify(clientRef ? { text, client_ref: clientRef } : { text }),
254
256
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
255
257
  });
256
258
  const body = await res.json().catch(() => ({}));
@@ -260,6 +262,21 @@ export class AxomConnector {
260
262
  return { status: res.status, body };
261
263
  }
262
264
 
265
+ // §14: ends the RUNTIME (distinct from stop, which halts a turn). 409 when
266
+ // a turn is in flight unless forced; statuses pass through so the GUI can
267
+ // say what happened rather than guess.
268
+ async shutdown(endpointName, { force = false } = {}) {
269
+ const ep = this._requireEndpoint(endpointName);
270
+ const res = await fetch(`${ep.url}/shutdown`, {
271
+ method: 'POST',
272
+ headers: { 'Content-Type': 'application/json' },
273
+ body: JSON.stringify({ force }),
274
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
275
+ });
276
+ const body = await res.json().catch(() => ({}));
277
+ return { status: res.status, body };
278
+ }
279
+
263
280
  async interrupt(endpointName, sessionId, text) {
264
281
  const ep = this._requireEndpoint(endpointName);
265
282
  return this._fetch(ep, `/session/${encodeURIComponent(sessionId)}/interrupt`, {
@@ -295,6 +312,14 @@ export class AxomConnector {
295
312
  }
296
313
 
297
314
  status() {
315
+ // A runtime we spawned can be shut down; one we merely connected to
316
+ // cannot (no signal reaches another machine's process). The GUI needs
317
+ // this to avoid offering a kill switch it can't honor.
318
+ const managed = new Set(
319
+ (this.daemon.axomServer?.list?.() || [])
320
+ .filter((i) => i.status === 'running')
321
+ .map((i) => `http://127.0.0.1:${i.port}`),
322
+ );
298
323
  return {
299
324
  endpoints: [...this.endpoints.values()].map((ep) => ({
300
325
  name: ep.name,
@@ -303,6 +328,9 @@ export class AxomConnector {
303
328
  error: ep.error,
304
329
  about: ep.about,
305
330
  drift: ep.drift,
331
+ managed: managed.has(ep.url),
332
+ instanceId: (this.daemon.axomServer?.list?.() || [])
333
+ .find((i) => i.status === 'running' && `http://127.0.0.1:${i.port}` === ep.url)?.id || null,
306
334
  sessions: [...ep.sessions.values()].map((s) => ({
307
335
  session: s.id,
308
336
  started: s.started,
@@ -29,8 +29,17 @@ export class AxomInstaller {
29
29
  this._running = false;
30
30
  }
31
31
 
32
+ // Availability is a first-class answer, not an error string. A GROOVE build
33
+ // with no manifest configured cannot install Axom at all — the UI must say
34
+ // so up front rather than offering a button that fails on click.
32
35
  getStatus() {
33
- return { ...this.status };
36
+ const manifestUrl = this.daemon.config?.axom?.manifestUrl || null;
37
+ return {
38
+ ...this.status,
39
+ available: !!manifestUrl,
40
+ manifestUrl,
41
+ unavailableReason: manifestUrl ? null : 'Coming soon',
42
+ };
34
43
  }
35
44
 
36
45
  _update(patch) {
@@ -39,8 +48,12 @@ export class AxomInstaller {
39
48
  }
40
49
 
41
50
  async install(manifestUrl) {
51
+ // Gate, don't explain: a build with no configured distribution simply
52
+ // cannot install. The UI shows "Coming soon" and never offers the action;
53
+ // this is the server-side half of the same gate (a hand-crafted POST
54
+ // can't bypass it either).
42
55
  const url = manifestUrl || this.daemon.config?.axom?.manifestUrl;
43
- if (!url) throw new Error('no install manifest configured (axom.manifestUrl)');
56
+ if (!url) throw new Error('Coming soon Axom is not yet available for local install.');
44
57
  if (this._running) throw new Error('an install is already running');
45
58
  // Same hardware floor as the instance manager — never download 4GB of
46
59
  // weights onto a machine that can't safely run them. Manifest min_ram_gb
@@ -58,6 +71,12 @@ export class AxomInstaller {
58
71
  try {
59
72
  this._update({ phase: 'manifest', file: null, receivedBytes: 0, totalBytes: 0, error: null });
60
73
  const res = await fetch(url, { signal: AbortSignal.timeout(15000) });
74
+ if (res.status === 401 || res.status === 403) {
75
+ throw new Error('This Axom build is private — your account does not have access to the distribution. Connect to an Axom running elsewhere instead.');
76
+ }
77
+ if (res.status === 404) {
78
+ throw new Error(`No Axom distribution found at ${url} — the configured manifest URL is wrong or the release was removed.`);
79
+ }
61
80
  if (!res.ok) throw new Error(`manifest fetch failed: HTTP ${res.status}`);
62
81
  const manifest = await res.json();
63
82
  if (!Array.isArray(manifest.models)) throw new Error('manifest has no models list');
@@ -106,6 +125,9 @@ export class AxomInstaller {
106
125
  this._update({ phase: 'models', file, receivedBytes: 0, totalBytes: bytes || 0 });
107
126
 
108
127
  const res = await fetch(url);
128
+ if (res.status === 401 || res.status === 403) {
129
+ throw new Error(`${file} is in a private repository your account can't read — the model weights are gated. Nothing was installed.`);
130
+ }
109
131
  if (!res.ok || !res.body) throw new Error(`download failed for ${file}: HTTP ${res.status}`);
110
132
 
111
133
  const hash = createHash('sha256');
@@ -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
+ }
@@ -56,6 +56,7 @@ export class AxomServerManager {
56
56
  this.daemon = daemon;
57
57
  this.command = opts.command || null; // resolved lazily from config
58
58
  this.totalRamGbOverride = opts.totalRamGb; // tests inject; prod reads os
59
+ this.portBase = opts.portBase || AXOM_DEFAULT_PORT;
59
60
  this.instances = new Map(); // id -> {id, proc, port, dataDir, status, startedAt, error}
60
61
  }
61
62
 
@@ -73,7 +74,7 @@ export class AxomServerManager {
73
74
  for (const ep of this.daemon.axom?.endpoints?.values?.() || []) {
74
75
  try { used.add(Number(new URL(ep.url).port)); } catch { /* non-numeric */ }
75
76
  }
76
- let port = AXOM_DEFAULT_PORT;
77
+ let port = this.portBase;
77
78
  while (used.has(port)) port += 1;
78
79
  return port;
79
80
  }
@@ -191,7 +192,26 @@ export class AxomServerManager {
191
192
  if (instance.status === 'error') return;
192
193
  try {
193
194
  const res = await fetch(`http://127.0.0.1:${port}/about`, { signal: AbortSignal.timeout(2000) });
194
- if (res.ok) return;
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
+ }
195
215
  } catch { /* not up yet */ }
196
216
  await new Promise((r) => setTimeout(r, 500));
197
217
  }
@@ -57,6 +57,7 @@ import { bindDaemon as bindAxomDaemon } from './providers/axom.js';
57
57
  import { AxomConnector } from './axom-connector.js';
58
58
  import { AxomServerManager } from './axom-server.js';
59
59
  import { AxomInstaller } from './axom-install.js';
60
+ import { AxomRemote } from './axom-remote.js';
60
61
  import { setProviderPaths } from './providers/index.js';
61
62
 
62
63
  const DEFAULT_PORT = 31415;
@@ -173,6 +174,7 @@ export class Daemon {
173
174
  this.axom = new AxomConnector(this);
174
175
  this.axomServer = new AxomServerManager(this);
175
176
  this.axomInstaller = new AxomInstaller(this);
177
+ this.axomRemote = new AxomRemote(this);
176
178
  this.trajectoryCapture = null;
177
179
 
178
180
  // Hook teams.delete to clean up agent-loop session files
@@ -886,6 +888,7 @@ export class Daemon {
886
888
  this.chatStore.stop();
887
889
  this.orchestrator.stop();
888
890
  this.timeline.stop();
891
+ this.axomRemote.closeTunnel();
889
892
  await this.axomServer.destroy();
890
893
  this.axom.destroy();
891
894
  if (this._gcInterval) clearInterval(this._gcInterval);
@@ -4,6 +4,7 @@ import os from 'os';
4
4
  import { saveConfig } from '../firstrun.js';
5
5
  import { validateEndpoint, AXOM_DEFAULT_PORT } from '../axom-connector.js';
6
6
  import { hardwareReport } from '../axom-server.js';
7
+ import { validateRemote } from '../axom-remote.js';
7
8
 
8
9
  // Interrupt text is capped runtime-side at 2000 chars (contract §2, with a
9
10
  // `truncated` flag in the response); we allow headroom and let the runtime be
@@ -37,11 +38,14 @@ export function registerAxomRoutes(app, daemon) {
37
38
  // through verbatim — the GUI reads them, we don't reinterpret.
38
39
  app.post('/api/axom/sessions/:id/message', async (req, res) => {
39
40
  try {
40
- const { text, endpoint } = req.body || {};
41
+ const { text, endpoint, clientRef } = req.body || {};
41
42
  if (!text || typeof text !== 'string' || !text.trim()) {
42
43
  return res.status(400).json({ error: 'text is required' });
43
44
  }
44
- const result = await daemon.axom.message(endpoint, req.params.id, text);
45
+ if (clientRef !== undefined && (typeof clientRef !== 'string' || clientRef.length > 64)) {
46
+ return res.status(400).json({ error: 'clientRef must be a string of at most 64 chars' });
47
+ }
48
+ const result = await daemon.axom.message(endpoint, req.params.id, text, clientRef);
45
49
  daemon.audit.log('axom.message', { session: req.params.id, chars: text.length, status: result.status });
46
50
  res.status(result.status).json(result.body);
47
51
  } catch (err) {
@@ -101,6 +105,73 @@ export function registerAxomRoutes(app, daemon) {
101
105
  });
102
106
  });
103
107
 
108
+ // §14: shut down the runtime itself. Works for any endpoint that supports
109
+ // the verb — including remote ones, which GROOVE could never reach with a
110
+ // signal. A 404 means the runtime predates §14; the GUI must say so rather
111
+ // than claim the runtime is gone.
112
+ app.post('/api/axom/shutdown', async (req, res) => {
113
+ try {
114
+ const result = await daemon.axom.shutdown(req.body?.endpoint, { force: !!req.body?.force });
115
+ daemon.audit.log('axom.shutdown', { endpoint: req.body?.endpoint || null, status: result.status });
116
+ res.status(result.status === 404 ? 501 : result.status)
117
+ .json(result.status === 404
118
+ ? { error: 'this runtime does not support remote shutdown (predates contract §14)' }
119
+ : result.body);
120
+ } catch (err) {
121
+ res.status(502).json({ error: err.message });
122
+ }
123
+ });
124
+
125
+ // ── Remote runtime control over SSH (manual only, never automatic) ──────
126
+
127
+ app.get('/api/axom/remote', async (req, res) => {
128
+ res.json(await daemon.axomRemote.status());
129
+ });
130
+
131
+ app.patch('/api/axom/remote', (req, res) => {
132
+ const remote = req.body || {};
133
+ if (remote.clear === true) {
134
+ delete daemon.config.axom?.remote;
135
+ saveConfig(daemon.grooveDir, daemon.config);
136
+ return res.json({ configured: false });
137
+ }
138
+ const problem = validateRemote(remote);
139
+ if (problem) return res.status(400).json({ error: problem });
140
+ const { host, user, sshPort, port, command, logPath } = remote;
141
+ daemon.config.axom = {
142
+ ...(daemon.config.axom || {}),
143
+ remote: { host, user, sshPort, port, command, logPath },
144
+ };
145
+ saveConfig(daemon.grooveDir, daemon.config);
146
+ daemon.audit.log('axom.remote.config', { host, user });
147
+ res.json(daemon.config.axom.remote);
148
+ });
149
+
150
+ // Reachability only — opens/heals the port-forward. Never starts a runtime.
151
+ app.post('/api/axom/remote/tunnel', async (req, res) => {
152
+ try {
153
+ res.json(await daemon.axomRemote.ensureTunnel());
154
+ } catch (err) {
155
+ res.status(502).json({ error: err.message });
156
+ }
157
+ });
158
+
159
+ app.post('/api/axom/remote/start', async (req, res) => {
160
+ try {
161
+ res.json(await daemon.axomRemote.start());
162
+ } catch (err) {
163
+ res.status(502).json({ error: err.message });
164
+ }
165
+ });
166
+
167
+ app.post('/api/axom/remote/stop', async (req, res) => {
168
+ try {
169
+ res.json(await daemon.axomRemote.stop({ force: !!req.body?.force }));
170
+ } catch (err) {
171
+ res.status(502).json({ error: err.message });
172
+ }
173
+ });
174
+
104
175
  // ── Managed local instances (contract §11) ──────────────────────────────
105
176
 
106
177
  app.get('/api/axom/instances', (req, res) => {