openclacky 1.5.11 → 1.5.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -0
- data/lib/clacky/access_key.rb +59 -0
- data/lib/clacky/agent/cost_tracker.rb +2 -0
- data/lib/clacky/agent/message_compressor_helper.rb +53 -2
- data/lib/clacky/agent/session_serializer.rb +72 -19
- data/lib/clacky/agent/time_machine.rb +10 -1
- data/lib/clacky/agent.rb +107 -26
- data/lib/clacky/agent_config.rb +40 -0
- data/lib/clacky/agent_profile.rb +5 -5
- data/lib/clacky/billing/billing_record.rb +3 -0
- data/lib/clacky/billing/billing_store.rb +31 -5
- data/lib/clacky/billing/platform_billing.rb +231 -0
- data/lib/clacky/brand_config.rb +4 -3
- data/lib/clacky/cancel_flag.rb +23 -0
- data/lib/clacky/cli.rb +4 -2
- data/lib/clacky/client.rb +107 -0
- data/lib/clacky/default_extensions/advisor/advisors/general.md +23 -17
- data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +104 -5
- data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +92 -75
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +31 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +74 -24
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -37
- data/lib/clacky/default_extensions/git/ext.yml +1 -1
- data/lib/clacky/default_extensions/git/panels/git/view.js +483 -67
- data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
- data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
- data/lib/clacky/extension/packager.rb +4 -2
- data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +2 -3
- data/lib/clacky/extension/verifier.rb +14 -1
- data/lib/clacky/fanout.rb +49 -2
- data/lib/clacky/json_ui_controller.rb +3 -2
- data/lib/clacky/message_format/open_ai.rb +5 -1
- data/lib/clacky/message_format/open_ai_responses.rb +409 -0
- data/lib/clacky/message_history.rb +43 -1
- data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
- data/lib/clacky/plain_ui_controller.rb +15 -5
- data/lib/clacky/providers.rb +24 -7
- data/lib/clacky/rich_ui/rich_ui_controller.rb +2 -2
- data/lib/clacky/server/channel/channel_manager.rb +93 -18
- data/lib/clacky/server/channel/channel_ui_controller.rb +16 -3
- data/lib/clacky/server/dir_picker.rb +154 -0
- data/lib/clacky/server/git_panel.rb +61 -9
- data/lib/clacky/server/http_server.rb +366 -42
- data/lib/clacky/server/web_ui_controller.rb +7 -5
- data/lib/clacky/session_manager.rb +74 -17
- data/lib/clacky/tools/ask_user.rb +3 -2
- data/lib/clacky/tools/terminal.rb +30 -3
- data/lib/clacky/ui2/markdown_renderer.rb +15 -12
- data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
- data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
- data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +2 -2
- data/lib/clacky/ui_interface.rb +2 -2
- data/lib/clacky/utils/model_pricing.rb +92 -4
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +915 -142
- data/lib/clacky/web/components/code-editor.js +33 -1
- data/lib/clacky/web/components/composer.js +315 -0
- data/lib/clacky/web/components/custom-select.js +136 -0
- data/lib/clacky/web/components/mentions.js +655 -0
- data/lib/clacky/web/components/model-picker.js +527 -0
- data/lib/clacky/web/components/onboard.js +29 -55
- data/lib/clacky/web/core/aside.js +3 -3
- data/lib/clacky/web/core/ext.js +38 -0
- data/lib/clacky/web/features/billing/store.js +2 -6
- data/lib/clacky/web/features/billing/view.js +32 -16
- data/lib/clacky/web/features/extensions/view.js +22 -10
- data/lib/clacky/web/features/new-session/store.js +15 -1
- data/lib/clacky/web/features/new-session/view.js +80 -34
- data/lib/clacky/web/features/trash/view.js +31 -19
- data/lib/clacky/web/features/workspace/store.js +1 -0
- data/lib/clacky/web/features/workspace/view.js +766 -80
- data/lib/clacky/web/i18n.js +107 -17
- data/lib/clacky/web/index.html +125 -45
- data/lib/clacky/web/sessions.js +704 -635
- data/lib/clacky/web/settings.js +164 -190
- data/lib/clacky/web/skills.js +11 -17
- data/lib/clacky/web/ws-dispatcher.js +11 -33
- data/lib/clacky.rb +4 -0
- metadata +12 -1
|
@@ -19,6 +19,7 @@ require "open3"
|
|
|
19
19
|
require_relative "session_registry"
|
|
20
20
|
require_relative "project_manager"
|
|
21
21
|
require_relative "git_panel"
|
|
22
|
+
require_relative "dir_picker"
|
|
22
23
|
require_relative "web_ui_controller"
|
|
23
24
|
require_relative "model_prices"
|
|
24
25
|
require_relative "scheduler"
|
|
@@ -36,14 +37,43 @@ module Clacky
|
|
|
36
37
|
def initialize(session_id, events)
|
|
37
38
|
@session_id = session_id
|
|
38
39
|
@events = events
|
|
40
|
+
@replay_phase_id = nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Group replayed subagent events into a foldable phase card, mirroring the
|
|
44
|
+
# real-time WebUIController#phase_start/phase_end so the frontend nests
|
|
45
|
+
# them instead of dropping them into the outer stream. Replay is a single
|
|
46
|
+
# sequential pass, so an instance variable stands in for the thread-local
|
|
47
|
+
# phase id the live path uses.
|
|
48
|
+
def phase_start(kind:, label: nil, concurrent: false)
|
|
49
|
+
pid = SecureRandom.uuid
|
|
50
|
+
@replay_phase_id = pid
|
|
51
|
+
ev = { type: "phase_start", session_id: @session_id, phase_id: pid, kind: kind.to_s }
|
|
52
|
+
ev[:label] = label if label
|
|
53
|
+
@events << ev
|
|
54
|
+
pid
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def phase_end(phase_id, summary: nil)
|
|
58
|
+
@replay_phase_id = nil if @replay_phase_id == phase_id
|
|
59
|
+
ev = { type: "phase_end", session_id: @session_id, phase_id: phase_id }
|
|
60
|
+
ev[:summary] = summary if summary
|
|
61
|
+
@events << ev
|
|
39
62
|
end
|
|
40
63
|
|
|
41
|
-
def
|
|
64
|
+
private def stamp(event)
|
|
65
|
+
event[:phase_id] = @replay_phase_id if @replay_phase_id && !event.key?(:phase_id)
|
|
66
|
+
event
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def show_user_message(content, task_id: nil, created_at: nil, files: [], editable: true, skill_command: nil, skill_command_display: nil, references: [])
|
|
42
70
|
ev = { type: "history_user_message", session_id: @session_id, content: content }
|
|
71
|
+
ev[:task_id] = task_id if task_id
|
|
43
72
|
ev[:created_at] = created_at if created_at
|
|
44
73
|
ev[:skill_command] = skill_command if skill_command
|
|
45
74
|
ev[:skill_command_display] = skill_command_display if skill_command_display
|
|
46
75
|
ev[:editable] = false unless editable
|
|
76
|
+
ev[:references] = references unless Array(references).empty?
|
|
47
77
|
rendered = Array(files).filter_map do |f|
|
|
48
78
|
url = f[:data_url] || f["data_url"]
|
|
49
79
|
name = f[:name] || f["name"]
|
|
@@ -67,18 +97,20 @@ module Clacky
|
|
|
67
97
|
@events << ev
|
|
68
98
|
end
|
|
69
99
|
|
|
70
|
-
def show_assistant_message(content, files:, interim: false)
|
|
100
|
+
def show_assistant_message(content, files:, interim: false, created_at: nil)
|
|
71
101
|
return if content.nil? || content.to_s.strip.empty?
|
|
72
102
|
|
|
73
103
|
# Rewrite local image paths to /api/local-image proxy URLs for browser rendering
|
|
74
104
|
rewritten = Utils::FileProcessor.rewrite_local_image_urls(content.to_s)
|
|
75
|
-
|
|
105
|
+
ev = { type: "assistant_message", session_id: @session_id, content: rewritten }
|
|
106
|
+
ev[:created_at] = created_at if created_at
|
|
107
|
+
@events << stamp(ev)
|
|
76
108
|
end
|
|
77
109
|
|
|
78
110
|
def show_tool_call(name, args)
|
|
79
111
|
args_data = args.is_a?(String) ? (JSON.parse(args) rescue args) : args
|
|
80
112
|
summary = tool_call_summary(name, args_data)
|
|
81
|
-
@events << { type: "tool_call", session_id: @session_id, name: name, args: args_data, summary: summary }
|
|
113
|
+
@events << stamp({ type: "tool_call", session_id: @session_id, name: name, args: args_data, summary: summary })
|
|
82
114
|
end
|
|
83
115
|
|
|
84
116
|
private def tool_call_summary(name, args)
|
|
@@ -93,7 +125,7 @@ module Clacky
|
|
|
93
125
|
end
|
|
94
126
|
|
|
95
127
|
def show_tool_result(result)
|
|
96
|
-
@events << { type: "tool_result", session_id: @session_id, result: result }
|
|
128
|
+
@events << stamp({ type: "tool_result", session_id: @session_id, result: result })
|
|
97
129
|
end
|
|
98
130
|
|
|
99
131
|
def show_token_usage(token_data)
|
|
@@ -109,12 +141,12 @@ module Clacky
|
|
|
109
141
|
end
|
|
110
142
|
|
|
111
143
|
def show_subagent_start(skill: nil, iterations: nil, cost_usd: nil)
|
|
112
|
-
@events << { type: "subagent_start", session_id: @session_id,
|
|
113
|
-
|
|
144
|
+
@events << stamp({ type: "subagent_start", session_id: @session_id,
|
|
145
|
+
skill: skill, iterations: iterations, cost_usd: cost_usd })
|
|
114
146
|
end
|
|
115
147
|
|
|
116
148
|
def show_subagent_end
|
|
117
|
-
@events << { type: "subagent_end", session_id: @session_id }
|
|
149
|
+
@events << stamp({ type: "subagent_end", session_id: @session_id })
|
|
118
150
|
end
|
|
119
151
|
|
|
120
152
|
# Custom extension events recorded on messages (Agent#emit_event). Must be
|
|
@@ -135,6 +167,8 @@ module Clacky
|
|
|
135
167
|
# * /api/* → JSON REST API (sessions, tasks, schedules)
|
|
136
168
|
# GET /** → static files served from lib/clacky/web/ directory
|
|
137
169
|
class HttpServer
|
|
170
|
+
include DirPicker
|
|
171
|
+
|
|
138
172
|
WEB_ROOT = File.expand_path("../web", __dir__)
|
|
139
173
|
# How long shutdown waits for each agent thread to unwind before falling
|
|
140
174
|
# back to a manual session save.
|
|
@@ -223,6 +257,9 @@ module Clacky
|
|
|
223
257
|
session_builder: method(:build_session),
|
|
224
258
|
run_agent_task: method(:run_agent_task),
|
|
225
259
|
interrupt_session: method(:interrupt_session),
|
|
260
|
+
# No updated_at: clearing a stale channel_info must not bump the session's
|
|
261
|
+
# position in the newest-first list.
|
|
262
|
+
persist_session: ->(agent) { @session_manager.save(agent.to_session_data) },
|
|
226
263
|
channel_config: Clacky::ChannelConfig.load
|
|
227
264
|
)
|
|
228
265
|
@browser_manager = Clacky::BrowserManager.instance
|
|
@@ -624,6 +661,8 @@ module Clacky
|
|
|
624
661
|
when ["PATCH", "/api/config/settings"] then api_update_settings(req, res)
|
|
625
662
|
when ["POST", "/api/config/models"] then api_add_model(req, res)
|
|
626
663
|
when ["POST", "/api/config/test"] then api_test_config(req, res)
|
|
664
|
+
when ["POST", "/api/config/reload"] then api_config_reload(req, res)
|
|
665
|
+
when ["POST", "/api/access_key/reload"] then api_access_key_reload(req, res)
|
|
627
666
|
when ["POST", "/api/config/media/test"] then api_test_media_config(req, res)
|
|
628
667
|
when ["GET", "/api/config/media"] then api_get_media_config(res)
|
|
629
668
|
when ["GET", "/api/config/ocr"] then api_get_ocr_config(res)
|
|
@@ -762,6 +801,9 @@ module Clacky
|
|
|
762
801
|
elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/git/commit$})
|
|
763
802
|
session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
|
|
764
803
|
api_session_git_commit(session_id, req, res)
|
|
804
|
+
elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/git/restore$})
|
|
805
|
+
session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
|
|
806
|
+
api_session_git_restore(session_id, req, res)
|
|
765
807
|
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/time_machine$})
|
|
766
808
|
session_id = path.sub("/api/sessions/", "").sub("/time_machine", "")
|
|
767
809
|
api_session_time_machine(session_id, res)
|
|
@@ -903,7 +945,13 @@ module Clacky
|
|
|
903
945
|
type ||= query["profile"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
904
946
|
type ||= query["source"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
905
947
|
|
|
906
|
-
|
|
948
|
+
# Paginated "load more" (before set) only fills the regular session list;
|
|
949
|
+
# project sessions already arrive via the first-page WS payload and render
|
|
950
|
+
# in the project section, so they must not eat into this page's quota.
|
|
951
|
+
# Search (no before) keeps the original semantics: without a type filter
|
|
952
|
+
# it may still match project sessions.
|
|
953
|
+
exclude_project = before ? true : !!type
|
|
954
|
+
sessions = @registry.list(limit: limit + 1, before: before, q: q, q_scope: q_scope, date: date, type: type, exclude_type: exclude_type, exclude_project: exclude_project)
|
|
907
955
|
|
|
908
956
|
pinned_part, non_pinned_part = sessions.partition { |s| s[:pinned] }
|
|
909
957
|
has_more = non_pinned_part.size > limit
|
|
@@ -987,7 +1035,8 @@ module Clacky
|
|
|
987
1035
|
end
|
|
988
1036
|
|
|
989
1037
|
broadcast_session_update(session_id)
|
|
990
|
-
|
|
1038
|
+
summary = @registry.session_summary(session_id)
|
|
1039
|
+
json_response(res, 201, { session: summary })
|
|
991
1040
|
end
|
|
992
1041
|
|
|
993
1042
|
# Auto-restore persisted sessions (or create a fresh default) when the server starts.
|
|
@@ -1863,6 +1912,7 @@ module Clacky
|
|
|
1863
1912
|
record = Clacky::Billing::BillingRecord.new(
|
|
1864
1913
|
session_id: session_id,
|
|
1865
1914
|
model: result["model"].to_s,
|
|
1915
|
+
provider: result["provider"].to_s,
|
|
1866
1916
|
prompt_tokens: usage.is_a?(Hash) ? usage["prompt_tokens"].to_i : 0,
|
|
1867
1917
|
completion_tokens: usage.is_a?(Hash) ? usage["completion_tokens"].to_i : 0,
|
|
1868
1918
|
cache_read_tokens: usage.is_a?(Hash) ? usage["cache_read_tokens"].to_i : 0,
|
|
@@ -2052,7 +2102,12 @@ module Clacky
|
|
|
2052
2102
|
if existing
|
|
2053
2103
|
existing.delete("disabled")
|
|
2054
2104
|
existing["mode"] = "auto"
|
|
2055
|
-
|
|
2105
|
+
if override.empty?
|
|
2106
|
+
# empty model on "auto" means "revert to the provider default"
|
|
2107
|
+
existing.delete("model")
|
|
2108
|
+
else
|
|
2109
|
+
existing["model"] = override
|
|
2110
|
+
end
|
|
2056
2111
|
else
|
|
2057
2112
|
unless override.empty?
|
|
2058
2113
|
@agent_config.models << {
|
|
@@ -3230,7 +3285,7 @@ module Clacky
|
|
|
3230
3285
|
model = query["model"]
|
|
3231
3286
|
|
|
3232
3287
|
store = Clacky::Billing::BillingStore.new
|
|
3233
|
-
summary = store
|
|
3288
|
+
summary = merged_billing_summary(store, period: period, model: model)
|
|
3234
3289
|
|
|
3235
3290
|
json_response(res, 200, summary)
|
|
3236
3291
|
end
|
|
@@ -3246,7 +3301,7 @@ module Clacky
|
|
|
3246
3301
|
model = query["model"]
|
|
3247
3302
|
|
|
3248
3303
|
store = Clacky::Billing::BillingStore.new
|
|
3249
|
-
daily = store
|
|
3304
|
+
daily = merged_billing_daily(store, days: days, model: model)
|
|
3250
3305
|
|
|
3251
3306
|
json_response(res, 200, { days: daily })
|
|
3252
3307
|
end
|
|
@@ -3307,6 +3362,123 @@ module Clacky
|
|
|
3307
3362
|
json_response(res, 500, { error: e.message })
|
|
3308
3363
|
end
|
|
3309
3364
|
|
|
3365
|
+
# Openclacky platform usage is authoritative; other providers are
|
|
3366
|
+
# estimated locally. Merge the two by summing, after stripping the
|
|
3367
|
+
# local openclacky records (they would otherwise be counted twice).
|
|
3368
|
+
private def merged_billing_summary(store, period:, model:)
|
|
3369
|
+
platform = platform_usage_summary(period: period, model: model)
|
|
3370
|
+
|
|
3371
|
+
local = store.summary(period: period, model: model, exclude_openclacky: !platform.nil?)
|
|
3372
|
+
|
|
3373
|
+
return local unless platform
|
|
3374
|
+
|
|
3375
|
+
{
|
|
3376
|
+
period: local[:period],
|
|
3377
|
+
from: local[:from] || platform[:from],
|
|
3378
|
+
to: local[:to] || platform[:to],
|
|
3379
|
+
total_cost: (platform[:total_cost].to_f + local[:total_cost].to_f).round(6),
|
|
3380
|
+
total_tokens: platform[:total_tokens].to_i + local[:total_tokens].to_i,
|
|
3381
|
+
prompt_tokens: platform[:prompt_tokens].to_i + local[:prompt_tokens].to_i,
|
|
3382
|
+
completion_tokens: platform[:completion_tokens].to_i + local[:completion_tokens].to_i,
|
|
3383
|
+
cache_read_tokens: platform[:cache_read_tokens].to_i + local[:cache_read_tokens].to_i,
|
|
3384
|
+
cache_write_tokens: platform[:cache_write_tokens].to_i + local[:cache_write_tokens].to_i,
|
|
3385
|
+
by_model: merge_billing_models(platform[:by_model], local[:by_model]),
|
|
3386
|
+
by_day: merge_billing_days(platform[:by_day], local[:by_day]),
|
|
3387
|
+
record_count: platform[:record_count].to_i + local[:record_count].to_i
|
|
3388
|
+
}
|
|
3389
|
+
end
|
|
3390
|
+
|
|
3391
|
+
private def merged_billing_daily(store, days:, model:)
|
|
3392
|
+
platform = platform_usage_daily(days: days, model: model)
|
|
3393
|
+
|
|
3394
|
+
local = store.daily_breakdown(days: days, model: model, exclude_openclacky: !platform.nil?)
|
|
3395
|
+
|
|
3396
|
+
return local unless platform
|
|
3397
|
+
|
|
3398
|
+
by_date = {}
|
|
3399
|
+
local.each { |d| by_date[d[:date]] = d.dup }
|
|
3400
|
+
(platform[:days] || []).each do |d|
|
|
3401
|
+
existing = by_date[d[:date]]
|
|
3402
|
+
if existing
|
|
3403
|
+
existing[:cost] = (existing[:cost].to_f + d[:cost].to_f).round(6)
|
|
3404
|
+
existing[:tokens] = existing[:tokens].to_i + d[:tokens].to_i
|
|
3405
|
+
existing[:prompt_tokens] = existing[:prompt_tokens].to_i + d[:prompt_tokens].to_i
|
|
3406
|
+
existing[:completion_tokens] = existing[:completion_tokens].to_i + d[:completion_tokens].to_i
|
|
3407
|
+
existing[:cache_read_tokens] = existing[:cache_read_tokens].to_i + d[:cache_read_tokens].to_i
|
|
3408
|
+
existing[:cache_write_tokens] = existing[:cache_write_tokens].to_i + d[:cache_write_tokens].to_i
|
|
3409
|
+
existing[:requests] = existing[:requests].to_i + d[:requests].to_i
|
|
3410
|
+
else
|
|
3411
|
+
by_date[d[:date]] = d.dup
|
|
3412
|
+
end
|
|
3413
|
+
end
|
|
3414
|
+
by_date.values.sort_by { |d| d[:date] }
|
|
3415
|
+
end
|
|
3416
|
+
|
|
3417
|
+
private def platform_usage_summary(period:, model:)
|
|
3418
|
+
require_relative "../billing/platform_billing"
|
|
3419
|
+
|
|
3420
|
+
api_keys = platform_api_keys
|
|
3421
|
+
return nil if api_keys.empty?
|
|
3422
|
+
|
|
3423
|
+
real = Clacky::Billing::PlatformBilling.real_model(model) || model
|
|
3424
|
+
Clacky::Billing::PlatformBilling.fetch_summary_merged(api_keys, period: period, model: real)
|
|
3425
|
+
end
|
|
3426
|
+
|
|
3427
|
+
private def platform_usage_daily(days:, model:)
|
|
3428
|
+
require_relative "../billing/platform_billing"
|
|
3429
|
+
|
|
3430
|
+
api_keys = platform_api_keys
|
|
3431
|
+
return nil if api_keys.empty?
|
|
3432
|
+
|
|
3433
|
+
real = Clacky::Billing::PlatformBilling.real_model(model) || model
|
|
3434
|
+
Clacky::Billing::PlatformBilling.fetch_daily_merged(api_keys, days: days, model: real)
|
|
3435
|
+
end
|
|
3436
|
+
|
|
3437
|
+
private def merge_billing_models(platform_models, local_models)
|
|
3438
|
+
merged = {}
|
|
3439
|
+
(platform_models || {}).each do |real_id, entry|
|
|
3440
|
+
alias_name = Clacky::Billing::PlatformBilling.display_model(real_id)
|
|
3441
|
+
merged[alias_name] = merge_billing_model_entry(merged[alias_name], entry)
|
|
3442
|
+
end
|
|
3443
|
+
(local_models || {}).each do |model, entry|
|
|
3444
|
+
merged[model] = merge_billing_model_entry(merged[model], entry)
|
|
3445
|
+
end
|
|
3446
|
+
merged
|
|
3447
|
+
end
|
|
3448
|
+
|
|
3449
|
+
private def merge_billing_model_entry(a, b)
|
|
3450
|
+
a = a || { cost: 0.0, prompt_tokens: 0, completion_tokens: 0, requests: 0 }
|
|
3451
|
+
b = b || { cost: 0.0, prompt_tokens: 0, completion_tokens: 0, requests: 0 }
|
|
3452
|
+
{
|
|
3453
|
+
cost: (a[:cost].to_f + b[:cost].to_f).round(6),
|
|
3454
|
+
prompt_tokens: a[:prompt_tokens].to_i + b[:prompt_tokens].to_i,
|
|
3455
|
+
completion_tokens: a[:completion_tokens].to_i + b[:completion_tokens].to_i,
|
|
3456
|
+
requests: a[:requests].to_i + b[:requests].to_i
|
|
3457
|
+
}
|
|
3458
|
+
end
|
|
3459
|
+
|
|
3460
|
+
private def merge_billing_days(platform_days, local_days)
|
|
3461
|
+
merged = {}
|
|
3462
|
+
(platform_days || {}).each { |date, cost| merged[date] = (merged[date] || 0.0) + cost.to_f }
|
|
3463
|
+
(local_days || {}).each { |date, cost| merged[date] = (merged[date] || 0.0) + cost.to_f }
|
|
3464
|
+
merged.transform_values { |v| v.round(6) }
|
|
3465
|
+
end
|
|
3466
|
+
|
|
3467
|
+
# All openclacky api keys in the user's model config, deduped and
|
|
3468
|
+
# empty-stripped. The billing page queries the platform once per key
|
|
3469
|
+
# and merges the results, so every configured account is included.
|
|
3470
|
+
private def platform_api_keys
|
|
3471
|
+
keys = []
|
|
3472
|
+
@agent_config.models.each do |m|
|
|
3473
|
+
next unless m.is_a?(Hash)
|
|
3474
|
+
next unless @agent_config.provider_id_for(m) == "openclacky"
|
|
3475
|
+
|
|
3476
|
+
key = m["api_key"].to_s.strip
|
|
3477
|
+
keys << key unless key.empty?
|
|
3478
|
+
end
|
|
3479
|
+
keys.uniq
|
|
3480
|
+
end
|
|
3481
|
+
|
|
3310
3482
|
# POST /api/ui/open_aside
|
|
3311
3483
|
# Broadcasts an open_aside event to the specified session's WebSocket clients,
|
|
3312
3484
|
# causing the browser to open the right-side panel.
|
|
@@ -3392,10 +3564,10 @@ module Clacky
|
|
|
3392
3564
|
s.start_with?("127.") || s == "::ffff:127.0.0.1"
|
|
3393
3565
|
end
|
|
3394
3566
|
|
|
3395
|
-
# Resolve access key from
|
|
3567
|
+
# Resolve access key from ~/.clacky/access_key, falling back to the
|
|
3568
|
+
# CLACKY_ACCESS_KEY env var.
|
|
3396
3569
|
private def resolve_access_key
|
|
3397
|
-
|
|
3398
|
-
key.empty? ? nil : key
|
|
3570
|
+
Clacky::AccessKey.resolve
|
|
3399
3571
|
end
|
|
3400
3572
|
|
|
3401
3573
|
# Extract bearer token or query param from a WEBrick request.
|
|
@@ -3517,7 +3689,7 @@ module Clacky
|
|
|
3517
3689
|
Clacky::Logger.info("[Upgrade] Official source — running: #{cmd}")
|
|
3518
3690
|
broadcast_all(type: "upgrade_log", line: "Starting upgrade: #{cmd}\n")
|
|
3519
3691
|
|
|
3520
|
-
output, exit_code = run_shell(cmd, timeout: 600)
|
|
3692
|
+
output, exit_code = run_shell(cmd, timeout: 600, env: gem_install_env)
|
|
3521
3693
|
|
|
3522
3694
|
Clacky::Logger.info("[Upgrade] exit_code=#{exit_code}")
|
|
3523
3695
|
Clacky::Logger.info("[Upgrade] output=#{output.slice(0, 1000)}")
|
|
@@ -3575,7 +3747,7 @@ module Clacky
|
|
|
3575
3747
|
broadcast_all(type: "upgrade_log", line: "Installing...\n")
|
|
3576
3748
|
Clacky::Logger.info("[Upgrade] Running: #{cmd}")
|
|
3577
3749
|
|
|
3578
|
-
output, exit_code = run_shell(cmd, timeout: 600)
|
|
3750
|
+
output, exit_code = run_shell(cmd, timeout: 600, env: gem_install_env)
|
|
3579
3751
|
success = exit_code&.zero? || false
|
|
3580
3752
|
|
|
3581
3753
|
broadcast_all(type: "upgrade_log", line: output)
|
|
@@ -3651,6 +3823,63 @@ module Clacky
|
|
|
3651
3823
|
schedule_restart
|
|
3652
3824
|
end
|
|
3653
3825
|
|
|
3826
|
+
# POST /api/config/reload
|
|
3827
|
+
# Re-reads ~/.clacky/config.yml into the live @agent_config. Every session
|
|
3828
|
+
# shares the same @models array, so a reload reaches all of them at once.
|
|
3829
|
+
def api_config_reload(_req, res)
|
|
3830
|
+
was_configured = @agent_config.models_configured?
|
|
3831
|
+
|
|
3832
|
+
unless @agent_config.reload!
|
|
3833
|
+
json_response(res, 500, { ok: false, error: "Failed to reload config.yml" })
|
|
3834
|
+
return
|
|
3835
|
+
end
|
|
3836
|
+
|
|
3837
|
+
# A server that booted with an empty config never built its default
|
|
3838
|
+
# session (create_default_session bails on models_configured?). Now
|
|
3839
|
+
# that models exist, complete that deferred startup step.
|
|
3840
|
+
if !was_configured && @agent_config.models_configured?
|
|
3841
|
+
create_default_session
|
|
3842
|
+
end
|
|
3843
|
+
|
|
3844
|
+
json_response(res, 200, {
|
|
3845
|
+
ok: true,
|
|
3846
|
+
models: @agent_config.models.size,
|
|
3847
|
+
models_configured: @agent_config.models_configured?
|
|
3848
|
+
})
|
|
3849
|
+
end
|
|
3850
|
+
|
|
3851
|
+
# POST /api/access_key/reload
|
|
3852
|
+
# Re-reads the access key from ~/.clacky/access_key (or CLACKY_ACCESS_KEY).
|
|
3853
|
+
# Write the new key to that file first, then call this to swap it in
|
|
3854
|
+
# without restarting. Pass {"key": "..."} to have the server persist the
|
|
3855
|
+
# key for you, or {"generate": true} to get a freshly generated one back.
|
|
3856
|
+
def api_access_key_reload(req, res)
|
|
3857
|
+
body = parse_json_body(req)
|
|
3858
|
+
|
|
3859
|
+
if body["generate"]
|
|
3860
|
+
key = Clacky::AccessKey.write(Clacky::AccessKey.generate)
|
|
3861
|
+
elsif !body["key"].to_s.strip.empty?
|
|
3862
|
+
key = Clacky::AccessKey.write(body["key"])
|
|
3863
|
+
else
|
|
3864
|
+
key = Clacky::AccessKey.resolve
|
|
3865
|
+
end
|
|
3866
|
+
|
|
3867
|
+
@access_key = @localhost_only ? nil : key
|
|
3868
|
+
# Old lockout counters were scoped to the previous key; a client that
|
|
3869
|
+
# tripped them should not stay banned after a rotation.
|
|
3870
|
+
@auth_failures_mutex.synchronize { @auth_failures.clear }
|
|
3871
|
+
|
|
3872
|
+
Clacky::Logger.info("[Auth] Access key reloaded (configured=#{!key.nil?})")
|
|
3873
|
+
|
|
3874
|
+
payload = { ok: true, configured: !key.nil? }
|
|
3875
|
+
# Only echo the key when the server generated it — otherwise the caller
|
|
3876
|
+
# already knows it, and logging/proxies shouldn't see it again.
|
|
3877
|
+
payload[:access_key] = key if body["generate"]
|
|
3878
|
+
json_response(res, 200, payload)
|
|
3879
|
+
rescue ArgumentError => e
|
|
3880
|
+
json_response(res, 400, { ok: false, error: e.message })
|
|
3881
|
+
end
|
|
3882
|
+
|
|
3654
3883
|
private def schedule_restart
|
|
3655
3884
|
Clacky::ThreadRegistry.spawn(name: "schedule-restart") do
|
|
3656
3885
|
sleep 0.5 # Let WEBrick flush the HTTP response
|
|
@@ -3763,8 +3992,23 @@ module Clacky
|
|
|
3763
3992
|
# Delegates to Terminal.run_sync which handles the idle-poll loop
|
|
3764
3993
|
# internally (see its docs for why that's needed — this wrapper used
|
|
3765
3994
|
# to re-implement it wrong and caused the 0.9.36 upgrade bug).
|
|
3766
|
-
private def run_shell(command, timeout: 120)
|
|
3767
|
-
Clacky::Tools::Terminal.run_sync(command, timeout: timeout)
|
|
3995
|
+
private def run_shell(command, timeout: 120, env: nil)
|
|
3996
|
+
Clacky::Tools::Terminal.run_sync(command, timeout: timeout, env: env)
|
|
3997
|
+
end
|
|
3998
|
+
|
|
3999
|
+
# Install next to the gem the server is currently running from, bypassing
|
|
4000
|
+
# any GEM_HOME hardcoded in the user's shell rc (system-Ruby users).
|
|
4001
|
+
private def gem_install_env
|
|
4002
|
+
spec = Gem.loaded_specs["openclacky"]
|
|
4003
|
+
gem_home = if spec
|
|
4004
|
+
spec.base_dir
|
|
4005
|
+
else
|
|
4006
|
+
Gem.dir
|
|
4007
|
+
end
|
|
4008
|
+
{
|
|
4009
|
+
"GEM_HOME" => gem_home,
|
|
4010
|
+
"GEM_PATH" => Gem.path.join(File::PATH_SEPARATOR),
|
|
4011
|
+
}
|
|
3768
4012
|
end
|
|
3769
4013
|
|
|
3770
4014
|
# ── Channel API ───────────────────────────────────────────────────────────
|
|
@@ -4816,7 +5060,26 @@ module Clacky
|
|
|
4816
5060
|
end
|
|
4817
5061
|
end
|
|
4818
5062
|
|
|
4819
|
-
#
|
|
5063
|
+
# POST /api/sessions/:id/git/restore - body: { file: }.
|
|
5064
|
+
# Discards uncommitted changes to one file, back to its HEAD state.
|
|
5065
|
+
def api_session_git_restore(session_id, req, res)
|
|
5066
|
+
dir = git_session_dir(session_id, res)
|
|
5067
|
+
return unless dir
|
|
5068
|
+
|
|
5069
|
+
unless Clacky::Server::GitPanel.repo?(dir)
|
|
5070
|
+
return json_response(res, 400, { error: "Not a git repository" })
|
|
5071
|
+
end
|
|
5072
|
+
|
|
5073
|
+
body = parse_json_body(req)
|
|
5074
|
+
result = Clacky::Server::GitPanel.restore(dir, file: body["file"])
|
|
5075
|
+
if result[:ok]
|
|
5076
|
+
json_response(res, 200, result)
|
|
5077
|
+
else
|
|
5078
|
+
json_response(res, 422, { error: result[:error] })
|
|
5079
|
+
end
|
|
5080
|
+
end
|
|
5081
|
+
|
|
5082
|
+
# GET /api/sessions/:id/time_machine - task history for the Time Machine
|
|
4820
5083
|
# panel. Mirrors the CLI menu: each entry carries id, summary, status
|
|
4821
5084
|
# (current/past/undone) and whether it branches.
|
|
4822
5085
|
def api_session_time_machine(session_id, res)
|
|
@@ -4935,6 +5198,7 @@ module Clacky
|
|
|
4935
5198
|
|
|
4936
5199
|
# Absolute mode: allow browsing outside working directory (e.g., root "/")
|
|
4937
5200
|
if absolute_mode
|
|
5201
|
+
rel = Utils::EnvironmentDetector.win_to_linux_path(rel)
|
|
4938
5202
|
target = File.expand_path(rel.empty? ? "/" : rel)
|
|
4939
5203
|
display_root = target
|
|
4940
5204
|
# Normalize rel for API response
|
|
@@ -4973,7 +5237,7 @@ module Clacky
|
|
|
4973
5237
|
# Directories first, then files; both case-insensitive alphabetical.
|
|
4974
5238
|
items.sort_by! { |it| [it[:type] == "dir" ? 0 : 1, it[:name].downcase] }
|
|
4975
5239
|
|
|
4976
|
-
json_response(res, 200, { root: display_root, path: rel, home: Dir.home, default: default_working_dir, entries: items })
|
|
5240
|
+
json_response(res, 200, { root: display_root, path: rel, home: Dir.home, default: default_working_dir, entries: items, places: dir_picker_places })
|
|
4977
5241
|
rescue StandardError => e
|
|
4978
5242
|
json_response(res, 500, { error: e.message })
|
|
4979
5243
|
end
|
|
@@ -4981,13 +5245,17 @@ module Clacky
|
|
|
4981
5245
|
# GET /api/dirs?path=<absolute-or-~-path>
|
|
4982
5246
|
# Session-independent directory browser used by the New Session modal,
|
|
4983
5247
|
# where no session (and thus no working_dir) exists yet. Always operates
|
|
4984
|
-
# in absolute mode
|
|
5248
|
+
# in absolute mode. Lists directories only by default; pass files=true to
|
|
5249
|
+
# include files (used by the @ mention file picker on the new-session page).
|
|
4985
5250
|
def api_browse_dirs(req, res)
|
|
4986
5251
|
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
4987
5252
|
rel = query["path"].to_s.strip
|
|
4988
5253
|
show_hidden = query["show_hidden"] == "true"
|
|
5254
|
+
include_files = query["files"] == "true"
|
|
4989
5255
|
rel = Dir.home if rel.empty?
|
|
5256
|
+
rel = Utils::EnvironmentDetector.win_to_linux_path(rel)
|
|
4990
5257
|
target = File.expand_path(rel.start_with?("~") ? rel.sub(/\A~/, Dir.home) : rel)
|
|
5258
|
+
requested_target = target
|
|
4991
5259
|
|
|
4992
5260
|
# The requested directory may not exist yet (e.g. the default
|
|
4993
5261
|
# ~/clacky_workspace before any session created it). Instead of 404,
|
|
@@ -5004,14 +5272,17 @@ module Clacky
|
|
|
5004
5272
|
end
|
|
5005
5273
|
items = entries.filter_map do |name|
|
|
5006
5274
|
full = File.join(target, name)
|
|
5007
|
-
next unless File.
|
|
5008
|
-
|
|
5275
|
+
next unless File.exist?(full)
|
|
5276
|
+
is_dir = File.directory?(full)
|
|
5277
|
+
next if !include_files && !is_dir
|
|
5278
|
+
{ name: name, path: full, type: is_dir ? "dir" : "file",
|
|
5279
|
+
size: is_dir ? nil : (File.size(full) rescue nil) }
|
|
5009
5280
|
rescue StandardError
|
|
5010
5281
|
nil
|
|
5011
5282
|
end
|
|
5012
|
-
items.sort_by! { |it| it[:name].downcase }
|
|
5283
|
+
items.sort_by! { |it| [it[:type] == "dir" ? 0 : 1, it[:name].downcase] }
|
|
5013
5284
|
|
|
5014
|
-
json_response(res, 200, { root: target, path: target, parent: File.dirname(target), home: Dir.home, default: default_working_dir, entries: items })
|
|
5285
|
+
json_response(res, 200, { root: target, path: target, parent: File.dirname(target), exact: target == requested_target, home: Dir.home, default: default_working_dir, entries: items, places: dir_picker_places })
|
|
5015
5286
|
rescue StandardError => e
|
|
5016
5287
|
json_response(res, 500, { error: e.message })
|
|
5017
5288
|
end
|
|
@@ -5032,7 +5303,7 @@ module Clacky
|
|
|
5032
5303
|
# Body: { parent: "/abs/parent", name: "New Folder" }
|
|
5033
5304
|
def api_dirs_mkdir(req, res)
|
|
5034
5305
|
body = parse_json_body(req)
|
|
5035
|
-
parent = body["parent"].to_s
|
|
5306
|
+
parent = Utils::EnvironmentDetector.win_to_linux_path(body["parent"].to_s)
|
|
5036
5307
|
name = body["name"].to_s.strip
|
|
5037
5308
|
|
|
5038
5309
|
return json_response(res, 422, { error: "parent must be an absolute path" }) unless parent.start_with?("/")
|
|
@@ -5514,10 +5785,12 @@ module Clacky
|
|
|
5514
5785
|
# GET /api/profile
|
|
5515
5786
|
# Returns { ok:, user: { path, content, is_default }, soul: { ... } }
|
|
5516
5787
|
private def api_profile_get(res)
|
|
5788
|
+
soul = _profile_read_file("SOUL.md")
|
|
5789
|
+
soul[:name] = _soul_name(soul[:content])
|
|
5517
5790
|
json_response(res, 200, {
|
|
5518
5791
|
ok: true,
|
|
5519
5792
|
user: _profile_read_file("USER.md"),
|
|
5520
|
-
soul:
|
|
5793
|
+
soul: soul
|
|
5521
5794
|
})
|
|
5522
5795
|
end
|
|
5523
5796
|
|
|
@@ -5588,6 +5861,16 @@ module Clacky
|
|
|
5588
5861
|
{ path: "", content: "", is_default: true, error: e.message }
|
|
5589
5862
|
end
|
|
5590
5863
|
|
|
5864
|
+
# Extracts the AI name from a SOUL.md heading ("# 老六 — Soul"). Returns
|
|
5865
|
+
# nil when the heading is absent (e.g. the built-in default soul).
|
|
5866
|
+
private def _soul_name(content)
|
|
5867
|
+
return nil if content.nil? || content.empty?
|
|
5868
|
+
m = content.match(/^#\s*(.+?)\s*[—–-]\s*Soul\s*$/i)
|
|
5869
|
+
return nil unless m
|
|
5870
|
+
name = m[1].to_s.strip
|
|
5871
|
+
name.empty? ? nil : name
|
|
5872
|
+
end
|
|
5873
|
+
|
|
5591
5874
|
# ── Memories API (~/.clacky/memories/*.md) ───────────────────────
|
|
5592
5875
|
#
|
|
5593
5876
|
# Long-term memories are plain Markdown files with YAML frontmatter
|
|
@@ -6137,13 +6420,13 @@ module Clacky
|
|
|
6137
6420
|
# never send "the whole list" anymore.
|
|
6138
6421
|
|
|
6139
6422
|
# Normalize an incoming api_format value into one of the supported
|
|
6140
|
-
# explicit formats ("anthropic-messages" / "openai-completions"
|
|
6141
|
-
# for auto. Returns :invalid for unsupported
|
|
6142
|
-
# reject the request outright.
|
|
6423
|
+
# explicit formats ("anthropic-messages" / "openai-completions" /
|
|
6424
|
+
# "openai-responses"), or nil for auto. Returns :invalid for unsupported
|
|
6425
|
+
# values so callers can reject the request outright.
|
|
6143
6426
|
private def normalize_api_format(value)
|
|
6144
6427
|
return nil if value.nil? || value.to_s.strip.empty?
|
|
6145
6428
|
v = value.to_s.strip
|
|
6146
|
-
return v if %w[anthropic-messages openai-completions].include?(v)
|
|
6429
|
+
return v if %w[anthropic-messages openai-completions openai-responses].include?(v)
|
|
6147
6430
|
:invalid
|
|
6148
6431
|
end
|
|
6149
6432
|
|
|
@@ -7082,7 +7365,7 @@ module Clacky
|
|
|
7082
7365
|
raw_images = (msg["images"] || []).map do |data_url|
|
|
7083
7366
|
{ "data_url" => data_url, "name" => "image.jpg", "mime_type" => "image/jpeg" }
|
|
7084
7367
|
end
|
|
7085
|
-
handle_user_message(session_id, msg["content"].to_s, (msg["files"] || []) + raw_images)
|
|
7368
|
+
handle_user_message(session_id, msg["content"].to_s, (msg["files"] || []) + raw_images, references: msg["references"] || [])
|
|
7086
7369
|
|
|
7087
7370
|
when "confirmation"
|
|
7088
7371
|
session_id = msg["session_id"] || conn.session_id
|
|
@@ -7095,8 +7378,14 @@ module Clacky
|
|
|
7095
7378
|
when "list_sessions"
|
|
7096
7379
|
groups = @registry.group_stats_all
|
|
7097
7380
|
page = @registry.list(limit: 11, exclude_type: SessionManager::GROUPED_SOURCES, exclude_project: true)
|
|
7098
|
-
|
|
7099
|
-
|
|
7381
|
+
# The sidebar's first page is capped at 10 rows total: pinned first,
|
|
7382
|
+
# non-pinned fill the remainder. Pinned sessions must never be
|
|
7383
|
+
# truncated — `page.first(10)` dropped the oldest pins once more than
|
|
7384
|
+
# 10 sessions were pinned.
|
|
7385
|
+
pinned_part, non_pinned_part = page.partition { |s| s[:pinned] }
|
|
7386
|
+
non_pinned_limit = [10 - pinned_part.size, 0].max
|
|
7387
|
+
has_more = non_pinned_part.size > non_pinned_limit
|
|
7388
|
+
all_sessions = pinned_part + non_pinned_part.first(non_pinned_limit)
|
|
7100
7389
|
projects = @project_manager.all
|
|
7101
7390
|
if projects.any?
|
|
7102
7391
|
project_ids = projects.map { |p| p[:id] }
|
|
@@ -7138,10 +7427,11 @@ module Clacky
|
|
|
7138
7427
|
# ── Session actions ───────────────────────────────────────────────────────
|
|
7139
7428
|
|
|
7140
7429
|
def handle_edit_message(session_id, content, created_at)
|
|
7141
|
-
|
|
7430
|
+
session = @registry.get(session_id)
|
|
7431
|
+
return unless session
|
|
7432
|
+
return if session[:status] == :running
|
|
7142
7433
|
|
|
7143
|
-
agent =
|
|
7144
|
-
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
7434
|
+
agent = session[:agent]
|
|
7145
7435
|
return unless agent
|
|
7146
7436
|
|
|
7147
7437
|
if agent.history.respond_to?(:truncate_from_created_at) && !created_at.to_s.empty?
|
|
@@ -7151,7 +7441,7 @@ module Clacky
|
|
|
7151
7441
|
handle_user_message(session_id, content)
|
|
7152
7442
|
end
|
|
7153
7443
|
|
|
7154
|
-
def handle_user_message(session_id, content, files = [])
|
|
7444
|
+
def handle_user_message(session_id, content, files = [], references: [])
|
|
7155
7445
|
return unless @registry.exist?(session_id)
|
|
7156
7446
|
|
|
7157
7447
|
session = @registry.get(session_id)
|
|
@@ -7211,7 +7501,36 @@ module Clacky
|
|
|
7211
7501
|
|
|
7212
7502
|
# File references are now handled inside agent.run — injected as a system_injected
|
|
7213
7503
|
# message after the user message, so replay_history skips them automatically.
|
|
7214
|
-
|
|
7504
|
+
reference_contexts = build_reference_contexts(references)
|
|
7505
|
+
run_agent_task(session_id, agent) { agent.run(content, files: files, reference_contexts: reference_contexts, created_at: msg_created_at, references_display: references) }
|
|
7506
|
+
end
|
|
7507
|
+
|
|
7508
|
+
# Build context blocks for non-file @mention references. Each reference is
|
|
7509
|
+
# { "type" => ..., ...payload } — dispatch on type so new reference kinds
|
|
7510
|
+
# (e.g. branch diff, browser snapshot) only add a `when` branch here.
|
|
7511
|
+
private def build_reference_contexts(references)
|
|
7512
|
+
Array(references).filter_map do |ref|
|
|
7513
|
+
next unless ref.is_a?(Hash)
|
|
7514
|
+
case ref["type"].to_s
|
|
7515
|
+
when "session" then build_session_reference_context(ref)
|
|
7516
|
+
end
|
|
7517
|
+
end
|
|
7518
|
+
end
|
|
7519
|
+
|
|
7520
|
+
# Resolve a past-chat reference into a lightweight pointer for the LLM:
|
|
7521
|
+
# the session name + ID + on-disk file path, no transcript inlined.
|
|
7522
|
+
private def build_session_reference_context(ref)
|
|
7523
|
+
session_id = ref["session_id"].to_s
|
|
7524
|
+
return nil if session_id.empty?
|
|
7525
|
+
|
|
7526
|
+
name = ref["name"].to_s
|
|
7527
|
+
name = session_id if name.empty?
|
|
7528
|
+
|
|
7529
|
+
lines = ["[Referenced conversation: #{name}]", "Session ID: #{session_id}"]
|
|
7530
|
+
files = @session_manager.files_for(session_id)
|
|
7531
|
+
lines << "Session file: #{files[:json_path]}" if files && files[:json_path]
|
|
7532
|
+
|
|
7533
|
+
lines.join("\n")
|
|
7215
7534
|
end
|
|
7216
7535
|
|
|
7217
7536
|
def deliver_confirmation(session_id, conf_id, result)
|
|
@@ -7392,12 +7711,17 @@ module Clacky
|
|
|
7392
7711
|
# Start idle compression timer now that the agent is idle
|
|
7393
7712
|
idle_timer&.start
|
|
7394
7713
|
rescue Clacky::AgentInterrupted
|
|
7714
|
+
# Persist the interrupted history snapshot unconditionally: it carries
|
|
7715
|
+
# this agent's own turns (including fan-out subagent trails flushed on
|
|
7716
|
+
# interrupt), and losing them means a page reload shows nothing of what
|
|
7717
|
+
# the subagents did. The epoch fence below still guards status/UI so a
|
|
7718
|
+
# superseding task keeps ownership of those.
|
|
7719
|
+
@session_manager.save(agent.to_session_data(status: :interrupted, updated_at: Time.now))
|
|
7395
7720
|
# A superseding task already owns the session — do not touch status
|
|
7396
7721
|
# or push UI events, they belong to the new epoch now.
|
|
7397
7722
|
next unless @registry.update_if_epoch(session_id, epoch, status: :idle)
|
|
7398
7723
|
broadcast_session_update(session_id)
|
|
7399
7724
|
broadcast(session_id, { type: "interrupted", session_id: session_id })
|
|
7400
|
-
@session_manager.save(agent.to_session_data(status: :interrupted, updated_at: Time.now))
|
|
7401
7725
|
rescue => e
|
|
7402
7726
|
# Route error through web_ui so channel subscribers (飞书/企微) receive it too.
|
|
7403
7727
|
web_ui = nil
|