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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -0
- data/docs/rich_ui_guide.md +277 -0
- data/docs/rich_ui_refactor_plan.md +396 -0
- data/lib/clacky/agent/fake_tool_call_detector.rb +52 -0
- data/lib/clacky/agent/llm_caller.rb +10 -4
- data/lib/clacky/agent/session_serializer.rb +5 -3
- data/lib/clacky/agent/tool_executor.rb +0 -12
- data/lib/clacky/agent.rb +77 -11
- data/lib/clacky/agent_config.rb +2 -14
- data/lib/clacky/api_extension.rb +343 -0
- data/lib/clacky/api_extension_loader.rb +168 -0
- data/lib/clacky/cli.rb +93 -3
- data/lib/clacky/client.rb +47 -25
- data/lib/clacky/default_agents/_panels/git/panel.js +1 -1
- data/lib/clacky/default_agents/_panels/time_machine/panel.js +23 -1
- data/lib/clacky/default_agents/base_prompt.md +1 -0
- data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
- data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
- data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
- data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
- data/lib/clacky/default_skills/media-gen/SKILL.md +72 -6
- data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
- data/lib/clacky/idle_compression_timer.rb +3 -1
- data/lib/clacky/json_ui_controller.rb +1 -1
- data/lib/clacky/locales/en.rb +26 -0
- data/lib/clacky/locales/i18n.rb +26 -0
- data/lib/clacky/locales/zh.rb +26 -0
- data/lib/clacky/media/base.rb +60 -0
- data/lib/clacky/media/dashscope.rb +385 -21
- data/lib/clacky/media/gemini.rb +9 -0
- data/lib/clacky/media/generator.rb +52 -0
- data/lib/clacky/media/openai_compat.rb +166 -0
- data/lib/clacky/null_ui_controller.rb +13 -0
- data/lib/clacky/plain_ui_controller.rb +1 -1
- data/lib/clacky/providers.rb +50 -2
- data/lib/clacky/rich_ui/components/base_component.rb +50 -0
- data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
- data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
- data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
- data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
- data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
- data/lib/clacky/rich_ui/components/status_view.rb +58 -0
- data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
- data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
- data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
- data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
- data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
- data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
- data/lib/clacky/rich_ui/view_renderer.rb +291 -0
- data/lib/clacky/rich_ui.rb +57 -0
- data/lib/clacky/rich_ui_controller.rb +3 -1549
- data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
- data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
- data/lib/clacky/server/http_server.rb +291 -109
- data/lib/clacky/server/session_registry.rb +5 -3
- data/lib/clacky/server/web_ui_controller.rb +3 -2
- data/lib/clacky/shell_hook_loader.rb +1 -1
- data/lib/clacky/skill_loader.rb +14 -2
- data/lib/clacky/tools/edit.rb +14 -2
- data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
- data/lib/clacky/tools/terminal.rb +0 -43
- data/lib/clacky/ui2/components/modal_component.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +147 -31
- data/lib/clacky/ui_interface.rb +10 -1
- data/lib/clacky/utils/encoding.rb +25 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +201 -81
- data/lib/clacky/web/app.js +65 -7
- data/lib/clacky/web/components/onboard.js +19 -16
- data/lib/clacky/web/core/aside.js +8 -3
- data/lib/clacky/web/core/ext.js +1 -1
- data/lib/clacky/web/features/brand/view.js +8 -5
- data/lib/clacky/web/features/channels/store.js +1 -20
- data/lib/clacky/web/features/mcp/store.js +1 -20
- data/lib/clacky/web/features/profile/store.js +1 -13
- data/lib/clacky/web/features/profile/view.js +16 -4
- data/lib/clacky/web/features/skills/store.js +36 -23
- data/lib/clacky/web/features/skills/view.js +32 -1
- data/lib/clacky/web/features/version/store.js +2 -0
- data/lib/clacky/web/features/workspace/view.js +1 -1
- data/lib/clacky/web/i18n.js +48 -13
- data/lib/clacky/web/index.html +24 -17
- data/lib/clacky/web/sessions.js +427 -79
- data/lib/clacky/web/settings.js +143 -113
- data/lib/clacky/web/ws-dispatcher.js +18 -6
- data/lib/clacky.rb +27 -5
- metadata +45 -2
- 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
|
-
|
|
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
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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
|
|
1040
|
-
#
|
|
1041
|
-
|
|
1042
|
-
|
|
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
|
data/lib/clacky/agent_config.rb
CHANGED
|
@@ -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
|