studyctl 2.2.0__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. {studyctl-2.2.0 → studyctl-2.3.0}/.gitignore +1 -0
  2. {studyctl-2.2.0 → studyctl-2.3.0}/PKG-INFO +1 -1
  3. {studyctl-2.2.0 → studyctl-2.3.0}/pyproject.toml +1 -1
  4. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_study.py +1 -1
  5. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/session/cleanup.py +14 -3
  6. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/session/orchestrator.py +70 -6
  7. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/session_state.py +1 -1
  8. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/settings.py +30 -0
  9. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/tui/sidebar.py +96 -33
  10. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/app.py +5 -2
  11. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/routes/session.py +56 -3
  12. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/routes/terminal_proxy.py +5 -1
  13. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/components.js +69 -10
  14. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/index.html +214 -151
  15. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/style.css +273 -28
  16. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/sw.js +15 -1
  17. studyctl-2.3.0/src/studyctl/web/static/vendor/js/split.min.js +3 -0
  18. studyctl-2.3.0/tests/test_fixes_pomodoro_auth_terminal.py +556 -0
  19. studyctl-2.3.0/tests/test_split_pane_layout.py +987 -0
  20. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_web_terminal.py +119 -66
  21. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_web_vendor.py +7 -2
  22. {studyctl-2.2.0 → studyctl-2.3.0}/README.md +0 -0
  23. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/__init__.py +0 -0
  24. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/agent_launcher.py +0 -0
  25. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/__init__.py +0 -0
  26. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/__main__.py +0 -0
  27. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_backup.py +0 -0
  28. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_clean.py +0 -0
  29. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_config.py +0 -0
  30. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_content.py +0 -0
  31. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_doctor.py +0 -0
  32. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_lazy.py +0 -0
  33. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_review.py +0 -0
  34. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_session.py +0 -0
  35. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_setup.py +0 -0
  36. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_shared.py +0 -0
  37. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_sync.py +0 -0
  38. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_topics.py +0 -0
  39. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_upgrade.py +0 -0
  40. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_web.py +0 -0
  41. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/content/__init__.py +0 -0
  42. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/content/markdown_converter.py +0 -0
  43. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/content/models.py +0 -0
  44. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/content/notebooklm_client.py +0 -0
  45. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/content/splitter.py +0 -0
  46. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/content/storage.py +0 -0
  47. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/content/syllabus.py +0 -0
  48. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/data/tmux-studyctl.conf +0 -0
  49. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/db.py +0 -0
  50. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/doctor/__init__.py +0 -0
  51. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/doctor/agents.py +0 -0
  52. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/doctor/config.py +0 -0
  53. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/doctor/core.py +0 -0
  54. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/doctor/database.py +0 -0
  55. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/doctor/deps.py +0 -0
  56. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/doctor/models.py +0 -0
  57. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/doctor/updates.py +0 -0
  58. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/history/__init__.py +0 -0
  59. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/history/_connection.py +0 -0
  60. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/history/bridges.py +0 -0
  61. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/history/concepts.py +0 -0
  62. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/history/medication.py +0 -0
  63. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/history/progress.py +0 -0
  64. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/history/search.py +0 -0
  65. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/history/sessions.py +0 -0
  66. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/history/streaks.py +0 -0
  67. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/history/teachback.py +0 -0
  68. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/logic/__init__.py +0 -0
  69. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/logic/backlog_logic.py +0 -0
  70. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/logic/break_logic.py +0 -0
  71. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/logic/briefing_logic.py +0 -0
  72. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/logic/clean_logic.py +0 -0
  73. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/logic/streaks_logic.py +0 -0
  74. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/logic/topic_resolver.py +0 -0
  75. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/maintenance.py +0 -0
  76. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/mcp/__init__.py +0 -0
  77. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/mcp/server.py +0 -0
  78. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/mcp/tools.py +0 -0
  79. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/output.py +0 -0
  80. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/parking.py +0 -0
  81. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/pdf.py +0 -0
  82. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/review_db.py +0 -0
  83. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/review_loader.py +0 -0
  84. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/services/__init__.py +0 -0
  85. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/services/backlog.py +0 -0
  86. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/services/content.py +0 -0
  87. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/services/flashcard_writer.py +0 -0
  88. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/services/review.py +0 -0
  89. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/session/__init__.py +0 -0
  90. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/session/resume.py +0 -0
  91. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/shared.py +0 -0
  92. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/state.py +0 -0
  93. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/sync.py +0 -0
  94. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/tmux.py +0 -0
  95. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/topics.py +0 -0
  96. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/tui/__init__.py +0 -0
  97. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/tui/__main__.py +0 -0
  98. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/__init__.py +0 -0
  99. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/auth.py +0 -0
  100. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/routes/__init__.py +0 -0
  101. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/routes/artefacts.py +0 -0
  102. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/routes/cards.py +0 -0
  103. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/routes/courses.py +0 -0
  104. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/routes/history.py +0 -0
  105. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/server.py +0 -0
  106. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/icon-192.svg +0 -0
  107. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/icon-512.svg +0 -0
  108. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/manifest.json +0 -0
  109. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/vendor/css/files/inter-latin-ext.woff2 +0 -0
  110. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/vendor/css/files/inter-latin.woff2 +0 -0
  111. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/vendor/css/files/opendyslexic-latin-400-normal.woff +0 -0
  112. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/vendor/css/files/opendyslexic-latin-400-normal.woff2 +0 -0
  113. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/vendor/css/inter.css +0 -0
  114. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/vendor/css/opendyslexic-400.css +0 -0
  115. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/vendor/js/alpine-3.14.8.min.js +0 -0
  116. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/vendor/js/htmx-2.0.4.min.js +0 -0
  117. {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/vendor/js/htmx-ext-sse-2.2.2.js +0 -0
  118. {studyctl-2.2.0 → studyctl-2.3.0}/tests/__init__.py +0 -0
  119. {studyctl-2.2.0 → studyctl-2.3.0}/tests/_helpers.py +0 -0
  120. {studyctl-2.2.0 → studyctl-2.3.0}/tests/harness/__init__.py +0 -0
  121. {studyctl-2.2.0 → studyctl-2.3.0}/tests/harness/agents.py +0 -0
  122. {studyctl-2.2.0 → studyctl-2.3.0}/tests/harness/study.py +0 -0
  123. {studyctl-2.2.0 → studyctl-2.3.0}/tests/harness/terminal.py +0 -0
  124. {studyctl-2.2.0 → studyctl-2.3.0}/tests/harness/tmux.py +0 -0
  125. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_agent_launcher.py +0 -0
  126. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_backlog_logic.py +0 -0
  127. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_backup.py +0 -0
  128. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_break_logic.py +0 -0
  129. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_briefing_logic.py +0 -0
  130. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_clean.py +0 -0
  131. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_cli.py +0 -0
  132. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_cli_doctor.py +0 -0
  133. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_cli_session.py +0 -0
  134. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_cli_upgrade.py +0 -0
  135. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_content_cli.py +0 -0
  136. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_content_notebooklm.py +0 -0
  137. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_content_splitter.py +0 -0
  138. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_content_storage.py +0 -0
  139. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_content_syllabus.py +0 -0
  140. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_doctor_agents.py +0 -0
  141. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_doctor_config.py +0 -0
  142. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_doctor_core.py +0 -0
  143. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_doctor_database.py +0 -0
  144. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_doctor_deps.py +0 -0
  145. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_doctor_integration.py +0 -0
  146. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_doctor_models.py +0 -0
  147. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_doctor_updates.py +0 -0
  148. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_e2e_session_demo.py +0 -0
  149. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_flashcard_writer.py +0 -0
  150. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_harness_matrix.py +0 -0
  151. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_history.py +0 -0
  152. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_install_mentor_prompt.py +0 -0
  153. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_lan_auth.py +0 -0
  154. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_lazy_imports.py +0 -0
  155. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_mcp_tools.py +0 -0
  156. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_migrate_from_empty_db.py +0 -0
  157. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_orchestrator.py +0 -0
  158. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_parking.py +0 -0
  159. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_review_db.py +0 -0
  160. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_review_loader.py +0 -0
  161. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_services_content.py +0 -0
  162. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_services_review.py +0 -0
  163. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_session_db_integration.py +0 -0
  164. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_session_state.py +0 -0
  165. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_setup_wizard.py +0 -0
  166. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_shared.py +0 -0
  167. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_sidebar_pilot.py +0 -0
  168. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_streaks_logic.py +0 -0
  169. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_study.py +0 -0
  170. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_study_integration.py +0 -0
  171. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_study_lifecycle.py +0 -0
  172. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_terminal_proxy.py +0 -0
  173. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_tmux.py +0 -0
  174. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_topic_resolver.py +0 -0
  175. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_topics_cli.py +0 -0
  176. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_uat_terminal.py +0 -0
  177. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_web_app.py +0 -0
  178. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_web_artefacts.py +0 -0
  179. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_web_cli.py +0 -0
  180. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_web_session.py +0 -0
  181. {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_web_sidebar.py +0 -0
@@ -500,6 +500,7 @@ eval-results.tsv
500
500
  .pytest-junit.xml
501
501
  docs/reports/
502
502
  docs/brainstorms/
503
+ packages/studyctl/tests/results/
503
504
 
504
505
  # Dev-only tooling (autoresearch eval harness — not shipped to users)
505
506
  dev/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: studyctl
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: AuDHD-aware study tool with AI Socratic mentoring, spaced repetition, and content pipeline
5
5
  Project-URL: Homepage, https://github.com/NetDevAutomate/socratic-study-mentor
6
6
  Project-URL: Repository, https://github.com/NetDevAutomate/socratic-study-mentor
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "studyctl"
3
- version = "2.2.0"
3
+ version = "2.3.0"
4
4
  description = "AuDHD-aware study tool with AI Socratic mentoring, spaced repetition, and content pipeline"
5
5
  requires-python = ">=3.12"
6
6
  readme = "README.md"
@@ -616,7 +616,7 @@ def _handle_start(
616
616
  start_web_background(session_name, lan=lan, password=lan_password)
617
617
 
618
618
  # Start ttyd if installed (allows iPad/LAN terminal access)
619
- start_ttyd_background(session_name, lan=lan)
619
+ start_ttyd_background(session_name, lan=lan, username=lan_username, password=lan_password)
620
620
 
621
621
  # Persist LAN info to session state so it's visible after os.execvp
622
622
  if lan:
@@ -140,11 +140,12 @@ def end_session_common(
140
140
  oneline.unlink()
141
141
 
142
142
  # Kill background processes (web dashboard, ttyd).
143
- # Verify PID identity before killing to guard against PID recycling:
144
- # if the process exited and its PID was reused by an unrelated process,
145
- # we'd kill the wrong thing.
143
+ # Strategy: kill by PID first (fast), then by port as fallback
144
+ # to catch processes started by a different code path.
146
145
  import subprocess as _sp
147
146
 
147
+ # PID-based kill — check command matches to guard against PID recycling.
148
+ # "studyctl" matches both the binary and "python -m studyctl.cli".
148
149
  pid_checks = {"web_pid": "studyctl", "ttyd_pid": "ttyd"}
149
150
  for pid_key, expected in pid_checks.items():
150
151
  pid = state.get(pid_key)
@@ -162,6 +163,16 @@ def end_session_common(
162
163
  except (OSError, _sp.TimeoutExpired):
163
164
  pass
164
165
 
166
+ # Port-based kill — fallback for orphaned processes whose PIDs we lost.
167
+ from studyctl.session.orchestrator import _kill_port_occupant
168
+
169
+ ttyd_port = state.get("ttyd_port")
170
+ if ttyd_port:
171
+ _kill_port_occupant(int(ttyd_port), expected_cmd="ttyd")
172
+ web_port = state.get("web_port")
173
+ if web_port:
174
+ _kill_port_occupant(int(web_port), expected_cmd="studyctl")
175
+
165
176
  # Kill all study tmux sessions
166
177
  with contextlib.suppress(Exception):
167
178
  kill_all_study_sessions(current_session=session_name)
@@ -237,6 +237,9 @@ def start_web_background(session_name: str, *, lan: bool = False, password: str
237
237
 
238
238
  port = _get_web_port()
239
239
 
240
+ # Kill any stale web server left over from a previous session
241
+ _kill_port_occupant(port, expected_cmd="studyctl")
242
+
240
243
  studyctl_bin = shutil.which("studyctl")
241
244
  cmd = (
242
245
  [studyctl_bin, "web", "--port", str(port)]
@@ -255,12 +258,53 @@ def start_web_background(session_name: str, *, lan: bool = False, password: str
255
258
  )
256
259
  from studyctl.session_state import write_session_state
257
260
 
258
- write_session_state({"web_pid": proc.pid})
261
+ write_session_state({"web_pid": proc.pid, "web_port": port})
259
262
  _open_browser(f"http://127.0.0.1:{port}/session")
260
263
  except Exception:
261
264
  console.print("[yellow]Could not start web dashboard.[/yellow]")
262
265
 
263
266
 
267
+ def _kill_port_occupant(port: int, expected_cmd: str = "") -> None:
268
+ """Kill any process listening on *port*.
269
+
270
+ If *expected_cmd* is given, only kill when the process command contains
271
+ that string (safety guard against killing unrelated processes).
272
+ """
273
+ import signal
274
+ import time as _time
275
+
276
+ killed = False
277
+ try:
278
+ result = subprocess.run(
279
+ ["lsof", "-ti", f":{port}"],
280
+ capture_output=True,
281
+ text=True,
282
+ timeout=5,
283
+ )
284
+ if not result.stdout.strip():
285
+ return
286
+ for pid_str in result.stdout.strip().splitlines():
287
+ pid = int(pid_str)
288
+ if expected_cmd:
289
+ ps_result = subprocess.run(
290
+ ["ps", "-p", str(pid), "-o", "command="],
291
+ capture_output=True,
292
+ text=True,
293
+ timeout=5,
294
+ )
295
+ if expected_cmd not in ps_result.stdout:
296
+ continue
297
+ with __import__("contextlib").suppress(OSError):
298
+ os.kill(pid, signal.SIGTERM)
299
+ killed = True
300
+ except (subprocess.TimeoutExpired, ValueError, OSError):
301
+ pass
302
+
303
+ # Wait for the port to be released after killing
304
+ if killed:
305
+ _time.sleep(0.5)
306
+
307
+
264
308
  def _get_web_port() -> int:
265
309
  """Read web port from config, default 8567."""
266
310
  try:
@@ -339,10 +383,18 @@ def _get_ttyd_port() -> int:
339
383
  return 7681
340
384
 
341
385
 
342
- def start_ttyd_background(session_name: str, *, lan: bool = False) -> None:
386
+ def start_ttyd_background(
387
+ session_name: str,
388
+ *,
389
+ lan: bool = False,
390
+ username: str = "",
391
+ password: str = "",
392
+ ) -> None:
343
393
  """Start ttyd to expose the tmux session over HTTP.
344
394
 
345
395
  Attaches a writable ttyd client to the study tmux session.
396
+ When credentials are provided, passes ``-c username:password``
397
+ so ttyd enforces HTTP Basic Auth on direct connections.
346
398
  Skips silently if ttyd is not installed.
347
399
  """
348
400
  from studyctl.session_state import write_session_state
@@ -354,6 +406,9 @@ def start_ttyd_background(session_name: str, *, lan: bool = False) -> None:
354
406
  host = "0.0.0.0" if lan else "127.0.0.1"
355
407
  port = _get_ttyd_port()
356
408
 
409
+ # Kill any stale ttyd left over from a previous session
410
+ _kill_port_occupant(port, expected_cmd="ttyd")
411
+
357
412
  cmd = [
358
413
  ttyd_bin,
359
414
  "-W", # writable (user interacts with the agent)
@@ -361,12 +416,21 @@ def start_ttyd_background(session_name: str, *, lan: bool = False) -> None:
361
416
  host,
362
417
  "-p",
363
418
  str(port),
364
- "tmux",
365
- "attach",
366
- "-t",
367
- session_name,
368
419
  ]
369
420
 
421
+ # Enforce auth on ttyd when credentials are available
422
+ if username and password:
423
+ cmd.extend(["-c", f"{username}:{password}"])
424
+
425
+ cmd.extend(
426
+ [
427
+ "tmux",
428
+ "attach",
429
+ "-t",
430
+ session_name,
431
+ ]
432
+ )
433
+
370
434
  try:
371
435
  proc = subprocess.Popen(
372
436
  cmd,
@@ -13,7 +13,7 @@ import threading
13
13
  from dataclasses import dataclass
14
14
  from pathlib import Path
15
15
 
16
- SESSION_DIR = Path.home() / ".config" / "studyctl"
16
+ SESSION_DIR = Path(os.environ.get("STUDYCTL_SESSION_DIR", Path.home() / ".config" / "studyctl"))
17
17
  STATE_FILE = SESSION_DIR / "session-state.json"
18
18
  TOPICS_FILE = SESSION_DIR / "session-topics.md"
19
19
  PARKING_FILE = SESSION_DIR / "session-parking.md"
@@ -103,6 +103,16 @@ class LocalLLMConfig:
103
103
  base_url: str = ""
104
104
 
105
105
 
106
+ @dataclass
107
+ class PomodoroConfig:
108
+ """Configuration for the Pomodoro timer (web UI + TUI sidebar)."""
109
+
110
+ focus: int = 25 # minutes
111
+ short_break: int = 5
112
+ long_break: int = 15
113
+ cycles: int = 4 # long break after this many focus blocks
114
+
115
+
106
116
  @dataclass
107
117
  class AgentsConfig:
108
118
  """Configuration for AI agent detection and priority."""
@@ -143,6 +153,7 @@ class Settings:
143
153
  ttyd_port: int = 7681
144
154
  web_port: int = 8567
145
155
  browser: str = "" # empty = system default; or "chrome", "safari", "firefox", "brave"
156
+ pomodoro: PomodoroConfig = field(default_factory=PomodoroConfig)
146
157
  lan_username: str = "study" # username for HTTP Basic Auth when using --lan
147
158
  lan_password: str = "" # password for HTTP Basic Auth when using --lan (empty = auto-generate)
148
159
 
@@ -222,6 +233,16 @@ def load_settings() -> Settings:
222
233
  ),
223
234
  )
224
235
 
236
+ # Pomodoro timer configuration
237
+ pomo = raw.get("pomodoro", {})
238
+ if pomo:
239
+ settings.pomodoro = PomodoroConfig(
240
+ focus=int(pomo.get("focus", 25)),
241
+ short_break=int(pomo.get("short_break", 5)),
242
+ long_break=int(pomo.get("long_break", 15)),
243
+ cycles=int(pomo.get("cycles", 4)),
244
+ )
245
+
225
246
  # Content pipeline configuration
226
247
  ct = raw.get("content", {})
227
248
  if ct:
@@ -362,6 +383,15 @@ topics:
362
383
  # - domain: cooking
363
384
  # anchors: ["mise en place", "flavour balancing"]
364
385
 
386
+ # Pomodoro timer (web UI + TUI sidebar)
387
+ # Adjust focus/break durations and cycle length.
388
+ # These are defaults — can also be changed in the web UI per-session.
389
+ # pomodoro:
390
+ # focus: 25 # Focus duration in minutes
391
+ # short_break: 5 # Short break in minutes
392
+ # long_break: 15 # Long break in minutes (after 'cycles' focus blocks)
393
+ # cycles: 4 # Number of focus blocks before a long break
394
+
365
395
  # LAN access credentials (for --lan mode)
366
396
  # Set these to avoid auto-generated passwords each session.
367
397
  # If lan_password is empty and --lan is used, a random password is generated.
@@ -96,17 +96,35 @@ def _timer_phase(elapsed_secs: int, energy: int) -> str:
96
96
  return "red"
97
97
 
98
98
 
99
- # Pomodoro cycle: 25 focus, 5 break, repeated 4 times, then 15 long break.
100
- # All durations in seconds.
101
- POMODORO_FOCUS = 25 * 60
102
- POMODORO_SHORT_BREAK = 5 * 60
103
- POMODORO_LONG_BREAK = 15 * 60
104
- POMODORO_CYCLE_LENGTH = 4 # long break after this many focus blocks
99
+ # Pomodoro defaults (overridden by config via load_settings().pomodoro).
100
+ _POMO_FOCUS = 25 * 60
101
+ _POMO_SHORT_BREAK = 5 * 60
102
+ _POMO_LONG_BREAK = 15 * 60
103
+ _POMO_CYCLES = 4
105
104
 
106
105
 
107
- def _pomodoro_state(elapsed_secs: int) -> tuple[str, int, int, int]:
106
+ def _load_pomodoro_config() -> tuple[int, int, int, int]:
107
+ """Load pomodoro durations from settings. Returns (focus, short, long, cycles) in seconds."""
108
+ try:
109
+ from studyctl.settings import load_settings
110
+
111
+ pomo = load_settings().pomodoro
112
+ return (pomo.focus * 60, pomo.short_break * 60, pomo.long_break * 60, pomo.cycles)
113
+ except Exception:
114
+ return (_POMO_FOCUS, _POMO_SHORT_BREAK, _POMO_LONG_BREAK, _POMO_CYCLES)
115
+
116
+
117
+ def _pomodoro_state(
118
+ elapsed_secs: int,
119
+ focus: int = 0,
120
+ short_break: int = 0,
121
+ long_break: int = 0,
122
+ cycles: int = 0,
123
+ ) -> tuple[str, int, int, int]:
108
124
  """Compute pomodoro state from total elapsed seconds.
109
125
 
126
+ If durations are not passed (0), loads from config.
127
+
110
128
  Returns:
111
129
  (phase, remaining_secs, cycle_number, block_in_cycle)
112
130
  phase: "focus" | "short_break" | "long_break"
@@ -114,36 +132,33 @@ def _pomodoro_state(elapsed_secs: int) -> tuple[str, int, int, int]:
114
132
  cycle_number: which full cycle (1-based)
115
133
  block_in_cycle: which focus block within the cycle (1-4)
116
134
  """
117
- # One full cycle = 4x(focus + short_break) - last short_break + long_break
118
- # = 4*25 + 3*5 + 15 = 130 min
119
- single_block = POMODORO_FOCUS + POMODORO_SHORT_BREAK # 30 min
120
- full_cycle = (single_block * POMODORO_CYCLE_LENGTH) - POMODORO_SHORT_BREAK + POMODORO_LONG_BREAK
135
+ if not focus:
136
+ focus, short_break, long_break, cycles = _load_pomodoro_config()
137
+
138
+ single_block = focus + short_break
139
+ full_cycle = (single_block * cycles) - short_break + long_break
121
140
 
122
141
  cycle_number = elapsed_secs // full_cycle + 1
123
142
  pos_in_cycle = elapsed_secs % full_cycle
124
143
 
125
- # Walk through the blocks in the cycle
126
- for block_idx in range(POMODORO_CYCLE_LENGTH):
127
- # Focus phase
128
- if pos_in_cycle < POMODORO_FOCUS:
129
- remaining = POMODORO_FOCUS - pos_in_cycle
144
+ for block_idx in range(cycles):
145
+ if pos_in_cycle < focus:
146
+ remaining = focus - pos_in_cycle
130
147
  return ("focus", remaining, cycle_number, block_idx + 1)
131
- pos_in_cycle -= POMODORO_FOCUS
148
+ pos_in_cycle -= focus
132
149
 
133
- # Break phase (short for blocks 1-3, long for block 4)
134
- if block_idx < POMODORO_CYCLE_LENGTH - 1:
135
- if pos_in_cycle < POMODORO_SHORT_BREAK:
136
- remaining = POMODORO_SHORT_BREAK - pos_in_cycle
150
+ if block_idx < cycles - 1:
151
+ if pos_in_cycle < short_break:
152
+ remaining = short_break - pos_in_cycle
137
153
  return ("short_break", remaining, cycle_number, block_idx + 1)
138
- pos_in_cycle -= POMODORO_SHORT_BREAK
154
+ pos_in_cycle -= short_break
139
155
  else:
140
- if pos_in_cycle < POMODORO_LONG_BREAK:
141
- remaining = POMODORO_LONG_BREAK - pos_in_cycle
156
+ if pos_in_cycle < long_break:
157
+ remaining = long_break - pos_in_cycle
142
158
  return ("long_break", remaining, cycle_number, block_idx + 1)
143
- pos_in_cycle -= POMODORO_LONG_BREAK
159
+ pos_in_cycle -= long_break
144
160
 
145
- # Shouldn't reach here, but safety: start a new focus
146
- return ("focus", POMODORO_FOCUS, cycle_number + 1, 1)
161
+ return ("focus", focus, cycle_number + 1, 1)
147
162
 
148
163
 
149
164
  # ---------------------------------------------------------------------------
@@ -156,7 +171,7 @@ class TimerWidget(Static):
156
171
 
157
172
  Two modes:
158
173
  - **elapsed**: counts up, colour transitions at energy thresholds
159
- - **pomodoro**: 25/5/25/5/25/5/25/15 cycle, counts down within each phase
174
+ - **pomodoro**: configurable focus/break cycles, counts down within each phase
160
175
  """
161
176
 
162
177
  elapsed: reactive[int] = reactive(0)
@@ -164,6 +179,17 @@ class TimerWidget(Static):
164
179
  energy: reactive[int] = reactive(5)
165
180
  timer_mode: reactive[str] = reactive("elapsed")
166
181
 
182
+ # Pomodoro durations (seconds) — loaded from config on mount
183
+ pomo_focus: int = 25 * 60
184
+ pomo_short_break: int = 5 * 60
185
+ pomo_long_break: int = 15 * 60
186
+ pomo_cycles: int = 4
187
+
188
+ def on_mount(self) -> None:
189
+ self.pomo_focus, self.pomo_short_break, self.pomo_long_break, self.pomo_cycles = (
190
+ _load_pomodoro_config()
191
+ )
192
+
167
193
  def render(self) -> str:
168
194
  indicator = " [bold red]PAUSED[/]" if self.paused else ""
169
195
 
@@ -182,12 +208,18 @@ class TimerWidget(Static):
182
208
 
183
209
  def _render_pomodoro(self) -> str:
184
210
  """Countdown timer with focus/break cycle display."""
185
- phase, remaining, _cycle, block = _pomodoro_state(self.elapsed)
211
+ phase, remaining, _cycle, block = _pomodoro_state(
212
+ self.elapsed,
213
+ focus=self.pomo_focus,
214
+ short_break=self.pomo_short_break,
215
+ long_break=self.pomo_long_break,
216
+ cycles=self.pomo_cycles,
217
+ )
186
218
  mins, secs = divmod(remaining, 60)
187
219
 
188
220
  if phase == "focus":
189
221
  colour = "green"
190
- label = f"FOCUS {block}/{POMODORO_CYCLE_LENGTH}"
222
+ label = f"FOCUS {block}/{self.pomo_cycles}"
191
223
  elif phase == "short_break":
192
224
  colour = "cyan"
193
225
  label = "BREAK"
@@ -195,7 +227,9 @@ class TimerWidget(Static):
195
227
  colour = "magenta"
196
228
  label = "LONG BREAK"
197
229
 
198
- return f"[bold {colour}]{mins:02d}:{secs:02d}[/] [{colour}]{label}[/]"
230
+ focus_min = self.pomo_focus // 60
231
+ time_part = f"[bold {colour}]{mins:02d}:{secs:02d}[/]"
232
+ return f"{time_part} [{colour}]{label}[/] [dim]({focus_min}m)[/]"
199
233
 
200
234
 
201
235
  class ActivityFeed(Static):
@@ -312,7 +346,10 @@ class SidebarApp(App[None]):
312
346
 
313
347
  BINDINGS: ClassVar[list[tuple[str, str, str]]] = [
314
348
  ("p", "toggle_pause", "Pause/Resume"),
349
+ ("s", "toggle_pomodoro", "Start/Stop Pomodoro"),
315
350
  ("r", "reset_timer", "Reset"),
351
+ ("plus_sign", "pomo_focus_up", "+5min focus"),
352
+ ("minus", "pomo_focus_down", "-5min focus"),
316
353
  ("Q", "end_session", "End Session"),
317
354
  ("q", "quit", "Quit sidebar"),
318
355
  ]
@@ -322,7 +359,7 @@ class SidebarApp(App[None]):
322
359
  yield BreakBanner(id="break_banner")
323
360
  yield ActivityFeed(id="activity")
324
361
  yield CounterBar(id="counters")
325
- yield Static("[dim]p:pause r:reset Q:end session[/]", id="status")
362
+ yield Static("[dim]p:pause s:pomodoro +/-:adjust r:reset Q:end[/]", id="status")
326
363
 
327
364
  def on_mount(self) -> None:
328
365
  self._poll_ipc_files()
@@ -427,7 +464,14 @@ class SidebarApp(App[None]):
427
464
 
428
465
  timer_mode = state.get("timer_mode", "elapsed")
429
466
  if timer_mode == "pomodoro":
430
- phase, remaining, _cycle, block = _pomodoro_state(elapsed)
467
+ tw = self.query_one("#timer", TimerWidget)
468
+ phase, remaining, _cycle, block = _pomodoro_state(
469
+ elapsed,
470
+ focus=tw.pomo_focus,
471
+ short_break=tw.pomo_short_break,
472
+ long_break=tw.pomo_long_break,
473
+ cycles=tw.pomo_cycles,
474
+ )
431
475
  r_mins, r_secs = divmod(remaining, 60)
432
476
  if phase == "focus":
433
477
  timer_str = f"{r_mins:02d}:{r_secs:02d} F{block}"
@@ -490,6 +534,25 @@ class SidebarApp(App[None]):
490
534
  }
491
535
  )
492
536
 
537
+ def action_toggle_pomodoro(self) -> None:
538
+ """Toggle between elapsed and pomodoro timer modes."""
539
+ state = read_session_state()
540
+ current = state.get("timer_mode", "elapsed")
541
+ new_mode = "elapsed" if current == "pomodoro" else "pomodoro"
542
+ write_session_state({"timer_mode": new_mode})
543
+
544
+ def action_pomo_focus_up(self) -> None:
545
+ """Increase pomodoro focus duration by 5 minutes."""
546
+ timer = self.query_one("#timer", TimerWidget)
547
+ timer.pomo_focus = min(timer.pomo_focus + 5 * 60, 120 * 60) # cap at 120min
548
+ timer.refresh()
549
+
550
+ def action_pomo_focus_down(self) -> None:
551
+ """Decrease pomodoro focus duration by 5 minutes."""
552
+ timer = self.query_one("#timer", TimerWidget)
553
+ timer.pomo_focus = max(timer.pomo_focus - 5 * 60, 5 * 60) # min 5min
554
+ timer.refresh()
555
+
493
556
  def action_end_session(self) -> None:
494
557
  """End the entire study session (agent + sidebar + tmux).
495
558
 
@@ -84,10 +84,13 @@ def create_app(
84
84
  except ImportError:
85
85
  pass # httpx/websockets not installed — proxy unavailable
86
86
 
87
- # Serve index.html at root
87
+ # Serve index.html at root (no-cache to prevent stale SW/browser cache)
88
88
  @app.get("/")
89
89
  async def index() -> FileResponse:
90
- return FileResponse(STATIC_DIR / "index.html")
90
+ return FileResponse(
91
+ STATIC_DIR / "index.html",
92
+ headers={"Cache-Control": "no-cache, no-store, must-revalidate"},
93
+ )
91
94
 
92
95
  # Redirect /session to hash-routed study-session tab
93
96
  @app.get("/session")
@@ -57,17 +57,40 @@ def _is_tmux_session_alive(session_name: str) -> bool:
57
57
  return result.returncode == 0
58
58
 
59
59
 
60
+ def _kill_stale_ttyd(state: dict) -> None:
61
+ """Kill a stale ttyd process if the tmux session it attaches to is gone."""
62
+ import os
63
+ import subprocess as _sp
64
+
65
+ ttyd_pid = state.get("ttyd_pid")
66
+ if not ttyd_pid:
67
+ return
68
+ try:
69
+ result = _sp.run(
70
+ ["ps", "-p", str(ttyd_pid), "-o", "command="],
71
+ capture_output=True,
72
+ text=True,
73
+ timeout=5,
74
+ )
75
+ if "ttyd" in result.stdout:
76
+ os.kill(ttyd_pid, 15) # SIGTERM
77
+ except (OSError, _sp.TimeoutExpired):
78
+ pass
79
+
80
+
60
81
  def _get_full_state() -> dict:
61
82
  """Read all IPC files into a single state dict.
62
83
 
63
84
  If the state file claims a session is active but the tmux session
64
- is gone (zombie), clears the stale state and returns empty.
85
+ is gone (zombie), kills stale ttyd, clears state, and returns empty.
65
86
  """
66
87
  state = read_session_state()
67
88
 
68
89
  # Zombie detection: state says active but tmux session is dead
69
90
  tmux_session = state.get("tmux_session")
70
91
  if tmux_session and state.get("mode") != "ended" and not _is_tmux_session_alive(tmux_session):
92
+ # Kill orphaned ttyd before clearing state
93
+ _kill_stale_ttyd(state)
71
94
  # Clear stale IPC files
72
95
  for f in (STATE_FILE, TOPICS_FILE, PARKING_FILE):
73
96
  if f.exists():
@@ -284,6 +307,26 @@ def get_topics() -> list[dict]:
284
307
  return []
285
308
 
286
309
 
310
+ @router.get("/settings/pomodoro")
311
+ def get_pomodoro_settings() -> dict:
312
+ """Return pomodoro timer defaults from config.
313
+
314
+ The web UI uses these as defaults, overridden by localStorage.
315
+ """
316
+ try:
317
+ from studyctl.settings import load_settings
318
+
319
+ pomo = load_settings().pomodoro
320
+ return {
321
+ "focus": pomo.focus,
322
+ "short_break": pomo.short_break,
323
+ "long_break": pomo.long_break,
324
+ "cycles": pomo.cycles,
325
+ }
326
+ except Exception:
327
+ return {"focus": 25, "short_break": 5, "long_break": 15, "cycles": 4}
328
+
329
+
287
330
  # ---------------------------------------------------------------------------
288
331
  # Start / End session from web UI
289
332
  # ---------------------------------------------------------------------------
@@ -480,8 +523,18 @@ def start_session(body: StartSessionRequest) -> JSONResponse:
480
523
  state_update["topic_config_name"] = topic_config.name
481
524
  write_session_state(state_update)
482
525
 
483
- # Start ttyd for terminal access
484
- start_ttyd_background(session_name)
526
+ # Start ttyd for terminal access (with auth from config if available)
527
+ ttyd_username = ""
528
+ ttyd_password = ""
529
+ try:
530
+ from studyctl.settings import load_settings as _ls_ttyd
531
+
532
+ _ttyd_settings = _ls_ttyd()
533
+ ttyd_username = _ttyd_settings.lan_username or ""
534
+ ttyd_password = _ttyd_settings.lan_password or ""
535
+ except Exception:
536
+ pass
537
+ start_ttyd_background(session_name, username=ttyd_username, password=ttyd_password)
485
538
 
486
539
  return JSONResponse(
487
540
  {
@@ -122,10 +122,14 @@ async def proxy_terminal_ws(ws: WebSocket) -> None:
122
122
  qs = "&".join(f"{k}={v}" for k, v in ws.query_params.items())
123
123
  upstream_ws_url = f"{upstream_ws_url}?{qs}"
124
124
 
125
- # Connect to upstream with the same subprotocol
125
+ # Connect to upstream with the same subprotocol.
126
+ # Forward the Authorization header so ttyd's -c auth is satisfied.
126
127
  upstream_kwargs: dict = {}
127
128
  if subprotocol:
128
129
  upstream_kwargs["subprotocols"] = [subprotocol]
130
+ auth_header = ws.headers.get("authorization")
131
+ if auth_header:
132
+ upstream_kwargs["additional_headers"] = {"Authorization": auth_header}
129
133
 
130
134
  try:
131
135
  async with websockets.connect(upstream_ws_url, **upstream_kwargs) as upstream: