diff --git a/package.json b/package.json index b90c750..1a58727 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "start": "tsx src/server.ts", "dev": "tsx watch src/server.ts", "build": "tsc -p tsconfig.json", - "build:web": "esbuild public/main.ts --bundle --format=esm --outdir=public/build --sourcemap", - "dev:web": "esbuild public/main.ts --bundle --format=esm --outdir=public/build --sourcemap --watch", + "build:web": "esbuild public/main.ts public/manage.ts --bundle --format=esm --outdir=public/build --sourcemap", + "dev:web": "esbuild public/main.ts public/manage.ts --bundle --format=esm --outdir=public/build --sourcemap --watch", "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.web.json", "test": "vitest run", "test:watch": "vitest", diff --git a/public/main.ts b/public/main.ts index 60c75f0..215f0a5 100644 --- a/public/main.ts +++ b/public/main.ts @@ -67,6 +67,18 @@ mountHistory(toolbar, { }) mountShortcuts(toolbar) mountShareSession(toolbar, () => app.activeSessionId()) + +// Session manager (standalone page) โ€” manage/kill the host's running sessions. +const manageBtn = document.createElement('button') +manageBtn.className = 'toolbtn' +manageBtn.textContent = '๐Ÿ—‚' +manageBtn.title = 'Manage sessions (open / kill)' +manageBtn.setAttribute('aria-label', 'Manage sessions') +manageBtn.addEventListener('click', () => { + location.href = '/manage.html' +}) +toolbar.appendChild(manageBtn) + mountQrConnect(toolbar) // PWA: register the service worker (installable + offline shell, M4). diff --git a/public/manage.html b/public/manage.html new file mode 100644 index 0000000..a1edba6 --- /dev/null +++ b/public/manage.html @@ -0,0 +1,14 @@ + + + + + + Session Manager โ€” Web Terminal + + + + +
+ + + diff --git a/public/manage.ts b/public/manage.ts new file mode 100644 index 0000000..50d3f3b --- /dev/null +++ b/public/manage.ts @@ -0,0 +1,144 @@ +/** + * public/manage.ts โ€” standalone Session Manager page (/manage.html). + * + * Lists the host's running server sessions (GET /live-sessions) and lets you + * open one (?join=), kill one (DELETE /live-sessions/:id), or bulk-kill all + * / only detached ones (DELETE /live-sessions[?detached=1]). Auto-refreshes. + * Independent of the main app, so it works even when many sessions are open. + */ + +interface LiveSession { + id: string + createdAt: number + clientCount: number + status: 'working' | 'waiting' | 'idle' | 'unknown' + exited: boolean + cwd: string | null + cols: number + rows: number +} + +const REFRESH_MS = 3000 + +function relTime(ms: number): string { + const s = Math.max(0, (Date.now() - ms) / 1000) + if (s < 60) return `${Math.floor(s)}s` + if (s < 3600) return `${Math.floor(s / 60)}m` + if (s < 86400) return `${Math.floor(s / 3600)}h` + return `${Math.floor(s / 86400)}d` +} + +function statusGlyph(s: LiveSession['status']): string { + return s === 'working' ? 'โš™ working' : s === 'waiting' ? 'โณ waiting' : s === 'idle' ? 'โœ“ idle' : 'ยท' +} + +async function fetchSessions(): Promise { + try { + const res = await fetch('/live-sessions') + const data: unknown = await res.json() + return Array.isArray(data) ? (data as LiveSession[]) : [] + } catch { + return [] + } +} + +const root = document.getElementById('manage-root') +if (!root) throw new Error('#manage-root not found') + +let busy = false + +function el( + tag: K, + cls?: string, + text?: string, +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag) + if (cls) node.className = cls + if (text !== undefined) node.textContent = text + return node +} + +async function killOne(id: string): Promise { + if (busy) return + busy = true + try { + await fetch(`/live-sessions/${id}`, { method: 'DELETE' }) + } catch { + /* best-effort */ + } + busy = false + void render() +} + +async function killBulk(detachedOnly: boolean): Promise { + if (busy) return + const label = detachedOnly ? 'all DETACHED sessions (no device watching)' : 'ALL sessions' + if (!confirm(`Kill ${label}? Running shells/Claude will be terminated.`)) return + busy = true + try { + await fetch(`/live-sessions${detachedOnly ? '?detached=1' : ''}`, { method: 'DELETE' }) + } catch { + /* best-effort */ + } + busy = false + void render() +} + +function row(s: LiveSession): HTMLElement { + const r = el('div', 'mg-row') + + const main = el('div', 'mg-main') + const top = el('div', 'mg-top') + const name = el('span', 'mg-name', s.cwd ? (s.cwd.split('/').filter(Boolean).pop() ?? s.cwd) : s.id.slice(0, 8)) + const watchers = el('span', s.clientCount > 0 ? 'mg-watch live' : 'mg-watch', `๐Ÿ‘ ${s.clientCount}`) + const status = el('span', `mg-status mg-${s.status}`, statusGlyph(s.status)) + top.append(name, watchers, status) + const meta = el('div', 'mg-meta', `${s.cwd ?? 'unknown dir'} ยท ${s.cols}ร—${s.rows} ยท ${relTime(s.createdAt)} old ยท ${s.id.slice(0, 8)}`) + main.append(top, meta) + + const actions = el('div', 'mg-actions') + const open = el('a', 'mg-open', 'Open โ†—') as HTMLAnchorElement + open.href = `/?join=${s.id}` + const kill = el('button', 'mg-kill', 'Kill โœ•') + kill.addEventListener('click', () => void killOne(s.id)) + actions.append(open, kill) + + r.append(main, actions) + return r +} + +async function render(): Promise { + const sessions = await fetchSessions() + root!.replaceChildren() + + const header = el('div', 'mg-header') + header.append(el('div', 'mg-title', 'Session Manager')) + const sub = el('div', 'mg-sub', `${sessions.length} session(s) running on the host`) + header.append(sub) + + const bar = el('div', 'mg-bar') + const back = el('a', 'mg-btn', 'โ† Back to terminal') as HTMLAnchorElement + back.href = '/' + const refresh = el('button', 'mg-btn', 'โ†ป Refresh') + refresh.addEventListener('click', () => void render()) + const killDetached = el('button', 'mg-btn warn', 'Kill detached') + killDetached.addEventListener('click', () => void killBulk(true)) + const killAll = el('button', 'mg-btn danger', 'Kill all') + killAll.addEventListener('click', () => void killBulk(false)) + bar.append(back, refresh, killDetached, killAll) + header.append(bar) + root!.append(header) + + if (sessions.length === 0) { + root!.append(el('div', 'mg-empty', 'No sessions running. Open the terminal to start one.')) + return + } + const list = el('div', 'mg-list') + for (const s of sessions) list.append(row(s)) + root!.append(list) +} + +void render() +setInterval(() => { + if (!busy) void render() +}, REFRESH_MS) diff --git a/public/style.css b/public/style.css index 3e084d1..b953ddb 100644 --- a/public/style.css +++ b/public/style.css @@ -659,3 +659,144 @@ body { background: var(--red); color: #2a0808; } + +/* โ”€โ”€ Session Manager page (manage.html) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +#manage-root { + max-width: 760px; + margin: 0 auto; + padding: 24px 16px 60px; +} +.mg-header { + margin-bottom: 18px; +} +.mg-title { + font-size: 22px; + font-weight: 700; + color: #fff; +} +.mg-sub { + color: var(--text-dim); + font-size: 13px; + margin-top: 2px; +} +.mg-bar { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} +.mg-btn { + background: var(--surface-2); + border: 1px solid var(--border-strong); + color: var(--text); + border-radius: 8px; + padding: 8px 14px; + cursor: pointer; + font: inherit; + font-size: 13px; + text-decoration: none; + transition: background 0.1s ease; +} +.mg-btn:hover { + background: var(--surface-3); +} +.mg-btn.warn { + border-color: rgba(245, 177, 76, 0.5); + color: var(--amber); +} +.mg-btn.danger { + border-color: rgba(255, 107, 107, 0.5); + color: var(--red); +} +.mg-list { + display: flex; + flex-direction: column; + gap: 8px; +} +.mg-row { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 10px; +} +.mg-main { + flex: 1 1 auto; + min-width: 0; +} +.mg-top { + display: flex; + align-items: center; + gap: 8px; +} +.mg-name { + color: #fff; + font-weight: 600; +} +.mg-watch { + font-size: 11px; + color: var(--text-faint); + background: var(--surface-3); + border-radius: 5px; + padding: 1px 6px; +} +.mg-watch.live { + color: var(--green); + background: rgba(70, 208, 127, 0.14); +} +.mg-status { + font-size: 11px; + color: var(--text-dim); +} +.mg-status.mg-waiting { + color: var(--amber); + font-weight: 600; +} +.mg-status.mg-working { + color: var(--accent); +} +.mg-meta { + color: var(--text-faint); + font-size: 12px; + margin-top: 3px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: Menlo, Consolas, monospace; +} +.mg-actions { + flex: none; + display: flex; + gap: 6px; +} +.mg-open { + background: var(--accent); + color: #fff; + border-radius: 8px; + padding: 7px 13px; + text-decoration: none; + font-size: 13px; +} +.mg-open:hover { + background: var(--accent-2); +} +.mg-kill { + background: transparent; + border: 1px solid rgba(255, 107, 107, 0.4); + color: var(--red); + border-radius: 8px; + padding: 7px 13px; + cursor: pointer; + font: inherit; + font-size: 13px; +} +.mg-kill:hover { + background: rgba(255, 107, 107, 0.14); +} +.mg-empty { + color: var(--text-dim); + padding: 30px 0; + text-align: center; +} diff --git a/public/terminal-session.ts b/public/terminal-session.ts index 35b36b4..99638f0 100644 --- a/public/terminal-session.ts +++ b/public/terminal-session.ts @@ -351,6 +351,14 @@ export class TerminalSession { hide(): void { this.el.style.display = 'none' + // v0.4: withdraw our size vote so a backgrounded mirror doesn't clamp the + // shared PTY to this device's size. Output still streams (background mirror). + if (this.ws !== null && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(buildMessage({ type: 'blur' })) + } + // Force show() to re-cast our dims even if unchanged. + this.lastCols = -1 + this.lastRows = -1 } /** Tear down: closing the WS detaches โ€” the server PTY keeps running. */ diff --git a/src/protocol.ts b/src/protocol.ts index 917369c..59ef83a 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -24,7 +24,7 @@ export const SESSION_ID_RE = // โ”€โ”€โ”€ Type whitelist โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -const ALLOWED_TYPES = new Set(['attach', 'input', 'resize', 'approve', 'reject']) +const ALLOWED_TYPES = new Set(['attach', 'input', 'resize', 'blur', 'approve', 'reject']) // โ”€โ”€โ”€ parseClientMessage โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -73,6 +73,11 @@ export function parseClientMessage(raw: string): ParseResult { return validateInput(obj) } + // blur (v0.4) carries no payload โ€” withdraw this client's size vote. + if (type === 'blur') { + return { ok: true, message: { type: 'blur' } } + } + // approve/reject (H3) carry no payload. if (type === 'approve') { return { ok: true, message: { type: 'approve' } } diff --git a/src/server.ts b/src/server.ts index 077e28e..fab3ace 100644 --- a/src/server.ts +++ b/src/server.ts @@ -39,7 +39,7 @@ import { isOriginAllowed } from './http/origin.js' import { parseHookEvent } from './http/hook.js' import { listSessions } from './http/history.js' import { createSessionManager } from './session/manager.js' -import { detachWs, writeInput, setClientDims } from './session/session.js' +import { detachWs, writeInput, setClientDims, clearClientDims } from './session/session.js' import type { Config } from './types.js' import { WS_OPEN } from './types.js' @@ -117,6 +117,23 @@ export function startServer(cfg: Config): { close(): Promise } { res.json(manager.list()) }) + // Kill ALL sessions, or only detached ones (?detached=1) โ€” manage page bulk action. + app.delete('/live-sessions', (req, res) => { + const onlyDetached = req.query['detached'] === '1' + let killed = 0 + for (const s of manager.list()) { + if (onlyDetached && s.clientCount > 0) continue + if (manager.killById(s.id)) killed += 1 + } + res.json({ killed }) + }) + + // Kill one session by id โ€” manage page per-row action. + app.delete('/live-sessions/:id', (req, res) => { + const ok = manager.killById(req.params.id) + res.status(ok ? 204 : 404).end() + }) + // โ”€โ”€ Claude Code hook side-channel (H2) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Hooks running inside a spawned shell POST status here (loopback only โ€” the // shell runs on the host). sessionId arrives in the X-Webterm-Session header. @@ -268,8 +285,11 @@ export function startServer(cfg: Config): { close(): Promise } { if (msg.type === 'input') { writeInput(session, msg.data) } else if (msg.type === 'resize') { - // Per-client dims; the PTY tracks the min across all sharing devices. + // Per-client dims; the PTY tracks the min across actively-viewing devices. setClientDims(session, ws, msg.cols, msg.rows) + } else if (msg.type === 'blur') { + // Tab hidden on this device โ€” withdraw its size vote (still mirrors output). + clearClientDims(session, ws) } else if (msg.type === 'approve') { // H3: resolve the held PermissionRequest with allow. resolvePending(boundSessionId, permDecision('allow')) diff --git a/src/session/manager.ts b/src/session/manager.ts index a630176..544cce7 100644 --- a/src/session/manager.ts +++ b/src/session/manager.ts @@ -92,7 +92,7 @@ export function createSessionManager(cfg: Config): SessionManager { // M4: createSession may throw (spawn failure). Do NOT catch here. // M6: cwd (if given) is the spawn directory for "new tab here". const session = createSession(cfg, dims, now, onSessionExit, undefined, cwd); - attachWs(session, ws, dims); + attachWs(session, ws); sessions = new Map(sessions).set(session.meta.id, session); return session; } @@ -101,7 +101,7 @@ export function createSessionManager(cfg: Config): SessionManager { // โ”€โ”€ Case 2: hit a live session โ†’ JOIN it (multi-device sharing) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if (existing !== undefined && existing.exitedAt === null) { - attachWs(existing, ws, dims); + attachWs(existing, ws); return existing; } @@ -122,7 +122,7 @@ export function createSessionManager(cfg: Config): SessionManager { // to the still-running shell instead of spawning a fresh one. if (cfg.useTmux && hasSession(tmuxName(sessionId))) { const revived = createSession(cfg, dims, now, onSessionExit, sessionId); - attachWs(revived, ws, dims); + attachWs(revived, ws); sessions = new Map(sessions).set(revived.meta.id, revived); return revived; } @@ -130,7 +130,7 @@ export function createSessionManager(cfg: Config): SessionManager { // โ”€โ”€ Case 4: session not found โ†’ create a new one โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // M4: createSession may throw. Do NOT catch here. const session = createSession(cfg, dims, now, onSessionExit); - attachWs(session, ws, dims); + attachWs(session, ws); sessions = new Map(sessions).set(session.meta.id, session); return session; } @@ -149,10 +149,29 @@ export function createSessionManager(cfg: Config): SessionManager { status: s.claudeStatus, exited: s.exitedAt !== null, cwd: s.cwd, + cols: s.pty.cols, + rows: s.pty.rows, })) .sort((a, b) => b.createdAt - a.createdAt); } + /** + * Kill a session by id (manage page): close every attached client, kill the + * PTY (and tmux session, H1), and drop it from the table. Returns whether a + * session was found. + */ + function killById(id: string): boolean { + const session = sessions.get(id); + if (session === undefined) return false; + for (const ws of session.clients) { + if (ws.readyState === WS_OPEN) ws.close(); + } + kill(session); + sessions = new Map(sessions); + sessions.delete(id); + return true; + } + /** * H2: a Claude Code hook reported activity for `sessionId`. Record it and push * a `status` frame to the attached ws (no-op if the session is gone/detached). @@ -223,5 +242,5 @@ export function createSessionManager(cfg: Config): SessionManager { sessions = new Map(); } - return { handleAttach, get, list, handleHookEvent, reapIdle, shutdown }; + return { handleAttach, get, list, killById, handleHookEvent, reapIdle, shutdown }; } diff --git a/src/session/session.ts b/src/session/session.ts index 48cb4c2..98e5b7c 100644 --- a/src/session/session.ts +++ b/src/session/session.ts @@ -150,16 +150,18 @@ export function createSession( } /** - * Add `ws` as a client (multi-device sharing): register its dims, clear the - * detached stamp, re-derive the shared PTY size, then replay the scrollback to - * THIS client only so it sees the current screen. Other clients are untouched. + * Add `ws` as a client (multi-device sharing): clear the detached stamp, then + * replay the scrollback to THIS client only so it sees the current screen. * No kicking โ€” devices share the session (invariant #5 relaxed for v0.4). + * + * The client does NOT get a size vote here: only a client that is actively + * viewing the session sends a `resize` (the frontend skips hidden panes), so a + * background mirror never clamps the shared PTY. The vote is added on the first + * `setClientDims` and removed on `clearClientDims`/`detachWs`. */ -export function attachWs(session: Session, ws: WebSocketLike, dims: Dims): void { +export function attachWs(session: Session, ws: WebSocketLike): void { session.clients.add(ws); - session.clientDims.set(ws, dims); session.detachedAt = null; - applyMinDims(session); // Replay the buffered scrollback so the joining client sees the last screen. sendIfOpen(ws, { type: 'output', data: session.buffer.snapshot() }); @@ -201,6 +203,15 @@ export function setClientDims( applyMinDims(session); } +/** + * Withdraw a client's size vote without detaching it (its tab was hidden). The + * client still receives output (it's a background mirror); it just stops + * constraining the shared PTY size. Re-votes on its next `setClientDims`. + */ +export function clearClientDims(session: Session, ws: WebSocketLike): void { + if (session.clientDims.delete(ws)) applyMinDims(session); +} + /** * Kill the session (idle reclaim). Under tmux this ends the actual shell via * `tmux kill-session` (H1); killing only the client pty would leave the tmux diff --git a/src/types.ts b/src/types.ts index 32a7520..2046d88 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,6 +45,9 @@ export type ClientMessage = | { type: 'attach'; sessionId: string | null; cwd?: string } | { type: 'input'; data: string } | { type: 'resize'; cols: number; rows: number } + // v0.4: this client stopped actively viewing (tab hidden) โ€” withdraw its size + // vote so a background mirror doesn't clamp the shared PTY (min-dims). + | { type: 'blur' } | { type: 'approve' } | { type: 'reject' }; @@ -167,10 +170,11 @@ export interface Session { // impl anchors [src/session/session.ts]: // createSession(cfg: Config, dims: Dims, now: number, onExit: (s: Session) => void): Session // // spawn failure THROWS, not swallowed (M4) -// attachWs(session: Session, ws: WebSocketLike, dims: Dims): void // adds a client, replays buffer (no kick) +// attachWs(session: Session, ws: WebSocketLike): void // adds a client, replays buffer (no size vote until it resizes) // detachWs(session: Session, ws: WebSocketLike, now: number): void // removes one client; never kills PTY // writeInput(session: Session, data: string): void // no-op after exit (L4) -// setClientDims(session: Session, ws: WebSocketLike, cols, rows): void // PTY = min over clients (L4 no-op after exit) +// setClientDims(session: Session, ws: WebSocketLike, cols, rows): void // PTY = min over active viewers (L4 no-op after exit) +// clearClientDims(session: Session, ws: WebSocketLike): void // withdraw this client's size vote (tab hidden / blur) // kill(session: Session): void /* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ manager (ยง3.5) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ @@ -183,6 +187,8 @@ export interface LiveSessionInfo { status: ClaudeStatus; exited: boolean; cwd: string | null; + cols: number; // current PTY size (for the manage page) + rows: number; } export interface SessionManager { @@ -196,6 +202,9 @@ export interface SessionManager { get(id: string): Session | undefined; /** Live sessions for multi-device discovery (newest first). */ list(): LiveSessionInfo[]; + /** Kill a session by id (manage page): close its clients, kill the PTY, drop it. + * Returns true if a session was found and killed. */ + killById(id: string): boolean; /** Set a session's Claude status (from a hook) and push it to the attached ws (H2/H3). */ handleHookEvent( sessionId: string, diff --git a/test/protocol.test.ts b/test/protocol.test.ts index d9c5392..452e70f 100644 --- a/test/protocol.test.ts +++ b/test/protocol.test.ts @@ -116,6 +116,12 @@ describe('parseClientMessage โ€” valid messages', () => { expect(r.ok).toBe(true) if (r.ok) expect(r.message).toEqual({ type: 'reject' }) }) + + it('parses blur (v0.4, no payload โ€” withdraw size vote)', () => { + const b = parseClientMessage(JSON.stringify({ type: 'blur' })) + expect(b.ok).toBe(true) + if (b.ok) expect(b.message).toEqual({ type: 'blur' }) + }) }) // โ”€โ”€โ”€ parseClientMessage โ€” invalid / error paths โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/test/session.test.ts b/test/session.test.ts index 435c321..d207744 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -33,9 +33,8 @@ vi.mock('node-pty', () => ({ })); // Imported AFTER vi.mock so the module under test binds to the mocked spawn. -const { createSession, attachWs, detachWs, writeInput, setClientDims, kill } = await import( - '../src/session/session.js' -); +const { createSession, attachWs, detachWs, writeInput, setClientDims, clearClientDims, kill } = + await import('../src/session/session.js'); // โ”€โ”€ helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const CFG: Config = { @@ -133,7 +132,7 @@ describe('onData', () => { it('broadcasts output to the attached client as an `output` message', () => { const s = newSession(); const ws = createMockWs(); - attachWs(s, ws, DIMS); + attachWs(s, ws); ws.sent.length = 0; // drop the replay frame from attach (s.pty as MockIPty).emitData('abc'); @@ -145,8 +144,8 @@ describe('onData', () => { const s = newSession(); const a = createMockWs(); const b = createMockWs(); - attachWs(s, a, DIMS); - attachWs(s, b, DIMS); + attachWs(s, a); + attachWs(s, b); a.sent.length = 0; b.sent.length = 0; @@ -165,7 +164,7 @@ describe('onData', () => { it('does NOT send to a client whose readyState is not OPEN (M5)', () => { const s = newSession(); const ws = createMockWs(WS_OPEN); - attachWs(s, ws, DIMS); + attachWs(s, ws); ws.sent.length = 0; ws.readyState = 3; // CLOSED @@ -182,7 +181,7 @@ describe('attachWs', () => { (s.pty as MockIPty).emitData('prior output'); const ws = createMockWs(); - attachWs(s, ws, DIMS); + attachWs(s, ws); expect(s.clients.has(ws)).toBe(true); expect(received(ws)).toEqual([{ type: 'output', data: '\x1b[0mprior output' }]); @@ -191,10 +190,10 @@ describe('attachWs', () => { it('a second attach JOINS (does not kick) โ€” both clients stay connected', () => { const s = newSession(); const a = createMockWs(); - attachWs(s, a, DIMS); + attachWs(s, a); const b = createMockWs(); - attachWs(s, b, DIMS); + attachWs(s, b); // Both present; the first was NOT closed. expect(s.clients.has(a)).toBe(true); @@ -209,21 +208,54 @@ describe('attachWs', () => { expect(received(b)).toEqual([{ type: 'output', data: 'live' }]); }); - it('resizes the PTY to the MIN dims across all clients (tmux-style)', () => { + it('resizes the PTY to the MIN dims across active viewers (tmux-style)', () => { const s = newSession(); // spawn dims 80x24 const wide = createMockWs(); const narrow = createMockWs(); - attachWs(s, wide, { cols: 200, rows: 50 }); - // one client at 200x50 โ†’ PTY grows to 200x50 + // Attach alone does NOT vote on size (a join could be a hidden mirror). + attachWs(s, wide); + expect(s.pty.cols).toBe(80); + + // A client only constrains the PTY once it reports dims (it's viewing). + setClientDims(s, wide, 200, 50); expect(s.pty.cols).toBe(200); expect(s.pty.rows).toBe(50); - attachWs(s, narrow, { cols: 90, rows: 30 }); + attachWs(s, narrow); + setClientDims(s, narrow, 90, 30); // min(200,90)=90, min(50,30)=30 expect(s.pty.cols).toBe(90); expect(s.pty.rows).toBe(30); }); + + it('a hidden mirror (no dims reported) does NOT clamp the shared PTY', () => { + const s = newSession(); + const viewer = createMockWs(); + const hidden = createMockWs(); + + attachWs(s, viewer); + setClientDims(s, viewer, 200, 50); + attachWs(s, hidden); // joins but never reports dims (background tab) + + // The hidden mirror must not drag the PTY down to the 80x24 default. + expect(s.pty.cols).toBe(200); + expect(s.pty.rows).toBe(50); + }); + + it('clearClientDims withdraws a vote (tab hidden) and lets the PTY grow', () => { + const s = newSession(); + const wide = createMockWs(); + const narrow = createMockWs(); + attachWs(s, wide); + setClientDims(s, wide, 200, 50); + attachWs(s, narrow); + setClientDims(s, narrow, 90, 30); // clamps to 90x30 + + clearClientDims(s, narrow); // narrow's tab hidden โ†’ withdraw vote + expect(s.pty.cols).toBe(200); + expect(s.pty.rows).toBe(50); + }); }); // โ”€โ”€ detachWs (remove one client) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -232,8 +264,8 @@ describe('detachWs', () => { const s = newSession(); const a = createMockWs(); const b = createMockWs(); - attachWs(s, a, DIMS); - attachWs(s, b, DIMS); + attachWs(s, a); + attachWs(s, b); detachWs(s, a, 5_000); // one client remains โ†’ still "attached", no detach stamp @@ -252,8 +284,10 @@ describe('detachWs', () => { const s = newSession(); const wide = createMockWs(); const narrow = createMockWs(); - attachWs(s, wide, { cols: 200, rows: 50 }); - attachWs(s, narrow, { cols: 90, rows: 30 }); // clamps to 90x30 + attachWs(s, wide); + setClientDims(s, wide, 200, 50); + attachWs(s, narrow); + setClientDims(s, narrow, 90, 30); // clamps to 90x30 detachWs(s, narrow, 5_000); // only the wide client remains โ†’ PTY expands back to 200x50 @@ -264,7 +298,7 @@ describe('detachWs', () => { it('keeps the PTY alive after the last detach: further onData still fills the buffer', () => { const s = newSession(); const ws = createMockWs(); - attachWs(s, ws, DIMS); + attachWs(s, ws); detachWs(s, ws, 5_000); (s.pty as MockIPty).emitData('background work'); @@ -283,8 +317,8 @@ describe('onExit', () => { const a = createMockWs(); const b = createMockWs(); - attachWs(s, a, DIMS); - attachWs(s, b, DIMS); + attachWs(s, a); + attachWs(s, b); a.sent.length = 0; b.sent.length = 0; @@ -303,7 +337,7 @@ describe('onExit', () => { const s = createSession(CFG, DIMS, 1_000, onExit); const ws = createMockWs(); - attachWs(s, ws, DIMS); + attachWs(s, ws); detachWs(s, ws, 5_000); ws.sent.length = 0; @@ -319,7 +353,7 @@ describe('onExit', () => { nextPty = createMockPty(); const s = createSession(CFG, DIMS, 1_000, () => {}); const ws = createMockWs(WS_OPEN); - attachWs(s, ws, DIMS); + attachWs(s, ws); ws.sent.length = 0; ws.readyState = 3; // CLOSED @@ -340,7 +374,7 @@ describe('writeInput / setClientDims', () => { it('setClientDims forwards to pty.resize while alive', () => { const s = newSession(); const ws = createMockWs(); - attachWs(s, ws, DIMS); + attachWs(s, ws); setClientDims(s, ws, 100, 40); expect((s.pty as MockIPty).resizes).toContainEqual({ cols: 100, rows: 40 }); }); @@ -348,7 +382,7 @@ describe('writeInput / setClientDims', () => { it('setClientDims is idempotent: same min cols/rows are skipped', () => { const s = newSession(); const ws = createMockWs(); - attachWs(s, ws, DIMS); // 80x24, equals spawn dims โ†’ no resize yet + attachWs(s, ws); // 80x24, equals spawn dims โ†’ no resize yet expect((s.pty as MockIPty).resizes).toHaveLength(0); setClientDims(s, ws, 90, 24); // changed โ†’ applied @@ -367,7 +401,7 @@ describe('writeInput / setClientDims', () => { it('ignores setClientDims once the PTY has exited (L4)', () => { const s = newSession(); const ws = createMockWs(); - attachWs(s, ws, DIMS); + attachWs(s, ws); (s.pty as MockIPty).emitExit(0); setClientDims(s, ws, 200, 50);