openclacky 1.3.3 → 1.3.5

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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/docs/rich_ui_guide.md +277 -0
  4. data/docs/rich_ui_refactor_plan.md +396 -0
  5. data/lib/clacky/agent/fake_tool_call_detector.rb +52 -0
  6. data/lib/clacky/agent/llm_caller.rb +10 -4
  7. data/lib/clacky/agent/session_serializer.rb +5 -3
  8. data/lib/clacky/agent/tool_executor.rb +0 -12
  9. data/lib/clacky/agent.rb +77 -11
  10. data/lib/clacky/agent_config.rb +2 -14
  11. data/lib/clacky/api_extension.rb +343 -0
  12. data/lib/clacky/api_extension_loader.rb +168 -0
  13. data/lib/clacky/cli.rb +93 -3
  14. data/lib/clacky/client.rb +47 -25
  15. data/lib/clacky/default_agents/_panels/git/panel.js +1 -1
  16. data/lib/clacky/default_agents/_panels/time_machine/panel.js +23 -1
  17. data/lib/clacky/default_agents/base_prompt.md +1 -0
  18. data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
  19. data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
  20. data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
  21. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
  22. data/lib/clacky/default_skills/media-gen/SKILL.md +72 -6
  23. data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
  24. data/lib/clacky/idle_compression_timer.rb +3 -1
  25. data/lib/clacky/json_ui_controller.rb +1 -1
  26. data/lib/clacky/locales/en.rb +26 -0
  27. data/lib/clacky/locales/i18n.rb +26 -0
  28. data/lib/clacky/locales/zh.rb +26 -0
  29. data/lib/clacky/media/base.rb +60 -0
  30. data/lib/clacky/media/dashscope.rb +385 -21
  31. data/lib/clacky/media/gemini.rb +9 -0
  32. data/lib/clacky/media/generator.rb +52 -0
  33. data/lib/clacky/media/openai_compat.rb +166 -0
  34. data/lib/clacky/null_ui_controller.rb +13 -0
  35. data/lib/clacky/plain_ui_controller.rb +1 -1
  36. data/lib/clacky/providers.rb +50 -2
  37. data/lib/clacky/rich_ui/components/base_component.rb +50 -0
  38. data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
  39. data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
  40. data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
  41. data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
  42. data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
  43. data/lib/clacky/rich_ui/components/status_view.rb +58 -0
  44. data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
  45. data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
  46. data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
  47. data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
  48. data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
  49. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
  50. data/lib/clacky/rich_ui/view_renderer.rb +291 -0
  51. data/lib/clacky/rich_ui.rb +57 -0
  52. data/lib/clacky/rich_ui_controller.rb +3 -1549
  53. data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
  54. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  55. data/lib/clacky/server/http_server.rb +291 -109
  56. data/lib/clacky/server/session_registry.rb +5 -3
  57. data/lib/clacky/server/web_ui_controller.rb +3 -2
  58. data/lib/clacky/shell_hook_loader.rb +1 -1
  59. data/lib/clacky/skill_loader.rb +14 -2
  60. data/lib/clacky/tools/edit.rb +14 -2
  61. data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
  62. data/lib/clacky/tools/terminal.rb +0 -43
  63. data/lib/clacky/ui2/components/modal_component.rb +1 -1
  64. data/lib/clacky/ui2/ui_controller.rb +147 -31
  65. data/lib/clacky/ui_interface.rb +10 -1
  66. data/lib/clacky/utils/encoding.rb +25 -0
  67. data/lib/clacky/version.rb +1 -1
  68. data/lib/clacky/web/app.css +201 -81
  69. data/lib/clacky/web/app.js +65 -7
  70. data/lib/clacky/web/components/onboard.js +19 -16
  71. data/lib/clacky/web/core/aside.js +8 -3
  72. data/lib/clacky/web/core/ext.js +1 -1
  73. data/lib/clacky/web/features/brand/view.js +8 -5
  74. data/lib/clacky/web/features/channels/store.js +1 -20
  75. data/lib/clacky/web/features/mcp/store.js +1 -20
  76. data/lib/clacky/web/features/profile/store.js +1 -13
  77. data/lib/clacky/web/features/profile/view.js +16 -4
  78. data/lib/clacky/web/features/skills/store.js +36 -23
  79. data/lib/clacky/web/features/skills/view.js +32 -1
  80. data/lib/clacky/web/features/version/store.js +2 -0
  81. data/lib/clacky/web/features/workspace/view.js +1 -1
  82. data/lib/clacky/web/i18n.js +48 -13
  83. data/lib/clacky/web/index.html +24 -17
  84. data/lib/clacky/web/sessions.js +427 -79
  85. data/lib/clacky/web/settings.js +143 -113
  86. data/lib/clacky/web/ws-dispatcher.js +18 -6
  87. data/lib/clacky.rb +27 -5
  88. metadata +45 -2
  89. data/lib/clacky/media/output_dir.rb +0 -43
data/lib/clacky/agent.rb CHANGED
@@ -5,6 +5,7 @@ require "json"
5
5
  require "cgi"
6
6
  require "tty-prompt"
7
7
  require "set"
8
+ require_relative "null_ui_controller"
8
9
  require_relative "utils/arguments_parser"
9
10
  require_relative "utils/file_processor"
10
11
  require_relative "utils/environment_detector"
@@ -23,6 +24,7 @@ require_relative "agent/memory_updater"
23
24
  require_relative "agent/skill_evolution"
24
25
  require_relative "agent/skill_reflector"
25
26
  require_relative "agent/skill_auto_creator"
27
+ require_relative "agent/fake_tool_call_detector"
26
28
 
27
29
  module Clacky
28
30
  class Agent
@@ -39,6 +41,7 @@ module Clacky
39
41
  include SkillEvolution
40
42
  include SkillReflector
41
43
  include SkillAutoCreator
44
+ include FakeToolCallDetector
42
45
 
43
46
  attr_reader :session_id, :name, :history, :iterations, :total_cost, :working_dir, :created_at, :total_tasks, :todos,
44
47
  :cache_stats, :cost_source, :ui, :skill_loader, :agent_profile,
@@ -252,7 +255,7 @@ module Clacky
252
255
  @name = new_name.to_s.strip
253
256
  end
254
257
 
255
- def run(user_input, files: [], display_text: nil)
258
+ def run(user_input, files: [], display_text: nil, created_at: nil)
256
259
  # Show the "thinking" indicator as early as possible so the user gets
257
260
  # immediate feedback after sending a message. Without this the UI stays
258
261
  # silent during synchronous setup work (system prompt assembly, file
@@ -274,6 +277,7 @@ module Clacky
274
277
  else
275
278
  @start_time = Time.now
276
279
  @task_truncation_count = 0 # Reset truncation counter for each task
280
+ @task_fake_tool_call_count = 0 # Reset fake tool-call counter for each task
277
281
  @task_timeout_hint_injected = false # Reset read-timeout hint injection (see LlmCaller)
278
282
  @task_upstream_truncation_hint_injected = false # Reset upstream-truncation hint injection (see LlmCaller)
279
283
  @task_cost_source = :estimated # Reset for new task
@@ -361,7 +365,8 @@ module Clacky
361
365
  preview_path: f[:preview_path] || f["preview_path"] }
362
366
  end
363
367
 
364
- @history.append({ role: "user", content: user_content, task_id: task_id, created_at: Time.now.to_f,
368
+ created_at ||= Time.now.to_f
369
+ @history.append({ role: "user", content: user_content, task_id: task_id, created_at: created_at,
365
370
  display_text: display_text,
366
371
  display_files: display_files.empty? ? nil : display_files })
367
372
  @total_tasks += 1
@@ -487,6 +492,18 @@ module Clacky
487
492
  Clacky::Logger.warn("agent.think_response.log_failed", error: e.message)
488
493
  end
489
494
 
495
+ # Detect fake tool-calls written as XML/text in content (model bug
496
+ # where it emits `<invoke name="...">` instead of using the
497
+ # structured tool_calls field). Only triggers when tool_calls is
498
+ # absent — a real call alongside stray XML is not our problem here.
499
+ if (response[:tool_calls].nil? || response[:tool_calls].empty?) &&
500
+ fake_tool_call_in_content?(response[:content])
501
+ case handle_fake_tool_call(response)
502
+ when :retry then next
503
+ when :stop then break
504
+ end
505
+ end
506
+
490
507
  # Check if done (no more tool calls needed).
491
508
  #
492
509
  # Defensive rule: we ONLY exit on empty/missing tool_calls.
@@ -948,10 +965,9 @@ module Clacky
948
965
  end
949
966
 
950
967
  # Special handling for request_user_feedback
951
- if call[:name] == "request_user_feedback"
952
- # In auto_approve mode, give user time to see and cancel before auto-answering
953
- auto_approve_countdown(seconds: 10) if @config.permission_mode == :auto_approve
954
- else
968
+ # The interactive countdown (auto_approve) is handled after the tool
969
+ # executes, once the question itself has been rendered to the user.
970
+ unless call[:name] == "request_user_feedback"
955
971
  @ui&.show_tool_call(call[:name], redact_tool_args(call[:arguments]))
956
972
  end
957
973
 
@@ -1036,11 +1052,26 @@ module Clacky
1036
1052
  @ui&.show_tool_call(call[:name], call[:arguments])
1037
1053
 
1038
1054
  if @config.permission_mode == :auto_approve
1039
- # auto_approve means no human is watching (unattended/scheduled tasks).
1040
- # Inject an auto_reply so the LLM makes a reasonable decision and keeps going.
1041
- result = result.merge(
1042
- auto_reply: "No user is available. Please make a reasonable decision based on the context and continue."
1043
- )
1055
+ # auto_approve means the agent runs unattended by default, but a
1056
+ # human MAY be watching the terminal. Show a short interactive
1057
+ # countdown: if the user steps in, hand control over and wait for
1058
+ # their answer; otherwise auto-decide and keep going.
1059
+ countdown = @ui&.request_feedback_with_countdown(seconds: 10)
1060
+
1061
+ if @ui.nil? || countdown == :timeout
1062
+ result = result.merge(
1063
+ auto_reply: "No user is available. Please make a reasonable decision based on the context and continue."
1064
+ )
1065
+ elsif countdown.is_a?(String) && !countdown.strip.empty?
1066
+ # User stepped in and typed an answer right away. Route it through
1067
+ # the denied+feedback path so the agent responds to it immediately
1068
+ # instead of breaking and forcing the user to re-type.
1069
+ denied = true
1070
+ feedback = countdown
1071
+ else
1072
+ # User stepped in but gave no text — hand control back to the CLI.
1073
+ awaiting_feedback = true
1074
+ end
1044
1075
  else
1045
1076
  # confirm_all / confirm_safes — a human is present, truly wait for user input.
1046
1077
  awaiting_feedback = true
@@ -1287,6 +1318,41 @@ module Clacky
1287
1318
  @tool_registry.register(Tools::Browser.new)
1288
1319
  end
1289
1320
 
1321
+ # Run a one-off task on a forked subagent and return its final reply text,
1322
+ # WITHOUT mutating this (parent) agent's history. Used by extensions that
1323
+ # need a side analysis (e.g. meeting annotate) which must reuse the parent's
1324
+ # cached context + unified billing, but must NOT pollute the main conversation.
1325
+ #
1326
+ # The subagent deep-clones the parent history (cache prefix + task state), runs
1327
+ # to completion, and is discarded. Only the cost is merged back into the parent.
1328
+ #
1329
+ # @param task [String] The task/prompt for the subagent
1330
+ # @param model [String, nil] Model name ("lite" for the lite companion, nil = current)
1331
+ # @param forbidden_tools [Array<String>] Tool names to block at runtime
1332
+ # @return [String] Subagent's final assistant reply (empty string if none)
1333
+ def run_detached(task, model: nil, forbidden_tools: [])
1334
+ subagent = fork_subagent(
1335
+ model: model,
1336
+ forbidden_tools: forbidden_tools,
1337
+ system_prompt_suffix: "You are running a one-off background analysis. Do the task and return only the requested output. Do not ask follow-up questions."
1338
+ )
1339
+ # Detached runs must stay invisible: a real UI (e.g. WebUIController bound
1340
+ # to the parent's session_id) would broadcast the subagent's raw output
1341
+ # into the parent chat transcript. Swap in a no-op UI so nothing leaks.
1342
+ subagent.instance_variable_set(:@ui, NullUIController.new)
1343
+ parent_count = subagent.instance_variable_get(:@parent_message_count) || 0
1344
+ result = subagent.run(task)
1345
+
1346
+ @total_cost += result[:total_cost_usd] || 0.0
1347
+
1348
+ new_messages = subagent.history.to_a[parent_count..] || []
1349
+ new_messages
1350
+ .reverse
1351
+ .find { |m| m[:role] == "assistant" && m[:content] && !m[:content].to_s.empty? }
1352
+ &.dig(:content)
1353
+ .to_s
1354
+ end
1355
+
1290
1356
  # Fork a subagent with specified configuration
1291
1357
  # The subagent inherits all messages and tools from parent agent
1292
1358
  # Tools are not modified (for cache reuse), but forbidden tools are blocked at runtime via hooks
@@ -165,8 +165,7 @@ module Clacky
165
165
  :memory_update_enabled, :skill_evolution,
166
166
  :max_running_agents, :max_idle_agents,
167
167
  :default_working_dir,
168
- :proxy_url,
169
- :media_output_dir
168
+ :proxy_url
170
169
 
171
170
  def initialize(options = {})
172
171
  @permission_mode = validate_permission_mode(options[:permission_mode])
@@ -224,15 +223,6 @@ module Clacky
224
223
  # a proxy. Leave nil to go direct.
225
224
  @proxy_url = options[:proxy_url]
226
225
 
227
- # User-configured directory where generated images / videos / audio
228
- # land when a /api/media/* call doesn't pass an explicit output_dir.
229
- # Final on-disk path is `<media_output_dir>/assets/generated/<file>`
230
- # (the `assets/generated/` suffix is fixed by Media::Base for stable
231
- # markdown/relative-path semantics across docs).
232
- # Leave nil → fall back to Dir.pwd (legacy behavior, preserved for
233
- # older configs that have no key set).
234
- @media_output_dir = options[:media_output_dir]
235
-
236
226
  # Per-session virtual model overlay.
237
227
  # When set, #current_model returns a *merged* hash (the resolved @models
238
228
  # entry merged with this overlay) without mutating the shared @models
@@ -425,7 +415,6 @@ module Clacky
425
415
  skill_evolution max_running_agents max_idle_agents
426
416
  default_working_dir
427
417
  proxy_url
428
- media_output_dir
429
418
  ].freeze
430
419
 
431
420
  # Serialize the current agent configuration to YAML.
@@ -445,8 +434,7 @@ module Clacky
445
434
  "max_running_agents" => @max_running_agents,
446
435
  "max_idle_agents" => @max_idle_agents,
447
436
  "default_working_dir" => @default_working_dir,
448
- "proxy_url" => @proxy_url,
449
- "media_output_dir" => @media_output_dir
437
+ "proxy_url" => @proxy_url
450
438
  }
451
439
  YAML.dump("settings" => settings, "models" => persistable_models)
452
440
  end
@@ -0,0 +1,343 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Clacky
7
+ # Base class for user-defined HTTP API extensions loaded from
8
+ # ~/.clacky/api_ext/<name>/handler.rb. Subclasses use a tiny route DSL
9
+ # (get/post/put/patch/delete) to expose endpoints under
10
+ # /api/ext/<name>/<sub-path>
11
+ #
12
+ # The framework wires up access-key auth, timeouts, JSON error envelopes,
13
+ # path-parameter parsing, and a curated handler context — extension authors
14
+ # only fill in business logic.
15
+ #
16
+ # Minimal example (~/.clacky/api_ext/my-dashboard/handler.rb):
17
+ #
18
+ # class MyDashboardExt < Clacky::ApiExtension
19
+ # get "/summary" do
20
+ # json(sessions: session_manager.list.size)
21
+ # end
22
+ # end
23
+ #
24
+ # Mounted automatically at: GET /api/ext/my-dashboard/summary
25
+ class ApiExtension
26
+ HTTP_METHODS = %i[get post put patch delete].freeze
27
+ MAX_TIMEOUT = 600
28
+ DEFAULT_TIMEOUT = 10
29
+
30
+ Route = Struct.new(:method, :pattern, :regex, :param_names, :block, :options, keyword_init: true)
31
+
32
+ class Halt < StandardError
33
+ attr_reader :status, :payload, :content_type
34
+
35
+ def initialize(status, payload, content_type)
36
+ super("api_ext halt #{status}")
37
+ @status = status
38
+ @payload = payload
39
+ @content_type = content_type
40
+ end
41
+ end
42
+
43
+ class << self
44
+ # Registry of all loaded ApiExtension subclasses, keyed by extension id
45
+ # (== directory name == mount prefix segment).
46
+ def registry
47
+ @registry ||= {}
48
+ end
49
+
50
+ def register(ext_id, klass)
51
+ registry[ext_id] = klass
52
+ end
53
+
54
+ def reset_registry!
55
+ @registry = {}
56
+ @pending_subclasses = []
57
+ end
58
+
59
+ # Captures every subclass at the moment its `class` body finishes being
60
+ # required — the loader pops the most recent one off this list to bind
61
+ # an ext_id/dir without relying on ObjectSpace scans.
62
+ def pending_subclasses
63
+ @pending_subclasses ||= []
64
+ end
65
+
66
+ def inherited(subclass)
67
+ super
68
+ Clacky::ApiExtension.pending_subclasses << subclass
69
+ end
70
+
71
+ # Per-subclass state — inherited classes carry their own routes/options.
72
+ def routes
73
+ @routes ||= []
74
+ end
75
+
76
+ def class_timeout
77
+ @class_timeout
78
+ end
79
+
80
+ def public_paths
81
+ @public_paths ||= []
82
+ end
83
+
84
+ def ext_id
85
+ @ext_id
86
+ end
87
+
88
+ def ext_id=(value)
89
+ @ext_id = value
90
+ end
91
+
92
+ def ext_dir
93
+ @ext_dir
94
+ end
95
+
96
+ def ext_dir=(value)
97
+ @ext_dir = value
98
+ end
99
+
100
+ def meta
101
+ @meta ||= {}
102
+ end
103
+
104
+ def meta=(value)
105
+ @meta = value || {}
106
+ end
107
+
108
+ # Set a default timeout (seconds) for every handler in this class.
109
+ # Per-route override available via `get "/x", timeout: 30 do ... end`.
110
+ def timeout(seconds)
111
+ raise ArgumentError, "timeout must be > 0" unless seconds.is_a?(Numeric) && seconds > 0
112
+ raise ArgumentError, "timeout exceeds MAX_TIMEOUT (#{MAX_TIMEOUT}s)" if seconds > MAX_TIMEOUT
113
+
114
+ @class_timeout = seconds.to_f
115
+ end
116
+
117
+ # Mark a route as not requiring access-key auth. Caller must also
118
+ # declare `public: true` in meta.yml for the framework to honor this.
119
+ def public_endpoint(pattern)
120
+ public_paths << normalize_pattern(pattern)
121
+ end
122
+
123
+ HTTP_METHODS.each do |verb|
124
+ define_method(verb) do |pattern, **opts, &block|
125
+ raise ArgumentError, "missing handler block for #{verb.upcase} #{pattern}" unless block
126
+
127
+ per_route_timeout = opts[:timeout]
128
+ if per_route_timeout
129
+ raise ArgumentError, "timeout must be > 0" unless per_route_timeout.is_a?(Numeric) && per_route_timeout > 0
130
+ raise ArgumentError, "timeout exceeds MAX_TIMEOUT (#{MAX_TIMEOUT}s)" if per_route_timeout > MAX_TIMEOUT
131
+ end
132
+
133
+ normalized = normalize_pattern(pattern)
134
+ regex, param_names = compile_pattern(normalized)
135
+ routes << Route.new(
136
+ method: verb,
137
+ pattern: normalized,
138
+ regex: regex,
139
+ param_names: param_names,
140
+ block: block,
141
+ options: opts.dup
142
+ )
143
+ end
144
+ end
145
+
146
+ def normalize_pattern(pattern)
147
+ pattern = pattern.to_s
148
+ pattern = "/#{pattern}" unless pattern.start_with?("/")
149
+ pattern = pattern.chomp("/")
150
+ pattern.empty? ? "/" : pattern
151
+ end
152
+
153
+ def compile_pattern(pattern)
154
+ param_names = []
155
+ regex_str = pattern.gsub(%r{:([a-zA-Z_][a-zA-Z0-9_]*)}) do |_match|
156
+ param_names << Regexp.last_match(1).to_sym
157
+ "([^/]+)"
158
+ end
159
+ [Regexp.new("\\A#{regex_str}\\z"), param_names]
160
+ end
161
+ end
162
+
163
+ attr_reader :req, :res, :route, :params
164
+
165
+ def initialize(req:, res:, route:, params:, http_server:)
166
+ @req = req
167
+ @res = res
168
+ @route = route
169
+ @params = params
170
+ @http_server = http_server
171
+ end
172
+
173
+ def invoke
174
+ instance_exec(&route.block)
175
+ end
176
+
177
+ # ---- handler context (white-listed access to host process) ----
178
+
179
+ def json(*args, **kwargs)
180
+ if args.empty?
181
+ # Treat kwargs as the body: json(foo: 1, bar: 2)
182
+ # For non-200 status, pass an explicit hash: json({foo: 1}, status: 422)
183
+ raise Halt.new(200, JSON.generate(kwargs), "application/json; charset=utf-8")
184
+ elsif args.size == 1
185
+ status = kwargs[:status] || 200
186
+ raise Halt.new(status, JSON.generate(args[0]), "application/json; charset=utf-8")
187
+ else
188
+ raise ArgumentError, "json: expected (hash) or (key: value, ...)"
189
+ end
190
+ end
191
+
192
+ def text(str, status: 200)
193
+ raise Halt.new(status, str.to_s, "text/plain; charset=utf-8")
194
+ end
195
+
196
+ def error!(message, status: 400, **extra)
197
+ payload = { error: message.to_s }
198
+ payload.merge!(extra) unless extra.empty?
199
+ raise Halt.new(status, JSON.generate(payload), "application/json; charset=utf-8")
200
+ end
201
+
202
+ def json_body
203
+ @json_body ||= begin
204
+ return {} if req.body.nil? || req.body.empty?
205
+ JSON.parse(req.body)
206
+ rescue JSON::ParserError
207
+ {}
208
+ end
209
+ end
210
+
211
+ def query
212
+ @query ||= req.query || {}
213
+ end
214
+
215
+ def data_path(*parts)
216
+ base = File.join(self.class.ext_dir, "data")
217
+ FileUtils.mkdir_p(base)
218
+ File.join(base, *parts.map(&:to_s))
219
+ end
220
+
221
+ def ext_dir
222
+ self.class.ext_dir
223
+ end
224
+
225
+ def ext_id
226
+ self.class.ext_id
227
+ end
228
+
229
+ def config
230
+ self.class.meta["config"] || {}
231
+ end
232
+
233
+ def session_manager
234
+ @http_server&.instance_variable_get(:@session_manager)
235
+ end
236
+
237
+ def agent_config
238
+ @http_server&.instance_variable_get(:@agent_config)
239
+ end
240
+
241
+ def registry
242
+ @http_server&.instance_variable_get(:@registry)
243
+ end
244
+
245
+ # Create a brand-new session and optionally kick off its first task.
246
+ # Returns the new session_id. When a prompt is given, the task is
247
+ # submitted immediately (the session starts running); display_message
248
+ # controls the user-facing bubble shown in place of the raw prompt.
249
+ def create_session(name: nil, prompt: nil, working_dir: nil, profile: "general",
250
+ source: :manual, display_message: nil)
251
+ error!("server not ready", status: 503) unless @http_server
252
+
253
+ session_id = @http_server.send(
254
+ :build_session,
255
+ name: name,
256
+ working_dir: working_dir,
257
+ profile: profile,
258
+ source: source
259
+ )
260
+
261
+ submit_task(session_id, prompt, display_message: display_message) if prompt && !prompt.strip.empty?
262
+
263
+ session_id
264
+ end
265
+
266
+ # Submit a prompt to an existing session for execution.
267
+ # The session must be idle; returns the session_id on success.
268
+ # Raises Halt (409) if the session is already running.
269
+ def submit_task(session_id, prompt, display_message: nil)
270
+ reg = registry
271
+ error!("server not ready", status: 503) unless reg
272
+
273
+ unless reg.exist?(session_id)
274
+ reg.ensure(session_id)
275
+ error!("session not found: #{session_id}", status: 404) unless reg.exist?(session_id)
276
+ end
277
+
278
+ session = reg.get(session_id)
279
+ error!("session is busy", status: 409) if session[:status] == :running
280
+
281
+ @http_server.send(:run_session_task, session_id, prompt, display_message: display_message)
282
+ session_id
283
+ end
284
+
285
+ # Run a one-off side task on an existing session's agent and return its
286
+ # reply text SYNCHRONOUSLY, without polluting the main conversation.
287
+ #
288
+ # Unlike submit_task (which enqueues a turn into the live conversation and
289
+ # returns immediately), this forks the session's agent — reusing its cached
290
+ # context and unified billing — runs the task to completion on the fork, and
291
+ # returns the fork's final reply. The main conversation is never touched.
292
+ #
293
+ # Strategy A (parent-busy → skip): if the session is currently running, or the
294
+ # server is at its concurrency limit, this returns { busy: true } without
295
+ # running. Callers (e.g. periodic analysis) should treat that as "try later".
296
+ #
297
+ # @param session_id [String]
298
+ # @param prompt [String]
299
+ # @param model [String, nil] "lite" for the lite companion, nil = current
300
+ # @param forbidden_tools [Array<String>] tool names blocked in the fork
301
+ # @return [Hash] { text: "..." } on success, or { busy: true } when skipped
302
+ def dispatch_to_session(session_id, prompt, model: nil, forbidden_tools: [])
303
+ reg = registry
304
+ error!("server not ready", status: 503) unless reg
305
+
306
+ unless reg.exist?(session_id)
307
+ reg.ensure(session_id)
308
+ error!("session not found: #{session_id}", status: 404) unless reg.exist?(session_id)
309
+ end
310
+
311
+ return { busy: true } if reg.respond_to?(:running_full?) && reg.running_full?
312
+
313
+ session = reg.get(session_id)
314
+ return { busy: true } if session[:status] == :running
315
+
316
+ agent = session[:agent]
317
+ error!("session agent not available", status: 503) unless agent
318
+
319
+ { text: agent.run_detached(prompt, model: model, forbidden_tools: forbidden_tools) }
320
+ end
321
+
322
+ def server_start_time
323
+ @http_server&.instance_variable_get(:@start_time)
324
+ end
325
+
326
+ def logger
327
+ @logger ||= ScopedLogger.new(self.class.ext_id)
328
+ end
329
+
330
+ # Lightweight wrapper that prefixes log lines with the extension id.
331
+ class ScopedLogger
332
+ def initialize(ext_id)
333
+ @prefix = "[api_ext:#{ext_id}]"
334
+ end
335
+
336
+ %i[debug info warn error].each do |level|
337
+ define_method(level) do |msg|
338
+ Clacky::Logger.public_send(level, "#{@prefix} #{msg}")
339
+ end
340
+ end
341
+ end
342
+ end
343
+ end