openclacky 1.5.6 → 1.5.8
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/.clacky/skills/gem-release/SKILL.md +18 -1
- data/.clackyrules +25 -1
- data/CHANGELOG.md +59 -0
- data/CONTRIBUTING.md +9 -0
- data/README.md +2 -2
- data/README_CN.md +1 -1
- data/README_JA.md +1 -1
- data/Rakefile +15 -0
- data/docs/design-emit-and-background-skills.md +253 -0
- data/docs/mcp-architecture.md +48 -9
- data/docs/mcp.example.json +9 -0
- data/docs/superpowers/plans/2026-08-09-chatcut-extension-bridge.md +191 -0
- data/docs/superpowers/plans/2026-08-09-native-mcp-oauth.md +243 -0
- data/docs/superpowers/specs/2026-08-09-mcp-oauth-extension-fallback-design.md +115 -0
- data/lib/clacky/agent/cost_tracker.rb +17 -2
- data/lib/clacky/agent/hook_manager.rb +14 -3
- data/lib/clacky/agent/memory_updater.rb +17 -18
- data/lib/clacky/agent/message_compressor_helper.rb +23 -0
- data/lib/clacky/agent/session_serializer.rb +136 -22
- data/lib/clacky/agent/skill_auto_creator.rb +1 -2
- data/lib/clacky/agent/skill_evolution.rb +11 -7
- data/lib/clacky/agent/skill_manager.rb +20 -18
- data/lib/clacky/agent/skill_reflector.rb +3 -6
- data/lib/clacky/agent.rb +113 -23
- data/lib/clacky/agent_config.rb +40 -18
- data/lib/clacky/anthropic_stream_aggregator.rb +6 -1
- data/lib/clacky/cli.rb +4 -1
- data/lib/clacky/client.rb +1 -1
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +23 -10
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -7
- data/lib/clacky/default_extensions/general/ext.yml +1 -1
- data/lib/clacky/extension/api_extension.rb +11 -3
- data/lib/clacky/extension/api_loader.rb +4 -11
- data/lib/clacky/extension/packager.rb +1 -1
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/fanout.rb +100 -0
- data/lib/clacky/locales/en.rb +2 -0
- data/lib/clacky/locales/zh.rb +2 -0
- data/lib/clacky/mcp/cli_commands.rb +61 -0
- data/lib/clacky/mcp/client.rb +18 -2
- data/lib/clacky/mcp/connection_manager.rb +85 -0
- data/lib/clacky/mcp/http_transport.rb +25 -9
- data/lib/clacky/mcp/oauth/authorization_manager.rb +257 -0
- data/lib/clacky/mcp/oauth/config.rb +51 -0
- data/lib/clacky/mcp/oauth/credential_store.rb +82 -0
- data/lib/clacky/mcp/oauth/session.rb +88 -0
- data/lib/clacky/media/generator.rb +3 -3
- data/lib/clacky/message_format/anthropic.rb +27 -13
- data/lib/clacky/message_history.rb +33 -2
- data/lib/clacky/providers.rb +13 -6
- data/lib/clacky/server/http_server.rb +102 -98
- data/lib/clacky/server/session_registry.rb +11 -15
- data/lib/clacky/server/web_ui_controller.rb +1 -1
- data/lib/clacky/session_manager.rb +21 -12
- data/lib/clacky/shell_hook_loader.rb +14 -8
- data/lib/clacky/tools/invoke_skill.rb +3 -2
- data/lib/clacky/tools/terminal.rb +11 -4
- data/lib/clacky/ui2/components/inline_input.rb +1 -0
- data/lib/clacky/ui2/components/input_area.rb +1 -0
- data/lib/clacky/ui2/components/modal_component.rb +0 -1
- data/lib/clacky/ui2/components/todo_area.rb +1 -0
- data/lib/clacky/ui2/line_editor.rb +1 -0
- data/lib/clacky/ui2/markdown_renderer.rb +5 -1
- data/lib/clacky/ui2/progress_handle.rb +27 -3
- data/lib/clacky/ui2/ui_controller.rb +207 -6
- data/lib/clacky/ui_interface.rb +13 -3
- data/lib/clacky/utils/arguments_parser.rb +73 -4
- data/lib/clacky/utils/model_pricing.rb +21 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +144 -213
- data/lib/clacky/web/components/chat-navigator.js +1 -1
- data/lib/clacky/web/features/billing/store.js +3 -3
- data/lib/clacky/web/features/billing/view.js +5 -61
- data/lib/clacky/web/features/extensions/store.js +1 -1
- data/lib/clacky/web/features/extensions/view.js +28 -5
- data/lib/clacky/web/features/new-session/view.js +43 -2
- data/lib/clacky/web/i18n.js +38 -22
- data/lib/clacky/web/index.html +19 -24
- data/lib/clacky/web/projects.js +96 -279
- data/lib/clacky/web/sessions.js +169 -119
- data/lib/clacky/web/settings.js +7 -1
- data/lib/clacky/web/ws-dispatcher.js +61 -34
- data/lib/clacky.rb +2 -0
- metadata +12 -1
|
@@ -113,6 +113,12 @@ module Clacky
|
|
|
113
113
|
@events << { type: "subagent_end", session_id: @session_id }
|
|
114
114
|
end
|
|
115
115
|
|
|
116
|
+
# Custom extension events recorded on messages (Agent#emit_event). Must be
|
|
117
|
+
# explicit: method_missing below would otherwise swallow them.
|
|
118
|
+
def emit(type, **data)
|
|
119
|
+
@events << { type: type, session_id: @session_id }.merge(data)
|
|
120
|
+
end
|
|
121
|
+
|
|
116
122
|
# Ignore all other UI methods (progress, errors, etc.) during history replay
|
|
117
123
|
def method_missing(name, *args, **kwargs); end
|
|
118
124
|
def respond_to_missing?(name, include_private = false); true; end
|
|
@@ -126,6 +132,9 @@ module Clacky
|
|
|
126
132
|
# GET /** → static files served from lib/clacky/web/ directory
|
|
127
133
|
class HttpServer
|
|
128
134
|
WEB_ROOT = File.expand_path("../web", __dir__)
|
|
135
|
+
# How long shutdown waits for each agent thread to unwind before falling
|
|
136
|
+
# back to a manual session save.
|
|
137
|
+
AGENT_INTERRUPT_JOIN_SECONDS = 2
|
|
129
138
|
EXCHANGE_RATE_PRIMARY_BASE_URL = "https://open.er-api.com/v6/latest"
|
|
130
139
|
EXCHANGE_RATE_FALLBACK_URL = "https://api.frankfurter.app/latest"
|
|
131
140
|
OSS_CDN_BASE = "https://oss.1024code.com/openclacky"
|
|
@@ -175,7 +184,7 @@ module Clacky
|
|
|
175
184
|
- 将大目标拆解为可执行的小步骤
|
|
176
185
|
MD
|
|
177
186
|
|
|
178
|
-
def initialize(host: "127.0.0.1", port: 7070, agent_config:, client_factory:, brand_test: false, sessions_dir: nil, socket: nil, master_pid: nil)
|
|
187
|
+
def initialize(host: "127.0.0.1", port: 7070, agent_config:, client_factory:, brand_test: false, sessions_dir: nil, projects_file: nil, socket: nil, master_pid: nil)
|
|
179
188
|
@host = host
|
|
180
189
|
@port = port
|
|
181
190
|
@agent_config = agent_config
|
|
@@ -188,7 +197,7 @@ module Clacky
|
|
|
188
197
|
@restart_script = File.expand_path($0)
|
|
189
198
|
@restart_argv = ARGV.dup
|
|
190
199
|
@session_manager = Clacky::SessionManager.new(sessions_dir: sessions_dir)
|
|
191
|
-
@project_manager = Clacky::Server::ProjectManager.new
|
|
200
|
+
@project_manager = Clacky::Server::ProjectManager.new(projects_file: projects_file)
|
|
192
201
|
@registry = SessionRegistry.new(
|
|
193
202
|
session_manager: @session_manager,
|
|
194
203
|
session_restorer: method(:build_session_from_data),
|
|
@@ -791,6 +800,9 @@ module Clacky
|
|
|
791
800
|
elsif method == "PATCH" && path.match?(%r{^/api/projects/[^/]+$})
|
|
792
801
|
project_id = path.sub("/api/projects/", "")
|
|
793
802
|
api_update_project(project_id, req, res)
|
|
803
|
+
elsif method == "DELETE" && path.match?(%r{^/api/projects/[^/]+/sessions$})
|
|
804
|
+
project_id = path.sub("/api/projects/", "").sub("/sessions", "")
|
|
805
|
+
api_delete_project_sessions(project_id, res)
|
|
794
806
|
elsif method == "DELETE" && path.match?(%r{^/api/projects/[^/]+$})
|
|
795
807
|
project_id = path.sub("/api/projects/", "")
|
|
796
808
|
api_delete_project(project_id, res)
|
|
@@ -810,21 +822,20 @@ module Clacky
|
|
|
810
822
|
q_scope = query["q_scope"].to_s.strip.then { |v| %w[name content].include?(v) ? v : "name" }
|
|
811
823
|
date = query["date"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
812
824
|
type = query["type"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
813
|
-
exclude_type = query["exclude_type"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
825
|
+
exclude_type = query["exclude_type"].to_s.split(",").map(&:strip).reject(&:empty?).then { |v| v.empty? ? nil : v }
|
|
814
826
|
# Backward-compat: ?source=<x> and ?profile=coding → type
|
|
815
827
|
type ||= query["profile"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
816
828
|
type ||= query["source"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
817
829
|
|
|
818
|
-
sessions = @registry.list(limit: limit + 1, before: before, q: q, q_scope: q_scope, date: date, type: type, exclude_type: exclude_type)
|
|
830
|
+
sessions = @registry.list(limit: limit + 1, before: before, q: q, q_scope: q_scope, date: date, type: type, exclude_type: exclude_type, exclude_project: !!type)
|
|
819
831
|
|
|
820
832
|
pinned_part, non_pinned_part = sessions.partition { |s| s[:pinned] }
|
|
821
833
|
has_more = non_pinned_part.size > limit
|
|
822
834
|
non_pinned_part = non_pinned_part.first(limit)
|
|
823
835
|
sessions = pinned_part + non_pinned_part
|
|
824
836
|
|
|
825
|
-
stats = @registry.cron_stats
|
|
826
837
|
json_response(res, 200, { sessions: sessions, has_more: has_more,
|
|
827
|
-
|
|
838
|
+
groups: @registry.group_stats_all })
|
|
828
839
|
end
|
|
829
840
|
|
|
830
841
|
# GET /api/sessions/:id — fetch a single session by id (memory + disk merged).
|
|
@@ -1829,6 +1840,9 @@ module Clacky
|
|
|
1829
1840
|
requested_model: state["requested_model"],
|
|
1830
1841
|
configured: state["configured"]
|
|
1831
1842
|
}
|
|
1843
|
+
if entry && entry["base_url"] && state["source"] != "custom"
|
|
1844
|
+
out[t][:saved_custom] = { model: entry["model"], base_url: entry["base_url"] }
|
|
1845
|
+
end
|
|
1832
1846
|
end
|
|
1833
1847
|
|
|
1834
1848
|
# Surface what the current default model can offer, even when the
|
|
@@ -1938,50 +1952,78 @@ module Clacky
|
|
|
1938
1952
|
end
|
|
1939
1953
|
|
|
1940
1954
|
def api_update_media_config(kind, req, res)
|
|
1941
|
-
|
|
1955
|
+
update_sidecar_config(res, kind, parse_json_body(req) || {})
|
|
1956
|
+
end
|
|
1957
|
+
|
|
1958
|
+
private def update_sidecar_config(res, kind, body)
|
|
1942
1959
|
source = body["source"].to_s
|
|
1943
1960
|
unless %w[off auto custom].include?(source)
|
|
1944
1961
|
return json_response(res, 422, { error: "invalid source" })
|
|
1945
1962
|
end
|
|
1946
1963
|
|
|
1947
|
-
@agent_config.models.
|
|
1964
|
+
existing = @agent_config.models.find { |m| m["type"] == kind }
|
|
1948
1965
|
|
|
1949
1966
|
case source
|
|
1950
1967
|
when "off"
|
|
1951
|
-
|
|
1952
|
-
"
|
|
1953
|
-
|
|
1954
|
-
"disabled" => true
|
|
1955
|
-
}
|
|
1956
|
-
when "auto"
|
|
1957
|
-
override = body["model"].to_s.strip
|
|
1958
|
-
unless override.empty?
|
|
1968
|
+
if existing
|
|
1969
|
+
existing["mode"] = "off"
|
|
1970
|
+
else
|
|
1959
1971
|
@agent_config.models << {
|
|
1960
|
-
"id"
|
|
1961
|
-
"type"
|
|
1962
|
-
"
|
|
1972
|
+
"id" => SecureRandom.uuid,
|
|
1973
|
+
"type" => kind,
|
|
1974
|
+
"mode" => "off"
|
|
1963
1975
|
}
|
|
1964
1976
|
end
|
|
1977
|
+
when "auto"
|
|
1978
|
+
override = body["model"].to_s.strip
|
|
1979
|
+
if existing
|
|
1980
|
+
existing.delete("disabled")
|
|
1981
|
+
existing["mode"] = "auto"
|
|
1982
|
+
existing["model"] = override unless override.empty?
|
|
1983
|
+
else
|
|
1984
|
+
unless override.empty?
|
|
1985
|
+
@agent_config.models << {
|
|
1986
|
+
"id" => SecureRandom.uuid,
|
|
1987
|
+
"type" => kind,
|
|
1988
|
+
"mode" => "auto",
|
|
1989
|
+
"model" => override
|
|
1990
|
+
}
|
|
1991
|
+
end
|
|
1992
|
+
end
|
|
1965
1993
|
when "custom"
|
|
1966
1994
|
model = body["model"].to_s.strip
|
|
1967
1995
|
base_url = body["base_url"].to_s.strip
|
|
1968
1996
|
api_key = body["api_key"].to_s
|
|
1969
|
-
if
|
|
1997
|
+
if api_key.empty? || api_key.include?("****")
|
|
1998
|
+
api_key = existing ? existing["api_key"].to_s : ""
|
|
1999
|
+
end
|
|
2000
|
+
if model.empty? || base_url.empty? || api_key.empty?
|
|
1970
2001
|
return json_response(res, 422, { error: "model, base_url, api_key are required" })
|
|
1971
2002
|
end
|
|
1972
2003
|
|
|
1973
|
-
|
|
1974
|
-
"
|
|
1975
|
-
"
|
|
1976
|
-
"
|
|
1977
|
-
"
|
|
1978
|
-
"
|
|
1979
|
-
"
|
|
1980
|
-
|
|
2004
|
+
if existing
|
|
2005
|
+
existing["model"] = model
|
|
2006
|
+
existing["base_url"] = base_url
|
|
2007
|
+
existing["api_key"] = api_key
|
|
2008
|
+
existing["anthropic_format"] = body["anthropic_format"] || false
|
|
2009
|
+
existing["mode"] = "custom"
|
|
2010
|
+
existing.delete("disabled")
|
|
2011
|
+
else
|
|
2012
|
+
@agent_config.models << {
|
|
2013
|
+
"id" => SecureRandom.uuid,
|
|
2014
|
+
"model" => model,
|
|
2015
|
+
"base_url" => base_url,
|
|
2016
|
+
"api_key" => api_key,
|
|
2017
|
+
"anthropic_format" => body["anthropic_format"] || false,
|
|
2018
|
+
"type" => kind,
|
|
2019
|
+
"mode" => "custom"
|
|
2020
|
+
}
|
|
2021
|
+
end
|
|
1981
2022
|
end
|
|
1982
2023
|
|
|
1983
2024
|
@agent_config.save
|
|
1984
|
-
|
|
2025
|
+
state = kind == "ocr" ? @agent_config.ocr_state : @agent_config.media_state(kind)
|
|
2026
|
+
json_response(res, 200, { ok: true, state: state })
|
|
1985
2027
|
rescue => e
|
|
1986
2028
|
json_response(res, 422, { error: e.message })
|
|
1987
2029
|
end
|
|
@@ -2005,6 +2047,9 @@ module Clacky
|
|
|
2005
2047
|
configured: state["configured"],
|
|
2006
2048
|
primary: state["primary"] || false
|
|
2007
2049
|
}
|
|
2050
|
+
if entry && entry["base_url"] && state["source"] != "custom"
|
|
2051
|
+
out[:saved_custom] = { model: entry["model"], base_url: entry["base_url"] }
|
|
2052
|
+
end
|
|
2008
2053
|
|
|
2009
2054
|
# Auto-mode preview: surface what the OCR sidecar *would* be if the
|
|
2010
2055
|
# user flipped to "auto" — derived from the same provider as the
|
|
@@ -2026,58 +2071,8 @@ module Clacky
|
|
|
2026
2071
|
# PATCH /api/config/ocr
|
|
2027
2072
|
# Body: { source: "off"|"auto"|"custom", model?, base_url?, api_key?,
|
|
2028
2073
|
# anthropic_format? }
|
|
2029
|
-
# Mirrors api_update_media_config but for the single "ocr" type.
|
|
2030
2074
|
def api_update_ocr_config(req, res)
|
|
2031
|
-
|
|
2032
|
-
source = body["source"].to_s
|
|
2033
|
-
unless %w[off auto custom].include?(source)
|
|
2034
|
-
return json_response(res, 422, { error: "invalid source" })
|
|
2035
|
-
end
|
|
2036
|
-
|
|
2037
|
-
@agent_config.models.reject! { |m| m["type"] == "ocr" }
|
|
2038
|
-
|
|
2039
|
-
case source
|
|
2040
|
-
when "off"
|
|
2041
|
-
@agent_config.models << {
|
|
2042
|
-
"id" => SecureRandom.uuid,
|
|
2043
|
-
"type" => "ocr",
|
|
2044
|
-
"disabled" => true
|
|
2045
|
-
}
|
|
2046
|
-
when "auto"
|
|
2047
|
-
override = body["model"].to_s.strip
|
|
2048
|
-
unless override.empty?
|
|
2049
|
-
@agent_config.models << {
|
|
2050
|
-
"id" => SecureRandom.uuid,
|
|
2051
|
-
"type" => "ocr",
|
|
2052
|
-
"model" => override
|
|
2053
|
-
}
|
|
2054
|
-
end
|
|
2055
|
-
when "custom"
|
|
2056
|
-
model = body["model"].to_s.strip
|
|
2057
|
-
base_url = body["base_url"].to_s.strip
|
|
2058
|
-
api_key = body["api_key"].to_s
|
|
2059
|
-
if api_key.include?("****")
|
|
2060
|
-
existing = @agent_config.models.find { |m| m["type"] == "ocr" && m["api_key"] }
|
|
2061
|
-
api_key = existing ? existing["api_key"].to_s : ""
|
|
2062
|
-
end
|
|
2063
|
-
if model.empty? || base_url.empty? || api_key.empty?
|
|
2064
|
-
return json_response(res, 422, { error: "model, base_url, api_key are required" })
|
|
2065
|
-
end
|
|
2066
|
-
|
|
2067
|
-
@agent_config.models << {
|
|
2068
|
-
"id" => SecureRandom.uuid,
|
|
2069
|
-
"model" => model,
|
|
2070
|
-
"base_url" => base_url,
|
|
2071
|
-
"api_key" => api_key,
|
|
2072
|
-
"anthropic_format" => body["anthropic_format"] || false,
|
|
2073
|
-
"type" => "ocr"
|
|
2074
|
-
}
|
|
2075
|
-
end
|
|
2076
|
-
|
|
2077
|
-
@agent_config.save
|
|
2078
|
-
json_response(res, 200, { ok: true, state: @agent_config.ocr_state })
|
|
2079
|
-
rescue => e
|
|
2080
|
-
json_response(res, 422, { error: e.message })
|
|
2075
|
+
update_sidecar_config(res, "ocr", parse_json_body(req) || {})
|
|
2081
2076
|
end
|
|
2082
2077
|
|
|
2083
2078
|
# POST /api/config/ocr/test
|
|
@@ -6351,8 +6346,25 @@ module Clacky
|
|
|
6351
6346
|
return json_response(res, 404, { error: "Project not found" }) unless @project_manager.find(project_id)
|
|
6352
6347
|
|
|
6353
6348
|
@project_manager.delete(project_id)
|
|
6349
|
+
_delete_project_sessions(project_id)
|
|
6350
|
+
|
|
6351
|
+
json_response(res, 200, { ok: true })
|
|
6352
|
+
rescue => e
|
|
6353
|
+
json_response(res, 500, { error: e.message })
|
|
6354
|
+
end
|
|
6355
|
+
|
|
6356
|
+
# DELETE /api/projects/:id/sessions - delete all sessions in a project but keep the project
|
|
6357
|
+
def api_delete_project_sessions(project_id, res)
|
|
6358
|
+
return json_response(res, 404, { error: "Project not found" }) unless @project_manager.find(project_id)
|
|
6354
6359
|
|
|
6355
|
-
|
|
6360
|
+
deleted = _delete_project_sessions(project_id)
|
|
6361
|
+
json_response(res, 200, { ok: true, deleted: deleted })
|
|
6362
|
+
rescue => e
|
|
6363
|
+
json_response(res, 500, { error: e.message })
|
|
6364
|
+
end
|
|
6365
|
+
|
|
6366
|
+
private def _delete_project_sessions(project_id)
|
|
6367
|
+
deleted = 0
|
|
6356
6368
|
@session_manager.all_sessions.each do |session_data|
|
|
6357
6369
|
next unless session_data[:project_id].to_s == project_id
|
|
6358
6370
|
|
|
@@ -6365,11 +6377,9 @@ module Clacky
|
|
|
6365
6377
|
|
|
6366
6378
|
broadcast(sid, { type: "session_deleted", session_id: sid })
|
|
6367
6379
|
unsubscribe_all(sid)
|
|
6380
|
+
deleted += 1
|
|
6368
6381
|
end
|
|
6369
|
-
|
|
6370
|
-
json_response(res, 200, { ok: true })
|
|
6371
|
-
rescue => e
|
|
6372
|
-
json_response(res, 500, { error: e.message })
|
|
6382
|
+
deleted
|
|
6373
6383
|
end
|
|
6374
6384
|
|
|
6375
6385
|
# PATCH /api/sessions/:id/project — move a session into or out of a project
|
|
@@ -6877,28 +6887,23 @@ module Clacky
|
|
|
6877
6887
|
interrupt_session(session_id)
|
|
6878
6888
|
|
|
6879
6889
|
when "list_sessions"
|
|
6880
|
-
|
|
6881
|
-
page = @registry.list(limit: 11, exclude_type:
|
|
6890
|
+
groups = @registry.group_stats_all
|
|
6891
|
+
page = @registry.list(limit: 11, exclude_type: SessionManager::GROUPED_SOURCES, exclude_project: true)
|
|
6882
6892
|
has_more = page.size > 10
|
|
6883
6893
|
all_sessions = page.first(10)
|
|
6884
6894
|
projects = @project_manager.all
|
|
6885
|
-
# Include ALL sessions that belong to any project, regardless of the
|
|
6886
|
-
# 15-item pagination limit. We merge them into the same `sessions`
|
|
6887
|
-
# array; the client deduplicates by id in `setAll`.
|
|
6888
6895
|
if projects.any?
|
|
6889
6896
|
project_ids = projects.map { |p| p[:id] }
|
|
6890
6897
|
project_sessions = project_ids.flat_map do |pid|
|
|
6891
|
-
@registry.list(project_id: pid
|
|
6898
|
+
@registry.list(project_id: pid)
|
|
6892
6899
|
end
|
|
6893
6900
|
# Deduplicate: project sessions that are already in the first page
|
|
6894
6901
|
# will be replaced by the enriched version from `all_sessions`.
|
|
6895
6902
|
paged_ids = all_sessions.map { |s| s[:id] }.to_set
|
|
6896
|
-
|
|
6897
|
-
all_sessions = all_sessions + extra_project_sessions
|
|
6903
|
+
all_sessions += project_sessions.reject { |s| paged_ids.include?(s[:id]) }
|
|
6898
6904
|
end
|
|
6899
6905
|
conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more,
|
|
6900
|
-
|
|
6901
|
-
projects: projects)
|
|
6906
|
+
groups: groups, projects: projects)
|
|
6902
6907
|
|
|
6903
6908
|
when "run_task"
|
|
6904
6909
|
# Client sends this after subscribing to guarantee it's ready to receive
|
|
@@ -7094,7 +7099,7 @@ module Clacky
|
|
|
7094
7099
|
rescue => e
|
|
7095
7100
|
Clacky::Logger.error("[shutdown] interrupt failed for session=#{id}: #{e.message}")
|
|
7096
7101
|
end
|
|
7097
|
-
thread.join(
|
|
7102
|
+
thread.join(AGENT_INTERRUPT_JOIN_SECONDS)
|
|
7098
7103
|
@session_manager.save(agent.to_session_data(status: :interrupted, updated_at: Time.now))
|
|
7099
7104
|
end
|
|
7100
7105
|
end
|
|
@@ -7279,7 +7284,7 @@ module Clacky
|
|
|
7279
7284
|
# @param working_dir [String] working directory for the agent
|
|
7280
7285
|
# @param permission_mode [Symbol] :confirm_all (default, human present) or
|
|
7281
7286
|
# :auto_approve (unattended — suppresses request_user_feedback waits)
|
|
7282
|
-
def build_session(name:, working_dir: nil, permission_mode: :confirm_all, profile: "general", source: :manual, model_id: nil
|
|
7287
|
+
def build_session(name:, working_dir: nil, permission_mode: :confirm_all, profile: "general", source: :manual, model_id: nil)
|
|
7283
7288
|
working_dir ||= default_working_dir
|
|
7284
7289
|
FileUtils.mkdir_p(working_dir) unless Dir.exist?(working_dir)
|
|
7285
7290
|
session_id = Clacky::SessionManager.generate_id
|
|
@@ -7318,7 +7323,6 @@ module Clacky
|
|
|
7318
7323
|
agent = Clacky::Agent.new(client, config, working_dir: working_dir, ui: ui, profile: profile,
|
|
7319
7324
|
session_id: session_id, source: source)
|
|
7320
7325
|
agent.rename(name) unless name.nil? || name.empty?
|
|
7321
|
-
agent.hidden = hidden
|
|
7322
7326
|
idle_timer = build_idle_timer(session_id, agent)
|
|
7323
7327
|
|
|
7324
7328
|
@registry.with_session(session_id) do |s|
|
|
@@ -203,7 +203,7 @@ module Clacky
|
|
|
203
203
|
# [ ...all_pinned_matching (newest-first), ...non_pinned (newest-first, limited) ]
|
|
204
204
|
#
|
|
205
205
|
# source and profile are orthogonal — either can be nil independently.
|
|
206
|
-
def list(limit: nil, before: nil, q: nil, q_scope: "name", date: nil, type: nil, exclude_type: nil, include_pinned: true, project_id: nil, exclude_project: false
|
|
206
|
+
def list(limit: nil, before: nil, q: nil, q_scope: "name", date: nil, type: nil, exclude_type: nil, include_pinned: true, project_id: nil, exclude_project: false)
|
|
207
207
|
return [] unless @session_manager
|
|
208
208
|
|
|
209
209
|
live = @mutex.synchronize do
|
|
@@ -228,10 +228,6 @@ module Clacky
|
|
|
228
228
|
|
|
229
229
|
all = @session_manager.all_sessions # already sorted newest-first
|
|
230
230
|
|
|
231
|
-
# Hidden sessions (extension-managed) are excluded from the list by
|
|
232
|
-
# default; callers that need them pass include_hidden: true.
|
|
233
|
-
all = all.reject { |s| s[:hidden] } unless include_hidden
|
|
234
|
-
|
|
235
231
|
# ── type filter (replaces old source/profile split) ──────────────────
|
|
236
232
|
# type=coding → agent_profile == "coding"
|
|
237
233
|
# type=manual/cron/channel/setup → source match (profile=general implied)
|
|
@@ -412,18 +408,18 @@ module Clacky
|
|
|
412
408
|
|
|
413
409
|
public
|
|
414
410
|
|
|
415
|
-
# Count
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
411
|
+
# Count + latest updated_at for sessions of a given source, excluding
|
|
412
|
+
# those assigned to a project (they show in the project section instead).
|
|
413
|
+
def group_stats(source)
|
|
414
|
+
return { count: 0, latest_updated_at: nil } unless @session_manager
|
|
415
|
+
group_all = @session_manager.all_sessions.select { |s| s_source(s) == source && s[:project_id].to_s.strip == "" }
|
|
416
|
+
latest = group_all.map { |s| s[:updated_at] || s[:created_at] }.compact.max
|
|
417
|
+
{ count: group_all.size, latest_updated_at: latest }
|
|
419
418
|
end
|
|
420
419
|
|
|
421
|
-
#
|
|
422
|
-
def
|
|
423
|
-
|
|
424
|
-
cron_all = @session_manager.all_sessions.select { |s| s_source(s) == "cron" }
|
|
425
|
-
latest = cron_all.map { |s| s[:updated_at] || s[:created_at] }.compact.max
|
|
426
|
-
{ count: cron_all.size, latest_updated_at: latest }
|
|
420
|
+
# Stats for every folded sidebar group, keyed by source.
|
|
421
|
+
def group_stats_all
|
|
422
|
+
SessionManager::GROUPED_SOURCES.to_h { |source| [source, group_stats(source)] }
|
|
427
423
|
end
|
|
428
424
|
|
|
429
425
|
# Delete a session from registry (and interrupt its thread).
|
|
@@ -221,7 +221,7 @@ module Clacky
|
|
|
221
221
|
|
|
222
222
|
# === Phase grouping ===
|
|
223
223
|
|
|
224
|
-
def phase_start(kind:, label: nil)
|
|
224
|
+
def phase_start(kind:, label: nil, concurrent: false)
|
|
225
225
|
pid = SecureRandom.uuid
|
|
226
226
|
Thread.current[:clacky_phase_id] = pid
|
|
227
227
|
# Emit without auto-injection (the start event itself defines the phase)
|
|
@@ -9,6 +9,7 @@ require "set"
|
|
|
9
9
|
module Clacky
|
|
10
10
|
class SessionManager
|
|
11
11
|
SESSIONS_DIR = File.join(Dir.home, ".clacky", "sessions")
|
|
12
|
+
GROUPED_SOURCES = %w[cron ext].freeze
|
|
12
13
|
|
|
13
14
|
# Generate a new unique session ID (16-char hex string).
|
|
14
15
|
# This is the single authoritative source for session IDs — all components
|
|
@@ -35,7 +36,7 @@ module Clacky
|
|
|
35
36
|
|
|
36
37
|
# Keep only the most recent 200 sessions (best-effort, never block save)
|
|
37
38
|
begin
|
|
38
|
-
cleanup_by_count(keep: 200,
|
|
39
|
+
cleanup_by_count(keep: 200, grouped_keep: 200)
|
|
39
40
|
cleanup_trash(days: 8)
|
|
40
41
|
rescue Exception # rubocop:disable Lint/RescueException
|
|
41
42
|
# Cleanup is non-critical; swallow all errors (including AgentInterrupted)
|
|
@@ -65,7 +66,6 @@ module Clacky
|
|
|
65
66
|
forked[:created_at] = Time.now.iso8601
|
|
66
67
|
forked[:updated_at] = Time.now.iso8601
|
|
67
68
|
forked[:pinned] = false
|
|
68
|
-
forked[:hidden] = false
|
|
69
69
|
forked[:name] = "#{original[:name] || "Unnamed session"} (copy)"
|
|
70
70
|
forked[:stats] = (original[:stats] || {}).merge(
|
|
71
71
|
total_tasks: 0, total_iterations: 0, total_cost_usd: 0.0,
|
|
@@ -330,18 +330,27 @@ module Clacky
|
|
|
330
330
|
deleted
|
|
331
331
|
end
|
|
332
332
|
|
|
333
|
-
# Keep only the most recent N non-pinned
|
|
334
|
-
#
|
|
335
|
-
#
|
|
333
|
+
# Keep only the most recent N non-pinned sessions by created_at; the rest
|
|
334
|
+
# are soft-deleted (moved to the session trash, recoverable). Pinned
|
|
335
|
+
# sessions are never deleted and do not count toward the cap.
|
|
336
336
|
# Returns count of soft-deleted sessions.
|
|
337
|
-
def cleanup_by_count(keep:,
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
337
|
+
def cleanup_by_count(keep:, grouped_keep: 200)
|
|
338
|
+
# Fast path: if total session files can't exceed any single group's cap,
|
|
339
|
+
# skip the expensive all_sessions() full-scan (read + JSON.parse every file).
|
|
340
|
+
# min() is correct because groups are mutually exclusive — any single
|
|
341
|
+
# group's count ≤ non_pinned ≤ file_count ≤ min(keep, grouped_keep).
|
|
342
|
+
file_count = Dir.glob(File.join(@sessions_dir, "*.json")).size
|
|
343
|
+
return 0 if file_count <= [keep, grouped_keep].min
|
|
344
|
+
|
|
345
|
+
non_pinned = all_sessions.reject { |s| s[:pinned] }
|
|
346
|
+
|
|
347
|
+
groups = non_pinned.group_by do |s|
|
|
348
|
+
(s[:project_id] || !GROUPED_SOURCES.include?(s[:source].to_s)) ? "regular" : s[:source].to_s
|
|
349
|
+
end
|
|
341
350
|
|
|
342
|
-
victims =
|
|
343
|
-
|
|
344
|
-
|
|
351
|
+
victims = groups.flat_map do |source, sessions|
|
|
352
|
+
sessions[(source == "regular" ? keep : grouped_keep)..] || []
|
|
353
|
+
end
|
|
345
354
|
|
|
346
355
|
victims.each { |session| soft_delete(session[:session_id]) }
|
|
347
356
|
victims.size
|
|
@@ -48,6 +48,10 @@ module Clacky
|
|
|
48
48
|
DEFAULT_TIMEOUT = 10
|
|
49
49
|
REWRITE_DEFAULT_TIMEOUT = 60
|
|
50
50
|
DENY_EXIT_CODE = 2
|
|
51
|
+
# Grace period for a SIGTERM'd hook to exit before the group is SIGKILL'd,
|
|
52
|
+
# and for the stream readers to hit EOF afterwards.
|
|
53
|
+
KILL_GRACE_SECONDS = 1
|
|
54
|
+
DRAIN_JOIN_SECONDS = 2
|
|
51
55
|
|
|
52
56
|
Result = Struct.new(:registered, :skipped, keyword_init: true)
|
|
53
57
|
|
|
@@ -165,15 +169,17 @@ module Clacky
|
|
|
165
169
|
resolved = resolve_command(spec, result)
|
|
166
170
|
return unless resolved
|
|
167
171
|
name, command = resolved
|
|
168
|
-
timeout = (spec["timeout"] || DEFAULT_TIMEOUT).
|
|
172
|
+
timeout = (spec["timeout"] || DEFAULT_TIMEOUT).to_f
|
|
169
173
|
|
|
170
174
|
unless HookManager::HOOK_EVENTS.include?(event)
|
|
171
175
|
result.skipped << [name, "unknown event: #{event}"]
|
|
172
176
|
return
|
|
173
177
|
end
|
|
174
178
|
|
|
179
|
+
# HookManager appends the owning agent as a trailing arg; shell hooks
|
|
180
|
+
# serialize their args to JSON, so drop it before building the payload.
|
|
175
181
|
hook_manager.add(event) do |*args|
|
|
176
|
-
run_command(event, command, timeout, args)
|
|
182
|
+
run_command(event, command, timeout, args[0..-2])
|
|
177
183
|
end
|
|
178
184
|
result.registered << [event, name]
|
|
179
185
|
end
|
|
@@ -183,7 +189,7 @@ module Clacky
|
|
|
183
189
|
resolved = resolve_command(spec, result)
|
|
184
190
|
return unless resolved
|
|
185
191
|
name, command = resolved
|
|
186
|
-
timeout = (spec["timeout"] || REWRITE_DEFAULT_TIMEOUT).
|
|
192
|
+
timeout = (spec["timeout"] || REWRITE_DEFAULT_TIMEOUT).to_f
|
|
187
193
|
matcher = spec["matcher"]
|
|
188
194
|
|
|
189
195
|
hook_manager.add(:before_tool_use) do |call|
|
|
@@ -245,20 +251,20 @@ module Clacky
|
|
|
245
251
|
status = wait_thr.value
|
|
246
252
|
# Child exited; drain the readers (bounded join — a grandchild
|
|
247
253
|
# holding the pipe fd can keep them from EOF; see drain_stream).
|
|
248
|
-
out_reader.join(
|
|
249
|
-
err_reader.join(
|
|
254
|
+
out_reader.join(DRAIN_JOIN_SECONDS)
|
|
255
|
+
err_reader.join(DRAIN_JOIN_SECONDS)
|
|
250
256
|
else
|
|
251
257
|
# SIGTERM, then SIGKILL the whole group if it won't die. popen3's
|
|
252
258
|
# ensure joins wait_thr without a timeout, so the child MUST be dead
|
|
253
259
|
# before we leave the block — a hook that traps/ignores TERM can't be
|
|
254
260
|
# allowed to hang here.
|
|
255
261
|
Process.kill("TERM", -pgid) rescue nil
|
|
256
|
-
unless wait_thr.join(
|
|
262
|
+
unless wait_thr.join(KILL_GRACE_SECONDS)
|
|
257
263
|
Process.kill("KILL", -pgid) rescue nil
|
|
258
264
|
wait_thr.join
|
|
259
265
|
end
|
|
260
|
-
out_reader.join(
|
|
261
|
-
err_reader.join(
|
|
266
|
+
out_reader.join(KILL_GRACE_SECONDS)
|
|
267
|
+
err_reader.join(KILL_GRACE_SECONDS)
|
|
262
268
|
writer.kill rescue nil
|
|
263
269
|
raise Timeout::Error
|
|
264
270
|
end
|
|
@@ -28,8 +28,9 @@ module Clacky
|
|
|
28
28
|
# @param task [String] Task description to pass to the skill
|
|
29
29
|
# @param agent [Clacky::Agent] Agent instance (injected)
|
|
30
30
|
# @param skill_loader [Clacky::SkillLoader] Skill loader instance (injected)
|
|
31
|
+
# @param tool_call_id [String, nil] This call's id (injected), used to key the subagent transcript
|
|
31
32
|
# @return [Hash] Result of skill execution
|
|
32
|
-
def execute(skill_name:, task:, agent: nil, skill_loader: nil, working_dir: nil)
|
|
33
|
+
def execute(skill_name:, task:, agent: nil, skill_loader: nil, working_dir: nil, tool_call_id: nil)
|
|
33
34
|
# Validate injected dependencies
|
|
34
35
|
return { error: "Agent context is required" } unless agent
|
|
35
36
|
return { error: "Skill loader is required" } unless skill_loader
|
|
@@ -44,7 +45,7 @@ module Clacky
|
|
|
44
45
|
# have triggered this skill explicitly via a slash command (/skill-name).
|
|
45
46
|
if skill.fork_agent?
|
|
46
47
|
# Execute in isolated subagent
|
|
47
|
-
result = agent.send(:execute_skill_with_subagent, skill, task)
|
|
48
|
+
result = agent.send(:execute_skill_with_subagent, skill, task, tool_call_id: tool_call_id)
|
|
48
49
|
{
|
|
49
50
|
message: "Skill '#{skill_name}' executed in subagent",
|
|
50
51
|
result: result,
|
|
@@ -132,6 +132,13 @@ module Clacky
|
|
|
132
132
|
BACKGROUND_COLLECT_SECONDS = 2
|
|
133
133
|
# Sentinel: when passed as idle_ms, disables idle early-return.
|
|
134
134
|
DISABLED_IDLE_MS = 10_000_000
|
|
135
|
+
# Gap between log-file polls while waiting for a command's end marker.
|
|
136
|
+
MARKER_POLL_SECONDS = 0.05
|
|
137
|
+
# A freshly spawned login shell prints its banner asynchronously; wait
|
|
138
|
+
# this long before draining so the marker install doesn't interleave.
|
|
139
|
+
SHELL_BANNER_SETTLE_SECONDS = 0.2
|
|
140
|
+
# Upper bound on draining that banner once it starts arriving.
|
|
141
|
+
SHELL_BANNER_DRAIN_SECONDS = 2.5
|
|
135
142
|
|
|
136
143
|
# Commands that we know take a long time and produce bursty output
|
|
137
144
|
# (quiet gaps between test files, compile phases, download batches,
|
|
@@ -213,7 +220,7 @@ module Clacky
|
|
|
213
220
|
idle_ms ||= DISABLED_IDLE_MS
|
|
214
221
|
end
|
|
215
222
|
|
|
216
|
-
timeout = (timeout || DEFAULT_TIMEOUT).
|
|
223
|
+
timeout = (timeout || DEFAULT_TIMEOUT).to_f
|
|
217
224
|
idle_ms = (idle_ms || DEFAULT_IDLE_MS).to_i
|
|
218
225
|
cwd ||= working_dir
|
|
219
226
|
|
|
@@ -875,8 +882,8 @@ module Clacky
|
|
|
875
882
|
# Give the shell a moment to print its startup banner (zsh -l -i
|
|
876
883
|
# loads a lot of stuff), then drain whatever noise it wrote so the
|
|
877
884
|
# marker install doesn't collide with it.
|
|
878
|
-
sleep
|
|
879
|
-
drain_any(session, timeout:
|
|
885
|
+
sleep SHELL_BANNER_SETTLE_SECONDS
|
|
886
|
+
drain_any(session, timeout: SHELL_BANNER_DRAIN_SECONDS)
|
|
880
887
|
install_marker(session)
|
|
881
888
|
_before, _code, state = read_until_marker(session, timeout: 10, idle_ms: DISABLED_IDLE_MS)
|
|
882
889
|
unless state == :matched
|
|
@@ -1299,7 +1306,7 @@ module Clacky
|
|
|
1299
1306
|
flush_stream.call("", force_partial: true) unless stream_pending.empty?
|
|
1300
1307
|
return ["", nil, :timeout]
|
|
1301
1308
|
end
|
|
1302
|
-
sleep
|
|
1309
|
+
sleep MARKER_POLL_SECONDS
|
|
1303
1310
|
end
|
|
1304
1311
|
end
|
|
1305
1312
|
|