openclacky 0.9.34 → 0.9.35

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/lib/clacky/agent/cost_tracker.rb +1 -1
  4. data/lib/clacky/agent/llm_caller.rb +14 -10
  5. data/lib/clacky/agent/memory_updater.rb +1 -1
  6. data/lib/clacky/agent/session_serializer.rb +2 -0
  7. data/lib/clacky/agent/skill_manager.rb +1 -1
  8. data/lib/clacky/agent/tool_executor.rb +13 -16
  9. data/lib/clacky/agent/tool_registry.rb +0 -3
  10. data/lib/clacky/agent.rb +63 -38
  11. data/lib/clacky/agent_config.rb +5 -1
  12. data/lib/clacky/brand_config.rb +11 -27
  13. data/lib/clacky/cli.rb +36 -0
  14. data/lib/clacky/default_skills/channel-setup/SKILL.md +1 -1
  15. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +1 -1
  16. data/lib/clacky/default_skills/new/SKILL.md +1 -1
  17. data/lib/clacky/default_skills/product-help/SKILL.md +1 -1
  18. data/lib/clacky/default_skills/recall-memory/SKILL.md +1 -1
  19. data/lib/clacky/default_skills/skill-creator/SKILL.md +1 -1
  20. data/lib/clacky/idle_compression_timer.rb +8 -0
  21. data/lib/clacky/json_ui_controller.rb +2 -1
  22. data/lib/clacky/plain_ui_controller.rb +10 -3
  23. data/lib/clacky/platform_http_client.rb +161 -1
  24. data/lib/clacky/server/channel/channel_manager.rb +5 -3
  25. data/lib/clacky/server/channel/channel_ui_controller.rb +6 -2
  26. data/lib/clacky/server/http_server.rb +235 -40
  27. data/lib/clacky/server/scheduler.rb +17 -16
  28. data/lib/clacky/server/session_registry.rb +1 -5
  29. data/lib/clacky/server/web_ui_controller.rb +7 -6
  30. data/lib/clacky/session_manager.rb +22 -0
  31. data/lib/clacky/skill.rb +19 -3
  32. data/lib/clacky/skill_loader.rb +5 -59
  33. data/lib/clacky/tools/browser.rb +25 -73
  34. data/lib/clacky/tools/security.rb +326 -0
  35. data/lib/clacky/tools/terminal/output_cleaner.rb +63 -0
  36. data/lib/clacky/tools/terminal/persistent_session.rb +247 -0
  37. data/lib/clacky/tools/terminal/session_manager.rb +208 -0
  38. data/lib/clacky/tools/terminal.rb +818 -0
  39. data/lib/clacky/tools/todo_manager.rb +6 -16
  40. data/lib/clacky/tools/trash_manager.rb +2 -2
  41. data/lib/clacky/ui2/components/input_area.rb +11 -2
  42. data/lib/clacky/ui2/layout_manager.rb +438 -488
  43. data/lib/clacky/ui2/output_buffer.rb +310 -0
  44. data/lib/clacky/ui2/ui_controller.rb +72 -21
  45. data/lib/clacky/ui_interface.rb +1 -1
  46. data/lib/clacky/utils/encoding.rb +1 -1
  47. data/lib/clacky/utils/environment_detector.rb +43 -0
  48. data/lib/clacky/utils/model_pricing.rb +3 -3
  49. data/lib/clacky/version.rb +1 -1
  50. data/lib/clacky/web/app.css +479 -178
  51. data/lib/clacky/web/app.js +146 -4
  52. data/lib/clacky/web/auth.js +101 -0
  53. data/lib/clacky/web/i18n.js +35 -1
  54. data/lib/clacky/web/index.html +9 -2
  55. data/lib/clacky/web/sessions.js +254 -15
  56. data/lib/clacky/web/skills.js +20 -6
  57. data/lib/clacky/web/tasks.js +54 -2
  58. data/lib/clacky/web/theme.js +58 -20
  59. data/lib/clacky/web/ws.js +11 -2
  60. data/lib/clacky.rb +2 -2
  61. metadata +8 -3
  62. data/lib/clacky/tools/safe_shell.rb +0 -608
  63. data/lib/clacky/tools/shell.rb +0 -522
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 52f436f4aa95f2360172d33a3f6703b9106f9568d1c805fbc26248e9b483834c
4
- data.tar.gz: 42469a3ba3c357420b036fc4d877875e030e4dfba9a7d342a377c97991d370a7
3
+ metadata.gz: ff82f5ba11ed8afdfb840119c249c078f0b10024e746230eedf093169f63ae55
4
+ data.tar.gz: 0fe062fa3b73f168aeddde5a3a81a936a24faaa7be1b99329f8707bf52d89fbe
5
5
  SHA512:
6
- metadata.gz: 5f12512e1c10dbbe36db63aadfb221c84de40f5e944538b73fa3ebfd61839dbbe11d2906e2cc9c88dd67790b1d4060883805c5f61eeb1b21058a0f57e78732c7
7
- data.tar.gz: 10db3c5f50a2572198fa526fe1de55507b29d97499aae0238d2e8f447aac7ca960ce5159f897e4ba3150d90f8ffde5872848873a5414eb48b641fc91628247dc
6
+ metadata.gz: 1b7f42edce36076b5d467b6eefafdd02c40f381e25b9b010f0a32074ca51baea1a20545acdcde6a59467eb7a9b9b4fd930600f15a1858dd7aa54907ed855d391
7
+ data.tar.gz: edf9c74ae0704914ed4012984ee8642294e097092123ef9c79521e985857068df615946d74b6b059c69dd0868683e6706db971ec393b6fca44f961bbd190b403
data/CHANGELOG.md CHANGED
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.35] - 2026-04-23
11
+
12
+ ### Added
13
+ - **Unified Terminal tool**: merged the old `safe_shell` and `shell` tools into a single `terminal` tool with persistent PTY sessions — the agent can now keep a shell session alive across tool calls, send input to running prompts, poll long-running commands, and safely interrupt them (`Ctrl+C` / `Ctrl+D`). Replaces 1000+ lines of duplicated shell-handling logic with a cleaner, better-tested implementation.
14
+ - **Access key authentication for server mode**: start the Web UI server with `--access-key <key>` (or `CLACKY_ACCESS_KEY` env var) to require a login before anyone can open sessions — safe to expose the Web UI over the network or to share a running instance
15
+ - **Session debug download**: from the Web UI you can now download a full session bundle (messages, tool calls, config) as a zip for debugging or sharing — useful for bug reports and post-mortems
16
+ - **Scheduler now saves session state**: scheduled/cron tasks now persist their session after each run, so you can inspect what the scheduled task actually did from the Web UI just like a normal session
17
+ - **Web UI visual redesign**: substantial redesign of the sidebar, session list, settings panel, and theme — cleaner spacing, better contrast in both light and dark modes, smoother transitions
18
+ - **Web UI & channel message interrupt**: you can now cancel an in-progress agent reply from the Web UI or from an IM channel (Feishu/WeCom/WeChat) mid-flight instead of waiting for it to finish
19
+ - **Terminal tool UI tips**: the Web UI now surfaces helpful inline tips when the agent is running a terminal command (e.g. "waiting for input", "process still running"), making long-running commands easier to follow
20
+
21
+ ### Improved
22
+ - **Smaller tool descriptions**: trimmed the system-prompt footprint of `terminal`, `browser`, and `todo_manager` tool descriptions by ~40% — fewer tokens burned on every API call, slightly faster startup, and meaningfully cheaper sessions over time
23
+ - **Download fallback for skills & brand assets**: when the primary platform download host is unreachable (common in certain regions), the client now automatically falls back to a secondary URL — skill installs and brand asset fetches succeed in more network environments
24
+ - **Session cost shows "N/A" for unknown-price models**: instead of displaying `$0.00` when a model's pricing isn't registered, sessions now show "N/A" so you can tell the difference between "free call" and "we don't know the cost"
25
+ - **Faster, more accurate progress updates**: removed a delay in the progress spinner so the "Agent is thinking..." and tool-running indicators update immediately on state changes instead of a second later
26
+ - **No Claude-specific skill auto-loading**: removed legacy logic that special-cased loading `.claude/` skills at startup — skill loading is now uniform regardless of provider, reducing surprise behavior and confusing "unknown skill" errors
27
+
28
+ ### Fixed
29
+ - **`file://` links now render and open correctly** (C-5552, C-5553): file:// links are no longer stripped during streaming in the Web UI, and clicking them now opens the file via the backend (including proper foreground focus on WSL via `cmd.exe /c start`). Also fixes silent drop of `file://` links in the CLI.
30
+ - **Idle `Ctrl+C` no longer crashes the CLI**: pressing Ctrl+C while the CLI is idle (no task running) now exits cleanly instead of raising an error
31
+ - **Session pinned status persists correctly** (C-5556): pinning a session in the Web UI now survives server restarts and is correctly restored from disk
32
+ - **Brand skill names follow language switch**: brand-supplied skill names in the Web UI sidebar now update immediately when you toggle the UI language (previously stuck in the initial language until reload)
33
+ - **New sessions get the default model**: fixed a case where newly created sessions could end up on a different model than the configured default; the "lite UI" mode is no longer automatically forced either
34
+
35
+ ### More
36
+ - Large refactor of the UI2 `LayoutManager` + new `OutputBuffer` for cleaner CLI output line handling
37
+ - Agent progress-emission refactor for more consistent spinner/tool state reporting across Web, CLI, and channel UIs
38
+ - Removed the `safe_shell_spec` and `shell_spec` suites; replaced with a single, comprehensive `terminal_spec` (500+ lines of coverage)
39
+
10
40
  ## [0.9.34] - 2026-04-21
11
41
 
12
42
  ### Added
@@ -41,7 +41,7 @@ module Clacky
41
41
  token_data = collect_iteration_tokens(usage, iteration_cost)
42
42
 
43
43
  # Update session bar cost in real-time (don't wait for agent.run to finish)
44
- @ui&.update_sessionbar(cost: @total_cost)
44
+ @ui&.update_sessionbar(cost: @total_cost, cost_source: @cost_source)
45
45
 
46
46
  # Track cache usage statistics (global)
47
47
  @cache_stats[:total_requests] += 1
@@ -35,12 +35,20 @@ module Clacky
35
35
  # user experiences no extra delay.
36
36
  #
37
37
  # @return [Hash] API response with :content, :tool_calls, :usage, etc.
38
+ # NOTE on progress lifecycle:
39
+ # call_llm intentionally does NOT start or stop the progress indicator.
40
+ # Ownership lives with the caller (Agent#think for normal/compression
41
+ # paths, Agent#trigger_idle_compression for idle compression). This
42
+ # avoids nested active/done pairs clobbering each other — a bug that
43
+ # silently dropped the idle-compression summary line.
44
+ #
45
+ # Inside call_llm we only *update in place* during retries, so the
46
+ # already-live progress slot shows meaningful transient status
47
+ # ("Network failed… attempt 2/10", etc.).
38
48
  private def call_llm
39
49
  # Transition :fallback_active → :probing if cooling-off has expired.
40
50
  @config.maybe_start_probing
41
51
 
42
- @ui&.show_progress
43
-
44
52
  tools_to_send = @tool_registry.all_definitions
45
53
 
46
54
  max_retries = 10
@@ -68,7 +76,6 @@ module Clacky
68
76
  handle_probe_success if @config.probing?
69
77
 
70
78
  rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::SSLError, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
71
- @ui&.show_progress(phase: "done")
72
79
  retries += 1
73
80
 
74
81
  # Probing failure: primary still down — renew cooling-off and retry with fallback.
@@ -90,13 +97,12 @@ module Clacky
90
97
  sleep retry_delay
91
98
  retry
92
99
  else
93
- @ui&.show_progress(phase: "done")
94
- # Don't show_error here — let the outer rescue block handle it to avoid duplicates
100
+ # Don't show_error here — let the outer rescue block handle it to avoid duplicates.
101
+ # Progress cleanup is the caller's responsibility (via its own ensure block).
95
102
  raise AgentError, "[LLM] Network connection failed after #{max_retries} retries: #{e.message}"
96
103
  end
97
104
 
98
105
  rescue RetryableError => e
99
- @ui&.show_progress(phase: "done")
100
106
  retries += 1
101
107
 
102
108
  # Probing failure: primary still down — renew cooling-off and retry with fallback.
@@ -127,12 +133,10 @@ module Clacky
127
133
  sleep retry_delay
128
134
  retry
129
135
  else
130
- @ui&.show_progress(phase: "done")
131
- # Don't show_error here — let the outer rescue block handle it to avoid duplicates
136
+ # Don't show_error here — let the outer rescue block handle it to avoid duplicates.
137
+ # Progress cleanup is the caller's responsibility (via its own ensure block).
132
138
  raise AgentError, "[LLM] Service unavailable after #{current_max} retries"
133
139
  end
134
- ensure
135
- @ui&.show_progress(phase: "done")
136
140
  end
137
141
 
138
142
  # Track cost and collect token usage data.
@@ -130,7 +130,7 @@ module Clacky
130
130
  For each qualifying topic:
131
131
  a. If a matching file exists → read it with `file_reader(path: "~/.clacky/memories/<filename>")`, then write an updated version (merge new + old, drop stale)
132
132
  b. If no matching file → create a new one at `~/.clacky/memories/<new-filename>.md`
133
- Use the `write` tool to save each file. Do NOT use `safe_shell` or `file_reader` to list the directory.
133
+ Use the `write` tool to save each file. Do NOT use `terminal` or `file_reader` to list the directory.
134
134
 
135
135
  ## Hard constraints (CRITICAL)
136
136
  - Each file MUST stay under 4000 characters of content (after the frontmatter)
@@ -10,6 +10,7 @@ module Clacky
10
10
  def restore_session(session_data)
11
11
  @session_id = session_data[:session_id]
12
12
  @name = session_data[:name] || ""
13
+ @pinned = session_data[:pinned] || false
13
14
  @history = MessageHistory.new(session_data[:messages] || [])
14
15
  @todos = session_data[:todos] || [] # Restore todos from session
15
16
  @iterations = session_data.dig(:stats, :total_iterations) || 0
@@ -77,6 +78,7 @@ module Clacky
77
78
  {
78
79
  session_id: @session_id,
79
80
  name: @name,
81
+ pinned: @pinned,
80
82
  created_at: @created_at,
81
83
  updated_at: Time.now.iso8601,
82
84
  working_dir: @working_dir,
@@ -456,7 +456,7 @@ module Clacky
456
456
  # the real cumulative spend across all subagents
457
457
  subagent_cost = result[:total_cost_usd] || 0.0
458
458
  @total_cost += subagent_cost
459
- @ui&.update_sessionbar(cost: @total_cost)
459
+ @ui&.update_sessionbar(cost: @total_cost, cost_source: @cost_source)
460
460
 
461
461
  # Log completion
462
462
  @ui&.show_info("Subagent completed: #{result[:iterations]} iterations, $#{subagent_cost.round(4)} (total: $#{@total_cost.round(4)})")
@@ -21,7 +21,7 @@ module Clacky
21
21
  # confirm_all → human present, truly wait for user input
22
22
  true
23
23
  when :confirm_safes
24
- # Use SafeShell integration for safety check
24
+ # Use Security module to check auto-execution safety
25
25
  is_safe_operation?(tool_name, tool_params)
26
26
  else
27
27
  false
@@ -33,13 +33,14 @@ module Clacky
33
33
  # @param tool_params [Hash, String] Tool parameters
34
34
  # @return [Boolean] true if safe operation
35
35
  def is_safe_operation?(tool_name, tool_params = {})
36
- # For shell commands, use SafeShell to check safety
37
- if tool_name.to_s.downcase == 'shell' || tool_name.to_s.downcase == 'safe_shell'
36
+ # For terminal commands, defer to Security layer for the verdict.
37
+ if tool_name.to_s.downcase == 'terminal'
38
38
  params = tool_params.is_a?(String) ? JSON.parse(tool_params) : tool_params
39
39
  command = params[:command] || params['command']
40
- return false unless command
40
+ # No command = session_id continuation / kill / action → safe by default.
41
+ return true unless command
41
42
 
42
- return Tools::SafeShell.command_safe_for_auto_execution?(command)
43
+ return Clacky::Tools::Security.command_safe_for_auto_execution?(command)
43
44
  end
44
45
 
45
46
  if tool_name.to_s.downcase == 'edit' || tool_name.to_s.downcase == 'write'
@@ -140,10 +141,10 @@ module Clacky
140
141
  else
141
142
  "Write(#{filename}) - create new"
142
143
  end
143
- when "shell", "safe_shell"
144
+ when "terminal"
144
145
  cmd = args[:command] || ''
145
146
  display_cmd = cmd.length > 30 ? "#{cmd[0..27]}..." : cmd
146
- "#{call[:name]}(\"#{display_cmd}\")"
147
+ "terminal(\"#{display_cmd}\")"
147
148
  else
148
149
  "Allow #{call[:name]}"
149
150
  end
@@ -241,7 +242,7 @@ module Clacky
241
242
  # @return [Boolean] true if tool is potentially slow
242
243
  private def potentially_slow_tool?(tool_name, args)
243
244
  case tool_name.to_s.downcase
244
- when 'shell', 'safe_shell'
245
+ when 'terminal'
245
246
  # Check if the command is a slow command
246
247
  command = args[:command] || args['command']
247
248
  return false unless command
@@ -257,24 +258,20 @@ module Clacky
257
258
  /make\s+(test|build)/,
258
259
  /pytest/,
259
260
  /jest/,
260
- /sleep\s+\d+/ # sleep command with duration
261
+ /sleep\s+\d+/
261
262
  ]
262
263
 
263
264
  slow_patterns.any? { |pattern| command.match?(pattern) }
264
265
  when 'web_fetch', 'web_search'
265
- true # Network operations can be slow
266
+ true
266
267
  else
267
- false # Most file operations are fast
268
+ false
268
269
  end
269
270
  end
270
271
 
271
- # Build progress message for tool execution
272
- # @param tool_name [String] Name of the tool
273
- # @param args [Hash] Tool arguments
274
- # @return [String] Progress message
275
272
  private def build_tool_progress_message(tool_name, args)
276
273
  case tool_name.to_s.downcase
277
- when 'shell', 'safe_shell'
274
+ when 'terminal'
278
275
  "Running command"
279
276
  when 'web_fetch'
280
277
  "Fetching web page"
@@ -11,9 +11,6 @@ module Clacky
11
11
  end
12
12
 
13
13
  def get(name)
14
- # Handle shell alias to safe_shell for backward compatibility
15
- name = 'safe_shell' if name == 'shell' && @tools.key?('safe_shell') && !@tools.key?('shell')
16
-
17
14
  @tools[name] || raise(Clacky::ToolCallError, "Tool not found: #{name}")
18
15
  end
19
16
 
data/lib/clacky/agent.rb CHANGED
@@ -43,6 +43,7 @@ module Clacky
43
43
  attr_reader :session_id, :name, :history, :iterations, :total_cost, :working_dir, :created_at, :total_tasks, :todos,
44
44
  :cache_stats, :cost_source, :ui, :skill_loader, :agent_profile,
45
45
  :status, :error, :updated_at, :source
46
+ attr_accessor :pinned
46
47
 
47
48
  def permission_mode
48
49
  @config&.permission_mode&.to_s || ""
@@ -57,6 +58,7 @@ module Clacky
57
58
  @hooks = HookManager.new
58
59
  @session_id = session_id
59
60
  @name = ""
61
+ @pinned = false
60
62
  @history = MessageHistory.new
61
63
  @todos = [] # Store todos in memory
62
64
  @iterations = 0
@@ -188,6 +190,14 @@ module Clacky
188
190
  end
189
191
 
190
192
  def run(user_input, files: [])
193
+ # Show the "thinking" indicator as early as possible so the user gets
194
+ # immediate feedback after sending a message. Without this the UI stays
195
+ # silent during synchronous setup work (system prompt assembly, file
196
+ # parsing, history compression checks) before the first LLM call. The
197
+ # subsequent `think` call will re-emit show_progress, which is an
198
+ # idempotent update on the same progress UI element.
199
+ @ui&.show_progress
200
+
191
201
  # Start new task for Time Machine
192
202
  task_id = start_new_task
193
203
 
@@ -477,6 +487,11 @@ module Clacky
477
487
  # Always clean up memory update messages, even if interrupted or error occurred
478
488
  cleanup_memory_messages
479
489
 
490
+ # Safety net: ensure any lingering progress spinner is stopped.
491
+ # Normal paths close their own spinners; this guards against exceptions
492
+ # raised between a progress slot's active/done pair.
493
+ @ui&.show_progress(phase: "done")
494
+
480
495
  # Shred any decrypted-script tmpdirs created during this run for encrypted brand skills.
481
496
  # This covers the inline-injection path; the subagent path shreds immediately after
482
497
  # subagent.run returns (see execute_skill_with_subagent).
@@ -491,6 +506,12 @@ module Clacky
491
506
  raise AgentError, "API key is not configured"
492
507
  end
493
508
 
509
+ # Ensure a thinking progress indicator is live for the duration of this
510
+ # LLM turn. This is idempotent — if `run` already started one at task
511
+ # entry (or a previous iteration left one running), the UI recognizes
512
+ # the bare reentry and preserves the existing spinner.
513
+ @ui&.show_progress
514
+
494
515
  # Check if compression is needed
495
516
  compression_context = compress_messages_if_needed(force: false)
496
517
 
@@ -500,6 +521,14 @@ module Clacky
500
521
  @ui&.show_info(
501
522
  "Message history compression starting (~#{compression_context[:original_token_count]} tokens, #{compression_context[:original_message_count]} messages) - Level #{compression_context[:compression_level]}"
502
523
  )
524
+ # Take over the progress slot with a compression-specific message.
525
+ # handle_compression_response will close it with the summary line on
526
+ # success; on failure the outer ensure below finalizes it.
527
+ @ui&.show_progress(
528
+ "Compressing message history...",
529
+ progress_type: "idle_compress",
530
+ phase: "active"
531
+ )
503
532
  compression_message = compression_context[:compression_message]
504
533
  @history.append(compression_message)
505
534
  compression_handled = false
@@ -518,13 +547,24 @@ module Clacky
518
547
  # (with the user's new message as the last entry), producing consecutive user messages
519
548
  # that confuse the LLM into echoing compression instructions.
520
549
  @compression_level -= 1
550
+ # Close the compression progress slot so the spinner does not linger.
551
+ @ui&.show_progress(phase: "done")
521
552
  end
522
553
  end
523
554
  return nil
524
555
  end
525
556
 
526
- # Normal LLM call
527
- response = call_llm
557
+ # Normal LLM call. call_llm no longer manages the progress lifecycle;
558
+ # we keep the spinner live across the call and finalize it here so the
559
+ # UI transitions cleanly to the assistant message that follows.
560
+ response = nil
561
+ begin
562
+ response = call_llm
563
+ rescue
564
+ # Ensure the spinner is stopped on any error path before it bubbles up.
565
+ @ui&.show_progress(phase: "done")
566
+ raise
567
+ end
528
568
 
529
569
  # Handle truncated responses (when max_tokens limit is reached)
530
570
  if response[:finish_reason] == "length"
@@ -533,6 +573,7 @@ module Clacky
533
573
 
534
574
  if @task_truncation_count >= 3
535
575
  # Too many truncations - task is too complex
576
+ @ui&.show_progress(phase: "done")
536
577
  @ui&.show_error("Response truncated multiple times. Task is too complex.")
537
578
 
538
579
  # Create a response that tells the user to break down the task
@@ -578,6 +619,9 @@ module Clacky
578
619
  truncated: true
579
620
  })
580
621
 
622
+ # Close the current spinner so the warning appears cleanly;
623
+ # the recursive think() call below will reopen a new one.
624
+ @ui&.show_progress(phase: "done")
581
625
  @ui&.show_warning("Response truncated (#{@task_truncation_count}/3). Retrying with smaller steps...")
582
626
 
583
627
  # Recursively retry
@@ -600,6 +644,11 @@ module Clacky
600
644
  msg[:reasoning_content] = response[:reasoning_content] if response[:reasoning_content]
601
645
  @history.append(msg)
602
646
 
647
+ # Close the thinking spinner before returning. The caller (run loop)
648
+ # is about to render the assistant message and/or tool invocations,
649
+ # which should appear after the spinner disappears.
650
+ @ui&.show_progress(phase: "done")
651
+
603
652
  response
604
653
  end
605
654
 
@@ -686,46 +735,24 @@ module Clacky
686
735
  args[:agent] = self
687
736
  end
688
737
 
689
- # For safe_shell, skip safety check if user has already confirmed
690
- if call[:name] == "safe_shell" || call[:name] == "shell"
691
- args[:skip_safety_check] = true
692
- end
693
-
694
738
  # Inject working_dir so tools don't rely on Dir.chdir global state
695
739
  args[:working_dir] = @working_dir if @working_dir
696
740
 
697
- # Automatic progress display after 2 seconds for any tool execution
741
+ # Show progress immediately for every tool execution so the user
742
+ # always knows the agent is working. (Previously we deferred this by
743
+ # 2 seconds to avoid flicker in the legacy CLI TUI; that trade-off is
744
+ # no longer desirable now that progress is a first-class UI state in
745
+ # the Web UI and structured JSON UIs.)
698
746
  progress_shown = false
699
- progress_timer = nil
700
-
701
747
  if @ui
702
748
  progress_message = build_tool_progress_message(call[:name], args)
703
-
704
- # For shell/safe_shell: inject on_output callback for real-time stdout streaming.
705
- # The callback fires immediately on each read_nonblock chunk — no polling delay.
706
- if (call[:name] == "shell" || call[:name] == "safe_shell") &&
707
- @ui.respond_to?(:show_tool_stdout)
708
- args[:on_output] = ->(stream, data) {
709
- @ui.show_tool_stdout([data]) if stream == :stdout
710
- }
711
- end
712
-
713
- progress_timer = Thread.new do
714
- sleep 2
715
- @ui.show_progress(progress_message, prefix_newline: false)
716
- progress_shown = true
717
- # Streaming is handled by on_output callback — no polling loop needed here
718
- end
749
+ @ui.show_progress(progress_message, prefix_newline: false)
750
+ progress_shown = true
719
751
  end
720
752
 
721
753
  begin
722
754
  result = tool.execute(**args)
723
755
  ensure
724
- # Cancel timer and clear progress if shown
725
- if progress_timer
726
- progress_timer.kill
727
- progress_timer.join
728
- end
729
756
  @ui&.show_progress(phase: "done") if progress_shown
730
757
  end
731
758
 
@@ -927,7 +954,7 @@ module Clacky
927
954
  end
928
955
 
929
956
  private def register_builtin_tools
930
- @tool_registry.register(Tools::SafeShell.new)
957
+ @tool_registry.register(Tools::Terminal.new)
931
958
  @tool_registry.register(Tools::FileReader.new)
932
959
  @tool_registry.register(Tools::Write.new)
933
960
  @tool_registry.register(Tools::Edit.new)
@@ -936,7 +963,6 @@ module Clacky
936
963
  @tool_registry.register(Tools::WebSearch.new)
937
964
  @tool_registry.register(Tools::WebFetch.new)
938
965
  @tool_registry.register(Tools::TodoManager.new)
939
- # @tool_registry.register(Tools::RunProject.new) # temporarily disabled
940
966
  @tool_registry.register(Tools::RequestUserFeedback.new)
941
967
  @tool_registry.register(Tools::InvokeSkill.new)
942
968
  @tool_registry.register(Tools::UndoTask.new)
@@ -1250,13 +1276,13 @@ module Clacky
1250
1276
  # [Download report](file:///path/to/file.pdf)
1251
1277
  # ![chart](file:///path/to/chart.png)
1252
1278
  #
1253
- # Returns { text: String, files: Array<{name:, path:, inline:}> }
1254
- # File links are stripped from the returned text.
1279
+ # Returns { text: String (original content, unmodified),
1280
+ # files: Array<{name:, path:, inline:}> }
1255
1281
  private def parse_file_links(content)
1256
1282
  return { text: content, files: [] } if content.nil? || content.empty?
1257
1283
 
1258
1284
  files = []
1259
- text = content.gsub(/(!?)\[([^\]]*)\]\(file:\/\/([^)]+)\)/) do
1285
+ content.scan(/(!?)\[([^\]]*)\]\(file:\/\/([^)]+)\)/) do
1260
1286
  inline = $1 == "!"
1261
1287
  # URL-decode percent-encoded characters (e.g. Chinese filenames encoded by AI)
1262
1288
  raw_path = CGI.unescape($3)
@@ -1264,9 +1290,8 @@ module Clacky
1264
1290
  path = File.expand_path(raw_path)
1265
1291
  Clacky::Logger.info("[parse_file_links] raw=#{$3.inspect} expanded=#{path.inspect} exist=#{File.exist?(path)}")
1266
1292
  files << { name: name, path: path, inline: inline }
1267
- ""
1268
1293
  end
1269
- { text: text.strip, files: files }
1294
+ { text: content, files: files }
1270
1295
  end
1271
1296
 
1272
1297
  # Emit assistant message to UI, parsing any embedded file:// links first.
@@ -238,7 +238,11 @@ module Clacky
238
238
  # The injected lite model is runtime-only (not persisted to config.yml)
239
239
  inject_provider_lite_model(models)
240
240
 
241
- new(models: models)
241
+ # Find the index of the model marked as "default" (type: default)
242
+ # Fall back to 0 if no model has type: default
243
+ default_index = models.find_index { |m| m["type"] == "default" } || 0
244
+
245
+ new(models: models, current_model_index: default_index)
242
246
  end
243
247
 
244
248
  # Auto-inject a lite model entry if the default model's provider supports one
@@ -533,9 +533,11 @@ module Clacky
533
533
  dest_dir = File.join(brand_skills_dir, slug)
534
534
  FileUtils.mkdir_p(dest_dir)
535
535
 
536
- # Download the zip file to a temp path
536
+ # Download the zip file to a temp path via PlatformHttpClient so the
537
+ # primary → fallback host failover applies uniformly to every download.
537
538
  tmp_zip = File.join(brand_skills_dir, "#{slug}.zip")
538
- download_file(url, tmp_zip)
539
+ dl = platform_client.download_file(url, tmp_zip)
540
+ raise dl[:error].to_s unless dl[:success]
539
541
 
540
542
  # Extract into dest_dir (overwrite existing files).
541
543
  # Auto-detect whether the zip has a single root folder to strip.
@@ -1072,32 +1074,14 @@ module Clacky
1072
1074
  end
1073
1075
 
1074
1076
  # Download a remote URL to a local file path.
1077
+ #
1078
+ # Deprecated: this method now delegates to
1079
+ # Clacky::PlatformHttpClient#download_file so that every brand-skill download
1080
+ # benefits from primary → fallback host failover. Kept as a thin wrapper
1081
+ # so existing callers / tests that stub it continue to work.
1075
1082
  private def download_file(url, dest, max_redirects: 10)
1076
- require "net/http"
1077
- require "uri"
1078
-
1079
- uri = URI.parse(url)
1080
- max_redirects.times do
1081
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
1082
- open_timeout: 15, read_timeout: 60) do |http|
1083
- http.request_get(uri.request_uri) do |resp|
1084
- case resp.code.to_i
1085
- when 200
1086
- File.open(dest, "wb") { |f| resp.read_body { |chunk| f.write(chunk) } }
1087
- return
1088
- when 301, 302, 303, 307, 308
1089
- location = resp["location"]
1090
- raise "Redirect with no Location header" if location.nil? || location.empty?
1091
-
1092
- uri = URI.parse(location)
1093
- break # break out of Net::HTTP.start, re-enter loop with new uri
1094
- else
1095
- raise "HTTP #{resp.code}"
1096
- end
1097
- end
1098
- end
1099
- end
1100
- raise "Too many redirects"
1083
+ result = platform_client.download_file(url, dest)
1084
+ raise result[:error].to_s unless result[:success]
1101
1085
  end
1102
1086
 
1103
1087
  # Persist installed skill metadata to brand_skills.json.
data/lib/clacky/cli.rb CHANGED
@@ -663,6 +663,18 @@ module Clacky
663
663
 
664
664
  # Set up interrupt handler
665
665
  ui_controller.on_interrupt do |input_was_empty:|
666
+ # Priority 1: if idle compression work is actually in flight,
667
+ # Ctrl+C should stop compression — not exit the program. The
668
+ # compress thread rolls back history cleanly on AgentInterrupted.
669
+ if idle_timer.compressing?
670
+ idle_timer.cancel
671
+ ui_controller.show_progress(phase: "done")
672
+ ui_controller.set_idle_status
673
+ ui_controller.show_warning("Compression interrupted by user")
674
+ ui_controller.clear_input
675
+ next
676
+ end
677
+
666
678
  if (not current_task_thread&.alive?) && input_was_empty
667
679
  # Save final session state before exit
668
680
  if session_manager && agent.total_tasks > 0
@@ -822,6 +834,30 @@ module Clacky
822
834
  option :brand_test, type: :boolean, default: false,
823
835
  desc: "Enable brand test mode: mock license activation without calling remote API"
824
836
  def server
837
+ # ── Security gate ──────────────────────────────────────────────────────
838
+ # Binding to 0.0.0.0 exposes the server to the public network.
839
+ # Refuse to start unless CLACKY_ACCESS_KEY env var is set.
840
+ if options[:host] == "0.0.0.0" && ENV.fetch("CLACKY_ACCESS_KEY", "").strip.empty?
841
+ puts <<~MSG
842
+ ╔══════════════════════════════════════════════════════════════╗
843
+ ║ ⚠️ Security Warning: Refusing to start ║
844
+ ╠══════════════════════════════════════════════════════════════╣
845
+ ║ ║
846
+ ║ Binding to 0.0.0.0 exposes Clacky to the public network. ║
847
+ ║ You must set CLACKY_ACCESS_KEY before starting the server. ║
848
+ ║ ║
849
+ ║ Generate a secure key: ║
850
+ ║ openssl rand -hex 32 ║
851
+ ║ ║
852
+ ║ Then export it: ║
853
+ ║ export CLACKY_ACCESS_KEY=<your-generated-key> ║
854
+ ║ ║
855
+ ╚══════════════════════════════════════════════════════════════╝
856
+ MSG
857
+ exit(1)
858
+ end
859
+ # ─────────────────────────────────────────────────────────────────────
860
+
825
861
  if ENV["CLACKY_WORKER"] == "1"
826
862
  # ── Worker mode ───────────────────────────────────────────────────────
827
863
  # Spawned by Master. Inherit the listen socket from the file descriptor
@@ -92,7 +92,7 @@ Run the setup script (full path is available in the supporting files list above)
92
92
  ```bash
93
93
  ruby "SKILL_DIR/feishu_setup.rb"
94
94
  ```
95
- **Important**: call `safe_shell` with `timeout: 180` — the script may wait up to 90s for a WebSocket connection in Phase 4.
95
+ **Important**: call `terminal` with `timeout: 180` — the script may wait up to 90s for a WebSocket connection in Phase 4.
96
96
 
97
97
  **If exit code is 0:**
98
98
  - The script completed successfully.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Ensure all output is flushed immediately so users see live progress
4
- # even when the script is run inside a subprocess (safe_shell / Open3).
4
+ # even when the script is run inside a subprocess (terminal / Open3).
5
5
  $stdout.sync = true
6
6
  $stderr.sync = true
7
7
 
@@ -100,7 +100,7 @@ Do you want to reuse this binding? (y/n)
100
100
 
101
101
  #### 3.2 Run the cloud project init script
102
102
  Use the `cloud_project_init.sh` script from Supporting Files.
103
- Run it with safe_shell from the project directory (no arguments needed — it auto-reads `~/.clacky/clacky_cloud.yml`):
103
+ Run it with `terminal` from the project directory (no arguments needed — it auto-reads `~/.clacky/clacky_cloud.yml`):
104
104
 
105
105
  ```bash
106
106
  bash <absolute_path_to_cloud_project_init.sh>
@@ -7,7 +7,7 @@ auto_summarize: true
7
7
  forbidden_tools:
8
8
  - write
9
9
  - edit
10
- - safe_shell
10
+ - terminal
11
11
  - web_search
12
12
  ---
13
13
 
@@ -45,7 +45,7 @@ file_reader(path: "~/.clacky/memories/<filename>")
45
45
 
46
46
  2. Touch the file to update its mtime (LRU signal — keeps it surfaced in future recalls):
47
47
  ```
48
- safe_shell(command: "touch ~/.clacky/memories/<filename>")
48
+ terminal(command: "touch ~/.clacky/memories/<filename>")
49
49
  ```
50
50
 
51
51
  Return ONLY the memory content, structured as:
@@ -62,7 +62,7 @@ Fast, opinionated skill creation without user interaction. This mode is used by
62
62
  ```
63
63
  invoke_skill(
64
64
  skill_name: "skill-creator",
65
- task: "Create a skill to extract and summarize content from URLs. The skill should: 1) fetch the URL using safe_shell with curl, 2) parse the HTML to extract main text content, 3) generate a concise markdown summary. Expected input: URL string. Expected output: markdown summary with title and key points.",
65
+ task: "Create a skill to extract and summarize content from URLs. The skill should: 1) fetch the URL using terminal with curl, 2) parse the HTML to extract main text content, 3) generate a concise markdown summary. Expected input: URL string. Expected output: markdown summary with title and key points.",
66
66
  mode: "quick",
67
67
  suggested_name: "url-summarizer"
68
68
  )