openclacky 1.3.6 → 1.3.7

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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/lib/clacky/agent.rb +8 -1
  4. data/lib/clacky/agent_profile.rb +121 -53
  5. data/lib/clacky/brand_config.rb +309 -6
  6. data/lib/clacky/cli.rb +4 -57
  7. data/lib/clacky/default_extensions/coding/ext.yml +18 -0
  8. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +95 -0
  9. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +254 -0
  10. data/lib/clacky/default_extensions/ext-studio/ext.yml +32 -0
  11. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +1269 -0
  12. data/lib/clacky/default_extensions/ext-studio/skills/ext-debug/SKILL.md +71 -0
  13. data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +74 -0
  14. data/lib/clacky/default_extensions/ext-studio/skills/ext-scaffold/SKILL.md +65 -0
  15. data/lib/clacky/default_extensions/general/ext.yml +18 -0
  16. data/lib/clacky/default_extensions/git/ext.yml +12 -0
  17. data/lib/clacky/{default_agents/_panels/git/panel.js → default_extensions/git/panels/git/view.js} +1 -2
  18. data/lib/clacky/default_extensions/meeting/{handler.rb → api/handler.rb} +78 -26
  19. data/lib/clacky/default_extensions/meeting/ext.yml +15 -0
  20. data/lib/clacky/default_extensions/meeting/{meeting.js → panels/meeting/view.js} +266 -89
  21. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +2 -1
  22. data/lib/clacky/default_extensions/time_machine/ext.yml +12 -0
  23. data/lib/clacky/{default_agents/_panels/time_machine/panel.js → default_extensions/time_machine/panels/time_machine/view.js} +58 -47
  24. data/lib/clacky/default_skills/channel-manager/discord_setup.rb +1 -1
  25. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +83 -18
  26. data/lib/clacky/{api_extension.rb → extension/api_extension.rb} +27 -11
  27. data/lib/clacky/extension/api_loader.rb +136 -0
  28. data/lib/clacky/extension/cli_commands.rb +226 -0
  29. data/lib/clacky/{server/api_extension_dispatcher.rb → extension/dispatcher.rb} +22 -9
  30. data/lib/clacky/extension/hook_loader.rb +77 -0
  31. data/lib/clacky/extension/loader.rb +483 -0
  32. data/lib/clacky/extension/packager.rb +226 -0
  33. data/lib/clacky/{patch_loader.rb → extension/patch_loader.rb} +45 -0
  34. data/lib/clacky/extension/scaffold/template_renderer.rb +64 -0
  35. data/lib/clacky/extension/scaffold/templates/full/README.md.erb +48 -0
  36. data/lib/clacky/extension/scaffold/templates/full/agents/designer.md +5 -0
  37. data/lib/clacky/extension/scaffold/templates/full/api/handler.rb.erb +16 -0
  38. data/lib/clacky/extension/scaffold/templates/full/channels/noop.rb.erb +40 -0
  39. data/lib/clacky/extension/scaffold/templates/full/ext.yml.erb +41 -0
  40. data/lib/clacky/extension/scaffold/templates/full/hooks/audit.rb +9 -0
  41. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +194 -0
  42. data/lib/clacky/extension/scaffold/templates/full/patches/audit.rb +15 -0
  43. data/lib/clacky/extension/scaffold/templates/full/skills/__slug__-skill/SKILL.md.erb +8 -0
  44. data/lib/clacky/extension/scaffold/templates/hello/api/handler.rb.erb +9 -0
  45. data/lib/clacky/extension/scaffold/templates/hello/ext.yml.erb +12 -0
  46. data/lib/clacky/extension/scaffold/templates/hello/panels/hello/view.js.erb +31 -0
  47. data/lib/clacky/extension/scaffold.rb +55 -0
  48. data/lib/clacky/extension/verifier.rb +196 -0
  49. data/lib/clacky/identity.rb +70 -0
  50. data/lib/clacky/locales/en.rb +19 -1
  51. data/lib/clacky/locales/zh.rb +19 -1
  52. data/lib/clacky/platform_http_client.rb +18 -15
  53. data/lib/clacky/server/channel/extension_adapter_loader.rb +32 -0
  54. data/lib/clacky/server/channel.rb +6 -0
  55. data/lib/clacky/server/http_server.rb +313 -144
  56. data/lib/clacky/server/session_registry.rb +9 -2
  57. data/lib/clacky/skill_loader.rb +28 -1
  58. data/lib/clacky/ui2/ui_controller.rb +9 -6
  59. data/lib/clacky/version.rb +1 -1
  60. data/lib/clacky/web/app.css +748 -573
  61. data/lib/clacky/web/app.js +63 -15
  62. data/lib/clacky/web/auth.js +2 -0
  63. data/lib/clacky/web/components/notify.js +2 -0
  64. data/lib/clacky/web/components/sidebar.js +1 -5
  65. data/lib/clacky/web/core/ext.js +316 -79
  66. data/lib/clacky/web/features/backup/store.js +1 -0
  67. data/lib/clacky/web/features/backup/view.js +0 -1
  68. data/lib/clacky/web/features/brand/view.js +13 -5
  69. data/lib/clacky/web/features/extensions/store.js +184 -0
  70. data/lib/clacky/web/features/extensions/view.js +389 -0
  71. data/lib/clacky/web/features/new-session/store.js +186 -0
  72. data/lib/clacky/web/features/new-session/view.js +319 -0
  73. data/lib/clacky/web/features/share/view.js +63 -14
  74. data/lib/clacky/web/features/skills/store.js +3 -1
  75. data/lib/clacky/web/features/skills/view.js +1 -1
  76. data/lib/clacky/web/features/tasks/view.js +12 -0
  77. data/lib/clacky/web/features/trash/store.js +24 -6
  78. data/lib/clacky/web/features/trash/view.js +46 -9
  79. data/lib/clacky/web/features/workspace/store.js +2 -0
  80. data/lib/clacky/web/features/workspace/view.js +0 -1
  81. data/lib/clacky/web/i18n.js +118 -106
  82. data/lib/clacky/web/index.html +138 -188
  83. data/lib/clacky/web/sessions.js +64 -269
  84. data/lib/clacky/web/settings.js +1 -1
  85. data/lib/clacky/web/skills.js +2 -0
  86. data/lib/clacky/web/theme.js +2 -0
  87. data/lib/clacky/web/ws-dispatcher.js +1 -0
  88. data/lib/clacky/web/ws.js +2 -0
  89. data/lib/clacky.rb +21 -10
  90. metadata +55 -24
  91. data/benchmark/runner.rb +0 -441
  92. data/lib/clacky/api_extension_loader.rb +0 -168
  93. data/lib/clacky/default_agents/SOUL.md +0 -3
  94. data/lib/clacky/default_agents/USER.md +0 -1
  95. data/lib/clacky/default_agents/coding/profile.yml +0 -5
  96. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  97. data/lib/clacky/default_agents/general/profile.yml +0 -2
  98. data/lib/clacky/default_extensions/meeting/meta.yml +0 -3
  99. data/lib/clacky/web/features/creator/store.js +0 -81
  100. data/lib/clacky/web/features/creator/view.js +0 -380
  101. /data/lib/clacky/{default_agents → default_extensions/coding/agents}/coding/system_prompt.md +0 -0
  102. /data/lib/clacky/{default_agents → default_extensions/general/agents}/general/system_prompt.md +0 -0
  103. /data/lib/clacky/{default_agents/base_prompt.md → prompts/base.md} +0 -0
@@ -293,6 +293,25 @@ const Sessions = (() => {
293
293
 
294
294
  let _userScrolledUp = false; // true if user manually scrolled away from bottom
295
295
 
296
+ let _agentsById = null;
297
+ let _agentsLoadingPromise = null;
298
+
299
+ function _ensureAgentsLoaded() {
300
+ if (_agentsById) return Promise.resolve(_agentsById);
301
+ if (_agentsLoadingPromise) return _agentsLoadingPromise;
302
+ _agentsLoadingPromise = fetch("/api/agents")
303
+ .then(r => (r.ok ? r.json() : { agents: [] }))
304
+ .then(data => {
305
+ const map = {};
306
+ ((data && data.agents) || []).forEach(a => { map[a.id] = a; });
307
+ _agentsById = map;
308
+ return map;
309
+ })
310
+ .catch(() => (_agentsById = {}))
311
+ .finally(() => { _agentsLoadingPromise = null; });
312
+ return _agentsLoadingPromise;
313
+ }
314
+
296
315
  function _isAtBottom(container) {
297
316
  if (!container) return false;
298
317
  const threshold = 150;
@@ -450,76 +469,9 @@ const Sessions = (() => {
450
469
  // we call addEventListener directly (no ?. / no `if` guards). If any is
451
470
  // missing, it means HTML and JS drifted and we want the loud error.
452
471
  function _initNewSessionControls() {
453
- // Split button: main (quick create)
454
472
  document.getElementById("btn-new-session-inline")
455
- .addEventListener("click", () => Sessions.create("general"));
456
-
457
- // Split button: arrow — show dropdown on hover, hide when leaving the whole wrap
458
- const arrow = document.getElementById("btn-new-session-arrow");
459
- const wrap = arrow.closest(".btn-split-wrap");
460
- const dd = document.getElementById("new-session-dropdown");
461
-
462
- arrow.addEventListener("mouseenter", () => { dd.hidden = false; });
463
- dd.addEventListener("mouseenter", () => { dd.hidden = false; });
464
- wrap.addEventListener("mouseleave", () => { dd.hidden = true; });
465
- dd.addEventListener("mouseleave", () => { dd.hidden = true; });
473
+ .addEventListener("click", () => { location.hash = "#new"; });
466
474
 
467
- // Dropdown item "Advanced Options…"
468
- document.addEventListener("click", (e) => {
469
- if (e.target && e.target.id === "btn-new-session-modal") {
470
- dd.hidden = true;
471
- Sessions.openNewSessionModal();
472
- } else if (!wrap.contains(e.target)) {
473
- dd.hidden = true;
474
- }
475
- });
476
-
477
- // Welcome screen "+ New Session" button
478
- document.getElementById("btn-welcome-new")
479
- .addEventListener("click", () => Sessions.create("general"));
480
-
481
- // Welcome screen starter chips: create a session, then prefill the prompt
482
- document.querySelectorAll(".chip[data-welcome-prompt]").forEach((chip) => {
483
- chip.addEventListener("click", async () => {
484
- await Sessions.create("general");
485
- const prompt = I18n.t(chip.dataset.welcomePrompt);
486
- const input = document.getElementById("user-input");
487
- if (input && prompt) {
488
- input.value = prompt;
489
- input.focus();
490
- input.dispatchEvent(new Event("input", { bubbles: true }));
491
- }
492
- });
493
- });
494
-
495
- // Modal: cancel / create / overlay click
496
- document.getElementById("new-session-cancel")
497
- .addEventListener("click", () => Sessions.closeNewSessionModal());
498
- document.getElementById("new-session-create")
499
- .addEventListener("click", () => Sessions.createFromModal());
500
- document.getElementById("new-session-modal")
501
- .addEventListener("click", (e) => {
502
- // Only close when the click lands on the overlay itself, not on
503
- // the inner dialog panel.
504
- if (e.target.id === "new-session-modal") {
505
- Sessions.closeNewSessionModal();
506
- }
507
- });
508
-
509
- // Working-directory browse button → reuses the session picker in
510
- // session-less mode (no session exists yet, so it browses via /api/dirs).
511
- const browseBtn = document.getElementById("new-session-browse-btn");
512
- if (browseBtn) {
513
- browseBtn.addEventListener("click", async () => {
514
- const dirInput = document.getElementById("new-session-directory");
515
- const start = dirInput ? dirInput.value.trim() : "";
516
- const picked = await window.openDirectoryPicker(start, null);
517
- if (picked && dirInput) dirInput.value = picked;
518
- });
519
- }
520
-
521
- // Load-more sessions button is rendered dynamically by renderList(),
522
- // so we listen via event delegation.
523
475
  document.addEventListener("click", (e) => {
524
476
  if (e.target && e.target.id === "btn-load-more-sessions") {
525
477
  Sessions.loadMore();
@@ -639,11 +591,11 @@ const Sessions = (() => {
639
591
 
640
592
  function _addImageFile(file) {
641
593
  if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
642
- alert(`Unsupported image type: ${file.type}\nSupported: PNG, JPEG, GIF, WEBP`);
594
+ alert(I18n.t("chat.file.unsupportedType", { type: file.type }));
643
595
  return;
644
596
  }
645
597
  if (file.size > MAX_IMAGE_SIZE) {
646
- alert(`Image too large: ${file.name} (max 5 MB)`);
598
+ alert(I18n.t("chat.file.imageTooLarge", { name: file.name, max: "5 MB" }));
647
599
  return;
648
600
  }
649
601
  const seq = ++_imageSeq;
@@ -655,12 +607,12 @@ const Sessions = (() => {
655
607
  _pendingImages.push({ dataUrl, name: displayName, mimeType: file.type === "image/png" ? "image/png" : "image/jpeg", seq });
656
608
  _renderAttachmentPreviews();
657
609
  })
658
- .catch(err => alert(`Image processing failed: ${err.message}`));
610
+ .catch(err => alert(I18n.t("chat.file.processFailed", { msg: err.message })));
659
611
  }
660
612
 
661
613
  function _addGenericFile(file) {
662
614
  if (file.size > MAX_FILE_BYTES) {
663
- alert(`File too large: ${file.name} (max 32 MB)`);
615
+ alert(I18n.t("chat.file.tooLarge", { name: file.name, max: "32 MB" }));
664
616
  return;
665
617
  }
666
618
  // Upload file to server via HTTP — only the path is returned, no base64 in memory
@@ -669,7 +621,7 @@ const Sessions = (() => {
669
621
  fetch("/api/upload", { method: "POST", body: formData })
670
622
  .then(r => r.json())
671
623
  .then(data => {
672
- if (!data.ok) { alert(`Upload failed: ${data.error}`); return; }
624
+ if (!data.ok) { alert(I18n.t("chat.file.uploadFailed", { msg: data.error })); return; }
673
625
  _pendingFiles.push({
674
626
  name: data.name,
675
627
  path: data.path,
@@ -2166,11 +2118,22 @@ const Sessions = (() => {
2166
2118
  ? `<span class="session-badge session-badge--${s.source}">${I18n.t(badgeKey)}</span>`
2167
2119
  : "";
2168
2120
 
2169
- // Coding profile badge (agent_profile === "coding"). Neutral styling so
2170
- // it lives peacefully with the source badge and the status dot.
2171
- const codingBadgeHtml = s.agent_profile === "coding"
2172
- ? `<span class="session-badge session-badge--coding">${I18n.t("sessions.badge.coding")}</span>`
2173
- : "";
2121
+ // Agent profile badge: any non-general profile gets a badge. Coding
2122
+ // (built-in) keeps its i18n label + hollow neutral style; extension /
2123
+ // custom agents fall back to their configured title (or profile id).
2124
+ let agentBadgeHtml = "";
2125
+ const profile = s.agent_profile;
2126
+ if (profile && profile !== "general") {
2127
+ if (profile === "coding") {
2128
+ agentBadgeHtml = `<span class="session-badge session-badge--coding">${I18n.t("sessions.badge.coding")}</span>`;
2129
+ } else {
2130
+ const meta = _agentsById && _agentsById[profile];
2131
+ const isZh = I18n.lang && I18n.lang().startsWith("zh");
2132
+ const label = (meta && ((isZh && meta.title_zh) || meta.title)) || profile;
2133
+ const safeLabel = escapeHtml(label);
2134
+ agentBadgeHtml = `<span class="session-badge session-badge--agent" title="${safeLabel}">${safeLabel}</span>`;
2135
+ }
2136
+ }
2174
2137
 
2175
2138
  // Pin icon (always visible for pinned sessions)
2176
2139
  const pinIcon = s.pinned ? `<span class="session-pin-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" style="transform:rotate(45deg);display:block"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"/></svg></span>` : "";
@@ -2187,7 +2150,7 @@ const Sessions = (() => {
2187
2150
 
2188
2151
  el.innerHTML = `
2189
2152
  <div class="session-body">
2190
- <div class="session-name">${dotHtml}<span class="session-name__text">${nameHtml}</span>${badgeHtml}${codingBadgeHtml}${pinIcon}</div>
2153
+ <div class="session-name">${dotHtml}<span class="session-name__text">${nameHtml}</span>${badgeHtml}${agentBadgeHtml}${pinIcon}</div>
2191
2154
  <div class="session-meta">${metaText}</div>
2192
2155
  ${snippetHtml}
2193
2156
  </div>
@@ -2325,6 +2288,7 @@ const Sessions = (() => {
2325
2288
  _initComposer();
2326
2289
  _initSearch();
2327
2290
  _initMessageHistory();
2291
+ _ensureAgentsLoaded().then(() => Sessions.renderList());
2328
2292
  // Re-render session list (badges/labels) when the user switches language
2329
2293
  document.addEventListener("langchange", () => Sessions.renderList());
2330
2294
 
@@ -2899,7 +2863,7 @@ const Sessions = (() => {
2899
2863
  let cronEntryRendered = false;
2900
2864
 
2901
2865
  nonCron.forEach(s => {
2902
- if (!cronEntryRendered && _latestCronUpdatedAt) {
2866
+ if (!cronEntryRendered && !s.pinned && _latestCronUpdatedAt) {
2903
2867
  const t = s.updated_at || s.created_at;
2904
2868
  if (!t || t < _latestCronUpdatedAt) {
2905
2869
  _renderCronGroupItem(list, _cronCount, cronHasRunning);
@@ -2937,14 +2901,22 @@ const Sessions = (() => {
2937
2901
  // has scrolled away to browse other sessions.
2938
2902
  if (scrollToActive) {
2939
2903
  const activeEl = list.querySelector(".session-item.active");
2940
- if (activeEl) {
2941
- // If the active session is the very first item, scroll to top of the sidebar
2942
- // container so sticky headers / expanded panels don't obscure it.
2943
- if (activeEl === list.firstElementChild) {
2944
- const sidebarList = document.getElementById("sidebar-list");
2945
- if (sidebarList) sidebarList.scrollTop = 0;
2946
- } else {
2947
- activeEl.scrollIntoView({ block: "nearest" });
2904
+ const sidebarList = document.getElementById("sidebar-list");
2905
+ if (activeEl && sidebarList) {
2906
+ // Only scroll when the active row is not already fully visible.
2907
+ // The "Sessions" divider is position:sticky at the top of
2908
+ // #sidebar-list, so the usable viewport starts *below* it — treat
2909
+ // that as the top edge, otherwise a row tucked under the header
2910
+ // reads as "visible" and never gets revealed.
2911
+ const header = sidebarList.querySelector(".sidebar-divider");
2912
+ const headerH = (header && header.offsetParent) ? header.offsetHeight : 0;
2913
+ const listRect = sidebarList.getBoundingClientRect();
2914
+ const rowRect = activeEl.getBoundingClientRect();
2915
+ const topEdge = listRect.top + headerH;
2916
+ if (rowRect.top < topEdge) {
2917
+ sidebarList.scrollTop += rowRect.top - topEdge;
2918
+ } else if (rowRect.bottom > listRect.bottom) {
2919
+ sidebarList.scrollTop += rowRect.bottom - listRect.bottom;
2948
2920
  }
2949
2921
  }
2950
2922
  }
@@ -3189,7 +3161,7 @@ const Sessions = (() => {
3189
3161
  /** Update the session info bar below the chat header with current session metadata. */
3190
3162
  updateInfoBar(s) {
3191
3163
  this._lastSession = s;
3192
- if (window.Workspace) Workspace.onSession(s);
3164
+ if (Clacky.Workspace) Clacky.Workspace.onSession(s);
3193
3165
  if (!s) {
3194
3166
  // Hide all spans when no session
3195
3167
  ["sib-id", "sib-status", "sib-dir", "sib-mode", "sib-model", "sib-reasoning", "sib-tasks", "sib-cost"].forEach(id => {
@@ -4028,187 +4000,8 @@ const Sessions = (() => {
4028
4000
  _pendingMessage = null;
4029
4001
  return msg;
4030
4002
  },
4031
-
4032
- // ── New Session Modal ──────────────────────────────────────────────────
4033
-
4034
- /** Open the New Session modal with configuration options. */
4035
- openNewSessionModal() {
4036
- const modal = $("new-session-modal");
4037
- if (!modal) return;
4038
-
4039
- // Populate model dropdown from configured models
4040
- _populateModelDropdown();
4041
-
4042
- // Set default working directory to an absolute path (home/clacky_workspace).
4043
- const dirInput = $("new-session-directory");
4044
- if (dirInput && !dirInput.value) {
4045
- fetch("/api/dirs")
4046
- .then(r => r.ok ? r.json() : null)
4047
- .then(data => {
4048
- const home = data && data.home;
4049
- if (home && !dirInput.value) {
4050
- dirInput.value = home.replace(/\/+$/, "") + "/clacky_workspace";
4051
- }
4052
- })
4053
- .catch(() => {});
4054
- }
4055
-
4056
- // Setup agent type change listener to show/hide init project checkbox
4057
- const agentSelect = $("new-session-agent");
4058
- const initProjectField = $("new-session-init-project-field");
4059
-
4060
- if (agentSelect && initProjectField) {
4061
- // Set initial state based on current selection
4062
- initProjectField.style.display = agentSelect.value === "coding" ? "block" : "none";
4063
-
4064
- // Listen for changes
4065
- agentSelect.addEventListener("change", function() {
4066
- initProjectField.style.display = this.value === "coding" ? "block" : "none";
4067
- });
4068
- }
4069
-
4070
- // Show modal
4071
- modal.style.display = "flex";
4072
- },
4073
-
4074
- /** Close the New Session modal. */
4075
- closeNewSessionModal() {
4076
- const modal = $("new-session-modal");
4077
- if (modal) modal.style.display = "none";
4078
- },
4079
-
4080
- /** Create session from modal form data. */
4081
- async createFromModal() {
4082
- const agentSelect = $("new-session-agent");
4083
- const nameInput = $("new-session-name");
4084
- const modelSelect = $("new-session-model");
4085
- const dirInput = $("new-session-directory");
4086
- const initCheckbox = $("new-session-init-project");
4087
- const createBtn = $("new-session-create");
4088
-
4089
- const agentProfile = agentSelect ? agentSelect.value : "general";
4090
- const customName = nameInput ? nameInput.value.trim() : "";
4091
- // The dropdown's value is the model's stable runtime id (see
4092
- // _populateModelDropdown). Using the id — not the model *name* — lets
4093
- // the backend switch to the right full model entry (api_key, base_url,
4094
- // anthropic_format) instead of mutating the current default entry's
4095
- // name in place, which caused "unknown model <name>" errors when the
4096
- // chosen model belonged to a different provider than the default.
4097
- const selectedModelId = modelSelect ? modelSelect.value : "";
4098
- const workingDir = dirInput ? dirInput.value.trim() : "";
4099
- const initProject = initCheckbox ? initCheckbox.checked : false;
4100
-
4101
- // Auto-generate name if not provided
4102
- let name = customName;
4103
- if (!name) {
4104
- const maxN = _sessions.reduce((max, s) => {
4105
- const m = s.name.match(/^Session (\d+)$/);
4106
- return m ? Math.max(max, parseInt(m[1], 10)) : max;
4107
- }, 0);
4108
- name = "Session " + (maxN + 1);
4109
- }
4110
-
4111
- if (createBtn) createBtn.disabled = true;
4112
-
4113
- try {
4114
- const payload = {
4115
- name,
4116
- agent_profile: agentProfile,
4117
- source: "manual"
4118
- };
4119
-
4120
- // Add optional fields
4121
- if (workingDir) payload.working_dir = workingDir;
4122
- if (selectedModelId) payload.model_id = selectedModelId;
4123
-
4124
- const res = await fetch("/api/sessions", {
4125
- method: "POST",
4126
- headers: { "Content-Type": "application/json" },
4127
- body: JSON.stringify(payload)
4128
- });
4129
- const data = await res.json();
4130
-
4131
- if (!res.ok) {
4132
- const msg = data.error || "unknown error";
4133
- const friendly = res.status === 409
4134
- ? I18n.t("sessions.dirNotEmpty")
4135
- : I18n.t("sessions.createError") + msg;
4136
- alert(friendly);
4137
- if (createBtn) createBtn.disabled = false;
4138
- return;
4139
- }
4140
-
4141
- const session = data.session;
4142
- if (!session) return;
4143
-
4144
- // Close modal and reset form
4145
- Sessions.closeNewSessionModal();
4146
- if (nameInput) nameInput.value = "";
4147
- if (dirInput) dirInput.value = "";
4148
- if (initCheckbox) initCheckbox.checked = false;
4149
-
4150
- // Add to list and select
4151
- Sessions.add(session);
4152
- Sessions.renderList();
4153
- Sessions.select(session.id);
4154
-
4155
- // If init project was checked, send /new command
4156
- if (initProject) {
4157
- Sessions.setPendingMessage(session.id, "/new");
4158
- }
4159
- } catch (e) {
4160
- alert(I18n.t("sessions.createError") + e.message);
4161
- } finally {
4162
- if (createBtn) createBtn.disabled = false;
4163
- }
4164
- },
4165
4003
  };
4166
4004
 
4167
- // ── Helper: Populate model dropdown ────────────────────────────────────────
4168
-
4169
- async function _populateModelDropdown() {
4170
- const modelSelect = $("new-session-model");
4171
- if (!modelSelect) return;
4172
-
4173
- try {
4174
- const res = await fetch("/api/config");
4175
- const data = await res.json();
4176
- const models = data.models || [];
4177
-
4178
- modelSelect.innerHTML = "";
4179
-
4180
- if (models.length === 0) {
4181
- const opt = document.createElement("option");
4182
- opt.value = "";
4183
- opt.textContent = "No models configured";
4184
- modelSelect.appendChild(opt);
4185
- return;
4186
- }
4187
-
4188
- // Add each configured model (CLI-style format).
4189
- // The option's value is the model's stable runtime id — not the bare
4190
- // model name — so the backend can switch to the exact model entry
4191
- // (with matching api_key / base_url / anthropic_format) when the user
4192
- // chooses a non-default model. See createFromModal + build_session.
4193
- models.forEach(m => {
4194
- const opt = document.createElement("option");
4195
- opt.value = m.id || "";
4196
-
4197
- // Format: [default] abs-claude-sonnet-4-5 (clacky...8825)
4198
- const typeBadge = m.type === "default" ? "[default] " : "";
4199
- const label = `${typeBadge}${m.model} (${m.api_key_masked})`;
4200
- opt.textContent = label;
4201
-
4202
- // Pre-select default model
4203
- if (m.type === "default") opt.selected = true;
4204
- modelSelect.appendChild(opt);
4205
- });
4206
- } catch (e) {
4207
- console.error("Failed to load models:", e);
4208
- modelSelect.innerHTML = '<option value="">Error loading models</option>';
4209
- }
4210
- }
4211
-
4212
4005
  return Sessions;
4213
4006
  })();
4214
4007
 
@@ -5681,7 +5474,7 @@ const Sessions = (() => {
5681
5474
  // ── Session Info Bar Reasoning Effort Switcher ────────────────────────────
5682
5475
  (function() {
5683
5476
  let _isOpen = false;
5684
- const LEVELS = ["off", "low", "medium", "high"];
5477
+ const LEVELS = ["off", "low", "medium", "high", "xhigh"];
5685
5478
 
5686
5479
  document.addEventListener("click", async (e) => {
5687
5480
  const el = e.target.closest("#sib-reasoning");
@@ -5790,3 +5583,5 @@ document.addEventListener("currencychange", () => {
5790
5583
  scrollTimer = setTimeout(() => sidebarList.classList.remove("is-scrolling"), 1000);
5791
5584
  }, { passive: true });
5792
5585
  })();
5586
+
5587
+ Clacky.Sessions = Sessions;
@@ -20,7 +20,7 @@ const Settings = (() => {
20
20
  _loadBrand();
21
21
  _loadBrowserStatus();
22
22
  _initNetworkSettings();
23
- if (window.Backup) Backup.load();
23
+ if (Clacky.Backup) Clacky.Backup.load();
24
24
  _applyAboutTabVisibility();
25
25
  }
26
26
 
@@ -452,3 +452,5 @@ const SkillAC = (() => {
452
452
  handleKey: _handleKey,
453
453
  };
454
454
  })();
455
+
456
+ Clacky.SkillAC = SkillAC;
@@ -30,11 +30,13 @@ const Theme = (() => {
30
30
  // Icon shows what you'd switch TO, not what you are on.
31
31
  if (theme === "light") {
32
32
  // In light mode → show moon (click to go dark)
33
+ headerToggle.title = I18n.t("header.theme.toDark");
33
34
  headerToggle.innerHTML = `<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">
34
35
  <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
35
36
  </svg>`;
36
37
  } else {
37
38
  // In dark mode → show sun (click to go light)
39
+ headerToggle.title = I18n.t("header.theme.toLight");
38
40
  headerToggle.innerHTML = `<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">
39
41
  <circle cx="12" cy="12" r="4"/>
40
42
  <path d="M12 2v2"/>
@@ -281,6 +281,7 @@ WS.onEvent(ev => {
281
281
  }
282
282
 
283
283
  case "session_deleted":
284
+ if (window.Clacky && Clacky.ext) Clacky.ext.notifySessionRemoved(ev.session_id);
284
285
  Sessions.remove(ev.session_id);
285
286
  if (ev.session_id === Sessions.activeId) Router.navigate("welcome");
286
287
  Sessions.renderList();
data/lib/clacky/web/ws.js CHANGED
@@ -126,3 +126,5 @@ const WS = (() => {
126
126
  get ready() { return _ready; },
127
127
  };
128
128
  })();
129
+
130
+ Clacky.WS = WS;
data/lib/clacky.rb CHANGED
@@ -95,6 +95,7 @@ require_relative "clacky/ui2/progress_indicator"
95
95
  require_relative "clacky/utils/logger"
96
96
  require_relative "clacky/proxy_config"
97
97
  require_relative "clacky/platform_http_client"
98
+ require_relative "clacky/identity"
98
99
  require_relative "clacky/utils/encoding"
99
100
  require_relative "clacky/utils/environment_detector"
100
101
  require_relative "clacky/utils/browser_detector"
@@ -140,18 +141,28 @@ require_relative "clacky/server/browser_manager"
140
141
  require_relative "clacky/server/backup_manager"
141
142
  require_relative "clacky/cli"
142
143
 
143
- # Runtime patch layer: load user/AI patches from ~/.clacky/patches/ after all
144
- # gem code is defined, so fingerprints reflect the actual installed source.
145
- require_relative "clacky/patch_loader"
144
+ # Extension architecture: define the base class + container loader + scaffolding
145
+ # + dispatcher up front so the patch / hook loaders below can read its result.
146
+ require_relative "clacky/extension/api_extension"
147
+ require_relative "clacky/extension/api_loader"
148
+ require_relative "clacky/extension/loader"
149
+ require_relative "clacky/extension/scaffold/template_renderer"
150
+ require_relative "clacky/extension/scaffold"
151
+ require_relative "clacky/extension/packager"
152
+ require_relative "clacky/extension/dispatcher"
153
+ require_relative "clacky/extension/hook_loader"
154
+ require_relative "clacky/extension/verifier"
155
+ Clacky::ExtensionLoader.load_all
156
+
157
+ # Runtime patch layer: load user/AI patches from ~/.clacky/patches/ AND patches
158
+ # contributed by ext.yml containers (contributes.patches). Runs after all gem
159
+ # code is defined, so fingerprints reflect the actual installed source.
160
+ require_relative "clacky/extension/patch_loader"
146
161
  Clacky::PatchLoader.load_all
147
162
 
148
- # HTTP API extension layer: define the base class + loader + dispatcher.
149
- # Loading of user extensions is deferred to HttpServer#start so the host
150
- # process is fully initialized before extension handlers can resolve helpers
151
- # like session_manager or agent_config.
152
- require_relative "clacky/api_extension"
153
- require_relative "clacky/api_extension_loader"
154
- require_relative "clacky/server/api_extension_dispatcher"
163
+ # Process-wide registry of hook callbacks contributed by ext.yml containers
164
+ # (contributes.hooks). Each agent copies them onto its own HookManager at init.
165
+ Clacky::ExtensionHookLoader.load_all
155
166
 
156
167
  module Clacky
157
168
  class AgentInterrupted < Exception; end # Inherit from Exception to bypass rescue StandardError