groove-dev 0.16.3 → 0.16.4

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.
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>GROOVE</title>
7
- <script type="module" crossorigin src="/assets/index-CFeltwTB.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-B_VHpncx.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-BhjOFLBc.css">
9
9
  </head>
10
10
  <body>
@@ -1,7 +1,7 @@
1
1
  // GROOVE GUI — Embedded Terminal (xterm.js)
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
- import React, { useRef, useEffect, useCallback } from 'react';
4
+ import React, { useRef, useEffect } from 'react';
5
5
  import { Terminal as XTerm } from '@xterm/xterm';
6
6
  import { FitAddon } from '@xterm/addon-fit';
7
7
  import { WebLinksAddon } from '@xterm/addon-web-links';
@@ -13,9 +13,10 @@ export default function Terminal({ visible }) {
13
13
  const termRef = useRef(null);
14
14
  const fitAddonRef = useRef(null);
15
15
  const sessionIdRef = useRef(null);
16
+ const spawnedRef = useRef(false);
16
17
  const ws = useGrooveStore((s) => s.ws);
17
18
 
18
- // Create terminal on mount
19
+ // Create xterm instance on mount
19
20
  useEffect(() => {
20
21
  if (!containerRef.current) return;
21
22
 
@@ -55,7 +56,11 @@ export default function Terminal({ visible }) {
55
56
  term.loadAddon(webLinksAddon);
56
57
 
57
58
  term.open(containerRef.current);
58
- fitAddon.fit();
59
+
60
+ // Fit after a frame so container has final dimensions
61
+ requestAnimationFrame(() => {
62
+ try { fitAddon.fit(); } catch { /* ignore */ }
63
+ });
59
64
 
60
65
  termRef.current = term;
61
66
  fitAddonRef.current = fitAddon;
@@ -64,19 +69,24 @@ export default function Terminal({ visible }) {
64
69
  term.dispose();
65
70
  termRef.current = null;
66
71
  fitAddonRef.current = null;
72
+ spawnedRef.current = false;
67
73
  };
68
74
  }, []);
69
75
 
70
- // Spawn shell session when ws is ready
76
+ // Spawn shell session when ws + term are ready
71
77
  useEffect(() => {
72
- if (!ws || ws.readyState !== 1 || !termRef.current) return;
78
+ if (!ws || ws.readyState !== 1 || !termRef.current || spawnedRef.current) return;
73
79
 
74
80
  const term = termRef.current;
81
+ spawnedRef.current = true;
75
82
 
76
- // Request a shell session
77
- ws.send(JSON.stringify({ type: 'terminal:spawn' }));
83
+ // Send spawn with initial dimensions
84
+ ws.send(JSON.stringify({
85
+ type: 'terminal:spawn',
86
+ cols: term.cols || 120,
87
+ rows: term.rows || 30,
88
+ }));
78
89
 
79
- // Listen for terminal messages
80
90
  const handler = (event) => {
81
91
  try {
82
92
  const msg = JSON.parse(event.data);
@@ -93,31 +103,38 @@ export default function Terminal({ visible }) {
93
103
 
94
104
  ws.addEventListener('message', handler);
95
105
 
96
- // Forward keystrokes to daemon
106
+ // Forward keystrokes
97
107
  const inputDisposable = term.onData((data) => {
98
108
  if (sessionIdRef.current && ws.readyState === 1) {
99
109
  ws.send(JSON.stringify({ type: 'terminal:input', id: sessionIdRef.current, data }));
100
110
  }
101
111
  });
102
112
 
113
+ // Forward resize events
114
+ const resizeDisposable = term.onResize(({ cols, rows }) => {
115
+ if (sessionIdRef.current && ws.readyState === 1) {
116
+ ws.send(JSON.stringify({ type: 'terminal:resize', id: sessionIdRef.current, cols, rows }));
117
+ }
118
+ });
119
+
103
120
  return () => {
104
121
  ws.removeEventListener('message', handler);
105
122
  inputDisposable.dispose();
106
- // Kill session on unmount
123
+ resizeDisposable.dispose();
107
124
  if (sessionIdRef.current && ws.readyState === 1) {
108
125
  ws.send(JSON.stringify({ type: 'terminal:kill', id: sessionIdRef.current }));
109
126
  }
110
127
  sessionIdRef.current = null;
128
+ spawnedRef.current = false;
111
129
  };
112
130
  }, [ws]);
113
131
 
114
132
  // Refit on visibility change
115
133
  useEffect(() => {
116
134
  if (visible && fitAddonRef.current) {
117
- // Small delay so container has its final size
118
135
  const timer = setTimeout(() => {
119
136
  try { fitAddonRef.current.fit(); } catch { /* ignore */ }
120
- }, 50);
137
+ }, 80);
121
138
  return () => clearTimeout(timer);
122
139
  }
123
140
  }, [visible]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.16.3",
3
+ "version": "0.16.4",
4
4
  "description": "Open-source agent orchestration layer for AI coding tools. GUI dashboard, multi-agent coordination, zero cold-start (Journalist), infinite sessions (adaptive context rotation), AI Project Manager, Quick Launch. Works with Claude Code, Codex, Gemini CLI, Ollama.",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
@@ -174,13 +174,16 @@ export class Daemon {
174
174
  break;
175
175
  // Terminal
176
176
  case 'terminal:spawn': {
177
- const id = this.terminalManager.spawn(ws, { cwd: msg.cwd });
177
+ const id = this.terminalManager.spawn(ws, { cwd: msg.cwd, cols: msg.cols, rows: msg.rows });
178
178
  ws.send(JSON.stringify({ type: 'terminal:spawned', id }));
179
179
  break;
180
180
  }
181
181
  case 'terminal:input':
182
182
  if (msg.id && msg.data) this.terminalManager.write(msg.id, msg.data);
183
183
  break;
184
+ case 'terminal:resize':
185
+ if (msg.id && msg.rows && msg.cols) this.terminalManager.resize(msg.id, msg.rows, msg.cols);
186
+ break;
184
187
  case 'terminal:kill':
185
188
  if (msg.id) this.terminalManager.kill(msg.id);
186
189
  break;
@@ -1,66 +1,129 @@
1
1
  // GROOVE — Terminal PTY Manager (shell sessions over WebSocket)
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
- import { spawn } from 'child_process';
4
+ import { spawn, execFileSync } from 'child_process';
5
5
  import { existsSync } from 'fs';
6
6
 
7
- /**
8
- * Manages interactive shell sessions for the GUI terminal.
9
- * Uses the `script` command to allocate a real PTY (prompts, colors, line editing).
10
- * No native modules required works on macOS and Linux out of the box.
11
- */
7
+ // Python helper that creates a real PTY and relays I/O through stdin/stdout pipes.
8
+ // The shell sees a genuine TTY prompts, colors, line editing, tab completion all work.
9
+ const PTY_HELPER = `
10
+ import pty, os, sys, select, signal, struct, fcntl, termios, errno
11
+
12
+ master, slave = pty.openpty()
13
+
14
+ cols = int(os.environ.get('COLS', '120'))
15
+ rows = int(os.environ.get('ROWS', '30'))
16
+ fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', rows, cols, 0, 0))
17
+
18
+ pid = os.fork()
19
+ if pid == 0:
20
+ os.setsid()
21
+ fcntl.ioctl(slave, termios.TIOCSCTTY, 0)
22
+ os.dup2(slave, 0)
23
+ os.dup2(slave, 1)
24
+ os.dup2(slave, 2)
25
+ os.close(master)
26
+ os.close(slave)
27
+ shell = os.environ.get('GROOVE_SHELL', os.environ.get('SHELL', '/bin/bash'))
28
+ os.execvp(shell, [shell, '-l'])
29
+
30
+ os.close(slave)
31
+
32
+ def resize(sig, frame):
33
+ pass
34
+ signal.signal(signal.SIGWINCH, resize)
35
+
36
+ flags = fcntl.fcntl(0, fcntl.F_GETFL)
37
+ fcntl.fcntl(0, fcntl.F_SETFL, flags | os.O_NONBLOCK)
38
+
39
+ try:
40
+ while True:
41
+ rlist = select.select([0, master], [], [], 0.05)[0]
42
+ if 0 in rlist:
43
+ try:
44
+ data = os.read(0, 4096)
45
+ if not data: break
46
+ # Resize command: ESC ] 7 ; <rows> ; <cols> BEL
47
+ if b'\\x1b]7;' in data:
48
+ idx = data.index(b'\\x1b]7;')
49
+ end = data.index(b'\\x07', idx)
50
+ params = data[idx+4:end].decode().split(';')
51
+ if len(params) == 2:
52
+ r, c = int(params[0]), int(params[1])
53
+ fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', r, c, 0, 0))
54
+ os.kill(pid, signal.SIGWINCH)
55
+ rest = data[:idx] + data[end+1:]
56
+ if rest:
57
+ os.write(master, rest)
58
+ else:
59
+ os.write(master, data)
60
+ except OSError as e:
61
+ if e.errno != errno.EAGAIN: break
62
+ if master in rlist:
63
+ try:
64
+ data = os.read(master, 4096)
65
+ if not data: break
66
+ sys.stdout.buffer.write(data)
67
+ sys.stdout.buffer.flush()
68
+ except OSError: break
69
+ # Check child
70
+ try:
71
+ p, status = os.waitpid(pid, os.WNOHANG)
72
+ if p != 0: break
73
+ except ChildProcessError: break
74
+ except: pass
75
+ finally:
76
+ try: os.kill(pid, signal.SIGTERM)
77
+ except: pass
78
+ `.trim();
79
+
12
80
  export class TerminalManager {
13
81
  constructor(daemon) {
14
82
  this.daemon = daemon;
15
- this.sessions = new Map(); // sessionId → { proc, ws }
83
+ this.sessions = new Map();
16
84
  this.counter = 0;
85
+ this._python = this._findPython();
17
86
  }
18
87
 
19
- /**
20
- * Spawn a new shell session connected to a specific WebSocket client.
21
- */
22
88
  spawn(ws, options = {}) {
23
89
  const id = `term-${++this.counter}`;
24
90
  const shell = this._detectShell();
25
91
  const cwd = options.cwd || this.daemon.projectDir;
26
-
27
- // Use `script` to allocate a real PTY — gives us prompts, colors, and line editing.
28
- // macOS: script -q /dev/null <shell>
29
- // Linux: script -qfc "<shell>" /dev/null
30
- let proc;
31
- const env = {
32
- ...process.env,
33
- TERM: 'xterm-256color',
34
- COLORTERM: 'truecolor',
35
- LANG: process.env.LANG || 'en_US.UTF-8',
36
- };
37
-
38
- if (process.platform === 'darwin') {
39
- proc = spawn('script', ['-q', '/dev/null', shell, '-l'], {
40
- cwd,
41
- env,
42
- stdio: ['pipe', 'pipe', 'pipe'],
43
- });
44
- } else {
45
- // Linux (util-linux script)
46
- proc = spawn('script', ['-qfc', `${shell} -l`, '/dev/null'], {
47
- cwd,
48
- env,
49
- stdio: ['pipe', 'pipe', 'pipe'],
50
- });
92
+ const cols = options.cols || 120;
93
+ const rows = options.rows || 30;
94
+
95
+ if (!this._python) {
96
+ ws.send(JSON.stringify({
97
+ type: 'terminal:output', id,
98
+ data: '\r\n\x1b[31mTerminal requires Python 3 (python3 not found in PATH)\x1b[0m\r\n',
99
+ }));
100
+ ws.send(JSON.stringify({ type: 'terminal:exit', id, code: 1 }));
101
+ return id;
51
102
  }
52
103
 
104
+ const proc = spawn(this._python, ['-u', '-c', PTY_HELPER], {
105
+ cwd,
106
+ env: {
107
+ ...process.env,
108
+ TERM: 'xterm-256color',
109
+ COLORTERM: 'truecolor',
110
+ LANG: process.env.LANG || 'en_US.UTF-8',
111
+ GROOVE_SHELL: shell,
112
+ COLS: String(cols),
113
+ ROWS: String(rows),
114
+ },
115
+ stdio: ['pipe', 'pipe', 'pipe'],
116
+ });
117
+
53
118
  const session = { proc, ws, id };
54
119
  this.sessions.set(id, session);
55
120
 
56
- // Relay stdout → WS
57
121
  proc.stdout.on('data', (data) => {
58
122
  if (ws.readyState === 1) {
59
123
  ws.send(JSON.stringify({ type: 'terminal:output', id, data: data.toString('utf8') }));
60
124
  }
61
125
  });
62
126
 
63
- // Relay stderr → WS (merge with stdout)
64
127
  proc.stderr.on('data', (data) => {
65
128
  if (ws.readyState === 1) {
66
129
  ws.send(JSON.stringify({ type: 'terminal:output', id, data: data.toString('utf8') }));
@@ -84,18 +147,19 @@ export class TerminalManager {
84
147
  return id;
85
148
  }
86
149
 
87
- /**
88
- * Write input to a terminal session.
89
- */
90
150
  write(id, data) {
91
151
  const session = this.sessions.get(id);
92
152
  if (!session || !session.proc.stdin.writable) return;
93
153
  session.proc.stdin.write(data);
94
154
  }
95
155
 
96
- /**
97
- * Kill a terminal session.
98
- */
156
+ resize(id, rows, cols) {
157
+ const session = this.sessions.get(id);
158
+ if (!session || !session.proc.stdin.writable) return;
159
+ // Send resize command via the custom escape sequence
160
+ session.proc.stdin.write(`\x1b]7;${rows};${cols}\x07`);
161
+ }
162
+
99
163
  kill(id) {
100
164
  const session = this.sessions.get(id);
101
165
  if (!session) return;
@@ -108,18 +172,12 @@ export class TerminalManager {
108
172
  this.sessions.delete(id);
109
173
  }
110
174
 
111
- /**
112
- * Kill all sessions (daemon shutdown).
113
- */
114
175
  killAll() {
115
176
  for (const [id] of this.sessions) {
116
177
  this.kill(id);
117
178
  }
118
179
  }
119
180
 
120
- /**
121
- * Clean up sessions for a disconnected WS client.
122
- */
123
181
  cleanupClient(ws) {
124
182
  for (const [id, session] of this.sessions) {
125
183
  if (session.ws === ws) {
@@ -129,13 +187,20 @@ export class TerminalManager {
129
187
  }
130
188
 
131
189
  _detectShell() {
132
- if (process.env.SHELL && existsSync(process.env.SHELL)) {
133
- return process.env.SHELL;
134
- }
135
- const candidates = ['/bin/zsh', '/bin/bash', '/bin/sh'];
136
- for (const sh of candidates) {
190
+ if (process.env.SHELL && existsSync(process.env.SHELL)) return process.env.SHELL;
191
+ for (const sh of ['/bin/zsh', '/bin/bash', '/bin/sh']) {
137
192
  if (existsSync(sh)) return sh;
138
193
  }
139
194
  return 'sh';
140
195
  }
196
+
197
+ _findPython() {
198
+ for (const cmd of ['python3', 'python']) {
199
+ try {
200
+ const v = execFileSync(cmd, ['--version'], { encoding: 'utf8', timeout: 3000 }).trim();
201
+ if (v.startsWith('Python 3')) return cmd;
202
+ } catch { /* not found */ }
203
+ }
204
+ return null;
205
+ }
141
206
  }