openclacky 1.2.5 → 1.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -0
  3. data/README.md +34 -0
  4. data/README_CN.md +34 -0
  5. data/lib/clacky/agent/cost_tracker.rb +24 -10
  6. data/lib/clacky/agent/llm_caller.rb +25 -3
  7. data/lib/clacky/agent/message_compressor.rb +2 -1
  8. data/lib/clacky/agent/message_compressor_helper.rb +6 -2
  9. data/lib/clacky/agent/session_serializer.rb +23 -4
  10. data/lib/clacky/agent/tool_executor.rb +14 -0
  11. data/lib/clacky/agent/tool_registry.rb +0 -7
  12. data/lib/clacky/agent.rb +43 -10
  13. data/lib/clacky/agent_config.rb +54 -6
  14. data/lib/clacky/billing/billing_store.rb +62 -4
  15. data/lib/clacky/brand_config.rb +5 -0
  16. data/lib/clacky/cli.rb +76 -24
  17. data/lib/clacky/client.rb +59 -4
  18. data/lib/clacky/default_parsers/wps_parser.rb +82 -0
  19. data/lib/clacky/default_skills/onboard/SKILL.md +2 -2
  20. data/lib/clacky/json_ui_controller.rb +5 -2
  21. data/lib/clacky/message_format/anthropic.rb +13 -3
  22. data/lib/clacky/message_format/bedrock.rb +2 -2
  23. data/lib/clacky/plain_ui_controller.rb +1 -1
  24. data/lib/clacky/platform_http_client.rb +28 -1
  25. data/lib/clacky/providers.rb +11 -29
  26. data/lib/clacky/server/channel/channel_manager.rb +148 -12
  27. data/lib/clacky/server/channel/channel_ui_controller.rb +4 -2
  28. data/lib/clacky/server/http_server.rb +133 -13
  29. data/lib/clacky/server/session_registry.rb +30 -4
  30. data/lib/clacky/server/web_ui_controller.rb +6 -3
  31. data/lib/clacky/tools/browser.rb +4 -13
  32. data/lib/clacky/tools/terminal.rb +23 -27
  33. data/lib/clacky/ui2/ui_controller.rb +1 -1
  34. data/lib/clacky/ui_interface.rb +1 -1
  35. data/lib/clacky/utils/file_processor.rb +3 -0
  36. data/lib/clacky/utils/parser_manager.rb +3 -0
  37. data/lib/clacky/version.rb +1 -1
  38. data/lib/clacky/web/app.css +659 -75
  39. data/lib/clacky/web/app.js +0 -1
  40. data/lib/clacky/web/billing.js +371 -99
  41. data/lib/clacky/web/i18n.js +48 -2
  42. data/lib/clacky/web/index.html +34 -1
  43. data/lib/clacky/web/sessions.js +213 -82
  44. data/lib/clacky/web/settings.js +59 -17
  45. data/lib/clacky/web/workspace.js +204 -0
  46. data/lib/clacky/web/ws-dispatcher.js +19 -3
  47. data/lib/clacky.rb +9 -3
  48. metadata +4 -5
  49. data/lib/clacky/tools/list_tasks.rb +0 -54
  50. data/lib/clacky/tools/redo_task.rb +0 -41
  51. data/lib/clacky/tools/undo_task.rb +0 -35
@@ -31,6 +31,7 @@ module Clacky
31
31
  "api" => "bedrock",
32
32
  "default_model" => "abs-claude-sonnet-4-6",
33
33
  "models" => [
34
+ "abs-claude-opus-4-8",
34
35
  "abs-claude-opus-4-7",
35
36
  "abs-claude-opus-4-6",
36
37
  "abs-claude-sonnet-4-6",
@@ -61,6 +62,7 @@ module Clacky
61
62
  # sibling wired up (yet) on this provider; subagents using the
62
63
  # Gemini default will just reuse it for lite work until we add one.
63
64
  "lite_models" => {
65
+ "abs-claude-opus-4-8" => "abs-claude-haiku-4-5",
64
66
  "abs-claude-opus-4-7" => "abs-claude-haiku-4-5",
65
67
  "abs-claude-opus-4-6" => "abs-claude-haiku-4-5",
66
68
  "abs-claude-sonnet-4-6" => "abs-claude-haiku-4-5",
@@ -88,6 +90,7 @@ module Clacky
88
90
  # ID manually; this list only seeds the picker.
89
91
  "models" => [
90
92
  "anthropic/claude-sonnet-4-6",
93
+ "anthropic/claude-opus-4-8",
91
94
  "anthropic/claude-opus-4-7",
92
95
  "anthropic/claude-opus-4-6",
93
96
  "anthropic/claude-haiku-4-5",
@@ -101,6 +104,7 @@ module Clacky
101
104
  # cheap/fast sidekick automatically.
102
105
  "lite_models" => {
103
106
  "anthropic/claude-sonnet-4-6" => "anthropic/claude-haiku-4-5",
107
+ "anthropic/claude-opus-4-8" => "anthropic/claude-haiku-4-5",
104
108
  "anthropic/claude-opus-4-7" => "anthropic/claude-haiku-4-5",
105
109
  "anthropic/claude-opus-4-6" => "anthropic/claude-haiku-4-5",
106
110
  "openai/gpt-5.5" => "openai/gpt-5.4-mini",
@@ -232,38 +236,11 @@ module Clacky
232
236
  "name" => "Anthropic (Claude)",
233
237
  "base_url" => "https://api.anthropic.com",
234
238
  "api" => "anthropic-messages",
235
- "default_model" => "claude-sonnet-4.6",
236
- "models" => ["claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4.6", "claude-haiku-4.5"],
239
+ "default_model" => "claude-sonnet-4-6",
240
+ "models" => ["claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"],
237
241
  "website_url" => "https://console.anthropic.com/settings/keys"
238
242
  }.freeze,
239
243
 
240
- "clackyai-sea" => {
241
- "name" => "ClackyAI(Sea)",
242
- "base_url" => "https://api.clacky.ai",
243
- "api" => "bedrock",
244
- "default_model" => "abs-claude-sonnet-4-5",
245
- "models" => [
246
- "abs-claude-opus-4-6",
247
- "abs-claude-sonnet-4-6",
248
- "abs-claude-sonnet-4-5",
249
- "abs-claude-haiku-4-5"
250
- ],
251
- # Claude family — all vision-capable.
252
- "capabilities" => { "vision" => true }.freeze,
253
- # Per-primary lite pairing — see openclacky preset for rationale.
254
- "lite_models" => {
255
- "abs-claude-opus-4-6" => "abs-claude-haiku-4-5",
256
- "abs-claude-sonnet-4-6" => "abs-claude-haiku-4-5",
257
- "abs-claude-sonnet-4-5" => "abs-claude-haiku-4-5"
258
- },
259
- # Fallback chain: if a model is unavailable, try the next one in order.
260
- # Keys are primary model names; values are the fallback model to use instead.
261
- "fallback_models" => {
262
- "abs-claude-sonnet-4-6" => "abs-claude-sonnet-4-5"
263
- },
264
- "website_url" => "https://clacky.ai"
265
- }.freeze,
266
-
267
244
  "mimo" => {
268
245
  "name" => "MiMo (Xiaomi)",
269
246
  "base_url" => "https://api.xiaomimimo.com/v1",
@@ -572,6 +549,11 @@ module Clacky
572
549
  return "openclacky"
573
550
  end
574
551
 
552
+ if base_url.is_a?(String) &&
553
+ base_url.match?(%r{\Ahttps?://(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|/|\z)}i)
554
+ return "openclacky"
555
+ end
556
+
575
557
  nil
576
558
  end
577
559
 
@@ -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|
@@ -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)
@@ -436,6 +436,7 @@ 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)
439
440
  when ["PATCH", "/api/sessions/:id/model"] then api_switch_session_model(req, res)
440
441
  when ["PATCH", "/api/sessions/:id/working_dir"] then api_change_session_working_dir(req, res)
441
442
  else
@@ -480,6 +481,9 @@ module Clacky
480
481
  elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/skills$})
481
482
  session_id = path.sub("/api/sessions/", "").sub("/skills", "")
482
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)
483
487
  elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/export$})
484
488
  session_id = path.sub("/api/sessions/", "").sub("/export", "")
485
489
  api_export_session(session_id, res)
@@ -495,6 +499,9 @@ module Clacky
495
499
  elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/reasoning_effort$})
496
500
  session_id = path.sub("/api/sessions/", "").sub("/reasoning_effort", "")
497
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)
498
505
  elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/benchmark$})
499
506
  session_id = path.sub("/api/sessions/", "").sub("/benchmark", "")
500
507
  api_benchmark_session_models(session_id, req, res)
@@ -1113,30 +1120,32 @@ module Clacky
1113
1120
 
1114
1121
  # GET /api/billing/summary
1115
1122
  # Returns billing summary for a time period
1116
- # Query params: period (day|week|month|year|all, default: month)
1123
+ # Query params: period (day|week|month|year|all, default: month), model (optional)
1117
1124
  def api_billing_summary(req, res)
1118
1125
  require_relative "../billing/billing_store"
1119
1126
 
1120
1127
  query = URI.decode_www_form(req.query_string.to_s).to_h
1121
1128
  period = (query["period"] || "month").to_sym
1129
+ model = query["model"]
1122
1130
 
1123
1131
  store = Clacky::Billing::BillingStore.new
1124
- summary = store.summary(period: period)
1132
+ summary = store.summary(period: period, model: model)
1125
1133
 
1126
1134
  json_response(res, 200, summary)
1127
1135
  end
1128
1136
 
1129
1137
  # GET /api/billing/daily
1130
1138
  # Returns daily cost breakdown
1131
- # Query params: days (default: 30)
1139
+ # Query params: days (default: 30), model (optional)
1132
1140
  def api_billing_daily(req, res)
1133
1141
  require_relative "../billing/billing_store"
1134
1142
 
1135
1143
  query = URI.decode_www_form(req.query_string.to_s).to_h
1136
1144
  days = [(query["days"] || "30").to_i, 90].min
1145
+ model = query["model"]
1137
1146
 
1138
1147
  store = Clacky::Billing::BillingStore.new
1139
- daily = store.daily_breakdown(days: days)
1148
+ daily = store.daily_breakdown(days: days, model: model)
1140
1149
 
1141
1150
  json_response(res, 200, { days: daily })
1142
1151
  end
@@ -1161,6 +1170,23 @@ module Clacky
1161
1170
  })
1162
1171
  end
1163
1172
 
1173
+ # DELETE /api/billing/clear
1174
+ # Clears billing records
1175
+ # Query params: scope (today|all, default: today)
1176
+ def api_billing_clear(req, res)
1177
+ require_relative "../billing/billing_store"
1178
+
1179
+ query = URI.decode_www_form(req.query_string.to_s).to_h
1180
+ scope = query["scope"] || "today"
1181
+
1182
+ store = Clacky::Billing::BillingStore.new
1183
+ deleted = store.clear(scope: scope.to_sym)
1184
+
1185
+ json_response(res, 200, { ok: true, deleted: deleted, scope: scope })
1186
+ rescue => e
1187
+ json_response(res, 500, { error: e.message })
1188
+ end
1189
+
1164
1190
  # GET /api/version
1165
1191
  # Returns current version and latest version from RubyGems (cached for 1 hour).
1166
1192
  def api_get_version(res)
@@ -2425,7 +2451,56 @@ module Clacky
2425
2451
  json_response(res, 200, { skills: skill_data })
2426
2452
  end
2427
2453
 
2428
- # PATCH /api/skills/:name/toggle — enable or disable a skill
2454
+ # GET /api/sessions/:id/files?path=<relative dir>
2455
+ # Lists one directory level inside the session's working_dir (lazy, per-layer).
2456
+ # Path traversal outside working_dir is rejected. Noisy dirs are hidden.
2457
+ IGNORED_FILE_ENTRIES = %w[.git .svn .hg node_modules .DS_Store .bundle vendor/bundle tmp .sass-cache].freeze
2458
+
2459
+ def api_session_files(session_id, req, res)
2460
+ unless @registry.ensure(session_id)
2461
+ return json_response(res, 404, { error: "Session not found" })
2462
+ end
2463
+ session = @registry.get(session_id)
2464
+ agent = session && session[:agent]
2465
+ return json_response(res, 404, { error: "Session not found" }) unless agent
2466
+
2467
+ root = File.expand_path(agent.working_dir.to_s)
2468
+ return json_response(res, 404, { error: "Working directory not found" }) unless Dir.exist?(root)
2469
+
2470
+ rel = URI.decode_www_form(req.query_string.to_s).to_h["path"].to_s
2471
+ rel = rel.sub(%r{\A/+}, "").strip
2472
+ target = File.expand_path(File.join(root, rel))
2473
+
2474
+ # Reject traversal outside the working directory.
2475
+ unless target == root || target.start_with?("#{root}/")
2476
+ return json_response(res, 403, { error: "Path outside working directory" })
2477
+ end
2478
+ return json_response(res, 404, { error: "Directory not found" }) unless Dir.exist?(target)
2479
+
2480
+ entries = Dir.children(target).reject { |name| IGNORED_FILE_ENTRIES.include?(name) }
2481
+
2482
+ items = entries.filter_map do |name|
2483
+ full = File.join(target, name)
2484
+ is_dir = File.directory?(full)
2485
+ # Skip symlinks pointing outside the root, and anything unreadable.
2486
+ next unless File.exist?(full)
2487
+ {
2488
+ name: name,
2489
+ path: rel.empty? ? name : "#{rel}/#{name}",
2490
+ type: is_dir ? "dir" : "file",
2491
+ size: is_dir ? nil : (File.size(full) rescue nil)
2492
+ }
2493
+ rescue StandardError
2494
+ nil
2495
+ end
2496
+
2497
+ # Directories first, then files; both case-insensitive alphabetical.
2498
+ items.sort_by! { |it| [it[:type] == "dir" ? 0 : 1, it[:name].downcase] }
2499
+
2500
+ json_response(res, 200, { root: root, path: rel, entries: items })
2501
+ rescue StandardError => e
2502
+ json_response(res, 500, { error: e.message })
2503
+ end
2429
2504
  # Body: { enabled: true/false }
2430
2505
  def api_toggle_skill(name, req, res)
2431
2506
  body = parse_json_body(req)
@@ -3581,6 +3656,47 @@ module Clacky
3581
3656
  json_response(res, 500, { error: e.message })
3582
3657
  end
3583
3658
 
3659
+ # PATCH /api/sessions/:id/submodel
3660
+ # Body: { "model_name": "dsk-deepseek-v4-pro" | null }
3661
+ #
3662
+ # Pin this session to a sub-model under its current card without
3663
+ # touching credentials or the global @models. Pass null/empty to clear
3664
+ # and fall back to the card default. The name must appear in the
3665
+ # provider preset's "models" list — anything else is rejected.
3666
+ def api_switch_session_submodel(session_id, req, res)
3667
+ body = parse_json_body(req)
3668
+ raw = body["model_name"]
3669
+ model_name = raw.is_a?(String) ? raw.strip : nil
3670
+
3671
+ return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
3672
+
3673
+ agent = nil
3674
+ @registry.with_session(session_id) { |s| agent = s[:agent] }
3675
+ return json_response(res, 404, { error: "Session not found" }) unless agent
3676
+
3677
+ if model_name && !model_name.empty?
3678
+ info = agent.current_model_info
3679
+ provider_id = info && Clacky::Providers.find_by_base_url(info[:base_url])
3680
+ allowed = provider_id ? Clacky::Providers.models(provider_id) : []
3681
+ if allowed.empty?
3682
+ return json_response(res, 400, { error: "Current model has no provider preset; sub-model switching unavailable" })
3683
+ end
3684
+ unless allowed.include?(model_name)
3685
+ return json_response(res, 400, { error: "Sub-model '#{model_name}' not listed under provider '#{provider_id}'" })
3686
+ end
3687
+ else
3688
+ model_name = nil
3689
+ end
3690
+
3691
+ agent.set_session_sub_model(model_name)
3692
+ @session_manager.save(agent.to_session_data)
3693
+ broadcast_session_update(session_id)
3694
+
3695
+ json_response(res, 200, { ok: true, sub_model: agent.current_model_info[:sub_model] })
3696
+ rescue => e
3697
+ json_response(res, 500, { error: e.message })
3698
+ end
3699
+
3584
3700
  # POST /api/sessions/:id/benchmark
3585
3701
  #
3586
3702
  # Speed-test every configured model in one shot so the user can pick the
@@ -3686,11 +3802,9 @@ module Clacky
3686
3802
 
3687
3803
  # Expand ~ to home directory
3688
3804
  expanded_dir = File.expand_path(new_dir)
3689
-
3690
- # Validate directory exists
3691
- unless Dir.exist?(expanded_dir)
3692
- return json_response(res, 400, { error: "Directory does not exist: #{expanded_dir}" })
3693
- end
3805
+
3806
+ # Auto-create the directory if it doesn't exist yet.
3807
+ FileUtils.mkdir_p(expanded_dir)
3694
3808
 
3695
3809
  agent = nil
3696
3810
  @registry.with_session(session_id) { |s| agent = s[:agent] }
@@ -4169,12 +4283,18 @@ module Clacky
4169
4283
  broadcast(session_id, { type: "interrupted", session_id: session_id })
4170
4284
  @session_manager.save(agent.to_session_data(status: :interrupted))
4171
4285
  rescue => e
4172
- @registry.update(session_id, status: :error, error: e.message)
4173
- broadcast_session_update(session_id)
4174
4286
  # Route error through web_ui so channel subscribers (飞书/企微) receive it too.
4175
4287
  web_ui = nil
4176
4288
  @registry.with_session(session_id) { |s| web_ui = s[:ui] }
4177
- web_ui&.show_error(e.message)
4289
+ code = e.is_a?(Clacky::InsufficientCreditError) ? e.error_code : nil
4290
+ top_up_url = nil
4291
+ if e.is_a?(Clacky::InsufficientCreditError) && e.provider_id
4292
+ preset = Clacky::Providers::PRESETS[e.provider_id]
4293
+ top_up_url = preset && preset["website_url"]
4294
+ end
4295
+ @registry.update(session_id, status: :error, error: e.message, error_code: code, top_up_url: top_up_url)
4296
+ broadcast_session_update(session_id)
4297
+ web_ui&.show_error(e.message, code: code, top_up_url: top_up_url)
4178
4298
  @session_manager.save(agent.to_session_data(status: :error, error_message: e.message))
4179
4299
  end
4180
4300
  @registry.with_session(session_id) { |s| s[:thread] = thread }
@@ -40,6 +40,8 @@ module Clacky
40
40
  id: session_id,
41
41
  status: :idle,
42
42
  error: nil,
43
+ error_code: nil,
44
+ top_up_url: nil,
43
45
  updated_at: Time.now,
44
46
  agent: nil,
45
47
  ui: nil,
@@ -166,11 +168,15 @@ module Clacky
166
168
  live_name = s[:agent]&.name
167
169
  live_name = nil if live_name&.empty?
168
170
  live_cost_source = s[:agent]&.cost_source
169
- { status: s[:status], error: s[:error], model: model_info&.dig(:model), model_id: model_info&.dig(:id), name: live_name,
171
+ { status: s[:status], error: s[:error], error_code: s[:error_code], top_up_url: s[:top_up_url],
172
+ model: model_info&.dig(:model), model_id: model_info&.dig(:id), name: live_name,
170
173
  total_tasks: s[:agent]&.total_tasks, total_cost: s[:agent]&.total_cost,
171
174
  cost_source: live_cost_source,
172
175
  reasoning_effort: s[:agent]&.reasoning_effort,
173
- latest_latency: s[:agent]&.latest_latency }
176
+ latest_latency: s[:agent]&.latest_latency,
177
+ card_model: model_info&.dig(:card_model),
178
+ sub_model: model_info&.dig(:sub_model),
179
+ sub_model_options: sub_model_options_for(model_info) }
174
180
  end
175
181
  end
176
182
 
@@ -239,11 +245,15 @@ module Clacky
239
245
  model_info = s[:agent]&.current_model_info
240
246
  live_name = s[:agent]&.name
241
247
  live_name = nil if live_name&.empty?
242
- { status: s[:status], error: s[:error], model: model_info&.dig(:model), model_id: model_info&.dig(:id),
248
+ { status: s[:status], error: s[:error], error_code: s[:error_code], top_up_url: s[:top_up_url],
249
+ model: model_info&.dig(:model), model_id: model_info&.dig(:id),
243
250
  name: live_name, total_tasks: s[:agent]&.total_tasks,
244
251
  total_cost: s[:agent]&.total_cost, cost_source: s[:agent]&.cost_source,
245
252
  reasoning_effort: s[:agent]&.reasoning_effort,
246
- latest_latency: s[:agent]&.latest_latency }
253
+ latest_latency: s[:agent]&.latest_latency,
254
+ card_model: model_info&.dig(:card_model),
255
+ sub_model: model_info&.dig(:sub_model),
256
+ sub_model_options: sub_model_options_for(model_info) }
247
257
  end
248
258
 
249
259
  build_enriched_row(disk, live)
@@ -259,8 +269,13 @@ module Clacky
259
269
  name: ls&.dig(:name) || s[:name] || "",
260
270
  status: ls ? ls[:status].to_s : "idle",
261
271
  error: ls ? ls[:error] : nil,
272
+ error_code: ls&.dig(:error_code),
273
+ top_up_url: ls&.dig(:top_up_url),
262
274
  model: ls&.dig(:model),
263
275
  model_id: ls&.dig(:model_id),
276
+ card_model: ls&.dig(:card_model),
277
+ sub_model: ls&.dig(:sub_model),
278
+ sub_model_options: ls&.dig(:sub_model_options) || [],
264
279
  source: s_source(s),
265
280
  agent_profile: (s[:agent_profile] || "general").to_s,
266
281
  working_dir: s[:working_dir],
@@ -280,6 +295,17 @@ module Clacky
280
295
  end
281
296
 
282
297
 
298
+ # Look up the provider preset for the session's current card and
299
+ # return the sub-model list. Empty when the card's base_url isn't
300
+ # in any preset (e.g. self-hosted custom endpoints) — the WebUI
301
+ # treats that as "no sub-model switcher available".
302
+ private def sub_model_options_for(model_info)
303
+ return [] unless model_info && model_info[:base_url]
304
+ provider_id = Clacky::Providers.find_by_base_url(model_info[:base_url])
305
+ return [] unless provider_id
306
+ Clacky::Providers.models(provider_id)
307
+ end
308
+
283
309
  # Normalize source field from a disk session hash.
284
310
  # "system" is a legacy value renamed to "setup" — treat them as equivalent.
285
311
  def s_source(s)
@@ -210,9 +210,12 @@ module Clacky
210
210
  forward_to_subscribers { |sub| sub.show_warning(message) }
211
211
  end
212
212
 
213
- def show_error(message)
214
- emit("error", message: message)
215
- forward_to_subscribers { |sub| sub.show_error(message) }
213
+ def show_error(message, code: nil, top_up_url: nil)
214
+ payload = { message: message }
215
+ payload[:code] = code if code
216
+ payload[:top_up_url] = top_up_url if top_up_url
217
+ emit("error", **payload)
218
+ forward_to_subscribers { |sub| sub.show_error(message, code: code, top_up_url: top_up_url) }
216
219
  end
217
220
 
218
221
  def show_success(message)