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
@@ -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)
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "timeout"
6
+ require "yaml"
7
+ require "fileutils"
8
+
9
+ module Clacky
10
+ # Loads declarative, shell-based hooks from ~/.clacky/hooks.yml and registers
11
+ # them on a HookManager. Each hook runs an external command rather than Ruby in
12
+ # the agent process, which keeps user-authored hooks sandboxed and safe.
13
+ #
14
+ # hooks.yml format:
15
+ # hooks:
16
+ # before_tool_use:
17
+ # - name: guard # optional label for logs
18
+ # command: "~/.clacky/hook-scripts/guard.sh"
19
+ # timeout: 10 # optional, seconds (default 10)
20
+ # on_complete:
21
+ # - command: "notify-send done"
22
+ #
23
+ # Runtime contract (per invocation):
24
+ # - The event payload is passed to the command as JSON on STDIN.
25
+ # - exit 0 → allow (default).
26
+ # - exit 2 → deny; STDOUT becomes the denial reason. Only meaningful for
27
+ # before_tool_use, which the agent checks for {action: :deny}.
28
+ # - any other exit / timeout / crash → logged, treated as allow (a broken
29
+ # hook must never wedge the agent).
30
+ class ShellHookLoader
31
+ DEFAULT_PATH = File.expand_path("~/.clacky/hooks.yml")
32
+ DEFAULT_TIMEOUT = 10
33
+ DENY_EXIT_CODE = 2
34
+
35
+ Result = Struct.new(:registered, :skipped, keyword_init: true)
36
+
37
+ def self.load_into(hook_manager, path: DEFAULT_PATH)
38
+ new(path: path).load_into(hook_manager)
39
+ end
40
+
41
+ # Create a starter hooks.yml plus an example guard script. Idempotent-ish:
42
+ # raises if hooks.yml already exists so we never clobber user config.
43
+ # @return [String] path to the created hooks.yml
44
+ def self.scaffold(path: DEFAULT_PATH)
45
+ raise ArgumentError, "hooks file already exists: #{path}" if File.exist?(path)
46
+
47
+ dir = File.dirname(path)
48
+ scripts_dir = File.join(dir, "hook-scripts")
49
+ FileUtils.mkdir_p(scripts_dir)
50
+
51
+ guard = File.join(scripts_dir, "deny-example.sh")
52
+ File.write(guard, <<~SH)
53
+ #!/usr/bin/env bash
54
+ # Example before_tool_use hook.
55
+ # Reads the event JSON on STDIN; exit 2 to DENY, exit 0 to ALLOW.
56
+ # STDOUT on exit 2 becomes the denial reason shown to the agent.
57
+ payload="$(cat)"
58
+ # Example: deny any terminal command containing "rm -rf /"
59
+ if echo "$payload" | grep -q 'rm -rf /'; then
60
+ echo "blocked dangerous command"
61
+ exit 2
62
+ fi
63
+ exit 0
64
+ SH
65
+ FileUtils.chmod("+x", guard)
66
+
67
+ File.write(path, <<~YAML)
68
+ # Declarative shell hooks. Each command receives the event payload as JSON
69
+ # on STDIN. For before_tool_use: exit 2 = deny (STDOUT = reason), exit 0 = allow.
70
+ # Events: #{HookManager::HOOK_EVENTS.join(", ")}
71
+ hooks:
72
+ before_tool_use:
73
+ - name: deny-example
74
+ command: "#{guard}"
75
+ timeout: 10
76
+ # on_complete:
77
+ # - command: "echo task finished"
78
+ YAML
79
+
80
+ path
81
+ end
82
+
83
+ def initialize(path: DEFAULT_PATH)
84
+ @path = path
85
+ end
86
+
87
+ # @return [Result] counts of registered hooks and skipped (with reasons)
88
+ def load_into(hook_manager)
89
+ result = Result.new(registered: [], skipped: [])
90
+ return result unless File.exist?(@path)
91
+
92
+ doc = YAMLCompat.load_file(@path) || {}
93
+ events = doc["hooks"] || {}
94
+
95
+ events.each do |event_name, specs|
96
+ event = event_name.to_sym
97
+ Array(specs).each do |spec|
98
+ register_one(hook_manager, event, spec, result)
99
+ end
100
+ end
101
+
102
+ result
103
+ rescue StandardError => e
104
+ Clacky::Logger.error("[ShellHookLoader] Failed to load #{@path}: #{e.message}")
105
+ result
106
+ end
107
+
108
+ private def register_one(hook_manager, event, spec, result)
109
+ command = spec["command"].to_s.strip
110
+ name = spec["name"] || command
111
+ timeout = (spec["timeout"] || DEFAULT_TIMEOUT).to_i
112
+
113
+ if command.empty?
114
+ result.skipped << [name, "missing command"]
115
+ return
116
+ end
117
+
118
+ unless HookManager::HOOK_EVENTS.include?(event)
119
+ result.skipped << [name, "unknown event: #{event}"]
120
+ return
121
+ end
122
+
123
+ hook_manager.add(event) do |*args|
124
+ run_command(event, command, timeout, args)
125
+ end
126
+ result.registered << [event, name]
127
+ end
128
+
129
+ private def run_command(event, command, timeout, args)
130
+ payload = JSON.generate(build_payload(event, args))
131
+
132
+ out = +""
133
+ status = nil
134
+ Open3.popen3(command) do |stdin, stdout, _stderr, wait_thr|
135
+ stdin.write(payload)
136
+ stdin.close
137
+ if wait_thr.join(timeout)
138
+ out = stdout.read
139
+ status = wait_thr.value
140
+ else
141
+ Process.kill("TERM", wait_thr.pid) rescue nil
142
+ raise Timeout::Error
143
+ end
144
+ end
145
+
146
+ if status&.exitstatus == DENY_EXIT_CODE
147
+ { action: :deny, reason: out.strip.empty? ? "Denied by hook" : out.strip }
148
+ else
149
+ { action: :allow }
150
+ end
151
+ rescue Timeout::Error
152
+ Clacky::Logger.warn("[ShellHookLoader] Hook '#{command}' timed out after #{timeout}s — allowing")
153
+ { action: :allow }
154
+ rescue StandardError => e
155
+ Clacky::Logger.warn("[ShellHookLoader] Hook '#{command}' failed: #{e.message} — allowing")
156
+ { action: :allow }
157
+ end
158
+
159
+ # Normalize the positional trigger args of each event into a JSON-serializable hash.
160
+ private def build_payload(event, args)
161
+ base = { event: event.to_s }
162
+
163
+ case event
164
+ when :before_tool_use, :after_tool_use, :on_tool_error
165
+ base[:tool] = args[0]
166
+ base[:result] = args[1] if args.length > 1 && event == :after_tool_use
167
+ base[:error] = args[1].to_s if event == :on_tool_error && args[1]
168
+ when :on_start
169
+ base[:user_input] = args[0].to_s
170
+ when :on_iteration
171
+ base[:iteration] = args[0]
172
+ when :on_complete
173
+ base[:result] = args[0]
174
+ when :session_rollback
175
+ base[:info] = args[0]
176
+ end
177
+
178
+ base
179
+ end
180
+ end
181
+ end
@@ -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.8"
5
5
  end