From 114dc5bf3572fecb4c98c614510b94b4e9e2dce3 Mon Sep 17 00:00:00 2001 From: jedarden Date: Thu, 2 Jul 2026 09:47:11 -0400 Subject: [PATCH] feat(tmux-detector): implement automatic poller with @tb- prefix opt-in Implements the tmux detector poller module based on the design in docs/notes/tmux-detector-design.md. Features: - Auto-discovers opted-in panes via @tb- prefix in window title - Polls every 2s (configurable via TRAILBOSS_POLL_INTERVAL_MS) - Detects stuck sessions: quiet for 30s with prompt-like last line - Detects unstuck: any output change after being stuck - Emits normalized events to /event/normalized endpoint - Synthetic session IDs: tmux-%446-{timestamp} - Graceful shutdown with SIGINT/SIGTERM handling Configuration: - TRAILBOSS_POLL_INTERVAL_MS (default: 2000) - TRAILBOSS_QUIET_THRESHOLD_MS (default: 30000) - TRAILBOSS_OPT_IN_PREFIX (default: @tb-) - TRAILBOSS_DAEMON_URL (default: http://127.0.0.1:4000/event/normalized) Usage: bun run daemon/tmux-detector.ts Co-Authored-By: Claude --- daemon/tmux-detector.ts | 447 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 447 insertions(+) create mode 100755 daemon/tmux-detector.ts diff --git a/daemon/tmux-detector.ts b/daemon/tmux-detector.ts new file mode 100755 index 0000000..3e90a69 --- /dev/null +++ b/daemon/tmux-detector.ts @@ -0,0 +1,447 @@ +#!/usr/bin/env bun +/** + * Trail Boss Tmux Detector Poller + * + * A harness-agnostic fallback for detecting stuck coding sessions by watching + * opted-in tmux panes. Emits normalized events to the daemon's /event/normalized endpoint. + * + * Opt-in: Panes must include "@tb-" prefix in their title (e.g., "@tb-feature-x") + * + * Usage: bun run daemon/tmux-detector.ts + */ + +import { execSync } from "node:child_process"; + +// ============================================================================ +// Configuration +// ============================================================================ + +const POLL_INTERVAL_MS = parseInt(process.env.TRAILBOSS_POLL_INTERVAL_MS || "") || 2000; +const QUIET_THRESHOLD_MS = parseInt(process.env.TRAILBOSS_QUIET_THRESHOLD_MS || "") || 30000; +const OPT_IN_PREFIX = process.env.TRAILBOSS_OPT_IN_PREFIX || "@tb-"; +const DAEMON_URL = process.env.TRAILBOSS_DAEMON_URL || "http://127.0.0.1:4000/event/normalized"; + +// ============================================================================ +// Types +// ============================================================================ + +interface PaneState { + paneId: string; + sessionId: string; + cwd: string; + lastOutputHash: string; + lastOutputTime: number; + isStuck: boolean; + firstSeenAt: number; +} + +interface NormalizedEvent { + type: "stuck" | "unstuck" | "registered" | "ended"; + sessionId?: string; + paneId?: string; + cwd?: string; + transcriptPath?: string; + reason?: string; + message?: string; + timestamp: number; +} + +// ============================================================================ +// Prompt Patterns +// ============================================================================ + +const PROMPT_PATTERNS = [ + /\$\s*$/, // bash/zsh $ + />\s*$/, // many shells > + /#\s*$/, // root # + /\?\s*$/, // confirmation prompts + /\[.*?\]\s*$/, // bracketed prompts like [y/N] + /:\s*$/, // colon prompts + />>>\s*$/, // Python REPL + /\.\.\.\s*$/, // Python continuation + />\s*\>/, // MySQL prompt + /@/, // Augie/other shells +]; + +// ============================================================================ +// State +// ============================================================================ + +const trackedPanes = new Map(); +let isRunning = true; + +// ============================================================================ +// Logging +// ============================================================================ + +function log(message: string): void { + const timestamp = new Date().toISOString(); + console.log(`[${timestamp}] ${message}`); +} + +// ============================================================================ +// Tmux Utilities +// ============================================================================ + +/** + * Discover all panes that have opted in (title starts with @tb-) + */ +function discoverOptedInPanes(): Set { + const result = new Set(); + + try { + const cmd = `tmux list-panes -a -F '#{pane_id} #{pane_title}'`; + const output = execSync(cmd, { stdio: ["ignore", "pipe", "ignore"] }) + .toString() + .trim(); + const lines = output.split("\n"); + + for (const line of lines) { + if (!line) continue; + const parts = line.split(" "); + if (parts.length < 2) continue; + + const paneId = parts[0]; + const title = parts.slice(1).join(" "); // Rejoin in case title has spaces + + if (title.startsWith(OPT_IN_PREFIX)) { + result.add(paneId); + } + } + } catch (err) { + log(`[tmux] Failed to list panes: ${err}`); + } + + return result; +} + +/** + * Capture the visible output of a pane + */ +function capturePane(paneId: string): string { + try { + const cmd = `tmux capture-pane -p -t ${paneId}`; + return execSync(cmd, { stdio: ["ignore", "pipe", "ignore"] }) + .toString() + .trim(); + } catch (err) { + log(`[tmux] Failed to capture pane ${paneId}: ${err}`); + return ""; + } +} + +/** + * Check if a pane still exists + */ +function paneExists(paneId: string): boolean { + try { + const cmd = `tmux display -p -t ${paneId} '#{pane_id}'`; + const output = execSync(cmd, { stdio: ["ignore", "pipe", "ignore"] }) + .toString() + .trim(); + return output === paneId; + } catch { + return false; + } +} + +/** + * Get the current working directory of a pane + */ +function getPaneCwd(paneId: string): string { + try { + const cmd = `tmux display -p -t ${paneId} '#{pane_current_path}'`; + return execSync(cmd, { stdio: ["ignore", "pipe", "ignore"] }) + .toString() + .trim(); + } catch { + return ""; + } +} + +// ============================================================================ +// Output Analysis +// ============================================================================ + +/** + * Hash output for comparison (avoid storing full content) + */ +function hashOutput(output: string): string { + if (!output) return ""; + const lines = output.split("\n"); + const firstLine = lines[0] || ""; + const lastLine = lines[lines.length - 1] || ""; + return `${firstLine.length}-${lastLine.length}-${lines.length}-${firstLine.slice(0, 10)}-${lastLine.slice(-10)}`; +} + +/** + * Check if the last line looks like a prompt + */ +function looksLikePrompt(output: string): boolean { + const lines = output.split("\n"); + const lastLine = lines[lines.length - 1]?.trim() || ""; + + for (const pattern of PROMPT_PATTERNS) { + if (pattern.test(lastLine)) { + return true; + } + } + + return false; +} + +/** + * Get the last line for context + */ +function getLastLine(output: string): string { + const lines = output.split("\n"); + return lines[lines.length - 1]?.trim() || "[no output]"; +} + +// ============================================================================ +// Event Emission +// ============================================================================ + +/** + * Emit a normalized event to the daemon + */ +async function emitEvent(event: NormalizedEvent): Promise { + try { + const response = await fetch(DAEMON_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(event), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: "Unknown error" })); + log(`[emit] Daemon error: ${response.status} - ${error.error || response.statusText}`); + return false; + } + + return true; + } catch (err) { + log(`[emit] Network error: ${err}`); + return false; + } +} + +// ============================================================================ +// Pane Lifecycle +// ============================================================================ + +/** + * Register a new pane for monitoring + */ +function registerPane(paneId: string): void { + const cwd = getPaneCwd(paneId); + const sessionId = `tmux-${paneId}-${Date.now()}`; + const output = capturePane(paneId); + const hash = hashOutput(output); + + trackedPanes.set(paneId, { + paneId, + sessionId, + cwd, + lastOutputHash: hash, + lastOutputTime: Date.now(), + isStuck: false, + firstSeenAt: Date.now(), + }); + + // Emit registration event + emitEvent({ + type: "registered", + sessionId, + paneId, + cwd, + transcriptPath: "", // No transcript for synthetic sessions + timestamp: Date.now(), + }); + + log(`[detector] registered pane ${paneId} as ${sessionId} (cwd: ${cwd})`); +} + +/** + * Untrack a pane (closed or removed prefix) + */ +function untrackPane(paneId: string): void { + const state = trackedPanes.get(paneId); + if (!state) return; + + emitEvent({ + type: "ended", + sessionId: state.sessionId, + timestamp: Date.now(), + }); + + trackedPanes.delete(paneId); + log(`[detector] untracked pane ${paneId} (${state.sessionId})`); +} + +/** + * Check a single pane for stuck state + */ +function checkPane(paneId: string, state: PaneState): void { + const now = Date.now(); + + // 1. Verify pane still exists + if (!paneExists(paneId)) { + untrackPane(paneId); + return; + } + + // 2. Capture output + const output = capturePane(paneId); + const hash = hashOutput(output); + const outputChanged = hash !== state.lastOutputHash; + + // 3. Handle output change + if (outputChanged) { + state.lastOutputHash = hash; + state.lastOutputTime = now; + + // If was stuck, now unstuck + if (state.isStuck) { + state.isStuck = false; + emitEvent({ + type: "unstuck", + sessionId: state.sessionId, + timestamp: now, + }); + log(`[detector] unstuck: ${state.sessionId} (output changed)`); + } + + return; // Not stuck, continue monitoring + } + + // 4. Output hasn't changed — check quiet threshold + const timeSinceOutput = now - state.lastOutputTime; + const isQuiet = timeSinceOutput >= QUIET_THRESHOLD_MS; + + if (isQuiet && !state.isStuck) { + // Quiet threshold exceeded — check if looks like prompt + if (looksLikePrompt(output)) { + // Transition to stuck + state.isStuck = true; + emitEvent({ + type: "stuck", + sessionId: state.sessionId, + paneId: state.paneId, + cwd: state.cwd, + transcriptPath: "", // No transcript for synthetic sessions + reason: "stopped", // Tmux detector can't distinguish permission vs stopped + message: getLastLine(output), + timestamp: now, + }); + log(`[detector] stuck: ${state.sessionId} (quiet for ${timeSinceOutput}ms, last line: "${getLastLine(output)}")`); + } + } +} + +// ============================================================================ +// Main Poll Loop +// ============================================================================ + +async function pollLoop(): Promise { + log(`[detector] starting poll loop (interval: ${POLL_INTERVAL_MS}ms, quiet threshold: ${QUIET_THRESHOLD_MS}ms)`); + + while (isRunning) { + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + + if (!isRunning) break; + + try { + // 1. Discover opted-in panes + const optedInPanes = discoverOptedInPanes(); + + // 2. Register new panes + for (const paneId of optedInPanes) { + if (!trackedPanes.has(paneId)) { + registerPane(paneId); + } + } + + // 3. Untrack removed panes + for (const paneId of trackedPanes.keys()) { + if (!optedInPanes.has(paneId)) { + untrackPane(paneId); + } + } + + // 4. Check each tracked pane + for (const [paneId, state] of trackedPanes) { + checkPane(paneId, state); + } + + // Log status if we have tracked panes + if (trackedPanes.size > 0) { + const stuckCount = Array.from(trackedPanes.values()).filter((s) => s.isStuck).length; + log(`[detector] poll: ${trackedPanes.size} panes tracked, ${stuckCount} stuck`); + } + } catch (err) { + log(`[detector] poll error: ${err}`); + } + } + + log("[detector] poll loop stopped"); +} + +// ============================================================================ +// Graceful Shutdown +// ============================================================================ + +/** + * Stop the detector gracefully + */ +export function stop(): void { + log("[detector] shutting down..."); + isRunning = false; + + // Emit ended events for all tracked panes + for (const [paneId, state] of trackedPanes) { + emitEvent({ + type: "ended", + sessionId: state.sessionId, + timestamp: Date.now(), + }); + } + + trackedPanes.clear(); +} + +// ============================================================================ +// Signal Handlers +// ============================================================================ + +process.on("SIGINT", () => { + log("[detector] received SIGINT"); + stop(); + process.exit(0); +}); + +process.on("SIGTERM", () => { + log("[detector] received SIGTERM"); + stop(); + process.exit(0); +}); + +// ============================================================================ +// Main Entry Point +// ============================================================================ + +async function main(): Promise { + log("[detector] Trail Boss Tmux Detector Poller starting..."); + log(`[detector] daemon URL: ${DAEMON_URL}`); + log(`[detector] opt-in prefix: ${OPT_IN_PREFIX}`); + + await pollLoop(); +} + +// Run if executed directly +if (import.meta.main) { + main().catch((err) => { + log(`[detector] fatal error: ${err}`); + process.exit(1); + }); +}