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 `curl ... | bash` into "download & review".
- Protects Gemfile / .env / .ssh / etc. from writes.
‘rm` is additionally intercepted at runtime by a shell function installed in each PTY session (see SAFE_RM_BASH): it moves files into the per-project trash at $CLACKY_TRASH_DIR instead of deleting them. See trash_manager for list/restore. `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 =
Hard ceiling on the raw ‘output:` string we send back to the LLM. 4000 chars ≈ 1000 tokens — matches the value the legacy safe_shell tool used, which was empirically tuned to keep tool-call turns cheap. When real output exceeds this we SPILL the full cleaned text to a dedicated overflow file and only return the first portion — see OVERFLOW_PREVIEW_CHARS / spill_overflow_file below.
4_000- OVERFLOW_PREVIEW_CHARS =
When output overflows, the preview we keep in-context is slightly shorter than the hard ceiling so the “full output at: /tmp/…” notice + path still fits under MAX_LLM_OUTPUT_CHARS.
3_800- MAX_LINE_CHARS =
Per-line cap applied at write-time (inside the cleaning pipeline). Prevents a single minified JSON / CSS / JS blob from eating the entire 4 KB budget in one go. 500 chars is long enough to preserve real error messages (including stack frames) but short enough to survive dozens of lines inside 4 KB.
500- 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 → 10_000ms: real shell prompts (sudo, REPL, [Y/n] confirmations) stay quiet forever, so 10s still feels instant for them; long builds / test runs frequently have multi- second gaps between phases (compilation ↔ linking, spec file transitions), and anything below 10s split those into multiple polls — each poll replays the whole LLM context, which is expensive.
10_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- SLOW_COMMAND_PATTERNS =
Commands that we know take a long time and produce bursty output (quiet gaps between test files, compile phases, download batches, etc.). When the command line STARTS WITH or CONTAINS any of these tokens, we auto-extend the timeout to SLOW_COMMAND_TIMEOUT and disable idle-return entirely — otherwise the LLM ends up polling the same long-running job 5-10x, replaying full context each time. Taken verbatim from the legacy shell.rb list.
[ "bundle install", "bundle update", "bundle exec rspec", "npm install", "npm run build", "npm run test", "yarn install", "yarn build", "pnpm install", "pnpm build", "rspec", "rake test", "rails test", "cargo build", "cargo test", "go build", "go test", "mvn test", "mvn package", "gradle build", "pytest", "pip install", "docker build", "docker-compose build" ].freeze
- SLOW_COMMAND_TIMEOUT =
Timeout granted to commands matched by SLOW_COMMAND_PATTERNS. 180s matches the legacy safe_shell “hard_timeout” for slow commands.
180- SAFE_RM_PATH =
Absolute path to the safe-rm shell snippet shipped with the gem. Sourced by every interactive PTY session to install a ‘rm` shell function that moves files to $CLACKY_TRASH_DIR instead of deleting them.
Why source-from-file instead of writing the function body into the PTY directly?
Writing a multi-line function definition into `zsh -l -i` is unreliable — ZLE (Zsh Line Editor) treats multi-line input as interactive editing and garbles the body. Loading from a file via a single `source` line avoids ZLE entirely.Why a shell function (instead of a Ruby-side text rewrite)?
A function defers parsing to the shell itself, so heredocs, multi-line commands, globs, and variable expansion are all handled correctly. The previous Ruby rewriter mis-parsed any command containing a heredoc body with "rm" in it.Coverage:
Intercepts — direct `rm …` in the interactive shell (incl. multi-line, heredoc, glob, env-var expansion). Bypassed by — `command rm`, `/bin/rm`, `xargs rm`, `find -exec rm`, child scripts. Same coverage as the old rewriter. File.("terminal/safe_rm.sh", __dir__).freeze
- OVERFLOW_DIR_NAME =
Overflow directory: shared across sessions (and persists after Clacky exits) so the LLM can re-read the full output in later turns. Lives under /tmp so it is naturally swept by the OS, and we also best-effort prune files older than OVERFLOW_MAX_AGE_SEC on each write so long-running servers don’t accumulate garbage.
"clacky-terminal-overflow"- OVERFLOW_MAX_AGE_SEC =
7 days
7 * 24 * 60 * 60
- 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- POWERSHELL_PREAMBLE =
PowerShell 5 on Chinese Windows defaults [Console]::OutputEncoding to CP936/GBK; our PTY decodes as UTF-8 so non-ASCII output becomes ‘???`. Inject UTF-8 setup into the user’s PowerShell command so the shell emits UTF-8 bytes regardless of host locale.
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;" \ "$OutputEncoding=[Text.Encoding]::UTF8;"
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, on_output: 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.
259 260 261 |
# File 'lib/clacky/tools/terminal.rb', line 259 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.
293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 |
# File 'lib/clacky/tools/terminal.rb', line 293 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`.
1101 1102 1103 |
# File 'lib/clacky/tools/terminal.rb', line 1101 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, on_output: nil, **_ignored) ⇒ Object
Public entrypoint — dispatches on parameter shape
200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 |
# File 'lib/clacky/tools/terminal.rb', line 200 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, on_output: nil, **_ignored) # Auto-tune: if the caller didn't explicitly set a timeout/idle_ms # AND the command is a well-known long-runner (rspec, bundle install, # cargo build, etc.), we stretch the budget AND disable idle-return. # This collapses what would otherwise be 5-10 "is it still running?" # LLM round-trips into a single synchronous call. Background flag and # session-continuation calls are NOT auto-tuned — background already # returns quickly by design, and continuing a session uses whatever # budget the caller requests. if command && !background && !session_id && slow_command?(command) timeout ||= SLOW_COMMAND_TIMEOUT idle_ms ||= DISABLED_IDLE_MS end 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, on_output: on_output) end # Start a new command if command && !command.to_s.strip.empty? if multiline_command?(command) return { error: "Multi-line commands are unreliable in our PTY shell " \ "(heredocs / unclosed quotes / multi-line blocks can hang the session).", hint: "Write the script to a file first, then execute it. " \ "Example: 1) write(path: \"/tmp/run.sh\", content: \"...\") " \ "2) terminal(command: \"bash /tmp/run.sh\")", multiline_blocked: true } end return do_start(command.to_s, cwd: cwd, env: env, timeout: timeout, idle_ms: idle_ms, background: background ? true : false, on_output: on_output) 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
1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 |
# File 'lib/clacky/tools/terminal.rb', line 1414 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
1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 |
# File 'lib/clacky/tools/terminal.rb', line 1455 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? # When output overflowed, surface the file path in the UI too # (not just in the LLM-facing `output`). Keeps the dev aware that # the full log is recoverable. if result[:full_output_file] status = "#{status} [full: #{result[:full_output_file]}]" end 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.
1092 1093 1094 1095 1096 1097 1098 |
# File 'lib/clacky/tools/terminal.rb', line 1092 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 in shell-startup order so later files can see env set by earlier ones.
Notes:
- Errors inside each `source` are NOT silenced (dropping stderr
previously masked failures like a broken `mise activate` that
would leave PATH without node/ruby/etc.). They land in the PTY
log where a developer can inspect them if a command mysteriously
fails to find a tool.
- `|| true` keeps the compound line's exit code at 0 so our
marker reader treats the re-source as "succeeded" regardless
of per-file hiccups — we don't want a flaky rc to disable the
whole persistent shell.
1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 |
# File 'lib/clacky/tools/terminal.rb', line 1075 def source_rc_in_session(session, rc_files) return if rc_files.empty? sources = rc_files.map { |f| escaped = f.gsub('"', '\"') "source \"#{escaped}\" || true" }.join("; ") # rc files often gate interactive-only setup (mise activate, direnv # hook, nvm, pyenv, oh-my-zsh) on `[ -z "$PS1" ]` / `[[ -o interactive ]]`. # We normally keep PS1="" to suppress prompt noise in captured output, # but that makes those gates fail when we re-source rc here. Set a # placeholder PS1 just for the duration of the source, then restore "". cmd = %Q{__clacky_old_ps1="$PS1"; PS1="__CLACKY_PS1__"; #{sources}; PS1="$__clacky_old_ps1"; unset __clacky_old_ps1} run_inline(session, cmd, timeout: 15) 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.
747 748 749 750 751 752 753 754 |
# File 'lib/clacky/tools/terminal.rb', line 747 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 |