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.
Files changed (175) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +23 -26
  3. data/README.md +118 -104
  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 +16 -5
  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 +23 -11
  30. data/app/models/message.rb +46 -48
  31. data/app/models/pending_message.rb +407 -12
  32. data/app/models/pinned_message.rb +8 -3
  33. data/app/models/session.rb +660 -566
  34. data/app/models/snapshot.rb +11 -21
  35. data/bin/inspect-cassette +157 -0
  36. data/bin/release +212 -0
  37. data/bin/with-llms +20 -0
  38. data/config/application.rb +1 -0
  39. data/config/database.yml +1 -0
  40. data/config/initializers/event_subscribers.rb +71 -4
  41. data/config/initializers/inflections.rb +3 -1
  42. data/db/cable_structure.sql +9 -0
  43. data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
  44. data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
  45. data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
  46. data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
  47. data/db/migrate/20260407170803_remove_viewport_message_ids_from_sessions.rb +5 -0
  48. data/db/migrate/20260407180400_remove_mneme_snapshot_pointer_columns_from_sessions.rb +6 -0
  49. data/db/migrate/20260411120553_add_token_count_to_pinned_messages.rb +5 -0
  50. data/db/migrate/20260411172926_remove_active_skills_and_workflow_from_sessions.rb +6 -0
  51. data/db/migrate/20260412110625_replace_processing_with_aasm_state.rb +6 -0
  52. data/db/migrate/20260418150323_add_kind_and_message_type_to_pending_messages.rb +6 -0
  53. data/db/migrate/20260419120000_add_drain_fields_to_pending_messages.rb +7 -0
  54. data/db/migrate/20260419130000_drop_pending_messages_kind_default.rb +5 -0
  55. data/db/migrate/20260419140000_add_drain_indexes_to_pending_messages.rb +8 -0
  56. data/db/migrate/20260420100000_add_hud_visibility_to_sessions.rb +15 -0
  57. data/db/queue_structure.sql +61 -0
  58. data/db/structure.sql +133 -0
  59. data/lib/agents/registry.rb +1 -1
  60. data/lib/anima/cli.rb +41 -13
  61. data/lib/anima/installer.rb +13 -0
  62. data/lib/anima/settings.rb +16 -36
  63. data/lib/anima/version.rb +1 -1
  64. data/lib/events/authentication_required.rb +24 -0
  65. data/lib/events/bounce_back.rb +4 -4
  66. data/lib/events/eviction_completed.rb +28 -0
  67. data/lib/events/goal_created.rb +28 -0
  68. data/lib/events/goal_updated.rb +32 -0
  69. data/lib/events/llm_responded.rb +35 -0
  70. data/lib/events/message_created.rb +27 -0
  71. data/lib/events/message_updated.rb +25 -0
  72. data/lib/events/session_state_changed.rb +30 -0
  73. data/lib/events/skill_activated.rb +28 -0
  74. data/lib/events/start_melete.rb +36 -0
  75. data/lib/events/start_mneme.rb +33 -0
  76. data/lib/events/start_processing.rb +32 -0
  77. data/lib/events/subagent_evicted.rb +31 -0
  78. data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
  79. data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
  80. data/lib/events/subscribers/drain_kickoff.rb +20 -0
  81. data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
  82. data/lib/events/subscribers/llm_response_handler.rb +111 -0
  83. data/lib/events/subscribers/melete_kickoff.rb +24 -0
  84. data/lib/events/subscribers/message_broadcaster.rb +34 -0
  85. data/lib/events/subscribers/mneme_kickoff.rb +24 -0
  86. data/lib/events/subscribers/mneme_scheduler.rb +21 -0
  87. data/lib/events/subscribers/persister.rb +8 -9
  88. data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
  89. data/lib/events/subscribers/subagent_message_router.rb +28 -34
  90. data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
  91. data/lib/events/subscribers/tool_response_creator.rb +33 -0
  92. data/lib/events/subscribers/transient_broadcaster.rb +1 -1
  93. data/lib/events/tool_executed.rb +34 -0
  94. data/lib/events/workflow_activated.rb +27 -0
  95. data/lib/llm/client.rb +46 -199
  96. data/lib/mcp/client_manager.rb +41 -46
  97. data/lib/mcp/stdio_transport.rb +9 -5
  98. data/lib/{analytical_brain → melete}/runner.rb +73 -68
  99. data/lib/{analytical_brain → melete}/tools/activate_skill.rb +3 -3
  100. data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +3 -3
  101. data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
  102. data/lib/{analytical_brain → melete}/tools/finish_goal.rb +6 -3
  103. data/lib/melete/tools/goal_messaging.rb +29 -0
  104. data/lib/{analytical_brain → melete}/tools/read_workflow.rb +4 -4
  105. data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
  106. data/lib/{analytical_brain → melete}/tools/set_goal.rb +6 -2
  107. data/lib/{analytical_brain → melete}/tools/update_goal.rb +9 -5
  108. data/lib/{analytical_brain.rb → melete.rb} +6 -3
  109. data/lib/mneme/base_runner.rb +121 -0
  110. data/lib/mneme/l2_runner.rb +14 -20
  111. data/lib/mneme/recall_runner.rb +132 -0
  112. data/lib/mneme/runner.rb +123 -165
  113. data/lib/mneme/search.rb +104 -62
  114. data/lib/mneme/tools/nothing_to_surface.rb +25 -0
  115. data/lib/mneme/tools/save_snapshot.rb +2 -10
  116. data/lib/mneme/tools/surface_memory.rb +89 -0
  117. data/lib/mneme.rb +11 -5
  118. data/lib/providers/anthropic.rb +112 -7
  119. data/lib/shell_session.rb +290 -432
  120. data/lib/skills/definition.rb +2 -2
  121. data/lib/skills/registry.rb +1 -1
  122. data/lib/tools/base.rb +16 -1
  123. data/lib/tools/bash.rb +25 -55
  124. data/lib/tools/edit.rb +2 -0
  125. data/lib/tools/mark_goal_completed.rb +4 -5
  126. data/lib/tools/read.rb +2 -0
  127. data/lib/tools/registry.rb +85 -4
  128. data/lib/tools/response_truncator.rb +1 -1
  129. data/lib/tools/{recall.rb → search_messages.rb} +19 -21
  130. data/lib/tools/spawn_specialist.rb +22 -14
  131. data/lib/tools/spawn_subagent.rb +30 -20
  132. data/lib/tools/subagent_prompts.rb +17 -19
  133. data/lib/tools/think.rb +1 -1
  134. data/lib/tools/{remember.rb → view_messages.rb} +10 -10
  135. data/lib/tools/write.rb +2 -0
  136. data/lib/tui/app.rb +393 -149
  137. data/lib/tui/braille_spinner.rb +7 -7
  138. data/lib/tui/cable_client.rb +9 -16
  139. data/lib/tui/decorators/base_decorator.rb +47 -6
  140. data/lib/tui/decorators/bash_decorator.rb +1 -1
  141. data/lib/tui/decorators/edit_decorator.rb +4 -2
  142. data/lib/tui/decorators/read_decorator.rb +4 -2
  143. data/lib/tui/decorators/think_decorator.rb +2 -2
  144. data/lib/tui/decorators/web_get_decorator.rb +1 -1
  145. data/lib/tui/decorators/write_decorator.rb +4 -2
  146. data/lib/tui/flash.rb +19 -14
  147. data/lib/tui/formatting.rb +20 -9
  148. data/lib/tui/input_buffer.rb +6 -6
  149. data/lib/tui/message_store.rb +165 -28
  150. data/lib/tui/performance_logger.rb +2 -3
  151. data/lib/tui/screens/chat.rb +149 -79
  152. data/lib/tui/settings.rb +93 -0
  153. data/lib/workflows/definition.rb +3 -3
  154. data/lib/workflows/registry.rb +1 -1
  155. data/skills/github.md +38 -0
  156. data/templates/config.toml +16 -32
  157. data/templates/tui.toml +209 -0
  158. data/workflows/review_pr.md +18 -14
  159. metadata +98 -29
  160. data/app/jobs/agent_request_job.rb +0 -199
  161. data/app/jobs/analytical_brain_job.rb +0 -33
  162. data/app/jobs/count_message_tokens_job.rb +0 -39
  163. data/app/jobs/passive_recall_job.rb +0 -29
  164. data/app/models/concerns/message/broadcasting.rb +0 -85
  165. data/config/initializers/fts5_schema_dump.rb +0 -21
  166. data/lib/agent_loop.rb +0 -186
  167. data/lib/analytical_brain/tools/deactivate_skill.rb +0 -39
  168. data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -34
  169. data/lib/environment_probe.rb +0 -232
  170. data/lib/events/agent_message.rb +0 -11
  171. data/lib/events/subscribers/message_collector.rb +0 -64
  172. data/lib/events/tool_call.rb +0 -31
  173. data/lib/events/tool_response.rb +0 -33
  174. data/lib/mneme/compressed_viewport.rb +0 -200
  175. 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 "io/console"
4
- require "pty"
3
+ require "open3"
5
4
  require "securerandom"
6
5
  require "shellwords"
7
6
 
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.
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
- # Auto-recovers from timeouts and crashes: if the shell dies, the next command
13
- # 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).
14
15
  #
15
- # Uses IO.select-based deadlines instead of Timeout.timeout for all PTY reads.
16
- # Timeout.timeout is unsafe with PTY I/O it uses Thread.raise which can
17
- # corrupt mutex state, leave resources inconsistent, and cause exceptions
18
- # to fire outside handler blocks when nested.
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
- # session = ShellSession.new(session_id: 42)
22
- # session.run("cd /tmp")
23
- # session.run("pwd")
24
- # # => {stdout: "/tmp", stderr: "", exit_code: 0}
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
- # @return [String, nil] current working directory of the shell process
28
- attr_reader :pwd
29
-
30
- # @param session_id [Integer, String] unique identifier for logging/diagnostics
31
- def initialize(session_id:)
32
- @session_id = session_id
33
- @mutex = Mutex.new
34
- @fifo_path = File.join(Dir.tmpdir, "anima-stderr-#{Process.pid}-#{SecureRandom.hex(8)}")
35
- @alive = false
36
- @finalized = false
37
- @pwd = nil
38
- @read_buffer = +""
39
- self.class.cleanup_orphans
40
- start
41
- self.class.register(self)
42
- 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
43
63
 
44
- # Execute a command in the persistent shell. Respawns the shell
45
- # 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.
46
90
  #
47
- # @param command [String] bash command to execute
48
- # @param timeout [Integer, nil] per-call timeout in seconds; overrides
49
- # Settings.command_timeout when provided
50
- # @param interrupt_check [Proc, nil] callable returning truthy when the
51
- # user has requested an interrupt. Polled every
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
- at_exit { ShellSession.cleanup_all }
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 saved_pwd [String, nil] directory path to restore
101
+ # @param session_id [Integer, String]
157
102
  # @return [void]
158
- def restore_working_directory(saved_pwd)
159
- return unless saved_pwd && File.directory?(saved_pwd)
160
- execute_in_pty("cd #{Shellwords.shellescape(saved_pwd)}")
161
- end
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
- # Env vars that prevent interactive pagers and credential prompts from
170
- # hanging the PTY. We need a PTY (not pipes) for pwd tracking via /proc
171
- # and signal handling, but this makes programs think they're on a terminal
172
- # and launch pagers. No single switch disables all pagers — each tool has
173
- # its own env var — so we set a comprehensive list plus LESS flags as a
174
- # safety net for direct `less` invocations.
175
- SHELL_ENV = {
176
- "TERM" => "dumb",
177
- "PAGER" => "cat", # Default pager for most Unix tools
178
- "LESS" => "-eFRX", # Safety net: make less auto-exit at EOF, no screen clear
179
- "GIT_PAGER" => "cat", # Git checks this before PAGER
180
- "MANPAGER" => "cat", # man pages
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
- # Disable terminal echo via termios before bash can echo our commands.
194
- # This is instant (kernel-level), unlike stty -echo which races with input.
195
- @pty_stdin.echo = false
122
+ return nil unless status.success?
123
+ cwd = output.strip
124
+ cwd.empty? ? nil : cwd
196
125
  end
197
126
 
198
- def start_stderr_reader
199
- @stderr_mutex = Mutex.new
200
- @stderr_buffer = []
201
- @stderr_bytes = 0
202
- @stderr_truncated = false
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
- # With echo already off (set in spawn_shell), only command output appears.
225
- # The initial bash prompt merges with the marker output on one gets line.
226
- def init_shell
227
- marker = "__ANIMA_INIT_#{SecureRandom.hex(8)}__"
228
- @pty_stdin.puts "PS1=''"
229
- @pty_stdin.puts "exec 2>#{@fifo_path}"
230
- @pty_stdin.puts "echo '#{marker}'"
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
- def execute_in_pty(command, timeout: nil, interrupt_check: nil)
237
- clear_stderr
238
- 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)
239
172
  timeout ||= Anima::Settings.command_timeout
240
- deadline = monotonic_now + timeout
241
173
 
242
- @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)
243
177
 
244
- 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
245
180
 
246
- if exit_code == :interrupted
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
- if exit_code.nil?
258
- recover_shell
259
- stderr = drain_stderr
260
- parts = ["Command timed out after #{timeout} seconds."]
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
- update_pwd
267
- stderr = drain_stderr
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
- # Reads lines from the PTY until the marker appears.
282
- #
283
- # @param marker [String] unique marker to detect command completion
284
- # @param deadline [Float] monotonic clock deadline
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
- if line == :interrupted
298
- exit_code = :interrupted
299
- break
300
- end
200
+ # Kills the underlying tmux session. Idempotent.
201
+ def finalize
202
+ self.class.release(@session_id)
203
+ end
301
204
 
302
- break if line.nil?
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
- line = line.chomp.delete("\r")
214
+ private
305
215
 
306
- if line.include?(marker)
307
- exit_code = line.split.last.to_i
308
- break
309
- end
216
+ def ensure_session(cwd)
217
+ return if alive?
310
218
 
311
- lines << line
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
- # Strip trailing empty line added by our separator echo
315
- lines.pop if lines.last == ""
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
- [lines.join("\n"), exit_code]
234
+ inject_shell_env
318
235
  end
319
236
 
320
- # Reads and discards PTY output until the marker appears or deadline expires.
321
- #
322
- # @param marker [String] unique marker to wait for
323
- # @param deadline [Float] monotonic clock deadline
324
- # @return [Boolean] true if marker was found, false if deadline expired
325
- # @raise [Errno::EIO] when the PTY child process has exited
326
- # @raise [IOError] when the PTY file descriptor is closed
327
- def consume_until(marker, deadline:)
328
- loop do
329
- line = gets_with_deadline(deadline)
330
- return false if line.nil?
331
- return true if line.chomp.delete("\r").include?(marker)
332
- 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)
249
+
250
+ reap_waiter(pid, waiter)
251
+ raise "tmux session #{@target} init timed out"
333
252
  end
334
253
 
335
- # Reads a single line from the PTY, respecting a deadline.
336
- # Caller must hold @mutex @read_buffer is not independently synchronized.
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
- # Uses IO.select for safe, non-interruptive timeout handling instead of
339
- # Timeout.timeout (which uses Thread.raise that can corrupt mutex state
340
- # and leave resources inconsistent).
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
- # When +interrupt_check+ is provided, IO.select uses a shorter timeout
343
- # (capped at {Anima::Settings.interrupt_check_interval}) and polls the
344
- # callback between iterations. Returns +:interrupted+ when the callback
345
- # fires, allowing the caller to send Ctrl+C and return partial output.
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
- # @param deadline [Float] monotonic clock deadline
348
- # @param interrupt_check [Proc, nil] callable returning truthy on user interrupt
349
- # @param check_interval [Float, nil] resolved interrupt check interval (seconds);
350
- # pre-computed by the caller to avoid re-reading Settings on every line
351
- # @return [String] line including trailing newline
352
- # @return [:interrupted] when user interrupt detected
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 nil if remaining <= 0
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
- if ready
370
- begin
371
- @read_buffer << @pty_stdout.read_nonblock(4096)
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 and drains leftover output after a timeout or user interrupt.
382
- # If recovery fails, marks the session as dead (will be respawned on next run).
383
- #
384
- # @return [void]
385
- # @raise [Errno::EIO] when the PTY child process has exited
386
- # @raise [IOError] when the PTY file descriptor is closed
387
- def recover_shell
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
- def clear_stderr
399
- @stderr_mutex.synchronize do
400
- @stderr_buffer.clear
401
- @stderr_bytes = 0
402
- @stderr_truncated = false
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
- end
305
+ return if waiter.join(WAITER_KILL_GRACE)
405
306
 
406
- def drain_stderr
407
- # Allow FIFO reader thread time to flush kernel buffers into @stderr_buffer.
408
- # Without this, stderr arriving just before the marker may be missed.
409
- sleep 0.01
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
- # Reads the shell's current working directory via the /proc filesystem.
421
- # @note Linux-only. Falls back silently on other platforms or if the
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
- def truncate(output)
430
- max_bytes = @max_output_bytes
431
- output = output.dup.force_encoding("UTF-8").scrub
432
-
433
- return output if output.bytesize <= max_bytes
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
- output.byteslice(0, max_bytes)
436
- .scrub +
437
- "\n\n[Truncated: output exceeded #{max_bytes} bytes]"
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