anima-core 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +18 -0
  3. data/CHANGELOG.md +26 -0
  4. data/README.md +134 -19
  5. data/Rakefile +3 -0
  6. data/app/jobs/application_job.rb +4 -0
  7. data/app/jobs/count_event_tokens_job.rb +28 -0
  8. data/app/models/application_record.rb +5 -0
  9. data/app/models/event.rb +64 -0
  10. data/app/models/session.rb +105 -0
  11. data/config/application.rb +31 -0
  12. data/config/boot.rb +8 -0
  13. data/config/database.yml +33 -0
  14. data/config/environment.rb +5 -0
  15. data/config/environments/development.rb +8 -0
  16. data/config/environments/production.rb +8 -0
  17. data/config/environments/test.rb +9 -0
  18. data/config/initializers/inflections.rb +9 -0
  19. data/config/queue.yml +18 -0
  20. data/config/recurring.yml +15 -0
  21. data/config/routes.rb +4 -0
  22. data/db/migrate/.keep +0 -0
  23. data/db/migrate/20260308124202_create_sessions.rb +9 -0
  24. data/db/migrate/20260308124203_create_events.rb +18 -0
  25. data/db/migrate/20260308130000_add_event_indexes.rb +9 -0
  26. data/db/migrate/20260308140000_remove_position_from_events.rb +8 -0
  27. data/db/migrate/20260308150000_add_token_count_to_events.rb +7 -0
  28. data/db/migrate/20260308160000_add_tool_use_id_to_events.rb +8 -0
  29. data/db/queue_schema.rb +141 -0
  30. data/db/seeds.rb +1 -0
  31. data/exe/anima +6 -0
  32. data/lib/anima/cli.rb +55 -0
  33. data/lib/anima/installer.rb +118 -0
  34. data/lib/anima/version.rb +1 -1
  35. data/lib/anima.rb +4 -0
  36. data/lib/events/agent_message.rb +11 -0
  37. data/lib/events/base.rb +38 -0
  38. data/lib/events/bus.rb +39 -0
  39. data/lib/events/subscriber.rb +26 -0
  40. data/lib/events/subscribers/message_collector.rb +64 -0
  41. data/lib/events/subscribers/persister.rb +46 -0
  42. data/lib/events/system_message.rb +11 -0
  43. data/lib/events/tool_call.rb +29 -0
  44. data/lib/events/tool_response.rb +33 -0
  45. data/lib/events/user_message.rb +11 -0
  46. data/lib/llm/client.rb +161 -0
  47. data/lib/providers/anthropic.rb +164 -0
  48. data/lib/shell_session.rb +333 -0
  49. data/lib/tools/base.rb +58 -0
  50. data/lib/tools/bash.rb +53 -0
  51. data/lib/tools/registry.rb +60 -0
  52. data/lib/tools/web_get.rb +62 -0
  53. data/lib/tui/app.rb +181 -0
  54. data/lib/tui/screens/anthropic.rb +25 -0
  55. data/lib/tui/screens/chat.rb +210 -0
  56. data/lib/tui/screens/settings.rb +52 -0
  57. metadata +124 -4
  58. data/BRAINSTORM.md +0 -466
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ class ToolResponse < Base
5
+ TYPE = "tool_response"
6
+
7
+ attr_reader :tool_name, :success, :tool_use_id
8
+
9
+ # @param content [String] tool execution output
10
+ # @param tool_name [String] registered tool name
11
+ # @param success [Boolean] whether the tool executed successfully
12
+ # @param tool_use_id [String, nil] Anthropic-assigned ID for correlating call/result
13
+ # @param session_id [String, nil] optional session identifier
14
+ def initialize(content:, tool_name:, success: true, tool_use_id: nil, session_id: nil)
15
+ super(content: content, session_id: session_id)
16
+ @tool_name = tool_name
17
+ @success = success
18
+ @tool_use_id = tool_use_id
19
+ end
20
+
21
+ def type
22
+ TYPE
23
+ end
24
+
25
+ def success?
26
+ @success
27
+ end
28
+
29
+ def to_h
30
+ super.merge(tool_name: tool_name, success: success, tool_use_id: tool_use_id)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ class UserMessage < Base
5
+ TYPE = "user_message"
6
+
7
+ def type
8
+ TYPE
9
+ end
10
+ end
11
+ end
data/lib/llm/client.rb ADDED
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ # Convenience layer over {Providers::Anthropic} for sending messages
5
+ # and handling tool execution loops. Supports both simple text chat
6
+ # and multi-turn tool calling via the Anthropic tool use protocol.
7
+ #
8
+ # @example Simple chat (no tools)
9
+ # client = LLM::Client.new
10
+ # client.chat([{role: "user", content: "Say hello"}])
11
+ # # => "Hello! How can I help you today?"
12
+ #
13
+ # @example Chat with tools
14
+ # registry = Tools::Registry.new
15
+ # registry.register(Tools::WebGet)
16
+ # client.chat_with_tools(messages, registry: registry, session_id: session.id)
17
+ class Client
18
+ DEFAULT_MODEL = "claude-sonnet-4-20250514"
19
+ DEFAULT_MAX_TOKENS = 8192
20
+ MAX_TOOL_ROUNDS = 25
21
+
22
+ # @return [Providers::Anthropic] the underlying API provider
23
+ attr_reader :provider
24
+
25
+ # @return [String] the model identifier used for API calls
26
+ attr_reader :model
27
+
28
+ # @return [Integer] maximum tokens in the response
29
+ attr_reader :max_tokens
30
+
31
+ # @param model [String] Anthropic model identifier
32
+ # @param max_tokens [Integer] maximum tokens in the response
33
+ # @param provider [Providers::Anthropic, nil] injectable provider instance;
34
+ # defaults to a new {Providers::Anthropic} using credentials
35
+ def initialize(model: DEFAULT_MODEL, max_tokens: DEFAULT_MAX_TOKENS, provider: nil)
36
+ @provider = build_provider(provider)
37
+ @model = model
38
+ @max_tokens = max_tokens
39
+ end
40
+
41
+ # Send messages to the LLM and return the assistant's text response.
42
+ #
43
+ # @param messages [Array<Hash>] conversation messages, each with +:role+ and +:content+
44
+ # @param options [Hash] additional API parameters (e.g. +system:+, +temperature:+)
45
+ # @return [String] the assistant's response text
46
+ # @raise [Providers::Anthropic::Error] on API errors
47
+ # @raise [Providers::Anthropic::AuthenticationError] on auth failures
48
+ def chat(messages, **options)
49
+ response = provider.create_message(
50
+ model: model,
51
+ messages: messages,
52
+ max_tokens: max_tokens,
53
+ **options
54
+ )
55
+
56
+ extract_text(response)
57
+ end
58
+
59
+ # Send messages with tool support. Runs the full tool execution loop:
60
+ # call LLM, execute any requested tools, feed results back, repeat
61
+ # until the LLM produces a final text response.
62
+ #
63
+ # Emits {Events::ToolCall} and {Events::ToolResponse} events for each
64
+ # tool interaction so they're persisted and visible in the event stream.
65
+ #
66
+ # @param messages [Array<Hash>] conversation messages in Anthropic format
67
+ # @param registry [Tools::Registry] registered tools to make available
68
+ # @param session_id [Integer, String] session ID for emitted events
69
+ # @param options [Hash] additional API parameters (e.g. +system:+)
70
+ # @return [String] the assistant's final text response
71
+ # @raise [Providers::Anthropic::Error] on API errors
72
+ def chat_with_tools(messages, registry:, session_id:, **options)
73
+ messages = messages.dup
74
+ rounds = 0
75
+
76
+ loop do
77
+ rounds += 1
78
+ if rounds > MAX_TOOL_ROUNDS
79
+ return "[Tool loop exceeded #{MAX_TOOL_ROUNDS} rounds — halting]"
80
+ end
81
+
82
+ response = provider.create_message(
83
+ model: model,
84
+ messages: messages,
85
+ max_tokens: max_tokens,
86
+ tools: registry.schemas,
87
+ **options
88
+ )
89
+
90
+ if response["stop_reason"] == "tool_use"
91
+ tool_results = execute_tools(response, registry, session_id)
92
+
93
+ messages += [
94
+ {role: "assistant", content: response["content"]},
95
+ {role: "user", content: tool_results}
96
+ ]
97
+ else
98
+ return extract_text(response)
99
+ end
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ def build_provider(provider)
106
+ provider || Providers::Anthropic.new
107
+ end
108
+
109
+ def extract_text(response)
110
+ content = response["content"] || []
111
+
112
+ content
113
+ .select { |block| block["type"] == "text" }
114
+ .map { |block| block["text"] }
115
+ .join
116
+ end
117
+
118
+ def extract_tool_uses(response)
119
+ content = response["content"] || []
120
+ content.select { |block| block["type"] == "tool_use" }
121
+ end
122
+
123
+ # Executes all tool_use blocks from a response, emitting events for each.
124
+ #
125
+ # @param response [Hash] Anthropic API response with tool_use content blocks
126
+ # @param registry [Tools::Registry] tool registry for dispatch
127
+ # @param session_id [Integer, String] session ID for events
128
+ # @return [Array<Hash>] tool_result content blocks for the next API call
129
+ def execute_tools(response, registry, session_id)
130
+ extract_tool_uses(response).map do |tool_use|
131
+ execute_single_tool(tool_use, registry, session_id)
132
+ end
133
+ end
134
+
135
+ def execute_single_tool(tool_use, registry, session_id)
136
+ name = tool_use["name"]
137
+ id = tool_use["id"]
138
+ input = tool_use["input"] || {}
139
+
140
+ Events::Bus.emit(Events::ToolCall.new(
141
+ content: "Calling #{name}", tool_name: name,
142
+ tool_input: input, tool_use_id: id, session_id: session_id
143
+ ))
144
+
145
+ result = registry.execute(name, input)
146
+ result_content = format_tool_result(result)
147
+
148
+ Events::Bus.emit(Events::ToolResponse.new(
149
+ content: result_content, tool_name: name, tool_use_id: id,
150
+ success: !result.is_a?(Hash) || !result.key?(:error),
151
+ session_id: session_id
152
+ ))
153
+
154
+ {type: "tool_result", tool_use_id: id, content: result_content}
155
+ end
156
+
157
+ def format_tool_result(result)
158
+ result.is_a?(Hash) ? result.to_json : result.to_s
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "httparty"
4
+
5
+ module Providers
6
+ class Anthropic
7
+ include HTTParty
8
+
9
+ base_uri "https://api.anthropic.com"
10
+ default_timeout 30
11
+
12
+ TOKEN_PREFIX = "sk-ant-oat01-"
13
+ TOKEN_MIN_LENGTH = 80
14
+ API_VERSION = "2023-06-01"
15
+ REQUIRED_BETA = "oauth-2025-04-20"
16
+ VALIDATION_MODEL = "claude-sonnet-4-20250514"
17
+
18
+ class Error < StandardError; end
19
+ class AuthenticationError < Error; end
20
+ class TokenFormatError < Error; end
21
+
22
+ class << self
23
+ def validate!
24
+ token = fetch_token
25
+ validate_token_format!(token)
26
+ validate_token_api!(token)
27
+ true
28
+ end
29
+
30
+ def fetch_token
31
+ token = Rails.application.credentials.dig(:anthropic, :subscription_token)
32
+ raise AuthenticationError, <<~MSG.strip if token.blank?
33
+ No Anthropic subscription token found in credentials.
34
+ Run: bin/rails credentials:edit
35
+ Add:
36
+ anthropic:
37
+ subscription_token: sk-ant-oat01-YOUR_TOKEN_HERE
38
+ MSG
39
+ token
40
+ end
41
+
42
+ def validate_token_format!(token)
43
+ unless token.start_with?(TOKEN_PREFIX)
44
+ raise TokenFormatError,
45
+ "Token must start with '#{TOKEN_PREFIX}'. Got: '#{token[0..12]}...'"
46
+ end
47
+
48
+ unless token.length >= TOKEN_MIN_LENGTH
49
+ raise TokenFormatError,
50
+ "Token must be at least #{TOKEN_MIN_LENGTH} characters (got #{token.length})"
51
+ end
52
+
53
+ true
54
+ end
55
+
56
+ def validate_token_api!(token)
57
+ provider = new(token)
58
+ provider.validate_credentials!
59
+ end
60
+ end
61
+
62
+ attr_reader :token
63
+
64
+ def initialize(token = nil)
65
+ @token = token || self.class.fetch_token
66
+ end
67
+
68
+ def create_message(model:, messages:, max_tokens:, **options)
69
+ body = {model: model, messages: messages, max_tokens: max_tokens}.merge(options)
70
+
71
+ response = self.class.post(
72
+ "/v1/messages",
73
+ body: body.to_json,
74
+ headers: request_headers
75
+ )
76
+
77
+ handle_response(response)
78
+ end
79
+
80
+ # Count tokens in a message payload without creating a message.
81
+ # Uses the free Anthropic token counting endpoint.
82
+ #
83
+ # @param model [String] Anthropic model identifier
84
+ # @param messages [Array<Hash>] conversation messages
85
+ # @param options [Hash] additional parameters (e.g. +system:+, +tools:+)
86
+ # @return [Integer] estimated input token count
87
+ # @raise [Error] on API errors
88
+ def count_tokens(model:, messages:, **options)
89
+ body = {model: model, messages: messages}.merge(options)
90
+
91
+ response = self.class.post(
92
+ "/v1/messages/count_tokens",
93
+ body: body.to_json,
94
+ headers: request_headers
95
+ )
96
+
97
+ result = handle_response(response)
98
+ result["input_tokens"]
99
+ end
100
+
101
+ def validate_credentials!
102
+ response = self.class.post(
103
+ "/v1/messages",
104
+ body: {
105
+ model: VALIDATION_MODEL,
106
+ messages: [{role: "user", content: "Hi"}],
107
+ max_tokens: 1
108
+ }.to_json,
109
+ headers: request_headers
110
+ )
111
+
112
+ case response.code
113
+ when 200
114
+ true
115
+ when 401
116
+ raise AuthenticationError,
117
+ "Token rejected by Anthropic API (401). Re-run `claude setup-token` and update credentials."
118
+ when 403
119
+ raise AuthenticationError,
120
+ "Token not authorized for API access (403). This credential may be restricted to Claude Code only."
121
+ else
122
+ handle_response(response)
123
+ end
124
+ end
125
+
126
+ private
127
+
128
+ def request_headers
129
+ {
130
+ "Authorization" => "Bearer #{token}",
131
+ "anthropic-version" => API_VERSION,
132
+ "anthropic-beta" => REQUIRED_BETA,
133
+ "content-type" => "application/json"
134
+ }
135
+ end
136
+
137
+ def handle_response(response)
138
+ case response.code
139
+ when 200
140
+ response.parsed_response
141
+ when 400
142
+ raise Error, "Bad request: #{error_message(response)}"
143
+ when 401
144
+ raise AuthenticationError,
145
+ "Authentication failed (401): #{error_message(response)}. Re-run `claude setup-token` and update credentials."
146
+ when 403
147
+ raise AuthenticationError,
148
+ "Forbidden (403): #{error_message(response)}"
149
+ when 429
150
+ raise Error, "Rate limit exceeded: #{error_message(response)}"
151
+ when 500..599
152
+ raise Error, "Anthropic server error (#{response.code}): #{response.message}"
153
+ else
154
+ raise Error, "Unexpected response (#{response.code}): #{response.message}"
155
+ end
156
+ end
157
+
158
+ def error_message(response)
159
+ response.parsed_response&.dig("error", "message") || response.message
160
+ rescue JSON::ParserError, NoMethodError
161
+ response.message
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+ require "pty"
5
+ require "securerandom"
6
+ require "timeout"
7
+
8
+ # Persistent shell session backed by a PTY with FIFO-based stderr separation.
9
+ # Commands share working directory, environment variables, and shell history
10
+ # within a conversation. Multiple tools share the same session.
11
+ #
12
+ # @example
13
+ # session = ShellSession.new(session_id: 42)
14
+ # session.run("cd /tmp")
15
+ # session.run("pwd")
16
+ # # => {stdout: "/tmp", stderr: "", exit_code: 0}
17
+ # session.finalize
18
+ class ShellSession
19
+ COMMAND_TIMEOUT = 30
20
+ MAX_OUTPUT_BYTES = 100_000
21
+
22
+ # @return [String, nil] current working directory of the shell process
23
+ attr_reader :pwd
24
+
25
+ # @param session_id [Integer, String] unique identifier for logging/diagnostics
26
+ def initialize(session_id:)
27
+ @session_id = session_id
28
+ @mutex = Mutex.new
29
+ @fifo_path = File.join(Dir.tmpdir, "anima-stderr-#{Process.pid}-#{SecureRandom.hex(8)}")
30
+ @alive = false
31
+ @pwd = nil
32
+ self.class.cleanup_orphans
33
+ start
34
+ self.class.register(self)
35
+ end
36
+
37
+ # Execute a command in the persistent shell.
38
+ #
39
+ # @param command [String] bash command to execute
40
+ # @return [Hash] with :stdout, :stderr, :exit_code keys on success
41
+ # @return [Hash] with :error key on failure
42
+ def run(command)
43
+ @mutex.synchronize do
44
+ return {error: "Shell session is not running"} unless @alive
45
+ execute_in_pty(command)
46
+ end
47
+ end
48
+
49
+ # Clean up PTY, FIFO, and child process.
50
+ def finalize
51
+ @mutex.synchronize { shutdown }
52
+ self.class.unregister(self)
53
+ end
54
+
55
+ # @return [Boolean] whether the shell process is still running
56
+ def alive?
57
+ @mutex.synchronize { @alive }
58
+ end
59
+
60
+ # --- Class-level session tracking for at_exit cleanup ---
61
+
62
+ @sessions = []
63
+ @sessions_mutex = Mutex.new
64
+
65
+ class << self
66
+ # @api private
67
+ def register(session)
68
+ @sessions_mutex.synchronize { @sessions << session }
69
+ end
70
+
71
+ # @api private
72
+ def unregister(session)
73
+ @sessions_mutex.synchronize { @sessions.delete(session) }
74
+ end
75
+
76
+ # Finalize all live sessions. Called automatically via at_exit.
77
+ def cleanup_all
78
+ @sessions_mutex.synchronize do
79
+ @sessions.each { |session| session.send(:shutdown) }
80
+ @sessions.clear
81
+ end
82
+ end
83
+
84
+ # Remove stale FIFO files left by crashed processes.
85
+ # FIFO naming format: anima-stderr-{pid}-{hex}
86
+ def cleanup_orphans
87
+ Dir.glob(File.join(Dir.tmpdir, "anima-stderr-*")).each do |path|
88
+ match = File.basename(path).match(/\Aanima-stderr-(\d+)-/)
89
+ next unless match
90
+
91
+ pid = match[1].to_i
92
+ next if pid <= 0
93
+
94
+ begin
95
+ Process.kill(0, pid)
96
+ rescue Errno::ESRCH
97
+ begin
98
+ File.delete(path)
99
+ rescue SystemCallError
100
+ # Best-effort cleanup
101
+ end
102
+ rescue Errno::EPERM
103
+ # Process exists but we can't signal it — leave it
104
+ end
105
+ end
106
+ end
107
+ end
108
+
109
+ at_exit { ShellSession.cleanup_all }
110
+
111
+ private
112
+
113
+ def start
114
+ create_fifo
115
+ spawn_shell
116
+ start_stderr_reader
117
+ init_shell
118
+ update_pwd
119
+ @alive = true
120
+ end
121
+
122
+ def create_fifo
123
+ File.mkfifo(@fifo_path)
124
+ rescue Errno::EEXIST
125
+ # FIFO already exists — reuse it
126
+ end
127
+
128
+ def spawn_shell
129
+ @pty_stdout, @pty_stdin, @pid = PTY.spawn(
130
+ {"TERM" => "dumb"},
131
+ "bash", "--norc", "--noprofile"
132
+ )
133
+ # Disable terminal echo via termios before bash can echo our commands.
134
+ # This is instant (kernel-level), unlike stty -echo which races with input.
135
+ @pty_stdin.echo = false
136
+ end
137
+
138
+ def start_stderr_reader
139
+ @stderr_mutex = Mutex.new
140
+ @stderr_buffer = []
141
+ @stderr_bytes = 0
142
+ @stderr_truncated = false
143
+ @stderr_thread = Thread.new do
144
+ File.open(@fifo_path, "r") do |fifo|
145
+ while (line = fifo.gets)
146
+ cleaned = line.chomp.delete("\r")
147
+ @stderr_mutex.synchronize do
148
+ if @stderr_bytes < MAX_OUTPUT_BYTES
149
+ @stderr_buffer << cleaned
150
+ @stderr_bytes += cleaned.bytesize
151
+ else
152
+ @stderr_truncated = true
153
+ end
154
+ end
155
+ end
156
+ end
157
+ rescue Errno::ENOENT, IOError
158
+ # FIFO was cleaned up or closed
159
+ end
160
+ end
161
+
162
+ # With echo already off (set in spawn_shell), only command output appears.
163
+ # The initial bash prompt merges with the marker output on one gets line.
164
+ def init_shell
165
+ marker = "__ANIMA_INIT_#{SecureRandom.hex(8)}__"
166
+ @pty_stdin.puts "PS1=''"
167
+ @pty_stdin.puts "exec 2>#{@fifo_path}"
168
+ @pty_stdin.puts "echo '#{marker}'"
169
+ consume_until(marker)
170
+ end
171
+
172
+ def execute_in_pty(command)
173
+ clear_stderr
174
+ marker = "__ANIMA_#{SecureRandom.hex(8)}__"
175
+
176
+ Timeout.timeout(COMMAND_TIMEOUT) do
177
+ # All on one line: run command, capture exit code, ensure newline
178
+ # before marker so output without trailing newline doesn't merge.
179
+ @pty_stdin.puts "#{command}; __anima_ec=$?; echo; echo '#{marker}' $__anima_ec"
180
+
181
+ stdout, exit_code = read_until_marker(marker)
182
+ update_pwd
183
+ stderr = drain_stderr
184
+
185
+ {
186
+ stdout: truncate(stdout),
187
+ stderr: truncate(stderr),
188
+ exit_code: exit_code
189
+ }
190
+ end
191
+ rescue Timeout::Error
192
+ recover_from_timeout
193
+ {error: "Command timed out after #{COMMAND_TIMEOUT} seconds"}
194
+ rescue Errno::EIO
195
+ @alive = false
196
+ {error: "Shell session terminated unexpectedly"}
197
+ rescue => error
198
+ {error: "#{error.class}: #{error.message}"}
199
+ end
200
+
201
+ def read_until_marker(marker)
202
+ lines = []
203
+ exit_code = nil
204
+
205
+ loop do
206
+ line = @pty_stdout.gets
207
+ break if line.nil?
208
+
209
+ line = line.chomp.delete("\r")
210
+
211
+ if line.include?(marker)
212
+ exit_code = line.split.last.to_i
213
+ break
214
+ end
215
+
216
+ lines << line
217
+ end
218
+
219
+ # Strip trailing empty line added by our separator echo
220
+ lines.pop if lines.last == ""
221
+
222
+ [lines.join("\n"), exit_code || -1]
223
+ end
224
+
225
+ def consume_until(marker)
226
+ loop do
227
+ line = @pty_stdout.gets
228
+ break if line.nil?
229
+ break if line.chomp.delete("\r").include?(marker)
230
+ end
231
+ end
232
+
233
+ # Sends Ctrl+C to interrupt the running command and drains leftover output.
234
+ # If recovery fails, marks the session as dead.
235
+ def recover_from_timeout
236
+ @pty_stdin.write("\x03")
237
+ sleep 0.1
238
+ marker = "__ANIMA_RECOVER_#{SecureRandom.hex(8)}__"
239
+ @pty_stdin.puts "echo '#{marker}'"
240
+ Timeout.timeout(3) { consume_until(marker) }
241
+ rescue Timeout::Error, Errno::EIO, IOError
242
+ @alive = false
243
+ end
244
+
245
+ def clear_stderr
246
+ @stderr_mutex.synchronize do
247
+ @stderr_buffer.clear
248
+ @stderr_bytes = 0
249
+ @stderr_truncated = false
250
+ end
251
+ end
252
+
253
+ def drain_stderr
254
+ # Allow FIFO reader thread time to flush kernel buffers into @stderr_buffer.
255
+ # Without this, stderr arriving just before the marker may be missed.
256
+ sleep 0.01
257
+ @stderr_mutex.synchronize do
258
+ result = @stderr_buffer.join("\n")
259
+ truncated = @stderr_truncated
260
+ @stderr_buffer.clear
261
+ @stderr_bytes = 0
262
+ @stderr_truncated = false
263
+ truncated ? result + "\n\n[Truncated: output exceeded #{MAX_OUTPUT_BYTES} bytes]" : result
264
+ end
265
+ end
266
+
267
+ # Reads the shell's current working directory via the /proc filesystem.
268
+ # @note Linux-only. Falls back silently on other platforms or if the
269
+ # process has exited.
270
+ def update_pwd
271
+ @pwd = File.readlink("/proc/#{@pid}/cwd")
272
+ rescue Errno::ENOENT, Errno::EACCES
273
+ # Process exited or no access — @pwd retains its previous value
274
+ end
275
+
276
+ def truncate(output)
277
+ return output if output.bytesize <= MAX_OUTPUT_BYTES
278
+
279
+ output.byteslice(0, MAX_OUTPUT_BYTES)
280
+ .force_encoding("UTF-8")
281
+ .scrub +
282
+ "\n\n[Truncated: output exceeded #{MAX_OUTPUT_BYTES} bytes]"
283
+ end
284
+
285
+ def shutdown
286
+ return unless @alive
287
+ @alive = false
288
+
289
+ begin
290
+ pgid = Process.getpgid(@pid)
291
+ Process.kill("TERM", -pgid)
292
+ rescue Errno::ESRCH, Errno::EPERM
293
+ # Process group already gone
294
+ end
295
+
296
+ begin
297
+ @pty_stdin&.close
298
+ rescue IOError
299
+ # Already closed
300
+ end
301
+
302
+ begin
303
+ @pty_stdout&.close
304
+ rescue IOError
305
+ # Already closed
306
+ end
307
+
308
+ begin
309
+ @stderr_thread&.kill
310
+ rescue ThreadError
311
+ # Thread already dead
312
+ end
313
+
314
+ File.delete(@fifo_path) if File.exist?(@fifo_path)
315
+
316
+ begin
317
+ # Non-blocking reap with SIGKILL fallback if process doesn't exit in time
318
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
319
+ loop do
320
+ _, status = Process.wait2(@pid, Process::WNOHANG)
321
+ break if status
322
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
323
+ Process.kill("KILL", @pid)
324
+ Process.wait(@pid)
325
+ break
326
+ end
327
+ sleep 0.05
328
+ end
329
+ rescue Errno::ECHILD, Errno::ESRCH
330
+ # Already reaped
331
+ end
332
+ end
333
+ end