openclacky 1.0.0 → 1.0.2
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 +39 -0
- data/README.md +87 -53
- data/lib/clacky/agent/cost_tracker.rb +19 -2
- data/lib/clacky/agent/llm_caller.rb +218 -0
- data/lib/clacky/agent/message_compressor_helper.rb +32 -2
- data/lib/clacky/agent.rb +54 -22
- data/lib/clacky/client.rb +44 -5
- data/lib/clacky/default_parsers/pdf_parser.rb +58 -17
- data/lib/clacky/default_parsers/pdf_parser_ocr.py +103 -0
- data/lib/clacky/default_parsers/pdf_parser_plumber.py +62 -0
- data/lib/clacky/default_skills/deploy/SKILL.md +201 -77
- data/lib/clacky/default_skills/new/SKILL.md +3 -114
- data/lib/clacky/default_skills/onboard/SKILL.md +349 -133
- data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +371 -0
- data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +175 -0
- data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +59 -26
- data/lib/clacky/message_format/anthropic.rb +72 -8
- data/lib/clacky/message_format/bedrock.rb +6 -3
- data/lib/clacky/providers.rb +146 -3
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +14 -0
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +10 -0
- data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +1 -0
- data/lib/clacky/server/channel/channel_manager.rb +12 -4
- data/lib/clacky/server/channel/channel_ui_controller.rb +8 -2
- data/lib/clacky/server/http_server.rb +746 -13
- data/lib/clacky/server/session_registry.rb +55 -24
- data/lib/clacky/skill.rb +10 -9
- data/lib/clacky/skill_loader.rb +23 -11
- data/lib/clacky/tools/file_reader.rb +232 -127
- data/lib/clacky/tools/security.rb +42 -64
- data/lib/clacky/tools/terminal/persistent_session.rb +15 -4
- data/lib/clacky/tools/terminal/safe_rm.sh +106 -0
- data/lib/clacky/tools/terminal/session_manager.rb +8 -3
- data/lib/clacky/tools/terminal.rb +263 -16
- data/lib/clacky/ui2/layout_manager.rb +8 -1
- data/lib/clacky/ui2/output_buffer.rb +83 -23
- data/lib/clacky/ui2/ui_controller.rb +74 -7
- data/lib/clacky/utils/file_processor.rb +14 -40
- data/lib/clacky/utils/model_pricing.rb +215 -0
- data/lib/clacky/utils/parser_manager.rb +70 -6
- data/lib/clacky/utils/string_matcher.rb +23 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +673 -9
- data/lib/clacky/web/app.js +40 -1608
- data/lib/clacky/web/i18n.js +209 -0
- data/lib/clacky/web/index.html +166 -2
- data/lib/clacky/web/onboard.js +77 -1
- data/lib/clacky/web/profile.js +442 -0
- data/lib/clacky/web/sessions.js +1034 -2
- data/lib/clacky/web/settings.js +127 -6
- data/lib/clacky/web/sidebar.js +39 -0
- data/lib/clacky/web/skills.js +460 -0
- data/lib/clacky/web/trash.js +343 -0
- data/lib/clacky/web/ws-dispatcher.js +255 -0
- data/lib/clacky.rb +5 -3
- metadata +16 -17
- data/lib/clacky/clacky_auth_client.rb +0 -152
- data/lib/clacky/clacky_cloud_config.rb +0 -123
- data/lib/clacky/cloud_project_client.rb +0 -169
- data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +0 -1377
- data/lib/clacky/default_skills/deploy/tools/check_health.rb +0 -116
- data/lib/clacky/default_skills/deploy/tools/create_database_service.rb +0 -341
- data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +0 -99
- data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +0 -77
- data/lib/clacky/default_skills/deploy/tools/list_services.rb +0 -67
- data/lib/clacky/default_skills/deploy/tools/report_deploy_status.rb +0 -67
- data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +0 -189
- data/lib/clacky/default_skills/new/scripts/cloud_project_init.sh +0 -74
- data/lib/clacky/deploy_api_client.rb +0 -484
|
@@ -5,6 +5,7 @@ require "securerandom"
|
|
|
5
5
|
require "fileutils"
|
|
6
6
|
require_relative "base"
|
|
7
7
|
require_relative "security"
|
|
8
|
+
require_relative "../utils/trash_directory"
|
|
8
9
|
require_relative "terminal/session_manager"
|
|
9
10
|
require_relative "terminal/output_cleaner"
|
|
10
11
|
require_relative "terminal/persistent_session"
|
|
@@ -52,9 +53,12 @@ module Clacky
|
|
|
52
53
|
# Every new `command` is routed through Clacky::Tools::Security before
|
|
53
54
|
# being handed to the shell. This:
|
|
54
55
|
# - Blocks sudo / pkill clacky / eval / curl|bash / etc.
|
|
55
|
-
# - Rewrites `rm` into `mv <trash>` so deletions are recoverable.
|
|
56
56
|
# - Rewrites `curl ... | bash` into "download & review".
|
|
57
57
|
# - Protects Gemfile / .env / .ssh / etc. from writes.
|
|
58
|
+
# `rm` is additionally intercepted at runtime by a shell function
|
|
59
|
+
# installed in each PTY session (see SAFE_RM_BASH): it moves files
|
|
60
|
+
# into the per-project trash at $CLACKY_TRASH_DIR instead of
|
|
61
|
+
# deleting them. See trash_manager for list/restore.
|
|
58
62
|
# `input` is NOT subject to these rules (it is a reply to an already-
|
|
59
63
|
# running program, not a fresh command).
|
|
60
64
|
class Terminal < Base
|
|
@@ -69,6 +73,8 @@ module Clacky
|
|
|
69
73
|
{session_id, kill:true} stop
|
|
70
74
|
|
|
71
75
|
Response: exit_code = done; session_id = running (state: waiting/background/timeout).
|
|
76
|
+
If output exceeds the limit, `output` is truncated and `full_output_file` points
|
|
77
|
+
at a file on disk — use terminal(command: "grep ... <path>") to search it.
|
|
72
78
|
input supports byte escapes: \x03 Ctrl-C, \x04 Ctrl-D, \t Tab, \x1b Esc.
|
|
73
79
|
DESC
|
|
74
80
|
self.tool_category = "system"
|
|
@@ -86,7 +92,23 @@ module Clacky
|
|
|
86
92
|
}
|
|
87
93
|
}
|
|
88
94
|
|
|
89
|
-
|
|
95
|
+
# Hard ceiling on the raw `output:` string we send back to the LLM.
|
|
96
|
+
# 4000 chars ≈ 1000 tokens — matches the value the legacy safe_shell
|
|
97
|
+
# tool used, which was empirically tuned to keep tool-call turns cheap.
|
|
98
|
+
# When real output exceeds this we SPILL the full cleaned text to a
|
|
99
|
+
# dedicated overflow file and only return the first portion — see
|
|
100
|
+
# OVERFLOW_PREVIEW_CHARS / spill_overflow_file below.
|
|
101
|
+
MAX_LLM_OUTPUT_CHARS = 4_000
|
|
102
|
+
# When output overflows, the preview we keep in-context is slightly
|
|
103
|
+
# shorter than the hard ceiling so the "full output at: /tmp/..."
|
|
104
|
+
# notice + path still fits under MAX_LLM_OUTPUT_CHARS.
|
|
105
|
+
OVERFLOW_PREVIEW_CHARS = 3_800
|
|
106
|
+
# Per-line cap applied at write-time (inside the cleaning pipeline).
|
|
107
|
+
# Prevents a single minified JSON / CSS / JS blob from eating the
|
|
108
|
+
# entire 4 KB budget in one go. 500 chars is long enough to preserve
|
|
109
|
+
# real error messages (including stack frames) but short enough to
|
|
110
|
+
# survive dozens of lines inside 4 KB.
|
|
111
|
+
MAX_LINE_CHARS = 500
|
|
90
112
|
# Max seconds we keep a single tool call blocked inside the shell.
|
|
91
113
|
# Raised from 15s → 60s so long-running installs/builds (bundle install,
|
|
92
114
|
# gem install, npm install, docker build, rails new, ...) produce far
|
|
@@ -95,23 +117,99 @@ module Clacky
|
|
|
95
117
|
DEFAULT_TIMEOUT = 60
|
|
96
118
|
# How long output must be quiet before we assume the foreground command
|
|
97
119
|
# is waiting for user input and return control to the LLM.
|
|
98
|
-
# Raised from 500ms → 3000ms: real shell prompts
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
|
|
120
|
+
# Raised from 500ms → 3000ms → 10_000ms: real shell prompts (sudo,
|
|
121
|
+
# REPL, [Y/n] confirmations) stay quiet forever, so 10s still feels
|
|
122
|
+
# instant for them; long builds / test runs frequently have multi-
|
|
123
|
+
# second gaps between phases (compilation ↔ linking, spec file
|
|
124
|
+
# transitions), and anything below 10s split those into multiple
|
|
125
|
+
# polls — each poll replays the whole LLM context, which is expensive.
|
|
126
|
+
DEFAULT_IDLE_MS = 10_000
|
|
103
127
|
# Background commands collect this many seconds of startup output so
|
|
104
128
|
# the agent can see crashes / readiness before getting the session_id.
|
|
105
129
|
BACKGROUND_COLLECT_SECONDS = 2
|
|
106
130
|
# Sentinel: when passed as idle_ms, disables idle early-return.
|
|
107
131
|
DISABLED_IDLE_MS = 10_000_000
|
|
108
132
|
|
|
133
|
+
# Commands that we know take a long time and produce bursty output
|
|
134
|
+
# (quiet gaps between test files, compile phases, download batches,
|
|
135
|
+
# etc.). When the command line STARTS WITH or CONTAINS any of these
|
|
136
|
+
# tokens, we auto-extend the timeout to SLOW_COMMAND_TIMEOUT and
|
|
137
|
+
# disable idle-return entirely — otherwise the LLM ends up polling
|
|
138
|
+
# the same long-running job 5-10x, replaying full context each time.
|
|
139
|
+
# Taken verbatim from the legacy shell.rb list.
|
|
140
|
+
SLOW_COMMAND_PATTERNS = [
|
|
141
|
+
"bundle install",
|
|
142
|
+
"bundle update",
|
|
143
|
+
"bundle exec rspec",
|
|
144
|
+
"npm install",
|
|
145
|
+
"npm run build",
|
|
146
|
+
"npm run test",
|
|
147
|
+
"yarn install",
|
|
148
|
+
"yarn build",
|
|
149
|
+
"pnpm install",
|
|
150
|
+
"pnpm build",
|
|
151
|
+
"rspec",
|
|
152
|
+
"rake test",
|
|
153
|
+
"rails test",
|
|
154
|
+
"cargo build",
|
|
155
|
+
"cargo test",
|
|
156
|
+
"go build",
|
|
157
|
+
"go test",
|
|
158
|
+
"mvn test",
|
|
159
|
+
"mvn package",
|
|
160
|
+
"gradle build",
|
|
161
|
+
"pytest",
|
|
162
|
+
"pip install",
|
|
163
|
+
"docker build",
|
|
164
|
+
"docker-compose build"
|
|
165
|
+
].freeze
|
|
166
|
+
# Timeout granted to commands matched by SLOW_COMMAND_PATTERNS.
|
|
167
|
+
# 180s matches the legacy safe_shell "hard_timeout" for slow commands.
|
|
168
|
+
SLOW_COMMAND_TIMEOUT = 180
|
|
169
|
+
|
|
170
|
+
# Absolute path to the safe-rm shell snippet shipped with the gem.
|
|
171
|
+
# Sourced by every interactive PTY session to install a `rm` shell
|
|
172
|
+
# function that moves files to $CLACKY_TRASH_DIR instead of
|
|
173
|
+
# deleting them.
|
|
174
|
+
#
|
|
175
|
+
# Why source-from-file instead of writing the function body into
|
|
176
|
+
# the PTY directly?
|
|
177
|
+
# Writing a multi-line function definition into `zsh -l -i` is
|
|
178
|
+
# unreliable — ZLE (Zsh Line Editor) treats multi-line input as
|
|
179
|
+
# interactive editing and garbles the body. Loading from a file
|
|
180
|
+
# via a single `source` line avoids ZLE entirely.
|
|
181
|
+
#
|
|
182
|
+
# Why a shell function (instead of a Ruby-side text rewrite)?
|
|
183
|
+
# A function defers parsing to the shell itself, so heredocs,
|
|
184
|
+
# multi-line commands, globs, and variable expansion are all
|
|
185
|
+
# handled correctly. The previous Ruby rewriter mis-parsed any
|
|
186
|
+
# command containing a heredoc body with "rm" in it.
|
|
187
|
+
#
|
|
188
|
+
# Coverage:
|
|
189
|
+
# Intercepts — direct `rm …` in the interactive shell (incl.
|
|
190
|
+
# multi-line, heredoc, glob, env-var expansion).
|
|
191
|
+
# Bypassed by — `command rm`, `/bin/rm`, `xargs rm`, `find -exec rm`,
|
|
192
|
+
# child scripts. Same coverage as the old rewriter.
|
|
193
|
+
SAFE_RM_PATH = File.expand_path("terminal/safe_rm.sh", __dir__).freeze
|
|
109
194
|
# ---------------------------------------------------------------------
|
|
110
195
|
# Public entrypoint — dispatches on parameter shape
|
|
111
196
|
# ---------------------------------------------------------------------
|
|
112
197
|
def execute(command: nil, session_id: nil, input: nil, background: false,
|
|
113
198
|
cwd: nil, env: nil, timeout: nil, kill: nil, idle_ms: nil,
|
|
114
199
|
working_dir: nil, **_ignored)
|
|
200
|
+
# Auto-tune: if the caller didn't explicitly set a timeout/idle_ms
|
|
201
|
+
# AND the command is a well-known long-runner (rspec, bundle install,
|
|
202
|
+
# cargo build, etc.), we stretch the budget AND disable idle-return.
|
|
203
|
+
# This collapses what would otherwise be 5-10 "is it still running?"
|
|
204
|
+
# LLM round-trips into a single synchronous call. Background flag and
|
|
205
|
+
# session-continuation calls are NOT auto-tuned — background already
|
|
206
|
+
# returns quickly by design, and continuing a session uses whatever
|
|
207
|
+
# budget the caller requests.
|
|
208
|
+
if command && !background && !session_id && slow_command?(command)
|
|
209
|
+
timeout ||= SLOW_COMMAND_TIMEOUT
|
|
210
|
+
idle_ms ||= DISABLED_IDLE_MS
|
|
211
|
+
end
|
|
212
|
+
|
|
115
213
|
timeout = (timeout || DEFAULT_TIMEOUT).to_i
|
|
116
214
|
idle_ms = (idle_ms || DEFAULT_IDLE_MS).to_i
|
|
117
215
|
cwd ||= working_dir
|
|
@@ -347,16 +445,37 @@ module Clacky
|
|
|
347
445
|
cleaned = OutputCleaner.clean(raw)
|
|
348
446
|
cleaned = cleaned.sub(session.marker_regex, "").rstrip if session.marker_regex
|
|
349
447
|
cleaned = strip_command_echo(cleaned, marker_token: session.marker_token)
|
|
448
|
+
# Per-line cap first: one minified JSON blob shouldn't blow the
|
|
449
|
+
# whole 4 KB budget. MUST run before overflow spill so the file
|
|
450
|
+
# on disk also has the long lines shortened (otherwise grep-ing
|
|
451
|
+
# the spill file returns thousand-char lines the LLM chokes on).
|
|
452
|
+
cleaned = truncate_long_lines(cleaned)
|
|
350
453
|
truncated = false
|
|
454
|
+
overflow_file = nil
|
|
455
|
+
total_chars = cleaned.bytesize
|
|
351
456
|
if cleaned.bytesize > MAX_LLM_OUTPUT_CHARS
|
|
457
|
+
# Spill the FULL cleaned output to a sidecar file before we chop,
|
|
458
|
+
# so the LLM can cat/grep/tail it in a follow-up tool call.
|
|
459
|
+
overflow_file = spill_overflow_file(cleaned, session_id: session.id)
|
|
460
|
+
|
|
352
461
|
# byteslice may cut through the middle of a multi-byte char, which
|
|
353
462
|
# leaves the result as invalid UTF-8. Re-scrub after truncation so
|
|
354
463
|
# everything downstream (JSON.generate, format_result, UI) gets a
|
|
355
464
|
# guaranteed-valid UTF-8 string.
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
465
|
+
preview = cleaned.byteslice(0, OVERFLOW_PREVIEW_CHARS)
|
|
466
|
+
preview.force_encoding(Encoding::UTF_8)
|
|
467
|
+
preview = preview.scrub("?") unless preview.valid_encoding?
|
|
468
|
+
|
|
469
|
+
notice = if overflow_file
|
|
470
|
+
"\n\n...[Output truncated for LLM: showing first #{OVERFLOW_PREVIEW_CHARS} " \
|
|
471
|
+
"of #{total_chars} chars. Full output saved to: #{overflow_file} — " \
|
|
472
|
+
"use `grep`, `head`, or `tail` on this path to search the rest.]"
|
|
473
|
+
else
|
|
474
|
+
"\n\n...[output truncated at #{OVERFLOW_PREVIEW_CHARS} chars " \
|
|
475
|
+
"(overflow file unavailable; total was #{total_chars} chars)]"
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
cleaned = preview + notice
|
|
360
479
|
truncated = true
|
|
361
480
|
end
|
|
362
481
|
SessionManager.advance_offset(session.id, new_offset)
|
|
@@ -370,7 +489,8 @@ module Clacky
|
|
|
370
489
|
if persistent && state == :matched && session_healthy?(session)
|
|
371
490
|
# Command finished cleanly — return the shell to the pool so
|
|
372
491
|
# the next call reuses it (no cold-start cost).
|
|
373
|
-
PersistentSessionPool.instance.release(session)
|
|
492
|
+
stored = PersistentSessionPool.instance.release(session)
|
|
493
|
+
cleanup_session(session) unless stored
|
|
374
494
|
else
|
|
375
495
|
cleanup_session(session)
|
|
376
496
|
end
|
|
@@ -379,6 +499,7 @@ module Clacky
|
|
|
379
499
|
exit_code: exit_code,
|
|
380
500
|
bytes_read: new_offset - start_offset,
|
|
381
501
|
output_truncated: truncated,
|
|
502
|
+
full_output_file: overflow_file,
|
|
382
503
|
security_rewrite: rewrite_note
|
|
383
504
|
}.compact
|
|
384
505
|
when :idle, :timeout
|
|
@@ -393,6 +514,7 @@ module Clacky
|
|
|
393
514
|
state: background ? "background" : (state == :idle ? "waiting" : "timeout"),
|
|
394
515
|
bytes_read: new_offset - start_offset,
|
|
395
516
|
output_truncated: truncated,
|
|
517
|
+
full_output_file: overflow_file,
|
|
396
518
|
security_rewrite: rewrite_note,
|
|
397
519
|
hint: background_hint(background, session.id)
|
|
398
520
|
}.compact
|
|
@@ -623,6 +745,16 @@ module Clacky
|
|
|
623
745
|
|
|
624
746
|
# Core spawn: PTY + reader thread + marker install.
|
|
625
747
|
private def spawn_shell(args:, shell_name:, command:, cwd:, env:)
|
|
748
|
+
# Per-project trash dir — the rm shell-function (see SAFE_RM_BASH
|
|
749
|
+
# and install_marker) reads this env var to know where to move
|
|
750
|
+
# deleted files.
|
|
751
|
+
trash_dir =
|
|
752
|
+
begin
|
|
753
|
+
Clacky::TrashDirectory.new(cwd || Dir.pwd).trash_dir
|
|
754
|
+
rescue StandardError
|
|
755
|
+
nil
|
|
756
|
+
end
|
|
757
|
+
|
|
626
758
|
spawn_env = {
|
|
627
759
|
"TERM" => "xterm-256color",
|
|
628
760
|
"PS1" => "",
|
|
@@ -640,6 +772,7 @@ module Clacky
|
|
|
640
772
|
"HISTSIZE" => "0",
|
|
641
773
|
"SAVEHIST" => "0"
|
|
642
774
|
}
|
|
775
|
+
spawn_env["CLACKY_TRASH_DIR"] = trash_dir if trash_dir
|
|
643
776
|
(env || {}).each { |k, v| spawn_env[k.to_s] = v.to_s }
|
|
644
777
|
|
|
645
778
|
log_file = SessionManager.allocate_log_file
|
|
@@ -760,9 +893,18 @@ module Clacky
|
|
|
760
893
|
# 2. stty -echo stops the PTY from echoing our wrapper lines
|
|
761
894
|
# back into captured output.
|
|
762
895
|
# 3. Empty PS1/PS2 keeps prompt noise out of captured output.
|
|
763
|
-
setup_line = %Q{HISTFILE=/dev/null; HISTSIZE=0; SAVEHIST=0; unset HISTFILE 2>/dev/null; stty -echo 2>/dev/null; PS1=""; PS2=""\n}
|
|
896
|
+
setup_line = %Q{HISTFILE=/dev/null; HISTSIZE=0; SAVEHIST=0; unset HISTFILE 2>/dev/null; set +o histexpand 2>/dev/null; stty -echo 2>/dev/null; PS1=""; PS2=""\n}
|
|
764
897
|
session.mutex.synchronize { session.writer.write(setup_line) }
|
|
765
898
|
|
|
899
|
+
# Install the safe-rm shell function. Single-line `source`
|
|
900
|
+
# avoids feeding a multi-line function definition through ZLE
|
|
901
|
+
# (which would garble it under zsh -l -i). The file itself
|
|
902
|
+
# ships with the gem — see SAFE_RM_PATH.
|
|
903
|
+
if File.exist?(SAFE_RM_PATH)
|
|
904
|
+
source_line = %Q{source #{SAFE_RM_PATH} 2>/dev/null || true\n}
|
|
905
|
+
session.mutex.synchronize { session.writer.write(source_line) }
|
|
906
|
+
end
|
|
907
|
+
|
|
766
908
|
# Emit the first marker by running a no-op through the same wrapper
|
|
767
909
|
# we use for real commands. spawn_shell's read_until_marker will
|
|
768
910
|
# match this and consider the shell ready.
|
|
@@ -1001,9 +1143,106 @@ module Clacky
|
|
|
1001
1143
|
""
|
|
1002
1144
|
end
|
|
1003
1145
|
|
|
1004
|
-
#
|
|
1005
|
-
#
|
|
1006
|
-
#
|
|
1146
|
+
# Detect commands that are known to take a long time and produce
|
|
1147
|
+
# bursty output with multi-second quiet gaps. Used by `execute` to
|
|
1148
|
+
# auto-widen the timeout / disable idle-return so the LLM doesn't
|
|
1149
|
+
# poll a rspec/bundle-install 10 times over.
|
|
1150
|
+
#
|
|
1151
|
+
# Matching is substring-based after stripping common prefixes
|
|
1152
|
+
# (`sudo `, `env VAR=val `, `cd path && ...`) so that wrapping the
|
|
1153
|
+
# real slow command in another shell construct still hits.
|
|
1154
|
+
private def slow_command?(command)
|
|
1155
|
+
return false if command.nil? || command.empty?
|
|
1156
|
+
s = command.to_s
|
|
1157
|
+
|
|
1158
|
+
# Strip leading `cd ... && ` / `cd ...;` — users / the agent often
|
|
1159
|
+
# prepend a cd to the real command.
|
|
1160
|
+
s = s.sub(/\Acd\s+\S+\s*(?:&&|;)\s*/, "")
|
|
1161
|
+
# Strip leading env-var assignments: `FOO=bar BAZ=qux cmd`.
|
|
1162
|
+
s = s.sub(/\A(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)+/, "")
|
|
1163
|
+
# Strip leading `sudo ` (not actually allowed by Security, but harmless).
|
|
1164
|
+
s = s.sub(/\Asudo\s+/, "")
|
|
1165
|
+
# Trim leading whitespace.
|
|
1166
|
+
s = s.lstrip
|
|
1167
|
+
|
|
1168
|
+
SLOW_COMMAND_PATTERNS.any? { |pat| s.include?(pat) }
|
|
1169
|
+
end
|
|
1170
|
+
|
|
1171
|
+
# Apply per-line truncation to a cleaned (post-OutputCleaner) string.
|
|
1172
|
+
# If any single line exceeds MAX_LINE_CHARS, we chop it at that length
|
|
1173
|
+
# and append `…[line truncated: <original> chars]` so the LLM knows
|
|
1174
|
+
# content was elided. Critical for minified JS/CSS/JSON dumps that
|
|
1175
|
+
# would otherwise swallow the entire 4 KB budget with one line.
|
|
1176
|
+
private def truncate_long_lines(text, max: MAX_LINE_CHARS)
|
|
1177
|
+
return text if text.nil? || text.empty?
|
|
1178
|
+
lines = text.split("\n", -1)
|
|
1179
|
+
any_truncated = false
|
|
1180
|
+
truncated_lines = lines.map do |line|
|
|
1181
|
+
if line.bytesize > max
|
|
1182
|
+
any_truncated = true
|
|
1183
|
+
sliced = line.byteslice(0, max).to_s
|
|
1184
|
+
sliced.force_encoding(Encoding::UTF_8)
|
|
1185
|
+
sliced = sliced.scrub("?") unless sliced.valid_encoding?
|
|
1186
|
+
"#{sliced} …[line truncated: #{line.bytesize} chars]"
|
|
1187
|
+
else
|
|
1188
|
+
line
|
|
1189
|
+
end
|
|
1190
|
+
end
|
|
1191
|
+
return text unless any_truncated
|
|
1192
|
+
truncated_lines.join("\n")
|
|
1193
|
+
end
|
|
1194
|
+
|
|
1195
|
+
# Overflow directory: shared across sessions (and persists after
|
|
1196
|
+
# Clacky exits) so the LLM can re-read the full output in later
|
|
1197
|
+
# turns. Lives under /tmp so it is naturally swept by the OS, and
|
|
1198
|
+
# we also best-effort prune files older than OVERFLOW_MAX_AGE_SEC
|
|
1199
|
+
# on each write so long-running servers don't accumulate garbage.
|
|
1200
|
+
OVERFLOW_DIR_NAME = "clacky-terminal-overflow"
|
|
1201
|
+
OVERFLOW_MAX_AGE_SEC = 7 * 24 * 60 * 60 # 7 days
|
|
1202
|
+
|
|
1203
|
+
private def overflow_dir
|
|
1204
|
+
@overflow_dir ||= begin
|
|
1205
|
+
dir = File.join(Dir.tmpdir, OVERFLOW_DIR_NAME)
|
|
1206
|
+
FileUtils.mkdir_p(dir)
|
|
1207
|
+
dir
|
|
1208
|
+
end
|
|
1209
|
+
end
|
|
1210
|
+
|
|
1211
|
+
# Drop overflow files older than OVERFLOW_MAX_AGE_SEC. Best-effort —
|
|
1212
|
+
# any error (permission, race with another process) is swallowed,
|
|
1213
|
+
# we'd rather keep the current command's result than crash because
|
|
1214
|
+
# of stale cleanup.
|
|
1215
|
+
private def prune_old_overflow_files
|
|
1216
|
+
cutoff = Time.now - OVERFLOW_MAX_AGE_SEC
|
|
1217
|
+
Dir.glob(File.join(overflow_dir, "*.log")).each do |f|
|
|
1218
|
+
next unless File.file?(f)
|
|
1219
|
+
begin
|
|
1220
|
+
File.delete(f) if File.mtime(f) < cutoff
|
|
1221
|
+
rescue StandardError
|
|
1222
|
+
# ignore
|
|
1223
|
+
end
|
|
1224
|
+
end
|
|
1225
|
+
rescue StandardError
|
|
1226
|
+
# ignore
|
|
1227
|
+
end
|
|
1228
|
+
|
|
1229
|
+
# Write the full cleaned output to a sidecar file so the LLM can
|
|
1230
|
+
# `grep` / `head` / `tail` it in a follow-up tool call. Returns the
|
|
1231
|
+
# absolute path, or nil if the write failed (in which case we'll
|
|
1232
|
+
# just truncate without disclosure).
|
|
1233
|
+
private def spill_overflow_file(cleaned, session_id:)
|
|
1234
|
+
prune_old_overflow_files
|
|
1235
|
+
ts = Time.now.strftime("%Y%m%d-%H%M%S")
|
|
1236
|
+
sid = session_id || "nosid"
|
|
1237
|
+
rand = SecureRandom.hex(3)
|
|
1238
|
+
path = File.join(overflow_dir, "#{ts}-s#{sid}-#{rand}.log")
|
|
1239
|
+
File.open(path, "wb") { |f| f.write(cleaned) }
|
|
1240
|
+
path
|
|
1241
|
+
rescue StandardError
|
|
1242
|
+
nil
|
|
1243
|
+
end
|
|
1244
|
+
|
|
1245
|
+
|
|
1007
1246
|
|
|
1008
1247
|
# Max visible length of a command inside the tool-call summary line.
|
|
1009
1248
|
# Keeps the "terminal(...)" summary on a single UI row even when the
|
|
@@ -1076,6 +1315,14 @@ module Clacky
|
|
|
1076
1315
|
end
|
|
1077
1316
|
|
|
1078
1317
|
status = "#{prefix}#{status}" unless prefix.empty?
|
|
1318
|
+
|
|
1319
|
+
# When output overflowed, surface the file path in the UI too
|
|
1320
|
+
# (not just in the LLM-facing `output`). Keeps the dev aware that
|
|
1321
|
+
# the full log is recoverable.
|
|
1322
|
+
if result[:full_output_file]
|
|
1323
|
+
status = "#{status} [full: #{result[:full_output_file]}]"
|
|
1324
|
+
end
|
|
1325
|
+
|
|
1079
1326
|
tail.empty? ? status : "#{tail}\n#{status}"
|
|
1080
1327
|
end
|
|
1081
1328
|
|
|
@@ -112,14 +112,17 @@ module Clacky
|
|
|
112
112
|
|
|
113
113
|
# Replace an existing entry's content. The screen is updated in place
|
|
114
114
|
# if the entry still lives in the output area; otherwise (committed
|
|
115
|
-
# to scrollback) this is a silent no-op.
|
|
115
|
+
# to scrollback, or partially scrolled off) this is a silent no-op.
|
|
116
116
|
def replace_entry(id, content)
|
|
117
117
|
return if id.nil? || content.nil?
|
|
118
118
|
content = sanitize(content)
|
|
119
119
|
|
|
120
120
|
@render_mutex.synchronize do
|
|
121
121
|
entry = @buffer.entry_by_id(id)
|
|
122
|
+
# Skip if gone, fully committed, or only partially visible (its
|
|
123
|
+
# prefix is already in terminal scrollback and cannot be edited).
|
|
122
124
|
return if entry.nil? || entry.committed
|
|
125
|
+
return if (entry.committed_line_offset || 0) > 0
|
|
123
126
|
|
|
124
127
|
old_lines = entry.lines.dup
|
|
125
128
|
new_lines = wrap_content_to_lines(content)
|
|
@@ -167,6 +170,10 @@ module Clacky
|
|
|
167
170
|
@render_mutex.synchronize do
|
|
168
171
|
entry = @buffer.entry_by_id(id)
|
|
169
172
|
return if entry.nil? || entry.committed
|
|
173
|
+
# Can't remove an entry whose prefix has already scrolled into
|
|
174
|
+
# terminal scrollback — those rows are immutable. The visible
|
|
175
|
+
# suffix will roll off on its own as more output is produced.
|
|
176
|
+
return if (entry.committed_line_offset || 0) > 0
|
|
170
177
|
|
|
171
178
|
height = entry.height
|
|
172
179
|
# Check whether this entry is the tail of live entries. Only tail
|
|
@@ -33,10 +33,23 @@ module Clacky
|
|
|
33
33
|
# @!attribute lines [Array<String>] Rendered (already-wrapped) visual lines
|
|
34
34
|
# @!attribute kind [Symbol] :text | :progress | :system (hint for renderer)
|
|
35
35
|
# @!attribute committed [Boolean] True once pushed into terminal scrollback
|
|
36
|
-
Entry = Struct.new(:id, :lines, :kind, :committed, keyword_init: true) do
|
|
37
|
-
# Visual row count this entry
|
|
36
|
+
Entry = Struct.new(:id, :lines, :kind, :committed, :committed_line_offset, keyword_init: true) do
|
|
37
|
+
# Visual row count this entry currently OCCUPIES on screen. Once a
|
|
38
|
+
# prefix of the entry's lines has been pushed into scrollback by
|
|
39
|
+
# a scroll+partial-commit, those prefix rows are no longer on
|
|
40
|
+
# screen — so height drops accordingly. When +committed+ flips to
|
|
41
|
+
# true the entry is considered fully off-screen and height is 0.
|
|
38
42
|
def height
|
|
39
|
-
|
|
43
|
+
return 0 if committed
|
|
44
|
+
lines.length - (committed_line_offset || 0)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The currently on-screen lines of this entry (lines that haven't
|
|
48
|
+
# been pushed to scrollback yet). Returns [] once fully committed.
|
|
49
|
+
def visible_lines
|
|
50
|
+
return [] if committed
|
|
51
|
+
off = committed_line_offset || 0
|
|
52
|
+
off.zero? ? lines : lines[off..] || []
|
|
40
53
|
end
|
|
41
54
|
|
|
42
55
|
def to_s
|
|
@@ -69,7 +82,7 @@ module Clacky
|
|
|
69
82
|
def append(content, kind: :text)
|
|
70
83
|
@mutex.synchronize do
|
|
71
84
|
lines = normalize_lines(content)
|
|
72
|
-
entry = Entry.new(id: next_id!, lines: lines, kind: kind, committed: false)
|
|
85
|
+
entry = Entry.new(id: next_id!, lines: lines, kind: kind, committed: false, committed_line_offset: 0)
|
|
73
86
|
@entries << entry
|
|
74
87
|
@index[entry.id] = entry
|
|
75
88
|
trim_if_needed
|
|
@@ -83,18 +96,22 @@ module Clacky
|
|
|
83
96
|
# this is a no-op and returns nil.
|
|
84
97
|
#
|
|
85
98
|
# Replacing a committed entry is silently ignored — committed content
|
|
86
|
-
# lives in terminal scrollback and cannot be edited in place.
|
|
99
|
+
# lives in terminal scrollback and cannot be edited in place. Same
|
|
100
|
+
# for an entry whose prefix has been partial-committed: the prefix
|
|
101
|
+
# is already in scrollback and replacing the entry would either
|
|
102
|
+
# strand those lines (if shorter) or duplicate them (if longer).
|
|
87
103
|
#
|
|
88
104
|
# @param id [Integer]
|
|
89
105
|
# @param content [String, Array<String>]
|
|
90
|
-
# @return [Integer, nil] Old height if replaced, nil if no-op
|
|
106
|
+
# @return [Integer, nil] Old visible height if replaced, nil if no-op
|
|
91
107
|
def replace(id, content)
|
|
92
108
|
@mutex.synchronize do
|
|
93
109
|
entry = @index[id]
|
|
94
110
|
return nil unless entry
|
|
95
111
|
return nil if entry.committed
|
|
112
|
+
return nil if (entry.committed_line_offset || 0) > 0
|
|
96
113
|
|
|
97
|
-
old_height = entry.
|
|
114
|
+
old_height = entry.height
|
|
98
115
|
entry.lines = normalize_lines(content)
|
|
99
116
|
bump_version
|
|
100
117
|
old_height
|
|
@@ -102,7 +119,9 @@ module Clacky
|
|
|
102
119
|
end
|
|
103
120
|
|
|
104
121
|
# Remove an entry. Committed entries cannot be removed (they are in
|
|
105
|
-
# terminal scrollback).
|
|
122
|
+
# terminal scrollback). Partially-committed entries also cannot be
|
|
123
|
+
# removed — their prefix is frozen in scrollback. Returns the
|
|
124
|
+
# removed Entry, or nil if no-op.
|
|
106
125
|
#
|
|
107
126
|
# @param id [Integer]
|
|
108
127
|
# @return [Entry, nil]
|
|
@@ -111,6 +130,7 @@ module Clacky
|
|
|
111
130
|
entry = @index[id]
|
|
112
131
|
return nil unless entry
|
|
113
132
|
return nil if entry.committed
|
|
133
|
+
return nil if (entry.committed_line_offset || 0) > 0
|
|
114
134
|
|
|
115
135
|
@entries.delete(entry)
|
|
116
136
|
@index.delete(id)
|
|
@@ -142,34 +162,55 @@ module Clacky
|
|
|
142
162
|
end
|
|
143
163
|
end
|
|
144
164
|
|
|
145
|
-
# Commit the oldest N
|
|
146
|
-
# off the top via native \n.
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
#
|
|
165
|
+
# Commit the oldest N VISUAL rows. Used when the renderer scrolls N
|
|
166
|
+
# lines off the top via native \n. Commits are precise at the visual
|
|
167
|
+
# row granularity (even mid-entry): if the oldest live entry is
|
|
168
|
+
# multi-line and only its prefix has scrolled off, that prefix is
|
|
169
|
+
# recorded in +committed_line_offset+ and only the still-visible
|
|
170
|
+
# suffix remains eligible for future repaints.
|
|
171
|
+
#
|
|
172
|
+
# This is the critical invariant for preventing the "scroll up to
|
|
173
|
+
# see a line already in scrollback, then render_output_from_buffer
|
|
174
|
+
# repaints it again on screen" duplicate-output regression: every
|
|
175
|
+
# visual row that went into terminal scrollback MUST be removed
|
|
176
|
+
# from the buffer's pool of repaintable live rows, regardless of
|
|
177
|
+
# whether it sat alone in a 1-line entry or at the top of a 10-line
|
|
178
|
+
# entry.
|
|
150
179
|
#
|
|
151
180
|
# @param line_count [Integer] Number of visual lines pushed to scrollback
|
|
152
|
-
# @return [Integer] Number of entries
|
|
181
|
+
# @return [Integer] Number of entries NEWLY marked fully committed
|
|
182
|
+
# (partial commits on an entry do NOT count toward this total —
|
|
183
|
+
# callers use the return value only as a debug hint, not for row
|
|
184
|
+
# bookkeeping).
|
|
153
185
|
def commit_oldest_lines(line_count)
|
|
154
186
|
return 0 if line_count <= 0
|
|
155
187
|
|
|
156
188
|
@mutex.synchronize do
|
|
157
189
|
remaining = line_count
|
|
158
190
|
committed = 0
|
|
191
|
+
changed = false
|
|
159
192
|
@entries.each do |e|
|
|
160
193
|
break if remaining <= 0
|
|
161
194
|
next if e.committed
|
|
162
195
|
|
|
163
|
-
|
|
196
|
+
h = e.height
|
|
197
|
+
if h <= remaining
|
|
198
|
+
# Full scroll-off of this entry's remaining visible rows.
|
|
164
199
|
e.committed = true
|
|
165
|
-
|
|
166
|
-
|
|
200
|
+
e.committed_line_offset = e.lines.length # normalize
|
|
201
|
+
remaining -= h
|
|
202
|
+
committed += 1
|
|
203
|
+
changed = true
|
|
167
204
|
else
|
|
168
|
-
# Partial scroll
|
|
205
|
+
# Partial scroll: record the new offset and stop (there are
|
|
206
|
+
# still visible rows of this entry on screen).
|
|
207
|
+
e.committed_line_offset = (e.committed_line_offset || 0) + remaining
|
|
208
|
+
remaining = 0
|
|
209
|
+
changed = true
|
|
169
210
|
break
|
|
170
211
|
end
|
|
171
212
|
end
|
|
172
|
-
bump_version if
|
|
213
|
+
bump_version if changed
|
|
173
214
|
committed
|
|
174
215
|
end
|
|
175
216
|
end
|
|
@@ -198,12 +239,17 @@ module Clacky
|
|
|
198
239
|
break if collected.length >= n
|
|
199
240
|
next if e.committed
|
|
200
241
|
|
|
201
|
-
#
|
|
242
|
+
# The entry's still-visible lines (excluding any prefix already
|
|
243
|
+
# committed to scrollback via a partial commit).
|
|
244
|
+
vis = e.visible_lines
|
|
245
|
+
next if vis.empty?
|
|
246
|
+
|
|
247
|
+
# Prepend the entry's visible lines in order
|
|
202
248
|
remaining = n - collected.length
|
|
203
|
-
if
|
|
204
|
-
collected =
|
|
249
|
+
if vis.length <= remaining
|
|
250
|
+
collected = vis + collected
|
|
205
251
|
else
|
|
206
|
-
collected =
|
|
252
|
+
collected = vis.last(remaining) + collected
|
|
207
253
|
break
|
|
208
254
|
end
|
|
209
255
|
end
|
|
@@ -227,6 +273,20 @@ module Clacky
|
|
|
227
273
|
end
|
|
228
274
|
end
|
|
229
275
|
|
|
276
|
+
# Does this id refer to an entry that can still be replaced or
|
|
277
|
+
# removed in place? A partially-committed entry (prefix already in
|
|
278
|
+
# scrollback via a scroll) is NOT editable — its visible suffix is
|
|
279
|
+
# frozen until it either fully commits or (rare) a full repaint
|
|
280
|
+
# rewrites the screen.
|
|
281
|
+
#
|
|
282
|
+
# @param id [Integer]
|
|
283
|
+
def fully_editable?(id)
|
|
284
|
+
@mutex.synchronize do
|
|
285
|
+
e = @index[id]
|
|
286
|
+
!!(e && !e.committed && (e.committed_line_offset || 0) == 0)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
230
290
|
# Total number of entries (committed + live) currently tracked.
|
|
231
291
|
def size
|
|
232
292
|
@mutex.synchronize { @entries.size }
|