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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/lib/clacky/access_key.rb +59 -0
  4. data/lib/clacky/agent/cost_tracker.rb +2 -0
  5. data/lib/clacky/agent/message_compressor_helper.rb +53 -2
  6. data/lib/clacky/agent/session_serializer.rb +72 -19
  7. data/lib/clacky/agent/time_machine.rb +10 -1
  8. data/lib/clacky/agent.rb +107 -26
  9. data/lib/clacky/agent_config.rb +40 -0
  10. data/lib/clacky/agent_profile.rb +5 -5
  11. data/lib/clacky/billing/billing_record.rb +3 -0
  12. data/lib/clacky/billing/billing_store.rb +31 -5
  13. data/lib/clacky/billing/platform_billing.rb +231 -0
  14. data/lib/clacky/brand_config.rb +4 -3
  15. data/lib/clacky/cancel_flag.rb +23 -0
  16. data/lib/clacky/cli.rb +4 -2
  17. data/lib/clacky/client.rb +107 -0
  18. data/lib/clacky/default_extensions/advisor/advisors/general.md +23 -17
  19. data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +104 -5
  20. data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +92 -75
  21. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +31 -0
  22. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +74 -24
  23. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -37
  24. data/lib/clacky/default_extensions/git/ext.yml +1 -1
  25. data/lib/clacky/default_extensions/git/panels/git/view.js +483 -67
  26. data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
  27. data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
  28. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
  29. data/lib/clacky/extension/packager.rb +4 -2
  30. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +2 -3
  31. data/lib/clacky/extension/verifier.rb +14 -1
  32. data/lib/clacky/fanout.rb +49 -2
  33. data/lib/clacky/json_ui_controller.rb +3 -2
  34. data/lib/clacky/message_format/open_ai.rb +5 -1
  35. data/lib/clacky/message_format/open_ai_responses.rb +409 -0
  36. data/lib/clacky/message_history.rb +43 -1
  37. data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
  38. data/lib/clacky/plain_ui_controller.rb +15 -5
  39. data/lib/clacky/providers.rb +24 -7
  40. data/lib/clacky/rich_ui/rich_ui_controller.rb +2 -2
  41. data/lib/clacky/server/channel/channel_manager.rb +93 -18
  42. data/lib/clacky/server/channel/channel_ui_controller.rb +16 -3
  43. data/lib/clacky/server/dir_picker.rb +154 -0
  44. data/lib/clacky/server/git_panel.rb +61 -9
  45. data/lib/clacky/server/http_server.rb +366 -42
  46. data/lib/clacky/server/web_ui_controller.rb +7 -5
  47. data/lib/clacky/session_manager.rb +74 -17
  48. data/lib/clacky/tools/ask_user.rb +3 -2
  49. data/lib/clacky/tools/terminal.rb +30 -3
  50. data/lib/clacky/ui2/markdown_renderer.rb +15 -12
  51. data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
  52. data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
  53. data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
  54. data/lib/clacky/ui2/ui_controller.rb +2 -2
  55. data/lib/clacky/ui_interface.rb +2 -2
  56. data/lib/clacky/utils/model_pricing.rb +92 -4
  57. data/lib/clacky/version.rb +1 -1
  58. data/lib/clacky/web/app.css +915 -142
  59. data/lib/clacky/web/components/code-editor.js +33 -1
  60. data/lib/clacky/web/components/composer.js +315 -0
  61. data/lib/clacky/web/components/custom-select.js +136 -0
  62. data/lib/clacky/web/components/mentions.js +655 -0
  63. data/lib/clacky/web/components/model-picker.js +527 -0
  64. data/lib/clacky/web/components/onboard.js +29 -55
  65. data/lib/clacky/web/core/aside.js +3 -3
  66. data/lib/clacky/web/core/ext.js +38 -0
  67. data/lib/clacky/web/features/billing/store.js +2 -6
  68. data/lib/clacky/web/features/billing/view.js +32 -16
  69. data/lib/clacky/web/features/extensions/view.js +22 -10
  70. data/lib/clacky/web/features/new-session/store.js +15 -1
  71. data/lib/clacky/web/features/new-session/view.js +80 -34
  72. data/lib/clacky/web/features/trash/view.js +31 -19
  73. data/lib/clacky/web/features/workspace/store.js +1 -0
  74. data/lib/clacky/web/features/workspace/view.js +766 -80
  75. data/lib/clacky/web/i18n.js +107 -17
  76. data/lib/clacky/web/index.html +125 -45
  77. data/lib/clacky/web/sessions.js +704 -635
  78. data/lib/clacky/web/settings.js +164 -190
  79. data/lib/clacky/web/skills.js +11 -17
  80. data/lib/clacky/web/ws-dispatcher.js +11 -33
  81. data/lib/clacky.rb +4 -0
  82. 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 show_user_message(content, created_at: nil, files: [], editable: true, skill_command: nil, skill_command_display: nil)
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
- @events << { type: "assistant_message", session_id: @session_id, content: rewritten }
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
- skill: skill, iterations: iterations, cost_usd: cost_usd }
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
- 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)
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
- json_response(res, 201, { session: @registry.session_summary(session_id) })
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
- existing["model"] = override unless override.empty?
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.summary(period: period, model: model)
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.daily_breakdown(days: days, model: model)
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 CLACKY_ACCESS_KEY env var only.
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
- key = ENV.fetch("CLACKY_ACCESS_KEY", "").strip
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
- # GET /api/sessions/:id/time_machine — task history for the Time Machine
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 and lists directories only.
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.directory?(full) && File.exist?(full)
5008
- { name: name, path: full, type: "dir" }
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: _profile_read_file("SOUL.md")
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"), or nil
6141
- # for auto. Returns :invalid for unsupported values so callers can
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
- has_more = page.size > 10
7099
- all_sessions = page.first(10)
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
- return unless @registry.exist?(session_id)
7430
+ session = @registry.get(session_id)
7431
+ return unless session
7432
+ return if session[:status] == :running
7142
7433
 
7143
- agent = nil
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
- run_agent_task(session_id, agent) { agent.run(content, files: files, created_at: msg_created_at) }
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