openclacky 1.0.0 → 1.0.2

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/README.md +87 -53
  4. data/lib/clacky/agent/cost_tracker.rb +19 -2
  5. data/lib/clacky/agent/llm_caller.rb +218 -0
  6. data/lib/clacky/agent/message_compressor_helper.rb +32 -2
  7. data/lib/clacky/agent.rb +54 -22
  8. data/lib/clacky/client.rb +44 -5
  9. data/lib/clacky/default_parsers/pdf_parser.rb +58 -17
  10. data/lib/clacky/default_parsers/pdf_parser_ocr.py +103 -0
  11. data/lib/clacky/default_parsers/pdf_parser_plumber.py +62 -0
  12. data/lib/clacky/default_skills/deploy/SKILL.md +201 -77
  13. data/lib/clacky/default_skills/new/SKILL.md +3 -114
  14. data/lib/clacky/default_skills/onboard/SKILL.md +349 -133
  15. data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +371 -0
  16. data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +175 -0
  17. data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +59 -26
  18. data/lib/clacky/message_format/anthropic.rb +72 -8
  19. data/lib/clacky/message_format/bedrock.rb +6 -3
  20. data/lib/clacky/providers.rb +146 -3
  21. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +14 -0
  22. data/lib/clacky/server/channel/adapters/feishu/bot.rb +10 -0
  23. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +1 -0
  24. data/lib/clacky/server/channel/channel_manager.rb +12 -4
  25. data/lib/clacky/server/channel/channel_ui_controller.rb +8 -2
  26. data/lib/clacky/server/http_server.rb +746 -13
  27. data/lib/clacky/server/session_registry.rb +55 -24
  28. data/lib/clacky/skill.rb +10 -9
  29. data/lib/clacky/skill_loader.rb +23 -11
  30. data/lib/clacky/tools/file_reader.rb +232 -127
  31. data/lib/clacky/tools/security.rb +42 -64
  32. data/lib/clacky/tools/terminal/persistent_session.rb +15 -4
  33. data/lib/clacky/tools/terminal/safe_rm.sh +106 -0
  34. data/lib/clacky/tools/terminal/session_manager.rb +8 -3
  35. data/lib/clacky/tools/terminal.rb +263 -16
  36. data/lib/clacky/ui2/layout_manager.rb +8 -1
  37. data/lib/clacky/ui2/output_buffer.rb +83 -23
  38. data/lib/clacky/ui2/ui_controller.rb +74 -7
  39. data/lib/clacky/utils/file_processor.rb +14 -40
  40. data/lib/clacky/utils/model_pricing.rb +215 -0
  41. data/lib/clacky/utils/parser_manager.rb +70 -6
  42. data/lib/clacky/utils/string_matcher.rb +23 -1
  43. data/lib/clacky/version.rb +1 -1
  44. data/lib/clacky/web/app.css +673 -9
  45. data/lib/clacky/web/app.js +40 -1608
  46. data/lib/clacky/web/i18n.js +209 -0
  47. data/lib/clacky/web/index.html +166 -2
  48. data/lib/clacky/web/onboard.js +77 -1
  49. data/lib/clacky/web/profile.js +442 -0
  50. data/lib/clacky/web/sessions.js +1034 -2
  51. data/lib/clacky/web/settings.js +127 -6
  52. data/lib/clacky/web/sidebar.js +39 -0
  53. data/lib/clacky/web/skills.js +460 -0
  54. data/lib/clacky/web/trash.js +343 -0
  55. data/lib/clacky/web/ws-dispatcher.js +255 -0
  56. data/lib/clacky.rb +5 -3
  57. metadata +16 -17
  58. data/lib/clacky/clacky_auth_client.rb +0 -152
  59. data/lib/clacky/clacky_cloud_config.rb +0 -123
  60. data/lib/clacky/cloud_project_client.rb +0 -169
  61. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +0 -1377
  62. data/lib/clacky/default_skills/deploy/tools/check_health.rb +0 -116
  63. data/lib/clacky/default_skills/deploy/tools/create_database_service.rb +0 -341
  64. data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +0 -99
  65. data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +0 -77
  66. data/lib/clacky/default_skills/deploy/tools/list_services.rb +0 -67
  67. data/lib/clacky/default_skills/deploy/tools/report_deploy_status.rb +0 -67
  68. data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +0 -189
  69. data/lib/clacky/default_skills/new/scripts/cloud_project_init.sh +0 -74
  70. data/lib/clacky/deploy_api_client.rb +0 -484
@@ -0,0 +1,343 @@
1
+ // trash.js — Recently Deleted panel
2
+ //
3
+ // Top-level sidebar panel that lists files moved to trash by the agent
4
+ // across every project-scoped trash dir under ~/.clacky/trash/.
5
+ //
6
+ // Each card shows the original path, project, size and deleted-at,
7
+ // plus Restore / Delete buttons. Bulk actions at the top:
8
+ // refresh, empty files older than 7 days, empty everything.
9
+ //
10
+ // Load order: after app.js modules (I18n, Modal), before app.js boot.
11
+
12
+ const Trash = (() => {
13
+ // ── Private state ────────────────────────────────────────────────────
14
+ let _files = [];
15
+ let _totals = { count: 0, size: 0 };
16
+ let _loading = false;
17
+ let _wired = false;
18
+
19
+ // ── Helpers ──────────────────────────────────────────────────────────
20
+
21
+ function $(id) { return document.getElementById(id); }
22
+
23
+ function escapeHtml(s) {
24
+ return String(s ?? "")
25
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;")
26
+ .replace(/>/g, "&gt;").replace(/"/g, "&quot;");
27
+ }
28
+
29
+ function _t(key) {
30
+ return I18n.t ? I18n.t(key) : key;
31
+ }
32
+
33
+ function _humanBytes(n) {
34
+ if (!n || n < 0) return "0 B";
35
+ const units = ["B", "KB", "MB", "GB"];
36
+ let i = 0;
37
+ while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
38
+ return (i === 0 ? n.toFixed(0) : n.toFixed(2)) + " " + units[i];
39
+ }
40
+
41
+ function _humanTime(iso) {
42
+ if (!iso) return "";
43
+ const d = new Date(iso);
44
+ if (isNaN(d.getTime())) return iso;
45
+ const now = new Date();
46
+ const ms = now - d;
47
+ const mins = Math.floor(ms / 60000);
48
+ const hours = Math.floor(ms / 3600000);
49
+ const days = Math.floor(ms / 86400000);
50
+ const zh = I18n.lang() === "zh";
51
+ if (mins < 1) return zh ? "刚刚" : "just now";
52
+ if (mins < 60) return zh ? `${mins} 分钟前` : `${mins}m ago`;
53
+ if (hours < 24) return zh ? `${hours} 小时前` : `${hours}h ago`;
54
+ if (days < 7) return zh ? `${days} 天前` : `${days}d ago`;
55
+ return d.toLocaleDateString();
56
+ }
57
+
58
+ // ── Data loading ─────────────────────────────────────────────────────
59
+
60
+ async function _load() {
61
+ if (_loading) return;
62
+ _loading = true;
63
+ const list = $("trash-list");
64
+ if (list) list.innerHTML =
65
+ `<div class="creator-loading">${_t("trash.loading")}</div>`;
66
+ try {
67
+ const res = await fetch("/api/trash");
68
+ const data = await res.json();
69
+ if (!res.ok) throw new Error(data.error || "Load failed");
70
+ _files = data.files || [];
71
+ _totals = { count: data.total_count || 0, size: data.total_size || 0 };
72
+ _render();
73
+ } catch (e) {
74
+ console.error("[Trash] load failed", e);
75
+ if (list) list.innerHTML =
76
+ `<div class="creator-empty creator-error">${escapeHtml(e.message)}</div>`;
77
+ } finally {
78
+ _loading = false;
79
+ }
80
+ }
81
+
82
+ function _render() {
83
+ const list = $("trash-list");
84
+ const summary = $("trash-summary");
85
+ const btnOld = $("btn-trash-empty-old");
86
+ const btnOrphans = $("btn-trash-empty-orphans");
87
+ const btnAll = $("btn-trash-empty-all");
88
+ if (!list) return;
89
+
90
+ const orphanCount = _files.filter(f => {
91
+ const root = f.project_root || "";
92
+ return /^\/(?:var\/folders|tmp|private\/var\/folders)\b/.test(root) ||
93
+ /\/d\d{8}-\d+-[a-z0-9]+(?:\/|$)/.test(root);
94
+ }).length;
95
+
96
+ if (summary) {
97
+ summary.textContent = _files.length
98
+ ? I18n.t("trash.summary", {
99
+ count: _totals.count,
100
+ size: _humanBytes(_totals.size)
101
+ }) + (orphanCount > 0 ? " • " + I18n.t("trash.summaryOrphans", { count: orphanCount }) : "")
102
+ : "";
103
+ }
104
+ if (btnOld) btnOld.disabled = _files.length === 0;
105
+ if (btnOrphans) btnOrphans.disabled = orphanCount === 0;
106
+ if (btnAll) btnAll.disabled = _files.length === 0;
107
+
108
+ if (_files.length === 0) {
109
+ list.innerHTML = `<div class="creator-empty">${_t("trash.empty")}</div>`;
110
+ return;
111
+ }
112
+
113
+ list.innerHTML = "";
114
+ _files.forEach(f => list.appendChild(_buildCard(f)));
115
+ }
116
+
117
+ function _buildCard(file) {
118
+ const card = document.createElement("div");
119
+ card.className = "trash-card";
120
+ card.dataset.project = file.project_root;
121
+ card.dataset.path = file.original_path;
122
+
123
+ const original = file.original_path || "";
124
+ const basename = original.split("/").pop() || original;
125
+ // Show last two path segments after basename to give agents context when
126
+ // many files share the same basename (very common: "package.json", "index.js").
127
+ const parts = original.split("/").filter(Boolean);
128
+ const shortPath = parts.length > 3
129
+ ? ".../" + parts.slice(-3).join("/")
130
+ : original;
131
+ const sizeStr = _humanBytes(file.file_size || 0);
132
+ const whenStr = _humanTime(file.deleted_at);
133
+ // Heuristic: if project_root starts with /var/folders or /tmp, or contains
134
+ // a tempdir-style name (d20260502-...), the original project is gone.
135
+ // We still show it, but mark it so the user can clean it up confidently.
136
+ const orphan = /^\/(?:var\/folders|tmp|private\/var\/folders)\b/.test(file.project_root || "") ||
137
+ /\/d\d{8}-\d+-[a-z0-9]+(?:\/|$)/.test(file.project_root || "");
138
+
139
+ card.innerHTML = `
140
+ <div class="trash-card-info">
141
+ <div class="trash-card-title" title="${escapeHtml(original)}">${escapeHtml(basename)}</div>
142
+ <div class="trash-card-path" title="${escapeHtml(original)}">${escapeHtml(shortPath)}</div>
143
+ <div class="trash-card-meta">
144
+ <span class="trash-project" title="${escapeHtml(file.project_root)}">
145
+ <svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
146
+ <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
147
+ </svg>
148
+ ${escapeHtml(file.project_name || "")}
149
+ </span>
150
+ <span>${sizeStr}</span>
151
+ <span title="${escapeHtml(file.deleted_at || "")}">${escapeHtml(whenStr)}</span>
152
+ ${orphan ? `<span class="trash-missing" title="${_t("trash.orphanHint")}">⚠ ${_t("trash.orphan")}</span>` : ""}
153
+ </div>
154
+ </div>
155
+ <div class="trash-card-actions">
156
+ <button class="btn-trash-restore" title="${_t("trash.restore")}" ${orphan ? "disabled" : ""}>
157
+ <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
158
+ <polyline points="1 4 1 10 7 10"/>
159
+ <path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
160
+ </svg>
161
+ ${_t("trash.restore")}
162
+ </button>
163
+ <button class="btn-trash-delete" title="${_t("trash.delete")}">
164
+ <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
165
+ <polyline points="3 6 5 6 21 6"/>
166
+ <path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
167
+ <path d="M10 11v6"/><path d="M14 11v6"/>
168
+ </svg>
169
+ </button>
170
+ </div>`;
171
+
172
+ card.querySelector(".btn-trash-restore").addEventListener("click", () =>
173
+ _restoreOne(file, card));
174
+ card.querySelector(".btn-trash-delete").addEventListener("click", () =>
175
+ _deleteOne(file, card));
176
+
177
+ return card;
178
+ }
179
+
180
+ async function _restoreOne(file, card) {
181
+ const btn = card.querySelector(".btn-trash-restore");
182
+ btn.disabled = true;
183
+ try {
184
+ const res = await fetch("/api/trash/restore", {
185
+ method: "POST",
186
+ headers: { "Content-Type": "application/json" },
187
+ body: JSON.stringify({
188
+ project_root: file.project_root,
189
+ original_path: file.original_path
190
+ })
191
+ });
192
+ const data = await res.json();
193
+ if (!res.ok || !data.ok) {
194
+ alert(I18n.t("trash.restoreFail", {
195
+ msg: data.error || res.statusText
196
+ }));
197
+ } else {
198
+ // Remove card, update totals locally for instant feedback.
199
+ _files = _files.filter(f =>
200
+ !(f.project_root === file.project_root && f.original_path === file.original_path));
201
+ _totals = {
202
+ count: Math.max(0, _totals.count - 1),
203
+ size: Math.max(0, _totals.size - (file.file_size || 0))
204
+ };
205
+ _render();
206
+ }
207
+ } catch (e) {
208
+ alert(I18n.t("trash.restoreFail", { msg: e.message }));
209
+ } finally {
210
+ btn.disabled = false;
211
+ }
212
+ }
213
+
214
+ async function _deleteOne(file, card) {
215
+ const basename = (file.original_path || "").split("/").pop() || file.original_path;
216
+ const confirmed = await Modal.confirm(
217
+ I18n.t("trash.confirmDeleteOne", { name: basename })
218
+ );
219
+ if (!confirmed) return;
220
+
221
+ const url = "/api/trash?" + new URLSearchParams({
222
+ project: file.project_root,
223
+ file: file.original_path
224
+ }).toString();
225
+
226
+ try {
227
+ const res = await fetch(url, { method: "DELETE" });
228
+ const data = await res.json();
229
+ if (!res.ok || !data.ok) {
230
+ alert(data.error || res.statusText);
231
+ return;
232
+ }
233
+ _files = _files.filter(f =>
234
+ !(f.project_root === file.project_root && f.original_path === file.original_path));
235
+ _totals = {
236
+ count: Math.max(0, _totals.count - 1),
237
+ size: Math.max(0, _totals.size - (file.file_size || 0))
238
+ };
239
+ _render();
240
+ } catch (e) {
241
+ alert(e.message);
242
+ }
243
+ }
244
+
245
+ async function _emptyBulk(daysOld, confirmKey) {
246
+ const confirmed = await Modal.confirm(_t(confirmKey));
247
+ if (!confirmed) return;
248
+
249
+ const qs = new URLSearchParams();
250
+ qs.set("days_old", String(daysOld));
251
+ const url = "/api/trash?" + qs.toString();
252
+
253
+ try {
254
+ const res = await fetch(url, { method: "DELETE" });
255
+ const data = await res.json();
256
+ if (!res.ok || !data.ok) {
257
+ alert(data.error || res.statusText);
258
+ return;
259
+ }
260
+ if (data.deleted_count === 0 && daysOld > 0) {
261
+ alert(_t("trash.nothingOld"));
262
+ } else {
263
+ alert(I18n.t("trash.emptied", {
264
+ count: data.deleted_count || 0,
265
+ size: _humanBytes(data.freed_size || 0)
266
+ }));
267
+ }
268
+ await _load();
269
+ } catch (e) {
270
+ alert(e.message);
271
+ }
272
+ }
273
+
274
+ // Detects trash entries whose original project_root clearly no longer
275
+ // exists (test temp dirs under /var/folders, /tmp, or dir-format "dYYYYMMDD-...").
276
+ // The delete API does permanent deletion on a per-file basis.
277
+ async function _emptyOrphans() {
278
+ const orphans = _files.filter(f => {
279
+ const root = f.project_root || "";
280
+ return /^\/(?:var\/folders|tmp|private\/var\/folders)\b/.test(root) ||
281
+ /\/d\d{8}-\d+-[a-z0-9]+(?:\/|$)/.test(root);
282
+ });
283
+ if (orphans.length === 0) {
284
+ alert(_t("trash.noOrphans"));
285
+ return;
286
+ }
287
+ const confirmed = await Modal.confirm(
288
+ I18n.t("trash.confirmEmptyOrphans", { count: orphans.length })
289
+ );
290
+ if (!confirmed) return;
291
+
292
+ let deleted = 0, freed = 0, failed = 0;
293
+ for (const f of orphans) {
294
+ const url = "/api/trash?" + new URLSearchParams({
295
+ project: f.project_root,
296
+ file: f.original_path
297
+ }).toString();
298
+ try {
299
+ const r = await fetch(url, { method: "DELETE" });
300
+ const d = await r.json();
301
+ if (r.ok && d.ok) {
302
+ deleted += 1;
303
+ freed += d.freed_size || 0;
304
+ } else {
305
+ failed += 1;
306
+ }
307
+ } catch (_e) {
308
+ failed += 1;
309
+ }
310
+ }
311
+ alert(I18n.t("trash.orphansCleaned", {
312
+ count: deleted,
313
+ size: _humanBytes(freed),
314
+ failed: failed
315
+ }));
316
+ await _load();
317
+ }
318
+
319
+ function _wire() {
320
+ if (_wired) return;
321
+ _wired = true;
322
+ const btnRefresh = $("btn-trash-refresh");
323
+ const btnOld = $("btn-trash-empty-old");
324
+ const btnOrphans = $("btn-trash-empty-orphans");
325
+ const btnAll = $("btn-trash-empty-all");
326
+ if (btnRefresh) btnRefresh.addEventListener("click", () => _load());
327
+ if (btnOld) btnOld.addEventListener("click",
328
+ () => _emptyBulk(7, "trash.confirmEmptyOld"));
329
+ if (btnOrphans) btnOrphans.addEventListener("click", () => _emptyOrphans());
330
+ if (btnAll) btnAll.addEventListener("click",
331
+ () => _emptyBulk(0, "trash.confirmEmptyAll"));
332
+ }
333
+
334
+ // ── Public API ────────────────────────────────────────────────────────
335
+
336
+ return {
337
+ /** Called by Router when the trash panel becomes active. */
338
+ onPanelShow() {
339
+ _wire();
340
+ _load();
341
+ },
342
+ };
343
+ })();
@@ -0,0 +1,255 @@
1
+ // ── WS event dispatcher ───────────────────────────────────────────────────
2
+ //
3
+ // Consumes events emitted by WS (ws.js) and dispatches them to the right
4
+ // business module (Sessions, Tasks, Skills, Channels, Settings, Brand, ...).
5
+ //
6
+ // Kept as a separate file from ws.js on purpose:
7
+ // - ws.js is a pure transport layer (connect / send / subscribe / reconnect)
8
+ // - this file is the application-level router that knows about every
9
+ // business module. Mixing the two would force ws.js to depend on every
10
+ // other module, breaking layering.
11
+ //
12
+ // Depends on: WS (ws.js), Sessions, Tasks, Skills, Channels, Settings, Brand,
13
+ // Router, I18n, global $ / escapeHtml / showConfirmModal helpers.
14
+ // ─────────────────────────────────────────────────────────────────────────
15
+ (function() {
16
+ // Guard: restore hash routing only once after initial session_list arrives.
17
+ let _initialRestoreDone = false;
18
+
19
+
20
+ WS.onEvent(ev => {
21
+ console.log("[DEBUG] WS event received:", ev.type, ev);
22
+ switch (ev.type) {
23
+
24
+ // ── Internal WS lifecycle ──────────────────────────────────────────
25
+ case "_ws_connected": {
26
+ const banner = document.getElementById("offline-banner");
27
+ if (banner) banner.style.display = "none";
28
+ const hint = $("ws-disconnect-hint");
29
+ if (hint) hint.style.display = "none";
30
+ break;
31
+ }
32
+
33
+ case "_ws_disconnected": {
34
+ const banner = document.getElementById("offline-banner");
35
+ if (banner) {
36
+ banner.textContent = I18n.t("offline.banner");
37
+ banner.style.display = "block";
38
+ }
39
+ // Do NOT force status bar to "idle" here — on a brief WS hiccup the
40
+ // agent may still be running, and reconnect will deliver a fresh
41
+ // session snapshot that patches the real status. Forcing idle here
42
+ // caused stuck UI after reconnect when the snapshot logic wasn't
43
+ // re-asserting status on every reconnect.
44
+ Sessions.clearAllProgress();
45
+ break;
46
+ }
47
+
48
+ // ── Session list ───────────────────────────────────────────────────
49
+ case "session_list": {
50
+ Sessions.setAll(ev.sessions || [], !!ev.has_more);
51
+ Sessions.renderList();
52
+
53
+ // Restore URL hash once on initial connect; ignore subsequent session_list events.
54
+ // Skip if we are already on a session view (e.g. onboard flow navigated there
55
+ // before WS connected) — restoreFromHash would wrongly redirect to "welcome"
56
+ // because there is no hash set during onboarding.
57
+ if (!_initialRestoreDone) {
58
+ _initialRestoreDone = true;
59
+ if (Router.current !== "session") {
60
+ Router.restoreFromHash();
61
+ }
62
+ } else {
63
+ // If active session was deleted, go to welcome
64
+ if (Sessions.activeId && !Sessions.find(Sessions.activeId)) {
65
+ Router.navigate("welcome");
66
+ }
67
+ }
68
+ break;
69
+ }
70
+
71
+ // ── Session lifecycle ──────────────────────────────────────────────
72
+ case "subscribed": {
73
+ // Re-enable send button now that the server has confirmed the subscription.
74
+ $("btn-send").disabled = false;
75
+ $("user-input").focus();
76
+ // If this session was created by Tasks.run(), fire the agent now that
77
+ // we're guaranteed to receive its broadcasts.
78
+ const pendingId = Sessions.takePendingRunTask();
79
+ if (pendingId && pendingId === ev.session_id) {
80
+ WS.send({ type: "run_task", session_id: pendingId });
81
+ }
82
+ // If a slash-command was queued (e.g. /onboard from first-boot flow),
83
+ // send it now — after restoreFromHash has settled — so appendMsg won't be wiped.
84
+ const pendingMsg = Sessions.takePendingMessage();
85
+ if (pendingMsg && pendingMsg.session_id === ev.session_id) {
86
+ Sessions.appendMsg("user", escapeHtml(pendingMsg.content), { time: new Date() });
87
+ WS.send({ type: "message", session_id: pendingMsg.session_id, content: pendingMsg.content });
88
+ }
89
+ break;
90
+ }
91
+
92
+ case "session_update": {
93
+ // Two shapes arrive under this type:
94
+ // (1) Full session object from http_server broadcast_session_update:
95
+ // { type, session: { id, name, status, total_cost, total_tasks, ... } }
96
+ // (2) Partial real-time update from web_ui_controller (cost/tasks/status):
97
+ // { type, session_id, cost?, tasks?, status? }
98
+ let sid, patch;
99
+ if (ev.session) {
100
+ // Shape (1): full session — use as-is
101
+ sid = ev.session.id;
102
+ patch = ev.session;
103
+ } else {
104
+ // Shape (2): partial update — build patch from top-level fields
105
+ sid = ev.session_id;
106
+ patch = {};
107
+ if (ev.cost !== undefined) patch.total_cost = ev.cost;
108
+ if (ev.tasks !== undefined) patch.total_tasks = ev.tasks;
109
+ if (ev.status !== undefined) patch.status = ev.status;
110
+ // Latency pushed by Agent after each LLM call (see update_sessionbar).
111
+ // Stored under latest_latency — same field name the HTTP /api/sessions
112
+ // list returns, so updateInfoBar doesn't need to branch on the source.
113
+ if (ev.latency !== undefined) patch.latest_latency = ev.latency;
114
+ }
115
+ if (!sid) break;
116
+ Sessions.patch(sid, patch);
117
+ Sessions.renderList();
118
+ if (sid === Sessions.activeId) {
119
+ const current = Sessions.find(sid);
120
+ if (patch.status !== undefined) Sessions.updateStatusBar(patch.status);
121
+ Sessions.updateInfoBar(current);
122
+ // Update chat title/subtitle in case session was renamed or working_dir changed
123
+ Sessions.updateChatHeader(current);
124
+ }
125
+ // When a session finishes, refresh tasks and skills, and clear any progress state
126
+ if (patch.status === "idle") {
127
+ Tasks.load();
128
+ Skills.load();
129
+ // Clear progress state for this session (even if not currently active)
130
+ Sessions.clearProgress(sid);
131
+ }
132
+ break;
133
+ }
134
+
135
+ case "session_renamed": {
136
+ Sessions.patch(ev.session_id, { name: ev.name });
137
+ Sessions.renderList();
138
+ // Title is now shown only in the sidebar; chat-header element was removed.
139
+ break;
140
+ }
141
+
142
+ case "session_deleted":
143
+ Sessions.remove(ev.session_id);
144
+ if (ev.session_id === Sessions.activeId) Router.navigate("welcome");
145
+ Sessions.renderList();
146
+ break;
147
+
148
+ // ── Chat messages ──────────────────────────────────────────────────
149
+ case "history_user_message":
150
+ // Emitted only during history replay — never from live WS.
151
+ // Rendered by Sessions._fetchHistory; nothing to do here.
152
+ break;
153
+
154
+ case "assistant_message":
155
+ if (ev.session_id !== Sessions.activeId) break;
156
+ Sessions.clearProgress();
157
+ Sessions.appendMsg("assistant", ev.content);
158
+ break;
159
+
160
+ case "tool_call":
161
+ if (ev.session_id !== Sessions.activeId) break;
162
+ Sessions.clearProgress();
163
+ Sessions.appendToolCall(ev.name, ev.args, ev.summary);
164
+ break;
165
+
166
+ case "tool_result":
167
+ if (ev.session_id !== Sessions.activeId) break;
168
+ Sessions.appendToolResult(ev.result);
169
+ break;
170
+
171
+ case "tool_stdout":
172
+ if (ev.session_id !== Sessions.activeId) break;
173
+ Sessions.appendToolStdout(ev.lines);
174
+ break;
175
+
176
+ case "tool_error":
177
+ if (ev.session_id !== Sessions.activeId) break;
178
+ Sessions.appendMsg("info", `⚠ Tool error: ${escapeHtml(ev.error)}`);
179
+ break;
180
+
181
+ case "token_usage":
182
+ if (ev.session_id !== Sessions.activeId) break;
183
+ Sessions.appendTokenUsage(ev);
184
+ break;
185
+
186
+ case "progress":
187
+ console.log("[DEBUG] progress event:", ev);
188
+ if (ev.session_id !== Sessions.activeId) break;
189
+ if (ev.phase === "active" || ev.status === "start") {
190
+ const progress_type = ev.progress_type || "thinking";
191
+ const metadata = ev.metadata || {};
192
+ console.log("[DEBUG] calling showProgress:", { message: ev.message, progress_type, metadata, started_at: ev.started_at });
193
+ Sessions.showProgress(ev.message, progress_type, metadata, ev.started_at || null);
194
+ } else {
195
+ console.log("[DEBUG] calling clearProgress:", ev.message);
196
+ Sessions.clearProgress(ev.message);
197
+ }
198
+ break;
199
+
200
+ case "complete":
201
+ if (ev.session_id !== Sessions.activeId) break;
202
+ Sessions.clearProgress();
203
+ Sessions.collapseToolGroup();
204
+ {
205
+ const costSource = ev.cost_source;
206
+ const costDisplay = (!costSource || costSource === "estimated")
207
+ ? "N/A"
208
+ : `$${(ev.cost || 0).toFixed(4)}`;
209
+ Sessions.appendInfo(`✓ ${I18n.t("chat.done", { n: ev.iterations, cost: costDisplay })}`);
210
+ }
211
+ break;
212
+
213
+ case "request_feedback":
214
+ if (ev.session_id !== Sessions.activeId) break;
215
+ Sessions.showFeedbackRequest(ev.question, ev.context, ev.options);
216
+ break;
217
+
218
+ case "request_confirmation":
219
+ if (ev.session_id !== Sessions.activeId) break;
220
+ showConfirmModal(ev.id, ev.message);
221
+ break;
222
+
223
+ case "interrupted":
224
+ if (ev.session_id !== Sessions.activeId) break;
225
+ Sessions.clearProgress();
226
+ Sessions.collapseToolGroup();
227
+ Sessions.appendInfo(I18n.t("chat.interrupted"));
228
+ break;
229
+
230
+ // ── Info / errors ──────────────────────────────────────────────────
231
+ case "info":
232
+ Sessions.appendInfo(ev.message);
233
+ break;
234
+
235
+ case "warning":
236
+ // Optimize retry messages for better UX
237
+ const friendlyWarning = _transformRetryWarning(ev.message);
238
+ if (friendlyWarning) {
239
+ Sessions.appendInfo(friendlyWarning);
240
+ }
241
+ break;
242
+
243
+ case "success":
244
+ Sessions.appendMsg("success", "✓ " + escapeHtml(ev.message));
245
+ break;
246
+
247
+ case "error":
248
+ if (!ev.session_id || ev.session_id === Sessions.activeId)
249
+ Sessions.appendMsg("error", escapeHtml(ev.message));
250
+ break;
251
+ }
252
+ });
253
+
254
+
255
+ })();
data/lib/clacky.rb CHANGED
@@ -74,9 +74,6 @@ require_relative "clacky/message_history"
74
74
  require_relative "clacky/agent_config"
75
75
  require_relative "clacky/agent_profile"
76
76
  require_relative "clacky/providers"
77
- require_relative "clacky/clacky_auth_client"
78
- require_relative "clacky/clacky_cloud_config"
79
- require_relative "clacky/cloud_project_client"
80
77
  require_relative "clacky/session_manager"
81
78
  require_relative "clacky/idle_compression_timer"
82
79
 
@@ -136,6 +133,11 @@ module Clacky
136
133
  class AgentError < StandardError; end
137
134
  class BadRequestError < AgentError; end # 400 errors — our request was malformed, history should be rolled back
138
135
  class RetryableError < StandardError; end # Transient errors that should be retried (5xx, HTML response, rate limit)
136
+ # Upstream (model/router like OpenRouter/Bedrock) returned finish_reason="stop" together with
137
+ # one or more tool_calls whose `arguments` JSON was truncated (empty, "{}" placeholder, or
138
+ # otherwise unparseable). Subclass of RetryableError so it flows through the existing
139
+ # retry/fallback pipeline in LlmCaller#call_llm.
140
+ class UpstreamTruncatedError < RetryableError; end
139
141
  class ToolCallError < AgentError; end # Raised when tool call fails due to invalid parameters
140
142
  class BrowserNotReachableError < AgentError; end # Chrome/Edge not running or remote debugging disabled
141
143
  # BrowserManager singleton: Clacky::BrowserManager.instance