openclacky 1.3.6 → 1.3.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 (112) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -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 +17 -3
  6. data/lib/clacky/agent_profile.rb +124 -53
  7. data/lib/clacky/billing/billing_store.rb +3 -2
  8. data/lib/clacky/brand_config.rb +309 -6
  9. data/lib/clacky/cli.rb +4 -57
  10. data/lib/clacky/default_extensions/coding/agents/coding/avatar.png +0 -0
  11. data/lib/clacky/default_extensions/coding/ext.yml +19 -0
  12. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/avatar.png +0 -0
  13. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +95 -0
  14. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +254 -0
  15. data/lib/clacky/default_extensions/ext-studio/ext.yml +33 -0
  16. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +1288 -0
  17. data/lib/clacky/default_extensions/ext-studio/skills/ext-debug/SKILL.md +71 -0
  18. data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +74 -0
  19. data/lib/clacky/default_extensions/ext-studio/skills/ext-scaffold/SKILL.md +65 -0
  20. data/lib/clacky/default_extensions/general/agents/general/avatar.png +0 -0
  21. data/lib/clacky/default_extensions/general/ext.yml +19 -0
  22. data/lib/clacky/default_extensions/git/ext.yml +12 -0
  23. data/lib/clacky/{default_agents/_panels/git/panel.js → default_extensions/git/panels/git/view.js} +4 -5
  24. data/lib/clacky/default_extensions/meeting/{handler.rb → api/handler.rb} +78 -26
  25. data/lib/clacky/default_extensions/meeting/ext.yml +15 -0
  26. data/lib/clacky/default_extensions/meeting/{meeting.js → panels/meeting/view.js} +266 -90
  27. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +2 -1
  28. data/lib/clacky/default_extensions/time_machine/ext.yml +12 -0
  29. data/lib/clacky/{default_agents/_panels/time_machine/panel.js → default_extensions/time_machine/panels/time_machine/view.js} +58 -47
  30. data/lib/clacky/default_skills/channel-manager/discord_setup.rb +1 -1
  31. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +83 -18
  32. data/lib/clacky/{api_extension.rb → extension/api_extension.rb} +27 -11
  33. data/lib/clacky/extension/api_loader.rb +136 -0
  34. data/lib/clacky/extension/cli_commands.rb +226 -0
  35. data/lib/clacky/{server/api_extension_dispatcher.rb → extension/dispatcher.rb} +22 -9
  36. data/lib/clacky/extension/hook_loader.rb +77 -0
  37. data/lib/clacky/extension/loader.rb +489 -0
  38. data/lib/clacky/extension/packager.rb +226 -0
  39. data/lib/clacky/{patch_loader.rb → extension/patch_loader.rb} +45 -0
  40. data/lib/clacky/extension/scaffold/template_renderer.rb +64 -0
  41. data/lib/clacky/extension/scaffold/templates/full/README.md.erb +48 -0
  42. data/lib/clacky/extension/scaffold/templates/full/agents/designer.md +5 -0
  43. data/lib/clacky/extension/scaffold/templates/full/api/handler.rb.erb +16 -0
  44. data/lib/clacky/extension/scaffold/templates/full/channels/noop.rb.erb +40 -0
  45. data/lib/clacky/extension/scaffold/templates/full/ext.yml.erb +41 -0
  46. data/lib/clacky/extension/scaffold/templates/full/hooks/audit.rb +9 -0
  47. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +194 -0
  48. data/lib/clacky/extension/scaffold/templates/full/patches/audit.rb +15 -0
  49. data/lib/clacky/extension/scaffold/templates/full/skills/__slug__-skill/SKILL.md.erb +8 -0
  50. data/lib/clacky/extension/scaffold/templates/hello/api/handler.rb.erb +9 -0
  51. data/lib/clacky/extension/scaffold/templates/hello/ext.yml.erb +12 -0
  52. data/lib/clacky/extension/scaffold/templates/hello/panels/hello/view.js.erb +31 -0
  53. data/lib/clacky/extension/scaffold.rb +55 -0
  54. data/lib/clacky/extension/verifier.rb +196 -0
  55. data/lib/clacky/identity.rb +70 -0
  56. data/lib/clacky/locales/en.rb +19 -1
  57. data/lib/clacky/locales/zh.rb +19 -1
  58. data/lib/clacky/platform_http_client.rb +18 -15
  59. data/lib/clacky/server/channel/extension_adapter_loader.rb +32 -0
  60. data/lib/clacky/server/channel.rb +6 -0
  61. data/lib/clacky/server/http_server.rb +439 -146
  62. data/lib/clacky/server/session_registry.rb +9 -2
  63. data/lib/clacky/shell_hook_loader.rb +266 -22
  64. data/lib/clacky/skill_loader.rb +28 -1
  65. data/lib/clacky/ui2/components/welcome_banner.rb +1 -1
  66. data/lib/clacky/ui2/ui_controller.rb +9 -6
  67. data/lib/clacky/utils/workspace_rules.rb +2 -2
  68. data/lib/clacky/version.rb +1 -1
  69. data/lib/clacky/web/app.css +1034 -675
  70. data/lib/clacky/web/app.js +63 -15
  71. data/lib/clacky/web/auth.js +2 -0
  72. data/lib/clacky/web/components/notify.js +2 -0
  73. data/lib/clacky/web/components/sidebar.js +1 -5
  74. data/lib/clacky/web/core/ext.js +432 -89
  75. data/lib/clacky/web/features/backup/store.js +1 -0
  76. data/lib/clacky/web/features/backup/view.js +0 -1
  77. data/lib/clacky/web/features/brand/view.js +13 -5
  78. data/lib/clacky/web/features/extensions/store.js +184 -0
  79. data/lib/clacky/web/features/extensions/view.js +390 -0
  80. data/lib/clacky/web/features/new-session/store.js +199 -0
  81. data/lib/clacky/web/features/new-session/view.js +619 -0
  82. data/lib/clacky/web/features/share/view.js +63 -14
  83. data/lib/clacky/web/features/skills/store.js +3 -1
  84. data/lib/clacky/web/features/skills/view.js +1 -1
  85. data/lib/clacky/web/features/tasks/view.js +12 -0
  86. data/lib/clacky/web/features/trash/store.js +24 -6
  87. data/lib/clacky/web/features/trash/view.js +48 -12
  88. data/lib/clacky/web/features/workspace/store.js +2 -0
  89. data/lib/clacky/web/features/workspace/view.js +0 -1
  90. data/lib/clacky/web/i18n.js +152 -106
  91. data/lib/clacky/web/index.html +172 -190
  92. data/lib/clacky/web/sessions.js +67 -272
  93. data/lib/clacky/web/settings.js +1 -1
  94. data/lib/clacky/web/skills.js +88 -48
  95. data/lib/clacky/web/theme.js +5 -0
  96. data/lib/clacky/web/ws-dispatcher.js +12 -2
  97. data/lib/clacky/web/ws.js +2 -0
  98. data/lib/clacky.rb +21 -10
  99. metadata +52 -21
  100. data/benchmark/runner.rb +0 -441
  101. data/lib/clacky/api_extension_loader.rb +0 -168
  102. data/lib/clacky/default_agents/SOUL.md +0 -3
  103. data/lib/clacky/default_agents/USER.md +0 -1
  104. data/lib/clacky/default_agents/coding/profile.yml +0 -5
  105. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  106. data/lib/clacky/default_agents/general/profile.yml +0 -2
  107. data/lib/clacky/default_extensions/meeting/meta.yml +0 -3
  108. data/lib/clacky/web/features/creator/store.js +0 -81
  109. data/lib/clacky/web/features/creator/view.js +0 -380
  110. /data/lib/clacky/{default_agents → default_extensions/coding/agents}/coding/system_prompt.md +0 -0
  111. /data/lib/clacky/{default_agents → default_extensions/general/agents}/general/system_prompt.md +0 -0
  112. /data/lib/clacky/{default_agents/base_prompt.md → prompts/base.md} +0 -0
@@ -117,8 +117,15 @@ module Clacky
117
117
  src = (session_data[:source] || "manual").to_s
118
118
  next if counts[src] >= n
119
119
  next if exist?(session_data[:session_id])
120
- @session_restorer.call(session_data)
121
- counts[src] += 1
120
+ begin
121
+ @session_restorer.call(session_data)
122
+ counts[src] += 1
123
+ rescue => e
124
+ Clacky::Logger.warn(
125
+ "[SessionRegistry] skip session #{session_data[:session_id]}: " \
126
+ "#{e.class}: #{e.message}"
127
+ )
128
+ end
122
129
  end
123
130
  end
124
131
 
@@ -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
@@ -12,6 +12,7 @@ module Clacky
12
12
  # Skill discovery locations (in priority order: lower index = lower priority)
13
13
  LOCATIONS = [
14
14
  :default, # gem's built-in default skills (lowest priority)
15
+ :extension, # contributes.skills declared in ext.yml containers
15
16
  :global_clacky, # ~/.clacky/skills/
16
17
  :project_clacky, # .clacky/skills/ (highest priority among plain skills)
17
18
  :brand # ~/.clacky/brand_skills/ (encrypted, license-gated)
@@ -62,6 +63,7 @@ module Clacky
62
63
  clear
63
64
 
64
65
  load_default_skills
66
+ load_extension_skills
65
67
  load_global_clacky_skills
66
68
 
67
69
  # Only load project-level skills when working_dir is explicitly provided.
@@ -401,7 +403,7 @@ module Clacky
401
403
  # @return [Skill, nil] nil when the skill was rejected (duplicate/limit)
402
404
  private def register_skill(skill, source:)
403
405
  id = skill.identifier
404
- priority_order = %i[default global_clacky project_clacky brand]
406
+ priority_order = %i[default extension global_clacky project_clacky brand]
405
407
 
406
408
  # --- duplicate check ---
407
409
  if (existing = @skills[id])
@@ -472,5 +474,30 @@ module Clacky
472
474
  end
473
475
  end
474
476
  end
477
+
478
+ # Load skills declared via `contributes.skills` in ext.yml containers.
479
+ # Protected (encrypted) units are skipped here — they are listed by
480
+ # ExtensionLoader for display/management but their decryption still flows
481
+ # through the legacy brand_skill chain (see load_brand_skills).
482
+ private def load_extension_skills
483
+ result = Clacky::ExtensionLoader.last_result
484
+ return unless result&.skills
485
+
486
+ result.skills.each do |unit|
487
+ next if unit.spec["protected"] || unit.spec["encrypted"]
488
+
489
+ skill_dir_abs = unit.spec["skill_dir_abs"]
490
+ next unless File.directory?(skill_dir_abs) && File.file?(File.join(skill_dir_abs, "SKILL.md"))
491
+
492
+ begin
493
+ skill = Skill.new(Pathname.new(skill_dir_abs))
494
+ register_skill(skill, source: :extension)
495
+ rescue Clacky::AgentError => e
496
+ @errors << "Error loading extension skill '#{unit.id}' from #{skill_dir_abs}: #{e.message}"
497
+ rescue StandardError => e
498
+ @errors << "Unexpected error loading extension skill '#{unit.id}' from #{skill_dir_abs}: #{e.message}"
499
+ end
500
+ end
501
+ end
475
502
  end
476
503
  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
 
@@ -1085,12 +1085,15 @@ module Clacky
1085
1085
  queue.push(:timeout) unless session[:intervened]
1086
1086
  end
1087
1087
 
1088
- result = queue.pop
1089
- session[:watchdog].kill if session[:watchdog]&.alive?
1090
- @feedback_countdown = nil
1091
- @layout.remove_entry(entry_id) if entry_id
1092
- @layout.recalculate_layout
1093
- @layout.render_all
1088
+ begin
1089
+ result = queue.pop
1090
+ ensure
1091
+ session[:watchdog].kill if session[:watchdog]&.alive?
1092
+ @feedback_countdown = nil
1093
+ @layout.remove_entry(entry_id) if entry_id
1094
+ @layout.recalculate_layout
1095
+ @layout.render_all
1096
+ end
1094
1097
 
1095
1098
  if result == :timeout
1096
1099
  append_output(theme.format_text(" No response — continuing automatically.", :thinking))
@@ -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.6"
4
+ VERSION = "1.3.8"
5
5
  end