openclacky 1.2.6 → 1.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +34 -0
  4. data/README_CN.md +34 -0
  5. data/lib/clacky/agent/cost_tracker.rb +7 -1
  6. data/lib/clacky/agent/message_compressor.rb +2 -1
  7. data/lib/clacky/agent/message_compressor_helper.rb +6 -2
  8. data/lib/clacky/agent/session_serializer.rb +23 -4
  9. data/lib/clacky/agent.rb +46 -2
  10. data/lib/clacky/agent_config.rb +54 -6
  11. data/lib/clacky/billing/billing_store.rb +107 -3
  12. data/lib/clacky/brand_config.rb +0 -6
  13. data/lib/clacky/cli.rb +107 -1
  14. data/lib/clacky/client.rb +56 -6
  15. data/lib/clacky/default_skills/deploy/SKILL.md +2 -1
  16. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +39 -0
  17. data/lib/clacky/default_skills/mcp-manager/SKILL.md +0 -7
  18. data/lib/clacky/default_skills/onboard/SKILL.md +2 -2
  19. data/lib/clacky/json_ui_controller.rb +5 -2
  20. data/lib/clacky/patch_loader.rb +282 -0
  21. data/lib/clacky/plain_ui_controller.rb +1 -1
  22. data/lib/clacky/providers.rb +11 -2
  23. data/lib/clacky/server/channel/adapters/base.rb +4 -0
  24. data/lib/clacky/server/channel/channel_manager.rb +149 -13
  25. data/lib/clacky/server/channel/channel_ui_controller.rb +4 -2
  26. data/lib/clacky/server/channel/user_adapter_loader.rb +177 -0
  27. data/lib/clacky/server/channel.rb +5 -0
  28. data/lib/clacky/server/http_server.rb +135 -14
  29. data/lib/clacky/server/scheduler.rb +1 -4
  30. data/lib/clacky/server/session_registry.rb +30 -4
  31. data/lib/clacky/server/web_ui_controller.rb +6 -3
  32. data/lib/clacky/shell_hook_loader.rb +181 -0
  33. data/lib/clacky/tools/terminal.rb +22 -26
  34. data/lib/clacky/ui2/ui_controller.rb +1 -1
  35. data/lib/clacky/ui_interface.rb +1 -1
  36. data/lib/clacky/version.rb +1 -1
  37. data/lib/clacky/web/app.css +392 -14
  38. data/lib/clacky/web/app.js +0 -1
  39. data/lib/clacky/web/billing.js +117 -22
  40. data/lib/clacky/web/i18n.js +50 -6
  41. data/lib/clacky/web/index.html +33 -0
  42. data/lib/clacky/web/sessions.js +203 -14
  43. data/lib/clacky/web/settings.js +59 -17
  44. data/lib/clacky/web/workspace.js +204 -0
  45. data/lib/clacky/web/ws-dispatcher.js +19 -3
  46. data/lib/clacky.rb +15 -0
  47. metadata +7 -2
@@ -234,7 +234,7 @@ module Clacky
234
234
  return if (text.nil? || text.empty?) && files.empty?
235
235
 
236
236
  # Handle built-in commands
237
- if text&.start_with?("/")
237
+ if text&.match?(KNOWN_COMMAND) || text&.match?(/\A([\?h]|help)\z/i)
238
238
  handle_command(adapter, event, text)
239
239
  return
240
240
  end
@@ -249,7 +249,8 @@ module Clacky
249
249
  return
250
250
  end
251
251
 
252
- Clacky::Logger.info("[ChannelManager] Routing to session #{session_id[0, 8]} (status=#{session[:status]})")
252
+ sub_count = web_ui_for_session_diag(session_id)
253
+ Clacky::Logger.info("[ChannelManager] Routing to session #{session_id[0, 8]} (status=#{session[:status]}, text=#{text.inspect}, channel_subs=#{sub_count})")
253
254
 
254
255
  # If session is running, interrupt it automatically (mimics CLI behavior)
255
256
  if session[:status] == :running
@@ -283,7 +284,12 @@ module Clacky
283
284
 
284
285
  @run_agent_task.call(session_id, agent) do
285
286
  begin
287
+ Clacky::Logger.info("[ChannelManager] agent.run START session=#{session_id[0, 8]} text=#{text.inspect}")
286
288
  agent.run(text, files: files)
289
+ Clacky::Logger.info("[ChannelManager] agent.run END session=#{session_id[0, 8]} text=#{text.inspect}")
290
+ rescue StandardError => e
291
+ Clacky::Logger.error("[ChannelManager] agent.run RAISED session=#{session_id[0, 8]} #{e.class}: #{e.message}\n#{e.backtrace.first(8).join("\n")}")
292
+ raise
287
293
  ensure
288
294
  adapter.stop_typing_keepalive(chat_id) if adapter.respond_to?(:stop_typing_keepalive)
289
295
  end
@@ -295,11 +301,24 @@ module Clacky
295
301
  key = channel_key(event)
296
302
 
297
303
  case text
304
+ when /\A([\?h]|help)\z/i
305
+ adapter.send_text(chat_id, COMMAND_HELP)
306
+
307
+ when "/new", "/clear"
308
+ session_id = auto_create_session(adapter, event)
309
+ adapter.send_text(chat_id, "New session `#{session_id[0, 8]}` created.") if session_id
310
+
311
+ when /\A\/model\b/i
312
+ handle_model_command(adapter, event, text)
313
+
314
+ when /\A\/skills\b/i
315
+ handle_skills_command(adapter, event)
316
+
298
317
  when /\A\/bind\s+(\S+)\z/i
299
318
  arg = Regexp.last_match(1)
300
319
  # Support numeric index from /list (1-based)
301
320
  session_id = if arg =~ /\A\d+\z/
302
- recent = @registry.list.last(5).reverse
321
+ recent = @registry.list.first(5)
303
322
  idx = arg.to_i - 1
304
323
  recent[idx]&.fetch(:id, nil)
305
324
  else
@@ -351,7 +370,9 @@ module Clacky
351
370
  session_id = resolve_session(event)
352
371
  if session_id
353
372
  session = @registry.get(session_id)
354
- adapter.send_text(chat_id, "Bound to session `#{session_id[0, 8]}` (status: #{session&.dig(:status) || "unknown"})")
373
+ model = session&.dig(:agent)&.current_model_info
374
+ model_name = model&.dig(:model) || "unknown"
375
+ adapter.send_text(chat_id, "Bound to session `#{session_id[0, 8]}` (status: #{session&.dig(:status) || "unknown"}, model: #{model_name})")
355
376
  else
356
377
  adapter.send_text(chat_id, "No session bound yet. Send any message to auto-create one.")
357
378
  end
@@ -360,16 +381,118 @@ module Clacky
360
381
  list_sessions(adapter, chat_id)
361
382
 
362
383
  else
363
- adapter.send_text(chat_id,
364
- "Commands:\n" \
365
- " /bind <n|session_id> - switch to a session (use /list to see numbers)\n" \
366
- " /unbind - remove binding\n" \
367
- " /stop - interrupt current task\n" \
368
- " /status - show current binding\n" \
369
- " /list - show recent sessions")
384
+ adapter.send_text(chat_id, "Unknown command. Type ? for help.")
370
385
  end
371
386
  end
372
387
 
388
+ KNOWN_COMMAND = %r{\A/(new|clear|model|skills|bind|stop|unbind|status|list)\b}i
389
+
390
+ COMMAND_HELP = <<~HELP.strip
391
+ Commands:
392
+ ? / h / help - show this help
393
+ /new / /clear - start a new session
394
+ /model - show current model & available models
395
+ /model <n> - switch to model n
396
+ /skills - list available skills
397
+ /<skill> <args> - invoke a skill directly
398
+ /bind <n|session_id> - switch to a session (use /list to see numbers)
399
+ /unbind - remove binding
400
+ /stop - interrupt current task
401
+ /status - show current binding
402
+ /list - show recent sessions
403
+ HELP
404
+
405
+ def handle_model_command(adapter, event, text)
406
+ chat_id = event[:chat_id]
407
+ session_id = resolve_session(event)
408
+
409
+ unless session_id
410
+ adapter.send_text(chat_id, "No session bound. Send any message to auto-create one first.")
411
+ return
412
+ end
413
+
414
+ session = @registry.get(session_id)
415
+ agent = session&.dig(:agent)
416
+ unless agent
417
+ adapter.send_text(chat_id, "Session not ready.")
418
+ return
419
+ end
420
+
421
+ arg = text.sub(/\A\/model\s*/i, "").strip
422
+
423
+ if arg.empty?
424
+ # Show current model and available list
425
+ info = agent.current_model_info
426
+ current = info&.dig(:model) || "unknown"
427
+ sub = info&.dig(:sub_model)
428
+ card = info&.dig(:card_model)
429
+ header = "Current model: #{current}"
430
+ header += " (#{card} · #{sub})" if card && sub && sub != current
431
+ header += " (#{card})" if card && !sub
432
+
433
+ models = agent.available_models
434
+ if models.empty?
435
+ adapter.send_text(chat_id, "#{header}\nNo other models available.")
436
+ return
437
+ end
438
+
439
+ lines = models.each_with_index.map do |name, i|
440
+ marker = name == current ? " *" : ""
441
+ "#{i + 1}. #{name}#{marker}"
442
+ end
443
+ adapter.send_text(chat_id, "#{header}\n\nSwitch with /model <n>:\n#{lines.join("\n")}")
444
+ elsif arg =~ /\A\d+\z/
445
+ idx = arg.to_i - 1
446
+ models = agent.config.models
447
+ if idx < 0 || idx >= models.length
448
+ adapter.send_text(chat_id, "Invalid model number. Use /model to see available models.")
449
+ return
450
+ end
451
+
452
+ model_id = models[idx]["id"]
453
+ if agent.switch_model_by_id(model_id)
454
+ new_info = agent.current_model_info
455
+ adapter.send_text(chat_id, "Switched to #{new_info&.dig(:model) || model_id}.")
456
+ else
457
+ adapter.send_text(chat_id, "Failed to switch model.")
458
+ end
459
+ else
460
+ adapter.send_text(chat_id, "Usage: /model to list, /model <n> to switch.")
461
+ end
462
+ end
463
+
464
+ def handle_skills_command(adapter, event)
465
+ chat_id = event[:chat_id]
466
+ session_id = resolve_session(event)
467
+
468
+ unless session_id
469
+ adapter.send_text(chat_id, "No session bound. Send any message to auto-create one first.")
470
+ return
471
+ end
472
+
473
+ session = @registry.get(session_id)
474
+ agent = session&.dig(:agent)
475
+ unless agent
476
+ adapter.send_text(chat_id, "Session not ready.")
477
+ return
478
+ end
479
+
480
+ skills = agent.skill_loader.user_invocable_skills
481
+ .reject { |s| s.source == :default }
482
+ .first(10)
483
+ if skills.empty?
484
+ adapter.send_text(chat_id, "No skills available.")
485
+ return
486
+ end
487
+
488
+ lines = skills.each_with_index.map do |s, i|
489
+ desc = s.description.to_s.strip
490
+ desc = desc.empty? ? "(no description)" : desc.length > 50 ? "#{desc[0..49]}..." : desc
491
+ "#{i + 1}. #{s.name} - #{desc}"
492
+ end
493
+ adapter.send_text(chat_id, "Skills:\n#{lines.join("\n")}")
494
+ end
495
+
373
496
  def resolve_session(event)
374
497
  key = channel_key(event)
375
498
  @registry.list.each do |summary|
@@ -388,7 +511,7 @@ module Clacky
388
511
  platform = event[:platform].to_s
389
512
  count = @mutex.synchronize { @session_counters[platform] += 1 }
390
513
  name = "#{platform}-#{count}"
391
- session_id = @session_builder.call(name: name, working_dir: Dir.home, source: :channel)
514
+ session_id = @session_builder.call(name: name, source: :channel)
392
515
  bind_key_to_session(key, session_id)
393
516
 
394
517
  # Create a long-lived ChannelUIController for this session and subscribe it
@@ -411,6 +534,19 @@ module Clacky
411
534
  result
412
535
  end
413
536
 
537
+ def web_ui_for_session_diag(session_id)
538
+ result = nil
539
+ @registry.with_session(session_id) do |s|
540
+ ui = s[:ui]
541
+ result = if ui.respond_to?(:channel_subscribed?)
542
+ ui.instance_variable_get(:@channel_subscribers)&.size || 0
543
+ else
544
+ -1
545
+ end
546
+ end
547
+ result
548
+ end
549
+
414
550
  def bind_key_to_session(key, session_id)
415
551
  @registry.list.each do |summary|
416
552
  @registry.with_session(summary[:id]) { |s| s[:channel_keys]&.delete(key) }
@@ -422,7 +558,7 @@ module Clacky
422
558
  end
423
559
 
424
560
  def list_sessions(adapter, chat_id)
425
- sessions = @registry.list.last(5).reverse
561
+ sessions = @registry.list.first(5)
426
562
  if sessions.empty?
427
563
  adapter.send_text(chat_id, "No sessions available.")
428
564
  return
@@ -145,8 +145,10 @@ module Clacky
145
145
  send_text("Warning: #{message}")
146
146
  end
147
147
 
148
- def show_error(message)
149
- send_text("Error: #{message}")
148
+ def show_error(message, code: nil, top_up_url: nil)
149
+ text = "Error: #{message}"
150
+ text += "\n#{top_up_url}" if top_up_url
151
+ send_text(text)
150
152
  end
151
153
 
152
154
  def show_success(message)
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Clacky
6
+ module Channel
7
+ module Adapters
8
+ # Loads user-defined channel adapters from ~/.clacky/channels/<name>/adapter.rb.
9
+ #
10
+ # Each adapter file is plain Ruby that defines a subclass of
11
+ # Clacky::Channel::Adapters::Base and self-registers via Adapters.register,
12
+ # exactly like the bundled adapters. This loader only discovers and requires
13
+ # those files after the built-in adapters are loaded — the existing
14
+ # self-registration mechanism then takes over with no further wiring.
15
+ #
16
+ # A broken adapter (syntax error, missing interface methods) is isolated:
17
+ # it is skipped with a logged warning and never aborts the load of others.
18
+ module UserAdapterLoader
19
+ DEFAULT_DIR = File.expand_path("~/.clacky/channels")
20
+
21
+ # Required class/instance methods a user adapter must implement to be usable.
22
+ REQUIRED_CLASS_METHODS = %i[platform_id platform_config].freeze
23
+ REQUIRED_INSTANCE_METHODS = %i[start stop send_text].freeze
24
+
25
+ Result = Struct.new(:loaded, :skipped, keyword_init: true)
26
+
27
+ # @param dir [String] directory to scan (override for tests)
28
+ # @return [Result] names loaded and skipped (with reasons)
29
+ def self.load_all(dir: DEFAULT_DIR)
30
+ result = Result.new(loaded: [], skipped: [])
31
+ if Dir.exist?(dir)
32
+ Dir.glob(File.join(dir, "*", "adapter.rb")).sort.each do |path|
33
+ name = File.basename(File.dirname(path))
34
+ load_one(path, name, result)
35
+ end
36
+ end
37
+ @last_result = result
38
+ result
39
+ end
40
+
41
+ # The result of the most recent load_all (set at startup). Lets `channel_verify`
42
+ # report status without re-requiring files (require is idempotent and would
43
+ # otherwise report already-loaded adapters as "did not register").
44
+ def self.last_result
45
+ @last_result || load_all
46
+ end
47
+
48
+ def self.load_one(path, name, result)
49
+ before = Adapters.all.dup
50
+
51
+ require path
52
+
53
+ newly = Adapters.all - before
54
+ klass = newly.last
55
+
56
+ unless klass
57
+ result.skipped << [name, "did not register an adapter (missing Adapters.register?)"]
58
+ log_skip(name, result.skipped.last[1])
59
+ return
60
+ end
61
+
62
+ if (missing = interface_gaps(klass)).any?
63
+ unregister(klass)
64
+ result.skipped << [name, "missing required methods: #{missing.join(", ")}"]
65
+ log_skip(name, result.skipped.last[1])
66
+ return
67
+ end
68
+
69
+ result.loaded << name
70
+ Clacky::Logger.info("[UserAdapterLoader] Loaded channel adapter '#{name}' → :#{klass.platform_id}")
71
+ rescue StandardError, ScriptError => e
72
+ result.skipped << [name, e.message]
73
+ log_skip(name, e.message)
74
+ end
75
+
76
+ def self.interface_gaps(klass)
77
+ missing = REQUIRED_CLASS_METHODS.reject { |m| klass.respond_to?(m) }
78
+ # Base defines stub instance methods that only raise NotImplementedError,
79
+ # so method_defined? alone passes via inheritance. Require the subclass to
80
+ # actually override them — i.e. the method's owner must not be Base.
81
+ missing += REQUIRED_INSTANCE_METHODS.reject do |m|
82
+ klass.method_defined?(m) && klass.instance_method(m).owner != Base
83
+ end
84
+ missing
85
+ end
86
+
87
+ def self.unregister(klass)
88
+ platform = (klass.platform_id if klass.respond_to?(:platform_id))
89
+ return unless platform
90
+
91
+ Adapters.unregister(platform)
92
+ end
93
+
94
+ def self.log_skip(name, reason)
95
+ Clacky::Logger.warn("[UserAdapterLoader] Skipped channel adapter '#{name}': #{reason}")
96
+ end
97
+
98
+ # Generate a ready-to-edit adapter skeleton at ~/.clacky/channels/<name>/adapter.rb.
99
+ # The skeleton already self-registers and implements the full interface with
100
+ # TODO markers — the author only fills in the method bodies.
101
+ # @return [String] path to the generated adapter.rb
102
+ def self.scaffold(name, dir: DEFAULT_DIR)
103
+ slug = name.to_s.strip.downcase.gsub(/[^a-z0-9_]+/, "_").gsub(/\A_+|_+\z/, "")
104
+ raise ArgumentError, "invalid channel name: #{name.inspect}" if slug.empty?
105
+
106
+ target_dir = File.join(dir, slug)
107
+ path = File.join(target_dir, "adapter.rb")
108
+ raise ArgumentError, "adapter already exists: #{path}" if File.exist?(path)
109
+
110
+ FileUtils.mkdir_p(target_dir)
111
+ File.write(path, skeleton(slug))
112
+ path
113
+ end
114
+
115
+ def self.skeleton(slug)
116
+ const = slug.split("_").map(&:capitalize).join
117
+ <<~RUBY
118
+ # frozen_string_literal: true
119
+
120
+ # User-defined channel adapter for ":#{slug}".
121
+ # Edit the TODO sections, then it loads automatically on next start.
122
+ # Verify with: clacky channel verify
123
+
124
+ module Clacky
125
+ module Channel
126
+ module Adapters
127
+ class #{const}Adapter < Base
128
+ def self.platform_id
129
+ :#{slug}
130
+ end
131
+
132
+ # Map raw config (channels.yml `#{slug}` section) to a symbol-keyed hash.
133
+ def self.platform_config(data)
134
+ {
135
+ # TODO: pull your credentials out of `data`
136
+ # token: data["IM_#{slug.upcase}_TOKEN"] || data["token"]
137
+ }.compact
138
+ end
139
+
140
+ def initialize(config)
141
+ @config = config
142
+ end
143
+
144
+ # Begin receiving messages. Blocks until #stop — runs inside a Thread.
145
+ # Yield one standardized event Hash per inbound message.
146
+ def start(&on_message)
147
+ # TODO: connect to your platform and loop, calling on_message.call(event)
148
+ raise NotImplementedError
149
+ end
150
+
151
+ def stop
152
+ # TODO: close connections / stop the read loop
153
+ end
154
+
155
+ # Send a plain text (or Markdown) message to a chat.
156
+ # @return [Hash] { message_id: String }
157
+ def send_text(chat_id, text, reply_to: nil)
158
+ # TODO: call your platform's send API
159
+ raise NotImplementedError
160
+ end
161
+
162
+ # Optional: validate config; return array of error strings (empty = ok).
163
+ def validate_config(config)
164
+ []
165
+ end
166
+
167
+ Adapters.register(platform_id, self)
168
+ end
169
+ end
170
+ end
171
+ end
172
+ RUBY
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
@@ -31,3 +31,8 @@ require_relative "channel/adapters/dingtalk/adapter"
31
31
  require_relative "channel/channel_config"
32
32
  require_relative "channel/channel_ui_controller"
33
33
  require_relative "channel/channel_manager"
34
+
35
+ # Discover and load user-defined adapters from ~/.clacky/channels/.
36
+ # Must run after the bundled adapters so user adapters can extend or override.
37
+ require_relative "channel/user_adapter_loader"
38
+ Clacky::Channel::Adapters::UserAdapterLoader.load_all
@@ -436,8 +436,8 @@ module Clacky
436
436
  when ["GET", "/api/billing/summary"] then api_billing_summary(req, res)
437
437
  when ["GET", "/api/billing/daily"] then api_billing_daily(req, res)
438
438
  when ["GET", "/api/billing/records"] then api_billing_records(req, res)
439
- when ["DELETE", "/api/billing/clear"] then api_billing_clear(req, res)
440
- when ["PATCH", "/api/sessions/:id/model"] then api_switch_session_model(req, res)
439
+ when ["GET", "/api/billing/sessions"] then api_billing_sessions(req, res)
440
+ when ["DELETE", "/api/billing/clear"] then api_billing_clear(req, res) when ["PATCH", "/api/sessions/:id/model"] then api_switch_session_model(req, res)
441
441
  when ["PATCH", "/api/sessions/:id/working_dir"] then api_change_session_working_dir(req, res)
442
442
  else
443
443
  if method == "POST" && path.match?(%r{^/api/channels/[^/]+/send$})
@@ -481,6 +481,9 @@ module Clacky
481
481
  elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/skills$})
482
482
  session_id = path.sub("/api/sessions/", "").sub("/skills", "")
483
483
  api_session_skills(session_id, res)
484
+ elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/files$})
485
+ session_id = path.sub("/api/sessions/", "").sub("/files", "")
486
+ api_session_files(session_id, req, res)
484
487
  elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/export$})
485
488
  session_id = path.sub("/api/sessions/", "").sub("/export", "")
486
489
  api_export_session(session_id, res)
@@ -496,6 +499,9 @@ module Clacky
496
499
  elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/reasoning_effort$})
497
500
  session_id = path.sub("/api/sessions/", "").sub("/reasoning_effort", "")
498
501
  api_switch_session_reasoning_effort(session_id, req, res)
502
+ elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/submodel$})
503
+ session_id = path.sub("/api/sessions/", "").sub("/submodel", "")
504
+ api_switch_session_submodel(session_id, req, res)
499
505
  elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/benchmark$})
500
506
  session_id = path.sub("/api/sessions/", "").sub("/benchmark", "")
501
507
  api_benchmark_session_models(session_id, req, res)
@@ -1164,8 +1170,27 @@ module Clacky
1164
1170
  })
1165
1171
  end
1166
1172
 
1167
- # DELETE /api/billing/clear
1168
- # Clears billing records
1173
+ # GET /api/billing/sessions
1174
+ # Returns session-level billing summary
1175
+ # Query params: period (day|week|month|year|all, default: month), model, limit
1176
+ def api_billing_sessions(req, res)
1177
+ require_relative "../billing/billing_store"
1178
+
1179
+ query = URI.decode_www_form(req.query_string.to_s).to_h
1180
+ period = (query["period"] || "month").to_sym
1181
+ model = query["model"]
1182
+ limit = [(query["limit"] || "50").to_i, 200].min
1183
+
1184
+ store = Clacky::Billing::BillingStore.new
1185
+ sessions = store.session_summary(period: period, model: model, limit: limit)
1186
+
1187
+ json_response(res, 200, {
1188
+ sessions: sessions,
1189
+ count: sessions.size
1190
+ })
1191
+ end
1192
+
1193
+ # DELETE /api/billing/clear # Clears billing records
1169
1194
  # Query params: scope (today|all, default: today)
1170
1195
  def api_billing_clear(req, res)
1171
1196
  require_relative "../billing/billing_store"
@@ -2445,7 +2470,56 @@ module Clacky
2445
2470
  json_response(res, 200, { skills: skill_data })
2446
2471
  end
2447
2472
 
2448
- # PATCH /api/skills/:name/toggle — enable or disable a skill
2473
+ # GET /api/sessions/:id/files?path=<relative dir>
2474
+ # Lists one directory level inside the session's working_dir (lazy, per-layer).
2475
+ # Path traversal outside working_dir is rejected. Noisy dirs are hidden.
2476
+ IGNORED_FILE_ENTRIES = %w[.git .svn .hg node_modules .DS_Store .bundle vendor/bundle tmp .sass-cache].freeze
2477
+
2478
+ def api_session_files(session_id, req, res)
2479
+ unless @registry.ensure(session_id)
2480
+ return json_response(res, 404, { error: "Session not found" })
2481
+ end
2482
+ session = @registry.get(session_id)
2483
+ agent = session && session[:agent]
2484
+ return json_response(res, 404, { error: "Session not found" }) unless agent
2485
+
2486
+ root = File.expand_path(agent.working_dir.to_s)
2487
+ return json_response(res, 404, { error: "Working directory not found" }) unless Dir.exist?(root)
2488
+
2489
+ rel = URI.decode_www_form(req.query_string.to_s).to_h["path"].to_s
2490
+ rel = rel.sub(%r{\A/+}, "").strip
2491
+ target = File.expand_path(File.join(root, rel))
2492
+
2493
+ # Reject traversal outside the working directory.
2494
+ unless target == root || target.start_with?("#{root}/")
2495
+ return json_response(res, 403, { error: "Path outside working directory" })
2496
+ end
2497
+ return json_response(res, 404, { error: "Directory not found" }) unless Dir.exist?(target)
2498
+
2499
+ entries = Dir.children(target).reject { |name| IGNORED_FILE_ENTRIES.include?(name) }
2500
+
2501
+ items = entries.filter_map do |name|
2502
+ full = File.join(target, name)
2503
+ is_dir = File.directory?(full)
2504
+ # Skip symlinks pointing outside the root, and anything unreadable.
2505
+ next unless File.exist?(full)
2506
+ {
2507
+ name: name,
2508
+ path: rel.empty? ? name : "#{rel}/#{name}",
2509
+ type: is_dir ? "dir" : "file",
2510
+ size: is_dir ? nil : (File.size(full) rescue nil)
2511
+ }
2512
+ rescue StandardError
2513
+ nil
2514
+ end
2515
+
2516
+ # Directories first, then files; both case-insensitive alphabetical.
2517
+ items.sort_by! { |it| [it[:type] == "dir" ? 0 : 1, it[:name].downcase] }
2518
+
2519
+ json_response(res, 200, { root: root, path: rel, entries: items })
2520
+ rescue StandardError => e
2521
+ json_response(res, 500, { error: e.message })
2522
+ end
2449
2523
  # Body: { enabled: true/false }
2450
2524
  def api_toggle_skill(name, req, res)
2451
2525
  body = parse_json_body(req)
@@ -3601,6 +3675,47 @@ module Clacky
3601
3675
  json_response(res, 500, { error: e.message })
3602
3676
  end
3603
3677
 
3678
+ # PATCH /api/sessions/:id/submodel
3679
+ # Body: { "model_name": "dsk-deepseek-v4-pro" | null }
3680
+ #
3681
+ # Pin this session to a sub-model under its current card without
3682
+ # touching credentials or the global @models. Pass null/empty to clear
3683
+ # and fall back to the card default. The name must appear in the
3684
+ # provider preset's "models" list — anything else is rejected.
3685
+ def api_switch_session_submodel(session_id, req, res)
3686
+ body = parse_json_body(req)
3687
+ raw = body["model_name"]
3688
+ model_name = raw.is_a?(String) ? raw.strip : nil
3689
+
3690
+ return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
3691
+
3692
+ agent = nil
3693
+ @registry.with_session(session_id) { |s| agent = s[:agent] }
3694
+ return json_response(res, 404, { error: "Session not found" }) unless agent
3695
+
3696
+ if model_name && !model_name.empty?
3697
+ info = agent.current_model_info
3698
+ provider_id = info && Clacky::Providers.find_by_base_url(info[:base_url])
3699
+ allowed = provider_id ? Clacky::Providers.models(provider_id) : []
3700
+ if allowed.empty?
3701
+ return json_response(res, 400, { error: "Current model has no provider preset; sub-model switching unavailable" })
3702
+ end
3703
+ unless allowed.include?(model_name)
3704
+ return json_response(res, 400, { error: "Sub-model '#{model_name}' not listed under provider '#{provider_id}'" })
3705
+ end
3706
+ else
3707
+ model_name = nil
3708
+ end
3709
+
3710
+ agent.set_session_sub_model(model_name)
3711
+ @session_manager.save(agent.to_session_data)
3712
+ broadcast_session_update(session_id)
3713
+
3714
+ json_response(res, 200, { ok: true, sub_model: agent.current_model_info[:sub_model] })
3715
+ rescue => e
3716
+ json_response(res, 500, { error: e.message })
3717
+ end
3718
+
3604
3719
  # POST /api/sessions/:id/benchmark
3605
3720
  #
3606
3721
  # Speed-test every configured model in one shot so the user can pick the
@@ -3706,11 +3821,9 @@ module Clacky
3706
3821
 
3707
3822
  # Expand ~ to home directory
3708
3823
  expanded_dir = File.expand_path(new_dir)
3709
-
3710
- # Validate directory exists
3711
- unless Dir.exist?(expanded_dir)
3712
- return json_response(res, 400, { error: "Directory does not exist: #{expanded_dir}" })
3713
- end
3824
+
3825
+ # Auto-create the directory if it doesn't exist yet.
3826
+ FileUtils.mkdir_p(expanded_dir)
3714
3827
 
3715
3828
  agent = nil
3716
3829
  @registry.with_session(session_id) { |s| agent = s[:agent] }
@@ -4189,12 +4302,18 @@ module Clacky
4189
4302
  broadcast(session_id, { type: "interrupted", session_id: session_id })
4190
4303
  @session_manager.save(agent.to_session_data(status: :interrupted))
4191
4304
  rescue => e
4192
- @registry.update(session_id, status: :error, error: e.message)
4193
- broadcast_session_update(session_id)
4194
4305
  # Route error through web_ui so channel subscribers (飞书/企微) receive it too.
4195
4306
  web_ui = nil
4196
4307
  @registry.with_session(session_id) { |s| web_ui = s[:ui] }
4197
- web_ui&.show_error(e.message)
4308
+ code = e.is_a?(Clacky::InsufficientCreditError) ? e.error_code : nil
4309
+ top_up_url = nil
4310
+ if e.is_a?(Clacky::InsufficientCreditError) && e.provider_id
4311
+ preset = Clacky::Providers::PRESETS[e.provider_id]
4312
+ top_up_url = preset && preset["website_url"]
4313
+ end
4314
+ @registry.update(session_id, status: :error, error: e.message, error_code: code, top_up_url: top_up_url)
4315
+ broadcast_session_update(session_id)
4316
+ web_ui&.show_error(e.message, code: code, top_up_url: top_up_url)
4198
4317
  @session_manager.save(agent.to_session_data(status: :error, error_message: e.message))
4199
4318
  end
4200
4319
  @registry.with_session(session_id) { |s| s[:thread] = thread }
@@ -4286,7 +4405,9 @@ module Clacky
4286
4405
  # @param working_dir [String] working directory for the agent
4287
4406
  # @param permission_mode [Symbol] :confirm_all (default, human present) or
4288
4407
  # :auto_approve (unattended — suppresses request_user_feedback waits)
4289
- def build_session(name:, working_dir:, permission_mode: :confirm_all, profile: "general", source: :manual, model_id: nil)
4408
+ def build_session(name:, working_dir: nil, permission_mode: :confirm_all, profile: "general", source: :manual, model_id: nil)
4409
+ working_dir ||= default_working_dir
4410
+ FileUtils.mkdir_p(working_dir) unless Dir.exist?(working_dir)
4290
4411
  session_id = Clacky::SessionManager.generate_id
4291
4412
  @registry.create(session_id: session_id)
4292
4413
 
@@ -223,11 +223,8 @@ module Clacky
223
223
  prompt = read_task(task_name)
224
224
  name = "⏰ #{schedule["name"]} #{Time.now.strftime("%H:%M")}"
225
225
 
226
- working_dir = File.expand_path("~/clacky_workspace")
227
- FileUtils.mkdir_p(working_dir)
228
-
229
226
  # Scheduled tasks run unattended — use auto_approve so request_user_feedback doesn't block.
230
- session_id = @session_builder.call(name: name, working_dir: working_dir, permission_mode: :auto_approve, source: :cron)
227
+ session_id = @session_builder.call(name: name, permission_mode: :auto_approve, source: :cron)
231
228
 
232
229
  Clacky::Logger.info("scheduler_task_fired", task: task_name, session: session_id)
233
230