Class: ShellSession

Inherits:
Object
  • Object
show all
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.

Examples:

shell = ShellSession.for_session(session)
shell.run("cd /tmp")
shell.run("pwd")
# => {output: "/tmp"}

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, journalctl spawn less and 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 / #initializealive?new-sessioninject_shell_env. Without it, two threads racing on the same session_id both see alive? false, both run new-session (the second silently fails), and both run inject_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

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(session_id:, initial_cwd: nil) ⇒ ShellSession

Returns a new instance of ShellSession.

Parameters:

  • session_id (Integer, String)
  • initial_cwd (String, nil) (defaults to: nil)

    starting working directory

Raises:

  • (RuntimeError)

    if tmux is missing or the session can’t be created



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_idInteger, String (readonly)

Returns identifier of the Session this shell belongs to.

Returns:

  • (Integer, String)

    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.

Parameters:

  • session_id (Integer, String)

Returns:

  • (String, nil)

    absolute path, or nil when the session is gone



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.

Parameters:

  • session (Session)

    owning conversation

Returns:



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.

Parameters:

  • session_id (Integer, String)


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.

Returns:

  • (Boolean)

    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

#finalizeObject

Kills the underlying tmux session. Idempotent.



201
202
203
# File 'lib/shell_session.rb', line 201

def finalize
  self.class.release(@session_id)
end

#pwdString?

Reads the shell’s current working directory directly from the tmux server. Works even mid-command — the lookup is server-side, not shell-mediated.

Returns:

  • (String, nil)


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:

  1. tmux clear-history wipes off-screen scrollback.

  2. send-keys “clear; <cmd>; tmux wait-for -S done-<uuid>” — bash receives the line: shell clear erases the visible pane (and scrollback via the \e[3J sequence on modern terminfo), <cmd> runs, then wait-for -S signals the synchronization channel.

  3. We block on tmux wait-for done-<uuid> in a child process and poll for interrupt/timeout. On either we send C-c to 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.

  4. capture-pane -pJ -S - pulls scrollback + visible pane, which (after clear wiped both) is exactly the new command’s output and trailing prompt.

Parameters:

  • command (String)

    bash command to execute

  • timeout (Integer, nil) (defaults to: nil)

    per-call timeout in seconds; defaults to Anima::Settings.command_timeout

  • interrupt_check (Proc, nil) (defaults to: nil)

    callable returning truthy when the user has requested an interrupt

Returns:

  • (Hash{Symbol => Object})

    :output on success; :output + :interrupted on user cancel; :error on failure



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.message}"}
end