diff --git a/.gitignore b/.gitignore index 7f934a2..860efc9 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,8 @@ docs/ .server.pid .bkit/ + +# Large assets copied from webterm project — not tracked in git +public/sherpa-moonshine-v2-base-en/ +public/ghostty-vt.wasm +public/fonts/ diff --git a/CLAUDE.md b/CLAUDE.md index 4761df4..1f76f45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Large Assets + +`public/sherpa-moonshine-v2-base-en/` (~147MB), `public/ghostty-vt.wasm` (~416KB), and `public/fonts/` (~2.6MB) are copied from the **webterm** project and are excluded from git. Copy them manually from `../webterm/public/` when setting up. + ## Commands ```bash diff --git a/package.json b/package.json index 5f0d5d9..983059e 100644 --- a/package.json +++ b/package.json @@ -47,9 +47,11 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "express": "^5.1.0", + "ghostty-web": "^0.4.0", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.577.0", "multer": "^2.1.1", + "node-pty": "^1.1.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", @@ -84,6 +86,7 @@ "bcrypt@6.0.0": true, "better-sqlite3@12.10.0": true, "esbuild@0.28.0": true, - "esbuild@0.25.12": true + "esbuild@0.25.12": true, + "node-pty@1.1.0": true } } diff --git a/server/adapters/claude/tmux-adapter.ts b/server/adapters/claude/tmux-adapter.ts index 6d99e22..d548fc5 100644 --- a/server/adapters/claude/tmux-adapter.ts +++ b/server/adapters/claude/tmux-adapter.ts @@ -104,6 +104,7 @@ export class TmuxAdapter extends EventEmitter { this._clientChecker = null; this._cleanupInterval = null; this._startSessionCleanup(); + this._recoverSessions().catch((err) => console.error('[tmux] Recovery error:', err)); } /** Set a function that checks if WS clients are connected for a session */ @@ -806,6 +807,48 @@ export class TmuxAdapter extends EventEmitter { // === Helpers === + private async _recoverSessions(): Promise { + const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + let windows: TmuxWindow[]; + try { + windows = await tmuxManager.listWindows(); + } catch (err) { + console.error('[tmux] Recovery: listWindows failed:', err); + return; + } + + console.log(`[tmux] Recovery: found ${windows.length} window(s):`, windows.map(w => `${w.name}(${w.command})`).join(', ')); + const candidates = windows.filter(w => UUID_RE.test(w.name)); + if (candidates.length === 0) { + console.log('[tmux] Recovery: no UUID-named windows to recover'); + return; + } + + await Promise.all(candidates.map(async (win) => { + const sessionId = win.name; + if (this.sessions.has(sessionId)) return; + + // Find the JSONL file to recover real cwd and firstPrompt + let cwd = win.cwd; + let firstPrompt: string | null = null; + const jsonlPath = await this._findJsonlPath(sessionId); + if (jsonlPath) { + try { + const header = await parseSessionHeader(jsonlPath, sessionId); + if (header.cwd) cwd = header.cwd; + firstPrompt = header.firstPrompt; + } catch {} + } + + const state = this._createSession(win.id, cwd, sessionId, 'bypassPermissions'); + state.firstPrompt = firstPrompt; + this.sessions.set(sessionId, state); + this._startMonitor(sessionId, win.id); + await this._ensureWatcher(sessionId); + console.log(`[tmux] Recovered session ${sessionId} (${cwd})`); + })); + } + private _createSession(windowId: string, cwd: string, cliSessionId: string, permissionMode: string): SessionState { return { windowId, diff --git a/server/transport/websocket-transport.ts b/server/transport/websocket-transport.ts index e85a5a8..5588b0f 100644 --- a/server/transport/websocket-transport.ts +++ b/server/transport/websocket-transport.ts @@ -22,11 +22,10 @@ export class WebSocketTransport extends EventEmitter { // connection is never terminated before it has a chance to respond. private alive = new WeakMap(); - /** Create WebSocketServer on /ws path with JWT verification and ping/pong keepalive. */ - setup(server: HttpServer | HttpsServer): void { + /** Create WebSocketServer in noServer mode. Call handleUpgrade() from your server upgrade handler. */ + setup(_server: HttpServer | HttpsServer): void { this.wss = new WebSocketServer({ - server, - path: '/ws', + noServer: true, verifyClient: ({ req }, cb) => { const url = new URL(req.url!, `http://${req.headers.host}`); const token = url.searchParams.get('token'); @@ -73,6 +72,13 @@ export class WebSocketTransport extends EventEmitter { this.pingInterval.unref(); } + /** Route an HTTP upgrade request into this WSS (called from server's upgrade handler). */ + handleUpgrade(req: import('http').IncomingMessage, socket: import('stream').Duplex, head: Buffer): void { + this.wss!.handleUpgrade(req, socket, head, (ws) => { + this.wss!.emit('connection', ws, req); + }); + } + /** Shut down the WebSocket server and stop keepalive. */ destroy(): void { if (this.pingInterval) { diff --git a/src/sw.ts b/src/sw.ts index ba486e8..a64cf33 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -1,7 +1,7 @@ /// import { precacheAndRoute } from 'workbox-precaching'; import { registerRoute } from 'workbox-routing'; -import { NetworkFirst } from 'workbox-strategies'; +import { NetworkFirst, CacheFirst } from 'workbox-strategies'; declare const self: ServiceWorkerGlobalScope; @@ -31,6 +31,12 @@ registerRoute( }) ); +// Sherpa voice model assets — large (~147MB total), cached on first voice use +registerRoute( + ({ url }) => url.pathname.startsWith('/sherpa-moonshine-v2-base-en/'), + new CacheFirst({ cacheName: 'terminal-voice-assets' }) +); + // Push notification handler — server already filters by clientCount, // so we always show the notification if one is received. self.addEventListener('push', (event) => { diff --git a/vite.config.ts b/vite.config.ts index 56b8523..ca2a507 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -39,7 +39,9 @@ export default defineConfig({ categories: ['developer-tools', 'productivity'], }, injectManifest: { - globPatterns: ['**/*.{js,css,html,ico,svg,woff2}', 'favicon-*.png', 'apple-touch-icon.png', 'badge-*.png', 'mascot/*.png'], + globPatterns: ['**/*.{js,css,html,ico,svg,woff2,wasm,ttf}', 'favicon-*.png', 'apple-touch-icon.png', 'badge-*.png', 'mascot/*.png'], + globIgnores: ['sherpa-moonshine-v2-base-en/**'], + maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // 5 MiB (font is ~2.6 MiB) }, }), ], @@ -60,9 +62,20 @@ export default defineConfig({ ws: true, secure: false, }, + '/terminal': { + target: 'wss://localhost:3456', + ws: true, + secure: false, + }, }, }, build: { outDir: 'dist', + rollupOptions: { + external: ['node-pty'], + }, + }, + optimizeDeps: { + exclude: ['node-pty'], }, });