groove-dev 0.27.217 → 0.27.220

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.
@@ -0,0 +1 @@
1
+ axom@10.0.0.205 # DGX Spark (edgexpert-aaa6) — "axom" is just the login user on that box
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.217",
3
+ "version": "0.27.220",
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.217",
3
+ "version": "0.27.220",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -43,11 +43,12 @@ export class ClaudeCodeProvider extends Provider {
43
43
  // FIRST model of a tier. Opus 5 stays first so auto-routing doesn't silently
44
44
  // move heavy agents onto the Fable price tier (2× Opus per token) — Fable is
45
45
  // an explicit user choice.
46
- { id: 'claude-opus-5', name: 'Claude Opus 5', tier: 'heavy', contextWindow: 1_000_000 },
46
+ { id: 'claude-opus-5-5', name: 'Claude Opus 5.5', tier: 'heavy', contextWindow: 1_000_000 },
47
47
  { id: 'claude-fable-5-1', name: 'Claude Fable 5.1', tier: 'heavy', contextWindow: 1_000_000 },
48
48
  { id: 'claude-fable-5', name: 'Claude Fable 5', tier: 'heavy', contextWindow: 1_000_000 },
49
49
  { id: 'claude-opus-4-8', name: 'Claude Opus 4.8', tier: 'heavy', contextWindow: 1_000_000 },
50
50
  { id: 'claude-opus-4-6', name: 'Claude Opus 4.6', tier: 'heavy', contextWindow: 1_000_000 },
51
+ { id: 'claude-sonnet-5', name: 'Claude Sonnet 5', tier: 'medium', contextWindow: 1_000_000 },
51
52
  { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', tier: 'medium', contextWindow: 200_000 },
52
53
  { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', tier: 'light', contextWindow: 200_000 },
53
54
  ];
@@ -4,6 +4,7 @@
4
4
  import { spawn, execFileSync } from 'child_process';
5
5
  import { randomUUID } from 'crypto';
6
6
  import { existsSync } from 'fs';
7
+ import { StringDecoder } from 'string_decoder';
7
8
 
8
9
  // Python helper that creates a real PTY and relays I/O through stdin/stdout pipes.
9
10
  // The shell sees a genuine TTY — prompts, colors, line editing, tab completion all work.
@@ -64,12 +65,22 @@ try:
64
65
  bel_pos = stdin_buf.find(ESC_END)
65
66
  if bel_pos == -1:
66
67
  break
67
- params = stdin_buf[4:bel_pos].decode().split(';')
68
- if len(params) == 2:
69
- r, c = int(params[0]), int(params[1])
70
- fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', r, c, 0, 0))
71
- os.kill(pid, signal.SIGWINCH)
68
+ # Only OUR resize command lives on this channel — no real terminal
69
+ # sends OSC 7 as keyboard input. A malformed one used to raise
70
+ # inside this loop, escape it, and take the whole shell down; now
71
+ # it is simply dropped (forwarding it would feed the shell's line
72
+ # editor a garbage key chord).
73
+ raw = stdin_buf[:bel_pos+1]
72
74
  stdin_buf = stdin_buf[bel_pos+1:]
75
+ try:
76
+ params = raw[4:-1].decode().split(';')
77
+ if len(params) == 2:
78
+ r, c = int(params[0]), int(params[1])
79
+ if 0 < r < 1000 and 0 < c < 1000:
80
+ fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', r, c, 0, 0))
81
+ os.kill(pid, signal.SIGWINCH)
82
+ except (ValueError, UnicodeDecodeError, OSError):
83
+ pass
73
84
  if master in rlist:
74
85
  try:
75
86
  data = os.read(master, 4096)
@@ -128,16 +139,23 @@ export class TerminalManager {
128
139
  const session = { proc, ws, id, label: options.label || '' };
129
140
  this.sessions.set(id, session);
130
141
 
142
+ // The PTY relay hands us arbitrary 4096-byte slices, and a multi-byte UTF-8
143
+ // character (box drawing, emoji — TUI apps are full of them) can straddle a
144
+ // slice. Buffer.toString() on each slice independently turns every such
145
+ // character into U+FFFD garbage; a StringDecoder carries the partial byte
146
+ // sequence across chunks.
147
+ const outDecoder = new StringDecoder('utf8');
148
+ const errDecoder = new StringDecoder('utf8');
131
149
  proc.stdout.on('data', (data) => {
132
- if (ws.readyState === 1) {
133
- ws.send(JSON.stringify({ type: 'terminal:output', id, data: data.toString('utf8') }));
134
- }
150
+ if (ws.readyState !== 1) return;
151
+ const text = outDecoder.write(data);
152
+ if (text) ws.send(JSON.stringify({ type: 'terminal:output', id, data: text }));
135
153
  });
136
154
 
137
155
  proc.stderr.on('data', (data) => {
138
- if (ws.readyState === 1) {
139
- ws.send(JSON.stringify({ type: 'terminal:output', id, data: data.toString('utf8') }));
140
- }
156
+ if (ws.readyState !== 1) return;
157
+ const text = errDecoder.write(data);
158
+ if (text) ws.send(JSON.stringify({ type: 'terminal:output', id, data: text }));
141
159
  });
142
160
 
143
161
  proc.on('exit', (code) => {
@@ -15,6 +15,20 @@ function getLocalVersion() {
15
15
  } catch { return '0.0.0'; }
16
16
  }
17
17
 
18
+ // True only when `a` is strictly older than `b` (numeric x.y.z; any suffix such
19
+ // as "-local" is ignored). Auto-upgrade must never move a remote BACKWARDS: a
20
+ // box running a newer local build (promote-local.sh) than npm's latest would
21
+ // otherwise be "upgraded" down to the published release on every connect.
22
+ export function isOlderVersion(a, b) {
23
+ const parse = (v) => String(v || '').split('-')[0].split('.').map((n) => parseInt(n, 10) || 0);
24
+ const pa = parse(a), pb = parse(b);
25
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
26
+ const d = (pa[i] || 0) - (pb[i] || 0);
27
+ if (d !== 0) return d < 0;
28
+ }
29
+ return false;
30
+ }
31
+
18
32
  const REMOTE_PORT = 31415;
19
33
 
20
34
  // Every remote `groove start` must run from the directory of the daemon's
@@ -665,13 +679,13 @@ export class TunnelManager {
665
679
  }).trim();
666
680
  } catch { return; }
667
681
 
668
- if (!npmVer || npmVer === remoteVer) {
682
+ if (!npmVer || !isOlderVersion(remoteVer, npmVer)) {
669
683
  const localVer = getLocalVersion();
670
684
  this.daemon.broadcast({ type: 'tunnel.version-info', data: { id, localVersion: localVer, remoteVersion: remoteVer, match: remoteVer === localVer } });
671
685
  return;
672
686
  }
673
687
 
674
- // Remote is behind npm — upgrade
688
+ // Remote is strictly behind npm — upgrade
675
689
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'upgrading', from: remoteVer, to: npmVer } });
676
690
 
677
691
  const target = `${config.user}@${config.host}`;
@@ -125,9 +125,9 @@ describe('ClaudeCodeProvider models', () => {
125
125
  it('offers Fable 5', () => {
126
126
  assert.ok(ClaudeCodeProvider.models.some((m) => m.id === 'claude-fable-5'));
127
127
  assert.ok(ClaudeCodeProvider.models.some((m) => m.id === 'claude-fable-5-1'));
128
- // Routing guard: Opus 5 must stay models[0] — the Router defaults to it and
129
- // putting a Fable model first would silently route heavy agents onto a tier
130
- // priced at twice Opus.
131
- assert.equal(ClaudeCodeProvider.models[0].id, 'claude-opus-5');
128
+ // Routing guard: an Opus model must stay models[0] — the Router defaults to
129
+ // it and putting a Fable model first would silently route heavy agents onto
130
+ // a tier priced at twice Opus.
131
+ assert.equal(ClaudeCodeProvider.models[0].id, 'claude-opus-5-5');
132
132
  });
133
133
  });
@@ -0,0 +1,80 @@
1
+ // GROOVE — Terminal PTY relay tests
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+ //
4
+ // Spawns the real Python PTY helper + a shell. Guards the relay against the
5
+ // failures that made TUI apps (Textual etc.) render garbage or die:
6
+ // - multi-byte UTF-8 split across 4096-byte relay chunks → U+FFFD garbage
7
+ // - a malformed in-band resize sequence killing the whole relay loop
8
+
9
+ import { describe, it, afterEach } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { TerminalManager } from '../src/terminal-pty.js';
12
+
13
+ function fakeWs() {
14
+ const ws = { readyState: 1, out: [], exit: null };
15
+ ws.send = (raw) => {
16
+ const m = JSON.parse(raw);
17
+ if (m.type === 'terminal:output') ws.out.push(m.data);
18
+ if (m.type === 'terminal:exit') ws.exit = m.code;
19
+ };
20
+ return ws;
21
+ }
22
+ const settle = (ms) => new Promise((r) => setTimeout(r, ms));
23
+ async function waitFor(pred, ms = 20000) {
24
+ const end = Date.now() + ms;
25
+ while (Date.now() < end) { if (pred()) return true; await settle(100); }
26
+ return pred();
27
+ }
28
+
29
+ describe('TerminalManager (real PTY helper)', () => {
30
+ let mgr;
31
+ afterEach(() => { mgr?.killAll(); });
32
+
33
+ it('relays multi-byte UTF-8 intact across chunk boundaries', async () => {
34
+ mgr = new TerminalManager({ projectDir: '/tmp' });
35
+ const ws = fakeWs();
36
+ const id = mgr.spawn(ws, { cols: 100, rows: 30, cwd: '/tmp' });
37
+ assert.ok(id);
38
+ await waitFor(() => ws.out.join('').length > 0);
39
+
40
+ // 3000 × '★' (3 bytes each) = 9000 bytes: guaranteed to straddle the
41
+ // helper's 4096-byte reads several times. The marker is assembled at
42
+ // runtime from two halves so the shell's ECHO of the typed command never
43
+ // contains it — only the real output does. (Counting echoed markers made
44
+ // this race on shells that redraw the input line.)
45
+ const marker = 'UTF8TEST';
46
+ mgr.write(id, `M="UTF8""TEST"; printf "$M"; python3 -c "print('\\u2605'*3000, end='')"; printf "$M\\n"\r`);
47
+ const ok = await waitFor(() => {
48
+ const all = ws.out.join('');
49
+ const first = all.indexOf(marker);
50
+ return first !== -1 && all.indexOf(marker, first + marker.length) !== -1;
51
+ });
52
+ assert.ok(ok, 'command output arrived');
53
+
54
+ const all = ws.out.join('');
55
+ const body = all.slice(all.indexOf(marker) + marker.length, all.lastIndexOf(marker));
56
+ assert.equal((body.match(/�/g) || []).length, 0, 'no replacement characters');
57
+ assert.equal((body.match(/★/g) || []).length, 3000, 'every star survived the relay');
58
+ });
59
+
60
+ it('resizes the PTY in-band and survives a malformed resize sequence', async () => {
61
+ mgr = new TerminalManager({ projectDir: '/tmp' });
62
+ const ws = fakeWs();
63
+ const id = mgr.spawn(ws, { cols: 80, rows: 24, cwd: '/tmp' });
64
+ await waitFor(() => ws.out.join('').length > 0);
65
+
66
+ mgr.resize(id, 40, 132);
67
+ await settle(300);
68
+ mgr.write(id, 'stty size\r');
69
+ assert.ok(await waitFor(() => /40 132/.test(ws.out.join(''))), 'shell sees the new size');
70
+
71
+ // Garbage that shares our OSC 7 prefix — previously raised inside the relay
72
+ // loop, escaped it, and took the shell down with it.
73
+ mgr.write(id, '\x1b]7;not;numbers;at;all\x07');
74
+ mgr.write(id, '\x1b]7;file://host/tmp\x07');
75
+ await settle(300);
76
+ mgr.write(id, 'echo STILL_ALIVE_$((6*7))\r');
77
+ assert.ok(await waitFor(() => ws.out.join('').includes('STILL_ALIVE_42')), 'relay survived');
78
+ assert.equal(ws.exit, null, 'session did not exit');
79
+ });
80
+ });
@@ -392,3 +392,14 @@ describe('resolveBestAddress — multi-homed host selection', () => {
392
392
  assert.equal(await resolveBestAddress('localhost', deadPort, 500), 'localhost');
393
393
  });
394
394
  });
395
+
396
+ describe('isOlderVersion (auto-upgrade never downgrades)', () => {
397
+ it('only reports strictly older versions', async () => {
398
+ const { isOlderVersion } = await import('../src/tunnel-manager.js');
399
+ assert.equal(isOlderVersion('0.27.216', '0.27.217'), true);
400
+ assert.equal(isOlderVersion('0.27.9', '0.27.10'), true);
401
+ assert.equal(isOlderVersion('0.27.219', '0.27.217'), false); // local build ahead of npm
402
+ assert.equal(isOlderVersion('0.27.217', '0.27.217'), false);
403
+ assert.equal(isOlderVersion('0.27.219-local', '0.27.217'), false);
404
+ });
405
+ });