openclacky 1.2.6 → 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.
@@ -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)
@@ -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)
@@ -2445,7 +2451,56 @@ module Clacky
2445
2451
  json_response(res, 200, { skills: skill_data })
2446
2452
  end
2447
2453
 
2448
- # 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
2449
2504
  # Body: { enabled: true/false }
2450
2505
  def api_toggle_skill(name, req, res)
2451
2506
  body = parse_json_body(req)
@@ -3601,6 +3656,47 @@ module Clacky
3601
3656
  json_response(res, 500, { error: e.message })
3602
3657
  end
3603
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
+
3604
3700
  # POST /api/sessions/:id/benchmark
3605
3701
  #
3606
3702
  # Speed-test every configured model in one shot so the user can pick the
@@ -3706,11 +3802,9 @@ module Clacky
3706
3802
 
3707
3803
  # Expand ~ to home directory
3708
3804
  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
3805
+
3806
+ # Auto-create the directory if it doesn't exist yet.
3807
+ FileUtils.mkdir_p(expanded_dir)
3714
3808
 
3715
3809
  agent = nil
3716
3810
  @registry.with_session(session_id) { |s| agent = s[:agent] }
@@ -4189,12 +4283,18 @@ module Clacky
4189
4283
  broadcast(session_id, { type: "interrupted", session_id: session_id })
4190
4284
  @session_manager.save(agent.to_session_data(status: :interrupted))
4191
4285
  rescue => e
4192
- @registry.update(session_id, status: :error, error: e.message)
4193
- broadcast_session_update(session_id)
4194
4286
  # Route error through web_ui so channel subscribers (飞书/企微) receive it too.
4195
4287
  web_ui = nil
4196
4288
  @registry.with_session(session_id) { |s| web_ui = s[:ui] }
4197
- 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)
4198
4298
  @session_manager.save(agent.to_session_data(status: :error, error_message: e.message))
4199
4299
  end
4200
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)
@@ -340,12 +340,6 @@ module Clacky
340
340
  project_root: cwd || Dir.pwd
341
341
  )
342
342
 
343
- # WSL interop fix: Windows .exe processes inherit the PTY's stdin fd
344
- # and attempt to use it as a Windows Console, causing them to hang
345
- # indefinitely. Redirect stdin from /dev/null for any .exe invocation
346
- # that doesn't already have an explicit stdin redirect.
347
- safe_command = redirect_exe_stdin(safe_command)
348
-
349
343
  # PowerShell 5 on Chinese Windows emits CP936/GBK by default; force
350
344
  # UTF-8 so our PTY (which decodes as UTF-8) doesn't see ??? bytes.
351
345
  safe_command = force_powershell_utf8(safe_command)
@@ -988,10 +982,31 @@ module Clacky
988
982
  # exit codes are also swallowed so the *user* command's $? is what
989
983
  # lands in `__clacky_ec`.
990
984
  hooks_line = with_hooks ? hooks_prefix_for(session) : ""
991
- line = %Q|#{hooks_line}{ #{command}\n}; __clacky_ec=$?; printf "\n__CLACKY_DONE_#{token}_%s__\n" "$__clacky_ec"\n|
985
+ # WSL interop fix: Windows .exe processes inherit the PTY slave fd
986
+ # as their stdin and treat it like a Windows Console — they sit
987
+ # there waiting for input nobody will ever send, hanging the whole
988
+ # session. Wrapping the user command's group with `</dev/null` gives
989
+ # every process inside it (including .exe interop children) an
990
+ # immediate EOF on stdin, so they exit cleanly.
991
+ #
992
+ # We only do this on WSL when the command actually mentions `.exe`,
993
+ # so Linux interactive commands like `read -p` / `python` REPL on
994
+ # non-WSL hosts (and on WSL when not invoking Windows binaries)
995
+ # keep their PTY stdin and continue to behave as before.
996
+ stdin_redirect = exe_needs_stdin_isolation?(command) ? " </dev/null" : ""
997
+ line = %Q|#{hooks_line}{ #{command}\n}#{stdin_redirect}; __clacky_ec=$?; printf "\n__CLACKY_DONE_#{token}_%s__\n" "$__clacky_ec"\n|
992
998
  session.mutex.synchronize { session.writer.write(line) }
993
999
  end
994
1000
 
1001
+ # True when the command should run with stdin redirected from
1002
+ # /dev/null. Currently only triggers on WSL when the command string
1003
+ # mentions a Windows `.exe` binary — see write_user_command for the
1004
+ # full rationale.
1005
+ private def exe_needs_stdin_isolation?(command)
1006
+ return false unless Clacky::Utils::EnvironmentDetector.wsl?
1007
+ command.to_s =~ /\.exe\b/i ? true : false
1008
+ end
1009
+
995
1010
  # Build the "run hooks" prefix line. Empty string for shells where
996
1011
  # we don't know how to introspect hook registries.
997
1012
  private def hooks_prefix_for(session)
@@ -1508,25 +1523,6 @@ module Clacky
1508
1523
  lines.last(DISPLAY_TAIL_LINES).join("\n")
1509
1524
  end
1510
1525
 
1511
- # WSL interop fix: Windows .exe processes inherit the PTY stdin fd
1512
- # and try to use it as a Windows Console, which hangs indefinitely.
1513
- # Detect .exe invocations and redirect stdin from /dev/null unless
1514
- # the command already has an explicit stdin redirect.
1515
- private def redirect_exe_stdin(command)
1516
- return command unless Clacky::Utils::EnvironmentDetector.wsl?
1517
- return command unless command =~ /\.exe\b/i
1518
- return command if command =~ /<\s*[^\s|&;]/
1519
-
1520
- # If the command has a shell-level pipe, insert </dev/null before
1521
- # the first pipe so only the .exe segment gets its stdin redirected,
1522
- # rather than starving a downstream pipe reader (e.g. `tr`, `grep`).
1523
- if command =~ /\|/
1524
- command.sub(/\s*\|/, ' </dev/null |')
1525
- else
1526
- "#{command} </dev/null"
1527
- end
1528
- end
1529
-
1530
1526
  # PowerShell 5 on Chinese Windows defaults [Console]::OutputEncoding
1531
1527
  # to CP936/GBK; our PTY decodes as UTF-8 so non-ASCII output becomes
1532
1528
  # `???`. Inject UTF-8 setup into the user's PowerShell command so the
@@ -861,7 +861,7 @@ module Clacky
861
861
 
862
862
  # Show error message
863
863
  # @param message [String] Error message
864
- def show_error(message)
864
+ def show_error(message, code: nil, top_up_url: nil)
865
865
  output = @renderer.render_error(message)
866
866
  append_output(output)
867
867
  end
@@ -26,7 +26,7 @@ module Clacky
26
26
  # === Status messages ===
27
27
  def show_info(message, prefix_newline: true); end
28
28
  def show_warning(message); end
29
- def show_error(message); end
29
+ def show_error(message, code: nil, top_up_url: nil); end
30
30
  def show_success(message); end
31
31
  def log(message, level: :info); end
32
32
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.2.6"
4
+ VERSION = "1.2.7"
5
5
  end