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
@@ -607,55 +607,66 @@
607
607
  confirmRow, confirmYes, confirmNo, files: filesEl, diff: diffEl, diffPath: diffPathEl };
608
608
  }
609
609
 
610
- Clacky.ext.ui.mount("session.aside", (ctx) => {
611
- if (!ctx || !ctx.sessionId) return null;
612
-
613
- const list = el("div", { class: "tm-list" });
614
- const foot = el("div", { class: "tm-foot", text: t("tm.foot", "每完成一步会自动存档。点击想回到的版本即可恢复。") });
615
- const root = el("div", { class: "tm-panel", "data-panel": "tm" }, list, foot);
616
-
617
- const d = buildDrawer();
618
-
619
- const state = {
620
- sessionId: ctx.sessionId,
621
- tasks: [],
622
- selected: null,
623
- expanded: null,
624
- panelEl: root, listEl: list, footEl: foot,
625
- maskEl: d.mask, drawerEl: d.drawer,
626
- drawerTitleEl: d.title, drawerTimeEl: d.time,
627
- restoreBtn: d.restore, filesEl: d.files, diffEl: d.diff, diffPathEl: d.diffPath, confirmRow: d.confirmRow,
628
- };
610
+ Clacky.ext.ui.mount("session.aside", {
611
+ create(ctx) {
612
+ const list = el("div", { class: "tm-list" });
613
+ const foot = el("div", { class: "tm-foot", text: t("tm.foot", "每完成一步会自动存档。点击想回到的版本即可恢复。") });
614
+ const root = el("div", { class: "tm-panel", "data-panel": "tm" }, list, foot);
615
+
616
+ const d = buildDrawer();
617
+
618
+ const state = {
619
+ sessionId: ctx.sessionId,
620
+ tasks: [],
621
+ selected: null,
622
+ expanded: null,
623
+ panelEl: root, listEl: list, footEl: foot,
624
+ maskEl: d.mask, drawerEl: d.drawer,
625
+ drawerTitleEl: d.title, drawerTimeEl: d.time,
626
+ restoreBtn: d.restore, filesEl: d.files, diffEl: d.diff, diffPathEl: d.diffPath, confirmRow: d.confirmRow,
627
+ };
629
628
 
630
- // Re-bind the global drawer's controls to this mount's state. (The drawer
631
- // is shared across mounts but only one is interactive at a time.)
632
- const onClose = () => closeDrawer(state);
633
- const onRestoreClick = () => {
634
- d.confirmRow.style.display = "flex";
635
- d.restore.disabled = true;
636
- };
637
- const onCancelClick = () => {
638
- d.confirmRow.style.display = "none";
639
- d.restore.disabled = false;
640
- };
641
- const onGoClick = () => performRestore(state);
642
- const onMaskClick = () => closeDrawer(state);
643
- const onKey = (e) => { if (e.key === "Escape" && d.drawer.classList.contains("open")) closeDrawer(state); };
644
-
645
- d.close.onclick = onClose;
646
- d.restore.onclick = onRestoreClick;
647
- d.confirmNo.onclick = onCancelClick;
648
- d.confirmYes.onclick = onGoClick;
649
- d.mask.onclick = onMaskClick;
650
- document.addEventListener("keydown", onKey);
651
-
652
- _activeState = state;
653
- _hookWs();
654
-
655
- loadHistory(state);
656
- return root;
629
+ const onClose = () => closeDrawer(state);
630
+ const onRestoreClick = () => {
631
+ d.confirmRow.style.display = "flex";
632
+ d.restore.disabled = true;
633
+ };
634
+ const onCancelClick = () => {
635
+ d.confirmRow.style.display = "none";
636
+ d.restore.disabled = false;
637
+ };
638
+ const onGoClick = () => performRestore(state);
639
+ const onMaskClick = () => closeDrawer(state);
640
+ const onKey = (e) => { if (e.key === "Escape" && d.drawer.classList.contains("open")) closeDrawer(state); };
641
+
642
+ d.close.onclick = onClose;
643
+ d.restore.onclick = onRestoreClick;
644
+ d.confirmNo.onclick = onCancelClick;
645
+ d.confirmYes.onclick = onGoClick;
646
+ d.mask.onclick = onMaskClick;
647
+ document.addEventListener("keydown", onKey);
648
+
649
+ _activeState = state;
650
+ _hookWs();
651
+
652
+ loadHistory(state);
653
+
654
+ return {
655
+ state,
656
+ root,
657
+ dispose() {
658
+ document.removeEventListener("keydown", onKey);
659
+ try { d.mask.remove(); } catch (_) {}
660
+ try { d.drawer.remove(); } catch (_) {}
661
+ if (_activeState === state) _activeState = null;
662
+ },
663
+ };
664
+ },
665
+ render(container, ctx, runtime) {
666
+ container.appendChild(runtime.root);
667
+ _activeState = runtime.state;
668
+ },
657
669
  }, {
658
- panel: "time_machine",
659
670
  order: 20,
660
671
  tab: { id: "tm", label: () => t("tm.tab") },
661
672
  });
@@ -85,7 +85,7 @@ end
85
85
  def saved_bot_token
86
86
  yml_path = File.expand_path("~/.clacky/channels.yml")
87
87
  return nil unless File.exist?(yml_path)
88
- data = YAML.safe_load_file(yml_path, permitted_classes: [Symbol], aliases: true) rescue nil
88
+ data = YAML.safe_load(File.read(yml_path), permitted_classes: [Symbol], aliases: true) rescue nil
89
89
  data&.dig("channels", "discord", "bot_token") || data&.dig(:channels, :discord, :bot_token)
90
90
  end
91
91
 
@@ -5,37 +5,102 @@ description: Customize, fix, override or extend openclacky itself — change a b
5
5
 
6
6
  # Extending Openclacky
7
7
 
8
- Openclacky ships four official extension mechanisms that survive `gem update` and never require editing the gem source.
9
- **Never tell the user to `bundle show openclacky` and edit the gem always use one of these.**
8
+ Openclacky ships one unified extension mechanism an **extension container**
9
+ declared by a single `ext.yml`. It survives `gem update` and never requires
10
+ editing the gem source.
10
11
 
11
- ## Pick the right mechanism
12
+ **Never tell the user to `bundle show openclacky` and edit the gem.**
12
13
 
13
- | User wants to… | Use | Scaffold | Verify |
14
- |---|---|---|---|
15
- | Change behavior of an **existing method** in openclacky (e.g. `WebSearch#execute` timeout, fix a bug in a built-in tool) | **Patch** | `clacky patch_new <id> "Const#method" -d "<desc>"` | `clacky patch_verify` |
16
- | **Audit / block / observe** tool calls (block `rm -rf /`, log every shell command) — no Ruby needed | **Shell Hook** | `clacky hook_new <id> -e <event>` | `clacky hook_verify` |
17
- | Plug openclacky into a **new IM platform** (Slack, in-house IM, custom webhook…) | **Channel Adapter** | `clacky channel_new <platform_id>` | `clacky channel_verify` |
18
- | Add UI to the **Web UI** (custom panel, header button, settings tab, visualize data) | **Web UI Extension** | drop a `.js` file in `~/.clacky/webui_ext/` | reload page; `Clacky.ext.slots()` in console; `?pure=true` to escape |
14
+ ## The one entry point
15
+
16
+ Every extension lives in a container directory:
17
+
18
+ ```
19
+ ~/.clacky/ext/local/<id>/
20
+ ext.yml # single manifest — declares everything the container contributes
21
+ panels/… # WebUI panels (JS)
22
+ api/handler.rb # HTTP API backend
23
+ skills/… # AI skills
24
+ agents/… # agent profiles + prompts
25
+ channels/… # IM adapters
26
+ patches/… # runtime method patches
27
+ hooks/… # shell hooks
28
+ ```
29
+
30
+ Scaffold with:
31
+ ```bash
32
+ clacky ext new <id> # minimal hello-panel starter
33
+ clacky ext new <id> --full # kitchen-sink example with every contributes type
34
+ ```
35
+
36
+ The ext.yml `contributes:` map declares which of these 7 types the container
37
+ provides. A container may use one, several, or all.
38
+
39
+ ## Pick what to add to `contributes:`
40
+
41
+ | User wants to… | contributes: field |
42
+ |---|---|
43
+ | Add a **WebUI panel / button / settings tab / data visualisation** | `panels:` |
44
+ | Add an **HTTP API backend** (routes under `/api/ext/<id>/…`) | `api:` (a single `handler.rb`) |
45
+ | **Change behavior of a built-in method** in openclacky (e.g. `WebSearch#execute` timeout) | `patches:` |
46
+ | **Audit / block / observe** tool calls (block `rm -rf /`, log every shell command) | `hooks:` |
47
+ | Plug openclacky into a **new IM platform** (Slack, in-house IM, custom webhook) | `channels:` |
48
+ | Add a **new AI skill** (SKILL.md) | `skills:` |
49
+ | Bundle a **custom agent profile** with its own panels + skills | `agents:` |
19
50
 
20
51
  ## Authoritative documentation
21
52
 
22
- Each mechanism has a full reference doc — read the relevant one with `web_fetch` before writing code:
53
+ Read the relevant reference doc with `web_fetch` before writing code
54
+ don't guess field names, hook events, adapter methods, or the `Clacky.ext`
55
+ WebUI contract.
23
56
 
57
+ - Extension containers (ext.yml overview) → https://www.openclacky.com/docs/extend
58
+ - Panels (WebUI) → https://www.openclacky.com/docs/extend-webui
59
+ - API backends → https://www.openclacky.com/docs/extend-api
24
60
  - Patches → https://www.openclacky.com/docs/extend-patches
25
61
  - Shell Hooks → https://www.openclacky.com/docs/extend-shell-hooks
26
62
  - Channel Adapters → https://www.openclacky.com/docs/extend-channel-adapter
27
- - Web UI Extensions → https://www.openclacky.com/docs/extend-webui
63
+
64
+ ## WebUI host services live under `Clacky.*`
65
+
66
+ The single public API surface for WebUI extensions is `window.Clacky`.
67
+ All host services are exposed as properties on it — reach for them there,
68
+ not through bare globals or `window.Xxx`:
69
+
70
+ ```js
71
+ Clacky.Sessions.on("switched", handler); // active session store
72
+ Clacky.Router.go("session"); // top-level view routing
73
+ Clacky.I18n.t("some.key"); // translations
74
+ Clacky.Modal.confirm("Delete?"); // dialogs
75
+ Clacky.Notify.info("Saved"); // toasts
76
+ Clacky.Auth.passed; // auth state
77
+ Clacky.Workspace.list(dir); // working-directory files
78
+ Clacky.Skills.list(); // skill catalog
79
+ Clacky.Backup.load(); // backup/restore state
80
+ Clacky.WS.send({ type: "..." }); // send a WebSocket message to the agent
81
+ ```
82
+
83
+ Rules:
84
+
85
+ - Prefer `Clacky.Xxx.method(...)` — this is the recommended, forward-stable form.
86
+ - `window.Clacky.Xxx.method(...)` works too and is fine in defensive code.
87
+ - **Never** write `window.Sessions` / `typeof window.Sessions` / `"Sessions" in window`
88
+ — bare host names are `const` bindings, not `window` properties, so those checks
89
+ return `undefined` / `false` even though the module is loaded.
90
+ - The bare form (`Sessions.on(...)`) still works for backwards compatibility
91
+ but is not the pattern to teach or generate.
28
92
 
29
93
  ## Execution playbook
30
94
 
31
- 1. **Identify** which mechanism fits (use the table above; ask if genuinely ambiguous).
32
- 2. **Read the doc** for that mechanism with `web_fetch`. Don't guess fields, hook events, or required methods — the doc is the contract.
33
- 3. **Run the scaffold** CLI command. It generates the file(s) in `~/.clacky/...` with correct meta. *(Web UI Extensions have no scaffold — just create a `.js` file under `~/.clacky/webui_ext/`; the doc shows the `Clacky.ext` contract.)*
34
- 4. **Edit** the generated file to implement the user's intent. Keep generated meta fields (`target`, `event`, `platform_id`, the `Clacky::ChannelRegistry.register(...)` line, etc.) intact unless the doc says otherwise.
35
- 5. **Verify** with the matching `*_verify` command. Surface any `[FAIL]` lines to the user verbatim. *(Web UI Extensions have no verify command — reload the page and confirm the slot rendered; if anything breaks, `?pure=true` disables all extensions instantly.)*
95
+ 1. **Identify** which `contributes:` fields the user's intent needs (use the table above; ask if genuinely ambiguous).
96
+ 2. **Read the doc(s)** for those fields. The doc is the contract.
97
+ 3. **Scaffold** with `clacky ext new <id>` (or `--full` if the user wants every type wired up as a reference).
98
+ 4. **Edit** `ext.yml` to declare the fields, and fill in the referenced files (panel view.js, api handler.rb, patches/xxx.rb, etc.).
99
+ 5. **Verify** with `clacky ext verify`. Surface any error/skip lines to the user verbatim.
100
+ 6. **Reload** the WebUI page (for panel/api changes take effect on next request — no restart needed).
36
101
 
37
102
  ## When NOT to use this skill
38
103
 
39
- - The user is building features in their own application that just *use* openclacky — that's normal coding, no patch/hook/channel needed.
40
- - The user wants a brand-new tool/skill for *their* project — use `.clacky/skills/` or `.clacky/tools/`, not these gem-level mechanisms.
104
+ - The user is building features in their own application that just *use* openclacky — that's normal coding, no extension container needed.
105
+ - The user wants a brand-new tool/skill for *their* project — use `.clacky/skills/` or `.clacky/tools/` in their project, not a gem-level container.
41
106
  - The change can be made via `clacky config set ...` — prefer config over patches.
@@ -4,16 +4,16 @@ require "json"
4
4
  require "fileutils"
5
5
 
6
6
  module Clacky
7
- # Base class for user-defined HTTP API extensions loaded from
8
- # ~/.clacky/api_ext/<name>/handler.rb. Subclasses use a tiny route DSL
7
+ # Base class for HTTP API extensions declared in an ext.yml container as
8
+ # `contributes.api: <path/to/handler.rb>`. Subclasses use a tiny route DSL
9
9
  # (get/post/put/patch/delete) to expose endpoints under
10
- # /api/ext/<name>/<sub-path>
10
+ # /api/ext/<ext_id>/<sub-path>
11
11
  #
12
12
  # The framework wires up access-key auth, timeouts, JSON error envelopes,
13
13
  # path-parameter parsing, and a curated handler context — extension authors
14
14
  # only fill in business logic.
15
15
  #
16
- # Minimal example (~/.clacky/api_ext/my-dashboard/handler.rb):
16
+ # Minimal example (~/.clacky/ext/local/my-dashboard/api/handler.rb):
17
17
  #
18
18
  # class MyDashboardExt < Clacky::ApiExtension
19
19
  # get "/summary" do
@@ -41,8 +41,8 @@ module Clacky
41
41
  end
42
42
 
43
43
  class << self
44
- # Registry of all loaded ApiExtension subclasses, keyed by extension id
45
- # (== directory name == mount prefix segment).
44
+ # Keyed by ext_id every extension contributes at most one API unit
45
+ # (declared as `contributes.api: <path>` in ext.yml).
46
46
  def registry
47
47
  @registry ||= {}
48
48
  end
@@ -81,6 +81,14 @@ module Clacky
81
81
  @public_paths ||= []
82
82
  end
83
83
 
84
+ # Clears accumulated route/public-path state so a force-reload (`load`) of
85
+ # a named-constant handler re-registers its routes from scratch instead of
86
+ # appending duplicates onto the reopened class.
87
+ def reset_routes!
88
+ @routes = []
89
+ @public_paths = []
90
+ end
91
+
84
92
  def ext_id
85
93
  @ext_id
86
94
  end
@@ -115,7 +123,7 @@ module Clacky
115
123
  end
116
124
 
117
125
  # Mark a route as not requiring access-key auth. Caller must also
118
- # declare `public: true` in meta.yml for the framework to honor this.
126
+ # declare `public: true` at ext.yml top level for the framework to honor this.
119
127
  def public_endpoint(pattern)
120
128
  public_paths << normalize_pattern(pattern)
121
129
  end
@@ -264,9 +272,11 @@ module Clacky
264
272
  end
265
273
 
266
274
  # Submit a prompt to an existing session for execution.
267
- # The session must be idle; returns the session_id on success.
268
- # Raises Halt (409) if the session is already running.
269
- def submit_task(session_id, prompt, display_message: nil)
275
+ # Returns the session_id on success.
276
+ # Raises Halt (409) if the session is already running and `interrupt: false`.
277
+ # When `interrupt: true`, supersedes the current turn (raises AgentInterrupted
278
+ # on the running thread, waits up to 2s for it to exit) then runs the new task.
279
+ def submit_task(session_id, prompt, display_message: nil, interrupt: false)
270
280
  reg = registry
271
281
  error!("server not ready", status: 503) unless reg
272
282
 
@@ -276,7 +286,13 @@ module Clacky
276
286
  end
277
287
 
278
288
  session = reg.get(session_id)
279
- error!("session is busy", status: 409) if session[:status] == :running
289
+ if session[:status] == :running
290
+ error!("session is busy", status: 409) unless interrupt
291
+ @http_server.send(:interrupt_session, session_id)
292
+ old_thread = nil
293
+ reg.with_session(session_id) { |s| old_thread = s[:thread] }
294
+ old_thread&.join(2)
295
+ end
280
296
 
281
297
  @http_server.send(:run_session_task, session_id, prompt, display_message: display_message)
282
298
  session_id
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Clacky
6
+ # Loads HTTP API extensions from ext.yml containers into the shared
7
+ # Clacky::ApiExtension registry, keyed by ext_id. Every ext contributes
8
+ # at most one api unit (`contributes.api: <path>` in ext.yml), so the
9
+ # registry key is simply the ext id.
10
+ #
11
+ # A broken extension (syntax error, missing base class, no routes) is
12
+ # isolated: skipped with a logged warning, never aborts sibling loads.
13
+ module ApiExtensionLoader
14
+ class << self
15
+ def load_all(reload: false)
16
+ result = Result.new(loaded: [], skipped: [])
17
+ Clacky::ApiExtension.reset_registry!
18
+ handler_mtime_cache.clear
19
+
20
+ loader_result = Clacky::ExtensionLoader.load_all
21
+ loader_result.api.each do |unit|
22
+ container = loader_result.containers[unit.ext_id]
23
+ load_one(unit.ext_id, unit.spec["handler_abs"], unit.dir, container, result, reload: reload)
24
+ end
25
+
26
+ @last_result = result
27
+ log_summary(result)
28
+ result
29
+ end
30
+
31
+ Result = Struct.new(:loaded, :skipped, keyword_init: true)
32
+
33
+ def last_result
34
+ @last_result || load_all
35
+ end
36
+
37
+ # Per-request hot path used by the dispatcher. Re-loads the handler
38
+ # only when its file mtime has changed since the last load.
39
+ def ensure_fresh(ext_id)
40
+ loader_result = Clacky::ExtensionLoader.load_all
41
+ unit = loader_result.api.find { |u| u.ext_id == ext_id.to_s }
42
+ return unless unit
43
+
44
+ path = unit.spec["handler_abs"]
45
+ current_mtime = File.mtime(path).to_f
46
+ cache = handler_mtime_cache
47
+ return if cache[ext_id.to_s] == current_mtime
48
+
49
+ container = loader_result.containers[unit.ext_id]
50
+ r = Result.new(loaded: [], skipped: [])
51
+ load_one(unit.ext_id, path, unit.dir, container, r, reload: true)
52
+ r.skipped.each { |(id, reason)| log_skip(id, reason) }
53
+ cache[ext_id.to_s] = current_mtime
54
+ rescue StandardError => e
55
+ Clacky::Logger.warn("[ApiExtensionLoader] ensure_fresh(#{ext_id}) failed: #{e.message}")
56
+ end
57
+
58
+ private def handler_mtime_cache
59
+ @handler_mtime_cache ||= {}
60
+ end
61
+
62
+ def load_one(ext_id, handler_path, ext_dir, container, result, reload: false)
63
+ before = Clacky::ApiExtension.pending_subclasses.size
64
+
65
+ existing = reload ? Clacky::ApiExtension.registry[ext_id] : nil
66
+ existing&.reset_routes!
67
+
68
+ if reload
69
+ old_verbose = $VERBOSE
70
+ $VERBOSE = nil
71
+ begin
72
+ load(handler_path)
73
+ ensure
74
+ $VERBOSE = old_verbose
75
+ end
76
+ else
77
+ require(handler_path)
78
+ end
79
+
80
+ new_subclasses = Clacky::ApiExtension.pending_subclasses[before..] || []
81
+ klass = new_subclasses.last || existing
82
+
83
+ unless klass
84
+ result.skipped << [ext_id, "no Clacky::ApiExtension subclass defined in handler.rb"]
85
+ log_skip(ext_id, result.skipped.last[1])
86
+ return
87
+ end
88
+
89
+ klass.ext_id = ext_id
90
+ klass.ext_dir = ext_dir
91
+ klass.meta = (container && container[:raw]) || {}
92
+
93
+ if klass.routes.empty?
94
+ result.skipped << [ext_id, "no routes declared (use get/post/... DSL)"]
95
+ log_skip(ext_id, result.skipped.last[1])
96
+ Clacky::ApiExtension.registry.delete(ext_id)
97
+ return
98
+ end
99
+
100
+ if (gap = validate_public_endpoints(klass, container))
101
+ result.skipped << [ext_id, gap]
102
+ log_skip(ext_id, gap)
103
+ return
104
+ end
105
+
106
+ Clacky::ApiExtension.register(ext_id, klass)
107
+ result.loaded << ext_id
108
+ handler_mtime_cache[ext_id] = File.mtime(handler_path).to_f rescue nil
109
+ public_count = klass.public_paths.size
110
+ suffix = public_count > 0 ? " (#{public_count} public)" : ""
111
+ Clacky::Logger.info("[ApiExtensionLoader] Loaded '#{ext_id}' — #{klass.routes.size} route(s)#{suffix}")
112
+ rescue StandardError, ScriptError => e
113
+ result.skipped << [ext_id, e.message]
114
+ log_skip(ext_id, e.message)
115
+ end
116
+
117
+ private def validate_public_endpoints(klass, container)
118
+ return nil if klass.public_paths.empty?
119
+ return nil if container && container[:public] == true
120
+
121
+ "uses public_endpoint but ext.yml is missing 'public: true' at top level"
122
+ end
123
+
124
+ private def log_skip(ext_id, reason)
125
+ Clacky::Logger.warn("[ApiExtensionLoader] Skipped '#{ext_id}': #{reason}")
126
+ end
127
+
128
+ private def log_summary(result)
129
+ return if result.loaded.empty? && result.skipped.empty?
130
+
131
+ total_routes = result.loaded.sum { |id| Clacky::ApiExtension.registry[id]&.routes&.size || 0 }
132
+ Clacky::Logger.info("[ApiExtensionLoader] #{result.loaded.size} extension(s), #{total_routes} route(s); #{result.skipped.size} skipped")
133
+ end
134
+ end
135
+ end
136
+ end