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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/lib/clacky/agent/llm_caller.rb +11 -12
- data/lib/clacky/agent/skill_auto_creator.rb +16 -21
- data/lib/clacky/agent/skill_manager.rb +18 -21
- data/lib/clacky/agent/skill_reflector.rb +16 -24
- data/lib/clacky/agent/system_prompt_builder.rb +5 -0
- data/lib/clacky/agent.rb +45 -19
- data/lib/clacky/client.rb +47 -16
- data/lib/clacky/server/http_server.rb +116 -12
- data/lib/clacky/server/session_registry.rb +7 -0
- data/lib/clacky/server/web_ui_controller.rb +6 -0
- data/lib/clacky/skill.rb +5 -0
- data/lib/clacky/skill_loader.rb +2 -10
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +383 -124
- data/lib/clacky/web/app.js +233 -115
- data/lib/clacky/web/i18n.js +42 -0
- data/lib/clacky/web/index.html +86 -32
- data/lib/clacky/web/sessions.js +349 -30
- data/lib/clacky/web/settings.js +76 -2
- metadata +1 -1
|
@@ -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
|
-
#
|
|
471
|
-
|
|
472
|
-
|
|
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"]
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
1823
|
-
|
|
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>]
|
data/lib/clacky/skill_loader.rb
CHANGED
|
@@ -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
|
data/lib/clacky/version.rb
CHANGED