groove-dev 0.16.2 → 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.
@@ -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,38 +1,116 @@
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 child_process.spawn with pipe stdio + TERM=xterm-256color
10
- * so most CLI tools produce colored output.
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;
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;
102
+ }
26
103
 
27
- const proc = spawn(shell, ['-l'], {
104
+ const proc = spawn(this._python, ['-u', '-c', PTY_HELPER], {
28
105
  cwd,
29
106
  env: {
30
107
  ...process.env,
31
108
  TERM: 'xterm-256color',
32
109
  COLORTERM: 'truecolor',
33
110
  LANG: process.env.LANG || 'en_US.UTF-8',
34
- // Remove NODE_CHANNEL_FD to prevent child confusion
35
- NODE_CHANNEL_FD: undefined,
111
+ GROOVE_SHELL: shell,
112
+ COLS: String(cols),
113
+ ROWS: String(rows),
36
114
  },
37
115
  stdio: ['pipe', 'pipe', 'pipe'],
38
116
  });
@@ -40,14 +118,12 @@ export class TerminalManager {
40
118
  const session = { proc, ws, id };
41
119
  this.sessions.set(id, session);
42
120
 
43
- // Relay stdout → WS
44
121
  proc.stdout.on('data', (data) => {
45
122
  if (ws.readyState === 1) {
46
123
  ws.send(JSON.stringify({ type: 'terminal:output', id, data: data.toString('utf8') }));
47
124
  }
48
125
  });
49
126
 
50
- // Relay stderr → WS (merge with stdout)
51
127
  proc.stderr.on('data', (data) => {
52
128
  if (ws.readyState === 1) {
53
129
  ws.send(JSON.stringify({ type: 'terminal:output', id, data: data.toString('utf8') }));
@@ -71,18 +147,19 @@ export class TerminalManager {
71
147
  return id;
72
148
  }
73
149
 
74
- /**
75
- * Write input to a terminal session.
76
- */
77
150
  write(id, data) {
78
151
  const session = this.sessions.get(id);
79
152
  if (!session || !session.proc.stdin.writable) return;
80
153
  session.proc.stdin.write(data);
81
154
  }
82
155
 
83
- /**
84
- * Kill a terminal session.
85
- */
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
+
86
163
  kill(id) {
87
164
  const session = this.sessions.get(id);
88
165
  if (!session) return;
@@ -95,18 +172,12 @@ export class TerminalManager {
95
172
  this.sessions.delete(id);
96
173
  }
97
174
 
98
- /**
99
- * Kill all sessions (daemon shutdown).
100
- */
101
175
  killAll() {
102
176
  for (const [id] of this.sessions) {
103
177
  this.kill(id);
104
178
  }
105
179
  }
106
180
 
107
- /**
108
- * Clean up sessions for a disconnected WS client.
109
- */
110
181
  cleanupClient(ws) {
111
182
  for (const [id, session] of this.sessions) {
112
183
  if (session.ws === ws) {
@@ -116,14 +187,20 @@ export class TerminalManager {
116
187
  }
117
188
 
118
189
  _detectShell() {
119
- // Prefer user's shell, fall back to common ones
120
- if (process.env.SHELL && existsSync(process.env.SHELL)) {
121
- return process.env.SHELL;
122
- }
123
- const candidates = ['/bin/zsh', '/bin/bash', '/bin/sh'];
124
- 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']) {
125
192
  if (existsSync(sh)) return sh;
126
193
  }
127
194
  return 'sh';
128
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
+ }
129
206
  }