anima-core 1.4.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.
Files changed (149) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +18 -20
  3. data/README.md +61 -95
  4. data/agents/thoughts-analyzer.md +12 -7
  5. data/anima-core.gemspec +1 -0
  6. data/app/channels/session_channel.rb +38 -58
  7. data/app/decorators/agent_message_decorator.rb +7 -2
  8. data/app/decorators/message_decorator.rb +31 -100
  9. data/app/decorators/pending_from_melete_decorator.rb +36 -0
  10. data/app/decorators/pending_from_melete_goal_decorator.rb +13 -0
  11. data/app/decorators/pending_from_melete_skill_decorator.rb +19 -0
  12. data/app/decorators/pending_from_melete_workflow_decorator.rb +13 -0
  13. data/app/decorators/pending_from_mneme_decorator.rb +44 -0
  14. data/app/decorators/pending_message_decorator.rb +94 -0
  15. data/app/decorators/pending_subagent_decorator.rb +46 -0
  16. data/app/decorators/pending_tool_response_decorator.rb +51 -0
  17. data/app/decorators/pending_user_message_decorator.rb +22 -0
  18. data/app/decorators/system_message_decorator.rb +5 -0
  19. data/app/decorators/tool_call_decorator.rb +13 -2
  20. data/app/decorators/tool_response_decorator.rb +2 -2
  21. data/app/decorators/user_message_decorator.rb +7 -2
  22. data/app/jobs/count_tokens_job.rb +23 -0
  23. data/app/jobs/drain_job.rb +169 -0
  24. data/app/jobs/melete_enrichment_job/goal_change_listener.rb +52 -0
  25. data/app/jobs/melete_enrichment_job.rb +48 -0
  26. data/app/jobs/mneme_enrichment_job.rb +46 -0
  27. data/app/jobs/tool_execution_job.rb +87 -0
  28. data/app/models/concerns/token_estimation.rb +54 -0
  29. data/app/models/goal.rb +21 -10
  30. data/app/models/message.rb +47 -36
  31. data/app/models/pending_message.rb +276 -29
  32. data/app/models/pinned_message.rb +8 -3
  33. data/app/models/session.rb +468 -432
  34. data/app/models/snapshot.rb +11 -21
  35. data/bin/inspect-cassette +17 -4
  36. data/config/application.rb +1 -0
  37. data/config/initializers/event_subscribers.rb +71 -4
  38. data/config/initializers/inflections.rb +3 -1
  39. data/db/cable_structure.sql +3 -3
  40. data/db/migrate/20260407170803_remove_viewport_message_ids_from_sessions.rb +5 -0
  41. data/db/migrate/20260407180400_remove_mneme_snapshot_pointer_columns_from_sessions.rb +6 -0
  42. data/db/migrate/20260411120553_add_token_count_to_pinned_messages.rb +5 -0
  43. data/db/migrate/20260411172926_remove_active_skills_and_workflow_from_sessions.rb +6 -0
  44. data/db/migrate/20260412110625_replace_processing_with_aasm_state.rb +6 -0
  45. data/db/migrate/20260418150323_add_kind_and_message_type_to_pending_messages.rb +6 -0
  46. data/db/migrate/20260419120000_add_drain_fields_to_pending_messages.rb +7 -0
  47. data/db/migrate/20260419130000_drop_pending_messages_kind_default.rb +5 -0
  48. data/db/migrate/20260419140000_add_drain_indexes_to_pending_messages.rb +8 -0
  49. data/db/migrate/20260420100000_add_hud_visibility_to_sessions.rb +15 -0
  50. data/db/queue_structure.sql +13 -13
  51. data/db/structure.sql +44 -31
  52. data/lib/agents/registry.rb +1 -1
  53. data/lib/anima/settings.rb +7 -33
  54. data/lib/anima/version.rb +1 -1
  55. data/lib/events/authentication_required.rb +24 -0
  56. data/lib/events/bounce_back.rb +4 -4
  57. data/lib/events/eviction_completed.rb +28 -0
  58. data/lib/events/goal_created.rb +28 -0
  59. data/lib/events/goal_updated.rb +32 -0
  60. data/lib/events/llm_responded.rb +35 -0
  61. data/lib/events/message_created.rb +27 -0
  62. data/lib/events/message_updated.rb +25 -0
  63. data/lib/events/session_state_changed.rb +30 -0
  64. data/lib/events/skill_activated.rb +28 -0
  65. data/lib/events/start_melete.rb +36 -0
  66. data/lib/events/start_mneme.rb +33 -0
  67. data/lib/events/start_processing.rb +32 -0
  68. data/lib/events/subagent_evicted.rb +31 -0
  69. data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
  70. data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
  71. data/lib/events/subscribers/drain_kickoff.rb +20 -0
  72. data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
  73. data/lib/events/subscribers/llm_response_handler.rb +111 -0
  74. data/lib/events/subscribers/melete_kickoff.rb +24 -0
  75. data/lib/events/subscribers/message_broadcaster.rb +34 -0
  76. data/lib/events/subscribers/mneme_kickoff.rb +24 -0
  77. data/lib/events/subscribers/mneme_scheduler.rb +21 -0
  78. data/lib/events/subscribers/persister.rb +6 -8
  79. data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
  80. data/lib/events/subscribers/subagent_message_router.rb +26 -29
  81. data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
  82. data/lib/events/subscribers/tool_response_creator.rb +33 -0
  83. data/lib/events/subscribers/transient_broadcaster.rb +1 -1
  84. data/lib/events/tool_executed.rb +34 -0
  85. data/lib/events/workflow_activated.rb +27 -0
  86. data/lib/llm/client.rb +41 -201
  87. data/lib/mcp/client_manager.rb +41 -46
  88. data/lib/mcp/stdio_transport.rb +9 -5
  89. data/lib/{analytical_brain → melete}/runner.rb +63 -68
  90. data/lib/{analytical_brain → melete}/tools/activate_skill.rb +1 -1
  91. data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +2 -2
  92. data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
  93. data/lib/{analytical_brain → melete}/tools/finish_goal.rb +3 -3
  94. data/lib/{analytical_brain → melete}/tools/goal_messaging.rb +4 -3
  95. data/lib/{analytical_brain → melete}/tools/read_workflow.rb +2 -2
  96. data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
  97. data/lib/{analytical_brain → melete}/tools/set_goal.rb +1 -1
  98. data/lib/{analytical_brain → melete}/tools/update_goal.rb +4 -4
  99. data/lib/{analytical_brain.rb → melete.rb} +6 -3
  100. data/lib/mneme/base_runner.rb +121 -0
  101. data/lib/mneme/l2_runner.rb +14 -20
  102. data/lib/mneme/recall_runner.rb +132 -0
  103. data/lib/mneme/runner.rb +118 -171
  104. data/lib/mneme/search.rb +104 -62
  105. data/lib/mneme/tools/nothing_to_surface.rb +25 -0
  106. data/lib/mneme/tools/save_snapshot.rb +2 -10
  107. data/lib/mneme/tools/surface_memory.rb +89 -0
  108. data/lib/mneme.rb +11 -5
  109. data/lib/shell_session.rb +287 -612
  110. data/lib/skills/definition.rb +2 -2
  111. data/lib/skills/registry.rb +1 -1
  112. data/lib/tools/base.rb +16 -0
  113. data/lib/tools/bash.rb +25 -57
  114. data/lib/tools/edit.rb +2 -0
  115. data/lib/tools/read.rb +2 -0
  116. data/lib/tools/registry.rb +79 -3
  117. data/lib/tools/{recall.rb → search_messages.rb} +19 -21
  118. data/lib/tools/spawn_specialist.rb +16 -10
  119. data/lib/tools/spawn_subagent.rb +20 -14
  120. data/lib/tools/subagent_prompts.rb +4 -4
  121. data/lib/tools/think.rb +1 -1
  122. data/lib/tools/{remember.rb → view_messages.rb} +10 -10
  123. data/lib/tools/write.rb +2 -0
  124. data/lib/tui/app.rb +5 -4
  125. data/lib/tui/braille_spinner.rb +7 -7
  126. data/lib/tui/decorators/base_decorator.rb +24 -3
  127. data/lib/tui/message_store.rb +93 -44
  128. data/lib/tui/screens/chat.rb +94 -20
  129. data/lib/tui/settings.rb +9 -2
  130. data/lib/workflows/definition.rb +3 -3
  131. data/lib/workflows/registry.rb +1 -1
  132. data/skills/github.md +38 -0
  133. data/templates/config.toml +4 -23
  134. data/workflows/review_pr.md +18 -14
  135. metadata +86 -28
  136. data/app/jobs/agent_request_job.rb +0 -199
  137. data/app/jobs/analytical_brain_job.rb +0 -33
  138. data/app/jobs/count_message_tokens_job.rb +0 -39
  139. data/app/jobs/passive_recall_job.rb +0 -24
  140. data/app/models/concerns/message/broadcasting.rb +0 -86
  141. data/lib/agent_loop.rb +0 -215
  142. data/lib/analytical_brain/tools/deactivate_skill.rb +0 -40
  143. data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -35
  144. data/lib/events/agent_message.rb +0 -25
  145. data/lib/events/subscribers/message_collector.rb +0 -64
  146. data/lib/events/tool_call.rb +0 -31
  147. data/lib/events/tool_response.rb +0 -33
  148. data/lib/mneme/compressed_viewport.rb +0 -204
  149. data/lib/mneme/passive_recall.rb +0 -138
data/lib/shell_session.rb CHANGED
@@ -1,687 +1,362 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "io/console"
4
3
  require "open3"
5
- require "pathname"
6
- require "pty"
7
4
  require "securerandom"
8
5
  require "shellwords"
9
- require "uri"
10
6
 
11
- # Immutable snapshot of the shell's environment for change detection.
12
- # Compared between commands to produce natural-language summaries of what
13
- # changed the agent discovers its environment through Bash tool responses.
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}.
14
10
  #
15
- # @!attribute [r] pwd
16
- # @return [String, nil] current working directory
17
- # @!attribute [r] branch
18
- # @return [String, nil] current git branch name
19
- # @!attribute [r] repo
20
- # @return [String, nil] "owner/repo" extracted from git origin remote
21
- # @!attribute [r] project_files
22
- # @return [Array<String>] sorted relative paths to project instruction files
23
- EnvironmentSnapshot = Data.define(:pwd, :branch, :repo, :project_files) do
24
- # Sentinel for "never detected" — diffs against this produce a full snapshot.
25
- def self.blank = new(pwd: nil, branch: nil, repo: nil, project_files: [])
26
- end
27
-
28
- # Persistent shell session backed by a PTY with FIFO-based stderr separation.
29
- # Commands share working directory, environment variables, and shell history
30
- # within a conversation. Multiple tools share the same session.
31
- #
32
- # Auto-recovers from timeouts and crashes: if the shell dies, the next command
33
- # transparently respawns a fresh shell and restores the working directory.
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).
34
15
  #
35
- # After each successful command, detects environment changes (CWD, git branch,
36
- # project files) and includes a natural-language summary in the result hash.
37
- # This replaces the old EnvironmentProbe system-prompt injection, keeping the
38
- # system prompt static for prompt caching.
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.
39
19
  #
40
- # Uses IO.select-based deadlines instead of Timeout.timeout for all PTY reads.
41
- # Timeout.timeout is unsafe with PTY I/O — it uses Thread.raise which can
42
- # corrupt mutex state, leave resources inconsistent, and cause exceptions
43
- # to fire outside handler blocks when nested.
20
+ # tmux is a hard runtime dependency. {#initialize} raises a clear error if
21
+ # tmux is missing.
44
22
  #
45
23
  # @example
46
- # session = ShellSession.new(session_id: 42)
47
- # session.run("cd /tmp")
48
- # session.run("pwd")
49
- # # => {stdout: "/tmp", stderr: "", exit_code: 0}
50
- # session.finalize
24
+ # shell = ShellSession.for_session(session)
25
+ # shell.run("cd /tmp")
26
+ # shell.run("pwd")
27
+ # # => {output: "/tmp"}
51
28
  class ShellSession
52
- # @return [String, nil] current working directory of the shell process
53
- attr_reader :pwd
54
-
55
- # @param session_id [Integer, String] unique identifier for logging/diagnostics
56
- def initialize(session_id:)
57
- @session_id = session_id
58
- @mutex = Mutex.new
59
- @fifo_path = File.join(Dir.tmpdir, "anima-stderr-#{Process.pid}-#{SecureRandom.hex(8)}")
60
- @alive = false
61
- @finalized = false
62
- @pwd = nil
63
- @env_snapshot = nil
64
- @read_buffer = +""
65
- self.class.cleanup_orphans
66
- start
67
- self.class.register(self)
68
- end
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
69
63
 
70
- # Execute a command in the persistent shell. Respawns the shell
71
- # automatically if the previous session died (timeout, crash, etc.).
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.
72
90
  #
73
- # @param command [String] bash command to execute
74
- # @param timeout [Integer, nil] per-call timeout in seconds; overrides
75
- # Settings.command_timeout when provided
76
- # @param interrupt_check [Proc, nil] callable returning truthy when the
77
- # user has requested an interrupt. Polled every
78
- # {Anima::Settings.interrupt_check_interval} seconds during command execution.
79
- # @return [Hash] with :stdout, :stderr, :exit_code keys on success
80
- # @return [Hash] with :interrupted, :stdout, :stderr keys on user interrupt
81
- # @return [Hash] with :error key on failure
82
- def run(command, timeout: nil, interrupt_check: nil)
83
- @mutex.synchronize do
84
- return {error: "Shell session is not running"} if @finalized
85
- restart unless @alive
86
- execute_in_pty(command, timeout: timeout, interrupt_check: interrupt_check)
87
- end
88
- rescue => error # rubocop:disable Lint/RescueException -- LLM must always get a result hash, never a stack trace
89
- {error: "#{error.class}: #{error.message}"}
90
- end
91
-
92
- # Clean up PTY, FIFO, and child process. Permanent — the session
93
- # will not auto-respawn after this call.
94
- def finalize
95
- @mutex.synchronize do
96
- @finalized = true
97
- teardown
98
- end
99
- self.class.unregister(self)
100
- end
101
-
102
- # @return [Boolean] whether the shell process is still running
103
- def alive?
104
- @mutex.synchronize { @alive }
105
- end
106
-
107
- # --- Class-level session tracking for at_exit cleanup ---
108
-
109
- @sessions = []
110
- @sessions_mutex = Mutex.new
111
-
112
- class << self
113
- # @api private
114
- def register(session)
115
- @sessions_mutex.synchronize { @sessions << session }
116
- end
117
-
118
- # @api private
119
- def unregister(session)
120
- @sessions_mutex.synchronize { @sessions.delete(session) }
121
- end
122
-
123
- # Finalize all live sessions. Called automatically via at_exit.
124
- def cleanup_all
125
- @sessions_mutex.synchronize do
126
- @sessions.each { |session| session.send(:teardown) }
127
- @sessions.clear
128
- end
129
- end
130
-
131
- # Remove stale FIFO files left by crashed processes.
132
- # FIFO naming format: anima-stderr-{pid}-{hex}
133
- def cleanup_orphans
134
- Dir.glob(File.join(Dir.tmpdir, "anima-stderr-*")).each do |path|
135
- match = File.basename(path).match(/\Aanima-stderr-(\d+)-/)
136
- next unless match
137
-
138
- pid = match[1].to_i
139
- next if pid <= 0
140
-
141
- begin
142
- Process.kill(0, pid)
143
- rescue Errno::ESRCH
144
- begin
145
- File.delete(path)
146
- rescue SystemCallError
147
- # Best-effort cleanup
148
- end
149
- rescue Errno::EPERM
150
- # Process exists but we can't signal it — leave it
151
- end
152
- end
153
- end
154
- end
155
-
156
- at_exit { ShellSession.cleanup_all }
157
-
158
- private
159
-
160
- def start
161
- create_fifo
162
- spawn_shell
163
- start_stderr_reader
164
- init_shell
165
- update_pwd
166
- seed_env_snapshot
167
- @alive = true
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)
168
96
  end
169
97
 
170
- # Shuts down the current shell and spawns a fresh one, restoring the
171
- # previous working directory. Called automatically when @alive is false.
172
- def restart
173
- saved_pwd = @pwd
174
- teardown
175
- @fifo_path = File.join(Dir.tmpdir, "anima-stderr-#{Process.pid}-#{SecureRandom.hex(8)}")
176
- start
177
- restore_working_directory(saved_pwd)
178
- end
179
-
180
- # Restores the shell's working directory after a respawn.
181
- # 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.
182
100
  #
183
- # @param saved_pwd [String, nil] directory path to restore
101
+ # @param session_id [Integer, String]
184
102
  # @return [void]
185
- def restore_working_directory(saved_pwd)
186
- return unless saved_pwd && File.directory?(saved_pwd)
187
- execute_in_pty("cd #{Shellwords.shellescape(saved_pwd)}")
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
188
107
  end
189
108
 
190
- def create_fifo
191
- File.mkfifo(@fifo_path, 0o600)
192
- rescue Errno::EEXIST
193
- # FIFO already exists reuse it
194
- end
195
-
196
- # Env vars that prevent interactive pagers and credential prompts from
197
- # hanging the PTY. We need a PTY (not pipes) for pwd tracking via /proc
198
- # and signal handling, but this makes programs think they're on a terminal
199
- # and launch pagers. No single switch disables all pagers — each tool has
200
- # its own env var — so we set a comprehensive list plus LESS flags as a
201
- # safety net for direct `less` invocations.
202
- SHELL_ENV = {
203
- "TERM" => "dumb",
204
- "PAGER" => "cat", # Default pager for most Unix tools
205
- "LESS" => "-eFRX", # Safety net: make less auto-exit at EOF, no screen clear
206
- "GIT_PAGER" => "cat", # Git checks this before PAGER
207
- "MANPAGER" => "cat", # man pages
208
- "SYSTEMD_PAGER" => "", # journalctl, systemctl (empty = disable)
209
- "BAT_PAGER" => "cat", # bat (cat alternative)
210
- "AWS_PAGER" => "", # AWS CLI v2 (empty = disable)
211
- "PSQL_PAGER" => "cat", # PostgreSQL psql
212
- "GIT_TERMINAL_PROMPT" => "0" # Fail immediately instead of prompting for credentials
213
- }.freeze
214
-
215
- def spawn_shell
216
- @pty_stdout, @pty_stdin, @pid = PTY.spawn(
217
- SHELL_ENV,
218
- "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
219
121
  )
220
- # Disable terminal echo via termios before bash can echo our commands.
221
- # This is instant (kernel-level), unlike stty -echo which races with input.
222
- @pty_stdin.echo = false
122
+ return nil unless status.success?
123
+ cwd = output.strip
124
+ cwd.empty? ? nil : cwd
223
125
  end
224
126
 
225
- def start_stderr_reader
226
- @stderr_mutex = Mutex.new
227
- @stderr_buffer = []
228
- @stderr_bytes = 0
229
- @stderr_truncated = false
230
- @max_output_bytes = Anima::Settings.max_output_bytes
231
- @stderr_thread = Thread.new do
232
- max_bytes = @max_output_bytes
233
- File.open(@fifo_path, "r") do |fifo|
234
- while (line = fifo.gets)
235
- cleaned = line.chomp.delete("\r")
236
- @stderr_mutex.synchronize do
237
- if @stderr_bytes < max_bytes
238
- @stderr_buffer << cleaned
239
- @stderr_bytes += cleaned.bytesize
240
- else
241
- @stderr_truncated = true
242
- end
243
- end
244
- end
245
- end
246
- rescue Errno::ENOENT, IOError
247
- # FIFO was cleaned up or closed
248
- 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)
249
132
  end
133
+ private_class_method :parent_cwd_for
250
134
 
251
- # With echo already off (set in spawn_shell), only command output appears.
252
- # The initial bash prompt merges with the marker output on one gets line.
253
- def init_shell
254
- marker = "__ANIMA_INIT_#{SecureRandom.hex(8)}__"
255
- @pty_stdin.puts "PS1=''"
256
- @pty_stdin.puts "exec 2>#{@fifo_path}"
257
- @pty_stdin.puts "echo '#{marker}'"
258
- unless consume_until(marker, deadline: monotonic_now + 10)
259
- raise IOError, "Shell initialization timed out"
260
- 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) }
261
142
  end
262
143
 
263
- def execute_in_pty(command, timeout: nil, interrupt_check: nil)
264
- clear_stderr
265
- marker = "__ANIMA_#{SecureRandom.hex(8)}__"
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)
266
172
  timeout ||= Anima::Settings.command_timeout
267
- deadline = monotonic_now + timeout
268
173
 
269
- @pty_stdin.puts "#{command}; __anima_ec=$?; echo; echo '#{marker}' $__anima_ec"
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)
270
177
 
271
- stdout, exit_code = read_until_marker(marker, deadline: deadline, interrupt_check: interrupt_check)
178
+ state = wait_for_completion(uuid, timeout, interrupt_check)
179
+ output = capture_output
272
180
 
273
- if exit_code == :interrupted
274
- recover_shell
275
- update_pwd
276
- stderr = drain_stderr
277
- return {
278
- interrupted: true,
279
- stdout: truncate(stdout),
280
- stderr: truncate(stderr)
281
- }
282
- end
181
+ return {error: "tmux capture-pane failed (session may have died)"} if output.nil?
283
182
 
284
- if exit_code.nil?
285
- recover_shell
286
- stderr = drain_stderr
287
- parts = ["Command timed out after #{timeout} seconds."]
288
- parts << "Partial stdout:\n#{truncate(stdout)}" unless stdout.empty?
289
- parts << "stderr:\n#{truncate(stderr)}" unless stderr.empty?
290
- 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}"}
291
187
  end
292
-
293
- env_summary = update_environment
294
- stderr = drain_stderr
295
-
296
- result = {
297
- stdout: truncate(stdout),
298
- stderr: truncate(stderr),
299
- exit_code: exit_code
300
- }
301
- result[:env_summary] = env_summary if env_summary
302
- result
303
- rescue Errno::EIO, IOError
304
- @alive = false
305
- {error: "Shell session terminated unexpectedly"}
306
- 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.
307
192
  {error: "#{error.class}: #{error.message}"}
308
193
  end
309
194
 
310
- # Reads lines from the PTY until the marker appears.
311
- #
312
- # @param marker [String] unique marker to detect command completion
313
- # @param deadline [Float] monotonic clock deadline
314
- # @param interrupt_check [Proc, nil] callable returning truthy on user interrupt
315
- # @return [Array(String, Integer)] stdout and exit code on success
316
- # @return [Array(String, Symbol)] partial stdout and +:interrupted+ on user interrupt
317
- # @return [Array(String, nil)] partial stdout and nil exit code on timeout
318
- def read_until_marker(marker, deadline:, interrupt_check: nil)
319
- lines = []
320
- exit_code = nil
321
- check_interval = interrupt_check ? [Anima::Settings.interrupt_check_interval, 0.5].max : nil
322
-
323
- loop do
324
- line = gets_with_deadline(deadline, interrupt_check: interrupt_check, check_interval: check_interval)
325
-
326
- if line == :interrupted
327
- exit_code = :interrupted
328
- break
329
- end
330
-
331
- break if line.nil?
332
-
333
- line = line.chomp.delete("\r")
334
-
335
- if line.include?(marker)
336
- exit_code = line.split.last.to_i
337
- break
338
- end
339
-
340
- lines << line
341
- end
342
-
343
- # Strip trailing empty line added by our separator echo
344
- lines.pop if lines.last == ""
345
-
346
- [lines.join("\n"), exit_code]
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)
347
198
  end
348
199
 
349
- # Reads and discards PTY output until the marker appears or deadline expires.
350
- #
351
- # @param marker [String] unique marker to wait for
352
- # @param deadline [Float] monotonic clock deadline
353
- # @return [Boolean] true if marker was found, false if deadline expired
354
- # @raise [Errno::EIO] when the PTY child process has exited
355
- # @raise [IOError] when the PTY file descriptor is closed
356
- def consume_until(marker, deadline:)
357
- loop do
358
- line = gets_with_deadline(deadline)
359
- return false if line.nil?
360
- return true if line.chomp.delete("\r").include?(marker)
361
- end
200
+ # Kills the underlying tmux session. Idempotent.
201
+ def finalize
202
+ self.class.release(@session_id)
362
203
  end
363
204
 
364
- # Reads a single line from the PTY, respecting a deadline.
365
- # Caller must hold @mutex@read_buffer is not independently synchronized.
205
+ # Reads the shell's current working directory directly from the tmux
206
+ # server. Works even mid-commandthe lookup is server-side, not
207
+ # shell-mediated.
366
208
  #
367
- # Uses IO.select for safe, non-interruptive timeout handling instead of
368
- # Timeout.timeout (which uses Thread.raise that can corrupt mutex state
369
- # and leave resources inconsistent).
370
- #
371
- # When +interrupt_check+ is provided, IO.select uses a shorter timeout
372
- # (capped at {Anima::Settings.interrupt_check_interval}) and polls the
373
- # callback between iterations. Returns +:interrupted+ when the callback
374
- # fires, allowing the caller to send Ctrl+C and return partial output.
375
- #
376
- # @param deadline [Float] monotonic clock deadline
377
- # @param interrupt_check [Proc, nil] callable returning truthy on user interrupt
378
- # @param check_interval [Float, nil] resolved interrupt check interval (seconds);
379
- # pre-computed by the caller to avoid re-reading Settings on every line
380
- # @return [String] line including trailing newline
381
- # @return [:interrupted] when user interrupt detected
382
- # @return [nil] if deadline expired
383
- # @raise [Errno::EIO] when the PTY child process exits (Linux)
384
- # @raise [IOError] when the PTY file descriptor is closed
385
- def gets_with_deadline(deadline, interrupt_check: nil, check_interval: nil)
386
- loop do
387
- if (idx = @read_buffer.index("\n"))
388
- return @read_buffer.slice!(0..idx)
389
- end
390
-
391
- remaining = deadline - monotonic_now
392
- return nil if remaining <= 0
393
-
394
- select_timeout = check_interval ? [remaining, check_interval].min : remaining
209
+ # @return [String, nil]
210
+ def pwd
211
+ self.class.cwd_via_tmux(@session_id)
212
+ end
395
213
 
396
- ready = IO.select([@pty_stdout], nil, nil, select_timeout)
214
+ private
397
215
 
398
- if ready
399
- begin
400
- @read_buffer << @pty_stdout.read_nonblock(4096)
401
- rescue IO::WaitReadable
402
- # Spurious wakeup from IO.select — retry
403
- end
404
- end
216
+ def ensure_session(cwd)
217
+ return if alive?
405
218
 
406
- return :interrupted if interrupt_check&.call
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`)."
407
221
  end
408
- end
409
222
 
410
- # Sends Ctrl+C and drains leftover output after a timeout or user interrupt.
411
- # If recovery fails, marks the session as dead (will be respawned on next run).
412
- #
413
- # @return [void]
414
- # @raise [Errno::EIO] when the PTY child process has exited
415
- # @raise [IOError] when the PTY file descriptor is closed
416
- def recover_shell
417
- @pty_stdin.write("\x03")
418
- sleep 0.1
419
- marker = "__ANIMA_RECOVER_#{SecureRandom.hex(8)}__"
420
- @pty_stdin.puts "echo '#{marker}'"
421
- recovered = consume_until(marker, deadline: monotonic_now + 3)
422
- @alive = false unless recovered
423
- rescue Errno::EIO, IOError
424
- @alive = false
425
- end
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)
426
226
 
427
- def clear_stderr
428
- @stderr_mutex.synchronize do
429
- @stderr_buffer.clear
430
- @stderr_bytes = 0
431
- @stderr_truncated = false
432
- end
433
- end
227
+ raise "tmux session #{@target} could not be created" unless alive?
434
228
 
435
- def drain_stderr
436
- # Allow FIFO reader thread time to flush kernel buffers into @stderr_buffer.
437
- # Without this, stderr arriving just before the marker may be missed.
438
- sleep 0.01
439
- @stderr_mutex.synchronize do
440
- result = @stderr_buffer.join("\n")
441
- truncated = @stderr_truncated
442
- @stderr_buffer.clear
443
- @stderr_bytes = 0
444
- @stderr_truncated = false
445
- truncated ? result + "\n\n[Truncated: output exceeded #{@max_output_bytes} bytes]" : result
446
- end
447
- end
229
+ system(
230
+ "tmux", "set-option", "-t", @target, "history-limit", HISTORY_LIMIT.to_s,
231
+ out: File::NULL, err: File::NULL
232
+ )
448
233
 
449
- # Captures the initial environment snapshot so the first real Bash call
450
- # can diff against the actual shell state rather than a blank sentinel
451
- # whose nil pwd would always trigger a "location changed" report.
452
- #
453
- # Sets {#env_snapshot} to a real snapshot of the current pwd, git branch,
454
- # repo, and project files. Called within {#start} after {#update_pwd}
455
- # and before the session is marked alive.
456
- #
457
- # @return [void]
458
- def seed_env_snapshot
459
- @env_snapshot = take_env_snapshot(EnvironmentSnapshot.blank)
234
+ inject_shell_env
460
235
  end
461
236
 
462
- # Snapshots the shell's environment and returns a natural-language summary
463
- # of what changed since the last snapshot. The agent discovers its
464
- # environment through these summaries in Bash tool responses.
465
- #
466
- # Each call only mentions what changed. Returns nil when nothing did.
467
- #
468
- # @return [String, nil] human-readable summary of environment changes
469
- def update_environment
470
- update_pwd
471
- previous = @env_snapshot || EnvironmentSnapshot.blank
472
- @env_snapshot = take_env_snapshot(previous)
473
- describe_env_changes(previous, @env_snapshot)
474
- end
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)
475
249
 
476
- # Reads the shell's current working directory via the /proc filesystem.
477
- # @note Linux-only. Falls back silently on other platforms or if the
478
- # process has exited.
479
- def update_pwd
480
- @pwd = File.readlink("/proc/#{@pid}/cwd")
481
- rescue Errno::ENOENT, Errno::EACCES
482
- # Process exited or no access — @pwd retains its previous value
250
+ reap_waiter(pid, waiter)
251
+ raise "tmux session #{@target} init timed out"
483
252
  end
484
253
 
485
- # Captures the current environment as an immutable snapshot.
486
- # Re-detects git state on every call (branch can change without cd).
487
- # Re-scans project files only when the working directory changed.
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.
488
257
  #
489
- # @param previous [EnvironmentSnapshot] the last known snapshot
490
- # @return [EnvironmentSnapshot]
491
- def take_env_snapshot(previous)
492
- branch, repo = detect_git
493
- files = (@pwd != previous.pwd) ? scan_project_files : previous.project_files
494
-
495
- EnvironmentSnapshot.new(pwd: @pwd, branch: branch, repo: repo, project_files: files)
496
- end
497
-
498
- # Detects git branch and repo name for the current working directory.
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.
499
261
  #
500
- # @return [Array(String, String)] branch and repo name
501
- # @return [Array(nil, nil)] when not inside a git repository
502
- def detect_git
503
- return [nil, nil] unless @pwd
504
-
505
- _, status = Open3.capture2("git", "-C", @pwd, "rev-parse", "--is-inside-work-tree", err: File::NULL)
506
- return [nil, nil] unless status.success?
507
-
508
- branch = detect_git_branch
509
- repo = detect_git_repo
510
- [branch, repo]
511
- rescue Errno::ENOENT
512
- [nil, nil]
513
- end
514
-
515
- # @return [String, nil] current branch name
516
- def detect_git_branch
517
- output, = Open3.capture2("git", "-C", @pwd, "rev-parse", "--abbrev-ref", "HEAD", err: File::NULL)
518
- output.strip.presence
519
- end
520
-
521
- # @return [String, nil] "owner/repo" extracted from the origin remote
522
- def detect_git_repo
523
- output, = Open3.capture2("git", "-C", @pwd, "remote", "get-url", "origin", err: File::NULL)
524
- remote = output.strip
525
- return unless remote.present?
526
-
527
- extract_repo_name(remote)
528
- end
529
-
530
- # Scans for well-known project files in the current working directory.
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.
531
266
  #
532
- # @return [Array<String>] sorted relative paths
533
- def scan_project_files
534
- return [] unless @pwd
535
-
536
- base = Pathname.new(@pwd)
537
- whitelist = Anima::Settings.project_files_whitelist
538
- max_depth = Anima::Settings.project_files_max_depth
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
539
273
 
540
- patterns = whitelist.product((0..max_depth).to_a).map do |filename, depth|
541
- File.join(@pwd, Array.new(depth, "*"), filename)
542
- end
274
+ loop do
275
+ remaining = deadline - monotonic_now
276
+ return cancel_command(pid, waiter, :timeout) if remaining <= 0
543
277
 
544
- patterns.flat_map { |pattern| Dir.glob(pattern) }
545
- .map { |path| Pathname.new(path).relative_path_from(base).to_s }
546
- .sort
547
- .uniq
548
- 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)
549
281
 
550
- # Extracts owner/repo from a Git remote URL (SSH or HTTPS).
551
- #
552
- # @param remote_url [String] SSH or HTTPS remote URL
553
- # @return [String] "owner/repo" path
554
- def extract_repo_name(remote_url)
555
- path = if remote_url.match?(%r{\A\w+://})
556
- URI.parse(remote_url).path
557
- else
558
- remote_url.split(":").last
282
+ return cancel_command(pid, waiter, :interrupted) if interrupt_check&.call
559
283
  end
560
- path.delete_prefix("/").delete_suffix(".git")
561
- rescue URI::InvalidURIError
562
- remote_url
563
284
  end
564
285
 
565
- # ─── Environment change description ──────────────────────────────
566
-
567
- # Builds a natural-language summary describing what changed between two
568
- # environment snapshots. Returns nil when nothing changed.
569
- #
570
- # @param old_snap [EnvironmentSnapshot]
571
- # @param new_snap [EnvironmentSnapshot]
572
- # @return [String, nil]
573
- def describe_env_changes(old_snap, new_snap)
574
- parts = []
575
- parts << describe_location_change(old_snap, new_snap)
576
- parts << describe_project_files(old_snap, new_snap)
577
- parts.compact!
578
- parts.empty? ? nil : parts.join("\n")
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
579
293
  end
580
294
 
581
- # @return [String, nil] location/branch change line
582
- def describe_location_change(old_snap, new_snap)
583
- if new_snap.pwd != old_snap.pwd
584
- format_full_location(new_snap)
585
- elsif new_snap.branch != old_snap.branch && new_snap.branch
586
- "Branch changed to #{new_snap.branch}."
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.
587
304
  end
588
- end
589
-
590
- # @return [String, nil] project files line
591
- def describe_project_files(old_snap, new_snap)
592
- return unless new_snap.project_files.any?
593
- return unless new_snap.pwd != old_snap.pwd || new_snap.project_files != old_snap.project_files
305
+ return if waiter.join(WAITER_KILL_GRACE)
594
306
 
595
- "Project has instructions in #{new_snap.project_files.join(", ")}."
596
- end
597
-
598
- # Formats the full location line for display in tool responses.
599
- #
600
- # @param snap [EnvironmentSnapshot]
601
- # @return [String]
602
- def format_full_location(snap)
603
- parts = ["You are now in #{snap.pwd}"]
604
- if snap.repo && snap.branch
605
- parts << ", git repo #{snap.repo} on branch #{snap.branch}"
606
- elsif snap.branch
607
- parts << " on branch #{snap.branch}"
307
+ begin
308
+ Process.kill("KILL", pid)
309
+ rescue Errno::ESRCH
310
+ # Exited between TERM and KILL fine.
608
311
  end
609
- parts.join + "."
610
- end
611
-
312
+ waiter.join
313
+ end
314
+
315
+ def send_ctrl_c
316
+ system("tmux", "send-keys", "-t", @target, "C-c", out: File::NULL, err: File::NULL)
317
+ end
318
+
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
347
+
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.
612
352
  def truncate(output)
613
- max_bytes = @max_output_bytes
614
- output = output.dup.force_encoding("UTF-8").scrub
615
-
616
- return output if output.bytesize <= max_bytes
617
-
618
- output.byteslice(0, max_bytes)
619
- .scrub +
620
- "\n\n[Truncated: output exceeded #{max_bytes} bytes]"
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
621
357
  end
622
358
 
623
359
  def monotonic_now
624
360
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
625
361
  end
626
-
627
- # Unconditionally cleans up all shell resources (PTY, FIFO, child process).
628
- # Does NOT short-circuit when @alive is already false — this ensures leaked
629
- # processes are reaped even after failed recovery marked the session dead.
630
- #
631
- # @return [void]
632
- def teardown
633
- @alive = false
634
- @read_buffer = +""
635
-
636
- if @pid
637
- begin
638
- pgid = Process.getpgid(@pid)
639
- Process.kill("TERM", -pgid)
640
- rescue Errno::ESRCH, Errno::EPERM
641
- # Process group already gone
642
- end
643
- end
644
-
645
- begin
646
- @pty_stdin&.close
647
- rescue IOError
648
- # Already closed
649
- end
650
-
651
- begin
652
- @pty_stdout&.close
653
- rescue IOError
654
- # Already closed
655
- end
656
-
657
- begin
658
- @stderr_thread&.join(1)
659
- @stderr_thread&.kill
660
- rescue ThreadError
661
- # Thread already dead
662
- end
663
-
664
- File.delete(@fifo_path) if @fifo_path && File.exist?(@fifo_path)
665
-
666
- if @pid
667
- begin
668
- # Non-blocking reap with SIGKILL fallback if process doesn't exit in time
669
- deadline = monotonic_now + 2
670
- loop do
671
- _, status = Process.wait2(@pid, Process::WNOHANG)
672
- break if status
673
- if monotonic_now > deadline
674
- Process.kill("KILL", @pid)
675
- Process.wait(@pid)
676
- break
677
- end
678
- sleep 0.05
679
- end
680
- rescue Errno::ECHILD, Errno::ESRCH
681
- # Already reaped
682
- end
683
-
684
- @pid = nil
685
- end
686
- end
687
362
  end