fix(v0.4): mirror size-clamp bug + session manager page
The mirror looked broken because a backgrounded tab on one device still pinned the shared PTY to its (default 80x24) size — so the device actively viewing got a cramped terminal. Fix: only an ACTIVELY-VIEWING client votes on PTY size. - attachWs no longer seeds a default size vote (a join may be a hidden mirror) - new 'blur' client message + clearClientDims(): a tab going hidden withdraws its size vote (still mirrors output); show() re-casts it - PTY size = min cols/rows across clients that have actually reported dims - session/protocol tests cover hidden-mirror-doesn't-clamp + blur Session manager (separate page, for the 'too many sessions' problem): - GET stays; add DELETE /live-sessions/:id and DELETE /live-sessions[?detached=1] - manager.killById(); LiveSessionInfo gains cols/rows - public/manage.html + manage.ts: list/open/kill sessions, kill-all / kill-detached, auto-refresh; 🗂 toolbar button; bundled as a 2nd esbuild entry Verified: two concurrent clients mirror output + shared input; manage page lists/kills (3→2→0). 225 tests green, tsc clean.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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).
|
||||
|
||||
14
public/manage.html
Normal file
14
public/manage.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>Session Manager — Web Terminal</title>
|
||||
<meta name="theme-color" content="#0e0f13">
|
||||
<link rel="stylesheet" href="./style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="manage-root"></div>
|
||||
<script type="module" src="./build/manage.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
144
public/manage.ts
Normal file
144
public/manage.ts
Normal file
@@ -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=<id>), 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<LiveSession[]> {
|
||||
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<K extends keyof HTMLElementTagNameMap>(
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
141
public/style.css
141
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;
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -24,7 +24,7 @@ export const SESSION_ID_RE =
|
||||
|
||||
// ─── Type whitelist ───────────────────────────────────────────────────────────
|
||||
|
||||
const ALLOWED_TYPES = new Set<string>(['attach', 'input', 'resize', 'approve', 'reject'])
|
||||
const ALLOWED_TYPES = new Set<string>(['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' } }
|
||||
|
||||
@@ -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<void> } {
|
||||
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<void> } {
|
||||
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'))
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
13
src/types.ts
13
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,
|
||||
|
||||
@@ -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 ───────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user