@bitseek/hermes-webui 0.1.0-beta.0 → 0.1.0-beta.1

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 (99) hide show
  1. package/package.json +2 -2
  2. package/vendor/agent-frontend-shell/.bitseek-source.json +2 -2
  3. package/vendor/agent-frontend-shell/CHANGELOG.md +178 -1
  4. package/vendor/agent-frontend-shell/CONTRIBUTORS.md +5 -5
  5. package/vendor/agent-frontend-shell/api/agent_health.py +134 -0
  6. package/vendor/agent-frontend-shell/api/config.py +145 -104
  7. package/vendor/agent-frontend-shell/api/gateway_chat.py +56 -12
  8. package/vendor/agent-frontend-shell/api/helpers.py +4 -2
  9. package/vendor/agent-frontend-shell/api/models.py +202 -20
  10. package/vendor/agent-frontend-shell/api/paths.py +77 -0
  11. package/vendor/agent-frontend-shell/api/plugins.py +185 -0
  12. package/vendor/agent-frontend-shell/api/profiles.py +95 -16
  13. package/vendor/agent-frontend-shell/api/routes.py +831 -30
  14. package/vendor/agent-frontend-shell/api/run_journal.py +1 -0
  15. package/vendor/agent-frontend-shell/api/state_sync.py +5 -4
  16. package/vendor/agent-frontend-shell/api/streaming.py +211 -56
  17. package/vendor/agent-frontend-shell/api/todo_state.py +122 -0
  18. package/vendor/agent-frontend-shell/api/updates.py +30 -3
  19. package/vendor/agent-frontend-shell/api/upload.py +251 -18
  20. package/vendor/agent-frontend-shell/api/workspace.py +323 -65
  21. package/vendor/agent-frontend-shell/bitseek_docs/BitSeek_Claw_Operation_Manual_EN.docx +0 -0
  22. package/vendor/agent-frontend-shell/bitseek_docs/BitSeek_Claw_Operation_Manual_ZH.docx +0 -0
  23. package/vendor/agent-frontend-shell/bitseek_docs/en/00-Installation.md +174 -0
  24. package/vendor/agent-frontend-shell/bitseek_docs/en/01-Overview.md +128 -0
  25. package/vendor/agent-frontend-shell/bitseek_docs/en/02-Page-Operations.md +461 -0
  26. package/vendor/agent-frontend-shell/bitseek_docs/en/README.md +61 -0
  27. package/vendor/agent-frontend-shell/bitseek_docs/en/images/ai-colleagues.png +0 -0
  28. package/vendor/agent-frontend-shell/bitseek_docs/en/images/chat-area.png +0 -0
  29. package/vendor/agent-frontend-shell/bitseek_docs/en/images/kanban.png +0 -0
  30. package/vendor/agent-frontend-shell/bitseek_docs/en/images/main-page.png +0 -0
  31. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-notes.png +0 -0
  32. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-overview.png +0 -0
  33. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-profile.png +0 -0
  34. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-soul.png +0 -0
  35. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory.png +0 -0
  36. package/vendor/agent-frontend-shell/bitseek_docs/en/images/navigation-bar.png +0 -0
  37. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-appearance.png +0 -0
  38. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-conversation.png +0 -0
  39. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-overview.png +0 -0
  40. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-plugins.png +0 -0
  41. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-preferences.png +0 -0
  42. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-providers.png +0 -0
  43. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-system.png +0 -0
  44. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings.png +0 -0
  45. package/vendor/agent-frontend-shell/bitseek_docs/en/images/sidebar.png +0 -0
  46. package/vendor/agent-frontend-shell/bitseek_docs/en/images/skills.png +0 -0
  47. package/vendor/agent-frontend-shell/bitseek_docs/en/images/tasks.png +0 -0
  48. package/vendor/agent-frontend-shell/bitseek_docs/en/images/workspace-panel.png +0 -0
  49. package/vendor/agent-frontend-shell/bitseek_docs/md_to_docx.py +351 -0
  50. package/vendor/agent-frontend-shell/bitseek_docs/zh/00-/345/256/211/350/243/205/345/220/257/345/212/250.md +174 -0
  51. package/vendor/agent-frontend-shell/bitseek_docs/zh/01-/346/225/264/344/275/223/346/246/202/350/247/210.md +128 -0
  52. package/vendor/agent-frontend-shell/bitseek_docs/zh/02-/351/241/265/351/235/242/346/223/215/344/275/234.md +463 -0
  53. package/vendor/agent-frontend-shell/bitseek_docs/zh/README.md +61 -0
  54. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/ai-colleagues.png +0 -0
  55. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/chat-area.png +0 -0
  56. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/kanban.png +0 -0
  57. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/main-page.png +0 -0
  58. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-notes.png +0 -0
  59. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-overview.png +0 -0
  60. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-profile.png +0 -0
  61. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-soul.png +0 -0
  62. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory.png +0 -0
  63. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/navigation-bar.png +0 -0
  64. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-appearance.png +0 -0
  65. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-conversation.png +0 -0
  66. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-overview.png +0 -0
  67. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-plugins.png +0 -0
  68. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-preferences.png +0 -0
  69. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-providers.png +0 -0
  70. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-system.png +0 -0
  71. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings.png +0 -0
  72. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/sidebar.png +0 -0
  73. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/skills.png +0 -0
  74. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/tasks.png +0 -0
  75. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/workspace-panel.png +0 -0
  76. package/vendor/agent-frontend-shell/build-release.sh +62 -0
  77. package/vendor/agent-frontend-shell/ctl.sh +1 -0
  78. package/vendor/agent-frontend-shell/docker-compose.local.yml +33 -0
  79. package/vendor/agent-frontend-shell/docker-compose.yml +8 -0
  80. package/vendor/agent-frontend-shell/docker_init.bash +1 -0
  81. package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +74 -15
  82. package/vendor/agent-frontend-shell/extensions/common/index.css +6 -0
  83. package/vendor/agent-frontend-shell/extensions/manifest.json +6 -0
  84. package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +60 -14
  85. package/vendor/agent-frontend-shell/readme-simple.md +103 -0
  86. package/vendor/agent-frontend-shell/requirements.txt +5 -0
  87. package/vendor/agent-frontend-shell/server.py +7 -0
  88. package/vendor/agent-frontend-shell/static/boot.js +53 -1
  89. package/vendor/agent-frontend-shell/static/commands.js +20 -10
  90. package/vendor/agent-frontend-shell/static/i18n.js +1142 -1016
  91. package/vendor/agent-frontend-shell/static/index.html +13 -3
  92. package/vendor/agent-frontend-shell/static/messages.js +48 -3
  93. package/vendor/agent-frontend-shell/static/panels.js +199 -30
  94. package/vendor/agent-frontend-shell/static/sessions.js +249 -39
  95. package/vendor/agent-frontend-shell/static/style.css +46 -2
  96. package/vendor/agent-frontend-shell/static/ui.js +323 -79
  97. package/vendor/agent-frontend-shell/static/workspace.js +185 -7
  98. package/vendor/agent-frontend-shell/README-CUSTOM.md +0 -76
  99. package/vendor/agent-frontend-shell/docker-compose.custom.yml +0 -26
@@ -105,6 +105,50 @@ def _session_counts_toward_pin_quota(session) -> bool:
105
105
  return not _hide_from_default_sidebar(row)
106
106
 
107
107
 
108
+ def _session_row_lineage_root_id(session, sessions_by_id) -> str:
109
+ sid = str(_session_field(session, "session_id", "") or "")
110
+ explicit = _session_field(session, "_lineage_root_id", None)
111
+ if explicit:
112
+ return str(explicit)
113
+ # A branch/fork is an independent, separately-visible session (it carries a
114
+ # parent_session_id purely for provenance), so it must count as its OWN pin
115
+ # lineage — only compression/continuation rows should collapse to a shared
116
+ # root. Without this, two pinned forks of the same parent would collapse to a
117
+ # single quota lineage and let the user exceed pinned_sessions_limit (#3288).
118
+ if _session_field(session, "session_source", None) == "fork":
119
+ return sid
120
+ current = sid
121
+ seen = {sid} if sid else set()
122
+ parent = _session_field(session, "parent_session_id", None)
123
+ while parent:
124
+ parent = str(parent)
125
+ if parent in seen:
126
+ break
127
+ current = parent
128
+ seen.add(parent)
129
+ parent_row = sessions_by_id.get(parent)
130
+ if not parent_row:
131
+ break
132
+ parent = _session_field(parent_row, "parent_session_id", None)
133
+ return current or sid
134
+
135
+
136
+ def _visible_pinned_lineage_ids(session_rows) -> set[str]:
137
+ sessions_by_id = {}
138
+ for row in session_rows:
139
+ sid = str(_session_field(row, "session_id", "") or "")
140
+ if sid:
141
+ sessions_by_id[sid] = row
142
+ roots: set[str] = set()
143
+ for row in session_rows:
144
+ if not _session_counts_toward_pin_quota(row):
145
+ continue
146
+ root = _session_row_lineage_root_id(row, sessions_by_id)
147
+ if root:
148
+ roots.add(root)
149
+ return roots
150
+
151
+
108
152
  # ── Profile-scoped session/project filtering (#1611, #1614) ────────────────
109
153
  #
110
154
  # Sessions and projects are stored in the WebUI sidecar without per-row
@@ -2801,6 +2845,7 @@ def _keep_latest_messaging_session_per_source(
2801
2845
  from api.models import (
2802
2846
  Session,
2803
2847
  get_session,
2848
+ get_session_for_file_ops,
2804
2849
  new_session,
2805
2850
  all_sessions,
2806
2851
  title_from,
@@ -2819,6 +2864,7 @@ from api.models import (
2819
2864
  _is_empty_partial_activity_message,
2820
2865
  _hide_from_default_sidebar,
2821
2866
  prune_session_from_index,
2867
+ agent_session_row_exists,
2822
2868
  ensure_cron_project,
2823
2869
  is_cron_session,
2824
2870
  is_safe_session_id,
@@ -2828,6 +2874,7 @@ from api.workspace import (
2828
2874
  save_workspaces,
2829
2875
  get_last_workspace,
2830
2876
  set_last_workspace,
2877
+ git_info_for_workspace,
2831
2878
  list_dir,
2832
2879
  dir_signature,
2833
2880
  list_workspace_suggestions,
@@ -2839,12 +2886,13 @@ from api.workspace import (
2839
2886
  _strip_surrounding_quotes,
2840
2887
  _workspace_blocked_roots,
2841
2888
  )
2842
- from api.upload import handle_upload, handle_upload_extract, handle_transcribe
2889
+ from api.upload import handle_upload, handle_upload_extract, handle_transcribe, handle_workspace_upload
2843
2890
  from api.streaming import (
2844
2891
  _sse,
2845
2892
  _run_agent_streaming,
2846
2893
  cancel_stream,
2847
2894
  _materialize_pending_user_turn_before_error,
2895
+ generate_session_title_for_session,
2848
2896
  )
2849
2897
  from api.gateway_chat import _run_gateway_chat_streaming, webui_gateway_chat_enabled
2850
2898
  from api.run_journal import (
@@ -2852,6 +2900,7 @@ from api.run_journal import (
2852
2900
  read_run_events,
2853
2901
  stale_interrupted_event,
2854
2902
  )
2903
+ from api.todo_state import attach_todo_state
2855
2904
  from api.providers import get_providers, get_provider_quota, get_provider_cost_history, set_provider_key, remove_provider_key
2856
2905
  from api.onboarding import (
2857
2906
  apply_onboarding_setup,
@@ -3744,6 +3793,322 @@ def _handle_insights(handler, parsed) -> bool:
3744
3793
  })
3745
3794
 
3746
3795
 
3796
+ def _project_os_workspace_read(repo_root: Path, rel: str) -> dict | None:
3797
+ try:
3798
+ return read_file_content(repo_root, rel)
3799
+ except Exception:
3800
+ return None
3801
+
3802
+
3803
+ def _project_os_workspace_json(repo_root: Path, rel: str) -> dict | None:
3804
+ payload = _project_os_workspace_read(repo_root, rel)
3805
+ if not payload or not isinstance(payload.get("content"), str):
3806
+ return None
3807
+ try:
3808
+ parsed = json.loads(payload.get("content") or "")
3809
+ return parsed if isinstance(parsed, dict) else None
3810
+ except Exception:
3811
+ return None
3812
+
3813
+
3814
+ def _project_os_truth_board_slugs(repo_root: Path) -> set[str]:
3815
+ slugs: set[str] = set()
3816
+
3817
+ def add(value) -> None:
3818
+ text = str(value or "").strip()
3819
+ if text:
3820
+ slugs.add(text)
3821
+
3822
+ for rel in (
3823
+ ".ax/handoff/current.json",
3824
+ ".ax/status/active.json",
3825
+ ".ax/status/heartbeat.json",
3826
+ ):
3827
+ truth = _project_os_workspace_json(repo_root, rel)
3828
+ if not isinstance(truth, dict):
3829
+ continue
3830
+ raw_board = truth.get("board")
3831
+ if isinstance(raw_board, dict):
3832
+ add(raw_board.get("slug"))
3833
+ add(raw_board.get("id"))
3834
+ add(raw_board.get("name"))
3835
+ add(raw_board.get("display_name"))
3836
+ else:
3837
+ add(raw_board)
3838
+ for key in (
3839
+ "selected_board_slug",
3840
+ "canonical_backlog_board_id",
3841
+ "current_browser_board_id",
3842
+ "active_proof_board_id",
3843
+ "recover_board_id",
3844
+ ):
3845
+ add(truth.get(key))
3846
+ return slugs
3847
+
3848
+
3849
+ def _project_os_repo_matches_board(repo_root: Path, board_slug: str | None) -> bool:
3850
+ slug = str(board_slug or "").strip()
3851
+ return bool(slug and slug in _project_os_truth_board_slugs(repo_root))
3852
+
3853
+
3854
+ def _project_os_candidate_repo_roots(workspace_root: Path | None) -> list[Path]:
3855
+ candidates: list[Path] = []
3856
+ seen: set[str] = set()
3857
+
3858
+ def add(path: Path | None) -> None:
3859
+ if path is None:
3860
+ return
3861
+ try:
3862
+ resolved = path.expanduser().resolve()
3863
+ except Exception:
3864
+ return
3865
+ if not resolved.is_dir():
3866
+ return
3867
+ key = str(resolved)
3868
+ if key not in seen:
3869
+ seen.add(key)
3870
+ candidates.append(resolved)
3871
+
3872
+ add(workspace_root)
3873
+
3874
+ scan_root = None
3875
+ if workspace_root is not None:
3876
+ try:
3877
+ scan_root = workspace_root.expanduser().resolve()
3878
+ except Exception:
3879
+ scan_root = None
3880
+ if not scan_root or not scan_root.is_dir():
3881
+ return candidates
3882
+
3883
+ skip_names = {
3884
+ ".git",
3885
+ ".hg",
3886
+ ".svn",
3887
+ ".venv",
3888
+ "__pycache__",
3889
+ "node_modules",
3890
+ "vendor",
3891
+ "dist",
3892
+ "build",
3893
+ }
3894
+ queue_dirs: list[tuple[Path, int]] = [(scan_root, 0)]
3895
+ inspected = 0
3896
+ while queue_dirs and inspected < 300:
3897
+ current, depth = queue_dirs.pop(0)
3898
+ inspected += 1
3899
+ if (current / ".ax").is_dir() or (current / "docs" / "project-os").is_dir():
3900
+ add(current)
3901
+ if depth >= 3:
3902
+ continue
3903
+ try:
3904
+ children = sorted(
3905
+ (child for child in current.iterdir() if child.is_dir()),
3906
+ key=lambda child: child.name,
3907
+ )
3908
+ except Exception:
3909
+ continue
3910
+ for child in children:
3911
+ name = child.name
3912
+ if name in skip_names or (name.startswith(".") and name != ".ax"):
3913
+ continue
3914
+ queue_dirs.append((child, depth + 1))
3915
+ add(Path.cwd())
3916
+ return candidates
3917
+
3918
+
3919
+ def _project_os_resolve_repo_root_for_board(repo_root: Path | None, board_slug: str | None) -> Path | None:
3920
+ slug = str(board_slug or "").strip()
3921
+ if not slug:
3922
+ return repo_root if repo_root and repo_root.exists() else None
3923
+ if repo_root and repo_root.exists() and _project_os_repo_matches_board(repo_root, slug):
3924
+ return repo_root
3925
+ for candidate in _project_os_candidate_repo_roots(repo_root):
3926
+ if _project_os_repo_matches_board(candidate, slug):
3927
+ return candidate
3928
+ return repo_root if repo_root and repo_root.exists() else None
3929
+
3930
+
3931
+ def _project_os_goal_summary(project_md: dict | None, handoff: dict | None, status_md: dict | None, board_name: str | None = None, board_desc: str | None = None) -> str:
3932
+ project_text = str((project_md or {}).get("content") or "")
3933
+ for line in project_text.splitlines():
3934
+ text = line.strip().lstrip("- ").strip()
3935
+ if not text or text.startswith("#"):
3936
+ continue
3937
+ return text[:220]
3938
+ handoff_summary = str((handoff or {}).get("goal_summary") or "").strip()
3939
+ if handoff_summary:
3940
+ return handoff_summary[:220]
3941
+ desc = str(board_desc or "").strip()
3942
+ if desc:
3943
+ return desc[:220]
3944
+ status_text = str((status_md or {}).get("content") or "")
3945
+ for line in status_text.splitlines():
3946
+ text = line.strip().lstrip("- ").strip()
3947
+ if not text or text.startswith("#"):
3948
+ continue
3949
+ return text[:220]
3950
+ return str(board_name or "Project OS").strip()[:220]
3951
+
3952
+
3953
+ def _project_os_onboarding_context(repo_root: Path, project_md: dict | None, plan_md: dict | None, status_md: dict | None) -> dict:
3954
+ project_text = str((project_md or {}).get("content") or "")
3955
+ plan_text = str((plan_md or {}).get("content") or "")
3956
+ status_text = str((status_md or {}).get("content") or "")
3957
+ merged = "\n".join([project_text, plan_text, status_text])
3958
+ is_non_git_workspace = not (repo_root / ".git").exists()
3959
+ has_boundary_hold = "TO_BE_VALIDATED_BY_HERMES" in merged
3960
+ child_repo_blocked = (
3961
+ "auto-promoted" in merged
3962
+ or "auto-adopted" in merged
3963
+ or "auto-adoption | `금지`" in merged
3964
+ or "자동 승격 금지" in merged
3965
+ or "canonical repo continuity로 승격하지 않습니다" in merged
3966
+ )
3967
+ workspace_root_confirmed = str(repo_root) in merged
3968
+ if not (is_non_git_workspace and (project_text or plan_text or status_text)):
3969
+ return {
3970
+ "active": False,
3971
+ "doc_source": "project-os",
3972
+ }
3973
+ status_label = "보류(안전)" if has_boundary_hold else "확인됨"
3974
+ summary = "workspace root onboarding 진행 중 · 저장소 경계는 아직 미확정이며 자동 승격은 금지됩니다."
3975
+ next_safe_action = "workspace-root 기준으로 경계만 좁게 검증"
3976
+ if has_boundary_hold:
3977
+ summary = "workspace root onboarding 진행 중 · 저장소 경계는 아직 미확정이며 TO_BE_VALIDATED_BY_HERMES 상태를 유지합니다."
3978
+ return {
3979
+ "active": True,
3980
+ "doc_source": "root",
3981
+ "status_label": status_label,
3982
+ "summary": summary,
3983
+ "next_safe_action": next_safe_action,
3984
+ "workspace_root_confirmed": workspace_root_confirmed,
3985
+ "repo_boundary_status": "TO_BE_VALIDATED_BY_HERMES" if has_boundary_hold else "confirmed",
3986
+ "child_repo_auto_promotion_blocked": bool(child_repo_blocked),
3987
+ "guardrails": [
3988
+ "workspace root 확인됨" if workspace_root_confirmed else "workspace root 확인 필요",
3989
+ "child repo 자동 승격 금지 유지" if child_repo_blocked else "child repo guardrail 확인 필요",
3990
+ "repo boundary 미확정 유지" if has_boundary_hold else "repo boundary confirmed",
3991
+ ],
3992
+ }
3993
+
3994
+
3995
+ def _handle_project_os_dashboard(handler, parsed) -> bool:
3996
+ qs = parse_qs(parsed.query or "")
3997
+ requested_board = str((qs.get("board") or [""])[0] or "").strip()
3998
+ workspace_raw = str(get_last_workspace() or "").strip()
3999
+ repo_root = Path(workspace_raw).expanduser() if workspace_raw else None
4000
+ selected_board_meta = None
4001
+ if requested_board:
4002
+ try:
4003
+ from api.kanban_bridge import _kb, _board_meta_dict
4004
+ kb = _kb()
4005
+ for meta in kb.list_boards(include_archived=True) or []:
4006
+ board = _board_meta_dict(meta)
4007
+ if str(board.get("slug") or "") == requested_board:
4008
+ selected_board_meta = board
4009
+ workdir = str(board.get("default_workdir") or "").strip()
4010
+ if workdir:
4011
+ candidate = Path(workdir).expanduser()
4012
+ if candidate.exists():
4013
+ repo_root = candidate
4014
+ break
4015
+ except Exception:
4016
+ selected_board_meta = None
4017
+ repo_root = _project_os_resolve_repo_root_for_board(repo_root, requested_board)
4018
+ if not repo_root or not repo_root.exists():
4019
+ j(handler, {
4020
+ "workspace": None,
4021
+ "repo_root": None,
4022
+ "git": None,
4023
+ "docs": {},
4024
+ "handoff": None,
4025
+ "active": None,
4026
+ "heartbeat": None,
4027
+ "goal_summary": "",
4028
+ })
4029
+ return True
4030
+
4031
+ handoff = _project_os_workspace_json(repo_root, ".ax/handoff/current.json")
4032
+ active = _project_os_workspace_json(repo_root, ".ax/status/active.json")
4033
+ heartbeat = _project_os_workspace_json(repo_root, ".ax/status/heartbeat.json")
4034
+ project_md = _project_os_workspace_read(repo_root, "docs/project-os/PROJECT.md")
4035
+ plan_md = _project_os_workspace_read(repo_root, "docs/project-os/PLAN.md")
4036
+ status_md = _project_os_workspace_read(repo_root, "docs/project-os/STATUS.md")
4037
+ blocker_md = _project_os_workspace_read(repo_root, "docs/project-os/BLOCKER-RESOLVER.md")
4038
+ root_project_md = _project_os_workspace_read(repo_root, "PROJECT.md")
4039
+ root_plan_md = _project_os_workspace_read(repo_root, "PLAN.md")
4040
+ root_status_md = _project_os_workspace_read(repo_root, "STATUS.md")
4041
+ onboarding = _project_os_onboarding_context(repo_root, root_project_md, root_plan_md, root_status_md)
4042
+ if onboarding.get("active"):
4043
+ project_md = root_project_md or project_md
4044
+ plan_md = root_plan_md or plan_md
4045
+ status_md = root_status_md or status_md
4046
+
4047
+ if isinstance(active, dict):
4048
+ original_repo_root = repo_root
4049
+ active_repo_root = str(active.get("repo_root") or "").strip()
4050
+ if active_repo_root:
4051
+ candidate = Path(active_repo_root).expanduser()
4052
+ if candidate.exists():
4053
+ try:
4054
+ repo_root = candidate.resolve()
4055
+ except Exception:
4056
+ repo_root = candidate
4057
+ if repo_root != original_repo_root:
4058
+ handoff = _project_os_workspace_json(repo_root, ".ax/handoff/current.json")
4059
+ active = _project_os_workspace_json(repo_root, ".ax/status/active.json")
4060
+ heartbeat = _project_os_workspace_json(repo_root, ".ax/status/heartbeat.json")
4061
+ project_md = _project_os_workspace_read(repo_root, "docs/project-os/PROJECT.md")
4062
+ plan_md = _project_os_workspace_read(repo_root, "docs/project-os/PLAN.md")
4063
+ status_md = _project_os_workspace_read(repo_root, "docs/project-os/STATUS.md")
4064
+ blocker_md = _project_os_workspace_read(repo_root, "docs/project-os/BLOCKER-RESOLVER.md")
4065
+ root_project_md = _project_os_workspace_read(repo_root, "PROJECT.md")
4066
+ root_plan_md = _project_os_workspace_read(repo_root, "PLAN.md")
4067
+ root_status_md = _project_os_workspace_read(repo_root, "STATUS.md")
4068
+ onboarding = _project_os_onboarding_context(repo_root, root_project_md, root_plan_md, root_status_md)
4069
+ if onboarding.get("active"):
4070
+ project_md = root_project_md or project_md
4071
+ plan_md = root_plan_md or plan_md
4072
+ status_md = root_status_md or status_md
4073
+
4074
+ try:
4075
+ git = git_info_for_workspace(repo_root)
4076
+ except Exception:
4077
+ git = None
4078
+
4079
+ board_name = None
4080
+ board_desc = None
4081
+ if isinstance(handoff, dict):
4082
+ board_dict: dict = {}
4083
+ raw_board = handoff.get("board")
4084
+ if isinstance(raw_board, dict):
4085
+ board_dict = raw_board
4086
+ board_name = board_dict.get("display_name") or board_dict.get("name") or board_dict.get("slug")
4087
+ board_desc = handoff.get("goal_summary") or board_dict.get("repo_corroboration")
4088
+ if selected_board_meta:
4089
+ board_name = board_name or selected_board_meta.get("name") or selected_board_meta.get("slug")
4090
+ board_desc = board_desc or selected_board_meta.get("description")
4091
+
4092
+ j(handler, {
4093
+ "workspace": str(repo_root),
4094
+ "repo_root": str(repo_root),
4095
+ "selected_board_slug": requested_board or (selected_board_meta or {}).get("slug"),
4096
+ "git": git,
4097
+ "docs": {
4098
+ "project": project_md,
4099
+ "plan": plan_md,
4100
+ "status": status_md,
4101
+ "blocker_resolver": blocker_md,
4102
+ },
4103
+ "handoff": handoff,
4104
+ "active": active,
4105
+ "heartbeat": heartbeat,
4106
+ "onboarding": onboarding,
4107
+ "goal_summary": _project_os_goal_summary(project_md, handoff, status_md, board_name, board_desc),
4108
+ })
4109
+ return True
4110
+
4111
+
3747
4112
  # ── GET routes ────────────────────────────────────────────────────────────────
3748
4113
 
3749
4114
 
@@ -4012,6 +4377,8 @@ def _plugin_visibility_payload(manager=None) -> dict:
4012
4377
  manager.discover_and_load(force=False)
4013
4378
 
4014
4379
  plugins = []
4380
+
4381
+ # Hermes Agent lifecycle-hook plugins
4015
4382
  raw_plugins = getattr(manager, "_plugins", {}) or {}
4016
4383
  for key, loaded in sorted(raw_plugins.items(), key=lambda item: str(item[0])):
4017
4384
  manifest = getattr(loaded, "manifest", None)
@@ -4059,9 +4426,41 @@ def _plugin_visibility_payload(manager=None) -> dict:
4059
4426
  }
4060
4427
 
4061
4428
 
4429
+ # WebUI dashboard plugins (from manifest.json discovery)
4430
+ def _dashboard_plugin_enabled(plugin_name: str) -> bool:
4431
+ """True if a dashboard plugin is enabled in settings.
4432
+
4433
+ Dashboard plugins are opt-in (default off). Enforced server-side so a
4434
+ disabled plugin's page + asset URLs are fully 404'd, not merely hidden in
4435
+ the Settings UI.
4436
+ """
4437
+ try:
4438
+ from api.config import load_settings
4439
+ prefs = (load_settings() or {}).get("dashboard_plugins", {}) or {}
4440
+ return bool(prefs.get(plugin_name, False))
4441
+ except Exception:
4442
+ return False
4443
+
4444
+
4445
+ def _webui_plugin_payload() -> list[dict]:
4446
+ try:
4447
+ from api.plugins import get_plugin_metadata
4448
+ return get_plugin_metadata()
4449
+ except Exception:
4450
+ return []
4451
+
4452
+
4062
4453
  def _handle_plugins(handler, parsed) -> bool:
4063
4454
  try:
4064
- return j(handler, _plugin_visibility_payload())
4455
+ hermes_plugins = _plugin_visibility_payload()
4456
+ webui = _webui_plugin_payload()
4457
+ all_plugins = hermes_plugins["plugins"] + webui
4458
+ return j(handler, {
4459
+ "plugins": all_plugins,
4460
+ "empty": not bool(all_plugins),
4461
+ "supported_hooks": hermes_plugins["supported_hooks"],
4462
+ "read_only": True,
4463
+ })
4065
4464
  except Exception as exc:
4066
4465
  logger.warning("Failed to build plugin visibility payload: %s", exc)
4067
4466
  return j(
@@ -4318,6 +4717,8 @@ def handle_get(handler, parsed) -> bool:
4318
4717
  # ── Insights / knowledge status ──
4319
4718
  if parsed.path == "/api/insights":
4320
4719
  return _handle_insights(handler, parsed)
4720
+ if parsed.path == "/api/project-os/dashboard":
4721
+ return _handle_project_os_dashboard(handler, parsed)
4321
4722
 
4322
4723
  if parsed.path.startswith("/api/kanban/"):
4323
4724
  from api.kanban_bridge import handle_kanban_get
@@ -4678,6 +5079,14 @@ def handle_get(handler, parsed) -> bool:
4678
5079
  journal,
4679
5080
  active=bool(getattr(s, "active_stream_id", None)),
4680
5081
  )
5082
+ # Cold-load: derive the latest settled todo snapshot from the full
5083
+ # merged transcript, not the truncated display window. This keeps
5084
+ # the Todos panel correct after refresh even when the latest todo
5085
+ # tool result is outside msg_limit, and treats an explicit empty
5086
+ # todo list as the current state instead of falling through to an
5087
+ # older non-empty write.
5088
+ if load_messages and _all_msgs:
5089
+ attach_todo_state(raw, _all_msgs)
4681
5090
  if _merged_last_message_at:
4682
5091
  raw["last_message_at"] = max(
4683
5092
  float(raw.get("last_message_at") or 0),
@@ -4753,6 +5162,7 @@ def handle_get(handler, parsed) -> bool:
4753
5162
  "messages": msgs,
4754
5163
  "tool_calls": [],
4755
5164
  }
5165
+ attach_todo_state(sess, msgs)
4756
5166
  sess = _merge_cli_sidebar_metadata(sess, cli_meta)
4757
5167
  return j(handler, {"session": redact_session_data(sess)})
4758
5168
  return bad(handler, "Session not found", 404)
@@ -4822,6 +5232,37 @@ def handle_get(handler, parsed) -> bool:
4822
5232
  cli = get_cli_sessions()
4823
5233
  diag.stage("merge_cli_sessions")
4824
5234
  cli_by_id = {s["session_id"]: s for s in cli}
5235
+ # #3238: reconcile orphaned imported-CLI sidecars. When a CLI
5236
+ # session was clicked in WebUI it gets a WebUI-owned sidecar that
5237
+ # all_sessions() returns independently of state.db. If the user
5238
+ # later deletes the backing CLI session from the command line,
5239
+ # the sidecar is never pruned and the stale row lingers in the
5240
+ # sidebar forever (there is no WebUI delete affordance for it).
5241
+ # Drop rows whose backing agent row is genuinely gone. We probe
5242
+ # state.db directly (agent_session_row_exists) rather than trust
5243
+ # cli_by_id absence, because get_cli_sessions() caps at
5244
+ # CLI_VISIBLE_SESSION_LIMIT (20) — an existing session can fall
5245
+ # out of that window and look deleted. Native WebUI sessions
5246
+ # (source == "webui") that merely have a CLI ancestor are never
5247
+ # pruned.
5248
+ _kept_after_orphan_prune = []
5249
+ for s in webui_sessions:
5250
+ _sid = s.get("session_id")
5251
+ if (
5252
+ _sid
5253
+ and is_cli_session_row(s)
5254
+ and not _session_source_is_webui(s)
5255
+ and _sid not in cli_by_id
5256
+ and not agent_session_row_exists(_sid, profile=s.get("profile"))
5257
+ ):
5258
+ try:
5259
+ prune_session_from_index(_sid)
5260
+ except Exception:
5261
+ logger.debug("Failed to prune orphaned CLI sidecar %s", _sid, exc_info=True)
5262
+ diag.stage("prune_orphaned_cli_sidecar")
5263
+ continue
5264
+ _kept_after_orphan_prune.append(s)
5265
+ webui_sessions = _kept_after_orphan_prune
4825
5266
  for s in webui_sessions:
4826
5267
  meta = cli_by_id.get(s.get("session_id"))
4827
5268
  if not meta:
@@ -5421,6 +5862,130 @@ def handle_get(handler, parsed) -> bool:
5421
5862
  logger.exception("rollback/diff failed")
5422
5863
  return bad(handler, str(e), status=500)
5423
5864
 
5865
+ # ── Plugin shared assets (e.g. /plugins/plugin.css) ──
5866
+ # Restricted to shared plugin assets only — no cross-plugin file access.
5867
+ if parsed.path.startswith("/plugins/"):
5868
+ from api.plugins import _get_plugin_base
5869
+ plugin_base = _get_plugin_base()
5870
+ rel = parsed.path[len("/plugins/"):]
5871
+ allowed = {"plugin.css"}
5872
+ if rel not in allowed:
5873
+ return False # 404
5874
+ safe = (plugin_base / rel).resolve()
5875
+ try:
5876
+ safe.relative_to(plugin_base.resolve())
5877
+ except ValueError:
5878
+ return False # path traversal — 404
5879
+ if safe.is_file():
5880
+ import os as _os
5881
+ data = safe.read_bytes()
5882
+ ext = _os.path.splitext(rel.lower())[1]
5883
+ ct = {
5884
+ ".css": "text/css; charset=utf-8",
5885
+ ".js": "application/javascript; charset=utf-8",
5886
+ ".json": "application/json; charset=utf-8",
5887
+ ".png": "image/png",
5888
+ ".svg": "image/svg+xml",
5889
+ }.get(ext, "application/octet-stream")
5890
+ handler.send_response(200)
5891
+ handler.send_header("Content-Type", ct)
5892
+ handler.send_header("Content-Length", str(len(data)))
5893
+ handler.end_headers()
5894
+ handler.wfile.write(data)
5895
+ return True
5896
+
5897
+ # ── Plugin static assets ──
5898
+ if parsed.path.startswith("/dashboard-plugins/"):
5899
+ parts = parsed.path.split("/", 3)
5900
+ if len(parts) >= 3:
5901
+ plugin_name = parts[2]
5902
+ rel_path = parts[3] if len(parts) > 3 else ""
5903
+ # Server-side enable-gate: a plugin disabled in Settings must have its
5904
+ # entire URL surface shut off, not merely hidden in the UI.
5905
+ if not _dashboard_plugin_enabled(plugin_name):
5906
+ return False # 404 — disabled plugins serve nothing
5907
+ from api.plugins import serve_plugin_static
5908
+ result = serve_plugin_static(plugin_name, rel_path)
5909
+ if result:
5910
+ data, content_type = result
5911
+ handler.send_response(200)
5912
+ handler.send_header("Content-Type", content_type)
5913
+ # Defense-in-depth: plugin-controlled assets are served from the
5914
+ # WebUI's own origin. Sandbox them (null origin) so a plugin's
5915
+ # .html/.svg can't run privileged same-origin script if navigated
5916
+ # to directly (the in-panel iframe sandbox doesn't cover direct
5917
+ # navigation). nosniff prevents content-type confusion.
5918
+ handler.send_header("Content-Security-Policy", "sandbox allow-scripts allow-forms allow-popups")
5919
+ handler.send_header("X-Content-Type-Options", "nosniff")
5920
+ handler.send_header("Content-Length", str(len(data)))
5921
+ handler.end_headers()
5922
+ handler.wfile.write(data)
5923
+ return True
5924
+
5925
+ # ── Plugin pages (HTML shell) ──
5926
+ from api.plugins import PLUGIN_MANIFESTS, _PLUGIN_STATIC_ROOTS
5927
+ for name, manifest in PLUGIN_MANIFESTS.items():
5928
+ tab = manifest.get("tab", {})
5929
+ tab_path = tab.get("path", f"/{name}")
5930
+ if parsed.path == tab_path:
5931
+ # Server-side enable-gate (opt-in): a disabled plugin's page 404s.
5932
+ if not _dashboard_plugin_enabled(name):
5933
+ return False
5934
+ dashboard_dir = _PLUGIN_STATIC_ROOTS.get(name)
5935
+ if dashboard_dir:
5936
+ # 1) dashboard/dist/index.html (full SPA build)
5937
+ index_html = dashboard_dir / "dist" / "index.html"
5938
+ if index_html.is_file():
5939
+ data = index_html.read_bytes()
5940
+ handler.send_response(200)
5941
+ handler.send_header("Content-Type", "text/html; charset=utf-8")
5942
+ handler.send_header("Content-Security-Policy", "sandbox allow-scripts allow-forms allow-popups")
5943
+ handler.send_header("Content-Length", str(len(data)))
5944
+ handler.end_headers()
5945
+ handler.wfile.write(data)
5946
+ return True
5947
+ # 2) static/index.html in plugin root (content page for IIFE loader)
5948
+ plugin_root = dashboard_dir.parent
5949
+ static_html = plugin_root / "static" / "index.html"
5950
+ if static_html.is_file():
5951
+ data = static_html.read_bytes()
5952
+ handler.send_response(200)
5953
+ handler.send_header("Content-Type", "text/html; charset=utf-8")
5954
+ handler.send_header("Content-Security-Policy", "sandbox allow-scripts allow-forms allow-popups")
5955
+ handler.send_header("Content-Length", str(len(data)))
5956
+ handler.end_headers()
5957
+ handler.wfile.write(data)
5958
+ return True
5959
+ # 3) Fallback: generate shell that loads the IIFE bundle
5960
+ index_js = dashboard_dir / "dist" / "index.js"
5961
+ if index_js.is_file():
5962
+ import html
5963
+ label = html.escape(manifest.get("label") or name)
5964
+ css = html.escape(manifest.get("css", ""))
5965
+ name_escaped = html.escape(name)
5966
+ css_tag = f'<link rel="stylesheet" href="/dashboard-plugins/{name_escaped}/{css}">' if css else ""
5967
+ html_content = (
5968
+ f"<!doctype html>\n"
5969
+ f"<html lang=\"en\">\n"
5970
+ f"<head>\n"
5971
+ f" <meta charset=\"utf-8\">\n"
5972
+ f" <title>{label}</title>\n"
5973
+ f" {css_tag}\n"
5974
+ f"</head>\n"
5975
+ f"<body>\n"
5976
+ f' <div id="pluginPageContainer"></div>\n'
5977
+ f' <script src="/dashboard-plugins/{name_escaped}/dist/index.js"></script>\n'
5978
+ f"</body>\n"
5979
+ f"</html>\n"
5980
+ ).encode("utf-8")
5981
+ handler.send_response(200)
5982
+ handler.send_header("Content-Type", "text/html; charset=utf-8")
5983
+ handler.send_header("Content-Security-Policy", "sandbox allow-scripts allow-forms allow-popups")
5984
+ handler.send_header("Content-Length", str(len(html_content)))
5985
+ handler.end_headers()
5986
+ handler.wfile.write(html_content)
5987
+ return True
5988
+
5424
5989
  return False # 404
5425
5990
 
5426
5991
 
@@ -5457,10 +6022,15 @@ def handle_post(handler, parsed) -> bool:
5457
6022
  return handle_upload(handler)
5458
6023
  if parsed.path == "/api/upload/extract":
5459
6024
  return handle_upload_extract(handler)
6025
+ if parsed.path == "/api/workspace/upload":
6026
+ return handle_workspace_upload(handler)
5460
6027
 
5461
6028
  if parsed.path == "/api/transcribe":
5462
6029
  return handle_transcribe(handler)
5463
6030
 
6031
+ if parsed.path == "/api/tts":
6032
+ return _handle_tts(handler, parsed)
6033
+
5464
6034
  if parsed.path == "/api/client-events/log":
5465
6035
  if diag:
5466
6036
  diag.stage("read_client_event_body")
@@ -5737,6 +6307,27 @@ def handle_post(handler, parsed) -> bool:
5737
6307
  if parsed.path == "/api/sessions/cleanup_zero_message":
5738
6308
  return _handle_sessions_cleanup(handler, body, zero_only=True)
5739
6309
 
6310
+ def _sync_session_title_to_insights(session):
6311
+ """Write title-only session metadata updates through to state.db when enabled."""
6312
+ try:
6313
+ if not load_settings().get("sync_to_insights"):
6314
+ return
6315
+ from api.state_sync import sync_session_usage
6316
+
6317
+ messages = getattr(session, "messages", None) or []
6318
+ sync_session_usage(
6319
+ session_id=session.session_id,
6320
+ input_tokens=getattr(session, "input_tokens", None) or 0,
6321
+ output_tokens=getattr(session, "output_tokens", None) or 0,
6322
+ estimated_cost=getattr(session, "estimated_cost", 0.0),
6323
+ model=getattr(session, "model", ""),
6324
+ title=session.title,
6325
+ message_count=len(messages),
6326
+ profile=getattr(session, "profile", None),
6327
+ )
6328
+ except Exception:
6329
+ logger.debug("Failed to update session title in state.db", exc_info=True)
6330
+
5740
6331
  if parsed.path == "/api/session/rename":
5741
6332
  try:
5742
6333
  require(body, "session_id", "title")
@@ -5753,6 +6344,37 @@ def handle_post(handler, parsed) -> bool:
5753
6344
  publish_session_list_changed("session_rename")
5754
6345
  return j(handler, {"session": s.compact()})
5755
6346
 
6347
+
6348
+ if parsed.path == "/api/session/title/regenerate":
6349
+ try:
6350
+ require(body, "session_id")
6351
+ except ValueError as e:
6352
+ return bad(handler, str(e))
6353
+ sid = body["session_id"]
6354
+ prefer_latest = bool(body.get("prefer_latest", False))
6355
+ try:
6356
+ s = get_session(sid)
6357
+ s = _ensure_full_session_before_mutation(sid, s)
6358
+ except KeyError:
6359
+ return bad(handler, "Session not found", 404)
6360
+ if getattr(s, "read_only", False) or getattr(s, "is_imported", False):
6361
+ return bad(handler, "Read-only imported sessions cannot be renamed", 403)
6362
+ next_title, reason, raw_preview = generate_session_title_for_session(s, prefer_latest=prefer_latest)
6363
+ if not next_title:
6364
+ return bad(handler, f"Could not generate a better title ({reason or 'empty'})", 422)
6365
+ with _get_session_agent_lock(sid):
6366
+ s.title = str(next_title).strip()[:80] or "Untitled"
6367
+ s.llm_title_generated = True
6368
+ s.save(touch_updated_at=False)
6369
+ _sync_session_title_to_insights(s)
6370
+ publish_session_list_changed("session_title_regenerate")
6371
+ return j(handler, {
6372
+ "session": s.compact(),
6373
+ "title": s.title,
6374
+ "status": reason,
6375
+ "raw_preview": (raw_preview or "")[:240],
6376
+ })
6377
+
5756
6378
  if parsed.path == "/api/personality/set":
5757
6379
  try:
5758
6380
  require(body, "session_id")
@@ -6057,13 +6679,28 @@ def handle_post(handler, parsed) -> bool:
6057
6679
  return bad(handler, "Session not found", 404)
6058
6680
  keep = int(body["keep_count"])
6059
6681
  with _get_session_agent_lock(body["session_id"]):
6682
+ old_msg_count = len(s.messages or [])
6683
+ old_ctx_count = len(getattr(s, 'context_messages', None) or [])
6060
6684
  s.messages = s.messages[:keep]
6685
+ # Truncate context_messages in sync with messages so the agent's
6686
+ # model-facing context doesn't retain rows the user removed via
6687
+ # Edit / Regenerate. Without this, context_messages still contains
6688
+ # the full pre-truncation history and the agent sees "deleted"
6689
+ # turns on the next turn (#2914).
6690
+ if isinstance(getattr(s, 'context_messages', None), list):
6691
+ s.context_messages = s.context_messages[:keep]
6061
6692
  try:
6062
6693
  from api.session_ops import _truncation_watermark_for
6063
6694
  s.truncation_watermark = _truncation_watermark_for(s.messages)
6064
6695
  except Exception:
6065
6696
  s.truncation_watermark = 0.0
6066
6697
  s.save()
6698
+ logger.info(
6699
+ "truncate %s: messages %d→%d, context_messages %d→%d, watermark=%.2f",
6700
+ body["session_id"], old_msg_count, len(s.messages or []),
6701
+ old_ctx_count, len(getattr(s, 'context_messages', None) or []),
6702
+ s.truncation_watermark or 0,
6703
+ )
6067
6704
  return j(
6068
6705
  handler, {"ok": True, "session": s.compact() | {"messages": s.messages}}
6069
6706
  )
@@ -6702,23 +7339,34 @@ def handle_post(handler, parsed) -> bool:
6702
7339
  if pin_requested and not getattr(s, "pinned", False):
6703
7340
  # Pre-snapshot from persisted index (acquires LOCK internally,
6704
7341
  # so must run outside our own LOCK acquire below).
6705
- persisted_pinned_ids = {
6706
- _session_field(existing, "session_id", None) for existing in all_sessions()
7342
+ persisted_rows = [
7343
+ existing for existing in all_sessions()
6707
7344
  if _session_counts_toward_pin_quota(existing)
6708
- }
7345
+ ]
6709
7346
  with LOCK:
6710
- # Final authoritative count: merge persisted-pinned with the
6711
- # in-memory SESSIONS snapshot. SESSIONS may have pin mutations
6712
- # that haven't yet flushed to the index, so the in-memory side
6713
- # is the truth for in-flight contention.
6714
- pinned_ids = set(persisted_pinned_ids)
6715
- pinned_ids.update(
6716
- sid for sid, existing in SESSIONS.items()
7347
+ # Final authoritative count: merge persisted pinned rows with the
7348
+ # in-memory SESSIONS snapshot. Count logical sidebar-visible pin
7349
+ # lineages rather than raw session rows so continuation siblings
7350
+ # in the same visible lineage do not consume extra pin quota.
7351
+ candidate_rows = list(persisted_rows)
7352
+ candidate_rows.extend(
7353
+ existing.compact() for existing in SESSIONS.values()
6717
7354
  if _session_counts_toward_pin_quota(existing)
6718
7355
  )
6719
- pinned_ids.discard(body["session_id"])
7356
+ target_row = s.compact()
7357
+ candidate_rows.append(target_row)
7358
+ pinned_lineage_ids = _visible_pinned_lineage_ids(candidate_rows)
7359
+ target_lineage = _session_row_lineage_root_id(
7360
+ target_row,
7361
+ {
7362
+ str(_session_field(row, "session_id", "") or ""): row
7363
+ for row in candidate_rows
7364
+ if _session_field(row, "session_id", None)
7365
+ },
7366
+ )
7367
+ pinned_lineage_ids.discard(target_lineage)
6720
7368
  pinned_sessions_limit = int(load_settings().get("pinned_sessions_limit", 3) or 3)
6721
- if len(pinned_ids) >= pinned_sessions_limit:
7369
+ if len(pinned_lineage_ids) >= pinned_sessions_limit:
6722
7370
  return bad(handler, f"Up to {pinned_sessions_limit} sessions can be pinned. Unpin one before pinning another.", 400)
6723
7371
  # Mark in-memory pin state under LOCK so concurrent pin
6724
7372
  # requests see the increment immediately, even before
@@ -6824,14 +7472,20 @@ def handle_post(handler, parsed) -> bool:
6824
7472
  target_pid = body.get("project_id") or None
6825
7473
  if target_pid:
6826
7474
  from api.profiles import get_active_profile_name
6827
- active_profile = get_active_profile_name()
7475
+ # Use the session's own profile for authorization, not the global
7476
+ # active profile. A session belongs to a specific profile set at
7477
+ # creation; projects from that profile should always be assignable,
7478
+ # regardless of which profile is "active" at the process level.
7479
+ # Matches the same principle as the profile chip fix — prefer
7480
+ # session-scoped state over global active profile. (#3325 follow-up)
7481
+ _session_profile = getattr(s, 'profile', None) or get_active_profile_name()
6828
7482
  target = next(
6829
7483
  (p for p in load_projects() if p["project_id"] == target_pid),
6830
7484
  None,
6831
7485
  )
6832
7486
  if not target:
6833
7487
  return bad(handler, "Project not found", 404)
6834
- if not _profiles_match(target.get("profile"), active_profile):
7488
+ if not _profiles_match(target.get("profile"), _session_profile):
6835
7489
  return bad(handler, "Project not found", 404)
6836
7490
  with _get_session_agent_lock(body["session_id"]):
6837
7491
  s.project_id = target_pid
@@ -6855,11 +7509,20 @@ def handle_post(handler, parsed) -> bool:
6855
7509
  if color and not _re.match(r"^#[0-9a-fA-F]{3,8}$", color):
6856
7510
  return bad(handler, "Invalid color format")
6857
7511
  projects = load_projects()
7512
+ # #3331 follow-up (Codex+Opus gate): validate the optional client-supplied
7513
+ # `profile` before stamping it, mirroring /api/profile/switch — otherwise a
7514
+ # client could create a project tagged with an arbitrary/unknown profile,
7515
+ # producing hidden cross-profile rows that can't be managed normally.
7516
+ _requested_profile = str(body.get('profile') or "").strip()
7517
+ if _requested_profile and _requested_profile != "default":
7518
+ from api.profiles import _PROFILE_ID_RE
7519
+ if not _PROFILE_ID_RE.fullmatch(_requested_profile):
7520
+ return bad(handler, "invalid profile")
6858
7521
  proj = {
6859
7522
  "project_id": uuid.uuid4().hex[:12],
6860
7523
  "name": name,
6861
7524
  "color": color,
6862
- "profile": get_active_profile_name() or 'default',
7525
+ "profile": _requested_profile or get_active_profile_name() or 'default',
6863
7526
  "created_at": time.time(),
6864
7527
  }
6865
7528
  projects.append(proj)
@@ -8076,6 +8739,143 @@ def _serve_file_bytes(handler, target: Path, mime: str, disposition: str, cache_
8076
8739
  return True
8077
8740
 
8078
8741
 
8742
+
8743
+ def _handle_tts(handler, parsed):
8744
+ """Generate TTS audio via Edge TTS. POST JSON body only.
8745
+
8746
+ Design note addressing deep review blocker #4 (synchronous I/O):
8747
+ The server uses ThreadingHTTPServer (see server.py:173), so each request
8748
+ already runs in its own dedicated thread. A TTS request therefore occupies
8749
+ only its own thread during Microsoft network I/O + streaming; other clients
8750
+ are unaffected. Combined with early auth, a strict per-client 2 s rate
8751
+ limit, 5000-char cap, and voice allowlist, the blocking cost is bounded and
8752
+ intentional. Streaming chunks directly via stream_sync() keeps memory
8753
+ usage low. A cross-thread pool + queue would add complexity and wfile
8754
+ thread-safety issues with no practical gain under the current model.
8755
+ If the HTTP layer ever moves to asyncio we can adopt edge_tts's native
8756
+ async API at that time.
8757
+ """
8758
+ text = ""
8759
+ voice = "zh-CN-XiaoxiaoNeural"
8760
+ rate_str = ""
8761
+ pitch_str = ""
8762
+
8763
+ if handler.command != "POST":
8764
+ from api.helpers import bad as _bad
8765
+ return _bad(handler, "POST required for /api/tts", 405)
8766
+
8767
+ try:
8768
+ data = read_body(handler)
8769
+ text = (data.get("text") or "").strip()
8770
+ voice = data.get("voice") or voice
8771
+ rate_str = data.get("rate") or ""
8772
+ pitch_str = data.get("pitch") or ""
8773
+ except Exception:
8774
+ from api.helpers import bad as _bad
8775
+ return _bad(handler, "invalid request body", 400)
8776
+
8777
+ if not text:
8778
+ from api.helpers import bad as _bad
8779
+ return _bad(handler, "text is required", 400)
8780
+ if len(text) > 5000:
8781
+ from api.helpers import bad as _bad
8782
+ return _bad(handler, "text too long (max 5000 characters)", 400)
8783
+
8784
+ from api.auth import is_auth_enabled, parse_cookie, verify_session
8785
+ cv = None
8786
+ if is_auth_enabled():
8787
+ cv = parse_cookie(handler)
8788
+ if not (cv and verify_session(cv)):
8789
+ from api.helpers import bad as _bad
8790
+ return _bad(handler, "unauthorized", 401)
8791
+
8792
+ # High-quality per-client rate limiting for TTS.
8793
+ if not hasattr(_handle_tts, "_tts_limiter"):
8794
+ import time as _time, threading as _threading
8795
+ class _TtsRateLimiter:
8796
+ def __init__(self, window_seconds=2.0, prune_interval=50):
8797
+ self.window = window_seconds
8798
+ self.prune_interval = prune_interval
8799
+ self._hits = {}
8800
+ self._lock = _threading.Lock()
8801
+ self._checks = 0
8802
+
8803
+ def _get_client_key(self, h):
8804
+ for hdr in ("X-Forwarded-For", "X-Real-IP", "Forwarded"):
8805
+ val = h.headers.get(hdr)
8806
+ if val:
8807
+ ip = val.split(",")[0].strip().split(";")[0].strip()
8808
+ if ip:
8809
+ return ip
8810
+ return getattr(h, "client_address", ("unknown",))[0]
8811
+
8812
+ def check(self, handler, session_cookie=None):
8813
+ key = self._get_client_key(handler)
8814
+ if session_cookie and "." in str(session_cookie):
8815
+ key = str(session_cookie).split(".", 1)[0]
8816
+ now = _time.time()
8817
+ with self._lock:
8818
+ self._checks += 1
8819
+ if self._checks % self.prune_interval == 0:
8820
+ cutoff = now - (self.window * 10)
8821
+ self._hits = {k: v for k, v in self._hits.items() if v > cutoff}
8822
+ last = self._hits.get(key, 0)
8823
+ if now - last < self.window:
8824
+ return False
8825
+ self._hits[key] = now
8826
+ return True
8827
+
8828
+ _handle_tts._tts_limiter = _TtsRateLimiter(window_seconds=2.0)
8829
+
8830
+ limiter = _handle_tts._tts_limiter
8831
+ if not limiter.check(handler, cv):
8832
+ logger.warning("TTS rate limit hit for client=%s", limiter._get_client_key(handler))
8833
+ from api.helpers import bad as _bad
8834
+ return _bad(handler, "rate limit exceeded — please wait", 429)
8835
+
8836
+ allowed = {
8837
+ "zh-CN-XiaoxiaoNeural", "zh-CN-XiaoyiNeural", "zh-CN-YunxiNeural",
8838
+ "zh-CN-YunjianNeural", "zh-CN-YunyangNeural",
8839
+ "en-US-AriaNeural", "en-US-GuyNeural"
8840
+ }
8841
+ if voice not in allowed:
8842
+ from api.helpers import bad as _bad
8843
+ return _bad(handler, "invalid voice", 400)
8844
+
8845
+ try:
8846
+ try:
8847
+ import edge_tts
8848
+ except ImportError:
8849
+ from api.helpers import bad as _bad
8850
+ return _bad(handler, "Edge TTS engine not installed on the server. Install it with: pip install edge-tts", 503)
8851
+
8852
+ kwargs = {}
8853
+ if rate_str:
8854
+ kwargs["rate"] = rate_str
8855
+ if pitch_str:
8856
+ kwargs["pitch"] = pitch_str
8857
+
8858
+ comm = edge_tts.Communicate(text, voice, **kwargs)
8859
+
8860
+ handler.send_response(200)
8861
+ handler.send_header("Content-Type", "audio/mpeg")
8862
+ handler.send_header("Cache-Control", "no-store")
8863
+ handler.end_headers()
8864
+
8865
+ for chunk in comm.stream_sync():
8866
+ if chunk.get("type") == "audio" and chunk.get("data"):
8867
+ try:
8868
+ handler.wfile.write(chunk["data"])
8869
+ except (BrokenPipeError, ConnectionResetError):
8870
+ return True
8871
+ return True
8872
+
8873
+ except BrokenPipeError:
8874
+ return True
8875
+ except Exception:
8876
+ logger.exception("Edge TTS generation failed")
8877
+ from api.helpers import bad as _bad
8878
+ return _bad(handler, "TTS generation failed", 500)
8079
8879
  def _html_preview_with_blank_base(raw: bytes) -> bytes:
8080
8880
  base = '<base target="_blank">'
8081
8881
  text = raw.decode("utf-8", errors="replace")
@@ -8579,7 +9379,7 @@ def _handle_folder_download(handler, parsed):
8579
9379
  if not sid:
8580
9380
  return bad(handler, "session_id is required")
8581
9381
  try:
8582
- s = get_session(sid)
9382
+ s = get_session_for_file_ops(sid)
8583
9383
  except KeyError:
8584
9384
  return bad(handler, "Session not found", 404)
8585
9385
 
@@ -8652,7 +9452,7 @@ def _handle_file_raw(handler, parsed):
8652
9452
  if not sid:
8653
9453
  return bad(handler, "session_id is required")
8654
9454
  try:
8655
- s = get_session(sid)
9455
+ s = get_session_for_file_ops(sid)
8656
9456
  except KeyError:
8657
9457
  return bad(handler, "Session not found", 404)
8658
9458
  rel = qs.get("path", [""])[0]
@@ -8677,10 +9477,11 @@ def _handle_file_raw(handler, parsed):
8677
9477
  # CSP sandbox directive applies the same isolation server-side: without
8678
9478
  # allow-same-origin, the document is treated as a unique opaque origin and
8679
9479
  # cannot read WebUI cookies, localStorage, or postMessage to the parent.
8680
- csp = "sandbox allow-scripts allow-popups allow-popups-to-escape-sandbox" if html_inline_ok else None
9480
+ sandbox_csp = "sandbox allow-scripts allow-popups allow-popups-to-escape-sandbox"
9481
+ csp = sandbox_csp if (inline_preview and not force_download and disposition == "inline") else None
8681
9482
  # _serve_file_bytes sends Content-Security-Policy when csp is set.
8682
9483
  if html_inline_ok:
8683
- return _serve_inline_html_preview(handler, target, "no-store", csp=csp)
9484
+ return _serve_inline_html_preview(handler, target, "no-store", csp=sandbox_csp)
8684
9485
  return _serve_file_bytes(handler, target, mime, disposition, "no-store", csp=csp)
8685
9486
 
8686
9487
 
@@ -8690,7 +9491,7 @@ def _handle_file_read(handler, parsed):
8690
9491
  if not sid:
8691
9492
  return bad(handler, "session_id is required")
8692
9493
  try:
8693
- s = get_session(sid)
9494
+ s = get_session_for_file_ops(sid)
8694
9495
  except KeyError:
8695
9496
  return bad(handler, "Session not found", 404)
8696
9497
  rel = qs.get("path", [""])[0]
@@ -11011,7 +11812,7 @@ def _handle_file_delete(handler, body):
11011
11812
  except ValueError as e:
11012
11813
  return bad(handler, str(e))
11013
11814
  try:
11014
- s = get_session(body["session_id"])
11815
+ s = get_session_for_file_ops(body["session_id"])
11015
11816
  except KeyError:
11016
11817
  return bad(handler, "Session not found", 404)
11017
11818
  try:
@@ -11035,7 +11836,7 @@ def _handle_file_save(handler, body):
11035
11836
  except ValueError as e:
11036
11837
  return bad(handler, str(e))
11037
11838
  try:
11038
- s = get_session(body["session_id"])
11839
+ s = get_session_for_file_ops(body["session_id"])
11039
11840
  except KeyError:
11040
11841
  return bad(handler, "Session not found", 404)
11041
11842
  try:
@@ -11058,7 +11859,7 @@ def _handle_file_create(handler, body):
11058
11859
  except ValueError as e:
11059
11860
  return bad(handler, str(e))
11060
11861
  try:
11061
- s = get_session(body["session_id"])
11862
+ s = get_session_for_file_ops(body["session_id"])
11062
11863
  except KeyError:
11063
11864
  return bad(handler, "Session not found", 404)
11064
11865
  try:
@@ -11080,7 +11881,7 @@ def _handle_file_rename(handler, body):
11080
11881
  except ValueError as e:
11081
11882
  return bad(handler, str(e))
11082
11883
  try:
11083
- s = get_session(body["session_id"])
11884
+ s = get_session_for_file_ops(body["session_id"])
11084
11885
  except KeyError:
11085
11886
  return bad(handler, "Session not found", 404)
11086
11887
  try:
@@ -11106,7 +11907,7 @@ def _handle_create_dir(handler, body):
11106
11907
  except ValueError as e:
11107
11908
  return bad(handler, str(e))
11108
11909
  try:
11109
- s = get_session(body["session_id"])
11910
+ s = get_session_for_file_ops(body["session_id"])
11110
11911
  except KeyError:
11111
11912
  return bad(handler, "Session not found", 404)
11112
11913
  try:
@@ -11127,7 +11928,7 @@ def _handle_file_reveal(handler, body):
11127
11928
  except ValueError as e:
11128
11929
  return bad(handler, str(e))
11129
11930
  try:
11130
- s = get_session(body["session_id"])
11931
+ s = get_session_for_file_ops(body["session_id"])
11131
11932
  except KeyError:
11132
11933
  return bad(handler, "Session not found", 404)
11133
11934
  try:
@@ -11174,7 +11975,7 @@ def _handle_file_path(handler, body):
11174
11975
  except ValueError as e:
11175
11976
  return bad(handler, str(e))
11176
11977
  try:
11177
- s = get_session(body["session_id"])
11978
+ s = get_session_for_file_ops(body["session_id"])
11178
11979
  except KeyError:
11179
11980
  return bad(handler, "Session not found", 404)
11180
11981
  try:
@@ -11204,7 +12005,7 @@ def _handle_file_open_vscode(handler, body):
11204
12005
  except ValueError as e:
11205
12006
  return bad(handler, str(e))
11206
12007
  try:
11207
- s = get_session(body["session_id"])
12008
+ s = get_session_for_file_ops(body["session_id"])
11208
12009
  except KeyError:
11209
12010
  return bad(handler, "Session not found", 404)
11210
12011
  try: