create-workframe 0.1.14 → 0.1.16

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 (136) hide show
  1. package/README.md +1 -1
  2. package/bin/create-workframe.js +4 -0
  3. package/package.json +1 -1
  4. package/scripts/sync-canonical-to-package.mjs +3 -0
  5. package/workframe-api/activity_feed.py +53 -5
  6. package/workframe-api/api_errors.py +126 -0
  7. package/workframe-api/chat_bind.py +8 -1
  8. package/workframe-api/chat_stream.py +70 -26
  9. package/workframe-api/crew_registry.py +3 -6
  10. package/workframe-api/google_auth.py +20 -12
  11. package/workframe-api/handler_modules/handler_auth.py +66 -21
  12. package/workframe-api/handler_modules/handler_chat.py +25 -0
  13. package/workframe-api/handler_modules/handler_install.py +45 -0
  14. package/workframe-api/handler_modules/handler_provider.py +1 -0
  15. package/workframe-api/handler_modules/handler_workspace.py +30 -16
  16. package/workframe-api/hermes_admin.py +9 -2
  17. package/workframe-api/hermes_profiles.py +25 -5
  18. package/workframe-api/install_api.py +38 -4
  19. package/workframe-api/lane_bindings.py +24 -0
  20. package/workframe-api/llm_error_glossary.py +58 -4
  21. package/workframe-api/mention_invoke.py +89 -27
  22. package/workframe-api/model_surface.py +1 -1
  23. package/workframe-api/provider_bindings.py +44 -2
  24. package/workframe-api/provider_bootstrap.py +9 -0
  25. package/workframe-api/provider_catalog.py +3 -3
  26. package/workframe-api/rooms.py +3 -0
  27. package/workframe-api/route_registry.py +1 -0
  28. package/workframe-api/run_ledger.py +52 -8
  29. package/workframe-api/runtime_cohort.py +11 -1
  30. package/workframe-api/server.py +25 -1
  31. package/workframe-api/stack_config.py +4 -1
  32. package/workframe-api/test_api_errors.py +33 -0
  33. package/workframe-api/test_enrich_room_messages.py +72 -0
  34. package/workframe-api/test_exception_hygiene.py +4 -2
  35. package/workframe-api/test_hermes_admin.py +7 -0
  36. package/workframe-api/test_install_wizard_resume.py +39 -13
  37. package/workframe-api/test_model_surface_consistency.py +118 -100
  38. package/workframe-api/test_profile_model_yaml.py +4 -1
  39. package/workframe-api/test_provider_bindings.py +10 -0
  40. package/workframe-api/test_run_ledger.py +2 -1
  41. package/workframe-api/test_server_reexports.py +4 -0
  42. package/workframe-api/test_user_llm_prefs.py +99 -0
  43. package/workframe-api/time-overlay-split.py +58 -0
  44. package/workframe-api/time-ttft-quick.py +135 -0
  45. package/workframe-api/time-ttft.py +250 -0
  46. package/workframe-api/time-wf-stream.py +145 -0
  47. package/workframe-api/turn_overlay.py +96 -23
  48. package/workframe-api/updates.py +1 -2
  49. package/workframe-api/user_prefs.py +26 -5
  50. package/workframe-api/workspace_bootstrap.py +16 -10
  51. package/workframe-ui/public/assets/{arc-BT9iZUSd.js → arc-esSBm9C_.js} +1 -1
  52. package/workframe-ui/public/assets/architecture-7EHR7CIX-j-DiBdGu.js +1 -0
  53. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-CaEDd3np.js → architectureDiagram-3BPJPVTR-DVVz9RGn.js} +1 -1
  54. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-DHJhMrIS.js → blockDiagram-GPEHLZMM-B6i10GMX.js} +1 -1
  55. package/workframe-ui/public/assets/brave-B885JYnH.svg +11 -0
  56. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-Cdyz9hIV.js → c4Diagram-AAUBKEIU-BqwJ7JoH.js} +1 -1
  57. package/workframe-ui/public/assets/channel-CJ9pFXbx.js +1 -0
  58. package/workframe-ui/public/assets/{chunk-2J33WTMH-BbZx76LX.js → chunk-2J33WTMH-CyhcedSa.js} +1 -1
  59. package/workframe-ui/public/assets/{chunk-3OPIFGDE-DcSxjTIP.js → chunk-3OPIFGDE-1Rr6WuvI.js} +1 -1
  60. package/workframe-ui/public/assets/{chunk-4BX2VUAB-B7F-kTY1.js → chunk-4BX2VUAB-D2bRrARV.js} +1 -1
  61. package/workframe-ui/public/assets/{chunk-55IACEB6-BwwEiRTK.js → chunk-55IACEB6-CKXeZxSr.js} +1 -1
  62. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-DAi_NksX.js → chunk-5ZQYHXKU-DYQxxPZ4.js} +1 -1
  63. package/workframe-ui/public/assets/{chunk-727SXJPM-BIaF4XCN.js → chunk-727SXJPM-CGpIgqvD.js} +1 -1
  64. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-DjYSk1dG.js → chunk-AQP2D5EJ-B88NGQ7d.js} +1 -1
  65. package/workframe-ui/public/assets/{chunk-BSJP7CBP-_twnyfgV.js → chunk-BSJP7CBP-CCymiI0O.js} +1 -1
  66. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-CkNmdy5s.js → chunk-CSCIHK7Q-DRgey03j.js} +1 -1
  67. package/workframe-ui/public/assets/{chunk-FMBD7UC4-CjLqd-gl.js → chunk-FMBD7UC4-l1adn8gM.js} +1 -1
  68. package/workframe-ui/public/assets/{chunk-KSCS5N6A-_6oNqFjj.js → chunk-KSCS5N6A-CaEvDRPK.js} +1 -1
  69. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-CeO9Am_D.js → chunk-L5ZTLDWV-BgCnvPTv.js} +1 -1
  70. package/workframe-ui/public/assets/{chunk-LZXEDZCA-sRM4YeIe.js → chunk-LZXEDZCA-t2zokHpn.js} +2 -2
  71. package/workframe-ui/public/assets/{chunk-ND2GUHAM-CH8tMb21.js → chunk-ND2GUHAM-C3c9p0sv.js} +1 -1
  72. package/workframe-ui/public/assets/{chunk-NZK2D7GU-DAI1bsII.js → chunk-NZK2D7GU-DSujF-R8.js} +1 -1
  73. package/workframe-ui/public/assets/{chunk-O5CBEL6O-B2e-5a2G.js → chunk-O5CBEL6O-DJJTbM6p.js} +1 -1
  74. package/workframe-ui/public/assets/chunk-QZHKN3VN-Bxk3enxo.js +1 -0
  75. package/workframe-ui/public/assets/chunk-WU5MYG2G-DdbkX1rg.js +1 -0
  76. package/workframe-ui/public/assets/{chunk-XPW4576I-DM2-0q37.js → chunk-XPW4576I-D2Cxn3AG.js} +1 -1
  77. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-bwCRiiUl.js +1 -0
  78. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-bwCRiiUl.js +1 -0
  79. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-BnbbewLv.js → cose-bilkent-S5V4N54A---dQ-pFK.js} +1 -1
  80. package/workframe-ui/public/assets/{dagre-BM42HDAG-C4zmB1Vt.js → dagre-BM42HDAG-B_qe1ODY.js} +1 -1
  81. package/workframe-ui/public/assets/{diagram-2AECGRRQ-n-BJmsiC.js → diagram-2AECGRRQ-Bou8vEYl.js} +1 -1
  82. package/workframe-ui/public/assets/{diagram-5GNKFQAL-BzQGQnZi.js → diagram-5GNKFQAL-DEUEzONU.js} +1 -1
  83. package/workframe-ui/public/assets/{diagram-KO2AKTUF-Cx_De-wS.js → diagram-KO2AKTUF-Ctq571VA.js} +1 -1
  84. package/workframe-ui/public/assets/{diagram-LMA3HP47-CIOI3Lok.js → diagram-LMA3HP47-CnKn89lm.js} +1 -1
  85. package/workframe-ui/public/assets/{diagram-OG6HWLK6-DkRuPLWY.js → diagram-OG6HWLK6-DnKlrMx2.js} +1 -1
  86. package/workframe-ui/public/assets/{dist-XhCT1Q-V.js → dist-ubrEgQyZ.js} +1 -1
  87. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-C3GwhDU1.js → erDiagram-TEJ5UH35-PjqO7CJd.js} +1 -1
  88. package/workframe-ui/public/assets/eventmodeling-FCH6USID-g-Xo_eS4.js +1 -0
  89. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-CLWH8z2e.js → flowDiagram-I6XJVG4X-B2PN_nNG.js} +1 -1
  90. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-D4h66Vy3.js → ganttDiagram-6RSMTGT7-ThyAtNQP.js} +1 -1
  91. package/workframe-ui/public/assets/gemini-Cq-0-rmr.svg +105 -0
  92. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-IW5F-Q6c.js → gitGraph-WXDBUCRP-CB2UgoWy.js} +1 -1
  93. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-BjNIbmRM.js → gitGraphDiagram-PVQCEYII-DRJJ-zT-.js} +1 -1
  94. package/workframe-ui/public/assets/index-1euVfd3b.js +132 -0
  95. package/workframe-ui/public/assets/index-BLVAsAeO.css +1 -0
  96. package/workframe-ui/public/assets/{info-J43DQDTF-CcJspM79.js → info-J43DQDTF-CLtR33Q_.js} +1 -1
  97. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-DP4xR9jt.js → infoDiagram-5YYISTIA-Cmunqfvi.js} +1 -1
  98. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-D4cZb21V.js → ishikawaDiagram-YF4QCWOH-DWE_84eL.js} +1 -1
  99. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-DsLuHkIJ.js → journeyDiagram-JHISSGLW-BtBhpyRL.js} +1 -1
  100. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-Wu65nOsR.js → kanban-definition-UN3LZRKU-B8QspDDL.js} +1 -1
  101. package/workframe-ui/public/assets/{line-ByBDzRpy.js → line-iOgDn4O8.js} +1 -1
  102. package/workframe-ui/public/assets/{linear-DzXi2vnq.js → linear-BBehZNVA.js} +1 -1
  103. package/workframe-ui/public/assets/{mermaid-parser.core-CaB-1Ckn.js → mermaid-parser.core-DQK5HcdU.js} +2 -2
  104. package/workframe-ui/public/assets/{mermaid.core-D7HTHC17.js → mermaid.core-Dsrdd1Wr.js} +3 -3
  105. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-BfKsC9aj.js → mindmap-definition-RKZ34NQL-Ds_BfsJA.js} +1 -1
  106. package/workframe-ui/public/assets/{packet-YPE3B663-ClpU98TO.js → packet-YPE3B663-Bz3pi4i8.js} +1 -1
  107. package/workframe-ui/public/assets/{pie-LRSECV5Y-KeOyY_-u.js → pie-LRSECV5Y-BFFah-6g.js} +1 -1
  108. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CUpjnzbs.js → pieDiagram-4H26LBE5-CVCvLPcC.js} +1 -1
  109. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-CJdPTszF.js → quadrantDiagram-W4KKPZXB-Cx6rrc_7.js} +1 -1
  110. package/workframe-ui/public/assets/{radar-GUYGQ44K-C9BpandZ.js → radar-GUYGQ44K-DJ5vN_cg.js} +1 -1
  111. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-CHDZPwry.js → requirementDiagram-4Y6WPE33-Cbry6b5c.js} +1 -1
  112. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-CMJJh91s.js → sankeyDiagram-5OEKKPKP-CICUr5En.js} +1 -1
  113. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BsV4GppP.js → sequenceDiagram-3UESZ5HK-BsOuB3F-.js} +1 -1
  114. package/workframe-ui/public/assets/{src-WHks82Up.js → src-TS8jZspq.js} +1 -1
  115. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-eSX9P8z0.js → stateDiagram-AJRCARHV-avBzMQy_.js} +1 -1
  116. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-BFRA5TYD.js +1 -0
  117. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-Dg1UkhGo.js → timeline-definition-PNZ67QCA-DHLm6UFN.js} +1 -1
  118. package/workframe-ui/public/assets/{treeView-BLDUP644-B7yGsBuj.js → treeView-BLDUP644-B3Qd5t3j.js} +1 -1
  119. package/workframe-ui/public/assets/{treemap-LRROVOQU-j_G2oyQ_.js → treemap-LRROVOQU-D1dMQS7x.js} +1 -1
  120. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-BRrT0jqC.js → vennDiagram-CIIHVFJN-DZ6SoniY.js} +1 -1
  121. package/workframe-ui/public/assets/{wardley-L42UT6IY-DEdUqez2.js → wardley-L42UT6IY-9gK17Tp3.js} +1 -1
  122. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-C-bBK9Bb.js → wardleyDiagram-YWT4CUSO-FCU_wmRE.js} +1 -1
  123. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DhH5xuR2.js → xychartDiagram-2RQKCTM6-DWowvseN.js} +1 -1
  124. package/workframe-ui/public/index.html +2 -2
  125. package/workframe-ui/public/workframe-build.json +3 -3
  126. package/docs/security.md +0 -85
  127. package/workframe-ui/public/assets/architecture-7EHR7CIX-m1aPUQ_r.js +0 -1
  128. package/workframe-ui/public/assets/channel-3M69ekE5.js +0 -1
  129. package/workframe-ui/public/assets/chunk-QZHKN3VN-BMl9ed8P.js +0 -1
  130. package/workframe-ui/public/assets/chunk-WU5MYG2G-C08HH6BU.js +0 -1
  131. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BFcOE4Aq.js +0 -1
  132. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BFcOE4Aq.js +0 -1
  133. package/workframe-ui/public/assets/eventmodeling-FCH6USID-DIVBL6CF.js +0 -1
  134. package/workframe-ui/public/assets/index-DL2vJP4L.css +0 -1
  135. package/workframe-ui/public/assets/index-ElS_D59P.js +0 -130
  136. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-FzMK4PkM.js +0 -1
@@ -32,7 +32,7 @@ PLAYBOOK: dict[str, dict[str, Any]] = {
32
32
  },
33
33
  "provider_invalid_key": {
34
34
  "message": "Your API key was rejected by the provider.",
35
- "hint": "Reset the key at your provider, then paste it again under Connected accounts.",
35
+ "hint": "The key may be expired or revoked. Paste a new one under Settings → Integrations.",
36
36
  "action": _ACTIONS["open_providers"],
37
37
  "action_label": "Update API key",
38
38
  "secondary_action": _ACTIONS["openrouter_keys"],
@@ -95,6 +95,38 @@ PLAYBOOK: dict[str, dict[str, Any]] = {
95
95
  }
96
96
 
97
97
 
98
+ _PROVIDER_LABELS: dict[str, str] = {
99
+ "openrouter": "OpenRouter",
100
+ "openai": "OpenAI",
101
+ "anthropic": "Anthropic",
102
+ "google": "Google",
103
+ "deepseek": "DeepSeek",
104
+ }
105
+
106
+
107
+ def _provider_label(provider: str) -> str:
108
+ key = str(provider or "openrouter").strip().lower()
109
+ return _PROVIDER_LABELS.get(key, key.title() or "Provider")
110
+
111
+
112
+ def tailor_provider_entry(entry: dict[str, Any], provider: str) -> dict[str, Any]:
113
+ """Agent-facing copy when a provider rejects credentials."""
114
+ if str(entry.get("code") or "") != "provider_invalid_key":
115
+ return entry
116
+ label = _provider_label(provider)
117
+ out = dict(entry)
118
+ out["message"] = (
119
+ f"I couldn't authenticate with {label} — your API key may be expired or revoked."
120
+ )
121
+ out["hint"] = (
122
+ f"Open Settings → Integrations to paste a new {label} key, then send your message again."
123
+ )
124
+ if str(provider or "").strip().lower() == "openrouter":
125
+ out["secondary_action"] = _ACTIONS["openrouter_keys"]
126
+ out["secondary_action_label"] = "Manage OpenRouter keys"
127
+ return out
128
+
129
+
98
130
  def playbook_entry(code: str, **overrides: Any) -> dict[str, Any]:
99
131
  """Return a copy of a playbook row with optional string format overrides."""
100
132
  base = dict(PLAYBOOK.get(code) or PLAYBOOK["unknown_provider_error"])
@@ -156,8 +188,17 @@ def classify_upstream(
156
188
  low = msg.lower()
157
189
  model_id = model or _model_from_text(msg)
158
190
 
159
- if status == 401 or "invalid api key" in low or "incorrect api key" in low or "unauthorized" in low:
160
- return playbook_entry("provider_invalid_key", provider=provider)
191
+ if (
192
+ status == 401
193
+ or "invalid api key" in low
194
+ or "incorrect api key" in low
195
+ or "unauthorized" in low
196
+ or "user not found" in low
197
+ ):
198
+ return tailor_provider_entry(
199
+ playbook_entry("provider_invalid_key", provider=provider),
200
+ provider,
201
+ )
161
202
  if status == 402 or "insufficient" in low and "credit" in low or "payment required" in low:
162
203
  return playbook_entry("provider_no_credits", provider=provider)
163
204
  if status == 429 or "rate limit" in low:
@@ -197,11 +238,18 @@ def classify_exception_text(text: str) -> dict[str, Any]:
197
238
  return classify_upstream(429, raw)
198
239
  if "credit" in low:
199
240
  return classify_upstream(402, raw)
200
- if "api key" in low or "unauthorized" in low:
241
+ if "api key" in low or "unauthorized" in low or "user not found" in low:
201
242
  return classify_upstream(401, raw)
202
243
  return playbook_entry("unknown_provider_error", detail=raw[:200])
203
244
 
204
245
 
246
+ def classify_stream_error(text: str, *, provider: str = "openrouter") -> dict[str, Any]:
247
+ """Classify upstream/Hermes failure text into UI notice payload."""
248
+ entry = classify_exception_text(str(text or "").strip())
249
+ entry = tailor_provider_entry(entry, provider)
250
+ return notice_payload(entry)
251
+
252
+
205
253
  def notice_payload(entry: dict[str, Any]) -> dict[str, Any]:
206
254
  """SSE/JSON-safe dict for UI."""
207
255
  out: dict[str, Any] = {
@@ -233,4 +281,10 @@ if __name__ == "__main__":
233
281
  assert e2["code"] == "no_llm_provider"
234
282
  e3 = classify_upstream(401, b'{"error":"Invalid API key"}')
235
283
  assert e3["code"] == "provider_invalid_key"
284
+ e4 = classify_exception_text("HTTP 401: User not found.")
285
+ assert e4["code"] == "provider_invalid_key"
286
+ assert "OpenRouter" in e4["message"] or "authenticate" in e4["message"]
287
+ p = classify_stream_error("HTTP 401: User not found.", provider="openrouter")
288
+ assert p["code"] == "provider_invalid_key"
289
+ assert p.get("action", {}).get("type") == "open_settings"
236
290
  print("llm_error_glossary ok")
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import sqlite3
7
7
  import time
8
+ import urllib.error
8
9
  import urllib.parse
9
10
  import urllib.request
10
11
  import uuid
@@ -70,14 +71,22 @@ def _invoke_room_agent_mention(
70
71
  template_slug = str(agent_row.get("slug") or "").strip()
71
72
  agent_db_id = str(agent_row.get("id") or "").strip()
72
73
  agent_name = str(agent_row.get("display_name") or template_slug).strip() or template_slug
73
- if triggered_by_user_id:
74
- personal = _srv()._runtime_display_label(triggered_by_user_id, template_slug, workspace_id)
75
- if personal:
76
- agent_name = personal
77
74
  if not template_slug or not agent_db_id:
78
75
  return
79
- hermes_slug = _srv()._runtime_profile_slug(triggered_by_user_id, template_slug)
80
- _srv().ensure_runtime_profile(hermes_slug, template_slug, triggered_by_user_id, workspace_id)
76
+ # Shared rooms use the native/template Hermes profile and one shared
77
+ # session. The triggering user's vault credential is overlaid per turn
78
+ # below; do not switch the room onto that user's private runtime profile.
79
+ hermes_slug = template_slug
80
+ display_model = str(agent_row.get("model_name") or "").strip()
81
+ display_provider = str(agent_row.get("model_provider") or "").strip()
82
+ if not display_model:
83
+ display_model = str(_srv()._read_model_block(hermes_slug).get("default") or "").strip()
84
+ if not display_provider:
85
+ display_provider = _srv()._llm_billing_provider(
86
+ hermes_slug,
87
+ user_id=triggered_by_user_id,
88
+ workspace_id=workspace_id,
89
+ )
81
90
 
82
91
  turn_id = str(uuid.uuid4())
83
92
  segments: list[dict[str, Any]] = []
@@ -200,6 +209,8 @@ def _invoke_room_agent_mention(
200
209
  "agent_slug": hermes_slug,
201
210
  "agent_name": agent_name,
202
211
  "agent_profile_id": agent_db_id,
212
+ "model": display_model,
213
+ "llm_provider": display_provider,
203
214
  "triggered_by_user_id": triggered_by_user_id,
204
215
  },
205
216
  )
@@ -276,7 +287,7 @@ def _invoke_room_agent_mention(
276
287
  if not auth_decision.allowed:
277
288
  fail_turn(
278
289
  "I can't reply yet — connect an LLM provider in Profile → Connect accounts.",
279
- persist=False,
290
+ persist=True,
280
291
  )
281
292
  return
282
293
 
@@ -299,28 +310,79 @@ def _invoke_room_agent_mention(
299
310
  "Content-Type": "application/json",
300
311
  }
301
312
  req = urllib.request.Request(url, data=upstream_body, headers=headers, method="POST")
302
- upstream = urllib.request.urlopen(req, timeout=3600)
303
-
304
313
  final_text = ""
314
+ upstream = None
305
315
  try:
306
- publish_update(force=True, status="thinking")
307
- for event_name, data in chat_stream._iter_profile_stream_frames(upstream):
308
- segments[:] = chat_stream._live_reduce_stream_event(segments, event_name, data)
309
- if event_name in ("message.delta", "assistant.delta"):
310
- final_text += chat_stream._live_stream_text(data)
311
- publish_update(status="writing")
312
- elif event_name in ("thinking.delta", "reasoning.delta"):
313
- publish_update(status="thinking")
314
- elif event_name == "tool.started":
315
- publish_update(force=True, status=f"tool:{data.get('tool_name') or 'tool'}")
316
- elif event_name in ("tool.completed", "tool.failed", "tool.progress"):
317
- publish_update(force=True)
318
- elif event_name in ("message.complete", "assistant.completed"):
319
- final_text = str(data.get("content") or data.get("text") or final_text)
320
- publish_update(force=True, status="writing")
321
- publish_update(force=True)
322
- finally:
323
- upstream.close()
316
+ upstream = urllib.request.urlopen(req, timeout=3600)
317
+ except urllib.error.HTTPError as exc:
318
+ # Older Hermes runtimes expose the session chat endpoint but not
319
+ # the streaming variant. Room mentions should still work there;
320
+ # use the same non-stream request as the DM path.
321
+ if exc.code != 404:
322
+ raise
323
+ status, data = _srv()._profile_api_request(
324
+ hermes_slug,
325
+ "POST",
326
+ f"/api/sessions/{urllib.parse.quote(session_id, safe='')}/chat",
327
+ turn_body,
328
+ )
329
+ error_obj = data.get("error") if isinstance(data, dict) else None
330
+ error_code = error_obj.get("code") if isinstance(error_obj, dict) else ""
331
+ if status == 404 and error_code == "session_not_found":
332
+ # Hermes session state can be reset independently of the
333
+ # Workframe DB. Rebind this room once, then retry the turn.
334
+ session = lane_bindings.profile_chat_session(
335
+ hermes_slug,
336
+ {
337
+ "room_id": room_id,
338
+ "source_id": "room",
339
+ "client_id": room_id,
340
+ "workspace_id": workspace_id,
341
+ "new_session": True,
342
+ },
343
+ triggered_by_user_id,
344
+ )
345
+ session_id = str(session.get("session_id") or "").strip()
346
+ if not session_id:
347
+ raise ValueError("session_bootstrap_failed: could not recreate agent session for this room")
348
+ turn_body = profile_gateway._profile_turn_payload(hermes_slug, user_text, room_id)
349
+ _inject_turn_credentials(turn_body, triggered_by_user_id, workspace_id, provider)
350
+ status, data = _srv()._profile_api_request(
351
+ hermes_slug,
352
+ "POST",
353
+ f"/api/sessions/{urllib.parse.quote(session_id, safe='')}/chat",
354
+ turn_body,
355
+ )
356
+ if status >= 300:
357
+ raise ValueError(f"session chat failed: {data}")
358
+ if isinstance(data, dict):
359
+ message = data.get("message")
360
+ if isinstance(message, dict):
361
+ final_text = str(message.get("content") or message.get("text") or "")
362
+ if not final_text:
363
+ final_text = str(data.get("content") or data.get("text") or "")
364
+ publish_update(force=True, status="writing")
365
+
366
+ if upstream is not None:
367
+ try:
368
+ publish_update(force=True, status="thinking")
369
+ for event_name, data in chat_stream._iter_profile_stream_frames(upstream):
370
+ segments[:] = chat_stream._live_reduce_stream_event(segments, event_name, data)
371
+ if event_name in ("message.delta", "assistant.delta"):
372
+ final_text += chat_stream._live_stream_text(data)
373
+ publish_update(status="writing")
374
+ elif event_name in ("thinking.delta", "reasoning.delta"):
375
+ publish_update(status="thinking")
376
+ elif event_name == "tool.started":
377
+ publish_update(force=True, status=f"tool:{data.get('tool_name') or 'tool'}")
378
+ elif event_name in ("tool.completed", "tool.failed", "tool.progress"):
379
+ publish_update(force=True)
380
+ elif event_name in ("message.complete", "assistant.completed"):
381
+ final_text = str(data.get("content") or data.get("text") or final_text)
382
+ publish_update(force=True, status="writing")
383
+ publish_update(force=True)
384
+ finally:
385
+ upstream.close()
324
386
 
325
387
  assistant = final_text.strip() or chat_stream._segments_to_reply_text(segments)
326
388
  if not assistant:
@@ -493,7 +493,7 @@ def _apply_model_for_billing_provider(
493
493
  ok_provider, _ = _set_profile_model_provider(profile, "custom")
494
494
  if not ok_provider:
495
495
  return False
496
- _set_profile_model_base_url(profile, _srv()._llm_proxy_base_url(billing))
496
+ _srv()._set_profile_model_base_url(profile, _srv()._llm_proxy_base_url(billing))
497
497
  return True
498
498
 
499
499
 
@@ -434,6 +434,33 @@ def _parse_device_oauth_log(text: str) -> dict[str, str | None]:
434
434
  return {"verification_uri": verification_uri, "user_code": user_code}
435
435
 
436
436
 
437
+ def _device_oauth_error_from_log(log_text: str) -> str | None:
438
+ clean = _strip_ansi(log_text).strip()
439
+ if not clean:
440
+ return None
441
+ lowered = clean.lower()
442
+ auth_err = re.search(r"AuthError:\s*(.+)", clean)
443
+ if auth_err:
444
+ return auth_err.group(1).strip()[:500]
445
+ if "rate-limit" in lowered or "rate limiting" in lowered or "http 429" in lowered or re.search(r"\b429\b", lowered):
446
+ return (
447
+ "OpenAI is rate-limiting Codex login requests (HTTP 429). "
448
+ "Wait a minute and try again."
449
+ )
450
+ if any(token in lowered for token in ("login timed out", "login cancelled", "oauth_start_failed")):
451
+ lines = [line.strip() for line in clean.splitlines() if line.strip()]
452
+ return (lines[-1] if lines else "OAuth failed")[:500]
453
+ if "traceback" in lowered and any(token in lowered for token in ("error", "exception", "failed")):
454
+ for line in reversed(clean.splitlines()):
455
+ stripped = line.strip()
456
+ if not stripped or stripped.startswith("File ") or "Traceback" in stripped:
457
+ continue
458
+ if stripped.endswith(":") and "Error" not in stripped:
459
+ continue
460
+ return stripped[:500]
461
+ return None
462
+
463
+
437
464
  def _sync_user_oauth_provider_to_runtime_profiles(user_id: str, hermes_auth_id: str) -> None:
438
465
  if not _hermes_oauth_tokens_present(user_id, hermes_auth_id):
439
466
  return
@@ -527,11 +554,13 @@ def _start_device_oauth(user_id: str, provider_id: str, spec: dict[str, Any]) ->
527
554
  except (RuntimeError, ValueError) as exc:
528
555
  return {"ok": False, "provider": provider_id, "error": str(exc)}
529
556
  if rc != 0:
557
+ output = (out or "").strip()
530
558
  return {
531
559
  "ok": False,
532
560
  "provider": provider_id,
533
561
  "error": "oauth_start_failed",
534
- "output": (out or "").strip(),
562
+ "output": output,
563
+ "message": _device_oauth_error_from_log(output) or output[-500:] or "Could not start OAuth",
535
564
  }
536
565
  with _oauth_device_lock:
537
566
  _oauth_device_sessions[session_id] = {
@@ -583,6 +612,19 @@ def device_oauth_status(user_id: str, provider_id: str, session_id: str) -> dict
583
612
  if patch:
584
613
  _device_oauth_session_patch(session_id, patch)
585
614
  sess.update(patch)
615
+ log_error = _device_oauth_error_from_log(log_text)
616
+ if log_error:
617
+ _device_oauth_session_patch(session_id, {"status": "error"})
618
+ return {
619
+ "ok": False,
620
+ "provider": provider_id,
621
+ "session_id": session_id,
622
+ "status": "error",
623
+ "error": "oauth_failed",
624
+ "message": log_error,
625
+ "verification_uri": sess.get("verification_uri"),
626
+ "user_code": sess.get("user_code"),
627
+ }
586
628
  if _hermes_oauth_tokens_present(user_id, hermes_auth_id):
587
629
  if not sess.get("finalized"):
588
630
  _finalize_hermes_device_oauth(user_id, provider_id, spec)
@@ -617,7 +659,7 @@ def device_oauth_status(user_id: str, provider_id: str, session_id: str) -> dict
617
659
  "session_id": session_id,
618
660
  "status": "error",
619
661
  "error": "oauth_failed",
620
- "message": _strip_ansi(log_text).strip()[-500:] or "OAuth failed",
662
+ "message": _device_oauth_error_from_log(log_text) or _strip_ansi(log_text).strip()[-500:] or "OAuth failed",
621
663
  "verification_uri": sess.get("verification_uri"),
622
664
  "user_code": sess.get("user_code"),
623
665
  }
@@ -130,6 +130,15 @@ def _user_llm_providers_for_picker(user_id: str) -> set[str]:
130
130
  return set(connected)
131
131
 
132
132
 
133
+ def invalidate_user_llm_picker_cache(user_id: str = "") -> None:
134
+ """Forget cached provider availability after a credential mutation."""
135
+ user = str(user_id or "").strip()
136
+ if user:
137
+ _srv()._user_llm_picker_cache.pop(user, None)
138
+ else:
139
+ _srv()._user_llm_picker_cache.clear()
140
+
141
+
133
142
  def _user_has_llm_provider(user_id: str) -> bool:
134
143
  return bool(_srv()._user_llm_providers_for_picker(user_id))
135
144
 
@@ -12,7 +12,7 @@ PROVIDER_CONNECT_CATALOG: tuple[dict[str, Any], ...] = (
12
12
  "category": "llm",
13
13
  "connect_mode": "api_key",
14
14
  "env_var": "OPENROUTER_API_KEY",
15
- "description": "Primary LLM router — any model via one API key.",
15
+ "description": "Primary LLM router any model via one API key.",
16
16
  },
17
17
  {
18
18
  "id": "anthropic",
@@ -104,7 +104,7 @@ PROVIDER_CONNECT_CATALOG: tuple[dict[str, Any], ...] = (
104
104
  "oauth_provider": "github",
105
105
  "hermes_auth_id": "github",
106
106
  "user_only": True,
107
- "description": "OAuth (admin registers app) or fine-grained PAT — repo push and GitHub API.",
107
+ "description": "OAuth (admin registers app) or fine-grained PAT repo push and GitHub API.",
108
108
  },
109
109
  {
110
110
  "id": "stripe",
@@ -114,7 +114,7 @@ PROVIDER_CONNECT_CATALOG: tuple[dict[str, Any], ...] = (
114
114
  "env_var": "STRIPE_SECRET_KEY",
115
115
  "oauth_provider": "stripe",
116
116
  "user_only": True,
117
- "description": "Connect your Stripe account — charges, customers, and billing tools.",
117
+ "description": "Connect your Stripe account charges, customers, and billing tools.",
118
118
  },
119
119
  {
120
120
  "id": "vercel",
@@ -1304,6 +1304,9 @@ def _workspace_credential_mode(conn: sqlite3.Connection | None, workspace_id: st
1304
1304
  settings = _srv()._parse_workspace_settings(row) if row else {}
1305
1305
  mode = str(settings.get("credential_mode") or "byok").strip().lower()
1306
1306
  return mode if mode in ("byok", "workspace") else "byok"
1307
+ except sqlite3.Error:
1308
+ # Before bootstrap creates the workspace schema, BYOK is the safe default.
1309
+ return "byok"
1307
1310
  finally:
1308
1311
  if own_conn:
1309
1312
  conn.close()
@@ -146,6 +146,7 @@ ROUTES: tuple[Route, ...] = (
146
146
  Route("POST", "/api/admin/vault/wipe", AuthLevel.ROLE_OWNER_ADMIN, "_route_post_admin_vault_wipe"),
147
147
  Route("POST", "/api/install/stack/branding-asset", AuthLevel.ROLE_OWNER_ADMIN, "_route_post_install_stack_branding_asset"),
148
148
  # --- install-window POST ---
149
+ Route("POST", "/api/install/register-admin", AuthLevel.PUBLIC, "_route_post_install_register_admin", install_window=True),
149
150
  Route("POST", "/api/install/email/test", AuthLevel.PUBLIC, "_route_post_install_email_test", install_window=True),
150
151
  Route("POST", "/api/install/url/test", AuthLevel.PUBLIC, "_route_post_install_url_test", install_window=True),
151
152
  Route("POST", "/api/install/setup-https", AuthLevel.PUBLIC, "_route_post_install_setup_https", install_window=True),
@@ -11,6 +11,7 @@ from datetime import datetime, timezone
11
11
  from pathlib import Path
12
12
  from typing import Any, Mapping
13
13
 
14
+ import llm_error_glossary
14
15
  from domain.entities import (
15
16
  ActorType,
16
17
  FundingSource,
@@ -423,18 +424,21 @@ def list_run_events_for_room(room_id: str, *, limit: int = 40) -> list[dict[str,
423
424
  payload = json.loads(str(row["payload_json"] or "{}"))
424
425
  except json.JSONDecodeError:
425
426
  payload = {}
427
+ event_type = str(row["event_type"])
428
+ if event_type == "run.authorized":
429
+ continue
426
430
  out.append(
427
431
  {
428
432
  "id": f"run_event:{row['event_id']}",
429
433
  "kind": "run_event",
430
- "event_type": str(row["event_type"]),
434
+ "event_type": event_type,
431
435
  "run_id": str(row["run_id"]),
432
436
  "run_status": str(row["run_status"]),
433
437
  "profile": str(row["profile_slug"] or ""),
434
438
  "payer_user_id": str(row["payer_user_id"] or ""),
435
439
  "funding_source": str(row["funding_source"] or ""),
436
- "task_description": _event_description(str(row["event_type"]), payload),
437
- "status": str(row["run_status"]),
440
+ "task_description": _event_description(event_type, payload),
441
+ "status": _activity_status_for_event(event_type, str(row["run_status"])),
438
442
  "created_at": str(row["created_at"]),
439
443
  "source": "run_ledger",
440
444
  "payload": payload,
@@ -443,16 +447,56 @@ def list_run_events_for_room(room_id: str, *, limit: int = 40) -> list[dict[str,
443
447
  return out
444
448
 
445
449
 
450
+ def _activity_status_for_event(event_type: str, run_status: str) -> str:
451
+ et = str(event_type or "").strip().lower()
452
+ if et == "run.completed":
453
+ return "completed"
454
+ if et in ("run.failed", "run.denied"):
455
+ return "failed"
456
+ rs = str(run_status or "").strip().lower()
457
+ if rs == "running":
458
+ return "active"
459
+ if rs == "completed":
460
+ return "completed"
461
+ if rs in ("failed", "denied", "cancelled"):
462
+ return "failed"
463
+ return "idle"
464
+
465
+
466
+ def _humanize_run_failure(reason: str) -> str:
467
+ raw = str(reason or "").strip()
468
+ if not raw:
469
+ return "Turn failed"
470
+ entry = llm_error_glossary.classify_exception_text(raw)
471
+ msg = str(entry.get("message") or "").strip()
472
+ if msg:
473
+ return msg
474
+ return raw[:160]
475
+
476
+
477
+ def _humanize_deny_reason(reason: str) -> str:
478
+ key = str(reason or "").strip().lower()
479
+ labels = {
480
+ "no_credential_byok": "connect an LLM provider in Settings",
481
+ "no_credential": "connect an LLM provider in Settings",
482
+ "policy": "policy blocked this turn",
483
+ }
484
+ return labels.get(key, key.replace("_", " ") or "policy")
485
+
486
+
446
487
  def _event_description(event_type: str, payload: Mapping[str, Any]) -> str:
447
488
  if event_type == "run.authorized":
448
- fs = str(payload.get("funding_source") or "")
449
- return f"Run authorized ({fs})" if fs else "Run authorized"
489
+ return ""
450
490
  if event_type == "run.denied":
451
- return f"Run denied: {payload.get('deny_reason') or 'policy'}"
491
+ return f"Turn blocked {_humanize_deny_reason(str(payload.get('deny_reason') or 'policy'))}"
452
492
  if event_type == "run.completed":
453
- return "Chat turn completed"
493
+ model = str(payload.get("model") or "").strip()
494
+ if model:
495
+ short = model.rsplit("/", 1)[-1]
496
+ return f"Reply delivered · {short}"
497
+ return "Reply delivered"
454
498
  if event_type == "run.failed":
455
- return f"Run failed: {payload.get('reason') or 'error'}"
499
+ return _humanize_run_failure(str(payload.get("reason") or payload.get("code") or ""))
456
500
  if event_type == "slash.command":
457
501
  cmd = str(payload.get("command") or "")
458
502
  return f"Slash /{cmd}" if cmd else "Slash command"
@@ -570,10 +570,12 @@ def _purge_runtime_profile(runtime: str) -> None:
570
570
 
571
571
 
572
572
  def _register_runtime_profile(runtime: str, template: str) -> None:
573
+ _srv()._ensure_profiles_dir_ready()
573
574
  cmd = ["profile", "create", "--clone-from", template, runtime]
574
575
  code, out = _srv()._gateway_exec(_srv()._primary_profile(), cmd)
575
576
  if code == 0:
576
577
  _inherit_runtime_profile_config(runtime, template)
578
+ _srv()._chown_profile_tree(_srv()._profile_dir(runtime))
577
579
  return
578
580
  out_l = out.lower()
579
581
  if "already exists" in out_l:
@@ -583,6 +585,7 @@ def _register_runtime_profile(runtime: str, template: str) -> None:
583
585
  code, out = _srv()._gateway_exec(_srv()._primary_profile(), cmd)
584
586
  if code == 0:
585
587
  _inherit_runtime_profile_config(runtime, template)
588
+ _srv()._chown_profile_tree(_srv()._profile_dir(runtime))
586
589
  return
587
590
  if code != 0:
588
591
  raise ValueError(f"runtime profile create failed: {out.strip()}")
@@ -709,6 +712,7 @@ def ensure_runtime_profile(
709
712
  user_id: str,
710
713
  workspace_id: str = "",
711
714
  ) -> None:
715
+ _srv()._ensure_profiles_dir_ready()
712
716
  runtime = _srv().safe_profile_slug(runtime_slug)
713
717
  template = _srv().resolve_validated_profile(template_slug)
714
718
  if _runtime_profile_on_disk(runtime):
@@ -726,10 +730,16 @@ def ensure_runtime_profile(
726
730
  if not _profile_toolsets_ready(runtime, _srv()._chat_toolsets_for_profile(runtime)):
727
731
  _srv()._ensure_profile_toolsets(runtime)
728
732
  _ensure_profile_terminal_cwd(runtime)
729
- # ponytail: do not re-sync template model on every bind — destroys per-user reconcile
733
+ # Existing profiles still need invariant repair. This deliberately does
734
+ # not copy template model preferences; reconciliation keeps the agent's
735
+ # model when the acting user can use it and falls back to that user's
736
+ # connected providers otherwise.
737
+ _prepare_runtime_profile_credentials(runtime, user_id, workspace_id)
738
+ _srv()._ensure_profile_proxy_headers(runtime)
730
739
  return
731
740
  _purge_runtime_profile(runtime)
732
741
  _register_runtime_profile(runtime, template)
742
+ _srv()._chown_profile_tree(_srv()._profile_dir(runtime))
733
743
  if _srv()._profile_config_path(runtime) is None:
734
744
  raise ValueError(f"runtime profile bootstrap failed: {runtime}")
735
745
  # SOUL: clone inherits template at profile create; do not auto-overwrite on disk later.
@@ -190,6 +190,7 @@ _register_runtime_profile = runtime_cohort._register_runtime_profile
190
190
  _inherit_runtime_profile_config = runtime_cohort._inherit_runtime_profile_config
191
191
  ensure_runtime_profile = runtime_cohort.ensure_runtime_profile
192
192
  _ensure_profile_terminal_cwd = runtime_cohort._ensure_profile_terminal_cwd
193
+ _profile_toolsets_ready = runtime_cohort._profile_toolsets_ready
193
194
 
194
195
  health_data = health_monitor.health_data
195
196
  build_snapshot = snapshot_feed.build_snapshot
@@ -256,6 +257,7 @@ _user_has_llm_provider = provider_bootstrap._user_has_llm_provider
256
257
  _user_llm_has_provider = provider_bootstrap._user_llm_has_provider
257
258
  _workspace_has_llm_provider = provider_bootstrap._workspace_has_llm_provider
258
259
  _user_can_use_llm = provider_bootstrap._user_can_use_llm
260
+ _invalidate_user_llm_picker_cache = provider_bootstrap.invalidate_user_llm_picker_cache
259
261
  _overlay_chat_llm_env = provider_bootstrap._overlay_chat_llm_env
260
262
  _reconcile_profile_llm_for_user = provider_bootstrap._reconcile_profile_llm_for_user
261
263
  _ensure_profile_auth_pool = provider_bootstrap._ensure_profile_auth_pool
@@ -332,6 +334,7 @@ _native_profile_slug = hermes_profiles._native_profile_slug
332
334
  _native_profile_present = hermes_profiles._native_profile_present
333
335
  _hermes_data_uid_gid = hermes_profiles._hermes_data_uid_gid
334
336
  _chown_profile_tree = hermes_profiles._chown_profile_tree
337
+ _ensure_profiles_dir_ready = hermes_profiles._ensure_profiles_dir_ready
335
338
  _seed_native_profile_on_disk = hermes_profiles._seed_native_profile_on_disk
336
339
  _ensure_native_hermes_profile = hermes_profiles._ensure_native_hermes_profile
337
340
  _primary_profile = hermes_profiles._primary_profile
@@ -613,9 +616,14 @@ import zk_auth as _zk
613
616
  _profile_health_cache: dict[str, tuple[bool, float]] = {}
614
617
  _PROFILE_HEALTH_TTL_SEC = 8.0
615
618
  _user_llm_picker_cache: dict[str, tuple[frozenset[str], float]] = {}
619
+ _USER_LLM_PICKER_TTL_SEC = 60.0
616
620
  _SESSION_INFO_TTL_SEC = 5.0
617
621
 
618
622
 
623
+ def _invalidate_profile_health_cache(profile: str) -> None:
624
+ _profile_health_cache.pop(str(profile or "").strip(), None)
625
+
626
+
619
627
  def _install_window_open() -> bool:
620
628
  try:
621
629
  return install_api.install_window_open(str(DATA_DIR / "workframe.db"))
@@ -1366,6 +1374,7 @@ _read_profile_lease_token = turn_overlay._read_profile_lease_token
1366
1374
  _lease_reusable_for_turn = turn_overlay._lease_reusable_for_turn
1367
1375
  _read_config_model_api_key = turn_overlay._read_config_model_api_key
1368
1376
  _sync_profile_model_api_key = turn_overlay._sync_profile_model_api_key
1377
+ _schedule_profile_lease_yaml_reconcile = turn_overlay._schedule_profile_lease_yaml_reconcile
1369
1378
  _apply_turn_credential_lease = turn_overlay._apply_turn_credential_lease
1370
1379
  _action_proxy_base_url = turn_overlay._action_proxy_base_url
1371
1380
  _action_proxy_env_var = turn_overlay._action_proxy_env_var
@@ -1388,6 +1397,7 @@ _parse_user_content_segments = chat_sessions._parse_user_content_segments
1388
1397
  _parse_tool_content = chat_sessions._parse_tool_content
1389
1398
  _message_row_segments = chat_sessions._message_row_segments
1390
1399
  _llm_attribution_for_profile = chat_sessions._llm_attribution_for_profile
1400
+ _profile_model = chat_sessions._profile_model
1391
1401
  _latest_session_id = chat_sessions._latest_session_id
1392
1402
  _session_info = chat_sessions._session_info
1393
1403
  _is_blank_session_title = chat_sessions._is_blank_session_title
@@ -1460,12 +1470,26 @@ def _enrich_room_messages(conn: sqlite3.Connection, messages: list[Any]) -> list
1460
1470
  agent_id = str(item.get("sender_agent_id") or "").strip()
1461
1471
  if agent_id:
1462
1472
  agent = conn.execute(
1463
- "SELECT slug, display_name FROM agent_profiles WHERE id = ? AND deleted_at IS NULL",
1473
+ """
1474
+ SELECT slug, display_name, model_name, model_provider
1475
+ FROM agent_profiles WHERE id = ? AND deleted_at IS NULL
1476
+ """,
1464
1477
  (agent_id,),
1465
1478
  ).fetchone()
1466
1479
  if agent:
1467
1480
  item["sender_agent_slug"] = str(agent["slug"])
1468
1481
  item["sender_agent_name"] = str(agent["display_name"] or agent["slug"])
1482
+ model_name = str(agent["model_name"] or "").strip()
1483
+ model_provider = str(agent["model_provider"] or "").strip()
1484
+ slug = str(agent["slug"] or "").strip()
1485
+ if not model_name and slug:
1486
+ model_name = str(_read_model_block(slug).get("default") or "").strip()
1487
+ if not model_provider and slug:
1488
+ model_provider = _llm_billing_provider(slug)
1489
+ if model_name:
1490
+ item["model"] = model_name
1491
+ if model_provider:
1492
+ item["llm_provider"] = model_provider
1469
1493
  enriched.append(item)
1470
1494
  return enriched
1471
1495
 
@@ -375,7 +375,10 @@ def mark_install_admin_verified(email: str) -> None:
375
375
  smtp["admin_email"] = normalized
376
376
  smtp["admin_verified_at"] = int(time.time())
377
377
  raw["smtp"] = smtp
378
- raw["wizard_step"] = "workframe"
378
+ if not str(raw.get("deployment_mode") or "").strip():
379
+ raw["wizard_step"] = "welcome"
380
+ else:
381
+ raw["wizard_step"] = "workframe"
379
382
  _write_raw(raw)
380
383
 
381
384