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.
- checksums.yaml +4 -4
- data/.reek.yml +18 -0
- data/CHANGELOG.md +26 -0
- data/README.md +134 -19
- data/Rakefile +3 -0
- data/app/jobs/application_job.rb +4 -0
- data/app/jobs/count_event_tokens_job.rb +28 -0
- data/app/models/application_record.rb +5 -0
- data/app/models/event.rb +64 -0
- data/app/models/session.rb +105 -0
- data/config/application.rb +31 -0
- data/config/boot.rb +8 -0
- data/config/database.yml +33 -0
- data/config/environment.rb +5 -0
- data/config/environments/development.rb +8 -0
- data/config/environments/production.rb +8 -0
- data/config/environments/test.rb +9 -0
- data/config/initializers/inflections.rb +9 -0
- data/config/queue.yml +18 -0
- data/config/recurring.yml +15 -0
- data/config/routes.rb +4 -0
- data/db/migrate/.keep +0 -0
- data/db/migrate/20260308124202_create_sessions.rb +9 -0
- data/db/migrate/20260308124203_create_events.rb +18 -0
- data/db/migrate/20260308130000_add_event_indexes.rb +9 -0
- data/db/migrate/20260308140000_remove_position_from_events.rb +8 -0
- data/db/migrate/20260308150000_add_token_count_to_events.rb +7 -0
- data/db/migrate/20260308160000_add_tool_use_id_to_events.rb +8 -0
- data/db/queue_schema.rb +141 -0
- data/db/seeds.rb +1 -0
- data/exe/anima +6 -0
- data/lib/anima/cli.rb +55 -0
- data/lib/anima/installer.rb +118 -0
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +4 -0
- data/lib/events/agent_message.rb +11 -0
- data/lib/events/base.rb +38 -0
- data/lib/events/bus.rb +39 -0
- data/lib/events/subscriber.rb +26 -0
- data/lib/events/subscribers/message_collector.rb +64 -0
- data/lib/events/subscribers/persister.rb +46 -0
- data/lib/events/system_message.rb +11 -0
- data/lib/events/tool_call.rb +29 -0
- data/lib/events/tool_response.rb +33 -0
- data/lib/events/user_message.rb +11 -0
- data/lib/llm/client.rb +161 -0
- data/lib/providers/anthropic.rb +164 -0
- data/lib/shell_session.rb +333 -0
- data/lib/tools/base.rb +58 -0
- data/lib/tools/bash.rb +53 -0
- data/lib/tools/registry.rb +60 -0
- data/lib/tools/web_get.rb +62 -0
- data/lib/tui/app.rb +181 -0
- data/lib/tui/screens/anthropic.rb +25 -0
- data/lib/tui/screens/chat.rb +210 -0
- data/lib/tui/screens/settings.rb +52 -0
- metadata +124 -4
- 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
|
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
|