openclacky 1.5.15 → 1.5.17

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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +13 -3
  3. data/CHANGELOG.md +79 -0
  4. data/README.md +12 -1
  5. data/README_CN.md +1 -1
  6. data/README_JA.md +1 -1
  7. data/lib/clacky/agent/history_navigation.rb +1 -1
  8. data/lib/clacky/agent/hook_manager.rb +7 -3
  9. data/lib/clacky/agent/llm_caller.rb +36 -58
  10. data/lib/clacky/agent/message_compressor_helper.rb +13 -7
  11. data/lib/clacky/agent/session_serializer.rb +67 -6
  12. data/lib/clacky/agent.rb +215 -4
  13. data/lib/clacky/agent_config.rb +21 -73
  14. data/lib/clacky/billing/platform_billing.rb +12 -2
  15. data/lib/clacky/cli.rb +12 -0
  16. data/lib/clacky/client.rb +51 -22
  17. data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +1 -1
  18. data/lib/clacky/default_extensions/computer-use/ext.yml +15 -0
  19. data/lib/clacky/default_extensions/computer-use/lib/computer/geometry.rb +134 -0
  20. data/lib/clacky/default_extensions/computer-use/lib/computer/grid.rb +214 -0
  21. data/lib/clacky/default_extensions/computer-use/lib/computer/keycodes.rb +100 -0
  22. data/lib/clacky/default_extensions/computer-use/lib/computer/macos.rb +557 -0
  23. data/lib/clacky/default_extensions/computer-use/lib/computer/windows.rb +299 -0
  24. data/lib/clacky/default_extensions/computer-use/lib/computer/windows_agent.ps1 +436 -0
  25. data/lib/clacky/default_extensions/computer-use/lib/computer/windows_keycodes.rb +119 -0
  26. data/lib/clacky/default_extensions/computer-use/skills/computer-use/SKILL.md +143 -0
  27. data/lib/clacky/default_extensions/computer-use/skills/computer-use/bin/computer.rb +547 -0
  28. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +13 -19
  29. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +2 -0
  30. data/lib/clacky/default_extensions/meeting/ext.yml +1 -0
  31. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +2 -0
  32. data/lib/clacky/default_skills/browser-setup/SKILL.md +2 -0
  33. data/lib/clacky/default_skills/channel-manager/SKILL.md +2 -0
  34. data/lib/clacky/default_skills/channel-manager/install_feishu_skills.rb +1 -2
  35. data/lib/clacky/default_skills/code-explorer/SKILL.md +2 -0
  36. data/lib/clacky/default_skills/cron-task-creator/SKILL.md +2 -0
  37. data/lib/clacky/default_skills/deploy/SKILL.md +3 -1
  38. data/lib/clacky/default_skills/mcp-manager/SKILL.md +2 -0
  39. data/lib/clacky/default_skills/media-gen/SKILL.md +2 -0
  40. data/lib/clacky/default_skills/new/SKILL.md +2 -0
  41. data/lib/clacky/default_skills/onboard/SKILL.md +2 -0
  42. data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +1 -2
  43. data/lib/clacky/default_skills/persist-memory/SKILL.md +2 -0
  44. data/lib/clacky/default_skills/personal-website/SKILL.md +2 -0
  45. data/lib/clacky/default_skills/personal-website/publish.rb +6 -10
  46. data/lib/clacky/default_skills/product-help/SKILL.md +2 -0
  47. data/lib/clacky/default_skills/recall-memory/SKILL.md +2 -0
  48. data/lib/clacky/default_skills/search-skills/SKILL.md +2 -0
  49. data/lib/clacky/default_skills/skill-add/SKILL.md +2 -0
  50. data/lib/clacky/default_skills/skill-creator/SKILL.md +2 -0
  51. data/lib/clacky/extension/loader.rb +102 -25
  52. data/lib/clacky/extension/packager.rb +3 -20
  53. data/lib/clacky/extension/verifier.rb +1 -1
  54. data/lib/clacky/locales/en.rb +35 -3
  55. data/lib/clacky/locales/i18n.rb +16 -2
  56. data/lib/clacky/locales/zh.rb +36 -4
  57. data/lib/clacky/media/base.rb +2 -2
  58. data/lib/clacky/media/generator.rb +2 -2
  59. data/lib/clacky/message_format/open_ai.rb +28 -0
  60. data/lib/clacky/platform_http_client.rb +52 -117
  61. data/lib/clacky/providers.rb +190 -148
  62. data/lib/clacky/rich_ui/view_renderer.rb +3 -2
  63. data/lib/clacky/server/browser_manager.rb +14 -0
  64. data/lib/clacky/server/channel/adapters/base.rb +19 -0
  65. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +76 -2
  66. data/lib/clacky/server/channel/adapters/feishu/bot.rb +546 -0
  67. data/lib/clacky/server/channel/adapters/feishu/ws_client.rb +48 -13
  68. data/lib/clacky/server/channel/channel_config.rb +21 -4
  69. data/lib/clacky/server/channel/channel_manager.rb +15 -30
  70. data/lib/clacky/server/channel/channel_ui_controller.rb +264 -17
  71. data/lib/clacky/server/http_server.rb +183 -103
  72. data/lib/clacky/server/scheduler.rb +117 -2
  73. data/lib/clacky/server/server_master.rb +17 -7
  74. data/lib/clacky/server/session_registry.rb +0 -4
  75. data/lib/clacky/server/web_ui_controller.rb +2 -1
  76. data/lib/clacky/shell_hook_loader.rb +3 -3
  77. data/lib/clacky/skill.rb +3 -0
  78. data/lib/clacky/skill_loader.rb +5 -1
  79. data/lib/clacky/tools/ask_user.rb +3 -12
  80. data/lib/clacky/tools/file_reader.rb +9 -4
  81. data/lib/clacky/tools/terminal/session_manager.rb +3 -1
  82. data/lib/clacky/tools/terminal.rb +12 -1
  83. data/lib/clacky/ui2/ui_controller.rb +5 -5
  84. data/lib/clacky/utils/channel_prompt.rb +33 -0
  85. data/lib/clacky/utils/file_processor.rb +28 -3
  86. data/lib/clacky/utils/model_pricing.rb +95 -34
  87. data/lib/clacky/utils/resource_group.rb +67 -0
  88. data/lib/clacky/version.rb +1 -1
  89. data/lib/clacky/web/app.css +1848 -1757
  90. data/lib/clacky/web/app.js +123 -21
  91. data/lib/clacky/web/components/composer.js +130 -6
  92. data/lib/clacky/web/components/datepicker.js +11 -4
  93. data/lib/clacky/web/components/model-picker.js +181 -34
  94. data/lib/clacky/web/components/onboard.js +14 -22
  95. data/lib/clacky/web/components/quote-select.js +51 -14
  96. data/lib/clacky/web/core/ext.js +39 -1
  97. data/lib/clacky/web/features/billing/view.js +63 -17
  98. data/lib/clacky/web/features/channels/store.js +16 -1
  99. data/lib/clacky/web/features/channels/view.js +158 -148
  100. data/lib/clacky/web/features/extensions/store.js +206 -272
  101. data/lib/clacky/web/features/extensions/view.js +388 -274
  102. data/lib/clacky/web/features/mcp/store.js +1 -1
  103. data/lib/clacky/web/features/mcp/view.js +112 -119
  104. data/lib/clacky/web/features/new-session/store.js +4 -2
  105. data/lib/clacky/web/features/new-session/view.js +94 -16
  106. data/lib/clacky/web/features/profile/view.js +9 -9
  107. data/lib/clacky/web/features/share/view.js +500 -571
  108. data/lib/clacky/web/features/skills/store.js +16 -8
  109. data/lib/clacky/web/features/skills/view.js +568 -215
  110. data/lib/clacky/web/features/tasks/store.js +28 -6
  111. data/lib/clacky/web/features/tasks/view.js +314 -102
  112. data/lib/clacky/web/features/trash/view.js +4 -6
  113. data/lib/clacky/web/features/workspace/view.js +32 -0
  114. data/lib/clacky/web/i18n.js +198 -70
  115. data/lib/clacky/web/index.html +180 -134
  116. data/lib/clacky/web/projects.js +10 -3
  117. data/lib/clacky/web/sessions.js +347 -127
  118. data/lib/clacky/web/settings.js +8 -0
  119. data/lib/clacky/web/skills.js +47 -16
  120. data/lib/clacky/web/utils.js +20 -0
  121. data/lib/clacky/web/ws-dispatcher.js +26 -10
  122. data/lib/clacky.rb +16 -1
  123. metadata +13 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 878e99c7061a59dee8c50f4ec416986c33c7d93538595e90719042827a9b774e
4
- data.tar.gz: 51a940cdc2e2f25b95a6ce96d84cd7c45988154b324ca2c9962bcddf4f213fa4
3
+ metadata.gz: 23761f95ba9b1fa52c2acc37020fd4e903a92d8f49c0c97668d5cfbb6e572329
4
+ data.tar.gz: 16838f20ba7d650e15952c738c35b93ca418a8df81baaeb6f9fd0263c5754e65
5
5
  SHA512:
6
- metadata.gz: 82af0b7e1e966e3e8eafc888006a05e75f637b6d6508721432bc484394f9699cb2a82ad0f0abee73cd88214bcad6e865acf8e1840e357059a470aee5e59f00c5
7
- data.tar.gz: f996cf6c38e1070608dd49955e68c1154301152b8fc51d0e3e392055dcd51bebc21c234e0bf20fddbdb0a2a9ba48dcc527afcfd1e92e43fc2fc82a1ddbbcfa33
6
+ metadata.gz: d0a29e5ab8a0e6b21e1f2a2f36657bcb8043cfc9d904671acdead8157f082ac032c2d1f7f0a0cee16c0f72a7fdbb939020b3abfe52145a9eea722f4f4856bac2
7
+ data.tar.gz: '050389221f4f71ab6e54a6038606f0d1f48f1a6571116c5b052493362158a850fe0f1a90a4d520b35fff875315fd1292610bab104366251626d5d9fdcb5b7f54'
@@ -95,15 +95,25 @@ This is the one step the agent handles manually — the script does not write ch
95
95
  ```bash
96
96
  gh api orgs/clacky-ai/members --paginate --jq '.[].login' | sort
97
97
  ```
98
- - Then cross-reference PR authors in the release range:
98
+ - Then cross-reference PR authors in the release range. Use `grep -oE`, not `-oP` —
99
+ BSD grep on macOS rejects `-P` with "invalid option", and the loop silently yields nothing:
99
100
  ```bash
100
- git log <previous_tag>..HEAD --oneline | grep -oP '#\d+' | while read pr; do
101
+ git log <previous_tag>..HEAD --oneline | grep -oE '#[0-9]+' | while read pr; do
101
102
  num=${pr#\#}
102
103
  gh pr view $num --json number,author --jq '"#\(.number) \(.author.login)"'
103
104
  done
104
105
  ```
105
106
  - Skip attribution for org members (internal contributors) - only call out external contributors
106
- - If a PR was closed and resubmitted, use the merged PR number
107
+ - Some cited numbers no longer resolve at all. A commit subject can name a superseded PR
108
+ next to the merged one (`feat: discover custom-endpoint models in the add-model dialog
109
+ (#559) (#560)`), and `gh pr view 559` answers "Could not resolve to a PullRequest".
110
+ When that happens, ask the commit which PR actually merged it:
111
+ ```bash
112
+ gh api repos/clacky-ai/openclacky/commits/<sha>/pulls \
113
+ --jq '.[] | "PR #\(.number) | \(.user.login) | \(.state)"'
114
+ ```
115
+ Attribute the bullet to the PR that resolves and carries a real author; a number that
116
+ resolves to nobody is not a contributor reference.
107
117
  - Example: `- Todo panel with SVG icons (#437 - @shipinliang)`
108
118
 
109
119
  6. Commit the changelog:
data/CHANGELOG.md CHANGED
@@ -4,6 +4,85 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
 
7
+ ## [1.5.17] - 2026-09-30
8
+
9
+ ### Added
10
+ - Show a top-up action when a free-trial model rejects the request
11
+ - Add Requesty as a named provider preset (#587 - @Thibaultjaigu)
12
+ - Redesign the skills page around cards and a detail view
13
+ - Redesign the scheduled tasks page and keep a run history per task
14
+ - Redesign the channels page as a compact two-column list
15
+ - Rework the MCP page layout, its row menu and the remove confirmation
16
+ - Add "Add to chat" to the workspace file context menu
17
+ - Add a global switch for IM progress cards (#599)
18
+ - Show the vendor logo on the model chip of a new session
19
+ - Use Chinese names for the built-in skills
20
+
21
+ ### Improved
22
+ - Optimize the Feishu card styling (#597)
23
+ - Improve drag-and-drop for session references and files (#600)
24
+ - Restyle the channel status badge, cron rows, message settings and search boxes
25
+ - Polish the assistant memory page, onboarding page, billing header and trash toolbar
26
+ - Improve the discoverability of overflowing aside tabs (#601)
27
+ - Show skill, MCP and task errors as in-app toasts instead of browser alerts
28
+ - Localize the default extension icon letter
29
+ - Ship meeting as a default-off extension
30
+
31
+ ### Fixed
32
+ - Make an IM message interrupt the running task instead of queueing silently (#586)
33
+ - Hide the sidecar models from the model switchers (#598)
34
+ - Show the "View process" panel in Feishu only when there is process history (#596)
35
+ - Keep a subagent phase running when a message is queued (#594)
36
+ - Fix the skill install click and the extension installed state
37
+ - Strip channel-only prompt prefixes when replaying history (#585)
38
+ - Use the in-app dialog for delete confirmations instead of the browser prompt
39
+ - Toggle a sidebar ··· menu on a repeated click (#595)
40
+ - Hide the auto tag and routing tooltip once the alias is pinned to a model
41
+
42
+ ### More
43
+ - Retire Claude 4.5 / Opus 4.6 and Gemini 3.5 / 3.6 Flash from the catalogs
44
+ - Remove legacy fallback domains and dead failover paths
45
+
46
+ ## [1.5.16] - 2026-09-24
47
+
48
+ ### Added
49
+ - Add Claude Opus 5.5 and GPT-6 Sol/Luna to the model catalog
50
+ - Send task progress cards in Feishu, with collapsible process history (#582)
51
+ - Print a receipt-style usage scorecard from the billing page and share it as an image
52
+ - Understand video attachments through a sidecar model (#564)
53
+ - Open the app straight into a chat from an external clacky:// link, including the welcome view
54
+ - Route requests automatically: the auto alias picks a tier per request and reports which tier served it (#576)
55
+ - Drive the Windows desktop through WSL in computer-use
56
+ - Ship computer-use as a default-off extension
57
+ - Let on_start hooks veto a turn before the agent starts (#573 - @afumu)
58
+ - Cap the memory and CPU of a task's shells and browser daemons on Linux hosts with --task-cgroup
59
+ - Refuse to start on a taken port instead of silently moving to another one, with --strict-port
60
+ - Keep attachment paths when a compressed history chunk is replayed (#581 - @BoyYangzai)
61
+ - Point generated skills at their own directory through an inlined skill_dir
62
+
63
+ ### Improved
64
+ - Restyle the web search result card (#575)
65
+ - Use official brand marks for model vendor badges (#566)
66
+ - Refresh the supported model catalogs and pricing (#571)
67
+ - Rename the extension group label User to Personal
68
+ - Adjust the session title size and unify the sidebar active highlight
69
+ - Soften the extension row action buttons and tighten the segmented control corners
70
+
71
+ ### Fixed
72
+ - Keep queued guidance attached to the message it was sent from, and allow cancelling it (#584, #578)
73
+ - Stop treating a pasted path as a slash command (#577)
74
+ - Restore structured tool result cards when a session history is replayed (#574)
75
+ - Stop duplicating user bubbles after an edit (#568)
76
+ - Show attachment badges in realtime user bubbles (#567)
77
+ - Transcribe audio attachments through the STT sidecar (#565)
78
+ - Preserve a quote selection while a live update streams in (#569)
79
+ - Load per-key platform usage in parallel in the billing view (#580)
80
+ - Prevent subagents from publishing input queues (#570 - @afumu)
81
+ - Block model switching while a session is running instead of racing the running turn
82
+ - Hand binary files back to the OS when they are reopened
83
+ - Report the doctor host before resolving the backend
84
+ - Stop the DeepSeek preset from showing a version suffix
85
+
7
86
  ## [1.5.15] - 2026-09-17
8
87
 
9
88
  ### Added
data/README.md CHANGED
@@ -185,6 +185,17 @@ openclacky server --port 8080 # custom port
185
185
  openclacky server --host 0.0.0.0 # listen on all interfaces (remote access)
186
186
  ```
187
187
 
188
+ Linux sandbox hosts can opt into task limits with
189
+ `openclacky server --task-cgroup /sys/fs/cgroup/tasks`. The host must first create
190
+ that cgroup, configure its aggregate memory/CPU limits, and enable the `memory`
191
+ and `cpu` subtree controllers. Terminal shells and browser MCP daemons enter
192
+ child groups before exec; the server remains outside. Terminal results include
193
+ `resource_exhausted: "memory"` and an error when their group records an OOM kill,
194
+ even if the shell masks the child's failure. A separately managed Chrome must
195
+ also be placed under the same parent by its launcher. This option is off by
196
+ default and does not establish a security boundary against privileged commands.
197
+
198
+
188
199
  ## Configuration
189
200
 
190
201
  ```bash
@@ -194,7 +205,7 @@ $ openclacky
194
205
 
195
206
  Set your **API Key**, **Model**, and **Base URL** (any OpenAI-compatible provider).
196
207
 
197
- Supported out of the box: **Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter · OrcaRouter** — or any custom endpoint.
208
+ Supported out of the box: **Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter · OrcaRouter · Requesty** — or any custom endpoint.
198
209
 
199
210
  ## Coding use case
200
211
 
data/README_CN.md CHANGED
@@ -189,7 +189,7 @@ $ openclacky
189
189
 
190
190
  设置你的 **API Key**、**模型**和 **Base URL**(任意 OpenAI 兼容提供商)。
191
191
 
192
- 开箱即支持:**Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter · OrcaRouter**,或任意自定义端点。
192
+ 开箱即支持:**Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter · OrcaRouter · Requesty**,或任意自定义端点。
193
193
 
194
194
  ## 代码开发场景
195
195
 
data/README_JA.md CHANGED
@@ -194,7 +194,7 @@ $ openclacky
194
194
 
195
195
  **API Key**、**Model**、**Base URL**(任意の OpenAI 互換プロバイダー)を設定します。
196
196
 
197
- 標準でサポート: **Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter · OrcaRouter** — または任意のカスタムエンドポイント。
197
+ 標準でサポート: **Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter · OrcaRouter · Requesty** — または任意のカスタムエンドポイント。
198
198
 
199
199
  ## コーディングのユースケース
200
200
 
@@ -208,7 +208,7 @@ module Clacky
208
208
 
209
209
  private def navigation_entry(round)
210
210
  msg = round[:user_msg]
211
- user = navigation_preview(msg[:display_text] || extract_text_from_content(msg[:content]))
211
+ user = navigation_preview(msg[:display_text] || strip_channel_prompt_prefix(extract_text_from_content(msg[:content])))
212
212
  if user.empty?
213
213
  names = Array(msg[:display_files]).map { |file| file[:name] || file["name"] }
214
214
  Array(msg[:content]).each do |block|
@@ -25,7 +25,11 @@ module Clacky
25
25
  end
26
26
 
27
27
  # @return [Hash] `{action: :allow}`, `{action: :deny, reason:}`, or
28
- # `{action: :handled, result:}` when a hook fulfilled the call itself.
28
+ # `{action: :handled, result:}` when a hook fulfilled the event itself.
29
+ # on_start receives (user_input, agent) after task/history initialization.
30
+ # Its handled result is returned unchanged from Agent#run; the extension
31
+ # supplies the run-result status and any queue/feedback flags it needs.
32
+ # on_start is not fired for steering input inside an already running turn.
29
33
  # Extra trailing arg: the agent that owns this hook chain, so ext hooks can
30
34
  # call `agent.emit_event(...)`. Blocks are procs — those declaring fewer
31
35
  # params (`|call|`, `|call, result|`) silently ignore it.
@@ -43,8 +47,8 @@ module Clacky
43
47
  # place (chained rewrite), so for non-deny results there's nothing to
44
48
  # merge — we just keep going.
45
49
  #
46
- # :handled short-circuits the same way — the hook has already produced
47
- # the tool's result, so later hooks have nothing left to act on.
50
+ # :handled short-circuits the same way: the hook has already fulfilled
51
+ # the event, so later hooks have nothing left to act on.
48
52
  if hook_result[:action] == :deny || hook_result[:action] == :handled
49
53
  result = hook_result
50
54
  break
@@ -45,7 +45,7 @@ module Clacky
45
45
  # Inside call_llm we only *update in place* during retries, so the
46
46
  # already-live progress slot shows meaningful transient status
47
47
  # ("Network failed… attempt 2/10", etc.).
48
- private def call_llm
48
+ private def call_llm(agent_upstream_fails: nil, agent_upgrade_fails: nil)
49
49
  # Transition :fallback_active → :probing if cooling-off has expired.
50
50
  @config.maybe_start_probing
51
51
 
@@ -54,6 +54,12 @@ module Clacky
54
54
  max_retries = 10
55
55
  retry_delay = 5
56
56
  retries = 0
57
+ # Per-call tier attribution for auto routing: each retryable failure is
58
+ # charged to the tier the gateway actually routed that call to (echoed
59
+ # in X-Clacky-Routed-Tier). Failures without a tier (network layer,
60
+ # timeouts — the gateway never answered) are not attributed to either.
61
+ floor_fails = 0
62
+ upgrade_fails = 0
57
63
 
58
64
  # Track whether any of the retry/fallback branches below opened a
59
65
  # "retrying" progress slot via show_progress(progress_type:
@@ -111,7 +117,9 @@ module Clacky
111
117
  max_tokens: @config.max_tokens,
112
118
  enable_caching: @config.enable_prompt_caching,
113
119
  reasoning_effort: @reasoning_effort,
114
- on_chunk: build_progress_on_chunk
120
+ on_chunk: build_progress_on_chunk,
121
+ agent_upstream_fails: (agent_upstream_fails || 0) + floor_fails,
122
+ agent_upgrade_fails: (agent_upgrade_fails || 0) + upgrade_fails
115
123
  )
116
124
 
117
125
  # Successful response — if we were probing, confirm primary is healthy.
@@ -142,7 +150,7 @@ module Clacky
142
150
  finish_reason: response[:finish_reason].to_s,
143
151
  completion_tokens: response.dig(:token_usage, :completion_tokens)
144
152
  )
145
- raise RetryableError, "[LLM] Model returned empty response (no content, no tool_calls), retrying..."
153
+ raise RetryableError.new("[LLM] Model returned empty response (no content, no tool_calls), retrying...", routed_tier: response[:routed_tier])
146
154
  end
147
155
 
148
156
  # Thinking-mode silent response detector. DeepSeek V4 / Kimi K2 /
@@ -164,7 +172,7 @@ module Clacky
164
172
  reasoning_tail: reasoning_str[-200, 200] || reasoning_str,
165
173
  completion_tokens: response.dig(:token_usage, :completion_tokens)
166
174
  )
167
- raise RetryableError, "[LLM] Thinking-mode model produced reasoning but empty content/tool_calls, retrying..."
175
+ raise RetryableError.new("[LLM] Thinking-mode model produced reasoning but empty content/tool_calls, retrying...", routed_tier: response[:routed_tier])
168
176
  end
169
177
 
170
178
  rescue Faraday::TimeoutError => e
@@ -203,21 +211,7 @@ module Clacky
203
211
  Clacky::Shutdown.sleep(retry_delay)
204
212
  retry
205
213
  else
206
- # All retries exhausted for a timeout. Try the secondary gateway URL if available.
207
- if !@config.url_fallback_active?
208
- fallback_url = @config.fallback_base_url_for_current_provider
209
- if fallback_url
210
- @config.activate_url_fallback!(fallback_url)
211
- rebuild_client_for_current_model!
212
- retries = 0
213
- @ui&.show_warning(
214
- I18n.t("llm.warn.switching_to_fallback_url", url: fallback_url)
215
- )
216
- retry
217
- end
218
- end
219
-
220
- raise AgentError.new("[LLM] #{I18n.t("llm.error.request_timeout", retries: max_retries)}", raw_message: e.message)
214
+ raise AgentError.new("#{I18n.t("llm.error.request_timeout", retries: max_retries)}", raw_message: e.message)
221
215
  end
222
216
 
223
217
  rescue Faraday::ConnectionFailed, Faraday::SSLError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::EPIPE => e
@@ -251,30 +245,20 @@ module Clacky
251
245
  Clacky::Shutdown.sleep(retry_delay)
252
246
  retry
253
247
  else
254
- # All retries on the primary endpoint exhausted for a network-level
255
- # failure (DNS/TCP/TLS). Try the secondary gateway URL if available.
256
- if !@config.url_fallback_active?
257
- fallback_url = @config.fallback_base_url_for_current_provider
258
- if fallback_url
259
- @config.activate_url_fallback!(fallback_url)
260
- rebuild_client_for_current_model!
261
- retries = 0
262
- @ui&.show_warning(
263
- I18n.t("llm.warn.switching_to_fallback_url", url: fallback_url)
264
- )
265
- retry
266
- end
267
- end
268
-
269
248
  # Don't show_error here — let the outer rescue block handle it to avoid duplicates.
270
249
  # Progress cleanup is the caller's responsibility (via its own ensure block).
271
- raise AgentError.new("[LLM] #{I18n.t("llm.error.network_failed", retries: max_retries)}", raw_message: e.message)
250
+ raise AgentError.new("#{I18n.t("llm.error.network_failed", retries: max_retries)}", raw_message: e.message)
272
251
  end
273
252
 
274
253
  rescue RetryableError => e
275
254
  Clacky::Shutdown.checkpoint!
276
255
  retries += 1
277
256
 
257
+ case e.routed_tier
258
+ when "floor" then floor_fails += 1
259
+ when "upgrade" then upgrade_fails += 1
260
+ end
261
+
278
262
  # Probing failure: primary still down — renew cooling-off and retry with fallback.
279
263
  if @config.probing?
280
264
  handle_probe_failure
@@ -305,28 +289,9 @@ module Clacky
305
289
  Clacky::Shutdown.sleep(retry_delay)
306
290
  retry
307
291
  else
308
- # All retries on the current endpoint exhausted.
309
- # If a secondary gateway URL is configured and not yet tried, switch
310
- # to it now, rebuild the client, and reset the retry counter so the
311
- # same request gets a fresh MAX_RETRIES_ON_FALLBACK budget on the
312
- # new endpoint. This is a one-shot switch — url_fallback_active?
313
- # prevents a second switch if the fallback URL also fails.
314
- if !@config.url_fallback_active?
315
- fallback_url = @config.fallback_base_url_for_current_provider
316
- if fallback_url
317
- @config.activate_url_fallback!(fallback_url)
318
- rebuild_client_for_current_model!
319
- retries = 0
320
- @ui&.show_warning(
321
- I18n.t("llm.warn.switching_to_fallback_url", url: fallback_url)
322
- )
323
- retry
324
- end
325
- end
326
-
327
292
  # Don't show_error here — let the outer rescue block handle it to avoid duplicates.
328
293
  # Progress cleanup is the caller's responsibility (via its own ensure block).
329
- raise AgentError.new("[LLM] #{I18n.t("llm.error.service_unavailable", retries: current_max)}", raw_message: e.message)
294
+ raise AgentError.new("#{I18n.t("llm.error.service_unavailable", retries: current_max)}", raw_message: e.message)
330
295
  end
331
296
 
332
297
  rescue Clacky::BadRequestError => e
@@ -398,10 +363,21 @@ module Clacky
398
363
  raise
399
364
  end
400
365
 
366
+ # Fold this call's tier-attributed failures into the task-level tallies
367
+ # so later calls in the same task keep routing away from the failing
368
+ # lane — the per-call counters above reset to 0 once this call
369
+ # succeeds. A floor failure proves the floor lane is shaky for this
370
+ # task (route up); an upgrade failure proves the upgrade lane is shaky
371
+ # (route back down and stay on the floor until the task boundary).
372
+ @task_upstream_fails = (@task_upstream_fails || 0) + floor_fails if floor_fails > 0
373
+ @task_upgrade_fails = (@task_upgrade_fails || 0) + upgrade_fails if upgrade_fails > 0
374
+
401
375
  # Track cost and collect token usage data.
402
376
  # Pass the model name captured at API call time to ensure accurate billing
403
377
  # even if the user switched models during the (potentially long) API call.
404
- token_data = track_cost(response[:usage], raw_api_usage: response[:raw_api_usage], model: api_call_model)
378
+ # For the "auto" alias the gateway reports the concrete model it routed
379
+ # to via X-Clacky-Routed-Model — bill under that so pricing resolves.
380
+ token_data = track_cost(response[:usage], raw_api_usage: response[:raw_api_usage], model: response[:routed_model] || api_call_model)
405
381
  response[:token_usage] = token_data
406
382
 
407
383
  # [DIAG] Log raw client response shape. Only emit when we see the
@@ -721,9 +697,11 @@ module Clacky
721
697
  # and the hint just sits in history without affecting behaviour.
722
698
  inject_upstream_truncation_hint_if_first(truncated)
723
699
 
724
- raise Clacky::UpstreamTruncatedError,
700
+ raise Clacky::UpstreamTruncatedError.new(
725
701
  "[LLM] Upstream truncated tool_call `#{truncated[:name]}` " \
726
- "(args=#{args_str[0, 40].inspect}). Retrying..."
702
+ "(args=#{args_str[0, 40].inspect}). Retrying...",
703
+ routed_tier: response[:routed_tier]
704
+ )
727
705
  end
728
706
 
729
707
  # True when a tool_call's arguments field is unusable — either empty
@@ -678,9 +678,8 @@ module Clacky
678
678
  lines
679
679
  end
680
680
 
681
- # Serialize only the lightweight UI metadata needed to reconstruct file
682
- # badges after compression. File paths, previews, sizes, MIME types, and
683
- # contents deliberately stay out of the chunk archive.
681
+ # Serialize lightweight UI metadata for file badges after compression.
682
+ # Includes path/preview_path when set; file contents and sizes stay out of the archive.
684
683
  def display_files_for_archive(msg)
685
684
  files = Array(msg[:display_files]).dup
686
685
  Array(msg[:content]).each do |block|
@@ -688,12 +687,14 @@ module Clacky
688
687
  next unless %w[image image_url].include?((block[:type] || block["type"]).to_s)
689
688
 
690
689
  name = block[:image_name] || block["image_name"]
690
+ path = block[:image_path] || block["image_path"]
691
691
  if name.nil? || name.to_s.strip.empty?
692
- path = block[:image_path] || block["image_path"]
693
692
  name = File.basename(path.to_s).sub(/\A[0-9a-f]{16}_/, "") unless path.to_s.empty?
694
693
  end
695
694
  name = "image" if name.nil? || name.to_s.strip.empty?
696
- files << { name: name, type: "image" }
695
+ entry = { name: name, type: "image" }
696
+ entry[:path] = path.to_s unless path.nil? || path.to_s.strip.empty?
697
+ files << entry
697
698
  end
698
699
 
699
700
  files.filter_map do |file|
@@ -704,8 +705,13 @@ module Clacky
704
705
 
705
706
  type = file[:type] || file["type"] || "file"
706
707
  type = "file" if type.to_s.strip.empty?
707
- { name: name.to_s, type: type.to_s }
708
- end.uniq { |file| [file[:name], file[:type]] }
708
+ entry = { name: name.to_s, type: type.to_s }
709
+ path = file[:path] || file["path"]
710
+ entry[:path] = path.to_s unless path.nil? || path.to_s.strip.empty?
711
+ preview = file[:preview_path] || file["preview_path"]
712
+ entry[:preview_path] = preview.to_s unless preview.nil? || preview.to_s.strip.empty?
713
+ entry
714
+ end.uniq { |file| [file[:name], file[:type], file[:path]] }
709
715
  end
710
716
 
711
717
  # Once an image has a badge in the archive, omit the generic [image_url]
@@ -332,10 +332,20 @@ module Clacky
332
332
  { has_more: has_more }
333
333
  end
334
334
 
335
+ # ChannelManager prefixes channel prompts with agent-only context for
336
+ # the LLM; the clean original rides in display_text. Replay paths that
337
+ # fall back to raw content (pre-display_text sessions, chunk MD archives)
338
+ # must strip that block for UI parity with live rendering. Gated on
339
+ # channel_info so a normal user literally typing these lines keeps them.
340
+ private def strip_channel_prompt_prefix(text)
341
+ return text unless @channel_info
342
+ Clacky::Utils::ChannelPrompt.strip(text)
343
+ end
344
+
335
345
  private def replay_rounds(ui, page)
336
346
  page.each do |round|
337
347
  msg = round[:user_msg]
338
- raw_text = msg[:display_text] || extract_text_from_content(msg[:content])
348
+ raw_text = msg[:display_text] || strip_channel_prompt_prefix(extract_text_from_content(msg[:content]))
339
349
  # Images: recovered from inline image_url blocks in content (carry data_url for <img> rendering)
340
350
  image_files = extract_image_files_from_content(msg[:content])
341
351
  # Disk files (PDF, doc, etc.): stored in display_files on the user message at send time
@@ -507,7 +517,7 @@ module Clacky
507
517
  synthetic_ts = base_time - ((index ? index[:section_count] : sections.size) - round_offset - round_index) * 1.0
508
518
  user_msg = {
509
519
  role: "user",
510
- content: text,
520
+ content: strip_channel_prompt_prefix(text),
511
521
  created_at: synthetic_ts,
512
522
  ext_events: sec_ext_events,
513
523
  _from_chunk: true
@@ -576,7 +586,7 @@ module Clacky
576
586
 
577
587
  # Pull lightweight attachment badge metadata out of a chunk section.
578
588
  # The marker is internal archive data and must not appear in replayed text.
579
- # Only name + type are accepted; paths and file contents are never restored.
589
+ # Restores name, type, and path/preview_path when present in the chunk marker.
580
590
  def extract_display_files_from_text(text)
581
591
  return [text, []] unless text.include?("_Display files:")
582
592
 
@@ -594,7 +604,12 @@ module Clacky
594
604
 
595
605
  type = file["type"] || file[:type] || "file"
596
606
  type = "file" if type.to_s.strip.empty?
597
- files << { name: name.to_s, type: type.to_s }
607
+ entry = { name: name.to_s, type: type.to_s }
608
+ path = file["path"] || file[:path]
609
+ entry[:path] = path.to_s unless path.nil? || path.to_s.strip.empty?
610
+ preview = file["preview_path"] || file[:preview_path]
611
+ entry[:preview_path] = preview.to_s unless preview.nil? || preview.to_s.strip.empty?
612
+ files << entry
598
613
  end
599
614
  true
600
615
  end
@@ -750,6 +765,7 @@ module Clacky
750
765
  questions: questions)
751
766
  end
752
767
  else
768
+ remember_replayed_tool_name(tc[:id], name)
753
769
  ui.show_tool_call(name, args)
754
770
  end
755
771
  end
@@ -768,20 +784,65 @@ module Clacky
768
784
  msg[:content].each do |blk|
769
785
  next unless blk.is_a?(Hash) && blk[:type] == "tool_result"
770
786
 
771
- ui.show_tool_result(blk[:content].to_s)
787
+ content = blk[:content].to_s
788
+ emit_replayed_tool_result(ui, content, blk[:tool_use_id])
772
789
  end
773
790
  replay_subagent_transcript(msg, ui)
774
791
  end
775
792
 
776
793
  when "tool"
777
794
  # OpenAI-format tool result
778
- ui.show_tool_result(msg[:content].to_s)
795
+ emit_replayed_tool_result(ui, msg[:content].to_s, msg[:tool_call_id])
779
796
  replay_subagent_transcript(msg, ui)
780
797
  end
781
798
 
782
799
  replay_ext_events(msg, ui)
783
800
  end
784
801
 
802
+ # Tool results persist as the raw JSON the tool returned, so replay can
803
+ # rebuild the same structured `ui:` payload the live path sent instead of
804
+ # degrading the card to plain text (C-5797).
805
+ private def emit_replayed_tool_result(ui, content, tool_call_id)
806
+ ui_payload = replayed_tool_ui(content, tool_call_id)
807
+ if ui_payload
808
+ ui.show_tool_result(content, ui: ui_payload)
809
+ else
810
+ ui.show_tool_result(content)
811
+ end
812
+ end
813
+
814
+ # Tool names live on the preceding assistant message, so remember them by
815
+ # call id while walking the round.
816
+ private def remember_replayed_tool_name(call_id, name)
817
+ return if call_id.nil? || name.to_s.empty?
818
+
819
+ @replayed_tool_names ||= {}
820
+ @replayed_tool_names[call_id.to_s] = name
821
+ end
822
+
823
+ private def replayed_tool_ui(content, tool_call_id)
824
+ return nil if tool_call_id.nil?
825
+
826
+ name = (@replayed_tool_names || {})[tool_call_id.to_s]
827
+ return nil if name.nil?
828
+
829
+ text = content.to_s
830
+ return nil unless text.start_with?("{")
831
+
832
+ resolved = @tool_registry.resolve(name)
833
+ return nil if resolved.nil?
834
+
835
+ tool = @tool_registry.get(resolved)
836
+ return nil unless tool.respond_to?(:ui_result)
837
+
838
+ parsed = JSON.parse(text, symbolize_names: true)
839
+ return nil unless parsed.is_a?(Hash)
840
+
841
+ tool.ui_result(parsed)
842
+ rescue StandardError
843
+ nil
844
+ end
845
+
785
846
  # Re-emit custom extension events recorded on a message so extension
786
847
  # panels can rebuild their state after a page reload. Mirrors what
787
848
  # Agent#emit_event pushed live. No-op when the message carries none.