pythinker-code 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (731) hide show
  1. pythinker_code/CHANGELOG.md +16 -0
  2. pythinker_code/__init__.py +0 -0
  3. pythinker_code/__main__.py +92 -0
  4. pythinker_code/acp/AGENTS.md +93 -0
  5. pythinker_code/acp/__init__.py +13 -0
  6. pythinker_code/acp/convert.py +128 -0
  7. pythinker_code/acp/host.py +298 -0
  8. pythinker_code/acp/mcp.py +46 -0
  9. pythinker_code/acp/server.py +497 -0
  10. pythinker_code/acp/session.py +496 -0
  11. pythinker_code/acp/tools.py +167 -0
  12. pythinker_code/acp/types.py +13 -0
  13. pythinker_code/acp/version.py +45 -0
  14. pythinker_code/agents/default/agent.yaml +36 -0
  15. pythinker_code/agents/default/coder.yaml +25 -0
  16. pythinker_code/agents/default/explore.yaml +46 -0
  17. pythinker_code/agents/default/plan.yaml +30 -0
  18. pythinker_code/agents/default/system.md +164 -0
  19. pythinker_code/agents/okabe/agent.yaml +22 -0
  20. pythinker_code/agentspec.py +163 -0
  21. pythinker_code/app.py +820 -0
  22. pythinker_code/approval_runtime/__init__.py +29 -0
  23. pythinker_code/approval_runtime/models.py +42 -0
  24. pythinker_code/approval_runtime/runtime.py +235 -0
  25. pythinker_code/auth/__init__.py +25 -0
  26. pythinker_code/auth/anthropic_direct.py +207 -0
  27. pythinker_code/auth/deepseek.py +192 -0
  28. pythinker_code/auth/lm_studio.py +418 -0
  29. pythinker_code/auth/minimax.py +203 -0
  30. pythinker_code/auth/oauth.py +1122 -0
  31. pythinker_code/auth/ollama.py +293 -0
  32. pythinker_code/auth/openai.py +771 -0
  33. pythinker_code/auth/opencode_go.py +203 -0
  34. pythinker_code/auth/openrouter.py +225 -0
  35. pythinker_code/auth/platforms.py +466 -0
  36. pythinker_code/background/__init__.py +36 -0
  37. pythinker_code/background/agent_runner.py +231 -0
  38. pythinker_code/background/ids.py +19 -0
  39. pythinker_code/background/manager.py +650 -0
  40. pythinker_code/background/models.py +105 -0
  41. pythinker_code/background/store.py +237 -0
  42. pythinker_code/background/summary.py +66 -0
  43. pythinker_code/background/worker.py +209 -0
  44. pythinker_code/cli/__init__.py +1326 -0
  45. pythinker_code/cli/__main__.py +19 -0
  46. pythinker_code/cli/_lazy_group.py +238 -0
  47. pythinker_code/cli/export.py +322 -0
  48. pythinker_code/cli/info.py +62 -0
  49. pythinker_code/cli/mcp.py +349 -0
  50. pythinker_code/cli/plugin.py +351 -0
  51. pythinker_code/cli/toad.py +74 -0
  52. pythinker_code/cli/vis.py +38 -0
  53. pythinker_code/cli/web.py +80 -0
  54. pythinker_code/config.py +453 -0
  55. pythinker_code/constant.py +33 -0
  56. pythinker_code/exception.py +43 -0
  57. pythinker_code/hooks/__init__.py +4 -0
  58. pythinker_code/hooks/config.py +34 -0
  59. pythinker_code/hooks/engine.py +371 -0
  60. pythinker_code/hooks/events.py +190 -0
  61. pythinker_code/hooks/runner.py +89 -0
  62. pythinker_code/llm.py +412 -0
  63. pythinker_code/metadata.py +79 -0
  64. pythinker_code/notifications/__init__.py +33 -0
  65. pythinker_code/notifications/llm.py +77 -0
  66. pythinker_code/notifications/manager.py +145 -0
  67. pythinker_code/notifications/models.py +50 -0
  68. pythinker_code/notifications/notifier.py +41 -0
  69. pythinker_code/notifications/store.py +118 -0
  70. pythinker_code/notifications/wire.py +21 -0
  71. pythinker_code/plugin/__init__.py +124 -0
  72. pythinker_code/plugin/manager.py +153 -0
  73. pythinker_code/plugin/tool.py +173 -0
  74. pythinker_code/prompts/__init__.py +6 -0
  75. pythinker_code/prompts/compact.md +73 -0
  76. pythinker_code/prompts/init.md +21 -0
  77. pythinker_code/py.typed +0 -0
  78. pythinker_code/session.py +319 -0
  79. pythinker_code/session_fork.py +325 -0
  80. pythinker_code/session_state.py +132 -0
  81. pythinker_code/share.py +14 -0
  82. pythinker_code/skill/__init__.py +727 -0
  83. pythinker_code/skill/flow/__init__.py +99 -0
  84. pythinker_code/skill/flow/d2.py +482 -0
  85. pythinker_code/skill/flow/mermaid.py +266 -0
  86. pythinker_code/skills/pythinker-code-help/SKILL.md +54 -0
  87. pythinker_code/skills/skill-creator/SKILL.md +367 -0
  88. pythinker_code/soul/__init__.py +304 -0
  89. pythinker_code/soul/agent.py +520 -0
  90. pythinker_code/soul/approval.py +267 -0
  91. pythinker_code/soul/btw.py +214 -0
  92. pythinker_code/soul/compaction.py +189 -0
  93. pythinker_code/soul/context.py +339 -0
  94. pythinker_code/soul/denwarenji.py +39 -0
  95. pythinker_code/soul/dynamic_injection.py +84 -0
  96. pythinker_code/soul/dynamic_injections/__init__.py +0 -0
  97. pythinker_code/soul/dynamic_injections/auto_mode.py +72 -0
  98. pythinker_code/soul/dynamic_injections/plan_mode.py +239 -0
  99. pythinker_code/soul/message.py +92 -0
  100. pythinker_code/soul/pythinkersoul.py +1613 -0
  101. pythinker_code/soul/slash.py +340 -0
  102. pythinker_code/soul/toolset.py +788 -0
  103. pythinker_code/subagents/__init__.py +21 -0
  104. pythinker_code/subagents/builder.py +42 -0
  105. pythinker_code/subagents/core.py +86 -0
  106. pythinker_code/subagents/git_context.py +172 -0
  107. pythinker_code/subagents/models.py +54 -0
  108. pythinker_code/subagents/output.py +71 -0
  109. pythinker_code/subagents/registry.py +28 -0
  110. pythinker_code/subagents/runner.py +428 -0
  111. pythinker_code/subagents/store.py +196 -0
  112. pythinker_code/telemetry/__init__.py +211 -0
  113. pythinker_code/telemetry/config.py +54 -0
  114. pythinker_code/telemetry/crash.py +157 -0
  115. pythinker_code/telemetry/metrics.py +208 -0
  116. pythinker_code/telemetry/otel.py +240 -0
  117. pythinker_code/telemetry/sentry.py +167 -0
  118. pythinker_code/telemetry/sink.py +189 -0
  119. pythinker_code/tools/AGENTS.md +6 -0
  120. pythinker_code/tools/__init__.py +105 -0
  121. pythinker_code/tools/agent/__init__.py +277 -0
  122. pythinker_code/tools/agent/description.md +41 -0
  123. pythinker_code/tools/ask_user/__init__.py +159 -0
  124. pythinker_code/tools/ask_user/description.md +19 -0
  125. pythinker_code/tools/background/__init__.py +318 -0
  126. pythinker_code/tools/background/list.md +10 -0
  127. pythinker_code/tools/background/output.md +11 -0
  128. pythinker_code/tools/background/stop.md +8 -0
  129. pythinker_code/tools/display.py +46 -0
  130. pythinker_code/tools/dmail/__init__.py +38 -0
  131. pythinker_code/tools/dmail/dmail.md +17 -0
  132. pythinker_code/tools/file/__init__.py +30 -0
  133. pythinker_code/tools/file/glob.md +17 -0
  134. pythinker_code/tools/file/glob.py +160 -0
  135. pythinker_code/tools/file/grep.md +6 -0
  136. pythinker_code/tools/file/grep_local.py +589 -0
  137. pythinker_code/tools/file/plan_mode.py +45 -0
  138. pythinker_code/tools/file/read.md +16 -0
  139. pythinker_code/tools/file/read.py +300 -0
  140. pythinker_code/tools/file/read_media.md +24 -0
  141. pythinker_code/tools/file/read_media.py +217 -0
  142. pythinker_code/tools/file/replace.md +7 -0
  143. pythinker_code/tools/file/replace.py +195 -0
  144. pythinker_code/tools/file/utils.py +257 -0
  145. pythinker_code/tools/file/write.md +5 -0
  146. pythinker_code/tools/file/write.py +177 -0
  147. pythinker_code/tools/plan/__init__.py +327 -0
  148. pythinker_code/tools/plan/description.md +29 -0
  149. pythinker_code/tools/plan/enter.py +190 -0
  150. pythinker_code/tools/plan/enter_description.md +35 -0
  151. pythinker_code/tools/plan/heroes.py +277 -0
  152. pythinker_code/tools/shell/__init__.py +253 -0
  153. pythinker_code/tools/shell/bash.md +35 -0
  154. pythinker_code/tools/shell/powershell.md +30 -0
  155. pythinker_code/tools/test.py +55 -0
  156. pythinker_code/tools/think/__init__.py +21 -0
  157. pythinker_code/tools/think/think.md +1 -0
  158. pythinker_code/tools/todo/__init__.py +168 -0
  159. pythinker_code/tools/todo/set_todo_list.md +23 -0
  160. pythinker_code/tools/utils.py +199 -0
  161. pythinker_code/tools/web/__init__.py +4 -0
  162. pythinker_code/tools/web/fetch.md +1 -0
  163. pythinker_code/tools/web/fetch.py +189 -0
  164. pythinker_code/tools/web/search.md +1 -0
  165. pythinker_code/tools/web/search.py +163 -0
  166. pythinker_code/ui/__init__.py +0 -0
  167. pythinker_code/ui/acp/__init__.py +99 -0
  168. pythinker_code/ui/print/__init__.py +474 -0
  169. pythinker_code/ui/print/visualize.py +185 -0
  170. pythinker_code/ui/shell/__init__.py +1696 -0
  171. pythinker_code/ui/shell/console.py +109 -0
  172. pythinker_code/ui/shell/debug.py +190 -0
  173. pythinker_code/ui/shell/echo.py +17 -0
  174. pythinker_code/ui/shell/export_import.py +117 -0
  175. pythinker_code/ui/shell/keyboard.py +300 -0
  176. pythinker_code/ui/shell/mcp_status.py +113 -0
  177. pythinker_code/ui/shell/model_picker.py +318 -0
  178. pythinker_code/ui/shell/oauth.py +272 -0
  179. pythinker_code/ui/shell/placeholders.py +531 -0
  180. pythinker_code/ui/shell/prompt.py +2278 -0
  181. pythinker_code/ui/shell/replay.py +215 -0
  182. pythinker_code/ui/shell/session_picker.py +227 -0
  183. pythinker_code/ui/shell/setup.py +212 -0
  184. pythinker_code/ui/shell/slash.py +898 -0
  185. pythinker_code/ui/shell/startup.py +32 -0
  186. pythinker_code/ui/shell/task_browser.py +486 -0
  187. pythinker_code/ui/shell/update.py +350 -0
  188. pythinker_code/ui/shell/usage.py +291 -0
  189. pythinker_code/ui/shell/usage_adapters/__init__.py +50 -0
  190. pythinker_code/ui/shell/usage_adapters/anthropic_admin.py +233 -0
  191. pythinker_code/ui/shell/usage_adapters/base.py +72 -0
  192. pythinker_code/ui/shell/usage_adapters/deepseek.py +137 -0
  193. pythinker_code/ui/shell/usage_adapters/minimax.py +236 -0
  194. pythinker_code/ui/shell/usage_adapters/openai_admin.py +225 -0
  195. pythinker_code/ui/shell/usage_adapters/openai_chatgpt.py +241 -0
  196. pythinker_code/ui/shell/usage_adapters/opencode_go.py +232 -0
  197. pythinker_code/ui/shell/usage_adapters/openrouter.py +105 -0
  198. pythinker_code/ui/shell/usage_adapters/pythinker.py +189 -0
  199. pythinker_code/ui/shell/usage_adapters/pythinker_ai.py +50 -0
  200. pythinker_code/ui/shell/usage_render.py +150 -0
  201. pythinker_code/ui/shell/visualize/__init__.py +165 -0
  202. pythinker_code/ui/shell/visualize/_approval_panel.py +505 -0
  203. pythinker_code/ui/shell/visualize/_blocks.py +629 -0
  204. pythinker_code/ui/shell/visualize/_btw_panel.py +224 -0
  205. pythinker_code/ui/shell/visualize/_input_router.py +48 -0
  206. pythinker_code/ui/shell/visualize/_interactive.py +523 -0
  207. pythinker_code/ui/shell/visualize/_live_view.py +826 -0
  208. pythinker_code/ui/shell/visualize/_question_panel.py +586 -0
  209. pythinker_code/ui/theme.py +241 -0
  210. pythinker_code/usage_ratelimit_cache.py +175 -0
  211. pythinker_code/utils/__init__.py +0 -0
  212. pythinker_code/utils/aiohttp.py +24 -0
  213. pythinker_code/utils/aioqueue.py +72 -0
  214. pythinker_code/utils/broadcast.py +37 -0
  215. pythinker_code/utils/changelog.py +108 -0
  216. pythinker_code/utils/clipboard.py +246 -0
  217. pythinker_code/utils/datetime.py +64 -0
  218. pythinker_code/utils/diff.py +135 -0
  219. pythinker_code/utils/editor.py +91 -0
  220. pythinker_code/utils/environment.py +73 -0
  221. pythinker_code/utils/envvar.py +22 -0
  222. pythinker_code/utils/export.py +696 -0
  223. pythinker_code/utils/file_filter.py +375 -0
  224. pythinker_code/utils/frontmatter.py +70 -0
  225. pythinker_code/utils/io.py +27 -0
  226. pythinker_code/utils/logging.py +146 -0
  227. pythinker_code/utils/media_tags.py +29 -0
  228. pythinker_code/utils/message.py +24 -0
  229. pythinker_code/utils/path.py +199 -0
  230. pythinker_code/utils/proctitle.py +33 -0
  231. pythinker_code/utils/proxy.py +31 -0
  232. pythinker_code/utils/pyinstaller.py +45 -0
  233. pythinker_code/utils/rich/__init__.py +33 -0
  234. pythinker_code/utils/rich/columns.py +99 -0
  235. pythinker_code/utils/rich/diff_render.py +481 -0
  236. pythinker_code/utils/rich/markdown.py +900 -0
  237. pythinker_code/utils/rich/markdown_sample.md +108 -0
  238. pythinker_code/utils/rich/markdown_sample_short.md +2 -0
  239. pythinker_code/utils/rich/syntax.py +114 -0
  240. pythinker_code/utils/sensitive.py +54 -0
  241. pythinker_code/utils/server.py +121 -0
  242. pythinker_code/utils/signals.py +43 -0
  243. pythinker_code/utils/slashcmd.py +124 -0
  244. pythinker_code/utils/string.py +41 -0
  245. pythinker_code/utils/subprocess_env.py +73 -0
  246. pythinker_code/utils/term.py +168 -0
  247. pythinker_code/utils/typing.py +20 -0
  248. pythinker_code/vis/__init__.py +0 -0
  249. pythinker_code/vis/api/__init__.py +5 -0
  250. pythinker_code/vis/api/sessions.py +687 -0
  251. pythinker_code/vis/api/statistics.py +209 -0
  252. pythinker_code/vis/api/system.py +19 -0
  253. pythinker_code/vis/app.py +175 -0
  254. pythinker_code/vis/static/assets/highlighted-body-B3W2YXNL-D2MTYyJz.js +1 -0
  255. pythinker_code/vis/static/assets/index-CezafTt_.js +185 -0
  256. pythinker_code/vis/static/assets/index-DSRInNnm.css +1 -0
  257. pythinker_code/vis/static/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  258. pythinker_code/vis/static/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  259. pythinker_code/vis/static/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  260. pythinker_code/vis/static/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  261. pythinker_code/vis/static/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  262. pythinker_code/vis/static/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  263. pythinker_code/vis/static/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  264. pythinker_code/vis/static/index.html +17 -0
  265. pythinker_code/web/__init__.py +5 -0
  266. pythinker_code/web/api/__init__.py +15 -0
  267. pythinker_code/web/api/config.py +208 -0
  268. pythinker_code/web/api/open_in.py +199 -0
  269. pythinker_code/web/api/sessions.py +1225 -0
  270. pythinker_code/web/app.py +451 -0
  271. pythinker_code/web/auth.py +191 -0
  272. pythinker_code/web/models.py +98 -0
  273. pythinker_code/web/runner/__init__.py +5 -0
  274. pythinker_code/web/runner/messages.py +57 -0
  275. pythinker_code/web/runner/process.py +754 -0
  276. pythinker_code/web/runner/worker.py +97 -0
  277. pythinker_code/web/static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  278. pythinker_code/web/static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  279. pythinker_code/web/static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  280. pythinker_code/web/static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  281. pythinker_code/web/static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  282. pythinker_code/web/static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  283. pythinker_code/web/static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  284. pythinker_code/web/static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  285. pythinker_code/web/static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  286. pythinker_code/web/static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  287. pythinker_code/web/static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  288. pythinker_code/web/static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  289. pythinker_code/web/static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  290. pythinker_code/web/static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  291. pythinker_code/web/static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  292. pythinker_code/web/static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  293. pythinker_code/web/static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  294. pythinker_code/web/static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  295. pythinker_code/web/static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  296. pythinker_code/web/static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  297. pythinker_code/web/static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  298. pythinker_code/web/static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  299. pythinker_code/web/static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  300. pythinker_code/web/static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  301. pythinker_code/web/static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  302. pythinker_code/web/static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  303. pythinker_code/web/static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  304. pythinker_code/web/static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  305. pythinker_code/web/static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  306. pythinker_code/web/static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  307. pythinker_code/web/static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  308. pythinker_code/web/static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  309. pythinker_code/web/static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  310. pythinker_code/web/static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  311. pythinker_code/web/static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  312. pythinker_code/web/static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  313. pythinker_code/web/static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  314. pythinker_code/web/static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  315. pythinker_code/web/static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  316. pythinker_code/web/static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  317. pythinker_code/web/static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  318. pythinker_code/web/static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  319. pythinker_code/web/static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  320. pythinker_code/web/static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  321. pythinker_code/web/static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  322. pythinker_code/web/static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  323. pythinker_code/web/static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  324. pythinker_code/web/static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  325. pythinker_code/web/static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  326. pythinker_code/web/static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  327. pythinker_code/web/static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  328. pythinker_code/web/static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  329. pythinker_code/web/static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  330. pythinker_code/web/static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  331. pythinker_code/web/static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  332. pythinker_code/web/static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  333. pythinker_code/web/static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  334. pythinker_code/web/static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  335. pythinker_code/web/static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  336. pythinker_code/web/static/assets/_baseUniq--dyU3g5v.js +1 -0
  337. pythinker_code/web/static/assets/abap-BdImnpbu.js +1 -0
  338. pythinker_code/web/static/assets/actionscript-3-CfeIJUat.js +1 -0
  339. pythinker_code/web/static/assets/ada-bCR0ucgS.js +1 -0
  340. pythinker_code/web/static/assets/andromeeda-C-Jbm3Hp.js +1 -0
  341. pythinker_code/web/static/assets/angular-html-CU67Zn6k.js +1 -0
  342. pythinker_code/web/static/assets/angular-ts-BwZT4LLn.js +1 -0
  343. pythinker_code/web/static/assets/apache-Pmp26Uib.js +1 -0
  344. pythinker_code/web/static/assets/apex-D8_7TLub.js +1 -0
  345. pythinker_code/web/static/assets/apl-dKokRX4l.js +1 -0
  346. pythinker_code/web/static/assets/applescript-Co6uUVPk.js +1 -0
  347. pythinker_code/web/static/assets/ara-BRHolxvo.js +1 -0
  348. pythinker_code/web/static/assets/arc-DkMjLpYa.js +1 -0
  349. pythinker_code/web/static/assets/architectureDiagram-VXUJARFQ-CHWVaGo9.js +36 -0
  350. pythinker_code/web/static/assets/asciidoc-Dv7Oe6Be.js +1 -0
  351. pythinker_code/web/static/assets/asm-D_Q5rh1f.js +1 -0
  352. pythinker_code/web/static/assets/astro-CbQHKStN.js +1 -0
  353. pythinker_code/web/static/assets/aurora-x-D-2ljcwZ.js +1 -0
  354. pythinker_code/web/static/assets/awk-DMzUqQB5.js +1 -0
  355. pythinker_code/web/static/assets/ayu-dark-CmMr59Fi.js +1 -0
  356. pythinker_code/web/static/assets/ballerina-BFfxhgS-.js +1 -0
  357. pythinker_code/web/static/assets/bat-BkioyH1T.js +1 -0
  358. pythinker_code/web/static/assets/beancount-k_qm7-4y.js +1 -0
  359. pythinker_code/web/static/assets/berry-uYugtg8r.js +1 -0
  360. pythinker_code/web/static/assets/bibtex-CHM0blh-.js +1 -0
  361. pythinker_code/web/static/assets/bicep-Bmn6On1c.js +1 -0
  362. pythinker_code/web/static/assets/blade-D4QpJJKB.js +1 -0
  363. pythinker_code/web/static/assets/blockDiagram-VD42YOAC-DzdKe497.js +122 -0
  364. pythinker_code/web/static/assets/bsl-BO_Y6i37.js +1 -0
  365. pythinker_code/web/static/assets/c-BIGW1oBm.js +1 -0
  366. pythinker_code/web/static/assets/c3-VCDPK7BO.js +1 -0
  367. pythinker_code/web/static/assets/c4Diagram-YG6GDRKO-D84Blotg.js +10 -0
  368. pythinker_code/web/static/assets/cadence-Bv_4Rxtq.js +1 -0
  369. pythinker_code/web/static/assets/cairo-KRGpt6FW.js +1 -0
  370. pythinker_code/web/static/assets/catppuccin-frappe-DFWUc33u.js +1 -0
  371. pythinker_code/web/static/assets/catppuccin-latte-C9dUb6Cb.js +1 -0
  372. pythinker_code/web/static/assets/catppuccin-macchiato-DQyhUUbL.js +1 -0
  373. pythinker_code/web/static/assets/catppuccin-mocha-D87Tk5Gz.js +1 -0
  374. pythinker_code/web/static/assets/channel-CllSjjdl.js +1 -0
  375. pythinker_code/web/static/assets/chunk-4BX2VUAB-C9w8wleE.js +1 -0
  376. pythinker_code/web/static/assets/chunk-55IACEB6-YlYJ8HnF.js +1 -0
  377. pythinker_code/web/static/assets/chunk-B4BG7PRW-Bwtz_AHU.js +165 -0
  378. pythinker_code/web/static/assets/chunk-DI55MBZ5-BbEHkl8h.js +220 -0
  379. pythinker_code/web/static/assets/chunk-FMBD7UC4-BKPbvjLC.js +15 -0
  380. pythinker_code/web/static/assets/chunk-QN33PNHL-D73dQvKf.js +1 -0
  381. pythinker_code/web/static/assets/chunk-QZHKN3VN-zGiLKes_.js +1 -0
  382. pythinker_code/web/static/assets/chunk-TZMSLE5B-LHJCi2fy.js +1 -0
  383. pythinker_code/web/static/assets/clarity-D53aC0YG.js +1 -0
  384. pythinker_code/web/static/assets/classDiagram-2ON5EDUG-vX27iZwa.js +1 -0
  385. pythinker_code/web/static/assets/classDiagram-v2-WZHVMYZB-vX27iZwa.js +1 -0
  386. pythinker_code/web/static/assets/clojure-P80f7IUj.js +1 -0
  387. pythinker_code/web/static/assets/clone-DYBkaPm2.js +1 -0
  388. pythinker_code/web/static/assets/cmake-D1j8_8rp.js +1 -0
  389. pythinker_code/web/static/assets/cobol-nwyudZeR.js +1 -0
  390. pythinker_code/web/static/assets/code-block-IT6T5CEO-NtKViZGl.js +2 -0
  391. pythinker_code/web/static/assets/codeowners-Bp6g37R7.js +1 -0
  392. pythinker_code/web/static/assets/codeql-DsOJ9woJ.js +1 -0
  393. pythinker_code/web/static/assets/coffee-Ch7k5sss.js +1 -0
  394. pythinker_code/web/static/assets/common-lisp-Cg-RD9OK.js +1 -0
  395. pythinker_code/web/static/assets/coq-DkFqJrB1.js +1 -0
  396. pythinker_code/web/static/assets/cose-bilkent-S5V4N54A-DialjZpd.js +1 -0
  397. pythinker_code/web/static/assets/cpp-CofmeUqb.js +1 -0
  398. pythinker_code/web/static/assets/crystal-tKQVLTB8.js +1 -0
  399. pythinker_code/web/static/assets/csharp-K5feNrxe.js +1 -0
  400. pythinker_code/web/static/assets/css-DPfMkruS.js +1 -0
  401. pythinker_code/web/static/assets/csv-fuZLfV_i.js +1 -0
  402. pythinker_code/web/static/assets/cue-D82EKSYY.js +1 -0
  403. pythinker_code/web/static/assets/cypher-COkxafJQ.js +1 -0
  404. pythinker_code/web/static/assets/cytoscape.esm-C_Fzpdck.js +321 -0
  405. pythinker_code/web/static/assets/d-85-TOEBH.js +1 -0
  406. pythinker_code/web/static/assets/dagre-6UL2VRFP-DfuvkZZ7.js +4 -0
  407. pythinker_code/web/static/assets/dark-plus-C3mMm8J8.js +1 -0
  408. pythinker_code/web/static/assets/dart-CF10PKvl.js +1 -0
  409. pythinker_code/web/static/assets/dax-CEL-wOlO.js +1 -0
  410. pythinker_code/web/static/assets/defaultLocale-DX6XiGOO.js +1 -0
  411. pythinker_code/web/static/assets/desktop-BmXAJ9_W.js +1 -0
  412. pythinker_code/web/static/assets/diagram-PSM6KHXK-DLGctX3v.js +24 -0
  413. pythinker_code/web/static/assets/diagram-QEK2KX5R-DnxN6S0F.js +43 -0
  414. pythinker_code/web/static/assets/diagram-S2PKOQOG-Caq_Set9.js +24 -0
  415. pythinker_code/web/static/assets/diff-D97Zzqfu.js +1 -0
  416. pythinker_code/web/static/assets/docker-BcOcwvcX.js +1 -0
  417. pythinker_code/web/static/assets/dotenv-Da5cRb03.js +1 -0
  418. pythinker_code/web/static/assets/dracula-BzJJZx-M.js +1 -0
  419. pythinker_code/web/static/assets/dracula-soft-BXkSAIEj.js +1 -0
  420. pythinker_code/web/static/assets/dream-maker-BtqSS_iP.js +1 -0
  421. pythinker_code/web/static/assets/edge-BkV0erSs.js +1 -0
  422. pythinker_code/web/static/assets/elixir-CDX3lj18.js +1 -0
  423. pythinker_code/web/static/assets/elm-DbKCFpqz.js +1 -0
  424. pythinker_code/web/static/assets/emacs-lisp-C9XAeP06.js +1 -0
  425. pythinker_code/web/static/assets/erDiagram-Q2GNP2WA-BgTfALoK.js +60 -0
  426. pythinker_code/web/static/assets/erb-BOJIQeun.js +1 -0
  427. pythinker_code/web/static/assets/erlang-DsQrWhSR.js +1 -0
  428. pythinker_code/web/static/assets/everforest-dark-BgDCqdQA.js +1 -0
  429. pythinker_code/web/static/assets/everforest-light-C8M2exoo.js +1 -0
  430. pythinker_code/web/static/assets/fennel-BYunw83y.js +1 -0
  431. pythinker_code/web/static/assets/fish-BvzEVeQv.js +1 -0
  432. pythinker_code/web/static/assets/flowDiagram-NV44I4VS-QjW_fnGi.js +162 -0
  433. pythinker_code/web/static/assets/fluent-C4IJs8-o.js +1 -0
  434. pythinker_code/web/static/assets/fortran-fixed-form-CkoXwp7k.js +1 -0
  435. pythinker_code/web/static/assets/fortran-free-form-BxgE0vQu.js +1 -0
  436. pythinker_code/web/static/assets/fsharp-CXgrBDvD.js +1 -0
  437. pythinker_code/web/static/assets/ganttDiagram-JELNMOA3-fqi8JFof.js +267 -0
  438. pythinker_code/web/static/assets/gdresource-B7Tvp0Sc.js +1 -0
  439. pythinker_code/web/static/assets/gdscript-DTMYz4Jt.js +1 -0
  440. pythinker_code/web/static/assets/gdshader-DkwncUOv.js +1 -0
  441. pythinker_code/web/static/assets/genie-D0YGMca9.js +1 -0
  442. pythinker_code/web/static/assets/gherkin-DyxjwDmM.js +1 -0
  443. pythinker_code/web/static/assets/git-commit-F4YmCXRG.js +1 -0
  444. pythinker_code/web/static/assets/git-rebase-r7XF79zn.js +1 -0
  445. pythinker_code/web/static/assets/gitGraphDiagram-NY62KEGX-i7o6VQ8x.js +65 -0
  446. pythinker_code/web/static/assets/github-dark-DHJKELXO.js +1 -0
  447. pythinker_code/web/static/assets/github-dark-default-Cuk6v7N8.js +1 -0
  448. pythinker_code/web/static/assets/github-dark-dimmed-DH5Ifo-i.js +1 -0
  449. pythinker_code/web/static/assets/github-dark-high-contrast-E3gJ1_iC.js +1 -0
  450. pythinker_code/web/static/assets/github-light-DAi9KRSo.js +1 -0
  451. pythinker_code/web/static/assets/github-light-default-D7oLnXFd.js +1 -0
  452. pythinker_code/web/static/assets/github-light-high-contrast-BfjtVDDH.js +1 -0
  453. pythinker_code/web/static/assets/gleam-BspZqrRM.js +1 -0
  454. pythinker_code/web/static/assets/glimmer-js-Rg0-pVw9.js +1 -0
  455. pythinker_code/web/static/assets/glimmer-ts-U6CK756n.js +1 -0
  456. pythinker_code/web/static/assets/glsl-DplSGwfg.js +1 -0
  457. pythinker_code/web/static/assets/gn-n2N0HUVH.js +1 -0
  458. pythinker_code/web/static/assets/gnuplot-DdkO51Og.js +1 -0
  459. pythinker_code/web/static/assets/go-Dn2_MT6a.js +1 -0
  460. pythinker_code/web/static/assets/graph-C0vZK2pT.js +1 -0
  461. pythinker_code/web/static/assets/graphql-ChdNCCLP.js +1 -0
  462. pythinker_code/web/static/assets/groovy-gcz8RCvz.js +1 -0
  463. pythinker_code/web/static/assets/gruvbox-dark-hard-CFHQjOhq.js +1 -0
  464. pythinker_code/web/static/assets/gruvbox-dark-medium-GsRaNv29.js +1 -0
  465. pythinker_code/web/static/assets/gruvbox-dark-soft-CVdnzihN.js +1 -0
  466. pythinker_code/web/static/assets/gruvbox-light-hard-CH1njM8p.js +1 -0
  467. pythinker_code/web/static/assets/gruvbox-light-medium-DRw_LuNl.js +1 -0
  468. pythinker_code/web/static/assets/gruvbox-light-soft-hJgmCMqR.js +1 -0
  469. pythinker_code/web/static/assets/hack-CaT9iCJl.js +1 -0
  470. pythinker_code/web/static/assets/haml-B8DHNrY2.js +1 -0
  471. pythinker_code/web/static/assets/handlebars-BL8al0AC.js +1 -0
  472. pythinker_code/web/static/assets/haskell-Df6bDoY_.js +1 -0
  473. pythinker_code/web/static/assets/haxe-CzTSHFRz.js +1 -0
  474. pythinker_code/web/static/assets/hcl-BWvSN4gD.js +1 -0
  475. pythinker_code/web/static/assets/hjson-D5-asLiD.js +1 -0
  476. pythinker_code/web/static/assets/hlsl-D3lLCCz7.js +1 -0
  477. pythinker_code/web/static/assets/houston-DnULxvSX.js +1 -0
  478. pythinker_code/web/static/assets/html-GMplVEZG.js +1 -0
  479. pythinker_code/web/static/assets/html-derivative-BFtXZ54Q.js +1 -0
  480. pythinker_code/web/static/assets/http-jrhK8wxY.js +1 -0
  481. pythinker_code/web/static/assets/hurl-irOxFIW8.js +1 -0
  482. pythinker_code/web/static/assets/hxml-Bvhsp5Yf.js +1 -0
  483. pythinker_code/web/static/assets/hy-DFXneXwc.js +1 -0
  484. pythinker_code/web/static/assets/imba-DGztddWO.js +1 -0
  485. pythinker_code/web/static/assets/index-BYCCk6-K.js +153 -0
  486. pythinker_code/web/static/assets/index-BpoLgcEt.js +1 -0
  487. pythinker_code/web/static/assets/index-Cpy4G3uJ.js +2 -0
  488. pythinker_code/web/static/assets/index-CzV_vCfu.css +1 -0
  489. pythinker_code/web/static/assets/index-DI2oedCt.js +19 -0
  490. pythinker_code/web/static/assets/index-DdIkp80K.js +5 -0
  491. pythinker_code/web/static/assets/infoDiagram-WHAUD3N6-BMPpeZSM.js +2 -0
  492. pythinker_code/web/static/assets/ini-BEwlwnbL.js +1 -0
  493. pythinker_code/web/static/assets/init-Gi6I4Gst.js +1 -0
  494. pythinker_code/web/static/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  495. pythinker_code/web/static/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  496. pythinker_code/web/static/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  497. pythinker_code/web/static/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  498. pythinker_code/web/static/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  499. pythinker_code/web/static/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  500. pythinker_code/web/static/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  501. pythinker_code/web/static/assets/java-CylS5w8V.js +1 -0
  502. pythinker_code/web/static/assets/javascript-wDzz0qaB.js +1 -0
  503. pythinker_code/web/static/assets/jinja-4LBKfQ-Z.js +1 -0
  504. pythinker_code/web/static/assets/jison-wvAkD_A8.js +1 -0
  505. pythinker_code/web/static/assets/journeyDiagram-XKPGCS4Q-DAM7gngo.js +139 -0
  506. pythinker_code/web/static/assets/json-Cp-IABpG.js +1 -0
  507. pythinker_code/web/static/assets/json5-C9tS-k6U.js +1 -0
  508. pythinker_code/web/static/assets/jsonc-Des-eS-w.js +1 -0
  509. pythinker_code/web/static/assets/jsonl-DcaNXYhu.js +1 -0
  510. pythinker_code/web/static/assets/jsonnet-DFQXde-d.js +1 -0
  511. pythinker_code/web/static/assets/jssm-C2t-YnRu.js +1 -0
  512. pythinker_code/web/static/assets/jsx-g9-lgVsj.js +1 -0
  513. pythinker_code/web/static/assets/julia-CxzCAyBv.js +1 -0
  514. pythinker_code/web/static/assets/kanagawa-dragon-CkXjmgJE.js +1 -0
  515. pythinker_code/web/static/assets/kanagawa-lotus-CfQXZHmo.js +1 -0
  516. pythinker_code/web/static/assets/kanagawa-wave-DWedfzmr.js +1 -0
  517. pythinker_code/web/static/assets/kanban-definition-3W4ZIXB7-ChpBpV0k.js +89 -0
  518. pythinker_code/web/static/assets/katex-D2lIc1rk.css +1 -0
  519. pythinker_code/web/static/assets/kdl-DV7GczEv.js +1 -0
  520. pythinker_code/web/static/assets/kotlin-BdnUsdx6.js +1 -0
  521. pythinker_code/web/static/assets/kusto-DZf3V79B.js +1 -0
  522. pythinker_code/web/static/assets/laserwave-DUszq2jm.js +1 -0
  523. pythinker_code/web/static/assets/latex-B4uzh10-.js +1 -0
  524. pythinker_code/web/static/assets/layout-C3Jp1gKO.js +1 -0
  525. pythinker_code/web/static/assets/lean-BZvkOJ9d.js +1 -0
  526. pythinker_code/web/static/assets/less-B1dDrJ26.js +1 -0
  527. pythinker_code/web/static/assets/light-plus-B7mTdjB0.js +1 -0
  528. pythinker_code/web/static/assets/linear-BGHtL1N4.js +1 -0
  529. pythinker_code/web/static/assets/liquid-DYVedYrR.js +1 -0
  530. pythinker_code/web/static/assets/llvm-BtvRca6l.js +1 -0
  531. pythinker_code/web/static/assets/log-2UxHyX5q.js +1 -0
  532. pythinker_code/web/static/assets/logo-BtOb2qkB.js +1 -0
  533. pythinker_code/web/static/assets/lua-BbnMAYS6.js +1 -0
  534. pythinker_code/web/static/assets/luau-C-HG3fhB.js +1 -0
  535. pythinker_code/web/static/assets/make-CHLpvVh8.js +1 -0
  536. pythinker_code/web/static/assets/markdown-Cvjx9yec.js +1 -0
  537. pythinker_code/web/static/assets/marko-DZsq8hO1.js +1 -0
  538. pythinker_code/web/static/assets/material-theme-D5KoaKCx.js +1 -0
  539. pythinker_code/web/static/assets/material-theme-darker-BfHTSMKl.js +1 -0
  540. pythinker_code/web/static/assets/material-theme-lighter-B0m2ddpp.js +1 -0
  541. pythinker_code/web/static/assets/material-theme-ocean-CyktbL80.js +1 -0
  542. pythinker_code/web/static/assets/material-theme-palenight-Csfq5Kiy.js +1 -0
  543. pythinker_code/web/static/assets/matlab-D7o27uSR.js +1 -0
  544. pythinker_code/web/static/assets/mdc-DUICxH0z.js +1 -0
  545. pythinker_code/web/static/assets/mdx-Cmh6b_Ma.js +1 -0
  546. pythinker_code/web/static/assets/mermaid-VLURNSYL-B2P5VJ9v.css +1 -0
  547. pythinker_code/web/static/assets/mermaid-VLURNSYL-C_HW6koB.js +465 -0
  548. pythinker_code/web/static/assets/mermaid-mWjccvbQ.js +1 -0
  549. pythinker_code/web/static/assets/mermaid.core-CnT4VrPC.js +191 -0
  550. pythinker_code/web/static/assets/min-Dn5VRVmX.js +1 -0
  551. pythinker_code/web/static/assets/min-dark-CafNBF8u.js +1 -0
  552. pythinker_code/web/static/assets/min-light-CTRr51gU.js +1 -0
  553. pythinker_code/web/static/assets/mindmap-definition-VGOIOE7T-x8EwhfIt.js +68 -0
  554. pythinker_code/web/static/assets/mipsasm-CKIfxQSi.js +1 -0
  555. pythinker_code/web/static/assets/mojo-B93PlW-d.js +1 -0
  556. pythinker_code/web/static/assets/monokai-D4h5O-jR.js +1 -0
  557. pythinker_code/web/static/assets/moonbit-Ba13S78F.js +1 -0
  558. pythinker_code/web/static/assets/move-Bu9oaDYs.js +1 -0
  559. pythinker_code/web/static/assets/narrat-DRg8JJMk.js +1 -0
  560. pythinker_code/web/static/assets/nextflow-BrzmwbiE.js +1 -0
  561. pythinker_code/web/static/assets/nginx-DknmC5AR.js +1 -0
  562. pythinker_code/web/static/assets/night-owl-C39BiMTA.js +1 -0
  563. pythinker_code/web/static/assets/nim-CVrawwO9.js +1 -0
  564. pythinker_code/web/static/assets/nix-CwoSXNpI.js +1 -0
  565. pythinker_code/web/static/assets/nord-Ddv68eIx.js +1 -0
  566. pythinker_code/web/static/assets/nushell-C-sUppwS.js +1 -0
  567. pythinker_code/web/static/assets/objective-c-DXmwc3jG.js +1 -0
  568. pythinker_code/web/static/assets/objective-cpp-CLxacb5B.js +1 -0
  569. pythinker_code/web/static/assets/ocaml-C0hk2d4L.js +1 -0
  570. pythinker_code/web/static/assets/one-dark-pro-DVMEJ2y_.js +1 -0
  571. pythinker_code/web/static/assets/one-light-PoHY5YXO.js +1 -0
  572. pythinker_code/web/static/assets/openscad-C4EeE6gA.js +1 -0
  573. pythinker_code/web/static/assets/ordinal-Cboi1Yqb.js +1 -0
  574. pythinker_code/web/static/assets/pascal-D93ZcfNL.js +1 -0
  575. pythinker_code/web/static/assets/perl-C0TMdlhV.js +1 -0
  576. pythinker_code/web/static/assets/php-CDn_0X-4.js +1 -0
  577. pythinker_code/web/static/assets/pieDiagram-ADFJNKIX-DgxBKGz2.js +30 -0
  578. pythinker_code/web/static/assets/pkl-u5AG7uiY.js +1 -0
  579. pythinker_code/web/static/assets/plastic-3e1v2bzS.js +1 -0
  580. pythinker_code/web/static/assets/plsql-ChMvpjG-.js +1 -0
  581. pythinker_code/web/static/assets/po-BTJTHyun.js +1 -0
  582. pythinker_code/web/static/assets/poimandres-CS3Unz2-.js +1 -0
  583. pythinker_code/web/static/assets/polar-C0HS_06l.js +1 -0
  584. pythinker_code/web/static/assets/postcss-CXtECtnM.js +1 -0
  585. pythinker_code/web/static/assets/powerquery-CEu0bR-o.js +1 -0
  586. pythinker_code/web/static/assets/powershell-Dpen1YoG.js +1 -0
  587. pythinker_code/web/static/assets/prisma-Dd19v3D-.js +1 -0
  588. pythinker_code/web/static/assets/prolog-CbFg5uaA.js +1 -0
  589. pythinker_code/web/static/assets/proto-C7zT0LnQ.js +1 -0
  590. pythinker_code/web/static/assets/pug-CGlum2m_.js +1 -0
  591. pythinker_code/web/static/assets/puppet-BMWR74SV.js +1 -0
  592. pythinker_code/web/static/assets/purescript-CklMAg4u.js +1 -0
  593. pythinker_code/web/static/assets/python-B6aJPvgy.js +1 -0
  594. pythinker_code/web/static/assets/qml-3beO22l8.js +1 -0
  595. pythinker_code/web/static/assets/qmldir-C8lEn-DE.js +1 -0
  596. pythinker_code/web/static/assets/qss-IeuSbFQv.js +1 -0
  597. pythinker_code/web/static/assets/quadrantDiagram-AYHSOK5B-DSpe_dqk.js +7 -0
  598. pythinker_code/web/static/assets/r-Dspwwk_N.js +1 -0
  599. pythinker_code/web/static/assets/racket-BqYA7rlc.js +1 -0
  600. pythinker_code/web/static/assets/raku-DXvB9xmW.js +1 -0
  601. pythinker_code/web/static/assets/razor-C1TweQQi.js +1 -0
  602. pythinker_code/web/static/assets/red-bN70gL4F.js +1 -0
  603. pythinker_code/web/static/assets/reg-C-SQnVFl.js +1 -0
  604. pythinker_code/web/static/assets/regexp-CDVJQ6XC.js +1 -0
  605. pythinker_code/web/static/assets/rel-C3B-1QV4.js +1 -0
  606. pythinker_code/web/static/assets/requirementDiagram-UZGBJVZJ-8o9hozL-.js +64 -0
  607. pythinker_code/web/static/assets/riscv-BM1_JUlF.js +1 -0
  608. pythinker_code/web/static/assets/rose-pine-dawn-DHQR4-dF.js +1 -0
  609. pythinker_code/web/static/assets/rose-pine-moon-D4_iv3hh.js +1 -0
  610. pythinker_code/web/static/assets/rose-pine-qdsjHGoJ.js +1 -0
  611. pythinker_code/web/static/assets/rosmsg-BJDFO7_C.js +1 -0
  612. pythinker_code/web/static/assets/rst-B0xPkSld.js +1 -0
  613. pythinker_code/web/static/assets/ruby-BvKwtOVI.js +1 -0
  614. pythinker_code/web/static/assets/rust-B1yitclQ.js +1 -0
  615. pythinker_code/web/static/assets/sankeyDiagram-TZEHDZUN-BLOSUixH.js +10 -0
  616. pythinker_code/web/static/assets/sas-cz2c8ADy.js +1 -0
  617. pythinker_code/web/static/assets/sass-Cj5Yp3dK.js +1 -0
  618. pythinker_code/web/static/assets/scala-C151Ov-r.js +1 -0
  619. pythinker_code/web/static/assets/scheme-C98Dy4si.js +1 -0
  620. pythinker_code/web/static/assets/scss-OYdSNvt2.js +1 -0
  621. pythinker_code/web/static/assets/sdbl-DVxCFoDh.js +1 -0
  622. pythinker_code/web/static/assets/sequenceDiagram-WL72ISMW-6F2G8JTU.js +145 -0
  623. pythinker_code/web/static/assets/shaderlab-Dg9Lc6iA.js +1 -0
  624. pythinker_code/web/static/assets/shellscript-Yzrsuije.js +1 -0
  625. pythinker_code/web/static/assets/shellsession-BADoaaVG.js +1 -0
  626. pythinker_code/web/static/assets/slack-dark-BthQWCQV.js +1 -0
  627. pythinker_code/web/static/assets/slack-ochin-DqwNpetd.js +1 -0
  628. pythinker_code/web/static/assets/smalltalk-BERRCDM3.js +1 -0
  629. pythinker_code/web/static/assets/snazzy-light-Bw305WKR.js +1 -0
  630. pythinker_code/web/static/assets/solarized-dark-DXbdFlpD.js +1 -0
  631. pythinker_code/web/static/assets/solarized-light-L9t79GZl.js +1 -0
  632. pythinker_code/web/static/assets/solidity-rGO070M0.js +1 -0
  633. pythinker_code/web/static/assets/soy-Brmx7dQM.js +1 -0
  634. pythinker_code/web/static/assets/sparql-rVzFXLq3.js +1 -0
  635. pythinker_code/web/static/assets/splunk-BtCnVYZw.js +1 -0
  636. pythinker_code/web/static/assets/sql-BLtJtn59.js +1 -0
  637. pythinker_code/web/static/assets/ssh-config-_ykCGR6B.js +1 -0
  638. pythinker_code/web/static/assets/stata-BH5u7GGu.js +1 -0
  639. pythinker_code/web/static/assets/stateDiagram-FKZM4ZOC-DP8xP0iJ.js +1 -0
  640. pythinker_code/web/static/assets/stateDiagram-v2-4FDKWEC3-1l6-EZNX.js +1 -0
  641. pythinker_code/web/static/assets/stylus-BEDo0Tqx.js +1 -0
  642. pythinker_code/web/static/assets/svelte-zxCyuUbr.js +1 -0
  643. pythinker_code/web/static/assets/swift-Dg5xB15N.js +1 -0
  644. pythinker_code/web/static/assets/synthwave-84-CbfX1IO0.js +1 -0
  645. pythinker_code/web/static/assets/system-verilog-CnnmHF94.js +1 -0
  646. pythinker_code/web/static/assets/systemd-4A_iFExJ.js +1 -0
  647. pythinker_code/web/static/assets/talonscript-CkByrt1z.js +1 -0
  648. pythinker_code/web/static/assets/tasl-QIJgUcNo.js +1 -0
  649. pythinker_code/web/static/assets/tcl-dwOrl1Do.js +1 -0
  650. pythinker_code/web/static/assets/templ-W15q3VgB.js +1 -0
  651. pythinker_code/web/static/assets/terraform-BETggiCN.js +1 -0
  652. pythinker_code/web/static/assets/tex-CvyZ59Mk.js +1 -0
  653. pythinker_code/web/static/assets/timeline-definition-IT6M3QCI-DMgruDfK.js +61 -0
  654. pythinker_code/web/static/assets/tokyo-night-hegEt444.js +1 -0
  655. pythinker_code/web/static/assets/toml-vGWfd6FD.js +1 -0
  656. pythinker_code/web/static/assets/treemap-KMMF4GRG-Bo9ZkrAK.js +128 -0
  657. pythinker_code/web/static/assets/ts-tags-zn1MmPIZ.js +1 -0
  658. pythinker_code/web/static/assets/tsv-B_m7g4N7.js +1 -0
  659. pythinker_code/web/static/assets/tsx-COt5Ahok.js +1 -0
  660. pythinker_code/web/static/assets/turtle-BsS91CYL.js +1 -0
  661. pythinker_code/web/static/assets/twig-CO9l9SDP.js +1 -0
  662. pythinker_code/web/static/assets/typescript-BPQ3VLAy.js +1 -0
  663. pythinker_code/web/static/assets/typespec-BGHnOYBU.js +1 -0
  664. pythinker_code/web/static/assets/typst-DHCkPAjA.js +1 -0
  665. pythinker_code/web/static/assets/v-BcVCzyr7.js +1 -0
  666. pythinker_code/web/static/assets/vala-CsfeWuGM.js +1 -0
  667. pythinker_code/web/static/assets/vb-D17OF-Vu.js +1 -0
  668. pythinker_code/web/static/assets/verilog-BQ8w6xss.js +1 -0
  669. pythinker_code/web/static/assets/vesper-DU1UobuO.js +1 -0
  670. pythinker_code/web/static/assets/vhdl-CeAyd5Ju.js +1 -0
  671. pythinker_code/web/static/assets/viml-CJc9bBzg.js +1 -0
  672. pythinker_code/web/static/assets/vitesse-black-Bkuqu6BP.js +1 -0
  673. pythinker_code/web/static/assets/vitesse-dark-D0r3Knsf.js +1 -0
  674. pythinker_code/web/static/assets/vitesse-light-CVO1_9PV.js +1 -0
  675. pythinker_code/web/static/assets/vue-DN_0RTcg.js +1 -0
  676. pythinker_code/web/static/assets/vue-html-AaS7Mt5G.js +1 -0
  677. pythinker_code/web/static/assets/vue-vine-CQOfvN7w.js +1 -0
  678. pythinker_code/web/static/assets/vyper-CDx5xZoG.js +1 -0
  679. pythinker_code/web/static/assets/wasm-CG6Dc4jp.js +1 -0
  680. pythinker_code/web/static/assets/wasm-MzD3tlZU.js +1 -0
  681. pythinker_code/web/static/assets/wenyan-BV7otONQ.js +1 -0
  682. pythinker_code/web/static/assets/wgsl-Dx-B1_4e.js +1 -0
  683. pythinker_code/web/static/assets/wikitext-BhOHFoWU.js +1 -0
  684. pythinker_code/web/static/assets/wit-5i3qLPDT.js +1 -0
  685. pythinker_code/web/static/assets/wolfram-lXgVvXCa.js +1 -0
  686. pythinker_code/web/static/assets/xml-sdJ4AIDG.js +1 -0
  687. pythinker_code/web/static/assets/xsl-CtQFsRM5.js +1 -0
  688. pythinker_code/web/static/assets/xychartDiagram-PRI3JC2R-GeF2johi.js +7 -0
  689. pythinker_code/web/static/assets/yaml-Buea-lGh.js +1 -0
  690. pythinker_code/web/static/assets/zenscript-DVFEvuxE.js +1 -0
  691. pythinker_code/web/static/assets/zig-VOosw3JB.js +1 -0
  692. pythinker_code/web/static/brand/apple-touch-icon.png +0 -0
  693. pythinker_code/web/static/brand/arctecture.webp +0 -0
  694. pythinker_code/web/static/brand/bimi-logo.svg +46 -0
  695. pythinker_code/web/static/brand/favicon.ico +0 -0
  696. pythinker_code/web/static/brand/fonts/dm-sans-latin-ext.woff2 +0 -0
  697. pythinker_code/web/static/brand/fonts/dm-sans-latin.woff2 +0 -0
  698. pythinker_code/web/static/brand/fonts/instrument-sans-latin-ext.woff2 +0 -0
  699. pythinker_code/web/static/brand/fonts/instrument-sans-latin.woff2 +0 -0
  700. pythinker_code/web/static/brand/fonts/instrument-serif-latin-ext.woff2 +0 -0
  701. pythinker_code/web/static/brand/fonts/instrument-serif-latin.woff2 +0 -0
  702. pythinker_code/web/static/brand/fonts/libre-baskerville-italic-latin-ext.woff2 +0 -0
  703. pythinker_code/web/static/brand/fonts/libre-baskerville-italic-latin.woff2 +0 -0
  704. pythinker_code/web/static/brand/fonts/libre-baskerville-latin-ext.woff2 +0 -0
  705. pythinker_code/web/static/brand/fonts/libre-baskerville-latin.woff2 +0 -0
  706. pythinker_code/web/static/brand/fonts/roboto-latin-ext.woff2 +0 -0
  707. pythinker_code/web/static/brand/fonts/roboto-latin.woff2 +0 -0
  708. pythinker_code/web/static/brand/icon-192.png +0 -0
  709. pythinker_code/web/static/brand/icon-512.png +0 -0
  710. pythinker_code/web/static/brand/icon.svg +1 -0
  711. pythinker_code/web/static/brand/logo.png +0 -0
  712. pythinker_code/web/static/brand/pythinker_animated.svg +79 -0
  713. pythinker_code/web/static/brand/robots.txt +4 -0
  714. pythinker_code/web/static/index.html +15 -0
  715. pythinker_code/web/static/logo.png +0 -0
  716. pythinker_code/web/store/__init__.py +1 -0
  717. pythinker_code/web/store/sessions.py +432 -0
  718. pythinker_code/wire/__init__.py +148 -0
  719. pythinker_code/wire/file.py +151 -0
  720. pythinker_code/wire/jsonrpc.py +263 -0
  721. pythinker_code/wire/protocol.py +2 -0
  722. pythinker_code/wire/root_hub.py +27 -0
  723. pythinker_code/wire/serde.py +26 -0
  724. pythinker_code/wire/server.py +1069 -0
  725. pythinker_code/wire/types.py +698 -0
  726. pythinker_code-2.0.0.dist-info/METADATA +660 -0
  727. pythinker_code-2.0.0.dist-info/RECORD +731 -0
  728. pythinker_code-2.0.0.dist-info/WHEEL +4 -0
  729. pythinker_code-2.0.0.dist-info/entry_points.txt +4 -0
  730. pythinker_code-2.0.0.dist-info/licenses/LICENSE +202 -0
  731. pythinker_code-2.0.0.dist-info/licenses/NOTICE +14 -0
@@ -0,0 +1,1225 @@
1
+ """Sessions API routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import mimetypes
8
+ import os
9
+ import shutil
10
+ import time
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+ from typing import Any, cast
14
+ from urllib.parse import quote
15
+ from uuid import UUID, uuid4
16
+
17
+ from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status
18
+ from fastapi.responses import FileResponse, Response
19
+ from pydantic import BaseModel, Field
20
+ from pythinker_host.path import HostPath
21
+ from starlette.websockets import WebSocket, WebSocketDisconnect
22
+
23
+ from pythinker_code.metadata import load_metadata, save_metadata
24
+ from pythinker_code.session import Session as PythinkerCLISession
25
+ from pythinker_code.utils.logging import logger
26
+ from pythinker_code.utils.subprocess_env import get_clean_env
27
+ from pythinker_code.web.auth import is_origin_allowed, is_private_ip, verify_token
28
+ from pythinker_code.web.models import (
29
+ GenerateTitleRequest,
30
+ GenerateTitleResponse,
31
+ GitDiffStats,
32
+ GitFileDiff,
33
+ Session,
34
+ SessionStatus,
35
+ UpdateSessionRequest,
36
+ )
37
+ from pythinker_code.web.runner.messages import new_session_status_message, send_history_complete
38
+ from pythinker_code.web.runner.process import PythinkerCLIRunner
39
+ from pythinker_code.web.store.sessions import (
40
+ JointSession,
41
+ invalidate_sessions_cache,
42
+ load_session_by_id,
43
+ load_sessions_page,
44
+ run_auto_archive,
45
+ )
46
+ from pythinker_code.wire.jsonrpc import (
47
+ ErrorCodes,
48
+ JSONRPCErrorObject,
49
+ JSONRPCErrorResponse,
50
+ JSONRPCInMessageAdapter,
51
+ JSONRPCPromptMessage,
52
+ )
53
+ from pythinker_code.wire.serde import deserialize_wire_message
54
+ from pythinker_code.wire.types import is_request
55
+
56
+ router = APIRouter(prefix="/api/sessions", tags=["sessions"])
57
+ work_dirs_router = APIRouter(prefix="/api/work-dirs", tags=["work-dirs"])
58
+
59
+ # Constants
60
+ MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100MB
61
+ DEFAULT_MAX_PUBLIC_PATH_DEPTH = 6
62
+ SENSITIVE_PATH_PARTS = {
63
+ "id_rsa",
64
+ "id_ed25519",
65
+ "known_hosts",
66
+ "credentials",
67
+ ".aws",
68
+ ".ssh",
69
+ ".gnupg",
70
+ ".kube",
71
+ ".npmrc",
72
+ ".pypirc",
73
+ ".netrc",
74
+ }
75
+ SENSITIVE_PATH_EXTENSIONS = {
76
+ ".pem",
77
+ ".key",
78
+ ".p12",
79
+ ".pfx",
80
+ ".kdbx",
81
+ ".der",
82
+ }
83
+ # Home directory patterns to detect if resolved path escapes to sensitive locations
84
+ SENSITIVE_HOME_PATHS = {
85
+ ".ssh",
86
+ ".gnupg",
87
+ ".aws",
88
+ ".kube",
89
+ }
90
+
91
+
92
+ def sanitize_filename(filename: str) -> str:
93
+ """Remove potentially dangerous characters from filename."""
94
+ # Keep only alphanumeric, dots, underscores, hyphens, and spaces
95
+ safe = "".join(c for c in filename if c.isalnum() or c in "._- ")
96
+ return safe.strip() or "unnamed"
97
+
98
+
99
+ def get_runner(req: Request) -> PythinkerCLIRunner:
100
+ """Get the PythinkerCLIRunner from the FastAPI app state."""
101
+ return req.app.state.runner
102
+
103
+
104
+ def get_runner_ws(ws: WebSocket) -> PythinkerCLIRunner:
105
+ """Get the PythinkerCLIRunner from the FastAPI app state (for WebSocket routes)."""
106
+ return ws.app.state.runner
107
+
108
+
109
+ def get_editable_session(
110
+ session_id: UUID,
111
+ runner: PythinkerCLIRunner,
112
+ ) -> JointSession:
113
+ """Get a session and verify it's not busy."""
114
+ session = load_session_by_id(session_id)
115
+ if session is None:
116
+ raise HTTPException(
117
+ status_code=status.HTTP_404_NOT_FOUND,
118
+ detail="Session not found",
119
+ )
120
+ # Check if session is busy
121
+ session_process = runner.get_session(session_id)
122
+ if session_process and session_process.is_busy:
123
+ raise HTTPException(
124
+ status_code=status.HTTP_400_BAD_REQUEST,
125
+ detail="Session is busy. Please wait for it to complete before modifying.",
126
+ )
127
+ return session
128
+
129
+
130
+ def _relative_parts(path: Path) -> list[str]:
131
+ return [part for part in path.parts if part not in {"", "."}]
132
+
133
+
134
+ def _is_sensitive_relative_path(rel_path: Path) -> bool:
135
+ parts = _relative_parts(rel_path)
136
+ for part in parts:
137
+ if part.startswith("."):
138
+ return True
139
+ if part.lower() in SENSITIVE_PATH_PARTS:
140
+ return True
141
+ return rel_path.suffix.lower() in SENSITIVE_PATH_EXTENSIONS
142
+
143
+
144
+ def _contains_symlink(path: Path, base: Path) -> bool:
145
+ """Check if any component of the path (relative to base) is a symlink."""
146
+ try:
147
+ current = base
148
+ rel_parts = path.relative_to(base).parts
149
+ for part in rel_parts:
150
+ current = current / part
151
+ if current.is_symlink():
152
+ return True
153
+ except (ValueError, OSError):
154
+ return True
155
+ return False
156
+
157
+
158
+ def _is_path_in_sensitive_location(path: Path) -> bool:
159
+ """Check if resolved path points to a sensitive location (e.g., ~/.ssh, ~/.aws)."""
160
+ try:
161
+ home = Path.home()
162
+ if path.is_relative_to(home):
163
+ rel_to_home = path.relative_to(home)
164
+ first_part = rel_to_home.parts[0] if rel_to_home.parts else ""
165
+ if first_part in SENSITIVE_HOME_PATHS:
166
+ return True
167
+ except (ValueError, RuntimeError):
168
+ pass
169
+ return False
170
+
171
+
172
+ def _ensure_public_file_access_allowed(
173
+ rel_path: Path,
174
+ restrict_sensitive_apis: bool,
175
+ max_path_depth: int = DEFAULT_MAX_PUBLIC_PATH_DEPTH,
176
+ ) -> None:
177
+ if not restrict_sensitive_apis:
178
+ return
179
+ rel_parts = _relative_parts(rel_path)
180
+ if len(rel_parts) > max_path_depth:
181
+ raise HTTPException(
182
+ status_code=status.HTTP_403_FORBIDDEN,
183
+ detail=f"Path too deep for public access "
184
+ f"(max depth: {max_path_depth}, current: {len(rel_parts)}).",
185
+ )
186
+ if _is_sensitive_relative_path(rel_path):
187
+ raise HTTPException(
188
+ status_code=status.HTTP_403_FORBIDDEN,
189
+ detail="Access to sensitive files is disabled.",
190
+ )
191
+
192
+
193
+ def _read_wire_lines(wire_file: Path) -> list[str]:
194
+ """Read and parse wire.jsonl into JSONRPC event strings (runs in thread)."""
195
+ result: list[str] = []
196
+ with open(wire_file, encoding="utf-8") as f:
197
+ for line in f:
198
+ line = line.strip()
199
+ if not line:
200
+ continue
201
+ try:
202
+ record = json.loads(line)
203
+ if not isinstance(record, dict):
204
+ continue
205
+ record = cast(dict[str, Any], record)
206
+ record_type = record.get("type")
207
+ if isinstance(record_type, str) and record_type == "metadata":
208
+ continue
209
+ message_raw = record.get("message")
210
+ if not isinstance(message_raw, dict):
211
+ continue
212
+ message_raw = cast(dict[str, Any], message_raw)
213
+ message = deserialize_wire_message(message_raw)
214
+ _is_req = is_request(message)
215
+ event_msg: dict[str, Any] = {
216
+ "jsonrpc": "2.0",
217
+ "method": "request" if _is_req else "event",
218
+ "params": message_raw,
219
+ }
220
+ if _is_req:
221
+ # JSON-RPC requests require a top-level ``id`` so the
222
+ # client can correlate its response. Use the request's
223
+ # own ``id`` field (e.g. ApprovalRequest.id,
224
+ # QuestionRequest.id). Note: ``message_raw`` wraps data
225
+ # as ``{"type": ..., "payload": {...}}`` so the id lives
226
+ # on the deserialized object, not at the raw dict top level.
227
+ event_msg["id"] = message.id
228
+ result.append(json.dumps(event_msg, ensure_ascii=False))
229
+ except (json.JSONDecodeError, KeyError, ValueError, TypeError):
230
+ continue
231
+ return result
232
+
233
+
234
+ async def replay_history(ws: WebSocket, session_dir: Path) -> None:
235
+ """Replay historical wire messages from wire.jsonl to a WebSocket."""
236
+ wire_file = session_dir / "wire.jsonl"
237
+ if not await asyncio.to_thread(wire_file.exists):
238
+ return
239
+
240
+ try:
241
+ lines = await asyncio.to_thread(_read_wire_lines, wire_file)
242
+ for event_text in lines:
243
+ await ws.send_text(event_text)
244
+ except Exception:
245
+ pass
246
+
247
+
248
+ @router.get("/", summary="List all sessions")
249
+ async def list_sessions(
250
+ runner: PythinkerCLIRunner = Depends(get_runner),
251
+ limit: int = 100,
252
+ offset: int = 0,
253
+ q: str | None = None,
254
+ archived: bool | None = None,
255
+ ) -> list[Session]:
256
+ """List sessions with optional pagination and search.
257
+
258
+ Args:
259
+ limit: Maximum number of sessions to return (default 100, max 500).
260
+ offset: Number of sessions to skip (default 0).
261
+ q: Optional search query to filter by title or work_dir.
262
+ archived: Filter by archived status.
263
+ - None (default): Only return non-archived sessions.
264
+ - True: Only return archived sessions.
265
+ """
266
+ if limit <= 0:
267
+ limit = 100
268
+ if limit > 500:
269
+ limit = 500
270
+ if offset < 0:
271
+ offset = 0
272
+
273
+ # Run auto-archive in background (throttled internally, runs at most once per 5 minutes)
274
+ await asyncio.to_thread(run_auto_archive)
275
+
276
+ sessions = load_sessions_page(limit=limit, offset=offset, query=q, archived=archived)
277
+ for session in sessions:
278
+ session_process = runner.get_session(session.session_id)
279
+ session.is_running = session_process is not None and session_process.is_running
280
+ session.status = session_process.status if session_process else None
281
+ return cast(list[Session], sessions)
282
+
283
+
284
+ @router.get("/{session_id}", summary="Get session")
285
+ async def get_session(
286
+ session_id: UUID,
287
+ runner: PythinkerCLIRunner = Depends(get_runner),
288
+ ) -> Session | None:
289
+ """Get a session by ID."""
290
+ session = load_session_by_id(session_id)
291
+ if session is not None:
292
+ session_process = runner.get_session(session_id)
293
+ session.is_running = session_process is not None and session_process.is_running
294
+ session.status = session_process.status if session_process else None
295
+ return session
296
+
297
+
298
+ @router.post("/", summary="Create a new session")
299
+ async def create_session(request: CreateSessionRequest | None = None) -> Session:
300
+ """Create a new session."""
301
+ # Use provided work_dir or default to user's home directory
302
+ if request and request.work_dir:
303
+ work_dir_path = Path(request.work_dir).expanduser().resolve()
304
+ # Validate the directory exists
305
+ if not work_dir_path.exists():
306
+ if request.create_dir:
307
+ # Auto-create the directory
308
+ try:
309
+ work_dir_path.mkdir(parents=True, exist_ok=True)
310
+ except PermissionError as e:
311
+ raise HTTPException(
312
+ status_code=status.HTTP_403_FORBIDDEN,
313
+ detail=f"Permission denied: cannot create directory {request.work_dir}",
314
+ ) from e
315
+ except OSError as e:
316
+ raise HTTPException(
317
+ status_code=status.HTTP_400_BAD_REQUEST,
318
+ detail=f"Failed to create directory: {e}",
319
+ ) from e
320
+ else:
321
+ # Return 404 to indicate directory does not exist
322
+ raise HTTPException(
323
+ status_code=status.HTTP_404_NOT_FOUND,
324
+ detail=f"Directory does not exist: {request.work_dir}",
325
+ )
326
+ if not work_dir_path.is_dir():
327
+ raise HTTPException(
328
+ status_code=status.HTTP_400_BAD_REQUEST,
329
+ detail=f"Path is not a directory: {request.work_dir}",
330
+ )
331
+ work_dir = HostPath.unsafe_from_local_path(work_dir_path)
332
+ else:
333
+ work_dir = HostPath.unsafe_from_local_path(Path.home())
334
+ pythinker_code_session = await PythinkerCLISession.create(work_dir=work_dir)
335
+ context_file = pythinker_code_session.dir / "context.jsonl"
336
+ invalidate_sessions_cache()
337
+ invalidate_work_dirs_cache()
338
+ return Session(
339
+ session_id=UUID(pythinker_code_session.id),
340
+ title=pythinker_code_session.title,
341
+ last_updated=datetime.fromtimestamp(context_file.stat().st_mtime, tz=UTC),
342
+ is_running=False,
343
+ status=SessionStatus(
344
+ session_id=UUID(pythinker_code_session.id),
345
+ state="stopped",
346
+ seq=0,
347
+ worker_id=None,
348
+ reason=None,
349
+ detail=None,
350
+ updated_at=datetime.now(UTC),
351
+ ),
352
+ work_dir=str(work_dir),
353
+ session_dir=str(pythinker_code_session.dir),
354
+ )
355
+
356
+
357
+ class CreateSessionRequest(BaseModel):
358
+ """Create session request."""
359
+
360
+ work_dir: str | None = None
361
+ create_dir: bool = False # Whether to auto-create directory if it doesn't exist
362
+
363
+
364
+ class ForkSessionRequest(BaseModel):
365
+ """Fork session request."""
366
+
367
+ turn_index: int = Field(..., ge=0) # 0-based, fork includes this turn and all previous turns
368
+
369
+
370
+ class UploadSessionFileResponse(BaseModel):
371
+ """Upload file response."""
372
+
373
+ path: str
374
+ filename: str
375
+ size: int
376
+
377
+
378
+ @router.post("/{session_id}/files", summary="Upload file to session")
379
+ async def upload_session_file(
380
+ session_id: UUID,
381
+ file: UploadFile,
382
+ runner: PythinkerCLIRunner = Depends(get_runner),
383
+ ) -> UploadSessionFileResponse:
384
+ """Upload a file to a session."""
385
+ session = get_editable_session(session_id, runner)
386
+ session_dir = session.pythinker_code_session.dir
387
+ upload_dir = session_dir / "uploads"
388
+ upload_dir.mkdir(parents=True, exist_ok=True)
389
+
390
+ # Read and validate file size
391
+ content = await file.read()
392
+ if len(content) > MAX_UPLOAD_SIZE:
393
+ raise HTTPException(
394
+ status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
395
+ detail=f"File too large (max {MAX_UPLOAD_SIZE // 1024 // 1024}MB)",
396
+ )
397
+
398
+ # Generate safe filename
399
+ file_name = str(uuid4())
400
+ if file.filename:
401
+ safe_name = sanitize_filename(file.filename)
402
+ name, ext = os.path.splitext(safe_name)
403
+ file_name = f"{name}_{file_name[:6]}{ext}"
404
+
405
+ upload_path = upload_dir / file_name
406
+ upload_path.write_bytes(content)
407
+
408
+ return UploadSessionFileResponse(
409
+ path=str(upload_path),
410
+ filename=file_name,
411
+ size=len(content),
412
+ )
413
+
414
+
415
+ @router.get(
416
+ "/{session_id}/uploads/{path:path}",
417
+ summary="Get uploaded file from session uploads",
418
+ )
419
+ async def get_session_upload_file(
420
+ session_id: UUID,
421
+ path: str,
422
+ ) -> Response:
423
+ """Get a file from a session's uploads directory."""
424
+ session = load_session_by_id(session_id)
425
+ if session is None:
426
+ raise HTTPException(
427
+ status_code=status.HTTP_404_NOT_FOUND,
428
+ detail="Session not found",
429
+ )
430
+
431
+ uploads_dir = (session.pythinker_code_session.dir / "uploads").resolve()
432
+ if not uploads_dir.exists():
433
+ raise HTTPException(
434
+ status_code=status.HTTP_404_NOT_FOUND,
435
+ detail="Uploads directory not found",
436
+ )
437
+
438
+ file_path = (uploads_dir / path).resolve()
439
+ if not file_path.is_relative_to(uploads_dir):
440
+ raise HTTPException(
441
+ status_code=status.HTTP_400_BAD_REQUEST,
442
+ detail="Invalid path: path traversal not allowed",
443
+ )
444
+
445
+ if not file_path.exists() or not file_path.is_file():
446
+ raise HTTPException(
447
+ status_code=status.HTTP_404_NOT_FOUND,
448
+ detail="File not found",
449
+ )
450
+
451
+ media_type, _ = mimetypes.guess_type(file_path.name)
452
+ encoded_filename = quote(file_path.name, safe="")
453
+ return FileResponse(
454
+ file_path,
455
+ media_type=media_type or "application/octet-stream",
456
+ headers={
457
+ "Content-Disposition": f"inline; filename*=UTF-8''{encoded_filename}",
458
+ },
459
+ )
460
+
461
+
462
+ @router.get(
463
+ "/{session_id}/files/{path:path}",
464
+ summary="Get file or list directory from session work_dir",
465
+ )
466
+ async def get_session_file(
467
+ session_id: UUID,
468
+ path: str,
469
+ request: Request,
470
+ ) -> Response:
471
+ """Get a file or list directory from session work directory."""
472
+ session = load_session_by_id(session_id)
473
+ if session is None:
474
+ raise HTTPException(
475
+ status_code=status.HTTP_404_NOT_FOUND,
476
+ detail="Session not found",
477
+ )
478
+
479
+ # Security check: prevent path traversal attacks using resolve()
480
+ work_dir = Path(str(session.pythinker_code_session.work_dir)).resolve()
481
+ requested_path = work_dir / path
482
+ file_path = requested_path.resolve()
483
+
484
+ # Check path traversal
485
+ if not file_path.is_relative_to(work_dir):
486
+ raise HTTPException(
487
+ status_code=status.HTTP_400_BAD_REQUEST,
488
+ detail="Invalid path: path traversal not allowed",
489
+ )
490
+
491
+ rel_path = file_path.relative_to(work_dir)
492
+ restrict_sensitive_apis = getattr(request.app.state, "restrict_sensitive_apis", False)
493
+ max_path_depth = (
494
+ getattr(request.app.state, "max_public_path_depth", None) or DEFAULT_MAX_PUBLIC_PATH_DEPTH
495
+ )
496
+
497
+ # Additional security checks when restricting sensitive APIs
498
+ if restrict_sensitive_apis:
499
+ # Check for symlinks in the path
500
+ if _contains_symlink(requested_path, work_dir):
501
+ raise HTTPException(
502
+ status_code=status.HTTP_403_FORBIDDEN,
503
+ detail="Symbolic links are not allowed in public mode.",
504
+ )
505
+
506
+ # Check if resolved path points to sensitive location
507
+ if _is_path_in_sensitive_location(file_path):
508
+ raise HTTPException(
509
+ status_code=status.HTTP_403_FORBIDDEN,
510
+ detail="Access to sensitive system directories is not allowed.",
511
+ )
512
+
513
+ _ensure_public_file_access_allowed(rel_path, restrict_sensitive_apis, max_path_depth)
514
+
515
+ if not file_path.exists():
516
+ raise HTTPException(
517
+ status_code=status.HTTP_404_NOT_FOUND,
518
+ detail="File not found",
519
+ )
520
+
521
+ if file_path.is_dir():
522
+ result: list[dict[str, str | int]] = []
523
+ for subpath in file_path.iterdir():
524
+ if restrict_sensitive_apis:
525
+ rel_subpath = rel_path / subpath.name
526
+ if _is_sensitive_relative_path(rel_subpath):
527
+ continue
528
+ if subpath.is_dir():
529
+ result.append({"name": subpath.name, "type": "directory"})
530
+ else:
531
+ try:
532
+ size = subpath.stat().st_size
533
+ except OSError:
534
+ size = 0
535
+ result.append({"name": subpath.name, "type": "file", "size": size})
536
+ result.sort(key=lambda x: (cast(str, x["type"]), cast(str, x["name"])))
537
+ return Response(content=json.dumps(result), media_type="application/json")
538
+
539
+ content = file_path.read_bytes()
540
+ media_type, _ = mimetypes.guess_type(file_path.name)
541
+ encoded_filename = quote(file_path.name, safe="")
542
+ return Response(
543
+ content=content,
544
+ media_type=media_type or "application/octet-stream",
545
+ headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"},
546
+ )
547
+
548
+
549
+ def _update_last_session_id(session: JointSession) -> None:
550
+ """Update last_session_id for the session's work directory."""
551
+ pythinker_session = session.pythinker_code_session
552
+ work_dir = pythinker_session.work_dir
553
+
554
+ metadata = load_metadata()
555
+ work_dir_meta = metadata.get_work_dir_meta(work_dir)
556
+
557
+ if work_dir_meta is None:
558
+ work_dir_meta = metadata.new_work_dir_meta(work_dir)
559
+
560
+ work_dir_meta.last_session_id = pythinker_session.id
561
+ save_metadata(metadata)
562
+
563
+
564
+ @router.delete("/{session_id}", summary="Delete a session")
565
+ async def delete_session(
566
+ session_id: UUID, runner: PythinkerCLIRunner = Depends(get_runner)
567
+ ) -> None:
568
+ """Delete a session."""
569
+ session = get_editable_session(session_id, runner)
570
+ session_process = runner.get_session(session_id)
571
+ if session_process is not None:
572
+ await session_process.stop()
573
+ wd_meta = session.pythinker_code_session.work_dir_meta
574
+ if wd_meta.last_session_id == str(session_id):
575
+ metadata = load_metadata()
576
+ for wd in metadata.work_dirs:
577
+ if wd.path == wd_meta.path:
578
+ wd.last_session_id = None
579
+ break
580
+ save_metadata(metadata)
581
+ session_dir = session.pythinker_code_session.dir
582
+ if session_dir.exists():
583
+ shutil.rmtree(session_dir)
584
+ invalidate_sessions_cache()
585
+
586
+
587
+ @router.patch("/{session_id}", summary="Update session")
588
+ async def update_session(
589
+ session_id: UUID,
590
+ request: UpdateSessionRequest,
591
+ runner: PythinkerCLIRunner = Depends(get_runner),
592
+ ) -> Session:
593
+ """Update a session (e.g., rename title or archive/unarchive)."""
594
+ from pythinker_code.session_state import load_session_state, save_session_state
595
+
596
+ session = get_editable_session(session_id, runner)
597
+ session_dir = session.pythinker_code_session.dir
598
+ state = load_session_state(session_dir)
599
+
600
+ # Update title if provided
601
+ if request.title is not None:
602
+ state.custom_title = request.title
603
+ state.title_generated = True
604
+
605
+ # Update archived status if provided
606
+ if request.archived is not None:
607
+ state.archived = request.archived
608
+ if request.archived:
609
+ state.archived_at = time.time()
610
+ state.auto_archive_exempt = False
611
+ else:
612
+ state.archived_at = None
613
+ state.auto_archive_exempt = True
614
+
615
+ save_session_state(state, session_dir)
616
+
617
+ # Invalidate cache to force reload
618
+ invalidate_sessions_cache()
619
+
620
+ # Return updated session
621
+ updated_session = load_session_by_id(session_id)
622
+ if updated_session is None:
623
+ raise HTTPException(
624
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
625
+ detail="Failed to reload session after update",
626
+ )
627
+ return updated_session
628
+
629
+
630
+ def extract_first_turn_from_wire(session_dir: Path) -> tuple[str, str] | None:
631
+ """Extract the first turn's user message and assistant response from wire.jsonl.
632
+
633
+ Returns:
634
+ tuple[str, str] | None: (user_message, assistant_response) or None if not found
635
+ """
636
+ wire_file = session_dir / "wire.jsonl"
637
+ if not wire_file.exists():
638
+ return None
639
+
640
+ user_message: str | None = None
641
+ assistant_response_parts: list[str] = []
642
+ in_first_turn = False
643
+
644
+ try:
645
+ with open(wire_file, encoding="utf-8") as f:
646
+ for line in f:
647
+ line = line.strip()
648
+ if not line:
649
+ continue
650
+ try:
651
+ record = json.loads(line)
652
+ message = record.get("message", {})
653
+ msg_type = message.get("type")
654
+
655
+ if msg_type == "TurnBegin":
656
+ if in_first_turn:
657
+ # Second turn started, stop
658
+ break
659
+ in_first_turn = True
660
+ user_input = message.get("payload", {}).get("user_input")
661
+ if user_input:
662
+ from pythinker_core.message import Message
663
+
664
+ msg = Message(role="user", content=user_input)
665
+ user_message = msg.extract_text(" ")
666
+
667
+ elif msg_type == "ContentPart" and in_first_turn:
668
+ payload = message.get("payload", {})
669
+ if payload.get("type") == "text" and payload.get("text"):
670
+ assistant_response_parts.append(payload["text"])
671
+
672
+ elif msg_type == "TurnEnd" and in_first_turn:
673
+ break
674
+
675
+ except json.JSONDecodeError:
676
+ continue
677
+ except OSError:
678
+ return None
679
+
680
+ if user_message and assistant_response_parts:
681
+ return (user_message, "".join(assistant_response_parts))
682
+ return None
683
+
684
+
685
+ @router.post("/{session_id}/fork", summary="Fork a session at a specific turn")
686
+ async def fork_session_endpoint(
687
+ session_id: UUID,
688
+ request: ForkSessionRequest,
689
+ runner: PythinkerCLIRunner = Depends(get_runner),
690
+ ) -> Session:
691
+ """Fork a session, creating a new session with history up to the specified turn.
692
+
693
+ The new session shares the same work_dir as the original session.
694
+ """
695
+ from pythinker_code.session_fork import fork_session as do_fork
696
+
697
+ source_session = get_editable_session(session_id, runner)
698
+ source_dir = source_session.pythinker_code_session.dir
699
+ work_dir = source_session.pythinker_code_session.work_dir
700
+
701
+ source_title = source_session.title
702
+
703
+ try:
704
+ new_session_id = await do_fork(
705
+ source_session_dir=source_dir,
706
+ work_dir=work_dir,
707
+ turn_index=request.turn_index,
708
+ title_prefix="Fork",
709
+ source_title=source_title,
710
+ )
711
+ except ValueError as e:
712
+ raise HTTPException(
713
+ status_code=status.HTTP_400_BAD_REQUEST,
714
+ detail=str(e),
715
+ ) from e
716
+
717
+ invalidate_sessions_cache()
718
+ invalidate_work_dirs_cache()
719
+
720
+ from pythinker_code.metadata import load_metadata
721
+ from pythinker_code.session_state import load_session_state
722
+
723
+ metadata = load_metadata()
724
+ work_dir_meta = metadata.get_work_dir_meta(work_dir)
725
+ assert work_dir_meta is not None
726
+ new_session_dir = work_dir_meta.sessions_dir / new_session_id
727
+ new_state = load_session_state(new_session_dir)
728
+ fork_title = new_state.custom_title or f"Fork: {source_title}"
729
+
730
+ context_file = new_session_dir / "context.jsonl"
731
+ return Session(
732
+ session_id=UUID(new_session_id),
733
+ title=fork_title,
734
+ last_updated=datetime.fromtimestamp(context_file.stat().st_mtime, tz=UTC),
735
+ is_running=False,
736
+ status=SessionStatus(
737
+ session_id=UUID(new_session_id),
738
+ state="stopped",
739
+ seq=0,
740
+ worker_id=None,
741
+ reason=None,
742
+ detail=None,
743
+ updated_at=datetime.now(UTC),
744
+ ),
745
+ work_dir=str(work_dir),
746
+ session_dir=str(new_session_dir),
747
+ )
748
+
749
+
750
+ @router.post("/{session_id}/generate-title", summary="Generate session title using AI")
751
+ async def generate_session_title(
752
+ session_id: UUID,
753
+ request: GenerateTitleRequest | None = None,
754
+ runner: PythinkerCLIRunner = Depends(get_runner),
755
+ ) -> GenerateTitleResponse:
756
+ """Generate a concise session title using AI based on the first conversation turn.
757
+
758
+ If request body is empty or parameters are missing, the backend will
759
+ automatically read the first turn from wire.jsonl.
760
+ """
761
+ session = get_editable_session(session_id, runner)
762
+ session_dir = session.pythinker_code_session.dir
763
+
764
+ from pythinker_code.session_state import load_session_state, save_session_state
765
+
766
+ state = load_session_state(session_dir)
767
+
768
+ # Check if title was already generated (avoid duplicate calls)
769
+ if state.title_generated:
770
+ return GenerateTitleResponse(title=state.custom_title or "Untitled")
771
+
772
+ # Get message content: prefer request parameters, otherwise read from wire.jsonl
773
+ user_message = request.user_message if request else None
774
+ assistant_response = request.assistant_response if request else None
775
+
776
+ if not user_message or not assistant_response:
777
+ first_turn = extract_first_turn_from_wire(session_dir)
778
+ if first_turn:
779
+ user_message, assistant_response = first_turn
780
+
781
+ # If still no user message, return default title
782
+ if not user_message:
783
+ return GenerateTitleResponse(title="Untitled")
784
+
785
+ from pythinker_code.utils.string import shorten
786
+
787
+ user_text = user_message.strip()
788
+ user_text = " ".join(user_text.split())
789
+ fallback_title = shorten(user_text, width=50) or "Untitled"
790
+
791
+ # If AI generation failed too many times, use fallback and mark as generated
792
+ if state.title_generate_attempts >= 3:
793
+ fresh = load_session_state(session_dir)
794
+ # Respect a title finalized by another request/user action while we
795
+ # were preparing a fallback.
796
+ if fresh.title_generated:
797
+ invalidate_sessions_cache()
798
+ return GenerateTitleResponse(title=fresh.custom_title or "Untitled")
799
+ fresh.custom_title = fallback_title
800
+ fresh.title_generated = True
801
+ save_session_state(fresh, session_dir)
802
+ invalidate_sessions_cache()
803
+ return GenerateTitleResponse(title=fallback_title)
804
+
805
+ # Try to generate title using AI
806
+ title = fallback_title
807
+ ai_generated = False
808
+ try:
809
+ from pythinker_core import generate
810
+ from pythinker_core.message import Message
811
+
812
+ from pythinker_code.auth.oauth import OAuthManager
813
+ from pythinker_code.config import load_config
814
+ from pythinker_code.llm import create_llm
815
+
816
+ config = load_config()
817
+ model_name = config.default_model
818
+
819
+ if model_name and model_name in config.models:
820
+ model_config = config.models[model_name]
821
+ provider_config = config.providers.get(model_config.provider)
822
+
823
+ if provider_config:
824
+ oauth = OAuthManager(config)
825
+ await oauth.ensure_fresh()
826
+ llm = create_llm(provider_config, model_config, oauth=oauth)
827
+
828
+ if llm:
829
+ system_prompt = (
830
+ "Generate a concise session title (max 50 characters) "
831
+ "based on the conversation. "
832
+ "Only respond with the title text, nothing else. "
833
+ "No quotes, no explanation."
834
+ )
835
+
836
+ prompt = f"""User: {user_message[:300]}
837
+ Assistant: {(assistant_response or "")[:300]}
838
+
839
+ Title:"""
840
+
841
+ result = await generate(
842
+ chat_provider=llm.chat_provider,
843
+ system_prompt=system_prompt,
844
+ tools=[],
845
+ history=[Message(role="user", content=prompt)],
846
+ )
847
+
848
+ generated_title = result.message.extract_text().strip()
849
+ # Remove quotes if present
850
+ generated_title = generated_title.strip("\"'")
851
+
852
+ if generated_title and len(generated_title) <= 50:
853
+ title = generated_title
854
+ ai_generated = True
855
+ elif generated_title:
856
+ title = shorten(generated_title, width=50)
857
+ ai_generated = True
858
+
859
+ except Exception as e:
860
+ logger.warning(f"Failed to generate title using AI: {e}")
861
+ # Keep fallback_title, ai_generated stays False
862
+
863
+ # Read-modify-write: reload fresh state to avoid overwriting
864
+ # worker changes made during the LLM call
865
+ fresh = load_session_state(session_dir)
866
+ # Another request or manual rename may have finalized the title while the
867
+ # LLM call was in flight. Preserve that newer title instead of clobbering it.
868
+ if fresh.title_generated:
869
+ invalidate_sessions_cache()
870
+ return GenerateTitleResponse(title=fresh.custom_title or "Untitled")
871
+ fresh.custom_title = title
872
+ if ai_generated:
873
+ fresh.title_generated = True
874
+ else:
875
+ fresh.title_generate_attempts = fresh.title_generate_attempts + 1
876
+ save_session_state(fresh, session_dir)
877
+
878
+ # Invalidate cache
879
+ invalidate_sessions_cache()
880
+
881
+ return GenerateTitleResponse(title=title)
882
+
883
+
884
+ @router.websocket("/{session_id}/stream")
885
+ async def session_stream(
886
+ session_id: UUID,
887
+ websocket: WebSocket,
888
+ runner: PythinkerCLIRunner = Depends(get_runner_ws),
889
+ ) -> None:
890
+ """WebSocket stream for a session.
891
+
892
+ Flow:
893
+ 1. Accept the WebSocket connection
894
+ 2. If history exists, attach WebSocket in replay mode
895
+ 3. Replay history messages from wire.jsonl
896
+ 4. Start worker if needed
897
+ 5. Flush buffered live messages and send status snapshot
898
+ 6. Forward incoming messages to the subprocess
899
+ 7. Clean up on disconnect
900
+ """
901
+ expected_token = getattr(websocket.app.state, "session_token", None)
902
+ enforce_origin = getattr(websocket.app.state, "enforce_origin", False)
903
+ allowed_origins = getattr(websocket.app.state, "allowed_origins", [])
904
+ lan_only = getattr(websocket.app.state, "lan_only", False)
905
+
906
+ # LAN-only check
907
+ if lan_only:
908
+ client_ip = websocket.client.host if websocket.client else None
909
+ if client_ip and not is_private_ip(client_ip):
910
+ await websocket.close(code=4403, reason="Access denied: LAN only")
911
+ return
912
+
913
+ if enforce_origin:
914
+ origin = websocket.headers.get("origin")
915
+ if origin and not is_origin_allowed(origin, allowed_origins):
916
+ await websocket.close(code=4403, reason="Origin not allowed")
917
+ return
918
+
919
+ if expected_token:
920
+ token = websocket.query_params.get("token")
921
+ if not verify_token(token, expected_token):
922
+ await websocket.close(code=4401, reason="Auth required")
923
+ return
924
+
925
+ await websocket.accept()
926
+
927
+ # Check if session exists
928
+ session = await asyncio.to_thread(load_session_by_id, session_id)
929
+ if session is None:
930
+ await websocket.close(code=4004, reason="Session not found")
931
+ return
932
+
933
+ # Check if session has history
934
+ session_dir = session.pythinker_code_session.dir
935
+ wire_file = session_dir / "wire.jsonl"
936
+ has_history = await asyncio.to_thread(wire_file.exists)
937
+
938
+ session_process = await runner.get_or_create_session(session_id)
939
+ attached = False
940
+ try:
941
+ if has_history:
942
+ # Attach WebSocket in replay mode before history replay
943
+ await session_process.add_websocket_and_begin_replay(websocket)
944
+ attached = True
945
+
946
+ # Replay history
947
+ try:
948
+ await replay_history(websocket, session_dir)
949
+ except Exception as e:
950
+ logger.warning(f"Failed to replay history: {e}")
951
+
952
+ # Check if WebSocket is still connected before continuing
953
+ if not await send_history_complete(websocket):
954
+ logger.debug("WebSocket disconnected during history replay")
955
+ return
956
+
957
+ # Start session environment – if anything fails here, send an error
958
+ # status so the client doesn't hang on "Connecting to environment...".
959
+ try:
960
+ # Ensure work_dir exists
961
+ work_dir = Path(str(session.pythinker_code_session.work_dir))
962
+ await asyncio.to_thread(lambda: work_dir.mkdir(parents=True, exist_ok=True))
963
+
964
+ if not attached:
965
+ # No history: attach and start worker
966
+ session_process = await runner.get_or_create_session(session_id)
967
+ await session_process.add_websocket_and_begin_replay(websocket)
968
+ attached = True
969
+
970
+ assert session_process is not None
971
+ # End replay and start worker
972
+ await session_process.end_replay(websocket)
973
+ await session_process.start()
974
+ await session_process.send_status_snapshot(websocket)
975
+ except Exception as e:
976
+ logger.warning(f"Failed to start session environment: {e}")
977
+ try:
978
+ error_status = SessionStatus(
979
+ session_id=session_id,
980
+ state="error",
981
+ seq=0,
982
+ worker_id=None,
983
+ reason="initialization_failed",
984
+ detail=str(e),
985
+ updated_at=datetime.now(UTC),
986
+ )
987
+ await websocket.send_text(
988
+ new_session_status_message(error_status).model_dump_json()
989
+ )
990
+ except Exception:
991
+ pass
992
+ return
993
+
994
+ # Track whether we've updated last_session_id for this connection.
995
+ # We defer the update until the first prompt message is actually forwarded,
996
+ # so that merely opening/viewing a session does not change last_session_id.
997
+ last_session_id_updated = False
998
+
999
+ # Forward incoming messages to the subprocess
1000
+ while True:
1001
+ try:
1002
+ message = await websocket.receive_text()
1003
+ # Reject new prompts when session is busy
1004
+ if session_process.is_busy:
1005
+ try:
1006
+ in_message = JSONRPCInMessageAdapter.validate_json(message)
1007
+ except ValueError:
1008
+ in_message = None
1009
+ if isinstance(in_message, JSONRPCPromptMessage):
1010
+ # If the session is in error state, the in-flight IDs
1011
+ # are stale from a failed prompt. Clear them so the
1012
+ # user can recover by sending a new message.
1013
+ if session_process.status.state == "error":
1014
+ logger.info(
1015
+ "Clearing stale in-flight prompts for "
1016
+ f"session {session_id} (was in error state)"
1017
+ )
1018
+ session_process.clear_in_flight()
1019
+ else:
1020
+ await websocket.send_text(
1021
+ JSONRPCErrorResponse(
1022
+ id=in_message.id,
1023
+ error=JSONRPCErrorObject(
1024
+ code=ErrorCodes.INVALID_STATE,
1025
+ message=(
1026
+ "Session is busy; wait for completion before sending "
1027
+ "a new prompt."
1028
+ ),
1029
+ ),
1030
+ ).model_dump_json()
1031
+ )
1032
+ continue
1033
+
1034
+ # Update last_session_id on first successful prompt
1035
+ if not last_session_id_updated:
1036
+ try:
1037
+ in_message = JSONRPCInMessageAdapter.validate_json(message)
1038
+ except ValueError:
1039
+ in_message = None
1040
+ if isinstance(in_message, JSONRPCPromptMessage):
1041
+ await asyncio.to_thread(_update_last_session_id, session)
1042
+ last_session_id_updated = True
1043
+
1044
+ logger.debug(f"sending message to session {session_id}")
1045
+ await session_process.send_message(message)
1046
+ except WebSocketDisconnect:
1047
+ logger.debug("WebSocket disconnected")
1048
+ break
1049
+ except Exception as e:
1050
+ logger.warning(f"WebSocket error: {e.__class__.__name__} {e}")
1051
+ break
1052
+ finally:
1053
+ if attached and session_process:
1054
+ await session_process.remove_websocket(websocket)
1055
+
1056
+
1057
+ # Work dirs cache
1058
+ _work_dirs_cache: list[str] | None = None
1059
+ _work_dirs_cache_time: float = 0.0
1060
+ _WORK_DIRS_CACHE_TTL = 30.0 # seconds
1061
+
1062
+
1063
+ def invalidate_work_dirs_cache() -> None:
1064
+ """Clear the work dirs cache."""
1065
+ global _work_dirs_cache, _work_dirs_cache_time
1066
+ _work_dirs_cache = None
1067
+ _work_dirs_cache_time = 0.0
1068
+
1069
+
1070
+ def _get_work_dirs_sync() -> list[str]:
1071
+ """Synchronous helper for get_work_dirs (runs in thread pool)."""
1072
+ import time
1073
+
1074
+ global _work_dirs_cache, _work_dirs_cache_time
1075
+
1076
+ # Check cache
1077
+ now = time.time()
1078
+ if _work_dirs_cache is not None and (now - _work_dirs_cache_time) < _WORK_DIRS_CACHE_TTL:
1079
+ return _work_dirs_cache
1080
+
1081
+ # Build fresh list
1082
+ metadata = load_metadata()
1083
+ work_dirs: list[str] = []
1084
+ for wd in metadata.work_dirs:
1085
+ # Filter out temporary directories
1086
+ if "/tmp" in wd.path or "/var/folders" in wd.path or "/.cache/" in wd.path:
1087
+ continue
1088
+ # Verify directory exists
1089
+ if Path(wd.path).exists():
1090
+ work_dirs.append(wd.path)
1091
+
1092
+ # Update cache
1093
+ result = work_dirs[:20]
1094
+ _work_dirs_cache = result
1095
+ _work_dirs_cache_time = now
1096
+ return result
1097
+
1098
+
1099
+ @work_dirs_router.get("/", summary="List available work directories")
1100
+ async def get_work_dirs() -> list[str]:
1101
+ """Get a list of available work directories from metadata."""
1102
+ return await asyncio.to_thread(_get_work_dirs_sync)
1103
+
1104
+
1105
+ @work_dirs_router.get("/startup", summary="Get the startup directory")
1106
+ async def get_startup_dir(request: Request) -> str:
1107
+ """Get the directory where pythinker web was started."""
1108
+ return request.app.state.startup_dir
1109
+
1110
+
1111
+ @router.get("/{session_id}/git-diff", summary="Get git diff stats")
1112
+ async def get_session_git_diff(session_id: UUID) -> GitDiffStats:
1113
+ """get git diff stats for the session's work directory"""
1114
+ session = load_session_by_id(session_id)
1115
+ if session is None:
1116
+ raise HTTPException(status_code=404, detail="Session not found")
1117
+
1118
+ work_dir = Path(str(session.pythinker_code_session.work_dir))
1119
+
1120
+ # Check if it is a git repository
1121
+ if not (work_dir / ".git").exists():
1122
+ return GitDiffStats(is_git_repo=False)
1123
+
1124
+ try:
1125
+ files: list[GitFileDiff] = []
1126
+ total_add, total_del = 0, 0
1127
+
1128
+ # Check if HEAD exists (repo has at least one commit)
1129
+ check_proc = await asyncio.create_subprocess_exec(
1130
+ "git",
1131
+ "rev-parse",
1132
+ "--verify",
1133
+ "HEAD",
1134
+ cwd=str(work_dir),
1135
+ stdout=asyncio.subprocess.DEVNULL,
1136
+ stderr=asyncio.subprocess.DEVNULL,
1137
+ env=get_clean_env(),
1138
+ )
1139
+ await check_proc.wait()
1140
+ has_head = check_proc.returncode == 0
1141
+
1142
+ if has_head:
1143
+ # Execute git diff --numstat HEAD (including staged and unstaged)
1144
+ proc = await asyncio.create_subprocess_exec(
1145
+ "git",
1146
+ "diff",
1147
+ "--numstat",
1148
+ "HEAD",
1149
+ cwd=str(work_dir),
1150
+ stdout=asyncio.subprocess.PIPE,
1151
+ stderr=asyncio.subprocess.PIPE,
1152
+ env=get_clean_env(),
1153
+ )
1154
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5.0)
1155
+
1156
+ # Parse output
1157
+ for line in stdout.decode("utf-8", errors="replace").strip().split("\n"):
1158
+ if not line:
1159
+ continue
1160
+ parts = line.split("\t")
1161
+ if len(parts) >= 3:
1162
+ add = int(parts[0]) if parts[0] != "-" else 0
1163
+ dele = int(parts[1]) if parts[1] != "-" else 0
1164
+ total_add += add
1165
+ total_del += dele
1166
+ # Determine file status
1167
+ file_status: str = "modified"
1168
+ if dele == 0 and add > 0:
1169
+ file_status = "added"
1170
+ elif add == 0 and dele > 0:
1171
+ file_status = "deleted"
1172
+ files.append(
1173
+ GitFileDiff(
1174
+ path=parts[2],
1175
+ additions=add,
1176
+ deletions=dele,
1177
+ status=file_status, # type: ignore[arg-type]
1178
+ )
1179
+ )
1180
+
1181
+ # Also get untracked files (new files not yet added to git)
1182
+ untracked_proc = await asyncio.create_subprocess_exec(
1183
+ "git",
1184
+ "ls-files",
1185
+ "--others",
1186
+ "--exclude-standard",
1187
+ cwd=str(work_dir),
1188
+ stdout=asyncio.subprocess.PIPE,
1189
+ stderr=asyncio.subprocess.DEVNULL,
1190
+ env=get_clean_env(),
1191
+ )
1192
+ untracked_stdout, _ = await asyncio.wait_for(untracked_proc.communicate(), timeout=5.0)
1193
+
1194
+ # Add untracked files to the result
1195
+ for line in untracked_stdout.decode("utf-8", errors="replace").strip().split("\n"):
1196
+ if line:
1197
+ files.append(
1198
+ GitFileDiff(
1199
+ path=line,
1200
+ additions=0, # Cannot count lines for untracked files
1201
+ deletions=0,
1202
+ status="added",
1203
+ )
1204
+ )
1205
+
1206
+ if not has_head:
1207
+ return GitDiffStats(
1208
+ is_git_repo=True,
1209
+ has_changes=len(files) > 0,
1210
+ total_additions=0,
1211
+ total_deletions=0,
1212
+ files=files,
1213
+ )
1214
+
1215
+ return GitDiffStats(
1216
+ is_git_repo=True,
1217
+ has_changes=len(files) > 0,
1218
+ total_additions=total_add,
1219
+ total_deletions=total_del,
1220
+ files=files,
1221
+ )
1222
+ except TimeoutError:
1223
+ return GitDiffStats(is_git_repo=True, error="Git command timed out")
1224
+ except Exception as e:
1225
+ return GitDiffStats(is_git_repo=True, error=str(e))