diff --git a/apps/booterm/src/config.ts b/apps/booterm/src/config.ts index b32c321..c153d3f 100644 --- a/apps/booterm/src/config.ts +++ b/apps/booterm/src/config.ts @@ -7,6 +7,8 @@ const ConfigSchema = z.object({ DATABASE_URL: z.string().url(), LOG_LEVEL: z.string().default('info'), 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; diff --git a/apps/booterm/src/db.ts b/apps/booterm/src/db.ts index 4455825..b2c343e 100644 --- a/apps/booterm/src/db.ts +++ b/apps/booterm/src/db.ts @@ -14,12 +14,13 @@ interface SessionInfo { id: string; project_id: string; project_path: string; + name: string | null; } export async function getSessionInfo(sessionId: string): Promise { if (!pool) throw new Error('db pool not initialized'); const res = await pool.query( - `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 JOIN projects p ON p.id = s.project_id WHERE s.id = $1`, diff --git a/apps/booterm/src/pty/manager.ts b/apps/booterm/src/pty/manager.ts index dc148e4..bc5be9b 100644 --- a/apps/booterm/src/pty/manager.ts +++ b/apps/booterm/src/pty/manager.ts @@ -1,5 +1,6 @@ import { spawn } from 'node:child_process'; import type { FastifyBaseLogger } from 'fastify'; +import * as registry from './registry.js'; const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/; @@ -162,3 +163,36 @@ export async function capturePane( if (res.code !== 0) return ''; 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 { + 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; +} diff --git a/apps/booterm/src/pty/registry.ts b/apps/booterm/src/pty/registry.ts index 1fc6df1..5a2b22a 100644 --- a/apps/booterm/src/pty/registry.ts +++ b/apps/booterm/src/pty/registry.ts @@ -3,17 +3,30 @@ export interface SessionMeta { sessionId: string; projectPath: string; title?: string; + description?: string; + parentAgent?: string; createdAt: Date; lastActivityAt: Date; + timeoutSeconds?: number; + idleExpiresAt?: Date; + absoluteExpiresAt?: Date; } const sessions = new Map(); +export interface RegisterOpts { + timeoutSeconds?: number; + absoluteTimeoutSeconds?: number; + description?: string; + parentAgent?: string; +} + export function register( sessionId: string, paneId: string, projectPath: string, title?: string, + opts?: RegisterOpts, ): void { const now = new Date(); const existing = sessions.get(paneId); @@ -21,13 +34,24 @@ export function register( existing.lastActivityAt = now; 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, { paneId, sessionId, projectPath, title, + description: opts?.description, + parentAgent: opts?.parentAgent, createdAt: now, lastActivityAt: now, + timeoutSeconds: opts?.timeoutSeconds, + idleExpiresAt, + absoluteExpiresAt, }); } @@ -36,6 +60,18 @@ export function unregister(paneId: string): void { 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[] { return Array.from(sessions.values()); } @@ -44,6 +80,30 @@ export function get(paneId: string): SessionMeta | undefined { 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(); + +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 ────────────────────────────────────── export interface SearchMatch { @@ -160,3 +220,21 @@ export function searchRingBuffer( export function clearBuffer(paneId: string): void { 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; +} diff --git a/apps/booterm/src/routes/sessions.ts b/apps/booterm/src/routes/sessions.ts index a53bd9d..3704c17 100644 --- a/apps/booterm/src/routes/sessions.ts +++ b/apps/booterm/src/routes/sessions.ts @@ -10,6 +10,8 @@ export function registerSessionRoutes(app: FastifyInstance): void { sessionId: s.sessionId, projectPath: s.projectPath, title: s.title ?? null, + description: s.description ?? null, + parentAgent: s.parentAgent ?? null, createdAt: s.createdAt.toISOString(), lastActivityAt: s.lastActivityAt.toISOString(), })), diff --git a/apps/booterm/src/routes/terminals.ts b/apps/booterm/src/routes/terminals.ts index 602fbeb..52b339d 100644 --- a/apps/booterm/src/routes/terminals.ts +++ b/apps/booterm/src/routes/terminals.ts @@ -8,6 +8,7 @@ import { killSession, hasSession, } from '../pty/manager.js'; +import { setPendingMetadata } from '../pty/registry.js'; 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 @@ -17,6 +18,8 @@ const StartBodySchema = z .object({ cols: 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() .optional(); @@ -29,7 +32,7 @@ export function registerTerminalRoutes(app: FastifyInstance, tmuxConfPath: strin // errors as HTTP responses (vs WS 1011 close codes). app.post<{ 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', async (req, reply) => { @@ -43,6 +46,14 @@ export function registerTerminalRoutes(app: FastifyInstance, tmuxConfPath: strin const cols = b.success ? b.data?.cols : 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); if (!session) return reply.code(404).send({ error: 'unknown_session' }); diff --git a/apps/booterm/src/ws/attach.ts b/apps/booterm/src/ws/attach.ts index 8ee1db2..6963257 100644 --- a/apps/booterm/src/ws/attach.ts +++ b/apps/booterm/src/ws/attach.ts @@ -9,9 +9,14 @@ import { } from '../pty/manager.js'; import { attachPty } from '../pty/pty.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<{ Params: { sid: string; pid: string }; Querystring: { cols?: string; rows?: string }; @@ -58,7 +63,25 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string 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; try { @@ -108,6 +131,8 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string } // Feed the ring buffer for pattern-based search appendOutput(pid, data); + // Bump activity timestamp for idle-timeout tracking + touchActivity(pid); }; handle.onData(onData);