Class: Clacky::Tools::Terminal

Inherits:
Base
  • Object
show all
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

Instance Method Summary collapse

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.

Returns:

  • (Boolean)


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.

Parameters:

  • command (String)

    the shell command to run

  • timeout (Integer) (defaults to: 120)

    per-poll timeout AND the basis for the overall deadline (deadline = timeout + 60s)

  • cwd (String) (defaults to: nil)

    optional working directory

  • env (Hash) (defaults to: nil)

    optional env overrides

Returns:

  • (Array(String, Integer|nil))

    [output, exit_code]. exit_code is nil only if the overall deadline was hit and the session had to be force-killed.



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.message}", security_blocked: true }
rescue StandardError => e
  { error: "terminal failed: #{e.class}: #{e.message}", 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_sessionObject

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.

Raises:



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