openclacky 1.2.7 → 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 +14 -0
- data/lib/clacky/agent.rb +3 -0
- data/lib/clacky/billing/billing_store.rb +107 -3
- data/lib/clacky/cli.rb +105 -0
- data/lib/clacky/client.rb +32 -3
- 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/patch_loader.rb +282 -0
- data/lib/clacky/server/channel/adapters/base.rb +4 -0
- data/lib/clacky/server/channel/channel_manager.rb +1 -1
- 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 +26 -5
- data/lib/clacky/server/scheduler.rb +1 -4
- data/lib/clacky/shell_hook_loader.rb +181 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +155 -13
- data/lib/clacky/web/billing.js +117 -22
- data/lib/clacky/web/i18n.js +26 -6
- data/lib/clacky.rb +6 -0
- metadata +6 -2
|
@@ -511,7 +511,7 @@ module Clacky
|
|
|
511
511
|
platform = event[:platform].to_s
|
|
512
512
|
count = @mutex.synchronize { @session_counters[platform] += 1 }
|
|
513
513
|
name = "#{platform}-#{count}"
|
|
514
|
-
session_id = @session_builder.call(name: name,
|
|
514
|
+
session_id = @session_builder.call(name: name, source: :channel)
|
|
515
515
|
bind_key_to_session(key, session_id)
|
|
516
516
|
|
|
517
517
|
# Create a long-lived ChannelUIController for this session and subscribe it
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Clacky
|
|
6
|
+
module Channel
|
|
7
|
+
module Adapters
|
|
8
|
+
# Loads user-defined channel adapters from ~/.clacky/channels/<name>/adapter.rb.
|
|
9
|
+
#
|
|
10
|
+
# Each adapter file is plain Ruby that defines a subclass of
|
|
11
|
+
# Clacky::Channel::Adapters::Base and self-registers via Adapters.register,
|
|
12
|
+
# exactly like the bundled adapters. This loader only discovers and requires
|
|
13
|
+
# those files after the built-in adapters are loaded — the existing
|
|
14
|
+
# self-registration mechanism then takes over with no further wiring.
|
|
15
|
+
#
|
|
16
|
+
# A broken adapter (syntax error, missing interface methods) is isolated:
|
|
17
|
+
# it is skipped with a logged warning and never aborts the load of others.
|
|
18
|
+
module UserAdapterLoader
|
|
19
|
+
DEFAULT_DIR = File.expand_path("~/.clacky/channels")
|
|
20
|
+
|
|
21
|
+
# Required class/instance methods a user adapter must implement to be usable.
|
|
22
|
+
REQUIRED_CLASS_METHODS = %i[platform_id platform_config].freeze
|
|
23
|
+
REQUIRED_INSTANCE_METHODS = %i[start stop send_text].freeze
|
|
24
|
+
|
|
25
|
+
Result = Struct.new(:loaded, :skipped, keyword_init: true)
|
|
26
|
+
|
|
27
|
+
# @param dir [String] directory to scan (override for tests)
|
|
28
|
+
# @return [Result] names loaded and skipped (with reasons)
|
|
29
|
+
def self.load_all(dir: DEFAULT_DIR)
|
|
30
|
+
result = Result.new(loaded: [], skipped: [])
|
|
31
|
+
if Dir.exist?(dir)
|
|
32
|
+
Dir.glob(File.join(dir, "*", "adapter.rb")).sort.each do |path|
|
|
33
|
+
name = File.basename(File.dirname(path))
|
|
34
|
+
load_one(path, name, result)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
@last_result = result
|
|
38
|
+
result
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The result of the most recent load_all (set at startup). Lets `channel_verify`
|
|
42
|
+
# report status without re-requiring files (require is idempotent and would
|
|
43
|
+
# otherwise report already-loaded adapters as "did not register").
|
|
44
|
+
def self.last_result
|
|
45
|
+
@last_result || load_all
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.load_one(path, name, result)
|
|
49
|
+
before = Adapters.all.dup
|
|
50
|
+
|
|
51
|
+
require path
|
|
52
|
+
|
|
53
|
+
newly = Adapters.all - before
|
|
54
|
+
klass = newly.last
|
|
55
|
+
|
|
56
|
+
unless klass
|
|
57
|
+
result.skipped << [name, "did not register an adapter (missing Adapters.register?)"]
|
|
58
|
+
log_skip(name, result.skipped.last[1])
|
|
59
|
+
return
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
if (missing = interface_gaps(klass)).any?
|
|
63
|
+
unregister(klass)
|
|
64
|
+
result.skipped << [name, "missing required methods: #{missing.join(", ")}"]
|
|
65
|
+
log_skip(name, result.skipped.last[1])
|
|
66
|
+
return
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
result.loaded << name
|
|
70
|
+
Clacky::Logger.info("[UserAdapterLoader] Loaded channel adapter '#{name}' → :#{klass.platform_id}")
|
|
71
|
+
rescue StandardError, ScriptError => e
|
|
72
|
+
result.skipped << [name, e.message]
|
|
73
|
+
log_skip(name, e.message)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.interface_gaps(klass)
|
|
77
|
+
missing = REQUIRED_CLASS_METHODS.reject { |m| klass.respond_to?(m) }
|
|
78
|
+
# Base defines stub instance methods that only raise NotImplementedError,
|
|
79
|
+
# so method_defined? alone passes via inheritance. Require the subclass to
|
|
80
|
+
# actually override them — i.e. the method's owner must not be Base.
|
|
81
|
+
missing += REQUIRED_INSTANCE_METHODS.reject do |m|
|
|
82
|
+
klass.method_defined?(m) && klass.instance_method(m).owner != Base
|
|
83
|
+
end
|
|
84
|
+
missing
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.unregister(klass)
|
|
88
|
+
platform = (klass.platform_id if klass.respond_to?(:platform_id))
|
|
89
|
+
return unless platform
|
|
90
|
+
|
|
91
|
+
Adapters.unregister(platform)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.log_skip(name, reason)
|
|
95
|
+
Clacky::Logger.warn("[UserAdapterLoader] Skipped channel adapter '#{name}': #{reason}")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Generate a ready-to-edit adapter skeleton at ~/.clacky/channels/<name>/adapter.rb.
|
|
99
|
+
# The skeleton already self-registers and implements the full interface with
|
|
100
|
+
# TODO markers — the author only fills in the method bodies.
|
|
101
|
+
# @return [String] path to the generated adapter.rb
|
|
102
|
+
def self.scaffold(name, dir: DEFAULT_DIR)
|
|
103
|
+
slug = name.to_s.strip.downcase.gsub(/[^a-z0-9_]+/, "_").gsub(/\A_+|_+\z/, "")
|
|
104
|
+
raise ArgumentError, "invalid channel name: #{name.inspect}" if slug.empty?
|
|
105
|
+
|
|
106
|
+
target_dir = File.join(dir, slug)
|
|
107
|
+
path = File.join(target_dir, "adapter.rb")
|
|
108
|
+
raise ArgumentError, "adapter already exists: #{path}" if File.exist?(path)
|
|
109
|
+
|
|
110
|
+
FileUtils.mkdir_p(target_dir)
|
|
111
|
+
File.write(path, skeleton(slug))
|
|
112
|
+
path
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def self.skeleton(slug)
|
|
116
|
+
const = slug.split("_").map(&:capitalize).join
|
|
117
|
+
<<~RUBY
|
|
118
|
+
# frozen_string_literal: true
|
|
119
|
+
|
|
120
|
+
# User-defined channel adapter for ":#{slug}".
|
|
121
|
+
# Edit the TODO sections, then it loads automatically on next start.
|
|
122
|
+
# Verify with: clacky channel verify
|
|
123
|
+
|
|
124
|
+
module Clacky
|
|
125
|
+
module Channel
|
|
126
|
+
module Adapters
|
|
127
|
+
class #{const}Adapter < Base
|
|
128
|
+
def self.platform_id
|
|
129
|
+
:#{slug}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Map raw config (channels.yml `#{slug}` section) to a symbol-keyed hash.
|
|
133
|
+
def self.platform_config(data)
|
|
134
|
+
{
|
|
135
|
+
# TODO: pull your credentials out of `data`
|
|
136
|
+
# token: data["IM_#{slug.upcase}_TOKEN"] || data["token"]
|
|
137
|
+
}.compact
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def initialize(config)
|
|
141
|
+
@config = config
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Begin receiving messages. Blocks until #stop — runs inside a Thread.
|
|
145
|
+
# Yield one standardized event Hash per inbound message.
|
|
146
|
+
def start(&on_message)
|
|
147
|
+
# TODO: connect to your platform and loop, calling on_message.call(event)
|
|
148
|
+
raise NotImplementedError
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def stop
|
|
152
|
+
# TODO: close connections / stop the read loop
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Send a plain text (or Markdown) message to a chat.
|
|
156
|
+
# @return [Hash] { message_id: String }
|
|
157
|
+
def send_text(chat_id, text, reply_to: nil)
|
|
158
|
+
# TODO: call your platform's send API
|
|
159
|
+
raise NotImplementedError
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Optional: validate config; return array of error strings (empty = ok).
|
|
163
|
+
def validate_config(config)
|
|
164
|
+
[]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
Adapters.register(platform_id, self)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
RUBY
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -31,3 +31,8 @@ require_relative "channel/adapters/dingtalk/adapter"
|
|
|
31
31
|
require_relative "channel/channel_config"
|
|
32
32
|
require_relative "channel/channel_ui_controller"
|
|
33
33
|
require_relative "channel/channel_manager"
|
|
34
|
+
|
|
35
|
+
# Discover and load user-defined adapters from ~/.clacky/channels/.
|
|
36
|
+
# Must run after the bundled adapters so user adapters can extend or override.
|
|
37
|
+
require_relative "channel/user_adapter_loader"
|
|
38
|
+
Clacky::Channel::Adapters::UserAdapterLoader.load_all
|
|
@@ -436,8 +436,8 @@ module Clacky
|
|
|
436
436
|
when ["GET", "/api/billing/summary"] then api_billing_summary(req, res)
|
|
437
437
|
when ["GET", "/api/billing/daily"] then api_billing_daily(req, res)
|
|
438
438
|
when ["GET", "/api/billing/records"] then api_billing_records(req, res)
|
|
439
|
-
when ["
|
|
440
|
-
when ["PATCH", "/api/sessions/:id/model"] then api_switch_session_model(req, res)
|
|
439
|
+
when ["GET", "/api/billing/sessions"] then api_billing_sessions(req, res)
|
|
440
|
+
when ["DELETE", "/api/billing/clear"] then api_billing_clear(req, res) when ["PATCH", "/api/sessions/:id/model"] then api_switch_session_model(req, res)
|
|
441
441
|
when ["PATCH", "/api/sessions/:id/working_dir"] then api_change_session_working_dir(req, res)
|
|
442
442
|
else
|
|
443
443
|
if method == "POST" && path.match?(%r{^/api/channels/[^/]+/send$})
|
|
@@ -1170,8 +1170,27 @@ module Clacky
|
|
|
1170
1170
|
})
|
|
1171
1171
|
end
|
|
1172
1172
|
|
|
1173
|
-
#
|
|
1174
|
-
#
|
|
1173
|
+
# GET /api/billing/sessions
|
|
1174
|
+
# Returns session-level billing summary
|
|
1175
|
+
# Query params: period (day|week|month|year|all, default: month), model, limit
|
|
1176
|
+
def api_billing_sessions(req, res)
|
|
1177
|
+
require_relative "../billing/billing_store"
|
|
1178
|
+
|
|
1179
|
+
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
1180
|
+
period = (query["period"] || "month").to_sym
|
|
1181
|
+
model = query["model"]
|
|
1182
|
+
limit = [(query["limit"] || "50").to_i, 200].min
|
|
1183
|
+
|
|
1184
|
+
store = Clacky::Billing::BillingStore.new
|
|
1185
|
+
sessions = store.session_summary(period: period, model: model, limit: limit)
|
|
1186
|
+
|
|
1187
|
+
json_response(res, 200, {
|
|
1188
|
+
sessions: sessions,
|
|
1189
|
+
count: sessions.size
|
|
1190
|
+
})
|
|
1191
|
+
end
|
|
1192
|
+
|
|
1193
|
+
# DELETE /api/billing/clear # Clears billing records
|
|
1175
1194
|
# Query params: scope (today|all, default: today)
|
|
1176
1195
|
def api_billing_clear(req, res)
|
|
1177
1196
|
require_relative "../billing/billing_store"
|
|
@@ -4386,7 +4405,9 @@ module Clacky
|
|
|
4386
4405
|
# @param working_dir [String] working directory for the agent
|
|
4387
4406
|
# @param permission_mode [Symbol] :confirm_all (default, human present) or
|
|
4388
4407
|
# :auto_approve (unattended — suppresses request_user_feedback waits)
|
|
4389
|
-
def build_session(name:, working_dir
|
|
4408
|
+
def build_session(name:, working_dir: nil, permission_mode: :confirm_all, profile: "general", source: :manual, model_id: nil)
|
|
4409
|
+
working_dir ||= default_working_dir
|
|
4410
|
+
FileUtils.mkdir_p(working_dir) unless Dir.exist?(working_dir)
|
|
4390
4411
|
session_id = Clacky::SessionManager.generate_id
|
|
4391
4412
|
@registry.create(session_id: session_id)
|
|
4392
4413
|
|
|
@@ -223,11 +223,8 @@ module Clacky
|
|
|
223
223
|
prompt = read_task(task_name)
|
|
224
224
|
name = "⏰ #{schedule["name"]} #{Time.now.strftime("%H:%M")}"
|
|
225
225
|
|
|
226
|
-
working_dir = File.expand_path("~/clacky_workspace")
|
|
227
|
-
FileUtils.mkdir_p(working_dir)
|
|
228
|
-
|
|
229
226
|
# Scheduled tasks run unattended — use auto_approve so request_user_feedback doesn't block.
|
|
230
|
-
session_id = @session_builder.call(name: name,
|
|
227
|
+
session_id = @session_builder.call(name: name, permission_mode: :auto_approve, source: :cron)
|
|
231
228
|
|
|
232
229
|
Clacky::Logger.info("scheduler_task_fired", task: task_name, session: session_id)
|
|
233
230
|
|
|
@@ -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
|
data/lib/clacky/version.rb
CHANGED
data/lib/clacky/web/app.css
CHANGED
|
@@ -9018,11 +9018,10 @@ body.setup-mode[data-theme="dark"] {
|
|
|
9018
9018
|
transition: height 0.2s ease;
|
|
9019
9019
|
}
|
|
9020
9020
|
.billing-cache-miss {
|
|
9021
|
-
background: #
|
|
9021
|
+
background: #f59e0b;
|
|
9022
9022
|
width: 100%;
|
|
9023
9023
|
transition: height 0.2s ease;
|
|
9024
|
-
}
|
|
9025
|
-
.billing-output-bar {
|
|
9024
|
+
}.billing-output-bar {
|
|
9026
9025
|
width: 12px;
|
|
9027
9026
|
background: #10b981;
|
|
9028
9027
|
border-radius: 2px 2px 0 0;
|
|
@@ -9038,10 +9037,10 @@ body.setup-mode[data-theme="dark"] {
|
|
|
9038
9037
|
}
|
|
9039
9038
|
|
|
9040
9039
|
/* Legend colors */
|
|
9040
|
+
.billing-legend-total { background: #6366f1; }
|
|
9041
9041
|
.billing-legend-cache-hit { background: #93c5fd; }
|
|
9042
|
-
.billing-legend-cache-miss { background: #
|
|
9042
|
+
.billing-legend-cache-miss { background: #f59e0b; }
|
|
9043
9043
|
.billing-legend-output { background: #10b981; }
|
|
9044
|
-
|
|
9045
9044
|
/* ── Chart Tooltip ───────────────────────────────────────────────────── */
|
|
9046
9045
|
.billing-chart-tooltip {
|
|
9047
9046
|
display: none;
|
|
@@ -9085,10 +9084,10 @@ body.setup-mode[data-theme="dark"] {
|
|
|
9085
9084
|
border-radius: 50%;
|
|
9086
9085
|
flex-shrink: 0;
|
|
9087
9086
|
}
|
|
9087
|
+
.tooltip-total { background: #6366f1; }
|
|
9088
9088
|
.tooltip-cache-hit { background: #93c5fd; }
|
|
9089
|
-
.tooltip-cache-miss { background: #
|
|
9090
|
-
.tooltip-output { background: #10b981; }
|
|
9091
|
-
.tooltip-label {
|
|
9089
|
+
.tooltip-cache-miss { background: #f59e0b; }
|
|
9090
|
+
.tooltip-output { background: #10b981; }.tooltip-label {
|
|
9092
9091
|
flex: 1;
|
|
9093
9092
|
color: var(--color-text-secondary);
|
|
9094
9093
|
}
|
|
@@ -9155,8 +9154,8 @@ body.setup-mode[data-theme="dark"] {
|
|
|
9155
9154
|
border-radius: 3px;
|
|
9156
9155
|
transition: width 0.3s ease;
|
|
9157
9156
|
}
|
|
9158
|
-
.billing-bar-
|
|
9159
|
-
background: linear-gradient(90deg, #
|
|
9157
|
+
.billing-bar-total {
|
|
9158
|
+
background: linear-gradient(90deg, #6366f1 0%, #818cf8 100%);
|
|
9160
9159
|
}
|
|
9161
9160
|
.billing-bar-completion {
|
|
9162
9161
|
background: linear-gradient(90deg, #10b981 0%, #34d399 100%);
|
|
@@ -9164,10 +9163,9 @@ body.setup-mode[data-theme="dark"] {
|
|
|
9164
9163
|
.billing-bar-cache-read {
|
|
9165
9164
|
background: linear-gradient(90deg, #93c5fd 0%, #bfdbfe 100%);
|
|
9166
9165
|
}
|
|
9167
|
-
.billing-bar-cache-
|
|
9168
|
-
background: linear-gradient(90deg, #
|
|
9166
|
+
.billing-bar-cache-miss {
|
|
9167
|
+
background: linear-gradient(90deg, #f59e0b 0%, #fbbf24 100%);
|
|
9169
9168
|
}
|
|
9170
|
-
|
|
9171
9169
|
/* ── Model List ──────────────────────────────────────────────────────── */
|
|
9172
9170
|
.billing-model-list {
|
|
9173
9171
|
display: flex;
|
|
@@ -9569,3 +9567,147 @@ body.setup-mode[data-theme="dark"] {
|
|
|
9569
9567
|
color: var(--color-text-secondary);
|
|
9570
9568
|
font-weight: 500;
|
|
9571
9569
|
}
|
|
9570
|
+
|
|
9571
|
+
/* ── Sessions List ───────────────────────────────────────────────────── */
|
|
9572
|
+
.billing-sessions-row {
|
|
9573
|
+
margin-top: 1rem;
|
|
9574
|
+
}
|
|
9575
|
+
.billing-sessions-section {
|
|
9576
|
+
background: var(--color-bg-secondary);
|
|
9577
|
+
border: 1px solid var(--color-border-primary);
|
|
9578
|
+
border-radius: 12px;
|
|
9579
|
+
padding: 1.25rem;
|
|
9580
|
+
}
|
|
9581
|
+
.billing-sessions-section h3 {
|
|
9582
|
+
font-size: 0.9375rem;
|
|
9583
|
+
font-weight: 600;
|
|
9584
|
+
color: var(--color-text-primary);
|
|
9585
|
+
margin: 0 0 1rem 0;
|
|
9586
|
+
}
|
|
9587
|
+
.billing-sessions-empty {
|
|
9588
|
+
text-align: center;
|
|
9589
|
+
padding: 2rem;
|
|
9590
|
+
color: var(--color-text-secondary);
|
|
9591
|
+
font-size: 0.875rem;
|
|
9592
|
+
}
|
|
9593
|
+
.billing-sessions-header {
|
|
9594
|
+
display: grid;
|
|
9595
|
+
grid-template-columns: 36px 1.5fr 0.8fr 0.8fr 0.8fr 0.8fr 0.8fr 1fr;
|
|
9596
|
+
gap: 0.5rem;
|
|
9597
|
+
padding: 0.75rem 1rem;
|
|
9598
|
+
background: var(--color-bg-tertiary);
|
|
9599
|
+
border-radius: 8px;
|
|
9600
|
+
font-size: 0.75rem;
|
|
9601
|
+
font-weight: 600;
|
|
9602
|
+
color: var(--color-text-secondary);
|
|
9603
|
+
text-transform: uppercase;
|
|
9604
|
+
letter-spacing: 0.05em;
|
|
9605
|
+
margin-bottom: 0.5rem;
|
|
9606
|
+
}
|
|
9607
|
+
.billing-sessions-list {
|
|
9608
|
+
display: flex;
|
|
9609
|
+
flex-direction: column;
|
|
9610
|
+
gap: 0.25rem;
|
|
9611
|
+
}
|
|
9612
|
+
.billing-session-row {
|
|
9613
|
+
display: grid;
|
|
9614
|
+
grid-template-columns: 36px 1.5fr 0.8fr 0.8fr 0.8fr 0.8fr 0.8fr 1fr;
|
|
9615
|
+
gap: 0.5rem;
|
|
9616
|
+
padding: 0.75rem 1rem;
|
|
9617
|
+
border-radius: 8px;
|
|
9618
|
+
transition: background 0.15s;
|
|
9619
|
+
align-items: center;
|
|
9620
|
+
}
|
|
9621
|
+
.billing-session-row:hover {
|
|
9622
|
+
background: var(--color-bg-tertiary);
|
|
9623
|
+
}
|
|
9624
|
+
.billing-session-deleted {
|
|
9625
|
+
opacity: 0.7;
|
|
9626
|
+
border-left: 3px solid var(--color-warning);
|
|
9627
|
+
}
|
|
9628
|
+
.billing-session-deleted .billing-cell-main {
|
|
9629
|
+
color: var(--color-text-secondary);
|
|
9630
|
+
font-style: italic;
|
|
9631
|
+
}
|
|
9632
|
+
.billing-cell {
|
|
9633
|
+
font-size: 0.875rem;
|
|
9634
|
+
white-space: nowrap;
|
|
9635
|
+
overflow: hidden;
|
|
9636
|
+
text-overflow: ellipsis;
|
|
9637
|
+
}
|
|
9638
|
+
.billing-cell-index {
|
|
9639
|
+
font-size: 0.75rem;
|
|
9640
|
+
color: var(--color-text-secondary);
|
|
9641
|
+
text-align: center;
|
|
9642
|
+
}
|
|
9643
|
+
.billing-cell-session {
|
|
9644
|
+
display: flex;
|
|
9645
|
+
flex-direction: column;
|
|
9646
|
+
gap: 0.125rem;
|
|
9647
|
+
min-width: 0;
|
|
9648
|
+
}
|
|
9649
|
+
.billing-cell-main {
|
|
9650
|
+
font-weight: 500;
|
|
9651
|
+
color: var(--color-text-primary);
|
|
9652
|
+
font-family: var(--font-mono);
|
|
9653
|
+
white-space: nowrap;
|
|
9654
|
+
overflow: hidden;
|
|
9655
|
+
text-overflow: ellipsis;
|
|
9656
|
+
}
|
|
9657
|
+
.billing-cell-sub {
|
|
9658
|
+
font-size: 0.7rem;
|
|
9659
|
+
color: var(--color-text-secondary);
|
|
9660
|
+
white-space: nowrap;
|
|
9661
|
+
overflow: hidden;
|
|
9662
|
+
text-overflow: ellipsis;
|
|
9663
|
+
}
|
|
9664
|
+
.billing-cell-number {
|
|
9665
|
+
font-family: var(--font-mono);
|
|
9666
|
+
text-align: right;
|
|
9667
|
+
color: var(--color-text-primary);
|
|
9668
|
+
}
|
|
9669
|
+
.billing-cell-hit {
|
|
9670
|
+
color: #3b82f6;
|
|
9671
|
+
}
|
|
9672
|
+
.billing-cell-miss {
|
|
9673
|
+
color: #f59e0b;
|
|
9674
|
+
}
|
|
9675
|
+
.billing-cell-cost {
|
|
9676
|
+
font-family: var(--font-mono);
|
|
9677
|
+
font-weight: 600;
|
|
9678
|
+
color: var(--color-accent);
|
|
9679
|
+
text-align: right;
|
|
9680
|
+
}
|
|
9681
|
+
.billing-cell-time {
|
|
9682
|
+
font-size: 0.75rem;
|
|
9683
|
+
color: var(--color-text-secondary);
|
|
9684
|
+
}
|
|
9685
|
+
@media (max-width: 768px) {
|
|
9686
|
+
.billing-sessions-header {
|
|
9687
|
+
display: none;
|
|
9688
|
+
}
|
|
9689
|
+
.billing-session-row {
|
|
9690
|
+
grid-template-columns: 1fr 1fr;
|
|
9691
|
+
gap: 0.5rem;
|
|
9692
|
+
padding: 1rem;
|
|
9693
|
+
}
|
|
9694
|
+
.billing-cell-index {
|
|
9695
|
+
display: none;
|
|
9696
|
+
}
|
|
9697
|
+
.billing-cell-session {
|
|
9698
|
+
grid-column: 1 / -1;
|
|
9699
|
+
}
|
|
9700
|
+
.billing-cell-number {
|
|
9701
|
+
text-align: left;
|
|
9702
|
+
}
|
|
9703
|
+
.billing-cell-number::before {
|
|
9704
|
+
font-size: 0.625rem;
|
|
9705
|
+
color: var(--color-text-secondary);
|
|
9706
|
+
text-transform: uppercase;
|
|
9707
|
+
display: block;
|
|
9708
|
+
}
|
|
9709
|
+
.billing-cell-time {
|
|
9710
|
+
grid-column: 1 / -1;
|
|
9711
|
+
text-align: right;
|
|
9712
|
+
}
|
|
9713
|
+
}
|