openclacky 1.3.7 → 1.3.9

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 +42 -0
  3. data/lib/clacky/agent/hook_manager.rb +10 -1
  4. data/lib/clacky/agent/system_prompt_builder.rb +1 -1
  5. data/lib/clacky/agent.rb +9 -2
  6. data/lib/clacky/agent_profile.rb +7 -4
  7. data/lib/clacky/billing/billing_store.rb +3 -2
  8. data/lib/clacky/brand_config.rb +2 -1
  9. data/lib/clacky/default_extensions/coding/agents/coding/avatar.png +0 -0
  10. data/lib/clacky/default_extensions/coding/ext.yml +4 -4
  11. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/avatar.png +0 -0
  12. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +0 -1
  13. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +4 -3
  14. data/lib/clacky/default_extensions/ext-studio/ext.yml +1 -1
  15. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +118 -50
  16. data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +1 -2
  17. data/lib/clacky/default_extensions/general/agents/general/avatar.png +0 -0
  18. data/lib/clacky/default_extensions/general/ext.yml +4 -4
  19. data/lib/clacky/default_extensions/git/ext.yml +0 -1
  20. data/lib/clacky/default_extensions/git/panels/git/view.js +3 -3
  21. data/lib/clacky/default_extensions/meeting/ext.yml +0 -1
  22. data/lib/clacky/default_extensions/meeting/panels/meeting/view.js +1 -2
  23. data/lib/clacky/default_extensions/time_machine/ext.yml +0 -1
  24. data/lib/clacky/extension/api_extension.rb +11 -2
  25. data/lib/clacky/extension/dispatcher.rb +3 -2
  26. data/lib/clacky/extension/loader.rb +6 -0
  27. data/lib/clacky/extension/packager.rb +0 -6
  28. data/lib/clacky/extension/verifier.rb +1 -1
  29. data/lib/clacky/server/http_server.rb +149 -5
  30. data/lib/clacky/shell_hook_loader.rb +266 -22
  31. data/lib/clacky/ui2/components/welcome_banner.rb +1 -1
  32. data/lib/clacky/utils/workspace_rules.rb +2 -2
  33. data/lib/clacky/version.rb +1 -1
  34. data/lib/clacky/web/app.css +334 -73
  35. data/lib/clacky/web/core/ext.js +130 -24
  36. data/lib/clacky/web/features/extensions/store.js +41 -6
  37. data/lib/clacky/web/features/extensions/view.js +44 -13
  38. data/lib/clacky/web/features/new-session/store.js +13 -0
  39. data/lib/clacky/web/features/new-session/view.js +314 -14
  40. data/lib/clacky/web/features/trash/view.js +4 -5
  41. data/lib/clacky/web/i18n.js +44 -2
  42. data/lib/clacky/web/index.html +54 -16
  43. data/lib/clacky/web/sessions.js +3 -3
  44. data/lib/clacky/web/skills.js +86 -48
  45. data/lib/clacky/web/theme.js +3 -0
  46. data/lib/clacky/web/ws-dispatcher.js +11 -2
  47. metadata +7 -7
@@ -369,6 +369,8 @@ module Clacky
369
369
  res.body = html
370
370
  elsif req.path.start_with?("/agent_ui/")
371
371
  self.send(:serve_agent_ui, req, res)
372
+ elsif req.path.start_with?("/agent_avatar/")
373
+ self.send(:serve_agent_avatar, req, res)
372
374
  elsif req.path.start_with?("/ext_ui/")
373
375
  self.send(:serve_ext_ui, req, res)
374
376
  else
@@ -538,6 +540,7 @@ module Clacky
538
540
  when ["GET", "/api/store/skills"] then api_store_skills(res)
539
541
  when ["GET", "/api/store/extensions"] then api_store_extensions(req, res)
540
542
  when ["GET", "/api/store/extension"] then api_store_extension_detail(req, res)
543
+ when ["POST", "/api/store/extension/install"] then api_store_extension_install(req, res)
541
544
  when ["POST", "/api/store/extension/disable"] then api_store_extension_disable(req, res)
542
545
  when ["POST", "/api/store/extension/enable"] then api_store_extension_enable(req, res)
543
546
  when ["DELETE", "/api/store/extension"] then api_store_extension_uninstall(req, res)
@@ -623,6 +626,9 @@ module Clacky
623
626
  elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/skills$})
624
627
  session_id = path.sub("/api/sessions/", "").sub("/skills", "")
625
628
  api_session_skills(session_id, res)
629
+ elsif method == "GET" && path.match?(%r{^/api/agents/[^/]+/skills$})
630
+ agent_id = path[%r{^/api/agents/([^/]+)/skills$}, 1]
631
+ api_agent_skills(agent_id, res)
626
632
  elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/git/[a-z]+$})
627
633
  session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
628
634
  action = path[%r{/git/([a-z]+)$}, 1]
@@ -1061,6 +1067,7 @@ module Clacky
1061
1067
  private def webui_ext_script_tags
1062
1068
  [
1063
1069
  panel_agents_script,
1070
+ ext_contributions_script,
1064
1071
  agent_webui_script_tags,
1065
1072
  container_ext_script_tags,
1066
1073
  ].reject(&:empty?).join("\n")
@@ -1097,6 +1104,36 @@ module Clacky
1097
1104
  "<script>Clacky.ext.registerPanelAgents(#{map.to_json})</script>"
1098
1105
  end
1099
1106
 
1107
+ # Inline script mapping agent id => the panels & skills its extension
1108
+ # contributes (straight from ext.yml). Built-in extensions are excluded so
1109
+ # the new-session page only advertises what a third-party agent adds.
1110
+ private def ext_contributions_script
1111
+ map = ext_contributions_map
1112
+ return "" if map.empty?
1113
+
1114
+ "<script>Clacky.ext.registerAgentContributions(#{map.to_json})</script>"
1115
+ end
1116
+
1117
+ private def ext_contributions_map
1118
+ result = Clacky::ExtensionLoader.load_all
1119
+ by_ext = Hash.new { |h, k| h[k] = { "panels" => [], "skills" => [] } }
1120
+ label = lambda do |unit|
1121
+ { "id" => unit.id, "title" => unit.spec["title"] || unit.id, "title_zh" => unit.spec["title_zh"] }
1122
+ end
1123
+
1124
+ result.panels.each { |u| by_ext[u.ext_id]["panels"] << label.call(u) unless u.layer == :builtin }
1125
+ result.skills.each { |u| by_ext[u.ext_id]["skills"] << label.call(u) unless u.layer == :builtin }
1126
+
1127
+ out = {}
1128
+ result.agents.each do |u|
1129
+ next if u.layer == :builtin
1130
+ contrib = by_ext[u.ext_id]
1131
+ next if contrib["panels"].empty? && contrib["skills"].empty?
1132
+ out[u.id] = contrib
1133
+ end
1134
+ out
1135
+ end
1136
+
1100
1137
  # panel id => list of agent names allowed to see it. Two sources merge:
1101
1138
  # 1. `agent.panels: [id]` — agent-side explicit reference (opt-in mount)
1102
1139
  # 2. `panel.attach: [...]` — panel-author's default suggestion
@@ -1283,6 +1320,42 @@ module Clacky
1283
1320
  serve_static_under(File.join(dir, "webui"), rel, res)
1284
1321
  end
1285
1322
 
1323
+ # GET /agent_avatar/<id> — serve an agent's avatar image (extension
1324
+ # `avatar` file, or a user agent's avatar.png). Read-only, images only.
1325
+ private def serve_agent_avatar(req, res)
1326
+ id = req.path.delete_prefix("/agent_avatar/").split("/").first.to_s
1327
+ return (res.status = 404; res.body = "not found") if id.empty?
1328
+
1329
+ abs = agent_avatar_path(id)
1330
+ unless abs && File.file?(abs)
1331
+ res.status = 404
1332
+ res.body = "not found"
1333
+ return
1334
+ end
1335
+
1336
+ ctype = { ".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg",
1337
+ ".webp" => "image/webp", ".svg" => "image/svg+xml", ".gif" => "image/gif" }[File.extname(abs).downcase]
1338
+ unless ctype
1339
+ res.status = 415
1340
+ res.body = "unsupported media type"
1341
+ return
1342
+ end
1343
+
1344
+ res.status = 200
1345
+ res["Content-Type"] = ctype
1346
+ res["Cache-Control"] = "public, max-age=86400"
1347
+ res.body = File.binread(abs)
1348
+ end
1349
+
1350
+ private def agent_avatar_path(id)
1351
+ unit = Clacky::ExtensionLoader.last_result&.agents&.find { |u| u.id == id }
1352
+ avatar_abs = unit&.spec&.[]("avatar_abs").to_s
1353
+ return avatar_abs unless avatar_abs.empty?
1354
+
1355
+ user_png = File.expand_path("~/.clacky/agents/#{id}/avatar.png")
1356
+ File.file?(user_png) ? user_png : nil
1357
+ end
1358
+
1286
1359
  # Read-only static serve of `rel` under `root`, JS/CSS/HTML only, with
1287
1360
  # strict path containment so a crafted rel cannot escape `root`.
1288
1361
  private def serve_static_under(root, rel, res)
@@ -1419,6 +1492,9 @@ module Clacky
1419
1492
  output_dir = body["output_dir"].to_s
1420
1493
  output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
1421
1494
 
1495
+ session_id = body["session_id"].to_s
1496
+ session_id = nil if session_id.empty?
1497
+
1422
1498
  result = Clacky::Media::Generator.new(@agent_config).generate_image(
1423
1499
  prompt: prompt,
1424
1500
  aspect_ratio: aspect_ratio,
@@ -1427,7 +1503,7 @@ module Clacky
1427
1503
  images: body["images"]
1428
1504
  )
1429
1505
  if result["success"]
1430
- log_media_usage(result, prompt: prompt)
1506
+ log_media_usage(result, prompt: prompt, session_id: session_id)
1431
1507
  end
1432
1508
  status = result["success"] ? 200 : 422
1433
1509
  json_response(res, status, result)
@@ -1451,6 +1527,9 @@ module Clacky
1451
1527
  output_dir = body["output_dir"].to_s
1452
1528
  output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
1453
1529
 
1530
+ session_id = body["session_id"].to_s
1531
+ session_id = nil if session_id.empty?
1532
+
1454
1533
  result = Clacky::Media::Generator.new(@agent_config).generate_video(
1455
1534
  prompt: prompt,
1456
1535
  aspect_ratio: aspect_ratio,
@@ -1459,7 +1538,7 @@ module Clacky
1459
1538
  image: image
1460
1539
  )
1461
1540
  if result["success"]
1462
- log_media_usage(result, prompt: prompt)
1541
+ log_media_usage(result, prompt: prompt, session_id: session_id)
1463
1542
  end
1464
1543
  status = result["success"] ? 200 : 422
1465
1544
  json_response(res, status, result)
@@ -1480,13 +1559,16 @@ module Clacky
1480
1559
  output_dir = body["output_dir"].to_s
1481
1560
  output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
1482
1561
 
1562
+ session_id = body["session_id"].to_s
1563
+ session_id = nil if session_id.empty?
1564
+
1483
1565
  result = Clacky::Media::Generator.new(@agent_config).generate_speech(
1484
1566
  input: input,
1485
1567
  voice: voice,
1486
1568
  output_dir: output_dir
1487
1569
  )
1488
1570
  if result["success"]
1489
- log_media_usage(result, prompt: input)
1571
+ log_media_usage(result, prompt: input, session_id: session_id)
1490
1572
  end
1491
1573
  status = result["success"] ? 200 : 422
1492
1574
  json_response(res, status, result)
@@ -1547,7 +1629,7 @@ module Clacky
1547
1629
  json_response(res, 500, { error: e.message })
1548
1630
  end
1549
1631
 
1550
- private def log_media_usage(result, prompt:)
1632
+ private def log_media_usage(result, prompt:, session_id: nil)
1551
1633
  usage = result["usage"]
1552
1634
  cost = result["cost_usd"]
1553
1635
  return if usage.nil? && cost.nil?
@@ -1568,6 +1650,20 @@ module Clacky
1568
1650
  else "image"
1569
1651
  end
1570
1652
  Clacky::Logger.info("[Media] #{kind} generated #{parts.join(" ")}")
1653
+
1654
+ require_relative "../billing/billing_store"
1655
+ require_relative "../billing/billing_record"
1656
+ record = Clacky::Billing::BillingRecord.new(
1657
+ session_id: session_id,
1658
+ model: result["model"].to_s,
1659
+ prompt_tokens: usage.is_a?(Hash) ? usage["prompt_tokens"].to_i : 0,
1660
+ completion_tokens: usage.is_a?(Hash) ? usage["completion_tokens"].to_i : 0,
1661
+ cache_read_tokens: usage.is_a?(Hash) ? usage["cache_read_tokens"].to_i : 0,
1662
+ cache_write_tokens: usage.is_a?(Hash) ? usage["cache_write_tokens"].to_i : 0,
1663
+ cost_usd: cost.to_f,
1664
+ cost_source: :api
1665
+ )
1666
+ Clacky::Billing::BillingStore.new.append(record)
1571
1667
  end
1572
1668
 
1573
1669
  # GET /api/media/types
@@ -2324,6 +2420,25 @@ module Clacky
2324
2420
  end
2325
2421
 
2326
2422
  # DELETE /api/store/extension body: { id: <slug> }
2423
+ def api_store_extension_install(req, res)
2424
+ body = parse_json_body(req)
2425
+ download_url = body["download_url"].to_s.strip
2426
+ name = body["name"].to_s.strip
2427
+
2428
+ if download_url.empty?
2429
+ json_response(res, 400, { ok: false, error: "Missing download_url." })
2430
+ return
2431
+ end
2432
+
2433
+ Clacky::ExtensionPackager.install(download_url, force: true)
2434
+ Clacky::ExtensionLoader.invalidate_cache!
2435
+ json_response(res, 200, { ok: true, name: name })
2436
+ rescue Clacky::ExtensionPackager::Error => e
2437
+ json_response(res, 422, { ok: false, error: e.message })
2438
+ rescue StandardError => e
2439
+ json_response(res, 500, { ok: false, error: e.message })
2440
+ end
2441
+
2327
2442
  def api_store_extension_uninstall(req, res)
2328
2443
  id = parse_json_body(req)["id"].to_s
2329
2444
  container = extension_container(id)
@@ -3917,7 +4032,36 @@ module Clacky
3917
4032
  json_response(res, 200, { skills: skill_data })
3918
4033
  end
3919
4034
 
3920
- # GET /api/sessions/:id/git/<action>read-only git info for the session's
4035
+ # GET /api/agents/:id/skillslike api_session_skills but keyed on an
4036
+ # agent id rather than a live session. Used by the New Session page so the
4037
+ # first-message composer can offer slash-command autocomplete before a
4038
+ # session exists.
4039
+ def api_agent_skills(agent_id, res)
4040
+ profile = begin
4041
+ Clacky::AgentProfile.load(agent_id)
4042
+ rescue StandardError
4043
+ nil
4044
+ end
4045
+
4046
+ @skill_loader.load_all
4047
+ skills = @skill_loader.user_invocable_skills
4048
+ skills = skills.select { |s| s.allowed_for_agent?(profile.name) } if profile
4049
+
4050
+ loaded_from = @skill_loader.loaded_from
4051
+ skill_data = skills.map do |skill|
4052
+ {
4053
+ name: skill.identifier,
4054
+ name_zh: skill.name_zh,
4055
+ description: skill.description || skill.context_description,
4056
+ description_zh: skill.description_zh,
4057
+ encrypted: skill.encrypted?,
4058
+ source_type: loaded_from[skill.identifier],
4059
+ always_show: skill.always_show
4060
+ }
4061
+ end
4062
+
4063
+ json_response(res, 200, { skills: skill_data })
4064
+ end
3921
4065
  # working_dir. action ∈ status|diff|log|branches. diff accepts ?file=,
3922
4066
  # log accepts ?limit=.
3923
4067
  def api_session_git(session_id, action, req, res)
@@ -14,28 +14,48 @@ module Clacky
14
14
  # hooks.yml format:
15
15
  # hooks:
16
16
  # before_tool_use:
17
+ # # Simple protocol — no `type` (or `type: command`). exit-code driven.
17
18
  # - name: guard # optional label for logs
18
19
  # command: "~/.clacky/hook-scripts/guard.sh"
19
20
  # timeout: 10 # optional, seconds (default 10)
21
+ #
22
+ # # Rewrite protocol — `type: rewrite`. Rich JSON stdin/stdout, supports
23
+ # # matcher, updatedInput rewrite. (before_tool_use only)
24
+ # - type: rewrite
25
+ # # matcher must be the CANONICAL tool name the agent dispatches
26
+ # # (e.g. `terminal`), NOT an alias (`bash`/`shell`/`exec`): the
27
+ # # agent resolves aliases to canonical names BEFORE firing hooks,
28
+ # # so `matcher: bash` would never match. "*" or omitted = all tools.
29
+ # matcher: terminal
30
+ # command: "~/.clacky/hook-scripts/rewrite.sh"
31
+ # timeout: 30 # optional, seconds (default 60)
20
32
  # on_complete:
21
33
  # - command: "notify-send done"
22
34
  #
23
35
  # Runtime contract (per invocation):
24
36
  # - 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}.
37
+ # - exit 0 → allow (default). Rewrite hooks parse STDOUT as hookSpecificOutput.
38
+ # - exit 2 → deny; STDOUT (simple) or stderr/stdout (rewrite) is the reason.
39
+ # Only before_tool_use is checked for {action: :deny}.
28
40
  # - any other exit / timeout / crash → logged, treated as allow (a broken
29
41
  # hook must never wedge the agent).
42
+ #
43
+ # Rewrite entries chain in config order: each applies its updatedInput IN
44
+ # PLACE on the tool call (complete replacement — no merge); the first deny
45
+ # stops the chain.
30
46
  class ShellHookLoader
31
- DEFAULT_PATH = File.expand_path("~/.clacky/hooks.yml")
32
- DEFAULT_TIMEOUT = 10
33
- DENY_EXIT_CODE = 2
47
+ DEFAULT_PATH = File.expand_path("~/.clacky/hooks.yml")
48
+ DEFAULT_TIMEOUT = 10
49
+ REWRITE_DEFAULT_TIMEOUT = 60
50
+ DENY_EXIT_CODE = 2
34
51
 
35
52
  Result = Struct.new(:registered, :skipped, keyword_init: true)
36
53
 
37
- def self.load_into(hook_manager, path: DEFAULT_PATH)
38
- new(path: path).load_into(hook_manager)
54
+ # The context procs supply rewrite-protocol context at run time; optional so
55
+ # simple-only callers (e.g. `clacky hook_verify`) can omit them.
56
+ def self.load_into(hook_manager, session_id_fn: nil, cwd_fn: nil, permission_mode_fn: nil, path: DEFAULT_PATH)
57
+ new(path: path, session_id_fn: session_id_fn, cwd_fn: cwd_fn, permission_mode_fn: permission_mode_fn)
58
+ .load_into(hook_manager)
39
59
  end
40
60
 
41
61
  # Create a starter hooks.yml plus an example guard script. Idempotent-ish:
@@ -67,12 +87,18 @@ module Clacky
67
87
  File.write(path, <<~YAML)
68
88
  # Declarative shell hooks. Each command receives the event payload as JSON
69
89
  # on STDIN. For before_tool_use: exit 2 = deny (STDOUT = reason), exit 0 = allow.
90
+ # Add `type: rewrite` to a before_tool_use entry to use the rich JSON
91
+ # protocol (updatedInput rewrite, matcher).
70
92
  # Events: #{HookManager::HOOK_EVENTS.join(", ")}
71
93
  hooks:
72
94
  before_tool_use:
73
95
  - name: deny-example
74
96
  command: "#{guard}"
75
97
  timeout: 10
98
+ # - type: rewrite
99
+ # matcher: terminal
100
+ # command: "~/.clacky/hook-scripts/rewrite.sh"
101
+ # timeout: 30
76
102
  # on_complete:
77
103
  # - command: "echo task finished"
78
104
  YAML
@@ -80,8 +106,11 @@ module Clacky
80
106
  path
81
107
  end
82
108
 
83
- def initialize(path: DEFAULT_PATH)
84
- @path = path
109
+ def initialize(path: DEFAULT_PATH, session_id_fn: nil, cwd_fn: nil, permission_mode_fn: nil)
110
+ @path = path
111
+ @session_id_fn = session_id_fn
112
+ @cwd_fn = cwd_fn
113
+ @permission_mode_fn = permission_mode_fn
85
114
  end
86
115
 
87
116
  # @return [Result] counts of registered hooks and skipped (with reasons)
@@ -106,14 +135,37 @@ module Clacky
106
135
  end
107
136
 
108
137
  private def register_one(hook_manager, event, spec, result)
138
+ if spec["type"] == "rewrite" && event != :before_tool_use
139
+ # type: rewrite only makes sense under before_tool_use (it rewrites tool
140
+ # input); elsewhere matcher/updatedInput would be silently ignored —
141
+ # skip + warn so the misconfiguration is visible.
142
+ name = (spec["name"] || spec["command"] || "rewrite hook").to_s
143
+ result.skipped << [name, "type: rewrite is only valid under before_tool_use"]
144
+ elsif spec["type"] == "rewrite"
145
+ register_rewrite(hook_manager, spec, result)
146
+ else
147
+ register_simple(hook_manager, event, spec, result)
148
+ end
149
+ end
150
+
151
+ # Returns [name, command] for a spec, or nil (after recording a skip) when
152
+ # the command is missing. Shared by the simple and rewrite registrars.
153
+ private def resolve_command(spec, result)
109
154
  command = spec["command"].to_s.strip
110
155
  name = spec["name"] || command
111
- timeout = (spec["timeout"] || DEFAULT_TIMEOUT).to_i
112
-
113
156
  if command.empty?
114
157
  result.skipped << [name, "missing command"]
115
- return
158
+ nil
159
+ else
160
+ [name, command]
116
161
  end
162
+ end
163
+
164
+ private def register_simple(hook_manager, event, spec, result)
165
+ resolved = resolve_command(spec, result)
166
+ return unless resolved
167
+ name, command = resolved
168
+ timeout = (spec["timeout"] || DEFAULT_TIMEOUT).to_i
117
169
 
118
170
  unless HookManager::HOOK_EVENTS.include?(event)
119
171
  result.skipped << [name, "unknown event: #{event}"]
@@ -126,25 +178,123 @@ module Clacky
126
178
  result.registered << [event, name]
127
179
  end
128
180
 
129
- private def run_command(event, command, timeout, args)
130
- payload = JSON.generate(build_payload(event, args))
181
+ # Rewrite protocol (before_tool_use only): rich JSON stdin/stdout.
182
+ private def register_rewrite(hook_manager, spec, result)
183
+ resolved = resolve_command(spec, result)
184
+ return unless resolved
185
+ name, command = resolved
186
+ timeout = (spec["timeout"] || REWRITE_DEFAULT_TIMEOUT).to_i
187
+ matcher = spec["matcher"]
131
188
 
132
- out = +""
189
+ hook_manager.add(:before_tool_use) do |call|
190
+ tool_name = (call[:name] || call["name"]).to_s
191
+ next { action: :allow } unless matcher_applies?(matcher, tool_name)
192
+ run_rewrite(command, timeout, call, tool_name)
193
+ end
194
+ result.registered << [:before_tool_use, name]
195
+ end
196
+
197
+ # "*" or omitted matches every tool; otherwise the matcher must equal the
198
+ # canonical tool name exactly (no aliases, regex, or lists).
199
+ private def matcher_applies?(matcher, tool_name)
200
+ matcher.nil? || matcher == "*" || matcher == tool_name
201
+ end
202
+
203
+ # Cap on bytes buffered per stream: before_tool_use fires on every tool
204
+ # call, so a runaway/malicious hook could otherwise OOM the agent.
205
+ MAX_OUTPUT_BYTES = 8 * 1024 * 1024
206
+
207
+ # Spawn `command` in its own process group, feed `payload` on STDIN, and
208
+ # return [stdout, stderr, status]. stdout and stderr are drained on parallel
209
+ # reader threads — a single sequential reader deadlocks once a child fills
210
+ # the 64KB stderr pipe while keeping stdout open. The dedicated process
211
+ # group lets us SIGKILL the whole tree on timeout (including grandchildren
212
+ # that inherit the fds), so a hook that traps TERM can't hang the agent.
213
+ # Output is scrubbed to valid UTF-8 so a stray non-UTF-8 byte can't turn a
214
+ # deny into an allow via a .strip / JSON.parse raise.
215
+ private def capture_streams(command, payload, timeout)
216
+ stdout = +""
217
+ stderr = +""
218
+ # Declared here so the block assigns this var, not a block-local one
219
+ # (else every deny silently becomes an allow).
133
220
  status = nil
134
- Open3.popen3(command) do |stdin, stdout, _stderr, wait_thr|
135
- stdin.write(payload)
136
- stdin.close
221
+
222
+ Open3.popen3(command, pgroup: true) do |stdin, out, err, wait_thr|
223
+ pgid = wait_thr.pid
224
+
225
+ out_reader = Thread.new { drain_stream(out, stdout) }
226
+ err_reader = Thread.new { drain_stream(err, stderr) }
227
+
228
+ # Write stdin on its own thread: a child that doesn't read stdin (or a
229
+ # payload larger than the ~64KB pipe buffer) would otherwise block the
230
+ # main thread before it reaches wait_thr.join(timeout) — the only thing
231
+ # enforcing the timeout — and wedge capture_streams forever.
232
+ writer = Thread.new do
233
+ begin
234
+ stdin.write(payload)
235
+ rescue Errno::EPIPE
236
+ # Child already exited / stopped reading; its stdout may still be readable.
237
+ rescue IOError
238
+ # Pipes closed (timeout path) — nothing more to write.
239
+ ensure
240
+ stdin.close rescue nil
241
+ end
242
+ end
243
+
137
244
  if wait_thr.join(timeout)
138
- out = stdout.read
139
245
  status = wait_thr.value
246
+ # Child exited; drain the readers (bounded join — a grandchild
247
+ # holding the pipe fd can keep them from EOF; see drain_stream).
248
+ out_reader.join(2)
249
+ err_reader.join(2)
140
250
  else
141
- Process.kill("TERM", wait_thr.pid) rescue nil
251
+ # SIGTERM, then SIGKILL the whole group if it won't die. popen3's
252
+ # ensure joins wait_thr without a timeout, so the child MUST be dead
253
+ # before we leave the block — a hook that traps/ignores TERM can't be
254
+ # allowed to hang here.
255
+ Process.kill("TERM", -pgid) rescue nil
256
+ unless wait_thr.join(1)
257
+ Process.kill("KILL", -pgid) rescue nil
258
+ wait_thr.join
259
+ end
260
+ out_reader.join(1)
261
+ err_reader.join(1)
262
+ writer.kill rescue nil
142
263
  raise Timeout::Error
143
264
  end
144
265
  end
145
266
 
267
+ [stdout.scrub!, stderr.scrub!, status]
268
+ end
269
+
270
+ # Read `io` into `buf` in readpartial chunks until EOF, instead of a single
271
+ # IO#read. readpartial returns whatever is currently available, so a
272
+ # grandchild that inherited the pipe fd (and thus keeps it from EOF) can no
273
+ # longer block us forever — already-read bytes are preserved in `buf`.
274
+ # Appending stops at MAX_OUTPUT_BYTES so a runaway hook can't OOM the agent;
275
+ # we keep draining past the cap so the child doesn't block on a full pipe.
276
+ private def drain_stream(io, buf)
277
+ loop do
278
+ chunk = io.readpartial(4096)
279
+ buf << chunk if buf.bytesize < MAX_OUTPUT_BYTES
280
+ end
281
+ rescue EOFError, IOError
282
+ # EOFError: pipe closed (normal end). IOError: pipes closed on block exit.
283
+ end
284
+
285
+ # Strip + default, so a deny never has a blank reason.
286
+ private def deny_reason(raw)
287
+ s = raw.to_s.strip
288
+ s.empty? ? "Denied by hook" : s
289
+ end
290
+
291
+ private def run_command(event, command, timeout, args)
292
+ payload = JSON.generate(build_payload(event, args))
293
+
294
+ out, _err, status = capture_streams(command, payload, timeout)
295
+
146
296
  if status&.exitstatus == DENY_EXIT_CODE
147
- { action: :deny, reason: out.strip.empty? ? "Denied by hook" : out.strip }
297
+ { action: :deny, reason: deny_reason(out) }
148
298
  else
149
299
  { action: :allow }
150
300
  end
@@ -177,5 +327,99 @@ module Clacky
177
327
 
178
328
  base
179
329
  end
330
+
331
+ # --- Rewrite protocol (type: rewrite) -------------------------------------
332
+
333
+ private def run_rewrite(command, timeout, call, tool_name)
334
+ tool_input = parse_tool_args(call)
335
+ payload = build_rewrite_payload(tool_name, tool_input)
336
+
337
+ stdout, stderr, status = capture_streams(command, payload, timeout)
338
+
339
+ # nil exitstatus = killed by signal (SIGKILL/OOM) — a crash, not a clean
340
+ # exit. Treat as allow so a partial deny payload can't deny from garbage.
341
+ if status&.exitstatus.nil?
342
+ Clacky::Logger.warn("[ShellHookLoader] Rewrite hook '#{command}' died (signal) — allowing")
343
+ return { action: :allow }
344
+ end
345
+ exit_code = status.exitstatus
346
+
347
+ if exit_code == DENY_EXIT_CODE
348
+ source = stderr.strip.empty? ? stdout : stderr
349
+ return { action: :deny, reason: deny_reason(source) }
350
+ end
351
+
352
+ if exit_code != 0
353
+ Clacky::Logger.warn("[ShellHookLoader] Rewrite hook '#{command}' exited #{exit_code} (non-blocking) — allowing")
354
+ return { action: :allow }
355
+ end
356
+
357
+ result = parse_hook_output(stdout)
358
+ # Chained rewrite: apply updatedInput IN PLACE on `call` so the next
359
+ # rewrite sees it. updatedInput is a complete replacement (no merge);
360
+ # empty/absent means "no rewrite this time" — leave `call` untouched.
361
+ updated = result.delete(:updated_input)
362
+ call[:arguments] = JSON.generate(updated) if updated.is_a?(Hash) && !updated.empty?
363
+ result
364
+ rescue Timeout::Error
365
+ Clacky::Logger.warn("[ShellHookLoader] Rewrite hook '#{command}' timed out after #{timeout}s — allowing")
366
+ { action: :allow }
367
+ rescue StandardError => e
368
+ Clacky::Logger.warn("[ShellHookLoader] Rewrite hook '#{command}' failed: #{e.message} — allowing")
369
+ { action: :allow }
370
+ end
371
+
372
+ private def build_rewrite_payload(tool_name, tool_input)
373
+ JSON.generate({
374
+ session_id: @session_id_fn&.call,
375
+ cwd: @cwd_fn&.call || Dir.pwd,
376
+ permission_mode: @permission_mode_fn&.call || "default",
377
+ hook_event_name: "PreToolUse",
378
+ tool_name: tool_name,
379
+ tool_input: tool_input
380
+ })
381
+ end
382
+
383
+ # Parse the tool arguments the hook should see. Unparseable JSON is logged
384
+ # (not silently swallowed) but defers to an empty Hash so a broken payload
385
+ # doesn't wedge the agent.
386
+ private def parse_tool_args(call)
387
+ args = call[:arguments] || call["arguments"]
388
+ case args
389
+ when String then JSON.parse(args)
390
+ when Hash then args
391
+ else {}
392
+ end
393
+ rescue JSON::ParserError => e
394
+ Clacky::Logger.warn("[ShellHookLoader] Unparseable tool arguments (#{e.message}); hook sees empty input")
395
+ {}
396
+ end
397
+
398
+ # Missing or unparseable hookSpecificOutput defers to allow.
399
+ private def parse_hook_output(stdout)
400
+ return { action: :allow } if stdout.nil? || stdout.strip.empty?
401
+
402
+ parsed = JSON.parse(stdout.strip)
403
+ specific = parsed.is_a?(Hash) && parsed["hookSpecificOutput"]
404
+ return { action: :allow } unless specific.is_a?(Hash)
405
+
406
+ decision = specific["permissionDecision"]
407
+ reason = specific["permissionDecisionReason"]
408
+ updated_input = specific["updatedInput"]
409
+
410
+ # Permission decision other than "deny" is treated as allow: the agent's
411
+ # own permission_mode governs confirmation, not the hook.
412
+ result = case decision
413
+ when "deny"
414
+ { action: :deny, reason: deny_reason(reason) }
415
+ else
416
+ { action: :allow } # "allow", "ask", "defer", unknown
417
+ end
418
+
419
+ result[:updated_input] = updated_input if updated_input.is_a?(Hash)
420
+ result
421
+ rescue JSON::ParserError
422
+ { action: :allow }
423
+ end
180
424
  end
181
425
  end
@@ -28,7 +28,7 @@ module Clacky
28
28
  TIPS = [
29
29
  "[*] Ask questions, edit files, or run commands",
30
30
  "[*] Be specific for the best results",
31
- "[*] Create .clackyrules to customize interactions",
31
+ "[*] Create .clackyrules or AGENTS.md to customize interactions",
32
32
  "[*] Type /help for more commands"
33
33
  ].freeze
34
34
 
@@ -2,10 +2,10 @@
2
2
 
3
3
  module Clacky
4
4
  module Utils
5
- # Discovers .clackyrules files in the workspace.
5
+ # Discovers project rules files in the workspace.
6
6
  # Used by both SystemPromptBuilder (rules injection) and WelcomeBanner (UI display).
7
7
  module WorkspaceRules
8
- RULES_FILENAMES = [".clackyrules", ".cursorrules", "CLAUDE.md"].freeze
8
+ RULES_FILENAMES = [".clackyrules", "AGENTS.md", ".cursorrules", "CLAUDE.md"].freeze
9
9
  SUB_PROJECT_SUMMARY_LINES = 5
10
10
 
11
11
  # Find the main rules file in the given directory.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.3.7"
4
+ VERSION = "1.3.9"
5
5
  end