Class: Clacky::Tools::Terminal
- Defined in:
- lib/clacky/tools/terminal.rb,
lib/clacky/tools/terminal/output_cleaner.rb,
lib/clacky/tools/terminal/session_manager.rb,
lib/clacky/tools/terminal/persistent_session.rb
Overview
Unified terminal tool — the SINGLE entry point for running shell commands. Replaces the former ‘shell` + `safe_shell` tools.
AI-facing contract
Five call shapes, all on one tool:
1) Run a command, wait for it:
terminal(command: "ls -la")
→ { exit_code: 0, output: "..." }
2) Run a command that is expected to keep running (dev servers,
watchers, REPLs meant to stay open):
terminal(command: "rails s", background: true)
– collects ~2s of startup output, then:
– if it crashed in those 2s → { exit_code: N, output: "..." }
– if still alive → { session_id: 7, state: "background",
output: "Puma starting..." }
3) A previous call returned a session_id because the command
blocked on input (sudo password, REPL, etc.). Answer it:
terminal(session_id: 3, input: "mypass\n")
4) Poll a running session for new output without sending anything:
terminal(session_id: 7, input: "")
5) Kill a stuck / no-longer-wanted session:
terminal(session_id: 7, kill: true)
Response handshake
- Response has `exit_code` → command finished.
- Response has `session_id` → command is still running;
look at `state`: "waiting" means blocked on input,
"background" means intentionally long-running.
Safety
Every new ‘command` is routed through Clacky::Tools::Security before being handed to the shell. This:
- Blocks sudo / pkill clacky / eval / curl|bash / etc.
- Rewrites `rm` into `mv <trash>` so deletions are recoverable.
- Rewrites `curl ... | bash` into "download & review".
- Protects Gemfile / .env / .ssh / etc. from writes.
‘input` is NOT subject to these rules (it is a reply to an already- running program, not a fresh command).
Defined Under Namespace
Modules: OutputCleaner Classes: PersistentSessionPool, SessionManager, SpawnFailed
Constant Summary collapse
- MAX_LLM_OUTPUT_CHARS =
8_000- DEFAULT_TIMEOUT =
Max seconds we keep a single tool call blocked inside the shell. Raised from 15s → 60s so long-running installs/builds (bundle install, gem install, npm install, docker build, rails new, …) produce far fewer LLM round-trips: each poll replays the full context, so every avoided poll saves ~all the tokens of one turn.
60- DEFAULT_IDLE_MS =
How long output must be quiet before we assume the foreground command is waiting for user input and return control to the LLM. Raised from 500ms → 3000ms: real shell prompts stay quiet forever (so 3s is still instant for them), but long builds have frequent sub-second quiet windows between phases — a small idle threshold shredded those runs into 20+ polls for no real benefit.
3_000- BACKGROUND_COLLECT_SECONDS =
Background commands collect this many seconds of startup output so the agent can see crashes / readiness before getting the session_id.
2- DISABLED_IDLE_MS =
Sentinel: when passed as idle_ms, disables idle early-return.
10_000_000- DISPLAY_COMMAND_MAX_CHARS =
Max visible length of a command inside the tool-call summary line. Keeps the “terminal(…)” summary on a single UI row even when the underlying command spans multiple lines (heredocs, multi-line ruby -e blocks, etc.). The full command is still executed — only the display is shortened.
80- DISPLAY_TAIL_LINES =
Number of trailing lines of output to include in the human-readable display string (the result text that shows up in CLI / WebUI bubbles under each tool call). Keep small so multi-poll loops stay readable.
6
Class Method Summary collapse
-
.command_safe_for_auto_execution?(command) ⇒ Boolean
Alias used by ToolExecutor to decide whether :confirm_safes mode should auto-execute without asking the user.
-
.run_sync(command, timeout: 120, cwd: nil, env: nil) ⇒ Array(String, Integer|nil)
——————————————————————— Internal Ruby API — synchronous capture ———————————————————————.
Instance Method Summary collapse
-
#cd_in_session(session, cwd) ⇒ Object
Called by the pool to move the live shell to ‘cwd`.
-
#execute(command: nil, session_id: nil, input: nil, background: false, cwd: nil, env: nil, timeout: nil, kill: nil, idle_ms: nil, working_dir: nil, **_ignored) ⇒ Object
——————————————————————— Public entrypoint — dispatches on parameter shape ———————————————————————.
- #format_call(args) ⇒ Object
- #format_result(result) ⇒ Object
-
#reset_env_in_session(session, unset_keys:, set_env:) ⇒ Object
Called by the pool to reset env between calls.
-
#source_rc_in_session(session, rc_files) ⇒ Object
Called by the pool when rc files (e.g. ~/.zshrc) have changed since this session was spawned.
-
#spawn_persistent_session ⇒ Object
Public-ish: called by PersistentSessionPool to build a new long-lived shell.
Methods inherited from Base
#category, #description, #name, #parameters, #to_function_definition
Class Method Details
.command_safe_for_auto_execution?(command) ⇒ Boolean
Alias used by ToolExecutor to decide whether :confirm_safes mode should auto-execute without asking the user.
146 147 148 |
# File 'lib/clacky/tools/terminal.rb', line 146 def self.command_safe_for_auto_execution?(command) Clacky::Tools::Security.command_safe_for_auto_execution?(command) end |
.run_sync(command, timeout: 120, cwd: nil, env: nil) ⇒ Array(String, Integer|nil)
Internal Ruby API — synchronous capture
Run a shell command and BLOCK until it terminates, returning [output, exit_code]. Drop-in replacement for Open3.capture2e that goes through the same PTY + login-shell + Security pipeline used by the AI-facing tool (so rbenv/mise shims and gem mirrors work).
Why this exists separately from #execute:
`execute` may return early with a :session_id the moment output
goes idle for DEFAULT_IDLE_MS (3s) — this is intentional for AI
agents (they can inspect progress, inject input, decide to kill).
Ruby callers like the HTTP server's upgrade flow only care about
"did it finish, with what output, what exit code" — they need
synchronous semantics. Previously each caller re-implemented the
poll loop (and 0.9.36's run_shell forgot to, causing the upgrade
failure bug).
NOT exposed in tool_parameters — AI agents cannot invoke this.
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 |
# File 'lib/clacky/tools/terminal.rb', line 180 def self.run_sync(command, timeout: 120, cwd: nil, env: nil) terminal = new result = terminal.execute( command: command, timeout: timeout, cwd: cwd, env: env, ) output = result[:output].to_s # Hard deadline in wall-clock terms — a genuinely stuck command # must terminate. Each individual poll still carries `timeout`. deadline = Time.now + timeout.to_i + 60 while result[:exit_code].nil? && result[:session_id] && Time.now < deadline result = terminal.execute( session_id: result[:session_id], input: "", timeout: timeout, ) output += result[:output].to_s end # Deadline exceeded — best-effort cleanup so the session doesn't leak. if result[:exit_code].nil? && result[:session_id] begin terminal.execute(session_id: result[:session_id], kill: true) rescue StandardError # swallow — cleanup is best-effort end end [output, result[:exit_code]] end |
Instance Method Details
#cd_in_session(session, cwd) ⇒ Object
Called by the pool to move the live shell to ‘cwd`.
775 776 777 |
# File 'lib/clacky/tools/terminal.rb', line 775 def cd_in_session(session, cwd) run_inline(session, "cd #{shell_escape_value(cwd)}") end |
#execute(command: nil, session_id: nil, input: nil, background: false, cwd: nil, env: nil, timeout: nil, kill: nil, idle_ms: nil, working_dir: nil, **_ignored) ⇒ Object
Public entrypoint — dispatches on parameter shape
112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 |
# File 'lib/clacky/tools/terminal.rb', line 112 def execute(command: nil, session_id: nil, input: nil, background: false, cwd: nil, env: nil, timeout: nil, kill: nil, idle_ms: nil, working_dir: nil, **_ignored) timeout = (timeout || DEFAULT_TIMEOUT).to_i idle_ms = (idle_ms || DEFAULT_IDLE_MS).to_i cwd ||= working_dir # Kill if kill return { error: "session_id is required when kill: true" } if session_id.nil? return do_kill(session_id.to_i) end # Continue / poll a running session if session_id return { error: "input is required when session_id is given" } if input.nil? return do_continue(session_id.to_i, input.to_s, timeout: timeout, idle_ms: idle_ms) end # Start a new command if command && !command.to_s.strip.empty? return do_start(command.to_s, cwd: cwd, env: env, timeout: timeout, idle_ms: idle_ms, background: background ? true : false) end { error: "terminal: must provide either `command`, or `session_id`+`input`, or `session_id`+`kill: true`." } rescue SecurityError => e { error: "[Security] #{e.}", security_blocked: true } rescue StandardError => e { error: "terminal failed: #{e.class}: #{e.}", backtrace: e.backtrace.first(5) } end |
#format_call(args) ⇒ Object
876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 |
# File 'lib/clacky/tools/terminal.rb', line 876 def format_call(args) cmd = args[:command] || args["command"] sid = args[:session_id] || args["session_id"] inp = args[:input] || args["input"] kill = args[:kill] || args["kill"] bg = args[:background] || args["background"] if kill && sid "terminal(stop)" elsif sid if inp.to_s.empty? "terminal(check output)" else preview = inp.to_s.strip preview = preview.length > 30 ? "#{preview[0, 30]}..." : preview "terminal(send #{preview.inspect})" end elsif cmd display_cmd = compact_command_for_display(cmd) bg ? "terminal(#{display_cmd}, background)" : "terminal(#{display_cmd})" else "terminal(?)" end end |
#format_result(result) ⇒ Object
917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 |
# File 'lib/clacky/tools/terminal.rb', line 917 def format_result(result) return "[Blocked] #{result[:error]}" if result.is_a?(Hash) && result[:security_blocked] return "error: #{result[:error]}" if result.is_a?(Hash) && result[:error] return "stopped" if result.is_a?(Hash) && result[:killed] return "done" unless result.is_a?(Hash) prefix = result[:security_rewrite] ? "[Safe] " : "" tail = display_tail(result[:output]) status = if result[:session_id] # still running / waiting for input state = result[:state] || "waiting" "… #{state}" elsif result.key?(:exit_code) ec = result[:exit_code] ec.to_i.zero? ? "✓ exit=0" : "✗ exit=#{ec}" else "done" end status = "#{prefix}#{status}" unless prefix.empty? tail.empty? ? status : "#{tail}\n#{status}" end |
#reset_env_in_session(session, unset_keys:, set_env:) ⇒ Object
Called by the pool to reset env between calls. First unsets any keys we exported last time, then exports the new ones.
766 767 768 769 770 771 772 |
# File 'lib/clacky/tools/terminal.rb', line 766 def reset_env_in_session(session, unset_keys:, set_env:) parts = [] unset_keys.each { |k| parts << "unset #{shell_escape_var(k)}" } set_env.each { |k, v| parts << "export #{shell_escape_var(k)}=#{shell_escape_value(v)}" } return if parts.empty? run_inline(session, parts.join("; ")) end |
#source_rc_in_session(session, rc_files) ⇒ Object
Called by the pool when rc files (e.g. ~/.zshrc) have changed since this session was spawned. Sources them all; ignores per-file errors.
754 755 756 757 758 759 760 761 762 |
# File 'lib/clacky/tools/terminal.rb', line 754 def source_rc_in_session(session, rc_files) return if rc_files.empty? esc = rc_files.map { |f| "\"#{f.gsub('"', '\"')}\"" }.join(" ") run_inline( session, rc_files.map { |f| "source \"#{f.gsub('"', '\"')}\" 2>/dev/null" }.join("; "), timeout: 10 ) end |
#spawn_persistent_session ⇒ Object
Public-ish: called by PersistentSessionPool to build a new long-lived shell. Uses the user’s SHELL with login+interactive flags so that all rc hooks (nvm, rbenv, brew shellenv, mise, conda, etc.) are loaded.
562 563 564 565 566 567 568 569 |
# File 'lib/clacky/tools/terminal.rb', line 562 def spawn_persistent_session shell, shell_name = user_shell args = persistent_shell_args(shell, shell_name) session = spawn_shell(args: args, shell_name: shell_name, command: "<persistent>", cwd: nil, env: {}) raise SpawnFailed, session[:error] if session.is_a?(Hash) session end |