openclacky 0.9.23 → 0.9.24

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e1add81a8b60017ed8ed70e090af57bd68d0dce5670124fab1c13b878e57d21d
4
- data.tar.gz: 1607062100acf56326e62d75a2fa0599d4eee61686785fceed1e16578c025c6c
3
+ metadata.gz: 5338bcdeaf4b2aafed416365f56467a99a6107911b24df2bfe9d94d757d03528
4
+ data.tar.gz: 8e06f6ca6b0d5a0a9c70ab758fc13b2f84d755f7d61e30a9708fdadd4db82309
5
5
  SHA512:
6
- metadata.gz: 3d499b6484f327b0cdcdaaba21019c9a5b314ba73910c03fde7d64be880e365dea1d1a5bd82cdf993647b11e5b749cd667567265897b99d4e5f98678651ea464
7
- data.tar.gz: f02f3b6317c4971152d055a4b6861d53bf719e8278bc65dd3031a80f718937f93c92add6b589b3c3396589e85d1dca4cf1100a440c65691352b0ab4e273c7f43
6
+ metadata.gz: 7862cd557ea5ba9e88184b314fd88fc820d1f4e0c5a9bbebbfcdee75f33301876aff7df7f13cf07eaa21c7fb47d0efa65e7f051c1641ced6c20d1142e9e52819
7
+ data.tar.gz: b33e3cc3331e70328d366a38b67e472c760eb6bbe4b5621532ee4ae60585303d9042c4953f29f31e4256f357765d8aafee2b3e69fa87f31c386f9310be790c46
data/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.24] - 2026-04-02
11
+
12
+ ### Added
13
+ - **New session list & search in Web UI**: sidebar now shows full session history with real-time search — find any past conversation instantly
14
+ - **Session type indicators**: sessions are labeled by type (chat / agent) so you can see at a glance what kind of interaction it was
15
+ - **Image lightbox**: click any image in the chat to expand it full-screen with a clean overlay viewer
16
+ - **Session history replay for streaming messages**: chunk-based (streaming) messages are now fully replayed when revisiting a past session
17
+ - **Xiaomi AI provider**: added Xiaomi as a supported AI provider
18
+ - **Chinese Bing web search**: web search now uses cn.bing.com for users in China, improving search relevance and reliability
19
+ - **Auto-install system dependencies script**: agent can now automatically install missing system packages (Node, Python, etc.) via a bundled `install_system_deps.sh` script
20
+ - **User message timestamps**: each user message now displays the time it was sent
21
+
22
+ ### Fixed
23
+ - **Bedrock file attachments & partial cost tracking**: fixed file handling and cost accumulation for AWS Bedrock sessions
24
+ - **Session name timestamp**: fixed incorrect timestamp display on session names
25
+ - **New session scroll**: new sessions now correctly scroll to the latest message
26
+ - **Feishu WebSocket client crash**: fixed a nil-reference error that caused the Feishu WS client to crash on reconnect
27
+
10
28
  ## [0.9.23] - 2026-04-01
11
29
 
12
30
  ### Improved
@@ -17,7 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
17
35
  - **CLI -c option model initialization**: fixed a bug where the CLI command with -c option was not passing the model name to the client, causing routing failures for certain providers
18
36
 
19
37
  ### More
20
- - Rename provider display name to "ClackyAI" for consistency
38
+ - ClackyAI provider updated to use the latest model name format (abs- prefix)
21
39
 
22
40
  ## [0.9.22] - 2026-03-31
23
41
 
@@ -305,9 +305,11 @@ module Clacky
305
305
  return nil unless @session_id && @created_at
306
306
 
307
307
  # Messages being compressed = original minus system message minus recent messages
308
+ # Also exclude system-injected scaffolding (session context, memory prompts, etc.)
309
+ # — these are internal CLI metadata and must not appear in chunk MD or WebUI history.
308
310
  recent_set = recent_messages.to_a
309
311
  messages_to_archive = original_messages.reject do |m|
310
- m[:role] == "system" || recent_set.include?(m)
312
+ m[:role] == "system" || m[:system_injected] || recent_set.include?(m)
311
313
  end
312
314
 
313
315
  return nil if messages_to_archive.empty?
@@ -374,9 +376,23 @@ module Clacky
374
376
  end
375
377
  lines << ""
376
378
  # Include tool calls summary if present
379
+ # Format: "_Tool calls: name | {args_json}_" so replay can restore args for WebUI display.
377
380
  if msg[:tool_calls]&.any?
378
- tool_names = msg[:tool_calls].map { |tc| tc.dig(:function, :name) }.compact.join(", ")
379
- lines << "_Tool calls: #{tool_names}_"
381
+ tc_parts = msg[:tool_calls].map do |tc|
382
+ name = tc.dig(:function, :name) || tc[:name] || ""
383
+ next nil if name.empty?
384
+
385
+ args_raw = tc.dig(:function, :arguments) || tc[:arguments] || {}
386
+ args = args_raw.is_a?(String) ? (JSON.parse(args_raw) rescue nil) : args_raw
387
+ if args.is_a?(Hash) && !args.empty?
388
+ # Truncate large string values to keep chunk MD readable
389
+ compact = args.transform_values { |v| v.is_a?(String) && v.length > 200 ? v[0..197] + "..." : v }
390
+ "#{name} | #{compact.to_json}"
391
+ else
392
+ name
393
+ end
394
+ end.compact
395
+ lines << "_Tool calls: #{tc_parts.join("; ")}_"
380
396
  lines << ""
381
397
  end
382
398
  lines << format_message_content(content) if content
@@ -114,6 +114,7 @@ module Clacky
114
114
  # Replay conversation history by calling ui.show_* methods for each message.
115
115
  # Supports cursor-based pagination using created_at timestamps on user messages.
116
116
  # Each "round" starts at a user message and includes all subsequent assistant/tool messages.
117
+ # Compressed chunks (chunk_path on assistant messages) are transparently expanded.
117
118
  #
118
119
  # @param ui [Object] UI interface that responds to show_user_message, show_assistant_message, etc.
119
120
  # @param limit [Integer] Maximum number of rounds (user turns) to replay
@@ -147,9 +148,19 @@ module Clacky
147
148
  rounds << current_round
148
149
  elsif current_round
149
150
  current_round[:events] << msg
151
+ elsif msg[:compressed_summary] && msg[:chunk_path]
152
+ # Compressed summary sitting before any user rounds — expand it from chunk md
153
+ chunk_rounds = parse_chunk_md_to_rounds(msg[:chunk_path])
154
+ rounds.concat(chunk_rounds)
150
155
  end
151
156
  end
152
157
 
158
+ # Expand any compressed_summary assistant messages sitting inside a round's events.
159
+ # These occur when compression happened mid-round (rare) — expand them in-place.
160
+ rounds.each do |round|
161
+ round[:events].select! { |ev| !ev[:compressed_summary] }
162
+ end
163
+
153
164
  # Apply before-cursor filter: only rounds whose user message created_at < before
154
165
  if before
155
166
  rounds = rounds.select { |r| r[:user_msg][:created_at] && r[:user_msg][:created_at] < before }
@@ -193,6 +204,159 @@ module Clacky
193
204
  { has_more: has_more }
194
205
  end
195
206
 
207
+ # Parse a chunk MD file into an array of rounds compatible with replay_history.
208
+ # Each round is { user_msg: Hash, events: Array<Hash> }.
209
+ # Timestamps are synthesised from the chunk's archived_at, spread backwards.
210
+ # Recursively expands nested chunk references (compressed summary inside a chunk).
211
+ #
212
+ # @param chunk_path [String] Path to the chunk md file
213
+ # @return [Array<Hash>] rounds array (may be empty if file missing/unreadable)
214
+ private def parse_chunk_md_to_rounds(chunk_path)
215
+ return [] unless chunk_path && File.exist?(chunk_path.to_s)
216
+
217
+ raw = File.read(chunk_path.to_s, encoding: "utf-8")
218
+
219
+ # Parse YAML front matter to get archived_at for synthetic timestamps
220
+ archived_at = nil
221
+ if raw.start_with?("---")
222
+ fm_end = raw.index("\n---\n", 4)
223
+ if fm_end
224
+ fm_text = raw[4...fm_end]
225
+ fm_text.each_line do |line|
226
+ if line.start_with?("archived_at:")
227
+ archived_at = Time.parse(line.split(":", 2).last.strip) rescue nil
228
+ end
229
+ end
230
+ end
231
+ end
232
+ base_time = (archived_at || Time.now).to_f
233
+ chunk_dir = File.dirname(chunk_path.to_s)
234
+
235
+ # Split into sections by ## headings
236
+ sections = []
237
+ current_role = nil
238
+ current_lines = []
239
+ current_nested_chunk = nil # chunk reference from a Compressed Summary heading
240
+
241
+ raw.each_line do |line|
242
+ stripped = line.chomp
243
+ if (m = stripped.match(/\A## Assistant \[Compressed Summary — original conversation at: (.+)\]/))
244
+ # Nested chunk reference — record it, treat as assistant section
245
+ sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
246
+ current_role = "assistant"
247
+ current_lines = []
248
+ current_nested_chunk = File.join(chunk_dir, m[1])
249
+ elsif stripped.match?(/\A## (User|Assistant)/)
250
+ sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
251
+ current_role = stripped.match(/\A## (User|Assistant)/)[1].downcase
252
+ current_lines = []
253
+ current_nested_chunk = nil
254
+ elsif stripped.match?(/\A### Tool Result:/)
255
+ sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
256
+ current_role = "tool"
257
+ current_lines = []
258
+ current_nested_chunk = nil
259
+ else
260
+ current_lines << line
261
+ end
262
+ end
263
+ sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
264
+
265
+ # Remove front-matter / header noise sections (nil role or non-user/assistant/tool)
266
+ sections.select! { |s| %w[user assistant tool].include?(s[:role]) }
267
+
268
+ # Group into rounds: each user section starts a new round
269
+ rounds = []
270
+ current_round = nil
271
+ round_index = 0
272
+
273
+ sections.each do |sec|
274
+ text = sec[:lines].join.strip
275
+
276
+ # Nested chunk: expand it recursively, prepend before current rounds
277
+ if sec[:nested_chunk]
278
+ nested = parse_chunk_md_to_rounds(sec[:nested_chunk])
279
+ rounds = nested + rounds unless nested.empty?
280
+ # Also render its summary text as an assistant event in current round if any
281
+ if current_round && !text.empty?
282
+ current_round[:events] << { role: "assistant", content: text }
283
+ end
284
+ next
285
+ end
286
+
287
+ next if text.empty?
288
+
289
+ if sec[:role] == "user"
290
+ round_index += 1
291
+ # Synthetic timestamp: spread rounds backwards from archived_at
292
+ synthetic_ts = base_time - (sections.size - round_index) * 1.0
293
+ current_round = {
294
+ user_msg: {
295
+ role: "user",
296
+ content: text,
297
+ created_at: synthetic_ts,
298
+ _from_chunk: true
299
+ },
300
+ events: []
301
+ }
302
+ rounds << current_round
303
+ elsif current_round
304
+ if sec[:role] == "assistant"
305
+ # Detect "_Tool calls: ..._" lines — convert to tool_calls events
306
+ # so _replay_single_message renders them as tool group UI (same as live).
307
+ #
308
+ # Formats supported:
309
+ # New: "_Tool calls: name | {"arg":"val"}; name2 | {"k":"v"}_"
310
+ # Old: "_Tool calls: name1, name2_" (backward compat)
311
+ remaining_lines = []
312
+ pending_tool_entries = [] # [{name:, args:}]
313
+
314
+ text.each_line do |line|
315
+ stripped = line.strip
316
+ if (m = stripped.match(/\A_Tool calls?:\s*(.+?)_?\z/i))
317
+ raw = m[1]
318
+ # New format uses ";" as separator between tools (each entry: "name | {json}")
319
+ # Old format uses "," with no JSON part.
320
+ entries = raw.include?(" | ") ? raw.split(/;\s*/) : raw.split(/,\s*/)
321
+ entries.each do |entry|
322
+ entry = entry.strip
323
+ if (parts = entry.match(/\A(.+?)\s*\|\s*(\{.+\})\z/))
324
+ tool_name = parts[1].strip
325
+ args = JSON.parse(parts[2]) rescue {}
326
+ pending_tool_entries << { name: tool_name, args: args }
327
+ else
328
+ pending_tool_entries << { name: entry, args: {} }
329
+ end
330
+ end
331
+ else
332
+ remaining_lines << line
333
+ end
334
+ end
335
+
336
+ # Flush any plain text
337
+ plain_text = remaining_lines.join.strip
338
+ current_round[:events] << { role: "assistant", content: plain_text } unless plain_text.empty?
339
+
340
+ # Emit one synthetic tool_calls message per detected tool
341
+ pending_tool_entries.each do |entry|
342
+ current_round[:events] << {
343
+ role: "assistant",
344
+ content: "",
345
+ tool_calls: [{ name: entry[:name], arguments: entry[:args] }]
346
+ }
347
+ end
348
+ else
349
+ current_round[:events] << { role: "tool", content: text }
350
+ end
351
+ end
352
+ end
353
+
354
+ rounds
355
+ rescue => e
356
+ Clacky::Logger.warn("parse_chunk_md_to_rounds failed for #{chunk_path}: #{e.message}")
357
+ []
358
+ end
359
+
196
360
 
197
361
  # Render a single non-user message into the UI.
198
362
  # Used by both the normal round-based replay and the compressed-session fallback.
@@ -328,12 +492,14 @@ module Clacky
328
492
  next unless block[:type] == "image_url"
329
493
 
330
494
  url = block.dig(:image_url, :url)
331
- next unless url && url.start_with?("data:")
495
+ # image_path is stored at send-time so replay can reconstruct the image from tmp
496
+ path = block[:image_path]
497
+
498
+ next unless url&.start_with?("data:") || path
332
499
 
333
- # Derive mime_type from the data URL prefix (e.g. "data:image/jpeg;base64,...")
334
- mime_type = url[/\Adata:([^;]+);/, 1] || "image/jpeg"
500
+ mime_type = (url || "")[/\Adata:([^;]+);/, 1] || "image/jpeg"
335
501
  ext = mime_type.split("/").last
336
- { name: "image_#{idx + 1}.#{ext}", mime_type: mime_type, data_url: url }
502
+ { name: "image_#{idx + 1}.#{ext}", mime_type: mime_type, data_url: url, path: path }
337
503
  end
338
504
  end
339
505
  end
data/lib/clacky/agent.rb CHANGED
@@ -218,7 +218,9 @@ module Clacky
218
218
  all_disk_files = disk_files + downgraded
219
219
 
220
220
  # Format user message — text + inline vision images
221
- user_content = format_user_content(user_input, vision_images.map { |v| v[:url] })
221
+ # Store the tmp path alongside the data_url so the history replay can
222
+ # reconstruct the image if the base64 was stripped (e.g. after compression).
223
+ user_content = format_user_content(user_input, vision_images.map { |v| { url: v[:url], path: v[:path] } })
222
224
 
223
225
  # Parse disk files — agent's responsibility, not the upload layer.
224
226
  # process_path runs the parser script and returns a FileRef with preview_path or parse_error.
@@ -1111,15 +1113,25 @@ module Clacky
1111
1113
 
1112
1114
  # Build user message content for LLM.
1113
1115
  # Returns plain String when no vision images; Array of content parts otherwise.
1114
- private def format_user_content(text, vision_urls)
1115
- vision_urls ||= []
1116
+ # Build user message content for LLM.
1117
+ # vision_images: Array of String (plain url) OR Hash { url:, path: }
1118
+ # path is stored in the block so history replay can reconstruct the image
1119
+ # from the tmp file when the base64 data_url is no longer available.
1120
+ private def format_user_content(text, vision_images)
1121
+ vision_images ||= []
1116
1122
 
1117
- return text if vision_urls.empty?
1123
+ return text if vision_images.empty?
1118
1124
 
1119
1125
  content = []
1120
1126
  content << { type: "text", text: text } unless text.nil? || text.empty?
1121
- vision_urls.each do |url|
1122
- content << { type: "image_url", image_url: { url: url } }
1127
+ vision_images.each do |img|
1128
+ if img.is_a?(Hash)
1129
+ block = { type: "image_url", image_url: { url: img[:url] } }
1130
+ block[:image_path] = img[:path] if img[:path]
1131
+ content << block
1132
+ else
1133
+ content << { type: "image_url", image_url: { url: img } }
1134
+ end
1123
1135
  end
1124
1136
  content
1125
1137
  end
@@ -1156,7 +1168,8 @@ module Clacky
1156
1168
  "Today is #{Time.now.strftime('%Y-%m-%d, %A')}",
1157
1169
  "Current model: #{current_model}",
1158
1170
  os != :unknown ? "OS: #{Clacky::Utils::EnvironmentDetector.os_label}" : nil,
1159
- desktop ? "Desktop: #{desktop}" : nil
1171
+ desktop ? "Desktop: #{desktop}" : nil,
1172
+ "Working directory: #{@working_dir}"
1160
1173
  ].compact.join(". ")
1161
1174
 
1162
1175
  content = "[Session context: #{parts}]"
@@ -168,6 +168,8 @@ module Clacky
168
168
  # user message as a cache breakpoint, which invalidates the intended cache boundary
169
169
  # and results in cache misses (cache_read=0) every turn.
170
170
  blocks = content_to_blocks(content)
171
+ # Anthropic rejects messages with an empty content array — use a placeholder text block.
172
+ blocks = [{ type: "text", text: "..." }] if blocks.empty?
171
173
  { role: role, content: blocks }
172
174
  end
173
175
 
@@ -176,11 +178,17 @@ module Clacky
176
178
  private_class_method def self.content_to_blocks(content)
177
179
  case content
178
180
  when String
181
+ # Anthropic rejects blank text blocks — skip empty strings
182
+ return [] if content.empty?
183
+
179
184
  [{ type: "text", text: content }]
180
185
  when Array
181
186
  content.map { |b| normalize_block(b) }.compact
182
187
  else
183
- [{ type: "text", text: content.to_s }]
188
+ str = content.to_s
189
+ return [] if str.empty?
190
+
191
+ [{ type: "text", text: str }]
184
192
  end
185
193
  end
186
194
 
@@ -190,8 +198,12 @@ module Clacky
190
198
 
191
199
  case block[:type]
192
200
  when "text"
201
+ # Anthropic rejects blank text blocks — drop them instead of sending { type:"text", text:"" }
202
+ text = block[:text]
203
+ return nil if text.nil? || text.empty?
204
+
193
205
  # Preserve cache_control if present (placed by Client#apply_message_caching)
194
- result = { type: "text", text: block[:text] }
206
+ result = { type: "text", text: text }
195
207
  result[:cache_control] = block[:cache_control] if block[:cache_control]
196
208
  result
197
209
  when "image_url"
@@ -180,6 +180,8 @@ module Clacky
180
180
 
181
181
  # regular user/assistant message
182
182
  blocks = content_to_blocks(content)
183
+ # Bedrock rejects messages with an empty content array — use a placeholder text block.
184
+ blocks = [{ text: "..." }] if blocks.empty?
183
185
  { role: role, content: blocks }
184
186
  end
185
187
 
@@ -187,11 +189,17 @@ module Clacky
187
189
  private_class_method def self.content_to_blocks(content)
188
190
  case content
189
191
  when String
192
+ # Bedrock rejects blank text blocks — skip empty strings
193
+ return [] if content.empty?
194
+
190
195
  [{ text: content }]
191
196
  when Array
192
197
  content.map { |b| normalize_block(b) }.compact
193
198
  else
194
- [{ text: content.to_s }]
199
+ str = content.to_s
200
+ return [] if str.empty?
201
+
202
+ [{ text: str }]
195
203
  end
196
204
  end
197
205
 
@@ -201,7 +209,11 @@ module Clacky
201
209
 
202
210
  case block[:type]
203
211
  when "text"
204
- { text: block[:text].to_s }
212
+ # Bedrock rejects blank text blocks — drop them
213
+ text = block[:text].to_s
214
+ return nil if text.empty?
215
+
216
+ { text: text }
205
217
  when "image_url"
206
218
  # Bedrock image format — base64 only
207
219
  url = block.dig(:image_url, :url) || block[:url]
@@ -59,6 +59,15 @@ module Clacky
59
59
  "abs-claude-haiku-4-5"
60
60
  ],
61
61
  "website_url" => "https://clacky.ai"
62
+ }.freeze,
63
+
64
+ "mimo" => {
65
+ "name" => "MiMo (Xiaomi)",
66
+ "base_url" => "https://api.xiaomimimo.com/v1",
67
+ "api" => "openai-completions",
68
+ "default_model" => "mimo-v2-pro",
69
+ "models" => ["mimo-v2-pro", "mimo-v2-omni"],
70
+ "website_url" => "https://platform.xiaomimimo.com/"
62
71
  }.freeze
63
72
 
64
73
  }.freeze
@@ -160,6 +160,12 @@ module Clacky
160
160
  @ping_interval = (client_config["PingInterval"] || 90).to_i
161
161
 
162
162
  url = data.dig("data", "URL")
163
+ if url.nil? || url.strip.empty?
164
+ Clacky::Logger.error("[feishu-ws] WebSocket endpoint URL is missing from response. " \
165
+ "Please verify your Feishu App ID and App Secret are correct.")
166
+ raise "Failed to get WebSocket endpoint: URL is missing (check your Feishu App ID / App Secret)"
167
+ end
168
+
163
169
  if url =~ /service_id=(\d+)/
164
170
  @service_id = $1.to_i
165
171
  end
@@ -34,7 +34,18 @@ module Clacky
34
34
  rendered = Array(files).filter_map do |f|
35
35
  url = f[:data_url] || f["data_url"]
36
36
  name = f[:name] || f["name"]
37
- url || (name ? "pdf:#{name}" : nil)
37
+ path = f[:path] || f["path"]
38
+
39
+ if url
40
+ url
41
+ elsif path && File.exist?(path.to_s)
42
+ # Reconstruct data_url from the tmp file (still present on disk)
43
+ Utils::FileProcessor.image_path_to_data_url(path) rescue "expired:#{name}"
44
+ elsif name
45
+ # File badge for non-image disk files, or image whose tmp file is gone
46
+ type = f[:type] || f["type"] || ""
47
+ type.to_s == "image" ? "expired:#{name}" : "pdf:#{name}"
48
+ end
38
49
  end
39
50
  ev[:images] = rendered unless rendered.empty?
40
51
  @events << ev
@@ -393,12 +404,16 @@ module Clacky
393
404
 
394
405
  def api_list_sessions(req, res)
395
406
  query = URI.decode_www_form(req.query_string.to_s).to_h
396
- limit = [query["limit"].to_i.then { |n| n > 0 ? n : 10 }, 50].min
397
- before = query["before"].to_s.strip.then { |v| v.empty? ? nil : v }
398
- source = query["source"].to_s.strip.then { |v| v.empty? ? nil : v }
399
- profile = query["profile"].to_s.strip.then { |v| v.empty? ? nil : v }
407
+ limit = [query["limit"].to_i.then { |n| n > 0 ? n : 20 }, 50].min
408
+ before = query["before"].to_s.strip.then { |v| v.empty? ? nil : v }
409
+ q = query["q"].to_s.strip.then { |v| v.empty? ? nil : v }
410
+ date = query["date"].to_s.strip.then { |v| v.empty? ? nil : v }
411
+ type = query["type"].to_s.strip.then { |v| v.empty? ? nil : v }
412
+ # Backward-compat: ?source=<x> and ?profile=coding → type
413
+ type ||= query["profile"].to_s.strip.then { |v| v.empty? ? nil : v }
414
+ type ||= query["source"].to_s.strip.then { |v| v.empty? ? nil : v }
400
415
  # Fetch one extra to detect has_more without a separate count query
401
- sessions = @registry.list(limit: limit + 1, before: before, source: source, profile: profile)
416
+ sessions = @registry.list(limit: limit + 1, before: before, q: q, date: date, type: type)
402
417
  has_more = sessions.size > limit
403
418
  sessions = sessions.first(limit)
404
419
  json_response(res, 200, { sessions: sessions, has_more: has_more })
@@ -1774,24 +1789,12 @@ module Clacky
1774
1789
  interrupt_session(session_id)
1775
1790
 
1776
1791
  when "list_sessions"
1777
- # Initial load: 5 per bucket so all tabs/sections get their first page.
1778
- # General area tabs: manual / cron / channel / setup — filtered by source.
1779
- # Coding section: profile=coding — source is irrelevant (no source filter).
1780
- # has_more_by_source drives independent load-more buttons on the frontend.
1781
- buckets = {
1782
- "manual" => { source: "manual", profile: "general" },
1783
- "cron" => { source: "cron", profile: "general" },
1784
- "channel" => { source: "channel", profile: "general" },
1785
- "setup" => { source: "setup", profile: "general" },
1786
- "coding" => { profile: "coding" },
1787
- }
1788
- by_bucket = buckets.each_with_object({}) do |(key, params), h|
1789
- page = @registry.list(limit: 6, **params) # +1 to detect has_more
1790
- h[key] = { sessions: page.first(5), has_more: page.size > 5 }
1791
- end
1792
- all_sessions = by_bucket.values.flat_map { |v| v[:sessions] }.uniq { |s| s[:id] }
1793
- has_more_map = by_bucket.transform_values { |v| v[:has_more] }
1794
- conn.send_json(type: "session_list", sessions: all_sessions, has_more_by_source: has_more_map)
1792
+ # Initial load: newest 20 sessions regardless of source/profile.
1793
+ # Single unified query frontend shows all in one time-sorted list.
1794
+ page = @registry.list(limit: 21)
1795
+ has_more = page.size > 20
1796
+ all_sessions = page.first(20)
1797
+ conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more)
1795
1798
 
1796
1799
  when "run_task"
1797
1800
  # Client sends this after subscribing to guarantee it's ready to receive
@@ -145,24 +145,40 @@ module Clacky
145
145
  # before: ISO8601 cursor — only sessions with created_at < before
146
146
  #
147
147
  # source and profile are orthogonal — either can be nil independently.
148
- def list(limit: nil, before: nil, source: nil, profile: nil)
148
+ def list(limit: nil, before: nil, q: nil, date: nil, type: nil)
149
149
  return [] unless @session_manager
150
150
 
151
151
  live = @mutex.synchronize do
152
152
  @sessions.transform_values do |s|
153
153
  model_info = s[:agent]&.current_model_info
154
- { status: s[:status], error: s[:error], model: model_info&.dig(:model) }
154
+ live_name = s[:agent]&.name
155
+ live_name = nil if live_name&.empty?
156
+ { status: s[:status], error: s[:error], model: model_info&.dig(:model), name: live_name,
157
+ total_tasks: s[:agent]&.total_tasks, total_cost: s[:agent]&.total_cost }
155
158
  end
156
159
  end
157
160
 
158
161
  all = @session_manager.all_sessions # already sorted newest-first
159
162
 
160
- # ── source filter ────────────────────────────────────────────────────
161
- all = all.select { |s| s_source(s) == source } if source
162
- # source == nil no filter, return all
163
+ # ── type filter (replaces old source/profile split) ──────────────────
164
+ # type=coding → agent_profile == "coding"
165
+ # type=manual/cron/channel/setupsource match (profile=general implied)
166
+ if type
167
+ if type == "coding"
168
+ all = all.select { |s| (s[:agent_profile] || "general").to_s == "coding" }
169
+ else
170
+ all = all.select { |s| s_source(s) == type && (s[:agent_profile] || "general").to_s != "coding" }
171
+ end
172
+ end
163
173
 
164
- # ── profile filter ───────────────────────────────────────────────────
165
- all = all.select { |s| (s[:agent_profile] || "general").to_s == profile } if profile
174
+ # ── date filter (YYYY-MM-DD, matches created_at prefix) ──────────────
175
+ all = all.select { |s| s[:created_at].to_s.start_with?(date) } if date
176
+
177
+ # ── name search ──────────────────────────────────────────────────────
178
+ if q && !q.empty?
179
+ q_down = q.downcase
180
+ all = all.select { |s| (s[:name] || "").downcase.include?(q_down) }
181
+ end
166
182
 
167
183
  all = all.select { |s| (s[:created_at] || "") < before } if before
168
184
  all = all.first(limit) if limit
@@ -172,7 +188,7 @@ module Clacky
172
188
  ls = live[id]
173
189
  {
174
190
  id: id,
175
- name: s[:name] || "",
191
+ name: ls&.dig(:name) || s[:name] || "",
176
192
  status: ls ? ls[:status].to_s : "idle",
177
193
  error: ls ? ls[:error] : nil,
178
194
  model: ls&.dig(:model),
@@ -181,8 +197,8 @@ module Clacky
181
197
  working_dir: s[:working_dir],
182
198
  created_at: s[:created_at],
183
199
  updated_at: s[:updated_at],
184
- total_tasks: s.dig(:stats, :total_tasks) || 0,
185
- total_cost: s.dig(:stats, :total_cost_usd) || 0.0,
200
+ total_tasks: ls&.dig(:total_tasks) || s.dig(:stats, :total_tasks) || 0,
201
+ total_cost: ls&.dig(:total_cost) || s.dig(:stats, :total_cost_usd) || 0.0,
186
202
  }
187
203
  end
188
204
  end
@@ -61,6 +61,15 @@ module Clacky
61
61
  # 4. Call parent class execution method
62
62
  result = super(command: safe_command, soft_timeout: soft_timeout, hard_timeout: hard_timeout, max_output_lines: max_output_lines, output_buffer: output_buffer, working_dir: working_dir)
63
63
 
64
+ # 4a. If macOS xcode-select shim detected, replace stderr with actionable message
65
+ if xcode_tools_missing?(result[:stderr])
66
+ result = result.merge(
67
+ stderr: "Xcode Command Line Tools are not installed.\nRun: bash ~/.clacky/scripts/install_system_deps.sh\nThen retry the original command.",
68
+ exit_code: 1,
69
+ success: false
70
+ )
71
+ end
72
+
64
73
  # 5. Enhance result information
65
74
  enhance_result(result, command, safe_command, safety_replacer)
66
75
 
@@ -294,6 +303,14 @@ module Clacky
294
303
  return text if text.length <= max_length
295
304
  "#{text[0...max_length-3]}..."
296
305
  end
306
+
307
+ # Returns true if stderr contains the macOS xcode-select shim message,
308
+ # which appears when Xcode Command Line Tools are not installed and the
309
+ # user (or LLM) tries to run python3, git, make, gcc, etc.
310
+ def xcode_tools_missing?(stderr)
311
+ return false if stderr.nil? || stderr.empty?
312
+ stderr.include?("xcode-select") && stderr.include?("No developer tools were found")
313
+ end
297
314
  end
298
315
 
299
316
  class CommandSafetyReplacer