Class: ShellSession
- Inherits:
-
Object
- Object
- ShellSession
- Defined in:
- lib/shell_session.rb
Overview
Persistent shell session backed by a tmux session. Commands share working directory, environment, and shell history within a conversation. Multiple tools share the same session via ShellSession.for_session.
tmux is the source of truth — the ShellSession object is a disposable handle. The tmux session survives Anima crashes; teardown happens only through ShellSession.release or #finalize (e.g. when the owning Session record is deleted).
Sub-agents inherit cwd from their parent’s tmux session at the moment the child shell is created. The lookup is dynamic — the parent’s current cwd is captured, not a snapshot from spawn time.
tmux is a hard runtime dependency. #initialize raises a clear error if tmux is missing.
Constant Summary collapse
- TMUX_SESSION_PREFIX =
Prefix for every tmux session Anima owns. The full session name is anima-shell-{session_id}; this prefix is what cleanup sweeps (current and future) match on to leave unrelated tmux sessions alone.
"anima-shell-"- PANE_WIDTH =
Pane geometry — 200×50 is wide enough for most tool output without forcing wraps that would inflate captures, and tall enough that the agent sees normal command runs without scrollback in the visible area.
200- PANE_HEIGHT =
50- HISTORY_LIMIT =
Scrollback cap. tmux retains the last N lines of output per pane, discarding older ones automatically — this is what bounds memory and closes the OOM bug from the old PTY+FIFO design. Each line costs roughly 1–2KB inside tmux, so 5000 lines ≈ 5–10MB resident per pane.
5_000- SHELL_ENV =
Env vars that disable interactive pagers and credential prompts in the shell. Without these, tools like
gh,git,man,journalctlspawnlessand block the pane waiting for keypresses — our wait-for -S never fires, the run hangs to timeout. Set once at session creation via new-session -e so they propagate to every command. { "PAGER" => "cat", "GIT_PAGER" => "cat", "MANPAGER" => "cat", "LESS" => "-eFRX", "SYSTEMD_PAGER" => "", "AWS_PAGER" => "", "PSQL_PAGER" => "cat", "BAT_PAGER" => "cat", "GIT_TERMINAL_PROMPT" => "0" }.freeze
- PANE_CWD_FORMAT =
tmux format-string for the pane’s current working directory. Single-quoted intentionally — tmux performs the #{…} substitution server-side, so Ruby must pass the literal string.
'#{pane_current_path}'- WAITER_KILL_GRACE =
Grace period before escalating SIGTERM → SIGKILL when reaping a wedged tmux wait-for child. tmux clients normally exit on TERM within milliseconds; 5 seconds is generous enough that a healthy one always makes it, while an unkillable one never hangs the shell.
5- INIT_MUTEX =
Serializes the cold-start path of for_session / #initialize —
alive?→new-session→inject_shell_env. Without it, two threads racing on the samesession_idboth seealive?false, both runnew-session(the second silently fails), and both runinject_shell_env, double-exporting and corrupting the pane. Held only during cold start; warm-path callers don’t contend. Mutex.new
Instance Attribute Summary collapse
-
#session_id ⇒ Integer, String
readonly
Identifier of the Session this shell belongs to.
Class Method Summary collapse
-
.cwd_via_tmux(session_id) ⇒ String?
Reads the working directory of
session_id‘s tmux pane directly from the tmux server. -
.for_session(session) ⇒ ShellSession
Returns the shell bound to
session. -
.release(session_id) ⇒ void
Kills the tmux session for
session_id.
Instance Method Summary collapse
-
#alive? ⇒ Boolean
Whether the underlying tmux session exists.
-
#finalize ⇒ Object
Kills the underlying tmux session.
-
#initialize(session_id:, initial_cwd: nil) ⇒ ShellSession
constructor
A new instance of ShellSession.
-
#pwd ⇒ String?
Reads the shell’s current working directory directly from the tmux server.
-
#run(command, timeout: nil, interrupt_check: nil) ⇒ Hash{Symbol => Object}
Execute a command in the persistent shell.
Constructor Details
#initialize(session_id:, initial_cwd: nil) ⇒ ShellSession
Returns a new instance of ShellSession.
138 139 140 141 142 |
# File 'lib/shell_session.rb', line 138 def initialize(session_id:, initial_cwd: nil) @session_id = session_id @target = "#{TMUX_SESSION_PREFIX}#{session_id}" INIT_MUTEX.synchronize { ensure_session(initial_cwd) } end |
Instance Attribute Details
#session_id ⇒ Integer, String (readonly)
Returns identifier of the Session this shell belongs to.
84 85 86 |
# File 'lib/shell_session.rb', line 84 def session_id @session_id end |
Class Method Details
.cwd_via_tmux(session_id) ⇒ String?
Reads the working directory of session_id‘s tmux pane directly from the tmux server. Works even when the pane is mid-command — the pane_current_path format variable is a server-side property (kernel /proc/{pid}/cwd readlink), not shell-mediated.
116 117 118 119 120 121 122 123 124 125 |
# File 'lib/shell_session.rb', line 116 def self.cwd_via_tmux(session_id) target = "#{TMUX_SESSION_PREFIX}#{session_id}" output, status = Open3.capture2( "tmux", "display-message", "-p", "-t", target, PANE_CWD_FORMAT, err: File::NULL ) return nil unless status.success? cwd = output.strip cwd.empty? ? nil : cwd end |
.for_session(session) ⇒ ShellSession
Returns the shell bound to session. Sub-agents inherit cwd from their parent’s tmux session via cwd_via_tmux, falling back to session.initial_cwd for root sessions or when the parent’s tmux session is gone.
93 94 95 96 |
# File 'lib/shell_session.rb', line 93 def self.for_session(session) cwd = parent_cwd_for(session) || session.initial_cwd new(session_id: session.id, initial_cwd: cwd) end |
.release(session_id) ⇒ void
This method returns an undefined value.
Kills the tmux session for session_id. Idempotent — silently succeeds when no such session exists.
103 104 105 106 107 |
# File 'lib/shell_session.rb', line 103 def self.release(session_id) target = "#{TMUX_SESSION_PREFIX}#{session_id}" system("tmux", "kill-session", "-t", target, out: File::NULL, err: File::NULL) nil end |
Instance Method Details
#alive? ⇒ Boolean
Returns whether the underlying tmux session exists.
196 197 198 |
# File 'lib/shell_session.rb', line 196 def alive? !!system("tmux", "has-session", "-t", @target, out: File::NULL, err: File::NULL) end |
#finalize ⇒ Object
Kills the underlying tmux session. Idempotent.
201 202 203 |
# File 'lib/shell_session.rb', line 201 def finalize self.class.release(@session_id) end |
#pwd ⇒ String?
Reads the shell’s current working directory directly from the tmux server. Works even mid-command — the lookup is server-side, not shell-mediated.
210 211 212 |
# File 'lib/shell_session.rb', line 210 def pwd self.class.cwd_via_tmux(@session_id) end |
#run(command, timeout: nil, interrupt_check: nil) ⇒ Hash{Symbol => Object}
Execute a command in the persistent shell.
Capture sequence:
-
tmux clear-history wipes off-screen scrollback.
-
send-keys “clear; <cmd>; tmux wait-for -S done-<uuid>” — bash receives the line: shell
clearerases the visible pane (and scrollback via the\e[3Jsequence on modern terminfo), <cmd> runs, then wait-for -S signals the synchronization channel. -
We block on tmux wait-for done-<uuid> in a child process and poll for interrupt/timeout. On either we send
C-cto the pane and kill the wait-for child — interactive bash discards the rest of the input line on SIGINT, so the trailing wait-for -S never fires and we can’t rely on natural signaling. -
capture-pane -pJ -S - pulls scrollback + visible pane, which (after
clearwiped both) is exactly the new command’s output and trailing prompt.
168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 |
# File 'lib/shell_session.rb', line 168 def run(command, timeout: nil, interrupt_check: nil) return {error: "Shell session is not running"} unless alive? uuid = SecureRandom.hex(8) timeout ||= Anima::Settings.command_timeout system("tmux", "clear-history", "-t", @target, out: File::NULL, err: File::NULL) line = "clear; #{command}; tmux wait-for -S done-#{uuid}" system("tmux", "send-keys", "-t", @target, line, "Enter", out: File::NULL, err: File::NULL) state = wait_for_completion(uuid, timeout, interrupt_check) output = capture_output return {error: "tmux capture-pane failed (session may have died)"} if output.nil? case state when :done then {output: output} when :interrupted then {output: output, interrupted: true} when :timeout then {error: "Command timed out after #{timeout} seconds.\n\n#{output}"} end rescue => error # Catch-all isolates the LLM tool-call boundary: a stray exception # from tmux internals must surface as a result hash rather than tear # down the conversation pipeline. {error: "#{error.class}: #{error.}"} end |