marvisx-cli 0.1.0__py3-none-any.whl

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 (587) hide show
  1. core/api/__init__.py +0 -0
  2. core/api/agents/__init__.py +0 -0
  3. core/api/agents/session_health.py +59 -0
  4. core/api/agents/session_manager.py +206 -0
  5. core/api/bin/marvisx-state-hook.py +182 -0
  6. core/api/config.py +533 -0
  7. core/api/db.py +1516 -0
  8. core/api/dependencies/__init__.py +0 -0
  9. core/api/dependencies/tenant.py +34 -0
  10. core/api/main.py +1641 -0
  11. core/api/mcp/__init__.py +8 -0
  12. core/api/mcp/_adapter.py +184 -0
  13. core/api/mcp/server.py +58 -0
  14. core/api/mcp/tools/__init__.py +59 -0
  15. core/api/mcp/tools/brain.py +599 -0
  16. core/api/mcp/tools/graph.py +380 -0
  17. core/api/mcp/tools/handoffs.py +112 -0
  18. core/api/mcp/tools/ingest.py +326 -0
  19. core/api/mcp/tools/learnings.py +144 -0
  20. core/api/mcp/tools/projects.py +99 -0
  21. core/api/mcp/tools/pull_requests.py +173 -0
  22. core/api/mcp/tools/safety.py +111 -0
  23. core/api/mcp/tools/search.py +79 -0
  24. core/api/mcp/tools/tasks.py +258 -0
  25. core/api/middleware/__init__.py +0 -0
  26. core/api/middleware/tool_call_audit.py +111 -0
  27. core/api/models/__init__.py +346 -0
  28. core/api/models/auth.py +48 -0
  29. core/api/models/brain.py +1006 -0
  30. core/api/models/common.py +76 -0
  31. core/api/models/costs.py +91 -0
  32. core/api/models/graph.py +66 -0
  33. core/api/models/graph_cosmo.py +125 -0
  34. core/api/models/graph_pr_impact.py +257 -0
  35. core/api/models/graph_ux.py +141 -0
  36. core/api/models/inbox.py +230 -0
  37. core/api/models/ingest_keys.py +108 -0
  38. core/api/models/kg.py +41 -0
  39. core/api/models/llm_config.py +56 -0
  40. core/api/models/monitoring.py +234 -0
  41. core/api/models/projects.py +161 -0
  42. core/api/models/search.py +42 -0
  43. core/api/models/sessions.py +322 -0
  44. core/api/models/tasks.py +184 -0
  45. core/api/models/teams.py +63 -0
  46. core/api/models/users.py +184 -0
  47. core/api/observability/__init__.py +0 -0
  48. core/api/observability/tracing.py +92 -0
  49. core/api/paths.py +26 -0
  50. core/api/rate_limit.py +24 -0
  51. core/api/rbac.py +112 -0
  52. core/api/routers/__init__.py +0 -0
  53. core/api/routers/_adapter.py +24 -0
  54. core/api/routers/admin_pr_impact.py +230 -0
  55. core/api/routers/admin_settings.py +147 -0
  56. core/api/routers/agent.py +1079 -0
  57. core/api/routers/agent_tokens.py +276 -0
  58. core/api/routers/app_settings.py +112 -0
  59. core/api/routers/audit.py +89 -0
  60. core/api/routers/auth.py +586 -0
  61. core/api/routers/bench.py +161 -0
  62. core/api/routers/brain.py +881 -0
  63. core/api/routers/brain_directions.py +527 -0
  64. core/api/routers/ci_checks.py +140 -0
  65. core/api/routers/comments.py +273 -0
  66. core/api/routers/costs.py +148 -0
  67. core/api/routers/docs_coverage.py +217 -0
  68. core/api/routers/docs_governance.py +63 -0
  69. core/api/routers/documents.py +318 -0
  70. core/api/routers/files.py +163 -0
  71. core/api/routers/finder.py +987 -0
  72. core/api/routers/graph.py +836 -0
  73. core/api/routers/handoffs.py +156 -0
  74. core/api/routers/inbox.py +496 -0
  75. core/api/routers/ingest_api_keys.py +205 -0
  76. core/api/routers/ingest_triage.py +1227 -0
  77. core/api/routers/judge.py +306 -0
  78. core/api/routers/kg.py +336 -0
  79. core/api/routers/learnings.py +253 -0
  80. core/api/routers/llm_config.py +130 -0
  81. core/api/routers/monitoring.py +347 -0
  82. core/api/routers/notifications.py +125 -0
  83. core/api/routers/pr_impact.py +315 -0
  84. core/api/routers/projects.py +1061 -0
  85. core/api/routers/pull_requests.py +312 -0
  86. core/api/routers/push.py +67 -0
  87. core/api/routers/raci.py +228 -0
  88. core/api/routers/search.py +125 -0
  89. core/api/routers/sessions.py +3100 -0
  90. core/api/routers/settings.py +90 -0
  91. core/api/routers/share_repo.py +68 -0
  92. core/api/routers/status_updates.py +96 -0
  93. core/api/routers/tags.py +45 -0
  94. core/api/routers/tasks.py +526 -0
  95. core/api/routers/teams.py +425 -0
  96. core/api/routers/terminal.py +105 -0
  97. core/api/routers/users.py +331 -0
  98. core/api/routers/webhooks.py +330 -0
  99. core/api/runtime_settings.py +84 -0
  100. core/api/security.py +652 -0
  101. core/api/services/__init__.py +0 -0
  102. core/api/services/audit.py +58 -0
  103. core/api/services/auto_approval.py +82 -0
  104. core/api/services/brain/__init__.py +52 -0
  105. core/api/services/brain/baseline.py +230 -0
  106. core/api/services/brain/capabilities.py +75 -0
  107. core/api/services/brain/cascade_rollup.py +388 -0
  108. core/api/services/brain/compound_bridge.py +215 -0
  109. core/api/services/brain/cycle.py +1242 -0
  110. core/api/services/brain/cycle_snapshot.py +371 -0
  111. core/api/services/brain/digest_collector.py +147 -0
  112. core/api/services/brain/direction.py +421 -0
  113. core/api/services/brain/drift.py +356 -0
  114. core/api/services/brain/drift_router.py +409 -0
  115. core/api/services/brain/edge_metrics.py +79 -0
  116. core/api/services/brain/events_reader.py +222 -0
  117. core/api/services/brain/findings.py +1379 -0
  118. core/api/services/brain/findings_reader.py +1006 -0
  119. core/api/services/brain/jobs.py +733 -0
  120. core/api/services/brain/journal.py +206 -0
  121. core/api/services/brain/knowledge_forms.py +92 -0
  122. core/api/services/brain/llm/__init__.py +37 -0
  123. core/api/services/brain/llm/_runner.py +62 -0
  124. core/api/services/brain/llm/base.py +70 -0
  125. core/api/services/brain/llm/cache.py +99 -0
  126. core/api/services/brain/llm/constants.py +46 -0
  127. core/api/services/brain/llm/direction_alignment.py +289 -0
  128. core/api/services/brain/llm/factory.py +132 -0
  129. core/api/services/brain/llm/finding_reasoning.py +98 -0
  130. core/api/services/brain/llm/finding_summary.py +92 -0
  131. core/api/services/brain/llm/grounding.py +46 -0
  132. core/api/services/brain/llm/journal_polish.py +96 -0
  133. core/api/services/brain/llm/local_gateway.py +426 -0
  134. core/api/services/brain/llm/parsers.py +71 -0
  135. core/api/services/brain/llm/router_glue.py +422 -0
  136. core/api/services/brain/memory_ops.py +1677 -0
  137. core/api/services/brain/models.py +140 -0
  138. core/api/services/brain/owner_hint.py +211 -0
  139. core/api/services/brain/recap.py +307 -0
  140. core/api/services/brain/rules/__init__.py +65 -0
  141. core/api/services/brain/rules/_signals.py +205 -0
  142. core/api/services/brain/rules/dr1_activity_without_status.py +108 -0
  143. core/api/services/brain/rules/dr2_decision_without_adr.py +110 -0
  144. core/api/services/brain/rules/dr3_stale_open_loop.py +127 -0
  145. core/api/services/brain/rules/dr4_docs_governance_drift.py +79 -0
  146. core/api/services/brain/rules/dr5_playbook_changed.py +99 -0
  147. core/api/services/brain/rules/dr6_external_update_unpropagated.py +100 -0
  148. core/api/services/brain/rules/dr7_claimed_decision_gap.py +123 -0
  149. core/api/services/brain/rules/dr8_direction_misalignment.py +230 -0
  150. core/api/services/brain/runs_reader.py +485 -0
  151. core/api/services/brain/scope.py +79 -0
  152. core/api/services/brain/sources/__init__.py +46 -0
  153. core/api/services/brain/sources/base.py +86 -0
  154. core/api/services/brain/sources/git_kg.py +393 -0
  155. core/api/services/brain/sources/handoffs.py +157 -0
  156. core/api/services/brain/sources/ingestor.py +130 -0
  157. core/api/services/brain/sources/learnings.py +121 -0
  158. core/api/services/brain/sources/pir_tasks.py +245 -0
  159. core/api/services/brain/watermarks.py +147 -0
  160. core/api/services/brain/ws_emitter.py +170 -0
  161. core/api/services/cc_tasks_reader.py +76 -0
  162. core/api/services/ci_service.py +263 -0
  163. core/api/services/claude_metrics.py +796 -0
  164. core/api/services/codex_metrics.py +364 -0
  165. core/api/services/conversation_reader.py +102 -0
  166. core/api/services/cost_service.py +243 -0
  167. core/api/services/crypto.py +147 -0
  168. core/api/services/docs_governance/__init__.py +1 -0
  169. core/api/services/docs_governance/confidence.py +230 -0
  170. core/api/services/docs_governance/config.py +87 -0
  171. core/api/services/docs_governance/enrichment.py +65 -0
  172. core/api/services/docs_governance/frontmatter_validator.py +83 -0
  173. core/api/services/docs_governance/hard_gates.py +221 -0
  174. core/api/services/docs_governance/triage_orchestrator.py +98 -0
  175. core/api/services/embedding_internal.py +395 -0
  176. core/api/services/embedding_service.py +832 -0
  177. core/api/services/event_dispatcher.py +167 -0
  178. core/api/services/events.py +70 -0
  179. core/api/services/git_ops.py +621 -0
  180. core/api/services/graph_cosmo_service.py +440 -0
  181. core/api/services/graph_ranker.py +306 -0
  182. core/api/services/graph_service.py +1589 -0
  183. core/api/services/inbox.py +800 -0
  184. core/api/services/inbox_digest.py +221 -0
  185. core/api/services/inbox_digest_deep_research.py +80 -0
  186. core/api/services/inbox_digest_jobs.py +595 -0
  187. core/api/services/inbox_gmail_sync.py +167 -0
  188. core/api/services/inbox_llm_classifier.py +906 -0
  189. core/api/services/inbox_source_identity.py +116 -0
  190. core/api/services/inbox_sources.py +456 -0
  191. core/api/services/inbox_taxonomy.py +195 -0
  192. core/api/services/inbox_tldr.py +1079 -0
  193. core/api/services/inbox_triage.py +899 -0
  194. core/api/services/ingest/__init__.py +13 -0
  195. core/api/services/ingest/api_key_auth.py +136 -0
  196. core/api/services/ingest/auto_approve.py +120 -0
  197. core/api/services/ingest/classifier.py +138 -0
  198. core/api/services/ingest/confidence.py +173 -0
  199. core/api/services/ingest/dispatch.py +88 -0
  200. core/api/services/ingest/embedding_router.py +272 -0
  201. core/api/services/ingest/events.py +33 -0
  202. core/api/services/ingest/ignore_patterns.py +79 -0
  203. core/api/services/ingest/image_probe.py +218 -0
  204. core/api/services/ingest/ingress.py +263 -0
  205. core/api/services/ingest/insert_saga.py +793 -0
  206. core/api/services/ingest/llm/__init__.py +13 -0
  207. core/api/services/ingest/llm/anthropic_haiku.py +23 -0
  208. core/api/services/ingest/llm/base.py +59 -0
  209. core/api/services/ingest/llm/byok_provider.py +130 -0
  210. core/api/services/ingest/llm/classification_context.py +301 -0
  211. core/api/services/ingest/llm/config_store.py +246 -0
  212. core/api/services/ingest/llm/factory.py +24 -0
  213. core/api/services/ingest/llm/kg_enricher.py +306 -0
  214. core/api/services/ingest/llm/local_gateway.py +821 -0
  215. core/api/services/ingest/llm/local_vllm.py +23 -0
  216. core/api/services/ingest/llm/openai_nano.py +349 -0
  217. core/api/services/ingest/lock_advisory.py +57 -0
  218. core/api/services/ingest/parser_router.py +1756 -0
  219. core/api/services/ingest/parsers/__init__.py +1 -0
  220. core/api/services/ingest/parsers/docling_parser.py +142 -0
  221. core/api/services/ingest/parsers/docparse_gateway.py +178 -0
  222. core/api/services/ingest/parsers/docx_parser.py +127 -0
  223. core/api/services/ingest/parsers/folder_unpacker.py +85 -0
  224. core/api/services/ingest/parsers/gateway_aux.py +147 -0
  225. core/api/services/ingest/parsers/image_parser.py +251 -0
  226. core/api/services/ingest/parsers/internal_markdown.py +89 -0
  227. core/api/services/ingest/parsers/ocr_gateway.py +117 -0
  228. core/api/services/ingest/parsers/ocr_pdf_parser.py +112 -0
  229. core/api/services/ingest/parsers/pdf_types.py +13 -0
  230. core/api/services/ingest/parsers/transcript_parser.py +445 -0
  231. core/api/services/ingest/parsers/vision_gateway.py +186 -0
  232. core/api/services/ingest/parsers/xlsx_parser.py +91 -0
  233. core/api/services/ingest/parsers/zip_unpacker.py +126 -0
  234. core/api/services/ingest/preflight.py +393 -0
  235. core/api/services/ingest/retry_voyage.py +88 -0
  236. core/api/services/ingest/routing_policy.py +307 -0
  237. core/api/services/ingest/serializers/__init__.py +1 -0
  238. core/api/services/ingest/serializers/xlsx_to_markdown.py +80 -0
  239. core/api/services/ingest/skip_log.py +74 -0
  240. core/api/services/ingest/watcher.py +637 -0
  241. core/api/services/kg/__init__.py +0 -0
  242. core/api/services/kg/audit.py +49 -0
  243. core/api/services/kg/hybrid_search.py +691 -0
  244. core/api/services/kg/lens.py +339 -0
  245. core/api/services/kg/pr_impact.py +770 -0
  246. core/api/services/kg/queries.py +152 -0
  247. core/api/services/kg/ranking.py +89 -0
  248. core/api/services/kg/rrf.py +143 -0
  249. core/api/services/kg_watcher_control.py +161 -0
  250. core/api/services/local_llm/__init__.py +19 -0
  251. core/api/services/local_llm/async_client.py +385 -0
  252. core/api/services/local_llm/client.py +173 -0
  253. core/api/services/local_llm/url_validator.py +44 -0
  254. core/api/services/metrics_collector.py +646 -0
  255. core/api/services/metrics_providers.py +65 -0
  256. core/api/services/model_registry.py +266 -0
  257. core/api/services/model_router.py +137 -0
  258. core/api/services/n8n_client.py +77 -0
  259. core/api/services/newsletter_llm_gateway.py +66 -0
  260. core/api/services/notification_service.py +134 -0
  261. core/api/services/openai_responses.py +55 -0
  262. core/api/services/opencode_metrics.py +375 -0
  263. core/api/services/opencode_sessions.py +173 -0
  264. core/api/services/pii_redactor.py +138 -0
  265. core/api/services/pr_impact_pipeline/__init__.py +21 -0
  266. core/api/services/pr_impact_pipeline/differ.py +421 -0
  267. core/api/services/pr_impact_pipeline/dispatcher.py +415 -0
  268. core/api/services/pr_impact_pipeline/gc.py +93 -0
  269. core/api/services/pr_impact_pipeline/languages.py +192 -0
  270. core/api/services/pr_impact_pipeline/parser.py +178 -0
  271. core/api/services/pr_impact_pipeline/writer.py +394 -0
  272. core/api/services/pr_service.py +1393 -0
  273. core/api/services/project_paths.py +70 -0
  274. core/api/services/project_status_updates.py +265 -0
  275. core/api/services/providers.py +276 -0
  276. core/api/services/push_service.py +170 -0
  277. core/api/services/reminder_service.py +89 -0
  278. core/api/services/runas.py +41 -0
  279. core/api/services/salience_service.py +69 -0
  280. core/api/services/security_collector.py +281 -0
  281. core/api/services/session_catalog.py +385 -0
  282. core/api/services/session_metrics_service.py +301 -0
  283. core/api/services/session_ops.py +272 -0
  284. core/api/services/session_state.py +173 -0
  285. core/api/services/share_links.py +222 -0
  286. core/api/services/task_transitions.py +146 -0
  287. core/api/services/terminal_metrics.py +462 -0
  288. core/api/services/terminal_metrics_dump.py +203 -0
  289. core/api/services/tmux.py +1205 -0
  290. core/api/services/webhook_service.py +422 -0
  291. core/api/services/workspace_sync.py +164 -0
  292. core/api/templates/__init__.py +1 -0
  293. core/api/templates/markdown_share.py +164 -0
  294. core/api/terminal.py +1031 -0
  295. core/api/tests/__init__.py +0 -0
  296. core/api/tests/test_agent_facing_auth_dependencies.py +132 -0
  297. core/api/tests/test_audit_permissions.py +133 -0
  298. core/api/tests/test_backfill_session_conversations.py +90 -0
  299. core/api/tests/test_backfill_working_seconds_msg.py +129 -0
  300. core/api/tests/test_claude_metrics.py +326 -0
  301. core/api/tests/test_codex_metrics.py +189 -0
  302. core/api/tests/test_finder_paths.py +74 -0
  303. core/api/tests/test_git_ops_merge.py +155 -0
  304. core/api/tests/test_learnings_check_search.py +81 -0
  305. core/api/tests/test_metrics_providers.py +133 -0
  306. core/api/tests/test_migration_087.py +164 -0
  307. core/api/tests/test_migration_088.py +94 -0
  308. core/api/tests/test_migration_089.py +116 -0
  309. core/api/tests/test_openai_responses.py +24 -0
  310. core/api/tests/test_opencode_metrics.py +740 -0
  311. core/api/tests/test_opencode_sessions.py +321 -0
  312. core/api/tests/test_pr_workflow_e2e.py +457 -0
  313. core/api/tests/test_projects_handoffs.py +31 -0
  314. core/api/tests/test_providers.py +138 -0
  315. core/api/tests/test_safety_bridge.py +347 -0
  316. core/api/tests/test_session_catalog.py +142 -0
  317. core/api/tests/test_session_conversations.py +512 -0
  318. core/api/tests/test_session_metrics_service.py +270 -0
  319. core/api/tests/test_session_resume_paths.py +548 -0
  320. core/api/tests/test_session_theme_mode_migration.py +56 -0
  321. core/api/tests/test_sessions_rbac.py +131 -0
  322. core/api/tests/test_share_edit.py +398 -0
  323. core/api/tests/test_share_repo.py +200 -0
  324. core/api/tests/test_terminal_session_manager.py +98 -0
  325. core/api/tests/test_terminal_upload.py +34 -0
  326. core/api/tests/test_tmux.py +272 -0
  327. core/api/tests/test_workspace_sync.py +186 -0
  328. core/api/tests/test_ws_ticket_in_memory.py +73 -0
  329. core/api/use_cases/__init__.py +11 -0
  330. core/api/use_cases/_context.py +89 -0
  331. core/api/use_cases/_errors.py +62 -0
  332. core/api/use_cases/_roles.py +16 -0
  333. core/api/use_cases/audit.py +171 -0
  334. core/api/use_cases/brain.py +1232 -0
  335. core/api/use_cases/costs.py +249 -0
  336. core/api/use_cases/graph.py +1153 -0
  337. core/api/use_cases/handoffs.py +506 -0
  338. core/api/use_cases/ingest_triage.py +1229 -0
  339. core/api/use_cases/learnings.py +538 -0
  340. core/api/use_cases/projects.py +705 -0
  341. core/api/use_cases/pull_requests.py +415 -0
  342. core/api/use_cases/search.py +926 -0
  343. core/api/use_cases/tasks.py +1495 -0
  344. core/api/visibility.py +141 -0
  345. core/cli/__init__.py +5 -0
  346. core/cli/_index_source.py +632 -0
  347. core/cli/_runtime_ctx.py +160 -0
  348. core/cli/_transmute.py +241 -0
  349. core/cli/marvis_doctor.py +704 -0
  350. core/cli/marvis_feedback.py +396 -0
  351. core/cli/marvis_governance.py +315 -0
  352. core/cli/marvis_hooks.py +515 -0
  353. core/cli/marvis_init.py +757 -0
  354. core/cli/marvis_mcp.py +401 -0
  355. core/cli/marvis_runtime.py +855 -0
  356. core/cli/marvis_telemetry.py +228 -0
  357. core/scripts/_drift_check.py +716 -0
  358. core/scripts/_frontmatter.py +66 -0
  359. core/scripts/_graph_writer.py +189 -0
  360. core/scripts/ast_parser.py +1553 -0
  361. core/scripts/install_hooks/__init__.py +1 -0
  362. core/scripts/install_hooks/_config.sh +109 -0
  363. core/scripts/install_hooks/block-dangerous-bash.sh +23 -0
  364. core/scripts/install_hooks/block-db-direct-write.sh +23 -0
  365. core/scripts/install_hooks/block-push-no-task.sh +23 -0
  366. core/scripts/install_hooks/block-staging-to-prod.sh +23 -0
  367. core/scripts/install_hooks/block-subtree-push.sh +23 -0
  368. core/scripts/install_hooks/config.json +53 -0
  369. core/scripts/install_hooks/enforce-no-merge-main.sh +23 -0
  370. core/scripts/install_hooks/enforce-worktree.sh +23 -0
  371. core/scripts/install_hooks/quality-gate.sh +170 -0
  372. core/scripts/install_hooks/safety_bridge.py +968 -0
  373. core/scripts/install_hooks/secret-scan.sh +23 -0
  374. core/scripts/migrate_spike_node_ids.py +122 -0
  375. core/scripts/populate_artifacts.py +2198 -0
  376. core/scripts/populate_cross_project.py +2457 -0
  377. core/scripts/populate_inbox_nodes.py +357 -0
  378. core/scripts/populate_pr_impact.py +267 -0
  379. core/scripts/populate_project_nodes.py +603 -0
  380. core/scripts/populate_touch_counter.py +337 -0
  381. core/scripts/reparse_failed.py +57 -0
  382. core/scripts/safety_bridge.py +968 -0
  383. core/telemetry/__init__.py +9 -0
  384. core/telemetry/client.py +405 -0
  385. core/telemetry/schema.py +122 -0
  386. core/wizard/__init__.py +65 -0
  387. core/wizard/byok_vault.py +147 -0
  388. core/wizard/defaults.py +58 -0
  389. core/wizard/state.py +117 -0
  390. core/wizard/steps.py +70 -0
  391. core/wizard/validation.py +136 -0
  392. marvisx_cli-0.1.0.dist-info/METADATA +201 -0
  393. marvisx_cli-0.1.0.dist-info/RECORD +587 -0
  394. marvisx_cli-0.1.0.dist-info/WHEEL +5 -0
  395. marvisx_cli-0.1.0.dist-info/entry_points.txt +3 -0
  396. marvisx_cli-0.1.0.dist-info/licenses/LICENSE +98 -0
  397. marvisx_cli-0.1.0.dist-info/top_level.txt +3 -0
  398. migrations/001_initial.sql +33 -0
  399. migrations/002_tasks.sql +30 -0
  400. migrations/003_session_management.sql +7 -0
  401. migrations/004_projects_comments.sql +65 -0
  402. migrations/005_session_intelligence.sql +15 -0
  403. migrations/006_settings.sql +12 -0
  404. migrations/007_task_scoring.sql +12 -0
  405. migrations/008_cost_tracking.sql +31 -0
  406. migrations/009_session_card_metrics.sql +3 -0
  407. migrations/010_monitoring.sql +55 -0
  408. migrations/012_agent_api.sql +21 -0
  409. migrations/013_session_complete.sql +8 -0
  410. migrations/015_pull_requests.sql +43 -0
  411. migrations/015_pull_requests_down.sql +5 -0
  412. migrations/016_users_raci.sql +116 -0
  413. migrations/017_task_cost_entries.sql +87 -0
  414. migrations/018_agents.sql +73 -0
  415. migrations/018_agents_down.sql +13 -0
  416. migrations/019_review_feedback.sql +18 -0
  417. migrations/020_pr_commit_sha.sql +4 -0
  418. migrations/021_webhook_events.sql +18 -0
  419. migrations/022_devx_agent_managed.sql +11 -0
  420. migrations/022_devx_agent_managed_down.sql +6 -0
  421. migrations/023_devx_p1_gate.sql +7 -0
  422. migrations/023_devx_p1_gate_down.sql +3 -0
  423. migrations/024_chat_messages.sql +16 -0
  424. migrations/024_pr_conversation_id.sql +8 -0
  425. migrations/024_task_indexes.sql +21 -0
  426. migrations/024_task_indexes_down.sql +7 -0
  427. migrations/025_audit_log.sql +17 -0
  428. migrations/026_agent_tokens.sql +20 -0
  429. migrations/027_teams_auth_phase_b.sql +35 -0
  430. migrations/028_learnings.sql +23 -0
  431. migrations/029_team_roles.sql +14 -0
  432. migrations/030_finder_pins.sql +10 -0
  433. migrations/031_pr_deploy_status.sql +9 -0
  434. migrations/032_task_reminders.sql +7 -0
  435. migrations/033_events_retry_count.sql +6 -0
  436. migrations/033_session_owner.sql +9 -0
  437. migrations/034_notifications.sql +38 -0
  438. migrations/035_shared_links.sql +15 -0
  439. migrations/036_session_index_upgrade.sql +29 -0
  440. migrations/037_pr_approval.sql +15 -0
  441. migrations/038_pr_submitted_by.sql +6 -0
  442. migrations/039_push_subscriptions.sql +17 -0
  443. migrations/040_semantic_search.sql +16 -0
  444. migrations/041_workspaces.sql +63 -0
  445. migrations/042_oidc_providers.sql +24 -0
  446. migrations/043_ci_checks.sql +31 -0
  447. migrations/044_agent_metrics.sql +30 -0
  448. migrations/045_documents_doc_type.sql +5 -0
  449. migrations/046_salience.sql +13 -0
  450. migrations/047_seed_missing_agents.sql +6 -0
  451. migrations/048_fix_agent_paths_roles.sql +5 -0
  452. migrations/049_agent_role_and_learnings_schema.sql +3 -0
  453. migrations/050_session_provider.sql +2 -0
  454. migrations/051_session_launch_profile.sql +4 -0
  455. migrations/052_session_theme_mode.sql +2 -0
  456. migrations/052_task_kind.sql +4 -0
  457. migrations/053_inbox_items.sql +31 -0
  458. migrations/054_inbox_triage_contract.sql +30 -0
  459. migrations/055_inbox_topic_treatment.sql +12 -0
  460. migrations/056_inbox_treatment_read_save.sql +57 -0
  461. migrations/057_session_theme_mode_backfill.sql +4 -0
  462. migrations/058_inbox_item_status_lifecycle.sql +13 -0
  463. migrations/059_inbox_tldr_and_source_scores.sql +18 -0
  464. migrations/060_newsletter.sql +16 -0
  465. migrations/061_inbox_redesign.sql +69 -0
  466. migrations/062_fix_inbox_sources_backfill.sql +37 -0
  467. migrations/063_task_completion_mode.sql +23 -0
  468. migrations/064_judge_mode_setting.sql +4 -0
  469. migrations/065_knowledge_graph_spike.sql +40 -0
  470. migrations/066_digest_ranking_inputs.sql +10 -0
  471. migrations/066_kg_artifact_nodes.sql +129 -0
  472. migrations/067_inbox_digest_selections.sql +28 -0
  473. migrations/067_kg_temporal.sql +53 -0
  474. migrations/068_inbox_digest_app_settings.sql +9 -0
  475. migrations/068_kg_touch_counter.sql +52 -0
  476. migrations/069_kg_doc_types.sql +117 -0
  477. migrations/070_digest_ranking_inputs_recovery.sql +3 -0
  478. migrations/071_inbox_digest_selections_recovery.sql +3 -0
  479. migrations/072_inbox_digest_app_settings_recovery.sql +3 -0
  480. migrations/073_kg_cross_project.sql +216 -0
  481. migrations/073_kg_cross_project_down.sql +77 -0
  482. migrations/074_kg_infra_types.sql +208 -0
  483. migrations/074_kg_infra_types_down.sql +80 -0
  484. migrations/075_kg_file_state_recovery.sql +35 -0
  485. migrations/075_kg_file_state_recovery_down.sql +5 -0
  486. migrations/076_kg_watcher_state.sql +33 -0
  487. migrations/076_kg_watcher_state_down.sql +3 -0
  488. migrations/077_kg_doc_types_extend.sql +226 -0
  489. migrations/077_kg_doc_types_extend_down.sql +80 -0
  490. migrations/078_kg_fts5.sql +102 -0
  491. migrations/078_kg_fts5_down.sql +14 -0
  492. migrations/079_kg_missing_indexes.sql +31 -0
  493. migrations/079_kg_missing_indexes_down.sql +10 -0
  494. migrations/080_kg_fts5_extended.sql +232 -0
  495. migrations/080_kg_fts5_extended_down.sql +25 -0
  496. migrations/081_kg_lens_indexes.sql +9 -0
  497. migrations/081_kg_lens_indexes_down.sql +3 -0
  498. migrations/082_kg_pins.sql +26 -0
  499. migrations/082_kg_pins_down.sql +14 -0
  500. migrations/083_kg_graph_nodes_degree.sql +20 -0
  501. migrations/083_kg_graph_nodes_degree_down.sql +15 -0
  502. migrations/084_drop_legacy_scheduler_tables.sql +58 -0
  503. migrations/084_drop_legacy_scheduler_tables_down.sql +112 -0
  504. migrations/085_kg_edge_resolves_to.sql +142 -0
  505. migrations/085_kg_edge_resolves_to_down.sql +66 -0
  506. migrations/086_project_status_updates_feed.sql +20 -0
  507. migrations/086_project_status_updates_feed_down.sql +36 -0
  508. migrations/087_session_metrics_dual.sql +50 -0
  509. migrations/087_session_metrics_dual_down.sql +21 -0
  510. migrations/088_rename_context_pct_legacy.sql +23 -0
  511. migrations/088_rename_context_pct_legacy_down.sql +8 -0
  512. migrations/089_session_metrics_equivalent_cost.sql +26 -0
  513. migrations/089_session_metrics_equivalent_cost_down.sql +11 -0
  514. migrations/090_kg_inbox_node_type.sql +26 -0
  515. migrations/090_kg_inbox_node_type_down.sql +20 -0
  516. migrations/091_kg_inbox_node_type_check.sql +265 -0
  517. migrations/091_kg_inbox_node_type_check_down.sql +129 -0
  518. migrations/092_sessions_activity_state_ts.sql +29 -0
  519. migrations/092_sessions_activity_state_ts_down.sql +14 -0
  520. migrations/093_sessions_activity_state_column.sql +29 -0
  521. migrations/093_sessions_activity_state_column_down.sql +10 -0
  522. migrations/094_ingest_pending.sql +55 -0
  523. migrations/094_ingest_pending_down.sql +15 -0
  524. migrations/095_kg_intent_first.sql +77 -0
  525. migrations/095_kg_intent_first_down.sql +25 -0
  526. migrations/096_kg_xlsx_artifact_prefix.sql +17 -0
  527. migrations/096_kg_xlsx_artifact_prefix_down.sql +11 -0
  528. migrations/097_ingest_change_history.sql +37 -0
  529. migrations/097_ingest_change_history_down.sql +13 -0
  530. migrations/098_kg_node_type_business.sql +254 -0
  531. migrations/098_kg_node_type_business_down.sql +195 -0
  532. migrations/099_kg_edges_restore_weight.sql +58 -0
  533. migrations/099_kg_edges_restore_weight_down.sql +12 -0
  534. migrations/100_kg_enriched_at.sql +25 -0
  535. migrations/100_kg_enriched_at_down.sql +12 -0
  536. migrations/101_local_llm_shadow_comparisons.sql +66 -0
  537. migrations/101_local_llm_shadow_comparisons_down.sql +15 -0
  538. migrations/102_promote_llm_costs.sql +69 -0
  539. migrations/102_promote_llm_costs_down.sql +19 -0
  540. migrations/103_ingest_skipped_log.sql +46 -0
  541. migrations/103_ingest_skipped_log_down.sql +15 -0
  542. migrations/120_docs_governance.sql +50 -0
  543. migrations/120_docs_governance_down.sql +11 -0
  544. migrations/121_notification_event_fk_cleanup.sql +21 -0
  545. migrations/121_notification_event_fk_cleanup_down.sql +10 -0
  546. migrations/122_docs_drift_history.sql +34 -0
  547. migrations/122_docs_drift_history_down.sql +15 -0
  548. migrations/123_ingest_parser_waiting_status.sql +69 -0
  549. migrations/123_ingest_parser_waiting_status_down.sql +69 -0
  550. migrations/124_heypocket_recordings.sql +63 -0
  551. migrations/124_heypocket_recordings_down.sql +13 -0
  552. migrations/125_kg_node_type_record.sql +219 -0
  553. migrations/125_kg_node_type_record_down.sql +205 -0
  554. migrations/126_ingest_terminal_upload_source_kind.sql +69 -0
  555. migrations/126_ingest_terminal_upload_source_kind_down.sql +69 -0
  556. migrations/127_brain_v1_substrate.sql +200 -0
  557. migrations/127_brain_v1_substrate_down.sql +32 -0
  558. migrations/128_brain_drift_signals.sql +157 -0
  559. migrations/128_brain_drift_signals_down.sql +23 -0
  560. migrations/129_brain_memory_operations.sql +232 -0
  561. migrations/129_brain_memory_operations_down.sql +27 -0
  562. migrations/130_brain_findings.sql +258 -0
  563. migrations/130_brain_findings_down.sql +29 -0
  564. migrations/132_kg_pr_modifies.sql +242 -0
  565. migrations/132_kg_pr_modifies_down.sql +99 -0
  566. migrations/133_brain_v1_2_direction_schema.sql +476 -0
  567. migrations/133_brain_v1_2_direction_schema_down.sql +273 -0
  568. migrations/134_brain_journal_narrative_polished.sql +8 -0
  569. migrations/134_brain_journal_narrative_polished_down.sql +6 -0
  570. migrations/135_kg_edges_provider.sql +21 -0
  571. migrations/136_documents_fts.sql +56 -0
  572. migrations/137_promote_llm_costs.sql +59 -0
  573. migrations/137_promote_llm_costs_down.sql +19 -0
  574. migrations/138_ingest_api_keys.sql +39 -0
  575. migrations/138_ingest_api_keys_down.sql +8 -0
  576. migrations/139_ingest_pending_ingress.sql +91 -0
  577. migrations/139_ingest_pending_ingress_down.sql +73 -0
  578. migrations/140_ingest_idempotency_quota.sql +45 -0
  579. migrations/140_ingest_idempotency_quota_down.sql +9 -0
  580. migrations/141_ingest_pending_metadata.sql +16 -0
  581. migrations/141_ingest_pending_metadata_down.sql +7 -0
  582. migrations/142_llm_function_config.sql +36 -0
  583. migrations/142_llm_function_config_down.sql +8 -0
  584. migrations/143_kg_code_embeddings.sql +25 -0
  585. migrations/143_kg_code_embeddings_down.sql +5 -0
  586. migrations/__init__.py +4 -0
  587. projects/_template/project.yaml +46 -0
@@ -0,0 +1,3100 @@
1
+ # v1.14.0 - 2026-04-14 - send_message_to_session uses get_write_db (refactor batch 4/6)
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import logging
6
+ import shlex
7
+ import time as _time
8
+ import re
9
+ import uuid as uuid_mod
10
+ from datetime import datetime, timezone
11
+ from typing import Any
12
+
13
+ import aiosqlite
14
+ from fastapi import APIRouter, Depends, HTTPException, Path as PathParam, Request
15
+
16
+ from core.api.config import settings
17
+ from core.api.db import acquire_db, get_db, get_write_db, write_db
18
+
19
+
20
+ def _get_session_manager():
21
+ """Lazy import to avoid circular dependency with terminal.py."""
22
+ from core.api.terminal import session_manager
23
+
24
+ return session_manager
25
+
26
+
27
+ from core.api.models import (
28
+ SendMessageBody,
29
+ SessionCatalogModel,
30
+ SessionCatalogProvider,
31
+ SessionCatalogResponse,
32
+ SessionCreate,
33
+ SessionInfo,
34
+ SessionMetricsResponse,
35
+ SessionPermissionPreset,
36
+ SessionReorder,
37
+ SessionStateUpdate,
38
+ SessionUpdate,
39
+ UserInfo,
40
+ )
41
+ from core.api.rbac import require_role, require_scope
42
+ from core.api.security import (
43
+ get_current_user,
44
+ get_current_user_or_agent,
45
+ resolve_session_owner,
46
+ )
47
+ from core.api.services import session_state as session_state_svc
48
+ from core.api.services import opencode_sessions
49
+ from core.api.services import claude_metrics, codex_metrics, tmux
50
+ from core.api.services.metrics_providers import get_metrics_provider
51
+ from core.api.services.project_paths import candidate_project_paths, resolve_project_path
52
+ from core.api.services.providers import (
53
+ ALL_KNOWN_PROCESS_NAMES,
54
+ build_start_command,
55
+ get_provider,
56
+ is_binary_available,
57
+ )
58
+ from core.api.services.session_catalog import list_catalog_models, list_provider_definitions
59
+ from core.api.services.session_ops import build_session_start_spec
60
+
61
+ _CONVERSATION_ID_RE = re.compile(
62
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
63
+ )
64
+ _UUID_V4_PATTERN = (
65
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
66
+ )
67
+
68
+ logger = logging.getLogger(__name__)
69
+
70
+ _GLOBAL_AGENT_SESSION_VIEWERS = {"marvisx", "console-api", "marvis-local"}
71
+
72
+
73
+ async def _table_has_columns(
74
+ db: aiosqlite.Connection,
75
+ table_name: str,
76
+ required_columns: set[str],
77
+ ) -> bool:
78
+ async with db.execute(f"PRAGMA table_info({table_name})") as cursor:
79
+ rows = await cursor.fetchall()
80
+ return required_columns.issubset({row["name"] for row in rows})
81
+
82
+
83
+ async def _tmux_user_env_for_session(
84
+ db: aiosqlite.Connection,
85
+ current_user: UserInfo,
86
+ ) -> dict[str, str] | None:
87
+ if not (settings.multi_tenant_enabled or settings.uid_isolation_enabled):
88
+ return None
89
+
90
+ user_id = current_user.user_id or current_user.username
91
+ env = {
92
+ "DEPLOY_MODE": settings.deploy_mode,
93
+ "TENANT_SLUG": settings.deploy_mode,
94
+ "USER_ID": user_id,
95
+ }
96
+
97
+ if not settings.uid_isolation_enabled:
98
+ return env
99
+
100
+ has_uid_columns = await _table_has_columns(
101
+ db,
102
+ "users",
103
+ {"uid_index", "assigned_uid"},
104
+ )
105
+ if not has_uid_columns:
106
+ raise HTTPException(
107
+ status_code=500,
108
+ detail="UID isolation is enabled but users.uid_index/assigned_uid are missing",
109
+ )
110
+
111
+ async with db.execute(
112
+ "SELECT uid_index, assigned_uid FROM users WHERE id = ?",
113
+ [user_id],
114
+ ) as cursor:
115
+ row = await cursor.fetchone()
116
+
117
+ if row is None or not row["uid_index"] or not row["assigned_uid"]:
118
+ raise HTTPException(
119
+ status_code=403,
120
+ detail="User has no UID isolation mapping",
121
+ )
122
+
123
+ uid_index = int(row["uid_index"])
124
+ assigned_uid = int(row["assigned_uid"])
125
+ if uid_index < 1 or uid_index > settings.uid_pool_size:
126
+ raise HTTPException(status_code=500, detail="User UID index is out of range")
127
+ if assigned_uid != settings.uid_pool_base + uid_index - 1:
128
+ raise HTTPException(status_code=500, detail="User UID assignment is inconsistent")
129
+
130
+ username = f"{settings.uid_pool_prefix}-{uid_index:02d}"
131
+ env["USER_UID_INDEX"] = str(uid_index)
132
+ env["USER_UID"] = str(assigned_uid)
133
+ env["USER_HOME"] = f"/data/users/{username}"
134
+ return env
135
+
136
+
137
+ def _get_system_uptime_seconds() -> float:
138
+ """Read system uptime from /proc/uptime. Returns 0.0 on error."""
139
+ try:
140
+ with open("/proc/uptime") as f:
141
+ return float(f.read().split()[0])
142
+ except Exception:
143
+ return 0.0
144
+
145
+
146
+ def _created_epoch_from_iso(value: str | None) -> float | None:
147
+ if not value:
148
+ return None
149
+ try:
150
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
151
+ except (ValueError, AttributeError):
152
+ return None
153
+ if dt.tzinfo is None:
154
+ dt = dt.replace(tzinfo=timezone.utc)
155
+ return dt.timestamp()
156
+
157
+
158
+ router = APIRouter(prefix="/sessions", tags=["sessions"])
159
+
160
+ # Sessions cache (TTL-based, shared full-list — filtering applied at endpoint level)
161
+ _sessions_cache: list | None = None
162
+ _sessions_cache_ts: float = 0.0
163
+ _sessions_cache_refresh_task: asyncio.Task[list[SessionInfo]] | None = None
164
+ _sessions_cache_generation: int = 0
165
+ _sessions_cache_last_state = "empty"
166
+ _sessions_cache_pending_patch_state: str | None = None
167
+ _sessions_last_sync_timings: dict[str, Any] | None = None
168
+ _sessions_cache_lock = asyncio.Lock()
169
+ _CACHE_TTL = (
170
+ 15 # seconds (was 2.5 — too short, caused ~500ms _sync_sessions on every request)
171
+ )
172
+ _CACHE_STALE_TTL = 120 # seconds: serve stale during background refresh
173
+
174
+
175
+ def _invalidate_sessions_cache() -> None:
176
+ """Force next list_sessions call to re-sync from tmux + DB."""
177
+ global _sessions_cache, _sessions_cache_ts, _sessions_cache_last_state
178
+ global _sessions_cache_refresh_task, _sessions_cache_generation
179
+ global _sessions_cache_pending_patch_state
180
+ _sessions_cache = None
181
+ _sessions_cache_ts = 0.0
182
+ _sessions_cache_generation += 1
183
+ _sessions_cache_refresh_task = None
184
+ _sessions_cache_last_state = "invalidated"
185
+ _sessions_cache_pending_patch_state = None
186
+
187
+
188
+ def _patch_sessions_cache_activity_state(session_name: str, state: str) -> bool:
189
+ """Patch a state-only update into the existing full-list cache.
190
+
191
+ The cache is unfiltered and RBAC is applied after reads. This helper only
192
+ updates an existing cached session and never creates a cache entry from a
193
+ state event, so it cannot broaden visibility.
194
+ """
195
+ global _sessions_cache_last_state, _sessions_cache_pending_patch_state
196
+ if _sessions_cache is None:
197
+ _sessions_cache_last_state = "state_patch_no_cache"
198
+ return False
199
+ for session in _sessions_cache:
200
+ if session.name == session_name:
201
+ session.activity_state = state
202
+ _sessions_cache_last_state = "state_patched"
203
+ _sessions_cache_pending_patch_state = "state_patched"
204
+ return True
205
+ _sessions_cache_last_state = "state_patch_miss"
206
+ return False
207
+
208
+
209
+ def _record_sessions_control_event(
210
+ request: Request | None,
211
+ *,
212
+ kind: str,
213
+ duration_ms: float,
214
+ metadata: dict[str, Any] | None = None,
215
+ ) -> None:
216
+ if request is None:
217
+ return
218
+ app = getattr(request, "app", None)
219
+ app_state = getattr(app, "state", None)
220
+ collector = getattr(app_state, "terminal_metrics", None)
221
+ try:
222
+ from core.api.services.terminal_metrics import TerminalMetricsCollector
223
+ except Exception:
224
+ return
225
+ if isinstance(collector, TerminalMetricsCollector):
226
+ collector.record_sessions_control_event(
227
+ kind=kind,
228
+ duration_ms=duration_ms,
229
+ metadata=metadata,
230
+ )
231
+
232
+
233
+ async def _refresh_sessions_cache_from_pool() -> list[SessionInfo]:
234
+ async with acquire_db() as db:
235
+ return await _sync_sessions_read_only(db)
236
+
237
+
238
+ def _finish_sessions_cache_refresh(
239
+ task: asyncio.Task[list[SessionInfo]],
240
+ generation: int,
241
+ ) -> None:
242
+ global _sessions_cache, _sessions_cache_ts, _sessions_cache_refresh_task
243
+ if generation != _sessions_cache_generation:
244
+ return
245
+ if _sessions_cache_refresh_task is task:
246
+ _sessions_cache_refresh_task = None
247
+ try:
248
+ sessions = task.result()
249
+ except asyncio.CancelledError:
250
+ return
251
+ except Exception:
252
+ logger.warning("sessions cache refresh failed", exc_info=True)
253
+ return
254
+ _sessions_cache = sessions
255
+ _sessions_cache_ts = _time.monotonic()
256
+
257
+
258
+ def _start_sessions_cache_refresh_unlocked(
259
+ db: aiosqlite.Connection,
260
+ *,
261
+ detached: bool,
262
+ ) -> asyncio.Task[list[SessionInfo]]:
263
+ global _sessions_cache_refresh_task
264
+ if _sessions_cache_refresh_task and not _sessions_cache_refresh_task.done():
265
+ return _sessions_cache_refresh_task
266
+ generation = _sessions_cache_generation
267
+ coro = _refresh_sessions_cache_from_pool() if detached else _sync_sessions_read_only(db)
268
+ task = asyncio.create_task(coro)
269
+ task.add_done_callback(
270
+ lambda completed, task_generation=generation: _finish_sessions_cache_refresh(
271
+ completed,
272
+ task_generation,
273
+ )
274
+ )
275
+ _sessions_cache_refresh_task = task
276
+ return task
277
+
278
+
279
+ async def _get_sessions_cached(db: aiosqlite.Connection) -> list[SessionInfo]:
280
+ """Return shared sessions with singleflight + stale-while-revalidate.
281
+
282
+ The cached value is the unfiltered full session list. RBAC filtering remains
283
+ request-scoped in list_sessions(), so stale serving cannot leak sessions
284
+ across users.
285
+ """
286
+ global _sessions_cache_last_state
287
+ now = _time.monotonic()
288
+ if _sessions_cache is not None and (now - _sessions_cache_ts) < _CACHE_TTL:
289
+ _sessions_cache_last_state = "hit"
290
+ return _sessions_cache
291
+
292
+ async with _sessions_cache_lock:
293
+ now = _time.monotonic()
294
+ if _sessions_cache is not None and (now - _sessions_cache_ts) < _CACHE_TTL:
295
+ _sessions_cache_last_state = "hit_after_lock"
296
+ return _sessions_cache
297
+
298
+ if (
299
+ _CACHE_TTL > 0
300
+ and _sessions_cache is not None
301
+ and (now - _sessions_cache_ts) < _CACHE_STALE_TTL
302
+ ):
303
+ _start_sessions_cache_refresh_unlocked(db, detached=True)
304
+ _sessions_cache_last_state = "stale_background_refresh"
305
+ return _sessions_cache
306
+
307
+ refresh_task = _start_sessions_cache_refresh_unlocked(db, detached=False)
308
+ _sessions_cache_last_state = "miss_wait"
309
+
310
+ return await asyncio.shield(refresh_task)
311
+
312
+
313
+ def _can_view_all_sessions(current_user: UserInfo) -> bool:
314
+ if current_user.system_role in ("admin", "super_admin"):
315
+ return True
316
+ canonical_username = current_user.username.removeprefix("agent:")
317
+ return (
318
+ current_user.user_type == "agent"
319
+ and canonical_username in _GLOBAL_AGENT_SESSION_VIEWERS
320
+ )
321
+
322
+
323
+ DB_COLUMNS = (
324
+ "name, display_name, pinned, sort_order, group_name, project_slug, session_uuid, "
325
+ "created_at, last_active, conversation_id, hibernated, model, launch_model, "
326
+ "permission_preset, theme_mode, bootstrap_message, "
327
+ # PR3: rename 088 — source `last_context_pct` (API field) from
328
+ # `last_context_pct_real` (true ratio). `last_context_pct_legacy`
329
+ # column survives read-only for forensics.
330
+ "last_context_pct_real AS last_context_pct, last_context_pct_legacy, "
331
+ "last_cost_usd, last_message_count, auto_hibernate_minutes, working_seconds, agent_managed, "
332
+ "owner_id, provider, "
333
+ # PR2 dual metrics (migration 087)
334
+ "last_context_pct_real, last_context_pct_scaled, "
335
+ "last_cost_conversation_usd, last_cost_session_usd, last_cost_session_incomplete, "
336
+ "last_input_tokens, last_output_tokens, last_reasoning_tokens, "
337
+ "working_seconds_msg, metrics_refreshed_at, pricing_version, "
338
+ # PR4 shadow cost (migration 089)
339
+ "last_cost_conversation_equivalent_usd, last_cost_session_equivalent_usd, "
340
+ "last_cost_equivalent_pricing_version, "
341
+ # PR1 event-driven session state (migrations 092 + 093)
342
+ "activity_state, activity_state_updated_at"
343
+ )
344
+
345
+ # Fresh-event threshold for activity_state. If the DB-stored event timestamp
346
+ # is within this window, trust the column. Global session-list reads do not
347
+ # capture panes; stale/missing events surface as unknown instead of scraping.
348
+ _ACTIVITY_EVENT_TTL_SECS = 60.0
349
+
350
+
351
+ def _index_processes_by_parent(
352
+ processes: dict[int, tmux.ProcessSnapshot],
353
+ ) -> dict[int, list[tmux.ProcessSnapshot]]:
354
+ by_parent: dict[int, list[tmux.ProcessSnapshot]] = {}
355
+ for process in processes.values():
356
+ by_parent.setdefault(process.parent_pid, []).append(process)
357
+ return by_parent
358
+
359
+
360
+ def _session_process_snapshot(
361
+ session: SessionInfo,
362
+ pane_pids: dict[str, int],
363
+ processes_by_parent: dict[int, list[tmux.ProcessSnapshot]],
364
+ ) -> tmux.ProcessSnapshot | None:
365
+ pane_pid = pane_pids.get(session.name)
366
+ if pane_pid is None:
367
+ return None
368
+
369
+ children = processes_by_parent.get(pane_pid, [])
370
+ if not children:
371
+ return None
372
+
373
+ try:
374
+ process_names = get_provider(session.provider).process_names
375
+ except ValueError:
376
+ process_names = ALL_KNOWN_PROCESS_NAMES
377
+ for process_name in process_names:
378
+ for child in children:
379
+ if child.command == process_name:
380
+ return child
381
+ return None
382
+
383
+
384
+ def _detect_project_from_path(cwd: str) -> str | None:
385
+ """Match a filesystem path to a project slug using the project index."""
386
+ from core.api.routers.projects import (
387
+ _build_project_index,
388
+ _project_index,
389
+ _index_built_at,
390
+ _INDEX_TTL,
391
+ )
392
+ import time as _t
393
+
394
+ if _t.monotonic() - _index_built_at > _INDEX_TTL:
395
+ _build_project_index()
396
+ for slug, entry in _project_index.items():
397
+ path = (
398
+ str(entry.repo_path)
399
+ if entry.repo_path
400
+ else str(entry.metadata_path.resolve())
401
+ )
402
+ if cwd == path or cwd.startswith(path + "/"):
403
+ return slug
404
+ return None
405
+
406
+
407
+ def _resolve_conversation_cwd(
408
+ conversation_id: str | None, project_slug: str | None
409
+ ) -> str | None:
410
+ """Find the Claude JSONL cwd for a stored conversation_id.
411
+
412
+ Primary lookup uses the session project path. Workspace remains as a legacy
413
+ fallback for rows that were contaminated before project-aware lookup landed.
414
+ """
415
+ if not conversation_id or not _CONVERSATION_ID_RE.match(conversation_id):
416
+ return None
417
+ return claude_metrics.find_conversation_cwd(
418
+ conversation_id,
419
+ candidate_project_paths(project_slug),
420
+ )
421
+
422
+
423
+ def _project_bootstrap_message(project_slug: str | None) -> str | None:
424
+ _ = project_slug
425
+ return None
426
+
427
+
428
+ def _row_value(row: aiosqlite.Row | None, key: str) -> Any:
429
+ if row is None or key not in row.keys():
430
+ return None
431
+ return row[key]
432
+
433
+
434
+ async def _fetch_session_meta_row(
435
+ db: aiosqlite.Connection,
436
+ name: str,
437
+ ) -> aiosqlite.Row | None:
438
+ cursor = await db.execute(
439
+ f"SELECT {DB_COLUMNS} FROM sessions_meta WHERE name = ?",
440
+ (name,),
441
+ )
442
+ return await cursor.fetchone()
443
+
444
+
445
+ def _is_claimable_marvisx_orphan(
446
+ row: aiosqlite.Row | None,
447
+ current_user: UserInfo,
448
+ ) -> bool:
449
+ if row is None:
450
+ return True
451
+
452
+ owner_id = _row_value(row, "owner_id")
453
+ current_owner_id = current_user.user_id or None
454
+ if owner_id and owner_id != current_owner_id:
455
+ return False
456
+
457
+ return (
458
+ not owner_id
459
+ or not _row_value(row, "session_uuid")
460
+ or not _row_value(row, "provider")
461
+ or not (_row_value(row, "model") or _row_value(row, "launch_model"))
462
+ )
463
+
464
+
465
+ async def _persist_session_create_metadata(
466
+ db: aiosqlite.Connection,
467
+ *,
468
+ name: str,
469
+ current_user: UserInfo,
470
+ project_slug: str | None,
471
+ provider_name: str,
472
+ selected_model: str | None,
473
+ selected_model_id: str | None,
474
+ permission_preset: str | None,
475
+ theme_mode: str | None,
476
+ bootstrap_message: str | None,
477
+ ) -> tuple[str, str, str]:
478
+ existing = await _fetch_session_meta_row(db, name)
479
+ now = datetime.now(timezone.utc).isoformat()
480
+ session_uuid = _row_value(existing, "session_uuid") or str(uuid_mod.uuid4())
481
+ created_at = _row_value(existing, "created_at") or now
482
+ owner_id = current_user.user_id or None
483
+
484
+ try:
485
+ await db.execute(
486
+ "INSERT OR IGNORE INTO sessions_meta "
487
+ "(name, session_uuid, created_at, last_active, owner_id, "
488
+ "project_slug, provider, model, launch_model, permission_preset, "
489
+ "theme_mode, bootstrap_message) "
490
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
491
+ (
492
+ name,
493
+ session_uuid,
494
+ created_at,
495
+ now,
496
+ owner_id,
497
+ project_slug,
498
+ provider_name,
499
+ selected_model,
500
+ selected_model_id,
501
+ permission_preset,
502
+ theme_mode,
503
+ bootstrap_message,
504
+ ),
505
+ )
506
+ await db.execute(
507
+ """UPDATE sessions_meta SET
508
+ session_uuid = COALESCE(NULLIF(session_uuid, ''), ?),
509
+ created_at = COALESCE(created_at, ?),
510
+ last_active = ?,
511
+ owner_id = ?,
512
+ project_slug = ?,
513
+ provider = ?,
514
+ model = ?,
515
+ launch_model = ?,
516
+ permission_preset = ?,
517
+ theme_mode = ?,
518
+ bootstrap_message = ?
519
+ WHERE name = ?""",
520
+ (
521
+ session_uuid,
522
+ created_at,
523
+ now,
524
+ owner_id,
525
+ project_slug,
526
+ provider_name,
527
+ selected_model,
528
+ selected_model_id,
529
+ permission_preset,
530
+ theme_mode,
531
+ bootstrap_message,
532
+ name,
533
+ ),
534
+ )
535
+ except Exception:
536
+ # Fallback pre-migration: provider/owner/theme columns may not exist yet.
537
+ await db.execute(
538
+ "INSERT OR REPLACE INTO sessions_meta "
539
+ "(name, session_uuid, created_at, last_active, owner_id, project_slug, model) "
540
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
541
+ (
542
+ name,
543
+ session_uuid,
544
+ created_at,
545
+ now,
546
+ owner_id,
547
+ project_slug,
548
+ selected_model,
549
+ ),
550
+ )
551
+
552
+ return session_uuid, created_at, now
553
+
554
+
555
+ async def _process_command(pid: int | None) -> str:
556
+ if not pid:
557
+ return ""
558
+ try:
559
+ proc = await asyncio.create_subprocess_exec(
560
+ "ps",
561
+ "-p",
562
+ str(pid),
563
+ "-o",
564
+ "args=",
565
+ stdout=asyncio.subprocess.PIPE,
566
+ stderr=asyncio.subprocess.DEVNULL,
567
+ )
568
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=2)
569
+ return stdout.decode("utf-8", errors="replace").strip()
570
+ except (asyncio.TimeoutError, OSError):
571
+ return ""
572
+
573
+
574
+ def _infer_provider_from_runtime(status: str | None, command: str) -> str | None:
575
+ haystack = f"{status or ''} {command}".lower()
576
+ for provider in ("opencode", "codex", "gemini", "claude"):
577
+ if provider in haystack:
578
+ return provider
579
+ return None
580
+
581
+
582
+ def _infer_model_from_command(command: str) -> str | None:
583
+ if not command:
584
+ return None
585
+ try:
586
+ parts = shlex.split(command)
587
+ except ValueError:
588
+ return None
589
+ for idx, part in enumerate(parts):
590
+ if part in {"-m", "--model"} and idx + 1 < len(parts):
591
+ return parts[idx + 1]
592
+ return None
593
+
594
+
595
+ def _infer_project_from_command(command: str) -> str | None:
596
+ if not command:
597
+ return None
598
+ try:
599
+ parts = shlex.split(command)
600
+ except ValueError:
601
+ return None
602
+ for idx, part in enumerate(parts):
603
+ if part in {"--add-dir", "--include-directories"} and idx + 1 < len(parts):
604
+ detected = _detect_project_from_path(parts[idx + 1])
605
+ if detected:
606
+ return detected
607
+ return None
608
+
609
+
610
+ async def reconcile_sessions_metadata() -> int:
611
+ """Persist tmux-only sessions and backfill missing stable metadata.
612
+
613
+ Public session-list reads use the read-only DB pool, so they can expose a
614
+ live tmux session that is not yet persisted. This maintenance helper runs
615
+ out of band, performs tmux/process inspection outside the writer lock, then
616
+ applies a small metadata-only write batch.
617
+ """
618
+ tmux_sessions = await tmux.list_sessions()
619
+ tmux_names = {s["name"] for s in tmux_sessions}
620
+ statuses = await tmux.get_all_session_statuses() if tmux_names else {}
621
+
622
+ async with acquire_db() as db:
623
+ cursor = await db.execute(
624
+ "SELECT name, session_uuid, created_at, last_active, project_slug, "
625
+ "provider, model, launch_model FROM sessions_meta"
626
+ )
627
+ db_rows = {row["name"]: row for row in await cursor.fetchall()}
628
+
629
+ now = datetime.now(timezone.utc).isoformat()
630
+ live_updates: list[dict[str, str | None]] = []
631
+ for name in tmux_names:
632
+ row = db_rows.get(name)
633
+ if (
634
+ row
635
+ and row["session_uuid"]
636
+ and row["created_at"]
637
+ and row["project_slug"]
638
+ and row["provider"]
639
+ and (row["model"] or row["launch_model"])
640
+ ):
641
+ continue
642
+
643
+ cwd = await tmux.get_pane_cwd(name)
644
+ created_epoch = await tmux.get_pane_start_time(name)
645
+ created_at = (
646
+ datetime.fromtimestamp(created_epoch, timezone.utc).isoformat()
647
+ if created_epoch
648
+ else now
649
+ )
650
+ pid = await tmux.get_cli_pid(name, process_names=ALL_KNOWN_PROCESS_NAMES)
651
+ command = await _process_command(pid)
652
+ provider = (
653
+ (row["provider"] if row and row["provider"] else None)
654
+ or _infer_provider_from_runtime(statuses.get(name), command)
655
+ or "claude"
656
+ )
657
+ model = (
658
+ (row["model"] if row and row["model"] else None)
659
+ or (row["launch_model"] if row and row["launch_model"] else None)
660
+ or _infer_model_from_command(command)
661
+ )
662
+ project_slug = (
663
+ (row["project_slug"] if row and row["project_slug"] else None)
664
+ or _infer_project_from_command(command)
665
+ or (_detect_project_from_path(cwd) if cwd else None)
666
+ )
667
+ live_updates.append(
668
+ {
669
+ "name": name,
670
+ "session_uuid": (
671
+ row["session_uuid"]
672
+ if row and row["session_uuid"]
673
+ else str(uuid_mod.uuid4())
674
+ ),
675
+ "created_at": (
676
+ row["created_at"] if row and row["created_at"] else created_at
677
+ ),
678
+ "last_active": (
679
+ row["last_active"] if row and row["last_active"] else now
680
+ ),
681
+ "project_slug": project_slug,
682
+ "provider": provider,
683
+ "model": model,
684
+ "launch_model": (
685
+ row["launch_model"] if row and row["launch_model"] else model
686
+ ),
687
+ }
688
+ )
689
+
690
+ dead_names = set(db_rows.keys()) - tmux_names
691
+ should_cleanup_dead = bool(
692
+ dead_names and (tmux_names or _get_system_uptime_seconds() > 300)
693
+ )
694
+ if not live_updates and not should_cleanup_dead:
695
+ return 0
696
+
697
+ changed = 0
698
+ async with write_db() as db:
699
+ if should_cleanup_dead:
700
+ for dead in dead_names:
701
+ await db.execute("DELETE FROM sessions_meta WHERE name = ?", (dead,))
702
+ changed += 1
703
+ for item in live_updates:
704
+ await db.execute(
705
+ "INSERT OR IGNORE INTO sessions_meta "
706
+ "(name, session_uuid, created_at, last_active, project_slug, provider, model, launch_model) "
707
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
708
+ (
709
+ item["name"],
710
+ item["session_uuid"],
711
+ item["created_at"],
712
+ item["last_active"],
713
+ item["project_slug"],
714
+ item["provider"],
715
+ item["model"],
716
+ item["launch_model"],
717
+ ),
718
+ )
719
+ await db.execute(
720
+ """UPDATE sessions_meta SET
721
+ session_uuid = COALESCE(NULLIF(session_uuid, ''), ?),
722
+ created_at = COALESCE(created_at, ?),
723
+ last_active = COALESCE(last_active, ?),
724
+ project_slug = COALESCE(NULLIF(project_slug, ''), ?),
725
+ provider = COALESCE(NULLIF(provider, ''), ?),
726
+ model = COALESCE(NULLIF(model, ''), ?),
727
+ launch_model = COALESCE(NULLIF(launch_model, ''), ?)
728
+ WHERE name = ?""",
729
+ (
730
+ item["session_uuid"],
731
+ item["created_at"],
732
+ item["last_active"],
733
+ item["project_slug"],
734
+ item["provider"],
735
+ item["model"],
736
+ item["launch_model"],
737
+ item["name"],
738
+ ),
739
+ )
740
+ changed += 1
741
+ if changed:
742
+ _invalidate_sessions_cache()
743
+ return changed
744
+
745
+
746
+ async def _recreate_tmux_session(name: str, start_command: str) -> None:
747
+ """Recreate a tmux session with the same name and a fresh start command."""
748
+ if await tmux.session_exists(name) and not await tmux.kill_session(name):
749
+ raise HTTPException(status_code=500, detail="Failed to restart tmux session")
750
+ if not await tmux.create_session(name, start_command=start_command):
751
+ raise HTTPException(status_code=500, detail="Failed to restart session")
752
+
753
+
754
+ async def _resolve_opencode_session_id(
755
+ *,
756
+ db: aiosqlite.Connection,
757
+ name: str,
758
+ launch_dir: str,
759
+ stored_session_id: str | None,
760
+ created_at: str | None,
761
+ ) -> str | None:
762
+ if opencode_sessions.is_opencode_session_id(stored_session_id):
763
+ return stored_session_id
764
+ session_id = await asyncio.to_thread(
765
+ opencode_sessions.find_session_id_for_created_at,
766
+ launch_dir,
767
+ created_at,
768
+ )
769
+ if session_id and session_id != stored_session_id:
770
+ await db.execute(
771
+ "UPDATE sessions_meta SET conversation_id = ? WHERE name = ?",
772
+ (session_id, name),
773
+ )
774
+ return session_id
775
+
776
+
777
+ async def _capture_new_opencode_session_id(
778
+ *,
779
+ db: aiosqlite.Connection,
780
+ name: str,
781
+ launch_dir: str,
782
+ launched_at_ms: int,
783
+ ) -> str | None:
784
+ session_id = await opencode_sessions.wait_for_new_session_id(
785
+ launch_dir,
786
+ launched_at_ms,
787
+ )
788
+ if session_id:
789
+ await db.execute(
790
+ "UPDATE sessions_meta SET conversation_id = ? WHERE name = ?",
791
+ (session_id, name),
792
+ )
793
+ return session_id
794
+
795
+
796
+ async def _send_bootstrap_message(
797
+ name: str,
798
+ provider_name: str,
799
+ message: str | None,
800
+ ) -> None:
801
+ if not message:
802
+ return
803
+ try:
804
+ provider_config = get_provider(provider_name)
805
+ for _ in range(30):
806
+ if not await tmux.session_exists(name):
807
+ return
808
+ status = await tmux.get_session_status(name)
809
+ if status and status in provider_config.process_names:
810
+ break
811
+ await asyncio.sleep(0.5)
812
+ await tmux.send_keys(
813
+ name, message, double_enter=provider_config.submit_with_double_enter
814
+ )
815
+ logger.info("Bootstrap sent to session %s: %r", name, message[:80])
816
+ except Exception as exc:
817
+ logger.warning("Bootstrap failed for session %s: %s", name, exc)
818
+
819
+
820
+ def _catalog_response() -> SessionCatalogResponse:
821
+ providers = []
822
+ for provider in list_provider_definitions():
823
+ providers.append(
824
+ SessionCatalogProvider(
825
+ id=provider.id,
826
+ label=provider.label,
827
+ default_model=provider.default_model,
828
+ launch_root=provider.launch_root,
829
+ note=provider.note,
830
+ models=[
831
+ SessionCatalogModel(
832
+ id=model.id,
833
+ label=model.label,
834
+ description=model.description,
835
+ context_window=model.context_window,
836
+ supports_1m=model.supports_1m,
837
+ recommended=model.recommended,
838
+ experimental=model.experimental,
839
+ note=model.note,
840
+ )
841
+ for model in list_catalog_models(provider.id)
842
+ ],
843
+ permission_presets=[
844
+ SessionPermissionPreset(
845
+ id=preset.id,
846
+ label=preset.label,
847
+ badge=preset.badge,
848
+ description=preset.description,
849
+ )
850
+ for preset in provider.permission_presets
851
+ ],
852
+ )
853
+ )
854
+ return SessionCatalogResponse(providers=providers)
855
+
856
+
857
+ async def _sync_sessions_impl(
858
+ db: aiosqlite.Connection, *, allow_writes: bool
859
+ ) -> list[SessionInfo]:
860
+ """Merge tmux sessions (source of truth) with DB metadata.
861
+
862
+ Sessions in tmux but not in DB get added.
863
+ Sessions in DB but not in tmux get removed.
864
+ Returns sorted: pinned first, then sort_order, then name.
865
+ """
866
+ global _sessions_last_sync_timings
867
+ sync_started = _time.perf_counter()
868
+ tmux_sessions = await tmux.list_sessions()
869
+ tmux_list_done = _time.perf_counter()
870
+ tmux_names = {s["name"] for s in tmux_sessions}
871
+ tmux_map = {s["name"]: s for s in tmux_sessions}
872
+
873
+ # Get DB metadata
874
+ cursor = await db.execute(f"SELECT {DB_COLUMNS} FROM sessions_meta")
875
+ db_rows = {row["name"]: row for row in await cursor.fetchall()}
876
+ db_read_done = _time.perf_counter()
877
+
878
+ # Cleanup DB entries for dead tmux sessions
879
+ # SAFETY 1: only cleanup if tmux returned SOME sessions (prevents wipe on tmux failure)
880
+ # SAFETY 2: skip cleanup if system uptime < 300s (5 min) and no tmux sessions exist
881
+ # This prevents wiping DB right after reboot before sessions are re-created
882
+ dead_names = set(db_rows.keys()) - tmux_names
883
+ if allow_writes:
884
+ if dead_names and tmux_names:
885
+ for dead in dead_names:
886
+ await db.execute("DELETE FROM sessions_meta WHERE name = ?", (dead,))
887
+ await db.commit()
888
+ elif dead_names and not tmux_names:
889
+ uptime = _get_system_uptime_seconds()
890
+ if uptime > 300: # > 5 min: tmux truly empty, safe to cleanup
891
+ for dead in dead_names:
892
+ await db.execute(
893
+ "DELETE FROM sessions_meta WHERE name = ?", (dead,)
894
+ )
895
+ await db.commit()
896
+ else:
897
+ logger.info(
898
+ f"Boot grace period active (uptime={uptime:.0f}s): skipping DB cleanup of {len(dead_names)} dead sessions"
899
+ )
900
+
901
+ # Auto-register sessions without session_uuid (created from CLI, not Console)
902
+ if allow_writes:
903
+ for name in tmux_names:
904
+ row = db_rows.get(name)
905
+ if row and row["session_uuid"]:
906
+ continue # Already registered
907
+
908
+ # Generate UUID for unregistered sessions
909
+ new_uuid = str(uuid_mod.uuid4())
910
+ now = datetime.now(timezone.utc).isoformat()
911
+
912
+ # Try to detect project from CWD
913
+ project_slug = None
914
+ cwd = await tmux.get_pane_cwd(name)
915
+ if cwd:
916
+ project_slug = _detect_project_from_path(cwd)
917
+
918
+ if row:
919
+ # Session exists in DB but no UUID — update it
920
+ await db.execute(
921
+ "UPDATE sessions_meta SET session_uuid = ?, project_slug = COALESCE(project_slug, ?) WHERE name = ?",
922
+ (new_uuid, project_slug, name),
923
+ )
924
+ else:
925
+ # Brand new session — full insert
926
+ await db.execute(
927
+ "INSERT OR IGNORE INTO sessions_meta (name, session_uuid, created_at, last_active, project_slug) VALUES (?, ?, ?, ?, ?)",
928
+ (name, new_uuid, now, now, project_slug),
929
+ )
930
+
931
+ # Update local db_rows cache so the result loop sees the new data
932
+ cursor = await db.execute(
933
+ f"SELECT {DB_COLUMNS} FROM sessions_meta WHERE name = ?", (name,)
934
+ )
935
+ updated_row = await cursor.fetchone()
936
+ if updated_row:
937
+ db_rows[name] = updated_row
938
+
939
+ await db.commit()
940
+
941
+ # Get process statuses and metrics via bulk snapshots. This keeps the
942
+ # session-list path O(1) in subprocess calls even with hundreds of panes.
943
+ statuses = await tmux.get_all_session_statuses()
944
+ pane_pids = await tmux.get_all_session_pane_pids()
945
+ process_snapshots = await tmux.get_all_process_snapshots()
946
+ processes_by_parent = _index_processes_by_parent(process_snapshots)
947
+ status_metrics_done = _time.perf_counter()
948
+
949
+ # Build result
950
+ result = []
951
+ for ts in tmux_sessions:
952
+ name = ts["name"]
953
+ db_row = db_rows.get(name)
954
+
955
+ created_epoch = _created_epoch_from_iso(db_row["created_at"] if db_row else None)
956
+
957
+ result.append(
958
+ SessionInfo(
959
+ name=name,
960
+ display_name=db_row["display_name"] if db_row else None,
961
+ pinned=bool(db_row["pinned"]) if db_row and db_row["pinned"] else False,
962
+ sort_order=db_row["sort_order"]
963
+ if db_row and db_row["sort_order"]
964
+ else 0,
965
+ group_name=db_row["group_name"] if db_row else None,
966
+ project_slug=db_row["project_slug"] if db_row else None,
967
+ session_uuid=db_row["session_uuid"] if db_row else None,
968
+ status=statuses.get(name),
969
+ created_at=db_row["created_at"] if db_row else None,
970
+ last_active=db_row["last_active"] if db_row else None,
971
+ attached=ts["attached"],
972
+ hibernated=bool(db_row["hibernated"])
973
+ if db_row and db_row["hibernated"]
974
+ else False,
975
+ conversation_id=db_row["conversation_id"] if db_row else None,
976
+ model=(
977
+ db_row["model"]
978
+ if db_row and db_row["model"]
979
+ else db_row["launch_model"]
980
+ if db_row
981
+ else None
982
+ ),
983
+ launch_model=db_row["launch_model"] if db_row else None,
984
+ permission_preset=db_row["permission_preset"] if db_row else None,
985
+ last_context_pct=db_row["last_context_pct"] if db_row else None,
986
+ last_cost_usd=db_row["last_cost_usd"] if db_row else None,
987
+ last_message_count=db_row["last_message_count"] if db_row else None,
988
+ auto_hibernate_minutes=db_row["auto_hibernate_minutes"]
989
+ if db_row and db_row["auto_hibernate_minutes"]
990
+ else 30,
991
+ working_seconds=db_row["working_seconds"]
992
+ if db_row and db_row["working_seconds"]
993
+ else 0,
994
+ created_epoch=created_epoch,
995
+ agent_managed=bool(db_row["agent_managed"])
996
+ if db_row and db_row["agent_managed"]
997
+ else False,
998
+ owner_id=db_row["owner_id"]
999
+ if db_row and "owner_id" in db_row.keys()
1000
+ else None,
1001
+ provider=db_row["provider"]
1002
+ if db_row and db_row["provider"]
1003
+ else "claude",
1004
+ # PR2 dual metrics — defensive .keys() check so legacy test DBs
1005
+ # without migration 087 still work.
1006
+ last_context_pct_real=(
1007
+ db_row["last_context_pct_real"]
1008
+ if db_row and "last_context_pct_real" in db_row.keys()
1009
+ else None
1010
+ ),
1011
+ last_context_pct_scaled=(
1012
+ db_row["last_context_pct_scaled"]
1013
+ if db_row and "last_context_pct_scaled" in db_row.keys()
1014
+ else None
1015
+ ),
1016
+ last_cost_conversation_usd=(
1017
+ db_row["last_cost_conversation_usd"]
1018
+ if db_row and "last_cost_conversation_usd" in db_row.keys()
1019
+ else None
1020
+ ),
1021
+ last_cost_session_usd=(
1022
+ db_row["last_cost_session_usd"]
1023
+ if db_row and "last_cost_session_usd" in db_row.keys()
1024
+ else None
1025
+ ),
1026
+ last_cost_session_incomplete=bool(
1027
+ db_row["last_cost_session_incomplete"]
1028
+ if db_row and "last_cost_session_incomplete" in db_row.keys()
1029
+ else 0
1030
+ ),
1031
+ last_input_tokens=(
1032
+ db_row["last_input_tokens"]
1033
+ if db_row and "last_input_tokens" in db_row.keys()
1034
+ else None
1035
+ ),
1036
+ last_output_tokens=(
1037
+ db_row["last_output_tokens"]
1038
+ if db_row and "last_output_tokens" in db_row.keys()
1039
+ else None
1040
+ ),
1041
+ last_reasoning_tokens=(
1042
+ db_row["last_reasoning_tokens"]
1043
+ if db_row and "last_reasoning_tokens" in db_row.keys()
1044
+ else None
1045
+ ),
1046
+ working_seconds_msg=(
1047
+ db_row["working_seconds_msg"]
1048
+ if db_row and "working_seconds_msg" in db_row.keys()
1049
+ else None
1050
+ ),
1051
+ metrics_refreshed_at=(
1052
+ db_row["metrics_refreshed_at"]
1053
+ if db_row and "metrics_refreshed_at" in db_row.keys()
1054
+ else None
1055
+ ),
1056
+ pricing_version=(
1057
+ db_row["pricing_version"]
1058
+ if db_row and "pricing_version" in db_row.keys()
1059
+ else None
1060
+ ),
1061
+ # PR4 shadow cost (migration 089) — defensive .keys() check
1062
+ last_cost_conversation_equivalent_usd=(
1063
+ db_row["last_cost_conversation_equivalent_usd"]
1064
+ if db_row
1065
+ and "last_cost_conversation_equivalent_usd" in db_row.keys()
1066
+ else None
1067
+ ),
1068
+ last_cost_session_equivalent_usd=(
1069
+ db_row["last_cost_session_equivalent_usd"]
1070
+ if db_row
1071
+ and "last_cost_session_equivalent_usd" in db_row.keys()
1072
+ else None
1073
+ ),
1074
+ last_cost_equivalent_pricing_version=(
1075
+ db_row["last_cost_equivalent_pricing_version"]
1076
+ if db_row
1077
+ and "last_cost_equivalent_pricing_version" in db_row.keys()
1078
+ else None
1079
+ ),
1080
+ )
1081
+ )
1082
+ result_build_done = _time.perf_counter()
1083
+
1084
+ if allow_writes:
1085
+ # Lightweight metrics refresh for active CLI sessions
1086
+ now_ts = datetime.now(timezone.utc)
1087
+
1088
+ # Step 1: Get pane IDs for all active Claude sessions (metrics only for Claude provider)
1089
+ active_sessions = [
1090
+ s
1091
+ for s in result
1092
+ if s.status in ALL_KNOWN_PROCESS_NAMES and s.provider == "claude"
1093
+ ]
1094
+ pane_id_tasks = {s.name: tmux.get_pane_id(s.name) for s in active_sessions}
1095
+ pane_id_results = await asyncio.gather(*pane_id_tasks.values())
1096
+ pane_id_map = dict(zip(pane_id_tasks.keys(), pane_id_results))
1097
+
1098
+ for session_info in active_sessions:
1099
+ # --- Detection: statusline (most reliable) → PID → timestamp ---
1100
+ pane_id = pane_id_map.get(session_info.name)
1101
+ pane_data = None
1102
+
1103
+ # Method 1: Statusline per-pane file (written by statusline.sh v2.0.0+)
1104
+ if pane_id:
1105
+ pane_data = await asyncio.to_thread(
1106
+ claude_metrics.read_pane_metrics, pane_id
1107
+ )
1108
+ if pane_data:
1109
+ new_conv_id = pane_data.session_id
1110
+ if new_conv_id != session_info.conversation_id:
1111
+ session_info.conversation_id = new_conv_id
1112
+ await db.execute(
1113
+ "INSERT OR IGNORE INTO sessions_meta (name) VALUES (?)",
1114
+ (session_info.name,),
1115
+ )
1116
+ await db.execute(
1117
+ "UPDATE sessions_meta SET conversation_id = ? WHERE name = ?",
1118
+ (new_conv_id, session_info.name),
1119
+ )
1120
+
1121
+ # Method 1.5: Stale pane-metrics (session_id is valid even when idle)
1122
+ if not session_info.conversation_id and pane_id:
1123
+ stale_sid = await asyncio.to_thread(
1124
+ claude_metrics.read_pane_session_id, pane_id
1125
+ )
1126
+ if stale_sid:
1127
+ session_info.conversation_id = stale_sid
1128
+ await db.execute(
1129
+ "INSERT OR IGNORE INTO sessions_meta (name) VALUES (?)",
1130
+ (session_info.name,),
1131
+ )
1132
+ await db.execute(
1133
+ "UPDATE sessions_meta SET conversation_id = ? WHERE name = ?",
1134
+ (stale_sid, session_info.name),
1135
+ )
1136
+
1137
+ # Method 2: PID-based detection (fallback)
1138
+ if not session_info.conversation_id:
1139
+ claude_pid = await tmux.get_claude_pid(session_info.name)
1140
+ if claude_pid:
1141
+ pid_conv_id = claude_metrics.detect_conversation_by_pid(claude_pid)
1142
+ if pid_conv_id:
1143
+ session_info.conversation_id = pid_conv_id
1144
+ await db.execute(
1145
+ "INSERT OR IGNORE INTO sessions_meta (name) VALUES (?)",
1146
+ (session_info.name,),
1147
+ )
1148
+ await db.execute(
1149
+ "UPDATE sessions_meta SET conversation_id = ? WHERE name = ?",
1150
+ (pid_conv_id, session_info.name),
1151
+ )
1152
+
1153
+ # Method 3: Timestamp-based detection (last resort)
1154
+ if not session_info.conversation_id:
1155
+ pane_start = await tmux.get_pane_start_time(session_info.name)
1156
+ if pane_start:
1157
+ conv_id = claude_metrics.detect_conversation_for_session(
1158
+ pane_start,
1159
+ cwd=resolve_project_path(session_info.project_slug),
1160
+ )
1161
+ if conv_id:
1162
+ session_info.conversation_id = conv_id
1163
+ await db.execute(
1164
+ "UPDATE sessions_meta SET conversation_id = ? WHERE name = ?",
1165
+ (conv_id, session_info.name),
1166
+ )
1167
+ if not session_info.conversation_id:
1168
+ continue
1169
+
1170
+ conv_cwd = await asyncio.to_thread(
1171
+ _resolve_conversation_cwd,
1172
+ session_info.conversation_id,
1173
+ session_info.project_slug,
1174
+ )
1175
+
1176
+ # --- Metrics refresh ---
1177
+ # If we have fresh statusline data, use it directly (no JSONL parse needed for context_pct)
1178
+ # PR3: write the real ratio to last_context_pct_real (no /84 fudge).
1179
+ if pane_data and pane_data.session_id == session_info.conversation_id:
1180
+ real_pct = min(round(pane_data.used_pct, 1), 100.0)
1181
+ session_info.last_context_pct = real_pct
1182
+ session_info.last_context_pct_real = real_pct
1183
+ cursor_meta = await db.execute(
1184
+ "SELECT last_metrics_at FROM sessions_meta WHERE name = ?",
1185
+ (session_info.name,),
1186
+ )
1187
+ meta_row = await cursor_meta.fetchone()
1188
+ should_parse = True
1189
+ if meta_row and meta_row["last_metrics_at"]:
1190
+ try:
1191
+ last_refresh = datetime.fromisoformat(
1192
+ meta_row["last_metrics_at"].replace("Z", "+00:00")
1193
+ )
1194
+ if last_refresh.tzinfo is None:
1195
+ last_refresh = last_refresh.replace(tzinfo=timezone.utc)
1196
+ if (now_ts - last_refresh).total_seconds() < 10:
1197
+ should_parse = False
1198
+ except ValueError:
1199
+ pass
1200
+
1201
+ if should_parse:
1202
+ metrics = None
1203
+ if conv_cwd:
1204
+ metrics = await asyncio.to_thread(
1205
+ claude_metrics.find_conversation_by_id,
1206
+ session_info.conversation_id,
1207
+ conv_cwd,
1208
+ )
1209
+ if metrics:
1210
+ session_info.last_cost_usd = metrics.cost_usd
1211
+ session_info.last_message_count = metrics.message_count
1212
+ if metrics.model:
1213
+ session_info.model = metrics.model
1214
+ await db.execute(
1215
+ """UPDATE sessions_meta SET
1216
+ model = ?, last_context_pct_real = ?, last_cost_usd = ?,
1217
+ last_message_count = ?, last_metrics_at = datetime('now')
1218
+ WHERE name = ?""",
1219
+ (
1220
+ session_info.model,
1221
+ real_pct,
1222
+ session_info.last_cost_usd,
1223
+ session_info.last_message_count,
1224
+ session_info.name,
1225
+ ),
1226
+ )
1227
+ else:
1228
+ await db.execute(
1229
+ "UPDATE sessions_meta SET last_context_pct_real = ? WHERE name = ?",
1230
+ (real_pct, session_info.name),
1231
+ )
1232
+ else:
1233
+ cursor_meta = await db.execute(
1234
+ "SELECT last_metrics_at FROM sessions_meta WHERE name = ?",
1235
+ (session_info.name,),
1236
+ )
1237
+ meta_row = await cursor_meta.fetchone()
1238
+ last_refresh_epoch = 0.0
1239
+ if meta_row and meta_row["last_metrics_at"]:
1240
+ try:
1241
+ last_refresh = datetime.fromisoformat(
1242
+ meta_row["last_metrics_at"].replace("Z", "+00:00")
1243
+ )
1244
+ if last_refresh.tzinfo is None:
1245
+ last_refresh = last_refresh.replace(tzinfo=timezone.utc)
1246
+ last_refresh_epoch = last_refresh.timestamp()
1247
+ if (now_ts - last_refresh).total_seconds() < 5:
1248
+ continue
1249
+ except ValueError:
1250
+ pass
1251
+ if not conv_cwd:
1252
+ continue
1253
+ if last_refresh_epoch:
1254
+ jsonl_mtime = await asyncio.to_thread(
1255
+ claude_metrics.get_jsonl_mtime,
1256
+ session_info.conversation_id,
1257
+ conv_cwd,
1258
+ )
1259
+ if jsonl_mtime and jsonl_mtime <= last_refresh_epoch:
1260
+ continue
1261
+
1262
+ context_pct = await asyncio.to_thread(
1263
+ claude_metrics.get_last_context_pct,
1264
+ session_info.conversation_id,
1265
+ conv_cwd,
1266
+ )
1267
+ if context_pct is not None:
1268
+ session_info.last_context_pct = context_pct
1269
+ session_info.last_context_pct_real = context_pct
1270
+
1271
+ metrics = await asyncio.to_thread(
1272
+ claude_metrics.find_conversation_by_id,
1273
+ session_info.conversation_id,
1274
+ conv_cwd,
1275
+ )
1276
+ if metrics:
1277
+ effective_real = (
1278
+ context_pct if context_pct is not None else metrics.context_pct
1279
+ )
1280
+ if context_pct is None:
1281
+ session_info.last_context_pct = metrics.context_pct
1282
+ session_info.last_context_pct_real = metrics.context_pct
1283
+ session_info.last_cost_usd = metrics.cost_usd
1284
+ session_info.last_message_count = metrics.message_count
1285
+ session_info.model = metrics.model
1286
+ # PR3: write context_pct to _real; legacy column untouched.
1287
+ await db.execute(
1288
+ """UPDATE sessions_meta SET
1289
+ model = ?, last_context_pct_real = ?, last_cost_usd = ?,
1290
+ last_message_count = ?, last_metrics_at = datetime('now')
1291
+ WHERE name = ?""",
1292
+ (
1293
+ metrics.model,
1294
+ effective_real,
1295
+ metrics.cost_usd,
1296
+ metrics.message_count,
1297
+ session_info.name,
1298
+ ),
1299
+ )
1300
+
1301
+ await db.commit()
1302
+ write_metrics_done = _time.perf_counter()
1303
+
1304
+ # Apply event-driven activity_state from DB to ALL sessions, regardless of
1305
+ # current pane status. PR1.5 hotfix (2026-04-27): the previous version
1306
+ # applied this only inside the cli_sessions loop, so when a tmux pane was
1307
+ # running a Bash tool (status="bash") the session was excluded and
1308
+ # activity_state stayed None despite a fresh "working" event in the DB.
1309
+ from datetime import datetime as _dt, timezone as _tz, timedelta as _td
1310
+
1311
+ _now_utc = _dt.now(_tz.utc)
1312
+ _event_cutoff = _now_utc - _td(seconds=_ACTIVITY_EVENT_TTL_SECS)
1313
+
1314
+ def _fresh_event_state(db_row) -> str | None:
1315
+ if db_row is None:
1316
+ return None
1317
+ event_state = db_row["activity_state"]
1318
+ event_ts_iso = db_row["activity_state_updated_at"]
1319
+ if not (event_state and event_ts_iso):
1320
+ return None
1321
+ try:
1322
+ event_ts = _dt.fromisoformat(event_ts_iso.replace("Z", "+00:00"))
1323
+ if event_ts.tzinfo is None:
1324
+ event_ts = event_ts.replace(tzinfo=_tz.utc)
1325
+ except (ValueError, AttributeError):
1326
+ return None
1327
+ return event_state if event_ts > _event_cutoff else None
1328
+
1329
+ for s in result:
1330
+ fresh = _fresh_event_state(db_rows.get(s.name))
1331
+ if fresh:
1332
+ s.activity_state = fresh
1333
+ activity_merge_done = _time.perf_counter()
1334
+
1335
+ # No pane scraping on the global session-list request path. Activity state
1336
+ # is event-driven; process metrics are derived from bulk tmux+ps snapshots.
1337
+ cli_sessions = [s for s in result if s.status in ALL_KNOWN_PROCESS_NAMES]
1338
+ process_metrics_count = 0
1339
+ for s in cli_sessions:
1340
+ if not s.activity_state and s.provider != "claude":
1341
+ s.activity_state = "active"
1342
+
1343
+ process = _session_process_snapshot(s, pane_pids, processes_by_parent)
1344
+ if process:
1345
+ process_metrics_count += 1
1346
+ s.cpu_pct = round(process.cpu_pct, 1)
1347
+ s.ram_mb = round(process.rss_kb / 1024, 1)
1348
+ process_mapping_done = _time.perf_counter()
1349
+
1350
+ # Sort: pinned first, then sort_order, then name
1351
+ result.sort(key=lambda s: (not s.pinned, s.sort_order, s.name))
1352
+ sync_done = _time.perf_counter()
1353
+ _sessions_last_sync_timings = {
1354
+ "allow_writes": allow_writes,
1355
+ "session_count": len(result),
1356
+ "tmux_list_ms": (tmux_list_done - sync_started) * 1000,
1357
+ "db_read_ms": (db_read_done - tmux_list_done) * 1000,
1358
+ "status_metrics_ms": (status_metrics_done - db_read_done) * 1000,
1359
+ "result_build_ms": (result_build_done - status_metrics_done) * 1000,
1360
+ "write_metrics_ms": (write_metrics_done - result_build_done) * 1000,
1361
+ "activity_merge_ms": (activity_merge_done - write_metrics_done) * 1000,
1362
+ "process_mapping_ms": (process_mapping_done - activity_merge_done) * 1000,
1363
+ "pane_fallback_ms": 0.0,
1364
+ "pane_scrape_count": 0,
1365
+ "process_metrics_count": process_metrics_count,
1366
+ "total_ms": (sync_done - sync_started) * 1000,
1367
+ }
1368
+ return result
1369
+
1370
+
1371
+ async def _sync_sessions(db: aiosqlite.Connection) -> list[SessionInfo]:
1372
+ return await _sync_sessions_impl(db, allow_writes=True)
1373
+
1374
+
1375
+ async def _sync_sessions_read_only(db: aiosqlite.Connection) -> list[SessionInfo]:
1376
+ return await _sync_sessions_impl(db, allow_writes=False)
1377
+
1378
+
1379
+ @router.get("", response_model=list[SessionInfo])
1380
+ async def list_sessions(
1381
+ request: Request,
1382
+ agent_managed: bool | None = None,
1383
+ current_user: UserInfo = Depends(get_current_user_or_agent),
1384
+ db: aiosqlite.Connection = Depends(get_db),
1385
+ ):
1386
+ """List tmux sessions. Operators see only their own; admins see all."""
1387
+ global _sessions_cache_pending_patch_state
1388
+ started = _time.perf_counter()
1389
+ sessions = await _get_sessions_cached(db)
1390
+ unfiltered_count = len(sessions)
1391
+ cache_state = _sessions_cache_pending_patch_state or _sessions_cache_last_state
1392
+ sync_timings = _sessions_last_sync_timings
1393
+
1394
+ # RBAC: operator/viewer humans are owner-scoped. Only explicit system agent
1395
+ # identities retain global visibility on this read endpoint.
1396
+ if not _can_view_all_sessions(current_user) and current_user.system_role in (
1397
+ "operator",
1398
+ "viewer",
1399
+ ):
1400
+ sessions = [s for s in sessions if s.owner_id == current_user.user_id]
1401
+ elif current_user.system_role == "team_admin":
1402
+ # team_admin sees sessions owned by any member of their teams
1403
+ async with db.execute(
1404
+ """SELECT DISTINCT tm2.user_id FROM team_members tm1
1405
+ JOIN team_members tm2 ON tm1.team_id = tm2.team_id
1406
+ WHERE tm1.user_id = ?""",
1407
+ [current_user.user_id],
1408
+ ) as cursor:
1409
+ member_rows = await cursor.fetchall()
1410
+ team_user_ids = {r[0] for r in member_rows}
1411
+ sessions = [s for s in sessions if s.owner_id in team_user_ids]
1412
+ # admin / super_admin / agent: no filter
1413
+
1414
+ if agent_managed is not None:
1415
+ sessions = [
1416
+ s
1417
+ for s in sessions
1418
+ if bool(getattr(s, "agent_managed", False)) == agent_managed
1419
+ ]
1420
+ duration_ms = (_time.perf_counter() - started) * 1000
1421
+ metadata: dict[str, Any] = {
1422
+ "cache_state": cache_state,
1423
+ "unfiltered_count": unfiltered_count,
1424
+ "returned_count": len(sessions),
1425
+ "agent_managed_filter": agent_managed,
1426
+ }
1427
+ if sync_timings:
1428
+ metadata["last_sync_total_ms"] = round(float(sync_timings["total_ms"]), 3)
1429
+ metadata["last_sync_session_count"] = sync_timings["session_count"]
1430
+ _record_sessions_control_event(
1431
+ request,
1432
+ kind="list",
1433
+ duration_ms=duration_ms,
1434
+ metadata=metadata,
1435
+ )
1436
+ if cache_state == "miss_wait" and sync_timings:
1437
+ _record_sessions_control_event(
1438
+ request,
1439
+ kind="sync",
1440
+ duration_ms=float(sync_timings["total_ms"]),
1441
+ metadata={
1442
+ key: round(value, 3) if isinstance(value, float) else value
1443
+ for key, value in sync_timings.items()
1444
+ },
1445
+ )
1446
+ if _sessions_cache_pending_patch_state == "state_patched":
1447
+ _sessions_cache_pending_patch_state = None
1448
+ return sessions
1449
+
1450
+
1451
+ @router.get("/catalog", response_model=SessionCatalogResponse)
1452
+ async def get_session_catalog(
1453
+ _user: UserInfo = Depends(get_current_user_or_agent),
1454
+ ):
1455
+ return _catalog_response()
1456
+
1457
+
1458
+ @router.post("", response_model=SessionInfo, status_code=201)
1459
+ async def create_session(
1460
+ body: SessionCreate,
1461
+ current_user: UserInfo = Depends(get_current_user),
1462
+ db: aiosqlite.Connection = Depends(get_write_db),
1463
+ ):
1464
+ """Create a new tmux session. Sets owner_id from the authenticated user.
1465
+
1466
+ Sessions always launch from the shared MarvisX workspace so providers pick up
1467
+ the same instruction files, hooks, skills, and MCP config. Project
1468
+ selection remains metadata plus extra access directories where supported.
1469
+ """
1470
+ try:
1471
+ launch_spec = build_session_start_spec(
1472
+ body.provider,
1473
+ body.project_slug,
1474
+ body.model,
1475
+ body.permission_preset,
1476
+ session_name=body.name,
1477
+ theme_mode=body.theme_mode,
1478
+ )
1479
+ except ValueError as e:
1480
+ raise HTTPException(status_code=422, detail=str(e))
1481
+
1482
+ provider_config = launch_spec.provider_config
1483
+ start_command = launch_spec.start_command
1484
+ provider_name = launch_spec.provider
1485
+ selected_model = launch_spec.cli_model
1486
+ selected_model_id = launch_spec.model_id
1487
+ permission_preset = launch_spec.permission_preset
1488
+ bootstrap_message = _project_bootstrap_message(body.project_slug)
1489
+ launched_at_ms = int(_time.time() * 1000)
1490
+
1491
+ existing_server = await tmux.resolve_session_server(body.name)
1492
+ if existing_server is not None:
1493
+ row = await _fetch_session_meta_row(db, body.name)
1494
+ if existing_server != "marvisx" or not _is_claimable_marvisx_orphan(
1495
+ row, current_user
1496
+ ):
1497
+ raise HTTPException(status_code=409, detail="Session already exists")
1498
+
1499
+ session_uuid, created_at, now = await _persist_session_create_metadata(
1500
+ db,
1501
+ name=body.name,
1502
+ current_user=current_user,
1503
+ project_slug=body.project_slug,
1504
+ provider_name=provider_name,
1505
+ selected_model=selected_model,
1506
+ selected_model_id=selected_model_id,
1507
+ permission_preset=permission_preset,
1508
+ theme_mode=body.theme_mode,
1509
+ bootstrap_message=bootstrap_message,
1510
+ )
1511
+ await db.commit()
1512
+ _invalidate_sessions_cache()
1513
+ asyncio.create_task(_get_session_manager().broadcast_session_event("created"))
1514
+
1515
+ logger.warning(
1516
+ "Recovered orphan tmux session metadata: %s "
1517
+ "(uuid=%s, owner=%s, project=%s, provider=%s)",
1518
+ body.name,
1519
+ session_uuid,
1520
+ current_user.user_id,
1521
+ body.project_slug,
1522
+ provider_name,
1523
+ )
1524
+ return SessionInfo(
1525
+ name=body.name,
1526
+ session_uuid=session_uuid,
1527
+ created_at=created_at,
1528
+ last_active=now,
1529
+ attached=False,
1530
+ owner_id=current_user.user_id or None,
1531
+ project_slug=body.project_slug,
1532
+ provider=provider_name,
1533
+ model=selected_model,
1534
+ launch_model=selected_model_id,
1535
+ permission_preset=permission_preset,
1536
+ conversation_id=_row_value(row, "conversation_id"),
1537
+ )
1538
+
1539
+ if not await is_binary_available(provider_config):
1540
+ raise HTTPException(
1541
+ status_code=422,
1542
+ detail=f"CLI '{provider_config.binary}' not found on server",
1543
+ )
1544
+
1545
+ tmux_user_env = await _tmux_user_env_for_session(db, current_user)
1546
+ success = await tmux.create_session(
1547
+ body.name,
1548
+ start_command=start_command,
1549
+ tenant_slug=settings.deploy_mode if settings.multi_tenant_enabled else None,
1550
+ user_env=tmux_user_env,
1551
+ )
1552
+ if not success:
1553
+ raise HTTPException(status_code=500, detail="Failed to create session")
1554
+
1555
+ session_uuid, created_at, now = await _persist_session_create_metadata(
1556
+ db,
1557
+ name=body.name,
1558
+ current_user=current_user,
1559
+ project_slug=body.project_slug,
1560
+ provider_name=provider_name,
1561
+ selected_model=selected_model,
1562
+ selected_model_id=selected_model_id,
1563
+ permission_preset=permission_preset,
1564
+ theme_mode=body.theme_mode,
1565
+ bootstrap_message=bootstrap_message,
1566
+ )
1567
+
1568
+ opencode_session_id = None
1569
+ if provider_name == "opencode":
1570
+ opencode_session_id = await _capture_new_opencode_session_id(
1571
+ db=db,
1572
+ name=body.name,
1573
+ launch_dir=launch_spec.launch_dir,
1574
+ launched_at_ms=launched_at_ms,
1575
+ )
1576
+ await db.commit()
1577
+ _invalidate_sessions_cache()
1578
+ asyncio.create_task(_get_session_manager().broadcast_session_event("created"))
1579
+
1580
+ if bootstrap_message:
1581
+ asyncio.create_task(
1582
+ _send_bootstrap_message(body.name, provider_name, bootstrap_message)
1583
+ )
1584
+
1585
+ logger.info(
1586
+ "Session created: %s (uuid=%s, owner=%s, project=%s, provider=%s)",
1587
+ body.name,
1588
+ session_uuid,
1589
+ current_user.user_id,
1590
+ body.project_slug,
1591
+ provider_name,
1592
+ )
1593
+ return SessionInfo(
1594
+ name=body.name,
1595
+ session_uuid=session_uuid,
1596
+ created_at=created_at,
1597
+ last_active=now,
1598
+ attached=False,
1599
+ owner_id=current_user.user_id or None,
1600
+ project_slug=body.project_slug,
1601
+ provider=provider_name,
1602
+ model=selected_model,
1603
+ launch_model=selected_model_id,
1604
+ permission_preset=permission_preset,
1605
+ conversation_id=opencode_session_id,
1606
+ )
1607
+
1608
+
1609
+ @router.delete("/{name}", status_code=204)
1610
+ async def delete_session(
1611
+ name: str,
1612
+ _user: UserInfo = Depends(get_current_user),
1613
+ ):
1614
+ """Kill a tmux session."""
1615
+ try:
1616
+ tmux.validate_session_name(name)
1617
+ except ValueError as e:
1618
+ raise HTTPException(status_code=422, detail=str(e))
1619
+
1620
+ if not await tmux.session_exists(name):
1621
+ raise HTTPException(status_code=404, detail="Session not found")
1622
+
1623
+ success = await tmux.kill_session(name)
1624
+ if not success:
1625
+ raise HTTPException(status_code=500, detail="Failed to kill session")
1626
+
1627
+ async with write_db() as db:
1628
+ await db.execute("DELETE FROM sessions_meta WHERE name = ?", (name,))
1629
+ await db.commit()
1630
+
1631
+ _invalidate_sessions_cache()
1632
+ asyncio.create_task(_get_session_manager().broadcast_session_event("destroyed"))
1633
+
1634
+ logger.info("Session deleted: %s", name)
1635
+
1636
+
1637
+ @router.patch("/{name}", response_model=SessionInfo)
1638
+ async def update_session(
1639
+ name: str,
1640
+ body: SessionUpdate,
1641
+ current_user: UserInfo = Depends(get_current_user),
1642
+ db: aiosqlite.Connection = Depends(get_write_db),
1643
+ ):
1644
+ """Update session metadata: rename, display_name, pin, group, project_slug, agent_managed."""
1645
+ try:
1646
+ tmux.validate_session_name(name)
1647
+ except ValueError as e:
1648
+ raise HTTPException(status_code=422, detail=str(e))
1649
+
1650
+ if not await tmux.session_exists(name):
1651
+ raise HTTPException(status_code=404, detail="Session not found")
1652
+
1653
+ # Handle rename if requested
1654
+ effective_name = name
1655
+ if body.new_name and body.new_name != name:
1656
+ if await tmux.session_exists(body.new_name):
1657
+ raise HTTPException(
1658
+ status_code=409, detail="Target session name already exists"
1659
+ )
1660
+ success = await tmux.rename_session(name, body.new_name)
1661
+ if not success:
1662
+ raise HTTPException(status_code=500, detail="Failed to rename session")
1663
+ # session_conversations.session_name FK -> sessions_meta(name) is
1664
+ # ON DELETE CASCADE only (no ON UPDATE CASCADE). With foreign_keys=ON
1665
+ # any single statement in the rename chain trips IntegrityError
1666
+ # mid-transaction even if the final state is consistent. Defer the
1667
+ # FK check to COMMIT so all three UPDATEs land atomically.
1668
+ # Provider-agnostic: same FK chain applies to Claude/OpenCode/Codex.
1669
+ await db.execute("PRAGMA defer_foreign_keys = ON")
1670
+ await db.execute(
1671
+ "UPDATE session_conversations SET session_name = ? WHERE session_name = ?",
1672
+ (body.new_name, name),
1673
+ )
1674
+ await db.execute(
1675
+ "UPDATE session_costs SET session_name = ? WHERE session_name = ?",
1676
+ (body.new_name, name),
1677
+ )
1678
+ await db.execute(
1679
+ "UPDATE sessions_meta SET name = ? WHERE name = ?", (body.new_name, name)
1680
+ )
1681
+ # Sync display_name if it was auto-default (== old name). SessionCardV2
1682
+ # sidebar uses `display_name || name` as the primary label, so a stale
1683
+ # display_name shadows the rename. Preserve custom user labels (when
1684
+ # display_name ≠ old name) by skipping the sync. Plan 2026-05-22.
1685
+ await db.execute(
1686
+ "UPDATE sessions_meta SET display_name = ? "
1687
+ "WHERE name = ? AND display_name = ?",
1688
+ (body.new_name, body.new_name, name),
1689
+ )
1690
+ await db.commit()
1691
+ effective_name = body.new_name
1692
+ _invalidate_sessions_cache()
1693
+ # Build compact delta for WS broadcast (~9 fields, <2KB) — avoids full
1694
+ # SessionInfo (~50 fields) + privacy leak cross-tab (no conversation_ids).
1695
+ # Plan 2026-05-21: closes post-rename stale sidebar by giving clients
1696
+ # an optimistic patch payload instead of forcing a refetch round-trip.
1697
+ _delta_cursor = await db.execute(
1698
+ "SELECT display_name, provider, model, project_slug "
1699
+ "FROM sessions_meta WHERE name = ?",
1700
+ (effective_name,),
1701
+ )
1702
+ _delta_row = await _delta_cursor.fetchone()
1703
+ _now_iso = datetime.now(timezone.utc).isoformat()
1704
+ _rename_delta = {
1705
+ "name": effective_name,
1706
+ "prev_name": name,
1707
+ "display_name": _delta_row["display_name"] if _delta_row else None,
1708
+ "provider": _delta_row["provider"] if _delta_row else None,
1709
+ "model": _delta_row["model"] if _delta_row else None,
1710
+ "project_slug": _delta_row["project_slug"] if _delta_row else None,
1711
+ "updated_at": _now_iso,
1712
+ }
1713
+ asyncio.create_task(
1714
+ _get_session_manager().broadcast_session_event(
1715
+ "renamed",
1716
+ old_name=name,
1717
+ new_name=effective_name,
1718
+ session_info=_rename_delta,
1719
+ )
1720
+ )
1721
+
1722
+ # Provider-agnostic conversation rename via /rename slash command.
1723
+ # Verified 2026-04-24 via upstream GitHub issues:
1724
+ # - Claude Code: native /rename
1725
+ # - OpenCode: /rename exists (anomalyco/opencode#9398)
1726
+ # - Codex CLI: /rename (openai/codex#15533 — "already supported in the CLI")
1727
+ # Gemini CLI has no /rename; skipped. Rename is still reflected at
1728
+ # tmux + PiR DB level, which is enough for the Console UI.
1729
+ _prov_cursor = await db.execute(
1730
+ "SELECT provider FROM sessions_meta WHERE name = ?", (effective_name,)
1731
+ )
1732
+ _prov_row = await _prov_cursor.fetchone()
1733
+ _session_provider = (
1734
+ _prov_row["provider"] if _prov_row and _prov_row["provider"] else "claude"
1735
+ )
1736
+ if _session_provider in ("claude", "opencode", "codex"):
1737
+ await tmux.send_keys_raw(effective_name, "C-u")
1738
+ await asyncio.sleep(0.2)
1739
+ await tmux.send_keys_raw(effective_name, f"/rename {body.new_name}")
1740
+ await asyncio.sleep(0.3)
1741
+ await tmux.send_keys_raw(effective_name, "Escape")
1742
+ await asyncio.sleep(0.2)
1743
+ await tmux.send_keys_raw(effective_name, "Enter")
1744
+ logger.info(
1745
+ "Sent /rename %s to %s TUI in session %s",
1746
+ body.new_name,
1747
+ _session_provider,
1748
+ effective_name,
1749
+ )
1750
+ # /rename slash often forks the conversation into a new JSONL/UUID
1751
+ # (Claude Code creates a new file under ~/.claude/projects/...).
1752
+ # If we keep the stale conversation_id, metrics providers open a
1753
+ # ghost file and every metric (ctx %, cost, tokens, working_seconds)
1754
+ # turns NULL until manual relink. Reset the linked conversation_id
1755
+ # and the cached metrics — list_sessions() refresh logic (statusline
1756
+ # pane file → PID detect → recent-jsonl-by-cwd) will repopulate
1757
+ # them automatically on the next poll.
1758
+ await db.execute(
1759
+ "UPDATE sessions_meta SET "
1760
+ "conversation_id = NULL, "
1761
+ "last_context_pct_real = NULL, "
1762
+ "last_context_pct_scaled = NULL, "
1763
+ "last_cost_conversation_usd = NULL, "
1764
+ "last_cost_session_usd = NULL, "
1765
+ "last_input_tokens = NULL, "
1766
+ "last_output_tokens = NULL, "
1767
+ "last_reasoning_tokens = NULL, "
1768
+ "last_metrics_at = NULL, "
1769
+ "metrics_refreshed_at = NULL "
1770
+ "WHERE name = ?",
1771
+ (effective_name,),
1772
+ )
1773
+
1774
+ # Ensure DB row exists
1775
+ await db.execute(
1776
+ "INSERT OR IGNORE INTO sessions_meta (name) VALUES (?)",
1777
+ (effective_name,),
1778
+ )
1779
+
1780
+ # Update metadata fields (use model_fields_set to distinguish "not sent" from "sent as null")
1781
+ updates = []
1782
+ params = []
1783
+ if "display_name" in body.model_fields_set:
1784
+ updates.append("display_name = ?")
1785
+ params.append(body.display_name or None)
1786
+ if "pinned" in body.model_fields_set:
1787
+ updates.append("pinned = ?")
1788
+ params.append(1 if body.pinned else 0)
1789
+ if "group_name" in body.model_fields_set:
1790
+ updates.append("group_name = ?")
1791
+ params.append(body.group_name or None)
1792
+ if "project_slug" in body.model_fields_set:
1793
+ updates.append("project_slug = ?")
1794
+ params.append(body.project_slug or None)
1795
+ updates.append("bootstrap_message = ?")
1796
+ params.append(_project_bootstrap_message(body.project_slug or None))
1797
+ if "agent_managed" in body.model_fields_set:
1798
+ if current_user.system_role not in ("operator", "admin", "super_admin"):
1799
+ raise HTTPException(403, "Insufficient permissions to change agent_managed")
1800
+ updates.append("agent_managed = ?")
1801
+ params.append(1 if body.agent_managed else 0)
1802
+ if "owner_id" in body.model_fields_set:
1803
+ if current_user.system_role not in ("admin", "super_admin"):
1804
+ raise HTTPException(403, "Insufficient permissions to change owner_id")
1805
+ updates.append("owner_id = ?")
1806
+ params.append(body.owner_id or None)
1807
+
1808
+ now = datetime.now(timezone.utc).isoformat()
1809
+ updates.append("last_active = ?")
1810
+ params.append(now)
1811
+
1812
+ if updates:
1813
+ params.append(effective_name)
1814
+ await db.execute(
1815
+ f"UPDATE sessions_meta SET {', '.join(updates)} WHERE name = ?",
1816
+ params,
1817
+ )
1818
+ await db.commit()
1819
+ _invalidate_sessions_cache()
1820
+ asyncio.create_task(_get_session_manager().broadcast_session_event("updated"))
1821
+
1822
+ # Return updated info
1823
+ cursor = await db.execute(
1824
+ f"SELECT {DB_COLUMNS} FROM sessions_meta WHERE name = ?",
1825
+ (effective_name,),
1826
+ )
1827
+ row = await cursor.fetchone()
1828
+ session_provider = row["provider"] if row and row["provider"] else "claude"
1829
+ status = await tmux.get_session_status(effective_name)
1830
+ pane_text = (
1831
+ await tmux.capture_pane(effective_name)
1832
+ if status in ALL_KNOWN_PROCESS_NAMES
1833
+ else None
1834
+ )
1835
+ activity = tmux.detect_activity_state(pane_text, status, provider=session_provider)
1836
+
1837
+ return SessionInfo(
1838
+ name=effective_name,
1839
+ display_name=row["display_name"] if row else None,
1840
+ pinned=bool(row["pinned"]) if row and row["pinned"] else False,
1841
+ sort_order=row["sort_order"] if row and row["sort_order"] else 0,
1842
+ group_name=row["group_name"] if row else None,
1843
+ project_slug=row["project_slug"] if row else None,
1844
+ session_uuid=row["session_uuid"] if row else None,
1845
+ status=status,
1846
+ created_at=row["created_at"] if row else None,
1847
+ last_active=row["last_active"] if row else now,
1848
+ attached=False,
1849
+ model=row["model"]
1850
+ if row and row["model"]
1851
+ else row["launch_model"]
1852
+ if row
1853
+ else None,
1854
+ launch_model=row["launch_model"] if row else None,
1855
+ permission_preset=row["permission_preset"] if row else None,
1856
+ activity_state=activity,
1857
+ owner_id=row["owner_id"] if row and "owner_id" in row.keys() else None,
1858
+ provider=session_provider,
1859
+ )
1860
+
1861
+
1862
+ @router.put("/reorder", response_model=list[SessionInfo])
1863
+ async def reorder_sessions(
1864
+ body: SessionReorder,
1865
+ _user: UserInfo = Depends(get_current_user),
1866
+ db: aiosqlite.Connection = Depends(get_write_db),
1867
+ ):
1868
+ """Update sort_order for sessions based on position in list."""
1869
+ for i, name in enumerate(body.order):
1870
+ await db.execute(
1871
+ "UPDATE sessions_meta SET sort_order = ? WHERE name = ?",
1872
+ (i, name),
1873
+ )
1874
+ await db.commit()
1875
+ return await _sync_sessions(db)
1876
+
1877
+
1878
+ # --- Session state event endpoint (PR1, plan 2026-04-26) ---
1879
+ #
1880
+ # In-memory rate-limit token bucket. Per-worker (uvicorn runs N workers; with
1881
+ # N=4 the effective cap is 4 * cap), single-tenant Marvis context makes this
1882
+ # acceptable. Documented in plan §H15. Switch to SQLite/Redis-backed if
1883
+ # multi-tenant ever becomes scope.
1884
+ #
1885
+ # Two priority buckets (julik R5, plan M4):
1886
+ # - terminal events (Stop, StopFailure, SessionEnd, *.idle, *.error,
1887
+ # permission.updated): NO LIMIT — these are state-defining, dropping
1888
+ # leaves the session stuck in "working" until the 60s fallback gate.
1889
+ # - working events (PreToolUse, *.active): 10/s cap. Storms during
1890
+ # parallel tool batches are deduped by the LWW UPDATE anyway.
1891
+
1892
+ _TERMINAL_EVENTS = frozenset(
1893
+ {
1894
+ "Stop",
1895
+ "StopFailure",
1896
+ "SessionEnd",
1897
+ "session.status:idle",
1898
+ "session.status:error",
1899
+ "session.error",
1900
+ "session.idle",
1901
+ "session.deleted",
1902
+ "permission.updated",
1903
+ }
1904
+ )
1905
+
1906
+ _RATE_LIMIT_CAP = 10 # working events per second per session
1907
+ _RATE_LIMIT_WINDOW = 1.0 # seconds
1908
+ _rate_buckets: dict[str, list[float]] = {}
1909
+
1910
+
1911
+ def _check_rate_limit(session_name: str, event: str) -> bool:
1912
+ """Return True if event is allowed, False if dropped by rate limit.
1913
+
1914
+ Terminal events bypass. Per-session sliding window for working events.
1915
+ """
1916
+ if event in _TERMINAL_EVENTS:
1917
+ return True
1918
+ now = _time.monotonic()
1919
+ window = _rate_buckets.setdefault(session_name, [])
1920
+ cutoff = now - _RATE_LIMIT_WINDOW
1921
+ # Trim old timestamps in place.
1922
+ window[:] = [ts for ts in window if ts > cutoff]
1923
+ if len(window) >= _RATE_LIMIT_CAP:
1924
+ return False
1925
+ window.append(now)
1926
+ return True
1927
+
1928
+
1929
+ @router.post("/{identifier}/state", status_code=204)
1930
+ async def update_session_state(
1931
+ identifier: str,
1932
+ body: SessionStateUpdate,
1933
+ request: Request,
1934
+ user: UserInfo = Depends(get_current_user_or_agent),
1935
+ _scope: UserInfo = Depends(require_scope("write")),
1936
+ db: aiosqlite.Connection = Depends(get_write_db),
1937
+ ):
1938
+ """Record a session state event from a provider hook/plugin.
1939
+
1940
+ Single endpoint accepts either tmux session name or conversation_id (UUID
1941
+ for Claude, `ses_*` for OpenCode). Resolved server-side via
1942
+ `session_state.resolve_session_name`. The {identifier} path param is
1943
+ validated/looked-up before any DB write.
1944
+
1945
+ Returns 204 on success, 422 on invalid payload, 403 on missing scope, 404
1946
+ if the session can't be resolved. Rate-limited working events are silently
1947
+ dropped at 204 (no error to hook script — it's fire-and-forget).
1948
+ """
1949
+ # Operator role check (humans + agent tokens). require_scope("write")
1950
+ # already enforced by Depends.
1951
+ if not (
1952
+ user.system_role in ("operator", "admin", "super_admin")
1953
+ or user.user_type == "agent"
1954
+ ):
1955
+ raise HTTPException(status_code=403, detail="operator role required")
1956
+
1957
+ # Validate / resolve identifier. Reject early with 400 (not 500 from
1958
+ # uncaught ValueError — security P1-1).
1959
+ try:
1960
+ # Tmux name validator is strict regex; conversation_ids contain `-`
1961
+ # and `_` which the regex allows, so it accepts both shapes.
1962
+ tmux.validate_session_name(identifier)
1963
+ except ValueError as exc:
1964
+ raise HTTPException(status_code=400, detail=str(exc))
1965
+
1966
+ session_name = await session_state_svc.resolve_session_name(db, identifier)
1967
+ if session_name is None:
1968
+ # Don't 404 noisily — hooks can race against tmux session deletion.
1969
+ # 204 silent so hook script doesn't retry-storm.
1970
+ return
1971
+
1972
+ # Parse + bound-check client timestamp (rejects future spam + stale).
1973
+ client_ts = session_state_svc.parse_client_ts(body.ts)
1974
+ if client_ts is None:
1975
+ return # silent drop — already logged in parse_client_ts
1976
+
1977
+ # Rate-limit (priority bucket: terminal events bypass).
1978
+ if not _check_rate_limit(session_name, body.event):
1979
+ return # silent drop, no 429 — hook is fire-and-forget
1980
+
1981
+ state = await session_state_svc.record_state_event(
1982
+ db, session_name, body.provider, body.event, client_ts
1983
+ )
1984
+ await db.commit()
1985
+ if state is None:
1986
+ # Event ignored or LWW race lost — no broadcast spurious.
1987
+ return
1988
+
1989
+ # State-only updates patch the unfiltered full-list cache in place instead
1990
+ # of forcing the next /sessions caller into a cold tmux+DB sync. If the
1991
+ # cache exists but does not contain this resolved session, invalidate once
1992
+ # so the frontend fallback full refresh can converge.
1993
+ patched_cache = _patch_sessions_cache_activity_state(session_name, state)
1994
+ if not patched_cache and _sessions_cache is not None:
1995
+ _invalidate_sessions_cache()
1996
+ # Broadcast inline state payload (M1, julik R1) so frontend can apply
1997
+ # delta optimistically without round-tripping back to GET /sessions.
1998
+ asyncio.create_task(
1999
+ _get_session_manager().broadcast_session_event(
2000
+ "updated", session_name=session_name, state=state
2001
+ )
2002
+ )
2003
+
2004
+
2005
+ @router.post("/{name}/resurrect", response_model=SessionInfo)
2006
+ async def resurrect_session(
2007
+ name: str,
2008
+ _user: UserInfo = Depends(get_current_user),
2009
+ db: aiosqlite.Connection = Depends(get_write_db),
2010
+ ):
2011
+ """Recreate a dead tmux session, preserving DB metadata."""
2012
+ try:
2013
+ tmux.validate_session_name(name)
2014
+ except ValueError as e:
2015
+ raise HTTPException(status_code=422, detail=str(e))
2016
+
2017
+ if await tmux.session_exists(name):
2018
+ raise HTTPException(status_code=409, detail="Session is still alive")
2019
+
2020
+ # Read provider from DB to build correct start command
2021
+ cursor = await db.execute(
2022
+ f"SELECT {DB_COLUMNS} FROM sessions_meta WHERE name = ?",
2023
+ (name,),
2024
+ )
2025
+ row = await cursor.fetchone()
2026
+ launch_spec = build_session_start_spec(
2027
+ row["provider"] if row else None,
2028
+ row["project_slug"] if row else None,
2029
+ row["launch_model"] if row else None,
2030
+ row["permission_preset"] if row else None,
2031
+ session_name=name,
2032
+ theme_mode=row["theme_mode"] if row else None,
2033
+ )
2034
+ session_provider = launch_spec.provider
2035
+ if session_provider == "opencode":
2036
+ opencode_session_id = await _resolve_opencode_session_id(
2037
+ db=db,
2038
+ name=name,
2039
+ launch_dir=launch_spec.launch_dir,
2040
+ stored_session_id=row["conversation_id"] if row else None,
2041
+ created_at=row["created_at"] if row else None,
2042
+ )
2043
+ launch_spec = build_session_start_spec(
2044
+ session_provider,
2045
+ row["project_slug"] if row else None,
2046
+ row["launch_model"] if row else None,
2047
+ row["permission_preset"] if row else None,
2048
+ resume_session_id=opencode_session_id,
2049
+ session_name=name,
2050
+ theme_mode=row["theme_mode"] if row else None,
2051
+ )
2052
+ start_cmd = launch_spec.start_command
2053
+ bootstrap_message = None
2054
+ launched_at_ms = int(_time.time() * 1000)
2055
+
2056
+ success = await tmux.create_session(name, start_command=start_cmd)
2057
+ if not success:
2058
+ raise HTTPException(status_code=500, detail="Failed to resurrect session")
2059
+
2060
+ if session_provider == "opencode" and not row["conversation_id"]:
2061
+ await _capture_new_opencode_session_id(
2062
+ db=db,
2063
+ name=name,
2064
+ launch_dir=launch_spec.launch_dir,
2065
+ launched_at_ms=launched_at_ms,
2066
+ )
2067
+
2068
+ # Update last_active
2069
+ now = datetime.now(timezone.utc).isoformat()
2070
+ await db.execute(
2071
+ "UPDATE sessions_meta SET last_active = ? WHERE name = ?",
2072
+ (now, name),
2073
+ )
2074
+ await db.commit()
2075
+
2076
+ logger.info("Session resurrected: %s (provider=%s)", name, session_provider)
2077
+ return SessionInfo(
2078
+ name=name,
2079
+ display_name=row["display_name"] if row else None,
2080
+ pinned=bool(row["pinned"]) if row and row["pinned"] else False,
2081
+ sort_order=row["sort_order"] if row and row["sort_order"] else 0,
2082
+ group_name=row["group_name"] if row else None,
2083
+ project_slug=row["project_slug"] if row else None,
2084
+ session_uuid=row["session_uuid"] if row else None,
2085
+ created_at=row["created_at"] if row else now,
2086
+ last_active=now,
2087
+ attached=False,
2088
+ provider=session_provider,
2089
+ model=row["model"]
2090
+ if row and row["model"]
2091
+ else row["launch_model"]
2092
+ if row
2093
+ else None,
2094
+ launch_model=row["launch_model"] if row else None,
2095
+ permission_preset=row["permission_preset"] if row else None,
2096
+ )
2097
+
2098
+
2099
+ @router.get("/{name}/metrics", response_model=SessionMetricsResponse)
2100
+ async def get_session_metrics(
2101
+ name: str,
2102
+ _user: UserInfo = Depends(get_current_user_or_agent),
2103
+ db: aiosqlite.Connection = Depends(get_db),
2104
+ ):
2105
+ """Get session metrics: context %, cost, duration, message count."""
2106
+ try:
2107
+ tmux.validate_session_name(name)
2108
+ except ValueError as e:
2109
+ raise HTTPException(status_code=422, detail=str(e))
2110
+
2111
+ cursor = await db.execute(
2112
+ "SELECT conversation_id, hibernated, auto_hibernate_minutes, provider, project_slug "
2113
+ "FROM sessions_meta WHERE name = ?",
2114
+ (name,),
2115
+ )
2116
+ row = await cursor.fetchone()
2117
+ conv_id = row["conversation_id"] if row else None
2118
+ hibernated = bool(row["hibernated"]) if row else False
2119
+ auto_hibernate_min = (
2120
+ row["auto_hibernate_minutes"] if row and row["auto_hibernate_minutes"] else 30
2121
+ )
2122
+ session_provider = row["provider"] if row and row["provider"] else "claude"
2123
+
2124
+ # Provider-specific detection for sessions that have not been linked yet.
2125
+ if session_provider == "claude" and not conv_id:
2126
+ claude_pid = await tmux.get_cli_pid(name)
2127
+ if claude_pid:
2128
+ conv_id = claude_metrics.detect_conversation_by_pid(claude_pid)
2129
+ if not conv_id:
2130
+ pane_start = await tmux.get_pane_start_time(name)
2131
+ if pane_start:
2132
+ conv_id = claude_metrics.detect_conversation_for_session(
2133
+ pane_start,
2134
+ cwd=resolve_project_path(row["project_slug"] if row else None),
2135
+ )
2136
+ elif session_provider == "codex" and not conv_id:
2137
+ codex_pid = await tmux.get_cli_pid(
2138
+ name, process_names=get_provider("codex").process_names
2139
+ )
2140
+ if codex_pid:
2141
+ conv_id = await asyncio.to_thread(
2142
+ codex_metrics.detect_codex_for_process,
2143
+ codex_pid,
2144
+ (),
2145
+ )
2146
+ if not conv_id:
2147
+ pane_start = await tmux.get_pane_start_time(name)
2148
+ pane_cwd = await tmux.get_pane_cwd(name)
2149
+ conv_id = await asyncio.to_thread(
2150
+ codex_metrics.detect_codex_for_session,
2151
+ pane_start,
2152
+ pane_cwd,
2153
+ (),
2154
+ )
2155
+
2156
+ if not conv_id:
2157
+ return SessionMetricsResponse(
2158
+ hibernated=hibernated, auto_hibernate_minutes=auto_hibernate_min
2159
+ )
2160
+
2161
+ provider = get_metrics_provider(session_provider)
2162
+ if provider is None:
2163
+ return SessionMetricsResponse(
2164
+ conversation_id=conv_id,
2165
+ hibernated=hibernated,
2166
+ auto_hibernate_minutes=auto_hibernate_min,
2167
+ )
2168
+
2169
+ conv_cwd = None
2170
+ if session_provider == "claude":
2171
+ conv_cwd = await asyncio.to_thread(
2172
+ _resolve_conversation_cwd,
2173
+ conv_id,
2174
+ row["project_slug"] if row else None,
2175
+ )
2176
+ if not conv_cwd:
2177
+ return SessionMetricsResponse(
2178
+ conversation_id=conv_id,
2179
+ hibernated=hibernated,
2180
+ auto_hibernate_minutes=auto_hibernate_min,
2181
+ )
2182
+
2183
+ metrics = await asyncio.to_thread(provider.parse_session, conv_id, conv_cwd)
2184
+ if not metrics:
2185
+ return SessionMetricsResponse(
2186
+ conversation_id=conv_id,
2187
+ hibernated=hibernated,
2188
+ auto_hibernate_minutes=auto_hibernate_min,
2189
+ )
2190
+
2191
+ return SessionMetricsResponse(
2192
+ conversation_id=conv_id,
2193
+ model=metrics.model,
2194
+ context_pct=metrics.context_pct,
2195
+ cost_usd=metrics.cost_usd,
2196
+ message_count=metrics.message_count,
2197
+ duration_minutes=metrics.duration_minutes,
2198
+ hibernated=hibernated,
2199
+ auto_hibernate_minutes=auto_hibernate_min,
2200
+ # PR2 dual metrics
2201
+ context_pct_real=metrics.context_pct_real,
2202
+ context_pct_scaled=metrics.context_pct_scaled,
2203
+ cost_conversation_usd=metrics.cost_conversation_usd,
2204
+ cost_session_usd=metrics.cost_session_usd,
2205
+ cost_session_incomplete=metrics.cost_session_incomplete,
2206
+ input_tokens=metrics.input_tokens,
2207
+ output_tokens=metrics.output_tokens,
2208
+ reasoning_tokens=metrics.reasoning_tokens,
2209
+ working_seconds_msg=metrics.working_seconds_msg,
2210
+ pricing_version=metrics.pricing_version,
2211
+ # PR4 shadow cost
2212
+ cost_conversation_equivalent_usd=metrics.cost_conversation_equivalent_usd,
2213
+ cost_session_equivalent_usd=metrics.cost_session_equivalent_usd,
2214
+ cost_equivalent_pricing_version=metrics.cost_equivalent_pricing_version,
2215
+ )
2216
+
2217
+
2218
+ @router.post("/{name}/hibernate", status_code=200)
2219
+ async def hibernate_session(
2220
+ name: str,
2221
+ _user: UserInfo = Depends(get_current_user),
2222
+ db: aiosqlite.Connection = Depends(get_write_db),
2223
+ ):
2224
+ """Hibernate a session: exit CLI, preserve conversation for resume."""
2225
+ try:
2226
+ tmux.validate_session_name(name)
2227
+ except ValueError as e:
2228
+ raise HTTPException(status_code=422, detail=str(e))
2229
+
2230
+ if not await tmux.session_exists(name):
2231
+ raise HTTPException(status_code=404, detail="Session not found")
2232
+
2233
+ cursor = await db.execute(
2234
+ "SELECT hibernated, conversation_id, provider, project_slug, launch_model, permission_preset, theme_mode, bootstrap_message, created_at "
2235
+ "FROM sessions_meta WHERE name = ?",
2236
+ (name,),
2237
+ )
2238
+ row = await cursor.fetchone()
2239
+ if row and row["hibernated"]:
2240
+ raise HTTPException(status_code=409, detail="Session already hibernated")
2241
+
2242
+ session_provider = row["provider"] if row and row["provider"] else "claude"
2243
+ provider_config = get_provider(session_provider)
2244
+ is_claude = session_provider == "claude"
2245
+
2246
+ # Detect conversation_id before hibernating
2247
+ conv_id = row["conversation_id"] if row else None
2248
+ if is_claude and not conv_id:
2249
+ claude_pid = await tmux.get_cli_pid(
2250
+ name, process_names=provider_config.process_names
2251
+ )
2252
+ if claude_pid:
2253
+ conv_id = claude_metrics.detect_conversation_by_pid(claude_pid)
2254
+ if not conv_id:
2255
+ pane_start = await tmux.get_pane_start_time(name)
2256
+ if pane_start:
2257
+ conv_id = claude_metrics.detect_conversation_for_session(
2258
+ pane_start,
2259
+ cwd=resolve_project_path(row["project_slug"] if row else None),
2260
+ )
2261
+ elif session_provider == "opencode":
2262
+ launch_spec = build_session_start_spec(
2263
+ session_provider,
2264
+ row["project_slug"] if row else None,
2265
+ row["launch_model"] if row else None,
2266
+ row["permission_preset"] if row else None,
2267
+ session_name=name,
2268
+ )
2269
+ conv_id = await _resolve_opencode_session_id(
2270
+ db=db,
2271
+ name=name,
2272
+ launch_dir=launch_spec.launch_dir,
2273
+ stored_session_id=conv_id,
2274
+ created_at=row["created_at"] if row else None,
2275
+ )
2276
+
2277
+ # Get metrics before hibernating (Claude only)
2278
+ metrics = None
2279
+ if is_claude and conv_id:
2280
+ conv_cwd = await asyncio.to_thread(
2281
+ _resolve_conversation_cwd,
2282
+ conv_id,
2283
+ row["project_slug"] if row else None,
2284
+ )
2285
+ if conv_cwd:
2286
+ metrics = await asyncio.to_thread(
2287
+ claude_metrics.find_conversation_by_id, conv_id, conv_cwd
2288
+ )
2289
+
2290
+ # Send provider-specific exit sequence
2291
+ status = await tmux.get_session_status(name)
2292
+ if status and status in provider_config.process_names:
2293
+ for step in provider_config.exit_sequence:
2294
+ await tmux.send_keys_raw(name, step.key)
2295
+ await asyncio.sleep(step.delay_after)
2296
+
2297
+ # Update DB
2298
+ now = datetime.now(timezone.utc).isoformat()
2299
+ await db.execute("INSERT OR IGNORE INTO sessions_meta (name) VALUES (?)", (name,))
2300
+ updates = ["hibernated = 1", "hibernated_at = ?"]
2301
+ params: list = [now]
2302
+
2303
+ if conv_id:
2304
+ updates.append("conversation_id = ?")
2305
+ params.append(conv_id)
2306
+ if metrics:
2307
+ # PR3: drop legacy last_context_pct write — maintenance loop owns
2308
+ # last_context_pct_real / _scaled via Step 2.5.
2309
+ updates.extend(
2310
+ [
2311
+ "model = ?",
2312
+ "last_cost_usd = ?",
2313
+ "last_message_count = ?",
2314
+ ]
2315
+ )
2316
+ params.extend(
2317
+ [
2318
+ metrics.model,
2319
+ metrics.cost_usd,
2320
+ metrics.message_count,
2321
+ ]
2322
+ )
2323
+
2324
+ params.append(name)
2325
+ await db.execute(
2326
+ f"UPDATE sessions_meta SET {', '.join(updates)} WHERE name = ?", params
2327
+ )
2328
+ await db.commit()
2329
+
2330
+ logger.info(
2331
+ "Session hibernated: %s (conversation=%s, provider=%s)",
2332
+ name,
2333
+ conv_id,
2334
+ session_provider,
2335
+ )
2336
+ return {"status": "hibernated", "conversation_id": conv_id}
2337
+
2338
+
2339
+ @router.post("/{name}/resume", status_code=200)
2340
+ async def resume_session(
2341
+ name: str,
2342
+ _user: UserInfo = Depends(get_current_user),
2343
+ db: aiosqlite.Connection = Depends(get_write_db),
2344
+ ):
2345
+ """Resume a hibernated session: restart CLI with --resume (Claude) or fresh start (others)."""
2346
+ try:
2347
+ tmux.validate_session_name(name)
2348
+ except ValueError as e:
2349
+ raise HTTPException(status_code=422, detail=str(e))
2350
+
2351
+ if not await tmux.session_exists(name):
2352
+ raise HTTPException(status_code=404, detail="Session not found")
2353
+
2354
+ cursor = await db.execute(
2355
+ "SELECT hibernated, conversation_id, provider, project_slug, launch_model, permission_preset, theme_mode, bootstrap_message, created_at "
2356
+ "FROM sessions_meta WHERE name = ?",
2357
+ (name,),
2358
+ )
2359
+ row = await cursor.fetchone()
2360
+ if not row or not row["hibernated"]:
2361
+ raise HTTPException(status_code=409, detail="Session is not hibernated")
2362
+
2363
+ session_provider = row["provider"] if row["provider"] else "claude"
2364
+ provider_config = get_provider(session_provider)
2365
+ is_claude = session_provider == "claude"
2366
+ conv_id = row["conversation_id"]
2367
+ launch_model = row["launch_model"] if row else None
2368
+ permission_preset = row["permission_preset"] if row else None
2369
+ bootstrap_message = None
2370
+
2371
+ if is_claude:
2372
+ # Claude: try to detect conversation_id for --resume
2373
+ if not conv_id:
2374
+ claude_pid = await tmux.get_cli_pid(
2375
+ name, process_names=provider_config.process_names
2376
+ )
2377
+ if claude_pid:
2378
+ conv_id = claude_metrics.detect_conversation_by_pid(claude_pid)
2379
+ if not conv_id:
2380
+ pane_start = await tmux.get_pane_start_time(name)
2381
+ if pane_start:
2382
+ conv_id = claude_metrics.detect_conversation_for_session(
2383
+ pane_start,
2384
+ cwd=resolve_project_path(row["project_slug"] if row else None),
2385
+ )
2386
+ if conv_id:
2387
+ await db.execute(
2388
+ "UPDATE sessions_meta SET conversation_id = ? WHERE name = ?",
2389
+ (conv_id, name),
2390
+ )
2391
+
2392
+ project_slug = row["project_slug"] if row else None
2393
+ project_path = resolve_project_path(project_slug)
2394
+ resume_cwd = project_path
2395
+ if conv_id and not _CONVERSATION_ID_RE.match(conv_id):
2396
+ logger.warning(
2397
+ "Invalid conversation_id format for session %s: %s", name, conv_id
2398
+ )
2399
+ conv_id = None
2400
+ if conv_id:
2401
+ found_cwd = await asyncio.to_thread(
2402
+ _resolve_conversation_cwd, conv_id, project_slug
2403
+ )
2404
+ if found_cwd:
2405
+ resume_cwd = found_cwd
2406
+ if found_cwd != project_path:
2407
+ logger.warning(
2408
+ "Session %s resuming Claude conversation from fallback cwd %s instead of %s",
2409
+ name,
2410
+ found_cwd,
2411
+ project_path,
2412
+ )
2413
+ else:
2414
+ logger.warning(
2415
+ "Conversation %s for session %s not found under project paths; falling back to --continue",
2416
+ conv_id,
2417
+ name,
2418
+ )
2419
+ conv_id = None
2420
+ await db.execute(
2421
+ "UPDATE sessions_meta SET conversation_id = NULL WHERE name = ?",
2422
+ (name,),
2423
+ )
2424
+ if conv_id:
2425
+ cmd = build_start_command(
2426
+ provider_config, resume_cwd, model=launch_model
2427
+ ).replace(
2428
+ f"{provider_config.binary} {provider_config.cli_flags}",
2429
+ f"claude --resume {conv_id} --dangerously-skip-permissions",
2430
+ )
2431
+ else:
2432
+ cmd = build_start_command(
2433
+ provider_config, project_path, model=launch_model
2434
+ ).replace(
2435
+ f"{provider_config.binary} {provider_config.cli_flags}",
2436
+ "claude --continue --dangerously-skip-permissions",
2437
+ )
2438
+ logger.warning(
2439
+ "No conversation_id for session %s, using --continue fallback", name
2440
+ )
2441
+ elif session_provider == "opencode":
2442
+ launch_spec = build_session_start_spec(
2443
+ session_provider,
2444
+ row["project_slug"] if row else None,
2445
+ launch_model,
2446
+ permission_preset,
2447
+ session_name=name,
2448
+ theme_mode=row["theme_mode"] if row else None,
2449
+ )
2450
+ conv_id = await _resolve_opencode_session_id(
2451
+ db=db,
2452
+ name=name,
2453
+ launch_dir=launch_spec.launch_dir,
2454
+ stored_session_id=conv_id,
2455
+ created_at=row["created_at"] if row else None,
2456
+ )
2457
+ cmd = build_session_start_spec(
2458
+ session_provider,
2459
+ row["project_slug"] if row else None,
2460
+ launch_model,
2461
+ permission_preset,
2462
+ resume_session_id=conv_id,
2463
+ session_name=name,
2464
+ theme_mode=row["theme_mode"] if row else None,
2465
+ ).start_command
2466
+ else:
2467
+ # Other non-Claude providers still start fresh on resume.
2468
+ cmd = build_session_start_spec(
2469
+ session_provider,
2470
+ row["project_slug"] if row else None,
2471
+ launch_model,
2472
+ permission_preset,
2473
+ session_name=name,
2474
+ theme_mode=row["theme_mode"] if row else None,
2475
+ ).start_command
2476
+
2477
+ launched_at_ms = int(_time.time() * 1000)
2478
+ await tmux.exit_copy_mode(name)
2479
+ if session_provider == "opencode":
2480
+ await _recreate_tmux_session(name, cmd)
2481
+ else:
2482
+ await tmux.send_keys(
2483
+ name, cmd, double_enter=provider_config.submit_with_double_enter
2484
+ )
2485
+
2486
+ if session_provider == "opencode" and not conv_id:
2487
+ launch_spec = build_session_start_spec(
2488
+ session_provider,
2489
+ row["project_slug"] if row else None,
2490
+ launch_model,
2491
+ permission_preset,
2492
+ session_name=name,
2493
+ theme_mode=row["theme_mode"] if row else None,
2494
+ )
2495
+ conv_id = await _capture_new_opencode_session_id(
2496
+ db=db,
2497
+ name=name,
2498
+ launch_dir=launch_spec.launch_dir,
2499
+ launched_at_ms=launched_at_ms,
2500
+ )
2501
+
2502
+ await db.execute(
2503
+ "UPDATE sessions_meta SET hibernated = 0, hibernated_at = NULL WHERE name = ?",
2504
+ (name,),
2505
+ )
2506
+ await db.commit()
2507
+
2508
+ logger.info(
2509
+ "Session resumed: %s (conversation=%s, provider=%s)",
2510
+ name,
2511
+ conv_id,
2512
+ session_provider,
2513
+ )
2514
+ return {"status": "resumed", "conversation_id": conv_id}
2515
+
2516
+
2517
+ @router.post("/{name}/restart", status_code=200)
2518
+ async def restart_session(
2519
+ name: str,
2520
+ _user: UserInfo = Depends(get_current_user),
2521
+ db: aiosqlite.Connection = Depends(get_write_db),
2522
+ ):
2523
+ """Restart a session: exit and auto-resume last conversation, or start fresh if none."""
2524
+ try:
2525
+ tmux.validate_session_name(name)
2526
+ except ValueError as e:
2527
+ raise HTTPException(status_code=422, detail=str(e))
2528
+
2529
+ if not await tmux.session_exists(name):
2530
+ raise HTTPException(status_code=404, detail="Session not found")
2531
+
2532
+ # Detect conversation_id before restarting
2533
+ cursor = await db.execute(
2534
+ "SELECT conversation_id, provider, project_slug, launch_model, permission_preset, theme_mode, bootstrap_message, created_at "
2535
+ "FROM sessions_meta WHERE name = ?",
2536
+ (name,),
2537
+ )
2538
+ row = await cursor.fetchone()
2539
+ conv_id = row["conversation_id"] if row else None
2540
+ session_provider = row["provider"] if row and row["provider"] else "claude"
2541
+ provider_config = get_provider(session_provider)
2542
+ is_claude = session_provider == "claude"
2543
+ launch_model = row["launch_model"] if row else None
2544
+ permission_preset = row["permission_preset"] if row else None
2545
+ bootstrap_message = None
2546
+
2547
+ if is_claude and not conv_id:
2548
+ claude_pid = await tmux.get_cli_pid(
2549
+ name, process_names=provider_config.process_names
2550
+ )
2551
+ if claude_pid:
2552
+ conv_id = claude_metrics.detect_conversation_by_pid(claude_pid)
2553
+ if not conv_id:
2554
+ pane_start = await tmux.get_pane_start_time(name)
2555
+ if pane_start:
2556
+ conv_id = claude_metrics.detect_conversation_for_session(
2557
+ pane_start,
2558
+ cwd=resolve_project_path(row["project_slug"] if row else None),
2559
+ )
2560
+
2561
+ # Validate conv_id format (Claude only)
2562
+ if is_claude and conv_id and not _CONVERSATION_ID_RE.match(conv_id):
2563
+ logger.warning(
2564
+ "Invalid conversation_id format for session %s: %s", name, conv_id
2565
+ )
2566
+ conv_id = None
2567
+
2568
+ status = await tmux.get_session_status(name)
2569
+ if status and status in provider_config.process_names:
2570
+ if is_claude:
2571
+ # Claude-specific exit: C-c, C-u, optional /rename, /exit
2572
+ await tmux.send_keys_raw(name, "C-c")
2573
+ await asyncio.sleep(1.0)
2574
+ await tmux.send_keys_raw(name, "C-u")
2575
+ await asyncio.sleep(0.3)
2576
+ if not conv_id:
2577
+ # Fresh start: rename first to avoid conversation name collisions
2578
+ ts_suffix = datetime.now(timezone.utc).strftime("%y%m%d-%H%M")
2579
+ rename_label = f"{name}-{ts_suffix}"
2580
+ await tmux.send_keys_raw(name, f"/rename {rename_label}")
2581
+ await asyncio.sleep(0.3)
2582
+ await tmux.send_keys_raw(name, "Enter")
2583
+ await asyncio.sleep(1.0)
2584
+ await tmux.send_keys_raw(name, "C-u")
2585
+ await asyncio.sleep(0.3)
2586
+ await tmux.send_keys_raw(name, "/exit")
2587
+ await asyncio.sleep(0.3)
2588
+ await tmux.send_keys_raw(name, "Enter")
2589
+ await asyncio.sleep(3.0)
2590
+ else:
2591
+ # Non-Claude: use provider exit sequence
2592
+ for step in provider_config.exit_sequence:
2593
+ await tmux.send_keys_raw(name, step.key)
2594
+ await asyncio.sleep(step.delay_after)
2595
+
2596
+ # Resume last conversation if available (Claude only), otherwise start fresh
2597
+ await tmux.exit_copy_mode(name)
2598
+ project_slug = row["project_slug"] if row else None
2599
+ project_path = resolve_project_path(project_slug)
2600
+ resume_cwd = project_path
2601
+ if conv_id:
2602
+ found_cwd = await asyncio.to_thread(
2603
+ _resolve_conversation_cwd, conv_id, project_slug
2604
+ )
2605
+ if found_cwd:
2606
+ resume_cwd = found_cwd
2607
+ if found_cwd != project_path:
2608
+ logger.warning(
2609
+ "Session %s restarting Claude conversation from fallback cwd %s instead of %s",
2610
+ name,
2611
+ found_cwd,
2612
+ project_path,
2613
+ )
2614
+ else:
2615
+ logger.warning(
2616
+ "Conversation %s for session %s not found under project paths; restarting fresh",
2617
+ conv_id,
2618
+ name,
2619
+ )
2620
+ conv_id = None
2621
+ if is_claude and conv_id:
2622
+ cmd = build_start_command(
2623
+ provider_config, resume_cwd, model=launch_model
2624
+ ).replace(
2625
+ f"{provider_config.binary} {provider_config.cli_flags}",
2626
+ f"claude --resume {conv_id} --dangerously-skip-permissions",
2627
+ )
2628
+ elif session_provider == "opencode":
2629
+ launch_spec = build_session_start_spec(
2630
+ session_provider,
2631
+ project_slug,
2632
+ launch_model,
2633
+ permission_preset,
2634
+ session_name=name,
2635
+ theme_mode=row["theme_mode"] if row else None,
2636
+ )
2637
+ conv_id = await _resolve_opencode_session_id(
2638
+ db=db,
2639
+ name=name,
2640
+ launch_dir=launch_spec.launch_dir,
2641
+ stored_session_id=conv_id,
2642
+ created_at=row["created_at"] if row else None,
2643
+ )
2644
+ cmd = build_session_start_spec(
2645
+ session_provider,
2646
+ project_slug,
2647
+ launch_model,
2648
+ permission_preset,
2649
+ resume_session_id=conv_id,
2650
+ session_name=name,
2651
+ theme_mode=row["theme_mode"] if row else None,
2652
+ ).start_command
2653
+ else:
2654
+ cmd = build_session_start_spec(
2655
+ session_provider,
2656
+ project_slug,
2657
+ launch_model,
2658
+ permission_preset,
2659
+ session_name=name,
2660
+ theme_mode=row["theme_mode"] if row else None,
2661
+ ).start_command
2662
+ launched_at_ms = int(_time.time() * 1000)
2663
+ if is_claude:
2664
+ await tmux.send_keys(
2665
+ name, cmd, double_enter=provider_config.submit_with_double_enter
2666
+ )
2667
+ else:
2668
+ # Non-Claude restarts recreate the tmux session to avoid stale TUI state.
2669
+ await _recreate_tmux_session(name, cmd)
2670
+
2671
+ if session_provider == "opencode" and not conv_id:
2672
+ launch_spec = build_session_start_spec(
2673
+ session_provider,
2674
+ project_slug,
2675
+ launch_model,
2676
+ permission_preset,
2677
+ session_name=name,
2678
+ theme_mode=row["theme_mode"] if row else None,
2679
+ )
2680
+ conv_id = await _capture_new_opencode_session_id(
2681
+ db=db,
2682
+ name=name,
2683
+ launch_dir=launch_spec.launch_dir,
2684
+ launched_at_ms=launched_at_ms,
2685
+ )
2686
+
2687
+ # Update DB: clear metrics. Clear conversation_id only on fresh start.
2688
+ # PR3: clear dual columns (migration 087) + legacy column for completeness.
2689
+ now = datetime.now(timezone.utc).isoformat()
2690
+ if conv_id:
2691
+ await db.execute(
2692
+ """UPDATE sessions_meta SET
2693
+ hibernated = 0, hibernated_at = NULL,
2694
+ last_context_pct_real = NULL, last_context_pct_scaled = NULL,
2695
+ last_context_pct_legacy = NULL,
2696
+ last_cost_usd = NULL,
2697
+ last_cost_conversation_usd = NULL, last_cost_session_usd = NULL,
2698
+ last_cost_conversation_equivalent_usd = NULL,
2699
+ last_cost_session_equivalent_usd = NULL,
2700
+ last_cost_equivalent_pricing_version = NULL,
2701
+ last_message_count = NULL, last_metrics_at = NULL,
2702
+ last_active = ?
2703
+ WHERE name = ?""",
2704
+ (now, name),
2705
+ )
2706
+ else:
2707
+ await db.execute(
2708
+ """UPDATE sessions_meta SET
2709
+ conversation_id = NULL, hibernated = 0, hibernated_at = NULL,
2710
+ last_context_pct_real = NULL, last_context_pct_scaled = NULL,
2711
+ last_context_pct_legacy = NULL,
2712
+ last_cost_usd = NULL,
2713
+ last_cost_conversation_usd = NULL, last_cost_session_usd = NULL,
2714
+ last_cost_conversation_equivalent_usd = NULL,
2715
+ last_cost_session_equivalent_usd = NULL,
2716
+ last_cost_equivalent_pricing_version = NULL,
2717
+ last_message_count = NULL, last_metrics_at = NULL,
2718
+ last_active = ?
2719
+ WHERE name = ?""",
2720
+ (now, name),
2721
+ )
2722
+ await db.commit()
2723
+
2724
+ logger.info(
2725
+ "Session restarted: %s (conversation=%s, resumed=%s)",
2726
+ name,
2727
+ conv_id,
2728
+ bool(conv_id),
2729
+ )
2730
+ return {
2731
+ "status": "restarted",
2732
+ "previous_conversation_id": conv_id,
2733
+ "resumed": bool(conv_id),
2734
+ }
2735
+
2736
+
2737
+ @router.post("/{name}/complete", status_code=200)
2738
+ async def complete_session(
2739
+ name: str,
2740
+ _user: UserInfo = Depends(get_current_user),
2741
+ ):
2742
+ """Complete a session: stamp completed_at, exit Claude Code, kill tmux session.
2743
+
2744
+ Unlike delete (which destroys data), complete preserves session_costs history
2745
+ and sets completed_at so the CostsTab can show sessions that finished cleanly.
2746
+ """
2747
+ try:
2748
+ tmux.validate_session_name(name)
2749
+ except ValueError as e:
2750
+ raise HTTPException(status_code=422, detail=str(e))
2751
+
2752
+ if not await tmux.session_exists(name):
2753
+ raise HTTPException(status_code=404, detail="Session not found")
2754
+
2755
+ # Read metadata without holding the writer during tmux/provider I/O.
2756
+ async with acquire_db() as db:
2757
+ cursor = await db.execute(
2758
+ "SELECT conversation_id, project_slug, working_seconds, provider FROM sessions_meta WHERE name = ?",
2759
+ (name,),
2760
+ )
2761
+ row = await cursor.fetchone()
2762
+
2763
+ conv_id = row["conversation_id"] if row else None
2764
+ session_provider = row["provider"] if row and row["provider"] else "claude"
2765
+ provider_config = get_provider(session_provider)
2766
+ is_claude = session_provider == "claude"
2767
+
2768
+ if is_claude and not conv_id:
2769
+ claude_pid = await tmux.get_cli_pid(
2770
+ name, process_names=provider_config.process_names
2771
+ )
2772
+ if claude_pid:
2773
+ conv_id = claude_metrics.detect_conversation_by_pid(claude_pid)
2774
+ if not conv_id:
2775
+ pane_start = await tmux.get_pane_start_time(name)
2776
+ if pane_start:
2777
+ conv_id = claude_metrics.detect_conversation_for_session(
2778
+ pane_start,
2779
+ cwd=resolve_project_path(row["project_slug"] if row else None),
2780
+ )
2781
+
2782
+ now = datetime.now(timezone.utc).isoformat()
2783
+
2784
+ # Exit CLI gracefully if running (provider-specific exit sequence)
2785
+ status = await tmux.get_session_status(name)
2786
+ if status and status in provider_config.process_names:
2787
+ for step in provider_config.exit_sequence:
2788
+ await tmux.send_keys_raw(name, step.key)
2789
+ await asyncio.sleep(step.delay_after)
2790
+
2791
+ # Kill tmux session
2792
+ success = await tmux.kill_session(name)
2793
+ if not success:
2794
+ raise HTTPException(status_code=500, detail="Failed to kill session")
2795
+
2796
+ cost_row = None
2797
+ async with write_db() as db:
2798
+ if conv_id:
2799
+ await db.execute(
2800
+ "UPDATE session_costs SET completed_at = ? WHERE conversation_id = ?",
2801
+ (now, conv_id),
2802
+ )
2803
+
2804
+ await db.execute(
2805
+ "UPDATE session_costs SET session_name = NULL WHERE session_name = ? AND completed_at IS NOT NULL",
2806
+ (name,),
2807
+ )
2808
+ await db.execute("DELETE FROM sessions_meta WHERE name = ?", (name,))
2809
+
2810
+ if conv_id:
2811
+ cursor = await db.execute(
2812
+ "SELECT cost_usd, input_tokens, output_tokens, message_count FROM session_costs WHERE conversation_id = ?",
2813
+ (conv_id,),
2814
+ )
2815
+ cost_row = await cursor.fetchone()
2816
+
2817
+ await db.commit()
2818
+
2819
+ _invalidate_sessions_cache()
2820
+ asyncio.create_task(_get_session_manager().broadcast_session_event("destroyed"))
2821
+
2822
+ # Build recap from session_costs
2823
+ recap: dict = {"conversation_id": conv_id, "completed_at": now}
2824
+ if cost_row:
2825
+ recap.update(
2826
+ {
2827
+ "cost_usd": round(cost_row["cost_usd"], 4),
2828
+ "input_tokens": cost_row["input_tokens"] or 0,
2829
+ "output_tokens": cost_row["output_tokens"] or 0,
2830
+ "message_count": cost_row["message_count"],
2831
+ "working_seconds": row["working_seconds"] if row else 0,
2832
+ }
2833
+ )
2834
+
2835
+ logger.info("Session completed: %s (conversation=%s)", name, conv_id)
2836
+ return {"status": "completed", **recap}
2837
+
2838
+
2839
+ @router.post("/{name}/send-message", status_code=200)
2840
+ async def send_message_to_session(
2841
+ name: str,
2842
+ body: SendMessageBody,
2843
+ _user: UserInfo = Depends(get_current_user),
2844
+ db: aiosqlite.Connection = Depends(get_write_db),
2845
+ ):
2846
+ """Send a message to a session via tmux send-keys.
2847
+
2848
+ Polls until the CLI is ready, then injects the text using tmux send-keys.
2849
+ Uses double Enter for Claude Code (multiline input) and single Enter for others.
2850
+ """
2851
+ try:
2852
+ tmux.validate_session_name(name)
2853
+ except ValueError as e:
2854
+ raise HTTPException(status_code=422, detail=str(e))
2855
+
2856
+ if not await tmux.session_exists(name):
2857
+ raise HTTPException(status_code=404, detail="Session not found")
2858
+
2859
+ # Read provider from DB
2860
+ cursor = await db.execute(
2861
+ "SELECT provider FROM sessions_meta WHERE name = ?", (name,)
2862
+ )
2863
+ row = await cursor.fetchone()
2864
+ session_provider = row["provider"] if row and row["provider"] else "claude"
2865
+ provider_config = get_provider(session_provider)
2866
+
2867
+ # Poll until CLI is ready (max 15s)
2868
+ for _ in range(30):
2869
+ status = await tmux.get_session_status(name)
2870
+ if status and status in provider_config.process_names:
2871
+ break
2872
+ await asyncio.sleep(0.5)
2873
+
2874
+ await tmux.send_keys(
2875
+ name, body.text, double_enter=provider_config.submit_with_double_enter
2876
+ )
2877
+ logger.info(
2878
+ "Message sent to session %s: %r (provider=%s)",
2879
+ name,
2880
+ body.text[:80],
2881
+ session_provider,
2882
+ )
2883
+ return {"status": "sent"}
2884
+
2885
+
2886
+ @router.get("/{name}/conversation")
2887
+ async def get_session_conversation(
2888
+ name: str,
2889
+ limit: int = 20,
2890
+ role: str | None = None,
2891
+ since: str | None = None,
2892
+ db: aiosqlite.Connection = Depends(get_db),
2893
+ _user: UserInfo = Depends(get_current_user_or_agent),
2894
+ ):
2895
+ """Read conversation messages from a CC session JSONL."""
2896
+ from core.api.services.conversation_reader import read_conversation
2897
+
2898
+ cursor = await db.execute(
2899
+ "SELECT conversation_id, provider FROM sessions_meta WHERE name = ?",
2900
+ (name,),
2901
+ )
2902
+ row = await cursor.fetchone()
2903
+ if not row:
2904
+ raise HTTPException(404, "Session not found")
2905
+ if (row["provider"] or "claude") != "claude":
2906
+ raise HTTPException(
2907
+ 409, "Conversation history is only available for Claude sessions"
2908
+ )
2909
+ if not row["conversation_id"]:
2910
+ raise HTTPException(404, "No conversation linked to this session")
2911
+ try:
2912
+ messages = await read_conversation(
2913
+ row["conversation_id"], limit=limit, role=role, since=since
2914
+ )
2915
+ except ValueError as e:
2916
+ raise HTTPException(400, str(e))
2917
+ return {
2918
+ "conversation_id": row["conversation_id"],
2919
+ "messages": messages or [],
2920
+ }
2921
+
2922
+
2923
+ @router.get("/by-name/{name}", response_model=SessionInfo)
2924
+ async def get_session_by_name(
2925
+ name: str,
2926
+ _user: UserInfo = Depends(get_current_user_or_agent),
2927
+ db: aiosqlite.Connection = Depends(get_db),
2928
+ ):
2929
+ """Look up a single session by its tmux name (PR2 — MCP get_session).
2930
+
2931
+ Returns the full SessionInfo including the dual metrics introduced by
2932
+ migration 087 (last_context_pct_real/_scaled, last_cost_conversation_usd,
2933
+ last_cost_session_usd, input/output/reasoning token counts,
2934
+ working_seconds_msg, metrics_refreshed_at, pricing_version).
2935
+ """
2936
+ try:
2937
+ tmux.validate_session_name(name)
2938
+ except ValueError as e:
2939
+ raise HTTPException(status_code=422, detail=str(e))
2940
+
2941
+ cursor = await db.execute(
2942
+ f"SELECT {DB_COLUMNS} FROM sessions_meta WHERE name = ?",
2943
+ (name,),
2944
+ )
2945
+ row = await cursor.fetchone()
2946
+ if not row:
2947
+ raise HTTPException(status_code=404, detail="Session not found")
2948
+
2949
+ session_provider = row["provider"] if row["provider"] else "claude"
2950
+ alive = await tmux.session_exists(name)
2951
+ status = await tmux.get_session_status(name) if alive else None
2952
+ pane_text = (
2953
+ await tmux.capture_pane(name)
2954
+ if alive and status in ALL_KNOWN_PROCESS_NAMES
2955
+ else None
2956
+ )
2957
+ activity = (
2958
+ tmux.detect_activity_state(pane_text, status, provider=session_provider)
2959
+ if pane_text
2960
+ else None
2961
+ )
2962
+
2963
+ def _opt(col: str):
2964
+ return row[col] if row is not None and col in row.keys() else None
2965
+
2966
+ return SessionInfo(
2967
+ name=name,
2968
+ display_name=row["display_name"],
2969
+ pinned=bool(row["pinned"]) if row["pinned"] else False,
2970
+ sort_order=row["sort_order"] if row["sort_order"] else 0,
2971
+ group_name=row["group_name"],
2972
+ project_slug=row["project_slug"],
2973
+ session_uuid=row["session_uuid"],
2974
+ status=status,
2975
+ created_at=row["created_at"],
2976
+ last_active=row["last_active"],
2977
+ attached=False,
2978
+ hibernated=bool(row["hibernated"]) if row["hibernated"] else False,
2979
+ conversation_id=row["conversation_id"],
2980
+ model=row["model"] or row["launch_model"],
2981
+ launch_model=row["launch_model"],
2982
+ permission_preset=row["permission_preset"],
2983
+ last_context_pct=row["last_context_pct"],
2984
+ last_cost_usd=row["last_cost_usd"],
2985
+ last_message_count=row["last_message_count"],
2986
+ auto_hibernate_minutes=row["auto_hibernate_minutes"]
2987
+ if row["auto_hibernate_minutes"]
2988
+ else 30,
2989
+ activity_state=activity,
2990
+ working_seconds=row["working_seconds"] if row["working_seconds"] else 0,
2991
+ created_epoch=_created_epoch_from_iso(row["created_at"]),
2992
+ agent_managed=bool(row["agent_managed"]) if row["agent_managed"] else False,
2993
+ owner_id=row["owner_id"] if "owner_id" in row.keys() else None,
2994
+ provider=session_provider,
2995
+ # PR2 dual metrics
2996
+ last_context_pct_real=_opt("last_context_pct_real"),
2997
+ last_context_pct_scaled=_opt("last_context_pct_scaled"),
2998
+ last_cost_conversation_usd=_opt("last_cost_conversation_usd"),
2999
+ last_cost_session_usd=_opt("last_cost_session_usd"),
3000
+ last_cost_session_incomplete=bool(_opt("last_cost_session_incomplete") or 0),
3001
+ last_input_tokens=_opt("last_input_tokens"),
3002
+ last_output_tokens=_opt("last_output_tokens"),
3003
+ last_reasoning_tokens=_opt("last_reasoning_tokens"),
3004
+ working_seconds_msg=_opt("working_seconds_msg"),
3005
+ metrics_refreshed_at=_opt("metrics_refreshed_at"),
3006
+ pricing_version=_opt("pricing_version"),
3007
+ # PR4 shadow cost (migration 089)
3008
+ last_cost_conversation_equivalent_usd=_opt(
3009
+ "last_cost_conversation_equivalent_usd"
3010
+ ),
3011
+ last_cost_session_equivalent_usd=_opt("last_cost_session_equivalent_usd"),
3012
+ last_cost_equivalent_pricing_version=_opt(
3013
+ "last_cost_equivalent_pricing_version"
3014
+ ),
3015
+ )
3016
+
3017
+
3018
+ @router.get("/by-uuid/{session_uuid}", response_model=SessionInfo)
3019
+ async def get_session_by_uuid(
3020
+ session_uuid: str = PathParam(..., pattern=_UUID_V4_PATTERN),
3021
+ _user: UserInfo = Depends(get_current_user_or_agent),
3022
+ db: aiosqlite.Connection = Depends(get_db),
3023
+ ):
3024
+ """Look up a session by its stable UUID (for permalink support)."""
3025
+ cursor = await db.execute(
3026
+ f"SELECT {DB_COLUMNS} FROM sessions_meta WHERE session_uuid = ?",
3027
+ (session_uuid,),
3028
+ )
3029
+ row = await cursor.fetchone()
3030
+ if not row:
3031
+ raise HTTPException(status_code=404, detail="Session not found")
3032
+
3033
+ name = row["name"]
3034
+ session_provider = row["provider"] if row["provider"] else "claude"
3035
+ alive = await tmux.session_exists(name)
3036
+ status = await tmux.get_session_status(name) if alive else None
3037
+ pane_text = (
3038
+ await tmux.capture_pane(name)
3039
+ if alive and status in ALL_KNOWN_PROCESS_NAMES
3040
+ else None
3041
+ )
3042
+ activity = (
3043
+ tmux.detect_activity_state(pane_text, status, provider=session_provider)
3044
+ if pane_text
3045
+ else None
3046
+ )
3047
+
3048
+ def _opt(col: str):
3049
+ return row[col] if row is not None and col in row.keys() else None
3050
+
3051
+ return SessionInfo(
3052
+ name=name,
3053
+ display_name=row["display_name"],
3054
+ pinned=bool(row["pinned"]) if row["pinned"] else False,
3055
+ sort_order=row["sort_order"] if row["sort_order"] else 0,
3056
+ group_name=row["group_name"],
3057
+ project_slug=row["project_slug"],
3058
+ session_uuid=row["session_uuid"],
3059
+ status=status,
3060
+ created_at=row["created_at"],
3061
+ last_active=row["last_active"],
3062
+ attached=False,
3063
+ hibernated=bool(row["hibernated"]) if row["hibernated"] else False,
3064
+ conversation_id=row["conversation_id"],
3065
+ model=row["model"] or row["launch_model"],
3066
+ launch_model=row["launch_model"],
3067
+ permission_preset=row["permission_preset"],
3068
+ last_context_pct=row["last_context_pct"],
3069
+ last_cost_usd=row["last_cost_usd"],
3070
+ last_message_count=row["last_message_count"],
3071
+ auto_hibernate_minutes=row["auto_hibernate_minutes"]
3072
+ if row["auto_hibernate_minutes"]
3073
+ else 30,
3074
+ activity_state=activity,
3075
+ working_seconds=row["working_seconds"] if row["working_seconds"] else 0,
3076
+ created_epoch=_created_epoch_from_iso(row["created_at"]),
3077
+ agent_managed=bool(row["agent_managed"]) if row["agent_managed"] else False,
3078
+ owner_id=row["owner_id"] if "owner_id" in row.keys() else None,
3079
+ provider=session_provider,
3080
+ # PR2 dual metrics
3081
+ last_context_pct_real=_opt("last_context_pct_real"),
3082
+ last_context_pct_scaled=_opt("last_context_pct_scaled"),
3083
+ last_cost_conversation_usd=_opt("last_cost_conversation_usd"),
3084
+ last_cost_session_usd=_opt("last_cost_session_usd"),
3085
+ last_cost_session_incomplete=bool(_opt("last_cost_session_incomplete") or 0),
3086
+ last_input_tokens=_opt("last_input_tokens"),
3087
+ last_output_tokens=_opt("last_output_tokens"),
3088
+ last_reasoning_tokens=_opt("last_reasoning_tokens"),
3089
+ working_seconds_msg=_opt("working_seconds_msg"),
3090
+ metrics_refreshed_at=_opt("metrics_refreshed_at"),
3091
+ pricing_version=_opt("pricing_version"),
3092
+ # PR4 shadow cost (migration 089)
3093
+ last_cost_conversation_equivalent_usd=_opt(
3094
+ "last_cost_conversation_equivalent_usd"
3095
+ ),
3096
+ last_cost_session_equivalent_usd=_opt("last_cost_session_equivalent_usd"),
3097
+ last_cost_equivalent_pricing_version=_opt(
3098
+ "last_cost_equivalent_pricing_version"
3099
+ ),
3100
+ )