openclacky 1.3.6 → 1.3.8

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 (112) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/lib/clacky/agent/hook_manager.rb +10 -1
  4. data/lib/clacky/agent/system_prompt_builder.rb +1 -1
  5. data/lib/clacky/agent.rb +17 -3
  6. data/lib/clacky/agent_profile.rb +124 -53
  7. data/lib/clacky/billing/billing_store.rb +3 -2
  8. data/lib/clacky/brand_config.rb +309 -6
  9. data/lib/clacky/cli.rb +4 -57
  10. data/lib/clacky/default_extensions/coding/agents/coding/avatar.png +0 -0
  11. data/lib/clacky/default_extensions/coding/ext.yml +19 -0
  12. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/avatar.png +0 -0
  13. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +95 -0
  14. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +254 -0
  15. data/lib/clacky/default_extensions/ext-studio/ext.yml +33 -0
  16. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +1288 -0
  17. data/lib/clacky/default_extensions/ext-studio/skills/ext-debug/SKILL.md +71 -0
  18. data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +74 -0
  19. data/lib/clacky/default_extensions/ext-studio/skills/ext-scaffold/SKILL.md +65 -0
  20. data/lib/clacky/default_extensions/general/agents/general/avatar.png +0 -0
  21. data/lib/clacky/default_extensions/general/ext.yml +19 -0
  22. data/lib/clacky/default_extensions/git/ext.yml +12 -0
  23. data/lib/clacky/{default_agents/_panels/git/panel.js → default_extensions/git/panels/git/view.js} +4 -5
  24. data/lib/clacky/default_extensions/meeting/{handler.rb → api/handler.rb} +78 -26
  25. data/lib/clacky/default_extensions/meeting/ext.yml +15 -0
  26. data/lib/clacky/default_extensions/meeting/{meeting.js → panels/meeting/view.js} +266 -90
  27. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +2 -1
  28. data/lib/clacky/default_extensions/time_machine/ext.yml +12 -0
  29. data/lib/clacky/{default_agents/_panels/time_machine/panel.js → default_extensions/time_machine/panels/time_machine/view.js} +58 -47
  30. data/lib/clacky/default_skills/channel-manager/discord_setup.rb +1 -1
  31. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +83 -18
  32. data/lib/clacky/{api_extension.rb → extension/api_extension.rb} +27 -11
  33. data/lib/clacky/extension/api_loader.rb +136 -0
  34. data/lib/clacky/extension/cli_commands.rb +226 -0
  35. data/lib/clacky/{server/api_extension_dispatcher.rb → extension/dispatcher.rb} +22 -9
  36. data/lib/clacky/extension/hook_loader.rb +77 -0
  37. data/lib/clacky/extension/loader.rb +489 -0
  38. data/lib/clacky/extension/packager.rb +226 -0
  39. data/lib/clacky/{patch_loader.rb → extension/patch_loader.rb} +45 -0
  40. data/lib/clacky/extension/scaffold/template_renderer.rb +64 -0
  41. data/lib/clacky/extension/scaffold/templates/full/README.md.erb +48 -0
  42. data/lib/clacky/extension/scaffold/templates/full/agents/designer.md +5 -0
  43. data/lib/clacky/extension/scaffold/templates/full/api/handler.rb.erb +16 -0
  44. data/lib/clacky/extension/scaffold/templates/full/channels/noop.rb.erb +40 -0
  45. data/lib/clacky/extension/scaffold/templates/full/ext.yml.erb +41 -0
  46. data/lib/clacky/extension/scaffold/templates/full/hooks/audit.rb +9 -0
  47. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +194 -0
  48. data/lib/clacky/extension/scaffold/templates/full/patches/audit.rb +15 -0
  49. data/lib/clacky/extension/scaffold/templates/full/skills/__slug__-skill/SKILL.md.erb +8 -0
  50. data/lib/clacky/extension/scaffold/templates/hello/api/handler.rb.erb +9 -0
  51. data/lib/clacky/extension/scaffold/templates/hello/ext.yml.erb +12 -0
  52. data/lib/clacky/extension/scaffold/templates/hello/panels/hello/view.js.erb +31 -0
  53. data/lib/clacky/extension/scaffold.rb +55 -0
  54. data/lib/clacky/extension/verifier.rb +196 -0
  55. data/lib/clacky/identity.rb +70 -0
  56. data/lib/clacky/locales/en.rb +19 -1
  57. data/lib/clacky/locales/zh.rb +19 -1
  58. data/lib/clacky/platform_http_client.rb +18 -15
  59. data/lib/clacky/server/channel/extension_adapter_loader.rb +32 -0
  60. data/lib/clacky/server/channel.rb +6 -0
  61. data/lib/clacky/server/http_server.rb +439 -146
  62. data/lib/clacky/server/session_registry.rb +9 -2
  63. data/lib/clacky/shell_hook_loader.rb +266 -22
  64. data/lib/clacky/skill_loader.rb +28 -1
  65. data/lib/clacky/ui2/components/welcome_banner.rb +1 -1
  66. data/lib/clacky/ui2/ui_controller.rb +9 -6
  67. data/lib/clacky/utils/workspace_rules.rb +2 -2
  68. data/lib/clacky/version.rb +1 -1
  69. data/lib/clacky/web/app.css +1034 -675
  70. data/lib/clacky/web/app.js +63 -15
  71. data/lib/clacky/web/auth.js +2 -0
  72. data/lib/clacky/web/components/notify.js +2 -0
  73. data/lib/clacky/web/components/sidebar.js +1 -5
  74. data/lib/clacky/web/core/ext.js +432 -89
  75. data/lib/clacky/web/features/backup/store.js +1 -0
  76. data/lib/clacky/web/features/backup/view.js +0 -1
  77. data/lib/clacky/web/features/brand/view.js +13 -5
  78. data/lib/clacky/web/features/extensions/store.js +184 -0
  79. data/lib/clacky/web/features/extensions/view.js +390 -0
  80. data/lib/clacky/web/features/new-session/store.js +199 -0
  81. data/lib/clacky/web/features/new-session/view.js +619 -0
  82. data/lib/clacky/web/features/share/view.js +63 -14
  83. data/lib/clacky/web/features/skills/store.js +3 -1
  84. data/lib/clacky/web/features/skills/view.js +1 -1
  85. data/lib/clacky/web/features/tasks/view.js +12 -0
  86. data/lib/clacky/web/features/trash/store.js +24 -6
  87. data/lib/clacky/web/features/trash/view.js +48 -12
  88. data/lib/clacky/web/features/workspace/store.js +2 -0
  89. data/lib/clacky/web/features/workspace/view.js +0 -1
  90. data/lib/clacky/web/i18n.js +152 -106
  91. data/lib/clacky/web/index.html +172 -190
  92. data/lib/clacky/web/sessions.js +67 -272
  93. data/lib/clacky/web/settings.js +1 -1
  94. data/lib/clacky/web/skills.js +88 -48
  95. data/lib/clacky/web/theme.js +5 -0
  96. data/lib/clacky/web/ws-dispatcher.js +12 -2
  97. data/lib/clacky/web/ws.js +2 -0
  98. data/lib/clacky.rb +21 -10
  99. metadata +52 -21
  100. data/benchmark/runner.rb +0 -441
  101. data/lib/clacky/api_extension_loader.rb +0 -168
  102. data/lib/clacky/default_agents/SOUL.md +0 -3
  103. data/lib/clacky/default_agents/USER.md +0 -1
  104. data/lib/clacky/default_agents/coding/profile.yml +0 -5
  105. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  106. data/lib/clacky/default_agents/general/profile.yml +0 -2
  107. data/lib/clacky/default_extensions/meeting/meta.yml +0 -3
  108. data/lib/clacky/web/features/creator/store.js +0 -81
  109. data/lib/clacky/web/features/creator/view.js +0 -380
  110. /data/lib/clacky/{default_agents → default_extensions/coding/agents}/coding/system_prompt.md +0 -0
  111. /data/lib/clacky/{default_agents → default_extensions/general/agents}/general/system_prompt.md +0 -0
  112. /data/lib/clacky/{default_agents/base_prompt.md → prompts/base.md} +0 -0
@@ -1,12 +1,23 @@
1
1
  // ── Clacky.ext — WebUI extension registry ─────────────────────────────────
2
2
  //
3
- // The single, controlled entry point through which user/AI-authored
4
- // extensions (loaded from ~/.clacky/webui_ext/) hook into the WebUI.
3
+ // The single, controlled entry point through which extensions declared
4
+ // in an ext.yml container hook into the WebUI.
5
5
  //
6
6
  // Three capabilities (the whole contract an extension author must learn):
7
7
  // Clacky.ext.api.register(name, fn) — register a data source
8
8
  // Clacky.ext.subscribe(event, handler) — listen to store events (read-only)
9
- // Clacky.ext.ui.mount(slot, renderFn) — inject UI into a named slot
9
+ // Clacky.ext.ui.mount(slot, spec, opts) — inject UI into a named slot
10
+ //
11
+ // `spec` is either a render function or { create?, render }. The render
12
+ // signature is always `render(container, ctx, runtime)`:
13
+ // - `container` is a host-owned DOM element — append your UI into it,
14
+ // or return a DOM node / HTML string and the host appends it for you.
15
+ // - returning a function registers it as a teardown callback.
16
+ // - returning null is treated as an error (almost always a wrong
17
+ // signature, e.g. `(ctx) => ...`), surfaced as a crashed placeholder.
18
+ // On per-session slots (session.aside/banner/composer), providing
19
+ // `create(ctx)` gives you one runtime per sessionId, isolated across
20
+ // sessions and disposed only when the session leaves the sidebar.
10
21
  //
11
22
  // Safety guarantees enforced here (the "constitution"):
12
23
  // - Every extension callback is wrapped in try/catch. A throwing extension is
@@ -23,9 +34,9 @@
23
34
  window.Clacky = window.Clacky || {};
24
35
 
25
36
  Clacky.ext = (() => {
26
- // Pure mode: detect ?pure=true once. When on, all registration is a no-op and
27
- // the host must not load any webui_ext scripts (handled server-side / in the
28
- // loader). This is the ultimate escape hatch back to a clean official UI.
37
+ // Pure mode: detect ?pure=true once. When on, all registration is a no-op
38
+ // and the host must not inject any extension scripts (handled server-side).
39
+ // This is the ultimate escape hatch back to a clean official UI.
29
40
  const PURE = (() => {
30
41
  try {
31
42
  return new URLSearchParams(window.location.search).get("pure") === "true";
@@ -37,14 +48,47 @@ Clacky.ext = (() => {
37
48
  const _dataSources = {}; // name => fn
38
49
  const _subscribers = {}; // event => [handler]
39
50
  const _slotRenderers = {}; // slot => [{ fn, extId, agents, panel, order, tab }]
51
+ const _workspaces = {}; // id => { id, title, render, extId }
40
52
  let _currentExtId = null; // set while an extension file is loading
41
53
 
42
54
  // Slots that render as a tabbed container instead of a vertical stack: each
43
55
  // renderer becomes one tab (chrome drawn by the host), and only the active
44
56
  // tab's body is shown. Renderers in these slots must carry opts.tab.
45
57
  const TABBED_SLOTS = { "session.aside": true };
46
- // slot => active tab id (remembered across re-renders within a page load).
47
- const _activeTab = {};
58
+
59
+ // Slots that follow the current session's agent scope. A mount into any
60
+ // of these, made from inside a panel file, is confined to that panel's
61
+ // agents (via _currentPanel). Every other slot — sidebar, header, main
62
+ // workspace — is treated as global chrome: mounts there always show,
63
+ // regardless of which panel file happened to be loading. This is what
64
+ // separates "session UI" from "app chrome" and it is the ONLY place
65
+ // that distinction lives.
66
+ const SESSION_SCOPED_SLOTS = {
67
+ "session.banner": true,
68
+ "session.composer": true,
69
+ "session.aside": true,
70
+ "settings.tabs": true,
71
+ "settings.body": true,
72
+ };
73
+
74
+ // Named slots the host renders. Extensions mounting into any other name
75
+ // will silently do nothing — we warn once per bad name to catch typos
76
+ // like "sidebar-nav.top" or "session.aisde" during development.
77
+ const KNOWN_SLOTS = {
78
+ "header.left": true,
79
+ "header.right": true,
80
+ "sidebar.nav.top": true,
81
+ "sidebar.nav": true,
82
+ "sidebar.nav.bottom": true,
83
+ "sidebar.footer": true,
84
+ "main.workspace": true,
85
+ "session.banner": true,
86
+ "session.composer": true,
87
+ "session.aside": true,
88
+ "settings.tabs": true,
89
+ "settings.body": true,
90
+ };
91
+ const _warnedUnknownSlots = {};
48
92
 
49
93
  // Per-extension/panel agent scoping declared at load time:
50
94
  // _extAgents[extId] = ["coding", ...] (from <script data-agent=...>, may be multiple)
@@ -52,6 +96,7 @@ Clacky.ext = (() => {
52
96
  // Resolved by the host before the extension scripts run (see loader markers).
53
97
  const _extAgents = {};
54
98
  const _panelAgents = {};
99
+ const _agentContributions = {}; // agent id => { panels:[{id,title,title_zh}], skills:[...] }
55
100
  let _currentPanel = null; // set while an official panel file is loading
56
101
 
57
102
  // Current session context, kept in sync by the host on every session switch.
@@ -72,15 +117,88 @@ Clacky.ext = (() => {
72
117
  };
73
118
  }
74
119
 
75
- // Bracket an extension's synchronous evaluation so registrations made during
76
- // it are attributed to `extId`. The host emits _extBegin before the
120
+ // Warn once per unknown slot so a typo is loud but the log stays finite.
121
+ function _warnUnknownSlot(slot, label) {
122
+ if (KNOWN_SLOTS[slot] || _warnedUnknownSlots[slot]) return;
123
+ _warnedUnknownSlots[slot] = true;
124
+ const known = Object.keys(KNOWN_SLOTS).join(", ");
125
+ console.warn(
126
+ `[Clacky.ext] unknown slot ${JSON.stringify(slot)} in ${label}. ` +
127
+ `Nothing will render. Known slots: ${known}`
128
+ );
129
+ }
130
+
131
+ // Slots whose renderer represents "the current session's copy" of some UI
132
+ // (a tabbed panel body, a session banner, a session-composer button). Mounts
133
+ // to these slots may declare `create(ctx)` and get one runtime per session,
134
+ // isolated from every other session and torn down only when the session
135
+ // itself leaves the sidebar.
136
+ const PER_SESSION_SLOTS = {
137
+ "session.aside": true,
138
+ "session.banner": true,
139
+ "session.composer": true,
140
+ };
141
+
142
+ // sessionId => Map<mountKey, runtime>. Populated lazily on first render.
143
+ const _sessionRuntimes = new Map();
144
+ // sessionId => Map<mountKey, teardownFn|null>. Cleared before every rerender.
145
+ const _viewTeardowns = new Map();
146
+
147
+ // Deterministic key for a renderer within a session: same slot + extId + tab
148
+ // id always resolves to the same runtime for a given session.
149
+ function _mountKey(slot, entry) {
150
+ const tabId = entry.tab && entry.tab.id ? entry.tab.id : "_";
151
+ return `${slot}::${entry.extId || "?"}::${tabId}`;
152
+ }
153
+
154
+ // Normalize a mount registration. Accepts either a plain function (render
155
+ // only) or an object { create?, render }. Anything else is rejected with a
156
+ // console warning to catch typos early.
157
+ function _register(slot, spec, opts, extId, extra) {
158
+ let create = null;
159
+ let render;
160
+ if (typeof spec === "function") {
161
+ render = spec;
162
+ } else if (spec && typeof spec === "object" && typeof spec.render === "function") {
163
+ render = spec.render;
164
+ if (typeof spec.create === "function") create = spec.create;
165
+ } else {
166
+ console.warn(
167
+ `[Clacky.ext] ui.mount(${JSON.stringify(slot)}) needs a render function ` +
168
+ `or { create?, render } object. Got: ${typeof spec}`
169
+ );
170
+ return;
171
+ }
172
+
173
+ const isBuiltin = !!(extra && extra.builtin);
174
+ const explicit = opts && Array.isArray(opts.agents) ? opts.agents : null;
175
+ const panel = (!isBuiltin && SESSION_SCOPED_SLOTS[slot]) ? _currentPanel : null;
176
+ const scoped = isBuiltin ? null : (explicit || _extAgents[extId] || null);
177
+ const order = opts && Number.isFinite(opts.order) ? opts.order : 100;
178
+ const tab = (opts && opts.tab) || null;
179
+ const workspace = (opts && typeof opts.workspace === "string") ? opts.workspace : null;
180
+
181
+ const label = `ui.mount(${slot})`;
182
+ (_slotRenderers[slot] ||= []).push({
183
+ render: _guard(render, label, extId),
184
+ create: create ? _guard(create, `${label}#create`, extId) : null,
185
+ extId,
186
+ agents: scoped,
187
+ panel,
188
+ order,
189
+ tab,
190
+ workspace,
191
+ });
192
+ }
193
+
194
+
77
195
  // extension's <script src> and _extEnd right after. In pure mode these are
78
196
  // no-ops (and the host does not emit extension scripts at all).
79
197
  //
80
- // `agents` scopes a single-agent extension (from agents/<name>/webui/): a list
81
- // of agent profile names it should appear for. `panel` marks an official
82
- // panel (from _panels/<id>/) whose agent scope is resolved separately via
83
- // registerPanelAgents. Either may be omitted for a global extension.
198
+ // `agents` scopes a single-agent extension (from user agents/<name>/webui/):
199
+ // a list of agent profile names it should appear for. `panel` marks a panel
200
+ // whose agent scope is resolved separately via registerPanelAgents. Either
201
+ // may be omitted for a global extension.
84
202
  function _extBegin(extId, agents, panel) {
85
203
  if (PURE) return;
86
204
  _currentExtId = extId;
@@ -100,6 +218,11 @@ Clacky.ext = (() => {
100
218
  Object.assign(_panelAgents, map);
101
219
  }
102
220
 
221
+ function registerAgentContributions(map) {
222
+ if (PURE || !map) return;
223
+ Object.assign(_agentContributions, map);
224
+ }
225
+
103
226
  const api = {
104
227
  // Register a named data source. Host/extensions can later resolve it.
105
228
  register(name, fn) {
@@ -137,50 +260,246 @@ Clacky.ext = (() => {
137
260
  const ui = {
138
261
  // Register a renderer for a named slot. renderFn(ctx) -> Node | string | null.
139
262
  //
140
- // opts.agents restrict to these agent profile names (default: all agents).
141
- // For panels, scope is taken from the panel's registerPanelAgents map and
142
- // merged with any explicit opts.agents.
143
- // opts.order vertical sort weight when several renderers share one slot
144
- // (lower renders first). Default 100; ties keep registration order.
145
- // opts.tab { id, label, badge? } required for tabbed slots: marks this
146
- // renderer as one tab in the slot's tab bar.
147
- mount(slot, renderFn, opts) {
148
- if (PURE || typeof renderFn !== "function") return;
149
- const explicit = opts && Array.isArray(opts.agents) ? opts.agents : null;
150
- const panel = (opts && opts.panel) || _currentPanel || null;
151
- const scoped = explicit || _extAgents[_currentExtId] || null;
152
- const order = opts && Number.isFinite(opts.order) ? opts.order : 100;
153
- const tab = (opts && opts.tab) || null;
154
- (_slotRenderers[slot] ||= []).push({
155
- fn: _guard(renderFn, `ui.mount(${slot})`, _currentExtId),
263
+ // Scope rules (automatic you never spell them out):
264
+ // Mounts to session/settings slots inherit the current panel's agent
265
+ // scope, so a `session.aside` tab written next to a designer panel
266
+ // only shows when the user is on the designer agent.
267
+ // Mounts to every other slot (sidebar.*, header.*, main.workspace)
268
+ // are global app chrome they show regardless of panel/agent.
269
+ //
270
+ // opts.agents — restrict to these agent profile names, overriding the
271
+ // defaults above. Rarely needed.
272
+ // Mount UI into a named slot. `spec` is either:
273
+ // • a function (ctx) => Node|string|null — plain render, no state
274
+ // an object { create?, render } — render + optional
275
+ // per-session runtime bound to `create`
276
+ //
277
+ // If the slot is per-session (session.aside etc.) AND `create` is present,
278
+ // the host allocates one runtime per sessionId — `create(ctx)` runs the
279
+ // first time this session shows the tab, `render(container, ctx, runtime)`
280
+ // runs every time the tab becomes visible with a fresh empty container,
281
+ // and `runtime.dispose()` runs when the session leaves the sidebar. State
282
+ // set up in `create` (media recorders, timers, subscriptions, buffers) is
283
+ // therefore isolated per session and survives tab teardown.
284
+ //
285
+ // For non-per-session slots or specs without `create`, the mount is a
286
+ // pure global renderer — one call per (re)render.
287
+ //
288
+ // opts.tab — { id, label, badge? } required for tabbed slots.
289
+ // opts.order — vertical sort weight (lower first). Default 100.
290
+ // opts.workspace — id of a registerWorkspace() workspace this mount opens;
291
+ // the host stamps it on the mount so the Router highlights this item
292
+ // while that workspace is active. Use on sidebar.nav.* items.
293
+ // opts.agents — override the automatic agent scope.
294
+ mount(slot, spec, opts) {
295
+ if (PURE || spec == null) return;
296
+ _warnUnknownSlot(slot, `ui.mount(${_currentExtId || "?"})`);
297
+ _register(slot, spec, opts, _currentExtId);
298
+ },
299
+
300
+ // Host-owned mount: identical to `mount` but bypasses PURE so official
301
+ // built-in tabs keep working in safe mode, and defaults to no agent scope.
302
+ mountBuiltin(slot, spec, opts) {
303
+ if (spec == null) return;
304
+ _warnUnknownSlot(slot, "ui.mountBuiltin");
305
+ _register(slot, spec, opts, "host", { builtin: true });
306
+ },
307
+
308
+ // Register a full-page workspace under this extension. When opened it
309
+ // takes over the main content area (host hides its own panels first) and
310
+ // gets its own URL hash `#ext/<id>` so back/forward + reload work.
311
+ //
312
+ // Clacky.ext.ui.registerWorkspace("my-console", {
313
+ // title: "My Console",
314
+ // render(container) { container.textContent = "hello"; },
315
+ // });
316
+ //
317
+ // A workspace is opened with `Clacky.ext.ui.openWorkspace(id)` — typically
318
+ // wired to a `sidebar.nav` menu item mounted from the same extension.
319
+ // render(container, ctx) is called every time the workspace is shown; the
320
+ // container is cleared beforehand so the render function can be dumb.
321
+ registerWorkspace(id, def) {
322
+ if (PURE || !id || !def || typeof def.render !== "function") return;
323
+ _workspaces[id] = {
324
+ id,
325
+ title: def.title || id,
326
+ render: _guard(def.render, `ui.registerWorkspace(${id})`, _currentExtId),
156
327
  extId: _currentExtId,
157
- agents: scoped, // explicit/per-extension agent list, or null = global
158
- panel, // official-panel id, or null
159
- order,
160
- tab, // { id, label, badge? } for tabbed slots, else null
161
- });
328
+ };
162
329
  },
163
330
 
164
- // Register a host-owned renderer (not attributed to any extension). Used for
165
- // built-in tabs that must appear for every session regardless of agent
166
- // scope (e.g. the Files tab). Bypasses PURE so the official UI keeps working
167
- // in safe mode. agents=null => visible everywhere.
168
- mountBuiltin(slot, renderFn, opts) {
169
- if (typeof renderFn !== "function") return;
170
- const order = opts && Number.isFinite(opts.order) ? opts.order : 100;
171
- const tab = (opts && opts.tab) || null;
172
- (_slotRenderers[slot] ||= []).push({
173
- fn: _guard(renderFn, `ui.mountBuiltin(${slot})`, "host"),
174
- extId: "host",
175
- agents: null,
176
- panel: null,
177
- order,
178
- tab,
179
- });
331
+ // Open a registered workspace. Emits a `clacky:ext:navigate` event that
332
+ // the host router listens for; ext.js has no compile-time dependency on
333
+ // the router. No-op if the id was never registered.
334
+ openWorkspace(id) {
335
+ if (PURE || !_workspaces[id]) return;
336
+ document.dispatchEvent(new CustomEvent("clacky:ext:navigate", {
337
+ detail: { view: "ext-workspace", params: { id } },
338
+ }));
180
339
  },
181
340
  };
182
341
 
183
- // Decide whether a renderer is visible under the current agent profile.
342
+ // Return the per-session runtime for a mount, allocating it via `create` on
343
+ // first use. Global mounts (or per-session slots without `create`) return
344
+ // null — the render function is expected to work stateless.
345
+ function _getRuntime(slot, entry, ctx) {
346
+ if (!entry.create) return null;
347
+ if (!PER_SESSION_SLOTS[slot]) return null;
348
+ const sessionId = ctx && ctx.sessionId;
349
+ if (!sessionId) return null;
350
+
351
+ let bySession = _sessionRuntimes.get(sessionId);
352
+ if (!bySession) {
353
+ bySession = new Map();
354
+ _sessionRuntimes.set(sessionId, bySession);
355
+ }
356
+ const key = _mountKey(slot, entry);
357
+ let runtime = bySession.get(key);
358
+ if (!runtime) {
359
+ runtime = entry.create(ctx);
360
+ if (runtime === undefined) return null; // create threw (guarded)
361
+ bySession.set(key, runtime || {});
362
+ }
363
+ return runtime;
364
+ }
365
+
366
+ // Run any view-level teardown collected on the previous render pass for the
367
+ // current session, so old event listeners / RAF loops don't leak when a tab
368
+ // is re-rendered or the session switches away.
369
+ function _runViewTeardowns(sessionId) {
370
+ if (!sessionId) return;
371
+ const map = _viewTeardowns.get(sessionId);
372
+ if (!map) return;
373
+ map.forEach((fn) => { if (typeof fn === "function") { try { fn(); } catch (_e) {} } });
374
+ map.clear();
375
+ }
376
+
377
+ function _rememberViewTeardown(sessionId, key, teardown) {
378
+ if (!sessionId || typeof teardown !== "function") return;
379
+ let map = _viewTeardowns.get(sessionId);
380
+ if (!map) { map = new Map(); _viewTeardowns.set(sessionId, map); }
381
+ map.set(key, teardown);
382
+ }
383
+
384
+ // Invoke a renderer's render function. Handles the (container, ctx, runtime)
385
+ // signature used everywhere; if the render returns a function it is stored
386
+ // as the view teardown, run before the next render or on session exit.
387
+ function _invokeRender(slot, entry, container, ctx) {
388
+ const runtime = _getRuntime(slot, entry, ctx);
389
+ let node, teardown, crashed = false, reason = null;
390
+ try {
391
+ const out = entry.render(container, ctx, runtime);
392
+ if (out === undefined || out === null) {
393
+ // undefined → container mutated in-place; null → renderer opted out of
394
+ // this context (e.g. `if (!ctx.sessionId) return null` on the new-session
395
+ // page). Both render nothing; neither is an error.
396
+ } else if (typeof out === "function") {
397
+ teardown = out;
398
+ } else {
399
+ node = out;
400
+ }
401
+ } catch (err) {
402
+ reason = String((err && err.stack) || err);
403
+ console.error(`[Clacky.ext] extension "${entry.extId || "?"}" threw in render for slot ${JSON.stringify(slot)}:`, err);
404
+ crashed = true;
405
+ }
406
+ if (crashed) return { crashed: true, reason, extId: entry.extId };
407
+ if (ctx && ctx.sessionId && teardown) {
408
+ _rememberViewTeardown(ctx.sessionId, _mountKey(slot, entry), teardown);
409
+ }
410
+ return { node };
411
+ }
412
+
413
+ // Build a styled, self-contained crashed-panel placeholder: a short headline
414
+ // plus a collapsible <details> exposing the actual reason and a nudge to the
415
+ // console. Never throws; the reason is rendered as plain text.
416
+ function _crashedPlaceholder(extId, reason) {
417
+ _injectCrashStyle();
418
+ const box = document.createElement("div");
419
+ box.className = "ext-slot-crashed";
420
+ box.setAttribute("data-ext-status", "crashed");
421
+
422
+ const head = document.createElement("div");
423
+ head.className = "ext-crashed-head";
424
+ const icon = document.createElement("span");
425
+ icon.className = "ext-crashed-icon";
426
+ icon.textContent = "!";
427
+ const title = document.createElement("span");
428
+ title.className = "ext-crashed-title";
429
+ title.textContent = extId ? `扩展「${extId}」渲染失败` : "扩展渲染失败";
430
+ head.appendChild(icon);
431
+ head.appendChild(title);
432
+ box.appendChild(head);
433
+
434
+ const hint = document.createElement("div");
435
+ hint.className = "ext-crashed-hint";
436
+ hint.textContent = "面板未能加载。展开下方详情,或打开浏览器控制台 (console) 查看完整错误。";
437
+ box.appendChild(hint);
438
+
439
+ if (reason) {
440
+ const details = document.createElement("details");
441
+ details.className = "ext-crashed-details";
442
+ const summary = document.createElement("summary");
443
+ summary.textContent = "错误详情";
444
+ const pre = document.createElement("pre");
445
+ pre.className = "ext-crashed-reason";
446
+ pre.textContent = reason;
447
+ details.appendChild(summary);
448
+ details.appendChild(pre);
449
+ box.appendChild(details);
450
+ }
451
+ return box;
452
+ }
453
+
454
+ let _crashStyleInjected = false;
455
+ function _injectCrashStyle() {
456
+ if (_crashStyleInjected) return;
457
+ _crashStyleInjected = true;
458
+ const style = document.createElement("style");
459
+ style.id = "ext-crashed-style";
460
+ style.textContent = `
461
+ .ext-slot-crashed {
462
+ margin: 12px;
463
+ padding: 14px 16px;
464
+ border: 1px solid var(--color-error-border, #fecaca);
465
+ background: var(--color-error-bg, #fef2f2);
466
+ border-radius: var(--radius-md, 8px);
467
+ color: var(--color-text-secondary, #56585e);
468
+ font-size: 13px;
469
+ line-height: 1.5;
470
+ }
471
+ .ext-crashed-head { display: flex; align-items: center; gap: 8px; }
472
+ .ext-crashed-icon {
473
+ display: inline-flex; align-items: center; justify-content: center;
474
+ width: 18px; height: 18px; flex: none;
475
+ border-radius: 50%;
476
+ background: var(--color-error, #ef4444);
477
+ color: #fff; font-size: 12px; font-weight: 700; line-height: 1;
478
+ }
479
+ .ext-crashed-title { font-weight: 600; color: var(--color-text-primary, #1a1b1e); }
480
+ .ext-crashed-hint { margin-top: 6px; color: var(--color-text-tertiary, #8a8d94); }
481
+ .ext-crashed-details { margin-top: 10px; }
482
+ .ext-crashed-details > summary {
483
+ cursor: pointer; user-select: none;
484
+ color: var(--color-error, #ef4444); font-weight: 500;
485
+ }
486
+ .ext-crashed-reason {
487
+ margin: 8px 0 0;
488
+ padding: 10px;
489
+ max-height: 220px; overflow: auto;
490
+ background: var(--color-bg-primary, #fbfbfa);
491
+ border: 1px solid var(--color-border-primary, #e8e8e4);
492
+ border-radius: var(--radius-sm, 6px);
493
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
494
+ font-size: 12px; line-height: 1.45;
495
+ white-space: pre-wrap; word-break: break-word;
496
+ color: var(--color-text-secondary, #56585e);
497
+ }
498
+ `;
499
+ (document.head || document.documentElement).appendChild(style);
500
+ }
501
+
502
+
184
503
  // null agents AND null panel => global (always visible). Otherwise the
185
504
  // current profile must be in the renderer's agent list, or in the set of
186
505
  // agents that reference its panel.
@@ -191,6 +510,13 @@ Clacky.ext = (() => {
191
510
  return false;
192
511
  }
193
512
 
513
+ // What a third-party agent's extension contributes (panels + skills), from
514
+ // ext.yml — so the new-session page can advertise it before a session exists.
515
+ // Returns { panels:[{id,title,title_zh}], skills:[...] } or null for built-ins.
516
+ function contributionsForAgent(agentId) {
517
+ return (agentId && _agentContributions[agentId]) || null;
518
+ }
519
+
194
520
  // Render every extension registered for `slot` into `container`, scoped to the
195
521
  // current agent profile. Called by the host's view layer wherever it exposes a
196
522
  // slot, and re-called on session switch (the container is cleared first so a
@@ -215,34 +541,25 @@ Clacky.ext = (() => {
215
541
  const ordered = renderers.slice().sort((a, b) => a.order - b.order);
216
542
  ordered.forEach((entry) => {
217
543
  if (!_visibleFor(entry, profile)) return;
218
- const { fn, extId } = entry;
219
- let node;
220
- let crashed = false;
221
- try {
222
- node = fn(renderCtx);
223
- if (node === undefined) crashed = true; // guard swallowed a throw
224
- } catch (_e) {
225
- crashed = true; // defensive: should already be guarded
226
- }
227
-
544
+ const wrap = document.createElement("div");
545
+ if (entry.extId) wrap.setAttribute("data-ext-id", entry.extId);
546
+ // Nav mounts that open a workspace declare it via opts.workspace; the
547
+ // host stamps it so the Router alone owns the active highlight.
548
+ if (entry.workspace) wrap.setAttribute("data-ext-workspace", entry.workspace);
549
+ const { node, crashed, reason, extId } = _invokeRender(slot, entry, wrap, renderCtx);
228
550
  if (crashed) {
229
- const ph = document.createElement("div");
230
- ph.setAttribute("data-ext-status", "crashed");
231
- ph.setAttribute("data-ext-id", extId || "");
232
- ph.className = "ext-slot-crashed";
233
- ph.textContent = "Extension failed to render.";
234
- container.appendChild(ph);
551
+ wrap.appendChild(_crashedPlaceholder(extId || entry.extId, reason));
552
+ container.appendChild(wrap);
235
553
  return;
236
554
  }
237
-
238
- if (node == null) return; // nothing to render is valid
239
- if (typeof node === "string") {
240
- const wrap = document.createElement("div");
241
- wrap.setAttribute("data-ext-id", extId || "");
242
- wrap.innerHTML = node;
555
+ if (node != null) {
556
+ if (typeof node === "string") wrap.innerHTML = node;
557
+ else wrap.appendChild(node);
558
+ }
559
+ // If render mutated `wrap` in-place (returned nothing) or produced a
560
+ // node, either way we only append when there's something to show.
561
+ if (wrap.childNodes.length > 0 || (node != null && typeof node === "string")) {
243
562
  container.appendChild(wrap);
244
- } else {
245
- container.appendChild(node);
246
563
  }
247
564
  });
248
565
  }
@@ -269,17 +586,16 @@ Clacky.ext = (() => {
269
586
  const bodies = document.createElement("div");
270
587
  bodies.className = "aside-bodies";
271
588
 
272
- // Restore previously active tab if still present, else first tab.
589
+ // Always default to the first (highest-priority) tab. Tabs may register in
590
+ // several async passes; whichever pass runs last simply lands on tab #1.
273
591
  const ids = renderers.map((e) => e.tab.id);
274
- let active = _activeTab[slot];
275
- if (!active || !ids.includes(active)) active = ids[0];
592
+ const active = ids[0];
276
593
 
277
594
  const tabBtns = {};
278
595
  const bodyEls = {};
279
596
  const rendered = {};
280
597
 
281
598
  function activate(id) {
282
- _activeTab[slot] = id;
283
599
  Object.keys(tabBtns).forEach((k) => tabBtns[k].classList.toggle("active", k === id));
284
600
  Object.keys(bodyEls).forEach((k) => bodyEls[k].classList.toggle("active", k === id));
285
601
  if (!rendered[id]) {
@@ -289,16 +605,9 @@ Clacky.ext = (() => {
289
605
  const localCtx = Object.assign({}, renderCtx, {
290
606
  setBadge: (n) => _setTabBadge(tabBtns[id], n),
291
607
  });
292
- let node, crashed = false;
293
- try {
294
- node = entry.fn(localCtx);
295
- if (node === undefined) crashed = true;
296
- } catch (_e) { crashed = true; }
608
+ const { node, crashed, reason, extId } = _invokeRender(slot, entry, body, localCtx);
297
609
  if (crashed) {
298
- const ph = document.createElement("div");
299
- ph.className = "ext-slot-crashed";
300
- ph.textContent = "Extension failed to render.";
301
- body.appendChild(ph);
610
+ body.appendChild(_crashedPlaceholder(extId || entry.extId, reason));
302
611
  } else if (node != null) {
303
612
  if (typeof node === "string") body.innerHTML = node;
304
613
  else body.appendChild(node);
@@ -355,11 +664,38 @@ Clacky.ext = (() => {
355
664
 
356
665
  // Update the current session context (host calls this on every session
357
666
  // switch) and re-render all slots so panels match the new agent profile.
667
+ // Runs the previous session's view-level teardowns first (event listeners /
668
+ // RAF loops attached to now-dead DOM), but leaves per-session runtimes
669
+ // alive — the meeting recorder for session A keeps running while you're
670
+ // looking at B, and its captions are still there when you switch back.
358
671
  function setContext(next) {
672
+ const prevSessionId = context.sessionId;
359
673
  Object.assign(context, next || {});
674
+ if (prevSessionId && prevSessionId !== context.sessionId) {
675
+ _runViewTeardowns(prevSessionId);
676
+ }
360
677
  refreshSlots();
361
678
  }
362
679
 
680
+ // Called by the host when a session leaves the sidebar for good (deleted,
681
+ // archived, or the page is unloading). Runs every runtime's `dispose()` for
682
+ // that session so timers, media recorders, and network handles are released.
683
+ function notifySessionRemoved(sessionId) {
684
+ if (!sessionId) return;
685
+ _runViewTeardowns(sessionId);
686
+ _viewTeardowns.delete(sessionId);
687
+ const bySession = _sessionRuntimes.get(sessionId);
688
+ if (!bySession) return;
689
+ bySession.forEach((runtime) => {
690
+ if (runtime && typeof runtime.dispose === "function") {
691
+ try { runtime.dispose(); } catch (err) {
692
+ console.error("[Clacky.ext] runtime.dispose failed:", err);
693
+ }
694
+ }
695
+ });
696
+ _sessionRuntimes.delete(sessionId);
697
+ }
698
+
363
699
  // Re-render every named slot present in the DOM against the current context.
364
700
  // Idempotent: each slot's container is cleared before re-rendering.
365
701
  function refreshSlots() {
@@ -373,14 +709,21 @@ Clacky.ext = (() => {
373
709
  get pure() { return PURE; },
374
710
  context,
375
711
  setContext,
712
+ notifySessionRemoved,
376
713
  refreshSlots,
377
714
  registerPanelAgents,
715
+ registerAgentContributions,
716
+ contributionsForAgent,
378
717
  api,
379
718
  ui,
380
719
  subscribe,
381
720
  emit,
382
721
  renderSlot,
383
722
  slots,
723
+ // Host-only: look up a registered workspace by id. Returns
724
+ // { id, title, render, extId } or undefined. Used by the Router when
725
+ // handling the `ext-workspace` view — extensions never call this.
726
+ _getWorkspace(id) { return _workspaces[id]; },
384
727
  _extBegin, // used by the loader; not part of the public extension API
385
728
  _extEnd, // used by the loader; not part of the public extension API
386
729
  };
@@ -113,3 +113,4 @@ const BackupStore = (() => {
113
113
  })();
114
114
 
115
115
  const Backup = BackupStore;
116
+ Clacky.Backup = Backup;
@@ -118,4 +118,3 @@ const BackupView = (() => {
118
118
  })();
119
119
 
120
120
  BackupView.init();
121
- window.Backup = Backup;