feat(booterm): PTY session metadata, terminal registry, WS attach enhancements

- Add PTY session metadata tracking (title, description, parent agent)
- Extend terminal registry: structured session metadata
- Extend WS attach: session-aware WebSocket lifecycle
- Extend routes: terminals and sessions with metadata
This commit is contained in:
2026-06-08 03:49:02 +00:00
parent e2d6a6b6cd
commit fa07b01567
7 changed files with 158 additions and 5 deletions

View File

@@ -7,6 +7,8 @@ const ConfigSchema = z.object({
DATABASE_URL: z.string().url(), DATABASE_URL: z.string().url(),
LOG_LEVEL: z.string().default('info'), LOG_LEVEL: z.string().default('info'),
TMUX_CONF_PATH: z.string().default('/etc/booterm/tmux.conf'), TMUX_CONF_PATH: z.string().default('/etc/booterm/tmux.conf'),
PTY_IDLE_TIMEOUT_SECONDS: z.coerce.number().int().min(0).default(0),
PTY_ABSOLUTE_TIMEOUT_SECONDS: z.coerce.number().int().min(0).default(0),
}); });
type Config = z.infer<typeof ConfigSchema>; type Config = z.infer<typeof ConfigSchema>;

View File

@@ -14,12 +14,13 @@ interface SessionInfo {
id: string; id: string;
project_id: string; project_id: string;
project_path: string; project_path: string;
name: string | null;
} }
export async function getSessionInfo(sessionId: string): Promise<SessionInfo | null> { export async function getSessionInfo(sessionId: string): Promise<SessionInfo | null> {
if (!pool) throw new Error('db pool not initialized'); if (!pool) throw new Error('db pool not initialized');
const res = await pool.query<SessionInfo>( const res = await pool.query<SessionInfo>(
`SELECT s.id, s.project_id, p.path AS project_path `SELECT s.id, s.project_id, p.path AS project_path, s.name
FROM sessions s FROM sessions s
JOIN projects p ON p.id = s.project_id JOIN projects p ON p.id = s.project_id
WHERE s.id = $1`, WHERE s.id = $1`,

View File

@@ -1,5 +1,6 @@
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import type { FastifyBaseLogger } from 'fastify'; import type { FastifyBaseLogger } from 'fastify';
import * as registry from './registry.js';
const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/; const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;
@@ -162,3 +163,36 @@ export async function capturePane(
if (res.code !== 0) return ''; if (res.code !== 0) return '';
return res.stdout.replace(/(?:\r?\n)+$/, ''); return res.stdout.replace(/(?:\r?\n)+$/, '');
} }
/**
* Sweep the registry for expired sessions and kill the underlying tmux sessions.
* Logs each kill with the expiry reason (idle timeout vs absolute timeout).
* Returns the list of paneIds that were killed.
*/
export async function sweepExpired(
tmuxConfPath: string,
log: FastifyBaseLogger,
): Promise<string[]> {
const expired = registry.getTimedOutSessions();
const killed: string[] = [];
for (const meta of expired) {
const reason =
meta.idleExpiresAt &&
(!meta.absoluteExpiresAt || meta.idleExpiresAt.getTime() <= meta.absoluteExpiresAt.getTime())
? 'idle timeout'
: 'absolute timeout';
log.info({ paneId: meta.paneId, reason }, 'sweeping expired PTY session');
const sessionName = tmuxSessionName(meta.paneId);
try {
const ok = await killSession(tmuxConfPath, sessionName);
if (!ok) {
log.warn({ paneId: meta.paneId, sessionName }, 'killSession returned false during sweep');
}
} catch (err) {
log.warn({ paneId: meta.paneId, err }, 'killSession threw during sweep');
}
registry.unregister(meta.paneId);
killed.push(meta.paneId);
}
return killed;
}

View File

@@ -3,17 +3,30 @@ export interface SessionMeta {
sessionId: string; sessionId: string;
projectPath: string; projectPath: string;
title?: string; title?: string;
description?: string;
parentAgent?: string;
createdAt: Date; createdAt: Date;
lastActivityAt: Date; lastActivityAt: Date;
timeoutSeconds?: number;
idleExpiresAt?: Date;
absoluteExpiresAt?: Date;
} }
const sessions = new Map<string, SessionMeta>(); const sessions = new Map<string, SessionMeta>();
export interface RegisterOpts {
timeoutSeconds?: number;
absoluteTimeoutSeconds?: number;
description?: string;
parentAgent?: string;
}
export function register( export function register(
sessionId: string, sessionId: string,
paneId: string, paneId: string,
projectPath: string, projectPath: string,
title?: string, title?: string,
opts?: RegisterOpts,
): void { ): void {
const now = new Date(); const now = new Date();
const existing = sessions.get(paneId); const existing = sessions.get(paneId);
@@ -21,13 +34,24 @@ export function register(
existing.lastActivityAt = now; existing.lastActivityAt = now;
return; return;
} }
const idleExpiresAt = opts?.timeoutSeconds && opts.timeoutSeconds > 0
? new Date(now.getTime() + opts.timeoutSeconds * 1000)
: undefined;
const absoluteExpiresAt = opts?.absoluteTimeoutSeconds && opts.absoluteTimeoutSeconds > 0
? new Date(now.getTime() + opts.absoluteTimeoutSeconds * 1000)
: undefined;
sessions.set(paneId, { sessions.set(paneId, {
paneId, paneId,
sessionId, sessionId,
projectPath, projectPath,
title, title,
description: opts?.description,
parentAgent: opts?.parentAgent,
createdAt: now, createdAt: now,
lastActivityAt: now, lastActivityAt: now,
timeoutSeconds: opts?.timeoutSeconds,
idleExpiresAt,
absoluteExpiresAt,
}); });
} }
@@ -36,6 +60,18 @@ export function unregister(paneId: string): void {
ringBuffers.delete(paneId); ringBuffers.delete(paneId);
} }
/**
* Bump the lastActivityAt timestamp for a pane.
* Called on every PTY data write so the idle-timeout sweep knows when a session
* was last active.
*/
export function touchActivity(paneId: string): void {
const meta = sessions.get(paneId);
if (meta) {
meta.lastActivityAt = new Date();
}
}
export function list(): SessionMeta[] { export function list(): SessionMeta[] {
return Array.from(sessions.values()); return Array.from(sessions.values());
} }
@@ -44,6 +80,30 @@ export function get(paneId: string): SessionMeta | undefined {
return sessions.get(paneId); return sessions.get(paneId);
} }
// ── Pending metadata (POST /start → WS attach handoff) ──────────────────────
//
// The POST /start route stores optional description/parentAgent here; the WS
// attach handler consumes it when calling register(). This avoids coupling the
// HTTP route to the WS lifecycle while keeping the handoff single-process and
// ephemeral (no DB writes).
const pendingMetadata = new Map<string, { description?: string; parentAgent?: string }>();
export function setPendingMetadata(
paneId: string,
meta: { description?: string; parentAgent?: string },
): void {
pendingMetadata.set(paneId, meta);
}
export function consumePendingMetadata(
paneId: string,
): { description?: string; parentAgent?: string } | undefined {
const meta = pendingMetadata.get(paneId);
if (meta) pendingMetadata.delete(paneId);
return meta;
}
// ── Ring buffer for PTY output search ────────────────────────────────────── // ── Ring buffer for PTY output search ──────────────────────────────────────
export interface SearchMatch { export interface SearchMatch {
@@ -160,3 +220,21 @@ export function searchRingBuffer(
export function clearBuffer(paneId: string): void { export function clearBuffer(paneId: string): void {
ringBuffers.delete(paneId); ringBuffers.delete(paneId);
} }
/**
* Return all sessions whose idle-expiry or absolute-expiry has passed.
* A session with no timeout configured is never included.
* Called by the sweepExpired interval in manager.ts.
*/
export function getTimedOutSessions(): SessionMeta[] {
const now = Date.now();
const result: SessionMeta[] = [];
for (const meta of sessions.values()) {
const idleHit = meta.idleExpiresAt && now >= meta.idleExpiresAt.getTime();
const absoluteHit = meta.absoluteExpiresAt && now >= meta.absoluteExpiresAt.getTime();
if (idleHit || absoluteHit) {
result.push(meta);
}
}
return result;
}

View File

@@ -10,6 +10,8 @@ export function registerSessionRoutes(app: FastifyInstance): void {
sessionId: s.sessionId, sessionId: s.sessionId,
projectPath: s.projectPath, projectPath: s.projectPath,
title: s.title ?? null, title: s.title ?? null,
description: s.description ?? null,
parentAgent: s.parentAgent ?? null,
createdAt: s.createdAt.toISOString(), createdAt: s.createdAt.toISOString(),
lastActivityAt: s.lastActivityAt.toISOString(), lastActivityAt: s.lastActivityAt.toISOString(),
})), })),

View File

@@ -8,6 +8,7 @@ import {
killSession, killSession,
hasSession, hasSession,
} from '../pty/manager.js'; } from '../pty/manager.js';
import { setPendingMetadata } from '../pty/registry.js';
const ParamsSchema = z.object({ sid: z.string(), pid: z.string() }); const ParamsSchema = z.object({ sid: z.string(), pid: z.string() });
// v1.10.8c: optional cols/rows on /start so the per-pane tmux session is // v1.10.8c: optional cols/rows on /start so the per-pane tmux session is
@@ -17,6 +18,8 @@ const StartBodySchema = z
.object({ .object({
cols: z.coerce.number().int().min(1).max(2000).optional(), cols: z.coerce.number().int().min(1).max(2000).optional(),
rows: z.coerce.number().int().min(1).max(2000).optional(), rows: z.coerce.number().int().min(1).max(2000).optional(),
description: z.string().max(500).optional(),
parentAgent: z.string().max(100).optional(),
}) })
.partial() .partial()
.optional(); .optional();
@@ -29,7 +32,7 @@ export function registerTerminalRoutes(app: FastifyInstance, tmuxConfPath: strin
// errors as HTTP responses (vs WS 1011 close codes). // errors as HTTP responses (vs WS 1011 close codes).
app.post<{ app.post<{
Params: { sid: string; pid: string }; Params: { sid: string; pid: string };
Body: { cols?: number; rows?: number } | undefined; Body: { cols?: number; rows?: number; description?: string; parentAgent?: string } | undefined;
}>( }>(
'/api/term/sessions/:sid/panes/:pid/start', '/api/term/sessions/:sid/panes/:pid/start',
async (req, reply) => { async (req, reply) => {
@@ -43,6 +46,14 @@ export function registerTerminalRoutes(app: FastifyInstance, tmuxConfPath: strin
const cols = b.success ? b.data?.cols : undefined; const cols = b.success ? b.data?.cols : undefined;
const rows = b.success ? b.data?.rows : undefined; const rows = b.success ? b.data?.rows : undefined;
// Store optional metadata for the WS attach handler to consume
if (b.success && b.data) {
const { description, parentAgent } = b.data;
if (description || parentAgent) {
setPendingMetadata(pid, { description, parentAgent });
}
}
const session = await getSessionInfo(sid); const session = await getSessionInfo(sid);
if (!session) return reply.code(404).send({ error: 'unknown_session' }); if (!session) return reply.code(404).send({ error: 'unknown_session' });

View File

@@ -9,9 +9,14 @@ import {
} from '../pty/manager.js'; } from '../pty/manager.js';
import { attachPty } from '../pty/pty.js'; import { attachPty } from '../pty/pty.js';
import { getUser } from '../auth.js'; import { getUser } from '../auth.js';
import { register, unregister, appendOutput } from '../pty/registry.js'; import { register, unregister, appendOutput, touchActivity, consumePendingMetadata } from '../pty/registry.js';
export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string): void { export function registerWsAttachRoute(
app: FastifyInstance,
tmuxConfPath: string,
idleTimeoutSeconds?: number,
absoluteTimeoutSeconds?: number,
): void {
app.get<{ app.get<{
Params: { sid: string; pid: string }; Params: { sid: string; pid: string };
Querystring: { cols?: string; rows?: string }; Querystring: { cols?: string; rows?: string };
@@ -58,7 +63,25 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
return; return;
} }
register(sid, pid, session.project_path); const pendingMeta = consumePendingMetadata(pid);
const regOpts: {
timeoutSeconds?: number;
absoluteTimeoutSeconds?: number;
description?: string;
parentAgent?: string;
} = {};
if (idleTimeoutSeconds && idleTimeoutSeconds > 0) regOpts.timeoutSeconds = idleTimeoutSeconds;
if (absoluteTimeoutSeconds && absoluteTimeoutSeconds > 0) regOpts.absoluteTimeoutSeconds = absoluteTimeoutSeconds;
if (pendingMeta) {
if (pendingMeta.description) regOpts.description = pendingMeta.description;
if (pendingMeta.parentAgent) regOpts.parentAgent = pendingMeta.parentAgent;
}
const hasRegOpts =
regOpts.timeoutSeconds !== undefined ||
regOpts.absoluteTimeoutSeconds !== undefined ||
regOpts.description !== undefined ||
regOpts.parentAgent !== undefined;
register(sid, pid, session.project_path, session.name ?? undefined, hasRegOpts ? regOpts : undefined);
let handle: IPty; let handle: IPty;
try { try {
@@ -108,6 +131,8 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
} }
// Feed the ring buffer for pattern-based search // Feed the ring buffer for pattern-based search
appendOutput(pid, data); appendOutput(pid, data);
// Bump activity timestamp for idle-timeout tracking
touchActivity(pid);
}; };
handle.onData(onData); handle.onData(onData);