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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +18 -1
  3. data/.clackyrules +25 -1
  4. data/CHANGELOG.md +59 -0
  5. data/CONTRIBUTING.md +9 -0
  6. data/README.md +2 -2
  7. data/README_CN.md +1 -1
  8. data/README_JA.md +1 -1
  9. data/Rakefile +15 -0
  10. data/docs/design-emit-and-background-skills.md +253 -0
  11. data/docs/mcp-architecture.md +48 -9
  12. data/docs/mcp.example.json +9 -0
  13. data/docs/superpowers/plans/2026-08-09-chatcut-extension-bridge.md +191 -0
  14. data/docs/superpowers/plans/2026-08-09-native-mcp-oauth.md +243 -0
  15. data/docs/superpowers/specs/2026-08-09-mcp-oauth-extension-fallback-design.md +115 -0
  16. data/lib/clacky/agent/cost_tracker.rb +17 -2
  17. data/lib/clacky/agent/hook_manager.rb +14 -3
  18. data/lib/clacky/agent/memory_updater.rb +17 -18
  19. data/lib/clacky/agent/message_compressor_helper.rb +23 -0
  20. data/lib/clacky/agent/session_serializer.rb +136 -22
  21. data/lib/clacky/agent/skill_auto_creator.rb +1 -2
  22. data/lib/clacky/agent/skill_evolution.rb +11 -7
  23. data/lib/clacky/agent/skill_manager.rb +20 -18
  24. data/lib/clacky/agent/skill_reflector.rb +3 -6
  25. data/lib/clacky/agent.rb +113 -23
  26. data/lib/clacky/agent_config.rb +40 -18
  27. data/lib/clacky/anthropic_stream_aggregator.rb +6 -1
  28. data/lib/clacky/cli.rb +4 -1
  29. data/lib/clacky/client.rb +1 -1
  30. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +23 -10
  31. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -7
  32. data/lib/clacky/default_extensions/general/ext.yml +1 -1
  33. data/lib/clacky/extension/api_extension.rb +11 -3
  34. data/lib/clacky/extension/api_loader.rb +4 -11
  35. data/lib/clacky/extension/packager.rb +1 -1
  36. data/lib/clacky/extension/verifier.rb +1 -1
  37. data/lib/clacky/fanout.rb +100 -0
  38. data/lib/clacky/locales/en.rb +2 -0
  39. data/lib/clacky/locales/zh.rb +2 -0
  40. data/lib/clacky/mcp/cli_commands.rb +61 -0
  41. data/lib/clacky/mcp/client.rb +18 -2
  42. data/lib/clacky/mcp/connection_manager.rb +85 -0
  43. data/lib/clacky/mcp/http_transport.rb +25 -9
  44. data/lib/clacky/mcp/oauth/authorization_manager.rb +257 -0
  45. data/lib/clacky/mcp/oauth/config.rb +51 -0
  46. data/lib/clacky/mcp/oauth/credential_store.rb +82 -0
  47. data/lib/clacky/mcp/oauth/session.rb +88 -0
  48. data/lib/clacky/media/generator.rb +3 -3
  49. data/lib/clacky/message_format/anthropic.rb +27 -13
  50. data/lib/clacky/message_history.rb +33 -2
  51. data/lib/clacky/providers.rb +13 -6
  52. data/lib/clacky/server/http_server.rb +102 -98
  53. data/lib/clacky/server/session_registry.rb +11 -15
  54. data/lib/clacky/server/web_ui_controller.rb +1 -1
  55. data/lib/clacky/session_manager.rb +21 -12
  56. data/lib/clacky/shell_hook_loader.rb +14 -8
  57. data/lib/clacky/tools/invoke_skill.rb +3 -2
  58. data/lib/clacky/tools/terminal.rb +11 -4
  59. data/lib/clacky/ui2/components/inline_input.rb +1 -0
  60. data/lib/clacky/ui2/components/input_area.rb +1 -0
  61. data/lib/clacky/ui2/components/modal_component.rb +0 -1
  62. data/lib/clacky/ui2/components/todo_area.rb +1 -0
  63. data/lib/clacky/ui2/line_editor.rb +1 -0
  64. data/lib/clacky/ui2/markdown_renderer.rb +5 -1
  65. data/lib/clacky/ui2/progress_handle.rb +27 -3
  66. data/lib/clacky/ui2/ui_controller.rb +207 -6
  67. data/lib/clacky/ui_interface.rb +13 -3
  68. data/lib/clacky/utils/arguments_parser.rb +73 -4
  69. data/lib/clacky/utils/model_pricing.rb +21 -0
  70. data/lib/clacky/version.rb +1 -1
  71. data/lib/clacky/web/app.css +144 -213
  72. data/lib/clacky/web/components/chat-navigator.js +1 -1
  73. data/lib/clacky/web/features/billing/store.js +3 -3
  74. data/lib/clacky/web/features/billing/view.js +5 -61
  75. data/lib/clacky/web/features/extensions/store.js +1 -1
  76. data/lib/clacky/web/features/extensions/view.js +28 -5
  77. data/lib/clacky/web/features/new-session/view.js +43 -2
  78. data/lib/clacky/web/i18n.js +38 -22
  79. data/lib/clacky/web/index.html +19 -24
  80. data/lib/clacky/web/projects.js +96 -279
  81. data/lib/clacky/web/sessions.js +169 -119
  82. data/lib/clacky/web/settings.js +7 -1
  83. data/lib/clacky/web/ws-dispatcher.js +61 -34
  84. data/lib/clacky.rb +2 -0
  85. 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
- cron_count: stats[:count], latest_cron_updated_at: stats[:latest_updated_at] })
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
- body = parse_json_body(req) || {}
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.reject! { |m| m["type"] == kind }
1964
+ existing = @agent_config.models.find { |m| m["type"] == kind }
1948
1965
 
1949
1966
  case source
1950
1967
  when "off"
1951
- @agent_config.models << {
1952
- "id" => SecureRandom.uuid,
1953
- "type" => kind,
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" => SecureRandom.uuid,
1961
- "type" => kind,
1962
- "model" => override
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 model.empty? || base_url.empty? || api_key.empty? || api_key.include?("****")
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
- @agent_config.models << {
1974
- "id" => SecureRandom.uuid,
1975
- "model" => model,
1976
- "base_url" => base_url,
1977
- "api_key" => api_key,
1978
- "anthropic_format" => body["anthropic_format"] || false,
1979
- "type" => kind
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
- json_response(res, 200, { ok: true, state: @agent_config.media_state(kind) })
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
- body = parse_json_body(req) || {}
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
- # Soft-delete all sessions that belonged to this project.
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
- stats = @registry.cron_stats
6881
- page = @registry.list(limit: 11, exclude_type: "cron", exclude_project: true)
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, exclude_type: "cron")
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
- extra_project_sessions = project_sessions.reject { |s| paged_ids.include?(s[:id]) }
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
- cron_count: stats[:count], latest_cron_updated_at: stats[:latest_updated_at],
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(2)
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, hidden: false)
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, include_hidden: 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 all cron sessions on disk (not filtered by pagination).
416
- def cron_count
417
- return 0 unless @session_manager
418
- @session_manager.all_sessions.count { |s| s_source(s) == "cron" }
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
- # Count + latest updated_at for cron sessions (used by sidebar virtual entry).
422
- def cron_stats
423
- return { count: 0, latest_updated_at: nil } unless @session_manager
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, keep_cron: 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, non-hidden sessions by created_at;
334
- # the rest are soft-deleted (moved to the session trash, recoverable). Pinned
335
- # and hidden sessions are never deleted and do not count toward the cap.
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:, keep_cron: 200)
338
- non_pinned = all_sessions.reject { |s| s[:pinned] || s[:hidden] } # already sorted newest-first
339
-
340
- cron, regular = non_pinned.partition { |s| s[:source].to_s == "cron" }
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
- victims += regular[keep..] if regular.size > keep
344
- victims += cron[keep_cron..] if cron.size > keep_cron
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).to_i
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).to_i
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(2)
249
- err_reader.join(2)
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(1)
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(1)
261
- err_reader.join(1)
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).to_i
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 0.2
879
- drain_any(session, timeout: 2.5)
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 0.05
1309
+ sleep MARKER_POLL_SECONDS
1303
1310
  end
1304
1311
  end
1305
1312
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "tty-screen"
3
4
  require_relative "../line_editor"
4
5
 
5
6
  module Clacky
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pastel"
4
+ require "tty-screen"
4
5
  require "tempfile"
5
6
  require_relative "../theme_manager"
6
7
  require_relative "../line_editor"
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'io/console'
4
- require 'tty-prompt'
5
4
  require_relative 'base_component'
6
5
 
7
6
  module Clacky
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pastel"
4
+ require "tty-screen"
4
5
 
5
6
  module Clacky
6
7
  module UI2
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pastel"
4
+ require "tty-screen"
4
5
 
5
6
  module Clacky
6
7
  module UI2