fix(terminal): reconcile tabs with server sessions and keep shown terminals alive

The tab list lived only in per-tab sessionStorage, so a new browser
tab, another device, or cleared storage showed an empty terminal
sidebar while PTYs kept running server-side, and orphans leaked until
the idle sweep. Add GET /api/terminal/sessions and adopt unknown
server sessions into the local tab projection (additive only; a failed
listing changes nothing).

The idle sweep also reaped terminals in background tabs because only
the active tab holds a WebSocket attachment. Add POST
/api/terminal/touch and have open clients periodically refresh
activity for every session their tabs reference.
This commit is contained in:
Bohdan Triapitsyn
2026-08-24 14:30:00 +03:00
parent 0918bee566
commit 2f27f0ec4b
9 changed files with 249 additions and 2 deletions
+11
View File
@@ -80,8 +80,19 @@ export interface ForceKillOptions {
cwd?: string;
}
export interface TerminalServerSession {
sessionId: string;
cwd: string;
status: 'running' | 'exited';
createdAt: number | null;
}
export interface TerminalAPI {
listShells?(): Promise<TerminalShellOption[]>;
/** Server-side sessions for a working directory; absent on runtimes without a server terminal list. */
listSessions?(cwd: string): Promise<TerminalServerSession[]>;
/** Marks the sessions as active so the server's idle sweep does not reap terminals an open client still shows. */
touchSessions?(sessionIds: string[]): Promise<void>;
createSession(options: CreateTerminalOptions): Promise<TerminalSession>;
connect(sessionId: string, handlers: TerminalHandlers): Subscription;
sendInput(sessionId: string, input: string): Promise<void>;
+27 -1
View File
@@ -1,4 +1,4 @@
import type { CreateTerminalOptions, TerminalError, TerminalHandlers, TerminalSession, TerminalShellOption, TerminalStreamEvent } from './api/types';
import type { CreateTerminalOptions, TerminalError, TerminalHandlers, TerminalServerSession, TerminalSession, TerminalShellOption, TerminalStreamEvent } from './api/types';
import { openRuntimeWebSocket } from './relay/runtime-socket';
import type { RelayTunnelWebSocket } from './relay/tunnel-client';
import { runtimeFetch } from './runtime-fetch';
@@ -356,6 +356,32 @@ export async function createTerminalSession(options: CreateTerminalOptions): Pro
if (!response.ok) throw await responseError(response, 'Failed to create terminal session');
return response.json() as Promise<TerminalSession>;
}
export async function listTerminalSessions(cwd: string): Promise<TerminalServerSession[]> {
const response = await runtimeFetch(`/api/terminal/sessions?cwd=${encodeURIComponent(cwd)}`);
if (!response.ok) throw await responseError(response, 'Failed to list terminal sessions');
const payload: unknown = await response.json().catch(() => null);
const rawSessions = payload && typeof payload === 'object' && 'sessions' in payload ? payload.sessions : null;
if (!Array.isArray(rawSessions)) throw new Error('Failed to list terminal sessions');
const parsed: TerminalServerSession[] = [];
for (const entry of rawSessions as unknown[]) {
if (typeof entry !== 'object' || entry === null) continue;
// SAFETY: every field is verified below before the value is used.
const candidate = entry as Partial<Record<keyof TerminalServerSession, unknown>>;
if (typeof candidate.sessionId !== 'string' || typeof candidate.cwd !== 'string') continue;
if (candidate.status !== 'running' && candidate.status !== 'exited') continue;
parsed.push({
sessionId: candidate.sessionId,
cwd: candidate.cwd,
status: candidate.status,
createdAt: typeof candidate.createdAt === 'number' ? candidate.createdAt : null,
});
}
return parsed;
}
export async function touchTerminalSessions(sessionIds: string[]): Promise<void> {
if (sessionIds.length === 0) return;
await command('/api/terminal/touch', 'POST', { sessionIds });
}
export async function listTerminalShells(): Promise<TerminalShellOption[]> {
const response = await runtimeFetch('/api/terminal/shells');
if (!response.ok) throw await responseError(response, 'Failed to list terminal shells');