Add Ghostty terminal, session recovery, voice asset caching, and gitignore large assets
Integrates ghostty-web and node-pty packages, adds tmux session recovery on startup, refactors WebSocket transport to noServer mode, caches Sherpa voice model assets via service worker. Large assets (sherpa, ghostty-vt.wasm, fonts) are now gitignored as they are copied from the webterm project. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,3 +11,8 @@ docs/
|
|||||||
.server.pid
|
.server.pid
|
||||||
|
|
||||||
.bkit/
|
.bkit/
|
||||||
|
|
||||||
|
# Large assets copied from webterm project — not tracked in git
|
||||||
|
public/sherpa-moonshine-v2-base-en/
|
||||||
|
public/ghostty-vt.wasm
|
||||||
|
public/fonts/
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
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
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+4
-1
@@ -47,9 +47,11 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"ghostty-web": "^0.4.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
|
"node-pty": "^1.1.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
@@ -84,6 +86,7 @@
|
|||||||
"bcrypt@6.0.0": true,
|
"bcrypt@6.0.0": true,
|
||||||
"better-sqlite3@12.10.0": true,
|
"better-sqlite3@12.10.0": true,
|
||||||
"esbuild@0.28.0": true,
|
"esbuild@0.28.0": true,
|
||||||
"esbuild@0.25.12": true
|
"esbuild@0.25.12": true,
|
||||||
|
"node-pty@1.1.0": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export class TmuxAdapter extends EventEmitter {
|
|||||||
this._clientChecker = null;
|
this._clientChecker = null;
|
||||||
this._cleanupInterval = null;
|
this._cleanupInterval = null;
|
||||||
this._startSessionCleanup();
|
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 */
|
/** Set a function that checks if WS clients are connected for a session */
|
||||||
@@ -806,6 +807,48 @@ export class TmuxAdapter extends EventEmitter {
|
|||||||
|
|
||||||
// === Helpers ===
|
// === Helpers ===
|
||||||
|
|
||||||
|
private async _recoverSessions(): Promise<void> {
|
||||||
|
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 {
|
private _createSession(windowId: string, cwd: string, cliSessionId: string, permissionMode: string): SessionState {
|
||||||
return {
|
return {
|
||||||
windowId,
|
windowId,
|
||||||
|
|||||||
@@ -22,11 +22,10 @@ export class WebSocketTransport extends EventEmitter {
|
|||||||
// connection is never terminated before it has a chance to respond.
|
// connection is never terminated before it has a chance to respond.
|
||||||
private alive = new WeakMap<WebSocket, boolean>();
|
private alive = new WeakMap<WebSocket, boolean>();
|
||||||
|
|
||||||
/** Create WebSocketServer on /ws path with JWT verification and ping/pong keepalive. */
|
/** Create WebSocketServer in noServer mode. Call handleUpgrade() from your server upgrade handler. */
|
||||||
setup(server: HttpServer | HttpsServer): void {
|
setup(_server: HttpServer | HttpsServer): void {
|
||||||
this.wss = new WebSocketServer({
|
this.wss = new WebSocketServer({
|
||||||
server,
|
noServer: true,
|
||||||
path: '/ws',
|
|
||||||
verifyClient: ({ req }, cb) => {
|
verifyClient: ({ req }, cb) => {
|
||||||
const url = new URL(req.url!, `http://${req.headers.host}`);
|
const url = new URL(req.url!, `http://${req.headers.host}`);
|
||||||
const token = url.searchParams.get('token');
|
const token = url.searchParams.get('token');
|
||||||
@@ -73,6 +72,13 @@ export class WebSocketTransport extends EventEmitter {
|
|||||||
this.pingInterval.unref();
|
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. */
|
/** Shut down the WebSocket server and stop keepalive. */
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
if (this.pingInterval) {
|
if (this.pingInterval) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
import { precacheAndRoute } from 'workbox-precaching';
|
import { precacheAndRoute } from 'workbox-precaching';
|
||||||
import { registerRoute } from 'workbox-routing';
|
import { registerRoute } from 'workbox-routing';
|
||||||
import { NetworkFirst } from 'workbox-strategies';
|
import { NetworkFirst, CacheFirst } from 'workbox-strategies';
|
||||||
|
|
||||||
declare const self: ServiceWorkerGlobalScope;
|
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,
|
// Push notification handler — server already filters by clientCount,
|
||||||
// so we always show the notification if one is received.
|
// so we always show the notification if one is received.
|
||||||
self.addEventListener('push', (event) => {
|
self.addEventListener('push', (event) => {
|
||||||
|
|||||||
+14
-1
@@ -39,7 +39,9 @@ export default defineConfig({
|
|||||||
categories: ['developer-tools', 'productivity'],
|
categories: ['developer-tools', 'productivity'],
|
||||||
},
|
},
|
||||||
injectManifest: {
|
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,
|
ws: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
},
|
},
|
||||||
|
'/terminal': {
|
||||||
|
target: 'wss://localhost:3456',
|
||||||
|
ws: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['node-pty'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ['node-pty'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user