openclacky 0.9.32 → 0.9.34

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.
@@ -385,6 +385,8 @@ module Clacky
385
385
  when ["GET", "/api/version"] then api_get_version(res)
386
386
  when ["POST", "/api/version/upgrade"] then api_upgrade_version(req, res)
387
387
  when ["POST", "/api/restart"] then api_restart(req, res)
388
+ when ["PATCH", "/api/sessions/:id/model"] then api_switch_session_model(req, res)
389
+ when ["PATCH", "/api/sessions/:id/working_dir"] then api_change_session_working_dir(req, res)
388
390
  else
389
391
  if method == "POST" && path.match?(%r{^/api/channels/[^/]+/test$})
390
392
  platform = path.sub("/api/channels/", "").sub("/test", "")
@@ -404,6 +406,12 @@ module Clacky
404
406
  elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+$})
405
407
  session_id = path.sub("/api/sessions/", "")
406
408
  api_rename_session(session_id, req, res)
409
+ elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/model$})
410
+ session_id = path.sub("/api/sessions/", "").sub("/model", "")
411
+ api_switch_session_model(session_id, req, res)
412
+ elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/working_dir$})
413
+ session_id = path.sub("/api/sessions/", "").sub("/working_dir", "")
414
+ api_change_session_working_dir(session_id, req, res)
407
415
  elsif method == "DELETE" && path.start_with?("/api/sessions/")
408
416
  session_id = path.sub("/api/sessions/", "")
409
417
  api_delete_session(session_id, res)
@@ -467,15 +475,15 @@ module Clacky
467
475
  raw_dir = body["working_dir"].to_s.strip
468
476
  working_dir = raw_dir.empty? ? default_working_dir : File.expand_path(raw_dir)
469
477
 
470
- # If a custom working_dir was requested and the directory already exists and is non-empty,
471
- # refuse to create the session to prevent accidentally clobbering an existing project.
472
- if !raw_dir.empty? && Dir.exist?(working_dir) && Dir.children(working_dir).any?
473
- return json_response(res, 409, { error: "Directory already exists and is not empty: #{working_dir}" })
474
- end
478
+ # Optional model override
479
+ model_override = body["model"].to_s.strip
480
+ model_override = nil if model_override.empty?
475
481
 
482
+ # Create working directory if it doesn't exist
483
+ # Allow multiple sessions in the same directory
476
484
  FileUtils.mkdir_p(working_dir)
477
485
 
478
- session_id = build_session(name: name, working_dir: working_dir, profile: profile, source: source)
486
+ session_id = build_session(name: name, working_dir: working_dir, profile: profile, source: source, model_override: model_override)
479
487
  broadcast_session_update(session_id)
480
488
  json_response(res, 201, { session: @registry.session_summary(session_id) })
481
489
  end
@@ -1810,17 +1818,107 @@ module Clacky
1810
1818
 
1811
1819
  def api_rename_session(session_id, req, res)
1812
1820
  body = parse_json_body(req)
1813
- new_name = body["name"].to_s.strip
1821
+ new_name = body["name"]&.to_s&.strip
1822
+ pinned = body["pinned"]
1823
+
1824
+ return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
1825
+
1826
+ agent = nil
1827
+ @registry.with_session(session_id) { |s| agent = s[:agent] }
1828
+
1829
+ # Update name if provided
1830
+ if new_name && !new_name.empty?
1831
+ agent.rename(new_name)
1832
+ end
1833
+
1834
+ # Save session data
1835
+ session_data = agent.to_session_data
1836
+
1837
+ # Update pinned field if provided (not stored in agent, only in session file)
1838
+ if !pinned.nil?
1839
+ session_data[:pinned] = pinned
1840
+ end
1841
+
1842
+ @session_manager.save(session_data)
1843
+
1844
+ # Broadcast update event
1845
+ update_data = { type: "session_updated", session_id: session_id }
1846
+ update_data[:name] = new_name if new_name && !new_name.empty?
1847
+ update_data[:pinned] = pinned unless pinned.nil?
1848
+ broadcast(session_id, update_data)
1849
+
1850
+ response_data = { ok: true }
1851
+ response_data[:name] = new_name if new_name && !new_name.empty?
1852
+ response_data[:pinned] = pinned unless pinned.nil?
1853
+ json_response(res, 200, response_data)
1854
+ rescue => e
1855
+ json_response(res, 500, { error: e.message })
1856
+ end
1857
+
1858
+ def api_switch_session_model(session_id, req, res)
1859
+ body = parse_json_body(req)
1860
+ new_model_name = body["model"].to_s.strip
1814
1861
 
1815
- return json_response(res, 400, { error: "name is required" }) if new_name.empty?
1862
+ return json_response(res, 400, { error: "model is required" }) if new_model_name.empty?
1816
1863
  return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
1817
1864
 
1818
1865
  agent = nil
1819
1866
  @registry.with_session(session_id) { |s| agent = s[:agent] }
1820
- agent.rename(new_name)
1867
+
1868
+ # Find the model configuration index by model name (use global config)
1869
+ model_index = @agent_config.models.find_index { |m| m["model"] == new_model_name }
1870
+
1871
+ if model_index.nil?
1872
+ return json_response(res, 400, { error: "Model '#{new_model_name}' not found in configuration" })
1873
+ end
1874
+
1875
+ # Switch to the model by index (unified interface with CLI)
1876
+ # This handles: config.switch_model + client rebuild + message_compressor rebuild
1877
+ success = agent.switch_model(model_index)
1878
+
1879
+ unless success
1880
+ return json_response(res, 500, { error: "Failed to switch model" })
1881
+ end
1882
+
1883
+ # Persist the change (saves to session file, NOT global config.yml)
1821
1884
  @session_manager.save(agent.to_session_data)
1822
- broadcast(session_id, { type: "session_renamed", session_id: session_id, name: new_name })
1823
- json_response(res, 200, { ok: true, name: new_name })
1885
+
1886
+ # Broadcast update to all clients
1887
+ broadcast_session_update(session_id)
1888
+
1889
+ json_response(res, 200, { ok: true, model: new_model_name })
1890
+ rescue => e
1891
+ json_response(res, 500, { error: e.message })
1892
+ end
1893
+
1894
+ def api_change_session_working_dir(session_id, req, res)
1895
+ body = parse_json_body(req)
1896
+ new_dir = body["working_dir"].to_s.strip
1897
+
1898
+ return json_response(res, 400, { error: "working_dir is required" }) if new_dir.empty?
1899
+ return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
1900
+
1901
+ # Expand ~ to home directory
1902
+ expanded_dir = File.expand_path(new_dir)
1903
+
1904
+ # Validate directory exists
1905
+ unless Dir.exist?(expanded_dir)
1906
+ return json_response(res, 400, { error: "Directory does not exist: #{expanded_dir}" })
1907
+ end
1908
+
1909
+ agent = nil
1910
+ @registry.with_session(session_id) { |s| agent = s[:agent] }
1911
+
1912
+ # Change the agent's working directory
1913
+ agent.change_working_dir(expanded_dir)
1914
+
1915
+ # Persist the change
1916
+ @session_manager.save(agent.to_session_data)
1917
+
1918
+ # Broadcast update to all clients
1919
+ broadcast_session_update(session_id)
1920
+
1921
+ json_response(res, 200, { ok: true, working_dir: expanded_dir })
1824
1922
  rescue => e
1825
1923
  json_response(res, 500, { error: e.message })
1826
1924
  end
@@ -2156,13 +2254,19 @@ module Clacky
2156
2254
  # @param working_dir [String] working directory for the agent
2157
2255
  # @param permission_mode [Symbol] :confirm_all (default, human present) or
2158
2256
  # :auto_approve (unattended — suppresses request_user_feedback waits)
2159
- def build_session(name:, working_dir:, permission_mode: :confirm_all, profile: "general", source: :manual)
2257
+ def build_session(name:, working_dir:, permission_mode: :confirm_all, profile: "general", source: :manual, model_override: nil)
2160
2258
  session_id = Clacky::SessionManager.generate_id
2161
2259
  @registry.create(session_id: session_id)
2162
2260
 
2163
2261
  client = @client_factory.call
2164
2262
  config = @agent_config.deep_copy
2165
2263
  config.permission_mode = permission_mode
2264
+
2265
+ # Apply model override if provided
2266
+ if model_override && config.current_model
2267
+ config.current_model["model"] = model_override
2268
+ end
2269
+
2166
2270
  broadcaster = method(:broadcast)
2167
2271
  ui = WebUIController.new(session_id, broadcaster)
2168
2272
  agent = Clacky::Agent.new(client, config, working_dir: working_dir, ui: ui, profile: profile,
@@ -204,6 +204,7 @@ module Clacky
204
204
  updated_at: s[:updated_at],
205
205
  total_tasks: ls&.dig(:total_tasks) || s.dig(:stats, :total_tasks) || 0,
206
206
  total_cost: ls&.dig(:total_cost) || s.dig(:stats, :total_cost_usd) || 0.0,
207
+ pinned: s[:pinned] || false,
207
208
  }
208
209
  end
209
210
  end
@@ -263,6 +264,11 @@ module Clacky
263
264
  return nil unless agent
264
265
 
265
266
  model_info = agent.current_model_info
267
+
268
+ # Load pinned status from disk session file
269
+ disk_session = @session_manager.load(session_id)
270
+ pinned = disk_session ? (disk_session[:pinned] || false) : false
271
+
266
272
  {
267
273
  id: session[:id],
268
274
  name: agent.name,
@@ -277,6 +283,7 @@ module Clacky
277
283
  permission_mode: agent.permission_mode,
278
284
  source: agent.source.to_s,
279
285
  agent_profile: agent.agent_profile.name,
286
+ pinned: pinned,
280
287
  }
281
288
  end
282
289
  end
@@ -320,6 +320,12 @@ module Clacky
320
320
  end
321
321
 
322
322
  def set_idle_status
323
+ # Clear any in-progress state when transitioning to idle
324
+ if @live_progress_state
325
+ emit("progress", phase: "done", status: "stop")
326
+ @live_progress_state = nil
327
+ @progress_start_time = nil
328
+ end
323
329
  emit("session_update", status: "idle")
324
330
  forward_to_subscribers { |sub| sub.set_idle_status }
325
331
  end
data/lib/clacky/skill.rb CHANGED
@@ -36,6 +36,11 @@ module Clacky
36
36
  attr_reader :fork_agent, :model, :forbidden_tools, :auto_summarize
37
37
  attr_reader :brand_skill, :brand_config
38
38
 
39
+ # Source location of this skill — set by SkillLoader after registration.
40
+ # One of: :default, :global_claude, :global_clacky, :project_claude, :project_clacky, :brand
41
+ # @return [Symbol, nil]
42
+ attr_accessor :source
43
+
39
44
  # Warnings accumulated during load (e.g. name was invalid and fell back to dir name).
40
45
  # Non-empty means the skill loaded but something was auto-corrected.
41
46
  # @return [Array<String>]
@@ -433,6 +433,7 @@ module Clacky
433
433
 
434
434
  @skills[id] = skill
435
435
  @loaded_from[id] = source
436
+ skill.source = source
436
437
 
437
438
  # Invalid skills have no usable slug — skip slash command registration but
438
439
  # still keep them in @skills so they appear (greyed-out) in the UI.
@@ -466,16 +467,7 @@ module Clacky
466
467
 
467
468
  begin
468
469
  skill = Skill.new(Pathname.new(skill_dir))
469
-
470
- # Check for duplicates (higher priority skills override)
471
- if @skills.key?(skill.identifier)
472
- next # Skip if already loaded from higher priority location
473
- end
474
-
475
- # Register skill
476
- @skills[skill.identifier] = skill
477
- @skills_by_command[skill.slash_command] = skill
478
- @loaded_from[skill.identifier] = :default
470
+ register_skill(skill, source: :default)
479
471
  rescue StandardError => e
480
472
  @errors << "Failed to load default skill #{skill_name}: #{e.message}"
481
473
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.9.32"
4
+ VERSION = "0.9.34"
5
5
  end