deckide 3.5.33 → 3.5.35

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.
@@ -3,12 +3,82 @@ import { Hono } from 'hono';
3
3
  import { spawn } from 'node-pty';
4
4
  import { TERMINAL_BUFFER_LIMIT } from '../config.js';
5
5
  import { createHttpError, handleError, readJson } from '../utils/error.js';
6
- import { getDefaultShell } from '../utils/shell.js';
7
- import { saveTerminal, deleteTerminal as deleteTerminalFromDb } from '../utils/database.js';
6
+ import { getDefaultShell, getAvailableLocales, getShellBasename } from '../utils/shell.js';
8
7
  import { alignToUtf8Start, skipPartialEscapeSequence } from '../utils/utf8.js';
9
8
  const DEFAULT_TERMINAL_TITLE = 'ターミナル';
10
9
  const MAX_SOCKET_BUFFERED_AMOUNT = 1024 * 1024;
11
- export function createTerminalRouter(db, decks, terminals) {
10
+ // 初期端末サイズ(リクエストで指定がない場合のデフォルト)。
11
+ const DEFAULT_TERMINAL_COLS = 120;
12
+ const DEFAULT_TERMINAL_ROWS = 32;
13
+ // WebSocket の resize と同じ範囲でバリデーションする。
14
+ const MIN_TERMINAL_SIZE = 1;
15
+ const MAX_TERMINAL_SIZE = 500;
16
+ // ログインシェルとして扱う POSIX シェルのベース名。
17
+ const POSIX_LOGIN_SHELLS = new Set(['bash', 'zsh', 'sh', 'fish', 'csh', 'tcsh']);
18
+ function isUtf8Locale(value) {
19
+ if (!value)
20
+ return false;
21
+ return /\.(utf-?8)$/i.test(value);
22
+ }
23
+ /**
24
+ * リクエストボディの cols/rows を整数化し 1..500 の範囲に収める。
25
+ * 数値でない場合は fallback を返す。
26
+ */
27
+ function normalizeTerminalSize(value, fallback) {
28
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
29
+ return fallback;
30
+ }
31
+ const intValue = Math.floor(value);
32
+ if (intValue < MIN_TERMINAL_SIZE)
33
+ return MIN_TERMINAL_SIZE;
34
+ if (intValue > MAX_TERMINAL_SIZE)
35
+ return MAX_TERMINAL_SIZE;
36
+ return intValue;
37
+ }
38
+ /**
39
+ * 継承した環境に有効な UTF-8 ロケールが無い場合のフォールバックを選ぶ。
40
+ * システムに存在しないロケールは決して設定しない。
41
+ * 優先順位: 継承 LANG と同じ言語の UTF-8 → "C.UTF-8" → "C.utf8" → 最初の "*.UTF-8"/"*.utf8"。
42
+ * 該当が無ければ null を返す(何も設定しない)。
43
+ */
44
+ function pickFallbackUtf8Locale(inheritedLang) {
45
+ const available = getAvailableLocales();
46
+ if (available.length === 0) {
47
+ return null;
48
+ }
49
+ const utf8Locales = available.filter((loc) => isUtf8Locale(loc));
50
+ if (utf8Locales.length === 0) {
51
+ return null;
52
+ }
53
+ // 継承 LANG の言語部分(例: "ja_JP.eucJP" -> "ja_JP" / "ja")に一致する UTF-8 ロケール。
54
+ if (inheritedLang) {
55
+ const langBase = inheritedLang.split('.')[0];
56
+ if (langBase) {
57
+ const exact = utf8Locales.find((loc) => loc.split('.')[0] === langBase);
58
+ if (exact)
59
+ return exact;
60
+ const lang = langBase.split('_')[0];
61
+ if (lang) {
62
+ const byLang = utf8Locales.find((loc) => loc.split(/[._]/)[0] === lang);
63
+ if (byLang)
64
+ return byLang;
65
+ }
66
+ }
67
+ }
68
+ const cUtf8 = utf8Locales.find((loc) => loc === 'C.UTF-8');
69
+ if (cUtf8)
70
+ return cUtf8;
71
+ const cUtf8Lower = utf8Locales.find((loc) => loc === 'C.utf8');
72
+ if (cUtf8Lower)
73
+ return cUtf8Lower;
74
+ return utf8Locales[0];
75
+ }
76
+ // PTY flow-control thresholds (per terminal session).
77
+ const FLOW_HIGH_WATER = 512 * 1024; // pause the PTY above this backlog
78
+ const FLOW_LOW_WATER = 64 * 1024; // resume once drained below this
79
+ const FLOW_CHECK_INTERVAL_MS = 100; // how often to poll for drain while paused
80
+ const FLOW_MAX_PAUSE_MS = 10_000; // evict a stuck consumer after this long
81
+ export function createTerminalRouter(decks, terminals) {
12
82
  const router = new Hono();
13
83
  function toBuffer(data) {
14
84
  return Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
@@ -124,11 +194,11 @@ export function createTerminalRouter(db, decks, terminals) {
124
194
  deadSockets.add(socket);
125
195
  }
126
196
  });
197
+ // resizeOwner(主クライアント)の付け替え・昇格は WebSocket の close
198
+ // ハンドラに一元化する。ここで resizeOwner を null にすると、主ソケットが
199
+ // 切断されたときの「残りクライアントへの昇格」が動かなくなるため触らない。
127
200
  deadSockets.forEach((s) => {
128
201
  session.sockets.delete(s);
129
- if (session.resizeOwner === s) {
130
- session.resizeOwner = null;
131
- }
132
202
  });
133
203
  }
134
204
  function handleTerminalExit(id) {
@@ -137,7 +207,6 @@ export function createTerminalRouter(db, decks, terminals) {
137
207
  return;
138
208
  console.log(`[TERMINAL] Terminal ${id} exited`);
139
209
  terminals.delete(id);
140
- deleteTerminalFromDb(db, id);
141
210
  session.sockets.forEach((socket) => {
142
211
  try {
143
212
  socket.close(1000, 'Terminal exited');
@@ -147,7 +216,7 @@ export function createTerminalRouter(db, decks, terminals) {
147
216
  session.sockets.clear();
148
217
  session.resizeOwner = null;
149
218
  }
150
- function createTerminalSession(deck, title, command) {
219
+ function createTerminalSession(deck, title, command, cols = DEFAULT_TERMINAL_COLS, rows = DEFAULT_TERMINAL_ROWS) {
151
220
  const id = crypto.randomUUID();
152
221
  // Resolve shell and arguments
153
222
  let shell;
@@ -169,6 +238,14 @@ export function createTerminalRouter(db, decks, terminals) {
169
238
  }
170
239
  else {
171
240
  shell = getDefaultShell();
241
+ // macOS のターミナルアプリはログインシェル(-l)で起動し
242
+ // ~/.zprofile / ~/.bash_profile から PATH を読み込む。これに合わせる。
243
+ // 一方 Linux の端末は通常「非ログインの対話シェル」で ~/.bashrc を読むため、
244
+ // -l を付けると ~/.bash_profile を持たないユーザーが alias/関数を失う回帰になる。
245
+ // そのため -l は darwin のみに限定する。
246
+ if (process.platform === 'darwin' && POSIX_LOGIN_SHELLS.has(getShellBasename(shell))) {
247
+ shellArgs = ['-l'];
248
+ }
172
249
  }
173
250
  // Build environment
174
251
  const env = {};
@@ -179,21 +256,32 @@ export function createTerminalRouter(db, decks, terminals) {
179
256
  env.TERM = env.TERM || 'xterm-256color';
180
257
  env.COLORTERM = 'truecolor';
181
258
  env.TERM_PROGRAM = 'xterm.js';
182
- env.TERM_PROGRAM_VERSION = '5.0.0';
259
+ env.TERM_PROGRAM_VERSION = '5.5.0';
183
260
  if (process.platform === 'win32') {
261
+ // Windows は従来どおり(locale -a 検出は行わない)。
184
262
  env.LANG = 'en_US.UTF-8';
185
263
  }
186
264
  else {
187
- env.LANG = env.LANG || 'en_US.UTF-8';
265
+ // LC_ALL は設定しない(ユーザーの実ロケールを上書きしない)。
266
+ // 継承した環境に有効な UTF-8 ロケールが無いときだけフォールバックを設定する。
267
+ const hasInheritedUtf8 = isUtf8Locale(env.LC_ALL) ||
268
+ isUtf8Locale(env.LANG) ||
269
+ isUtf8Locale(env.LC_CTYPE);
270
+ if (!hasInheritedUtf8) {
271
+ const fallback = pickFallbackUtf8Locale(env.LANG);
272
+ if (fallback) {
273
+ // 実在するロケールのみを設定する(存在しないロケールは設定しない)。
274
+ env.LANG = env.LANG || fallback;
275
+ env.LC_CTYPE = env.LC_CTYPE || fallback;
276
+ }
277
+ }
188
278
  }
189
- env.LC_ALL = env.LC_ALL || 'en_US.UTF-8';
190
- env.LC_CTYPE = env.LC_CTYPE || 'en_US.UTF-8';
191
279
  // Spawn PTY directly in this process
192
280
  const isWindows = process.platform === 'win32';
193
281
  const term = spawn(shell, shellArgs, {
194
282
  cwd: deck.root,
195
- cols: 120,
196
- rows: 32,
283
+ cols,
284
+ rows,
197
285
  env,
198
286
  encoding: null,
199
287
  ...(isWindows ? { useConpty: true } : {}),
@@ -229,16 +317,90 @@ export function createTerminalRouter(db, decks, terminals) {
229
317
  }
230
318
  catch { /* already dead */ } },
231
319
  };
320
+ // ── Flow control ────────────────────────────────────────────────────
321
+ // Instead of closing a slow client when its send buffer fills (which then
322
+ // reconnects and re-replays, looking like the terminal "freezes" or jumps
323
+ // during heavy output), pause the PTY when the slowest consumer falls
324
+ // behind and resume once it drains. A stuck consumer is evicted after a
325
+ // grace period so it can't deadlock the terminal for everyone else.
326
+ let ptyPaused = false;
327
+ let resumeTimer = null;
328
+ let pausedAt = 0;
329
+ const maxBufferedAmount = () => {
330
+ let max = 0;
331
+ session.sockets.forEach((socket) => {
332
+ if (socket.readyState === 1 && socket.bufferedAmount > max) {
333
+ max = socket.bufferedAmount;
334
+ }
335
+ });
336
+ return max;
337
+ };
338
+ const evictLaggards = (threshold) => {
339
+ session.sockets.forEach((socket) => {
340
+ if (socket.readyState === 1 && socket.bufferedAmount > threshold) {
341
+ // close() で readyState は即 CLOSING になり、maxBufferedAmount の
342
+ // 集計(OPEN のみ)から外れる。socket の削除と resizeOwner の昇格は
343
+ // WebSocket の close ハンドラに任せる(主クライアント昇格のため)。
344
+ try {
345
+ socket.close(1009, 'Terminal output overflow');
346
+ }
347
+ catch { /* ignore */ }
348
+ }
349
+ });
350
+ };
351
+ const resumePty = () => {
352
+ if (resumeTimer) {
353
+ clearInterval(resumeTimer);
354
+ resumeTimer = null;
355
+ }
356
+ if (!ptyPaused)
357
+ return;
358
+ ptyPaused = false;
359
+ try {
360
+ term.resume();
361
+ }
362
+ catch { /* terminal may be dying */ }
363
+ };
364
+ const pausePty = () => {
365
+ if (ptyPaused)
366
+ return;
367
+ ptyPaused = true;
368
+ pausedAt = Date.now();
369
+ try {
370
+ term.pause();
371
+ }
372
+ catch { /* terminal may be dying */ }
373
+ resumeTimer = setInterval(() => {
374
+ if (maxBufferedAmount() < FLOW_LOW_WATER) {
375
+ resumePty();
376
+ return;
377
+ }
378
+ if (Date.now() - pausedAt > FLOW_MAX_PAUSE_MS) {
379
+ // A consumer is stuck — drop it rather than freeze everyone.
380
+ evictLaggards(FLOW_LOW_WATER);
381
+ if (maxBufferedAmount() < FLOW_LOW_WATER) {
382
+ resumePty();
383
+ }
384
+ else {
385
+ pausedAt = Date.now();
386
+ }
387
+ }
388
+ }, FLOW_CHECK_INTERVAL_MS);
389
+ resumeTimer.unref?.();
390
+ };
232
391
  // Wire up PTY output → buffer + WebSocket broadcast
233
392
  term.on('data', (data) => {
234
393
  appendToTerminalBuffer(session, data);
235
394
  session.lastActive = Date.now();
236
395
  broadcastToSockets(session, data);
396
+ if (!ptyPaused && maxBufferedAmount() > FLOW_HIGH_WATER) {
397
+ pausePty();
398
+ }
237
399
  });
238
400
  term.onExit(() => {
401
+ resumePty();
239
402
  handleTerminalExit(id);
240
403
  });
241
- saveTerminal(db, id, deck.id, resolvedTitle, command || null, createdAt);
242
404
  terminals.set(id, session);
243
405
  return session;
244
406
  }
@@ -265,7 +427,9 @@ export function createTerminalRouter(db, decks, terminals) {
265
427
  const deck = decks.get(deckId);
266
428
  if (!deck)
267
429
  throw createHttpError('Deck not found', 404);
268
- const session = createTerminalSession(deck, body?.title, body?.command);
430
+ const cols = normalizeTerminalSize(body?.cols, DEFAULT_TERMINAL_COLS);
431
+ const rows = normalizeTerminalSize(body?.rows, DEFAULT_TERMINAL_ROWS);
432
+ const session = createTerminalSession(deck, body?.title, body?.command, cols, rows);
269
433
  return c.json({ id: session.id, title: session.title }, 201);
270
434
  }
271
435
  catch (error) {
@@ -279,7 +443,6 @@ export function createTerminalRouter(db, decks, terminals) {
279
443
  if (!session)
280
444
  throw createHttpError('Terminal not found', 404);
281
445
  terminals.delete(terminalId);
282
- deleteTerminalFromDb(db, terminalId);
283
446
  session.sockets.forEach((socket) => {
284
447
  try {
285
448
  socket.close(1000, 'Terminal deleted');
package/dist/server.js CHANGED
@@ -11,13 +11,16 @@ import { PORT, HOST, NODE_ENV, BASIC_AUTH_USER, BASIC_AUTH_PASSWORD, CORS_ORIGIN
11
11
  import { securityHeaders } from './middleware/security.js';
12
12
  import { corsMiddleware } from './middleware/cors.js';
13
13
  import { basicAuthMiddleware, generateWsToken, isBasicAuthEnabled } from './middleware/auth.js';
14
- import { checkDatabaseIntegrity, handleDatabaseCorruption, initializeDatabase, loadPersistedState, clearOrphanedTerminals, } from './utils/database.js';
14
+ import { checkDatabaseIntegrity, handleDatabaseCorruption, initializeDatabase, loadPersistedState, } from './utils/database.js';
15
15
  import { createWorkspaceRouter, getConfigHandler } from './routes/workspaces.js';
16
16
  import { createDeckRouter } from './routes/decks.js';
17
17
  import { createFileRouter } from './routes/files.js';
18
18
  import { createTerminalRouter } from './routes/terminals.js';
19
19
  import { createGitRouter } from './routes/git.js';
20
20
  import { createSettingsRouter } from './routes/settings.js';
21
+ import { createMcpRouter } from './routes/mcp.js';
22
+ import { createBrowserRouter } from './routes/browser.js';
23
+ import { AgentBrowserService } from './utils/agent-browser.js';
21
24
  import { setupWebSocketServer, getConnectionLimit, setConnectionLimit, getConnectionStats, clearAllConnections, } from './websocket.js';
22
25
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
26
  // Request ID and logging middleware
@@ -42,12 +45,12 @@ export async function createServer() {
42
45
  }
43
46
  const db = new DatabaseSync(dbPath);
44
47
  initializeDatabase(db);
45
- clearOrphanedTerminals(db);
46
48
  // Initialize state
47
49
  const workspaces = new Map();
48
50
  const workspacePathIndex = new Map();
49
51
  const decks = new Map();
50
52
  const terminals = new Map();
53
+ const browserService = new AgentBrowserService();
51
54
  loadPersistedState(db, workspaces, workspacePathIndex, decks);
52
55
  // Create Hono app
53
56
  const app = new Hono();
@@ -58,17 +61,27 @@ export async function createServer() {
58
61
  maxSize: MAX_REQUEST_BODY_SIZE,
59
62
  onError: (c) => c.json({ error: 'Request body too large' }, 413),
60
63
  }));
64
+ app.use('/mcp', bodyLimit({
65
+ maxSize: MAX_REQUEST_BODY_SIZE,
66
+ onError: (c) => c.json({ error: 'Request body too large' }, 413),
67
+ }));
68
+ app.use('/oauth/*', bodyLimit({
69
+ maxSize: MAX_REQUEST_BODY_SIZE,
70
+ onError: (c) => c.json({ error: 'Request body too large' }, 413),
71
+ }));
61
72
  if (basicAuthMiddleware) {
62
73
  app.use('/api/*', basicAuthMiddleware);
74
+ app.use('/oauth/authorize', basicAuthMiddleware);
63
75
  }
64
76
  app.get('/health', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString(), uptime: process.uptime() }));
65
77
  // Mount routers
66
78
  app.route('/api/settings', createSettingsRouter());
67
79
  app.route('/api/workspaces', createWorkspaceRouter(db, workspaces, workspacePathIndex));
68
80
  app.route('/api/decks', createDeckRouter(db, workspaces, decks, terminals));
69
- const terminalRouter = createTerminalRouter(db, decks, terminals);
81
+ const terminalRouter = createTerminalRouter(decks, terminals);
70
82
  app.route('/api/terminals', terminalRouter);
71
83
  app.route('/api/git', createGitRouter(workspaces));
84
+ app.route('/api/browser', createBrowserRouter(browserService));
72
85
  app.get('/api/config', getConfigHandler());
73
86
  app.get('/api/ws-token', (c) => c.json({ token: generateWsToken(), authEnabled: isBasicAuthEnabled() }));
74
87
  app.get('/api/ws/stats', (c) => c.json({ limit: getConnectionLimit(), connections: getConnectionStats() }));
@@ -86,6 +99,10 @@ export async function createServer() {
86
99
  });
87
100
  const fileRouter = createFileRouter(workspaces);
88
101
  app.route('/api', fileRouter);
102
+ app.route('/', createMcpRouter({
103
+ apiFetch: (request) => Promise.resolve(app.fetch(request)),
104
+ terminals,
105
+ }));
89
106
  if (hasStatic) {
90
107
  const serveAssets = serveStatic({ root: distDir });
91
108
  const serveIndex = serveStatic({ root: distDir, path: 'index.html' });
@@ -97,18 +114,20 @@ export async function createServer() {
97
114
  });
98
115
  }
99
116
  const server = serve({ fetch: app.fetch, port: PORT, hostname: HOST });
100
- setupWebSocketServer(server, terminals);
117
+ setupWebSocketServer(server, terminals, browserService);
101
118
  server.on('listening', () => {
102
119
  const baseUrl = `http://localhost:${PORT}`;
103
120
  console.log(`Deck IDE server listening on ${baseUrl}`);
104
121
  console.log(`UI: ${baseUrl}`);
105
122
  console.log(`API: ${baseUrl}/api`);
123
+ console.log(`MCP: ${baseUrl}/mcp`);
106
124
  console.log(`Health: ${baseUrl}/health`);
107
125
  console.log('');
108
126
  console.log('Security Status:');
109
127
  console.log(` - Basic Auth: ${BASIC_AUTH_USER && BASIC_AUTH_PASSWORD ? 'enabled' : 'DISABLED'}`);
110
128
  console.log(` - Max File Size: ${Math.round(MAX_FILE_SIZE / 1024 / 1024)}MB`);
111
129
  console.log(` - Max Request Body: ${Math.round(MAX_REQUEST_BODY_SIZE / 1024)}KB`);
130
+ console.log(' - MCP OAuth: enabled');
112
131
  console.log(` - Trust Proxy: ${TRUST_PROXY ? 'enabled' : 'disabled'}`);
113
132
  console.log(` - CORS Origin: ${CORS_ORIGIN || (NODE_ENV === 'development' ? '*' : 'NOT SET')}`);
114
133
  console.log(` - Environment: ${NODE_ENV}`);
@@ -130,6 +149,7 @@ export async function createServer() {
130
149
  session.kill();
131
150
  });
132
151
  terminals.clear();
152
+ await browserService.stop();
133
153
  try {
134
154
  db.close();
135
155
  }