create-workframe 0.1.12 → 0.1.13

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 (188) hide show
  1. package/README.md +1 -1
  2. package/bin/create-workframe.js +2 -2
  3. package/bin/workframe.js +130 -2
  4. package/docs/security.md +2 -2
  5. package/package.json +1 -1
  6. package/scripts/bundle-workframe-ui.mjs +9 -0
  7. package/scripts/sync-canonical-to-package.mjs +1 -0
  8. package/scripts/verify-public-deploy.sh +65 -3
  9. package/workframe-api/action_proxy.py +23 -17
  10. package/workframe-api/activity_feed.py +798 -0
  11. package/workframe-api/api_meta.py +157 -0
  12. package/workframe-api/auth_gate.py +566 -0
  13. package/workframe-api/avatar_registry.py +343 -0
  14. package/workframe-api/broker_audit.py +164 -0
  15. package/workframe-api/cell_authority.py +231 -0
  16. package/workframe-api/chat_bind.py +319 -0
  17. package/workframe-api/chat_sessions.py +509 -0
  18. package/workframe-api/chat_stream.py +694 -0
  19. package/workframe-api/cleanup_dogfood_smoke.py +252 -0
  20. package/workframe-api/credential_broker.py +162 -0
  21. package/workframe-api/credential_resolve.py +202 -0
  22. package/workframe-api/credential_store.py +245 -0
  23. package/workframe-api/crew_registry.py +250 -0
  24. package/workframe-api/db_schema.py +755 -0
  25. package/workframe-api/docker_gateway.py +166 -0
  26. package/workframe-api/doctor_runtime.py +109 -0
  27. package/workframe-api/domain/__init__.py +47 -0
  28. package/workframe-api/domain/entities.py +252 -0
  29. package/workframe-api/domain/schema/workframe-domain.schema.json +278 -0
  30. package/workframe-api/egress_policy.py +42 -0
  31. package/workframe-api/handler_modules/__init__.py +17 -0
  32. package/workframe-api/handler_modules/handler_admin.py +542 -0
  33. package/workframe-api/handler_modules/handler_auth.py +512 -0
  34. package/workframe-api/handler_modules/handler_chat.py +641 -0
  35. package/workframe-api/handler_modules/handler_install.py +178 -0
  36. package/workframe-api/handler_modules/handler_provider.py +325 -0
  37. package/workframe-api/handler_modules/handler_workspace.py +1420 -0
  38. package/workframe-api/health_monitor.py +80 -0
  39. package/workframe-api/hermes_admin.py +756 -0
  40. package/workframe-api/hermes_profiles.py +1328 -0
  41. package/workframe-api/install_api.py +123 -0
  42. package/workframe-api/kanban_cron.py +473 -0
  43. package/workframe-api/lane_bindings.py +423 -0
  44. package/workframe-api/llm_proxy.py +17 -43
  45. package/workframe-api/mention_helpers.py +180 -0
  46. package/workframe-api/mention_invoke.py +431 -0
  47. package/workframe-api/model_surface.py +1139 -0
  48. package/workframe-api/oauth_pending.py +85 -0
  49. package/workframe-api/oauth_redirect.py +381 -0
  50. package/workframe-api/package.json +2 -2
  51. package/workframe-api/profile_api_lifecycle.py +66 -0
  52. package/workframe-api/profile_gateway.py +436 -0
  53. package/workframe-api/provider_bindings.py +673 -0
  54. package/workframe-api/provider_bootstrap.py +347 -0
  55. package/workframe-api/provider_catalog.py +180 -0
  56. package/workframe-api/rooms.py +1613 -0
  57. package/workframe-api/route_registry.py +331 -191
  58. package/workframe-api/run-typecheck.mjs +2 -1
  59. package/workframe-api/run_authority.py +287 -0
  60. package/workframe-api/run_ledger.py +470 -0
  61. package/workframe-api/run_surface_wiring.py +344 -0
  62. package/workframe-api/runtime_cohort.py +752 -0
  63. package/workframe-api/runtime_tokens.py +127 -0
  64. package/workframe-api/server.py +1453 -19513
  65. package/workframe-api/snapshot_feed.py +49 -0
  66. package/workframe-api/stack_config.py +33 -1
  67. package/workframe-api/supervisor_client.py +154 -0
  68. package/workframe-api/test_api_meta_build_stamp.py +34 -0
  69. package/workframe-api/test_cell_authority.py +79 -0
  70. package/workframe-api/test_chat_bind.py +48 -0
  71. package/workframe-api/test_credential_lease_matrix.py +171 -0
  72. package/workframe-api/test_credential_resolve.py +51 -0
  73. package/workframe-api/test_credential_store.py +27 -0
  74. package/workframe-api/test_dogfood_flows.py +346 -0
  75. package/workframe-api/test_domain_entities.py +152 -0
  76. package/workframe-api/test_exception_hygiene.py +12 -0
  77. package/workframe-api/test_hermes_admin.py +72 -0
  78. package/workframe-api/test_install_wizard_resume.py +78 -0
  79. package/workframe-api/test_local_bootstrap.py +67 -0
  80. package/workframe-api/test_mention_helpers.py +35 -0
  81. package/workframe-api/test_mention_invoke.py +26 -0
  82. package/workframe-api/test_model_surface_consistency.py +1 -0
  83. package/workframe-api/test_oauth_pending.py +41 -0
  84. package/workframe-api/test_provider_bindings.py +52 -0
  85. package/workframe-api/test_provider_catalog.py +28 -0
  86. package/workframe-api/test_route_registry.py +171 -35
  87. package/workframe-api/test_run_authority.py +112 -0
  88. package/workframe-api/test_run_ledger.py +157 -0
  89. package/workframe-api/test_run_surface_wiring.py +130 -0
  90. package/workframe-api/test_runtime_cohort.py +85 -0
  91. package/workframe-api/test_secure_mode_docker_boundary.py +39 -0
  92. package/workframe-api/test_server_reexports.py +99 -0
  93. package/workframe-api/test_stack_config_install_smtp.py +7 -0
  94. package/workframe-api/turn_credentials.py +28 -13
  95. package/workframe-api/turn_overlay.py +715 -0
  96. package/workframe-api/updates.py +16 -1
  97. package/workframe-api/user_prefs.py +387 -0
  98. package/workframe-api/wf032_extract_remaining_handlers.py +417 -0
  99. package/workframe-api/workspace_bootstrap.py +536 -0
  100. package/workframe-api/workspace_files.py +344 -0
  101. package/workframe-api/workspace_messaging.py +206 -0
  102. package/workframe-api/zk_auth.py +3 -2
  103. package/workframe-supervisor/server.py +10 -1
  104. package/workframe-supervisor/test_supervisor_negative.py +75 -0
  105. package/workframe-ui/public/assets/{arc-B0OFRGmJ.js → arc-aUUffclm.js} +1 -1
  106. package/workframe-ui/public/assets/architecture-7EHR7CIX-BSUP4S8J.js +1 -0
  107. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DeBNltHS.js → architectureDiagram-3BPJPVTR-DU6oaqIb.js} +1 -1
  108. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-BDhCHu7I.js → blockDiagram-GPEHLZMM-D2p8BU6-.js} +1 -1
  109. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-olcDYvtI.js → c4Diagram-AAUBKEIU-ouwyPmTJ.js} +1 -1
  110. package/workframe-ui/public/assets/channel-DOunZZ0X.js +1 -0
  111. package/workframe-ui/public/assets/{chunk-2J33WTMH-D0obCBn_.js → chunk-2J33WTMH-DsE7U5dj.js} +1 -1
  112. package/workframe-ui/public/assets/{chunk-3OPIFGDE-DX93f-ZZ.js → chunk-3OPIFGDE-DM0kkZ9h.js} +1 -1
  113. package/workframe-ui/public/assets/{chunk-4BX2VUAB-BX9B5wUs.js → chunk-4BX2VUAB-CQ8mAyXp.js} +1 -1
  114. package/workframe-ui/public/assets/{chunk-55IACEB6-C4DGc6RO.js → chunk-55IACEB6-DN1BDtbH.js} +1 -1
  115. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-Crlja9Lf.js → chunk-5ZQYHXKU-BzK2KqOt.js} +1 -1
  116. package/workframe-ui/public/assets/{chunk-727SXJPM-JYSm3szI.js → chunk-727SXJPM-C3AxtOi9.js} +1 -1
  117. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-_KslxVEl.js → chunk-AQP2D5EJ-4mVwEFuD.js} +1 -1
  118. package/workframe-ui/public/assets/{chunk-BSJP7CBP-CpTO-jU8.js → chunk-BSJP7CBP-23kN2q0A.js} +1 -1
  119. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-JGUkCmbI.js → chunk-CSCIHK7Q-CPBKVW-L.js} +2 -2
  120. package/workframe-ui/public/assets/{chunk-FMBD7UC4-BscBwGaC.js → chunk-FMBD7UC4-CKqcYq7K.js} +1 -1
  121. package/workframe-ui/public/assets/{chunk-KSCS5N6A-5ZTZ0bpX.js → chunk-KSCS5N6A-JRQnhxQE.js} +1 -1
  122. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-DxvitYbW.js → chunk-L5ZTLDWV-BLZrdIwa.js} +1 -1
  123. package/workframe-ui/public/assets/chunk-LZXEDZCA-DisG0XJT.js +2 -0
  124. package/workframe-ui/public/assets/{chunk-ND2GUHAM-Cayl0iV9.js → chunk-ND2GUHAM-ClAOxArS.js} +1 -1
  125. package/workframe-ui/public/assets/{chunk-NZK2D7GU-_7dyBR5G.js → chunk-NZK2D7GU-XUE1aNkm.js} +1 -1
  126. package/workframe-ui/public/assets/{chunk-O5CBEL6O--xTpD0yi.js → chunk-O5CBEL6O-DDN-Ifon.js} +1 -1
  127. package/workframe-ui/public/assets/chunk-QZHKN3VN-EVQ0VMd5.js +1 -0
  128. package/workframe-ui/public/assets/chunk-WU5MYG2G-k_-ZsLOS.js +1 -0
  129. package/workframe-ui/public/assets/{chunk-XPW4576I-CwxJQaeK.js → chunk-XPW4576I-B4_iYZ6r.js} +1 -1
  130. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-W9WVQsby.js +1 -0
  131. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-W9WVQsby.js +1 -0
  132. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-CCspENYv.js → cose-bilkent-S5V4N54A-Cy1HhS7j.js} +1 -1
  133. package/workframe-ui/public/assets/{dagre-BM42HDAG-Dv8V6R60.js → dagre-BM42HDAG-Bjal81Ie.js} +1 -1
  134. package/workframe-ui/public/assets/{diagram-2AECGRRQ-Da48hFPT.js → diagram-2AECGRRQ-v5Gdvzp_.js} +1 -1
  135. package/workframe-ui/public/assets/{diagram-5GNKFQAL-dTihc7-3.js → diagram-5GNKFQAL-DuyRdfmY.js} +1 -1
  136. package/workframe-ui/public/assets/{diagram-KO2AKTUF-D_Jqu699.js → diagram-KO2AKTUF-WwWCGbIW.js} +1 -1
  137. package/workframe-ui/public/assets/{diagram-LMA3HP47-Bt8PtEaZ.js → diagram-LMA3HP47-DTYwZALT.js} +1 -1
  138. package/workframe-ui/public/assets/{diagram-OG6HWLK6-BtmPjlh0.js → diagram-OG6HWLK6-B46kBIqE.js} +1 -1
  139. package/workframe-ui/public/assets/{dist-Cz2turIT.js → dist-mQWmB3z6.js} +1 -1
  140. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-BxMGCflX.js → erDiagram-TEJ5UH35-__40xO-y.js} +1 -1
  141. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Cm1uRTxU.js +1 -0
  142. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-ivyroIZt.js → flowDiagram-I6XJVG4X-Cwj6dVUz.js} +1 -1
  143. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-B9llwShx.js → ganttDiagram-6RSMTGT7-D_IOSwAS.js} +1 -1
  144. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BFQHk4bw.js → gitGraph-WXDBUCRP-C1vGecee.js} +1 -1
  145. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-ursegLbc.js → gitGraphDiagram-PVQCEYII-DMTxFJxn.js} +1 -1
  146. package/workframe-ui/public/assets/index-BdiM4-lI.js +130 -0
  147. package/workframe-ui/public/assets/index-Fnad47vV.css +1 -0
  148. package/workframe-ui/public/assets/{info-J43DQDTF-DQpifAsB.js → info-J43DQDTF-CwjmyPmD.js} +1 -1
  149. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-C16XP2Q7.js → infoDiagram-5YYISTIA-BGzh7ZL2.js} +1 -1
  150. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-CiJoz7rP.js → ishikawaDiagram-YF4QCWOH-BLXdnR_5.js} +1 -1
  151. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CVwEvvUL.js → journeyDiagram-JHISSGLW-BK3behMe.js} +1 -1
  152. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-CWQ6hX5i.js → kanban-definition-UN3LZRKU-Bx172cCl.js} +1 -1
  153. package/workframe-ui/public/assets/{line-CgQsmU35.js → line-BTCEHb7l.js} +1 -1
  154. package/workframe-ui/public/assets/{linear-Bxbc77dL.js → linear-CkbydpnK.js} +1 -1
  155. package/workframe-ui/public/assets/{mermaid-parser.core-C_jMeF0a.js → mermaid-parser.core-DvrtArpk.js} +2 -2
  156. package/workframe-ui/public/assets/{mermaid.core-4RKV9MZP.js → mermaid.core-yj_1x_qj.js} +3 -3
  157. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-DZ7y7Sz0.js → mindmap-definition-RKZ34NQL-BXbMltQ0.js} +1 -1
  158. package/workframe-ui/public/assets/{packet-YPE3B663-B9h2I7Vq.js → packet-YPE3B663-9pVCBQ7S.js} +1 -1
  159. package/workframe-ui/public/assets/{pie-LRSECV5Y-B2mP5uHm.js → pie-LRSECV5Y-CdqoWesP.js} +1 -1
  160. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-DyYKyKiP.js → pieDiagram-4H26LBE5-kGs-VfXd.js} +1 -1
  161. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-CL_6nej-.js → quadrantDiagram-W4KKPZXB-D9D8gQw5.js} +1 -1
  162. package/workframe-ui/public/assets/{radar-GUYGQ44K-CNKNDQ7S.js → radar-GUYGQ44K-d4zMZpQS.js} +1 -1
  163. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-eWU_mhFa.js → requirementDiagram-4Y6WPE33-BI7EQ9zc.js} +1 -1
  164. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DrjcXvEQ.js → sankeyDiagram-5OEKKPKP-Bcz19jQB.js} +1 -1
  165. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BHWR1xTJ.js → sequenceDiagram-3UESZ5HK-iQiCEfYp.js} +1 -1
  166. package/workframe-ui/public/assets/{src-DfJB8kV1.js → src-D6mvlP96.js} +1 -1
  167. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BGJbSd3I.js → stateDiagram-AJRCARHV-CxaTXs02.js} +1 -1
  168. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CXikj9xi.js +1 -0
  169. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DkwoCjiZ.js → timeline-definition-PNZ67QCA-Bej-EAnf.js} +1 -1
  170. package/workframe-ui/public/assets/{treeView-BLDUP644-GxfhiqoW.js → treeView-BLDUP644-C7PnnzwX.js} +1 -1
  171. package/workframe-ui/public/assets/{treemap-LRROVOQU-DxrOsars.js → treemap-LRROVOQU-CqISLmkO.js} +1 -1
  172. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-BsWhpUfr.js → vennDiagram-CIIHVFJN-eJMTTEW-.js} +1 -1
  173. package/workframe-ui/public/assets/{wardley-L42UT6IY-CrFZWMHS.js → wardley-L42UT6IY-D4sMroBF.js} +1 -1
  174. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DV8Xpf5I.js → wardleyDiagram-YWT4CUSO-DC3DCUs7.js} +1 -1
  175. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DqMqnwdZ.js → xychartDiagram-2RQKCTM6-BeMaM6Aq.js} +1 -1
  176. package/workframe-ui/public/index.html +7 -5
  177. package/workframe-ui/public/workframe-build.json +5 -0
  178. package/workframe-ui/public/assets/architecture-7EHR7CIX-Dwwi9yA0.js +0 -1
  179. package/workframe-ui/public/assets/channel-fNHbDpV4.js +0 -1
  180. package/workframe-ui/public/assets/chunk-LZXEDZCA-DMfdMUwz.js +0 -2
  181. package/workframe-ui/public/assets/chunk-QZHKN3VN-ewTgEQK8.js +0 -1
  182. package/workframe-ui/public/assets/chunk-WU5MYG2G-D4Tg-A0l.js +0 -1
  183. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CFAgBpEl.js +0 -1
  184. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CFAgBpEl.js +0 -1
  185. package/workframe-ui/public/assets/eventmodeling-FCH6USID-BIJTP5k-.js +0 -1
  186. package/workframe-ui/public/assets/index-8CuZDEIG.css +0 -1
  187. package/workframe-ui/public/assets/index-xS9lFekI.js +0 -129
  188. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CMLuNyeC.js +0 -1
@@ -0,0 +1,756 @@
1
+ """WF-032 extract: Hermes admin/profile API and slash command catalog."""
2
+ from __future__ import annotations
3
+
4
+ import sqlite3
5
+ import time
6
+ from typing import Any
7
+
8
+ import run_surface_wiring
9
+
10
+
11
+ def _srv():
12
+ import server as srv
13
+
14
+ return srv
15
+
16
+
17
+ # Cache installed skill names for /api/hermes/commands/exec slash routing.
18
+ _skill_name_cache: dict[str, Any] = {"names": None, "expires_at": 0.0}
19
+ _SKILL_CACHE_TTL_SECONDS = 60
20
+
21
+
22
+ def _known_skill_names() -> set[str]:
23
+ now = time.time()
24
+ cached = _skill_name_cache.get("names")
25
+ if cached is not None and _skill_name_cache.get("expires_at", 0) > now:
26
+ return cached
27
+ try:
28
+ names = {str(s.get("name", "")).strip() for s in _srv().hermes_skills() if s.get("name")}
29
+ except Exception:
30
+ names = cached or set()
31
+ _skill_name_cache["names"] = names
32
+ _skill_name_cache["expires_at"] = now + _SKILL_CACHE_TTL_SECONDS
33
+ return names
34
+
35
+
36
+ def _is_known_skill(name: str) -> bool:
37
+ if not name:
38
+ return False
39
+ return name in _known_skill_names()
40
+
41
+
42
+ # Static mirror of Hermes' COMMAND_REGISTRY (hermes_cli/commands.py).
43
+ # The BFF does not import hermes_agent; it serves a curated snapshot so the
44
+ # Workframe UI has a fast, local source of truth for the slash palette.
45
+ # Drift is acceptable: any new command lands here in the same release that
46
+ # updates upstream Hermes. See workframe-api dead-code note for context.
47
+ HERMES_COMMANDS: list[dict[str, Any]] = [
48
+ # Session
49
+ {"name": "/new", "aliases": ["/reset"], "category": "Session",
50
+ "description": "Fresh session", "args_hint": "",
51
+ "dispatch": "client:startNewSession"},
52
+ {"name": "/clear", "aliases": [], "category": "Session",
53
+ "description": "Clear screen + new session", "args_hint": "",
54
+ "dispatch": "client:clearMessages"},
55
+ {"name": "/retry", "aliases": [], "category": "Session",
56
+ "description": "Resend last message", "args_hint": "",
57
+ "dispatch": "gateway"},
58
+ {"name": "/undo", "aliases": [], "category": "Session",
59
+ "description": "Remove last exchange", "args_hint": "",
60
+ "dispatch": "gateway"},
61
+ {"name": "/compress", "aliases": [], "category": "Session",
62
+ "description": "Manually compress context", "args_hint": "",
63
+ "dispatch": "gateway"},
64
+ {"name": "/stop", "aliases": [], "category": "Session",
65
+ "description": "Kill background processes", "args_hint": "",
66
+ "dispatch": "gateway"},
67
+ {"name": "/rollback", "aliases": [], "category": "Session",
68
+ "description": "Restore filesystem checkpoint", "args_hint": "[N]",
69
+ "dispatch": "gateway"},
70
+ {"name": "/snapshot", "aliases": [], "category": "Session",
71
+ "description": "Create or restore state snapshots", "args_hint": "[sub]",
72
+ "dispatch": "gateway"},
73
+ {"name": "/background", "aliases": [], "category": "Session",
74
+ "description": "Run prompt in background", "args_hint": "<prompt>",
75
+ "dispatch": "gateway"},
76
+ {"name": "/queue", "aliases": [], "category": "Session",
77
+ "description": "Queue for next turn", "args_hint": "<prompt>",
78
+ "dispatch": "gateway"},
79
+ {"name": "/steer", "aliases": [], "category": "Session",
80
+ "description": "Inject after the next tool call", "args_hint": "<prompt>",
81
+ "dispatch": "gateway"},
82
+ {"name": "/agents", "aliases": ["/tasks"], "category": "Session",
83
+ "description": "Show active agents and running tasks", "args_hint": "",
84
+ "dispatch": "gateway"},
85
+ {"name": "/resume", "aliases": [], "category": "Session",
86
+ "description": "Resume a named session", "args_hint": "[name]",
87
+ "dispatch": "gateway"},
88
+ {"name": "/goal", "aliases": [], "category": "Session",
89
+ "description": "Set a standing goal", "args_hint": "[text|sub]",
90
+ "dispatch": "gateway"},
91
+ {"name": "/redraw", "aliases": [], "category": "Session",
92
+ "description": "Force a full UI repaint", "args_hint": "",
93
+ "dispatch": "client:noOp"},
94
+
95
+ # Configuration
96
+ {"name": "/model", "aliases": [], "category": "Configuration",
97
+ "description": "Show or change model", "args_hint": "[name]",
98
+ "dispatch": "client:openModelSwitcher"},
99
+ {"name": "/personality", "aliases": [], "category": "Configuration",
100
+ "description": "Set personality", "args_hint": "[name]",
101
+ "dispatch": "client:openPersonality"},
102
+ {"name": "/reasoning", "aliases": [], "category": "Configuration",
103
+ "description": "Set reasoning", "args_hint": "[level]",
104
+ "dispatch": "gateway"},
105
+ {"name": "/verbose", "aliases": [], "category": "Configuration",
106
+ "description": "Cycle verbose level", "args_hint": "",
107
+ "dispatch": "gateway"},
108
+ {"name": "/voice", "aliases": [], "category": "Configuration",
109
+ "description": "Voice mode", "args_hint": "[on|off|tts]",
110
+ "dispatch": "gateway"},
111
+ {"name": "/yolo", "aliases": [], "category": "Configuration",
112
+ "description": "Toggle approval bypass", "args_hint": "",
113
+ "dispatch": "gateway"},
114
+ {"name": "/busy", "aliases": [], "category": "Configuration",
115
+ "description": "What Enter does while working", "args_hint": "[sub]",
116
+ "dispatch": "gateway"},
117
+ {"name": "/indicator", "aliases": [], "category": "Configuration",
118
+ "description": "Busy indicator style", "args_hint": "[style]",
119
+ "dispatch": "gateway"},
120
+ {"name": "/footer", "aliases": [], "category": "Configuration",
121
+ "description": "Gateway runtime footer on replies", "args_hint": "[on|off]",
122
+ "dispatch": "gateway"},
123
+ {"name": "/skin", "aliases": [], "category": "Configuration",
124
+ "description": "Change theme (CLI)", "args_hint": "[name]",
125
+ "dispatch": "gateway"},
126
+ {"name": "/statusbar", "aliases": [], "category": "Configuration",
127
+ "description": "Toggle status bar (CLI)", "args_hint": "",
128
+ "dispatch": "gateway"},
129
+
130
+ # Tools & Skills
131
+ {"name": "/tools", "aliases": [], "category": "Tools & Skills",
132
+ "description": "Manage tools", "args_hint": "",
133
+ "dispatch": "gateway"},
134
+ {"name": "/toolsets", "aliases": [], "category": "Tools & Skills",
135
+ "description": "List toolsets", "args_hint": "",
136
+ "dispatch": "gateway"},
137
+ {"name": "/skills", "aliases": [], "category": "Tools & Skills",
138
+ "description": "Search/install skills", "args_hint": "",
139
+ "dispatch": "client:openSkills"},
140
+ {"name": "/skill", "aliases": [], "category": "Tools & Skills",
141
+ "description": "Load a skill into session", "args_hint": "<name>",
142
+ "dispatch": "gateway"},
143
+ {"name": "/reload-skills", "aliases": [], "category": "Tools & Skills",
144
+ "description": "Re-scan ~/.hermes/skills/", "args_hint": "",
145
+ "dispatch": "gateway"},
146
+ {"name": "/reload", "aliases": [], "category": "Tools & Skills",
147
+ "description": "Reload .env into running session", "args_hint": "",
148
+ "dispatch": "gateway"},
149
+ {"name": "/reload-mcp", "aliases": [], "category": "Tools & Skills",
150
+ "description": "Reload MCP servers", "args_hint": "",
151
+ "dispatch": "gateway"},
152
+ {"name": "/cron", "aliases": [], "category": "Tools & Skills",
153
+ "description": "Manage cron jobs", "args_hint": "",
154
+ "dispatch": "gateway"},
155
+ {"name": "/curator", "aliases": [], "category": "Tools & Skills",
156
+ "description": "Skill lifecycle", "args_hint": "[sub]",
157
+ "dispatch": "gateway"},
158
+ {"name": "/kanban", "aliases": [], "category": "Tools & Skills",
159
+ "description": "Multi-profile board", "args_hint": "[sub]",
160
+ "dispatch": "gateway"},
161
+ {"name": "/plugins", "aliases": [], "category": "Tools & Skills",
162
+ "description": "List plugins", "args_hint": "",
163
+ "dispatch": "gateway"},
164
+
165
+ # Utility
166
+ {"name": "/branch", "aliases": ["/fork"], "category": "Utility",
167
+ "description": "Branch the current session", "args_hint": "",
168
+ "dispatch": "gateway"},
169
+ {"name": "/fast", "aliases": [], "category": "Utility",
170
+ "description": "Toggle priority/fast processing", "args_hint": "",
171
+ "dispatch": "gateway"},
172
+ {"name": "/browser", "aliases": [], "category": "Utility",
173
+ "description": "Open CDP browser connection", "args_hint": "",
174
+ "dispatch": "gateway"},
175
+ {"name": "/history", "aliases": [], "category": "Utility",
176
+ "description": "Show conversation history", "args_hint": "",
177
+ "dispatch": "gateway"},
178
+ {"name": "/save", "aliases": [], "category": "Utility",
179
+ "description": "Save conversation to file", "args_hint": "",
180
+ "dispatch": "gateway"},
181
+ {"name": "/copy", "aliases": [], "category": "Utility",
182
+ "description": "Copy last assistant reply", "args_hint": "[N]",
183
+ "dispatch": "gateway"},
184
+ {"name": "/paste", "aliases": [], "category": "Utility",
185
+ "description": "Attach clipboard image", "args_hint": "",
186
+ "dispatch": "gateway"},
187
+ {"name": "/image", "aliases": [], "category": "Utility",
188
+ "description": "Attach local image file", "args_hint": "",
189
+ "dispatch": "gateway"},
190
+
191
+ # Info
192
+ {"name": "/help", "aliases": [], "category": "Info",
193
+ "description": "Show commands", "args_hint": "",
194
+ "dispatch": "client:openHelp"},
195
+ {"name": "/status", "aliases": [], "category": "Info",
196
+ "description": "Session info", "args_hint": "",
197
+ "dispatch": "client:openStatus"},
198
+ {"name": "/usage", "aliases": [], "category": "Info",
199
+ "description": "Token usage for the active session", "args_hint": "",
200
+ "dispatch": "client:openUsage"},
201
+ {"name": "/insights", "aliases": [], "category": "Info",
202
+ "description": "Usage analytics", "args_hint": "[days]",
203
+ "dispatch": "client:openInsights"},
204
+ {"name": "/gquota", "aliases": [], "category": "Info",
205
+ "description": "Google Gemini quota", "args_hint": "",
206
+ "dispatch": "client:openGquota"},
207
+ {"name": "/commands", "aliases": [], "category": "Info",
208
+ "description": "Browse all commands", "args_hint": "[page]",
209
+ "dispatch": "client:openHelp"},
210
+ {"name": "/profile", "aliases": [], "category": "Info",
211
+ "description": "Active profile info", "args_hint": "",
212
+ "dispatch": "client:openProfile"},
213
+ {"name": "/debug", "aliases": [], "category": "Info",
214
+ "description": "Upload debug report", "args_hint": "",
215
+ "dispatch": "client:openDebug"},
216
+
217
+ # Exit
218
+ {"name": "/quit", "aliases": ["/exit", "/q"], "category": "Exit",
219
+ "description": "Exit CLI", "args_hint": "",
220
+ "dispatch": "client:noOp"},
221
+ ]
222
+
223
+
224
+ def _resolve_command(token: str) -> dict[str, Any] | None:
225
+ """Resolve a slash token (with or without leading /) to a wired
226
+ catalog entry. Wip commands are internal-only — they live in
227
+ `HERMES_COMMANDS` for the team to track progress, but `_resolve_command`
228
+ treats them as not-found so the user can never accidentally invoke
229
+ something that isn't wired end-to-end.
230
+ """
231
+ if not token:
232
+ return None
233
+ needle = token if token.startswith("/") else f"/{token}"
234
+ for entry in HERMES_COMMANDS:
235
+ if entry.get("wip", False):
236
+ continue
237
+ if entry["name"] == needle or needle in entry.get("aliases", []):
238
+ return entry
239
+ return None
240
+
241
+
242
+
243
+
244
+ def hermes_usage() -> dict[str, Any]:
245
+ """Token usage for the latest session of the active profile."""
246
+ profile = _srv()._primary_profile()
247
+ if not profile:
248
+ return {"ok": False, "error": "no active profile"}
249
+
250
+ session_id = _srv()._latest_session_id(profile)
251
+ if not session_id:
252
+ return {"ok": True, "profile": profile, "session": None}
253
+
254
+ db = _srv()._profile_dir(profile) / "state.db"
255
+ conn = _srv()._ro_sqlite_live(db)
256
+ if not conn:
257
+ return {"ok": False, "error": "state.db not reachable"}
258
+
259
+ try:
260
+ row = conn.execute(
261
+ "SELECT title, model, message_count, started_at, ended_at, "
262
+ "tool_call_count, input_tokens, output_tokens "
263
+ "FROM sessions WHERE id = ?",
264
+ (session_id,),
265
+ ).fetchone()
266
+
267
+ if not row:
268
+ return {"ok": True, "profile": profile, "session": None}
269
+
270
+ input_tokens = int(row["input_tokens"] or 0)
271
+ output_tokens = int(row["output_tokens"] or 0)
272
+
273
+ return {
274
+ "ok": True,
275
+ "profile": profile,
276
+ "session": {
277
+ "id": str(session_id)[:12] + "…",
278
+ "title": row["title"] or "(untitled)",
279
+ "model": row["model"] or "—",
280
+ "message_count": int(row["message_count"] or 0),
281
+ "tool_call_count": int(row["tool_call_count"] or 0),
282
+ "input_tokens": input_tokens,
283
+ "output_tokens": output_tokens,
284
+ "total_tokens": input_tokens + output_tokens,
285
+ "started_at": _srv()._iso_from_unix(row["started_at"]),
286
+ "ended_at": _srv()._iso_from_unix(row["ended_at"]) if row["ended_at"] else None,
287
+ },
288
+ }
289
+ except sqlite3.Error as exc:
290
+ return {"ok": False, "error": str(exc)}
291
+ finally:
292
+ conn.close()
293
+
294
+
295
+ def hermes_gateway_exec(
296
+ line: str,
297
+ profile: str = "",
298
+ user_id: str = "",
299
+ workspace_id: str = "",
300
+ ) -> dict[str, Any]:
301
+ """Execute a slash command through the Hermes CLI.
302
+
303
+ Runs `hermes -p {profile} {cmd}` via Docker exec in the gateway
304
+ container. This bypasses the streaming chat endpoint — slash commands
305
+ are processed synchronously by the CLI's command handler and the
306
+ output is returned immediately.
307
+ """
308
+ prof = _srv().resolve_validated_profile(profile) if profile else _srv()._primary_profile()
309
+ if not prof:
310
+ return {"ok": False, "error": "no active profile"}
311
+
312
+ if not _srv()._user_may_access_runtime_profile(user_id, prof, workspace_id):
313
+ return {"ok": False, "error": "profile_access_denied", "profile": prof}
314
+
315
+ text = (line or "").strip()
316
+ if not text:
317
+ return {"ok": False, "error": "empty command"}
318
+
319
+ # Parse the command: first token is the command name (with or without
320
+ # leading /), rest is args.
321
+ parts = text.split(None, 1)
322
+ cmd_token = parts[0].lstrip("/")
323
+ cmd_args = parts[1] if len(parts) > 1 else ""
324
+
325
+ # Build the CLI command.
326
+ cli_args = [cmd_token]
327
+ if cmd_args:
328
+ cli_args.extend(cmd_args.split())
329
+
330
+ if _srv()._exec_targets_runtime_profile_secrets(cli_args, prof) or _srv()._exec_targets_runtime_profile_secrets(
331
+ ["hermes", "-p", prof, *cli_args], prof,
332
+ ):
333
+ return {"ok": False, "error": "blocked_credential_path", "profile": prof}
334
+
335
+ run_id: str | None = None
336
+ try:
337
+ run_id, decision = run_surface_wiring.begin_slash_run(
338
+ cmd_token=cmd_token,
339
+ line=text,
340
+ profile_slug=prof,
341
+ user_id=user_id,
342
+ workspace_id=workspace_id,
343
+ )
344
+ if decision is not None and not decision.allowed:
345
+ return {
346
+ "ok": False,
347
+ "error": str(decision.deny_reason or "run_denied"),
348
+ "run_id": run_id,
349
+ "profile": prof,
350
+ "command": cmd_token,
351
+ }
352
+ except Exception as exc: # noqa: BLE001
353
+ _srv()._log_handler_error("hermes_gateway_exec.run_ledger", exc)
354
+
355
+ try:
356
+ rc, output = _srv()._gateway_exec(prof, cli_args)
357
+ out = output.strip() if output else ""
358
+ if run_id:
359
+ try:
360
+ run_surface_wiring.finish_surface_run(run_id, ok=rc == 0, detail=out)
361
+ except Exception as exc: # noqa: BLE001
362
+ _srv()._log_handler_error("hermes_gateway_exec.finish_run", exc)
363
+ return {
364
+ "ok": rc == 0,
365
+ "profile": prof,
366
+ "command": cmd_token,
367
+ "rc": rc,
368
+ "output": out,
369
+ "run_id": run_id,
370
+ }
371
+ except Exception as exc:
372
+ if run_id:
373
+ try:
374
+ run_surface_wiring.finish_surface_run(run_id, ok=False, detail=str(exc))
375
+ except Exception as finish_exc: # noqa: BLE001
376
+ _srv()._log_handler_error("hermes_gateway_exec.finish_run", finish_exc)
377
+ return {"ok": False, "error": str(exc), "run_id": run_id}
378
+
379
+
380
+
381
+
382
+ def hermes_profile() -> dict[str, Any]:
383
+ """Active profile info for the /profile dialog."""
384
+ profile = _srv()._primary_profile()
385
+ if not profile:
386
+ return {"ok": False, "error": "no active profile"}
387
+
388
+ soul_path = _srv()._profile_soul_path(profile)
389
+ description = ""
390
+ if soul_path.is_file():
391
+ try:
392
+ text = soul_path.read_text(encoding="utf-8", errors="replace")
393
+ for raw in text.splitlines():
394
+ line = raw.strip()
395
+ if not line or line.startswith("#"):
396
+ continue
397
+ description = line[:200]
398
+ break
399
+ except Exception:
400
+ description = ""
401
+
402
+ try:
403
+ state = _srv().gateway_data(profile)
404
+ gateway_running = bool(state.get("ok")) and str(state.get("state") or "").lower() == "running"
405
+ except Exception:
406
+ gateway_running = False
407
+
408
+ session_id = _srv()._latest_session_id(profile)
409
+ session_info = None
410
+ if session_id:
411
+ try:
412
+ info = _srv()._session_info(profile, session_id)
413
+ session_info = {
414
+ "id": str(session_id)[:12] + "…",
415
+ "title": info.get("session_title", ""),
416
+ "message_count": info.get("message_count", 0),
417
+ "model": info.get("model", ""),
418
+ }
419
+ except Exception:
420
+ pass
421
+
422
+ return {
423
+ "ok": True,
424
+ "profile": profile,
425
+ "description": description,
426
+ "gateway_running": gateway_running,
427
+ "session": session_info,
428
+ }
429
+
430
+
431
+ def _agent_db_row(template_slug: str, workspace_id: str = "") -> dict[str, Any]:
432
+ template_slug = _srv().safe_profile_slug(str(template_slug or "").strip())
433
+ if not template_slug:
434
+ return {}
435
+ conn = _srv()._workframe_db()
436
+ try:
437
+ if workspace_id:
438
+ row = _srv()._lookup_agent_profile(conn, workspace_id, template_slug)
439
+ else:
440
+ row = conn.execute(
441
+ """
442
+ SELECT display_name, tagline, role, avatar_url
443
+ FROM agent_profiles
444
+ WHERE slug = ? AND deleted_at IS NULL
445
+ ORDER BY is_native DESC, updated_at DESC
446
+ LIMIT 1
447
+ """,
448
+ (template_slug,),
449
+ ).fetchone()
450
+ if not row:
451
+ return {}
452
+ return {
453
+ "display_name": str(row["display_name"] or "").strip(),
454
+ "tagline": str(row["tagline"] or "").strip() if "tagline" in row.keys() else "",
455
+ "role": str(row["role"] or "").strip() if "role" in row.keys() else "",
456
+ "avatar_url": str(row["avatar_url"] or "").strip() if "avatar_url" in row.keys() else "",
457
+ }
458
+ finally:
459
+ conn.close()
460
+
461
+
462
+ def hermes_profile_detail(profile: str, workspace_id: str = "") -> dict[str, Any]:
463
+ """Profile metadata + SOUL + model surface for a specific Hermes profile."""
464
+ prof = _srv().resolve_validated_profile(profile)
465
+ reg = _srv()._agent_registry_row(prof)
466
+ db_row = _agent_db_row(prof, workspace_id)
467
+ soul_text = _srv()._profile_soul_text(prof)
468
+ block = _srv()._read_model_block(prof)
469
+ try:
470
+ state = _srv().gateway_data(prof)
471
+ gateway_running = bool(state.get("ok")) and str(state.get("state") or "").lower() == "running"
472
+ gateway_state = str(state.get("state") or "unknown")
473
+ except Exception:
474
+ gateway_running = False
475
+ gateway_state = "unknown"
476
+ row: dict[str, Any] = {
477
+ "ok": True,
478
+ "profile": prof,
479
+ "display_name": str(
480
+ db_row.get("display_name")
481
+ or reg.get("display_name")
482
+ or _srv()._profile_display_name(prof, workspace_id)
483
+ ),
484
+ "role": str(db_row.get("role") or reg.get("role") or _srv()._profile_role(prof)),
485
+ "tagline": str(db_row.get("tagline") or reg.get("tagline") or ""),
486
+ "description": str(reg.get("description") or ""),
487
+ "soul": soul_text,
488
+ "soul_exists": bool(soul_text),
489
+ "gateway_running": gateway_running,
490
+ "gateway_state": gateway_state,
491
+ "model": block.get("default", ""),
492
+ "provider": block.get("provider", ""),
493
+ "is_native": _srv()._is_native_profile(prof),
494
+ }
495
+ avatar_url = str(db_row.get("avatar_url") or reg.get("avatar_url") or "").strip()
496
+ if avatar_url:
497
+ row["avatar_url"] = avatar_url
498
+ if reg.get("avatar_id"):
499
+ row["avatar_id"] = str(reg["avatar_id"])
500
+ _srv()._resolve_avatar_fields(row)
501
+ return row
502
+
503
+
504
+ def hermes_profile_update(profile: str, body: dict[str, Any]) -> dict[str, Any]:
505
+ """Update Workframe agent registry metadata for a Hermes profile."""
506
+ prof = _srv().resolve_validated_profile(profile)
507
+ allowed = {"display_name", "role", "tagline", "description", "avatar_url", "avatar_id"}
508
+ patch: dict[str, Any] = {}
509
+ for key in allowed:
510
+ if key not in body:
511
+ continue
512
+ value = body[key]
513
+ patch[key] = str(value).strip() if value is not None else ""
514
+ if "avatar_url" in patch:
515
+ _srv()._validate_me_profile_updates({"avatar_url": patch["avatar_url"]})
516
+ if not patch:
517
+ return {"ok": False, "error": "no_allowed_fields"}
518
+ _srv()._upsert_agent_registry_row(prof, patch)
519
+ _srv()._sync_agent_profile_db(prof, patch)
520
+ return {"ok": True, "profile": prof, **patch}
521
+
522
+
523
+ def profile_soul_get(profile: str) -> dict[str, Any]:
524
+ prof = _srv().resolve_validated_profile(profile)
525
+ text = _srv()._profile_soul_text(prof)
526
+ path = _srv()._profile_soul_path(prof)
527
+ return {
528
+ "ok": True,
529
+ "profile": prof,
530
+ "soul": text,
531
+ "path": str(path),
532
+ "exists": path.is_file(),
533
+ }
534
+
535
+
536
+ def profile_soul_set(profile: str, soul: str) -> dict[str, Any]:
537
+ prof = _srv().resolve_validated_profile(profile)
538
+ if _srv()._is_native_profile(prof):
539
+ path = _srv()._profile_soul_path(prof)
540
+ path.parent.mkdir(parents=True, exist_ok=True)
541
+ normalized = soul if not soul or soul.endswith("\n") else f"{soul}\n"
542
+ path.write_text(normalized, encoding="utf-8")
543
+ return {"ok": True, "profile": prof, "bytes": len(normalized.encode("utf-8")), "target": "soul_file"}
544
+ lookup = _srv()._runtime_template_slug(prof) if _srv()._is_runtime_profile_slug(prof) else prof
545
+ _srv()._apply_profile_identity(lookup, user_soul=soul)
546
+ return {"ok": True, "profile": prof, "bytes": len(soul.encode("utf-8")), "target": "user_soul_overlay"}
547
+
548
+
549
+ def hermes_debug() -> dict[str, Any]:
550
+ """Debug info for the /debug dialog."""
551
+ profile = _srv()._primary_profile()
552
+ if not profile:
553
+ return {"ok": False, "error": "no active profile"}
554
+
555
+ import platform as plat
556
+ import sys
557
+
558
+ db = _srv()._profile_dir(profile) / "state.db"
559
+ conn = _srv()._ro_sqlite_live(db)
560
+ session_count = 0
561
+ message_count = 0
562
+ if conn:
563
+ try:
564
+ row = conn.execute("SELECT COUNT(*) as cnt FROM sessions").fetchone()
565
+ session_count = row["cnt"] if row else 0
566
+ row = conn.execute("SELECT COUNT(*) as cnt FROM messages").fetchone()
567
+ message_count = row["cnt"] if row else 0
568
+ except Exception:
569
+ pass
570
+ finally:
571
+ conn.close()
572
+
573
+ return {
574
+ "ok": True,
575
+ "profile": profile,
576
+ "python_version": sys.version,
577
+ "platform": plat.platform(),
578
+ "session_count": session_count,
579
+ "message_count": message_count,
580
+ }
581
+
582
+
583
+ def hermes_insights() -> dict[str, Any]:
584
+ """Usage analytics for the /insights dialog."""
585
+ profile = _srv()._primary_profile()
586
+ if not profile:
587
+ return {"ok": False, "error": "no active profile"}
588
+
589
+ db = _srv()._profile_dir(profile) / "state.db"
590
+ conn = _srv()._ro_sqlite_live(db)
591
+ if not conn:
592
+ return {"ok": False, "error": "state.db not reachable"}
593
+
594
+ try:
595
+ # Total tokens per day (last 7 days)
596
+ rows = conn.execute(
597
+ "SELECT DATE(started_at, 'unixepoch') as day, "
598
+ "SUM(input_tokens) as input_tok, SUM(output_tokens) as output_tok, "
599
+ "COUNT(*) as sessions "
600
+ "FROM sessions "
601
+ "WHERE started_at > strftime('%s', 'now', '-7 days') "
602
+ "GROUP BY day ORDER BY day DESC"
603
+ ).fetchall()
604
+
605
+ daily = []
606
+ for r in rows:
607
+ daily.append({
608
+ "day": r["day"] or "unknown",
609
+ "input_tokens": int(r["input_tok"] or 0),
610
+ "output_tokens": int(r["output_tok"] or 0),
611
+ "sessions": int(r["sessions"] or 0),
612
+ })
613
+
614
+ # Model usage breakdown
615
+ model_rows = conn.execute(
616
+ "SELECT model, COUNT(*) as cnt, SUM(input_tokens) as input_tok "
617
+ "FROM sessions GROUP BY model ORDER BY cnt DESC LIMIT 10"
618
+ ).fetchall()
619
+
620
+ models = []
621
+ for r in model_rows:
622
+ models.append({
623
+ "model": r["model"] or "unknown",
624
+ "sessions": int(r["cnt"] or 0),
625
+ "input_tokens": int(r["input_tok"] or 0),
626
+ })
627
+
628
+ return {"ok": True, "profile": profile, "daily": daily, "models": models}
629
+ except sqlite3.Error as exc:
630
+ return {"ok": False, "error": str(exc)}
631
+ finally:
632
+ conn.close()
633
+
634
+
635
+ def hermes_gquota() -> dict[str, Any]:
636
+ """Google Gemini quota info — stub until Google API is configured."""
637
+ profile = _srv()._primary_profile()
638
+ if not profile:
639
+ return {"ok": False, "error": "no active profile"}
640
+ return {
641
+ "ok": True,
642
+ "profile": profile,
643
+ "configured": False,
644
+ "message": "Google Gemini quota: not configured. Set GOOGLE_API_KEY in .env to enable.",
645
+ }
646
+
647
+
648
+ def hermes_commands_catalog() -> dict[str, Any]:
649
+ """Categorized slash command catalog for the Workframe composer palette.
650
+
651
+ `wip` commands are an internal tracker — they exist in
652
+ `HERMES_COMMANDS` so the team can see what's still to wire, but
653
+ they aren't returned here. The UI never sees a half-implemented
654
+ command; either it's wired end-to-end or it's invisible.
655
+ """
656
+ visible = [e for e in HERMES_COMMANDS if not e.get("wip", False)]
657
+ grouped: dict[str, list[dict[str, Any]]] = {}
658
+ for entry in visible:
659
+ grouped.setdefault(entry["category"], []).append(
660
+ {
661
+ "name": entry["name"],
662
+ "aliases": entry.get("aliases", []),
663
+ "description": entry["description"],
664
+ "args_hint": entry.get("args_hint", ""),
665
+ "dispatch": entry["dispatch"],
666
+ }
667
+ )
668
+ categories = [
669
+ {"name": name, "commands": [
670
+ (cmd["name"], cmd["description"]) for cmd in items
671
+ ]}
672
+ for name, items in grouped.items()
673
+ ]
674
+ return {
675
+ "ok": True,
676
+ "categories": categories,
677
+ "entries": [
678
+ {**entry, "wired": True}
679
+ for entry in visible
680
+ ],
681
+ }
682
+
683
+
684
+ def hermes_commands_exec(line: str) -> dict[str, Any]:
685
+ """Resolve a slash command line and return a dispatch decision for the UI.
686
+
687
+ The BFF does not run client-side UI effects; it classifies the command
688
+ and returns a hint. The UI executes the hint. For commands that need to
689
+ round-trip the gateway (`/auth`, `/config`, etc.) the BFF returns a
690
+ `gateway` dispatch and the UI calls a separate gateway proxy endpoint.
691
+
692
+ Skills (`/skillname` where the skill is installed in Hermes) aren't in
693
+ the static catalog. The BFF falls back to a skill-name lookup so the
694
+ UI gets a clean "this is dispatchable" decision rather than "unknown
695
+ command" for every installed skill.
696
+ """
697
+ raw = (line or "").strip()
698
+ if not raw.startswith("/"):
699
+ return {"ok": False, "error": "not a slash command", "line": raw}
700
+ parts = raw.split(maxsplit=1)
701
+ token = parts[0]
702
+ rest = parts[1] if len(parts) > 1 else ""
703
+ entry = _resolve_command(token)
704
+ if entry is None:
705
+ # Fallback: is this an installed skill? Skills route through
706
+ # the gateway once the gateway hookup lands (Slice 2). For now
707
+ # the UI gets a `gateway` dispatch so the user sees the same
708
+ # "real command, gateway side not wired yet" hint as the other
709
+ # gateway-class commands.
710
+ skill_name = token.lstrip("/")
711
+ if _is_known_skill(skill_name):
712
+ return {
713
+ "ok": True,
714
+ "dispatched": "gateway",
715
+ "command": token,
716
+ "args": rest,
717
+ "message": f"Skill: {skill_name} — gateway dispatch coming in Slice 2",
718
+ }
719
+ return {
720
+ "ok": False,
721
+ "error": f"unknown command: {token}",
722
+ "suggestion": "/help",
723
+ }
724
+ dispatch = entry["dispatch"]
725
+ if dispatch == "noop":
726
+ return {
727
+ "ok": True,
728
+ "dispatched": "noop",
729
+ "command": entry["name"],
730
+ "args": rest,
731
+ "message": "Not yet wired in Workframe — see M2 in the Workframe roadmap.",
732
+ }
733
+ if dispatch.startswith("client:"):
734
+ return {
735
+ "ok": True,
736
+ "dispatched": "client",
737
+ "command": entry["name"],
738
+ "args": rest,
739
+ "handler": dispatch.split(":", 1)[1],
740
+ }
741
+ if dispatch.startswith("bff:"):
742
+ return {
743
+ "ok": True,
744
+ "dispatched": "bff",
745
+ "command": entry["name"],
746
+ "args": rest,
747
+ "handler": dispatch.split(":", 1)[1],
748
+ }
749
+ return {
750
+ "ok": True,
751
+ "dispatched": "gateway",
752
+ "command": entry["name"],
753
+ "args": rest,
754
+ }
755
+
756
+