anima-core 1.3.0 → 1.5.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 +23 -26
- data/README.md +118 -104
- data/agents/thoughts-analyzer.md +12 -7
- data/anima-core.gemspec +1 -0
- data/app/channels/session_channel.rb +38 -58
- data/app/decorators/agent_message_decorator.rb +7 -2
- data/app/decorators/message_decorator.rb +31 -100
- data/app/decorators/pending_from_melete_decorator.rb +36 -0
- data/app/decorators/pending_from_melete_goal_decorator.rb +13 -0
- data/app/decorators/pending_from_melete_skill_decorator.rb +19 -0
- data/app/decorators/pending_from_melete_workflow_decorator.rb +13 -0
- data/app/decorators/pending_from_mneme_decorator.rb +44 -0
- data/app/decorators/pending_message_decorator.rb +94 -0
- data/app/decorators/pending_subagent_decorator.rb +46 -0
- data/app/decorators/pending_tool_response_decorator.rb +51 -0
- data/app/decorators/pending_user_message_decorator.rb +22 -0
- data/app/decorators/system_message_decorator.rb +5 -0
- data/app/decorators/tool_call_decorator.rb +16 -5
- data/app/decorators/tool_response_decorator.rb +2 -2
- data/app/decorators/user_message_decorator.rb +7 -2
- data/app/jobs/count_tokens_job.rb +23 -0
- data/app/jobs/drain_job.rb +169 -0
- data/app/jobs/melete_enrichment_job/goal_change_listener.rb +52 -0
- data/app/jobs/melete_enrichment_job.rb +48 -0
- data/app/jobs/mneme_enrichment_job.rb +46 -0
- data/app/jobs/tool_execution_job.rb +87 -0
- data/app/models/concerns/token_estimation.rb +54 -0
- data/app/models/goal.rb +23 -11
- data/app/models/message.rb +46 -48
- data/app/models/pending_message.rb +407 -12
- data/app/models/pinned_message.rb +8 -3
- data/app/models/session.rb +660 -566
- data/app/models/snapshot.rb +11 -21
- data/bin/inspect-cassette +157 -0
- data/bin/release +212 -0
- data/bin/with-llms +20 -0
- data/config/application.rb +1 -0
- data/config/database.yml +1 -0
- data/config/initializers/event_subscribers.rb +71 -4
- data/config/initializers/inflections.rb +3 -1
- data/db/cable_structure.sql +9 -0
- data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
- data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
- data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
- data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
- data/db/migrate/20260407170803_remove_viewport_message_ids_from_sessions.rb +5 -0
- data/db/migrate/20260407180400_remove_mneme_snapshot_pointer_columns_from_sessions.rb +6 -0
- data/db/migrate/20260411120553_add_token_count_to_pinned_messages.rb +5 -0
- data/db/migrate/20260411172926_remove_active_skills_and_workflow_from_sessions.rb +6 -0
- data/db/migrate/20260412110625_replace_processing_with_aasm_state.rb +6 -0
- data/db/migrate/20260418150323_add_kind_and_message_type_to_pending_messages.rb +6 -0
- data/db/migrate/20260419120000_add_drain_fields_to_pending_messages.rb +7 -0
- data/db/migrate/20260419130000_drop_pending_messages_kind_default.rb +5 -0
- data/db/migrate/20260419140000_add_drain_indexes_to_pending_messages.rb +8 -0
- data/db/migrate/20260420100000_add_hud_visibility_to_sessions.rb +15 -0
- data/db/queue_structure.sql +61 -0
- data/db/structure.sql +133 -0
- data/lib/agents/registry.rb +1 -1
- data/lib/anima/cli.rb +41 -13
- data/lib/anima/installer.rb +13 -0
- data/lib/anima/settings.rb +16 -36
- data/lib/anima/version.rb +1 -1
- data/lib/events/authentication_required.rb +24 -0
- data/lib/events/bounce_back.rb +4 -4
- data/lib/events/eviction_completed.rb +28 -0
- data/lib/events/goal_created.rb +28 -0
- data/lib/events/goal_updated.rb +32 -0
- data/lib/events/llm_responded.rb +35 -0
- data/lib/events/message_created.rb +27 -0
- data/lib/events/message_updated.rb +25 -0
- data/lib/events/session_state_changed.rb +30 -0
- data/lib/events/skill_activated.rb +28 -0
- data/lib/events/start_melete.rb +36 -0
- data/lib/events/start_mneme.rb +33 -0
- data/lib/events/start_processing.rb +32 -0
- data/lib/events/subagent_evicted.rb +31 -0
- data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
- data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
- data/lib/events/subscribers/drain_kickoff.rb +20 -0
- data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
- data/lib/events/subscribers/llm_response_handler.rb +111 -0
- data/lib/events/subscribers/melete_kickoff.rb +24 -0
- data/lib/events/subscribers/message_broadcaster.rb +34 -0
- data/lib/events/subscribers/mneme_kickoff.rb +24 -0
- data/lib/events/subscribers/mneme_scheduler.rb +21 -0
- data/lib/events/subscribers/persister.rb +8 -9
- data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
- data/lib/events/subscribers/subagent_message_router.rb +28 -34
- data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
- data/lib/events/subscribers/tool_response_creator.rb +33 -0
- data/lib/events/subscribers/transient_broadcaster.rb +1 -1
- data/lib/events/tool_executed.rb +34 -0
- data/lib/events/workflow_activated.rb +27 -0
- data/lib/llm/client.rb +46 -199
- data/lib/mcp/client_manager.rb +41 -46
- data/lib/mcp/stdio_transport.rb +9 -5
- data/lib/{analytical_brain → melete}/runner.rb +73 -68
- data/lib/{analytical_brain → melete}/tools/activate_skill.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
- data/lib/{analytical_brain → melete}/tools/finish_goal.rb +6 -3
- data/lib/melete/tools/goal_messaging.rb +29 -0
- data/lib/{analytical_brain → melete}/tools/read_workflow.rb +4 -4
- data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/set_goal.rb +6 -2
- data/lib/{analytical_brain → melete}/tools/update_goal.rb +9 -5
- data/lib/{analytical_brain.rb → melete.rb} +6 -3
- data/lib/mneme/base_runner.rb +121 -0
- data/lib/mneme/l2_runner.rb +14 -20
- data/lib/mneme/recall_runner.rb +132 -0
- data/lib/mneme/runner.rb +123 -165
- data/lib/mneme/search.rb +104 -62
- data/lib/mneme/tools/nothing_to_surface.rb +25 -0
- data/lib/mneme/tools/save_snapshot.rb +2 -10
- data/lib/mneme/tools/surface_memory.rb +89 -0
- data/lib/mneme.rb +11 -5
- data/lib/providers/anthropic.rb +112 -7
- data/lib/shell_session.rb +290 -432
- data/lib/skills/definition.rb +2 -2
- data/lib/skills/registry.rb +1 -1
- data/lib/tools/base.rb +16 -1
- data/lib/tools/bash.rb +25 -55
- data/lib/tools/edit.rb +2 -0
- data/lib/tools/mark_goal_completed.rb +4 -5
- data/lib/tools/read.rb +2 -0
- data/lib/tools/registry.rb +85 -4
- data/lib/tools/response_truncator.rb +1 -1
- data/lib/tools/{recall.rb → search_messages.rb} +19 -21
- data/lib/tools/spawn_specialist.rb +22 -14
- data/lib/tools/spawn_subagent.rb +30 -20
- data/lib/tools/subagent_prompts.rb +17 -19
- data/lib/tools/think.rb +1 -1
- data/lib/tools/{remember.rb → view_messages.rb} +10 -10
- data/lib/tools/write.rb +2 -0
- data/lib/tui/app.rb +393 -149
- data/lib/tui/braille_spinner.rb +7 -7
- data/lib/tui/cable_client.rb +9 -16
- data/lib/tui/decorators/base_decorator.rb +47 -6
- data/lib/tui/decorators/bash_decorator.rb +1 -1
- data/lib/tui/decorators/edit_decorator.rb +4 -2
- data/lib/tui/decorators/read_decorator.rb +4 -2
- data/lib/tui/decorators/think_decorator.rb +2 -2
- data/lib/tui/decorators/web_get_decorator.rb +1 -1
- data/lib/tui/decorators/write_decorator.rb +4 -2
- data/lib/tui/flash.rb +19 -14
- data/lib/tui/formatting.rb +20 -9
- data/lib/tui/input_buffer.rb +6 -6
- data/lib/tui/message_store.rb +165 -28
- data/lib/tui/performance_logger.rb +2 -3
- data/lib/tui/screens/chat.rb +149 -79
- data/lib/tui/settings.rb +93 -0
- data/lib/workflows/definition.rb +3 -3
- data/lib/workflows/registry.rb +1 -1
- data/skills/github.md +38 -0
- data/templates/config.toml +16 -32
- data/templates/tui.toml +209 -0
- data/workflows/review_pr.md +18 -14
- metadata +98 -29
- data/app/jobs/agent_request_job.rb +0 -199
- data/app/jobs/analytical_brain_job.rb +0 -33
- data/app/jobs/count_message_tokens_job.rb +0 -39
- data/app/jobs/passive_recall_job.rb +0 -29
- data/app/models/concerns/message/broadcasting.rb +0 -85
- data/config/initializers/fts5_schema_dump.rb +0 -21
- data/lib/agent_loop.rb +0 -186
- data/lib/analytical_brain/tools/deactivate_skill.rb +0 -39
- data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -34
- data/lib/environment_probe.rb +0 -232
- data/lib/events/agent_message.rb +0 -11
- data/lib/events/subscribers/message_collector.rb +0 -64
- data/lib/events/tool_call.rb +0 -31
- data/lib/events/tool_response.rb +0 -33
- data/lib/mneme/compressed_viewport.rb +0 -200
- data/lib/mneme/passive_recall.rb +0 -69
data/lib/shell_session.rb
CHANGED
|
@@ -1,504 +1,362 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
4
|
-
require "pty"
|
|
3
|
+
require "open3"
|
|
5
4
|
require "securerandom"
|
|
6
5
|
require "shellwords"
|
|
7
6
|
|
|
8
|
-
# Persistent shell session backed by a
|
|
9
|
-
#
|
|
10
|
-
#
|
|
7
|
+
# Persistent shell session backed by a tmux session. Commands share
|
|
8
|
+
# working directory, environment, and shell history within a conversation.
|
|
9
|
+
# Multiple tools share the same session via {.for_session}.
|
|
11
10
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
11
|
+
# tmux is the source of truth — the {ShellSession} object is a disposable
|
|
12
|
+
# handle. The tmux session survives Anima crashes; teardown happens only
|
|
13
|
+
# through {.release} or {#finalize} (e.g. when the owning {Session} record
|
|
14
|
+
# is deleted).
|
|
14
15
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
16
|
+
# Sub-agents inherit cwd from their parent's tmux session at the moment
|
|
17
|
+
# the child shell is created. The lookup is dynamic — the parent's
|
|
18
|
+
# *current* cwd is captured, not a snapshot from spawn time.
|
|
19
|
+
#
|
|
20
|
+
# tmux is a hard runtime dependency. {#initialize} raises a clear error if
|
|
21
|
+
# tmux is missing.
|
|
19
22
|
#
|
|
20
23
|
# @example
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
# # => {
|
|
25
|
-
# session.finalize
|
|
24
|
+
# shell = ShellSession.for_session(session)
|
|
25
|
+
# shell.run("cd /tmp")
|
|
26
|
+
# shell.run("pwd")
|
|
27
|
+
# # => {output: "/tmp"}
|
|
26
28
|
class ShellSession
|
|
27
|
-
#
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
29
|
+
# Prefix for every tmux session Anima owns. The full session name is
|
|
30
|
+
# +anima-shell-{session_id}+; this prefix is what cleanup sweeps
|
|
31
|
+
# (current and future) match on to leave unrelated tmux sessions alone.
|
|
32
|
+
TMUX_SESSION_PREFIX = "anima-shell-"
|
|
33
|
+
|
|
34
|
+
# Pane geometry — 200×50 is wide enough for most tool output without
|
|
35
|
+
# forcing wraps that would inflate captures, and tall enough that the
|
|
36
|
+
# agent sees normal command runs without scrollback in the visible area.
|
|
37
|
+
PANE_WIDTH = 200
|
|
38
|
+
PANE_HEIGHT = 50
|
|
39
|
+
|
|
40
|
+
# Scrollback cap. tmux retains the last N lines of output per pane,
|
|
41
|
+
# discarding older ones automatically — this is what bounds memory and
|
|
42
|
+
# closes the OOM bug from the old PTY+FIFO design. Each line costs
|
|
43
|
+
# roughly 1–2KB inside tmux, so 5000 lines ≈ 5–10MB resident per pane.
|
|
44
|
+
HISTORY_LIMIT = 5_000
|
|
45
|
+
|
|
46
|
+
# Env vars that disable interactive pagers and credential prompts in
|
|
47
|
+
# the shell. Without these, tools like +gh+, +git+, +man+, +journalctl+
|
|
48
|
+
# spawn +less+ and block the pane waiting for keypresses — our
|
|
49
|
+
# +wait-for -S+ never fires, the run hangs to timeout. Set once at
|
|
50
|
+
# session creation via +new-session -e+ so they propagate to every
|
|
51
|
+
# command.
|
|
52
|
+
SHELL_ENV = {
|
|
53
|
+
"PAGER" => "cat",
|
|
54
|
+
"GIT_PAGER" => "cat",
|
|
55
|
+
"MANPAGER" => "cat",
|
|
56
|
+
"LESS" => "-eFRX",
|
|
57
|
+
"SYSTEMD_PAGER" => "",
|
|
58
|
+
"AWS_PAGER" => "",
|
|
59
|
+
"PSQL_PAGER" => "cat",
|
|
60
|
+
"BAT_PAGER" => "cat",
|
|
61
|
+
"GIT_TERMINAL_PROMPT" => "0"
|
|
62
|
+
}.freeze
|
|
43
63
|
|
|
44
|
-
#
|
|
45
|
-
#
|
|
64
|
+
# tmux format-string for the pane's current working directory.
|
|
65
|
+
# Single-quoted intentionally — tmux performs the +#{...}+ substitution
|
|
66
|
+
# server-side, so Ruby must pass the literal string.
|
|
67
|
+
PANE_CWD_FORMAT = '#{pane_current_path}' # rubocop:disable Lint/InterpolationCheck
|
|
68
|
+
|
|
69
|
+
# Grace period before escalating SIGTERM → SIGKILL when reaping a
|
|
70
|
+
# wedged +tmux wait-for+ child. tmux clients normally exit on TERM
|
|
71
|
+
# within milliseconds; 5 seconds is generous enough that a healthy
|
|
72
|
+
# one always makes it, while an unkillable one never hangs the shell.
|
|
73
|
+
WAITER_KILL_GRACE = 5
|
|
74
|
+
|
|
75
|
+
# Serializes the cold-start path of {.for_session} / {#initialize} —
|
|
76
|
+
# +alive?+ → +new-session+ → +inject_shell_env+. Without it, two
|
|
77
|
+
# threads racing on the same +session_id+ both see +alive?+ false,
|
|
78
|
+
# both run +new-session+ (the second silently fails), and both run
|
|
79
|
+
# +inject_shell_env+, double-exporting and corrupting the pane.
|
|
80
|
+
# Held only during cold start; warm-path callers don't contend.
|
|
81
|
+
INIT_MUTEX = Mutex.new
|
|
82
|
+
|
|
83
|
+
# @return [Integer, String] identifier of the {Session} this shell belongs to
|
|
84
|
+
attr_reader :session_id
|
|
85
|
+
|
|
86
|
+
# Returns the shell bound to +session+. Sub-agents inherit cwd from
|
|
87
|
+
# their parent's tmux session via {.cwd_via_tmux}, falling back to
|
|
88
|
+
# +session.initial_cwd+ for root sessions or when the parent's tmux
|
|
89
|
+
# session is gone.
|
|
46
90
|
#
|
|
47
|
-
# @param
|
|
48
|
-
# @
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
# {Anima::Settings.interrupt_check_interval} seconds during command execution.
|
|
53
|
-
# @return [Hash] with :stdout, :stderr, :exit_code keys on success
|
|
54
|
-
# @return [Hash] with :interrupted, :stdout, :stderr keys on user interrupt
|
|
55
|
-
# @return [Hash] with :error key on failure
|
|
56
|
-
def run(command, timeout: nil, interrupt_check: nil)
|
|
57
|
-
@mutex.synchronize do
|
|
58
|
-
return {error: "Shell session is not running"} if @finalized
|
|
59
|
-
restart unless @alive
|
|
60
|
-
execute_in_pty(command, timeout: timeout, interrupt_check: interrupt_check)
|
|
61
|
-
end
|
|
62
|
-
rescue => error # rubocop:disable Lint/RescueException -- LLM must always get a result hash, never a stack trace
|
|
63
|
-
{error: "#{error.class}: #{error.message}"}
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
# Clean up PTY, FIFO, and child process. Permanent — the session
|
|
67
|
-
# will not auto-respawn after this call.
|
|
68
|
-
def finalize
|
|
69
|
-
@mutex.synchronize do
|
|
70
|
-
@finalized = true
|
|
71
|
-
teardown
|
|
72
|
-
end
|
|
73
|
-
self.class.unregister(self)
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
# @return [Boolean] whether the shell process is still running
|
|
77
|
-
def alive?
|
|
78
|
-
@mutex.synchronize { @alive }
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
# --- Class-level session tracking for at_exit cleanup ---
|
|
82
|
-
|
|
83
|
-
@sessions = []
|
|
84
|
-
@sessions_mutex = Mutex.new
|
|
85
|
-
|
|
86
|
-
class << self
|
|
87
|
-
# @api private
|
|
88
|
-
def register(session)
|
|
89
|
-
@sessions_mutex.synchronize { @sessions << session }
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
# @api private
|
|
93
|
-
def unregister(session)
|
|
94
|
-
@sessions_mutex.synchronize { @sessions.delete(session) }
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
# Finalize all live sessions. Called automatically via at_exit.
|
|
98
|
-
def cleanup_all
|
|
99
|
-
@sessions_mutex.synchronize do
|
|
100
|
-
@sessions.each { |session| session.send(:teardown) }
|
|
101
|
-
@sessions.clear
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
# Remove stale FIFO files left by crashed processes.
|
|
106
|
-
# FIFO naming format: anima-stderr-{pid}-{hex}
|
|
107
|
-
def cleanup_orphans
|
|
108
|
-
Dir.glob(File.join(Dir.tmpdir, "anima-stderr-*")).each do |path|
|
|
109
|
-
match = File.basename(path).match(/\Aanima-stderr-(\d+)-/)
|
|
110
|
-
next unless match
|
|
111
|
-
|
|
112
|
-
pid = match[1].to_i
|
|
113
|
-
next if pid <= 0
|
|
114
|
-
|
|
115
|
-
begin
|
|
116
|
-
Process.kill(0, pid)
|
|
117
|
-
rescue Errno::ESRCH
|
|
118
|
-
begin
|
|
119
|
-
File.delete(path)
|
|
120
|
-
rescue SystemCallError
|
|
121
|
-
# Best-effort cleanup
|
|
122
|
-
end
|
|
123
|
-
rescue Errno::EPERM
|
|
124
|
-
# Process exists but we can't signal it — leave it
|
|
125
|
-
end
|
|
126
|
-
end
|
|
127
|
-
end
|
|
91
|
+
# @param session [Session] owning conversation
|
|
92
|
+
# @return [ShellSession]
|
|
93
|
+
def self.for_session(session)
|
|
94
|
+
cwd = parent_cwd_for(session) || session.initial_cwd
|
|
95
|
+
new(session_id: session.id, initial_cwd: cwd)
|
|
128
96
|
end
|
|
129
97
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
private
|
|
133
|
-
|
|
134
|
-
def start
|
|
135
|
-
create_fifo
|
|
136
|
-
spawn_shell
|
|
137
|
-
start_stderr_reader
|
|
138
|
-
init_shell
|
|
139
|
-
update_pwd
|
|
140
|
-
@alive = true
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
# Shuts down the current shell and spawns a fresh one, restoring the
|
|
144
|
-
# previous working directory. Called automatically when @alive is false.
|
|
145
|
-
def restart
|
|
146
|
-
saved_pwd = @pwd
|
|
147
|
-
teardown
|
|
148
|
-
@fifo_path = File.join(Dir.tmpdir, "anima-stderr-#{Process.pid}-#{SecureRandom.hex(8)}")
|
|
149
|
-
start
|
|
150
|
-
restore_working_directory(saved_pwd)
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
# Restores the shell's working directory after a respawn.
|
|
154
|
-
# Skips silently if the directory no longer exists.
|
|
98
|
+
# Kills the tmux session for +session_id+. Idempotent — silently
|
|
99
|
+
# succeeds when no such session exists.
|
|
155
100
|
#
|
|
156
|
-
# @param
|
|
101
|
+
# @param session_id [Integer, String]
|
|
157
102
|
# @return [void]
|
|
158
|
-
def
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
def create_fifo
|
|
164
|
-
File.mkfifo(@fifo_path, 0o600)
|
|
165
|
-
rescue Errno::EEXIST
|
|
166
|
-
# FIFO already exists — reuse it
|
|
103
|
+
def self.release(session_id)
|
|
104
|
+
target = "#{TMUX_SESSION_PREFIX}#{session_id}"
|
|
105
|
+
system("tmux", "kill-session", "-t", target, out: File::NULL, err: File::NULL)
|
|
106
|
+
nil
|
|
167
107
|
end
|
|
168
108
|
|
|
169
|
-
#
|
|
170
|
-
#
|
|
171
|
-
#
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
#
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
"SYSTEMD_PAGER" => "", # journalctl, systemctl (empty = disable)
|
|
182
|
-
"BAT_PAGER" => "cat", # bat (cat alternative)
|
|
183
|
-
"AWS_PAGER" => "", # AWS CLI v2 (empty = disable)
|
|
184
|
-
"PSQL_PAGER" => "cat", # PostgreSQL psql
|
|
185
|
-
"GIT_TERMINAL_PROMPT" => "0" # Fail immediately instead of prompting for credentials
|
|
186
|
-
}.freeze
|
|
187
|
-
|
|
188
|
-
def spawn_shell
|
|
189
|
-
@pty_stdout, @pty_stdin, @pid = PTY.spawn(
|
|
190
|
-
SHELL_ENV,
|
|
191
|
-
"bash", "--norc", "--noprofile"
|
|
109
|
+
# Reads the working directory of +session_id+'s tmux pane directly
|
|
110
|
+
# from the tmux server. Works even when the pane is mid-command — the
|
|
111
|
+
# +pane_current_path+ format variable is a server-side property
|
|
112
|
+
# (kernel +/proc/{pid}/cwd+ readlink), not shell-mediated.
|
|
113
|
+
#
|
|
114
|
+
# @param session_id [Integer, String]
|
|
115
|
+
# @return [String, nil] absolute path, or nil when the session is gone
|
|
116
|
+
def self.cwd_via_tmux(session_id)
|
|
117
|
+
target = "#{TMUX_SESSION_PREFIX}#{session_id}"
|
|
118
|
+
output, status = Open3.capture2(
|
|
119
|
+
"tmux", "display-message", "-p", "-t", target, PANE_CWD_FORMAT,
|
|
120
|
+
err: File::NULL
|
|
192
121
|
)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
122
|
+
return nil unless status.success?
|
|
123
|
+
cwd = output.strip
|
|
124
|
+
cwd.empty? ? nil : cwd
|
|
196
125
|
end
|
|
197
126
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
@max_output_bytes = Anima::Settings.max_output_bytes
|
|
204
|
-
@stderr_thread = Thread.new do
|
|
205
|
-
max_bytes = @max_output_bytes
|
|
206
|
-
File.open(@fifo_path, "r") do |fifo|
|
|
207
|
-
while (line = fifo.gets)
|
|
208
|
-
cleaned = line.chomp.delete("\r")
|
|
209
|
-
@stderr_mutex.synchronize do
|
|
210
|
-
if @stderr_bytes < max_bytes
|
|
211
|
-
@stderr_buffer << cleaned
|
|
212
|
-
@stderr_bytes += cleaned.bytesize
|
|
213
|
-
else
|
|
214
|
-
@stderr_truncated = true
|
|
215
|
-
end
|
|
216
|
-
end
|
|
217
|
-
end
|
|
218
|
-
end
|
|
219
|
-
rescue Errno::ENOENT, IOError
|
|
220
|
-
# FIFO was cleaned up or closed
|
|
221
|
-
end
|
|
127
|
+
# @return [String, nil] parent's current working directory, or nil for
|
|
128
|
+
# root sessions and when the parent's tmux session is gone
|
|
129
|
+
def self.parent_cwd_for(session)
|
|
130
|
+
return nil unless session.parent_session_id
|
|
131
|
+
cwd_via_tmux(session.parent_session_id)
|
|
222
132
|
end
|
|
133
|
+
private_class_method :parent_cwd_for
|
|
223
134
|
|
|
224
|
-
#
|
|
225
|
-
#
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
@
|
|
229
|
-
@
|
|
230
|
-
|
|
231
|
-
unless consume_until(marker, deadline: monotonic_now + 10)
|
|
232
|
-
raise IOError, "Shell initialization timed out"
|
|
233
|
-
end
|
|
135
|
+
# @param session_id [Integer, String]
|
|
136
|
+
# @param initial_cwd [String, nil] starting working directory
|
|
137
|
+
# @raise [RuntimeError] if tmux is missing or the session can't be created
|
|
138
|
+
def initialize(session_id:, initial_cwd: nil)
|
|
139
|
+
@session_id = session_id
|
|
140
|
+
@target = "#{TMUX_SESSION_PREFIX}#{session_id}"
|
|
141
|
+
INIT_MUTEX.synchronize { ensure_session(initial_cwd) }
|
|
234
142
|
end
|
|
235
143
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
144
|
+
# Execute a command in the persistent shell.
|
|
145
|
+
#
|
|
146
|
+
# Capture sequence:
|
|
147
|
+
# 1. +tmux clear-history+ wipes off-screen scrollback.
|
|
148
|
+
# 2. +send-keys "clear; <cmd>; tmux wait-for -S done-<uuid>"+ — bash
|
|
149
|
+
# receives the line: shell +clear+ erases the visible pane (and
|
|
150
|
+
# scrollback via the +\e[3J+ sequence on modern terminfo), +<cmd>+
|
|
151
|
+
# runs, then +wait-for -S+ signals the synchronization channel.
|
|
152
|
+
# 3. We block on +tmux wait-for done-<uuid>+ in a child process and
|
|
153
|
+
# poll for interrupt/timeout. On either we send +C-c+ to the pane
|
|
154
|
+
# and kill the wait-for child — interactive bash discards the
|
|
155
|
+
# rest of the input line on SIGINT, so the trailing +wait-for -S+
|
|
156
|
+
# never fires and we can't rely on natural signaling.
|
|
157
|
+
# 4. +capture-pane -pJ -S -+ pulls scrollback + visible pane, which
|
|
158
|
+
# (after +clear+ wiped both) is exactly the new command's output
|
|
159
|
+
# and trailing prompt.
|
|
160
|
+
#
|
|
161
|
+
# @param command [String] bash command to execute
|
|
162
|
+
# @param timeout [Integer, nil] per-call timeout in seconds; defaults to
|
|
163
|
+
# {Anima::Settings.command_timeout}
|
|
164
|
+
# @param interrupt_check [Proc, nil] callable returning truthy when the
|
|
165
|
+
# user has requested an interrupt
|
|
166
|
+
# @return [Hash{Symbol => Object}] +:output+ on success;
|
|
167
|
+
# +:output+ + +:interrupted+ on user cancel; +:error+ on failure
|
|
168
|
+
def run(command, timeout: nil, interrupt_check: nil)
|
|
169
|
+
return {error: "Shell session is not running"} unless alive?
|
|
170
|
+
|
|
171
|
+
uuid = SecureRandom.hex(8)
|
|
239
172
|
timeout ||= Anima::Settings.command_timeout
|
|
240
|
-
deadline = monotonic_now + timeout
|
|
241
173
|
|
|
242
|
-
|
|
174
|
+
system("tmux", "clear-history", "-t", @target, out: File::NULL, err: File::NULL)
|
|
175
|
+
line = "clear; #{command}; tmux wait-for -S done-#{uuid}"
|
|
176
|
+
system("tmux", "send-keys", "-t", @target, line, "Enter", out: File::NULL, err: File::NULL)
|
|
243
177
|
|
|
244
|
-
|
|
178
|
+
state = wait_for_completion(uuid, timeout, interrupt_check)
|
|
179
|
+
output = capture_output
|
|
245
180
|
|
|
246
|
-
|
|
247
|
-
recover_shell
|
|
248
|
-
update_pwd
|
|
249
|
-
stderr = drain_stderr
|
|
250
|
-
return {
|
|
251
|
-
interrupted: true,
|
|
252
|
-
stdout: truncate(stdout),
|
|
253
|
-
stderr: truncate(stderr)
|
|
254
|
-
}
|
|
255
|
-
end
|
|
181
|
+
return {error: "tmux capture-pane failed (session may have died)"} if output.nil?
|
|
256
182
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
parts << "Partial stdout:\n#{truncate(stdout)}" unless stdout.empty?
|
|
262
|
-
parts << "stderr:\n#{truncate(stderr)}" unless stderr.empty?
|
|
263
|
-
return {error: parts.join("\n\n")}
|
|
183
|
+
case state
|
|
184
|
+
when :done then {output: output}
|
|
185
|
+
when :interrupted then {output: output, interrupted: true}
|
|
186
|
+
when :timeout then {error: "Command timed out after #{timeout} seconds.\n\n#{output}"}
|
|
264
187
|
end
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
{
|
|
270
|
-
stdout: truncate(stdout),
|
|
271
|
-
stderr: truncate(stderr),
|
|
272
|
-
exit_code: exit_code
|
|
273
|
-
}
|
|
274
|
-
rescue Errno::EIO, IOError
|
|
275
|
-
@alive = false
|
|
276
|
-
{error: "Shell session terminated unexpectedly"}
|
|
277
|
-
rescue => error # rubocop:disable Lint/RescueException -- LLM must always get a result hash, never a stack trace
|
|
188
|
+
rescue => error
|
|
189
|
+
# Catch-all isolates the LLM tool-call boundary: a stray exception
|
|
190
|
+
# from tmux internals must surface as a result hash rather than tear
|
|
191
|
+
# down the conversation pipeline.
|
|
278
192
|
{error: "#{error.class}: #{error.message}"}
|
|
279
193
|
end
|
|
280
194
|
|
|
281
|
-
#
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
# @param interrupt_check [Proc, nil] callable returning truthy on user interrupt
|
|
286
|
-
# @return [Array(String, Integer)] stdout and exit code on success
|
|
287
|
-
# @return [Array(String, Symbol)] partial stdout and +:interrupted+ on user interrupt
|
|
288
|
-
# @return [Array(String, nil)] partial stdout and nil exit code on timeout
|
|
289
|
-
def read_until_marker(marker, deadline:, interrupt_check: nil)
|
|
290
|
-
lines = []
|
|
291
|
-
exit_code = nil
|
|
292
|
-
check_interval = interrupt_check ? [Anima::Settings.interrupt_check_interval, 0.5].max : nil
|
|
293
|
-
|
|
294
|
-
loop do
|
|
295
|
-
line = gets_with_deadline(deadline, interrupt_check: interrupt_check, check_interval: check_interval)
|
|
195
|
+
# @return [Boolean] whether the underlying tmux session exists
|
|
196
|
+
def alive?
|
|
197
|
+
!!system("tmux", "has-session", "-t", @target, out: File::NULL, err: File::NULL)
|
|
198
|
+
end
|
|
296
199
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
200
|
+
# Kills the underlying tmux session. Idempotent.
|
|
201
|
+
def finalize
|
|
202
|
+
self.class.release(@session_id)
|
|
203
|
+
end
|
|
301
204
|
|
|
302
|
-
|
|
205
|
+
# Reads the shell's current working directory directly from the tmux
|
|
206
|
+
# server. Works even mid-command — the lookup is server-side, not
|
|
207
|
+
# shell-mediated.
|
|
208
|
+
#
|
|
209
|
+
# @return [String, nil]
|
|
210
|
+
def pwd
|
|
211
|
+
self.class.cwd_via_tmux(@session_id)
|
|
212
|
+
end
|
|
303
213
|
|
|
304
|
-
|
|
214
|
+
private
|
|
305
215
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
break
|
|
309
|
-
end
|
|
216
|
+
def ensure_session(cwd)
|
|
217
|
+
return if alive?
|
|
310
218
|
|
|
311
|
-
|
|
219
|
+
unless system("tmux", "-V", out: File::NULL, err: File::NULL)
|
|
220
|
+
raise "tmux is not installed. Install it with your package manager (e.g. `apt install tmux`)."
|
|
312
221
|
end
|
|
313
222
|
|
|
314
|
-
|
|
315
|
-
|
|
223
|
+
args = ["tmux", "new-session", "-d", "-s", @target, "-x", PANE_WIDTH.to_s, "-y", PANE_HEIGHT.to_s]
|
|
224
|
+
args.push("-c", cwd) if cwd && File.directory?(cwd)
|
|
225
|
+
system(*args, out: File::NULL, err: File::NULL)
|
|
226
|
+
|
|
227
|
+
raise "tmux session #{@target} could not be created" unless alive?
|
|
228
|
+
|
|
229
|
+
system(
|
|
230
|
+
"tmux", "set-option", "-t", @target, "history-limit", HISTORY_LIMIT.to_s,
|
|
231
|
+
out: File::NULL, err: File::NULL
|
|
232
|
+
)
|
|
316
233
|
|
|
317
|
-
|
|
234
|
+
inject_shell_env
|
|
318
235
|
end
|
|
319
236
|
|
|
320
|
-
#
|
|
321
|
-
#
|
|
322
|
-
#
|
|
323
|
-
#
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
237
|
+
# Sends +export+ statements to the pane after the user's login shell
|
|
238
|
+
# has sourced its rcfiles, so our pager-disabling env beats any
|
|
239
|
+
# +export PAGER=less+ in +~/.zshrc+ etc. Blocks via +wait-for+ so
|
|
240
|
+
# subsequent {#run} calls see the new env.
|
|
241
|
+
def inject_shell_env
|
|
242
|
+
uuid = SecureRandom.hex(8)
|
|
243
|
+
exports = SHELL_ENV.map { |k, v| "export #{k}=#{v.empty? ? "''" : v.shellescape}" }.join("; ")
|
|
244
|
+
line = "#{exports}; tmux wait-for -S init-#{uuid}"
|
|
245
|
+
system("tmux", "send-keys", "-t", @target, line, "Enter", out: File::NULL, err: File::NULL)
|
|
246
|
+
pid = Process.spawn("tmux", "wait-for", "init-#{uuid}", out: File::NULL, err: File::NULL)
|
|
247
|
+
waiter = Process.detach(pid)
|
|
248
|
+
return if waiter.join(WAITER_KILL_GRACE)
|
|
249
|
+
|
|
250
|
+
reap_waiter(pid, waiter)
|
|
251
|
+
raise "tmux session #{@target} init timed out"
|
|
333
252
|
end
|
|
334
253
|
|
|
335
|
-
#
|
|
336
|
-
#
|
|
254
|
+
# Blocks until the +tmux wait-for+ child exits (= the bash command in
|
|
255
|
+
# the pane finished and ran +tmux wait-for -S+), the deadline expires,
|
|
256
|
+
# or the user requests an interrupt.
|
|
337
257
|
#
|
|
338
|
-
#
|
|
339
|
-
#
|
|
340
|
-
#
|
|
258
|
+
# No polling: {Process.detach} returns a Thread that waits on the
|
|
259
|
+
# child, and {Thread#join} blocks until either the thread finishes or
|
|
260
|
+
# the timeout fires — returning immediately when the child exits.
|
|
341
261
|
#
|
|
342
|
-
#
|
|
343
|
-
#
|
|
344
|
-
#
|
|
345
|
-
#
|
|
262
|
+
# On cancel we send +C-c+ to abort the running command, then kill the
|
|
263
|
+
# wait-for child directly — interactive bash discards the rest of the
|
|
264
|
+
# input line on SIGINT, so the trailing +tmux wait-for -S+ never fires
|
|
265
|
+
# and we can't rely on natural signaling.
|
|
346
266
|
#
|
|
347
|
-
# @
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
# @return [nil] if deadline expired
|
|
354
|
-
# @raise [Errno::EIO] when the PTY child process exits (Linux)
|
|
355
|
-
# @raise [IOError] when the PTY file descriptor is closed
|
|
356
|
-
def gets_with_deadline(deadline, interrupt_check: nil, check_interval: nil)
|
|
357
|
-
loop do
|
|
358
|
-
if (idx = @read_buffer.index("\n"))
|
|
359
|
-
return @read_buffer.slice!(0..idx)
|
|
360
|
-
end
|
|
267
|
+
# @return [Symbol] +:done+, +:interrupted+, or +:timeout+
|
|
268
|
+
def wait_for_completion(uuid, timeout, interrupt_check)
|
|
269
|
+
pid = Process.spawn("tmux", "wait-for", "done-#{uuid}", out: File::NULL, err: File::NULL)
|
|
270
|
+
waiter = Process.detach(pid)
|
|
271
|
+
deadline = monotonic_now + timeout
|
|
272
|
+
interrupt_interval = interrupt_check ? Anima::Settings.interrupt_check_interval : timeout
|
|
361
273
|
|
|
274
|
+
loop do
|
|
362
275
|
remaining = deadline - monotonic_now
|
|
363
|
-
return
|
|
364
|
-
|
|
365
|
-
select_timeout = check_interval ? [remaining, check_interval].min : remaining
|
|
366
|
-
|
|
367
|
-
ready = IO.select([@pty_stdout], nil, nil, select_timeout)
|
|
276
|
+
return cancel_command(pid, waiter, :timeout) if remaining <= 0
|
|
368
277
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
rescue IO::WaitReadable
|
|
373
|
-
# Spurious wakeup from IO.select — retry
|
|
374
|
-
end
|
|
375
|
-
end
|
|
278
|
+
# Block until child exits, the next interrupt-check tick, or the deadline.
|
|
279
|
+
slice = [remaining, interrupt_interval].min
|
|
280
|
+
return :done if waiter.join(slice)
|
|
376
281
|
|
|
377
|
-
return :interrupted if interrupt_check&.call
|
|
282
|
+
return cancel_command(pid, waiter, :interrupted) if interrupt_check&.call
|
|
378
283
|
end
|
|
379
284
|
end
|
|
380
285
|
|
|
381
|
-
# Sends Ctrl+C
|
|
382
|
-
#
|
|
383
|
-
#
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
@pty_stdin.write("\x03")
|
|
389
|
-
sleep 0.1
|
|
390
|
-
marker = "__ANIMA_RECOVER_#{SecureRandom.hex(8)}__"
|
|
391
|
-
@pty_stdin.puts "echo '#{marker}'"
|
|
392
|
-
recovered = consume_until(marker, deadline: monotonic_now + 3)
|
|
393
|
-
@alive = false unless recovered
|
|
394
|
-
rescue Errno::EIO, IOError
|
|
395
|
-
@alive = false
|
|
286
|
+
# Sends Ctrl+C to abort the running command and reaps the wait-for
|
|
287
|
+
# child. Returns +state+ so call sites can inline
|
|
288
|
+
# +return cancel_command(...)+.
|
|
289
|
+
def cancel_command(pid, waiter, state)
|
|
290
|
+
send_ctrl_c
|
|
291
|
+
reap_waiter(pid, waiter)
|
|
292
|
+
state
|
|
396
293
|
end
|
|
397
294
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
295
|
+
# Reaps a +tmux wait-for+ child after a cancel decision (Ctrl+C or
|
|
296
|
+
# init timeout). Sends SIGTERM, waits up to {WAITER_KILL_GRACE}
|
|
297
|
+
# seconds, escalates to SIGKILL if the client is wedged. Guarantees
|
|
298
|
+
# the caller never blocks indefinitely on a stuck tmux client.
|
|
299
|
+
def reap_waiter(pid, waiter)
|
|
300
|
+
begin
|
|
301
|
+
Process.kill("TERM", pid)
|
|
302
|
+
rescue Errno::ESRCH
|
|
303
|
+
# Already exited (raced with our kill) — fine.
|
|
403
304
|
end
|
|
404
|
-
|
|
305
|
+
return if waiter.join(WAITER_KILL_GRACE)
|
|
405
306
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
@stderr_mutex.synchronize do
|
|
411
|
-
result = @stderr_buffer.join("\n")
|
|
412
|
-
truncated = @stderr_truncated
|
|
413
|
-
@stderr_buffer.clear
|
|
414
|
-
@stderr_bytes = 0
|
|
415
|
-
@stderr_truncated = false
|
|
416
|
-
truncated ? result + "\n\n[Truncated: output exceeded #{@max_output_bytes} bytes]" : result
|
|
307
|
+
begin
|
|
308
|
+
Process.kill("KILL", pid)
|
|
309
|
+
rescue Errno::ESRCH
|
|
310
|
+
# Exited between TERM and KILL — fine.
|
|
417
311
|
end
|
|
312
|
+
waiter.join
|
|
418
313
|
end
|
|
419
314
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
# process has exited.
|
|
423
|
-
def update_pwd
|
|
424
|
-
@pwd = File.readlink("/proc/#{@pid}/cwd")
|
|
425
|
-
rescue Errno::ENOENT, Errno::EACCES
|
|
426
|
-
# Process exited or no access — @pwd retains its previous value
|
|
315
|
+
def send_ctrl_c
|
|
316
|
+
system("tmux", "send-keys", "-t", @target, "C-c", out: File::NULL, err: File::NULL)
|
|
427
317
|
end
|
|
428
318
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
319
|
+
# Placeholder substituted when a command produces no visible output.
|
|
320
|
+
# Two cases collapse to the same message:
|
|
321
|
+
# 1. The command genuinely had nothing to say (+true+, +cd /+,
|
|
322
|
+
# +find ... 2>/dev/null+ with no matches).
|
|
323
|
+
# 2. We captured in the brief race window between +wait-for -S+ firing
|
|
324
|
+
# and bash redrawing its prompt — the pane is all whitespace.
|
|
325
|
+
# Either way the LLM gets a coherent message, and the downstream
|
|
326
|
+
# +Message#content+ validation doesn't reject the empty result.
|
|
327
|
+
EMPTY_OUTPUT_PLACEHOLDER = "OK"
|
|
328
|
+
|
|
329
|
+
# Captures the pane scrollback + visible content. Because we ran
|
|
330
|
+
# +tmux clear-history+ before sending and the shell +clear+ wiped both
|
|
331
|
+
# visible pane and scrollback (via the +\e[3J+ sequence on modern
|
|
332
|
+
# terminfo), what we capture is exactly the new command's output and
|
|
333
|
+
# trailing prompt — nothing leaked from the previous pane state.
|
|
334
|
+
# The +-J+ flag joins terminal-wrapped lines so a long single-line
|
|
335
|
+
# output comes back whole.
|
|
336
|
+
# @return [String] rendered terminal text on success
|
|
337
|
+
# @return [nil] when +capture-pane+ exits non-zero (e.g. the session
|
|
338
|
+
# died between {#wait_for_completion} and the capture). Caller
|
|
339
|
+
# surfaces this as an error to the LLM rather than letting an empty
|
|
340
|
+
# string be mistaken for a silent command success.
|
|
341
|
+
def capture_output
|
|
342
|
+
raw, status = Open3.capture2("tmux", "capture-pane", "-pJ", "-t", @target, "-S", "-", err: File::NULL)
|
|
343
|
+
return nil unless status.success?
|
|
344
|
+
output = truncate(raw.force_encoding("UTF-8").scrub)
|
|
345
|
+
output.strip.empty? ? EMPTY_OUTPUT_PLACEHOLDER : output
|
|
346
|
+
end
|
|
434
347
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
348
|
+
# Truncates +output+ to {Anima::Settings.max_output_bytes}. The
|
|
349
|
+
# truncation notice itself counts against the cap, so the returned
|
|
350
|
+
# string is always +<= max_output_bytes+ — a contract callers can rely
|
|
351
|
+
# on for context-window budgeting.
|
|
352
|
+
def truncate(output)
|
|
353
|
+
max = Anima::Settings.max_output_bytes
|
|
354
|
+
return output if output.bytesize <= max
|
|
355
|
+
notice = "\n\n[Truncated: output exceeded #{max} bytes]"
|
|
356
|
+
output.byteslice(0, max - notice.bytesize).scrub + notice
|
|
438
357
|
end
|
|
439
358
|
|
|
440
359
|
def monotonic_now
|
|
441
360
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
442
361
|
end
|
|
443
|
-
|
|
444
|
-
# Unconditionally cleans up all shell resources (PTY, FIFO, child process).
|
|
445
|
-
# Does NOT short-circuit when @alive is already false — this ensures leaked
|
|
446
|
-
# processes are reaped even after failed recovery marked the session dead.
|
|
447
|
-
#
|
|
448
|
-
# @return [void]
|
|
449
|
-
def teardown
|
|
450
|
-
@alive = false
|
|
451
|
-
@read_buffer = +""
|
|
452
|
-
|
|
453
|
-
if @pid
|
|
454
|
-
begin
|
|
455
|
-
pgid = Process.getpgid(@pid)
|
|
456
|
-
Process.kill("TERM", -pgid)
|
|
457
|
-
rescue Errno::ESRCH, Errno::EPERM
|
|
458
|
-
# Process group already gone
|
|
459
|
-
end
|
|
460
|
-
end
|
|
461
|
-
|
|
462
|
-
begin
|
|
463
|
-
@pty_stdin&.close
|
|
464
|
-
rescue IOError
|
|
465
|
-
# Already closed
|
|
466
|
-
end
|
|
467
|
-
|
|
468
|
-
begin
|
|
469
|
-
@pty_stdout&.close
|
|
470
|
-
rescue IOError
|
|
471
|
-
# Already closed
|
|
472
|
-
end
|
|
473
|
-
|
|
474
|
-
begin
|
|
475
|
-
@stderr_thread&.join(1)
|
|
476
|
-
@stderr_thread&.kill
|
|
477
|
-
rescue ThreadError
|
|
478
|
-
# Thread already dead
|
|
479
|
-
end
|
|
480
|
-
|
|
481
|
-
File.delete(@fifo_path) if @fifo_path && File.exist?(@fifo_path)
|
|
482
|
-
|
|
483
|
-
if @pid
|
|
484
|
-
begin
|
|
485
|
-
# Non-blocking reap with SIGKILL fallback if process doesn't exit in time
|
|
486
|
-
deadline = monotonic_now + 2
|
|
487
|
-
loop do
|
|
488
|
-
_, status = Process.wait2(@pid, Process::WNOHANG)
|
|
489
|
-
break if status
|
|
490
|
-
if monotonic_now > deadline
|
|
491
|
-
Process.kill("KILL", @pid)
|
|
492
|
-
Process.wait(@pid)
|
|
493
|
-
break
|
|
494
|
-
end
|
|
495
|
-
sleep 0.05
|
|
496
|
-
end
|
|
497
|
-
rescue Errno::ECHILD, Errno::ESRCH
|
|
498
|
-
# Already reaped
|
|
499
|
-
end
|
|
500
|
-
|
|
501
|
-
@pid = nil
|
|
502
|
-
end
|
|
503
|
-
end
|
|
504
362
|
end
|