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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/README.md +87 -53
  4. data/lib/clacky/agent/cost_tracker.rb +19 -2
  5. data/lib/clacky/agent/llm_caller.rb +218 -0
  6. data/lib/clacky/agent/message_compressor_helper.rb +32 -2
  7. data/lib/clacky/agent.rb +54 -22
  8. data/lib/clacky/client.rb +44 -5
  9. data/lib/clacky/default_parsers/pdf_parser.rb +58 -17
  10. data/lib/clacky/default_parsers/pdf_parser_ocr.py +103 -0
  11. data/lib/clacky/default_parsers/pdf_parser_plumber.py +62 -0
  12. data/lib/clacky/default_skills/deploy/SKILL.md +201 -77
  13. data/lib/clacky/default_skills/new/SKILL.md +3 -114
  14. data/lib/clacky/default_skills/onboard/SKILL.md +349 -133
  15. data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +371 -0
  16. data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +175 -0
  17. data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +59 -26
  18. data/lib/clacky/message_format/anthropic.rb +72 -8
  19. data/lib/clacky/message_format/bedrock.rb +6 -3
  20. data/lib/clacky/providers.rb +146 -3
  21. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +14 -0
  22. data/lib/clacky/server/channel/adapters/feishu/bot.rb +10 -0
  23. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +1 -0
  24. data/lib/clacky/server/channel/channel_manager.rb +12 -4
  25. data/lib/clacky/server/channel/channel_ui_controller.rb +8 -2
  26. data/lib/clacky/server/http_server.rb +746 -13
  27. data/lib/clacky/server/session_registry.rb +55 -24
  28. data/lib/clacky/skill.rb +10 -9
  29. data/lib/clacky/skill_loader.rb +23 -11
  30. data/lib/clacky/tools/file_reader.rb +232 -127
  31. data/lib/clacky/tools/security.rb +42 -64
  32. data/lib/clacky/tools/terminal/persistent_session.rb +15 -4
  33. data/lib/clacky/tools/terminal/safe_rm.sh +106 -0
  34. data/lib/clacky/tools/terminal/session_manager.rb +8 -3
  35. data/lib/clacky/tools/terminal.rb +263 -16
  36. data/lib/clacky/ui2/layout_manager.rb +8 -1
  37. data/lib/clacky/ui2/output_buffer.rb +83 -23
  38. data/lib/clacky/ui2/ui_controller.rb +74 -7
  39. data/lib/clacky/utils/file_processor.rb +14 -40
  40. data/lib/clacky/utils/model_pricing.rb +215 -0
  41. data/lib/clacky/utils/parser_manager.rb +70 -6
  42. data/lib/clacky/utils/string_matcher.rb +23 -1
  43. data/lib/clacky/version.rb +1 -1
  44. data/lib/clacky/web/app.css +673 -9
  45. data/lib/clacky/web/app.js +40 -1608
  46. data/lib/clacky/web/i18n.js +209 -0
  47. data/lib/clacky/web/index.html +166 -2
  48. data/lib/clacky/web/onboard.js +77 -1
  49. data/lib/clacky/web/profile.js +442 -0
  50. data/lib/clacky/web/sessions.js +1034 -2
  51. data/lib/clacky/web/settings.js +127 -6
  52. data/lib/clacky/web/sidebar.js +39 -0
  53. data/lib/clacky/web/skills.js +460 -0
  54. data/lib/clacky/web/trash.js +343 -0
  55. data/lib/clacky/web/ws-dispatcher.js +255 -0
  56. data/lib/clacky.rb +5 -3
  57. metadata +16 -17
  58. data/lib/clacky/clacky_auth_client.rb +0 -152
  59. data/lib/clacky/clacky_cloud_config.rb +0 -123
  60. data/lib/clacky/cloud_project_client.rb +0 -169
  61. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +0 -1377
  62. data/lib/clacky/default_skills/deploy/tools/check_health.rb +0 -116
  63. data/lib/clacky/default_skills/deploy/tools/create_database_service.rb +0 -341
  64. data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +0 -99
  65. data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +0 -77
  66. data/lib/clacky/default_skills/deploy/tools/list_services.rb +0 -67
  67. data/lib/clacky/default_skills/deploy/tools/report_deploy_status.rb +0 -67
  68. data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +0 -189
  69. data/lib/clacky/default_skills/new/scripts/cloud_project_init.sh +0 -74
  70. 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
- MAX_LLM_OUTPUT_CHARS = 8_000
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 stay quiet forever
99
- # (so 3s is still instant for them), but long builds have frequent
100
- # sub-second quiet windows between phases a small idle threshold
101
- # shredded those runs into 20+ polls for no real benefit.
102
- DEFAULT_IDLE_MS = 3_000
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
- cleaned = cleaned.byteslice(0, MAX_LLM_OUTPUT_CHARS)
357
- cleaned.force_encoding(Encoding::UTF_8)
358
- cleaned = cleaned.scrub("?") unless cleaned.valid_encoding?
359
- cleaned += "\n...[output truncated]"
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
- # Display helpers
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 occupies on screen (before it's committed)
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
- lines.length
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.lines.length
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). Returns the removed Entry, or nil if no-op.
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 entries. Used when the renderer scrolls N lines
146
- # off the top via native \n. It commits full entries greedily: if the
147
- # N lines span across entry boundaries, all fully-scrolled entries
148
- # are marked committed, and the partially-scrolled entry (if any) is
149
- # left uncommitted (it will be handled next time).
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 actually marked committed
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
- if e.height <= remaining
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
- remaining -= e.height
166
- committed += 1
200
+ e.committed_line_offset = e.lines.length # normalize
201
+ remaining -= h
202
+ committed += 1
203
+ changed = true
167
204
  else
168
- # Partial scroll can't commit this entry yet
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 committed > 0
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
- # Prepend the entry's lines in order
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 e.lines.length <= remaining
204
- collected = e.lines + collected
249
+ if vis.length <= remaining
250
+ collected = vis + collected
205
251
  else
206
- collected = e.lines.last(remaining) + 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 }