openclacky 1.5.12 → 1.5.13

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/lib/clacky/access_key.rb +59 -0
  4. data/lib/clacky/agent/message_compressor_helper.rb +52 -1
  5. data/lib/clacky/agent/session_serializer.rb +35 -2
  6. data/lib/clacky/agent/time_machine.rb +9 -1
  7. data/lib/clacky/agent.rb +11 -7
  8. data/lib/clacky/agent_config.rb +40 -0
  9. data/lib/clacky/agent_profile.rb +5 -5
  10. data/lib/clacky/billing/platform_billing.rb +84 -1
  11. data/lib/clacky/brand_config.rb +4 -3
  12. data/lib/clacky/cli.rb +4 -2
  13. data/lib/clacky/client.rb +107 -0
  14. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +3 -0
  15. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +40 -21
  16. data/lib/clacky/default_extensions/git/ext.yml +1 -1
  17. data/lib/clacky/default_extensions/git/panels/git/view.js +483 -77
  18. data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
  19. data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
  20. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
  21. data/lib/clacky/extension/packager.rb +4 -2
  22. data/lib/clacky/extension/verifier.rb +14 -1
  23. data/lib/clacky/message_format/open_ai.rb +5 -1
  24. data/lib/clacky/message_format/open_ai_responses.rb +409 -0
  25. data/lib/clacky/message_history.rb +6 -2
  26. data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
  27. data/lib/clacky/providers.rb +18 -6
  28. data/lib/clacky/server/dir_picker.rb +154 -0
  29. data/lib/clacky/server/git_panel.rb +61 -9
  30. data/lib/clacky/server/http_server.rb +147 -103
  31. data/lib/clacky/tools/terminal.rb +27 -3
  32. data/lib/clacky/ui2/markdown_renderer.rb +15 -12
  33. data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
  34. data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
  35. data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
  36. data/lib/clacky/utils/model_pricing.rb +70 -4
  37. data/lib/clacky/version.rb +1 -1
  38. data/lib/clacky/web/app.css +412 -108
  39. data/lib/clacky/web/components/composer.js +45 -7
  40. data/lib/clacky/web/components/custom-select.js +136 -0
  41. data/lib/clacky/web/components/mentions.js +58 -2
  42. data/lib/clacky/web/components/model-picker.js +527 -0
  43. data/lib/clacky/web/components/onboard.js +29 -55
  44. data/lib/clacky/web/core/ext.js +38 -0
  45. data/lib/clacky/web/features/billing/view.js +20 -6
  46. data/lib/clacky/web/features/extensions/view.js +22 -10
  47. data/lib/clacky/web/features/new-session/store.js +15 -1
  48. data/lib/clacky/web/features/new-session/view.js +31 -22
  49. data/lib/clacky/web/features/trash/view.js +31 -19
  50. data/lib/clacky/web/features/workspace/store.js +1 -0
  51. data/lib/clacky/web/features/workspace/view.js +54 -3
  52. data/lib/clacky/web/i18n.js +73 -11
  53. data/lib/clacky/web/index.html +92 -37
  54. data/lib/clacky/web/sessions.js +550 -592
  55. data/lib/clacky/web/settings.js +163 -190
  56. data/lib/clacky.rb +3 -0
  57. metadata +8 -1
@@ -19,6 +19,7 @@ require "open3"
19
19
  require_relative "session_registry"
20
20
  require_relative "project_manager"
21
21
  require_relative "git_panel"
22
+ require_relative "dir_picker"
22
23
  require_relative "web_ui_controller"
23
24
  require_relative "model_prices"
24
25
  require_relative "scheduler"
@@ -166,6 +167,8 @@ module Clacky
166
167
  # * /api/* → JSON REST API (sessions, tasks, schedules)
167
168
  # GET /** → static files served from lib/clacky/web/ directory
168
169
  class HttpServer
170
+ include DirPicker
171
+
169
172
  WEB_ROOT = File.expand_path("../web", __dir__)
170
173
  # How long shutdown waits for each agent thread to unwind before falling
171
174
  # back to a manual session save.
@@ -658,6 +661,8 @@ module Clacky
658
661
  when ["PATCH", "/api/config/settings"] then api_update_settings(req, res)
659
662
  when ["POST", "/api/config/models"] then api_add_model(req, res)
660
663
  when ["POST", "/api/config/test"] then api_test_config(req, res)
664
+ when ["POST", "/api/config/reload"] then api_config_reload(req, res)
665
+ when ["POST", "/api/access_key/reload"] then api_access_key_reload(req, res)
661
666
  when ["POST", "/api/config/media/test"] then api_test_media_config(req, res)
662
667
  when ["GET", "/api/config/media"] then api_get_media_config(res)
663
668
  when ["GET", "/api/config/ocr"] then api_get_ocr_config(res)
@@ -796,6 +801,9 @@ module Clacky
796
801
  elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/git/commit$})
797
802
  session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
798
803
  api_session_git_commit(session_id, req, res)
804
+ elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/git/restore$})
805
+ session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
806
+ api_session_git_restore(session_id, req, res)
799
807
  elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/time_machine$})
800
808
  session_id = path.sub("/api/sessions/", "").sub("/time_machine", "")
801
809
  api_session_time_machine(session_id, res)
@@ -3409,21 +3417,21 @@ module Clacky
3409
3417
  private def platform_usage_summary(period:, model:)
3410
3418
  require_relative "../billing/platform_billing"
3411
3419
 
3412
- api_key = platform_api_key
3413
- return nil if api_key.nil? || api_key.empty?
3420
+ api_keys = platform_api_keys
3421
+ return nil if api_keys.empty?
3414
3422
 
3415
3423
  real = Clacky::Billing::PlatformBilling.real_model(model) || model
3416
- Clacky::Billing::PlatformBilling.fetch_summary(api_key, period: period, model: real)
3424
+ Clacky::Billing::PlatformBilling.fetch_summary_merged(api_keys, period: period, model: real)
3417
3425
  end
3418
3426
 
3419
3427
  private def platform_usage_daily(days:, model:)
3420
3428
  require_relative "../billing/platform_billing"
3421
3429
 
3422
- api_key = platform_api_key
3423
- return nil if api_key.nil? || api_key.empty?
3430
+ api_keys = platform_api_keys
3431
+ return nil if api_keys.empty?
3424
3432
 
3425
3433
  real = Clacky::Billing::PlatformBilling.real_model(model) || model
3426
- Clacky::Billing::PlatformBilling.fetch_daily(api_key, days: days, model: real)
3434
+ Clacky::Billing::PlatformBilling.fetch_daily_merged(api_keys, days: days, model: real)
3427
3435
  end
3428
3436
 
3429
3437
  private def merge_billing_models(platform_models, local_models)
@@ -3456,14 +3464,19 @@ module Clacky
3456
3464
  merged.transform_values { |v| v.round(6) }
3457
3465
  end
3458
3466
 
3459
- private def platform_api_key
3467
+ # All openclacky api keys in the user's model config, deduped and
3468
+ # empty-stripped. The billing page queries the platform once per key
3469
+ # and merges the results, so every configured account is included.
3470
+ private def platform_api_keys
3471
+ keys = []
3460
3472
  @agent_config.models.each do |m|
3461
3473
  next unless m.is_a?(Hash)
3462
3474
  next unless @agent_config.provider_id_for(m) == "openclacky"
3475
+
3463
3476
  key = m["api_key"].to_s.strip
3464
- return key unless key.empty?
3477
+ keys << key unless key.empty?
3465
3478
  end
3466
- nil
3479
+ keys.uniq
3467
3480
  end
3468
3481
 
3469
3482
  # POST /api/ui/open_aside
@@ -3551,10 +3564,10 @@ module Clacky
3551
3564
  s.start_with?("127.") || s == "::ffff:127.0.0.1"
3552
3565
  end
3553
3566
 
3554
- # Resolve access key from CLACKY_ACCESS_KEY env var only.
3567
+ # Resolve access key from ~/.clacky/access_key, falling back to the
3568
+ # CLACKY_ACCESS_KEY env var.
3555
3569
  private def resolve_access_key
3556
- key = ENV.fetch("CLACKY_ACCESS_KEY", "").strip
3557
- key.empty? ? nil : key
3570
+ Clacky::AccessKey.resolve
3558
3571
  end
3559
3572
 
3560
3573
  # Extract bearer token or query param from a WEBrick request.
@@ -3676,7 +3689,7 @@ module Clacky
3676
3689
  Clacky::Logger.info("[Upgrade] Official source — running: #{cmd}")
3677
3690
  broadcast_all(type: "upgrade_log", line: "Starting upgrade: #{cmd}\n")
3678
3691
 
3679
- output, exit_code = run_shell(cmd, timeout: 600)
3692
+ output, exit_code = run_shell(cmd, timeout: 600, env: gem_install_env)
3680
3693
 
3681
3694
  Clacky::Logger.info("[Upgrade] exit_code=#{exit_code}")
3682
3695
  Clacky::Logger.info("[Upgrade] output=#{output.slice(0, 1000)}")
@@ -3734,7 +3747,7 @@ module Clacky
3734
3747
  broadcast_all(type: "upgrade_log", line: "Installing...\n")
3735
3748
  Clacky::Logger.info("[Upgrade] Running: #{cmd}")
3736
3749
 
3737
- output, exit_code = run_shell(cmd, timeout: 600)
3750
+ output, exit_code = run_shell(cmd, timeout: 600, env: gem_install_env)
3738
3751
  success = exit_code&.zero? || false
3739
3752
 
3740
3753
  broadcast_all(type: "upgrade_log", line: output)
@@ -3810,6 +3823,63 @@ module Clacky
3810
3823
  schedule_restart
3811
3824
  end
3812
3825
 
3826
+ # POST /api/config/reload
3827
+ # Re-reads ~/.clacky/config.yml into the live @agent_config. Every session
3828
+ # shares the same @models array, so a reload reaches all of them at once.
3829
+ def api_config_reload(_req, res)
3830
+ was_configured = @agent_config.models_configured?
3831
+
3832
+ unless @agent_config.reload!
3833
+ json_response(res, 500, { ok: false, error: "Failed to reload config.yml" })
3834
+ return
3835
+ end
3836
+
3837
+ # A server that booted with an empty config never built its default
3838
+ # session (create_default_session bails on models_configured?). Now
3839
+ # that models exist, complete that deferred startup step.
3840
+ if !was_configured && @agent_config.models_configured?
3841
+ create_default_session
3842
+ end
3843
+
3844
+ json_response(res, 200, {
3845
+ ok: true,
3846
+ models: @agent_config.models.size,
3847
+ models_configured: @agent_config.models_configured?
3848
+ })
3849
+ end
3850
+
3851
+ # POST /api/access_key/reload
3852
+ # Re-reads the access key from ~/.clacky/access_key (or CLACKY_ACCESS_KEY).
3853
+ # Write the new key to that file first, then call this to swap it in
3854
+ # without restarting. Pass {"key": "..."} to have the server persist the
3855
+ # key for you, or {"generate": true} to get a freshly generated one back.
3856
+ def api_access_key_reload(req, res)
3857
+ body = parse_json_body(req)
3858
+
3859
+ if body["generate"]
3860
+ key = Clacky::AccessKey.write(Clacky::AccessKey.generate)
3861
+ elsif !body["key"].to_s.strip.empty?
3862
+ key = Clacky::AccessKey.write(body["key"])
3863
+ else
3864
+ key = Clacky::AccessKey.resolve
3865
+ end
3866
+
3867
+ @access_key = @localhost_only ? nil : key
3868
+ # Old lockout counters were scoped to the previous key; a client that
3869
+ # tripped them should not stay banned after a rotation.
3870
+ @auth_failures_mutex.synchronize { @auth_failures.clear }
3871
+
3872
+ Clacky::Logger.info("[Auth] Access key reloaded (configured=#{!key.nil?})")
3873
+
3874
+ payload = { ok: true, configured: !key.nil? }
3875
+ # Only echo the key when the server generated it — otherwise the caller
3876
+ # already knows it, and logging/proxies shouldn't see it again.
3877
+ payload[:access_key] = key if body["generate"]
3878
+ json_response(res, 200, payload)
3879
+ rescue ArgumentError => e
3880
+ json_response(res, 400, { ok: false, error: e.message })
3881
+ end
3882
+
3813
3883
  private def schedule_restart
3814
3884
  Clacky::ThreadRegistry.spawn(name: "schedule-restart") do
3815
3885
  sleep 0.5 # Let WEBrick flush the HTTP response
@@ -3922,8 +3992,23 @@ module Clacky
3922
3992
  # Delegates to Terminal.run_sync which handles the idle-poll loop
3923
3993
  # internally (see its docs for why that's needed — this wrapper used
3924
3994
  # to re-implement it wrong and caused the 0.9.36 upgrade bug).
3925
- private def run_shell(command, timeout: 120)
3926
- Clacky::Tools::Terminal.run_sync(command, timeout: timeout)
3995
+ private def run_shell(command, timeout: 120, env: nil)
3996
+ Clacky::Tools::Terminal.run_sync(command, timeout: timeout, env: env)
3997
+ end
3998
+
3999
+ # Install next to the gem the server is currently running from, bypassing
4000
+ # any GEM_HOME hardcoded in the user's shell rc (system-Ruby users).
4001
+ private def gem_install_env
4002
+ spec = Gem.loaded_specs["openclacky"]
4003
+ gem_home = if spec
4004
+ spec.base_dir
4005
+ else
4006
+ Gem.dir
4007
+ end
4008
+ {
4009
+ "GEM_HOME" => gem_home,
4010
+ "GEM_PATH" => Gem.path.join(File::PATH_SEPARATOR),
4011
+ }
3927
4012
  end
3928
4013
 
3929
4014
  # ── Channel API ───────────────────────────────────────────────────────────
@@ -4975,7 +5060,26 @@ module Clacky
4975
5060
  end
4976
5061
  end
4977
5062
 
4978
- # GET /api/sessions/:id/time_machine — task history for the Time Machine
5063
+ # POST /api/sessions/:id/git/restore - body: { file: }.
5064
+ # Discards uncommitted changes to one file, back to its HEAD state.
5065
+ def api_session_git_restore(session_id, req, res)
5066
+ dir = git_session_dir(session_id, res)
5067
+ return unless dir
5068
+
5069
+ unless Clacky::Server::GitPanel.repo?(dir)
5070
+ return json_response(res, 400, { error: "Not a git repository" })
5071
+ end
5072
+
5073
+ body = parse_json_body(req)
5074
+ result = Clacky::Server::GitPanel.restore(dir, file: body["file"])
5075
+ if result[:ok]
5076
+ json_response(res, 200, result)
5077
+ else
5078
+ json_response(res, 422, { error: result[:error] })
5079
+ end
5080
+ end
5081
+
5082
+ # GET /api/sessions/:id/time_machine - task history for the Time Machine
4979
5083
  # panel. Mirrors the CLI menu: each entry carries id, summary, status
4980
5084
  # (current/past/undone) and whether it branches.
4981
5085
  def api_session_time_machine(session_id, res)
@@ -5076,82 +5180,6 @@ module Clacky
5076
5180
  # Path traversal outside working_dir is rejected. Noisy dirs are hidden.
5077
5181
  IGNORED_FILE_ENTRIES = %w[.git .svn .hg node_modules .DS_Store .bundle vendor/bundle tmp .sass-cache].freeze
5078
5182
 
5079
- # Windows "special" folders under C:\Users that aren't real user profiles.
5080
- WINDOWS_PROFILE_DIRS = ["Public", "Default", "Default User", "All Users"].freeze
5081
-
5082
- # Sidebar quick-access favorites for the directory picker (Finder-style).
5083
- # Translated client-side via `id`. On WSL every favorite targets the
5084
- # Windows profile (/mnt/<drive>/Users/<name>/...) so they match where a
5085
- # Windows browser actually saves files; elsewhere they stay under Dir.home.
5086
- private def dir_picker_places
5087
- home = Dir.home
5088
- win_home = wsl_windows_home
5089
- places = []
5090
-
5091
- [["home", win_home || home],
5092
- ["desktop", File.join(win_home || home, "Desktop")],
5093
- ["downloads", File.join(win_home || home, "Downloads")],
5094
- ["documents", File.join(win_home || home, "Documents")]].each do |id, path|
5095
- places << { id: id, path: path, kind: "favorite" } if Dir.exist?(path)
5096
- end
5097
-
5098
- places
5099
- end
5100
-
5101
- # True when running inside Windows Subsystem for Linux.
5102
- private def wsl?
5103
- return true if ENV["WSL_DISTRO_NAME"]
5104
- return true if File.exist?("/proc/sys/fs/binfmt_misc/WSLInterop")
5105
- version = begin
5106
- File.read("/proc/version")
5107
- rescue StandardError
5108
- ""
5109
- end
5110
- version.downcase.include?("microsoft")
5111
- end
5112
-
5113
- # Best-effort path to the Windows user profile under WSL, or nil when it
5114
- # can't be determined (then callers fall back to Dir.home). Scans every
5115
- # mounted drive's Users dir instead of assuming the system drive is C:.
5116
- private def wsl_windows_home
5117
- return nil unless wsl?
5118
-
5119
- roots = wsl_windows_users_roots
5120
- username = ENV["WINDOWS_USERNAME"] || ENV["USERNAME"]
5121
- if username && !username.empty?
5122
- roots.each do |root|
5123
- candidate = File.join(root, username)
5124
- return candidate if Dir.exist?(candidate)
5125
- end
5126
- end
5127
-
5128
- roots.each do |root|
5129
- profiles = Dir.children(root).select do |name|
5130
- full = File.join(root, name)
5131
- File.directory?(full) &&
5132
- !WINDOWS_PROFILE_DIRS.include?(name) &&
5133
- !name.start_with?(".")
5134
- end
5135
- return File.join(root, profiles.first) if profiles.one?
5136
- end
5137
-
5138
- nil
5139
- end
5140
-
5141
- # Every mounted drive that exposes a Windows Users directory, e.g.
5142
- # /mnt/c/Users and /mnt/d/Users.
5143
- private def wsl_windows_users_roots
5144
- mounts = "/mnt"
5145
- return [] unless Dir.exist?(mounts)
5146
-
5147
- roots = []
5148
- Dir.children(mounts).each do |drive|
5149
- root = File.join(mounts, drive, "Users")
5150
- roots << root if Dir.exist?(root)
5151
- end
5152
- roots
5153
- end
5154
-
5155
5183
  def api_session_files(session_id, req, res)
5156
5184
  unless @registry.ensure(session_id)
5157
5185
  return json_response(res, 404, { error: "Session not found" })
@@ -5170,6 +5198,7 @@ module Clacky
5170
5198
 
5171
5199
  # Absolute mode: allow browsing outside working directory (e.g., root "/")
5172
5200
  if absolute_mode
5201
+ rel = Utils::EnvironmentDetector.win_to_linux_path(rel)
5173
5202
  target = File.expand_path(rel.empty? ? "/" : rel)
5174
5203
  display_root = target
5175
5204
  # Normalize rel for API response
@@ -5224,7 +5253,9 @@ module Clacky
5224
5253
  show_hidden = query["show_hidden"] == "true"
5225
5254
  include_files = query["files"] == "true"
5226
5255
  rel = Dir.home if rel.empty?
5256
+ rel = Utils::EnvironmentDetector.win_to_linux_path(rel)
5227
5257
  target = File.expand_path(rel.start_with?("~") ? rel.sub(/\A~/, Dir.home) : rel)
5258
+ requested_target = target
5228
5259
 
5229
5260
  # The requested directory may not exist yet (e.g. the default
5230
5261
  # ~/clacky_workspace before any session created it). Instead of 404,
@@ -5251,7 +5282,7 @@ module Clacky
5251
5282
  end
5252
5283
  items.sort_by! { |it| [it[:type] == "dir" ? 0 : 1, it[:name].downcase] }
5253
5284
 
5254
- json_response(res, 200, { root: target, path: target, parent: File.dirname(target), home: Dir.home, default: default_working_dir, entries: items, places: dir_picker_places })
5285
+ json_response(res, 200, { root: target, path: target, parent: File.dirname(target), exact: target == requested_target, home: Dir.home, default: default_working_dir, entries: items, places: dir_picker_places })
5255
5286
  rescue StandardError => e
5256
5287
  json_response(res, 500, { error: e.message })
5257
5288
  end
@@ -5272,7 +5303,7 @@ module Clacky
5272
5303
  # Body: { parent: "/abs/parent", name: "New Folder" }
5273
5304
  def api_dirs_mkdir(req, res)
5274
5305
  body = parse_json_body(req)
5275
- parent = body["parent"].to_s
5306
+ parent = Utils::EnvironmentDetector.win_to_linux_path(body["parent"].to_s)
5276
5307
  name = body["name"].to_s.strip
5277
5308
 
5278
5309
  return json_response(res, 422, { error: "parent must be an absolute path" }) unless parent.start_with?("/")
@@ -5754,10 +5785,12 @@ module Clacky
5754
5785
  # GET /api/profile
5755
5786
  # Returns { ok:, user: { path, content, is_default }, soul: { ... } }
5756
5787
  private def api_profile_get(res)
5788
+ soul = _profile_read_file("SOUL.md")
5789
+ soul[:name] = _soul_name(soul[:content])
5757
5790
  json_response(res, 200, {
5758
5791
  ok: true,
5759
5792
  user: _profile_read_file("USER.md"),
5760
- soul: _profile_read_file("SOUL.md")
5793
+ soul: soul
5761
5794
  })
5762
5795
  end
5763
5796
 
@@ -5828,6 +5861,16 @@ module Clacky
5828
5861
  { path: "", content: "", is_default: true, error: e.message }
5829
5862
  end
5830
5863
 
5864
+ # Extracts the AI name from a SOUL.md heading ("# 老六 — Soul"). Returns
5865
+ # nil when the heading is absent (e.g. the built-in default soul).
5866
+ private def _soul_name(content)
5867
+ return nil if content.nil? || content.empty?
5868
+ m = content.match(/^#\s*(.+?)\s*[—–-]\s*Soul\s*$/i)
5869
+ return nil unless m
5870
+ name = m[1].to_s.strip
5871
+ name.empty? ? nil : name
5872
+ end
5873
+
5831
5874
  # ── Memories API (~/.clacky/memories/*.md) ───────────────────────
5832
5875
  #
5833
5876
  # Long-term memories are plain Markdown files with YAML frontmatter
@@ -6377,13 +6420,13 @@ module Clacky
6377
6420
  # never send "the whole list" anymore.
6378
6421
 
6379
6422
  # Normalize an incoming api_format value into one of the supported
6380
- # explicit formats ("anthropic-messages" / "openai-completions"), or nil
6381
- # for auto. Returns :invalid for unsupported values so callers can
6382
- # reject the request outright.
6423
+ # explicit formats ("anthropic-messages" / "openai-completions" /
6424
+ # "openai-responses"), or nil for auto. Returns :invalid for unsupported
6425
+ # values so callers can reject the request outright.
6383
6426
  private def normalize_api_format(value)
6384
6427
  return nil if value.nil? || value.to_s.strip.empty?
6385
6428
  v = value.to_s.strip
6386
- return v if %w[anthropic-messages openai-completions].include?(v)
6429
+ return v if %w[anthropic-messages openai-completions openai-responses].include?(v)
6387
6430
  :invalid
6388
6431
  end
6389
6432
 
@@ -7384,10 +7427,11 @@ module Clacky
7384
7427
  # ── Session actions ───────────────────────────────────────────────────────
7385
7428
 
7386
7429
  def handle_edit_message(session_id, content, created_at)
7387
- return unless @registry.exist?(session_id)
7430
+ session = @registry.get(session_id)
7431
+ return unless session
7432
+ return if session[:status] == :running
7388
7433
 
7389
- agent = nil
7390
- @registry.with_session(session_id) { |s| agent = s[:agent] }
7434
+ agent = session[:agent]
7391
7435
  return unless agent
7392
7436
 
7393
7437
  if agent.history.respond_to?(:truncate_from_created_at) && !created_at.to_s.empty?
@@ -876,7 +876,8 @@ module Clacky
876
876
  end
877
877
 
878
878
  marker_token = SecureRandom.hex(8)
879
- reader_thread = start_reader_thread(reader, log_io)
879
+ session_box = []
880
+ reader_thread = start_reader_thread(reader, log_io, session_box)
880
881
 
881
882
  session = SessionManager.register(
882
883
  pid: pid, command: command, cwd: cwd || Dir.pwd,
@@ -886,6 +887,7 @@ module Clacky
886
887
  mode: "shell", marker_token: marker_token,
887
888
  shell_name: shell_name
888
889
  )
890
+ session_box << session
889
891
 
890
892
  # Give the shell a moment to print its startup banner (zsh -l -i
891
893
  # loads a lot of stuff), then drain whatever noise it wrote so the
@@ -905,8 +907,21 @@ module Clacky
905
907
  session
906
908
  end
907
909
 
908
- # Background thread: drain PTY → log file.
909
- private def start_reader_thread(reader, log_io)
910
+ # Background thread: drain PTY → log file. It doubles as the session's
911
+ # death watcher: when the child exits, read_nonblock raises EIO and the
912
+ # thread ends up in the ensure block below. Releasing the PTY fds and
913
+ # forgetting the session there means a dead session's master fd is
914
+ # reclaimed the moment its child dies — even when the calling agent
915
+ # never comes back to poll or kill it, which used to leak the fd until
916
+ # the process hit EMFILE ("Too many open files").
917
+ #
918
+ # `session_box` is a one-element array filled by spawn_shell right
919
+ # after SessionManager.register (the session cannot exist before the
920
+ # thread starts); an empty box means registration never happened and
921
+ # spawn_shell itself owns the cleanup. Every close is rescue-guarded
922
+ # and SessionManager.forget is a plain Hash#delete, so this whole
923
+ # block is a no-op when the main thread's cleanup_session ran first.
924
+ private def start_reader_thread(reader, log_io, session_box)
910
925
  Clacky::ThreadRegistry.spawn(name: "terminal-pty-reader") do
911
926
  loop do
912
927
  break if reader.closed? || log_io.closed?
@@ -926,6 +941,15 @@ module Clacky
926
941
  end
927
942
  ensure
928
943
  log_io.close rescue nil
944
+ if (session = session_box[0])
945
+ # Flip the status first so a main thread parked in
946
+ # read_until_marker's poll loop notices the death immediately
947
+ # (its refresh() no longer finds the session in the registry).
948
+ session.status = "exited" unless %w[exited killed].include?(session.status)
949
+ session.writer.close rescue nil
950
+ session.reader.close rescue nil
951
+ SessionManager.forget(session.id)
952
+ end
929
953
  end
930
954
  end
931
955
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "tty-screen"
4
4
  require_relative "theme_manager"
5
+ require_relative "strings_cjk_patch"
5
6
 
6
7
  module Clacky
7
8
  module UI2
@@ -21,14 +22,24 @@ module Clacky
21
22
  # Get current theme colors
22
23
  theme = ThemeManager.current_theme
23
24
 
25
+ width = TTY::Screen.width - 4 # Leave some margin
26
+
24
27
  # Configure tty-markdown with custom theme and symbols
25
28
  parsed = TTY::Markdown.parse(content,
26
29
  theme: theme_colors,
27
30
  symbols: custom_symbols,
28
- width: TTY::Screen.width - 4 # Leave some margin
31
+ width: width
29
32
  )
30
33
 
31
34
  parsed
35
+ rescue IndexError => e
36
+ # Safety net behind strings_cjk_patch; retry uncolored if wrap still fails.
37
+ warn "[markdown] color render failed (#{e.class}), retrying without color" if ENV["CLACKY_DEBUG"]
38
+ begin
39
+ TTY::Markdown.parse(content, color: :never, symbols: custom_symbols, width: width)
40
+ rescue StandardError
41
+ content
42
+ end
32
43
  rescue StandardError => e
33
44
  warn "[markdown] render failed: #{e.class}: #{e.message}" if ENV["CLACKY_DEBUG"]
34
45
  content
@@ -59,20 +70,12 @@ module Clacky
59
70
  def theme_colors
60
71
  theme = ThemeManager.current_theme
61
72
 
62
- # Map our theme colors to tty-markdown's expected format
63
- # Note: theme.colors values are already arrays, so we need to flatten when adding styles
73
+ # tty-markdown accepts a single :header key for all header levels
74
+ # (h1 additionally underlines). Code block colors come from rouge.
64
75
  {
65
- # Headers use info color (cyan/blue)
66
- h1: Array(theme.colors[:info]) + [:bold],
67
- h2: Array(theme.colors[:info]) + [:bold],
68
- h3: Array(theme.colors[:info]),
69
- h4: Array(theme.colors[:info]),
70
- h5: Array(theme.colors[:info]),
71
- h6: Array(theme.colors[:info]),
76
+ header: [:bright_cyan, :bold],
72
77
  # Horizontal rule - make it subtle (dim gray)
73
78
  hr: [:bright_black],
74
- # Code blocks use dim color
75
- code: Array(theme.colors[:thinking]),
76
79
  # Links use success color (green)
77
80
  link: Array(theme.colors[:success]),
78
81
  # Lists use default text color
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strings"
4
+
5
+ # strings 0.2.1 (latest release, 2021) tracks ANSI reinsertion offsets in
6
+ # display width (CJK chars count as 2) but applies them as String character
7
+ # indexes via String#insert, so any colored CJK content that needs wrapping
8
+ # raises IndexError. These methods mirror upstream with offsets tracked in
9
+ # character count instead of display width.
10
+ module Strings
11
+ module Wrap
12
+ module_function
13
+
14
+ def format_line(text_line, wrap_at, ansi_stack)
15
+ lines = []
16
+ line = []
17
+ word = []
18
+ ansi = []
19
+ ansi_matched = false
20
+ word_length = 0
21
+ line_length = 0
22
+ word_chars = 0
23
+ line_chars = 0
24
+ char_length = 0
25
+ text_length = display_width(text_line)
26
+ total_length = 0
27
+
28
+ UnicodeUtils.each_grapheme(text_line) do |char|
29
+ if char == Strings::ANSI::CSI || ansi.length > 0
30
+ ansi << char
31
+ if Strings::ANSI.only_ansi?(ansi.join)
32
+ ansi_matched = true
33
+ elsif ansi_matched
34
+ ansi_stack << [ansi[0...-1].join, line_chars + word_chars]
35
+ ansi_matched = false
36
+
37
+ if ansi.last == Strings::ANSI::CSI
38
+ ansi = [ansi.last]
39
+ else
40
+ ansi = []
41
+ end
42
+ end
43
+ next if ansi.length > 0
44
+ end
45
+
46
+ char_length = display_width(char)
47
+ total_length += char_length
48
+ if line_length + word_length + char_length <= wrap_at
49
+ if char == SPACE || total_length == text_length
50
+ line << word.join + char
51
+ line_length += word_length + char_length
52
+ line_chars += word_chars + char.size
53
+ word = []
54
+ word_length = 0
55
+ word_chars = 0
56
+ else
57
+ word << char
58
+ word_length += char_length
59
+ word_chars += char.size
60
+ end
61
+ next
62
+ end
63
+
64
+ if char == SPACE
65
+ lines << insert_ansi(line.join, ansi_stack)
66
+ line = []
67
+ line_length = 0
68
+ line_chars = 0
69
+ word << char
70
+ word_length += char_length
71
+ word_chars += char.size
72
+ elsif word_length + char_length <= wrap_at
73
+ lines << insert_ansi(line.join, ansi_stack)
74
+ line = [word.join + char]
75
+ line_length = word_length + char_length
76
+ line_chars = word_chars + char.size
77
+ word = []
78
+ word_length = 0
79
+ word_chars = 0
80
+ else # hyphenate word - too long to fit a line
81
+ lines << insert_ansi(word.join, ansi_stack)
82
+ line_length = 0
83
+ line_chars = 0
84
+ word = [char]
85
+ word_length = char_length
86
+ word_chars = char.size
87
+ end
88
+ end
89
+ lines << insert_ansi(line.join, ansi_stack) unless line.empty?
90
+ lines << insert_ansi(word.join, ansi_stack) unless word.empty?
91
+ lines
92
+ end
93
+
94
+ def insert_ansi(string, ansi_stack = [])
95
+ return string if ansi_stack.empty?
96
+ return string if string.empty?
97
+
98
+ new_stack = []
99
+ output = string.dup
100
+ length = string.size
101
+ matched_reset = false
102
+ ansi_reset = Strings::ANSI::RESET
103
+
104
+ ansi_stack.reverse_each do |ansi|
105
+ pos = ansi[1]
106
+ # Offsets carried over from a previous (longer) line can exceed this
107
+ # line; such codes belong before the wrapped content.
108
+ pos = 0 if pos > length
109
+ if ansi[0] =~ /#{Regexp.quote(ansi_reset)}/
110
+ matched_reset = true
111
+ output.insert(pos, ansi_reset)
112
+ next
113
+ elsif !matched_reset # ansi without reset
114
+ matched_reset = false
115
+ new_stack << ansi # keep the ansi
116
+ next if pos == length
117
+ if output.end_with?(NEWLINE)
118
+ output.insert(-2, ansi_reset)
119
+ else
120
+ output.insert(-1, ansi_reset)
121
+ end
122
+ end
123
+
124
+ output.insert(pos, ansi[0])
125
+ end
126
+
127
+ ansi_stack.replace(new_stack)
128
+
129
+ output
130
+ end
131
+ end
132
+ end
@@ -31,7 +31,7 @@ module Clacky
31
31
  COLORS = {
32
32
  # Format: [symbol_color, dark_bg_text, light_bg_text]
33
33
  user: [:bright_black, :white, :black], # User prompt and input
34
- assistant: [:bright_green, :bright_black, :bright_black], # AI response - keep green hacker style
34
+ assistant: [:bright_green, :bright_white, :black], # AI response
35
35
  tool_call: [:bright_cyan, :cyan, :cyan], # Tool execution
36
36
  tool_result: [:bright_cyan, :bright_black, :bright_black], # Tool output
37
37
  tool_denied: [:bright_yellow, :yellow, :yellow], # Denied actions
@@ -28,7 +28,7 @@ module Clacky
28
28
  COLORS = {
29
29
  # Format: [symbol_color, dark_bg_text, light_bg_text]
30
30
  user: [:bright_black, :bright_black, :black], # User prompt and input
31
- assistant: [:green, :bright_black, :bright_black], # AI response
31
+ assistant: [:green, :bright_white, :black], # AI response
32
32
  tool_call: [:cyan, :cyan, :cyan], # Tool execution
33
33
  tool_result: [:cyan, :bright_black, :bright_black], # Tool output
34
34
  tool_denied: [:yellow, :yellow, :yellow], # Denied actions