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.
- core/api/__init__.py +0 -0
- core/api/agents/__init__.py +0 -0
- core/api/agents/session_health.py +59 -0
- core/api/agents/session_manager.py +206 -0
- core/api/bin/marvisx-state-hook.py +182 -0
- core/api/config.py +533 -0
- core/api/db.py +1516 -0
- core/api/dependencies/__init__.py +0 -0
- core/api/dependencies/tenant.py +34 -0
- core/api/main.py +1641 -0
- core/api/mcp/__init__.py +8 -0
- core/api/mcp/_adapter.py +184 -0
- core/api/mcp/server.py +58 -0
- core/api/mcp/tools/__init__.py +59 -0
- core/api/mcp/tools/brain.py +599 -0
- core/api/mcp/tools/graph.py +380 -0
- core/api/mcp/tools/handoffs.py +112 -0
- core/api/mcp/tools/ingest.py +326 -0
- core/api/mcp/tools/learnings.py +144 -0
- core/api/mcp/tools/projects.py +99 -0
- core/api/mcp/tools/pull_requests.py +173 -0
- core/api/mcp/tools/safety.py +111 -0
- core/api/mcp/tools/search.py +79 -0
- core/api/mcp/tools/tasks.py +258 -0
- core/api/middleware/__init__.py +0 -0
- core/api/middleware/tool_call_audit.py +111 -0
- core/api/models/__init__.py +346 -0
- core/api/models/auth.py +48 -0
- core/api/models/brain.py +1006 -0
- core/api/models/common.py +76 -0
- core/api/models/costs.py +91 -0
- core/api/models/graph.py +66 -0
- core/api/models/graph_cosmo.py +125 -0
- core/api/models/graph_pr_impact.py +257 -0
- core/api/models/graph_ux.py +141 -0
- core/api/models/inbox.py +230 -0
- core/api/models/ingest_keys.py +108 -0
- core/api/models/kg.py +41 -0
- core/api/models/llm_config.py +56 -0
- core/api/models/monitoring.py +234 -0
- core/api/models/projects.py +161 -0
- core/api/models/search.py +42 -0
- core/api/models/sessions.py +322 -0
- core/api/models/tasks.py +184 -0
- core/api/models/teams.py +63 -0
- core/api/models/users.py +184 -0
- core/api/observability/__init__.py +0 -0
- core/api/observability/tracing.py +92 -0
- core/api/paths.py +26 -0
- core/api/rate_limit.py +24 -0
- core/api/rbac.py +112 -0
- core/api/routers/__init__.py +0 -0
- core/api/routers/_adapter.py +24 -0
- core/api/routers/admin_pr_impact.py +230 -0
- core/api/routers/admin_settings.py +147 -0
- core/api/routers/agent.py +1079 -0
- core/api/routers/agent_tokens.py +276 -0
- core/api/routers/app_settings.py +112 -0
- core/api/routers/audit.py +89 -0
- core/api/routers/auth.py +586 -0
- core/api/routers/bench.py +161 -0
- core/api/routers/brain.py +881 -0
- core/api/routers/brain_directions.py +527 -0
- core/api/routers/ci_checks.py +140 -0
- core/api/routers/comments.py +273 -0
- core/api/routers/costs.py +148 -0
- core/api/routers/docs_coverage.py +217 -0
- core/api/routers/docs_governance.py +63 -0
- core/api/routers/documents.py +318 -0
- core/api/routers/files.py +163 -0
- core/api/routers/finder.py +987 -0
- core/api/routers/graph.py +836 -0
- core/api/routers/handoffs.py +156 -0
- core/api/routers/inbox.py +496 -0
- core/api/routers/ingest_api_keys.py +205 -0
- core/api/routers/ingest_triage.py +1227 -0
- core/api/routers/judge.py +306 -0
- core/api/routers/kg.py +336 -0
- core/api/routers/learnings.py +253 -0
- core/api/routers/llm_config.py +130 -0
- core/api/routers/monitoring.py +347 -0
- core/api/routers/notifications.py +125 -0
- core/api/routers/pr_impact.py +315 -0
- core/api/routers/projects.py +1061 -0
- core/api/routers/pull_requests.py +312 -0
- core/api/routers/push.py +67 -0
- core/api/routers/raci.py +228 -0
- core/api/routers/search.py +125 -0
- core/api/routers/sessions.py +3100 -0
- core/api/routers/settings.py +90 -0
- core/api/routers/share_repo.py +68 -0
- core/api/routers/status_updates.py +96 -0
- core/api/routers/tags.py +45 -0
- core/api/routers/tasks.py +526 -0
- core/api/routers/teams.py +425 -0
- core/api/routers/terminal.py +105 -0
- core/api/routers/users.py +331 -0
- core/api/routers/webhooks.py +330 -0
- core/api/runtime_settings.py +84 -0
- core/api/security.py +652 -0
- core/api/services/__init__.py +0 -0
- core/api/services/audit.py +58 -0
- core/api/services/auto_approval.py +82 -0
- core/api/services/brain/__init__.py +52 -0
- core/api/services/brain/baseline.py +230 -0
- core/api/services/brain/capabilities.py +75 -0
- core/api/services/brain/cascade_rollup.py +388 -0
- core/api/services/brain/compound_bridge.py +215 -0
- core/api/services/brain/cycle.py +1242 -0
- core/api/services/brain/cycle_snapshot.py +371 -0
- core/api/services/brain/digest_collector.py +147 -0
- core/api/services/brain/direction.py +421 -0
- core/api/services/brain/drift.py +356 -0
- core/api/services/brain/drift_router.py +409 -0
- core/api/services/brain/edge_metrics.py +79 -0
- core/api/services/brain/events_reader.py +222 -0
- core/api/services/brain/findings.py +1379 -0
- core/api/services/brain/findings_reader.py +1006 -0
- core/api/services/brain/jobs.py +733 -0
- core/api/services/brain/journal.py +206 -0
- core/api/services/brain/knowledge_forms.py +92 -0
- core/api/services/brain/llm/__init__.py +37 -0
- core/api/services/brain/llm/_runner.py +62 -0
- core/api/services/brain/llm/base.py +70 -0
- core/api/services/brain/llm/cache.py +99 -0
- core/api/services/brain/llm/constants.py +46 -0
- core/api/services/brain/llm/direction_alignment.py +289 -0
- core/api/services/brain/llm/factory.py +132 -0
- core/api/services/brain/llm/finding_reasoning.py +98 -0
- core/api/services/brain/llm/finding_summary.py +92 -0
- core/api/services/brain/llm/grounding.py +46 -0
- core/api/services/brain/llm/journal_polish.py +96 -0
- core/api/services/brain/llm/local_gateway.py +426 -0
- core/api/services/brain/llm/parsers.py +71 -0
- core/api/services/brain/llm/router_glue.py +422 -0
- core/api/services/brain/memory_ops.py +1677 -0
- core/api/services/brain/models.py +140 -0
- core/api/services/brain/owner_hint.py +211 -0
- core/api/services/brain/recap.py +307 -0
- core/api/services/brain/rules/__init__.py +65 -0
- core/api/services/brain/rules/_signals.py +205 -0
- core/api/services/brain/rules/dr1_activity_without_status.py +108 -0
- core/api/services/brain/rules/dr2_decision_without_adr.py +110 -0
- core/api/services/brain/rules/dr3_stale_open_loop.py +127 -0
- core/api/services/brain/rules/dr4_docs_governance_drift.py +79 -0
- core/api/services/brain/rules/dr5_playbook_changed.py +99 -0
- core/api/services/brain/rules/dr6_external_update_unpropagated.py +100 -0
- core/api/services/brain/rules/dr7_claimed_decision_gap.py +123 -0
- core/api/services/brain/rules/dr8_direction_misalignment.py +230 -0
- core/api/services/brain/runs_reader.py +485 -0
- core/api/services/brain/scope.py +79 -0
- core/api/services/brain/sources/__init__.py +46 -0
- core/api/services/brain/sources/base.py +86 -0
- core/api/services/brain/sources/git_kg.py +393 -0
- core/api/services/brain/sources/handoffs.py +157 -0
- core/api/services/brain/sources/ingestor.py +130 -0
- core/api/services/brain/sources/learnings.py +121 -0
- core/api/services/brain/sources/pir_tasks.py +245 -0
- core/api/services/brain/watermarks.py +147 -0
- core/api/services/brain/ws_emitter.py +170 -0
- core/api/services/cc_tasks_reader.py +76 -0
- core/api/services/ci_service.py +263 -0
- core/api/services/claude_metrics.py +796 -0
- core/api/services/codex_metrics.py +364 -0
- core/api/services/conversation_reader.py +102 -0
- core/api/services/cost_service.py +243 -0
- core/api/services/crypto.py +147 -0
- core/api/services/docs_governance/__init__.py +1 -0
- core/api/services/docs_governance/confidence.py +230 -0
- core/api/services/docs_governance/config.py +87 -0
- core/api/services/docs_governance/enrichment.py +65 -0
- core/api/services/docs_governance/frontmatter_validator.py +83 -0
- core/api/services/docs_governance/hard_gates.py +221 -0
- core/api/services/docs_governance/triage_orchestrator.py +98 -0
- core/api/services/embedding_internal.py +395 -0
- core/api/services/embedding_service.py +832 -0
- core/api/services/event_dispatcher.py +167 -0
- core/api/services/events.py +70 -0
- core/api/services/git_ops.py +621 -0
- core/api/services/graph_cosmo_service.py +440 -0
- core/api/services/graph_ranker.py +306 -0
- core/api/services/graph_service.py +1589 -0
- core/api/services/inbox.py +800 -0
- core/api/services/inbox_digest.py +221 -0
- core/api/services/inbox_digest_deep_research.py +80 -0
- core/api/services/inbox_digest_jobs.py +595 -0
- core/api/services/inbox_gmail_sync.py +167 -0
- core/api/services/inbox_llm_classifier.py +906 -0
- core/api/services/inbox_source_identity.py +116 -0
- core/api/services/inbox_sources.py +456 -0
- core/api/services/inbox_taxonomy.py +195 -0
- core/api/services/inbox_tldr.py +1079 -0
- core/api/services/inbox_triage.py +899 -0
- core/api/services/ingest/__init__.py +13 -0
- core/api/services/ingest/api_key_auth.py +136 -0
- core/api/services/ingest/auto_approve.py +120 -0
- core/api/services/ingest/classifier.py +138 -0
- core/api/services/ingest/confidence.py +173 -0
- core/api/services/ingest/dispatch.py +88 -0
- core/api/services/ingest/embedding_router.py +272 -0
- core/api/services/ingest/events.py +33 -0
- core/api/services/ingest/ignore_patterns.py +79 -0
- core/api/services/ingest/image_probe.py +218 -0
- core/api/services/ingest/ingress.py +263 -0
- core/api/services/ingest/insert_saga.py +793 -0
- core/api/services/ingest/llm/__init__.py +13 -0
- core/api/services/ingest/llm/anthropic_haiku.py +23 -0
- core/api/services/ingest/llm/base.py +59 -0
- core/api/services/ingest/llm/byok_provider.py +130 -0
- core/api/services/ingest/llm/classification_context.py +301 -0
- core/api/services/ingest/llm/config_store.py +246 -0
- core/api/services/ingest/llm/factory.py +24 -0
- core/api/services/ingest/llm/kg_enricher.py +306 -0
- core/api/services/ingest/llm/local_gateway.py +821 -0
- core/api/services/ingest/llm/local_vllm.py +23 -0
- core/api/services/ingest/llm/openai_nano.py +349 -0
- core/api/services/ingest/lock_advisory.py +57 -0
- core/api/services/ingest/parser_router.py +1756 -0
- core/api/services/ingest/parsers/__init__.py +1 -0
- core/api/services/ingest/parsers/docling_parser.py +142 -0
- core/api/services/ingest/parsers/docparse_gateway.py +178 -0
- core/api/services/ingest/parsers/docx_parser.py +127 -0
- core/api/services/ingest/parsers/folder_unpacker.py +85 -0
- core/api/services/ingest/parsers/gateway_aux.py +147 -0
- core/api/services/ingest/parsers/image_parser.py +251 -0
- core/api/services/ingest/parsers/internal_markdown.py +89 -0
- core/api/services/ingest/parsers/ocr_gateway.py +117 -0
- core/api/services/ingest/parsers/ocr_pdf_parser.py +112 -0
- core/api/services/ingest/parsers/pdf_types.py +13 -0
- core/api/services/ingest/parsers/transcript_parser.py +445 -0
- core/api/services/ingest/parsers/vision_gateway.py +186 -0
- core/api/services/ingest/parsers/xlsx_parser.py +91 -0
- core/api/services/ingest/parsers/zip_unpacker.py +126 -0
- core/api/services/ingest/preflight.py +393 -0
- core/api/services/ingest/retry_voyage.py +88 -0
- core/api/services/ingest/routing_policy.py +307 -0
- core/api/services/ingest/serializers/__init__.py +1 -0
- core/api/services/ingest/serializers/xlsx_to_markdown.py +80 -0
- core/api/services/ingest/skip_log.py +74 -0
- core/api/services/ingest/watcher.py +637 -0
- core/api/services/kg/__init__.py +0 -0
- core/api/services/kg/audit.py +49 -0
- core/api/services/kg/hybrid_search.py +691 -0
- core/api/services/kg/lens.py +339 -0
- core/api/services/kg/pr_impact.py +770 -0
- core/api/services/kg/queries.py +152 -0
- core/api/services/kg/ranking.py +89 -0
- core/api/services/kg/rrf.py +143 -0
- core/api/services/kg_watcher_control.py +161 -0
- core/api/services/local_llm/__init__.py +19 -0
- core/api/services/local_llm/async_client.py +385 -0
- core/api/services/local_llm/client.py +173 -0
- core/api/services/local_llm/url_validator.py +44 -0
- core/api/services/metrics_collector.py +646 -0
- core/api/services/metrics_providers.py +65 -0
- core/api/services/model_registry.py +266 -0
- core/api/services/model_router.py +137 -0
- core/api/services/n8n_client.py +77 -0
- core/api/services/newsletter_llm_gateway.py +66 -0
- core/api/services/notification_service.py +134 -0
- core/api/services/openai_responses.py +55 -0
- core/api/services/opencode_metrics.py +375 -0
- core/api/services/opencode_sessions.py +173 -0
- core/api/services/pii_redactor.py +138 -0
- core/api/services/pr_impact_pipeline/__init__.py +21 -0
- core/api/services/pr_impact_pipeline/differ.py +421 -0
- core/api/services/pr_impact_pipeline/dispatcher.py +415 -0
- core/api/services/pr_impact_pipeline/gc.py +93 -0
- core/api/services/pr_impact_pipeline/languages.py +192 -0
- core/api/services/pr_impact_pipeline/parser.py +178 -0
- core/api/services/pr_impact_pipeline/writer.py +394 -0
- core/api/services/pr_service.py +1393 -0
- core/api/services/project_paths.py +70 -0
- core/api/services/project_status_updates.py +265 -0
- core/api/services/providers.py +276 -0
- core/api/services/push_service.py +170 -0
- core/api/services/reminder_service.py +89 -0
- core/api/services/runas.py +41 -0
- core/api/services/salience_service.py +69 -0
- core/api/services/security_collector.py +281 -0
- core/api/services/session_catalog.py +385 -0
- core/api/services/session_metrics_service.py +301 -0
- core/api/services/session_ops.py +272 -0
- core/api/services/session_state.py +173 -0
- core/api/services/share_links.py +222 -0
- core/api/services/task_transitions.py +146 -0
- core/api/services/terminal_metrics.py +462 -0
- core/api/services/terminal_metrics_dump.py +203 -0
- core/api/services/tmux.py +1205 -0
- core/api/services/webhook_service.py +422 -0
- core/api/services/workspace_sync.py +164 -0
- core/api/templates/__init__.py +1 -0
- core/api/templates/markdown_share.py +164 -0
- core/api/terminal.py +1031 -0
- core/api/tests/__init__.py +0 -0
- core/api/tests/test_agent_facing_auth_dependencies.py +132 -0
- core/api/tests/test_audit_permissions.py +133 -0
- core/api/tests/test_backfill_session_conversations.py +90 -0
- core/api/tests/test_backfill_working_seconds_msg.py +129 -0
- core/api/tests/test_claude_metrics.py +326 -0
- core/api/tests/test_codex_metrics.py +189 -0
- core/api/tests/test_finder_paths.py +74 -0
- core/api/tests/test_git_ops_merge.py +155 -0
- core/api/tests/test_learnings_check_search.py +81 -0
- core/api/tests/test_metrics_providers.py +133 -0
- core/api/tests/test_migration_087.py +164 -0
- core/api/tests/test_migration_088.py +94 -0
- core/api/tests/test_migration_089.py +116 -0
- core/api/tests/test_openai_responses.py +24 -0
- core/api/tests/test_opencode_metrics.py +740 -0
- core/api/tests/test_opencode_sessions.py +321 -0
- core/api/tests/test_pr_workflow_e2e.py +457 -0
- core/api/tests/test_projects_handoffs.py +31 -0
- core/api/tests/test_providers.py +138 -0
- core/api/tests/test_safety_bridge.py +347 -0
- core/api/tests/test_session_catalog.py +142 -0
- core/api/tests/test_session_conversations.py +512 -0
- core/api/tests/test_session_metrics_service.py +270 -0
- core/api/tests/test_session_resume_paths.py +548 -0
- core/api/tests/test_session_theme_mode_migration.py +56 -0
- core/api/tests/test_sessions_rbac.py +131 -0
- core/api/tests/test_share_edit.py +398 -0
- core/api/tests/test_share_repo.py +200 -0
- core/api/tests/test_terminal_session_manager.py +98 -0
- core/api/tests/test_terminal_upload.py +34 -0
- core/api/tests/test_tmux.py +272 -0
- core/api/tests/test_workspace_sync.py +186 -0
- core/api/tests/test_ws_ticket_in_memory.py +73 -0
- core/api/use_cases/__init__.py +11 -0
- core/api/use_cases/_context.py +89 -0
- core/api/use_cases/_errors.py +62 -0
- core/api/use_cases/_roles.py +16 -0
- core/api/use_cases/audit.py +171 -0
- core/api/use_cases/brain.py +1232 -0
- core/api/use_cases/costs.py +249 -0
- core/api/use_cases/graph.py +1153 -0
- core/api/use_cases/handoffs.py +506 -0
- core/api/use_cases/ingest_triage.py +1229 -0
- core/api/use_cases/learnings.py +538 -0
- core/api/use_cases/projects.py +705 -0
- core/api/use_cases/pull_requests.py +415 -0
- core/api/use_cases/search.py +926 -0
- core/api/use_cases/tasks.py +1495 -0
- core/api/visibility.py +141 -0
- core/cli/__init__.py +5 -0
- core/cli/_index_source.py +632 -0
- core/cli/_runtime_ctx.py +160 -0
- core/cli/_transmute.py +241 -0
- core/cli/marvis_doctor.py +704 -0
- core/cli/marvis_feedback.py +396 -0
- core/cli/marvis_governance.py +315 -0
- core/cli/marvis_hooks.py +515 -0
- core/cli/marvis_init.py +757 -0
- core/cli/marvis_mcp.py +401 -0
- core/cli/marvis_runtime.py +855 -0
- core/cli/marvis_telemetry.py +228 -0
- core/scripts/_drift_check.py +716 -0
- core/scripts/_frontmatter.py +66 -0
- core/scripts/_graph_writer.py +189 -0
- core/scripts/ast_parser.py +1553 -0
- core/scripts/install_hooks/__init__.py +1 -0
- core/scripts/install_hooks/_config.sh +109 -0
- core/scripts/install_hooks/block-dangerous-bash.sh +23 -0
- core/scripts/install_hooks/block-db-direct-write.sh +23 -0
- core/scripts/install_hooks/block-push-no-task.sh +23 -0
- core/scripts/install_hooks/block-staging-to-prod.sh +23 -0
- core/scripts/install_hooks/block-subtree-push.sh +23 -0
- core/scripts/install_hooks/config.json +53 -0
- core/scripts/install_hooks/enforce-no-merge-main.sh +23 -0
- core/scripts/install_hooks/enforce-worktree.sh +23 -0
- core/scripts/install_hooks/quality-gate.sh +170 -0
- core/scripts/install_hooks/safety_bridge.py +968 -0
- core/scripts/install_hooks/secret-scan.sh +23 -0
- core/scripts/migrate_spike_node_ids.py +122 -0
- core/scripts/populate_artifacts.py +2198 -0
- core/scripts/populate_cross_project.py +2457 -0
- core/scripts/populate_inbox_nodes.py +357 -0
- core/scripts/populate_pr_impact.py +267 -0
- core/scripts/populate_project_nodes.py +603 -0
- core/scripts/populate_touch_counter.py +337 -0
- core/scripts/reparse_failed.py +57 -0
- core/scripts/safety_bridge.py +968 -0
- core/telemetry/__init__.py +9 -0
- core/telemetry/client.py +405 -0
- core/telemetry/schema.py +122 -0
- core/wizard/__init__.py +65 -0
- core/wizard/byok_vault.py +147 -0
- core/wizard/defaults.py +58 -0
- core/wizard/state.py +117 -0
- core/wizard/steps.py +70 -0
- core/wizard/validation.py +136 -0
- marvisx_cli-0.1.0.dist-info/METADATA +201 -0
- marvisx_cli-0.1.0.dist-info/RECORD +587 -0
- marvisx_cli-0.1.0.dist-info/WHEEL +5 -0
- marvisx_cli-0.1.0.dist-info/entry_points.txt +3 -0
- marvisx_cli-0.1.0.dist-info/licenses/LICENSE +98 -0
- marvisx_cli-0.1.0.dist-info/top_level.txt +3 -0
- migrations/001_initial.sql +33 -0
- migrations/002_tasks.sql +30 -0
- migrations/003_session_management.sql +7 -0
- migrations/004_projects_comments.sql +65 -0
- migrations/005_session_intelligence.sql +15 -0
- migrations/006_settings.sql +12 -0
- migrations/007_task_scoring.sql +12 -0
- migrations/008_cost_tracking.sql +31 -0
- migrations/009_session_card_metrics.sql +3 -0
- migrations/010_monitoring.sql +55 -0
- migrations/012_agent_api.sql +21 -0
- migrations/013_session_complete.sql +8 -0
- migrations/015_pull_requests.sql +43 -0
- migrations/015_pull_requests_down.sql +5 -0
- migrations/016_users_raci.sql +116 -0
- migrations/017_task_cost_entries.sql +87 -0
- migrations/018_agents.sql +73 -0
- migrations/018_agents_down.sql +13 -0
- migrations/019_review_feedback.sql +18 -0
- migrations/020_pr_commit_sha.sql +4 -0
- migrations/021_webhook_events.sql +18 -0
- migrations/022_devx_agent_managed.sql +11 -0
- migrations/022_devx_agent_managed_down.sql +6 -0
- migrations/023_devx_p1_gate.sql +7 -0
- migrations/023_devx_p1_gate_down.sql +3 -0
- migrations/024_chat_messages.sql +16 -0
- migrations/024_pr_conversation_id.sql +8 -0
- migrations/024_task_indexes.sql +21 -0
- migrations/024_task_indexes_down.sql +7 -0
- migrations/025_audit_log.sql +17 -0
- migrations/026_agent_tokens.sql +20 -0
- migrations/027_teams_auth_phase_b.sql +35 -0
- migrations/028_learnings.sql +23 -0
- migrations/029_team_roles.sql +14 -0
- migrations/030_finder_pins.sql +10 -0
- migrations/031_pr_deploy_status.sql +9 -0
- migrations/032_task_reminders.sql +7 -0
- migrations/033_events_retry_count.sql +6 -0
- migrations/033_session_owner.sql +9 -0
- migrations/034_notifications.sql +38 -0
- migrations/035_shared_links.sql +15 -0
- migrations/036_session_index_upgrade.sql +29 -0
- migrations/037_pr_approval.sql +15 -0
- migrations/038_pr_submitted_by.sql +6 -0
- migrations/039_push_subscriptions.sql +17 -0
- migrations/040_semantic_search.sql +16 -0
- migrations/041_workspaces.sql +63 -0
- migrations/042_oidc_providers.sql +24 -0
- migrations/043_ci_checks.sql +31 -0
- migrations/044_agent_metrics.sql +30 -0
- migrations/045_documents_doc_type.sql +5 -0
- migrations/046_salience.sql +13 -0
- migrations/047_seed_missing_agents.sql +6 -0
- migrations/048_fix_agent_paths_roles.sql +5 -0
- migrations/049_agent_role_and_learnings_schema.sql +3 -0
- migrations/050_session_provider.sql +2 -0
- migrations/051_session_launch_profile.sql +4 -0
- migrations/052_session_theme_mode.sql +2 -0
- migrations/052_task_kind.sql +4 -0
- migrations/053_inbox_items.sql +31 -0
- migrations/054_inbox_triage_contract.sql +30 -0
- migrations/055_inbox_topic_treatment.sql +12 -0
- migrations/056_inbox_treatment_read_save.sql +57 -0
- migrations/057_session_theme_mode_backfill.sql +4 -0
- migrations/058_inbox_item_status_lifecycle.sql +13 -0
- migrations/059_inbox_tldr_and_source_scores.sql +18 -0
- migrations/060_newsletter.sql +16 -0
- migrations/061_inbox_redesign.sql +69 -0
- migrations/062_fix_inbox_sources_backfill.sql +37 -0
- migrations/063_task_completion_mode.sql +23 -0
- migrations/064_judge_mode_setting.sql +4 -0
- migrations/065_knowledge_graph_spike.sql +40 -0
- migrations/066_digest_ranking_inputs.sql +10 -0
- migrations/066_kg_artifact_nodes.sql +129 -0
- migrations/067_inbox_digest_selections.sql +28 -0
- migrations/067_kg_temporal.sql +53 -0
- migrations/068_inbox_digest_app_settings.sql +9 -0
- migrations/068_kg_touch_counter.sql +52 -0
- migrations/069_kg_doc_types.sql +117 -0
- migrations/070_digest_ranking_inputs_recovery.sql +3 -0
- migrations/071_inbox_digest_selections_recovery.sql +3 -0
- migrations/072_inbox_digest_app_settings_recovery.sql +3 -0
- migrations/073_kg_cross_project.sql +216 -0
- migrations/073_kg_cross_project_down.sql +77 -0
- migrations/074_kg_infra_types.sql +208 -0
- migrations/074_kg_infra_types_down.sql +80 -0
- migrations/075_kg_file_state_recovery.sql +35 -0
- migrations/075_kg_file_state_recovery_down.sql +5 -0
- migrations/076_kg_watcher_state.sql +33 -0
- migrations/076_kg_watcher_state_down.sql +3 -0
- migrations/077_kg_doc_types_extend.sql +226 -0
- migrations/077_kg_doc_types_extend_down.sql +80 -0
- migrations/078_kg_fts5.sql +102 -0
- migrations/078_kg_fts5_down.sql +14 -0
- migrations/079_kg_missing_indexes.sql +31 -0
- migrations/079_kg_missing_indexes_down.sql +10 -0
- migrations/080_kg_fts5_extended.sql +232 -0
- migrations/080_kg_fts5_extended_down.sql +25 -0
- migrations/081_kg_lens_indexes.sql +9 -0
- migrations/081_kg_lens_indexes_down.sql +3 -0
- migrations/082_kg_pins.sql +26 -0
- migrations/082_kg_pins_down.sql +14 -0
- migrations/083_kg_graph_nodes_degree.sql +20 -0
- migrations/083_kg_graph_nodes_degree_down.sql +15 -0
- migrations/084_drop_legacy_scheduler_tables.sql +58 -0
- migrations/084_drop_legacy_scheduler_tables_down.sql +112 -0
- migrations/085_kg_edge_resolves_to.sql +142 -0
- migrations/085_kg_edge_resolves_to_down.sql +66 -0
- migrations/086_project_status_updates_feed.sql +20 -0
- migrations/086_project_status_updates_feed_down.sql +36 -0
- migrations/087_session_metrics_dual.sql +50 -0
- migrations/087_session_metrics_dual_down.sql +21 -0
- migrations/088_rename_context_pct_legacy.sql +23 -0
- migrations/088_rename_context_pct_legacy_down.sql +8 -0
- migrations/089_session_metrics_equivalent_cost.sql +26 -0
- migrations/089_session_metrics_equivalent_cost_down.sql +11 -0
- migrations/090_kg_inbox_node_type.sql +26 -0
- migrations/090_kg_inbox_node_type_down.sql +20 -0
- migrations/091_kg_inbox_node_type_check.sql +265 -0
- migrations/091_kg_inbox_node_type_check_down.sql +129 -0
- migrations/092_sessions_activity_state_ts.sql +29 -0
- migrations/092_sessions_activity_state_ts_down.sql +14 -0
- migrations/093_sessions_activity_state_column.sql +29 -0
- migrations/093_sessions_activity_state_column_down.sql +10 -0
- migrations/094_ingest_pending.sql +55 -0
- migrations/094_ingest_pending_down.sql +15 -0
- migrations/095_kg_intent_first.sql +77 -0
- migrations/095_kg_intent_first_down.sql +25 -0
- migrations/096_kg_xlsx_artifact_prefix.sql +17 -0
- migrations/096_kg_xlsx_artifact_prefix_down.sql +11 -0
- migrations/097_ingest_change_history.sql +37 -0
- migrations/097_ingest_change_history_down.sql +13 -0
- migrations/098_kg_node_type_business.sql +254 -0
- migrations/098_kg_node_type_business_down.sql +195 -0
- migrations/099_kg_edges_restore_weight.sql +58 -0
- migrations/099_kg_edges_restore_weight_down.sql +12 -0
- migrations/100_kg_enriched_at.sql +25 -0
- migrations/100_kg_enriched_at_down.sql +12 -0
- migrations/101_local_llm_shadow_comparisons.sql +66 -0
- migrations/101_local_llm_shadow_comparisons_down.sql +15 -0
- migrations/102_promote_llm_costs.sql +69 -0
- migrations/102_promote_llm_costs_down.sql +19 -0
- migrations/103_ingest_skipped_log.sql +46 -0
- migrations/103_ingest_skipped_log_down.sql +15 -0
- migrations/120_docs_governance.sql +50 -0
- migrations/120_docs_governance_down.sql +11 -0
- migrations/121_notification_event_fk_cleanup.sql +21 -0
- migrations/121_notification_event_fk_cleanup_down.sql +10 -0
- migrations/122_docs_drift_history.sql +34 -0
- migrations/122_docs_drift_history_down.sql +15 -0
- migrations/123_ingest_parser_waiting_status.sql +69 -0
- migrations/123_ingest_parser_waiting_status_down.sql +69 -0
- migrations/124_heypocket_recordings.sql +63 -0
- migrations/124_heypocket_recordings_down.sql +13 -0
- migrations/125_kg_node_type_record.sql +219 -0
- migrations/125_kg_node_type_record_down.sql +205 -0
- migrations/126_ingest_terminal_upload_source_kind.sql +69 -0
- migrations/126_ingest_terminal_upload_source_kind_down.sql +69 -0
- migrations/127_brain_v1_substrate.sql +200 -0
- migrations/127_brain_v1_substrate_down.sql +32 -0
- migrations/128_brain_drift_signals.sql +157 -0
- migrations/128_brain_drift_signals_down.sql +23 -0
- migrations/129_brain_memory_operations.sql +232 -0
- migrations/129_brain_memory_operations_down.sql +27 -0
- migrations/130_brain_findings.sql +258 -0
- migrations/130_brain_findings_down.sql +29 -0
- migrations/132_kg_pr_modifies.sql +242 -0
- migrations/132_kg_pr_modifies_down.sql +99 -0
- migrations/133_brain_v1_2_direction_schema.sql +476 -0
- migrations/133_brain_v1_2_direction_schema_down.sql +273 -0
- migrations/134_brain_journal_narrative_polished.sql +8 -0
- migrations/134_brain_journal_narrative_polished_down.sql +6 -0
- migrations/135_kg_edges_provider.sql +21 -0
- migrations/136_documents_fts.sql +56 -0
- migrations/137_promote_llm_costs.sql +59 -0
- migrations/137_promote_llm_costs_down.sql +19 -0
- migrations/138_ingest_api_keys.sql +39 -0
- migrations/138_ingest_api_keys_down.sql +8 -0
- migrations/139_ingest_pending_ingress.sql +91 -0
- migrations/139_ingest_pending_ingress_down.sql +73 -0
- migrations/140_ingest_idempotency_quota.sql +45 -0
- migrations/140_ingest_idempotency_quota_down.sql +9 -0
- migrations/141_ingest_pending_metadata.sql +16 -0
- migrations/141_ingest_pending_metadata_down.sql +7 -0
- migrations/142_llm_function_config.sql +36 -0
- migrations/142_llm_function_config_down.sql +8 -0
- migrations/143_kg_code_embeddings.sql +25 -0
- migrations/143_kg_code_embeddings_down.sql +5 -0
- migrations/__init__.py +4 -0
- 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
|
+
)
|