@vincentbanana/bananacli 0.4.6 → 0.4.8

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,305 @@
1
+ // 内嵌终端:真实 PTY + 多会话。
2
+ // 用 @lydell/node-pty(自带预编译二进制),Windows 走 ConPTY,输出天然是 UTF-8,
3
+ // 所以中文路径和中文输出不会再乱码,提示符也由 shell 自己打印(D:\banana>)。
4
+ import { existsSync, statSync } from 'node:fs';
5
+ import { join, resolve } from 'node:path';
6
+ const isWin = process.platform === 'win32';
7
+ /** 在 PATH 里找一个可执行文件(Windows 会补 .exe)。不依赖 shell,避免启动时开子进程。 */
8
+ function findOnPath(name) {
9
+ const paths = (process.env['PATH'] ?? '').split(isWin ? ';' : ':').filter(Boolean);
10
+ const exts = isWin ? ['', '.exe', '.cmd', '.bat'] : [''];
11
+ for (const dir of paths) {
12
+ for (const ext of exts) {
13
+ const candidate = join(dir, name.toLowerCase().endsWith(ext.toLowerCase()) ? name : name + ext);
14
+ try {
15
+ if (existsSync(candidate) && statSync(candidate).isFile())
16
+ return candidate;
17
+ }
18
+ catch {
19
+ /* 无权限的目录跳过 */
20
+ }
21
+ }
22
+ }
23
+ return null;
24
+ }
25
+ /** 探测机器上可用的 shell,顺序即默认优先级。 */
26
+ export function detectShells() {
27
+ const shells = [];
28
+ const seen = new Set();
29
+ const push = (item) => {
30
+ if (!item || seen.has(item.id))
31
+ return;
32
+ seen.add(item.id);
33
+ shells.push(item);
34
+ };
35
+ if (isWin) {
36
+ const pwsh = findOnPath('pwsh');
37
+ push(pwsh ? { id: 'pwsh', name: 'PowerShell 7', file: pwsh, args: ['-NoLogo'] } : null);
38
+ const powershell = findOnPath('powershell');
39
+ push(powershell ? { id: 'powershell', name: 'Windows PowerShell', file: powershell, args: ['-NoLogo'] } : null);
40
+ const comspec = process.env['ComSpec'] ?? findOnPath('cmd');
41
+ push(comspec ? { id: 'cmd', name: '命令提示符', file: comspec, args: [] } : null);
42
+ for (const bash of [
43
+ 'C:\\Program Files\\Git\\bin\\bash.exe',
44
+ 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
45
+ join(process.env['LOCALAPPDATA'] ?? '', 'Programs', 'Git', 'bin', 'bash.exe'),
46
+ ]) {
47
+ if (bash && existsSync(bash)) {
48
+ push({ id: 'bash', name: 'Git Bash', file: bash, args: ['--login', '-i'] });
49
+ break;
50
+ }
51
+ }
52
+ const wsl = findOnPath('wsl');
53
+ push(wsl ? { id: 'wsl', name: 'WSL', file: wsl, args: [] } : null);
54
+ }
55
+ else {
56
+ const candidates = [
57
+ { id: 'zsh', name: 'zsh', file: findOnPath('zsh') },
58
+ { id: 'bash', name: 'bash', file: findOnPath('bash') },
59
+ { id: 'sh', name: 'sh', file: findOnPath('sh') ?? '/bin/sh' },
60
+ ];
61
+ const preferred = process.env['SHELL'];
62
+ for (const item of candidates) {
63
+ if (!item.file)
64
+ continue;
65
+ push({ id: item.id, name: item.name, file: item.file, args: ['-l'] });
66
+ }
67
+ if (preferred) {
68
+ const found = shells.find((item) => item.file === preferred);
69
+ if (found) {
70
+ shells.splice(shells.indexOf(found), 1);
71
+ shells.unshift(found);
72
+ }
73
+ }
74
+ }
75
+ return shells;
76
+ }
77
+ export class TerminalManager {
78
+ sessions = new Map();
79
+ seq = 0;
80
+ shells;
81
+ constructor(shells) {
82
+ const detected = shells ?? detectShells();
83
+ this.shells = detected.length
84
+ ? detected
85
+ : [{ id: 'sh', name: 'sh', file: isWin ? (process.env['ComSpec'] ?? 'cmd.exe') : '/bin/sh', args: [] }];
86
+ }
87
+ defaultShell() {
88
+ return this.shells[0];
89
+ }
90
+ find(id) {
91
+ return this.sessions.get(id);
92
+ }
93
+ create(shellId, cwd, cols = 100, rows = 28) {
94
+ const shell = this.shells.find((item) => item.id === shellId) ?? this.defaultShell();
95
+ const id = `t${++this.seq}`;
96
+ const session = new TerminalSession(id, shell, cwd, cols, rows);
97
+ this.sessions.set(id, session);
98
+ return session;
99
+ }
100
+ async dispose() {
101
+ const sessions = [...this.sessions.values()];
102
+ this.sessions.clear();
103
+ await Promise.all(sessions.map((session) => session.dispose()));
104
+ }
105
+ }
106
+ export class TerminalSession {
107
+ id;
108
+ shell;
109
+ pty = null;
110
+ clients = new Set();
111
+ buffer = '';
112
+ startedAt = '';
113
+ cwd;
114
+ exitPromise = Promise.resolve();
115
+ exitResolve = null;
116
+ cols;
117
+ rows;
118
+ running = false;
119
+ constructor(id, shell, cwd, cols, rows) {
120
+ this.id = id;
121
+ this.shell = shell;
122
+ this.cwd = existsSync(cwd) ? cwd : process.cwd();
123
+ this.cols = Math.max(20, cols);
124
+ this.rows = Math.max(4, rows);
125
+ this.start();
126
+ }
127
+ info() {
128
+ return { id: this.id, shell: this.shell.id, name: this.shell.name, running: this.running, cwd: this.cwd };
129
+ }
130
+ start() {
131
+ this.startedAt = new Date().toISOString();
132
+ const env = {};
133
+ for (const [key, value] of Object.entries(process.env)) {
134
+ if (typeof value === 'string')
135
+ env[key] = value;
136
+ }
137
+ env['TERM'] = 'xterm-256color';
138
+ env['COLORTERM'] = 'truecolor';
139
+ // Windows 终端里让 Python / Node 也走 UTF-8,避免第三方程序自己输出 GBK
140
+ env['PYTHONIOENCODING'] = 'utf-8';
141
+ env['PYTHONUTF8'] = '1';
142
+ try {
143
+ // 延迟到运行时加载:没装到对应平台的预编译包时,服务本身仍然能启动
144
+ const spawnPty = globalThis.__bananaPtySpawn;
145
+ if (!spawnPty)
146
+ throw new Error('PTY 未加载');
147
+ this.pty = spawnPty(this.shell.file, this.shell.args, {
148
+ name: 'xterm-256color',
149
+ cols: this.cols,
150
+ rows: this.rows,
151
+ cwd: this.cwd,
152
+ env,
153
+ });
154
+ }
155
+ catch (error) {
156
+ this.running = false;
157
+ this.broadcast({
158
+ type: 'error',
159
+ message: `终端启动失败:${error instanceof Error ? error.message : String(error)}\n` +
160
+ 'PTY 原生模块可能没装上,试试重新安装:npm install -g @vincentbanana/bananacli',
161
+ });
162
+ return;
163
+ }
164
+ const pty = this.pty;
165
+ this.running = true;
166
+ this.exitPromise = new Promise((resolvePromise) => {
167
+ this.exitResolve = resolvePromise;
168
+ });
169
+ pty.onData((data) => {
170
+ this.buffer = (this.buffer + data).slice(-200_000);
171
+ this.broadcast({ type: 'data', data });
172
+ });
173
+ pty.onExit(({ exitCode, signal }) => {
174
+ // restart 时旧进程的退出事件可能晚到,不能把新进程的状态覆盖掉
175
+ if (this.pty !== pty)
176
+ return;
177
+ this.running = false;
178
+ this.pty = null;
179
+ this.exitResolve?.();
180
+ this.exitResolve = null;
181
+ const notice = `\r\n\x1b[90m[进程已退出,代码 ${exitCode ?? 0}]\x1b[0m\r\n`;
182
+ this.buffer += notice;
183
+ this.broadcast({ type: 'data', data: notice });
184
+ this.broadcast({ type: 'exit', code: exitCode ?? 0, signal: signal ?? null });
185
+ });
186
+ }
187
+ broadcast(payload) {
188
+ const line = JSON.stringify(payload);
189
+ for (const client of this.clients) {
190
+ if (client.ws.readyState === 1)
191
+ client.ws.send(line);
192
+ }
193
+ }
194
+ attach(ws) {
195
+ const client = { ws, alive: true };
196
+ this.clients.add(client);
197
+ ws.send(JSON.stringify({ type: 'ready', ...this.info(), startedAt: this.startedAt }));
198
+ // 重连时把最近的屏幕内容一次性补上,再让前端继续增量渲染
199
+ if (this.buffer)
200
+ ws.send(JSON.stringify({ type: 'data', data: this.buffer }));
201
+ ws.on('message', (raw) => {
202
+ let message;
203
+ try {
204
+ message = JSON.parse(String(raw));
205
+ }
206
+ catch {
207
+ return;
208
+ }
209
+ if (message.type === 'input' && typeof message.data === 'string')
210
+ this.write(message.data);
211
+ else if (message.type === 'resize')
212
+ this.resize(Number(message.cols) || 0, Number(message.rows) || 0);
213
+ else if (message.type === 'restart')
214
+ this.restart();
215
+ else if (message.type === 'close')
216
+ this.dispose();
217
+ });
218
+ ws.on('close', () => this.clients.delete(client));
219
+ ws.on('error', () => this.clients.delete(client));
220
+ }
221
+ write(data) {
222
+ if (this.pty && this.running)
223
+ this.pty.write(data);
224
+ }
225
+ resize(cols, rows) {
226
+ if (!cols || !rows)
227
+ return;
228
+ this.cols = Math.max(20, Math.floor(cols));
229
+ this.rows = Math.max(4, Math.floor(rows));
230
+ try {
231
+ this.pty?.resize(this.cols, this.rows);
232
+ }
233
+ catch {
234
+ /* 进程刚退出时 resize 会抛,忽略 */
235
+ }
236
+ }
237
+ restart() {
238
+ this.disposePty();
239
+ this.buffer = '';
240
+ this.start();
241
+ const pty = this.pty;
242
+ if (pty)
243
+ this.broadcast({ type: 'ready', ...this.info(), startedAt: this.startedAt });
244
+ }
245
+ async dispose() {
246
+ const pty = this.pty;
247
+ if (pty) {
248
+ try {
249
+ pty.kill();
250
+ }
251
+ catch {
252
+ /* 已经退出 */
253
+ }
254
+ // 等 PTY 真正退出,避免子进程的 stdout 句柄拖住父进程(npm publish 时会卡住)
255
+ await Promise.race([
256
+ this.exitPromise,
257
+ new Promise((resolvePromise) => setTimeout(resolvePromise, 1500)),
258
+ ]);
259
+ }
260
+ this.pty = null;
261
+ this.running = false;
262
+ for (const client of this.clients) {
263
+ try {
264
+ client.ws.close();
265
+ }
266
+ catch {
267
+ /* 已经断开 */
268
+ }
269
+ }
270
+ this.clients.clear();
271
+ }
272
+ disposePty() {
273
+ try {
274
+ this.pty?.kill();
275
+ }
276
+ catch {
277
+ /* 已经退出 */
278
+ }
279
+ this.pty = null;
280
+ this.running = false;
281
+ }
282
+ }
283
+ /** 启动时把 PTY 模块解析一次,失败不影响整个 GUI 服务。 */
284
+ export async function loadPty() {
285
+ try {
286
+ const mod = await import('@lydell/node-pty');
287
+ globalThis.__bananaPtySpawn = mod.spawn;
288
+ return true;
289
+ }
290
+ catch {
291
+ return false;
292
+ }
293
+ }
294
+ export function normalizeCwd(cwd, fallback) {
295
+ const raw = cwd?.trim();
296
+ if (!raw)
297
+ return fallback;
298
+ const next = resolve(raw);
299
+ try {
300
+ return existsSync(next) && statSync(next).isDirectory() ? next : fallback;
301
+ }
302
+ catch {
303
+ return fallback;
304
+ }
305
+ }
@@ -4,7 +4,10 @@ import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
4
4
  import { existsSync } from 'node:fs';
5
5
  import { extname, join, resolve, dirname } from 'node:path';
6
6
  import { spawn } from 'node:child_process';
7
+ import { WebSocketServer } from 'ws';
7
8
  import { fileURLToPath } from 'node:url';
9
+ import { createRequire } from 'node:module';
10
+ import { TerminalManager, loadPty, normalizeCwd } from "./terminal.js";
8
11
  import { bananaHome } from "../config/config.js";
9
12
  import { runTurn } from "../agent/loop.js";
10
13
  import { compactMessages } from "../agent/context.js";
@@ -24,6 +27,12 @@ const MIME = {
24
27
  '.js': 'text/javascript; charset=utf-8',
25
28
  '.svg': 'image/svg+xml',
26
29
  };
30
+ const VENDOR_FILES = {
31
+ 'xterm.js': '@xterm/xterm/lib/xterm.js',
32
+ 'xterm.css': '@xterm/xterm/css/xterm.css',
33
+ 'addon-fit.js': '@xterm/addon-fit/lib/addon-fit.js',
34
+ 'highlight.min.js': '@highlightjs/cdn-assets/highlight.min.js',
35
+ };
27
36
  const IMAGE_TYPES = {
28
37
  '.png': 'image/png',
29
38
  '.jpg': 'image/jpeg',
@@ -81,6 +90,9 @@ export async function startUiServer(options) {
81
90
  '多半是内核版本太旧或安装不完整,重新安装即可:npm install -g @vincentbanana/bananacli\n');
82
91
  }
83
92
  let turn = null;
93
+ // 当前会话记在服务进程内存里:新建对话后所有后续请求都认它,
94
+ // 不再依赖「磁盘上谁最新」这种容易串台的推断。
95
+ let activeId = null;
84
96
  // 内嵌终端:不弹外部窗口,命令在当前工作区里执行,cd 之后的工作目录会被记住
85
97
  let terminalCwd = null;
86
98
  let terminalChild = null;
@@ -126,6 +138,31 @@ export async function startUiServer(options) {
126
138
  res.end();
127
139
  });
128
140
  });
141
+ // 内嵌终端:WebSocket 双向通道 + 真实 PTY(cmd / pwsh / bash)
142
+ const terminals = new TerminalManager();
143
+ void loadPty();
144
+ const wss = new WebSocketServer({ noServer: true });
145
+ server.on('upgrade', (req, socket, head) => {
146
+ try {
147
+ const url = new URL(req.url ?? '/', `http://${host}`);
148
+ if (url.pathname !== `${prefix}/api/terminal/ws`) {
149
+ socket.destroy();
150
+ return;
151
+ }
152
+ wss.handleUpgrade(req, socket, head, (ws) => {
153
+ const shellId = url.searchParams.get('shell') ?? terminals.defaultShell().id;
154
+ const cwd = normalizeCwd(url.searchParams.get('cwd'), config.workspace);
155
+ const existing = url.searchParams.get('id');
156
+ const session = (existing ? terminals.find(existing) : undefined) ??
157
+ terminals.create(shellId, cwd, Number(url.searchParams.get('cols')) || 100, Number(url.searchParams.get('rows')) || 28);
158
+ ws.send(JSON.stringify({ type: 'id', id: session.id }));
159
+ session.attach(ws);
160
+ });
161
+ }
162
+ catch {
163
+ socket.destroy();
164
+ }
165
+ });
129
166
  async function handle(req, res) {
130
167
  const url = new URL(req.url ?? '/', `http://${host}`);
131
168
  if (!url.pathname.startsWith(prefix))
@@ -137,7 +174,18 @@ export async function startUiServer(options) {
137
174
  return sendFile(res, 'style.css');
138
175
  if (path === '/app.js')
139
176
  return sendFile(res, 'app.js');
140
- let session = turn?.session ?? (await store.lastActive().then(async (id) => (id ? await store.load(id) : null))) ?? store.create();
177
+ // 终端与语法高亮的本地资源:只放行固定文件,避免任意路径读取
178
+ if (path.startsWith('/vendor/')) {
179
+ const name = path.slice('/vendor/'.length);
180
+ if (Object.hasOwn(VENDOR_FILES, name))
181
+ return sendFile(res, `vendor/${name}`);
182
+ return notFound(res);
183
+ }
184
+ let session = turn?.session ??
185
+ (activeId ? await store.load(activeId) : null) ??
186
+ (await store.lastActive().then(async (id) => (id ? await store.load(id) : null))) ??
187
+ store.create();
188
+ activeId = session.id;
141
189
  if (path === '/api/info' && req.method === 'GET')
142
190
  return json(res, 200, await info(session));
143
191
  if (path === '/api/sessions' && req.method === 'GET')
@@ -207,6 +255,7 @@ export async function startUiServer(options) {
207
255
  }
208
256
  if (turn)
209
257
  turn.session = session;
258
+ activeId = session.id;
210
259
  return json(res, 200, { session: session.id, sessions: await listSessions(), info: await info(session) });
211
260
  }
212
261
  if (path === '/api/complete' && req.method === 'GET') {
@@ -320,110 +369,24 @@ export async function startUiServer(options) {
320
369
  const body = await readJson(req);
321
370
  if (turn)
322
371
  return json(res, 409, { error: '当前正在跑一轮对话' });
323
- return chat(body, res, session);
372
+ // 前端会带上会话 id:以它为准,避免新建对话后又被解析回旧对话
373
+ let target = session;
374
+ if (body.session && body.session !== session.id) {
375
+ const found = await store.load(body.session);
376
+ if (found)
377
+ target = found;
378
+ }
379
+ activeId = target.id;
380
+ return chat(body, res, target);
324
381
  }
325
- if (path === '/api/terminal' && req.method === 'POST') {
326
- const body = await readJson(req);
327
- return runTerminal(String(body.command ?? ''), res);
382
+ if (path === '/api/shells' && req.method === 'GET') {
383
+ return json(res, 200, {
384
+ shells: terminals.shells,
385
+ default: terminals.defaultShell().id,
386
+ });
328
387
  }
329
388
  return notFound(res);
330
389
  }
331
- /**
332
- * 内嵌终端:把一条命令跑在工作区里,输出按行流式推给界面。
333
- * 这里刻意不依赖 node-pty(原生编译在 Windows 上太重),用管道 + 目录标记实现,
334
- * 足以跑 git/npm/测试这类常用命令;cd 之后目录会被记住,Ctrl+C 会杀掉子进程。
335
- */
336
- async function runTerminal(command, res) {
337
- const cwd = terminalWorkdir();
338
- const trimmed = command.trim();
339
- if (!trimmed)
340
- return json(res, 200, { cwd });
341
- if (terminalChild)
342
- return json(res, 409, { error: '上一条命令还在跑,先按停止。' });
343
- res.writeHead(200, {
344
- 'Content-Type': 'text/event-stream; charset=utf-8',
345
- 'Cache-Control': 'no-cache, no-transform',
346
- Connection: 'keep-alive',
347
- 'X-Accel-Buffering': 'no',
348
- });
349
- const send = (type, payload = {}) => {
350
- if (!res.writableEnded)
351
- res.write(`data: ${JSON.stringify({ type, ...payload })}\n\n`);
352
- };
353
- send('start', { cwd });
354
- const isWin = process.platform === 'win32';
355
- const marker = '__BANANA_CWD__';
356
- // 命令跑完后回显一次当前目录,用来记住 cd 的结果
357
- const wrapped = isWin ? `${trimmed} & echo ${marker}%CD%` : `${trimmed}\n echo ${marker}$(pwd)`;
358
- let child;
359
- try {
360
- child = isWin
361
- ? spawn('cmd.exe', ['/d', '/s', '/c', wrapped], { cwd, env: process.env, windowsHide: true })
362
- : spawn('/bin/sh', ['-c', wrapped], { cwd, env: process.env, windowsHide: true });
363
- }
364
- catch (error) {
365
- send('exit', { code: -1, error: error instanceof Error ? error.message : String(error), cwd });
366
- res.end();
367
- return;
368
- }
369
- terminalChild = child;
370
- let tailOut = '';
371
- let tailErr = '';
372
- const pump = (text, stream) => {
373
- let buffer = (stream === 'stdout' ? tailOut : tailErr) + text;
374
- const lines = buffer.split('\n');
375
- buffer = lines.pop() ?? '';
376
- for (const raw of lines) {
377
- const line = raw.replace(/\r$/, '');
378
- if (line.startsWith(marker)) {
379
- const next = line.slice(marker.length).trim();
380
- if (next && existsSync(next))
381
- terminalCwd = next;
382
- continue;
383
- }
384
- send('out', { stream, data: line + '\n' });
385
- }
386
- if (stream === 'stdout')
387
- tailOut = buffer;
388
- else
389
- tailErr = buffer;
390
- };
391
- const flush = () => {
392
- for (const [buffer, stream] of [[tailOut, 'stdout'], [tailErr, 'stderr']]) {
393
- if (!buffer)
394
- continue;
395
- if (!buffer.startsWith(marker))
396
- send('out', { stream, data: buffer.replace(/\r$/, '') });
397
- }
398
- tailOut = '';
399
- tailErr = '';
400
- };
401
- child.stdout?.setEncoding('utf8');
402
- child.stderr?.setEncoding('utf8');
403
- child.stdout?.on('data', (chunk) => pump(chunk, 'stdout'));
404
- child.stderr?.on('data', (chunk) => pump(chunk, 'stderr'));
405
- // 前端断开(按停止 / 关页面)时连同子进程一起收掉
406
- res.on('close', () => {
407
- if (terminalChild === child)
408
- terminalChild = null;
409
- if (!child.killed)
410
- child.kill();
411
- });
412
- await new Promise((resolvePromise) => {
413
- child.on('error', (error) => {
414
- send('out', { stream: 'stderr', data: `启动失败:${error.message}\n` });
415
- });
416
- child.on('close', (code) => {
417
- flush();
418
- if (terminalChild === child)
419
- terminalChild = null;
420
- send('exit', { code: code ?? 0, cwd: terminalWorkdir() });
421
- if (!res.writableEnded)
422
- res.end();
423
- resolvePromise();
424
- });
425
- });
426
- }
427
390
  /**
428
391
  * 执行 CLI 命令:命令表只有一份(cli/commands.ts),GUI 只是换了个输出通道。
429
392
  * 输出逐行推给前端,行为和终端里一致。
@@ -656,6 +619,8 @@ export async function startUiServer(options) {
656
619
  token,
657
620
  port,
658
621
  close: async () => {
622
+ await terminals.dispose();
623
+ wss.close();
659
624
  // 浏览器可能留着 keep-alive 连接,先断开再关,否则 Windows 上会触发 libuv 断言
660
625
  server.closeAllConnections?.();
661
626
  await new Promise((resolvePromise) => server.close(() => resolvePromise()));
@@ -710,7 +675,22 @@ function notFound(res) {
710
675
  async function sendFile(res, name) {
711
676
  const { readFile } = await import('node:fs/promises');
712
677
  try {
713
- const file = await readFile(join(assetRoot(), name));
678
+ let target = join(assetRoot(), name);
679
+ // 源码模式(npm start)下 src/ui 里没有打包拷贝的 vendor;
680
+ // 直接从依赖包读,开发时也能秒开终端,不用先 build。
681
+ if (name.startsWith('vendor/') && !existsSync(target)) {
682
+ const vendorName = name.slice('vendor/'.length);
683
+ const specifier = VENDOR_FILES[vendorName];
684
+ if (specifier) {
685
+ try {
686
+ target = createRequire(import.meta.url).resolve(specifier);
687
+ }
688
+ catch {
689
+ /* 依赖缺失,走下面的 404 */
690
+ }
691
+ }
692
+ }
693
+ const file = await readFile(target);
714
694
  res.writeHead(200, {
715
695
  'Content-Type': MIME[extname(name)] ?? 'application/octet-stream',
716
696
  'Content-Length': file.byteLength,