openclacky 1.5.6 → 1.5.7

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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +18 -1
  3. data/.clackyrules +25 -1
  4. data/CHANGELOG.md +26 -0
  5. data/CONTRIBUTING.md +9 -0
  6. data/Rakefile +15 -0
  7. data/docs/design-emit-and-background-skills.md +253 -0
  8. data/docs/mcp-architecture.md +48 -9
  9. data/docs/mcp.example.json +9 -0
  10. data/docs/superpowers/plans/2026-08-09-chatcut-extension-bridge.md +191 -0
  11. data/docs/superpowers/plans/2026-08-09-native-mcp-oauth.md +243 -0
  12. data/docs/superpowers/specs/2026-08-09-mcp-oauth-extension-fallback-design.md +115 -0
  13. data/lib/clacky/agent/cost_tracker.rb +17 -2
  14. data/lib/clacky/agent/hook_manager.rb +14 -3
  15. data/lib/clacky/agent/memory_updater.rb +17 -18
  16. data/lib/clacky/agent/message_compressor_helper.rb +23 -0
  17. data/lib/clacky/agent/session_serializer.rb +136 -22
  18. data/lib/clacky/agent/skill_auto_creator.rb +1 -2
  19. data/lib/clacky/agent/skill_evolution.rb +11 -7
  20. data/lib/clacky/agent/skill_manager.rb +20 -18
  21. data/lib/clacky/agent/skill_reflector.rb +3 -6
  22. data/lib/clacky/agent.rb +111 -20
  23. data/lib/clacky/anthropic_stream_aggregator.rb +6 -1
  24. data/lib/clacky/cli.rb +4 -0
  25. data/lib/clacky/client.rb +1 -1
  26. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +13 -5
  27. data/lib/clacky/extension/api_extension.rb +11 -3
  28. data/lib/clacky/fanout.rb +100 -0
  29. data/lib/clacky/locales/en.rb +2 -0
  30. data/lib/clacky/locales/zh.rb +2 -0
  31. data/lib/clacky/mcp/cli_commands.rb +61 -0
  32. data/lib/clacky/mcp/client.rb +18 -2
  33. data/lib/clacky/mcp/connection_manager.rb +85 -0
  34. data/lib/clacky/mcp/http_transport.rb +25 -9
  35. data/lib/clacky/mcp/oauth/authorization_manager.rb +257 -0
  36. data/lib/clacky/mcp/oauth/config.rb +51 -0
  37. data/lib/clacky/mcp/oauth/credential_store.rb +82 -0
  38. data/lib/clacky/mcp/oauth/session.rb +88 -0
  39. data/lib/clacky/message_format/anthropic.rb +27 -13
  40. data/lib/clacky/message_history.rb +32 -1
  41. data/lib/clacky/providers.rb +11 -5
  42. data/lib/clacky/server/http_server.rb +42 -22
  43. data/lib/clacky/server/session_registry.rb +11 -15
  44. data/lib/clacky/server/web_ui_controller.rb +1 -1
  45. data/lib/clacky/session_manager.rb +13 -11
  46. data/lib/clacky/shell_hook_loader.rb +14 -8
  47. data/lib/clacky/tools/invoke_skill.rb +3 -2
  48. data/lib/clacky/tools/terminal.rb +11 -4
  49. data/lib/clacky/ui2/progress_handle.rb +27 -3
  50. data/lib/clacky/ui2/ui_controller.rb +206 -6
  51. data/lib/clacky/ui_interface.rb +13 -3
  52. data/lib/clacky/utils/arguments_parser.rb +73 -4
  53. data/lib/clacky/utils/model_pricing.rb +21 -0
  54. data/lib/clacky/version.rb +1 -1
  55. data/lib/clacky/web/app.css +10 -3
  56. data/lib/clacky/web/features/new-session/view.js +19 -2
  57. data/lib/clacky/web/i18n.js +22 -8
  58. data/lib/clacky/web/index.html +7 -11
  59. data/lib/clacky/web/projects.js +26 -0
  60. data/lib/clacky/web/sessions.js +169 -119
  61. data/lib/clacky/web/settings.js +1 -0
  62. data/lib/clacky/web/ws-dispatcher.js +61 -34
  63. data/lib/clacky.rb +2 -0
  64. metadata +12 -1
@@ -43,6 +43,7 @@ module Clacky
43
43
  "dsk-deepseek-v4-pro",
44
44
  "dsk-deepseek-v4-flash",
45
45
  "or-gemini-3-1-pro",
46
+ "or-gemini-3-6-flash",
46
47
  "or-gemini-3-5-flash"
47
48
  ],
48
49
  # Image generation models served by the openclacky platform
@@ -94,30 +95,34 @@ module Clacky
94
95
  # routes them to Vertex AI Gemini (generateContent with inline
95
96
  # audio parts). The gateway returns transcription text.
96
97
  "stt_models" => [
98
+ "or-stt-gemini-3-6-flash",
97
99
  "or-stt-gemini-3-5-flash",
98
100
  "or-stt-gemini-1-5-pro"
99
101
  ],
100
102
  "stt_model_aliases" => {
103
+ "or-stt-gemini-3-6-flash" => "Gemini 3.6 Flash STT",
101
104
  "or-stt-gemini-3-5-flash" => "Gemini 3.5 Flash STT",
102
105
  "or-stt-gemini-1-5-pro" => "Gemini 1.5 Pro STT"
103
106
  },
104
- "default_stt_model" => "or-stt-gemini-3-5-flash",
107
+ "default_stt_model" => "or-stt-gemini-3-6-flash",
105
108
  # Video understanding models served by the openclacky gateway, which
106
109
  # routes video frames to Gemini (generateContent with inline image
107
110
  # parts). The gateway returns analysis text.
108
111
  "video_understanding_models" => [
112
+ "or-gemini-3-6-flash",
109
113
  "or-gemini-3-5-flash",
110
114
  "or-gemini-3-1-pro"
111
115
  ],
112
116
  "video_understanding_model_aliases" => {
117
+ "or-gemini-3-6-flash" => "Gemini 3.6 Flash",
113
118
  "or-gemini-3-5-flash" => "Gemini 3.5 Flash",
114
119
  "or-gemini-3-1-pro" => "Gemini 3.1 Pro"
115
120
  },
116
- "default_video_understanding_model" => "or-gemini-3-5-flash",
121
+ "default_video_understanding_model" => "or-gemini-3-6-flash",
117
122
  # Default OCR sidecar — used when the primary model is text-only.
118
123
  # Candidates are derived from the provider's vision-capable models;
119
124
  # this just picks the cheap+fast default to surface in "auto" mode.
120
- "default_ocr_model" => "or-gemini-3-5-flash",
125
+ "default_ocr_model" => "or-gemini-3-6-flash",
121
126
  # Provider-level default: the Claude family served here is vision-capable.
122
127
  "capabilities" => { "vision" => true }.freeze,
123
128
  # Model-level overrides: DeepSeek models routed through this provider
@@ -144,7 +149,7 @@ module Clacky
144
149
  "abs-claude-sonnet-4-6" => "abs-claude-haiku-4-5",
145
150
  "abs-claude-sonnet-4-5" => "abs-claude-haiku-4-5",
146
151
  "dsk-deepseek-v4-pro" => "dsk-deepseek-v4-flash",
147
- "or-gemini-3-1-pro" => "or-gemini-3-5-flash"
152
+ "or-gemini-3-1-pro" => "or-gemini-3-6-flash"
148
153
  },
149
154
  # Fallback chain: if a model is unavailable, try the next one in order.
150
155
  # Keys are primary model names; values are the fallback model to use instead.
@@ -615,7 +620,8 @@ module Clacky
615
620
  { pattern: /glm/i, limit: 65_536 }, # GLM-5.2: 128K output ceiling; 64K ample for reasoning+answer
616
621
  { pattern: /kimi-k3/i, limit: 65_536 }, # Kimi K3: max_completion_tokens=131072 (max 1M); 64K ample
617
622
  { pattern: /mimo-v2\.5-pro/i, limit: 65_536 }, # MiMo-V2.5-Pro: max_completion_tokens=131072; 64K ample
618
- { pattern: /mimo/i, limit: 32_768 } # MiMo-V2.5: max_completion_tokens=32768; full default ceiling
623
+ { pattern: /mimo/i, limit: 32_768 }, # MiMo-V2.5: max_completion_tokens=32768; full default ceiling
624
+ { pattern: /gemini/i, limit: 65_536 } # Gemini 3.x: 64K max output ceiling
619
625
  ].freeze
620
626
 
621
627
  class << self
@@ -113,6 +113,12 @@ module Clacky
113
113
  @events << { type: "subagent_end", session_id: @session_id }
114
114
  end
115
115
 
116
+ # Custom extension events recorded on messages (Agent#emit_event). Must be
117
+ # explicit: method_missing below would otherwise swallow them.
118
+ def emit(type, **data)
119
+ @events << { type: type, session_id: @session_id }.merge(data)
120
+ end
121
+
116
122
  # Ignore all other UI methods (progress, errors, etc.) during history replay
117
123
  def method_missing(name, *args, **kwargs); end
118
124
  def respond_to_missing?(name, include_private = false); true; end
@@ -126,6 +132,9 @@ module Clacky
126
132
  # GET /** → static files served from lib/clacky/web/ directory
127
133
  class HttpServer
128
134
  WEB_ROOT = File.expand_path("../web", __dir__)
135
+ # How long shutdown waits for each agent thread to unwind before falling
136
+ # back to a manual session save.
137
+ AGENT_INTERRUPT_JOIN_SECONDS = 2
129
138
  EXCHANGE_RATE_PRIMARY_BASE_URL = "https://open.er-api.com/v6/latest"
130
139
  EXCHANGE_RATE_FALLBACK_URL = "https://api.frankfurter.app/latest"
131
140
  OSS_CDN_BASE = "https://oss.1024code.com/openclacky"
@@ -791,6 +800,9 @@ module Clacky
791
800
  elsif method == "PATCH" && path.match?(%r{^/api/projects/[^/]+$})
792
801
  project_id = path.sub("/api/projects/", "")
793
802
  api_update_project(project_id, req, res)
803
+ elsif method == "DELETE" && path.match?(%r{^/api/projects/[^/]+/sessions$})
804
+ project_id = path.sub("/api/projects/", "").sub("/sessions", "")
805
+ api_delete_project_sessions(project_id, res)
794
806
  elsif method == "DELETE" && path.match?(%r{^/api/projects/[^/]+$})
795
807
  project_id = path.sub("/api/projects/", "")
796
808
  api_delete_project(project_id, res)
@@ -810,21 +822,20 @@ module Clacky
810
822
  q_scope = query["q_scope"].to_s.strip.then { |v| %w[name content].include?(v) ? v : "name" }
811
823
  date = query["date"].to_s.strip.then { |v| v.empty? ? nil : v }
812
824
  type = query["type"].to_s.strip.then { |v| v.empty? ? nil : v }
813
- exclude_type = query["exclude_type"].to_s.strip.then { |v| v.empty? ? nil : v }
825
+ exclude_type = query["exclude_type"].to_s.split(",").map(&:strip).reject(&:empty?).then { |v| v.empty? ? nil : v }
814
826
  # Backward-compat: ?source=<x> and ?profile=coding → type
815
827
  type ||= query["profile"].to_s.strip.then { |v| v.empty? ? nil : v }
816
828
  type ||= query["source"].to_s.strip.then { |v| v.empty? ? nil : v }
817
829
 
818
- sessions = @registry.list(limit: limit + 1, before: before, q: q, q_scope: q_scope, date: date, type: type, exclude_type: exclude_type)
830
+ sessions = @registry.list(limit: limit + 1, before: before, q: q, q_scope: q_scope, date: date, type: type, exclude_type: exclude_type, exclude_project: !!type)
819
831
 
820
832
  pinned_part, non_pinned_part = sessions.partition { |s| s[:pinned] }
821
833
  has_more = non_pinned_part.size > limit
822
834
  non_pinned_part = non_pinned_part.first(limit)
823
835
  sessions = pinned_part + non_pinned_part
824
836
 
825
- stats = @registry.cron_stats
826
837
  json_response(res, 200, { sessions: sessions, has_more: has_more,
827
- cron_count: stats[:count], latest_cron_updated_at: stats[:latest_updated_at] })
838
+ groups: @registry.group_stats_all })
828
839
  end
829
840
 
830
841
  # GET /api/sessions/:id — fetch a single session by id (memory + disk merged).
@@ -6351,8 +6362,25 @@ module Clacky
6351
6362
  return json_response(res, 404, { error: "Project not found" }) unless @project_manager.find(project_id)
6352
6363
 
6353
6364
  @project_manager.delete(project_id)
6365
+ _delete_project_sessions(project_id)
6366
+
6367
+ json_response(res, 200, { ok: true })
6368
+ rescue => e
6369
+ json_response(res, 500, { error: e.message })
6370
+ end
6371
+
6372
+ # DELETE /api/projects/:id/sessions - delete all sessions in a project but keep the project
6373
+ def api_delete_project_sessions(project_id, res)
6374
+ return json_response(res, 404, { error: "Project not found" }) unless @project_manager.find(project_id)
6354
6375
 
6355
- # Soft-delete all sessions that belonged to this project.
6376
+ deleted = _delete_project_sessions(project_id)
6377
+ json_response(res, 200, { ok: true, deleted: deleted })
6378
+ rescue => e
6379
+ json_response(res, 500, { error: e.message })
6380
+ end
6381
+
6382
+ private def _delete_project_sessions(project_id)
6383
+ deleted = 0
6356
6384
  @session_manager.all_sessions.each do |session_data|
6357
6385
  next unless session_data[:project_id].to_s == project_id
6358
6386
 
@@ -6365,11 +6393,9 @@ module Clacky
6365
6393
 
6366
6394
  broadcast(sid, { type: "session_deleted", session_id: sid })
6367
6395
  unsubscribe_all(sid)
6396
+ deleted += 1
6368
6397
  end
6369
-
6370
- json_response(res, 200, { ok: true })
6371
- rescue => e
6372
- json_response(res, 500, { error: e.message })
6398
+ deleted
6373
6399
  end
6374
6400
 
6375
6401
  # PATCH /api/sessions/:id/project — move a session into or out of a project
@@ -6877,28 +6903,23 @@ module Clacky
6877
6903
  interrupt_session(session_id)
6878
6904
 
6879
6905
  when "list_sessions"
6880
- stats = @registry.cron_stats
6881
- page = @registry.list(limit: 11, exclude_type: "cron", exclude_project: true)
6906
+ groups = @registry.group_stats_all
6907
+ page = @registry.list(limit: 11, exclude_type: SessionManager::GROUPED_SOURCES, exclude_project: true)
6882
6908
  has_more = page.size > 10
6883
6909
  all_sessions = page.first(10)
6884
6910
  projects = @project_manager.all
6885
- # Include ALL sessions that belong to any project, regardless of the
6886
- # 15-item pagination limit. We merge them into the same `sessions`
6887
- # array; the client deduplicates by id in `setAll`.
6888
6911
  if projects.any?
6889
6912
  project_ids = projects.map { |p| p[:id] }
6890
6913
  project_sessions = project_ids.flat_map do |pid|
6891
- @registry.list(project_id: pid, exclude_type: "cron")
6914
+ @registry.list(project_id: pid)
6892
6915
  end
6893
6916
  # Deduplicate: project sessions that are already in the first page
6894
6917
  # will be replaced by the enriched version from `all_sessions`.
6895
6918
  paged_ids = all_sessions.map { |s| s[:id] }.to_set
6896
- extra_project_sessions = project_sessions.reject { |s| paged_ids.include?(s[:id]) }
6897
- all_sessions = all_sessions + extra_project_sessions
6919
+ all_sessions += project_sessions.reject { |s| paged_ids.include?(s[:id]) }
6898
6920
  end
6899
6921
  conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more,
6900
- cron_count: stats[:count], latest_cron_updated_at: stats[:latest_updated_at],
6901
- projects: projects)
6922
+ groups: groups, projects: projects)
6902
6923
 
6903
6924
  when "run_task"
6904
6925
  # Client sends this after subscribing to guarantee it's ready to receive
@@ -7094,7 +7115,7 @@ module Clacky
7094
7115
  rescue => e
7095
7116
  Clacky::Logger.error("[shutdown] interrupt failed for session=#{id}: #{e.message}")
7096
7117
  end
7097
- thread.join(2)
7118
+ thread.join(AGENT_INTERRUPT_JOIN_SECONDS)
7098
7119
  @session_manager.save(agent.to_session_data(status: :interrupted, updated_at: Time.now))
7099
7120
  end
7100
7121
  end
@@ -7279,7 +7300,7 @@ module Clacky
7279
7300
  # @param working_dir [String] working directory for the agent
7280
7301
  # @param permission_mode [Symbol] :confirm_all (default, human present) or
7281
7302
  # :auto_approve (unattended — suppresses request_user_feedback waits)
7282
- def build_session(name:, working_dir: nil, permission_mode: :confirm_all, profile: "general", source: :manual, model_id: nil, hidden: false)
7303
+ def build_session(name:, working_dir: nil, permission_mode: :confirm_all, profile: "general", source: :manual, model_id: nil)
7283
7304
  working_dir ||= default_working_dir
7284
7305
  FileUtils.mkdir_p(working_dir) unless Dir.exist?(working_dir)
7285
7306
  session_id = Clacky::SessionManager.generate_id
@@ -7318,7 +7339,6 @@ module Clacky
7318
7339
  agent = Clacky::Agent.new(client, config, working_dir: working_dir, ui: ui, profile: profile,
7319
7340
  session_id: session_id, source: source)
7320
7341
  agent.rename(name) unless name.nil? || name.empty?
7321
- agent.hidden = hidden
7322
7342
  idle_timer = build_idle_timer(session_id, agent)
7323
7343
 
7324
7344
  @registry.with_session(session_id) do |s|
@@ -203,7 +203,7 @@ module Clacky
203
203
  # [ ...all_pinned_matching (newest-first), ...non_pinned (newest-first, limited) ]
204
204
  #
205
205
  # source and profile are orthogonal — either can be nil independently.
206
- def list(limit: nil, before: nil, q: nil, q_scope: "name", date: nil, type: nil, exclude_type: nil, include_pinned: true, project_id: nil, exclude_project: false, include_hidden: false)
206
+ def list(limit: nil, before: nil, q: nil, q_scope: "name", date: nil, type: nil, exclude_type: nil, include_pinned: true, project_id: nil, exclude_project: false)
207
207
  return [] unless @session_manager
208
208
 
209
209
  live = @mutex.synchronize do
@@ -228,10 +228,6 @@ module Clacky
228
228
 
229
229
  all = @session_manager.all_sessions # already sorted newest-first
230
230
 
231
- # Hidden sessions (extension-managed) are excluded from the list by
232
- # default; callers that need them pass include_hidden: true.
233
- all = all.reject { |s| s[:hidden] } unless include_hidden
234
-
235
231
  # ── type filter (replaces old source/profile split) ──────────────────
236
232
  # type=coding → agent_profile == "coding"
237
233
  # type=manual/cron/channel/setup → source match (profile=general implied)
@@ -412,18 +408,18 @@ module Clacky
412
408
 
413
409
  public
414
410
 
415
- # Count all cron sessions on disk (not filtered by pagination).
416
- def cron_count
417
- return 0 unless @session_manager
418
- @session_manager.all_sessions.count { |s| s_source(s) == "cron" }
411
+ # Count + latest updated_at for sessions of a given source, excluding
412
+ # those assigned to a project (they show in the project section instead).
413
+ def group_stats(source)
414
+ return { count: 0, latest_updated_at: nil } unless @session_manager
415
+ group_all = @session_manager.all_sessions.select { |s| s_source(s) == source && s[:project_id].to_s.strip == "" }
416
+ latest = group_all.map { |s| s[:updated_at] || s[:created_at] }.compact.max
417
+ { count: group_all.size, latest_updated_at: latest }
419
418
  end
420
419
 
421
- # Count + latest updated_at for cron sessions (used by sidebar virtual entry).
422
- def cron_stats
423
- return { count: 0, latest_updated_at: nil } unless @session_manager
424
- cron_all = @session_manager.all_sessions.select { |s| s_source(s) == "cron" }
425
- latest = cron_all.map { |s| s[:updated_at] || s[:created_at] }.compact.max
426
- { count: cron_all.size, latest_updated_at: latest }
420
+ # Stats for every folded sidebar group, keyed by source.
421
+ def group_stats_all
422
+ SessionManager::GROUPED_SOURCES.to_h { |source| [source, group_stats(source)] }
427
423
  end
428
424
 
429
425
  # Delete a session from registry (and interrupt its thread).
@@ -221,7 +221,7 @@ module Clacky
221
221
 
222
222
  # === Phase grouping ===
223
223
 
224
- def phase_start(kind:, label: nil)
224
+ def phase_start(kind:, label: nil, concurrent: false)
225
225
  pid = SecureRandom.uuid
226
226
  Thread.current[:clacky_phase_id] = pid
227
227
  # Emit without auto-injection (the start event itself defines the phase)
@@ -9,6 +9,7 @@ require "set"
9
9
  module Clacky
10
10
  class SessionManager
11
11
  SESSIONS_DIR = File.join(Dir.home, ".clacky", "sessions")
12
+ GROUPED_SOURCES = %w[cron ext].freeze
12
13
 
13
14
  # Generate a new unique session ID (16-char hex string).
14
15
  # This is the single authoritative source for session IDs — all components
@@ -35,7 +36,7 @@ module Clacky
35
36
 
36
37
  # Keep only the most recent 200 sessions (best-effort, never block save)
37
38
  begin
38
- cleanup_by_count(keep: 200, keep_cron: 200)
39
+ cleanup_by_count(keep: 200, grouped_keep: 200)
39
40
  cleanup_trash(days: 8)
40
41
  rescue Exception # rubocop:disable Lint/RescueException
41
42
  # Cleanup is non-critical; swallow all errors (including AgentInterrupted)
@@ -65,7 +66,6 @@ module Clacky
65
66
  forked[:created_at] = Time.now.iso8601
66
67
  forked[:updated_at] = Time.now.iso8601
67
68
  forked[:pinned] = false
68
- forked[:hidden] = false
69
69
  forked[:name] = "#{original[:name] || "Unnamed session"} (copy)"
70
70
  forked[:stats] = (original[:stats] || {}).merge(
71
71
  total_tasks: 0, total_iterations: 0, total_cost_usd: 0.0,
@@ -330,18 +330,20 @@ module Clacky
330
330
  deleted
331
331
  end
332
332
 
333
- # Keep only the most recent N non-pinned, non-hidden sessions by created_at;
334
- # the rest are soft-deleted (moved to the session trash, recoverable). Pinned
335
- # and hidden sessions are never deleted and do not count toward the cap.
333
+ # Keep only the most recent N non-pinned sessions by created_at; the rest
334
+ # are soft-deleted (moved to the session trash, recoverable). Pinned
335
+ # sessions are never deleted and do not count toward the cap.
336
336
  # Returns count of soft-deleted sessions.
337
- def cleanup_by_count(keep:, keep_cron: 200)
338
- non_pinned = all_sessions.reject { |s| s[:pinned] || s[:hidden] } # already sorted newest-first
337
+ def cleanup_by_count(keep:, grouped_keep: 200)
338
+ non_pinned = all_sessions.reject { |s| s[:pinned] }
339
339
 
340
- cron, regular = non_pinned.partition { |s| s[:source].to_s == "cron" }
340
+ groups = non_pinned.group_by do |s|
341
+ (s[:project_id] || !GROUPED_SOURCES.include?(s[:source].to_s)) ? "regular" : s[:source].to_s
342
+ end
341
343
 
342
- victims = []
343
- victims += regular[keep..] if regular.size > keep
344
- victims += cron[keep_cron..] if cron.size > keep_cron
344
+ victims = groups.flat_map do |source, sessions|
345
+ sessions[(source == "regular" ? keep : grouped_keep)..] || []
346
+ end
345
347
 
346
348
  victims.each { |session| soft_delete(session[:session_id]) }
347
349
  victims.size
@@ -48,6 +48,10 @@ module Clacky
48
48
  DEFAULT_TIMEOUT = 10
49
49
  REWRITE_DEFAULT_TIMEOUT = 60
50
50
  DENY_EXIT_CODE = 2
51
+ # Grace period for a SIGTERM'd hook to exit before the group is SIGKILL'd,
52
+ # and for the stream readers to hit EOF afterwards.
53
+ KILL_GRACE_SECONDS = 1
54
+ DRAIN_JOIN_SECONDS = 2
51
55
 
52
56
  Result = Struct.new(:registered, :skipped, keyword_init: true)
53
57
 
@@ -165,15 +169,17 @@ module Clacky
165
169
  resolved = resolve_command(spec, result)
166
170
  return unless resolved
167
171
  name, command = resolved
168
- timeout = (spec["timeout"] || DEFAULT_TIMEOUT).to_i
172
+ timeout = (spec["timeout"] || DEFAULT_TIMEOUT).to_f
169
173
 
170
174
  unless HookManager::HOOK_EVENTS.include?(event)
171
175
  result.skipped << [name, "unknown event: #{event}"]
172
176
  return
173
177
  end
174
178
 
179
+ # HookManager appends the owning agent as a trailing arg; shell hooks
180
+ # serialize their args to JSON, so drop it before building the payload.
175
181
  hook_manager.add(event) do |*args|
176
- run_command(event, command, timeout, args)
182
+ run_command(event, command, timeout, args[0..-2])
177
183
  end
178
184
  result.registered << [event, name]
179
185
  end
@@ -183,7 +189,7 @@ module Clacky
183
189
  resolved = resolve_command(spec, result)
184
190
  return unless resolved
185
191
  name, command = resolved
186
- timeout = (spec["timeout"] || REWRITE_DEFAULT_TIMEOUT).to_i
192
+ timeout = (spec["timeout"] || REWRITE_DEFAULT_TIMEOUT).to_f
187
193
  matcher = spec["matcher"]
188
194
 
189
195
  hook_manager.add(:before_tool_use) do |call|
@@ -245,20 +251,20 @@ module Clacky
245
251
  status = wait_thr.value
246
252
  # Child exited; drain the readers (bounded join — a grandchild
247
253
  # holding the pipe fd can keep them from EOF; see drain_stream).
248
- out_reader.join(2)
249
- err_reader.join(2)
254
+ out_reader.join(DRAIN_JOIN_SECONDS)
255
+ err_reader.join(DRAIN_JOIN_SECONDS)
250
256
  else
251
257
  # SIGTERM, then SIGKILL the whole group if it won't die. popen3's
252
258
  # ensure joins wait_thr without a timeout, so the child MUST be dead
253
259
  # before we leave the block — a hook that traps/ignores TERM can't be
254
260
  # allowed to hang here.
255
261
  Process.kill("TERM", -pgid) rescue nil
256
- unless wait_thr.join(1)
262
+ unless wait_thr.join(KILL_GRACE_SECONDS)
257
263
  Process.kill("KILL", -pgid) rescue nil
258
264
  wait_thr.join
259
265
  end
260
- out_reader.join(1)
261
- err_reader.join(1)
266
+ out_reader.join(KILL_GRACE_SECONDS)
267
+ err_reader.join(KILL_GRACE_SECONDS)
262
268
  writer.kill rescue nil
263
269
  raise Timeout::Error
264
270
  end
@@ -28,8 +28,9 @@ module Clacky
28
28
  # @param task [String] Task description to pass to the skill
29
29
  # @param agent [Clacky::Agent] Agent instance (injected)
30
30
  # @param skill_loader [Clacky::SkillLoader] Skill loader instance (injected)
31
+ # @param tool_call_id [String, nil] This call's id (injected), used to key the subagent transcript
31
32
  # @return [Hash] Result of skill execution
32
- def execute(skill_name:, task:, agent: nil, skill_loader: nil, working_dir: nil)
33
+ def execute(skill_name:, task:, agent: nil, skill_loader: nil, working_dir: nil, tool_call_id: nil)
33
34
  # Validate injected dependencies
34
35
  return { error: "Agent context is required" } unless agent
35
36
  return { error: "Skill loader is required" } unless skill_loader
@@ -44,7 +45,7 @@ module Clacky
44
45
  # have triggered this skill explicitly via a slash command (/skill-name).
45
46
  if skill.fork_agent?
46
47
  # Execute in isolated subagent
47
- result = agent.send(:execute_skill_with_subagent, skill, task)
48
+ result = agent.send(:execute_skill_with_subagent, skill, task, tool_call_id: tool_call_id)
48
49
  {
49
50
  message: "Skill '#{skill_name}' executed in subagent",
50
51
  result: result,
@@ -132,6 +132,13 @@ module Clacky
132
132
  BACKGROUND_COLLECT_SECONDS = 2
133
133
  # Sentinel: when passed as idle_ms, disables idle early-return.
134
134
  DISABLED_IDLE_MS = 10_000_000
135
+ # Gap between log-file polls while waiting for a command's end marker.
136
+ MARKER_POLL_SECONDS = 0.05
137
+ # A freshly spawned login shell prints its banner asynchronously; wait
138
+ # this long before draining so the marker install doesn't interleave.
139
+ SHELL_BANNER_SETTLE_SECONDS = 0.2
140
+ # Upper bound on draining that banner once it starts arriving.
141
+ SHELL_BANNER_DRAIN_SECONDS = 2.5
135
142
 
136
143
  # Commands that we know take a long time and produce bursty output
137
144
  # (quiet gaps between test files, compile phases, download batches,
@@ -213,7 +220,7 @@ module Clacky
213
220
  idle_ms ||= DISABLED_IDLE_MS
214
221
  end
215
222
 
216
- timeout = (timeout || DEFAULT_TIMEOUT).to_i
223
+ timeout = (timeout || DEFAULT_TIMEOUT).to_f
217
224
  idle_ms = (idle_ms || DEFAULT_IDLE_MS).to_i
218
225
  cwd ||= working_dir
219
226
 
@@ -875,8 +882,8 @@ module Clacky
875
882
  # Give the shell a moment to print its startup banner (zsh -l -i
876
883
  # loads a lot of stuff), then drain whatever noise it wrote so the
877
884
  # marker install doesn't collide with it.
878
- sleep 0.2
879
- drain_any(session, timeout: 2.5)
885
+ sleep SHELL_BANNER_SETTLE_SECONDS
886
+ drain_any(session, timeout: SHELL_BANNER_DRAIN_SECONDS)
880
887
  install_marker(session)
881
888
  _before, _code, state = read_until_marker(session, timeout: 10, idle_ms: DISABLED_IDLE_MS)
882
889
  unless state == :matched
@@ -1299,7 +1306,7 @@ module Clacky
1299
1306
  flush_stream.call("", force_partial: true) unless stream_pending.empty?
1300
1307
  return ["", nil, :timeout]
1301
1308
  end
1302
- sleep 0.05
1309
+ sleep MARKER_POLL_SECONDS
1303
1310
  end
1304
1311
  end
1305
1312
 
@@ -131,6 +131,7 @@ module Clacky
131
131
  @metadata = {}
132
132
  @last_chunk_at = nil
133
133
  @monitor = Monitor.new
134
+ @closed_cond = @monitor.new_cond
134
135
  end
135
136
 
136
137
  # Start rendering. Registers with the owner (allocating an entry id
@@ -185,7 +186,10 @@ module Clacky
185
186
  snapshot = @monitor.synchronize do
186
187
  return if @unregistered
187
188
  first_close = @state == :running
188
- @state = :closed if first_close
189
+ if first_close
190
+ @state = :closed
191
+ @closed_cond.broadcast
192
+ end
189
193
  {
190
194
  first_close: first_close,
191
195
  message: final_message || @message,
@@ -205,6 +209,21 @@ module Clacky
205
209
  end
206
210
  alias_method :cancel, :finish
207
211
 
212
+ # Close without leaving any trace: the owner removes the entry instead
213
+ # of committing a final frame. For work whose outcome was "nothing to
214
+ # report", where a lingering line is pure noise.
215
+ def discard
216
+ @monitor.synchronize do
217
+ return if @unregistered
218
+ @state = :closed
219
+ @closed_cond.broadcast
220
+ end
221
+
222
+ stop_ticker
223
+ @owner.unregister_progress(self, final_frame: nil)
224
+ @monitor.synchronize { @unregistered = true }
225
+ end
226
+
208
227
  # True while the ticker thread is alive.
209
228
  def ticker_alive?
210
229
  t = @ticker
@@ -261,8 +280,13 @@ module Clacky
261
280
  Thread.current.name = "progress-ticker-#{object_id}"
262
281
  begin
263
282
  loop do
264
- sleep @tick_interval
265
- break if @monitor.synchronize { @state != :running }
283
+ # wait, not sleep: finish/discard signals the condition so a
284
+ # long tick_interval never delays shutdown.
285
+ closed = @monitor.synchronize do
286
+ @closed_cond.wait(@tick_interval) if @state == :running
287
+ @state != :running
288
+ end
289
+ break if closed
266
290
  render_now
267
291
  end
268
292
  rescue StandardError