create-workframe 0.1.12 → 0.1.14

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-BT9iZUSd.js} +1 -1
  106. package/workframe-ui/public/assets/architecture-7EHR7CIX-m1aPUQ_r.js +1 -0
  107. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DeBNltHS.js → architectureDiagram-3BPJPVTR-CaEDd3np.js} +1 -1
  108. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-BDhCHu7I.js → blockDiagram-GPEHLZMM-DHJhMrIS.js} +1 -1
  109. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-olcDYvtI.js → c4Diagram-AAUBKEIU-Cdyz9hIV.js} +1 -1
  110. package/workframe-ui/public/assets/channel-3M69ekE5.js +1 -0
  111. package/workframe-ui/public/assets/{chunk-2J33WTMH-D0obCBn_.js → chunk-2J33WTMH-BbZx76LX.js} +1 -1
  112. package/workframe-ui/public/assets/{chunk-3OPIFGDE-DX93f-ZZ.js → chunk-3OPIFGDE-DcSxjTIP.js} +1 -1
  113. package/workframe-ui/public/assets/{chunk-4BX2VUAB-BX9B5wUs.js → chunk-4BX2VUAB-B7F-kTY1.js} +1 -1
  114. package/workframe-ui/public/assets/{chunk-55IACEB6-C4DGc6RO.js → chunk-55IACEB6-BwwEiRTK.js} +1 -1
  115. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-Crlja9Lf.js → chunk-5ZQYHXKU-DAi_NksX.js} +1 -1
  116. package/workframe-ui/public/assets/{chunk-727SXJPM-JYSm3szI.js → chunk-727SXJPM-BIaF4XCN.js} +1 -1
  117. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-_KslxVEl.js → chunk-AQP2D5EJ-DjYSk1dG.js} +1 -1
  118. package/workframe-ui/public/assets/{chunk-BSJP7CBP-CpTO-jU8.js → chunk-BSJP7CBP-_twnyfgV.js} +1 -1
  119. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-JGUkCmbI.js → chunk-CSCIHK7Q-CkNmdy5s.js} +2 -2
  120. package/workframe-ui/public/assets/{chunk-FMBD7UC4-BscBwGaC.js → chunk-FMBD7UC4-CjLqd-gl.js} +1 -1
  121. package/workframe-ui/public/assets/{chunk-KSCS5N6A-5ZTZ0bpX.js → chunk-KSCS5N6A-_6oNqFjj.js} +1 -1
  122. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-DxvitYbW.js → chunk-L5ZTLDWV-CeO9Am_D.js} +1 -1
  123. package/workframe-ui/public/assets/chunk-LZXEDZCA-sRM4YeIe.js +2 -0
  124. package/workframe-ui/public/assets/{chunk-ND2GUHAM-Cayl0iV9.js → chunk-ND2GUHAM-CH8tMb21.js} +1 -1
  125. package/workframe-ui/public/assets/{chunk-NZK2D7GU-_7dyBR5G.js → chunk-NZK2D7GU-DAI1bsII.js} +1 -1
  126. package/workframe-ui/public/assets/{chunk-O5CBEL6O--xTpD0yi.js → chunk-O5CBEL6O-B2e-5a2G.js} +1 -1
  127. package/workframe-ui/public/assets/chunk-QZHKN3VN-BMl9ed8P.js +1 -0
  128. package/workframe-ui/public/assets/chunk-WU5MYG2G-C08HH6BU.js +1 -0
  129. package/workframe-ui/public/assets/{chunk-XPW4576I-CwxJQaeK.js → chunk-XPW4576I-DM2-0q37.js} +1 -1
  130. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BFcOE4Aq.js +1 -0
  131. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BFcOE4Aq.js +1 -0
  132. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-CCspENYv.js → cose-bilkent-S5V4N54A-BnbbewLv.js} +1 -1
  133. package/workframe-ui/public/assets/{dagre-BM42HDAG-Dv8V6R60.js → dagre-BM42HDAG-C4zmB1Vt.js} +1 -1
  134. package/workframe-ui/public/assets/{diagram-2AECGRRQ-Da48hFPT.js → diagram-2AECGRRQ-n-BJmsiC.js} +1 -1
  135. package/workframe-ui/public/assets/{diagram-5GNKFQAL-dTihc7-3.js → diagram-5GNKFQAL-BzQGQnZi.js} +1 -1
  136. package/workframe-ui/public/assets/{diagram-KO2AKTUF-D_Jqu699.js → diagram-KO2AKTUF-Cx_De-wS.js} +1 -1
  137. package/workframe-ui/public/assets/{diagram-LMA3HP47-Bt8PtEaZ.js → diagram-LMA3HP47-CIOI3Lok.js} +1 -1
  138. package/workframe-ui/public/assets/{diagram-OG6HWLK6-BtmPjlh0.js → diagram-OG6HWLK6-DkRuPLWY.js} +1 -1
  139. package/workframe-ui/public/assets/{dist-Cz2turIT.js → dist-XhCT1Q-V.js} +1 -1
  140. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-BxMGCflX.js → erDiagram-TEJ5UH35-C3GwhDU1.js} +1 -1
  141. package/workframe-ui/public/assets/eventmodeling-FCH6USID-DIVBL6CF.js +1 -0
  142. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-ivyroIZt.js → flowDiagram-I6XJVG4X-CLWH8z2e.js} +1 -1
  143. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-B9llwShx.js → ganttDiagram-6RSMTGT7-D4h66Vy3.js} +1 -1
  144. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BFQHk4bw.js → gitGraph-WXDBUCRP-IW5F-Q6c.js} +1 -1
  145. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-ursegLbc.js → gitGraphDiagram-PVQCEYII-BjNIbmRM.js} +1 -1
  146. package/workframe-ui/public/assets/index-DL2vJP4L.css +1 -0
  147. package/workframe-ui/public/assets/index-ElS_D59P.js +130 -0
  148. package/workframe-ui/public/assets/{info-J43DQDTF-DQpifAsB.js → info-J43DQDTF-CcJspM79.js} +1 -1
  149. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-C16XP2Q7.js → infoDiagram-5YYISTIA-DP4xR9jt.js} +1 -1
  150. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-CiJoz7rP.js → ishikawaDiagram-YF4QCWOH-D4cZb21V.js} +1 -1
  151. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CVwEvvUL.js → journeyDiagram-JHISSGLW-DsLuHkIJ.js} +1 -1
  152. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-CWQ6hX5i.js → kanban-definition-UN3LZRKU-Wu65nOsR.js} +1 -1
  153. package/workframe-ui/public/assets/{line-CgQsmU35.js → line-ByBDzRpy.js} +1 -1
  154. package/workframe-ui/public/assets/{linear-Bxbc77dL.js → linear-DzXi2vnq.js} +1 -1
  155. package/workframe-ui/public/assets/{mermaid-parser.core-C_jMeF0a.js → mermaid-parser.core-CaB-1Ckn.js} +2 -2
  156. package/workframe-ui/public/assets/{mermaid.core-4RKV9MZP.js → mermaid.core-D7HTHC17.js} +3 -3
  157. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-DZ7y7Sz0.js → mindmap-definition-RKZ34NQL-BfKsC9aj.js} +1 -1
  158. package/workframe-ui/public/assets/{packet-YPE3B663-B9h2I7Vq.js → packet-YPE3B663-ClpU98TO.js} +1 -1
  159. package/workframe-ui/public/assets/{pie-LRSECV5Y-B2mP5uHm.js → pie-LRSECV5Y-KeOyY_-u.js} +1 -1
  160. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-DyYKyKiP.js → pieDiagram-4H26LBE5-CUpjnzbs.js} +1 -1
  161. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-CL_6nej-.js → quadrantDiagram-W4KKPZXB-CJdPTszF.js} +1 -1
  162. package/workframe-ui/public/assets/{radar-GUYGQ44K-CNKNDQ7S.js → radar-GUYGQ44K-C9BpandZ.js} +1 -1
  163. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-eWU_mhFa.js → requirementDiagram-4Y6WPE33-CHDZPwry.js} +1 -1
  164. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DrjcXvEQ.js → sankeyDiagram-5OEKKPKP-CMJJh91s.js} +1 -1
  165. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BHWR1xTJ.js → sequenceDiagram-3UESZ5HK-BsV4GppP.js} +1 -1
  166. package/workframe-ui/public/assets/{src-DfJB8kV1.js → src-WHks82Up.js} +1 -1
  167. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BGJbSd3I.js → stateDiagram-AJRCARHV-eSX9P8z0.js} +1 -1
  168. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-FzMK4PkM.js +1 -0
  169. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DkwoCjiZ.js → timeline-definition-PNZ67QCA-Dg1UkhGo.js} +1 -1
  170. package/workframe-ui/public/assets/{treeView-BLDUP644-GxfhiqoW.js → treeView-BLDUP644-B7yGsBuj.js} +1 -1
  171. package/workframe-ui/public/assets/{treemap-LRROVOQU-DxrOsars.js → treemap-LRROVOQU-j_G2oyQ_.js} +1 -1
  172. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-BsWhpUfr.js → vennDiagram-CIIHVFJN-BRrT0jqC.js} +1 -1
  173. package/workframe-ui/public/assets/{wardley-L42UT6IY-CrFZWMHS.js → wardley-L42UT6IY-DEdUqez2.js} +1 -1
  174. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DV8Xpf5I.js → wardleyDiagram-YWT4CUSO-C-bBK9Bb.js} +1 -1
  175. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DqMqnwdZ.js → xychartDiagram-2RQKCTM6-DhH5xuR2.js} +1 -1
  176. package/workframe-ui/public/index.html +11 -7
  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,1328 @@
1
+ """WF-032 extract: hermes_profiles."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import queue
7
+ import re
8
+ import shlex
9
+ import shutil
10
+ import sqlite3
11
+ import time
12
+ import uuid
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from http.server import BaseHTTPRequestHandler
17
+
18
+ import user_prefs
19
+ import rooms
20
+
21
+ _AGENT_PROFILE_UUID_RE = rooms._AGENT_PROFILE_UUID_RE
22
+ _FORBIDDEN_CHILD_SKILLS = frozenset({"botfather", "crew-manager"})
23
+
24
+
25
+ def _srv():
26
+ import server as srv
27
+
28
+ return srv
29
+
30
+
31
+ def _list_profiles() -> list[str]:
32
+ profiles_dir = _srv().HERMES_DATA / "profiles"
33
+ if not profiles_dir.is_dir():
34
+ return []
35
+ return sorted(
36
+ p.name
37
+ for p in profiles_dir.iterdir()
38
+ if p.is_dir() and ((p / "profile.yaml").exists() or (p / "config.yaml").exists())
39
+ )
40
+
41
+
42
+ def _native_profile_slug() -> str:
43
+ return str(_srv().NATIVE_PROFILE or "workframe-agent").strip() or "workframe-agent"
44
+
45
+
46
+ def _native_profile_present() -> bool:
47
+ slug = _native_profile_slug()
48
+ prof_dir = _srv()._profile_dir(slug)
49
+ return (prof_dir / "config.yaml").is_file() or (prof_dir / "profile.yaml").is_file()
50
+
51
+
52
+ def _hermes_data_uid_gid() -> tuple[int, int]:
53
+ try:
54
+ st = _srv().HERMES_DATA.stat()
55
+ return int(st.st_uid), int(st.st_gid)
56
+ except OSError:
57
+ return 10000, 10000
58
+
59
+
60
+ def _chown_profile_tree(prof_dir: Path) -> None:
61
+ """Gateway runs as the Agents volume owner — seeded files must match."""
62
+ uid, gid = _hermes_data_uid_gid()
63
+ prof_dir.mkdir(parents=True, exist_ok=True)
64
+ (prof_dir / "logs").mkdir(parents=True, exist_ok=True)
65
+ for path in [prof_dir, *prof_dir.rglob("*")]:
66
+ try:
67
+ os.chown(path, uid, gid)
68
+ except OSError:
69
+ pass
70
+
71
+
72
+ def _seed_native_profile_on_disk(slug: str) -> bool:
73
+ """Filesystem fallback when Hermes CLI cannot run (empty / crash-looping gateway)."""
74
+ slug = _srv().safe_profile_slug(slug)
75
+ prof_dir = _srv()._profile_dir(slug)
76
+ prof_dir.mkdir(parents=True, exist_ok=True)
77
+ config = prof_dir / "config.yaml"
78
+ if not config.is_file():
79
+ proxy = _srv()._llm_proxy_base_url("openrouter")
80
+ config.write_text(
81
+ "model:\n"
82
+ " default: google/gemini-2.5-flash\n"
83
+ " provider: custom\n"
84
+ f" base_url: {proxy}\n",
85
+ encoding="utf-8",
86
+ )
87
+ soul = prof_dir / "SOUL.md"
88
+ if not soul.is_file():
89
+ soul.write_text(
90
+ f"You are the {_srv().PROJECT_NAME} native agent — orchestrator and workspace admin.\n",
91
+ encoding="utf-8",
92
+ )
93
+ _ensure_profile_toolsets(slug)
94
+ _srv()._ensure_profile_terminal_cwd(slug)
95
+ _register_profile_route(
96
+ slug,
97
+ {
98
+ "display_name": f"{_srv().PROJECT_NAME} Agent",
99
+ "role": f"{_srv().PROJECT_NAME} Manager",
100
+ },
101
+ )
102
+ routes_path = _srv().ROUTES_JSON
103
+ if routes_path.is_file():
104
+ try:
105
+ data = json.loads(routes_path.read_text(encoding="utf-8"))
106
+ if isinstance(data, dict) and not data.get("default_profile"):
107
+ data["default_profile"] = slug
108
+ routes_path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
109
+ except (OSError, json.JSONDecodeError):
110
+ pass
111
+ _chown_profile_tree(prof_dir)
112
+ return _native_profile_present()
113
+
114
+
115
+ def _ensure_native_hermes_profile() -> bool:
116
+ """Idempotent: native workframe-agent must exist before gateway boot or DM bootstrap."""
117
+ slug = _native_profile_slug()
118
+ if _native_profile_present():
119
+ return True
120
+ shell = (
121
+ "export HERMES_HOME=/opt/data HOME=/opt/data; "
122
+ f"/opt/hermes/bin/hermes profile create {shlex.quote(slug)} --clone "
123
+ f"--description {shlex.quote(f'{_srv().PROJECT_NAME} native agent')}"
124
+ )
125
+ try:
126
+ code, out = _srv()._gateway_container_exec(["sh", "-lc", shell])
127
+ if code == 0 and _native_profile_present():
128
+ _register_profile_route(
129
+ slug,
130
+ {"display_name": f"{_srv().PROJECT_NAME} Agent", "role": f"{_srv().PROJECT_NAME} Manager"},
131
+ )
132
+ _chown_profile_tree(_srv()._profile_dir(slug))
133
+ return True
134
+ if "already exists" in out.lower() and _native_profile_present():
135
+ _register_profile_route(
136
+ slug,
137
+ {"display_name": f"{_srv().PROJECT_NAME} Agent", "role": f"{_srv().PROJECT_NAME} Manager"},
138
+ )
139
+ _chown_profile_tree(_srv()._profile_dir(slug))
140
+ return True
141
+ except Exception:
142
+ pass
143
+ return _seed_native_profile_on_disk(slug)
144
+
145
+
146
+ def _primary_profile() -> str:
147
+ if _srv().NATIVE_PROFILE and (_srv().HERMES_DATA / "profiles" / _srv().NATIVE_PROFILE).is_dir():
148
+ return _srv().NATIVE_PROFILE
149
+ profiles = _list_profiles()
150
+ return profiles[0] if profiles else ""
151
+
152
+
153
+ def _profile_dir(profile: str) -> Path:
154
+ return _srv().HERMES_DATA / "profiles" / profile
155
+
156
+
157
+ def _profile_gateway_config_path(profile: str) -> Path | None:
158
+ """Hermes gateway reads platforms + model chain from config.yaml only."""
159
+ d = _srv()._profile_dir(profile)
160
+ if not d.is_dir():
161
+ return None
162
+ return d / "config.yaml"
163
+
164
+
165
+ def _ensure_gateway_config_file(profile: str) -> Path:
166
+ path = _profile_gateway_config_path(profile)
167
+ if path is None:
168
+ raise ValueError(f"profile not found: {profile}")
169
+ if path.is_file():
170
+ return path
171
+ provider, model = "", ""
172
+ meta = _srv()._profile_dir(profile) / "profile.yaml"
173
+ if meta.is_file():
174
+ provider, model = _parse_model_fields_from_yaml(meta.read_text(encoding="utf-8"))
175
+ seed = ""
176
+ if model:
177
+ seed = f"model:\n default: {model}\n"
178
+ if provider:
179
+ seed += f" provider: {provider}\n"
180
+ path.write_text(seed, encoding="utf-8")
181
+ return path
182
+
183
+
184
+ def _fix_invalid_model_header(raw: str) -> str:
185
+ """Scalar `model: ''` breaks yaml.safe_load when proxy writers add nested keys."""
186
+ lines = raw.splitlines(keepends=True)
187
+ out: list[str] = []
188
+ for line in lines:
189
+ stripped = line.lstrip()
190
+ if stripped.startswith("model:") and not line.startswith((" ", "\t")):
191
+ rest = stripped.split(":", 1)[1].strip().strip("'\"")
192
+ if not rest:
193
+ out.append("model:\n")
194
+ continue
195
+ if not stripped.endswith(":") and rest:
196
+ out.append("model:\n")
197
+ out.append(f" default: {rest}\n")
198
+ continue
199
+ out.append(line)
200
+ return "".join(out)
201
+
202
+
203
+ def _scrub_orphan_top_level_yaml_lines(profile: str) -> None:
204
+ """Drop root-level list items (e.g. `- provider: openrouter`) left by corrupt writers."""
205
+ config_path = _profile_gateway_config_path(profile)
206
+ if not config_path or not config_path.is_file():
207
+ return
208
+ try:
209
+ lines = config_path.read_text(encoding="utf-8").splitlines(keepends=True)
210
+ except OSError:
211
+ return
212
+ out: list[str] = []
213
+ changed = False
214
+ for line in lines:
215
+ stripped = line.lstrip()
216
+ indent = len(line) - len(stripped)
217
+ if indent == 0 and stripped.startswith("- "):
218
+ changed = True
219
+ continue
220
+ out.append(line)
221
+ if changed:
222
+ try:
223
+ config_path.write_text("".join(out), encoding="utf-8")
224
+ except OSError:
225
+ return
226
+
227
+
228
+ def _fix_model_child_indent(raw: str) -> str:
229
+ """Re-indent model block keys to 2 spaces — fixes api_key nested under base_url scalar."""
230
+ lines = raw.splitlines(keepends=True)
231
+ out: list[str] = []
232
+ in_model = False
233
+ for line in lines:
234
+ stripped = line.lstrip()
235
+ if not line.startswith((" ", "\t")) and stripped.startswith("model:"):
236
+ in_model = True
237
+ out.append(line if line.endswith("\n") else f"{line}\n")
238
+ continue
239
+ if in_model:
240
+ if stripped and not line.startswith((" ", "\t", "#")):
241
+ in_model = False
242
+ out.append(line)
243
+ continue
244
+ if stripped.startswith(("default:", "provider:", "base_url:", "api_key:")):
245
+ _, _, val = stripped.partition(":")
246
+ out.append(f" {stripped.split(':', 1)[0]}:{val}\n")
247
+ continue
248
+ out.append(line)
249
+ return "".join(out)
250
+
251
+
252
+ def _normalize_profile_config_yaml(profile: str) -> None:
253
+ """Repair invalid model headers before gateway yaml parsers run (no pyyaml in API image)."""
254
+ config_path = _profile_gateway_config_path(profile)
255
+ if not config_path or not config_path.is_file():
256
+ return
257
+ try:
258
+ raw = config_path.read_text(encoding="utf-8")
259
+ except OSError:
260
+ return
261
+ fixed = _fix_invalid_model_header(raw)
262
+ fixed = _fix_model_child_indent(fixed)
263
+ if fixed != raw:
264
+ try:
265
+ config_path.write_text(fixed, encoding="utf-8")
266
+ except OSError:
267
+ return
268
+ _srv()._coalesce_profile_model_yaml(profile)
269
+ _scrub_orphan_top_level_yaml_lines(profile)
270
+ _srv()._ensure_profile_terminal_cwd(profile)
271
+
272
+
273
+ def _parse_model_fields_from_yaml(text: str) -> tuple[str, str]:
274
+ provider = ""
275
+ model_name = ""
276
+ in_model_block = False
277
+ for raw_line in text.splitlines():
278
+ line = raw_line.rstrip()
279
+ if not line or line.lstrip().startswith("#"):
280
+ continue
281
+ if not line[:1].isspace() and line.endswith(":"):
282
+ in_model_block = line[:-1].strip() == "model"
283
+ continue
284
+ if not in_model_block or ":" not in line:
285
+ continue
286
+ key, _, value = line.strip().partition(":")
287
+ value = value.strip().strip('"').strip("'")
288
+ if key == "default":
289
+ model_name = value
290
+ elif key == "provider":
291
+ provider = value
292
+ return provider, model_name
293
+
294
+
295
+ def _profile_config_path(profile: str) -> Path | None:
296
+ """Readable profile yaml: prefer gateway config.yaml, else legacy profile.yaml metadata."""
297
+ gateway = _profile_gateway_config_path(profile)
298
+ if gateway and gateway.is_file():
299
+ return gateway
300
+ meta = _srv()._profile_dir(profile) / "profile.yaml"
301
+ return meta if meta.is_file() else None
302
+
303
+
304
+ def _profile_home(profile: str) -> Path:
305
+ return _srv()._profile_dir(profile)
306
+
307
+
308
+ def _render_soul_placeholders(text: str) -> str:
309
+ native_slug = str(_srv().NATIVE_PROFILE or "workframe-agent").strip() or "workframe-agent"
310
+ ctx = {
311
+ "projectName": _srv().PROJECT_NAME,
312
+ "nativeProfileSlug": native_slug,
313
+ "nativeAgentName": _native_display_name(),
314
+ }
315
+ out = str(text or "")
316
+ for key, value in ctx.items():
317
+ out = out.replace(f"{{{key}}}", value)
318
+ return out
319
+
320
+
321
+ def _soul_is_stub(text: str) -> bool:
322
+ """True when SOUL is missing, trivial, unexpanded, or not Workframe identity."""
323
+ body = str(text or "").strip()
324
+ if len(body) < 80:
325
+ return True
326
+ low = body.lower()
327
+ if any(token in low for token in ("{nativeagentname}", "{projectname}", "{nativeprofileslug}")):
328
+ return True
329
+ if "workframe" not in low and "botfather" not in low and "concierge" not in low:
330
+ return True
331
+ return False
332
+
333
+
334
+ def _format_child_template(template: str, **kwargs: str) -> str:
335
+ ctx = {"nativeAgentName": _native_display_name(), **kwargs}
336
+ return _render_soul_placeholders(template.format(**ctx))
337
+
338
+ _CHILD_SOUL_TEMPLATE = """# {display_name}
339
+
340
+ ## Identity (highest priority)
341
+ You are **{display_name}** — a Workframe agent. When greeting or identifying yourself, use **{display_name}** — never the underlying model or provider name.
342
+
343
+ Mission
344
+ - {role}
345
+
346
+ ## Hermes & Workframe
347
+ - Read `AGENTS.md` in this profile home for tools, skills, memory, and how to upkeep your `SOUL.md`.
348
+ - Read `/workspace/AGENTS.md` for project workspace rules.
349
+ - Load Hermes skills from `skills/` before specialized work.
350
+ - CLI: `/opt/hermes/.venv/bin/hermes -p <your-runtime-slug> …` via **terminal** tool.
351
+
352
+ Restrictions
353
+ - You are a child agent — you cannot create, delete, spawn, or reconfigure other agents.
354
+ - Escalate crew changes to the native Workframe agent ({nativeAgentName}).
355
+ """
356
+
357
+ _CHILD_AGENTS_TEMPLATE = """# AGENTS — {display_name}
358
+
359
+ Operating rules for **{display_name}**. Identity lives in `SOUL.md` in this profile home.
360
+
361
+ ## Tools & skills
362
+
363
+ - Load task-appropriate skills from `skills/` before specialized work.
364
+ - CLI: `/opt/hermes/.venv/bin/hermes -p <your-runtime-slug> …` via **terminal**.
365
+ - Read `WORKFRAME_COHORT.md` when present — use **runtime_slug** for kanban/delegate.
366
+
367
+ ## Restrictions
368
+
369
+ - Child agent — **not** Botfather. Escalate crew changes to **{nativeAgentName}**.
370
+ - You may refine your own `SOUL.md` and notes when the user asks.
371
+
372
+ ## Memory
373
+
374
+ - Outcomes in `/workspace`; evolve notes via Hermes memory when asked.
375
+ """
376
+
377
+
378
+ def _soul_is_native_concierge(text: str) -> bool:
379
+ low = str(text or "").lower()
380
+ return "botfather" in low or "concierge" in low
381
+
382
+
383
+ def _strip_forbidden_child_skills(profile: str) -> list[str]:
384
+ """Remove botfather/crew-manager skills from child profiles after native clone."""
385
+ prof = _srv().safe_profile_slug(profile)
386
+ skills_root = _srv()._profile_dir(prof) / "skills"
387
+ if not skills_root.is_dir():
388
+ return []
389
+ removed: list[str] = []
390
+
391
+ def walk(dir_path: Path) -> None:
392
+ for entry in list(dir_path.iterdir()):
393
+ if entry.is_dir():
394
+ if entry.name in _FORBIDDEN_CHILD_SKILLS:
395
+ shutil.rmtree(entry, ignore_errors=True)
396
+ removed.append(entry.name)
397
+ else:
398
+ walk(entry)
399
+ elif entry.name == "SKILL.md":
400
+ skill_name = entry.parent.name
401
+ if skill_name in _FORBIDDEN_CHILD_SKILLS:
402
+ shutil.rmtree(entry.parent, ignore_errors=True)
403
+ removed.append(skill_name)
404
+
405
+ walk(skills_root)
406
+ return sorted(set(removed))
407
+
408
+
409
+ def _seed_native_user_overlay(
410
+ runtime: str,
411
+ template: str,
412
+ *,
413
+ display_name: str = "",
414
+ role: str = "",
415
+ tagline: str = "",
416
+ user_soul: str = "",
417
+ ) -> None:
418
+ """Keep template SOUL/AGENTS on disk; layer user identity in registry overlay."""
419
+ runtime = _srv().safe_profile_slug(runtime)
420
+ template = _srv().safe_profile_slug(template)
421
+ if not runtime:
422
+ return
423
+ _write_profile_soul_if_stub(runtime, template)
424
+ if display_name.strip() or role.strip() or tagline.strip() or user_soul.strip():
425
+ _apply_profile_identity(
426
+ runtime,
427
+ display_name=display_name,
428
+ role=role,
429
+ tagline=tagline,
430
+ user_soul=user_soul,
431
+ )
432
+
433
+
434
+ def _apply_profile_identity(
435
+ profile: str,
436
+ *,
437
+ display_name: str = "",
438
+ role: str = "",
439
+ tagline: str = "",
440
+ user_soul: str = "",
441
+ ) -> None:
442
+ prof = _srv().safe_profile_slug(profile)
443
+ patch: dict[str, Any] = {}
444
+ if display_name.strip():
445
+ patch["display_name"] = display_name.strip()
446
+ if role.strip():
447
+ patch["role"] = role.strip()
448
+ if tagline.strip():
449
+ patch["tagline"] = tagline.strip()
450
+ if user_soul.strip():
451
+ patch["user_soul"] = user_soul.strip()
452
+ if patch.get("display_name"):
453
+ aid = _srv()._avatar_id_for_display_name(str(patch["display_name"]))
454
+ if aid:
455
+ patch.update(_srv()._normalize_agent_avatar_patch("", aid))
456
+ if patch:
457
+ _srv()._upsert_agent_registry_row(prof, patch)
458
+
459
+
460
+ def _profile_identity_overlay(profile: str) -> str:
461
+ prof = _srv().safe_profile_slug(profile)
462
+ if _srv()._is_runtime_profile_slug(prof):
463
+ template = _runtime_template_slug(prof)
464
+ reg = {**_srv()._agent_registry_row(template), **_srv()._agent_registry_row(prof)}
465
+ else:
466
+ reg = _srv()._agent_registry_row(prof)
467
+ blocks: list[str] = []
468
+ identity_lines: list[str] = []
469
+ display = str(reg.get("display_name") or "").strip()
470
+ role = str(reg.get("role") or reg.get("description") or "").strip()
471
+ tagline = str(reg.get("tagline") or "").strip()
472
+ if display:
473
+ identity_lines.append(f"- **Name:** {display}")
474
+ if role:
475
+ identity_lines.append(f"- **Role:** {role}")
476
+ if tagline:
477
+ identity_lines.append(f"- **Tagline:** {tagline}")
478
+ if identity_lines:
479
+ blocks.append("## User identity\n" + "\n".join(identity_lines))
480
+ user_soul = str(reg.get("user_soul") or "").strip()
481
+ if user_soul:
482
+ blocks.append("## Additional instructions\n" + user_soul)
483
+ return "\n\n".join(blocks).strip()
484
+
485
+
486
+ def _install_child_base_artifacts(profile: str, *, display_name: str, role: str) -> bool:
487
+ """Write child SOUL/AGENTS when profile was cloned from native concierge."""
488
+ prof = _srv().safe_profile_slug(profile)
489
+ if not prof or _is_native_profile(prof):
490
+ return False
491
+ label = display_name.strip() or _profile_display_name(prof)
492
+ mission = role.strip() or f"{label} specialist for {_srv().PROJECT_NAME}."
493
+ raw = _profile_soul_raw(prof)
494
+ wrote = False
495
+ if _soul_is_stub(raw) or _soul_is_native_concierge(raw):
496
+ body = _format_child_template(_CHILD_SOUL_TEMPLATE, display_name=label, role=mission)
497
+ soul_path = _srv()._profile_dir(prof) / "SOUL.md"
498
+ soul_path.parent.mkdir(parents=True, exist_ok=True)
499
+ soul_path.write_text(body if body.endswith("\n") else f"{body}\n", encoding="utf-8")
500
+ wrote = True
501
+ agents_path = _srv()._profile_dir(prof) / "AGENTS.md"
502
+ if not agents_path.is_file():
503
+ agents_body = _format_child_template(_CHILD_AGENTS_TEMPLATE, display_name=label)
504
+ agents_path.write_text(agents_body.strip() + "\n", encoding="utf-8")
505
+ wrote = True
506
+ return wrote
507
+
508
+
509
+ def _profile_base_soul_text(profile: str) -> str:
510
+ prof = _srv().safe_profile_slug(str(profile or "").strip())
511
+ if not prof:
512
+ return ""
513
+ path = _profile_soul_path(prof)
514
+ raw = ""
515
+ try:
516
+ if path.is_file():
517
+ raw = path.read_text(encoding="utf-8-sig", errors="replace").strip()
518
+ except OSError:
519
+ raw = ""
520
+ template = _runtime_template_slug(prof) if _srv()._is_runtime_profile_slug(prof) else prof
521
+ if _soul_is_stub(raw) and template:
522
+ tpl_path = _srv()._profile_dir(template) / "SOUL.md"
523
+ try:
524
+ if tpl_path.is_file() and tpl_path != path:
525
+ raw = tpl_path.read_text(encoding="utf-8-sig", errors="replace").strip()
526
+ except OSError:
527
+ pass
528
+ if not raw:
529
+ return ""
530
+ return _render_soul_placeholders(raw).strip()
531
+
532
+
533
+ def _profile_soul_text(profile: str) -> str:
534
+ base = _profile_base_soul_text(profile)
535
+ overlay = _profile_identity_overlay(profile)
536
+ if base and overlay:
537
+ return f"{base.rstrip()}\n\n---\n\n{overlay}"
538
+ return overlay or base
539
+
540
+
541
+ def _write_profile_soul_if_stub(profile: str, template: str = "") -> bool:
542
+ """Persist rendered template SOUL when runtime file is stub. Returns True if written."""
543
+ prof = _srv().safe_profile_slug(profile)
544
+ tpl = _srv().safe_profile_slug(template or (_runtime_template_slug(prof) if _srv()._is_runtime_profile_slug(prof) else prof))
545
+ if not prof or not tpl:
546
+ return False
547
+ dst = _srv()._profile_dir(prof) / "SOUL.md"
548
+ try:
549
+ current = dst.read_text(encoding="utf-8-sig", errors="replace") if dst.is_file() else ""
550
+ except OSError:
551
+ current = ""
552
+ if not _soul_is_stub(current):
553
+ return False
554
+ src = _srv()._profile_dir(tpl) / "SOUL.md"
555
+ if not src.is_file():
556
+ return False
557
+ try:
558
+ rendered = _render_soul_placeholders(src.read_text(encoding="utf-8-sig", errors="replace")).strip()
559
+ if not rendered or _soul_is_stub(rendered):
560
+ return False
561
+ dst.parent.mkdir(parents=True, exist_ok=True)
562
+ dst.write_text(rendered if rendered.endswith("\n") else f"{rendered}\n", encoding="utf-8")
563
+ return True
564
+ except OSError:
565
+ return False
566
+
567
+
568
+ def _profile_soul_path(profile: str) -> Path:
569
+ profile_soul = _profile_home(profile) / "SOUL.md"
570
+ if profile_soul.is_file():
571
+ return profile_soul
572
+ if profile == _primary_profile():
573
+ root_soul = _srv().HERMES_DATA / "SOUL.md"
574
+ if root_soul.is_file():
575
+ return root_soul
576
+ return profile_soul
577
+
578
+
579
+ def _profile_soul_raw(profile: str) -> str:
580
+ path = _profile_soul_path(profile)
581
+ try:
582
+ return path.read_text(encoding="utf-8-sig", errors="replace").strip()
583
+ except OSError:
584
+ return ""
585
+
586
+
587
+ def _file_size_mb(path: Path) -> float:
588
+ try:
589
+ return path.stat().st_size / (1024 * 1024)
590
+ except OSError:
591
+ return 0.0
592
+
593
+
594
+
595
+
596
+ def gateway_data(profile: str) -> dict[str, Any]:
597
+ path = _srv()._profile_dir(profile) / "gateway_state.json"
598
+ base: dict[str, Any] = {
599
+ "ok": False,
600
+ "exists": path.is_file(),
601
+ "state": "unknown",
602
+ "platforms": {},
603
+ "active_agents": 0,
604
+ "uptime": None,
605
+ "uptime_seconds": None,
606
+ "updated_at": None,
607
+ }
608
+ if not path.is_file():
609
+ return base
610
+ try:
611
+ raw = json.loads(path.read_text(encoding="utf-8"))
612
+ except (OSError, json.JSONDecodeError):
613
+ return base
614
+ start = raw.get("start_time")
615
+ uptime = None
616
+ if isinstance(start, (int, float)) and float(start) > 1_000_000_000:
617
+ uptime = max(0.0, time.time() - float(start))
618
+ platforms = raw.get("platforms") or {}
619
+ for name, info in platforms.items():
620
+ if isinstance(info, dict) and "status" not in info:
621
+ info = {**info, "status": info.get("state", "unknown")}
622
+ platforms[name] = info
623
+ base.update(
624
+ {
625
+ "ok": True,
626
+ "state": raw.get("gateway_state") or raw.get("state") or "unknown",
627
+ "kind": raw.get("kind"),
628
+ "pid": raw.get("pid"),
629
+ "platforms": platforms,
630
+ "active_agents": raw.get("active_agents", 0),
631
+ "updated_at": raw.get("updated_at"),
632
+ "uptime": uptime,
633
+ "uptime_seconds": uptime,
634
+ "raw": raw,
635
+ }
636
+ )
637
+ return base
638
+
639
+
640
+ def sessions_data(profile: str) -> dict[str, Any]:
641
+ db = _srv()._profile_dir(profile) / "state.db"
642
+ out: dict[str, Any] = {
643
+ "ok": False,
644
+ "count": 0,
645
+ "session_count": 0,
646
+ "message_count": 0,
647
+ "recent": [],
648
+ }
649
+ conn = _srv()._ro_sqlite_live(db)
650
+ if not conn:
651
+ return out
652
+ try:
653
+ row = conn.execute(
654
+ """
655
+ SELECT COUNT(*) AS sessions,
656
+ COALESCE(SUM(message_count), 0) AS messages
657
+ FROM sessions
658
+ """
659
+ ).fetchone()
660
+ recent = conn.execute(
661
+ """
662
+ SELECT id, source, title, started_at, ended_at, message_count,
663
+ model, estimated_cost_usd
664
+ FROM sessions
665
+ ORDER BY started_at DESC
666
+ LIMIT 25
667
+ """
668
+ ).fetchall()
669
+ count = int(row["sessions"] or 0)
670
+ messages = int(row["messages"] or 0)
671
+ out.update(
672
+ {
673
+ "ok": True,
674
+ "count": count,
675
+ "session_count": count,
676
+ "message_count": messages,
677
+ "recent": [
678
+ {
679
+ "id": r["id"],
680
+ "source": r["source"],
681
+ "title": r["title"] or "(untitled)",
682
+ "started_at": r["started_at"],
683
+ "started_at_iso": _srv()._iso_from_unix(r["started_at"]),
684
+ "ended_at": r["ended_at"],
685
+ "message_count": r["message_count"] or 0,
686
+ "model": r["model"] or "",
687
+ "cost_usd": r["estimated_cost_usd"],
688
+ }
689
+ for r in recent
690
+ ],
691
+ }
692
+ )
693
+ except sqlite3.Error:
694
+ pass
695
+ finally:
696
+ conn.close()
697
+ return out
698
+
699
+
700
+ def _profile_slug(profile: str) -> str:
701
+ if profile.endswith("-agent"):
702
+ return profile[: -len("-agent")]
703
+ return profile
704
+
705
+
706
+ def _agent_label(profile: str) -> str:
707
+ return _profile_slug(profile)
708
+
709
+
710
+ def _native_display_name() -> str:
711
+ return f"{_srv().PROJECT_NAME} Agent"
712
+
713
+
714
+ def _is_native_profile(profile: str) -> bool:
715
+ return bool(_srv().NATIVE_PROFILE and profile == _srv().NATIVE_PROFILE)
716
+
717
+
718
+ def _agent_db_display_name(template_slug: str, workspace_id: str = "") -> str:
719
+ template_slug = _srv().safe_profile_slug(str(template_slug or "").strip())
720
+ if not template_slug:
721
+ return ""
722
+ conn = _srv()._workframe_db()
723
+ try:
724
+ if workspace_id:
725
+ row = _srv()._lookup_agent_profile(conn, workspace_id, template_slug)
726
+ else:
727
+ row = conn.execute(
728
+ """
729
+ SELECT display_name FROM agent_profiles
730
+ WHERE slug = ? AND deleted_at IS NULL
731
+ ORDER BY is_native DESC, updated_at DESC
732
+ LIMIT 1
733
+ """,
734
+ (template_slug,),
735
+ ).fetchone()
736
+ if row and str(row["display_name"] or "").strip():
737
+ return str(row["display_name"]).strip()
738
+ finally:
739
+ conn.close()
740
+ return ""
741
+
742
+
743
+ def _profile_display_name(profile: str, workspace_id: str = "") -> str:
744
+ template = _runtime_template_slug(profile) if _srv()._is_runtime_profile_slug(profile) else profile
745
+ db_name = _agent_db_display_name(template, workspace_id)
746
+ if db_name:
747
+ return db_name
748
+ reg = _srv()._agent_registry_row(template)
749
+ if reg.get("display_name"):
750
+ return str(reg["display_name"])
751
+ if _is_native_profile(template):
752
+ return _native_display_name()
753
+ slug = _profile_slug(template)
754
+ return slug.replace("-", " ").title()
755
+
756
+
757
+ def _default_session_title(profile: str) -> str:
758
+ display = _profile_display_name(profile).strip() or "Agent"
759
+ return f"Session with {display}"
760
+
761
+
762
+ def _create_profile_session_via_api(profile: str, session_id: str, title: str) -> tuple[int, Any, str]:
763
+ base_title = (title or "").strip() or _default_session_title(profile)
764
+ candidate = base_title
765
+ for attempt in range(1, 25):
766
+ status, data = _srv()._profile_api_request(
767
+ profile,
768
+ "POST",
769
+ "/api/sessions",
770
+ {"session_id": session_id, "title": candidate},
771
+ )
772
+ if status < 300:
773
+ return status, data, candidate
774
+ msg = json.dumps(data).lower() if isinstance(data, dict) else str(data).lower()
775
+ if "invalid_title" not in msg and "already in use" not in msg:
776
+ return status, data, candidate
777
+ if attempt < 12:
778
+ candidate = f"{base_title} ({attempt + 1})"
779
+ else:
780
+ candidate = f"{base_title} {int(time.time())}-{attempt}"
781
+ return status, data, candidate
782
+
783
+
784
+ def _profile_role(profile: str) -> str:
785
+ if _is_native_profile(profile):
786
+ return (
787
+ f"Workframe Manager and orchestrator for {_srv().PROJECT_NAME}. "
788
+ "Routes work to installed specialist profiles."
789
+ )
790
+ slug = _profile_slug(profile)
791
+ return _srv().SPECIALIST_ROLES.get(slug, f"{_profile_display_name(profile)} specialist profile.")
792
+
793
+
794
+ def _profile_code(display_name: str, slug: str) -> str:
795
+ word = (display_name.split()[0] if display_name else slug)[:4]
796
+ return word.upper() or "AGNT"
797
+
798
+
799
+ def safe_profile_slug(value: str) -> str:
800
+ slug = (value or "").strip()
801
+ if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,63}", slug):
802
+ raise ValueError("invalid profile")
803
+ return slug
804
+
805
+
806
+ def profile_exists(profile: str) -> bool:
807
+ return _srv()._profile_dir(profile).is_dir()
808
+
809
+
810
+ def route_status_for_profile(profile: str) -> str:
811
+ if not profile_exists(profile):
812
+ return "not_installed"
813
+ return "available"
814
+
815
+
816
+ def _register_profile_route(profile: str, meta: dict[str, Any] | None = None) -> bool:
817
+ """Append an on-disk profile to routes.json when missing (post profile_create)."""
818
+ prof = _srv().safe_profile_slug(profile)
819
+ if not profile_exists(prof):
820
+ return False
821
+ _srv().ROUTES_JSON.parent.mkdir(parents=True, exist_ok=True)
822
+ data: dict[str, Any] = {"version": 1, "routes": []}
823
+ if _srv().ROUTES_JSON.is_file():
824
+ try:
825
+ parsed = json.loads(_srv().ROUTES_JSON.read_text(encoding="utf-8"))
826
+ if isinstance(parsed, dict):
827
+ data = parsed
828
+ except (OSError, json.JSONDecodeError):
829
+ pass
830
+ routes = data.get("routes")
831
+ if not isinstance(routes, list):
832
+ routes = []
833
+ data["routes"] = routes
834
+ if any(isinstance(row, dict) and str(row.get("profile") or "") == prof for row in routes):
835
+ return False
836
+ routes.append(_route_record(prof, meta))
837
+ if not data.get("default_profile"):
838
+ data["default_profile"] = _primary_profile()
839
+ _srv().ROUTES_JSON.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
840
+ return True
841
+
842
+
843
+ def _route_record(profile: str, meta: dict[str, Any] | None = None) -> dict[str, Any]:
844
+ meta = meta or {}
845
+ slug = _srv().safe_profile_slug(profile)
846
+ ident = _srv()._agent_identity_fields(slug)
847
+ row = {
848
+ "id": str(meta.get("id") or slug),
849
+ "profile": slug,
850
+ "display_name": str(ident.get("display_name") or meta.get("display_name") or _profile_display_name(slug)),
851
+ "role": str(ident.get("role") or meta.get("role") or _profile_role(slug)),
852
+ "mode": "lane",
853
+ "route_status": route_status_for_profile(slug),
854
+ }
855
+ if ident.get("avatar_url"):
856
+ row["avatar_url"] = str(ident["avatar_url"])
857
+ if ident.get("avatar_id"):
858
+ row["avatar_id"] = str(ident["avatar_id"])
859
+ _srv()._resolve_avatar_fields(row)
860
+ return row
861
+
862
+
863
+ def load_routes() -> dict[str, Any]:
864
+ default_profile = _primary_profile()
865
+ raw_routes: list[dict[str, Any]] = []
866
+ if _srv().ROUTES_JSON.is_file():
867
+ try:
868
+ data = json.loads(_srv().ROUTES_JSON.read_text(encoding="utf-8"))
869
+ if isinstance(data, dict) and isinstance(data.get("routes"), list):
870
+ raw_routes = data["routes"]
871
+ if data.get("default_profile"):
872
+ default_profile = _srv().safe_profile_slug(str(data["default_profile"]))
873
+ except (OSError, json.JSONDecodeError, ValueError):
874
+ raw_routes = []
875
+
876
+ routes: list[dict[str, Any]] = []
877
+ seen: set[str] = set()
878
+ for entry in raw_routes:
879
+ if not isinstance(entry, dict):
880
+ continue
881
+ try:
882
+ profile = _srv().safe_profile_slug(str(entry.get("profile") or entry.get("id") or ""))
883
+ except ValueError:
884
+ continue
885
+ if not profile_exists(profile) or profile in seen:
886
+ continue
887
+ seen.add(profile)
888
+ routes.append(_route_record(profile, entry))
889
+
890
+ if not routes:
891
+ for profile in _list_profiles():
892
+ if profile in seen:
893
+ continue
894
+ seen.add(profile)
895
+ routes.append(_route_record(profile, {}))
896
+
897
+ route_profiles = {r["profile"] for r in routes}
898
+ if default_profile and default_profile not in route_profiles and profile_exists(default_profile):
899
+ routes.insert(0, _route_record(default_profile, {}))
900
+ route_profiles.add(default_profile)
901
+ native = _srv().safe_profile_slug(_srv().NATIVE_PROFILE) if _srv().NATIVE_PROFILE else ""
902
+ if native and native not in route_profiles and profile_exists(native):
903
+ routes.insert(0, _route_record(native, {}))
904
+ route_profiles.add(native)
905
+ if default_profile and default_profile not in route_profiles:
906
+ default_profile = routes[0]["profile"] if routes else default_profile
907
+
908
+ return {"ok": True, "default_profile": default_profile, "routes": routes}
909
+
910
+
911
+ def resolve_validated_profile(profile: str) -> str:
912
+ raw = str(profile or _primary_profile()).strip()
913
+ if _AGENT_PROFILE_UUID_RE.match(raw):
914
+ resolved = _srv()._hermes_slug_from_agent_ref(raw)
915
+ if resolved:
916
+ raw = resolved
917
+ slug = _srv().safe_profile_slug(raw)
918
+ allowed = {r["profile"] for r in load_routes()["routes"]}
919
+ if slug not in allowed:
920
+ raise ValueError(f"unknown profile: {slug}")
921
+ if not profile_exists(slug):
922
+ raise ValueError(f"profile not installed: {slug}")
923
+ return slug
924
+
925
+
926
+ _RUNTIME_PROFILE_RE = re.compile(r"^u-[a-z0-9][a-z0-9-]{0,62}$")
927
+
928
+
929
+ def _is_runtime_profile_slug(slug: str) -> bool:
930
+ return bool(_RUNTIME_PROFILE_RE.fullmatch(str(slug or "").strip()))
931
+
932
+
933
+ def _runtime_template_slug(runtime: str) -> str:
934
+ """Map per-user runtime slug u-{user}-{template} back to the template profile."""
935
+ slug = _srv().safe_profile_slug(str(runtime or "").strip())
936
+ if not _srv()._is_runtime_profile_slug(slug):
937
+ return slug
938
+ rest = slug[2:]
939
+ templates: set[str] = {p for p in _list_profiles() if not _srv()._is_runtime_profile_slug(p)}
940
+ templates.update(_srv().load_agent_registry().keys())
941
+ native = _primary_profile()
942
+ if native:
943
+ templates.add(native)
944
+ for template in sorted(templates, key=len, reverse=True):
945
+ if rest == template or rest.endswith(f"-{template}"):
946
+ return template
947
+ return rest
948
+
949
+
950
+ _DEFAULT_CHAT_TOOLSETS = ("hermes-cli", "terminal")
951
+
952
+
953
+ def _chat_toolsets_for_profile(_profile: str = "") -> tuple[str, ...]:
954
+ """Multi-user safety: runtime RBAC + exec credential guards, not disabled terminal."""
955
+ return _DEFAULT_CHAT_TOOLSETS
956
+
957
+
958
+ def _ensure_profile_toolsets(profile: str, toolsets: tuple[str, ...] | None = None) -> None:
959
+ """Ensure Hermes toolsets (e.g. terminal) are enabled on a profile config."""
960
+ prof = _srv().safe_profile_slug(str(profile or "").strip())
961
+ cfg_path = _profile_gateway_config_path(prof)
962
+ if not prof or cfg_path is None:
963
+ return
964
+ _normalize_profile_config_yaml(prof)
965
+ want = list(toolsets if toolsets is not None else _chat_toolsets_for_profile(prof))
966
+ if _srv()._profile_toolsets_ready(prof, want):
967
+ return
968
+ try:
969
+ import yaml
970
+
971
+ cfg: dict[str, Any] = {}
972
+ if cfg_path.is_file():
973
+ loaded = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
974
+ cfg = loaded if isinstance(loaded, dict) else {}
975
+ ts = cfg.setdefault("toolsets", [])
976
+ if not isinstance(ts, list):
977
+ ts = []
978
+ cfg["toolsets"] = ts
979
+ pts = cfg.setdefault("platform_toolsets", {})
980
+ if not isinstance(pts, dict):
981
+ pts = {}
982
+ cfg["platform_toolsets"] = pts
983
+ changed = False
984
+ for name in want:
985
+ if name not in ts:
986
+ ts.append(name)
987
+ changed = True
988
+ for platform in ("api_server", "cli"):
989
+ plat = pts.setdefault(platform, [])
990
+ if not isinstance(plat, list):
991
+ plat = []
992
+ pts[platform] = plat
993
+ for name in want:
994
+ if name not in plat:
995
+ plat.append(name)
996
+ changed = True
997
+ agent = cfg.setdefault("agent", {})
998
+ if not isinstance(agent, dict):
999
+ agent = {}
1000
+ cfg["agent"] = agent
1001
+ disabled = agent.get("disabled_toolsets") if isinstance(agent.get("disabled_toolsets"), list) else []
1002
+ for name in want:
1003
+ if name in disabled:
1004
+ disabled = [x for x in disabled if x != name]
1005
+ changed = True
1006
+ agent["disabled_toolsets"] = disabled
1007
+ if changed:
1008
+ cfg_path.write_text(yaml.safe_dump(cfg, sort_keys=False), encoding="utf-8")
1009
+ except (OSError, ImportError):
1010
+ return
1011
+
1012
+
1013
+ def resolve_hermes_profile(profile: str) -> str:
1014
+ """Resolve a Hermes profile dir for API calls; runtime u-* slugs skip route rail."""
1015
+ raw = str(profile or _primary_profile()).strip()
1016
+ if _AGENT_PROFILE_UUID_RE.match(raw):
1017
+ resolved = _srv()._hermes_slug_from_agent_ref(raw)
1018
+ if resolved:
1019
+ raw = resolved
1020
+ slug = _srv().safe_profile_slug(raw)
1021
+ if _srv()._is_runtime_profile_slug(slug):
1022
+ if not profile_exists(slug):
1023
+ raise ValueError(f"profile not installed: {slug}")
1024
+ return slug
1025
+ return _srv().resolve_validated_profile(slug)
1026
+ def profile_create(
1027
+ name: str,
1028
+ model: str = "",
1029
+ description: str = "",
1030
+ clone_from: str = "",
1031
+ soul: str = "",
1032
+ display_name: str = "",
1033
+ role: str = "",
1034
+ tagline: str = "",
1035
+ avatar_url: str = "",
1036
+ avatar_id: str = "",
1037
+ *,
1038
+ user_id: str = "",
1039
+ workspace_id: str = "",
1040
+ bootstrap_dm: bool = True,
1041
+ ) -> dict[str, Any]:
1042
+ """Create a new Hermes profile via docker exec. Returns profile info."""
1043
+ # Validate name: lowercase, alphanumeric, hyphens
1044
+ import re as _re
1045
+ if not _re.match(r'^[a-z][a-z0-9-]{0,63}$', name):
1046
+ raise ValueError("profile name must be lowercase alphanumeric, start with a letter, max 64 chars")
1047
+
1048
+ # Check if profile already exists — register in workspace and return idempotently.
1049
+ pdir = _srv()._profile_dir(name)
1050
+ if pdir.exists():
1051
+ registry_patch: dict[str, Any] = {}
1052
+ if display_name.strip():
1053
+ registry_patch["display_name"] = display_name.strip()
1054
+ if role.strip():
1055
+ registry_patch["role"] = role.strip()
1056
+ elif description.strip():
1057
+ registry_patch["role"] = description.strip()
1058
+ if tagline.strip():
1059
+ registry_patch["tagline"] = tagline.strip()
1060
+ if description.strip():
1061
+ registry_patch["description"] = description.strip()
1062
+ if registry_patch:
1063
+ _srv()._upsert_agent_registry_row(name, registry_patch)
1064
+ _register_profile_route(
1065
+ name,
1066
+ {
1067
+ "display_name": display_name.strip() or name,
1068
+ "role": role.strip() or description.strip(),
1069
+ },
1070
+ )
1071
+ prof = _srv().resolve_validated_profile(name)
1072
+ _srv()._ensure_workspace_agent_profile_row(
1073
+ prof,
1074
+ workspace_id=workspace_id,
1075
+ display_name=display_name.strip() or prof,
1076
+ role=role.strip() or description.strip(),
1077
+ tagline=tagline.strip(),
1078
+ )
1079
+ soul_text = str(soul or "").strip()
1080
+ if soul_text:
1081
+ _apply_profile_identity(name, user_soul=soul_text)
1082
+ results: dict[str, Any] = {
1083
+ "ok": True,
1084
+ "profile": prof,
1085
+ "already_exists": True,
1086
+ "steps": [{"step": "reuse_existing_profile", "ok": True}],
1087
+ }
1088
+ if bootstrap_dm and user_id and workspace_id:
1089
+ try:
1090
+ lane = _srv().bootstrap_agent_dm_lane(
1091
+ user_id,
1092
+ workspace_id,
1093
+ prof,
1094
+ model=model,
1095
+ bind_session=True,
1096
+ room_name=display_name.strip() or prof,
1097
+ created_by=user_id,
1098
+ )
1099
+ results["bootstrap"] = lane
1100
+ results["room_id"] = lane.get("room_id")
1101
+ results["runtime_profile"] = lane.get("runtime")
1102
+ results["session_id"] = lane.get("session_id")
1103
+ if lane.get("room"):
1104
+ results["room"] = lane["room"]
1105
+ results["steps"].extend(lane.get("steps") or [])
1106
+ except Exception as exc:
1107
+ results["steps"].append({"step": "bootstrap_dm_lane", "ok": False, "error": str(exc)})
1108
+ return results
1109
+
1110
+ prof = _srv().safe_profile_slug(name)
1111
+ if not clone_from.strip():
1112
+ try:
1113
+ clone_slug = _srv().resolve_validated_profile(_primary_profile())
1114
+ except ValueError as exc:
1115
+ raise ValueError(f"native agent profile required to spawn children: {exc}") from exc
1116
+ else:
1117
+ clone_slug = _srv().resolve_validated_profile(clone_from)
1118
+ results: dict[str, Any] = {"profile": prof, "steps": []}
1119
+
1120
+ # 1. Create profile via hermes CLI — clone native capabilities minus forbidden skills.
1121
+ cmd = ["profile", "create", "--clone-from", clone_slug]
1122
+ if description:
1123
+ cmd.extend(["--description", description])
1124
+ cmd.append(prof)
1125
+
1126
+ try:
1127
+ code, out = _srv()._gateway_exec(_primary_profile(), cmd)
1128
+ results["steps"].append({"step": "hermes_profile_create", "ok": code == 0, "output": out.strip()})
1129
+ if code != 0:
1130
+ raise ValueError(f"hermes profile create failed: {out.strip()}")
1131
+ except Exception as exc:
1132
+ results["steps"].append({"step": "hermes_profile_create", "ok": False, "error": str(exc)})
1133
+ raise
1134
+
1135
+ removed_skills = _strip_forbidden_child_skills(prof)
1136
+ results["steps"].append({"step": "strip_forbidden_skills", "ok": True, "removed": removed_skills})
1137
+ child_label = display_name.strip() or prof.replace("-", " ").title()
1138
+ child_role = role.strip() or description.strip() or f"{child_label} specialist for {_srv().PROJECT_NAME}."
1139
+ if _install_child_base_artifacts(prof, display_name=child_label, role=child_role):
1140
+ results["steps"].append({"step": "install_child_base", "ok": True})
1141
+
1142
+ # 2. Start the gateway for this profile
1143
+ try:
1144
+ ok, out, port = _srv()._configure_profile_api(prof)
1145
+ results["steps"].append({"step": "configure_api", "ok": ok, "api_port": port, "output": out.strip()})
1146
+ if ok:
1147
+ ok2, out2 = _srv()._patch_profile_gateway_run_script(prof)
1148
+ results["steps"].append({"step": "patch_run_script", "ok": ok2, "output": out2.strip()})
1149
+ except Exception as exc:
1150
+ results["steps"].append({"step": "configure_api", "ok": False, "error": str(exc)})
1151
+
1152
+ # 3–4. Template model/gateway only when not bootstrapping a per-user DM runtime (C1).
1153
+ if not bootstrap_dm:
1154
+ if model:
1155
+ try:
1156
+ code, out = _srv()._gateway_exec(prof, ["model", model])
1157
+ results["steps"].append({"step": "set_model", "ok": code == 0, "model": model, "output": out.strip()})
1158
+ except Exception as exc:
1159
+ results["steps"].append({"step": "set_model", "ok": False, "error": str(exc)})
1160
+ try:
1161
+ state = _srv().profile_gateway_lifecycle(prof, "start")
1162
+ results["steps"].append({"step": "gateway_start", "ok": True, "state": state})
1163
+ except Exception as exc:
1164
+ results["steps"].append({"step": "gateway_start", "ok": False, "error": str(exc)})
1165
+
1166
+ registry_patch: dict[str, Any] = {}
1167
+ if display_name.strip():
1168
+ registry_patch["display_name"] = display_name.strip()
1169
+ if role.strip():
1170
+ registry_patch["role"] = role.strip()
1171
+ elif description.strip():
1172
+ registry_patch["role"] = description.strip()
1173
+ if tagline.strip():
1174
+ registry_patch["tagline"] = tagline.strip()
1175
+ if description.strip():
1176
+ registry_patch["description"] = description.strip()
1177
+ if registry_patch:
1178
+ _srv()._upsert_agent_registry_row(prof, registry_patch)
1179
+ results["registry"] = registry_patch
1180
+
1181
+ try:
1182
+ avatar = _srv()._assign_agent_avatar(
1183
+ prof,
1184
+ display_name=display_name.strip() or child_label,
1185
+ )
1186
+ results["avatar"] = avatar
1187
+ results["steps"].append({"step": "assign_avatar", "ok": True, **avatar})
1188
+ except Exception as exc:
1189
+ results["steps"].append({"step": "assign_avatar", "ok": False, "error": str(exc)})
1190
+
1191
+ explicit_avatar = _srv()._normalize_agent_avatar_patch(avatar_url, avatar_id)
1192
+ if explicit_avatar.get("avatar_url") or explicit_avatar.get("avatar_id"):
1193
+ _srv()._upsert_agent_registry_row(
1194
+ prof,
1195
+ {
1196
+ **explicit_avatar,
1197
+ "updated_at": datetime.now(timezone.utc).isoformat(),
1198
+ },
1199
+ )
1200
+ results["avatar"] = explicit_avatar
1201
+
1202
+ soul_text = str(soul or "").strip()
1203
+ if soul_text:
1204
+ _apply_profile_identity(prof, user_soul=soul_text)
1205
+ results["steps"].append({"step": "seed_user_soul", "ok": True})
1206
+
1207
+ _register_profile_route(
1208
+ prof,
1209
+ {
1210
+ "display_name": display_name.strip() or prof,
1211
+ "role": role.strip() or description.strip() or description,
1212
+ },
1213
+ )
1214
+ results["steps"].append({"step": "register_route", "ok": True})
1215
+
1216
+ _srv()._ensure_workspace_agent_profile_row(
1217
+ prof,
1218
+ workspace_id=workspace_id,
1219
+ display_name=display_name.strip() or prof,
1220
+ role=role.strip() or description.strip(),
1221
+ tagline=tagline.strip(),
1222
+ )
1223
+
1224
+ if bootstrap_dm and user_id and workspace_id:
1225
+ try:
1226
+ lane = _srv().bootstrap_agent_dm_lane(
1227
+ user_id,
1228
+ workspace_id,
1229
+ prof,
1230
+ model=model,
1231
+ bind_session=True,
1232
+ room_name=display_name.strip() or prof,
1233
+ created_by=user_id,
1234
+ )
1235
+ results["bootstrap"] = lane
1236
+ results["room_id"] = lane.get("room_id")
1237
+ results["runtime_profile"] = lane.get("runtime")
1238
+ results["session_id"] = lane.get("session_id")
1239
+ if lane.get("room"):
1240
+ results["room"] = lane["room"]
1241
+ results["steps"].extend(lane.get("steps") or [])
1242
+ except Exception as exc:
1243
+ results["steps"].append({"step": "bootstrap_dm_lane", "ok": False, "error": str(exc)})
1244
+
1245
+ results["ok"] = True
1246
+ return results
1247
+
1248
+
1249
+ def profile_delete(profile: str) -> dict[str, Any]:
1250
+ """Delete a child profile: stop gateway, remove directory, clean registries."""
1251
+ prof = _srv().resolve_validated_profile(profile)
1252
+ if prof == _primary_profile():
1253
+ raise ValueError("cannot delete the native profile")
1254
+
1255
+ results: dict[str, Any] = {"profile": prof, "steps": []}
1256
+
1257
+ # 1. Stop the gateway for this profile
1258
+ try:
1259
+ code, out = _srv()._gateway_exec(prof, ["gateway", "stop"])
1260
+ results["steps"].append({"step": "gateway_stop", "ok": code == 0, "output": out.strip()})
1261
+ except Exception as exc:
1262
+ results["steps"].append({"step": "gateway_stop", "ok": False, "error": str(exc)})
1263
+
1264
+ # 2. Kill any remaining process for this profile
1265
+ try:
1266
+ cmd = ["pkill", "-f", f"hermes.*-p {prof}.*gateway"]
1267
+ code, out = _srv()._docker_exec(_srv().GATEWAY_CONTAINER_NAME, cmd)
1268
+ results["steps"].append({"step": "kill_process", "ok": True, "output": out.strip()})
1269
+ except Exception:
1270
+ results["steps"].append({"step": "kill_process", "ok": True, "output": "no process found"})
1271
+
1272
+ # 3. Remove the profile directory (bypasses Hermes security guard)
1273
+ profile_dir = _srv()._profile_dir(prof)
1274
+ try:
1275
+ if profile_dir.exists():
1276
+ shutil.rmtree(str(profile_dir))
1277
+ results["steps"].append({"step": "remove_directory", "ok": True, "path": str(profile_dir)})
1278
+ else:
1279
+ results["steps"].append({"step": "remove_directory", "ok": True, "output": "directory not found"})
1280
+ except Exception as exc:
1281
+ results["steps"].append({"step": "remove_directory", "ok": False, "error": str(exc)})
1282
+
1283
+ # 4. Remove from agents.json
1284
+ agents_json = _srv().HERMES_DATA / "workframe" / "agents.json"
1285
+ try:
1286
+ if agents_json.exists():
1287
+ data = json.loads(agents_json.read_text(encoding="utf-8"))
1288
+ if prof in data.get("agents", {}):
1289
+ del data["agents"][prof]
1290
+ agents_json.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
1291
+ results["steps"].append({"step": "clean_agents_json", "ok": True})
1292
+ else:
1293
+ results["steps"].append({"step": "clean_agents_json", "ok": True, "output": "not in agents.json"})
1294
+ except Exception as exc:
1295
+ results["steps"].append({"step": "clean_agents_json", "ok": False, "error": str(exc)})
1296
+
1297
+ # 5. Remove from avatar-registry.json
1298
+ avatar_json = _srv().HERMES_DATA / "workframe" / "avatar-registry.json"
1299
+ try:
1300
+ if avatar_json.exists():
1301
+ data = json.loads(avatar_json.read_text(encoding="utf-8"))
1302
+ changed = False
1303
+ if prof in data.get("assignments", {}):
1304
+ del data["assignments"][prof]
1305
+ changed = True
1306
+ if changed:
1307
+ avatar_json.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
1308
+ results["steps"].append({"step": "clean_avatar_registry", "ok": True})
1309
+ except Exception as exc:
1310
+ results["steps"].append({"step": "clean_avatar_registry", "ok": False, "error": str(exc)})
1311
+
1312
+ # 6. Remove from routes.json
1313
+ routes_json = _srv().HERMES_DATA / "workframe" / "routes.json"
1314
+ try:
1315
+ if routes_json.exists():
1316
+ data = json.loads(routes_json.read_text(encoding="utf-8"))
1317
+ original_len = len(data.get("routes", []))
1318
+ data["routes"] = [r for r in data.get("routes", []) if r.get("profile") != prof]
1319
+ if len(data["routes"]) < original_len:
1320
+ routes_json.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
1321
+ results["steps"].append({"step": "clean_routes", "ok": True})
1322
+ else:
1323
+ results["steps"].append({"step": "clean_routes", "ok": True, "output": "not in routes"})
1324
+ except Exception as exc:
1325
+ results["steps"].append({"step": "clean_routes", "ok": False, "error": str(exc)})
1326
+
1327
+ results["ok"] = all(s.get("ok", False) for s in results["steps"])
1328
+ return results