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.
- checksums.yaml +4 -4
- data/.reek.yml +18 -20
- data/README.md +61 -95
- 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 +13 -2
- 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 +21 -10
- data/app/models/message.rb +47 -36
- data/app/models/pending_message.rb +276 -29
- data/app/models/pinned_message.rb +8 -3
- data/app/models/session.rb +468 -432
- data/app/models/snapshot.rb +11 -21
- data/bin/inspect-cassette +17 -4
- data/config/application.rb +1 -0
- data/config/initializers/event_subscribers.rb +71 -4
- data/config/initializers/inflections.rb +3 -1
- data/db/cable_structure.sql +3 -3
- 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 +13 -13
- data/db/structure.sql +44 -31
- data/lib/agents/registry.rb +1 -1
- data/lib/anima/settings.rb +7 -33
- 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 +6 -8
- data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
- data/lib/events/subscribers/subagent_message_router.rb +26 -29
- 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 +41 -201
- data/lib/mcp/client_manager.rb +41 -46
- data/lib/mcp/stdio_transport.rb +9 -5
- data/lib/{analytical_brain → melete}/runner.rb +63 -68
- data/lib/{analytical_brain → melete}/tools/activate_skill.rb +1 -1
- data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +2 -2
- data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
- data/lib/{analytical_brain → melete}/tools/finish_goal.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/goal_messaging.rb +4 -3
- data/lib/{analytical_brain → melete}/tools/read_workflow.rb +2 -2
- data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/set_goal.rb +1 -1
- data/lib/{analytical_brain → melete}/tools/update_goal.rb +4 -4
- 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 +118 -171
- 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/shell_session.rb +287 -612
- data/lib/skills/definition.rb +2 -2
- data/lib/skills/registry.rb +1 -1
- data/lib/tools/base.rb +16 -0
- data/lib/tools/bash.rb +25 -57
- data/lib/tools/edit.rb +2 -0
- data/lib/tools/read.rb +2 -0
- data/lib/tools/registry.rb +79 -3
- data/lib/tools/{recall.rb → search_messages.rb} +19 -21
- data/lib/tools/spawn_specialist.rb +16 -10
- data/lib/tools/spawn_subagent.rb +20 -14
- data/lib/tools/subagent_prompts.rb +4 -4
- 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 +5 -4
- data/lib/tui/braille_spinner.rb +7 -7
- data/lib/tui/decorators/base_decorator.rb +24 -3
- data/lib/tui/message_store.rb +93 -44
- data/lib/tui/screens/chat.rb +94 -20
- data/lib/tui/settings.rb +9 -2
- data/lib/workflows/definition.rb +3 -3
- data/lib/workflows/registry.rb +1 -1
- data/skills/github.md +38 -0
- data/templates/config.toml +4 -23
- data/workflows/review_pr.md +18 -14
- metadata +86 -28
- 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 -24
- data/app/models/concerns/message/broadcasting.rb +0 -86
- data/lib/agent_loop.rb +0 -215
- data/lib/analytical_brain/tools/deactivate_skill.rb +0 -40
- data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -35
- data/lib/events/agent_message.rb +0 -25
- 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 -204
- 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
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
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
|
-
#
|
|
41
|
-
#
|
|
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
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
# # => {
|
|
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
|
-
#
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
#
|
|
71
|
-
#
|
|
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
|
|
74
|
-
# @
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
#
|
|
171
|
-
#
|
|
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
|
|
101
|
+
# @param session_id [Integer, String]
|
|
184
102
|
# @return [void]
|
|
185
|
-
def
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
#
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
122
|
+
return nil unless status.success?
|
|
123
|
+
cwd = output.strip
|
|
124
|
+
cwd.empty? ? nil : cwd
|
|
223
125
|
end
|
|
224
126
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
#
|
|
252
|
-
#
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
@
|
|
256
|
-
@
|
|
257
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
+
state = wait_for_completion(uuid, timeout, interrupt_check)
|
|
179
|
+
output = capture_output
|
|
272
180
|
|
|
273
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
#
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
#
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
365
|
-
#
|
|
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.
|
|
366
208
|
#
|
|
367
|
-
#
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
214
|
+
private
|
|
397
215
|
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
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
|
-
|
|
411
|
-
|
|
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
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
463
|
-
#
|
|
464
|
-
#
|
|
465
|
-
#
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
477
|
-
|
|
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
|
-
#
|
|
486
|
-
#
|
|
487
|
-
#
|
|
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
|
-
#
|
|
490
|
-
#
|
|
491
|
-
|
|
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
|
-
#
|
|
501
|
-
#
|
|
502
|
-
|
|
503
|
-
|
|
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 [
|
|
533
|
-
def
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
274
|
+
loop do
|
|
275
|
+
remaining = deadline - monotonic_now
|
|
276
|
+
return cancel_command(pid, waiter, :timeout) if remaining <= 0
|
|
543
277
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
.
|
|
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
|
-
|
|
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
|
-
#
|
|
566
|
-
|
|
567
|
-
#
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
#
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
|
|
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
|
-
|
|
614
|
-
output
|
|
615
|
-
|
|
616
|
-
|
|
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
|