openclacky 0.9.28 → 0.9.30

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/docs/deploy-architecture.md +619 -0
  4. data/lib/clacky/agent/llm_caller.rb +14 -2
  5. data/lib/clacky/agent/message_compressor.rb +24 -6
  6. data/lib/clacky/agent/message_compressor_helper.rb +17 -10
  7. data/lib/clacky/agent/session_serializer.rb +69 -0
  8. data/lib/clacky/agent/skill_manager.rb +2 -2
  9. data/lib/clacky/agent.rb +3 -0
  10. data/lib/clacky/brand_config.rb +29 -3
  11. data/lib/clacky/clacky_auth_client.rb +152 -0
  12. data/lib/clacky/clacky_cloud_config.rb +123 -0
  13. data/lib/clacky/cli.rb +13 -0
  14. data/lib/clacky/client.rb +21 -7
  15. data/lib/clacky/cloud_project_client.rb +169 -0
  16. data/lib/clacky/default_agents/base_prompt.md +1 -0
  17. data/lib/clacky/default_parsers/doc_parser.rb +9 -9
  18. data/lib/clacky/default_skills/browser-setup/SKILL.md +9 -0
  19. data/lib/clacky/default_skills/channel-setup/SKILL.md +21 -4
  20. data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +8 -2
  21. data/lib/clacky/default_skills/deploy/SKILL.md +96 -5
  22. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +1268 -274
  23. data/lib/clacky/default_skills/deploy/tools/create_database_service.rb +341 -0
  24. data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +72 -147
  25. data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +60 -50
  26. data/lib/clacky/default_skills/deploy/tools/list_services.rb +47 -60
  27. data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +147 -96
  28. data/lib/clacky/default_skills/new/SKILL.md +117 -5
  29. data/lib/clacky/default_skills/new/scripts/cloud_project_init.sh +74 -0
  30. data/lib/clacky/default_skills/new/scripts/create_rails_project.sh +32 -0
  31. data/lib/clacky/deploy_api_client.rb +484 -0
  32. data/lib/clacky/json_ui_controller.rb +16 -10
  33. data/lib/clacky/message_format/bedrock.rb +3 -2
  34. data/lib/clacky/message_history.rb +8 -0
  35. data/lib/clacky/plain_ui_controller.rb +1 -6
  36. data/lib/clacky/providers.rb +23 -4
  37. data/lib/clacky/server/browser_manager.rb +3 -1
  38. data/lib/clacky/server/channel/adapters/feishu/ws_client.rb +2 -1
  39. data/lib/clacky/server/channel/adapters/wecom/ws_client.rb +3 -1
  40. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +5 -5
  41. data/lib/clacky/server/http_server.rb +12 -2
  42. data/lib/clacky/server/server_master.rb +43 -7
  43. data/lib/clacky/server/web_ui_controller.rb +17 -9
  44. data/lib/clacky/skill.rb +6 -2
  45. data/lib/clacky/tools/run_project.rb +4 -1
  46. data/lib/clacky/tools/shell.rb +7 -1
  47. data/lib/clacky/ui2/ui_controller.rb +1 -5
  48. data/lib/clacky/ui_interface.rb +5 -7
  49. data/lib/clacky/utils/arguments_parser.rb +22 -5
  50. data/lib/clacky/version.rb +1 -1
  51. data/lib/clacky/web/app.css +45 -5
  52. data/lib/clacky/web/app.js +126 -19
  53. data/lib/clacky/web/i18n.js +57 -0
  54. data/lib/clacky/web/sessions.js +108 -39
  55. data/lib/clacky/web/skills.js +8 -2
  56. data/lib/clacky.rb +3 -0
  57. metadata +8 -1
@@ -0,0 +1,484 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module Clacky
7
+ # DeployApiClient - Encapsulates all Deploy API calls for the Railway deployment flow.
8
+ #
9
+ # All endpoints use Workspace API Key (clacky_ak_*) authentication, as the backend
10
+ # supports both clacky_ak_* and clacky_dk_* for all deploy endpoints.
11
+ #
12
+ # Usage:
13
+ # client = DeployApiClient.new("clacky_ak_xxx", base_url: "https://api.clacky.ai")
14
+ #
15
+ # # Check payment status
16
+ # result = client.payment_status(project_id: "proj_abc")
17
+ # # => { success: true, is_paid: true }
18
+ #
19
+ # # Create a deploy task
20
+ # result = client.create_task(project_id: "proj_abc")
21
+ # # => { success: true, deploy_task_id: "...", platform_token: "...", ... }
22
+ #
23
+ # # Poll services until DB is ready
24
+ # result = client.services(deploy_task_id: "task_abc")
25
+ # # => { success: true, services: [...], domain_name: "..." }
26
+ #
27
+ # # Poll deploy status
28
+ # result = client.deploy_status(deploy_task_id: "task_abc")
29
+ # # => { success: true, status: "SUCCESS", url: "https://..." }
30
+ #
31
+ # # Bind domain
32
+ # result = client.bind_domain(deploy_task_id: "task_abc")
33
+ # # => { success: true, domain: "my-app.example.com" }
34
+ #
35
+ # # Notify backend of deploy outcome
36
+ # client.notify(project_id: "...", deploy_task_id: "...", status: "success")
37
+ class DeployApiClient
38
+ BASE_PATH = "/openclacky/v1"
39
+ REQUEST_TIMEOUT = 30 # seconds for normal requests
40
+ OPEN_TIMEOUT = 10 # seconds for connection
41
+
42
+ def initialize(workspace_key, base_url:)
43
+ @workspace_key = workspace_key.to_s.strip
44
+ @base_url = base_url.to_s.strip.sub(%r{/+$}, "")
45
+ end
46
+
47
+ # -------------------------------------------------------------------------
48
+ # Payment
49
+ # -------------------------------------------------------------------------
50
+
51
+ # Query whether the project has an active paid subscription.
52
+ #
53
+ # @param project_id [String]
54
+ # @return [Hash] { success: true, is_paid: Boolean } or { success: false, error: String }
55
+ def payment_status(project_id:)
56
+ response = connection.get("#{BASE_PATH}/deploy/payment") do |req|
57
+ req.params["project_id"] = project_id
58
+ end
59
+
60
+ return http_error(response) unless response.status == 200
61
+
62
+ body = parse_body(response)
63
+ return body_error(body) unless success_code?(body)
64
+
65
+ data = body["data"] || {}
66
+ { success: true, is_paid: data["is_paid"] == true }
67
+ rescue Faraday::Error => e
68
+ { success: false, error: "Network error: #{e.message}" }
69
+ rescue => e
70
+ { success: false, error: "Unexpected error: #{e.message}" }
71
+ end
72
+
73
+ # -------------------------------------------------------------------------
74
+ # Regions
75
+ # -------------------------------------------------------------------------
76
+
77
+ # Fetch the list of supported deployment regions.
78
+ #
79
+ # @param project_id [String] Required by the backend to scope region availability.
80
+ # @return [Hash] {
81
+ # success: true,
82
+ # regions: Array<Hash> # e.g. [{ "id" => "us-west", "name" => "US West", "label" => "US West (Oregon)" }, ...]
83
+ # } or { success: false, error: String }
84
+ def regions(project_id:)
85
+ response = connection.get("#{BASE_PATH}/deploy/regions") do |req|
86
+ req.params["project_id"] = project_id
87
+ end
88
+
89
+ return http_error(response) unless response.status == 200
90
+
91
+ body = parse_body(response)
92
+ return body_error(body) unless success_code?(body)
93
+
94
+ data = body["data"] || {}
95
+ list = data["regions"] || data || []
96
+ list = list.values if list.is_a?(Hash)
97
+ { success: true, regions: Array(list) }
98
+ rescue Faraday::Error => e
99
+ { success: false, error: "Network error: #{e.message}" }
100
+ rescue => e
101
+ { success: false, error: "Unexpected error: #{e.message}" }
102
+ end
103
+
104
+ # -------------------------------------------------------------------------
105
+ # Create Deploy Task
106
+ # -------------------------------------------------------------------------
107
+
108
+ # Create a new deployment task on the backend. Returns Railway credentials.
109
+ #
110
+ # @param project_id [String]
111
+ # @param backup_db [Boolean] default false
112
+ # @param env_vars [Hash] extra env vars to pass at task creation time
113
+ # @param region [String] optional Railway region slug
114
+ # @return [Hash] {
115
+ # success: true,
116
+ # deploy_task_id: String,
117
+ # deploy_service_id: String,
118
+ # platform_token: String, # RAILWAY_TOKEN
119
+ # platform_project_id: String,
120
+ # platform_environment_id: String
121
+ # }
122
+ def create_task(project_id:, backup_db: false, env_vars: {}, region: nil)
123
+ body_params = { project_id: project_id, backup_db: backup_db }
124
+ body_params[:env_vars] = env_vars unless env_vars.empty?
125
+ body_params[:region] = region if region
126
+
127
+ response = connection.post("#{BASE_PATH}/deploy/create-task") do |req|
128
+ req.headers["Content-Type"] = "application/json"
129
+ req.body = JSON.generate(body_params)
130
+ end
131
+
132
+ return http_error(response) unless response.status == 200
133
+
134
+ body = parse_body(response)
135
+ return body_error(body) unless success_code?(body)
136
+
137
+ data = body["data"] || {}
138
+ {
139
+ success: true,
140
+ deploy_task_id: data["deploy_task_id"],
141
+ deploy_service_id: data["deploy_service_id"],
142
+ platform_token: data["platform_token"],
143
+ platform_project_id: data["platform_project_id"],
144
+ platform_environment_id: data["platform_environment_id"]
145
+ }
146
+ rescue Faraday::Error => e
147
+ { success: false, error: "Network error: #{e.message}" }
148
+ rescue => e
149
+ { success: false, error: "Unexpected error: #{e.message}" }
150
+ end
151
+
152
+ # -------------------------------------------------------------------------
153
+ # Services (poll for middleware readiness)
154
+ # -------------------------------------------------------------------------
155
+
156
+ # Query all services under a deploy task.
157
+ # Used to wait for the PostgreSQL middleware to reach status SUCCESS
158
+ # before injecting the DATABASE_URL reference.
159
+ #
160
+ # @param deploy_task_id [String]
161
+ # @return [Hash] {
162
+ # success: true,
163
+ # services: Array<Hash>, # full service objects from API
164
+ # domain_name: String, # assigned domain (may be empty on first call)
165
+ # db_service: Hash | nil # first middleware service with status SUCCESS
166
+ # }
167
+ def services(deploy_task_id:)
168
+ url = "#{BASE_PATH}/deploy/services?deploy_task_id=#{deploy_task_id}"
169
+ puts " [DEBUG API] GET #{@base_url}#{url}"
170
+
171
+ response = connection.get("#{BASE_PATH}/deploy/services") do |req|
172
+ req.params["deploy_task_id"] = deploy_task_id
173
+ end
174
+
175
+ puts " [DEBUG API] Response status: #{response.status}"
176
+
177
+ return http_error(response) unless response.status == 200
178
+
179
+ body = parse_body(response)
180
+ puts " [DEBUG API] Response body: #{body.inspect[0..500]}..." if body
181
+
182
+ return body_error(body) unless success_code?(body)
183
+
184
+ data = body["data"] || {}
185
+ svcs = data["services"] || []
186
+ domain = data["domain_name"].to_s
187
+
188
+ # Debug: print detailed service info
189
+ puts " [DEBUG] Total services returned: #{svcs.size}"
190
+ svcs.each_with_index do |s, idx|
191
+ puts " [DEBUG] Service[#{idx}]: name=#{s['service_name']}, type=#{s['type']}, status=#{s['status']}"
192
+ if s["type"] == "middleware"
193
+ env_vars = s["env_vars"] || {}
194
+ puts " [DEBUG] - env_vars keys: #{env_vars.keys.join(', ')}"
195
+ puts " [DEBUG] - has DATABASE_URL: #{env_vars.key?('DATABASE_URL')}"
196
+ puts " [DEBUG] - has DATABASE_PUBLIC_URL: #{env_vars.key?('DATABASE_PUBLIC_URL')}"
197
+ end
198
+ end
199
+
200
+ # Find first middleware (DB) that is fully provisioned
201
+ db_svc = svcs.find do |s|
202
+ s["type"] == "middleware" && s["status"]&.upcase == "SUCCESS"
203
+ end
204
+
205
+ puts " [DEBUG] db_svc found: #{!db_svc.nil?}"
206
+ if db_svc
207
+ puts " [DEBUG] - db_svc name: #{db_svc['service_name']}"
208
+ puts " [DEBUG] - db_svc status: #{db_svc['status']}"
209
+ end
210
+
211
+ # middleware_support: { supported: Boolean, supported_types: Array }
212
+ # When supported == false, no DB middleware will be provisioned by Clacky.
213
+ # The deploy script uses this to skip the DB polling loop entirely.
214
+ middleware_support = data["middleware_support"] || {}
215
+ puts " [DEBUG] middleware_support: #{middleware_support.inspect}"
216
+
217
+ # platform_bucket_credentials contains S3-compatible storage credentials.
218
+ # Passed through so the deploy script can inject STORAGE_BUCKET_* env vars.
219
+ bucket_credentials = data["platform_bucket_credentials"]
220
+ bucket_name = data["platform_bucket_name"].to_s
221
+
222
+ {
223
+ success: true,
224
+ services: svcs,
225
+ domain_name: domain,
226
+ db_service: db_svc,
227
+ middleware_support: middleware_support,
228
+ bucket_credentials: bucket_credentials,
229
+ bucket_name: bucket_name
230
+ }
231
+ rescue Faraday::Error => e
232
+ { success: false, error: "Network error: #{e.message}" }
233
+ rescue => e
234
+ { success: false, error: "Unexpected error: #{e.message}" }
235
+ end
236
+
237
+ # -------------------------------------------------------------------------
238
+ # Deploy Status
239
+ # -------------------------------------------------------------------------
240
+
241
+ # Query the real-time deployment status for a task.
242
+ #
243
+ # @param deploy_task_id [String]
244
+ # @return [Hash] {
245
+ # success: true,
246
+ # status: String, # SUCCESS / FAILED / CRASHED / DEPLOYING / WAITING
247
+ # url: String
248
+ # }
249
+ def deploy_status(deploy_task_id:)
250
+ response = connection.get("#{BASE_PATH}/deploy/status") do |req|
251
+ req.params["deploy_task_id"] = deploy_task_id
252
+ end
253
+
254
+ return http_error(response) unless response.status == 200
255
+
256
+ body = parse_body(response)
257
+ return body_error(body) unless success_code?(body)
258
+
259
+ data = body["data"] || {}
260
+ {
261
+ success: true,
262
+ status: data["status"].to_s.upcase,
263
+ url: data["url"].to_s,
264
+ deploy_service_id: data["deploy_service_id"].to_s
265
+ }
266
+ rescue Faraday::Error => e
267
+ { success: false, error: "Network error: #{e.message}" }
268
+ rescue => e
269
+ { success: false, error: "Unexpected error: #{e.message}" }
270
+ end
271
+
272
+ # -------------------------------------------------------------------------
273
+ # Bind Domain
274
+ # -------------------------------------------------------------------------
275
+
276
+ # Bind a custom domain to the deploy task.
277
+ #
278
+ # @param deploy_task_id [String]
279
+ # @return [Hash] { success: true, domain: String } or { success: false, error: String }
280
+ def bind_domain(deploy_task_id:)
281
+ response = connection.post("#{BASE_PATH}/deploy/bind-domain") do |req|
282
+ req.headers["Content-Type"] = "application/json"
283
+ req.body = JSON.generate({ deploy_task_id: deploy_task_id })
284
+ end
285
+
286
+ return http_error(response) unless response.status == 200
287
+
288
+ body = parse_body(response)
289
+ return body_error(body) unless success_code?(body)
290
+
291
+ data = body["data"] || {}
292
+ { success: true, domain: data["domain"].to_s }
293
+ rescue Faraday::Error => e
294
+ { success: false, error: "Network error: #{e.message}" }
295
+ rescue => e
296
+ { success: false, error: "Unexpected error: #{e.message}" }
297
+ end
298
+
299
+ # -------------------------------------------------------------------------
300
+ # Build Logs
301
+ # -------------------------------------------------------------------------
302
+
303
+ # Fetch build logs for a deploy task (synchronous, not SSE).
304
+ #
305
+ # @param deploy_task_id [String]
306
+ # @param service_id [String, nil] optional service ID filter
307
+ # @param level [String] log level filter ("INFO", "ERROR", "WARN", etc.)
308
+ # @param lines [Integer] maximum number of lines to return (default: 100)
309
+ # @return [Hash] {
310
+ # success: true,
311
+ # logs: Array<Hash> # [{ "timestamp" => ..., "level" => "INFO", "message" => "..." }, ...]
312
+ # } or { success: false, error: String }
313
+ def build_logs(deploy_task_id:, service_id: nil, level: "INFO", lines: 100)
314
+ body_params = {
315
+ deploy_task_id: deploy_task_id,
316
+ level: level,
317
+ lines: lines
318
+ }
319
+ body_params[:service_id] = service_id if service_id
320
+
321
+ response = connection.post("#{BASE_PATH}/tasks/logs") do |req|
322
+ req.headers["Content-Type"] = "application/json"
323
+ req.body = JSON.generate(body_params)
324
+ end
325
+
326
+ return http_error(response) unless response.status == 200
327
+
328
+ body = parse_body(response)
329
+ return body_error(body) unless success_code?(body)
330
+
331
+ data = body["data"] || {}
332
+ logs = data["logs"] || []
333
+ { success: true, logs: logs }
334
+ rescue Faraday::Error => e
335
+ { success: false, error: "Network error: #{e.message}" }
336
+ rescue => e
337
+ { success: false, error: "Unexpected error: #{e.message}" }
338
+ end
339
+
340
+ # Stream build logs using SSE (Server-Sent Events).
341
+ # This method yields each log line as it arrives.
342
+ #
343
+ # @param deploy_task_id [String]
344
+ # @param service_id [String, nil] optional service ID filter
345
+ # @param level [String] log level filter ("INFO", "ERROR", "WARN", etc.)
346
+ # @yield [Hash] each log event: { "type" => "log", "timestamp" => ..., "message" => "..." }
347
+ # @return [Hash] { success: true } or { success: false, error: String }
348
+ def stream_build_logs(deploy_task_id:, service_id: nil, level: "INFO", &block)
349
+ require "net/http"
350
+ require "openssl"
351
+
352
+ body_params = {
353
+ deploy_task_id: deploy_task_id,
354
+ level: level
355
+ }
356
+ body_params[:service_id] = service_id if service_id
357
+
358
+ url = "#{@base_url}#{BASE_PATH}/tasks/stream/build-logs"
359
+ uri = URI.parse(url)
360
+
361
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", verify_mode: OpenSSL::SSL::VERIFY_NONE) do |http|
362
+ request = Net::HTTP::Post.new(uri.path)
363
+ request["Authorization"] = "Bearer #{@workspace_key}"
364
+ request["Accept"] = "text/event-stream"
365
+ request["Content-Type"] = "application/json"
366
+ request.body = JSON.generate(body_params)
367
+
368
+ http.request(request) do |response|
369
+ return { success: false, error: "HTTP #{response.code}: #{response.message}" } unless response.code.to_i == 200
370
+
371
+ buffer = ""
372
+ response.read_body do |chunk|
373
+ buffer << chunk
374
+ while (line_end = buffer.index("\n"))
375
+ line = buffer.slice!(0..line_end).strip
376
+ next if line.empty? || !line.start_with?("data:")
377
+
378
+ json_str = line.sub(/^data:\s*/, "")
379
+ begin
380
+ event = JSON.parse(json_str)
381
+ block.call(event) if block
382
+ rescue JSON::ParserError
383
+ # Ignore malformed JSON
384
+ end
385
+ end
386
+ end
387
+ end
388
+ end
389
+
390
+ { success: true }
391
+ rescue => e
392
+ { success: false, error: "Stream error: #{e.message}" }
393
+ end
394
+
395
+ # -------------------------------------------------------------------------
396
+ # Notify
397
+ # -------------------------------------------------------------------------
398
+
399
+ # Notify the backend of the current deployment outcome.
400
+ # Fire-and-forget — failures are logged but do not raise.
401
+ #
402
+ # @param project_id [String]
403
+ # @param deploy_task_id [String]
404
+ # @param deploy_service_id [String] optional
405
+ # @param status [String] "deploying" | "success" | "failed"
406
+ # @param message [String] optional description
407
+ # @param target_port [Integer] default 3000
408
+ # @return [Hash] { success: true } or { success: false, error: String }
409
+ def notify(project_id:, deploy_task_id:, status:,
410
+ deploy_service_id: nil, message: nil, target_port: nil)
411
+ payload = {
412
+ project_id: project_id,
413
+ deploy_task_id: deploy_task_id,
414
+ status: status
415
+ }
416
+ payload[:deploy_service_id] = deploy_service_id if deploy_service_id
417
+ payload[:message] = message if message
418
+ payload[:target_port] = target_port if target_port
419
+
420
+ response = connection.post("#{BASE_PATH}/deploy/notify") do |req|
421
+ req.headers["Content-Type"] = "application/json"
422
+ req.body = JSON.generate(payload)
423
+ end
424
+
425
+ return http_error(response) unless response.status == 200
426
+
427
+ body = parse_body(response)
428
+ return body_error(body) unless success_code?(body)
429
+
430
+ { success: true }
431
+ rescue => e
432
+ # Notify failures are non-fatal — log and move on
433
+ warn "[deploy_api] notify failed: #{e.message}"
434
+ { success: false, error: e.message }
435
+ end
436
+
437
+ # -------------------------------------------------------------------------
438
+ # Private helpers
439
+ # -------------------------------------------------------------------------
440
+
441
+ private def connection
442
+ @connection ||= Faraday.new(url: @base_url) do |f|
443
+ f.options.timeout = REQUEST_TIMEOUT
444
+ f.options.open_timeout = OPEN_TIMEOUT
445
+ f.headers["Authorization"] = "Bearer #{@workspace_key}"
446
+ f.headers["Accept"] = "application/json"
447
+ # Disable SSL verification to avoid OpenSSL certificate path issues
448
+ # on some macOS environments with system Ruby
449
+ f.ssl.verify = false
450
+ f.adapter Faraday.default_adapter
451
+ end
452
+ end
453
+
454
+ private def parse_body(response)
455
+ JSON.parse(response.body)
456
+ rescue JSON::ParserError
457
+ nil
458
+ end
459
+
460
+ private def success_code?(body)
461
+ return false if body.nil?
462
+
463
+ code = body["code"].to_i
464
+ code == 0 || code == 200
465
+ end
466
+
467
+ private def http_error(response)
468
+ msg = begin
469
+ parsed = JSON.parse(response.body)
470
+ parsed["message"] || parsed["msg"] || response.body.to_s[0, 200]
471
+ rescue
472
+ response.body.to_s[0, 200]
473
+ end
474
+ { success: false, error: "HTTP #{response.status}: #{msg}" }
475
+ end
476
+
477
+ private def body_error(body)
478
+ return { success: false, error: "Invalid JSON response from API" } if body.nil?
479
+
480
+ msg = body["message"] || body["msg"] || "Unknown API error (code: #{body["code"]})"
481
+ { success: false, error: msg }
482
+ end
483
+ end
484
+ end
@@ -97,10 +97,6 @@ module Clacky
97
97
  emit("info", message: message)
98
98
  end
99
99
 
100
- def show_idle_status(phase:, message:)
101
- emit("idle_status", phase: phase.to_s, message: message)
102
- end
103
-
104
100
  def show_warning(message)
105
101
  emit("warning", message: message)
106
102
  end
@@ -119,15 +115,25 @@ module Clacky
119
115
 
120
116
  # === Progress ===
121
117
 
122
- def show_progress(message = nil, prefix_newline: true, output_buffer: nil)
123
- @progress_start_time = Time.now
124
- emit("progress", message: message, status: "start")
118
+ def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {})
119
+ @progress_start_time = Time.now if phase == "active"
120
+
121
+ data = {
122
+ message: message,
123
+ progress_type: progress_type,
124
+ phase: phase,
125
+ status: phase == "active" ? "start" : "stop" # backward compat
126
+ }
127
+ data[:metadata] = metadata unless metadata.empty?
128
+ data[:elapsed] = (Time.now - @progress_start_time).round(1) if phase == "done" && @progress_start_time
129
+
130
+ emit("progress", **data)
131
+
132
+ @progress_start_time = nil if phase == "done"
125
133
  end
126
134
 
127
135
  def clear_progress
128
- elapsed = @progress_start_time ? (Time.now - @progress_start_time).round(1) : 0
129
- @progress_start_time = nil
130
- emit("progress", status: "stop", elapsed: elapsed)
136
+ show_progress(progress_type: "thinking", phase: "done")
131
137
  end
132
138
 
133
139
  # === State updates ===
@@ -17,11 +17,12 @@ module Clacky
17
17
  # This module converts canonical format ↔ Bedrock Converse API format.
18
18
  module Bedrock
19
19
  # Detect if the request should use the Bedrock Converse API.
20
- # Matches either:
20
+ # Matches any of:
21
21
  # - API key with "ABSK" prefix (native AWS Bedrock)
22
+ # - API key with "clacky-" prefix (Clacky workspace key, proxied via Bedrock Converse)
22
23
  # - Model ID with "abs-" prefix (Clacky AI proxy that speaks Bedrock Converse)
23
24
  def self.bedrock_api_key?(api_key, model)
24
- api_key.to_s.start_with?("ABSK") || model.to_s.start_with?("abs-")
25
+ api_key.to_s.start_with?("ABSK", "clacky-") || model.to_s.start_with?("abs-")
25
26
  end
26
27
 
27
28
  module_function
@@ -12,6 +12,7 @@ module Clacky
12
12
  task_id created_at system_injected session_context memory_update
13
13
  subagent_instructions subagent_result token_usage
14
14
  compressed_summary chunk_path truncated transient
15
+ chunk_index chunk_count
15
16
  ].freeze
16
17
 
17
18
  def initialize(messages = [])
@@ -123,6 +124,13 @@ module Clacky
123
124
  msg&.dig(:session_date)
124
125
  end
125
126
 
127
+ # Return the chunk_count from the most recently injected chunk index message.
128
+ # Used by inject_chunk_index_if_needed to avoid re-injecting when nothing changed.
129
+ def last_injected_chunk_count
130
+ msg = @messages.reverse.find { |m| m[:chunk_index] }
131
+ msg&.dig(:chunk_count) || 0
132
+ end
133
+
126
134
  # Return only real (non-system-injected) user messages.
127
135
  def real_user_messages
128
136
  @messages.select { |m| m[:role] == "user" && !m[:system_injected] }
@@ -87,11 +87,6 @@ module Clacky
87
87
  puts_line("[info] #{message}")
88
88
  end
89
89
 
90
- def show_idle_status(phase:, message:)
91
- # In plain mode, just print the final state
92
- puts_line("[info] #{message}") if phase.to_s == "end"
93
- end
94
-
95
90
  def show_warning(message)
96
91
  puts_line("[warn] #{message}")
97
92
  end
@@ -111,7 +106,7 @@ module Clacky
111
106
 
112
107
  # === Progress (no-ops — no spinner in plain mode) ===
113
108
 
114
- def show_progress(message = nil, prefix_newline: true, output_buffer: nil); end
109
+ def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {}); end
115
110
  def clear_progress; end
116
111
 
117
112
  # === State updates (no-ops) ===
@@ -11,13 +11,31 @@ module Clacky
11
11
  # - api: API type (anthropic-messages, openai-responses, openai-completions)
12
12
  # - default_model: Recommended default model
13
13
  PRESETS = {
14
+ "openclacky" => {
15
+ "name" => "OpenClacky",
16
+ "base_url" => "https://api.openclacky.com",
17
+ "api" => "bedrock",
18
+ "default_model" => "abs-claude-sonnet-4-5",
19
+ "lite_model" => "abs-claude-haiku-4-5",
20
+ "models" => [
21
+ "abs-claude-opus-4-6",
22
+ "abs-claude-sonnet-4-6",
23
+ "abs-claude-sonnet-4-5",
24
+ "abs-claude-haiku-4-5"
25
+ ],
26
+ # Fallback chain: if a model is unavailable, try the next one in order.
27
+ # Keys are primary model names; values are the fallback model to use instead.
28
+ "fallback_models" => {
29
+ "abs-claude-sonnet-4-6" => "abs-claude-sonnet-4-5"
30
+ },
31
+ "website_url" => "https://www.openclacky.com/ai-keys"
32
+ }.freeze,
14
33
 
15
34
  "openrouter" => {
16
35
  "name" => "OpenRouter",
17
36
  "base_url" => "https://openrouter.ai/api/v1",
18
37
  "api" => "openai-responses",
19
38
  "default_model" => "anthropic/claude-sonnet-4-6",
20
- "lite_model" => "anthropic/claude-haiku-4-5",
21
39
  "models" => [], # Dynamic - fetched from API
22
40
  "website_url" => "https://openrouter.ai/keys"
23
41
  }.freeze,
@@ -49,15 +67,16 @@ module Clacky
49
67
  "website_url" => "https://console.anthropic.com/settings/keys"
50
68
  }.freeze,
51
69
 
52
- "clackyai" => {
53
- "name" => "ClackyAI",
70
+ "clackyai-sea" => {
71
+ "name" => "ClackyAI( Sea )",
54
72
  "base_url" => "https://api.clacky.ai",
55
73
  "api" => "bedrock",
56
- "default_model" => "abs-claude-sonnet-4-6",
74
+ "default_model" => "abs-claude-sonnet-4-5",
57
75
  "lite_model" => "abs-claude-haiku-4-5",
58
76
  "models" => [
59
77
  "abs-claude-opus-4-6",
60
78
  "abs-claude-sonnet-4-6",
79
+ "abs-claude-sonnet-4-5",
61
80
  "abs-claude-haiku-4-5"
62
81
  ],
63
82
  # Fallback chain: if a model is unavailable, try the next one in order.
@@ -237,7 +237,9 @@ module Clacky
237
237
  cmd = build_mcp_command(detected)
238
238
  Clacky::Logger.info("[BrowserManager] Starting MCP daemon: #{cmd.join(' ')}")
239
239
 
240
- stdin, stdout, stderr_io, wait_thr = Open3.popen3(*cmd)
240
+ # close_others: true prevents inheriting the server's listening socket (port 7070).
241
+ # The MCP daemon is an independent external process and should not hold server fds.
242
+ stdin, stdout, stderr_io, wait_thr = Open3.popen3(*cmd, close_others: true)
241
243
  Thread.new { stderr_io.read rescue nil }
242
244
 
243
245
  # MCP handshake
@@ -130,7 +130,8 @@ module Clacky
130
130
  end
131
131
  rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE,
132
132
  Errno::ETIMEDOUT, OpenSSL::SSL::SSLError => e
133
- Clacky::Logger.warn("[feishu-ws] Connection lost (#{e.class}: #{e.message}), reconnecting in #{RECONNECT_DELAY}s...")
133
+ # Let the exception bubble up to start() where it will log and sleep before retry
134
+ raise
134
135
  ensure
135
136
  @ws_open = false
136
137
  @ws_socket = nil
@@ -361,7 +361,9 @@ module Clacky
361
361
 
362
362
  send_frame(cmd: cmd, req_id: req_id, body: body)
363
363
 
364
- result = queue.pop(timeout: 30)
364
+ timeout_thread = Thread.new { sleep 30; queue.push(nil) }
365
+ result = queue.pop
366
+ timeout_thread.kill
365
367
  raise "Timeout waiting for ack (req_id=#{req_id}, cmd=#{cmd})" if result.nil?
366
368
 
367
369
  errcode = result["errcode"] || result.dig("body", "errcode")