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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -0
- data/lib/clacky/agent/cost_tracker.rb +1 -1
- data/lib/clacky/agent/llm_caller.rb +14 -10
- data/lib/clacky/agent/memory_updater.rb +1 -1
- data/lib/clacky/agent/session_serializer.rb +2 -0
- data/lib/clacky/agent/skill_manager.rb +1 -1
- data/lib/clacky/agent/tool_executor.rb +13 -16
- data/lib/clacky/agent/tool_registry.rb +0 -3
- data/lib/clacky/agent.rb +63 -38
- data/lib/clacky/agent_config.rb +5 -1
- data/lib/clacky/brand_config.rb +11 -27
- data/lib/clacky/cli.rb +36 -0
- data/lib/clacky/default_skills/channel-setup/SKILL.md +1 -1
- data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +1 -1
- data/lib/clacky/default_skills/new/SKILL.md +1 -1
- data/lib/clacky/default_skills/product-help/SKILL.md +1 -1
- data/lib/clacky/default_skills/recall-memory/SKILL.md +1 -1
- data/lib/clacky/default_skills/skill-creator/SKILL.md +1 -1
- data/lib/clacky/idle_compression_timer.rb +8 -0
- data/lib/clacky/json_ui_controller.rb +2 -1
- data/lib/clacky/plain_ui_controller.rb +10 -3
- data/lib/clacky/platform_http_client.rb +161 -1
- data/lib/clacky/server/channel/channel_manager.rb +5 -3
- data/lib/clacky/server/channel/channel_ui_controller.rb +6 -2
- data/lib/clacky/server/http_server.rb +235 -40
- data/lib/clacky/server/scheduler.rb +17 -16
- data/lib/clacky/server/session_registry.rb +1 -5
- data/lib/clacky/server/web_ui_controller.rb +7 -6
- data/lib/clacky/session_manager.rb +22 -0
- data/lib/clacky/skill.rb +19 -3
- data/lib/clacky/skill_loader.rb +5 -59
- data/lib/clacky/tools/browser.rb +25 -73
- data/lib/clacky/tools/security.rb +326 -0
- data/lib/clacky/tools/terminal/output_cleaner.rb +63 -0
- data/lib/clacky/tools/terminal/persistent_session.rb +247 -0
- data/lib/clacky/tools/terminal/session_manager.rb +208 -0
- data/lib/clacky/tools/terminal.rb +818 -0
- data/lib/clacky/tools/todo_manager.rb +6 -16
- data/lib/clacky/tools/trash_manager.rb +2 -2
- data/lib/clacky/ui2/components/input_area.rb +11 -2
- data/lib/clacky/ui2/layout_manager.rb +438 -488
- data/lib/clacky/ui2/output_buffer.rb +310 -0
- data/lib/clacky/ui2/ui_controller.rb +72 -21
- data/lib/clacky/ui_interface.rb +1 -1
- data/lib/clacky/utils/encoding.rb +1 -1
- data/lib/clacky/utils/environment_detector.rb +43 -0
- data/lib/clacky/utils/model_pricing.rb +3 -3
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +479 -178
- data/lib/clacky/web/app.js +146 -4
- data/lib/clacky/web/auth.js +101 -0
- data/lib/clacky/web/i18n.js +35 -1
- data/lib/clacky/web/index.html +9 -2
- data/lib/clacky/web/sessions.js +254 -15
- data/lib/clacky/web/skills.js +20 -6
- data/lib/clacky/web/tasks.js +54 -2
- data/lib/clacky/web/theme.js +58 -20
- data/lib/clacky/web/ws.js +11 -2
- data/lib/clacky.rb +2 -2
- metadata +8 -3
- data/lib/clacky/tools/safe_shell.rb +0 -608
- 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
|