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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +34 -0
- data/README_CN.md +34 -0
- data/lib/clacky/agent/cost_tracker.rb +7 -1
- data/lib/clacky/agent/message_compressor.rb +2 -1
- data/lib/clacky/agent/message_compressor_helper.rb +6 -2
- data/lib/clacky/agent/session_serializer.rb +23 -4
- data/lib/clacky/agent.rb +46 -2
- data/lib/clacky/agent_config.rb +54 -6
- data/lib/clacky/billing/billing_store.rb +107 -3
- data/lib/clacky/brand_config.rb +0 -6
- data/lib/clacky/cli.rb +107 -1
- data/lib/clacky/client.rb +56 -6
- data/lib/clacky/default_skills/deploy/SKILL.md +2 -1
- data/lib/clacky/default_skills/extend-openclacky/SKILL.md +39 -0
- data/lib/clacky/default_skills/mcp-manager/SKILL.md +0 -7
- data/lib/clacky/default_skills/onboard/SKILL.md +2 -2
- data/lib/clacky/json_ui_controller.rb +5 -2
- data/lib/clacky/patch_loader.rb +282 -0
- data/lib/clacky/plain_ui_controller.rb +1 -1
- data/lib/clacky/providers.rb +11 -2
- data/lib/clacky/server/channel/adapters/base.rb +4 -0
- data/lib/clacky/server/channel/channel_manager.rb +149 -13
- data/lib/clacky/server/channel/channel_ui_controller.rb +4 -2
- data/lib/clacky/server/channel/user_adapter_loader.rb +177 -0
- data/lib/clacky/server/channel.rb +5 -0
- data/lib/clacky/server/http_server.rb +135 -14
- data/lib/clacky/server/scheduler.rb +1 -4
- data/lib/clacky/server/session_registry.rb +30 -4
- data/lib/clacky/server/web_ui_controller.rb +6 -3
- data/lib/clacky/shell_hook_loader.rb +181 -0
- data/lib/clacky/tools/terminal.rb +22 -26
- data/lib/clacky/ui2/ui_controller.rb +1 -1
- data/lib/clacky/ui_interface.rb +1 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +392 -14
- data/lib/clacky/web/app.js +0 -1
- data/lib/clacky/web/billing.js +117 -22
- data/lib/clacky/web/i18n.js +50 -6
- data/lib/clacky/web/index.html +33 -0
- data/lib/clacky/web/sessions.js +203 -14
- data/lib/clacky/web/settings.js +59 -17
- data/lib/clacky/web/workspace.js +204 -0
- data/lib/clacky/web/ws-dispatcher.js +19 -3
- data/lib/clacky.rb +15 -0
- 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],
|
|
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],
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
data/lib/clacky/ui_interface.rb
CHANGED
|
@@ -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
|
|
data/lib/clacky/version.rb
CHANGED