openclacky 1.2.14 → 1.2.15

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: 82874a3ac7c623672bd09b5fa1be1c5dd70b1f223119a1b58b86f85417e46f1c
4
- data.tar.gz: ba5f1cc02f50a0bee31e24a6ad009c265881eef8b8b9efa6f17b5bec29124414
3
+ metadata.gz: 84e7378b08b627bad34d327d1bd82cc7efbfe980a690d64e678242917be8125d
4
+ data.tar.gz: 87e4c1b8e99f2195c98c85124816503fd11436b3bdb38465bb7289fab1204fa3
5
5
  SHA512:
6
- metadata.gz: 5535350a83909fffe2471ab0f6505d54f9bc2436826636eacb1c8d6bbbd84e554087b31b9503d6671449789160072eb743711556f302dc42b820637b7edab83d
7
- data.tar.gz: bcdec5ed7e56cfc27ee2370ae46582239fa425254e013a7577f162b28c6e2d88b821768f2e367fe2b33dd6773804740692f1f29cba0ca2d73f40d38f6b8e2243
6
+ metadata.gz: 36cb343f4a81222b3a2861dcd80529f0d3216a341e19ea7ebfd1ea6dbebded9c0d31a212a645cffb7268e9faf844be9f257d739677a17d0387bf033970dc7675
7
+ data.tar.gz: 16d08fc33223c56024a27072de5a0f435ac969c1ac55fffa39738e0e8d9bfe77f1ebbe4c4cafb8793c2dac1367744daeb417b97db9a854ba7e04ffd4d2b17042
data/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.15] - 2026-06-10
9
+
10
+ ### Added
11
+ - Proxy configuration support
12
+ - Optional sound notification on task completion in Web UI
13
+
14
+ ### Fixed
15
+ - Prevent scheduler thread from dying on tick exception
16
+
17
+ ### More
18
+ - Tool diff CSS refinement
19
+
8
20
  ## [1.2.14] - 2026-06-08
9
21
 
10
22
  ### Added
@@ -164,7 +164,8 @@ module Clacky
164
164
  :models, :current_model_index, :current_model_id,
165
165
  :memory_update_enabled, :skill_evolution,
166
166
  :max_running_agents, :max_idle_agents,
167
- :default_working_dir
167
+ :default_working_dir,
168
+ :proxy_url
168
169
 
169
170
  def initialize(options = {})
170
171
  @permission_mode = validate_permission_mode(options[:permission_mode])
@@ -217,6 +218,11 @@ module Clacky
217
218
 
218
219
  @default_working_dir = options[:default_working_dir] || ENV["CLACKY_WORKSPACE_DIR"]
219
220
 
221
+ # HTTP proxy policy. The user's shell ENV (HTTP_PROXY etc.) is always
222
+ # ignored — set proxy_url here to route Clacky's outbound HTTP through
223
+ # a proxy. Leave nil to go direct.
224
+ @proxy_url = options[:proxy_url]
225
+
220
226
  # Per-session virtual model overlay.
221
227
  # When set, #current_model returns a *merged* hash (the resolved @models
222
228
  # entry merged with this overlay) without mutating the shared @models
@@ -390,6 +396,7 @@ module Clacky
390
396
  FileUtils.mkdir_p(config_dir)
391
397
  File.write(config_file, to_yaml)
392
398
  FileUtils.chmod(0o600, config_file)
399
+ Clacky::ProxyConfig.reset_cache! if defined?(Clacky::ProxyConfig)
393
400
  end
394
401
 
395
402
  # Convert to YAML format (top-level array)
@@ -407,6 +414,7 @@ module Clacky
407
414
  memory_update_enabled
408
415
  skill_evolution max_running_agents max_idle_agents
409
416
  default_working_dir
417
+ proxy_url
410
418
  ].freeze
411
419
 
412
420
  # Serialize the current agent configuration to YAML.
@@ -425,7 +433,8 @@ module Clacky
425
433
  "skill_evolution" => @skill_evolution,
426
434
  "max_running_agents" => @max_running_agents,
427
435
  "max_idle_agents" => @max_idle_agents,
428
- "default_working_dir" => @default_working_dir
436
+ "default_working_dir" => @default_working_dir,
437
+ "proxy_url" => @proxy_url
429
438
  }
430
439
  YAML.dump("settings" => settings, "models" => persistable_models)
431
440
  end
data/lib/clacky/client.rb CHANGED
@@ -516,61 +516,64 @@ module Clacky
516
516
  end
517
517
 
518
518
  def bedrock_connection
519
- @bedrock_connection ||= Faraday.new(url: @base_url) do |conn|
520
- conn.headers["Content-Type"] = "application/json"
521
- conn.headers["Authorization"] = "Bearer #{@api_key}"
522
- conn.options.timeout = @read_timeout || 300
523
- conn.options.open_timeout = 10
524
- conn.ssl.verify = false
525
- conn.adapter Faraday.default_adapter
519
+ current_epoch = Clacky::ProxyConfig.epoch
520
+ if @bedrock_connection.nil? ||
521
+ (!@bedrock_connection_epoch.nil? && @bedrock_connection_epoch != current_epoch)
522
+ @bedrock_connection = Faraday.new(url: @base_url) do |conn|
523
+ conn.headers["Content-Type"] = "application/json"
524
+ conn.headers["Authorization"] = "Bearer #{@api_key}"
525
+ conn.options.timeout = @read_timeout || 300
526
+ conn.options.open_timeout = 10
527
+ conn.ssl.verify = false
528
+ conn.adapter Faraday.default_adapter
529
+ end
530
+ @bedrock_connection_epoch = current_epoch
526
531
  end
532
+ @bedrock_connection
527
533
  end
528
534
 
529
535
  def openai_connection
530
- @openai_connection ||= Faraday.new(url: @base_url) do |conn|
531
- conn.headers["Content-Type"] = "application/json"
532
- conn.headers["Authorization"] = "Bearer #{@api_key}"
533
- conn.options.timeout = @read_timeout || 300
534
- conn.options.open_timeout = 10
535
- conn.ssl.verify = false
536
- conn.adapter Faraday.default_adapter
536
+ current_epoch = Clacky::ProxyConfig.epoch
537
+ if @openai_connection.nil? ||
538
+ (!@openai_connection_epoch.nil? && @openai_connection_epoch != current_epoch)
539
+ @openai_connection = Faraday.new(url: @base_url) do |conn|
540
+ conn.headers["Content-Type"] = "application/json"
541
+ conn.headers["Authorization"] = "Bearer #{@api_key}"
542
+ conn.options.timeout = @read_timeout || 300
543
+ conn.options.open_timeout = 10
544
+ conn.ssl.verify = false
545
+ conn.adapter Faraday.default_adapter
546
+ end
547
+ @openai_connection_epoch = current_epoch
537
548
  end
549
+ @openai_connection
538
550
  end
539
551
 
540
552
  def anthropic_connection
541
- @anthropic_connection ||= Faraday.new(url: @base_url) do |conn|
542
- conn.headers["Content-Type"] = "application/json"
543
- conn.headers["x-api-key"] = @api_key
544
- conn.headers["anthropic-version"] = "2023-06-01"
545
- conn.headers["anthropic-dangerous-direct-browser-access"] = "true"
546
- # OpenRouter's /v1/messages endpoint authenticates with a Bearer
547
- # token (the OpenRouter API key), not Anthropic's x-api-key. We send
548
- # both so the same connection code works for direct Anthropic and
549
- # for OpenRouter-proxied Claude — each endpoint ignores the header
550
- # it doesn't recognise.
551
- if @provider_id == "openrouter"
552
- conn.headers["Authorization"] = "Bearer #{@api_key}"
553
- end
554
- # Moonshot's Kimi Code (Coding Plan) endpoint enforces a User-Agent
555
- # prefix whitelist limited to first-party coding agents (Kimi CLI,
556
- # Claude Code, Roo Code, Kilo Code, ...). Requests with the default
557
- # Faraday UA are rejected with HTTP 403 access_terminated_error,
558
- # despite a valid API key. We send a Claude Code-shaped UA here
559
- # because openclacky talks to this endpoint over the same Anthropic
560
- # /v1/messages protocol that Claude Code uses, so the UA matches the
561
- # wire-level behaviour. Hardcoding rather than exposing as a config
562
- # field is intentional: the only UAs known to pass the gate are the
563
- # whitelisted-client formats, and the project's preset registry is
564
- # the single source of truth for provider-specific quirks (mirroring
565
- # how the openrouter Bearer-fallback above is hardcoded).
566
- if @provider_id == "kimi-coding"
567
- conn.headers["User-Agent"] = "claude-cli/1.0.51 (external, cli)"
553
+ current_epoch = Clacky::ProxyConfig.epoch
554
+ if @anthropic_connection.nil? ||
555
+ (!@anthropic_connection_epoch.nil? && @anthropic_connection_epoch != current_epoch)
556
+ @anthropic_connection = Faraday.new(url: @base_url) do |conn|
557
+ conn.headers["Content-Type"] = "application/json"
558
+ conn.headers["x-api-key"] = @api_key
559
+ conn.headers["anthropic-version"] = "2023-06-01"
560
+ conn.headers["anthropic-dangerous-direct-browser-access"] = "true"
561
+ if @provider_id == "openrouter"
562
+ conn.headers["Authorization"] = "Bearer #{@api_key}"
563
+ end
564
+ # Moonshot's Kimi Code (Coding Plan) endpoint enforces a User-Agent
565
+ # prefix whitelist limited to first-party coding agents.
566
+ if @provider_id == "kimi-coding"
567
+ conn.headers["User-Agent"] = "claude-cli/1.0.51 (external, cli)"
568
+ end
569
+ conn.options.timeout = @read_timeout || 300
570
+ conn.options.open_timeout = 10
571
+ conn.ssl.verify = false
572
+ conn.adapter Faraday.default_adapter
568
573
  end
569
- conn.options.timeout = @read_timeout || 300
570
- conn.options.open_timeout = 10
571
- conn.ssl.verify = false
572
- conn.adapter Faraday.default_adapter
574
+ @anthropic_connection_epoch = current_epoch
573
575
  end
576
+ @anthropic_connection
574
577
  end
575
578
 
576
579
  # Correct relative path for the Anthropic /v1/messages endpoint, accounting
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ # Centralized HTTP proxy policy for the current process.
5
+ #
6
+ # Single source of truth: AgentConfig#proxy_url. We never honour the user's
7
+ # shell ENV (HTTP_PROXY etc.) — it's stripped on every install! so a stale
8
+ # proxy in the launching shell can't poison Clacky.
9
+ #
10
+ # epoch increments on every actual change so that long-lived consumers
11
+ # (e.g. Faraday connections cached on Client instances) can detect when
12
+ # their cached state is stale and rebuild.
13
+ module ProxyConfig
14
+ PROXY_ENV_KEYS = %w[
15
+ http_proxy HTTP_PROXY
16
+ https_proxy HTTPS_PROXY
17
+ all_proxy ALL_PROXY
18
+ ].freeze
19
+
20
+ @installed_signature = nil
21
+ @epoch = 0
22
+
23
+ class << self
24
+ attr_reader :epoch
25
+
26
+ def install!
27
+ url = load_proxy_url
28
+ sig = url
29
+ return if sig == @installed_signature
30
+
31
+ strip_env_proxy
32
+ assign_env_proxy(url) if url && !url.empty?
33
+ ensure_faraday_reads_env
34
+
35
+ @installed_signature = sig
36
+ @epoch += 1
37
+ end
38
+
39
+ def reset_cache!
40
+ @installed_signature = nil
41
+ install!
42
+ end
43
+
44
+ private def assign_env_proxy(url)
45
+ %w[http_proxy HTTP_PROXY https_proxy HTTPS_PROXY].each { |k| ENV[k] = url }
46
+ end
47
+
48
+ private def strip_env_proxy
49
+ PROXY_ENV_KEYS.each { |k| ENV.delete(k) }
50
+ end
51
+
52
+ private def ensure_faraday_reads_env
53
+ return unless defined?(Faraday)
54
+ Faraday.ignore_env_proxy = false if Faraday.respond_to?(:ignore_env_proxy=)
55
+ end
56
+
57
+ private def load_proxy_url
58
+ cfg = Clacky::AgentConfig.load
59
+ cfg.respond_to?(:proxy_url) ? cfg.proxy_url.to_s.strip : ""
60
+ rescue StandardError
61
+ ""
62
+ end
63
+ end
64
+ end
65
+ end
@@ -3917,7 +3917,8 @@ module Clacky
3917
3917
  ok: true,
3918
3918
  enable_compression: @agent_config.enable_compression,
3919
3919
  enable_prompt_caching: @agent_config.enable_prompt_caching,
3920
- memory_update_enabled: @agent_config.memory_update_enabled
3920
+ memory_update_enabled: @agent_config.memory_update_enabled,
3921
+ proxy_url: @agent_config.proxy_url.to_s
3921
3922
  })
3922
3923
  end
3923
3924
 
@@ -3935,6 +3936,22 @@ module Clacky
3935
3936
  if body.key?("memory_update_enabled")
3936
3937
  @agent_config.memory_update_enabled = !!body["memory_update_enabled"]
3937
3938
  end
3939
+ if body.key?("proxy_url")
3940
+ raw = body["proxy_url"].to_s.strip
3941
+ if raw.empty?
3942
+ @agent_config.proxy_url = nil
3943
+ else
3944
+ begin
3945
+ uri = URI.parse(raw)
3946
+ unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty?
3947
+ return json_response(res, 422, { error: "proxy_url must be a valid http(s) URL" })
3948
+ end
3949
+ rescue URI::InvalidURIError
3950
+ return json_response(res, 422, { error: "proxy_url is not a valid URL" })
3951
+ end
3952
+ @agent_config.proxy_url = raw
3953
+ end
3954
+ end
3938
3955
 
3939
3956
  @agent_config.save
3940
3957
  json_response(res, 200, { ok: true })
@@ -4953,6 +4970,11 @@ module Clacky
4953
4970
  task.call
4954
4971
  @registry.update(session_id, status: :idle, error: nil)
4955
4972
  broadcast_session_update(session_id)
4973
+ # Transient global signal for the optional task-complete sound. Sent to
4974
+ # all clients (broadcast_all) so a browser viewing another session — or
4975
+ # with the tab/window in the background — can still chime. Not part of
4976
+ # session history: a chime is a live cue, never replayed on refresh.
4977
+ broadcast_all(type: "task_finished", session_id: session_id)
4956
4978
  @session_manager.save(agent.to_session_data(status: :success))
4957
4979
  # Start idle compression timer now that the agent is idle
4958
4980
  idle_timer&.start
@@ -192,17 +192,20 @@ module Clacky
192
192
 
193
193
  private def run_loop
194
194
  loop do
195
- break unless @running
196
-
197
- tick(Time.now)
198
-
199
- # Sleep until the start of the next minute
200
- now = Time.now
201
- sleep_s = 60 - now.sec
202
- sleep(sleep_s)
195
+ begin
196
+ break unless @running
197
+
198
+ tick(Time.now)
199
+
200
+ # Sleep until the start of the next minute
201
+ now = Time.now
202
+ sleep_s = 60 - now.sec
203
+ sleep(sleep_s)
204
+ rescue => e
205
+ Clacky::Logger.error("scheduler_tick_error", error: e)
206
+ sleep(5) # back off before retrying next tick
207
+ end
203
208
  end
204
- rescue => e
205
- Clacky::Logger.error("scheduler_fatal_error", error: e)
206
209
  end
207
210
 
208
211
  # Check all enabled schedules against the given time and fire matching ones.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.2.14"
4
+ VERSION = "1.2.15"
5
5
  end
@@ -325,6 +325,13 @@ body {
325
325
  .theme-toggle-btn:active {
326
326
  background: var(--color-bg-hover);
327
327
  }
328
+ /* Sound-notification toggle shares .theme-toggle-btn; highlight when ON. */
329
+ #notify-toggle-header.notify-on {
330
+ color: var(--color-accent-primary, var(--color-text-primary));
331
+ }
332
+ #notify-toggle-header.notify-on:hover {
333
+ color: var(--color-accent-primary, var(--color-text-primary));
334
+ }
328
335
 
329
336
  /* ── Content Row (Sidebar + Main) ───────────────────────────────────────── */
330
337
  #app > aside,
@@ -2282,19 +2289,31 @@ body {
2282
2289
 
2283
2290
  /* ── Diff block (rendered inline within edit/write tool-item) ─────────────── */
2284
2291
  .tool-item-diff {
2285
- margin: 0.25rem 0 0.25rem 1.25rem;
2286
- padding: 0.375rem 0.5rem;
2292
+ margin: 0.375rem 0 0.375rem 1.25rem;
2293
+ padding: 0.5rem 0.625rem;
2287
2294
  background: var(--color-bg-secondary);
2288
2295
  border: 1px solid var(--color-border-secondary);
2289
- border-radius: 4px;
2296
+ border-radius: 6px;
2290
2297
  font-size: 0.6875rem;
2291
2298
  font-family: monospace;
2292
- line-height: 1.5;
2299
+ line-height: 1.55;
2293
2300
  max-height: 20rem;
2294
- overflow: auto;
2301
+ overflow-x: hidden;
2302
+ overflow-y: auto;
2303
+ scrollbar-width: thin;
2304
+ scrollbar-color: var(--color-border-secondary) transparent;
2295
2305
  }
2306
+ .tool-item-diff::-webkit-scrollbar { width: 6px; height: 6px; }
2307
+ .tool-item-diff::-webkit-scrollbar-track { background: transparent; }
2308
+ .tool-item-diff::-webkit-scrollbar-thumb {
2309
+ background: var(--color-border-secondary);
2310
+ border-radius: 3px;
2311
+ }
2312
+ .tool-item-diff::-webkit-scrollbar-thumb:hover { background: var(--color-text-tertiary); }
2296
2313
  .diff-line {
2297
- white-space: pre;
2314
+ white-space: pre-wrap;
2315
+ word-break: break-all;
2316
+ overflow-wrap: anywhere;
2298
2317
  padding: 0 0.25rem;
2299
2318
  border-radius: 2px;
2300
2319
  }
@@ -3559,6 +3578,37 @@ body {
3559
3578
  }
3560
3579
  .btn-settings-action:hover { background: var(--color-bg-hover); border-color: var(--color-accent-primary); }
3561
3580
  .btn-settings-action:disabled { opacity: 0.5; cursor: not-allowed; }
3581
+
3582
+ .settings-network {
3583
+ display: flex;
3584
+ flex-direction: column;
3585
+ gap: 0.875rem;
3586
+ padding: 0.875rem 1rem;
3587
+ background: var(--color-bg-secondary);
3588
+ border: 1px solid var(--color-border-primary);
3589
+ border-radius: 10px;
3590
+ }
3591
+ .settings-network-desc {
3592
+ font-size: 0.8125rem;
3593
+ color: var(--color-text-secondary);
3594
+ line-height: 1.5;
3595
+ margin: 0;
3596
+ }
3597
+ .settings-network-url {
3598
+ display: flex;
3599
+ flex-direction: column;
3600
+ gap: 0.375rem;
3601
+ }
3602
+ .settings-network-url-label {
3603
+ font-size: 0.75rem;
3604
+ color: var(--color-text-secondary);
3605
+ }
3606
+ .settings-network-url-row {
3607
+ display: flex;
3608
+ gap: 0.5rem;
3609
+ align-items: center;
3610
+ }
3611
+ .settings-network-url-row .field-input { flex: 1; }
3562
3612
  .settings-loading, .settings-empty, .settings-error {
3563
3613
  color: var(--color-text-secondary);
3564
3614
  font-size: 0.8125rem;
@@ -598,6 +598,14 @@ const I18n = (() => {
598
598
  "settings.browser.btn": "🌐 Configure Browser",
599
599
  "settings.browser.btn.reconfigure": "🌐 Reconfigure Browser",
600
600
  "settings.browser.btn.starting": "Starting…",
601
+ "settings.network.title": "Network",
602
+ "settings.network.desc": "Clacky always ignores HTTP_PROXY / HTTPS_PROXY from your shell. Set an explicit proxy URL below to route Clacky's outbound traffic through a proxy.",
603
+ "settings.network.proxyUrl": "Proxy URL",
604
+ "settings.network.save": "Save",
605
+ "settings.network.saved": "Saved",
606
+ "settings.network.clear": "Clear",
607
+ "settings.network.cleared": "Cleared — direct connection",
608
+ "settings.network.invalidUrl": "Invalid URL — use http://host:port or http://user:pass@host:port",
601
609
  "settings.brand.title": "Brand & License",
602
610
  "settings.brand.label.brand": "Brand",
603
611
  "settings.brand.label.status": "Status",
@@ -698,6 +706,8 @@ const I18n = (() => {
698
706
  "brand.banner.freePromptBoth": "Welcome to {{name}} — {{free}} free skill{{freePlural}} ready to use, plus {{paid}} premium skill{{paidPlural}} unlockable with a serial number.",
699
707
 
700
708
  "header.owner.tooltip": "Creator — click to open Creator Hub",
709
+ "notify.tooltip.on": "Sound on task complete: ON (click to mute)",
710
+ "notify.tooltip.off": "Sound on task complete: OFF (click to enable)",
701
711
 
702
712
  // ── Session info bar / Model switcher benchmark ──
703
713
  "sib.bench.btn": "Benchmark",
@@ -1339,6 +1349,14 @@ const I18n = (() => {
1339
1349
  "settings.browser.btn": "🌐 配置浏览器",
1340
1350
  "settings.browser.btn.reconfigure": "🌐 重新配置浏览器",
1341
1351
  "settings.browser.btn.starting": "启动中…",
1352
+ "settings.network.title": "网络",
1353
+ "settings.network.desc": "Clacky 始终忽略系统的 HTTP_PROXY / HTTPS_PROXY。如需让 Clacky 走代理,请在下方填入显式代理地址。",
1354
+ "settings.network.proxyUrl": "代理地址",
1355
+ "settings.network.save": "保存",
1356
+ "settings.network.saved": "已保存",
1357
+ "settings.network.clear": "清除",
1358
+ "settings.network.cleared": "已清除 — 直连",
1359
+ "settings.network.invalidUrl": "地址格式不正确,请使用 http://host:port 或 http://user:pass@host:port",
1342
1360
  "settings.brand.title": "品牌 & 授权",
1343
1361
  "settings.brand.label.brand": "品牌",
1344
1362
  "settings.brand.label.status": "状态",
@@ -1439,6 +1457,8 @@ const I18n = (() => {
1439
1457
  "brand.banner.freePromptBoth": "欢迎使用 {{name}} — 已自动安装 {{free}} 个免费技能,还有 {{paid}} 个增值技能可输入序列号解锁。",
1440
1458
 
1441
1459
  "header.owner.tooltip": "创作者 — 点击进入创作者中心",
1460
+ "notify.tooltip.on": "任务完成提示音:已开启(点击关闭)",
1461
+ "notify.tooltip.off": "任务完成提示音:已关闭(点击开启)",
1442
1462
 
1443
1463
  // ── 会话信息栏 / 模型切换器 测速 ──
1444
1464
  "sib.bench.btn": "测速",
@@ -44,6 +44,7 @@
44
44
  </div>
45
45
  </div>
46
46
  <div id="header-right">
47
+ <button id="notify-toggle-header" class="theme-toggle-btn" data-i18n-title="notify.tooltip.off" title="Sound on task complete"></button>
47
48
  <button id="theme-toggle-header" class="theme-toggle-btn" title="Toggle theme">
48
49
  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon-sm">
49
50
  <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
@@ -869,6 +870,25 @@
869
870
  </div>
870
871
  </section>
871
872
 
873
+ <!-- Network / Proxy section -->
874
+ <section class="settings-section" id="network-section">
875
+ <div class="settings-section-title">
876
+ <span data-i18n="settings.network.title">Network</span>
877
+ </div>
878
+ <div class="settings-network">
879
+ <p class="settings-network-desc" data-i18n="settings.network.desc">Clacky always ignores HTTP_PROXY / HTTPS_PROXY from your shell. To route Clacky's outbound traffic through a proxy, set an explicit URL below.</p>
880
+ <div class="settings-network-url">
881
+ <label class="settings-network-url-label" for="settings-proxy-url" data-i18n="settings.network.proxyUrl">Proxy URL</label>
882
+ <div class="settings-network-url-row">
883
+ <input type="text" id="settings-proxy-url" class="field-input" placeholder="http://user:pass@host:port" autocomplete="off" spellcheck="false">
884
+ <button type="button" id="btn-save-proxy-url" class="btn-settings-action" data-i18n="settings.network.save">Save</button>
885
+ <button type="button" id="btn-clear-proxy-url" class="btn-settings-action" data-i18n="settings.network.clear">Clear</button>
886
+ </div>
887
+ <div id="settings-proxy-url-status" class="model-test-result"></div>
888
+ </div>
889
+ </div>
890
+ </section>
891
+
872
892
  <!-- Brand & License section -->
873
893
  <section class="settings-section" id="brand-license-section">
874
894
  <div class="settings-section-title">
@@ -1300,6 +1320,7 @@
1300
1320
  <script src="/i18n.js"></script>
1301
1321
  <script src="/auth.js"></script>
1302
1322
  <script src="/theme.js"></script>
1323
+ <script src="/notify.js"></script>
1303
1324
  <script src="/ws.js"></script>
1304
1325
  <script src="/ws-dispatcher.js"></script>
1305
1326
  <script src="/sessions.js"></script>
@@ -0,0 +1,154 @@
1
+ // notify.js — Task-complete sound notification module
2
+ //
3
+ // Plays a short sound when an agent task finishes, driven by the global
4
+ // `task_finished` event the server broadcasts (broadcast_all) the moment a
5
+ // task completes. We listen to this dedicated signal — rather than `complete`
6
+ // (only delivered to subscribers of that session) — so a background session
7
+ // finishing still reaches every browser. Whether a task *finished* is decided
8
+ // on the backend; this module only decides whether the user is looking.
9
+ //
10
+ // The chime only fires when the user is NOT actively looking at the
11
+ // finished session. "Not looking" means ANY of:
12
+ // 1. The finished session is not the currently open one
13
+ // (sid !== Sessions.activeId)
14
+ // 2. The browser window has lost focus (!document.hasFocus())
15
+ // 3. The tab is hidden / minimised / behind another tab (document.hidden)
16
+ //
17
+ // If the user is focused on the very session that just finished, we stay
18
+ // silent — they can already see the result.
19
+ //
20
+ // The feature is gated behind a header toggle (🔔/🔕) next to the theme
21
+ // switcher. Default OFF; the choice is persisted to localStorage.
22
+ //
23
+ // No history replay: a chime is a live cue, never re-fired on page refresh.
24
+ // The audio file is served as a static asset (/notify.mp3) by WEBrick.
25
+ //
26
+ // Depends on: Sessions (sessions.js) for activeId, I18n (i18n.js) for the
27
+ // tooltip text. Both are optional — guarded with typeof checks.
28
+ // ─────────────────────────────────────────────────────────────────────────
29
+ const Notify = (() => {
30
+ const STORAGE_KEY = "clacky-notify-sound";
31
+ const AUDIO_SRC = "/notify.mp3";
32
+
33
+ let _audio = null;
34
+
35
+ // ── State ────────────────────────────────────────────────────────────
36
+ // Default OFF: only enabled when localStorage explicitly says "on".
37
+ function enabled() {
38
+ return localStorage.getItem(STORAGE_KEY) === "on";
39
+ }
40
+
41
+ function setEnabled(on) {
42
+ localStorage.setItem(STORAGE_KEY, on ? "on" : "off");
43
+ _updateToggleIcon();
44
+ // On enabling, "prime" the audio element within this user gesture so the
45
+ // browser's autoplay policy lets later programmatic play() calls through.
46
+ if (on) _prime();
47
+ }
48
+
49
+ function toggle() {
50
+ setEnabled(!enabled());
51
+ }
52
+
53
+ // ── Audio ────────────────────────────────────────────────────────────
54
+ function _ensureAudio() {
55
+ if (!_audio) {
56
+ _audio = new Audio(AUDIO_SRC);
57
+ _audio.preload = "auto";
58
+ }
59
+ return _audio;
60
+ }
61
+
62
+ // Play+pause+reset muted once, triggered by the toggle click (a user
63
+ // gesture), to satisfy autoplay policies for subsequent unmuted plays.
64
+ function _prime() {
65
+ const a = _ensureAudio();
66
+ const prevMuted = a.muted;
67
+ a.muted = true;
68
+ const p = a.play();
69
+ if (p && typeof p.then === "function") {
70
+ p.then(() => {
71
+ a.pause();
72
+ a.currentTime = 0;
73
+ a.muted = prevMuted;
74
+ }).catch(() => { a.muted = prevMuted; });
75
+ } else {
76
+ a.muted = prevMuted;
77
+ }
78
+ }
79
+
80
+ function _play() {
81
+ const a = _ensureAudio();
82
+ try {
83
+ a.currentTime = 0;
84
+ const p = a.play();
85
+ // Swallow autoplay-policy rejections silently — better to miss a
86
+ // chime than to throw an unhandled promise rejection.
87
+ if (p && typeof p.catch === "function") p.catch(() => {});
88
+ } catch (_e) { /* ignore */ }
89
+ }
90
+
91
+ // ── Trigger decision ───────────────────────────────────────────────────
92
+ // Returns true when the user is NOT actively viewing the given session.
93
+ function _userIsAway(sid) {
94
+ // 1. Finished session is not the one currently open.
95
+ const activeId = (typeof Sessions !== "undefined") ? Sessions.activeId : null;
96
+ if (sid && sid !== activeId) return true;
97
+ // 2. Browser window is not focused (e.g. another app / window on top).
98
+ if (typeof document.hasFocus === "function" && !document.hasFocus()) return true;
99
+ // 3. Tab is hidden (switched to another tab, or window minimised).
100
+ if (document.hidden) return true;
101
+ return false;
102
+ }
103
+
104
+ // Called from ws-dispatcher on the `task_finished` event — a transient global
105
+ // signal the server broadcasts to every client the moment an agent task
106
+ // completes. We only decide whether the user is looking at that session;
107
+ // the "did a task just finish" judgement lives on the backend.
108
+ function onTaskFinished(sid) {
109
+ if (!enabled()) return;
110
+ if (!_userIsAway(sid)) return;
111
+ _play();
112
+ }
113
+
114
+ // ── Toggle button UI ───────────────────────────────────────────────────
115
+ function _updateToggleIcon() {
116
+ const btn = document.getElementById("notify-toggle-header");
117
+ if (!btn) return;
118
+ const on = enabled();
119
+ // Bell when ON, bell-off (muted) when OFF.
120
+ btn.innerHTML = on
121
+ ? `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon-sm">
122
+ <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/>
123
+ <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>
124
+ </svg>`
125
+ : `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon-sm">
126
+ <path d="M8.7 3A6 6 0 0 1 18 8c0 1.5.2 2.8.5 3.9"/>
127
+ <path d="M17 17H3s3-2 3-9a4.67 4.67 0 0 1 .3-1.7"/>
128
+ <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>
129
+ <line x1="2" y1="2" x2="22" y2="22"/>
130
+ </svg>`;
131
+ btn.classList.toggle("notify-on", on);
132
+ if (typeof I18n !== "undefined") {
133
+ const tip = I18n.t(on ? "notify.tooltip.on" : "notify.tooltip.off");
134
+ btn.title = tip;
135
+ btn.setAttribute("aria-label", tip);
136
+ }
137
+ }
138
+
139
+ // ── Init ─────────────────────────────────────────────────────────────
140
+ function init() {
141
+ _updateToggleIcon();
142
+ const btn = document.getElementById("notify-toggle-header");
143
+ if (btn) btn.addEventListener("click", toggle);
144
+ }
145
+
146
+ return { init, toggle, enabled, setEnabled, onTaskFinished };
147
+ })();
148
+
149
+ // Initialize on load (button binding + initial icon state).
150
+ if (document.readyState === "loading") {
151
+ document.addEventListener("DOMContentLoaded", () => Notify.init());
152
+ } else {
153
+ Notify.init();
154
+ }
Binary file
@@ -19,6 +19,7 @@ const Settings = (() => {
19
19
  _loadMedia();
20
20
  _loadBrand();
21
21
  _loadBrowserStatus();
22
+ _initNetworkSettings();
22
23
  _applyAboutTabVisibility();
23
24
  }
24
25
 
@@ -1200,6 +1201,62 @@ const Settings = (() => {
1200
1201
  }
1201
1202
  }
1202
1203
 
1204
+ // ── Network / Proxy ───────────────────────────────────────────────────────────
1205
+
1206
+ async function _initNetworkSettings() {
1207
+ const urlInput = document.getElementById("settings-proxy-url");
1208
+ const saveBtn = document.getElementById("btn-save-proxy-url");
1209
+ const clearBtn = document.getElementById("btn-clear-proxy-url");
1210
+ const status = document.getElementById("settings-proxy-url-status");
1211
+ if (!urlInput || !saveBtn) return;
1212
+
1213
+ try {
1214
+ const res = await fetch("/api/config/settings");
1215
+ const data = await res.json();
1216
+ if (data.ok) {
1217
+ urlInput.value = data.proxy_url || "";
1218
+ }
1219
+ } catch (_) { /* non-critical */ }
1220
+
1221
+ async function _patchProxyUrl(value, successKey) {
1222
+ status.textContent = "";
1223
+ status.className = "model-test-result";
1224
+ try {
1225
+ const res = await fetch("/api/config/settings", {
1226
+ method: "PATCH",
1227
+ headers: { "Content-Type": "application/json" },
1228
+ body: JSON.stringify({ proxy_url: value })
1229
+ });
1230
+ const data = await res.json();
1231
+ if (data.ok) {
1232
+ status.textContent = I18n.t(successKey);
1233
+ status.className = "model-test-result success";
1234
+ } else {
1235
+ status.textContent = data.error || I18n.t("settings.network.invalidUrl");
1236
+ status.className = "model-test-result error";
1237
+ }
1238
+ } catch (e) {
1239
+ status.textContent = e.message || I18n.t("settings.network.invalidUrl");
1240
+ status.className = "model-test-result error";
1241
+ }
1242
+ }
1243
+
1244
+ if (!saveBtn.dataset.bound) {
1245
+ saveBtn.dataset.bound = "1";
1246
+ saveBtn.addEventListener("click", () => {
1247
+ _patchProxyUrl(urlInput.value.trim(), "settings.network.saved");
1248
+ });
1249
+ }
1250
+
1251
+ if (clearBtn && !clearBtn.dataset.bound) {
1252
+ clearBtn.dataset.bound = "1";
1253
+ clearBtn.addEventListener("click", () => {
1254
+ urlInput.value = "";
1255
+ _patchProxyUrl("", "settings.network.cleared");
1256
+ });
1257
+ }
1258
+ }
1259
+
1203
1260
  // ── Brand & License ───────────────────────────────────────────────────────────
1204
1261
 
1205
1262
  // Whether the server was started with --brand-test (relaxed key validation).
@@ -265,6 +265,14 @@ WS.onEvent(ev => {
265
265
  break;
266
266
  }
267
267
 
268
+ // Transient global signal emitted the moment any agent task finishes
269
+ // (broadcast to every client, not just session subscribers). Used only
270
+ // to play the optional completion chime; the toggle gates it and the
271
+ // module decides whether the user is looking at that session.
272
+ case "task_finished":
273
+ if (typeof Notify !== "undefined") Notify.onTaskFinished(ev.session_id);
274
+ break;
275
+
268
276
  case "session_renamed": {
269
277
  Sessions.patch(ev.session_id, { name: ev.name });
270
278
  Sessions.renderList();
data/lib/clacky.rb CHANGED
@@ -92,6 +92,7 @@ require_relative "clacky/ui2/progress_indicator"
92
92
 
93
93
  # Utils
94
94
  require_relative "clacky/utils/logger"
95
+ require_relative "clacky/proxy_config"
95
96
  require_relative "clacky/platform_http_client"
96
97
  require_relative "clacky/utils/encoding"
97
98
  require_relative "clacky/utils/environment_detector"
@@ -165,3 +166,5 @@ module Clacky
165
166
  class BrowserNotReachableError < AgentError; end # Chrome/Edge not running or remote debugging disabled
166
167
  # BrowserManager singleton: Clacky::BrowserManager.instance
167
168
  end
169
+
170
+ Clacky::ProxyConfig.install!
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.14
4
+ version: 1.2.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-08 00:00:00.000000000 Z
11
+ date: 2026-06-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -429,6 +429,7 @@ files:
429
429
  - lib/clacky/plain_ui_controller.rb
430
430
  - lib/clacky/platform_http_client.rb
431
431
  - lib/clacky/providers.rb
432
+ - lib/clacky/proxy_config.rb
432
433
  - lib/clacky/rich_ui_controller.rb
433
434
  - lib/clacky/server/browser_manager.rb
434
435
  - lib/clacky/server/channel.rb
@@ -552,6 +553,8 @@ files:
552
553
  - lib/clacky/web/marked.min.js
553
554
  - lib/clacky/web/mcp.js
554
555
  - lib/clacky/web/model-tester.js
556
+ - lib/clacky/web/notify.js
557
+ - lib/clacky/web/notify.mp3
555
558
  - lib/clacky/web/onboard.js
556
559
  - lib/clacky/web/profile.js
557
560
  - lib/clacky/web/sessions.js