openclacky 0.9.34 → 0.9.35

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/lib/clacky/agent/cost_tracker.rb +1 -1
  4. data/lib/clacky/agent/llm_caller.rb +14 -10
  5. data/lib/clacky/agent/memory_updater.rb +1 -1
  6. data/lib/clacky/agent/session_serializer.rb +2 -0
  7. data/lib/clacky/agent/skill_manager.rb +1 -1
  8. data/lib/clacky/agent/tool_executor.rb +13 -16
  9. data/lib/clacky/agent/tool_registry.rb +0 -3
  10. data/lib/clacky/agent.rb +63 -38
  11. data/lib/clacky/agent_config.rb +5 -1
  12. data/lib/clacky/brand_config.rb +11 -27
  13. data/lib/clacky/cli.rb +36 -0
  14. data/lib/clacky/default_skills/channel-setup/SKILL.md +1 -1
  15. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +1 -1
  16. data/lib/clacky/default_skills/new/SKILL.md +1 -1
  17. data/lib/clacky/default_skills/product-help/SKILL.md +1 -1
  18. data/lib/clacky/default_skills/recall-memory/SKILL.md +1 -1
  19. data/lib/clacky/default_skills/skill-creator/SKILL.md +1 -1
  20. data/lib/clacky/idle_compression_timer.rb +8 -0
  21. data/lib/clacky/json_ui_controller.rb +2 -1
  22. data/lib/clacky/plain_ui_controller.rb +10 -3
  23. data/lib/clacky/platform_http_client.rb +161 -1
  24. data/lib/clacky/server/channel/channel_manager.rb +5 -3
  25. data/lib/clacky/server/channel/channel_ui_controller.rb +6 -2
  26. data/lib/clacky/server/http_server.rb +235 -40
  27. data/lib/clacky/server/scheduler.rb +17 -16
  28. data/lib/clacky/server/session_registry.rb +1 -5
  29. data/lib/clacky/server/web_ui_controller.rb +7 -6
  30. data/lib/clacky/session_manager.rb +22 -0
  31. data/lib/clacky/skill.rb +19 -3
  32. data/lib/clacky/skill_loader.rb +5 -59
  33. data/lib/clacky/tools/browser.rb +25 -73
  34. data/lib/clacky/tools/security.rb +326 -0
  35. data/lib/clacky/tools/terminal/output_cleaner.rb +63 -0
  36. data/lib/clacky/tools/terminal/persistent_session.rb +247 -0
  37. data/lib/clacky/tools/terminal/session_manager.rb +208 -0
  38. data/lib/clacky/tools/terminal.rb +818 -0
  39. data/lib/clacky/tools/todo_manager.rb +6 -16
  40. data/lib/clacky/tools/trash_manager.rb +2 -2
  41. data/lib/clacky/ui2/components/input_area.rb +11 -2
  42. data/lib/clacky/ui2/layout_manager.rb +438 -488
  43. data/lib/clacky/ui2/output_buffer.rb +310 -0
  44. data/lib/clacky/ui2/ui_controller.rb +72 -21
  45. data/lib/clacky/ui_interface.rb +1 -1
  46. data/lib/clacky/utils/encoding.rb +1 -1
  47. data/lib/clacky/utils/environment_detector.rb +43 -0
  48. data/lib/clacky/utils/model_pricing.rb +3 -3
  49. data/lib/clacky/version.rb +1 -1
  50. data/lib/clacky/web/app.css +479 -178
  51. data/lib/clacky/web/app.js +146 -4
  52. data/lib/clacky/web/auth.js +101 -0
  53. data/lib/clacky/web/i18n.js +35 -1
  54. data/lib/clacky/web/index.html +9 -2
  55. data/lib/clacky/web/sessions.js +254 -15
  56. data/lib/clacky/web/skills.js +20 -6
  57. data/lib/clacky/web/tasks.js +54 -2
  58. data/lib/clacky/web/theme.js +58 -20
  59. data/lib/clacky/web/ws.js +11 -2
  60. data/lib/clacky.rb +2 -2
  61. metadata +8 -3
  62. data/lib/clacky/tools/safe_shell.rb +0 -608
  63. data/lib/clacky/tools/shell.rb +0 -522
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Clacky
6
+ module Tools
7
+ class Terminal < Base
8
+ # Holds (at most) ONE long-lived PTY shell session that is reused
9
+ # across multiple terminal calls. Reusing the session eliminates the
10
+ # ~1s cold-start cost of `zsh -l -i` / `bash -l -i` on every command.
11
+ #
12
+ # Reuse rules:
13
+ # - Only non-background, non-dedicated calls take from the persistent
14
+ # slot. background / env-overridden calls spawn a fresh session.
15
+ # - Before each call we diff rc-file mtime(s); if changed, we
16
+ # `source` them once inside the live shell so the user sees freshly
17
+ # installed PATH / functions / aliases on the very next command.
18
+ # - If a command leaves the session in a non-clean state (marker not
19
+ # hit — i.e. the program is still running and interactive), the
20
+ # session is "donated" to the caller as a dedicated session_id and
21
+ # the persistent slot is cleared (next call rebuilds a fresh one).
22
+ # - If cleanup fails or a spawn fails, we transparently fall back to
23
+ # the old one-shot `bash --noprofile --norc -i` spawn.
24
+ #
25
+ # Thread safety:
26
+ # - Each persistent session has its own mutex (Session#mutex) that
27
+ # serialises PTY writes (unchanged).
28
+ # - The PersistentSessionPool itself is guarded by a class-level
29
+ # mutex so concurrent terminal calls don't race on acquire/release.
30
+ class PersistentSessionPool
31
+ class << self
32
+ def instance
33
+ @instance ||= new
34
+ end
35
+
36
+ def reset!
37
+ if @instance
38
+ begin
39
+ @instance.shutdown!
40
+ rescue StandardError
41
+ # swallow — best-effort during tests / shutdown
42
+ end
43
+ end
44
+ @instance = nil
45
+ end
46
+ end
47
+
48
+ def initialize
49
+ @mutex = Mutex.new
50
+ @session = nil # currently-idle persistent session, or nil
51
+ @rc_fingerprint = nil # mtime snapshot used to detect rc changes
52
+ @last_env_keys = [] # keys we exported last time; unset them on env change
53
+ @disabled = false # set to true after a spawn failure to stop retrying
54
+ end
55
+
56
+ # Acquire a persistent session for a new command.
57
+ #
58
+ # Returns [session, reused:] where `session` is a running PTY
59
+ # session ready to accept a command (no concurrent command in
60
+ # flight). Raises SpawnFailed if we can't build one.
61
+ #
62
+ # `reused:` is true when an existing session was handed out; false
63
+ # when we had to spawn a fresh one.
64
+ #
65
+ # Side effects when reusing:
66
+ # - Sources rc files if their mtimes changed.
67
+ # - `cd`s to `cwd` if given.
68
+ # - Resets env vars that were exported last time and exports the
69
+ # new ones (only when `env` is non-nil).
70
+ def acquire(runner:, cwd: nil, env: nil)
71
+ @mutex.synchronize do
72
+ return [nil, false] if @disabled
73
+
74
+ # 1) Make sure the stored session is still healthy.
75
+ if @session
76
+ unless session_healthy?(@session)
77
+ drop_locked
78
+ end
79
+ end
80
+
81
+ # 2) Spawn a fresh one if we don't have anything warm.
82
+ unless @session
83
+ begin
84
+ @session = runner.spawn_persistent_session
85
+ rescue StandardError => e
86
+ @disabled = true
87
+ raise SpawnFailed, e.message
88
+ end
89
+ @rc_fingerprint = current_rc_fingerprint
90
+ @last_env_keys = []
91
+ reused = false
92
+ else
93
+ reused = true
94
+ end
95
+
96
+ # 3) If rc files changed since last use, re-source them once.
97
+ if reused && rc_changed?
98
+ runner.source_rc_in_session(@session, rc_files_for_shell(@session.shell_name))
99
+ @rc_fingerprint = current_rc_fingerprint
100
+ end
101
+
102
+ # 4) Reset env — unset old, export new.
103
+ if env && !env.empty?
104
+ new_keys = env.keys.map(&:to_s)
105
+ to_unset = @last_env_keys - new_keys
106
+ runner.reset_env_in_session(@session, unset_keys: to_unset, set_env: env)
107
+ @last_env_keys = new_keys
108
+ elsif !@last_env_keys.empty?
109
+ runner.reset_env_in_session(@session, unset_keys: @last_env_keys, set_env: {})
110
+ @last_env_keys = []
111
+ end
112
+
113
+ # 5) cd to the requested directory.
114
+ if cwd && Dir.exist?(cwd.to_s)
115
+ runner.cd_in_session(@session, cwd.to_s)
116
+ end
117
+
118
+ session = @session
119
+ # Remove it from the slot for the duration of the command so
120
+ # a concurrent caller can't grab the same shell mid-run.
121
+ @session = nil
122
+
123
+ [session, reused]
124
+ end
125
+ end
126
+
127
+ # Put a session back into the persistent slot after a successful
128
+ # command. If the slot is already filled (concurrent call built
129
+ # another one), we just discard the extra to avoid leaks.
130
+ def release(session)
131
+ @mutex.synchronize do
132
+ if @session.nil? && session_healthy?(session)
133
+ @session = session
134
+ else
135
+ # Either we already have one, or this one looks unhealthy.
136
+ # Let the caller's cleanup_session path handle teardown.
137
+ end
138
+ end
139
+ end
140
+
141
+ # The caller has decided the session is unusable (e.g. command left
142
+ # an interactive program running). Forget it without killing — the
143
+ # caller is keeping the PTY alive for their own use.
144
+ def discard
145
+ @mutex.synchronize { @session = nil }
146
+ end
147
+
148
+ # Shut the persistent session down (typically at_exit).
149
+ def shutdown!
150
+ @mutex.synchronize do
151
+ sess = @session
152
+ @session = nil
153
+ next unless sess
154
+ begin
155
+ Process.kill("TERM", sess.pid)
156
+ rescue StandardError
157
+ # ignore
158
+ end
159
+ end
160
+ end
161
+
162
+ def drop_locked
163
+ sess = @session
164
+ @session = nil
165
+ return unless sess
166
+ begin
167
+ Process.kill("TERM", sess.pid)
168
+ rescue StandardError
169
+ # ignore
170
+ end
171
+ SessionManager.forget(sess.id)
172
+ end
173
+
174
+ private :drop_locked
175
+
176
+ def session_healthy?(session)
177
+ return false unless session
178
+ return false if %w[exited killed].include?(session.status.to_s)
179
+ # Probe the child process to make sure it's still alive.
180
+ begin
181
+ Process.kill(0, session.pid)
182
+ true
183
+ rescue Errno::ESRCH
184
+ false
185
+ rescue StandardError
186
+ # EPERM etc. — assume alive
187
+ true
188
+ end
189
+ end
190
+
191
+ private :session_healthy?
192
+
193
+ # --- rc mtime tracking ---------------------------------------------------
194
+
195
+ def current_rc_fingerprint
196
+ files = rc_files_for_shell(nil) # superset of all known rc files
197
+ files.each_with_object({}) do |path, h|
198
+ h[path] = File.mtime(path).to_f if File.exist?(path)
199
+ end
200
+ end
201
+
202
+ private :current_rc_fingerprint
203
+
204
+ def rc_changed?
205
+ new_fp = current_rc_fingerprint
206
+ changed = (new_fp != @rc_fingerprint)
207
+ changed
208
+ end
209
+
210
+ private :rc_changed?
211
+
212
+ # Return the rc files relevant to the given shell. If shell_name is
213
+ # nil (used by current_rc_fingerprint when we have no session), we
214
+ # return a superset so we always catch changes regardless of shell.
215
+ def rc_files_for_shell(shell_name)
216
+ home = ENV["HOME"].to_s
217
+ case shell_name
218
+ when "zsh"
219
+ %w[.zshrc .zprofile .zshenv]
220
+ when "bash"
221
+ %w[.bashrc .bash_profile .profile]
222
+ else
223
+ %w[.zshrc .zprofile .zshenv .bashrc .bash_profile .profile]
224
+ end.map { |f| File.join(home, f) }.select { |f| File.exist?(f) }
225
+ end
226
+
227
+ private :rc_files_for_shell
228
+ end
229
+
230
+ # Raised by the pool when a persistent spawn can't be created; callers
231
+ # should fall back to a one-shot session.
232
+ class SpawnFailed < StandardError; end
233
+ end
234
+ end
235
+ end
236
+
237
+ # Ensure the persistent shell is cleaned up on interpreter exit. Session-
238
+ # level kill_all! in SessionManager handles anything that's still registered,
239
+ # but we also explicitly SIGTERM the pool's current slot so the child shell
240
+ # doesn't linger.
241
+ at_exit do
242
+ begin
243
+ Clacky::Tools::Terminal::PersistentSessionPool.instance.shutdown!
244
+ rescue StandardError
245
+ # never raise from at_exit
246
+ end
247
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+ require "securerandom"
6
+
7
+ module Clacky
8
+ module Tools
9
+ class Terminal < Base
10
+ # In-process registry of interactive PTY sessions.
11
+ #
12
+ # Lifecycle: sessions die with the openclacky process because the child
13
+ # bash is a grandchild of openclacky (PTY.spawn forks then execs), and
14
+ # we also SIGKILL them on interpreter exit via an at_exit hook.
15
+ #
16
+ # Thread-safety: all mutations go through a class-level Mutex. The
17
+ # reader thread writes to Session#log_io concurrently with the main
18
+ # thread reading log_file, but File IO is append-safe on POSIX so we
19
+ # don't need to lock reads — we just pin them by byte offset.
20
+ #
21
+ # Status values:
22
+ # "starting" - PTY spawned, setup in progress
23
+ # "running" - ready to receive commands
24
+ # "exited" - child process ended
25
+ # "killed" - we signalled it
26
+ class SessionManager
27
+ Session = Struct.new(
28
+ :id, # Integer, 1-based unique id within this openclacky process
29
+ :pid, # Integer, PID of the PTY child
30
+ :command, # String, original command launched
31
+ :cwd, # String, working directory at launch
32
+ :started_at, # Time
33
+ :log_file, # String path, raw PTY output append-only
34
+ :log_io, # File, write handle owned by reader thread
35
+ :reader, # IO, PTY read end
36
+ :writer, # IO, PTY write end
37
+ :reader_thread, # Thread, reads PTY → log file
38
+ :status, # "starting" | "running" | "exited" | "killed"
39
+ :exit_code, # Integer or nil
40
+ :mode, # "shell" (marker-based) | "raw" (idle-based)
41
+ :marker_token, # String, unique per-session token for PROMPT_COMMAND
42
+ :marker_regex, # Regexp, compiled match for marker
43
+ :read_offset, # Integer, bytes already returned by previous read calls
44
+ :mutex, # per-session mutex for PTY writes
45
+ :shell_name, # "zsh" | "bash" | "sh" — informs marker syntax & rc reload
46
+ keyword_init: true
47
+ )
48
+
49
+ @sessions = {}
50
+ @next_id = 0
51
+ @mutex = Mutex.new
52
+
53
+ class << self
54
+ # Register a new session. Caller has already spawned the PTY and
55
+ # started the reader thread; we just record the metadata.
56
+ def register(pid:, command:, cwd:, log_file:, log_io:, reader:, writer:,
57
+ reader_thread:, mode:, marker_token: nil, shell_name: nil)
58
+ @mutex.synchronize do
59
+ @next_id += 1
60
+ session = Session.new(
61
+ id: @next_id,
62
+ pid: pid,
63
+ command: command,
64
+ cwd: cwd,
65
+ started_at: Time.now,
66
+ log_file: log_file,
67
+ log_io: log_io,
68
+ reader: reader,
69
+ writer: writer,
70
+ reader_thread: reader_thread,
71
+ status: "starting",
72
+ exit_code: nil,
73
+ mode: mode,
74
+ marker_token: marker_token,
75
+ marker_regex: marker_token ? /__CLACKY_DONE_#{marker_token}_(\d+)__/ : nil,
76
+ read_offset: 0,
77
+ mutex: Mutex.new,
78
+ shell_name: shell_name
79
+ )
80
+ @sessions[session.id] = session
81
+ session
82
+ end
83
+ end
84
+
85
+ def get(id)
86
+ @mutex.synchronize { @sessions[id] }
87
+ end
88
+
89
+ def list
90
+ refresh_all
91
+ @mutex.synchronize { @sessions.values.sort_by(&:id) }
92
+ end
93
+
94
+ # Send signal to child, mark as killed. Returns the Session, or nil
95
+ # if unknown.
96
+ def kill(id, signal: "TERM")
97
+ session = @mutex.synchronize { @sessions[id] }
98
+ return nil unless session
99
+
100
+ begin
101
+ Process.kill(signal, session.pid)
102
+ rescue Errno::ESRCH, Errno::EPERM
103
+ # Already dead — fall through and mark killed.
104
+ end
105
+
106
+ @mutex.synchronize do
107
+ if session.status == "starting" || session.status == "running"
108
+ session.status = "killed"
109
+ end
110
+ end
111
+ session
112
+ end
113
+
114
+ # Forget a session (after it has been killed/exited). Does NOT kill
115
+ # the process — callers should kill first.
116
+ def forget(id)
117
+ @mutex.synchronize { @sessions.delete(id) }
118
+ end
119
+
120
+ # Refresh status of one session in-place (mutex-held).
121
+ private def refresh_locked(session)
122
+ return unless session.status == "starting" || session.status == "running"
123
+
124
+ # Probe the child with kill(0).
125
+ begin
126
+ Process.kill(0, session.pid)
127
+ rescue Errno::ESRCH
128
+ session.status = "exited"
129
+ rescue Errno::EPERM
130
+ # Process exists but owned by someone else; keep as-is.
131
+ end
132
+ end
133
+
134
+ def refresh_all
135
+ @mutex.synchronize do
136
+ @sessions.each_value { |s| refresh_locked(s) }
137
+ end
138
+ end
139
+
140
+ def refresh(id)
141
+ @mutex.synchronize do
142
+ session = @sessions[id]
143
+ refresh_locked(session) if session
144
+ session
145
+ end
146
+ end
147
+
148
+ # Mark running (called by the Terminal action after setup completes).
149
+ def mark_running(id)
150
+ @mutex.synchronize do
151
+ session = @sessions[id]
152
+ session.status = "running" if session && session.status == "starting"
153
+ end
154
+ end
155
+
156
+ def advance_offset(id, new_offset)
157
+ @mutex.synchronize do
158
+ s = @sessions[id]
159
+ s.read_offset = new_offset if s
160
+ end
161
+ end
162
+
163
+ def log_dir
164
+ @log_dir ||= begin
165
+ dir = File.join(Dir.tmpdir, "clacky-terminals-#{Process.pid}")
166
+ FileUtils.mkdir_p(dir)
167
+ dir
168
+ end
169
+ end
170
+
171
+ def allocate_log_file
172
+ @mutex.synchronize do
173
+ next_id = @next_id + 1
174
+ File.join(log_dir, "#{next_id}.log")
175
+ end
176
+ end
177
+
178
+ # Kill every live session. Called from at_exit.
179
+ def kill_all!
180
+ (@sessions.values rescue []).each do |s|
181
+ next if s.status == "exited" || s.status == "killed"
182
+ Process.kill("KILL", s.pid) rescue nil
183
+ s.log_io&.close rescue nil
184
+ end
185
+ end
186
+
187
+ # Test-only: clear state without killing processes.
188
+ def reset!
189
+ @mutex.synchronize do
190
+ @sessions.clear
191
+ @next_id = 0
192
+ @log_dir = nil
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
200
+
201
+ # Ensure orphaned PTY children are reaped even on unclean exit.
202
+ at_exit do
203
+ begin
204
+ Clacky::Tools::Terminal::SessionManager.kill_all!
205
+ rescue StandardError
206
+ # never raise out of at_exit
207
+ end
208
+ end