openclacky 1.1.3 → 1.1.4

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.
@@ -407,7 +407,7 @@ module Clacky
407
407
  when ["GET", "/api/channels"] then api_list_channels(res)
408
408
  when ["POST", "/api/tool/browser"] then api_tool_browser(req, res)
409
409
  when ["POST", "/api/upload"] then api_upload_file(req, res)
410
- when ["POST", "/api/open-file"] then api_open_file(req, res)
410
+ when ["POST", "/api/file-action"] then api_file_action(req, res)
411
411
  when ["GET", "/api/local-image"] then api_serve_local_image(req, res)
412
412
  when ["GET", "/api/version"] then api_get_version(res)
413
413
  when ["POST", "/api/version/upgrade"] then api_upgrade_version(req, res)
@@ -448,6 +448,9 @@ module Clacky
448
448
  elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/model$})
449
449
  session_id = path.sub("/api/sessions/", "").sub("/model", "")
450
450
  api_switch_session_model(session_id, req, res)
451
+ elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/reasoning_effort$})
452
+ session_id = path.sub("/api/sessions/", "").sub("/reasoning_effort", "")
453
+ api_switch_session_reasoning_effort(session_id, req, res)
451
454
  elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/benchmark$})
452
455
  session_id = path.sub("/api/sessions/", "").sub("/benchmark", "")
453
456
  api_benchmark_session_models(session_id, req, res)
@@ -752,6 +755,9 @@ module Clacky
752
755
  else
753
756
  Clacky::Logger.debug("[Brand] async distribution refresh skipped/failed — #{result[:message]}")
754
757
  end
758
+ # Free-mode skill sync: branded + unactivated installs need their
759
+ # creator's free skills auto-installed for the "no serial number" UX.
760
+ brand.sync_free_skills_async!
755
761
  rescue StandardError => e
756
762
  Clacky::Logger.warn("[Brand] async distribution refresh raised: #{e.class}: #{e.message}")
757
763
  ensure
@@ -797,12 +803,30 @@ module Clacky
797
803
  refresh_pending = true
798
804
  end
799
805
 
806
+ # Free-mode counts: synchronous fetch is acceptable here because
807
+ # this endpoint is polled lazily and the platform call is cached
808
+ # via http keep-alive. On error we just return zero counts and the
809
+ # banner falls back to the legacy "not activated" message.
810
+ free_count = 0
811
+ paid_count = 0
812
+ begin
813
+ result = brand.fetch_free_skills!
814
+ if result[:success]
815
+ free_count = result[:skills].size
816
+ paid_count = result[:paid_skills_count].to_i
817
+ end
818
+ rescue StandardError
819
+ # Network errors are non-fatal here.
820
+ end
821
+
800
822
  json_response(res, 200, {
801
823
  branded: true,
802
824
  needs_activation: true,
803
825
  product_name: brand.product_name,
804
826
  test_mode: @brand_test,
805
- distribution_refresh_pending: refresh_pending
827
+ distribution_refresh_pending: refresh_pending,
828
+ free_skills_count: free_count,
829
+ paid_skills_count: paid_count
806
830
  })
807
831
  return
808
832
  end
@@ -918,7 +942,30 @@ module Clacky
918
942
  brand = Clacky::BrandConfig.load
919
943
 
920
944
  unless brand.activated?
921
- json_response(res, 403, { ok: false, error: "License not activated" })
945
+ # Free-mode: branded but no license. Return the unencrypted skills
946
+ # available to anonymous installs so the Brand Skills tab is not
947
+ # empty and the user can install/use them without a serial number.
948
+ # Each skill is tagged is_free=true so the UI can show a "Free" badge.
949
+ result = brand.fetch_free_skills!
950
+
951
+ if result[:success]
952
+ free_skills = result[:skills].map { |s| s.merge("is_free" => true) }
953
+ json_response(res, 200, {
954
+ ok: true,
955
+ skills: free_skills,
956
+ free_mode: true,
957
+ paid_skills_count: result[:paid_skills_count].to_i
958
+ })
959
+ else
960
+ json_response(res, 200, {
961
+ ok: true,
962
+ skills: [],
963
+ free_mode: true,
964
+ paid_skills_count: 0,
965
+ warning_code: "remote_unavailable",
966
+ warning: result[:error] || "Could not reach the license server."
967
+ })
968
+ end
922
969
  return
923
970
  end
924
971
 
@@ -963,8 +1010,29 @@ module Clacky
963
1010
  def api_brand_skill_install(slug, req, res)
964
1011
  brand = Clacky::BrandConfig.load
965
1012
 
1013
+ # Free-mode: branded but not activated. Fall back to the public free
1014
+ # skills endpoint and install with encrypted: false. Paid (encrypted)
1015
+ # skills still require activation and will return 404 here.
966
1016
  unless brand.activated?
967
- json_response(res, 403, { ok: false, error: "License not activated" })
1017
+ fetch_result = brand.fetch_free_skills!
1018
+ unless fetch_result[:success]
1019
+ json_response(res, 422, { ok: false, error: fetch_result[:error] })
1020
+ return
1021
+ end
1022
+
1023
+ skill_info = fetch_result[:skills].find { |s| s["name"] == slug }
1024
+ unless skill_info
1025
+ json_response(res, 404, { ok: false, error: "Skill '#{slug}' is not a free skill — activate your license to access it." })
1026
+ return
1027
+ end
1028
+
1029
+ result = brand.install_free_skill!(skill_info)
1030
+ if result[:success]
1031
+ @skill_loader = Clacky::SkillLoader.new(working_dir: nil, brand_config: brand)
1032
+ json_response(res, 200, { ok: true, name: result[:name], version: result[:version] })
1033
+ else
1034
+ json_response(res, 422, { ok: false, error: result[:error] })
1035
+ end
968
1036
  return
969
1037
  end
970
1038
 
@@ -1304,7 +1372,11 @@ module Clacky
1304
1372
  sleep 0.5 # Let WEBrick flush the HTTP response
1305
1373
 
1306
1374
  if @master_pid
1307
- # Worker mode: tell master to hot-restart, then exit cleanly.
1375
+ # Worker mode: tell master to hot-restart. Master will TERM us after the
1376
+ # new worker boots; our trap("TERM") then runs shutdown_proc, which detaches
1377
+ # the inherited listen socket before WEBrick shutdown. Do NOT exit(0) here —
1378
+ # that bypasses trap handlers and lets the OS close(fd) on a socket shared
1379
+ # with master+new worker, corrupting the listener on Linux/WSL.
1308
1380
  Clacky::Logger.info("[Restart] Sending USR1 to master (PID=#{@master_pid})")
1309
1381
  begin
1310
1382
  Process.kill("USR1", @master_pid)
@@ -1312,7 +1384,6 @@ module Clacky
1312
1384
  Clacky::Logger.warn("[Restart] Master PID=#{@master_pid} not found, falling back to exec.")
1313
1385
  standalone_exec_restart
1314
1386
  end
1315
- exit(0)
1316
1387
  else
1317
1388
  # Standalone mode (no master): fall back to the original exec approach.
1318
1389
  standalone_exec_restart
@@ -1549,12 +1620,16 @@ module Clacky
1549
1620
  json_response(res, 500, { ok: false, error: e.message })
1550
1621
  end
1551
1622
 
1552
- # POST /api/open-file
1553
- # Opens a local file or directory using the OS default handler.
1554
- # Used by the Web UI to handle file:// links — browsers block direct
1555
- # file:// navigation from http:// pages for security reasons.
1556
- def api_open_file(req, res)
1557
- path = parse_json_body(req)["path"]
1623
+ # POST /api/file-action
1624
+ # Unified file action endpoint open locally or download.
1625
+ # Body: { path: String, action: "open" | "download" }
1626
+ # open: opens the file with the OS default handler (local deployments).
1627
+ # download: returns the file as a download (remote deployments).
1628
+ def api_file_action(req, res)
1629
+ body = parse_json_body(req)
1630
+ path = body["path"]
1631
+ action = body["action"] || "open"
1632
+
1558
1633
  return json_response(res, 400, { error: "path is required" }) unless path && !path.empty?
1559
1634
 
1560
1635
  # Expand ~ to the user's home directory (e.g. "~/Desktop/file.pdf").
@@ -1567,13 +1642,33 @@ module Clacky
1567
1642
 
1568
1643
  return json_response(res, 404, { error: "file not found" }) unless File.exist?(linux_path)
1569
1644
 
1570
- result = Utils::EnvironmentDetector.open_file(linux_path)
1571
- return json_response(res, 501, { error: "unsupported OS" }) if result.nil?
1572
- json_response(res, 200, { ok: true })
1645
+ case action
1646
+ when "open"
1647
+ result = Utils::EnvironmentDetector.open_file(linux_path)
1648
+ return json_response(res, 501, { error: "unsupported OS" }) if result.nil?
1649
+ json_response(res, 200, { ok: true })
1650
+ when "download"
1651
+ serve_file_download(res, linux_path)
1652
+ else
1653
+ json_response(res, 400, { error: "invalid action. Must be 'open' or 'download'" })
1654
+ end
1573
1655
  rescue => e
1574
1656
  json_response(res, 500, { ok: false, error: e.message })
1575
1657
  end
1576
1658
 
1659
+ # Stream a file to the client as a download.
1660
+ # Content-Type is always application/octet-stream — the browser determines
1661
+ # file type and handling from the filename extension in Content-Disposition.
1662
+ def serve_file_download(res, path)
1663
+ filename = File.basename(path)
1664
+
1665
+ res.status = 200
1666
+ res["Content-Type"] = "application/octet-stream"
1667
+ res["Content-Disposition"] = "attachment; filename=\"#{filename}\""
1668
+ res["Content-Length"] = File.size(path).to_s
1669
+ res.body = File.binread(path)
1670
+ end
1671
+
1577
1672
  # GET /api/local-image?path=file:///path/to/image.png
1578
1673
  # GET /api/local-image?path=/path/to/image.png
1579
1674
  #
@@ -2999,6 +3094,26 @@ module Clacky
2999
3094
  json_response(res, 500, { error: e.message })
3000
3095
  end
3001
3096
 
3097
+ # PATCH /api/sessions/:id/reasoning_effort
3098
+ # Body: { "reasoning_effort": "off" | "low" | "medium" | "high" }
3099
+ def api_switch_session_reasoning_effort(session_id, req, res)
3100
+ body = parse_json_body(req)
3101
+ raw = body["reasoning_effort"]
3102
+ return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
3103
+
3104
+ agent = nil
3105
+ @registry.with_session(session_id) { |s| agent = s[:agent] }
3106
+ return json_response(res, 404, { error: "Session not found" }) unless agent
3107
+
3108
+ agent.reasoning_effort = raw
3109
+ @session_manager.save(agent.to_session_data)
3110
+ broadcast_session_update(session_id)
3111
+
3112
+ json_response(res, 200, { ok: true, reasoning_effort: agent.reasoning_effort })
3113
+ rescue => e
3114
+ json_response(res, 500, { error: e.message })
3115
+ end
3116
+
3002
3117
  # POST /api/sessions/:id/benchmark
3003
3118
  #
3004
3119
  # Speed-test every configured model in one shot so the user can pick the
@@ -165,11 +165,12 @@ module Clacky
165
165
  model_info = s[:agent]&.current_model_info
166
166
  live_name = s[:agent]&.name
167
167
  live_name = nil if live_name&.empty?
168
- live_cost_source = s[:agent]&.cost_source
169
- { status: s[:status], error: s[:error], model: model_info&.dig(:model), name: live_name,
170
- total_tasks: s[:agent]&.total_tasks, total_cost: s[:agent]&.total_cost,
171
- cost_source: live_cost_source,
172
- latest_latency: s[:agent]&.latest_latency }
168
+ live_cost_source = s[:agent]&.cost_source
169
+ { status: s[:status], error: s[:error], model: model_info&.dig(:model), name: live_name,
170
+ total_tasks: s[:agent]&.total_tasks, total_cost: s[:agent]&.total_cost,
171
+ cost_source: live_cost_source,
172
+ reasoning_effort: s[:agent]&.reasoning_effort,
173
+ latest_latency: s[:agent]&.latest_latency }
173
174
  end
174
175
  end
175
176
 
@@ -241,6 +242,7 @@ module Clacky
241
242
  { status: s[:status], error: s[:error], model: model_info&.dig(:model),
242
243
  name: live_name, total_tasks: s[:agent]&.total_tasks,
243
244
  total_cost: s[:agent]&.total_cost, cost_source: s[:agent]&.cost_source,
245
+ reasoning_effort: s[:agent]&.reasoning_effort,
244
246
  latest_latency: s[:agent]&.latest_latency }
245
247
  end
246
248
 
@@ -271,6 +273,7 @@ module Clacky
271
273
  # per-assistant-message `latency` fields in messages[]. Reloaded
272
274
  # sessions start with nil and get populated on the next LLM call.
273
275
  latest_latency: ls&.dig(:latest_latency),
276
+ reasoning_effort: ls&.dig(:reasoning_effort) || s.dig(:config, :reasoning_effort),
274
277
  pinned: s[:pinned] || false,
275
278
  }
276
279
  end
@@ -382,7 +385,7 @@ module Clacky
382
385
  return nil unless agent
383
386
 
384
387
  model_info = agent.current_model_info
385
-
388
+
386
389
  {
387
390
  id: session[:id],
388
391
  name: agent.name,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.1.3"
4
+ VERSION = "1.1.4"
5
5
  end