diff --git a/examples/tmux/tmux_cleanup.heroscript b/examples/tmux/tmux_cleanup.heroscript new file mode 100644 index 00000000..9db0508f --- /dev/null +++ b/examples/tmux/tmux_cleanup.heroscript @@ -0,0 +1,44 @@ +#!/usr/bin/env hero + +// Tmux Cleanup Script - Tears down all tmux sessions, windows, and panes +// Run this after tmux_setup.heroscript to clean up everything + +// Kill specific windows first (optional - sessions will kill all windows anyway) +!!tmux.window_delete + name:"dev|editor" + +!!tmux.window_delete + name:"dev|server" + +!!tmux.window_delete + name:"dev|logs" + +!!tmux.window_delete + name:"dev|services" + +!!tmux.window_delete + name:"monitoring|htop" + +!!tmux.window_delete + name:"monitoring|network" + +// Delete all sessions (this will kill all windows and panes within them) +!!tmux.session_delete + name:'dev' + +!!tmux.session_delete + name:'monitoring' + +// Optional: Kill any remaining panes explicitly (usually not needed after session delete) +!!tmux.pane_kill + name:"dev|editor|main" + +!!tmux.pane_kill + name:"dev|server|main" + +!!tmux.pane_kill + name:"monitoring|htop|main" + +// Stop any remaining ttyd processes system-wide +// This will clean up all ttyd processes regardless of which sessions exist +!!tmux.ttyd_stop_all \ No newline at end of file diff --git a/examples/tmux/tmux_setup.heroscript b/examples/tmux/tmux_setup.heroscript new file mode 100644 index 00000000..75daea60 --- /dev/null +++ b/examples/tmux/tmux_setup.heroscript @@ -0,0 +1,105 @@ +// Create development session +!!tmux.session_create + name:'dev' + reset:true + +// Create monitoring session +!!tmux.session_create + name:'monitoring' + reset:true + +// Create development windows (use reset to ensure clean state) +!!tmux.window_create + name:"dev|editor" + cmd:'vim' + reset:true + +!!tmux.window_create + name:"dev|server" + cmd:'python3 -m http.server 8000' + env:'PORT=8000,DEBUG=true' + reset:true + +!!tmux.window_create + name:"dev|logs" + cmd:'tail -f /var/log/system.log' + reset:true + +// Create monitoring windows +!!tmux.window_create + name:"monitoring|htop" + cmd:'htop' + reset:true + +!!tmux.window_create + name:"monitoring|network" + cmd:'netstat -tuln' + reset:true + +// Create a multi-service window with 4 panes +!!tmux.window_create + name:"dev|services" + cmd:'htop' + reset:true + +// Split the services window into 4 panes (1 initial + 3 splits = 4 total) +!!tmux.pane_split + name:"dev|services" + cmd:'python3 -m http.server 3000' + horizontal:true + +!!tmux.pane_split + name:"dev|services" + cmd:'watch -n 1 ps aux' + horizontal:false + +!!tmux.pane_split + name:"dev|services" + cmd:'tail -f /var/log/system.log' + horizontal:true + +!!tmux.pane_split + name:"dev|services" + cmd:'echo Fourth pane ready for commands' + horizontal:false + +// Execute welcome commands in panes +!!tmux.pane_execute + name:"dev|editor|main" + cmd:'echo Welcome to the editor pane' + +!!tmux.pane_execute + name:"dev|server|main" + cmd:'echo Starting development server' + +!!tmux.pane_execute + name:"monitoring|htop|main" + cmd:'echo System monitoring active' + +// Split panes for better workflow +!!tmux.pane_split + name:"dev|editor" + cmd:'echo Split pane for terminal' + horizontal:false + +!!tmux.pane_split + name:"monitoring|htop" + cmd:'watch -n 1 df -h' + horizontal:true + +// Expose sessions and windows via web browser using ttyd +!!tmux.session_ttyd + name:'dev' + port:8080 + editable:true + +!!tmux.window_ttyd + name:"monitoring|htop" + port:8081 + editable:false + +// Expose the 4-pane services window via web +!!tmux.window_ttyd + name:"dev|services" + port:7681 + editable:false diff --git a/lib/core/playcmds/factory.v b/lib/core/playcmds/factory.v index 90fce124..9eb0ffc1 100644 --- a/lib/core/playcmds/factory.v +++ b/lib/core/playcmds/factory.v @@ -7,6 +7,7 @@ import freeflowuniverse.herolib.web.site import freeflowuniverse.herolib.web.docusaurus import freeflowuniverse.herolib.clients.openai import freeflowuniverse.herolib.clients.giteaclient +import freeflowuniverse.herolib.osal.tmux // ------------------------------------------------------------------- // run – entry point for all HeroScript play‑commands @@ -39,6 +40,9 @@ pub fn run(args_ PlayArgs) ! { // Git actions play_git(mut plbook)! + // Tmux actions + tmux.play(mut plbook)! + // Business model (e.g. currency, bizmodel) bizmodel.play(mut plbook)! diff --git a/lib/osal/tmux/play.v b/lib/osal/tmux/play.v index 79a4ed0e..e5bf0f18 100644 --- a/lib/osal/tmux/play.v +++ b/lib/osal/tmux/play.v @@ -23,7 +23,12 @@ pub fn play(mut plbook PlayBook) ! { play_window_delete(mut plbook, mut tmux_instance)! play_pane_execute(mut plbook, mut tmux_instance)! play_pane_kill(mut plbook, mut tmux_instance)! - // TODO: Implement pane_create, pane_delete, pane_split when pane API is extended + play_pane_split(mut plbook, mut tmux_instance)! + play_session_ttyd(mut plbook, mut tmux_instance)! + play_window_ttyd(mut plbook, mut tmux_instance)! + play_session_ttyd_stop(mut plbook, mut tmux_instance)! + play_window_ttyd_stop(mut plbook, mut tmux_instance)! + play_ttyd_stop_all(mut plbook, mut tmux_instance)! } struct ParsedWindowName { @@ -43,8 +48,8 @@ fn parse_window_name(name string) !ParsedWindowName { return error('Window name must be in format "session|window", got: ${name}') } return ParsedWindowName{ - session: texttools.name_fix(parts[0]) - window: texttools.name_fix(parts[1]) + session: texttools.name_fix_token(parts[0]) + window: texttools.name_fix_token(parts[1]) } } @@ -54,9 +59,9 @@ fn parse_pane_name(name string) !ParsedPaneName { return error('Pane name must be in format "session|window|pane", got: ${name}') } return ParsedPaneName{ - session: texttools.name_fix(parts[0]) - window: texttools.name_fix(parts[1]) - pane: texttools.name_fix(parts[2]) + session: texttools.name_fix_token(parts[0]) + window: texttools.name_fix_token(parts[1]) + pane: texttools.name_fix_token(parts[2]) } } @@ -197,3 +202,138 @@ fn play_pane_kill(mut plbook PlayBook, mut tmux_instance Tmux) ! { action.done = true } } + +fn play_pane_split(mut plbook PlayBook, mut tmux_instance Tmux) ! { + mut actions := plbook.find(filter: 'tmux.pane_split')! + for mut action in actions { + mut p := action.params + name := p.get('name')! + cmd := p.get_default('cmd', '')! + horizontal := p.get_default_false('horizontal') + parsed := parse_window_name(name)! + + // Parse environment variables if provided + mut env := map[string]string{} + if env_str := p.get_default('env', '') { + env_pairs := env_str.split(',') + for pair in env_pairs { + kv := pair.split('=') + if kv.len == 2 { + env[kv[0].trim_space()] = kv[1].trim_space() + } + } + } + + // Find the session and window + if tmux_instance.session_exist(parsed.session) { + mut session := tmux_instance.session_get(parsed.session)! + if session.window_exist(name: parsed.window) { + mut window := session.window_get(name: parsed.window)! + + // Split the pane + window.pane_split( + cmd: cmd + horizontal: horizontal + env: env + )! + } + } + + action.done = true + } +} + +fn play_session_ttyd(mut plbook PlayBook, mut tmux_instance Tmux) ! { + mut actions := plbook.find(filter: 'tmux.session_ttyd')! + for mut action in actions { + mut p := action.params + session_name := p.get('name')! + port := p.get_int('port')! + editable := p.get_default_false('editable') + + if tmux_instance.session_exist(session_name) { + mut session := tmux_instance.session_get(session_name)! + session.run_ttyd( + port: port + editable: editable + )! + } + + action.done = true + } +} + +fn play_window_ttyd(mut plbook PlayBook, mut tmux_instance Tmux) ! { + mut actions := plbook.find(filter: 'tmux.window_ttyd')! + for mut action in actions { + mut p := action.params + name := p.get('name')! + port := p.get_int('port')! + editable := p.get_default_false('editable') + parsed := parse_window_name(name)! + + if tmux_instance.session_exist(parsed.session) { + mut session := tmux_instance.session_get(parsed.session)! + if session.window_exist(name: parsed.window) { + mut window := session.window_get(name: parsed.window)! + window.run_ttyd( + port: port + editable: editable + )! + } + } + + action.done = true + } +} + +// Handle tmux.session_ttyd_stop actions +fn play_session_ttyd_stop(mut plbook PlayBook, mut tmux_instance Tmux) ! { + for mut action in plbook.find(filter: 'tmux.session_ttyd_stop')! { + if action.done { + continue + } + + mut p := action.params + session_name := p.get('name')! + port := p.get_int('port')! + + mut session := tmux_instance.session_get(session_name)! + session.stop_ttyd(port)! + + action.done = true + } +} + +// Handle tmux.window_ttyd_stop actions +fn play_window_ttyd_stop(mut plbook PlayBook, mut tmux_instance Tmux) ! { + for mut action in plbook.find(filter: 'tmux.window_ttyd_stop')! { + if action.done { + continue + } + + mut p := action.params + name := p.get('name')! + port := p.get_int('port')! + + parsed := parse_window_name(name)! + mut session := tmux_instance.session_get(parsed.session)! + mut window := session.window_get(name: parsed.window)! + window.stop_ttyd(port)! + + action.done = true + } +} + +// Handle tmux.ttyd_stop_all actions +fn play_ttyd_stop_all(mut plbook PlayBook, mut tmux_instance Tmux) ! { + for mut action in plbook.find(filter: 'tmux.ttyd_stop_all')! { + if action.done { + continue + } + + stop_all_ttyd()! + + action.done = true + } +} diff --git a/lib/osal/tmux/readme.md b/lib/osal/tmux/readme.md index 052dcf74..88b5019c 100644 --- a/lib/osal/tmux/readme.md +++ b/lib/osal/tmux/readme.md @@ -1,6 +1,5 @@ # TMUX - TMUX is a very capable process manager. > TODO: TTYD, need to integrate with TMUX for exposing TMUX over http @@ -11,7 +10,6 @@ TMUX is a very capable process manager. - session = is a set of windows, it has a name and groups windows - window = is typically one process running (you can have panes but in our implementation we skip this) - ## structure tmux library provides functions for managing tmux sessions @@ -20,33 +18,156 @@ tmux library provides functions for managing tmux sessions - then windows (is where you see the app running) - then panes in windows (we don't support yet) - ## to attach to a tmux session -> TODO: -## HeroScript Usage Examples +> +## HeroScript Declarative Support + +The tmux module supports declarative configuration through heroscript, allowing you to define tmux sessions, windows, and panes in a structured way. + +### Running HeroScript + +```bash +hero run -p +``` + +### Supported Actions + +#### Session Management ```heroscript -!!tmux.session_create +// Create a new session +!!tmux.session_create name:'mysession' - reset:true + reset:true // Optional: delete existing session first -!!tmux.session_delete +// Delete a session +!!tmux.session_delete name:'mysession' +``` -!!tmux.window_create +#### Window Management + +```heroscript +// Create a new window +!!tmux.window_create + name:"mysession|mywindow" // Format: session|window + cmd:'htop' // Optional: command to run + env:'VAR1=value1,VAR2=value2' // Optional: environment variables + reset:true // Optional: recreate if exists + +// Delete a window +!!tmux.window_delete name:"mysession|mywindow" - cmd:'htop' - env:'VAR1=value1,VAR2=value2' - reset:true +``` -!!tmux.window_delete - name:"mysession|mywindow" +#### Pane Management -!!tmux.pane_execute - name:"mysession|mywindow|mypane" +```heroscript +// Execute command in a pane +!!tmux.pane_execute + name:"mysession|mywindow|mypane" // Format: session|window|pane cmd:'ls -la' -!!tmux.pane_kill +// Kill a pane +!!tmux.pane_kill name:"mysession|mywindow|mypane" -``` \ No newline at end of file +``` + +#### Ttyd Management + +```heroscript +// Start ttyd for session access +!!tmux.session_ttyd + name:'mysession' + port:8080 + editable:true // Optional: allows write access + +// Start ttyd for window access +!!tmux.window_ttyd + name:"mysession|mywindow" + port:8081 + editable:false // Optional: read-only access + +// Stop ttyd for session +!!tmux.session_ttyd_stop + name:'mysession' + port:8080 + +// Stop ttyd for window +!!tmux.window_ttyd_stop + name:"mysession|mywindow" + port:8081 + +// Stop all ttyd processes +!!tmux.ttyd_stop_all +``` + +### Complete Example + +```heroscript +#!/usr/bin/env hero + +// Create development environment +!!tmux.session_create + name:'dev' + reset:true + +!!tmux.window_create + name:"dev|editor" + cmd:'vim' + reset:true + +!!tmux.window_create + name:"dev|server" + cmd:'python3 -m http.server 8000' + env:'PORT=8000,DEBUG=true' + reset:true + +!!tmux.pane_execute + name:"dev|editor|main" + cmd:'echo "Welcome to development!"' +``` + +### Naming Convention + +- **Sessions**: Simple names like `dev`, `monitoring`, `main` +- **Windows**: Use pipe separator: `session|window` (e.g., `dev|editor`) +- **Panes**: Use pipe separator: `session|window|pane` (e.g., `dev|editor|main`) + +Names are automatically normalized using `texttools.name_fix()` for consistency. + +## Example Usage + +### Setup and Cleanup Scripts + +Two example heroscripts are provided to demonstrate complete tmux environment management: + +#### 1. Setup Script (`tmux_setup.heroscript`) + +Creates a complete development environment with multiple sessions and windows: + +```bash +hero run examples/tmux/tmux_setup.heroscript +``` + +This creates: + +- **dev session** with editor, server, logs, and a 4-pane services window +- **monitoring session** with htop and network monitoring windows +- **Web access** via ttyd on ports 8080, 8081, and 7681 + +#### 2. Cleanup Script (`tmux_cleanup.heroscript`) + +Tears down all created tmux resources: + +```bash +hero run examples/tmux/tmux_cleanup.heroscript +``` + +This removes: + +- All windows from both sessions +- Both dev and monitoring sessions +- All associated panes +- All ttyd web processes (ports 8080, 8081, 7681) diff --git a/lib/osal/tmux/tmux_session.v b/lib/osal/tmux/tmux_session.v index 2e7a8fde..f7b37603 100644 --- a/lib/osal/tmux/tmux_session.v +++ b/lib/osal/tmux/tmux_session.v @@ -61,16 +61,42 @@ pub fn (mut s Session) scan() ! { mut current_windows := map[string]bool{} for line in result.split_into_lines() { - if line.contains('|') { - parts := line.split('|') + line_trimmed := line.trim_space() + if line_trimmed.len == 0 { + continue + } + if line_trimmed.contains('|') { + parts := line_trimmed.split('|') if parts.len >= 3 && parts[0].len > 0 && parts[1].len > 0 { - window_name := texttools.name_fix(parts[0]) + // Safely extract window name with additional validation + raw_window_name := parts[0].trim_space() + if raw_window_name.len == 0 { + continue + } + + // Use safer name processing instead of texttools.name_fix + mut window_name := raw_window_name.to_lower().trim_space() + // Replace problematic characters with underscores + window_name = window_name.replace(' ', '_').replace('-', '_').replace('.', + '_') + + // Remove any non-ASCII characters safely + mut safe_name := '' + for c in window_name { + if c.is_letter() || c.is_digit() || c == `_` { + safe_name += c.ascii_str() + } + } + window_name = safe_name + if window_name.len == 0 { continue } + window_id := parts[1].replace('@', '').int() window_active := parts[2] == '1' + // Safe map assignment current_windows[window_name] = true // Update existing window or create new one @@ -102,7 +128,24 @@ pub fn (mut s Session) scan() ! { } // Remove windows that no longer exist in tmux - s.windows = s.windows.filter(it.name.len > 0 && current_windows[it.name] == true) + mut valid_windows := []&Window{} + for window in s.windows { + // Safety check: ensure window.name is valid + if window.name.len > 0 { + // Avoid map access entirely - check if window still exists by comparing with current windows + mut window_exists := false + for current_name, _ in current_windows { + if window.name == current_name { + window_exists = true + break + } + } + if window_exists { + valid_windows << window + } + } + } + s.windows = valid_windows } // window_name is the name of the window in session main (will always be called session main) @@ -217,7 +260,7 @@ pub fn (mut s Session) window_get(args_ WindowGetArgs) !&Window { if args.name.len == 0 { return error('Window name cannot be empty') } - args.name = texttools.name_fix(args.name) + args.name = texttools.name_fix_token(args.name) for w in s.windows { if w.name.len > 0 && w.name == args.name { if (args.id > 0 && w.id == args.id) || args.id == 0 { @@ -231,7 +274,7 @@ pub fn (mut s Session) window_get(args_ WindowGetArgs) !&Window { pub fn (mut s Session) window_delete(args_ WindowGetArgs) ! { // $if debug { console.print_debug(" - window delete: $args_")} mut args := args_ - args.name = texttools.name_fix(args.name) + args.name = texttools.name_fix_token(args.name) if !(s.window_exist(args)) { return } @@ -284,3 +327,16 @@ pub fn (mut s Session) run_ttyd(args TtydArgs) ! { pub fn (mut s Session) run_ttyd_readonly(port int) ! { s.run_ttyd(port: port, editable: false)! } + +// Stop ttyd for this session by killing the process on the specified port +pub fn (mut s Session) stop_ttyd(port int) ! { + // Kill any process running on the specified port + cmd := 'lsof -ti:${port} | xargs kill -9' + osal.execute_silent(cmd) or { return error("Can't stop ttyd on port ${port}: ${err}") } +} + +// Stop all ttyd processes (kills all ttyd processes system-wide) +pub fn stop_all_ttyd() ! { + cmd := 'pkill ttyd' + osal.execute_silent(cmd) or { return error("Can't stop all ttyd processes: ${err}") } +} diff --git a/lib/osal/tmux/tmux_window.v b/lib/osal/tmux/tmux_window.v index 6119ca93..c6c476d1 100644 --- a/lib/osal/tmux/tmux_window.v +++ b/lib/osal/tmux/tmux_window.v @@ -37,11 +37,27 @@ pub fn (mut w Window) scan() ! { mut current_panes := map[int]bool{} for line in result.split_into_lines() { - if line.contains('|') { - parts := line.split('|') - if parts.len >= 3 { - pane_id := parts[0].replace('%', '').int() - pane_pid := parts[1].int() + line_trimmed := line.trim_space() + if line_trimmed.len == 0 { + continue + } + if line_trimmed.contains('|') { + parts := line_trimmed.split('|') + if parts.len >= 3 && parts[0].len > 0 && parts[1].len > 0 { + // Safely parse pane ID + pane_id_str := parts[0].replace('%', '').trim_space() + if pane_id_str.len == 0 { + continue + } + pane_id := pane_id_str.int() + + // Safely parse PID + pane_pid_str := parts[1].trim_space() + if pane_pid_str.len == 0 { + continue + } + pane_pid := pane_pid_str.int() + pane_active := parts[2] == '1' pane_cmd := if parts.len > 3 { parts[3] } else { '' } @@ -77,7 +93,14 @@ pub fn (mut w Window) scan() ! { } // Remove panes that no longer exist - w.panes = w.panes.filter(current_panes[it.id] == true) + mut valid_panes := []&Pane{} + for pane in w.panes { + // Use safe map access with 'in' operator first + if pane.id in current_panes && current_panes[pane.id] == true { + valid_panes << pane + } + } + w.panes = valid_panes } pub fn (mut w Window) stop() ! { @@ -237,14 +260,8 @@ pub fn (mut w Window) pane_split(args PaneSplitArgs) !&Pane { w.panes << &new_pane w.scan()! - // Return reference to the new pane - for mut pane in w.panes { - if pane.id == pane_id { - return pane - } - } - - return error('Could not find newly created pane with ID ${pane_id}') + // Return the new pane reference + return &new_pane } // Split pane horizontally (side by side) @@ -289,3 +306,10 @@ pub fn (mut w Window) run_ttyd(args TtydArgs) ! { pub fn (mut w Window) run_ttyd_readonly(port int) ! { w.run_ttyd(port: port, editable: false)! } + +// Stop ttyd for this window by killing the process on the specified port +pub fn (mut w Window) stop_ttyd(port int) ! { + // Kill any process running on the specified port + cmd := 'lsof -ti:${port} | xargs kill -9' + osal.execute_silent(cmd) or { return error("Can't stop ttyd on port ${port}: ${err}") } +}