openclacky 1.5.6 → 1.5.7
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 +26 -0
- data/CONTRIBUTING.md +9 -0
- 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 +111 -20
- data/lib/clacky/anthropic_stream_aggregator.rb +6 -1
- data/lib/clacky/cli.rb +4 -0
- data/lib/clacky/client.rb +1 -1
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +13 -5
- data/lib/clacky/extension/api_extension.rb +11 -3
- 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/message_format/anthropic.rb +27 -13
- data/lib/clacky/message_history.rb +32 -1
- data/lib/clacky/providers.rb +11 -5
- data/lib/clacky/server/http_server.rb +42 -22
- 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 +13 -11
- 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/progress_handle.rb +27 -3
- data/lib/clacky/ui2/ui_controller.rb +206 -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 +10 -3
- data/lib/clacky/web/features/new-session/view.js +19 -2
- data/lib/clacky/web/i18n.js +22 -8
- data/lib/clacky/web/index.html +7 -11
- data/lib/clacky/web/projects.js +26 -0
- data/lib/clacky/web/sessions.js +169 -119
- data/lib/clacky/web/settings.js +1 -0
- data/lib/clacky/web/ws-dispatcher.js +61 -34
- data/lib/clacky.rb +2 -0
- metadata +12 -1
data/lib/clacky/providers.rb
CHANGED
|
@@ -43,6 +43,7 @@ module Clacky
|
|
|
43
43
|
"dsk-deepseek-v4-pro",
|
|
44
44
|
"dsk-deepseek-v4-flash",
|
|
45
45
|
"or-gemini-3-1-pro",
|
|
46
|
+
"or-gemini-3-6-flash",
|
|
46
47
|
"or-gemini-3-5-flash"
|
|
47
48
|
],
|
|
48
49
|
# Image generation models served by the openclacky platform
|
|
@@ -94,30 +95,34 @@ module Clacky
|
|
|
94
95
|
# routes them to Vertex AI Gemini (generateContent with inline
|
|
95
96
|
# audio parts). The gateway returns transcription text.
|
|
96
97
|
"stt_models" => [
|
|
98
|
+
"or-stt-gemini-3-6-flash",
|
|
97
99
|
"or-stt-gemini-3-5-flash",
|
|
98
100
|
"or-stt-gemini-1-5-pro"
|
|
99
101
|
],
|
|
100
102
|
"stt_model_aliases" => {
|
|
103
|
+
"or-stt-gemini-3-6-flash" => "Gemini 3.6 Flash STT",
|
|
101
104
|
"or-stt-gemini-3-5-flash" => "Gemini 3.5 Flash STT",
|
|
102
105
|
"or-stt-gemini-1-5-pro" => "Gemini 1.5 Pro STT"
|
|
103
106
|
},
|
|
104
|
-
"default_stt_model" => "or-stt-gemini-3-
|
|
107
|
+
"default_stt_model" => "or-stt-gemini-3-6-flash",
|
|
105
108
|
# Video understanding models served by the openclacky gateway, which
|
|
106
109
|
# routes video frames to Gemini (generateContent with inline image
|
|
107
110
|
# parts). The gateway returns analysis text.
|
|
108
111
|
"video_understanding_models" => [
|
|
112
|
+
"or-gemini-3-6-flash",
|
|
109
113
|
"or-gemini-3-5-flash",
|
|
110
114
|
"or-gemini-3-1-pro"
|
|
111
115
|
],
|
|
112
116
|
"video_understanding_model_aliases" => {
|
|
117
|
+
"or-gemini-3-6-flash" => "Gemini 3.6 Flash",
|
|
113
118
|
"or-gemini-3-5-flash" => "Gemini 3.5 Flash",
|
|
114
119
|
"or-gemini-3-1-pro" => "Gemini 3.1 Pro"
|
|
115
120
|
},
|
|
116
|
-
"default_video_understanding_model" => "or-gemini-3-
|
|
121
|
+
"default_video_understanding_model" => "or-gemini-3-6-flash",
|
|
117
122
|
# Default OCR sidecar — used when the primary model is text-only.
|
|
118
123
|
# Candidates are derived from the provider's vision-capable models;
|
|
119
124
|
# this just picks the cheap+fast default to surface in "auto" mode.
|
|
120
|
-
"default_ocr_model" => "or-gemini-3-
|
|
125
|
+
"default_ocr_model" => "or-gemini-3-6-flash",
|
|
121
126
|
# Provider-level default: the Claude family served here is vision-capable.
|
|
122
127
|
"capabilities" => { "vision" => true }.freeze,
|
|
123
128
|
# Model-level overrides: DeepSeek models routed through this provider
|
|
@@ -144,7 +149,7 @@ module Clacky
|
|
|
144
149
|
"abs-claude-sonnet-4-6" => "abs-claude-haiku-4-5",
|
|
145
150
|
"abs-claude-sonnet-4-5" => "abs-claude-haiku-4-5",
|
|
146
151
|
"dsk-deepseek-v4-pro" => "dsk-deepseek-v4-flash",
|
|
147
|
-
"or-gemini-3-1-pro" => "or-gemini-3-
|
|
152
|
+
"or-gemini-3-1-pro" => "or-gemini-3-6-flash"
|
|
148
153
|
},
|
|
149
154
|
# Fallback chain: if a model is unavailable, try the next one in order.
|
|
150
155
|
# Keys are primary model names; values are the fallback model to use instead.
|
|
@@ -615,7 +620,8 @@ module Clacky
|
|
|
615
620
|
{ pattern: /glm/i, limit: 65_536 }, # GLM-5.2: 128K output ceiling; 64K ample for reasoning+answer
|
|
616
621
|
{ pattern: /kimi-k3/i, limit: 65_536 }, # Kimi K3: max_completion_tokens=131072 (max 1M); 64K ample
|
|
617
622
|
{ pattern: /mimo-v2\.5-pro/i, limit: 65_536 }, # MiMo-V2.5-Pro: max_completion_tokens=131072; 64K ample
|
|
618
|
-
{ pattern: /mimo/i, limit: 32_768 }
|
|
623
|
+
{ pattern: /mimo/i, limit: 32_768 }, # MiMo-V2.5: max_completion_tokens=32768; full default ceiling
|
|
624
|
+
{ pattern: /gemini/i, limit: 65_536 } # Gemini 3.x: 64K max output ceiling
|
|
619
625
|
].freeze
|
|
620
626
|
|
|
621
627
|
class << self
|
|
@@ -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"
|
|
@@ -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).
|
|
@@ -6351,8 +6362,25 @@ module Clacky
|
|
|
6351
6362
|
return json_response(res, 404, { error: "Project not found" }) unless @project_manager.find(project_id)
|
|
6352
6363
|
|
|
6353
6364
|
@project_manager.delete(project_id)
|
|
6365
|
+
_delete_project_sessions(project_id)
|
|
6366
|
+
|
|
6367
|
+
json_response(res, 200, { ok: true })
|
|
6368
|
+
rescue => e
|
|
6369
|
+
json_response(res, 500, { error: e.message })
|
|
6370
|
+
end
|
|
6371
|
+
|
|
6372
|
+
# DELETE /api/projects/:id/sessions - delete all sessions in a project but keep the project
|
|
6373
|
+
def api_delete_project_sessions(project_id, res)
|
|
6374
|
+
return json_response(res, 404, { error: "Project not found" }) unless @project_manager.find(project_id)
|
|
6354
6375
|
|
|
6355
|
-
|
|
6376
|
+
deleted = _delete_project_sessions(project_id)
|
|
6377
|
+
json_response(res, 200, { ok: true, deleted: deleted })
|
|
6378
|
+
rescue => e
|
|
6379
|
+
json_response(res, 500, { error: e.message })
|
|
6380
|
+
end
|
|
6381
|
+
|
|
6382
|
+
private def _delete_project_sessions(project_id)
|
|
6383
|
+
deleted = 0
|
|
6356
6384
|
@session_manager.all_sessions.each do |session_data|
|
|
6357
6385
|
next unless session_data[:project_id].to_s == project_id
|
|
6358
6386
|
|
|
@@ -6365,11 +6393,9 @@ module Clacky
|
|
|
6365
6393
|
|
|
6366
6394
|
broadcast(sid, { type: "session_deleted", session_id: sid })
|
|
6367
6395
|
unsubscribe_all(sid)
|
|
6396
|
+
deleted += 1
|
|
6368
6397
|
end
|
|
6369
|
-
|
|
6370
|
-
json_response(res, 200, { ok: true })
|
|
6371
|
-
rescue => e
|
|
6372
|
-
json_response(res, 500, { error: e.message })
|
|
6398
|
+
deleted
|
|
6373
6399
|
end
|
|
6374
6400
|
|
|
6375
6401
|
# PATCH /api/sessions/:id/project — move a session into or out of a project
|
|
@@ -6877,28 +6903,23 @@ module Clacky
|
|
|
6877
6903
|
interrupt_session(session_id)
|
|
6878
6904
|
|
|
6879
6905
|
when "list_sessions"
|
|
6880
|
-
|
|
6881
|
-
page = @registry.list(limit: 11, exclude_type:
|
|
6906
|
+
groups = @registry.group_stats_all
|
|
6907
|
+
page = @registry.list(limit: 11, exclude_type: SessionManager::GROUPED_SOURCES, exclude_project: true)
|
|
6882
6908
|
has_more = page.size > 10
|
|
6883
6909
|
all_sessions = page.first(10)
|
|
6884
6910
|
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
6911
|
if projects.any?
|
|
6889
6912
|
project_ids = projects.map { |p| p[:id] }
|
|
6890
6913
|
project_sessions = project_ids.flat_map do |pid|
|
|
6891
|
-
@registry.list(project_id: pid
|
|
6914
|
+
@registry.list(project_id: pid)
|
|
6892
6915
|
end
|
|
6893
6916
|
# Deduplicate: project sessions that are already in the first page
|
|
6894
6917
|
# will be replaced by the enriched version from `all_sessions`.
|
|
6895
6918
|
paged_ids = all_sessions.map { |s| s[:id] }.to_set
|
|
6896
|
-
|
|
6897
|
-
all_sessions = all_sessions + extra_project_sessions
|
|
6919
|
+
all_sessions += project_sessions.reject { |s| paged_ids.include?(s[:id]) }
|
|
6898
6920
|
end
|
|
6899
6921
|
conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more,
|
|
6900
|
-
|
|
6901
|
-
projects: projects)
|
|
6922
|
+
groups: groups, projects: projects)
|
|
6902
6923
|
|
|
6903
6924
|
when "run_task"
|
|
6904
6925
|
# Client sends this after subscribing to guarantee it's ready to receive
|
|
@@ -7094,7 +7115,7 @@ module Clacky
|
|
|
7094
7115
|
rescue => e
|
|
7095
7116
|
Clacky::Logger.error("[shutdown] interrupt failed for session=#{id}: #{e.message}")
|
|
7096
7117
|
end
|
|
7097
|
-
thread.join(
|
|
7118
|
+
thread.join(AGENT_INTERRUPT_JOIN_SECONDS)
|
|
7098
7119
|
@session_manager.save(agent.to_session_data(status: :interrupted, updated_at: Time.now))
|
|
7099
7120
|
end
|
|
7100
7121
|
end
|
|
@@ -7279,7 +7300,7 @@ module Clacky
|
|
|
7279
7300
|
# @param working_dir [String] working directory for the agent
|
|
7280
7301
|
# @param permission_mode [Symbol] :confirm_all (default, human present) or
|
|
7281
7302
|
# :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
|
|
7303
|
+
def build_session(name:, working_dir: nil, permission_mode: :confirm_all, profile: "general", source: :manual, model_id: nil)
|
|
7283
7304
|
working_dir ||= default_working_dir
|
|
7284
7305
|
FileUtils.mkdir_p(working_dir) unless Dir.exist?(working_dir)
|
|
7285
7306
|
session_id = Clacky::SessionManager.generate_id
|
|
@@ -7318,7 +7339,6 @@ module Clacky
|
|
|
7318
7339
|
agent = Clacky::Agent.new(client, config, working_dir: working_dir, ui: ui, profile: profile,
|
|
7319
7340
|
session_id: session_id, source: source)
|
|
7320
7341
|
agent.rename(name) unless name.nil? || name.empty?
|
|
7321
|
-
agent.hidden = hidden
|
|
7322
7342
|
idle_timer = build_idle_timer(session_id, agent)
|
|
7323
7343
|
|
|
7324
7344
|
@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,20 @@ 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
|
-
non_pinned = all_sessions.reject { |s| s[:pinned]
|
|
337
|
+
def cleanup_by_count(keep:, grouped_keep: 200)
|
|
338
|
+
non_pinned = all_sessions.reject { |s| s[:pinned] }
|
|
339
339
|
|
|
340
|
-
|
|
340
|
+
groups = non_pinned.group_by do |s|
|
|
341
|
+
(s[:project_id] || !GROUPED_SOURCES.include?(s[:source].to_s)) ? "regular" : s[:source].to_s
|
|
342
|
+
end
|
|
341
343
|
|
|
342
|
-
victims =
|
|
343
|
-
|
|
344
|
-
|
|
344
|
+
victims = groups.flat_map do |source, sessions|
|
|
345
|
+
sessions[(source == "regular" ? keep : grouped_keep)..] || []
|
|
346
|
+
end
|
|
345
347
|
|
|
346
348
|
victims.each { |session| soft_delete(session[:session_id]) }
|
|
347
349
|
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
|
|
|
@@ -131,6 +131,7 @@ module Clacky
|
|
|
131
131
|
@metadata = {}
|
|
132
132
|
@last_chunk_at = nil
|
|
133
133
|
@monitor = Monitor.new
|
|
134
|
+
@closed_cond = @monitor.new_cond
|
|
134
135
|
end
|
|
135
136
|
|
|
136
137
|
# Start rendering. Registers with the owner (allocating an entry id
|
|
@@ -185,7 +186,10 @@ module Clacky
|
|
|
185
186
|
snapshot = @monitor.synchronize do
|
|
186
187
|
return if @unregistered
|
|
187
188
|
first_close = @state == :running
|
|
188
|
-
|
|
189
|
+
if first_close
|
|
190
|
+
@state = :closed
|
|
191
|
+
@closed_cond.broadcast
|
|
192
|
+
end
|
|
189
193
|
{
|
|
190
194
|
first_close: first_close,
|
|
191
195
|
message: final_message || @message,
|
|
@@ -205,6 +209,21 @@ module Clacky
|
|
|
205
209
|
end
|
|
206
210
|
alias_method :cancel, :finish
|
|
207
211
|
|
|
212
|
+
# Close without leaving any trace: the owner removes the entry instead
|
|
213
|
+
# of committing a final frame. For work whose outcome was "nothing to
|
|
214
|
+
# report", where a lingering line is pure noise.
|
|
215
|
+
def discard
|
|
216
|
+
@monitor.synchronize do
|
|
217
|
+
return if @unregistered
|
|
218
|
+
@state = :closed
|
|
219
|
+
@closed_cond.broadcast
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
stop_ticker
|
|
223
|
+
@owner.unregister_progress(self, final_frame: nil)
|
|
224
|
+
@monitor.synchronize { @unregistered = true }
|
|
225
|
+
end
|
|
226
|
+
|
|
208
227
|
# True while the ticker thread is alive.
|
|
209
228
|
def ticker_alive?
|
|
210
229
|
t = @ticker
|
|
@@ -261,8 +280,13 @@ module Clacky
|
|
|
261
280
|
Thread.current.name = "progress-ticker-#{object_id}"
|
|
262
281
|
begin
|
|
263
282
|
loop do
|
|
264
|
-
sleep
|
|
265
|
-
|
|
283
|
+
# wait, not sleep: finish/discard signals the condition so a
|
|
284
|
+
# long tick_interval never delays shutdown.
|
|
285
|
+
closed = @monitor.synchronize do
|
|
286
|
+
@closed_cond.wait(@tick_interval) if @state == :running
|
|
287
|
+
@state != :running
|
|
288
|
+
end
|
|
289
|
+
break if closed
|
|
266
290
|
render_now
|
|
267
291
|
end
|
|
268
292
|
rescue StandardError
|