tabby-bianbu-mcp 0.7.0 → 0.8.0

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  All notable changes to `tabby-bianbu-mcp` will be documented in this file.
4
4
 
5
+ ## [0.8.0] - 2026-03-23
6
+
7
+ ### Added (Remote MCP Server v1.4.0)
8
+ - **real PTY terminal sessions**: 5 new MCP tools (`open_pty_session`, `write_pty_input`, `read_pty_output`, `resize_pty`, `close_pty_session`) backed by a Python PTY helper using the standard library `pty` module — enables full interactive terminals (vim, htop, claude CLI, etc.)
9
+ - **long-polling output**: `read_pty_output` holds the request up to 5s (configurable `timeout_ms`) until data arrives, achieving near-instant output delivery with minimal request overhead (~0.2 req/s idle vs 20 req/s with short polling)
10
+ - **PTY session concurrency cap**: `MAX_PTY_SESSIONS` (default 4) limits simultaneous PTY sessions
11
+ - **`pty_session` capability flag** in health `supports` for client feature detection
12
+
13
+ ### Changed (Remote MCP Server v1.4.0)
14
+ - script version bumped to 1.4.0, server version bumped to 1.4.0
15
+ - `gracefulShutdown` now cleans up PTY sessions
16
+ - idle session sweeper now covers PTY sessions
17
+
18
+ ### Added (Plugin)
19
+ - **`BianbuPtySession`**: new session class extending Tabby's `BaseSession` with 16ms input batching, long-poll output loop, and automatic resize forwarding
20
+ - **PTY auto-detection**: shell tab checks remote `supports.ptySession` and automatically uses real PTY mode when available, falling back to the existing command-based `BianbuShellSession`
21
+ - **PTY lane bypass**: `readPtyOutputDirect()` calls `executeRequest` directly instead of going through the interactive RequestLane, preventing 5s long-polls from blocking other operations
22
+ - PTY capability indicator (`pty=✓/✗`) in the settings diagnostics panel
23
+
24
+ ### Changed (Plugin)
25
+ - bundled remote installer updated to script v1.4.0 / server v1.4.0
26
+
5
27
  ## [0.7.0] - 2026-03-23
6
28
 
7
29
  ### Added (Remote MCP Server v1.3.0)
package/README.md CHANGED
@@ -164,7 +164,63 @@ Search for `tabby-bianbu-mcp` in **Settings → Plugins → Install from npm**
164
164
 
165
165
  ## 🚀 Quick Start / 快速上手
166
166
 
167
- ### 1. Configure / 配置
167
+ ### 1. Get API Key / 获取 API 密钥
168
+
169
+ <table>
170
+ <tr><td width="50%">
171
+
172
+ **English**: Log in to [Bianbu Cloud](https://cloud.bianbu.org) console, click your avatar (top-right) → **API Key** in the sidebar. Copy the full key.
173
+
174
+ </td><td width="50%">
175
+
176
+ **中文**: 登录 [算能板卡宇宙](https://cloud.bianbu.org) 控制台,点击右上角头像 → 左侧菜单 **API Key**,复制完整密钥。
177
+
178
+ </td></tr>
179
+ </table>
180
+
181
+ <div align="center">
182
+ <img src="docs/images/bianbu-console-overview.png" width="720" />
183
+ <br/>
184
+ <sub>控制台首页 — 点击右上角头像进入个人设置 / Console home — click avatar to enter profile settings</sub>
185
+ <br/><br/>
186
+ <img src="docs/images/bianbu-api-key.png" width="720" />
187
+ <br/>
188
+ <sub>侧栏点击 "API Key",复制星号处的完整密钥 / Click "API Key" in sidebar, copy the full key</sub>
189
+ </div>
190
+
191
+ <br/>
192
+
193
+ ### 2. Get MCP URL / 获取 MCP 地址
194
+
195
+ <table>
196
+ <tr><td width="50%">
197
+
198
+ **English**: On the console home page, click **"开始远程"** (Start Remote) on your instance card. In the popup, find the domain like `xxx.gdriscv.com`. Your MCP URL is:
199
+
200
+ `https://<that-domain>/mcp`
201
+
202
+ In the plugin settings, you only need to paste the domain part (without `https://` or `/mcp`).
203
+
204
+ </td><td width="50%">
205
+
206
+ **中文**: 在控制台首页,点击实例卡片上的 **"开始远程"**。在弹窗中找到类似 `xxx.gdriscv.com` 的域名。你的 MCP 地址是:
207
+
208
+ `https://<该域名>/mcp`
209
+
210
+ 在插件设置中,只需粘贴域名部分(不含 `https://` 和 `/mcp`)。
211
+
212
+ </td></tr>
213
+ </table>
214
+
215
+ <div align="center">
216
+ <img src="docs/images/bianbu-mcp-url.png" width="720" />
217
+ <br/>
218
+ <sub>点击"本地连接",在 connect 行找到域名 / Click "本地连接", find the domain in the connect line</sub>
219
+ </div>
220
+
221
+ <br/>
222
+
223
+ ### 3. Configure Plugin / 配置插件
168
224
 
169
225
  Open **Settings → Bianbu MCP** in Tabby and fill in:
170
226
 
@@ -172,20 +228,20 @@ Open **Settings → Bianbu MCP** in Tabby and fill in:
172
228
 
173
229
  | Field / 字段 | Description / 说明 | Example / 示例 |
174
230
  |:---|:---|:---|
175
- | **MCP URL** | Your MCP endpoint / MCP 端点地址 | `https://your-domain.example.com/mcp` |
176
- | **X-API-KEY** | API key from Bianbu Cloud / API 密钥 | `your-api-key` |
177
- | **Profile Name** | Display name / 显示名称 | `bianbu` |
231
+ | **Domain** | Paste domain only (no `https://`, no `/mcp`) / 只粘贴域名 | `xxx.gdriscv.com` |
232
+ | **API Key** | Full key from step 1 / 1 步复制的完整密钥 | `your-api-key` |
233
+ | **Name** | Display name / 显示名称 | `bianbu` |
178
234
 
179
- ### 2. Connect / 连接
235
+ ### 4. Connect / 连接
180
236
 
181
- Click **"Test Connection"** to verify. Then use:
237
+ Click **"Test connection"** to verify. Then use:
182
238
 
183
239
  点击 **"Test connection"** 验证。然后使用:
184
240
 
185
- - **"Open Bianbu Cloud Shell"** — for terminal access / 打开终端
186
- - **"Open Bianbu Cloud Files"** — for file management / 打开文件管理器
241
+ - **"Open Shell"** — for terminal access / 打开终端
242
+ - **"Open Files"** — for file management / 打开文件管理器
187
243
 
188
- ### 3. MCP Snippet / MCP 配置片段
244
+ ### 5. MCP Snippet / MCP 配置片段
189
245
 
190
246
  Copy the auto-generated JSON config for other MCP clients:
191
247
 
@@ -219,15 +275,16 @@ Copy the auto-generated JSON config for other MCP clients:
219
275
  | Key / 键 | Default / 默认值 | Description (EN) | 说明 (中文) |
220
276
  |:---|:---|:---|:---|
221
277
  | `name` | `bianbu` | Profile display name | 配置显示名称 |
222
- | `url` | *(empty)* | MCP endpoint URL | MCP 端点 URL |
278
+ | `domain` | *(empty)* | MCP domain (without `https://` or `/mcp`) | MCP 域名(不含 `https://` 和 `/mcp`) |
223
279
  | `apiKey` | *(empty)* | `X-API-KEY` header value | API 密钥 |
224
280
  | `interactiveConcurrency` | `2` | Interactive request slots | 交互请求并发槽位数 |
225
281
  | `transferConcurrency` | `30` | Transfer request slots | 传输请求并发槽位数 |
226
- | `workerCadenceMs` | `100` | Dispatch cycle interval (ms) | 调度周期间隔 (毫秒) |
282
+ | `maxConcurrentFiles` | `3` | Max parallel file transfers | 最大并行文件传输数 |
283
+ | `workerCadenceMs` | `0` | Dispatch cycle interval (ms); adaptive throttling auto-adjusts | 调度周期间隔 (毫秒);自适应节流自动调整 |
227
284
  | `maxRetries` | `2` | Max retry attempts | 最大重试次数 |
228
285
  | `retryBaseMs` | `1000` | Base delay between retries (ms) | 重试基础间隔 (毫秒) |
229
- | `uploadChunkBytes` | `32768` | Upload chunk size (bytes) | 上传分块大小 (字节) |
230
- | `downloadChunkBytes` | `131072` | Download chunk size (bytes) | 下载分块大小 (字节) |
286
+ | `uploadChunkBytes` | `65536` | Upload chunk size (bytes) | 上传分块大小 (字节) |
287
+ | `downloadChunkBytes` | `262144` | Download chunk size (bytes) | 下载分块大小 (字节) |
231
288
  | `notes` | *(empty)* | User notes | 用户备注 |
232
289
  | `installerRemotePath` | `/tmp/bianbu_agent_proxy.sh` | Remote installer path | 远端安装脚本路径 |
233
290
  | `maintenanceAsRoot` | `true` | Run maintenance as root | 以 root 执行维护 |
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "fileName": "bianbu_agent_proxy.sh",
3
3
  "sourceFile": "bianbu_agent_proxy.sh",
4
- "scriptVersion": "1.3.0",
5
- "serverVersion": "1.3.0",
6
- "sha256": "b6cb60d6796e3e5d6e0a301e80ac17e8f0e9d6d610cf30d7078ca203f9ec2999",
7
- "bytes": 72162,
8
- "generatedAt": "2026-03-23T04:06:24.139Z"
4
+ "scriptVersion": "1.4.0",
5
+ "serverVersion": "1.4.0",
6
+ "sha256": "14e858536af489a30d7c7f5d3a078659015ac315d66b5486949ae1f998d33df8",
7
+ "bytes": 85475,
8
+ "generatedAt": "2026-03-23T09:16:09.632Z"
9
9
  }
@@ -4,8 +4,8 @@ set -Eeuo pipefail
4
4
  umask 077
5
5
 
6
6
  SCRIPT_NAME="$(basename "$0")"
7
- SCRIPT_VERSION="${SCRIPT_VERSION:-1.3.0}"
8
- SERVER_VERSION="${SERVER_VERSION:-1.3.0}"
7
+ SCRIPT_VERSION="${SCRIPT_VERSION:-1.4.0}"
8
+ SERVER_VERSION="${SERVER_VERSION:-1.4.0}"
9
9
  APP_NAME="bianbu-mcp-server"
10
10
  INSTALL_ROOT="/opt/${APP_NAME}"
11
11
  APP_FILE="${INSTALL_ROOT}/server.mjs"
@@ -29,6 +29,7 @@ MAX_CONCURRENT_REQUESTS="${MAX_CONCURRENT_REQUESTS:-32}"
29
29
  MAX_UPLOAD_SESSIONS="${MAX_UPLOAD_SESSIONS:-16}"
30
30
  MAX_DOWNLOAD_SESSIONS="${MAX_DOWNLOAD_SESSIONS:-16}"
31
31
  MAX_SHELL_SESSIONS="${MAX_SHELL_SESSIONS:-8}"
32
+ MAX_PTY_SESSIONS="${MAX_PTY_SESSIONS:-4}"
32
33
  TLS_CERT_FILE="${TLS_CERT_FILE:-}"
33
34
  TLS_KEY_FILE="${TLS_KEY_FILE:-}"
34
35
  MCP_TRANSPORT_MODE="${MCP_TRANSPORT_MODE:-stateless}"
@@ -91,6 +92,7 @@ MCP tools:
91
92
  - delete_path
92
93
  - rename_path
93
94
  - open_shell_session / exec_shell_session / close_shell_session
95
+ - open_pty_session / write_pty_input / read_pty_output / resize_pty / close_pty_session
94
96
  - upload_chunked_begin / upload_chunked_part / upload_chunked_finish / upload_chunked_abort
95
97
  - download_chunked_begin / download_chunked_part / download_chunked_close
96
98
 
@@ -110,6 +112,7 @@ MCP tools:
110
112
  MAX_UPLOAD_SESSIONS 最大并发上传会话数,默认: ${MAX_UPLOAD_SESSIONS}
111
113
  MAX_DOWNLOAD_SESSIONS 最大并发下载会话数,默认: ${MAX_DOWNLOAD_SESSIONS}
112
114
  MAX_SHELL_SESSIONS 最大并发 Shell 会话数,默认: ${MAX_SHELL_SESSIONS}
115
+ MAX_PTY_SESSIONS 最大并发 PTY 会话数,默认: ${MAX_PTY_SESSIONS}
113
116
  TLS_CERT_FILE 可选,HTTPS 证书路径
114
117
  TLS_KEY_FILE 可选,HTTPS 私钥路径
115
118
 
@@ -377,7 +380,7 @@ write_app() {
377
380
  local app_file="${1:-$APP_FILE}"
378
381
  write_root_file "$app_file" 755 <<'EOF'
379
382
  import { randomBytes, randomUUID } from 'node:crypto';
380
- import { exec as execCb } from 'node:child_process';
383
+ import { exec as execCb, spawn as spawnCb } from 'node:child_process';
381
384
  import { promisify } from 'node:util';
382
385
  import os from 'node:os';
383
386
  import fs from 'node:fs';
@@ -408,6 +411,11 @@ const SUPPORTED_TOOLS = [
408
411
  'open_shell_session',
409
412
  'exec_shell_session',
410
413
  'close_shell_session',
414
+ 'open_pty_session',
415
+ 'write_pty_input',
416
+ 'read_pty_output',
417
+ 'resize_pty',
418
+ 'close_pty_session',
411
419
  'upload_chunked_begin',
412
420
  'upload_chunked_part',
413
421
  'upload_chunked_finish',
@@ -434,11 +442,14 @@ const MAX_CONCURRENT_REQUESTS = Number(process.env.MAX_CONCURRENT_REQUESTS || '3
434
442
  const MAX_UPLOAD_SESSIONS = Number(process.env.MAX_UPLOAD_SESSIONS || '16');
435
443
  const MAX_DOWNLOAD_SESSIONS = Number(process.env.MAX_DOWNLOAD_SESSIONS || '16');
436
444
  const MAX_SHELL_SESSIONS = Number(process.env.MAX_SHELL_SESSIONS || '8');
445
+ const MAX_PTY_SESSIONS = Number(process.env.MAX_PTY_SESSIONS || '4');
446
+ const PTY_OUTPUT_BUFFER_MAX = 512 * 1024;
437
447
  const CANONICAL_FILE_ROOT = FILE_ROOT === '/' ? '/' : fs.realpathSync(FILE_ROOT);
438
448
  const HAS_SUDO = fs.existsSync('/usr/bin/sudo') || fs.existsSync('/bin/sudo');
439
449
  const shellSessions = new Map();
440
450
  const uploadSessions = new Map();
441
451
  const downloadSessions = new Map();
452
+ const ptySessions = new Map();
442
453
  const SESSION_IDLE_MS = 60 * 60 * 1000;
443
454
  const SERVER_START_TIME = Date.now();
444
455
  let activeRequests = 0;
@@ -731,6 +742,249 @@ async function cleanupUploadSession(session) {
731
742
  await deleteAnyPath(session.temp_dir, true, session.as_root).catch(() => {});
732
743
  }
733
744
 
745
+ function ptyHelperScript() {
746
+ return String.raw`#!/usr/bin/env python3
747
+ import pty, os, sys, select, signal, struct, fcntl, termios, json, base64
748
+
749
+ def set_winsize(fd, rows, cols):
750
+ winsize = struct.pack('HHHH', rows, cols, 0, 0)
751
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
752
+
753
+ def send_msg(msg):
754
+ sys.stdout.write(json.dumps(msg, ensure_ascii=False) + '\n')
755
+ sys.stdout.flush()
756
+
757
+ config = json.loads(base64.b64decode(sys.argv[1]).decode('utf-8'))
758
+ initial_cols = config.get('cols', 80)
759
+ initial_rows = config.get('rows', 24)
760
+ shell = config.get('shell', '/bin/bash')
761
+ cwd = config.get('cwd', '/')
762
+ env_override = config.get('env', {})
763
+
764
+ master_fd, slave_fd = pty.openpty()
765
+ set_winsize(slave_fd, initial_rows, initial_cols)
766
+
767
+ pid = os.fork()
768
+ if pid == 0:
769
+ os.close(master_fd)
770
+ os.setsid()
771
+ fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0)
772
+ os.dup2(slave_fd, 0)
773
+ os.dup2(slave_fd, 1)
774
+ os.dup2(slave_fd, 2)
775
+ if slave_fd > 2:
776
+ os.close(slave_fd)
777
+ try:
778
+ os.chdir(cwd)
779
+ except OSError:
780
+ pass
781
+ env = os.environ.copy()
782
+ env['TERM'] = 'xterm-256color'
783
+ env['COLUMNS'] = str(initial_cols)
784
+ env['LINES'] = str(initial_rows)
785
+ env.update(env_override)
786
+ os.execvpe(shell, [shell, '-l'], env)
787
+ else:
788
+ os.close(slave_fd)
789
+ flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
790
+ fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
791
+ stdin_fd = sys.stdin.fileno()
792
+ old_stdin_flags = fcntl.fcntl(stdin_fd, fcntl.F_GETFL)
793
+ fcntl.fcntl(stdin_fd, fcntl.F_SETFL, old_stdin_flags | os.O_NONBLOCK)
794
+ running = True
795
+ def on_sigchld(signum, frame):
796
+ nonlocal running
797
+ running = False
798
+ signal.signal(signal.SIGCHLD, on_sigchld)
799
+ stdin_buf = b''
800
+ while running:
801
+ try:
802
+ readable, _, _ = select.select([master_fd, stdin_fd], [], [], 0.05)
803
+ except (select.error, InterruptedError, ValueError):
804
+ if not running:
805
+ break
806
+ continue
807
+ if master_fd in readable:
808
+ try:
809
+ data = os.read(master_fd, 65536)
810
+ if not data:
811
+ break
812
+ send_msg({'type': 'output', 'data': base64.b64encode(data).decode('ascii')})
813
+ except OSError:
814
+ break
815
+ if stdin_fd in readable:
816
+ try:
817
+ chunk = os.read(stdin_fd, 65536)
818
+ if not chunk:
819
+ break
820
+ stdin_buf += chunk
821
+ while b'\n' in stdin_buf:
822
+ line, stdin_buf = stdin_buf.split(b'\n', 1)
823
+ line = line.strip()
824
+ if not line:
825
+ continue
826
+ try:
827
+ cmd = json.loads(line.decode('utf-8'))
828
+ if cmd['type'] == 'input':
829
+ raw = base64.b64decode(cmd['data'])
830
+ os.write(master_fd, raw)
831
+ elif cmd['type'] == 'resize':
832
+ set_winsize(master_fd, cmd['rows'], cmd['cols'])
833
+ try:
834
+ os.kill(pid, signal.SIGWINCH)
835
+ except OSError:
836
+ pass
837
+ elif cmd['type'] == 'close':
838
+ running = False
839
+ break
840
+ except (json.JSONDecodeError, KeyError, OSError):
841
+ pass
842
+ except OSError:
843
+ break
844
+ try:
845
+ os.kill(pid, signal.SIGTERM)
846
+ except OSError:
847
+ pass
848
+ exit_code = -1
849
+ try:
850
+ for _ in range(50):
851
+ rpid, status = os.waitpid(pid, os.WNOHANG)
852
+ if rpid != 0:
853
+ exit_code = os.WEXITSTATUS(status) if os.WIFEXITED(status) else -1
854
+ break
855
+ import time; time.sleep(0.1)
856
+ else:
857
+ try:
858
+ os.kill(pid, signal.SIGKILL)
859
+ os.waitpid(pid, 0)
860
+ except OSError:
861
+ pass
862
+ except ChildProcessError:
863
+ pass
864
+ send_msg({'type': 'exit', 'code': exit_code})
865
+ os.close(master_fd)
866
+ `;
867
+ }
868
+
869
+ let ptyHelperPath = '';
870
+ async function ensurePtyHelper() {
871
+ if (!ptyHelperPath) {
872
+ ptyHelperPath = '/tmp/.mcp_pty_helper.py';
873
+ await fs.promises.writeFile(ptyHelperPath, ptyHelperScript(), { mode: 0o755 });
874
+ }
875
+ }
876
+
877
+ function createPtySession(id, cwd, asRoot, cols, rows) {
878
+ const config = { cols, rows, shell: '/bin/bash', cwd };
879
+ const encoded = Buffer.from(JSON.stringify(config), 'utf8').toString('base64');
880
+ const args = asRoot
881
+ ? ['-c', `sudo -n -- python3 ${ptyHelperPath} ${shellQuote(encoded)}`]
882
+ : ['-c', `python3 ${ptyHelperPath} ${shellQuote(encoded)}`];
883
+ const child = spawnCb('/bin/bash', args, {
884
+ stdio: ['pipe', 'pipe', 'pipe'],
885
+ cwd: '/',
886
+ });
887
+ const session = {
888
+ id,
889
+ child,
890
+ outputBuffer: [],
891
+ outputBufferSize: 0,
892
+ alive: true,
893
+ asRoot,
894
+ cols,
895
+ rows,
896
+ exitCode: null,
897
+ createdAt: Date.now(),
898
+ updatedAt: Date.now(),
899
+ waiters: [],
900
+ };
901
+ let lineBuf = '';
902
+ child.stdout.on('data', (chunk) => {
903
+ lineBuf += chunk.toString();
904
+ let idx;
905
+ while ((idx = lineBuf.indexOf('\n')) >= 0) {
906
+ const line = lineBuf.slice(0, idx).trim();
907
+ lineBuf = lineBuf.slice(idx + 1);
908
+ if (!line) continue;
909
+ try {
910
+ const msg = JSON.parse(line);
911
+ if (msg.type === 'output' && msg.data) {
912
+ const buf = Buffer.from(msg.data, 'base64');
913
+ session.outputBuffer.push(buf);
914
+ session.outputBufferSize += buf.length;
915
+ while (session.outputBufferSize > PTY_OUTPUT_BUFFER_MAX && session.outputBuffer.length > 1) {
916
+ const dropped = session.outputBuffer.shift();
917
+ session.outputBufferSize -= dropped.length;
918
+ }
919
+ session.updatedAt = Date.now();
920
+ // Wake any long-poll waiters
921
+ for (const waiter of session.waiters) {
922
+ waiter();
923
+ }
924
+ session.waiters = [];
925
+ } else if (msg.type === 'exit') {
926
+ session.alive = false;
927
+ session.exitCode = msg.code;
928
+ session.updatedAt = Date.now();
929
+ for (const waiter of session.waiters) {
930
+ waiter();
931
+ }
932
+ session.waiters = [];
933
+ }
934
+ } catch {}
935
+ }
936
+ });
937
+ child.stderr.on('data', () => {});
938
+ child.on('exit', () => {
939
+ session.alive = false;
940
+ session.updatedAt = Date.now();
941
+ for (const waiter of session.waiters) {
942
+ waiter();
943
+ }
944
+ session.waiters = [];
945
+ });
946
+ ptySessions.set(id, session);
947
+ return session;
948
+ }
949
+
950
+ function destroyPtySession(id) {
951
+ const session = ptySessions.get(id);
952
+ if (!session) return;
953
+ session.alive = false;
954
+ for (const waiter of session.waiters) {
955
+ waiter();
956
+ }
957
+ session.waiters = [];
958
+ try {
959
+ session.child.stdin.write(JSON.stringify({ type: 'close' }) + '\n');
960
+ } catch {}
961
+ setTimeout(() => {
962
+ try { session.child.kill('SIGKILL'); } catch {}
963
+ }, 3000).unref();
964
+ session.outputBuffer = [];
965
+ session.outputBufferSize = 0;
966
+ ptySessions.delete(id);
967
+ }
968
+
969
+ function drainPtyOutput(session) {
970
+ if (session.outputBuffer.length === 0) {
971
+ return {
972
+ data_base64: '',
973
+ alive: session.alive,
974
+ exit_code: session.alive ? undefined : session.exitCode,
975
+ };
976
+ }
977
+ const data = Buffer.concat(session.outputBuffer);
978
+ session.outputBuffer = [];
979
+ session.outputBufferSize = 0;
980
+ session.updatedAt = Date.now();
981
+ return {
982
+ data_base64: data.toString('base64'),
983
+ alive: session.alive,
984
+ exit_code: session.alive ? undefined : session.exitCode,
985
+ };
986
+ }
987
+
734
988
  function sweepSessions() {
735
989
  const now = Date.now();
736
990
  for (const [id, entry] of shellSessions.entries()) {
@@ -749,6 +1003,11 @@ function sweepSessions() {
749
1003
  downloadSessions.delete(id);
750
1004
  }
751
1005
  }
1006
+ for (const [id, entry] of ptySessions.entries()) {
1007
+ if (now - entry.updatedAt > SESSION_IDLE_MS) {
1008
+ destroyPtySession(id);
1009
+ }
1010
+ }
752
1011
  }
753
1012
  setInterval(sweepSessions, 5 * 60 * 1000).unref();
754
1013
 
@@ -920,11 +1179,13 @@ function makeServer() {
920
1179
  shell: MAX_SHELL_SESSIONS,
921
1180
  upload: MAX_UPLOAD_SESSIONS,
922
1181
  download: MAX_DOWNLOAD_SESSIONS,
1182
+ pty: MAX_PTY_SESSIONS,
923
1183
  },
924
1184
  active_sessions: {
925
1185
  shell: shellSessions.size,
926
1186
  upload: uploadSessions.size,
927
1187
  download: downloadSessions.size,
1188
+ pty: ptySessions.size,
928
1189
  },
929
1190
  concurrency: {
930
1191
  max_concurrent_requests: MAX_CONCURRENT_REQUESTS,
@@ -951,6 +1212,7 @@ function makeServer() {
951
1212
  shell_session: true,
952
1213
  rate_limiting: true,
953
1214
  iso_timestamps: true,
1215
+ pty_session: true,
954
1216
  },
955
1217
  };
956
1218
  return textResult(JSON.stringify(payload, null, 2), payload);
@@ -1284,6 +1546,134 @@ function makeServer() {
1284
1546
  },
1285
1547
  );
1286
1548
 
1549
+ // ── PTY session tools ──────────────────────────────────────────
1550
+
1551
+ server.registerTool(
1552
+ 'open_pty_session',
1553
+ {
1554
+ description: 'Open a real PTY shell session with streaming I/O. Use write_pty_input to send keystrokes and read_pty_output to receive terminal output.',
1555
+ inputSchema: {
1556
+ cwd: z.string().default('.'),
1557
+ as_root: z.boolean().default(false),
1558
+ cols: z.number().int().min(1).max(500).default(80),
1559
+ rows: z.number().int().min(1).max(200).default(24),
1560
+ },
1561
+ },
1562
+ async ({ cwd, as_root, cols, rows }) => {
1563
+ if (ptySessions.size >= MAX_PTY_SESSIONS) {
1564
+ throw new Error(`pty session limit reached (max ${MAX_PTY_SESSIONS})`);
1565
+ }
1566
+ const workingDirectory = await resolveRequestedPath(cwd, as_root);
1567
+ const stat = await fs.promises.stat(workingDirectory).catch(() => null);
1568
+ if (!stat || !stat.isDirectory()) {
1569
+ throw new Error(`cwd not found or not a directory: ${workingDirectory}`);
1570
+ }
1571
+ await ensurePtyHelper();
1572
+ const session_id = newSessionId('pty');
1573
+ createPtySession(session_id, workingDirectory, as_root, cols, rows);
1574
+ const payload = { session_id, cwd: workingDirectory, as_root, cols, rows };
1575
+ return textResult(JSON.stringify(payload, null, 2), payload);
1576
+ },
1577
+ );
1578
+
1579
+ server.registerTool(
1580
+ 'write_pty_input',
1581
+ {
1582
+ description: 'Send raw terminal input (keystrokes) to a PTY session.',
1583
+ inputSchema: {
1584
+ session_id: z.string(),
1585
+ data_base64: z.string(),
1586
+ },
1587
+ },
1588
+ async ({ session_id, data_base64 }) => {
1589
+ const session = ptySessions.get(session_id);
1590
+ if (!session) throw new Error(`unknown pty session: ${session_id}`);
1591
+ if (!session.alive) throw new Error(`pty session is no longer alive: ${session_id}`);
1592
+ const msg = JSON.stringify({ type: 'input', data: data_base64 }) + '\n';
1593
+ session.child.stdin.write(msg);
1594
+ session.updatedAt = Date.now();
1595
+ const payload = { ok: true, session_id };
1596
+ return textResult(JSON.stringify(payload, null, 2), payload);
1597
+ },
1598
+ );
1599
+
1600
+ server.registerTool(
1601
+ 'read_pty_output',
1602
+ {
1603
+ description: 'Read buffered output from a PTY session. Supports long-polling: holds the request up to timeout_ms if no data is available yet.',
1604
+ inputSchema: {
1605
+ session_id: z.string(),
1606
+ timeout_ms: z.number().int().min(0).max(10000).default(5000),
1607
+ },
1608
+ },
1609
+ async ({ session_id, timeout_ms }) => {
1610
+ const session = ptySessions.get(session_id);
1611
+ if (!session) throw new Error(`unknown pty session: ${session_id}`);
1612
+ session.updatedAt = Date.now();
1613
+ // Immediate return if data or dead
1614
+ if (session.outputBuffer.length > 0 || !session.alive) {
1615
+ const payload = drainPtyOutput(session);
1616
+ return textResult(JSON.stringify(payload, null, 2), payload);
1617
+ }
1618
+ // Long-poll: wait for data or timeout
1619
+ const maxWait = Math.min(timeout_ms, 10000);
1620
+ const result = await new Promise((resolve) => {
1621
+ let resolved = false;
1622
+ const finish = () => {
1623
+ if (resolved) return;
1624
+ resolved = true;
1625
+ clearTimeout(timer);
1626
+ const idx = session.waiters.indexOf(waiter);
1627
+ if (idx >= 0) session.waiters.splice(idx, 1);
1628
+ resolve(drainPtyOutput(session));
1629
+ };
1630
+ const waiter = finish;
1631
+ session.waiters.push(waiter);
1632
+ const timer = setTimeout(finish, maxWait);
1633
+ });
1634
+ return textResult(JSON.stringify(result, null, 2), result);
1635
+ },
1636
+ );
1637
+
1638
+ server.registerTool(
1639
+ 'resize_pty',
1640
+ {
1641
+ description: 'Resize a PTY session terminal.',
1642
+ inputSchema: {
1643
+ session_id: z.string(),
1644
+ cols: z.number().int().min(1).max(500),
1645
+ rows: z.number().int().min(1).max(200),
1646
+ },
1647
+ },
1648
+ async ({ session_id, cols, rows }) => {
1649
+ const session = ptySessions.get(session_id);
1650
+ if (!session) throw new Error(`unknown pty session: ${session_id}`);
1651
+ if (!session.alive) throw new Error(`pty session is no longer alive: ${session_id}`);
1652
+ const msg = JSON.stringify({ type: 'resize', cols, rows }) + '\n';
1653
+ session.child.stdin.write(msg);
1654
+ session.cols = cols;
1655
+ session.rows = rows;
1656
+ session.updatedAt = Date.now();
1657
+ const payload = { ok: true, session_id, cols, rows };
1658
+ return textResult(JSON.stringify(payload, null, 2), payload);
1659
+ },
1660
+ );
1661
+
1662
+ server.registerTool(
1663
+ 'close_pty_session',
1664
+ {
1665
+ description: 'Close a PTY session.',
1666
+ inputSchema: {
1667
+ session_id: z.string(),
1668
+ },
1669
+ },
1670
+ async ({ session_id }) => {
1671
+ destroyPtySession(session_id);
1672
+ const payload = { ok: true, session_id };
1673
+ return textResult(JSON.stringify(payload, null, 2), payload);
1674
+ },
1675
+ );
1676
+
1287
1677
  server.registerTool(
1288
1678
  'upload_chunked_begin',
1289
1679
  {
@@ -1694,6 +2084,9 @@ async function gracefulShutdown(signal) {
1694
2084
  }
1695
2085
  downloadSessions.clear();
1696
2086
  shellSessions.clear();
2087
+ for (const [id] of ptySessions) {
2088
+ destroyPtySession(id);
2089
+ }
1697
2090
  await Promise.allSettled(cleanups);
1698
2091
  httpServer.close(() => {
1699
2092
  console.log('Server closed');
@@ -1795,6 +2188,7 @@ MAX_CONCURRENT_REQUESTS=${MAX_CONCURRENT_REQUESTS}
1795
2188
  MAX_UPLOAD_SESSIONS=${MAX_UPLOAD_SESSIONS}
1796
2189
  MAX_DOWNLOAD_SESSIONS=${MAX_DOWNLOAD_SESSIONS}
1797
2190
  MAX_SHELL_SESSIONS=${MAX_SHELL_SESSIONS}
2191
+ MAX_PTY_SESSIONS=${MAX_PTY_SESSIONS}
1798
2192
  TLS_CERT_FILE=${TLS_CERT_FILE}
1799
2193
  TLS_KEY_FILE=${TLS_KEY_FILE}
1800
2194
  EOF
@@ -1969,6 +2363,7 @@ MAX_CONCURRENT_REQUESTS=${MAX_CONCURRENT_REQUESTS}
1969
2363
  MAX_UPLOAD_SESSIONS=${MAX_UPLOAD_SESSIONS}
1970
2364
  MAX_DOWNLOAD_SESSIONS=${MAX_DOWNLOAD_SESSIONS}
1971
2365
  MAX_SHELL_SESSIONS=${MAX_SHELL_SESSIONS}
2366
+ MAX_PTY_SESSIONS=${MAX_PTY_SESSIONS}
1972
2367
  TLS_CERT_FILE=${TLS_CERT_FILE}
1973
2368
  TLS_KEY_FILE=${TLS_KEY_FILE}
1974
2369
  BACKUP_ROOT=${BACKUP_ROOT}