groove-dev 0.27.207 → 0.27.209

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 (34) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/src/axom-connector.js +50 -2
  4. package/node_modules/@groove-dev/daemon/src/axom-remote.js +16 -14
  5. package/node_modules/@groove-dev/daemon/src/axom-runtimes.js +498 -0
  6. package/node_modules/@groove-dev/daemon/src/axom-server.js +25 -4
  7. package/node_modules/@groove-dev/daemon/src/index.js +3 -1
  8. package/node_modules/@groove-dev/daemon/src/routes/axom.js +113 -0
  9. package/node_modules/@groove-dev/daemon/src/routes/watch.js +11 -0
  10. package/node_modules/@groove-dev/daemon/src/watcher.js +55 -2
  11. package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +44 -1
  12. package/node_modules/@groove-dev/daemon/test/axom-runtimes.test.js +367 -0
  13. package/node_modules/@groove-dev/daemon/test/watcher.test.js +68 -2
  14. package/node_modules/@groove-dev/gui/dist/assets/{index-OzNfp6-Y.js → index-217ZVIOc.js} +242 -237
  15. package/node_modules/@groove-dev/gui/dist/assets/index-DdCadtGL.css +1 -0
  16. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  17. package/node_modules/@groove-dev/gui/package.json +1 -1
  18. package/package.json +1 -1
  19. package/packages/cli/package.json +1 -1
  20. package/packages/daemon/package.json +1 -1
  21. package/packages/daemon/src/axom-connector.js +50 -2
  22. package/packages/daemon/src/axom-remote.js +16 -14
  23. package/packages/daemon/src/axom-runtimes.js +498 -0
  24. package/packages/daemon/src/axom-server.js +25 -4
  25. package/packages/daemon/src/index.js +3 -1
  26. package/packages/daemon/src/routes/axom.js +113 -0
  27. package/packages/daemon/src/routes/watch.js +11 -0
  28. package/packages/daemon/src/watcher.js +55 -2
  29. package/packages/gui/dist/assets/{index-OzNfp6-Y.js → index-217ZVIOc.js} +242 -237
  30. package/packages/gui/dist/assets/index-DdCadtGL.css +1 -0
  31. package/packages/gui/dist/index.html +2 -2
  32. package/packages/gui/package.json +1 -1
  33. package/node_modules/@groove-dev/gui/dist/assets/index-DG6yq4dB.css +0 -1
  34. package/packages/gui/dist/assets/index-DG6yq4dB.css +0 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.207",
3
+ "version": "0.27.209",
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.207",
3
+ "version": "0.27.209",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -186,6 +186,7 @@ export class AxomConnector {
186
186
  live: !!info.live,
187
187
  ws: null,
188
188
  lastSeq: 0,
189
+ epoch: null,
189
190
  ring: [],
190
191
  overflow: 0,
191
192
  unknownKinds: {},
@@ -209,14 +210,35 @@ export class AxomConnector {
209
210
 
210
211
  _watchSession(ep, s) {
211
212
  if (this.destroyed) return;
212
- const since = s.lastSeq > 0 ? `?since=ev-${String(s.lastSeq).padStart(6, '0')}` : '';
213
- const wsUrl = `${ep.url.replace(/^http/, 'ws')}/ws/session/${encodeURIComponent(s.id)}${since}`;
213
+ // §16.4: reconnects carry both cursor and epoch; a stale epoch voids the
214
+ // cursor runtime-side and we get a full replay instead of a silent gap.
215
+ const params = [];
216
+ if (s.lastSeq > 0) params.push(`since=ev-${String(s.lastSeq).padStart(6, '0')}`);
217
+ if (s.epoch) params.push(`epoch=${encodeURIComponent(s.epoch)}`);
218
+ const query = params.length ? `?${params.join('&')}` : '';
219
+ const wsUrl = `${ep.url.replace(/^http/, 'ws')}/ws/session/${encodeURIComponent(s.id)}${query}`;
214
220
  const ws = new WebSocket(wsUrl);
215
221
  s.ws = ws;
216
222
 
217
223
  ws.on('message', (data) => {
218
224
  let envelope;
219
225
  try { envelope = JSON.parse(data.toString()); } catch { return; }
226
+ // §16.4 handshake frame — transport metadata, never a transcript event.
227
+ // A changed epoch means the runtime restarted and its event ids reset:
228
+ // our monotonic dedup would silently swallow the entire replay, so the
229
+ // cursor, ring, and GUI copy are all voided together.
230
+ if (envelope.kind === 'ws_hello') {
231
+ const epoch = envelope.payload?.epoch;
232
+ if (epoch && s.epoch && epoch !== s.epoch) {
233
+ s.lastSeq = 0;
234
+ s.ring = [];
235
+ s.overflow = 0;
236
+ s.unknownKinds = {};
237
+ this.daemon.broadcast({ type: 'axom:session:reset', endpoint: ep.name, session: s.id, epoch });
238
+ }
239
+ if (epoch) s.epoch = epoch;
240
+ return;
241
+ }
220
242
  const seq = seqOf(envelope.id);
221
243
  // Dedup on ring-buffer replay after reconnect — ids are monotonic.
222
244
  if (seq !== null && seq <= s.lastSeq) return;
@@ -371,6 +393,32 @@ export class AxomConnector {
371
393
 
372
394
  _broadcastStatus() {
373
395
  this.daemon.broadcast({ type: 'axom:status', data: this.status() });
396
+ // Runtime-level state is derived from connector state — push it too so
397
+ // the GUI's runtime cards move on events, not on a poll.
398
+ this.daemon.axomRuntimes?.broadcastStatus?.();
399
+ }
400
+
401
+ // Collapse the 'running but not connected' window: when a runtime is known
402
+ // to be answering /about, don't make the user wait out our backoff.
403
+ nudge(name) {
404
+ const ep = this.endpoints.get(name);
405
+ if (!ep || ep.status === 'connected' || this.destroyed) return;
406
+ if (ep.retryTimer) { clearTimeout(ep.retryTimer); ep.retryTimer = null; }
407
+ ep.backoffMs = this.backoffBaseMs;
408
+ this._handshake(ep);
409
+ }
410
+
411
+ // The inverse of nudge: after a deliberate stop, 'connected' is presumed
412
+ // stale — re-probe now so the endpoint transitions (and broadcasts) with the
413
+ // verb instead of lingering until the next scheduled poll fails.
414
+ recheck(name) {
415
+ const ep = this.endpoints.get(name);
416
+ if (!ep || this.destroyed) return;
417
+ if (ep.status === 'connected') {
418
+ this._pollSessions(ep).catch(() => this._endpointLost(ep));
419
+ } else {
420
+ this.nudge(name);
421
+ }
374
422
  }
375
423
 
376
424
  _teardownEndpoint(ep) {
@@ -55,8 +55,8 @@ export class AxomRemote {
55
55
  return this.daemon.config?.axom?.remote || null;
56
56
  }
57
57
 
58
- _ssh(command, timeoutMs = 30000) {
59
- const cfg = this._config();
58
+ _ssh(command, timeoutMs = 30000, cfgOverride = null) {
59
+ const cfg = cfgOverride || this._config();
60
60
  if (!cfg) return Promise.reject(new Error('no remote Axom host configured'));
61
61
  const problem = validateRemote(cfg);
62
62
  if (problem) return Promise.reject(new Error(`remote config invalid: ${problem}`));
@@ -71,14 +71,15 @@ export class AxomRemote {
71
71
  }
72
72
 
73
73
  // Is a runtime listening on the remote port right now?
74
- async status() {
75
- const cfg = this._config();
74
+ async status(cfgOverride = null) {
75
+ const cfg = cfgOverride || this._config();
76
76
  if (!cfg) return { configured: false, running: null };
77
77
  const port = cfg.port || AXOM_DEFAULT_PORT;
78
78
  try {
79
79
  const { stdout } = await this._ssh(
80
80
  `curl -sf --max-time 4 http://127.0.0.1:${port}/about >/dev/null 2>&1 && echo UP || echo DOWN`,
81
81
  15000,
82
+ cfg,
82
83
  );
83
84
  return {
84
85
  configured: true,
@@ -94,11 +95,11 @@ export class AxomRemote {
94
95
  }
95
96
  }
96
97
 
97
- async start() {
98
- const cfg = this._config();
98
+ async start(cfgOverride = null) {
99
+ const cfg = cfgOverride || this._config();
99
100
  if (!cfg) throw new Error('no remote Axom host configured');
100
101
  const port = cfg.port || AXOM_DEFAULT_PORT;
101
- const already = await this.status();
102
+ const already = await this.status(cfg);
102
103
  if (already.running) return { started: false, alreadyRunning: true, port };
103
104
 
104
105
  const command = cfg.command
@@ -112,12 +113,12 @@ export class AxomRemote {
112
113
  // NOTHING while reporting success. Found by running it.
113
114
  const log = cfg.logPath || '~/axom-serve/serve.log';
114
115
  const quoted = `'${command.replace(/'/g, `'\\''`)}'`;
115
- await this._ssh(`nohup bash -lc ${quoted} >> ${log} 2>&1 < /dev/null & disown; echo STARTED`, 30000);
116
+ await this._ssh(`nohup bash -lc ${quoted} >> ${log} 2>&1 < /dev/null & disown; echo STARTED`, 30000, cfg);
116
117
 
117
118
  // Confirm it actually came up rather than reporting optimism.
118
119
  for (let i = 0; i < 30; i++) {
119
120
  await new Promise((r) => setTimeout(r, 2000));
120
- const s = await this.status();
121
+ const s = await this.status(cfg);
121
122
  if (s.running) {
122
123
  this.daemon.audit.log('axom.remote.start', { host: cfg.host, port });
123
124
  this._broadcast();
@@ -127,8 +128,8 @@ export class AxomRemote {
127
128
  throw new Error(`started the command but nothing answered on port ${port} within 60s — check ${log} on ${cfg.host}`);
128
129
  }
129
130
 
130
- async stop({ force = false } = {}) {
131
- const cfg = this._config();
131
+ async stop({ force = false } = {}, cfgOverride = null) {
132
+ const cfg = cfgOverride || this._config();
132
133
  if (!cfg) throw new Error('no remote Axom host configured');
133
134
  const port = cfg.port || AXOM_DEFAULT_PORT;
134
135
 
@@ -141,6 +142,7 @@ export class AxomRemote {
141
142
  + `-H 'Content-Type: application/json' -d '{"force":${force ? 'true' : 'false'}}' `
142
143
  + `http://127.0.0.1:${port}/shutdown`,
143
144
  20000,
145
+ cfg,
144
146
  );
145
147
  const code = parseInt(stdout.trim().slice(-3), 10);
146
148
  if (code === 202) {
@@ -156,7 +158,7 @@ export class AxomRemote {
156
158
 
157
159
  // Pre-§14 runtime: signal the process that owns the port. Still the
158
160
  // user's own machine, still an explicit action they asked for.
159
- await this._ssh(`PID=$(lsof -t -i:${port} 2>/dev/null | head -1); [ -n "$PID" ] && kill $PID && echo KILLED || echo NOTFOUND`, 20000);
161
+ await this._ssh(`PID=$(lsof -t -i:${port} 2>/dev/null | head -1); [ -n "$PID" ] && kill $PID && echo KILLED || echo NOTFOUND`, 20000, cfg);
160
162
  this.daemon.audit.log('axom.remote.stop', { host: cfg.host, port, via: 'signal' });
161
163
  this._broadcast();
162
164
  return { stopped: true, via: 'signal' };
@@ -181,8 +183,8 @@ export class AxomRemote {
181
183
  }
182
184
  }
183
185
 
184
- async ensureTunnel() {
185
- const cfg = this._config();
186
+ async ensureTunnel(cfgOverride = null) {
187
+ const cfg = cfgOverride || this._config();
186
188
  if (!cfg) return { tunneled: false, reason: 'no remote configured' };
187
189
  const problem = validateRemote(cfg);
188
190
  if (problem) return { tunneled: false, reason: problem };