Class: ShellSession

Inherits:
Object
  • Object
show all
Defined in:
lib/shell_session.rb

Overview

Persistent shell session backed by a PTY with FIFO-based stderr separation. Commands share working directory, environment variables, and shell history within a conversation. Multiple tools share the same session.

Auto-recovers from timeouts and crashes: if the shell dies, the next command transparently respawns a fresh shell and restores the working directory.

Uses IO.select-based deadlines instead of Timeout.timeout for all PTY reads. Timeout.timeout is unsafe with PTY I/O — it uses Thread.raise which can corrupt mutex state, leave resources inconsistent, and cause exceptions to fire outside handler blocks when nested.

Examples:

session = ShellSession.new(session_id: 42)
session.run("cd /tmp")
session.run("pwd")
# => {stdout: "/tmp", stderr: "", exit_code: 0}
session.finalize

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(session_id:) ⇒ ShellSession

Returns a new instance of ShellSession.

Parameters:

  • session_id (Integer, String)

    unique identifier for logging/diagnostics



31
32
33
34
35
36
37
38
39
40
41
42
# File 'lib/shell_session.rb', line 31

def initialize(session_id:)
  @session_id = session_id
  @mutex = Mutex.new
  @fifo_path = File.join(Dir.tmpdir, "anima-stderr-#{Process.pid}-#{SecureRandom.hex(8)}")
  @alive = false
  @finalized = false
  @pwd = nil
  @read_buffer = +""
  self.class.cleanup_orphans
  start
  self.class.register(self)
end

Instance Attribute Details

#pwdString? (readonly)

Returns current working directory of the shell process.

Returns:

  • (String, nil)

    current working directory of the shell process



28
29
30
# File 'lib/shell_session.rb', line 28

def pwd
  @pwd
end

Class Method Details

.cleanup_allObject

Finalize all live sessions. Called automatically via at_exit.



94
95
96
97
98
99
# File 'lib/shell_session.rb', line 94

def cleanup_all
  @sessions_mutex.synchronize do
    @sessions.each { |session| session.send(:teardown) }
    @sessions.clear
  end
end

.cleanup_orphansObject

Remove stale FIFO files left by crashed processes. FIFO naming format: anima-stderr-pid-hex



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
# File 'lib/shell_session.rb', line 103

def cleanup_orphans
  Dir.glob(File.join(Dir.tmpdir, "anima-stderr-*")).each do |path|
    match = File.basename(path).match(/\Aanima-stderr-(\d+)-/)
    next unless match

    pid = match[1].to_i
    next if pid <= 0

    begin
      Process.kill(0, pid)
    rescue Errno::ESRCH
      begin
        File.delete(path)
      rescue SystemCallError
        # Best-effort cleanup
      end
    rescue Errno::EPERM
      # Process exists but we can't signal it — leave it
    end
  end
end

.register(session) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



84
85
86
# File 'lib/shell_session.rb', line 84

def register(session)
  @sessions_mutex.synchronize { @sessions << session }
end

.unregister(session) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



89
90
91
# File 'lib/shell_session.rb', line 89

def unregister(session)
  @sessions_mutex.synchronize { @sessions.delete(session) }
end

Instance Method Details

#alive?Boolean

Returns whether the shell process is still running.

Returns:

  • (Boolean)

    whether the shell process is still running



73
74
75
# File 'lib/shell_session.rb', line 73

def alive?
  @mutex.synchronize { @alive }
end

#finalizeObject

Clean up PTY, FIFO, and child process. Permanent — the session will not auto-respawn after this call.



64
65
66
67
68
69
70
# File 'lib/shell_session.rb', line 64

def finalize
  @mutex.synchronize do
    @finalized = true
    teardown
  end
  self.class.unregister(self)
end

#run(command, timeout: nil) ⇒ Hash

Execute a command in the persistent shell. Respawns the shell automatically if the previous session died (timeout, crash, etc.).

Parameters:

  • command (String)

    bash command to execute

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

    per-call timeout in seconds; overrides Settings.command_timeout when provided

Returns:

  • (Hash)

    with :stdout, :stderr, :exit_code keys on success

  • (Hash)

    with :error key on failure



52
53
54
55
56
57
58
59
60
# File 'lib/shell_session.rb', line 52

def run(command, timeout: nil)
  @mutex.synchronize do
    return {error: "Shell session is not running"} if @finalized
    restart unless @alive
    execute_in_pty(command, timeout: timeout)
  end
rescue => error # rubocop:disable Lint/RescueException -- LLM must always get a result hash, never a stack trace
  {error: "#{error.class}: #{error.message}"}
end