openclacky 0.9.7 → 0.9.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +16 -1
  3. data/CHANGELOG.md +51 -0
  4. data/docs/browser-cdp-native-design.md +195 -0
  5. data/docs/session-management-redesign.md +202 -0
  6. data/docs/system-skill-authoring-guide.md +47 -0
  7. data/lib/clacky/agent/cost_tracker.rb +2 -1
  8. data/lib/clacky/agent/message_compressor_helper.rb +2 -2
  9. data/lib/clacky/agent/session_serializer.rb +36 -3
  10. data/lib/clacky/agent/skill_manager.rb +68 -5
  11. data/lib/clacky/agent/system_prompt_builder.rb +30 -13
  12. data/lib/clacky/agent/tool_executor.rb +8 -12
  13. data/lib/clacky/agent.rb +172 -30
  14. data/lib/clacky/brand_config.rb +90 -0
  15. data/lib/clacky/cli.rb +37 -8
  16. data/lib/clacky/default_parsers/doc_parser.rb +69 -0
  17. data/lib/clacky/default_parsers/docx_parser.rb +172 -0
  18. data/lib/clacky/default_parsers/pdf_parser.rb +79 -0
  19. data/lib/clacky/default_parsers/pptx_parser.rb +140 -0
  20. data/lib/clacky/default_parsers/xlsx_parser.rb +121 -0
  21. data/lib/clacky/default_skills/browser-setup/SKILL.md +158 -0
  22. data/lib/clacky/default_skills/channel-setup/SKILL.md +139 -42
  23. data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +582 -0
  24. data/lib/clacky/default_skills/channel-setup/weixin_setup.rb +274 -0
  25. data/lib/clacky/default_skills/onboard/SKILL.md +132 -6
  26. data/lib/clacky/default_skills/personal-website/SKILL.md +113 -0
  27. data/lib/clacky/default_skills/personal-website/publish.rb +214 -0
  28. data/lib/clacky/default_skills/skill-add/SKILL.md +6 -7
  29. data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +1 -1
  30. data/lib/clacky/default_skills/skill-creator/SKILL.md +3 -3
  31. data/lib/clacky/json_ui_controller.rb +5 -0
  32. data/lib/clacky/message_format/open_ai.rb +7 -1
  33. data/lib/clacky/plain_ui_controller.rb +5 -0
  34. data/lib/clacky/server/browser_manager.rb +308 -0
  35. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +80 -15
  36. data/lib/clacky/server/channel/adapters/feishu/bot.rb +216 -7
  37. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +12 -0
  38. data/lib/clacky/server/channel/adapters/wecom/adapter.rb +2 -8
  39. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +391 -0
  40. data/lib/clacky/server/channel/adapters/weixin/api_client.rb +374 -0
  41. data/lib/clacky/server/channel/channel_config.rb +6 -0
  42. data/lib/clacky/server/channel/channel_manager.rb +50 -15
  43. data/lib/clacky/server/channel/channel_ui_controller.rb +18 -0
  44. data/lib/clacky/server/channel.rb +1 -0
  45. data/lib/clacky/server/http_server.rb +210 -45
  46. data/lib/clacky/server/scheduler.rb +1 -1
  47. data/lib/clacky/server/session_registry.rb +131 -40
  48. data/lib/clacky/server/web_ui_controller.rb +21 -2
  49. data/lib/clacky/session_manager.rb +43 -73
  50. data/lib/clacky/skill.rb +64 -42
  51. data/lib/clacky/tools/browser.rb +728 -183
  52. data/lib/clacky/tools/invoke_skill.rb +18 -11
  53. data/lib/clacky/tools/shell.rb +16 -7
  54. data/lib/clacky/tools/web_fetch.rb +3 -1
  55. data/lib/clacky/ui2/README.md +1 -1
  56. data/lib/clacky/ui2/components/input_area.rb +5 -2
  57. data/lib/clacky/ui2/components/welcome_banner.rb +23 -0
  58. data/lib/clacky/ui2/ui_controller.rb +16 -2
  59. data/lib/clacky/ui_interface.rb +7 -0
  60. data/lib/clacky/utils/file_processor.rb +116 -139
  61. data/lib/clacky/utils/model_pricing.rb +5 -4
  62. data/lib/clacky/utils/parser_manager.rb +93 -0
  63. data/lib/clacky/utils/workspace_rules.rb +46 -0
  64. data/lib/clacky/version.rb +1 -1
  65. data/lib/clacky/web/app.css +696 -62
  66. data/lib/clacky/web/app.js +181 -51
  67. data/lib/clacky/web/brand.js +14 -0
  68. data/lib/clacky/web/channels.js +10 -1
  69. data/lib/clacky/web/i18n.js +49 -7
  70. data/lib/clacky/web/index.html +67 -10
  71. data/lib/clacky/web/onboard.js +2 -1
  72. data/lib/clacky/web/sessions.js +255 -69
  73. data/lib/clacky/web/settings.js +113 -1
  74. data/lib/clacky/web/skills.js +16 -3
  75. data/lib/clacky/web/tasks.js +11 -6
  76. data/lib/clacky/web/weixin-qr.html +104 -0
  77. data/lib/clacky.rb +2 -0
  78. data/scripts/install.sh +41 -11
  79. metadata +48 -7
  80. data/lib/clacky/default_skills/pdf-reader/SKILL.md +0 -90
  81. data/lib/clacky/utils/file_parser/docx_parser.rb +0 -156
  82. data/lib/clacky/utils/file_parser/pptx_parser.rb +0 -116
  83. data/lib/clacky/utils/file_parser/xlsx_parser.rb +0 -95
  84. data/lib/clacky/utils/file_parser/zip_parser.rb +0 -60
  85. data/scripts/install_agent_browser.sh +0 -67
@@ -31,7 +31,12 @@ module Clacky
31
31
  def show_user_message(content, created_at: nil, files: [])
32
32
  ev = { type: "history_user_message", session_id: @session_id, content: content }
33
33
  ev[:created_at] = created_at if created_at
34
- ev[:files] = files if files && !files.empty?
34
+ rendered = Array(files).filter_map do |f|
35
+ url = f[:data_url] || f["data_url"]
36
+ name = f[:name] || f["name"]
37
+ url || (name ? "pdf:#{name}" : nil)
38
+ end
39
+ ev[:images] = rendered unless rendered.empty?
35
40
  @events << ev
36
41
  end
37
42
 
@@ -127,7 +132,7 @@ module Clacky
127
132
  - 将大目标拆解为可执行的小步骤
128
133
  MD
129
134
 
130
- def initialize(host: "127.0.0.1", port: 7070, agent_config:, client_factory:, brand_test: false)
135
+ def initialize(host: "127.0.0.1", port: 7070, agent_config:, client_factory:, brand_test: false, sessions_dir: nil)
131
136
  @host = host
132
137
  @port = port
133
138
  @agent_config = agent_config
@@ -137,16 +142,16 @@ module Clacky
137
142
  # so api_restart can re-exec the correct binary even if cwd changes later.
138
143
  @restart_script = File.expand_path($0)
139
144
  @restart_argv = ARGV.dup
140
- @registry = SessionRegistry.new
141
- @session_manager = Clacky::SessionManager.new
145
+ @session_manager = Clacky::SessionManager.new(sessions_dir: sessions_dir)
146
+ @registry = SessionRegistry.new(
147
+ session_manager: @session_manager,
148
+ session_restorer: method(:build_session_from_data)
149
+ )
142
150
  @ws_clients = {} # session_id => [WebSocketConnection, ...]
143
151
  @ws_mutex = Mutex.new
144
152
  # Version cache: { latest: "x.y.z", checked_at: Time }
145
153
  @version_cache = nil
146
154
  @version_mutex = Mutex.new
147
- # Version cache: { latest: "x.y.z", checked_at: Time }
148
- @version_cache = nil
149
- @version_mutex = Mutex.new
150
155
  @scheduler = Scheduler.new(
151
156
  session_registry: @registry,
152
157
  session_builder: method(:build_session)
@@ -158,6 +163,7 @@ module Clacky
158
163
  interrupt_session: method(:interrupt_session),
159
164
  channel_config: Clacky::ChannelConfig.load
160
165
  )
166
+ @browser_manager = Clacky::BrowserManager.instance
161
167
  @skill_loader = Clacky::SkillLoader.new(working_dir: nil, brand_config: Clacky::BrandConfig.load)
162
168
  end
163
169
 
@@ -171,6 +177,14 @@ module Clacky
171
177
  File.write(pid_file, Process.pid.to_s)
172
178
  at_exit { File.delete(pid_file) if File.exist?(pid_file) }
173
179
 
180
+ # Expose server address and brand name to all child processes (skill scripts, shell commands, etc.)
181
+ # so they can call back into the server without hardcoding the port,
182
+ # and use the correct product name without re-reading brand.yml.
183
+ ENV["CLACKY_SERVER_PORT"] = @port.to_s
184
+ ENV["CLACKY_SERVER_HOST"] = (@host == "0.0.0.0" ? "127.0.0.1" : @host)
185
+ product_name = Clacky::BrandConfig.load.product_name
186
+ ENV["CLACKY_PRODUCT_NAME"] = (product_name.nil? || product_name.strip.empty?) ? "OpenClacky" : product_name
187
+
174
188
  # Override WEBrick's built-in signal traps via StartCallback,
175
189
  # which fires after WEBrick sets its own INT/TERM handlers.
176
190
  # This ensures Ctrl-C always exits immediately.
@@ -243,6 +257,9 @@ module Clacky
243
257
  # Start IM channel adapters (non-blocking — each platform runs in its own thread)
244
258
  @channel_manager.start
245
259
 
260
+ # Start browser MCP daemon if browser.yml is configured (non-blocking)
261
+ @browser_manager.start
262
+
246
263
  server.start
247
264
  end
248
265
 
@@ -261,7 +278,7 @@ module Clacky
261
278
  end
262
279
 
263
280
  case [method, path]
264
- when ["GET", "/api/sessions"] then api_list_sessions(res)
281
+ when ["GET", "/api/sessions"] then api_list_sessions(req, res)
265
282
  when ["POST", "/api/sessions"] then api_create_session(req, res)
266
283
  when ["GET", "/api/schedules"] then api_list_schedules(res)
267
284
  when ["POST", "/api/schedules"] then api_create_schedule(req, res)
@@ -274,6 +291,10 @@ module Clacky
274
291
  when ["POST", "/api/config/test"] then api_test_config(req, res)
275
292
  when ["GET", "/api/providers"] then api_list_providers(res)
276
293
  when ["GET", "/api/onboard/status"] then api_onboard_status(res)
294
+ when ["GET", "/api/browser/status"] then api_browser_status(res)
295
+ when ["POST", "/api/browser/configure"] then api_browser_configure(req, res)
296
+ when ["POST", "/api/browser/reload"] then api_browser_reload(res)
297
+ when ["POST", "/api/browser/toggle"] then api_browser_toggle(res)
277
298
  when ["POST", "/api/onboard/complete"] then api_onboard_complete(req, res)
278
299
  when ["POST", "/api/onboard/skip-soul"] then api_onboard_skip_soul(req, res)
279
300
  when ["GET", "/api/store/skills"] then api_store_skills(res)
@@ -282,6 +303,7 @@ module Clacky
282
303
  when ["GET", "/api/brand/skills"] then api_brand_skills(res)
283
304
  when ["GET", "/api/brand"] then api_brand_info(res)
284
305
  when ["GET", "/api/channels"] then api_list_channels(res)
306
+ when ["POST", "/api/tool/browser"] then api_tool_browser(req, res)
285
307
  when ["POST", "/api/upload"] then api_upload_file(req, res)
286
308
  when ["GET", "/api/version"] then api_get_version(res)
287
309
  when ["POST", "/api/version/upgrade"] then api_upgrade_version(req, res)
@@ -337,8 +359,17 @@ module Clacky
337
359
 
338
360
  # ── REST API ──────────────────────────────────────────────────────────────
339
361
 
340
- def api_list_sessions(res)
341
- json_response(res, 200, { sessions: @registry.list })
362
+ def api_list_sessions(req, res)
363
+ query = URI.decode_www_form(req.query_string.to_s).to_h
364
+ limit = [query["limit"].to_i.then { |n| n > 0 ? n : 10 }, 50].min
365
+ before = query["before"].to_s.strip.then { |v| v.empty? ? nil : v }
366
+ source = query["source"].to_s.strip.then { |v| v.empty? ? nil : v }
367
+ profile = query["profile"].to_s.strip.then { |v| v.empty? ? nil : v }
368
+ # Fetch one extra to detect has_more without a separate count query
369
+ sessions = @registry.list(limit: limit + 1, before: before, source: source, profile: profile)
370
+ has_more = sessions.size > limit
371
+ sessions = sessions.first(limit)
372
+ json_response(res, 200, { sessions: sessions, has_more: has_more })
342
373
  end
343
374
 
344
375
  def api_create_session(req, res)
@@ -346,11 +377,20 @@ module Clacky
346
377
  name = body["name"]
347
378
  return json_response(res, 400, { error: "name is required" }) if name.nil? || name.strip.empty?
348
379
 
380
+ # Optional agent_profile; defaults to "general" if omitted or invalid
381
+ profile = body["agent_profile"].to_s.strip
382
+ profile = "general" if profile.empty?
383
+
384
+ # Optional source; defaults to :manual. Accept "system" for skill-launched sessions
385
+ # (e.g. /onboard, /browser-setup, /channel-setup).
386
+ raw_source = body["source"].to_s.strip
387
+ source = %w[manual cron channel setup].include?(raw_source) ? raw_source.to_sym : :manual
388
+
349
389
  working_dir = default_working_dir
350
390
  FileUtils.mkdir_p(working_dir)
351
391
 
352
- session_id = build_session(name: name, working_dir: working_dir)
353
- json_response(res, 201, { session: @registry.list.find { |s| s[:id] == session_id } })
392
+ session_id = build_session(name: name, working_dir: working_dir, profile: profile, source: source)
393
+ json_response(res, 201, { session: @registry.session_summary(session_id) })
354
394
  end
355
395
 
356
396
  # Auto-restore persisted sessions (or create a fresh default) when the server starts.
@@ -363,15 +403,13 @@ module Clacky
363
403
  def create_default_session
364
404
  return unless @agent_config.models_configured?
365
405
 
366
- working_dir = default_working_dir
367
- FileUtils.mkdir_p(working_dir) unless Dir.exist?(working_dir)
368
-
369
- # Restore the most recent 5 sessions for this working directory
370
- sessions_data = @session_manager.latest_n_for_directory(working_dir, 5)
406
+ # Restore up to 5 sessions per source type from disk into the registry.
407
+ @registry.restore_from_disk(n: 5)
371
408
 
372
- if sessions_data.any?
373
- sessions_data.each { |session_data| build_session_from_data(session_data) }
374
- else
409
+ # If nothing was restored (no persisted sessions), create a fresh default.
410
+ unless @registry.list(limit: 1).any?
411
+ working_dir = default_working_dir
412
+ FileUtils.mkdir_p(working_dir) unless Dir.exist?(working_dir)
375
413
  build_session(name: "Session 1", working_dir: working_dir)
376
414
  end
377
415
  end
@@ -390,12 +428,50 @@ module Clacky
390
428
  end
391
429
  end
392
430
 
431
+ # GET /api/browser/status
432
+ # Returns real daemon liveness from BrowserManager (not just yml read).
433
+ def api_browser_status(res)
434
+ json_response(res, 200, @browser_manager.status)
435
+ end
436
+
437
+ # POST /api/browser/configure
438
+ # Called by browser-setup skill to write browser.yml and hot-reload the daemon.
439
+ # Body: { chrome_version: "146" }
440
+ def api_browser_configure(req, res)
441
+ body = JSON.parse(req.body.to_s) rescue {}
442
+ chrome_version = body["chrome_version"].to_s.strip
443
+ return json_response(res, 422, { ok: false, error: "chrome_version is required" }) if chrome_version.empty?
444
+
445
+ @browser_manager.configure(chrome_version: chrome_version)
446
+ json_response(res, 200, { ok: true })
447
+ rescue StandardError => e
448
+ json_response(res, 500, { ok: false, error: e.message })
449
+ end
450
+
451
+ # POST /api/browser/reload
452
+ # Called by browser-setup skill after writing browser.yml.
453
+ # Hot-reloads the MCP daemon with the new configuration.
454
+ def api_browser_reload(res)
455
+ @browser_manager.reload
456
+ json_response(res, 200, { ok: true })
457
+ rescue StandardError => e
458
+ json_response(res, 500, { ok: false, error: e.message })
459
+ end
460
+
461
+ # POST /api/browser/toggle
462
+ def api_browser_toggle(res)
463
+ enabled = @browser_manager.toggle
464
+ json_response(res, 200, { ok: true, enabled: enabled })
465
+ rescue StandardError => e
466
+ json_response(res, 500, { ok: false, error: e.message })
467
+ end
468
+
393
469
  # POST /api/onboard/complete
394
470
  # Called after key setup is done (soul_setup is optional/skipped).
395
471
  # Creates the default session if none exists yet, returns it.
396
472
  def api_onboard_complete(req, res)
397
- create_default_session if @registry.list.empty?
398
- first_session = @registry.list.first
473
+ create_default_session if @registry.list(limit: 1).empty?
474
+ first_session = @registry.list(limit: 1).first
399
475
  json_response(res, 200, { ok: true, session: first_session })
400
476
  end
401
477
 
@@ -674,8 +750,17 @@ module Clacky
674
750
  argv = @restart_argv
675
751
  Thread.new do
676
752
  sleep 0.5 # Let WEBrick flush the HTTP response
677
- Clacky::Logger.info("[Restart] exec: #{RbConfig.ruby} #{script} #{argv.join(' ')}")
678
- exec(RbConfig.ruby, script, *argv)
753
+
754
+ # Use login shell to re-exec so rbenv/mise shims resolve the newly installed gem version.
755
+ # Direct `exec(RbConfig.ruby, script, *argv)` would reuse the old Ruby interpreter path
756
+ # and miss gem updates installed under a different Ruby version managed by rbenv/mise.
757
+ shell = ENV["SHELL"].to_s
758
+ shell = "/bin/bash" if shell.empty?
759
+ cmd_parts = [Shellwords.escape(script), *argv.map { |a| Shellwords.escape(a) }]
760
+ cmd_string = cmd_parts.join(" ")
761
+
762
+ Clacky::Logger.info("[Restart] exec: #{shell} -l -c #{cmd_string}")
763
+ exec(shell, "-l", "-c", cmd_string)
679
764
  end
680
765
  end
681
766
 
@@ -753,6 +838,28 @@ module Clacky
753
838
 
754
839
  # GET /api/channels
755
840
  # Returns current config and running status for all supported platforms.
841
+ # POST /api/tool/browser
842
+ # Executes a browser tool action via the shared BrowserManager daemon.
843
+ # Used by skill scripts (e.g. feishu_setup.rb) to reuse the server's
844
+ # existing Chrome connection without spawning a second MCP daemon.
845
+ #
846
+ # Request body: JSON with same params as the browser tool
847
+ # { "action": "snapshot", "interactive": true, ... }
848
+ #
849
+ # Response: JSON result from the browser tool
850
+ def api_tool_browser(req, res)
851
+ params = parse_json_body(req)
852
+ action = params["action"]
853
+ return json_response(res, 400, { error: "action is required" }) if action.nil? || action.empty?
854
+
855
+ tool = Clacky::Tools::Browser.new
856
+ result = tool.execute(**params.transform_keys(&:to_sym))
857
+
858
+ json_response(res, 200, result)
859
+ rescue StandardError => e
860
+ json_response(res, 500, { error: e.message })
861
+ end
862
+
756
863
  def api_list_channels(res)
757
864
  config = Clacky::ChannelConfig.load
758
865
  running = @channel_manager.running_platforms
@@ -782,18 +889,12 @@ module Clacky
782
889
  return
783
890
  end
784
891
 
785
- ref = Clacky::Utils::FileProcessor.process(
892
+ saved = Clacky::Utils::FileProcessor.save(
786
893
  body: upload[:data],
787
894
  filename: upload[:filename].to_s
788
895
  )
789
896
 
790
- json_response(res, 200, {
791
- ok: true,
792
- name: ref.name,
793
- type: ref.type.to_s,
794
- path: ref.original_path,
795
- preview_path: ref.preview_path
796
- })
897
+ json_response(res, 200, { ok: true, name: saved[:name], path: saved[:path] })
797
898
  rescue => e
798
899
  json_response(res, 500, { ok: false, error: e.message })
799
900
  end
@@ -808,6 +909,20 @@ module Clacky
808
909
 
809
910
  fields = body.transform_keys(&:to_sym).reject { |k, _| k == :platform }
810
911
  fields = fields.transform_values { |v| v.is_a?(String) ? v.strip : v }
912
+
913
+ # Validate credentials against live API before persisting.
914
+ # Merge with existing config so partial updates (e.g. allowed_users only) still validate correctly.
915
+ klass = Clacky::Channel::Adapters.find(platform)
916
+ if klass && klass.respond_to?(:test_connection)
917
+ existing = config.platform_config(platform) || {}
918
+ merged = existing.merge(fields)
919
+ result = klass.test_connection(merged)
920
+ unless result[:ok]
921
+ json_response(res, 422, { ok: false, error: result[:error] || "Credential validation failed" })
922
+ return
923
+ end
924
+ end
925
+
811
926
  config.set_platform(platform, **fields)
812
927
  config.save
813
928
 
@@ -868,6 +983,13 @@ module Clacky
868
983
  {
869
984
  bot_id: raw["bot_id"] || ""
870
985
  }
986
+ when :weixin
987
+ {
988
+ base_url: raw["base_url"] || Clacky::Channel::Adapters::Weixin::ApiClient::DEFAULT_BASE_URL,
989
+ allowed_users: raw["allowed_users"] || [],
990
+ # Indicate whether a token is present (never expose the token itself)
991
+ has_token: !raw["token"].to_s.strip.empty?
992
+ }
871
993
  else
872
994
  {}
873
995
  end
@@ -1039,8 +1161,7 @@ module Clacky
1039
1161
  # after the client has subscribed and is ready to receive broadcasts.
1040
1162
  @registry.update(session_id, pending_task: prompt, pending_working_dir: working_dir)
1041
1163
 
1042
- session = @registry.list.find { |s| s[:id] == session_id }
1043
- json_response(res, 202, { ok: true, session: session })
1164
+ json_response(res, 202, { ok: true, session: @registry.session_summary(session_id) })
1044
1165
  rescue => e
1045
1166
  json_response(res, 422, { error: e.message })
1046
1167
  end
@@ -1071,6 +1192,10 @@ module Clacky
1071
1192
  # filtered by the session's agent profile. Used by the frontend slash-command
1072
1193
  # autocomplete so only skills valid for the current profile are suggested.
1073
1194
  def api_session_skills(session_id, res)
1195
+ unless @registry.ensure(session_id)
1196
+ json_response(res, 404, { error: "Session not found" })
1197
+ return
1198
+ end
1074
1199
  session = @registry.get(session_id)
1075
1200
  unless session
1076
1201
  json_response(res, 404, { error: "Session not found" })
@@ -1089,10 +1214,16 @@ module Clacky
1089
1214
  skills = agent.skill_loader.user_invocable_skills
1090
1215
  skills = skills.select { |s| s.allowed_for_agent?(profile.name) } if profile
1091
1216
 
1217
+ loader = agent.skill_loader
1218
+ loaded_from = loader.loaded_from
1219
+
1092
1220
  skill_data = skills.map do |skill|
1221
+ source_type = loaded_from[skill.identifier]
1093
1222
  {
1094
1223
  name: skill.identifier,
1095
- description: skill.description || skill.context_description
1224
+ description: skill.description || skill.context_description,
1225
+ encrypted: skill.encrypted?,
1226
+ source_type: source_type
1096
1227
  }
1097
1228
  end
1098
1229
 
@@ -1326,7 +1457,7 @@ module Clacky
1326
1457
  # Replays conversation history for a session via the agent's replay_history method.
1327
1458
  # Returns a list of UI events (same format as WS events) for the frontend to render.
1328
1459
  def api_session_messages(session_id, req, res)
1329
- unless @registry.exist?(session_id)
1460
+ unless @registry.ensure(session_id)
1330
1461
  return json_response(res, 404, { error: "Session not found" })
1331
1462
  end
1332
1463
 
@@ -1355,7 +1486,7 @@ module Clacky
1355
1486
  new_name = body["name"].to_s.strip
1356
1487
 
1357
1488
  return json_response(res, 400, { error: "name is required" }) if new_name.empty?
1358
- return json_response(res, 404, { error: "Session not found" }) unless @registry.exist?(session_id)
1489
+ return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
1359
1490
 
1360
1491
  agent = nil
1361
1492
  @registry.with_session(session_id) { |s| agent = s[:agent] }
@@ -1369,6 +1500,8 @@ module Clacky
1369
1500
 
1370
1501
  def api_delete_session(session_id, res)
1371
1502
  if @registry.delete(session_id)
1503
+ # Also remove the persisted session file from disk
1504
+ @session_manager.delete(session_id)
1372
1505
  # Notify connected clients the session is gone
1373
1506
  broadcast(session_id, { type: "session_deleted", session_id: session_id })
1374
1507
  unsubscribe_all(session_id)
@@ -1442,7 +1575,7 @@ module Clacky
1442
1575
  case type
1443
1576
  when "subscribe"
1444
1577
  session_id = msg["session_id"]
1445
- if @registry.exist?(session_id)
1578
+ if @registry.ensure(session_id)
1446
1579
  conn.session_id = session_id
1447
1580
  subscribe(session_id, conn)
1448
1581
  conn.send_json(type: "subscribed", session_id: session_id)
@@ -1467,7 +1600,24 @@ module Clacky
1467
1600
  interrupt_session(session_id)
1468
1601
 
1469
1602
  when "list_sessions"
1470
- conn.send_json(type: "session_list", sessions: @registry.list)
1603
+ # Initial load: 5 per bucket so all tabs/sections get their first page.
1604
+ # General area tabs: manual / cron / channel / setup — filtered by source.
1605
+ # Coding section: profile=coding — source is irrelevant (no source filter).
1606
+ # has_more_by_source drives independent load-more buttons on the frontend.
1607
+ buckets = {
1608
+ "manual" => { source: "manual", profile: "general" },
1609
+ "cron" => { source: "cron", profile: "general" },
1610
+ "channel" => { source: "channel", profile: "general" },
1611
+ "setup" => { source: "setup", profile: "general" },
1612
+ "coding" => { profile: "coding" },
1613
+ }
1614
+ by_bucket = buckets.each_with_object({}) do |(key, params), h|
1615
+ page = @registry.list(limit: 6, **params) # +1 to detect has_more
1616
+ h[key] = { sessions: page.first(5), has_more: page.size > 5 }
1617
+ end
1618
+ all_sessions = by_bucket.values.flat_map { |v| v[:sessions] }.uniq { |s| s[:id] }
1619
+ has_more_map = by_bucket.transform_values { |v| v[:has_more] }
1620
+ conn.send_json(type: "session_list", sessions: all_sessions, has_more_by_source: has_more_map)
1471
1621
 
1472
1622
  when "run_task"
1473
1623
  # Client sends this after subscribing to guarantee it's ready to receive
@@ -1513,6 +1663,11 @@ module Clacky
1513
1663
  broadcast(session_id, { type: "session_renamed", session_id: session_id, name: auto_name })
1514
1664
  end
1515
1665
 
1666
+ # Broadcast user message through web_ui so channel subscribers (飞书/企微) receive it.
1667
+ web_ui = nil
1668
+ @registry.with_session(session_id) { |s| web_ui = s[:ui] }
1669
+ web_ui&.show_user_message(content, source: :web)
1670
+
1516
1671
  # File references are now handled inside agent.run — injected as a system_injected
1517
1672
  # message after the user message, so replay_history skips them automatically.
1518
1673
  run_agent_task(session_id, agent) { agent.run(content, files: files) }
@@ -1580,7 +1735,10 @@ module Clacky
1580
1735
  rescue => e
1581
1736
  @registry.update(session_id, status: :error, error: e.message)
1582
1737
  broadcast_session_update(session_id)
1583
- broadcast(session_id, { type: "error", session_id: session_id, message: e.message })
1738
+ # Route error through web_ui so channel subscribers (飞书/企微) receive it too.
1739
+ web_ui = nil
1740
+ @registry.with_session(session_id) { |s| web_ui = s[:ui] }
1741
+ web_ui&.show_error(e.message)
1584
1742
  @session_manager.save(agent.to_session_data(status: :error, error_message: e.message))
1585
1743
  end
1586
1744
  @registry.with_session(session_id) { |s| s[:thread] = thread }
@@ -1623,7 +1781,7 @@ module Clacky
1623
1781
  # Broadcast a session_update event to all clients so they can patch their
1624
1782
  # local session list without needing a full session_list refresh.
1625
1783
  def broadcast_session_update(session_id)
1626
- session = @registry.list.find { |s| s[:id] == session_id }
1784
+ session = @registry.list(limit: 200).find { |s| s[:id] == session_id }
1627
1785
  return unless session
1628
1786
 
1629
1787
  broadcast_all(type: "session_update", session: session)
@@ -1631,7 +1789,6 @@ module Clacky
1631
1789
 
1632
1790
  # ── Helpers ───────────────────────────────────────────────────────────────
1633
1791
 
1634
- # Default working directory for new sessions.
1635
1792
  def default_working_dir
1636
1793
  File.expand_path("~/clacky_workspace")
1637
1794
  end
@@ -1643,7 +1800,7 @@ module Clacky
1643
1800
  # @param working_dir [String] working directory for the agent
1644
1801
  # @param permission_mode [Symbol] :confirm_all (default, human present) or
1645
1802
  # :auto_approve (unattended — suppresses request_user_feedback waits)
1646
- def build_session(name:, working_dir:, permission_mode: :confirm_all, profile: "general")
1803
+ def build_session(name:, working_dir:, permission_mode: :confirm_all, profile: "general", source: :manual)
1647
1804
  session_id = Clacky::SessionManager.generate_id
1648
1805
  @registry.create(session_id: session_id)
1649
1806
 
@@ -1653,7 +1810,7 @@ module Clacky
1653
1810
  broadcaster = method(:broadcast)
1654
1811
  ui = WebUIController.new(session_id, broadcaster)
1655
1812
  agent = Clacky::Agent.new(client, config, working_dir: working_dir, ui: ui, profile: profile,
1656
- session_id: session_id)
1813
+ session_id: session_id, source: source)
1657
1814
  agent.rename(name) unless name.nil? || name.empty?
1658
1815
  idle_timer = build_idle_timer(session_id, agent)
1659
1816
 
@@ -1663,6 +1820,10 @@ module Clacky
1663
1820
  s[:idle_timer] = idle_timer
1664
1821
  end
1665
1822
 
1823
+ # Persist an initial snapshot so the session is immediately visible in registry.list
1824
+ # (which reads from disk). Without this, new sessions only appear after their first task.
1825
+ @session_manager.save(agent.to_session_data)
1826
+
1666
1827
  session_id
1667
1828
  end
1668
1829
 
@@ -1683,7 +1844,11 @@ module Clacky
1683
1844
  config.permission_mode = permission_mode
1684
1845
  broadcaster = method(:broadcast)
1685
1846
  ui = WebUIController.new(original_id, broadcaster)
1686
- agent = Clacky::Agent.from_session(client, config, session_data, ui: ui, profile: "general")
1847
+ # Restore the agent profile from the persisted session; fall back to "general"
1848
+ # for sessions saved before the agent_profile field was introduced.
1849
+ profile = session_data[:agent_profile].to_s
1850
+ profile = "general" if profile.empty?
1851
+ agent = Clacky::Agent.from_session(client, config, session_data, ui: ui, profile: profile)
1687
1852
  idle_timer = build_idle_timer(original_id, agent)
1688
1853
 
1689
1854
  @registry.with_session(original_id) do |s|
@@ -168,7 +168,7 @@ module Clacky
168
168
  FileUtils.mkdir_p(working_dir)
169
169
 
170
170
  # Scheduled tasks run unattended — use auto_approve so request_user_feedback doesn't block.
171
- session_id = @session_builder.call(name: name, working_dir: working_dir, permission_mode: :auto_approve)
171
+ session_id = @session_builder.call(name: name, working_dir: working_dir, permission_mode: :auto_approve, source: :cron)
172
172
 
173
173
  Clacky::Logger.info("scheduler_task_fired", task: task_name, session: session_id)
174
174