@lemoncat7/dsh-partner 2.5.0 → 2.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (653) hide show
  1. package/README.md +4 -0
  2. package/docs/THIRD_PARTY_LICENSES.md +13 -0
  3. package/docs/attachment-storage.md +18 -0
  4. package/docs/director-skills.md +24 -0
  5. package/docs/long-task-continuation.md +1 -1
  6. package/docs/releases/2.6.0.md +11 -0
  7. package/docs/releases/2.7.1.md +10 -0
  8. package/lib/agent-runtime.d.ts +3 -1
  9. package/lib/agent-runtime.d.ts.map +1 -1
  10. package/lib/agent-runtime.js +35 -11
  11. package/lib/agent-runtime.js.map +1 -1
  12. package/lib/api/features/attachments-api.d.ts.map +1 -1
  13. package/lib/api/features/attachments-api.js +31 -1
  14. package/lib/api/features/attachments-api.js.map +1 -1
  15. package/lib/api.d.ts +1 -1
  16. package/lib/api.d.ts.map +1 -1
  17. package/lib/api.js +1 -1
  18. package/lib/api.js.map +1 -1
  19. package/lib/attachments/grouped-storage.d.ts +5 -0
  20. package/lib/attachments/grouped-storage.d.ts.map +1 -0
  21. package/lib/attachments/grouped-storage.js +47 -0
  22. package/lib/attachments/grouped-storage.js.map +1 -0
  23. package/lib/attachments/service.d.ts +17 -0
  24. package/lib/attachments/service.d.ts.map +1 -1
  25. package/lib/attachments/service.js +100 -8
  26. package/lib/attachments/service.js.map +1 -1
  27. package/lib/attachments/shared-storage.d.ts +7 -0
  28. package/lib/attachments/shared-storage.d.ts.map +1 -0
  29. package/lib/attachments/shared-storage.js +44 -0
  30. package/lib/attachments/shared-storage.js.map +1 -0
  31. package/lib/attachments/storage-admin.d.ts +6 -0
  32. package/lib/attachments/storage-admin.d.ts.map +1 -0
  33. package/lib/attachments/storage-admin.js +43 -0
  34. package/lib/attachments/storage-admin.js.map +1 -0
  35. package/lib/attachments/storage-types.d.ts +33 -0
  36. package/lib/attachments/storage-types.d.ts.map +1 -0
  37. package/lib/attachments/storage-types.js +2 -0
  38. package/lib/attachments/storage-types.js.map +1 -0
  39. package/lib/attachments/tool.d.ts.map +1 -1
  40. package/lib/attachments/tool.js +16 -5
  41. package/lib/attachments/tool.js.map +1 -1
  42. package/lib/channels/delivery-policy.d.ts.map +1 -1
  43. package/lib/channels/delivery-policy.js +6 -4
  44. package/lib/channels/delivery-policy.js.map +1 -1
  45. package/lib/channels/direct/message-format.js +294 -157
  46. package/lib/channels/direct/message-format.js.map +4 -4
  47. package/lib/channels/manager.d.ts +6 -3
  48. package/lib/channels/manager.d.ts.map +1 -1
  49. package/lib/channels/manager.js +64 -44
  50. package/lib/channels/manager.js.map +1 -1
  51. package/lib/channels/weixin/api.d.ts.map +1 -1
  52. package/lib/channels/weixin/api.js +7 -2
  53. package/lib/channels/weixin/api.js.map +1 -1
  54. package/lib/channels/weixin/errors.d.ts +14 -0
  55. package/lib/channels/weixin/errors.d.ts.map +1 -0
  56. package/lib/channels/weixin/errors.js +29 -0
  57. package/lib/channels/weixin/errors.js.map +1 -0
  58. package/lib/channels/weixin/poll.d.ts +14 -0
  59. package/lib/channels/weixin/poll.d.ts.map +1 -0
  60. package/lib/channels/weixin/poll.js +50 -0
  61. package/lib/channels/weixin/poll.js.map +1 -0
  62. package/lib/client-controller.d.ts.map +1 -1
  63. package/lib/client-controller.js +3 -3
  64. package/lib/client-controller.js.map +1 -1
  65. package/lib/client.d.ts.map +1 -1
  66. package/lib/client.js +983 -575
  67. package/lib/client.js.map +4 -4
  68. package/lib/collaboration/composition.d.ts.map +1 -1
  69. package/lib/collaboration/composition.js +16 -6
  70. package/lib/collaboration/composition.js.map +1 -1
  71. package/lib/collaboration/domain.d.ts +6 -0
  72. package/lib/collaboration/domain.d.ts.map +1 -1
  73. package/lib/collaboration/domain.js.map +1 -1
  74. package/lib/collaboration/service.d.ts.map +1 -1
  75. package/lib/collaboration/service.js +33 -3
  76. package/lib/collaboration/service.js.map +1 -1
  77. package/lib/concern-notification.d.ts.map +1 -1
  78. package/lib/concern-notification.js +2 -1
  79. package/lib/concern-notification.js.map +1 -1
  80. package/lib/concern-tool.js +1 -1
  81. package/lib/concern-tool.js.map +1 -1
  82. package/lib/execution/reply-progress.d.ts.map +1 -1
  83. package/lib/execution/reply-progress.js +4 -2
  84. package/lib/execution/reply-progress.js.map +1 -1
  85. package/lib/execution/service.d.ts +1 -1
  86. package/lib/execution/service.d.ts.map +1 -1
  87. package/lib/execution/service.js +4 -7
  88. package/lib/execution/service.js.map +1 -1
  89. package/lib/index.d.ts.map +1 -1
  90. package/lib/index.js +3 -1
  91. package/lib/index.js.map +1 -1
  92. package/lib/memory-reflection.js +2 -2
  93. package/lib/memory-reflection.js.map +1 -1
  94. package/lib/message-source.d.ts +25 -0
  95. package/lib/message-source.d.ts.map +1 -0
  96. package/lib/message-source.js +8 -0
  97. package/lib/message-source.js.map +1 -0
  98. package/lib/observation-loop.js +4 -4
  99. package/lib/observation-loop.js.map +1 -1
  100. package/lib/pendant/reader.js +1 -1
  101. package/lib/pendant/reader.js.map +1 -1
  102. package/lib/pendant/renderer.d.ts.map +1 -1
  103. package/lib/pendant/renderer.js +18 -2
  104. package/lib/pendant/renderer.js.map +1 -1
  105. package/lib/pendant/settings-panel.js +1 -1
  106. package/lib/pendant/settings-panel.js.map +1 -1
  107. package/lib/pendant/startup.d.ts +4 -0
  108. package/lib/pendant/startup.d.ts.map +1 -0
  109. package/lib/pendant/startup.js +64 -0
  110. package/lib/pendant/startup.js.map +1 -0
  111. package/lib/pendant/widget.d.ts.map +1 -1
  112. package/lib/pendant/widget.js +2 -1
  113. package/lib/pendant/widget.js.map +1 -1
  114. package/lib/pendant-renderer.js +169 -169
  115. package/lib/persona/service.js +1 -1
  116. package/lib/persona/service.js.map +1 -1
  117. package/lib/requirements/attachments.d.ts +7 -0
  118. package/lib/requirements/attachments.d.ts.map +1 -0
  119. package/lib/requirements/attachments.js +26 -0
  120. package/lib/requirements/attachments.js.map +1 -0
  121. package/lib/requirements/domain.d.ts +1 -1
  122. package/lib/requirements/domain.d.ts.map +1 -1
  123. package/lib/requirements/service.d.ts.map +1 -1
  124. package/lib/requirements/service.js +4 -1
  125. package/lib/requirements/service.js.map +1 -1
  126. package/lib/scheduler/channel-origin.d.ts +7 -0
  127. package/lib/scheduler/channel-origin.d.ts.map +1 -0
  128. package/lib/scheduler/channel-origin.js +27 -0
  129. package/lib/scheduler/channel-origin.js.map +1 -0
  130. package/lib/scheduler/continuations.d.ts +2 -2
  131. package/lib/scheduler/continuations.d.ts.map +1 -1
  132. package/lib/scheduler/continuations.js +28 -8
  133. package/lib/scheduler/continuations.js.map +1 -1
  134. package/lib/scheduler/domain.d.ts +13 -0
  135. package/lib/scheduler/domain.d.ts.map +1 -1
  136. package/lib/scheduler/final-reply.d.ts +13 -0
  137. package/lib/scheduler/final-reply.d.ts.map +1 -0
  138. package/lib/scheduler/final-reply.js +21 -0
  139. package/lib/scheduler/final-reply.js.map +1 -0
  140. package/lib/skills/builtin-resources.d.ts +6 -0
  141. package/lib/skills/builtin-resources.d.ts.map +1 -0
  142. package/lib/skills/builtin-resources.js +47 -0
  143. package/lib/skills/builtin-resources.js.map +1 -0
  144. package/lib/skills/builtin.d.ts +2 -5
  145. package/lib/skills/builtin.d.ts.map +1 -1
  146. package/lib/skills/builtin.js +6 -0
  147. package/lib/skills/builtin.js.map +1 -1
  148. package/lib/skills/director-builtins.d.ts +8 -0
  149. package/lib/skills/director-builtins.d.ts.map +1 -0
  150. package/lib/skills/director-builtins.js +65 -0
  151. package/lib/skills/director-builtins.js.map +1 -0
  152. package/lib/skills/drama-builtin.d.ts +3 -0
  153. package/lib/skills/drama-builtin.d.ts.map +1 -0
  154. package/lib/skills/drama-builtin.js +49 -0
  155. package/lib/skills/drama-builtin.js.map +1 -0
  156. package/lib/skills/long-task-continuation.d.ts +2 -2
  157. package/lib/skills/long-task-continuation.d.ts.map +1 -1
  158. package/lib/skills/long-task-continuation.js +2 -2
  159. package/lib/skills/service.d.ts.map +1 -1
  160. package/lib/skills/service.js +8 -3
  161. package/lib/skills/service.js.map +1 -1
  162. package/lib/skills/viora-canvas-builtin.d.ts +3 -0
  163. package/lib/skills/viora-canvas-builtin.d.ts.map +1 -0
  164. package/lib/skills/viora-canvas-builtin.js +19 -0
  165. package/lib/skills/viora-canvas-builtin.js.map +1 -0
  166. package/lib/storage/migrate-layout.d.ts.map +1 -1
  167. package/lib/storage/migrate-layout.js +13 -4
  168. package/lib/storage/migrate-layout.js.map +1 -1
  169. package/lib/tasks/context.js +1 -0
  170. package/lib/tasks/context.js.map +1 -1
  171. package/lib/tasks/continuation.d.ts.map +1 -1
  172. package/lib/tasks/continuation.js +3 -0
  173. package/lib/tasks/continuation.js.map +1 -1
  174. package/lib/tasks/domain.d.ts +2 -0
  175. package/lib/tasks/domain.d.ts.map +1 -1
  176. package/lib/tasks/service.d.ts +2 -1
  177. package/lib/tasks/service.d.ts.map +1 -1
  178. package/lib/tasks/service.js +15 -1
  179. package/lib/tasks/service.js.map +1 -1
  180. package/lib/ui/attachment-storage.d.ts +2 -0
  181. package/lib/ui/attachment-storage.d.ts.map +1 -0
  182. package/lib/ui/attachment-storage.js +59 -0
  183. package/lib/ui/attachment-storage.js.map +1 -0
  184. package/lib/ui/capability-editor.js +1 -1
  185. package/lib/ui/capability-editor.js.map +1 -1
  186. package/lib/ui/companion-access-panel.js +1 -1
  187. package/lib/ui/companion-access-panel.js.map +1 -1
  188. package/lib/ui/companion-create.js +1 -1
  189. package/lib/ui/companion-create.js.map +1 -1
  190. package/lib/ui/companion-picker.js +1 -1
  191. package/lib/ui/companion-picker.js.map +1 -1
  192. package/lib/ui/identity-editor.js +1 -1
  193. package/lib/ui/identity-editor.js.map +1 -1
  194. package/lib/ui/mcp-capabilities.js +1 -1
  195. package/lib/ui/mcp-capabilities.js.map +1 -1
  196. package/lib/ui/mcp-panel.js +1 -1
  197. package/lib/ui/mcp-panel.js.map +1 -1
  198. package/lib/ui/memory/concern-board.js +2 -2
  199. package/lib/ui/memory/concern-board.js.map +1 -1
  200. package/lib/ui/memory/concern-deletion.d.ts +1 -1
  201. package/lib/ui/memory/concern-deletion.d.ts.map +1 -1
  202. package/lib/ui/memory/concern-deletion.js +9 -4
  203. package/lib/ui/memory/concern-deletion.js.map +1 -1
  204. package/lib/ui/memory/concern-panel.js +1 -1
  205. package/lib/ui/memory/concern-panel.js.map +1 -1
  206. package/lib/ui/memory/memory-library.js +1 -1
  207. package/lib/ui/memory/memory-library.js.map +1 -1
  208. package/lib/ui/memory/memory-panel.js +1 -1
  209. package/lib/ui/memory/memory-panel.js.map +1 -1
  210. package/lib/ui/partner-components.js +1 -1
  211. package/lib/ui/partner-components.js.map +1 -1
  212. package/lib/ui/requirement-board.js +1 -1
  213. package/lib/ui/requirement-board.js.map +1 -1
  214. package/lib/ui/requirement-dialog.js +1 -1
  215. package/lib/ui/requirement-dialog.js.map +1 -1
  216. package/lib/ui/requirement-tasks-panel.js +1 -1
  217. package/lib/ui/requirement-tasks-panel.js.map +1 -1
  218. package/lib/ui/schedule-panel.js +1 -1
  219. package/lib/ui/schedule-panel.js.map +1 -1
  220. package/lib/ui/skills-panel.js +1 -1
  221. package/lib/ui/skills-panel.js.map +1 -1
  222. package/lib/ui/storage-settings.d.ts.map +1 -1
  223. package/lib/ui/storage-settings.js +2 -1
  224. package/lib/ui/storage-settings.js.map +1 -1
  225. package/lib/ui/task-board-stage.js +1 -1
  226. package/lib/ui/task-board-stage.js.map +1 -1
  227. package/lib/ui/workspace-components.js +1 -1
  228. package/lib/ui/workspace-components.js.map +1 -1
  229. package/lib/ui/workspace-navigation.d.ts +16 -16
  230. package/lib/ui/workspace-navigation.js +1 -1
  231. package/lib/ui/workspace-navigation.js.map +1 -1
  232. package/package.json +53 -41
  233. package/resources/skills/cinematic-director/manifest.json +205 -0
  234. package/resources/skills/cinematic-director/upstream/CHANGELOG.md +213 -0
  235. package/resources/skills/cinematic-director/upstream/CONTRIBUTING.md +316 -0
  236. package/resources/skills/cinematic-director/upstream/LICENSE +21 -0
  237. package/resources/skills/cinematic-director/upstream/README.md +507 -0
  238. package/resources/skills/cinematic-director/upstream/SKILL.md +420 -0
  239. package/resources/skills/cinematic-director/upstream/assets/beat-sheet-template.md +97 -0
  240. package/resources/skills/cinematic-director/upstream/assets/director-book-template.md +254 -0
  241. package/resources/skills/cinematic-director/upstream/assets/edit-timeline-template.md +147 -0
  242. package/resources/skills/cinematic-director/upstream/assets/keyframe-prompt-template.md +334 -0
  243. package/resources/skills/cinematic-director/upstream/assets/qc-checklist.md +153 -0
  244. package/resources/skills/cinematic-director/upstream/assets/shot-plan-template.md +95 -0
  245. package/resources/skills/cinematic-director/upstream/assets/sound-plan-template.md +181 -0
  246. package/resources/skills/cinematic-director/upstream/assets/video-prompt-template.md +305 -0
  247. package/resources/skills/cinematic-director/upstream/evals/run.md +69 -0
  248. package/resources/skills/cinematic-director/upstream/references/ai-video-tool-adapters.md +527 -0
  249. package/resources/skills/cinematic-director/upstream/references/blocking-and-staging.md +424 -0
  250. package/resources/skills/cinematic-director/upstream/references/cinematic-language.md +595 -0
  251. package/resources/skills/cinematic-director/upstream/references/continuity-bible.md +410 -0
  252. package/resources/skills/cinematic-director/upstream/references/director_styles/01_spielberg.md +192 -0
  253. package/resources/skills/cinematic-director/upstream/references/director_styles/02_hitchcock.md +193 -0
  254. package/resources/skills/cinematic-director/upstream/references/director_styles/03_kubrick.md +196 -0
  255. package/resources/skills/cinematic-director/upstream/references/director_styles/04_kurosawa.md +196 -0
  256. package/resources/skills/cinematic-director/upstream/references/director_styles/05_scorsese.md +195 -0
  257. package/resources/skills/cinematic-director/upstream/references/director_styles/06_fellini.md +197 -0
  258. package/resources/skills/cinematic-director/upstream/references/director_styles/07_bergman.md +197 -0
  259. package/resources/skills/cinematic-director/upstream/references/director_styles/08_tarkovsky.md +184 -0
  260. package/resources/skills/cinematic-director/upstream/references/director_styles/09_wong_kar_wai.md +189 -0
  261. package/resources/skills/cinematic-director/upstream/references/director_styles/10_nolan.md +185 -0
  262. package/resources/skills/cinematic-director/upstream/references/director_styles/11_villeneuve.md +188 -0
  263. package/resources/skills/cinematic-director/upstream/references/director_styles/12_fincher.md +185 -0
  264. package/resources/skills/cinematic-director/upstream/references/director_styles/13_refn.md +186 -0
  265. package/resources/skills/cinematic-director/upstream/references/director_styles/14_bi_gan.md +188 -0
  266. package/resources/skills/cinematic-director/upstream/references/director_styles/15_zhang_yimou.md +192 -0
  267. package/resources/skills/cinematic-director/upstream/references/director_styles/16_hou_hsiao_hsien.md +192 -0
  268. package/resources/skills/cinematic-director/upstream/references/director_styles/17_park_chan_wook.md +192 -0
  269. package/resources/skills/cinematic-director/upstream/references/director_styles/18_malick.md +191 -0
  270. package/resources/skills/cinematic-director/upstream/references/director_styles/19_michael_mann.md +192 -0
  271. package/resources/skills/cinematic-director/upstream/references/director_styles/20_coen_brothers.md +194 -0
  272. package/resources/skills/cinematic-director/upstream/references/director_styles/README.md +89 -0
  273. package/resources/skills/cinematic-director/upstream/references/director_styles/example_comparisons.md +140 -0
  274. package/resources/skills/cinematic-director/upstream/references/editing-and-assembly.md +263 -0
  275. package/resources/skills/cinematic-director/upstream/references/failure-modes.md +594 -0
  276. package/resources/skills/cinematic-director/upstream/references/genre-playbooks.md +407 -0
  277. package/resources/skills/cinematic-director/upstream/references/image-model-adapters.md +290 -0
  278. package/resources/skills/cinematic-director/upstream/references/lighting-and-color.md +349 -0
  279. package/resources/skills/cinematic-director/upstream/references/product-and-macro.md +207 -0
  280. package/resources/skills/cinematic-director/upstream/references/production-workflow.md +238 -0
  281. package/resources/skills/cinematic-director/upstream/references/prompt-lexicon.md +602 -0
  282. package/resources/skills/cinematic-director/upstream/references/sound-and-dialogue.md +364 -0
  283. package/resources/skills/director-skills/manifest.json +541 -0
  284. package/resources/skills/director-skills/upstream/CHANGELOG.md +32 -0
  285. package/resources/skills/director-skills/upstream/LICENSE +21 -0
  286. package/resources/skills/director-skills/upstream/MANIFEST.md +366 -0
  287. package/resources/skills/director-skills/upstream/README.md +251 -0
  288. package/resources/skills/director-skills/upstream/RESEARCH_PROVENANCE.md +37 -0
  289. package/resources/skills/director-skills/upstream/SKILL.md +114 -0
  290. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/SKILL.md +81 -0
  291. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/checklists/continuity_checklist.md +8 -0
  292. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/examples/character_continuity_update.md +9 -0
  293. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/examples/location_continuity_update.md +9 -0
  294. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/references/continuity_system.md +21 -0
  295. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/references/golden_image_workflow.md +29 -0
  296. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/references/identity_anchors.md +14 -0
  297. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/references/visual_bible_system.md +15 -0
  298. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/character_profile.md +15 -0
  299. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/continuity_checklist.md +11 -0
  300. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/costume_profile.md +12 -0
  301. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/location_profile.md +14 -0
  302. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/prop_profile.md +13 -0
  303. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/visual_style_bible.md +15 -0
  304. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/tests/continuity_tests.md +6 -0
  305. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/SKILL.md +80 -0
  306. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/checklists/image_prompt_quality_checklist.md +10 -0
  307. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/examples/shot_list_to_image_prompts.md +4 -0
  308. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/references/image_prompt_components.md +22 -0
  309. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/references/lighting_and_photorealism.md +19 -0
  310. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/references/negative_prompt_patterns.md +18 -0
  311. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/references/prompt_anatomy.md +18 -0
  312. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/character_reference_sheet_prompt.md +5 -0
  313. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/cinematic_still_prompt.md +3 -0
  314. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/keyframe_prompt.md +6 -0
  315. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/location_reference_prompt.md +3 -0
  316. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/prop_prompt.md +3 -0
  317. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/tests/image_prompt_completeness_tests.md +5 -0
  318. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/SKILL.md +84 -0
  319. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/checklists/video_prompt_quality_checklist.md +9 -0
  320. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/examples/image_prompt_to_video_prompt.md +3 -0
  321. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/ai_film_production_pipeline.md +21 -0
  322. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/audio_motion_timing.md +16 -0
  323. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/camera_lens_movement.md +26 -0
  324. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/negative_prompt_patterns.md +18 -0
  325. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/prompt_anatomy.md +18 -0
  326. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/video_prompt_components.md +25 -0
  327. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/templates/image_to_video_prompt.md +5 -0
  328. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/templates/text_to_video_prompt.md +5 -0
  329. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/templates/video_beat_prompt.md +9 -0
  330. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/tests/video_prompt_completeness_tests.md +5 -0
  331. package/resources/skills/director-skills/upstream/skills/model-adaptation/SKILL.md +82 -0
  332. package/resources/skills/director-skills/upstream/skills/model-adaptation/checklists/model_export_checklist.md +9 -0
  333. package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/failed_model_transfer_diagnosis.md +5 -0
  334. package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/image_prompt_translation.md +10 -0
  335. package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/same_scene_across_models.md +9 -0
  336. package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/universal_prompt_to_model_specific_exports.md +14 -0
  337. package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/video_prompt_translation.md +9 -0
  338. package/resources/skills/director-skills/upstream/skills/model-adaptation/references/failure_modes_by_model.md +13 -0
  339. package/resources/skills/director-skills/upstream/skills/model-adaptation/references/image_model_profiles.md +226 -0
  340. package/resources/skills/director-skills/upstream/skills/model-adaptation/references/model_capability_matrix.md +24 -0
  341. package/resources/skills/director-skills/upstream/skills/model-adaptation/references/model_update_protocol.md +10 -0
  342. package/resources/skills/director-skills/upstream/skills/model-adaptation/references/parameter_compatibility.md +20 -0
  343. package/resources/skills/director-skills/upstream/skills/model-adaptation/references/prompt_translation_rules.md +18 -0
  344. package/resources/skills/director-skills/upstream/skills/model-adaptation/references/video_model_profiles.md +251 -0
  345. package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/model_comparison_table.md +4 -0
  346. package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/model_specific_prompt_export.md +21 -0
  347. package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/multi_model_export_package.md +18 -0
  348. package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/prompt_translation_report.md +13 -0
  349. package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/universal_prompt_brief.md +25 -0
  350. package/resources/skills/director-skills/upstream/skills/model-adaptation/tests/model_routing_tests.md +8 -0
  351. package/resources/skills/director-skills/upstream/skills/model-adaptation/tests/prompt_translation_tests.md +7 -0
  352. package/resources/skills/director-skills/upstream/skills/model-adaptation/tests/research_provenance_tests.md +6 -0
  353. package/resources/skills/director-skills/upstream/skills/model-adaptation/tests/unknown_capability_tests.md +7 -0
  354. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/SKILL.md +82 -0
  355. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/checklists/diagnostic_checklist.md +8 -0
  356. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/checklists/release_quality_checklist.md +9 -0
  357. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/examples/failed_output_to_revised_prompt.md +10 -0
  358. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/examples/final_prompt_package_export.md +10 -0
  359. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/references/failure_modes.md +13 -0
  360. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/references/iteration_diagnostics.md +17 -0
  361. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/references/revision_loops.md +17 -0
  362. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/references/story_voice_release_qc.md +30 -0
  363. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/templates/failed_output_intake.md +13 -0
  364. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/templates/final_production_package.md +11 -0
  365. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/templates/prompt_revision_report.md +17 -0
  366. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/tests/prompt_quality_checklist_tests.md +6 -0
  367. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/SKILL.md +81 -0
  368. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/checklists/scene_quality_checklist.md +8 -0
  369. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/examples/scene_to_screenplay_excerpt.md +18 -0
  370. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/references/dialogue_and_subtext.md +15 -0
  371. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/references/fountain_workflow.md +29 -0
  372. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/references/scene_construction.md +20 -0
  373. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/references/screenplay_format.md +18 -0
  374. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/templates/beat_sheet.md +6 -0
  375. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/templates/dialogue_pass.md +9 -0
  376. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/templates/fountain_scene.md +25 -0
  377. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/templates/screenplay_scene.md +19 -0
  378. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/tests/screenplay_scene_tests.md +6 -0
  379. package/resources/skills/director-skills/upstream/skills/short-film-development/SKILL.md +83 -0
  380. package/resources/skills/director-skills/upstream/skills/short-film-development/checklists/concept_quality_checklist.md +8 -0
  381. package/resources/skills/director-skills/upstream/skills/short-film-development/examples/idea_to_logline.md +13 -0
  382. package/resources/skills/director-skills/upstream/skills/short-film-development/examples/logline_to_outline.md +10 -0
  383. package/resources/skills/director-skills/upstream/skills/short-film-development/references/ai_storytelling_workflow.md +46 -0
  384. package/resources/skills/director-skills/upstream/skills/short-film-development/references/continuity_handoff.md +8 -0
  385. package/resources/skills/director-skills/upstream/skills/short-film-development/references/screenplay_structure.md +19 -0
  386. package/resources/skills/director-skills/upstream/skills/short-film-development/references/story_development_workflow.md +23 -0
  387. package/resources/skills/director-skills/upstream/skills/short-film-development/templates/beat_sheet.md +7 -0
  388. package/resources/skills/director-skills/upstream/skills/short-film-development/templates/idea_intake.md +16 -0
  389. package/resources/skills/director-skills/upstream/skills/short-film-development/templates/logline.md +10 -0
  390. package/resources/skills/director-skills/upstream/skills/short-film-development/templates/short_film_treatment.md +23 -0
  391. package/resources/skills/director-skills/upstream/skills/short-film-development/templates/short_story_plan.md +22 -0
  392. package/resources/skills/director-skills/upstream/skills/short-film-development/templates/story_premise.md +19 -0
  393. package/resources/skills/director-skills/upstream/skills/short-film-development/templates/story_revision_audit.md +22 -0
  394. package/resources/skills/director-skills/upstream/skills/short-film-development/tests/output_format_tests.md +3 -0
  395. package/resources/skills/director-skills/upstream/skills/short-film-development/tests/routing_tests.md +7 -0
  396. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/SKILL.md +77 -0
  397. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/checklists/shotlist_quality_checklist.md +8 -0
  398. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/examples/screenplay_scene_to_shot_list.md +8 -0
  399. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/references/camera_plan.md +11 -0
  400. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/references/production_breakdown.md +11 -0
  401. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/references/shotlist_schema.md +21 -0
  402. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/templates/asset_list.md +7 -0
  403. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/templates/camera_plan.md +10 -0
  404. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/templates/scene_breakdown.md +16 -0
  405. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/templates/shot_list.md +5 -0
  406. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/tests/screenplay_to_shotlist_tests.md +5 -0
  407. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/SKILL.md +77 -0
  408. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/checklists/style_quality_checklist.md +8 -0
  409. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/examples/genre_to_visual_direction.md +15 -0
  410. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/references/camera_lens_movement.md +26 -0
  411. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/references/cinematic_language.md +32 -0
  412. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/references/genre_style_tone.md +17 -0
  413. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/references/lighting_and_photorealism.md +19 -0
  414. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/templates/visual_style_bible.md +33 -0
  415. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/tests/style_translation_tests.md +5 -0
  416. package/resources/skills/director-skills/upstream/tests/master_router_tests.md +80 -0
  417. package/resources/skills/drama-production/manifest.json +933 -0
  418. package/resources/skills/drama-production/upstream/LICENSE +21 -0
  419. package/resources/skills/drama-production/upstream/skills/short-drama/SKILL.md +153 -0
  420. package/resources/skills/drama-production/upstream/skills/short-drama/agents/openai.yaml +4 -0
  421. package/resources/skills/drama-production/upstream/skills/short-drama/assets/creator-decision.example.jsonl +4 -0
  422. package/resources/skills/drama-production/upstream/skills/short-drama/assets/dashboard/app.js +1263 -0
  423. package/resources/skills/drama-production/upstream/skills/short-drama/assets/dashboard/index.html +114 -0
  424. package/resources/skills/drama-production/upstream/skills/short-drama/assets/dashboard/styles.css +297 -0
  425. package/resources/skills/drama-production/upstream/skills/short-drama/assets/production-observation.example.jsonl +2 -0
  426. package/resources/skills/drama-production/upstream/skills/short-drama/assets/project-template/short-drama.json +36 -0
  427. package/resources/skills/drama-production/upstream/skills/short-drama/assets/reference-observation.example.jsonl +2 -0
  428. package/resources/skills/drama-production/upstream/skills/short-drama/references/audience-reveal.md +21 -0
  429. package/resources/skills/drama-production/upstream/skills/short-drama/references/contract-and-ownership.md +45 -0
  430. package/resources/skills/drama-production/upstream/skills/short-drama/references/creator-documents.md +230 -0
  431. package/resources/skills/drama-production/upstream/skills/short-drama/references/creator-workflow.md +98 -0
  432. package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards/Q/347/211/210/350/241/250/350/276/276.md +73 -0
  433. package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards//344/272/214/347/273/264/345/212/250/346/200/201/346/274/253.md +74 -0
  434. package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards//345/233/275/346/274/253/344/272/214/346/254/241/345/205/203.md +79 -0
  435. package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards//345/256/236/346/213/215.md +75 -0
  436. package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards//346/260/264/345/242/250/347/254/224/350/247/246.md +85 -0
  437. package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards//351/243/216/346/240/274/345/214/226/344/270/211/347/273/264.md +72 -0
  438. package/resources/skills/drama-production/upstream/skills/short-drama/references/knowhow-index.md +85 -0
  439. package/resources/skills/drama-production/upstream/skills/short-drama/references/look-development.md +51 -0
  440. package/resources/skills/drama-production/upstream/skills/short-drama/references/pickup-and-alternate.md +24 -0
  441. package/resources/skills/drama-production/upstream/skills/short-drama/references/production-form-profiles.md +126 -0
  442. package/resources/skills/drama-production/upstream/skills/short-drama/references/reference-roles.md +53 -0
  443. package/resources/skills/drama-production/upstream/skills/short-drama/references/runtime-preflight.md +24 -0
  444. package/resources/skills/drama-production/upstream/skills/short-drama/scripts/creator_markdown_check.py +1413 -0
  445. package/resources/skills/drama-production/upstream/skills/short-drama/scripts/dashboard_server.py +1798 -0
  446. package/resources/skills/drama-production/upstream/skills/short-drama/scripts/project_tool.py +2454 -0
  447. package/resources/skills/drama-production/upstream/skills/short-drama/scripts/selftest.py +287 -0
  448. package/resources/skills/drama-production/upstream/skills/short-drama-assets/SKILL.md +76 -0
  449. package/resources/skills/drama-production/upstream/skills/short-drama-assets/agents/openai.yaml +4 -0
  450. package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/character-look.example.jsonl +3 -0
  451. package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/continuity.example.jsonl +4 -0
  452. package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/decisions.example.jsonl +5 -0
  453. package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/location-view.example.jsonl +3 -0
  454. package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/occurrences.example.jsonl +5 -0
  455. package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/prop-state.example.jsonl +3 -0
  456. package/resources/skills/drama-production/upstream/skills/short-drama-assets/examples/minimal/characters.jsonl +2 -0
  457. package/resources/skills/drama-production/upstream/skills/short-drama-assets/examples/minimal/looks.jsonl +2 -0
  458. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/asset-review-checklist.md +82 -0
  459. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/character-and-look.md +190 -0
  460. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/continuity-delta.md +120 -0
  461. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/continuity-lock.md +122 -0
  462. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/identity-vs-variant.md +140 -0
  463. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/location-and-view.md +84 -0
  464. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/occurrence-extraction.md +108 -0
  465. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/prop-and-state.md +126 -0
  466. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/stage-contract.md +43 -0
  467. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/voice-direction.md +179 -0
  468. package/resources/skills/drama-production/upstream/skills/short-drama-assets/scripts/asset_check.py +267 -0
  469. package/resources/skills/drama-production/upstream/skills/short-drama-assets/scripts/selftest.py +89 -0
  470. package/resources/skills/drama-production/upstream/skills/short-drama-develop/SKILL.md +157 -0
  471. package/resources/skills/drama-production/upstream/skills/short-drama-develop/agents/openai.yaml +4 -0
  472. package/resources/skills/drama-production/upstream/skills/short-drama-develop/assets/adaptation-map.example.jsonl +1 -0
  473. package/resources/skills/drama-production/upstream/skills/short-drama-develop/assets/creative-brief.md +120 -0
  474. package/resources/skills/drama-production/upstream/skills/short-drama-develop/assets/episode-map.jsonl +1 -0
  475. package/resources/skills/drama-production/upstream/skills/short-drama-develop/assets/story-engine.md +181 -0
  476. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/adaptation-craft.md +219 -0
  477. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/creative-reference-intake.md +57 -0
  478. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/director-brief-craft.md +113 -0
  479. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/episode-design.md +509 -0
  480. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-and-hook-playbook.md +240 -0
  481. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//344/272/262/345/255/220/351/232/220/347/247/230.md +112 -0
  482. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//344/273/231/344/276/240/344/277/256/347/234/237.md +51 -0
  483. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//345/212/250/344/275/234/344/273/273/345/212/241.md +51 -0
  484. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//345/217/244/350/243/205/346/235/203/350/260/213.md +51 -0
  485. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//345/244/215/344/273/207/346/211/223/350/204/270.md +51 -0
  486. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//345/256/266/345/272/255/345/205/263/347/263/273.md +51 -0
  487. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//346/202/254/347/226/221/350/247/204/345/210/231.md +51 -0
  488. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//347/224/237/346/264/273/346/265/201.md +51 -0
  489. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//347/240/264/351/225/234/351/207/215/345/234/206.md +109 -0
  490. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//350/201/214/345/234/272/345/226/234/345/211/247.md +51 -0
  491. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//350/261/252/351/227/250/345/251/232/346/201/213.md +51 -0
  492. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//350/272/253/344/273/275/351/224/231/344/275/215.md +51 -0
  493. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards.md +92 -0
  494. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/mechanism-loop.md +282 -0
  495. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/multi-episode-intake.md +128 -0
  496. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/premise-devices.md +211 -0
  497. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/reveal-reversal-payoff.md +99 -0
  498. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/serial-character-and-memory.md +293 -0
  499. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/stage-contract.md +88 -0
  500. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/story-craft.md +359 -0
  501. package/resources/skills/drama-production/upstream/skills/short-drama-develop/scripts/episode_intake.py +699 -0
  502. package/resources/skills/drama-production/upstream/skills/short-drama-develop/scripts/selftest.py +47 -0
  503. package/resources/skills/drama-production/upstream/skills/short-drama-edit/SKILL.md +146 -0
  504. package/resources/skills/drama-production/upstream/skills/short-drama-edit/agents/openai.yaml +4 -0
  505. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/README.md +60 -0
  506. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/package.json +15 -0
  507. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/remotion.config.ts +6 -0
  508. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/Root.tsx +26 -0
  509. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/Subtitles.tsx +88 -0
  510. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/font.ts +46 -0
  511. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/index.ts +4 -0
  512. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/schema.ts +32 -0
  513. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/tsconfig.json +13 -0
  514. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets//345/211/252/350/276/221/345/215/225.md +43 -0
  515. package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/cut-craft.md +38 -0
  516. package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/delivery-verify.md +29 -0
  517. package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/generated-footage.md +54 -0
  518. package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/sound-and-subtitles.md +60 -0
  519. package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/stage-contract.md +51 -0
  520. package/resources/skills/drama-production/upstream/skills/short-drama-edit/scripts/edit_tool.py +1156 -0
  521. package/resources/skills/drama-production/upstream/skills/short-drama-edit/scripts/selftest.py +520 -0
  522. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/SKILL.md +73 -0
  523. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/agents/openai.yaml +4 -0
  524. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/assets/image-prompt-spec.jsonl.md +187 -0
  525. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/assets/image-prompts.md +42 -0
  526. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/assets/lookdev-frame-spec.jsonl.md +128 -0
  527. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/assets/lookdev-prompts.md +34 -0
  528. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/examples/minimal-image-prompt-specs.jsonl +2 -0
  529. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/character-and-look.md +153 -0
  530. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/common-recipe.md +241 -0
  531. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/edit-and-revision.md +110 -0
  532. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/location-plate.md +151 -0
  533. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/look-and-state-variant.md +97 -0
  534. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/lookdev-frame.md +34 -0
  535. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/production-sheet-recipes.md +177 -0
  536. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/prop-plate.md +137 -0
  537. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/review-and-fixtures.md +92 -0
  538. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/stage-contract.md +32 -0
  539. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/scripts/image_prompt_check.py +443 -0
  540. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/scripts/selftest.py +95 -0
  541. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/SKILL.md +245 -0
  542. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/agents/openai.yaml +4 -0
  543. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/assets/episode-candidate.example.jsonl +3 -0
  544. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/adaptation-triage.md +116 -0
  545. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/adaptation-value.md +124 -0
  546. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/aggregation-and-entities.md +155 -0
  547. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/chapter-extraction.md +144 -0
  548. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/stage-contract.md +67 -0
  549. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/scripts/novel_index.py +651 -0
  550. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/scripts/selftest.py +51 -0
  551. package/resources/skills/drama-production/upstream/skills/short-drama-produce/SKILL.md +183 -0
  552. package/resources/skills/drama-production/upstream/skills/short-drama-produce/agents/openai.yaml +4 -0
  553. package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/adapter-contract.md +219 -0
  554. package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/gpt-image-2.md +24 -0
  555. package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/minimax-h3-video.md +86 -0
  556. package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/minimax-music.md +25 -0
  557. package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/minimax-speech.md +41 -0
  558. package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/seedance.md +64 -0
  559. package/resources/skills/drama-production/upstream/skills/short-drama-produce/scripts/fixture_adapter.py +77 -0
  560. package/resources/skills/drama-production/upstream/skills/short-drama-produce/scripts/production_tool.py +2205 -0
  561. package/resources/skills/drama-production/upstream/skills/short-drama-produce/scripts/provider_adapters.py +1747 -0
  562. package/resources/skills/drama-production/upstream/skills/short-drama-produce/scripts/selftest.py +135 -0
  563. package/resources/skills/drama-production/upstream/skills/short-drama-review/SKILL.md +118 -0
  564. package/resources/skills/drama-production/upstream/skills/short-drama-review/agents/openai.yaml +4 -0
  565. package/resources/skills/drama-production/upstream/skills/short-drama-review/assets/finding-template.jsonl +2 -0
  566. package/resources/skills/drama-production/upstream/skills/short-drama-review/assets/supersession-decision.example.json +42 -0
  567. package/resources/skills/drama-production/upstream/skills/short-drama-review/assets/verdict-template.json +32 -0
  568. package/resources/skills/drama-production/upstream/skills/short-drama-review/examples/minimal-findings.jsonl +2 -0
  569. package/resources/skills/drama-production/upstream/skills/short-drama-review/examples/minimal-verdict.json +34 -0
  570. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/anti-template-repair.md +109 -0
  571. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/production-quality-gates.md +276 -0
  572. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/project-calibration.md +83 -0
  573. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/review-method.md +81 -0
  574. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/rubric-assets-prompts.md +111 -0
  575. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/rubric-source-analysis.md +108 -0
  576. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/rubric-story-script.md +264 -0
  577. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/rubric-visual-motion.md +184 -0
  578. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/stage-contract.md +28 -0
  579. package/resources/skills/drama-production/upstream/skills/short-drama-review/scripts/review_check.py +325 -0
  580. package/resources/skills/drama-production/upstream/skills/short-drama-review/scripts/selftest.py +87 -0
  581. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/SKILL.md +175 -0
  582. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/agents/openai.yaml +4 -0
  583. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/coverage-audition.example.jsonl +2 -0
  584. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/coverage-template.json +49 -0
  585. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/keyframe-prompts.md +36 -0
  586. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/keyframe-template.jsonl +2 -0
  587. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/revision-lineage.fragment.json +12 -0
  588. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/scene-visual-plan.example.jsonl +2 -0
  589. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/shot-template.jsonl +2 -0
  590. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/blocking-playbooks.md +216 -0
  591. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/comic-keyframe-lexicon.md +156 -0
  592. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/coverage-audition.md +34 -0
  593. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/keyframe-craft.md +174 -0
  594. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/lighting-craft.md +39 -0
  595. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/production-shot-grammar.md +279 -0
  596. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/review-and-fixtures.md +36 -0
  597. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/scene-visual-plan.md +30 -0
  598. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/screenplay-to-keyframe-example.md +155 -0
  599. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/shot-craft.md +266 -0
  600. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/shot-revision-identity.md +25 -0
  601. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/stage-contract.md +57 -0
  602. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/scripts/selftest.py +191 -0
  603. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/scripts/storyboard_check.py +824 -0
  604. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/SKILL.md +154 -0
  605. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/agents/openai.yaml +4 -0
  606. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/coverage-scope.fragment.json +21 -0
  607. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/delivery-container.jsonl.md +101 -0
  608. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/motion-spec.jsonl.md +217 -0
  609. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/motion-terminal.example.jsonl +2 -0
  610. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/music-spec.jsonl.md +31 -0
  611. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/performance.fragment.json +38 -0
  612. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/video-prompts.md +67 -0
  613. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/examples/minimal-music-specs.jsonl +2 -0
  614. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/camera-audio-continuity.md +259 -0
  615. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/delivery-profile.md +273 -0
  616. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/generability.md +93 -0
  617. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/minimax-h3.md +253 -0
  618. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/motion-recipe.md +315 -0
  619. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/performance-action-timing.md +209 -0
  620. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/production-prompt-grammar.md +304 -0
  621. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/review-and-fixtures.md +139 -0
  622. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/seedance-2.0.md +50 -0
  623. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/seedance-2.5.md +122 -0
  624. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/stage-contract.md +60 -0
  625. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/target-model-profile.md +116 -0
  626. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/scripts/container_check.py +329 -0
  627. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/scripts/motion_timing_check.py +450 -0
  628. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/scripts/music_spec_check.py +327 -0
  629. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/scripts/selftest.py +82 -0
  630. package/resources/skills/drama-production/upstream/skills/short-drama-write/SKILL.md +99 -0
  631. package/resources/skills/drama-production/upstream/skills/short-drama-write/agents/openai.yaml +4 -0
  632. package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/beats.jsonl +2 -0
  633. package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/episode-card-standalone.json +120 -0
  634. package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/episode-card.json +55 -0
  635. package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/screenplay.md +36 -0
  636. package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/voice-record-sheet.jsonl.md +89 -0
  637. package/resources/skills/drama-production/upstream/skills/short-drama-write/references/dialogue-craft.md +526 -0
  638. package/resources/skills/drama-production/upstream/skills/short-drama-write/references/production-format-dialect.md +273 -0
  639. package/resources/skills/drama-production/upstream/skills/short-drama-write/references/scene-handoff-capsule.md +25 -0
  640. package/resources/skills/drama-production/upstream/skills/short-drama-write/references/scene-sound-dramaturgy.md +43 -0
  641. package/resources/skills/drama-production/upstream/skills/short-drama-write/references/screenplay-format.md +266 -0
  642. package/resources/skills/drama-production/upstream/skills/short-drama-write/references/script-craft.md +745 -0
  643. package/resources/skills/drama-production/upstream/skills/short-drama-write/references/stage-contract.md +36 -0
  644. package/resources/skills/drama-production/upstream/skills/short-drama-write/references/substitutable-realization.md +107 -0
  645. package/resources/skills/drama-production/upstream/skills/short-drama-write/scripts/duration_estimate.py +309 -0
  646. package/resources/skills/drama-production/upstream/skills/short-drama-write/scripts/screenplay_index.py +1091 -0
  647. package/resources/skills/drama-production/upstream/skills/short-drama-write/scripts/selftest.py +229 -0
  648. package/resources/skills/drama-production/upstream/skills/short-drama-write/scripts/voice_sheet_check.py +416 -0
  649. package/resources/skills/viora-canvas-production/manifest.json +23 -0
  650. package/resources/skills/viora-canvas-production/references/canvas-contract.md +50 -0
  651. package/resources/skills/viora-canvas-production/references/creative-production.md +50 -0
  652. package/resources/skills/viora-canvas-production/references/generation-delivery.md +36 -0
  653. package/resources/skills/viora-canvas-production/references/production-ledger.md +25 -0
@@ -0,0 +1,1798 @@
1
+ #!/usr/bin/env python3
2
+ """A loopback-only HTTP dashboard for short-drama project text files."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import contextlib
8
+ import errno
9
+ import hmac
10
+ import hashlib
11
+ import importlib.util
12
+ import ipaddress
13
+ import json
14
+ import os
15
+ import re
16
+ import secrets
17
+ import signal
18
+ import stat
19
+ import subprocess
20
+ import sys
21
+ import threading
22
+ import time
23
+ import uuid
24
+ import webbrowser
25
+ from collections.abc import Iterator, Mapping
26
+ from http import HTTPStatus
27
+ from http.cookies import SimpleCookie
28
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
29
+ from pathlib import Path, PurePosixPath
30
+ from types import ModuleType
31
+ from typing import Any, BinaryIO, Union
32
+ from urllib.parse import parse_qs, unquote, urlencode, urlsplit
33
+
34
+
35
+ # Creators run these scripts on whatever interpreter their machine provides, so
36
+ # an unsupported version must say so instead of failing inside an import.
37
+ MINIMUM_PYTHON = (3, 9)
38
+ if sys.version_info < MINIMUM_PYTHON:
39
+ raise SystemExit(
40
+ "short-drama needs Python {}.{} or newer; this interpreter is {}.{}".format(
41
+ *MINIMUM_PYTHON, sys.version_info.major, sys.version_info.minor
42
+ )
43
+ )
44
+
45
+ TEXT_EXTENSIONS = frozenset({".md", ".json", ".jsonl", ".txt", ".srt", ".ass"})
46
+ MEDIA_EXTENSIONS = frozenset(
47
+ {
48
+ ".png",
49
+ ".jpg",
50
+ ".jpeg",
51
+ ".webp",
52
+ ".gif",
53
+ ".mp4",
54
+ ".webm",
55
+ ".mov",
56
+ ".wav",
57
+ ".mp3",
58
+ ".m4a",
59
+ ".aac",
60
+ ".flac",
61
+ ".opus",
62
+ }
63
+ )
64
+ MEDIA_TYPES = {
65
+ ".png": "image/png",
66
+ ".jpg": "image/jpeg",
67
+ ".jpeg": "image/jpeg",
68
+ ".webp": "image/webp",
69
+ ".gif": "image/gif",
70
+ ".mp4": "video/mp4",
71
+ ".webm": "video/webm",
72
+ ".mov": "video/quicktime",
73
+ ".wav": "audio/wav",
74
+ ".mp3": "audio/mpeg",
75
+ ".m4a": "audio/mp4",
76
+ ".aac": "audio/aac",
77
+ ".flac": "audio/flac",
78
+ ".opus": "audio/ogg",
79
+ }
80
+ DEFAULT_MAX_DEPTH = 8
81
+ DEFAULT_MAX_NODES = 2_000
82
+ DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024
83
+ DEFAULT_MAX_MEDIA_BYTES = 256 * 1024 * 1024
84
+ MAX_JSON_EXPANSION = 6
85
+ REQUEST_OVERHEAD_BYTES = 64 * 1024
86
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
87
+ SESSION_SCHEMA = "1.0"
88
+ SESSION_RELATIVE = PurePosixPath(".short-drama/dashboard.json")
89
+ SESSION_LOCK_SUFFIX = ".lock"
90
+ DETACH_TIMEOUT_SECONDS = 20.0
91
+ STOP_TIMEOUT_SECONDS = 10.0
92
+ STATIC_ROOT = SKILL_ROOT / "assets/dashboard"
93
+
94
+ # Windows opens files in text mode unless told otherwise, which would rewrite
95
+ # every newline and break the SHA-256 versions the editor round-trips on.
96
+ BINARY = getattr(os, "O_BINARY", 0)
97
+ NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
98
+ # POSIX pins directories by descriptor, which makes traversal free of races by
99
+ # construction. Everywhere else the same guarantee is approximated by checking
100
+ # each component and re-checking identity; see ``_PathDirectory``.
101
+ SECURE_DIR_FD = (
102
+ os.name != "nt"
103
+ and bool(getattr(os, "O_DIRECTORY", 0))
104
+ and bool(NOFOLLOW)
105
+ and os.open in os.supports_dir_fd
106
+ )
107
+ # Windows refuses to replace a file another process still holds open, and an
108
+ # editor or a search indexer is enough to hold one for a moment.
109
+ REPLACE_ATTEMPTS = 6
110
+
111
+
112
+ class DashboardError(Exception):
113
+ def __init__(self, status: int, message: str) -> None:
114
+ super().__init__(message)
115
+ self.status = status
116
+ self.message = message
117
+
118
+
119
+ def _is_loopback(host: str) -> bool:
120
+ if host.casefold() == "localhost":
121
+ return True
122
+ try:
123
+ return ipaddress.ip_address(host).is_loopback
124
+ except ValueError:
125
+ return False
126
+
127
+
128
+ def load_project_tool(root: Path) -> ModuleType:
129
+ """Dynamically load the suite's canonical project status implementation."""
130
+ root = root.resolve()
131
+ installed_script = root / "scripts/project_tool.py"
132
+ repository_script = root / "skills/short-drama/scripts/project_tool.py"
133
+ script = installed_script if installed_script.is_file() else repository_script
134
+ spec = importlib.util.spec_from_file_location("dashboard_project_tool", script)
135
+ if spec is None or spec.loader is None:
136
+ raise RuntimeError(f"cannot load project tool: {script}")
137
+ module = importlib.util.module_from_spec(spec)
138
+ spec.loader.exec_module(module)
139
+ return module
140
+
141
+
142
+ def _version(data: bytes) -> str:
143
+ return hashlib.sha256(data).hexdigest()
144
+
145
+
146
+ def _validate_structured_text(path: PurePosixPath, content: str) -> None:
147
+ suffix = path.suffix.casefold()
148
+ if suffix == ".json":
149
+ try:
150
+ json.loads(content)
151
+ except json.JSONDecodeError as exc:
152
+ raise DashboardError(
153
+ HTTPStatus.BAD_REQUEST,
154
+ f"JSON is invalid at line {exc.lineno}, column {exc.colno}",
155
+ ) from exc
156
+ return
157
+ if suffix != ".jsonl":
158
+ return
159
+ for line_number, line in enumerate(content.splitlines(), start=1):
160
+ if not line.strip():
161
+ continue
162
+ try:
163
+ json.loads(line)
164
+ except json.JSONDecodeError as exc:
165
+ raise DashboardError(
166
+ HTTPStatus.BAD_REQUEST,
167
+ f"JSONL line {line_number} is invalid at column {exc.colno}",
168
+ ) from exc
169
+
170
+
171
+ def _is_link_or_reparse(details: os.stat_result) -> bool:
172
+ """Say whether a stat result describes something that must not be traversed.
173
+
174
+ ``S_ISLNK`` alone is not enough on Windows: junctions and every other
175
+ reparse point redirect just as well, and need no privilege to create.
176
+ """
177
+
178
+ attributes = getattr(details, "st_file_attributes", 0)
179
+ reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
180
+ return stat.S_ISLNK(details.st_mode) or bool(attributes & reparse_flag)
181
+
182
+
183
+ def _entry_is_link(entry: os.DirEntry) -> bool:
184
+ try:
185
+ if entry.is_symlink():
186
+ return True
187
+ if os.name != "nt":
188
+ return False
189
+ return _is_link_or_reparse(entry.stat(follow_symlinks=False))
190
+ except OSError:
191
+ return True
192
+
193
+
194
+ def _identity(details: os.stat_result) -> tuple[int, int]:
195
+ return details.st_dev, details.st_ino
196
+
197
+
198
+ def _same_object(before: tuple[int, int], after: tuple[int, int]) -> bool:
199
+ # A volume that reports no inode gives nothing to compare, so treat the
200
+ # check as inapplicable there instead of failing every read on it.
201
+ if not before[1] or not after[1]:
202
+ return True
203
+ return before == after
204
+
205
+
206
+ class _DescriptorDirectory:
207
+ """A directory pinned by descriptor, the POSIX backend.
208
+
209
+ Every operation names a file relative to a descriptor the request already
210
+ holds, so the pin is the directory itself rather than a name that resolves
211
+ to one, and nothing in between can be swapped mid-request.
212
+ """
213
+
214
+ __slots__ = ("_fd",)
215
+
216
+ contract = (
217
+ "project_status_at",
218
+ "is_protected_project_text",
219
+ "coordinated_project_text_edit_at",
220
+ "project_path_lifecycle_at",
221
+ )
222
+
223
+ def __init__(self, descriptor: int) -> None:
224
+ self._fd = descriptor
225
+
226
+ @classmethod
227
+ def open_root(cls, path: Path) -> _DescriptorDirectory:
228
+ return cls(os.open(path, os.O_RDONLY | os.O_DIRECTORY | NOFOLLOW))
229
+
230
+ def reopen(self) -> _DescriptorDirectory:
231
+ # A directory cursor belongs to the open file description, so dup()
232
+ # would still let concurrent requests advance one another's scan.
233
+ # Opening "." gives every caller an independent description.
234
+ return _DescriptorDirectory(
235
+ os.open(".", os.O_RDONLY | os.O_DIRECTORY | NOFOLLOW, dir_fd=self._fd)
236
+ )
237
+
238
+ def child(self, name: str) -> _DescriptorDirectory:
239
+ return _DescriptorDirectory(
240
+ os.open(
241
+ name, os.O_RDONLY | os.O_DIRECTORY | NOFOLLOW, dir_fd=self._fd
242
+ )
243
+ )
244
+
245
+ @contextlib.contextmanager
246
+ def scandir(self) -> Iterator[Iterator[os.DirEntry]]:
247
+ with os.scandir(self._fd) as entries:
248
+ yield entries
249
+
250
+ def open_regular(self, name: str, flags: int, mode: int = 0o666) -> int:
251
+ return os.open(name, flags | NOFOLLOW | BINARY, mode, dir_fd=self._fd)
252
+
253
+ def replace(self, source: str, target: str) -> None:
254
+ os.replace(source, target, src_dir_fd=self._fd, dst_dir_fd=self._fd)
255
+
256
+ def unlink(self, name: str) -> None:
257
+ os.unlink(name, dir_fd=self._fd)
258
+
259
+ def sync(self) -> None:
260
+ os.fsync(self._fd)
261
+
262
+ def close(self) -> None:
263
+ if self._fd >= 0:
264
+ os.close(self._fd)
265
+ self._fd = -1
266
+
267
+ def status(self, tool: ModuleType, project_root: str) -> dict[str, Any]:
268
+ return tool.project_status_at(self._fd, project_root=project_root)
269
+
270
+ def lifecycle(self, tool: ModuleType, relative: str) -> dict[str, str] | None:
271
+ return tool.project_path_lifecycle_at(self._fd, relative)
272
+
273
+ def coordinated_edit(
274
+ self, tool: ModuleType, relative: str, expected_version: str
275
+ ) -> Any:
276
+ return tool.coordinated_project_text_edit_at(
277
+ self._fd, relative, expected_version
278
+ )
279
+
280
+
281
+ class _PathDirectory:
282
+ """A directory pinned by verified path, for platforms without ``openat``.
283
+
284
+ Windows cannot name a file relative to an open directory, so the guarantee
285
+ is rebuilt from two checks: every component is inspected with ``os.lstat``,
286
+ which never follows, and refused if it is a link or any other reparse
287
+ point; then the pinned directory's identity is re-checked before each
288
+ operation and an opened file's identity against what was inspected.
289
+
290
+ This is weaker than a descriptor -- a swap timed between the two checks is
291
+ not caught. It is the boundary this suite already accepts for Windows
292
+ ``verify``, and the adversary it admits, a local process running as the
293
+ creator, can open these files directly anyway.
294
+ """
295
+
296
+ __slots__ = ("_path", "_identity")
297
+
298
+ contract = (
299
+ "project_status_from_root",
300
+ "is_protected_project_text",
301
+ "coordinated_project_text_edit",
302
+ "project_path_lifecycle",
303
+ )
304
+
305
+ def __init__(self, path: Path, identity: tuple[int, int]) -> None:
306
+ self._path = path
307
+ self._identity = identity
308
+
309
+ @classmethod
310
+ def open_root(cls, path: Path) -> _PathDirectory:
311
+ details = os.lstat(path)
312
+ if _is_link_or_reparse(details) or not stat.S_ISDIR(details.st_mode):
313
+ raise OSError(
314
+ errno.ENOTDIR, "not a plain directory", str(path)
315
+ )
316
+ return cls(path, _identity(details))
317
+
318
+ def _pinned(self) -> Path:
319
+ details = os.lstat(self._path)
320
+ if _is_link_or_reparse(details) or not stat.S_ISDIR(details.st_mode):
321
+ raise OSError(
322
+ errno.ENOTDIR, "pinned directory is no longer a directory",
323
+ str(self._path),
324
+ )
325
+ if not _same_object(self._identity, _identity(details)):
326
+ raise OSError(
327
+ errno.ENOENT, "pinned directory was replaced", str(self._path)
328
+ )
329
+ return self._path
330
+
331
+ def reopen(self) -> _PathDirectory:
332
+ return _PathDirectory(self._pinned(), self._identity)
333
+
334
+ def child(self, name: str) -> _PathDirectory:
335
+ return _PathDirectory.open_root(self._pinned() / name)
336
+
337
+ @contextlib.contextmanager
338
+ def scandir(self) -> Iterator[Iterator[os.DirEntry]]:
339
+ with os.scandir(self._pinned()) as entries:
340
+ yield entries
341
+
342
+ def open_regular(self, name: str, flags: int, mode: int = 0o666) -> int:
343
+ target = self._pinned() / name
344
+ expected: tuple[int, int] | None = None
345
+ if not flags & os.O_CREAT:
346
+ # O_CREAT|O_EXCL already refuses an existing name, so only an open
347
+ # of something that must already be there needs inspecting first.
348
+ details = os.lstat(target)
349
+ if _is_link_or_reparse(details) or not stat.S_ISREG(details.st_mode):
350
+ raise OSError(errno.EPERM, "not a plain file", str(target))
351
+ expected = _identity(details)
352
+ descriptor = os.open(target, flags | BINARY, mode)
353
+ if expected is None:
354
+ return descriptor
355
+ try:
356
+ if not _same_object(expected, _identity(os.fstat(descriptor))):
357
+ raise OSError(
358
+ errno.ENOENT, "file was replaced while opening", str(target)
359
+ )
360
+ except BaseException:
361
+ os.close(descriptor)
362
+ raise
363
+ return descriptor
364
+
365
+ def replace(self, source: str, target: str) -> None:
366
+ root = self._pinned()
367
+ delay = 0.01
368
+ for remaining in range(REPLACE_ATTEMPTS - 1, -1, -1):
369
+ try:
370
+ os.replace(root / source, root / target)
371
+ return
372
+ except PermissionError:
373
+ if not remaining:
374
+ raise
375
+ time.sleep(delay)
376
+ delay *= 2
377
+
378
+ def unlink(self, name: str) -> None:
379
+ os.unlink(self._pinned() / name)
380
+
381
+ def sync(self) -> None:
382
+ # Windows has no directory handle to flush; the file itself was already
383
+ # fsynced before the replace, which is the same guarantee the CLI makes.
384
+ return None
385
+
386
+ def close(self) -> None:
387
+ return None
388
+
389
+ def status(self, tool: ModuleType, project_root: str) -> dict[str, Any]:
390
+ return tool.project_status_from_root(
391
+ self._pinned(), project_root=project_root
392
+ )
393
+
394
+ def lifecycle(self, tool: ModuleType, relative: str) -> dict[str, str] | None:
395
+ return tool.project_path_lifecycle(self._pinned(), relative)
396
+
397
+ def coordinated_edit(
398
+ self, tool: ModuleType, relative: str, expected_version: str
399
+ ) -> Any:
400
+ return tool.coordinated_project_text_edit(
401
+ self._pinned(), relative, expected_version
402
+ )
403
+
404
+
405
+ Directory = Union[_DescriptorDirectory, _PathDirectory]
406
+
407
+
408
+ def directory_backend() -> type[_DescriptorDirectory] | type[_PathDirectory]:
409
+ return _DescriptorDirectory if SECURE_DIR_FD else _PathDirectory
410
+
411
+
412
+ @contextlib.contextmanager
413
+ def _open_parent_directory(
414
+ root: Directory, relative: PurePosixPath
415
+ ) -> Iterator[tuple[Directory, str]]:
416
+ current = root.reopen()
417
+ try:
418
+ for part in relative.parts[:-1]:
419
+ child = current.child(part)
420
+ current.close()
421
+ current = child
422
+ yield current, relative.name
423
+ finally:
424
+ current.close()
425
+
426
+
427
+ class ProjectStore:
428
+ def __init__(
429
+ self,
430
+ workspace: Path,
431
+ project_tool: ModuleType,
432
+ *,
433
+ max_depth: int = DEFAULT_MAX_DEPTH,
434
+ max_nodes: int = DEFAULT_MAX_NODES,
435
+ max_file_bytes: int = DEFAULT_MAX_FILE_BYTES,
436
+ max_media_bytes: int = DEFAULT_MAX_MEDIA_BYTES,
437
+ ) -> None:
438
+ workspace = workspace.expanduser().resolve(strict=True)
439
+ if not workspace.is_dir():
440
+ raise NotADirectoryError(workspace)
441
+ self.workspace = workspace
442
+ self.project_tool = project_tool
443
+ self.max_depth = max_depth
444
+ self.max_nodes = max_nodes
445
+ self.max_file_bytes = max_file_bytes
446
+ self.max_media_bytes = max_media_bytes
447
+ # JSON can encode one content byte as a six-byte ``\u00xx`` escape.
448
+ # Size the transport envelope from the configured file limit so a
449
+ # valid text file is never rejected only because of JSON escaping.
450
+ self.max_request_bytes = (
451
+ max_file_bytes * MAX_JSON_EXPANSION + REQUEST_OVERHEAD_BYTES
452
+ )
453
+ # Which four entry points this server invokes depends on how the
454
+ # platform can pin a directory: by descriptor, or by verified path.
455
+ self.backend = directory_backend()
456
+ missing = [
457
+ name
458
+ for name in self.backend.contract
459
+ if not callable(getattr(project_tool, name, None))
460
+ ]
461
+ if missing:
462
+ raise RuntimeError(
463
+ "project tool does not support the Dashboard contract: "
464
+ + ", ".join(missing)
465
+ )
466
+ self._workspace = self.backend.open_root(workspace)
467
+ self._write_lock = threading.Lock()
468
+
469
+ def close(self) -> None:
470
+ self._workspace.close()
471
+
472
+ @staticmethod
473
+ def _project_id(relative: str) -> str:
474
+ return hashlib.sha256(relative.encode("utf-8")).hexdigest()[:20]
475
+
476
+ def discover(self) -> tuple[list[dict[str, Any]], list[str]]:
477
+ projects: list[dict[str, Any]] = []
478
+ warnings: list[str] = []
479
+ nodes = 0
480
+ depth_truncated = False
481
+ node_truncated = False
482
+
483
+ def walk(directory: Directory, parts: tuple[str, ...], depth: int) -> None:
484
+ nonlocal depth_truncated, node_truncated, nodes
485
+ if nodes >= self.max_nodes:
486
+ return
487
+ try:
488
+ with directory.scandir() as iterator:
489
+ entries = sorted(iterator, key=lambda item: item.name.casefold())
490
+ except OSError as exc:
491
+ warnings.append(f"无法读取 {'/'.join(parts) or '.'}: {exc}")
492
+ return
493
+ for entry in entries:
494
+ if nodes >= self.max_nodes:
495
+ node_truncated = True
496
+ return
497
+ nodes += 1
498
+ if _entry_is_link(entry):
499
+ continue
500
+ if (
501
+ entry.is_file(follow_symlinks=False)
502
+ and entry.name == "short-drama.json"
503
+ ):
504
+ relative = "/".join(parts) or "."
505
+ title = "未命名短剧"
506
+ try:
507
+ raw, _mode = self._read_regular(directory, entry.name)
508
+ manifest = json.loads(raw.decode("utf-8"))
509
+ candidate = manifest.get("title") if isinstance(manifest, dict) else None
510
+ if isinstance(candidate, str) and candidate.strip():
511
+ title = " ".join(candidate.split())[:200]
512
+ except (DashboardError, OSError, UnicodeError, json.JSONDecodeError):
513
+ pass
514
+ projects.append(
515
+ {
516
+ "id": self._project_id(relative),
517
+ "path": relative,
518
+ "title": title,
519
+ }
520
+ )
521
+ continue
522
+ if not entry.is_dir(follow_symlinks=False):
523
+ continue
524
+ if depth >= self.max_depth:
525
+ depth_truncated = True
526
+ continue
527
+ try:
528
+ child = directory.child(entry.name)
529
+ except OSError:
530
+ continue
531
+ try:
532
+ walk(child, (*parts, entry.name), depth + 1)
533
+ finally:
534
+ child.close()
535
+
536
+ # Reopen rather than share: on POSIX a directory cursor belongs to the
537
+ # open file description, so two concurrent discoveries walking one
538
+ # handle would advance each other's scan.
539
+ discovery = self._workspace.reopen()
540
+ try:
541
+ walk(discovery, (), 0)
542
+ finally:
543
+ discovery.close()
544
+ if node_truncated:
545
+ warnings.append(f"项目发现达到节点上限 {self.max_nodes},结果已截断")
546
+ if depth_truncated:
547
+ warnings.append(f"项目发现达到深度上限 {self.max_depth},更深目录已截断")
548
+ return projects, warnings
549
+
550
+ @contextlib.contextmanager
551
+ def _pinned_project(self, project_id: str) -> Iterator[tuple[Directory, Path]]:
552
+ """Pin the project root and report its creator-facing path.
553
+
554
+ Every parent is opened from the pinned workspace with the backend's
555
+ no-follow guarantee, so a directory swapped for a symlink mid-request
556
+ fails instead of redirecting the operation. The yielded path is for
557
+ display only — no caller reads through it.
558
+ """
559
+
560
+ selected = next(
561
+ (item for item in self.discover()[0] if item["id"] == project_id), None
562
+ )
563
+ if selected is None:
564
+ raise DashboardError(HTTPStatus.NOT_FOUND, "project not found")
565
+ display = (
566
+ self.workspace
567
+ if selected["path"] == "."
568
+ else self.workspace / selected["path"]
569
+ )
570
+ # Reopen instead of sharing the workspace handle: on POSIX that handle
571
+ # carries a seek position other requests would advance, including when
572
+ # the workspace itself is the selected project.
573
+ directory = self._workspace.reopen()
574
+ marker = -1
575
+ try:
576
+ for part in PurePosixPath(selected["path"]).parts:
577
+ if part == ".":
578
+ continue
579
+ child = directory.child(part)
580
+ directory.close()
581
+ directory = child
582
+ marker = directory.open_regular("short-drama.json", os.O_RDONLY)
583
+ if not stat.S_ISREG(os.fstat(marker).st_mode):
584
+ raise OSError("project manifest is not a regular file")
585
+ except OSError as exc:
586
+ if marker >= 0:
587
+ os.close(marker)
588
+ directory.close()
589
+ raise DashboardError(
590
+ HTTPStatus.FORBIDDEN, "project root cannot be opened safely"
591
+ ) from exc
592
+ os.close(marker)
593
+ try:
594
+ yield directory, display
595
+ finally:
596
+ directory.close()
597
+
598
+ def status(self, project_id: str) -> dict[str, Any]:
599
+ with self._pinned_project(project_id) as (directory, root):
600
+ return directory.status(self.project_tool, str(root))
601
+
602
+ @staticmethod
603
+ def _safe_relative(relative: str) -> PurePosixPath:
604
+ raw = unquote(relative).replace("\\", "/")
605
+ pure = PurePosixPath(raw)
606
+ if (
607
+ not raw
608
+ or pure.is_absolute()
609
+ or any(part in ("", ".", "..") for part in pure.parts)
610
+ ):
611
+ raise DashboardError(HTTPStatus.BAD_REQUEST, "unsafe project-relative path")
612
+ return pure
613
+
614
+ def _is_protected(self, relative: PurePosixPath) -> bool:
615
+ return bool(
616
+ self.project_tool.is_protected_project_text(relative.as_posix())
617
+ )
618
+
619
+ def tree(self, project_id: str) -> dict[str, Any]:
620
+ with self._pinned_project(project_id) as (directory, _root):
621
+ return self._tree_from_root(directory)
622
+
623
+ def _tree_from_root(self, directory: Directory) -> dict[str, Any]:
624
+ nodes = 0
625
+ warnings: list[str] = []
626
+ depth_truncated = False
627
+ node_truncated = False
628
+ oversized_text = 0
629
+ oversized_media = 0
630
+
631
+ def scan(
632
+ parent: Directory, parts: tuple[str, ...], depth: int
633
+ ) -> list[dict[str, Any]]:
634
+ nonlocal depth_truncated, node_truncated, nodes
635
+ nonlocal oversized_media, oversized_text
636
+ children: list[dict[str, Any]] = []
637
+ try:
638
+ with parent.scandir() as iterator:
639
+ entries = sorted(
640
+ iterator,
641
+ key=lambda item: (
642
+ not item.is_dir(follow_symlinks=False),
643
+ item.name.casefold(),
644
+ ),
645
+ )
646
+ except OSError as exc:
647
+ warnings.append(f"无法读取 {'/'.join(parts) or '.'}: {exc}")
648
+ return children
649
+ for entry in entries:
650
+ if _entry_is_link(entry):
651
+ continue
652
+ relative = "/".join((*parts, entry.name))
653
+ if parts == () and entry.name.casefold() == ".short-drama":
654
+ continue
655
+ if nodes >= self.max_nodes:
656
+ node_truncated = True
657
+ break
658
+ nodes += 1
659
+ if entry.is_dir(follow_symlinks=False):
660
+ node: dict[str, Any] = {
661
+ "name": entry.name,
662
+ "path": relative,
663
+ "type": "directory",
664
+ "children": [],
665
+ }
666
+ if depth < self.max_depth:
667
+ try:
668
+ child = parent.child(entry.name)
669
+ except OSError:
670
+ continue
671
+ try:
672
+ node["children"] = scan(
673
+ child, (*parts, entry.name), depth + 1
674
+ )
675
+ finally:
676
+ child.close()
677
+ else:
678
+ depth_truncated = True
679
+ node["truncated"] = True
680
+ children.append(node)
681
+ continue
682
+ suffix = PurePosixPath(entry.name).suffix.casefold()
683
+ if suffix not in TEXT_EXTENSIONS and suffix not in MEDIA_EXTENSIONS:
684
+ continue
685
+ try:
686
+ size = entry.stat(follow_symlinks=False).st_size
687
+ except OSError:
688
+ continue
689
+ limit = (
690
+ self.max_file_bytes
691
+ if suffix in TEXT_EXTENSIONS
692
+ else self.max_media_bytes
693
+ )
694
+ if size > limit:
695
+ if suffix in TEXT_EXTENSIONS:
696
+ oversized_text += 1
697
+ else:
698
+ oversized_media += 1
699
+ children.append(
700
+ {
701
+ "name": entry.name,
702
+ "path": relative,
703
+ "type": "text" if suffix in TEXT_EXTENSIONS else "media",
704
+ "size": size,
705
+ "oversize": size > limit,
706
+ "writable": suffix in TEXT_EXTENSIONS
707
+ and not self._is_protected(PurePosixPath(relative)),
708
+ }
709
+ )
710
+ return children
711
+
712
+ tree = scan(directory, (), 0)
713
+ if node_truncated:
714
+ warnings.append(f"文件树达到节点上限 {self.max_nodes},结果已截断")
715
+ if depth_truncated:
716
+ warnings.append(f"文件树达到深度上限 {self.max_depth},更深目录已截断")
717
+ if oversized_text:
718
+ warnings.append(
719
+ f"{oversized_text} 个文本文件超过大小上限 {self.max_file_bytes} bytes,内容预览已禁用"
720
+ )
721
+ if oversized_media:
722
+ warnings.append(
723
+ f"{oversized_media} 个媒体文件超过大小上限 {self.max_media_bytes} bytes,媒体预览已禁用"
724
+ )
725
+ return {
726
+ "tree": tree,
727
+ "warnings": warnings,
728
+ "limits": {
729
+ "depth": self.max_depth,
730
+ "nodes": self.max_nodes,
731
+ "fileBytes": self.max_file_bytes,
732
+ "mediaBytes": self.max_media_bytes,
733
+ },
734
+ }
735
+
736
+ def read_text(self, project_id: str, relative: str) -> dict[str, Any]:
737
+ pure = self._safe_relative(relative)
738
+ if pure.suffix.casefold() not in TEXT_EXTENSIONS:
739
+ raise DashboardError(
740
+ HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "file type is not editable text"
741
+ )
742
+ try:
743
+ with self._pinned_project(project_id) as (directory, _root):
744
+ with _open_parent_directory(directory, pure) as (parent, name):
745
+ data, _ = self._read_regular(parent, name)
746
+ except OSError as exc:
747
+ raise DashboardError(
748
+ HTTPStatus.FORBIDDEN, "text file cannot be opened safely"
749
+ ) from exc
750
+ try:
751
+ content = data.decode("utf-8")
752
+ except UnicodeDecodeError as exc:
753
+ raise DashboardError(
754
+ HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "text file must be UTF-8"
755
+ ) from exc
756
+ return {
757
+ "path": pure.as_posix(),
758
+ "content": content,
759
+ "version": _version(data),
760
+ "writable": not self._is_protected(pure),
761
+ }
762
+
763
+ def _read_regular(self, parent: Directory, name: str) -> tuple[bytes, int]:
764
+ descriptor = parent.open_regular(name, os.O_RDONLY)
765
+ try:
766
+ details = os.fstat(descriptor)
767
+ if not stat.S_ISREG(details.st_mode):
768
+ raise DashboardError(HTTPStatus.BAD_REQUEST, "path is not a file")
769
+ if details.st_size > self.max_file_bytes:
770
+ raise DashboardError(
771
+ HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
772
+ "file exceeds preview limit",
773
+ )
774
+ with os.fdopen(descriptor, "rb") as handle:
775
+ descriptor = -1
776
+ data = handle.read(self.max_file_bytes + 1)
777
+ if len(data) > self.max_file_bytes:
778
+ raise DashboardError(
779
+ HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
780
+ "file exceeds preview limit",
781
+ )
782
+ return data, stat.S_IMODE(details.st_mode)
783
+ finally:
784
+ if descriptor >= 0:
785
+ os.close(descriptor)
786
+
787
+ def _replace_text(
788
+ self,
789
+ parent: Directory,
790
+ name: str,
791
+ encoded: bytes,
792
+ mode: int,
793
+ expected_version: str,
794
+ ) -> None:
795
+ temporary_name = f".{name}.{uuid.uuid4().hex}.tmp"
796
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
797
+ descriptor = parent.open_regular(temporary_name, flags, mode)
798
+ replaced = False
799
+ try:
800
+ with os.fdopen(descriptor, "wb") as handle:
801
+ descriptor = -1
802
+ handle.write(encoded)
803
+ handle.flush()
804
+ os.fsync(handle.fileno())
805
+ # Windows has no fchmod, and its chmod only moves the read-only
806
+ # bit; the mode the file already carries is the right one there.
807
+ if hasattr(os, "fchmod"):
808
+ os.fchmod(handle.fileno(), mode)
809
+ latest, _ = self._read_regular(parent, name)
810
+ if _version(latest) != expected_version:
811
+ raise DashboardError(
812
+ HTTPStatus.CONFLICT, "file changed since it was opened"
813
+ )
814
+ try:
815
+ parent.replace(temporary_name, name)
816
+ except PermissionError as exc:
817
+ # Windows refuses the rename while anything else holds the
818
+ # target open; elsewhere the same errno means the file is
819
+ # write-protected. Neither is an unsafe path, so neither may
820
+ # report as one.
821
+ raise DashboardError(
822
+ HTTPStatus.CONFLICT, "file is locked or not writable"
823
+ ) from exc
824
+ replaced = True
825
+ parent.sync()
826
+ finally:
827
+ if descriptor >= 0:
828
+ os.close(descriptor)
829
+ if not replaced:
830
+ # Best effort: the save already failed, and a cleanup that
831
+ # cannot run must not replace that error with its own.
832
+ with contextlib.suppress(OSError):
833
+ parent.unlink(temporary_name)
834
+
835
+ @contextlib.contextmanager
836
+ def _coordinated_edit(
837
+ self, directory: Directory, relative: PurePosixPath, expected_version: str
838
+ ) -> Iterator[None]:
839
+ try:
840
+ with directory.coordinated_edit(
841
+ self.project_tool, relative.as_posix(), expected_version
842
+ ):
843
+ yield
844
+ except Exception as exc:
845
+ if exc.__class__.__name__ == "ProjectConflictError":
846
+ raise DashboardError(HTTPStatus.CONFLICT, str(exc)) from exc
847
+ if isinstance(exc, ValueError):
848
+ raise DashboardError(
849
+ HTTPStatus.FORBIDDEN, "project path changed during the save"
850
+ ) from exc
851
+ if isinstance(exc, OSError):
852
+ # Both backends refuse to take the project lock through a
853
+ # redirected `.short-drama`. That is a refused path, not the
854
+ # internal error it used to surface as.
855
+ raise DashboardError(
856
+ HTTPStatus.FORBIDDEN, "text file cannot be replaced safely"
857
+ ) from exc
858
+ raise
859
+
860
+ def write_text(
861
+ self, project_id: str, relative: str, content: Any, expected_version: Any
862
+ ) -> dict[str, Any]:
863
+ pure = self._safe_relative(relative)
864
+ if pure.suffix.casefold() not in TEXT_EXTENSIONS:
865
+ raise DashboardError(
866
+ HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "file type is not editable text"
867
+ )
868
+ if self._is_protected(pure):
869
+ raise DashboardError(
870
+ HTTPStatus.FORBIDDEN, "file is protected and read-only"
871
+ )
872
+ if not isinstance(content, str) or not isinstance(expected_version, str):
873
+ raise DashboardError(
874
+ HTTPStatus.BAD_REQUEST,
875
+ "content and expectedVersion are required strings",
876
+ )
877
+ if re.fullmatch(r"[0-9a-f]{64}", expected_version) is None:
878
+ raise DashboardError(
879
+ HTTPStatus.BAD_REQUEST, "expectedVersion must be a SHA-256 digest"
880
+ )
881
+ encoded = content.encode("utf-8")
882
+ if len(encoded) > self.max_file_bytes:
883
+ raise DashboardError(
884
+ HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "content exceeds file limit"
885
+ )
886
+ _validate_structured_text(pure, content)
887
+ with (
888
+ self._write_lock,
889
+ self._pinned_project(project_id) as (directory, _root),
890
+ self._coordinated_edit(directory, pure, expected_version),
891
+ ):
892
+ # The in-process lock and the project's file lock keep the version
893
+ # check and atomic replace together. Pinned parents stop a
894
+ # concurrent symlink swap from redirecting the write.
895
+ try:
896
+ with _open_parent_directory(directory, pure) as (parent, name):
897
+ current, mode = self._read_regular(parent, name)
898
+ if _version(current) != expected_version:
899
+ raise DashboardError(
900
+ HTTPStatus.CONFLICT,
901
+ "file changed since it was opened",
902
+ )
903
+ self._replace_text(
904
+ parent, name, encoded, mode, expected_version
905
+ )
906
+ except OSError as exc:
907
+ raise DashboardError(
908
+ HTTPStatus.FORBIDDEN, "text file cannot be replaced safely"
909
+ ) from exc
910
+ return {"path": pure.as_posix(), "version": _version(encoded), "saved": True}
911
+
912
+ def media_info(self, project_id: str, relative: str) -> dict[str, Any]:
913
+ pure = self._safe_relative(relative)
914
+ with self._pinned_project(project_id) as (directory, _root):
915
+ handle, content_type, size = self._open_media(directory, pure)
916
+ handle.close()
917
+ lifecycle = directory.lifecycle(self.project_tool, pure.as_posix())
918
+ kind = "image"
919
+ if content_type.startswith("video/"):
920
+ kind = "video"
921
+ elif content_type.startswith("audio/"):
922
+ kind = "audio"
923
+ result = {
924
+ "path": pure.as_posix(),
925
+ "kind": kind,
926
+ "contentType": content_type,
927
+ "size": size,
928
+ "readOnly": True,
929
+ "contentUrl": f"/api/media/content?{urlencode({'project': project_id, 'path': pure.as_posix()})}",
930
+ "status": "ready",
931
+ }
932
+ if lifecycle is not None:
933
+ result["lifecycle"] = lifecycle
934
+ return result
935
+
936
+ def open_media(
937
+ self, project_id: str, relative: str
938
+ ) -> tuple[BinaryIO, PurePosixPath, str, int]:
939
+ pure = self._safe_relative(relative)
940
+ with self._pinned_project(project_id) as (directory, _root):
941
+ handle, content_type, size = self._open_media(directory, pure)
942
+ return handle, pure, content_type, size
943
+
944
+ def _open_media(
945
+ self, directory: Directory, pure: PurePosixPath
946
+ ) -> tuple[BinaryIO, str, int]:
947
+ content_type = MEDIA_TYPES.get(pure.suffix.casefold())
948
+ if content_type is None:
949
+ raise DashboardError(
950
+ HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "unsupported preview media"
951
+ )
952
+ try:
953
+ with _open_parent_directory(directory, pure) as (parent, name):
954
+ descriptor = parent.open_regular(name, os.O_RDONLY)
955
+ except OSError as exc:
956
+ raise DashboardError(
957
+ HTTPStatus.FORBIDDEN, "media file cannot be opened safely"
958
+ ) from exc
959
+ try:
960
+ details = os.fstat(descriptor)
961
+ if not stat.S_ISREG(details.st_mode):
962
+ raise DashboardError(HTTPStatus.BAD_REQUEST, "media path is not a file")
963
+ if details.st_size > self.max_media_bytes:
964
+ raise DashboardError(
965
+ HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "media exceeds preview limit"
966
+ )
967
+ return os.fdopen(descriptor, "rb"), content_type, details.st_size
968
+ except Exception:
969
+ os.close(descriptor)
970
+ raise
971
+
972
+
973
+ class DashboardHTTPServer(ThreadingHTTPServer):
974
+ daemon_threads = True
975
+
976
+ def __init__(self, server_address: tuple[str, int], store: ProjectStore) -> None:
977
+ self.store = store
978
+ self.workspace_fingerprint = workspace_fingerprint(store.workspace)
979
+ self.access_token = secrets.token_urlsafe(32)
980
+ self.api_prefix = f"/_short_drama/{secrets.token_urlsafe(18)}"
981
+ cookie_suffix = hashlib.sha256(self.api_prefix.encode("utf-8")).hexdigest()[:16]
982
+ self.session_cookie = f"short_drama_{cookie_suffix}"
983
+ super().__init__(server_address, DashboardHandler)
984
+
985
+ def allowed_authority(self, authority: str) -> bool:
986
+ parsed = urlsplit(f"//{authority}")
987
+ if (
988
+ not parsed.hostname
989
+ or not _is_loopback(parsed.hostname)
990
+ or parsed.username is not None
991
+ or parsed.password is not None
992
+ or bool(parsed.path or parsed.query or parsed.fragment)
993
+ ):
994
+ return False
995
+ try:
996
+ port = parsed.port
997
+ except ValueError:
998
+ return False
999
+ return port == self.server_address[1]
1000
+
1001
+ def server_close(self) -> None:
1002
+ try:
1003
+ super().server_close()
1004
+ finally:
1005
+ self.store.close()
1006
+
1007
+
1008
+ class DashboardHandler(SimpleHTTPRequestHandler):
1009
+ server: DashboardHTTPServer
1010
+
1011
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
1012
+ super().__init__(*args, directory=str(STATIC_ROOT), **kwargs)
1013
+
1014
+ def end_headers(self) -> None:
1015
+ self.send_header("Cache-Control", "no-store")
1016
+ self.send_header("X-Content-Type-Options", "nosniff")
1017
+ self.send_header("X-Frame-Options", "DENY")
1018
+ self.send_header(
1019
+ "Content-Security-Policy",
1020
+ "default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:; media-src 'self'",
1021
+ )
1022
+ super().end_headers()
1023
+
1024
+ def _request_token(self) -> str:
1025
+ header = self.headers.get("X-Short-Drama-Token")
1026
+ if header:
1027
+ return header
1028
+ cookies = SimpleCookie()
1029
+ try:
1030
+ cookies.load(self.headers.get("Cookie", ""))
1031
+ except Exception:
1032
+ return ""
1033
+ morsel = cookies.get(self.server.session_cookie)
1034
+ return morsel.value if morsel is not None else ""
1035
+
1036
+ def _security_ok(self, *, require_token: bool = False) -> bool:
1037
+ host = self.headers.get("Host", "")
1038
+ if not host or not self.server.allowed_authority(host):
1039
+ self._json(HTTPStatus.FORBIDDEN, {"error": "invalid Host header"})
1040
+ return False
1041
+ origin = self.headers.get("Origin")
1042
+ if origin:
1043
+ parsed = urlsplit(origin)
1044
+ if (
1045
+ parsed.scheme != "http"
1046
+ or not parsed.netloc
1047
+ or bool(parsed.path not in {"", "/"} or parsed.query or parsed.fragment)
1048
+ or not self.server.allowed_authority(parsed.netloc)
1049
+ ):
1050
+ self._json(HTTPStatus.FORBIDDEN, {"error": "invalid Origin header"})
1051
+ return False
1052
+ # Compare bytes, not str: `hmac.compare_digest` raises TypeError on a
1053
+ # str operand holding any codepoint above U+007F, and http.client
1054
+ # decodes header bytes as iso-8859-1, so a single 0x80+ byte in the
1055
+ # header would escape this pre-auth check and drop the connection with
1056
+ # no response at all.
1057
+ if require_token and not hmac.compare_digest(
1058
+ self._request_token().encode("utf-8", "surrogateescape"),
1059
+ self.server.access_token.encode("utf-8"),
1060
+ ):
1061
+ self._json(HTTPStatus.UNAUTHORIZED, {"error": "dashboard session required"})
1062
+ return False
1063
+ return True
1064
+
1065
+ def _json(
1066
+ self, status: int, value: Any, *, headers: dict[str, str] | None = None
1067
+ ) -> None:
1068
+ body = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode(
1069
+ "utf-8"
1070
+ )
1071
+ self.send_response(status)
1072
+ self.send_header("Content-Type", "application/json; charset=utf-8")
1073
+ self.send_header("Content-Length", str(len(body)))
1074
+ for name, content in (headers or {}).items():
1075
+ self.send_header(name, content)
1076
+ self.end_headers()
1077
+ self.wfile.write(body)
1078
+
1079
+ def _query(self) -> tuple[str, dict[str, list[str]]]:
1080
+ parsed = urlsplit(self.path)
1081
+ path = parsed.path
1082
+ if path.startswith(f"{self.server.api_prefix}/api/"):
1083
+ path = path[len(self.server.api_prefix) :]
1084
+ return path, parse_qs(parsed.query, keep_blank_values=True)
1085
+
1086
+ @staticmethod
1087
+ def _one(query: dict[str, list[str]], name: str) -> str:
1088
+ values = query.get(name)
1089
+ if not values or not values[0]:
1090
+ raise DashboardError(
1091
+ HTTPStatus.BAD_REQUEST, f"missing query parameter: {name}"
1092
+ )
1093
+ return values[0]
1094
+
1095
+ @staticmethod
1096
+ def _byte_range(value: str | None, size: int) -> tuple[int, int] | None:
1097
+ if value is None:
1098
+ return None
1099
+ if not value.startswith("bytes=") or "," in value or size <= 0:
1100
+ raise DashboardError(
1101
+ HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE, "invalid byte range"
1102
+ )
1103
+ bounds = value[6:].strip()
1104
+ if "-" not in bounds:
1105
+ raise DashboardError(
1106
+ HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE, "invalid byte range"
1107
+ )
1108
+ first, last = bounds.split("-", 1)
1109
+ try:
1110
+ if first:
1111
+ start = int(first)
1112
+ end = int(last) if last else size - 1
1113
+ if start < 0 or start >= size or end < start:
1114
+ raise ValueError
1115
+ end = min(end, size - 1)
1116
+ else:
1117
+ suffix = int(last)
1118
+ if suffix <= 0:
1119
+ raise ValueError
1120
+ start = max(0, size - suffix)
1121
+ end = size - 1
1122
+ except ValueError as exc:
1123
+ raise DashboardError(
1124
+ HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE, "invalid byte range"
1125
+ ) from exc
1126
+ return start, end
1127
+
1128
+ def _serve_media(self, query: dict[str, list[str]], *, send_body: bool) -> None:
1129
+ handle, _, content_type, size = self.server.store.open_media(
1130
+ self._one(query, "project"), self._one(query, "path")
1131
+ )
1132
+ try:
1133
+ try:
1134
+ selected = self._byte_range(self.headers.get("Range"), size)
1135
+ except DashboardError:
1136
+ self.send_response(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
1137
+ self.send_header("Content-Range", f"bytes */{size}")
1138
+ self.send_header("Content-Length", "0")
1139
+ self.send_header("Accept-Ranges", "bytes")
1140
+ self.end_headers()
1141
+ return
1142
+ start, end = selected if selected is not None else (0, size - 1)
1143
+ length = 0 if size == 0 else end - start + 1
1144
+ self.send_response(
1145
+ HTTPStatus.PARTIAL_CONTENT if selected is not None else HTTPStatus.OK
1146
+ )
1147
+ self.send_header("Content-Type", content_type)
1148
+ self.send_header("Content-Length", str(length))
1149
+ self.send_header("Accept-Ranges", "bytes")
1150
+ if selected is not None:
1151
+ self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
1152
+ self.end_headers()
1153
+ if not send_body or length == 0:
1154
+ return
1155
+ handle.seek(start)
1156
+ remaining = length
1157
+ while remaining:
1158
+ chunk = handle.read(min(64 * 1024, remaining))
1159
+ if not chunk:
1160
+ break
1161
+ self.wfile.write(chunk)
1162
+ remaining -= len(chunk)
1163
+ except (BrokenPipeError, ConnectionResetError):
1164
+ pass
1165
+ finally:
1166
+ handle.close()
1167
+
1168
+ def do_GET(self) -> None:
1169
+ path, query = self._query()
1170
+ if not self._security_ok(require_token=path.startswith("/api/")):
1171
+ return
1172
+ try:
1173
+ if path == "/api/projects":
1174
+ projects, warnings = self.server.store.discover()
1175
+ self._json(HTTPStatus.OK, {"projects": projects, "warnings": warnings})
1176
+ return
1177
+ if path == "/api/status":
1178
+ self._json(
1179
+ HTTPStatus.OK, self.server.store.status(self._one(query, "project"))
1180
+ )
1181
+ return
1182
+ if path == "/api/tree":
1183
+ self._json(
1184
+ HTTPStatus.OK, self.server.store.tree(self._one(query, "project"))
1185
+ )
1186
+ return
1187
+ if path == "/api/file":
1188
+ self._json(
1189
+ HTTPStatus.OK,
1190
+ self.server.store.read_text(
1191
+ self._one(query, "project"), self._one(query, "path")
1192
+ ),
1193
+ )
1194
+ return
1195
+ if path == "/api/media":
1196
+ info = self.server.store.media_info(
1197
+ self._one(query, "project"), self._one(query, "path")
1198
+ )
1199
+ info["contentUrl"] = (
1200
+ f"{self.server.api_prefix}{info['contentUrl']}"
1201
+ )
1202
+ self._json(
1203
+ HTTPStatus.OK,
1204
+ info,
1205
+ )
1206
+ return
1207
+ if path == "/api/media/content":
1208
+ self._serve_media(query, send_body=True)
1209
+ return
1210
+ if path.startswith("/api/"):
1211
+ self._json(HTTPStatus.NOT_FOUND, {"error": "API endpoint not found"})
1212
+ return
1213
+ if path == "/":
1214
+ self.path = "/index.html"
1215
+ super().do_GET()
1216
+ except DashboardError as exc:
1217
+ self._json(exc.status, {"error": exc.message})
1218
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
1219
+ self._json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": str(exc)})
1220
+ except Exception:
1221
+ # A handler that lets anything escape closes the socket without a
1222
+ # status line, so the browser reports a network failure it cannot
1223
+ # act on. Every unexpected type still becomes an answered request.
1224
+ self._json(
1225
+ HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "internal dashboard error"}
1226
+ )
1227
+
1228
+ def do_HEAD(self) -> None:
1229
+ path, query = self._query()
1230
+ if not self._security_ok(require_token=path.startswith("/api/")):
1231
+ return
1232
+ if path == "/api/media/content":
1233
+ try:
1234
+ self._serve_media(query, send_body=False)
1235
+ except DashboardError as exc:
1236
+ self._json(exc.status, {"error": exc.message})
1237
+ except OSError as exc:
1238
+ self._json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": str(exc)})
1239
+ except Exception:
1240
+ self._json(
1241
+ HTTPStatus.INTERNAL_SERVER_ERROR,
1242
+ {"error": "internal dashboard error"},
1243
+ )
1244
+ return
1245
+ if path.startswith("/api/"):
1246
+ self._json(
1247
+ HTTPStatus.METHOD_NOT_ALLOWED,
1248
+ {"error": "HEAD is not supported for API endpoints"},
1249
+ )
1250
+ return
1251
+ if path == "/":
1252
+ self.path = "/index.html"
1253
+ super().do_HEAD()
1254
+
1255
+ def do_PUT(self) -> None:
1256
+ path, query = self._query()
1257
+ if not self._security_ok(require_token=path.startswith("/api/")):
1258
+ return
1259
+ if path != "/api/file":
1260
+ self._json(HTTPStatus.NOT_FOUND, {"error": "API endpoint not found"})
1261
+ return
1262
+ try:
1263
+ raw_length = self.headers.get("Content-Length")
1264
+ if raw_length is None:
1265
+ raise DashboardError(
1266
+ HTTPStatus.LENGTH_REQUIRED, "Content-Length is required"
1267
+ )
1268
+ length = int(raw_length)
1269
+ if length < 0 or length > self.server.store.max_request_bytes:
1270
+ raise DashboardError(
1271
+ HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "request body is too large"
1272
+ )
1273
+ if self.headers.get_content_type() != "application/json":
1274
+ raise DashboardError(
1275
+ HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
1276
+ "Content-Type must be application/json",
1277
+ )
1278
+ payload = json.loads(self.rfile.read(length).decode("utf-8"))
1279
+ if not isinstance(payload, dict):
1280
+ raise DashboardError(
1281
+ HTTPStatus.BAD_REQUEST, "request body must be an object"
1282
+ )
1283
+ result = self.server.store.write_text(
1284
+ self._one(query, "project"),
1285
+ self._one(query, "path"),
1286
+ payload.get("content"),
1287
+ payload.get("expectedVersion"),
1288
+ )
1289
+ self._json(HTTPStatus.OK, result)
1290
+ except DashboardError as exc:
1291
+ self._json(exc.status, {"error": exc.message})
1292
+ except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
1293
+ self._json(HTTPStatus.BAD_REQUEST, {"error": "invalid JSON request"})
1294
+ except OSError as exc:
1295
+ self._json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": str(exc)})
1296
+ except Exception:
1297
+ self._json(
1298
+ HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "internal dashboard error"}
1299
+ )
1300
+
1301
+ def do_POST(self) -> None:
1302
+ path, _ = self._query()
1303
+ if not self._security_ok(require_token=path.startswith("/api/")):
1304
+ return
1305
+ if path != "/api/session":
1306
+ self._json(HTTPStatus.NOT_FOUND, {"error": "API endpoint not found"})
1307
+ return
1308
+ self._json(
1309
+ HTTPStatus.OK,
1310
+ {"status": "ready", "apiBase": self.server.api_prefix},
1311
+ headers={
1312
+ "Set-Cookie": (
1313
+ f"{self.server.session_cookie}={self.server.access_token}; "
1314
+ f"HttpOnly; SameSite=Strict; Path={self.server.api_prefix}/"
1315
+ )
1316
+ },
1317
+ )
1318
+
1319
+
1320
+ def workspace_fingerprint(workspace: Path) -> str:
1321
+ """Identify one workspace without exposing its path in a response header."""
1322
+ return hashlib.sha256(
1323
+ str(workspace).encode("utf-8", "surrogateescape")
1324
+ ).hexdigest()[:16]
1325
+
1326
+
1327
+ def session_file_for(workspace: Path, override: Path | None = None) -> Path:
1328
+ if override is not None:
1329
+ return override.expanduser().resolve()
1330
+ return workspace / Path(str(SESSION_RELATIVE))
1331
+
1332
+
1333
+ def read_session(path: Path) -> dict[str, Any] | None:
1334
+ """Return the recorded session, or ``None`` when there is nothing usable."""
1335
+ try:
1336
+ document = json.loads(path.read_text(encoding="utf-8"))
1337
+ except (OSError, ValueError):
1338
+ return None
1339
+ if not isinstance(document, dict) or document.get("schema_version") != SESSION_SCHEMA:
1340
+ return None
1341
+ required = ("host", "port", "token", "fingerprint", "url", "pid")
1342
+ if any(key not in document for key in required):
1343
+ return None
1344
+ return document
1345
+
1346
+
1347
+ def write_session(path: Path, document: dict[str, Any]) -> None:
1348
+ path.parent.mkdir(parents=True, exist_ok=True)
1349
+ temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
1350
+ descriptor = os.open(
1351
+ str(temporary), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600
1352
+ )
1353
+ try:
1354
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
1355
+ json.dump(document, handle, ensure_ascii=False, indent=2, sort_keys=True)
1356
+ handle.write("\n")
1357
+ handle.flush()
1358
+ os.fsync(handle.fileno())
1359
+ os.replace(temporary, path)
1360
+ finally:
1361
+ with contextlib.suppress(OSError):
1362
+ temporary.unlink()
1363
+
1364
+
1365
+ def session_lock_path(session_path: Path) -> Path:
1366
+ return session_path.with_name(session_path.name + SESSION_LOCK_SUFFIX)
1367
+
1368
+
1369
+ def _try_exclusive_lock(handle: Any) -> bool:
1370
+ """Take the serving lock without waiting. False means someone else holds it."""
1371
+ if os.name == "nt":
1372
+ import msvcrt
1373
+
1374
+ handle.seek(0, os.SEEK_END)
1375
+ if handle.tell() == 0:
1376
+ handle.write(b"0")
1377
+ handle.flush()
1378
+ handle.seek(0)
1379
+ # Reached through getattr for the same reason project_tool.py does: the
1380
+ # POSIX stubs mypy checks against do not declare these Windows names.
1381
+ locking = getattr(msvcrt, "locking")
1382
+ non_blocking = getattr(msvcrt, "LK_NBLCK")
1383
+ try:
1384
+ locking(handle.fileno(), non_blocking, 1)
1385
+ except OSError:
1386
+ return False
1387
+ return True
1388
+ import fcntl
1389
+
1390
+ try:
1391
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
1392
+ except OSError:
1393
+ return False
1394
+ return True
1395
+
1396
+
1397
+ class SessionUnavailable(RuntimeError):
1398
+ """This location cannot hold a session record (read-only workspace, ...).
1399
+
1400
+ Serving does not depend on the record: it exists so --detach can report a
1401
+ URL and --status/--stop can find the server later. A workspace the creator
1402
+ can read but not write must still open, the way it did before sessions
1403
+ existed.
1404
+ """
1405
+
1406
+
1407
+ @contextlib.contextmanager
1408
+ def hold_session_lock(session_path: Path) -> Iterator[bool]:
1409
+ """Hold the serving lock, yielding whether WE hold it (vs. another process).
1410
+
1411
+ Liveness is answered by this lock rather than by probing the recorded port.
1412
+ A PID can be reused and a port can be inherited by something unrelated; a
1413
+ lock is released by the kernel exactly when the serving process ends. It
1414
+ also keeps the dashboard free of any outbound network client.
1415
+ """
1416
+ lock_path = session_lock_path(session_path)
1417
+ try:
1418
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
1419
+ handle = lock_path.open("a+b")
1420
+ except OSError as exc:
1421
+ raise SessionUnavailable(
1422
+ f"cannot use a dashboard session file at {lock_path}: {exc}"
1423
+ ) from exc
1424
+ if not _try_exclusive_lock(handle):
1425
+ handle.close()
1426
+ yield False
1427
+ return
1428
+ try:
1429
+ yield True
1430
+ finally:
1431
+ handle.close()
1432
+
1433
+
1434
+ def session_is_live(session_path: Path) -> bool:
1435
+ """Answer whether a dashboard is still serving this workspace."""
1436
+ try:
1437
+ with hold_session_lock(session_path) as held:
1438
+ return not held
1439
+ except SessionUnavailable:
1440
+ return False
1441
+
1442
+
1443
+ def session_matches(session: Mapping[str, Any], workspace: Path) -> bool:
1444
+ """Is this record about the workspace the caller asked for?
1445
+
1446
+ A --session-file shared between two workspaces would otherwise hand the
1447
+ second creator a link that serves the first one's project.
1448
+ """
1449
+ return session.get("fingerprint") == workspace_fingerprint(workspace)
1450
+
1451
+
1452
+ def watch_workspace(server: Any, workspace: Path, interval: float = 5.0) -> None:
1453
+ """Shut the server down if its workspace stops existing.
1454
+
1455
+ A detached dashboard used to die with its shell. Now that it does not, a
1456
+ creator who deletes or moves the project would otherwise leave a server
1457
+ holding a port and answering on the old token URL for a tree that is gone --
1458
+ and its session record went with the directory, so nothing could stop it.
1459
+ """
1460
+ try:
1461
+ expected = os.stat(workspace)
1462
+ except OSError:
1463
+ return
1464
+ identity = (expected.st_dev, expected.st_ino)
1465
+ misses = 0
1466
+
1467
+ def loop() -> None:
1468
+ nonlocal misses
1469
+ while True:
1470
+ time.sleep(interval)
1471
+ try:
1472
+ current = os.stat(workspace)
1473
+ gone = (current.st_dev, current.st_ino) != identity
1474
+ except OSError:
1475
+ gone = True
1476
+ misses = misses + 1 if gone else 0
1477
+ if misses >= 2: # tolerate one transient stat failure
1478
+ threading.Thread(target=server.shutdown, daemon=True).start()
1479
+ return
1480
+
1481
+ threading.Thread(target=loop, daemon=True).start()
1482
+
1483
+
1484
+ def stop_session(path: Path) -> bool:
1485
+ """Stop the recorded dashboard and forget it. Returns whether one was live."""
1486
+ session = read_session(path)
1487
+ live = session is not None and session_is_live(path)
1488
+ if live:
1489
+ pid = session.get("pid") if session else None
1490
+ if isinstance(pid, int) and pid > 0:
1491
+ with contextlib.suppress(OSError):
1492
+ os.kill(pid, signal.SIGTERM)
1493
+ deadline = time.monotonic() + STOP_TIMEOUT_SECONDS
1494
+ while time.monotonic() < deadline and session_is_live(path):
1495
+ time.sleep(0.1)
1496
+ with contextlib.suppress(OSError):
1497
+ path.unlink()
1498
+ return live
1499
+
1500
+
1501
+ def _detached_child(
1502
+ workspace: Path, *, host: str, port: int, session_path: Path
1503
+ ) -> subprocess.Popen[bytes]:
1504
+ command = [
1505
+ sys.executable,
1506
+ "-B",
1507
+ str(Path(__file__).resolve()),
1508
+ "--workspace",
1509
+ str(workspace),
1510
+ "--host",
1511
+ host,
1512
+ "--port",
1513
+ str(port),
1514
+ "--session-file",
1515
+ str(session_path),
1516
+ ]
1517
+ log_path = session_path.with_name(f"{session_path.stem}.log")
1518
+ log_path.parent.mkdir(parents=True, exist_ok=True)
1519
+ log = open(log_path, "ab")
1520
+ options: dict[str, Any] = {}
1521
+ if os.name == "nt":
1522
+ # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit and
1523
+ # no Ctrl-C from the launching shell.
1524
+ options["creationflags"] = 0x00000008 | 0x00000200
1525
+ else:
1526
+ options["start_new_session"] = True
1527
+ try:
1528
+ return subprocess.Popen(
1529
+ command,
1530
+ stdin=subprocess.DEVNULL,
1531
+ stdout=log,
1532
+ stderr=log,
1533
+ close_fds=True,
1534
+ cwd=str(workspace),
1535
+ **options,
1536
+ )
1537
+ finally:
1538
+ log.close()
1539
+
1540
+
1541
+ def start_detached(
1542
+ workspace: Path, *, host: str, port: int, session_path: Path
1543
+ ) -> dict[str, Any]:
1544
+ """Start a dashboard that outlives the shell that asked for it."""
1545
+ process = _detached_child(workspace, host=host, port=port, session_path=session_path)
1546
+ deadline = time.monotonic() + DETACH_TIMEOUT_SECONDS
1547
+ while time.monotonic() < deadline:
1548
+ session = read_session(session_path)
1549
+ if session is not None and session.get("pid") == process.pid:
1550
+ return session
1551
+ if process.poll() is not None:
1552
+ break
1553
+ time.sleep(0.1)
1554
+ if process.poll() is None:
1555
+ with contextlib.suppress(OSError):
1556
+ process.kill()
1557
+ log_path = session_path.with_name(f"{session_path.stem}.log")
1558
+ raise RuntimeError(f"dashboard did not start; see {log_path}")
1559
+
1560
+
1561
+ def create_server(
1562
+ workspace: Path,
1563
+ *,
1564
+ host: str = "127.0.0.1",
1565
+ port: int = 8765,
1566
+ suite_root: Path | None = None,
1567
+ max_depth: int = DEFAULT_MAX_DEPTH,
1568
+ max_nodes: int = DEFAULT_MAX_NODES,
1569
+ max_file_bytes: int = DEFAULT_MAX_FILE_BYTES,
1570
+ max_media_bytes: int = DEFAULT_MAX_MEDIA_BYTES,
1571
+ ) -> DashboardHTTPServer:
1572
+ try:
1573
+ address = ipaddress.ip_address(host)
1574
+ except ValueError:
1575
+ address = None
1576
+ if not _is_loopback(host) or (address is not None and address.version != 4):
1577
+ raise ValueError("dashboard may only bind to an IPv4 loopback address")
1578
+ suite = (suite_root or SKILL_ROOT).resolve()
1579
+ store = ProjectStore(
1580
+ workspace,
1581
+ load_project_tool(suite),
1582
+ max_depth=max_depth,
1583
+ max_nodes=max_nodes,
1584
+ max_file_bytes=max_file_bytes,
1585
+ max_media_bytes=max_media_bytes,
1586
+ )
1587
+ return DashboardHTTPServer((host, port), store)
1588
+
1589
+
1590
+ def main(argv: list[str] | None = None) -> int:
1591
+ parser = argparse.ArgumentParser(
1592
+ description="Serve the local short-drama project dashboard"
1593
+ )
1594
+ parser.add_argument(
1595
+ "--workspace", required=True, type=Path, help="explicit workspace root to scan"
1596
+ )
1597
+ parser.add_argument(
1598
+ "--host", default="127.0.0.1", help="loopback address (default: 127.0.0.1)"
1599
+ )
1600
+ parser.add_argument("--port", default=8765, type=int)
1601
+ parser.add_argument(
1602
+ "--open",
1603
+ action="store_true",
1604
+ help="open the dashboard in the default browser after binding",
1605
+ )
1606
+ parser.add_argument(
1607
+ "--detach",
1608
+ action="store_true",
1609
+ help="serve in a background process that outlives this shell",
1610
+ )
1611
+ parser.add_argument(
1612
+ "--restart",
1613
+ action="store_true",
1614
+ help="stop a dashboard already serving this workspace before starting",
1615
+ )
1616
+ parser.add_argument(
1617
+ "--status",
1618
+ action="store_true",
1619
+ help="print the recorded dashboard for this workspace and exit",
1620
+ )
1621
+ parser.add_argument(
1622
+ "--stop",
1623
+ action="store_true",
1624
+ help="stop the dashboard recorded for this workspace and exit",
1625
+ )
1626
+ parser.add_argument(
1627
+ "--session-file",
1628
+ type=Path,
1629
+ help=f"where to record the running dashboard (default: {SESSION_RELATIVE})",
1630
+ )
1631
+ args = parser.parse_args(argv)
1632
+ if args.status and args.stop:
1633
+ parser.error("--status and --stop cannot be combined")
1634
+
1635
+ workspace = args.workspace.expanduser().resolve()
1636
+ session_path = session_file_for(workspace, args.session_file)
1637
+ if not (args.status or args.stop) and not workspace.is_dir():
1638
+ # Creating a mistyped --workspace and serving it as an empty dashboard
1639
+ # leaves a long-lived server for a directory that never existed.
1640
+ print(f"workspace is not an existing directory: {workspace}", file=sys.stderr)
1641
+ return 2
1642
+
1643
+ if args.status:
1644
+ session = read_session(session_path)
1645
+ holding = session is not None and session_is_live(session_path)
1646
+ mine = holding and session is not None and session_matches(session, workspace)
1647
+ status: dict[str, Any] = {
1648
+ "running": bool(mine),
1649
+ "workspace": str(workspace),
1650
+ "session_file": str(session_path),
1651
+ }
1652
+ if holding and session is not None:
1653
+ status["url"] = session["url"]
1654
+ status["pid"] = session["pid"]
1655
+ recorded = session.get("workspace")
1656
+ if not mine:
1657
+ # The record is live but describes a different workspace.
1658
+ status["serves_workspace"] = recorded
1659
+ print(json.dumps(status, ensure_ascii=False, sort_keys=True), flush=True)
1660
+ return 0 if mine else 1
1661
+
1662
+ if args.stop:
1663
+ stopped = stop_session(session_path)
1664
+ print("Dashboard stopped" if stopped else "No dashboard was running", flush=True)
1665
+ return 0
1666
+
1667
+ existing = read_session(session_path)
1668
+ if (
1669
+ existing is not None
1670
+ and session_matches(existing, workspace)
1671
+ and session_is_live(session_path)
1672
+ ):
1673
+ if args.restart:
1674
+ stop_session(session_path)
1675
+ else:
1676
+ url = str(existing["url"])
1677
+ print(f"Dashboard: {url}", flush=True)
1678
+ print("Reusing the dashboard already serving this workspace", flush=True)
1679
+ if args.open:
1680
+ webbrowser.open(url)
1681
+ return 0
1682
+
1683
+ if args.detach:
1684
+ try:
1685
+ session = start_detached(
1686
+ workspace, host=args.host, port=args.port, session_path=session_path
1687
+ )
1688
+ except SessionUnavailable as exc:
1689
+ print(f"{exc}", file=sys.stderr)
1690
+ return 2
1691
+ except RuntimeError as exc:
1692
+ # Losing the race to another start lands here: the winner took the
1693
+ # serving lock and this child exited without recording anything.
1694
+ # A dashboard IS serving this workspace, so report its URL.
1695
+ winner = read_session(session_path)
1696
+ live = winner is not None and session_is_live(session_path)
1697
+ if live and winner is not None and session_matches(winner, workspace):
1698
+ url = str(winner["url"])
1699
+ print(f"Dashboard: {url}", flush=True)
1700
+ print("Reusing the dashboard already serving this workspace", flush=True)
1701
+ if args.open:
1702
+ webbrowser.open(url)
1703
+ return 0
1704
+ if live and winner is not None:
1705
+ print(
1706
+ f"{session_path} already records a dashboard serving "
1707
+ f"{winner.get('workspace')}; give this workspace its own "
1708
+ "--session-file",
1709
+ file=sys.stderr,
1710
+ )
1711
+ return 1
1712
+ print(str(exc), file=sys.stderr)
1713
+ return 1
1714
+ url = str(session["url"])
1715
+ print(f"Dashboard: {url}", flush=True)
1716
+ print(f"Serving in the background as pid {session['pid']}", flush=True)
1717
+ if args.open:
1718
+ webbrowser.open(url)
1719
+ return 0
1720
+
1721
+ with contextlib.ExitStack() as stack:
1722
+ try:
1723
+ held = stack.enter_context(hold_session_lock(session_path))
1724
+ recordable = True
1725
+ except SessionUnavailable as exc:
1726
+ # A read-only workspace still opens. The record is what --detach and
1727
+ # --status need; serving never depended on it.
1728
+ print(f"{exc}", file=sys.stderr)
1729
+ print(
1730
+ "serving without a session record; --status and --stop cannot find it",
1731
+ file=sys.stderr,
1732
+ )
1733
+ held, recordable = True, False
1734
+ if not held:
1735
+ reused = read_session(session_path)
1736
+ if reused is not None and session_matches(reused, workspace):
1737
+ print(f"Dashboard: {reused['url']}", flush=True)
1738
+ print("Reusing the dashboard already serving this workspace", flush=True)
1739
+ return 0
1740
+ print(
1741
+ "another dashboard is already starting for this workspace",
1742
+ file=sys.stderr,
1743
+ )
1744
+ return 1
1745
+ server = create_server(workspace, host=args.host, port=args.port)
1746
+ raw_host, port = server.server_address[:2]
1747
+ host = raw_host.decode("ascii") if isinstance(raw_host, bytes) else raw_host
1748
+ display_host = f"[{host}]" if ":" in host else host
1749
+ scheme = "http"
1750
+ url = f"{scheme}://{display_host}:{port}/#{server.access_token}"
1751
+ if recordable:
1752
+ try:
1753
+ write_session(
1754
+ session_path,
1755
+ {
1756
+ "schema_version": SESSION_SCHEMA,
1757
+ "workspace": str(workspace),
1758
+ "fingerprint": server.workspace_fingerprint,
1759
+ "host": host,
1760
+ "port": port,
1761
+ "pid": os.getpid(),
1762
+ "token": server.access_token,
1763
+ "url": url,
1764
+ "started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
1765
+ },
1766
+ )
1767
+ except OSError as exc:
1768
+ print(f"cannot write the session record: {exc}", file=sys.stderr)
1769
+ recordable = False
1770
+ # A detached server is stopped with SIGTERM. Handle it so the recorded
1771
+ # session is removed instead of being left behind. `shutdown` blocks
1772
+ # until `serve_forever` returns, so it cannot run on the thread that is
1773
+ # inside `serve_forever`.
1774
+ with contextlib.suppress(ValueError, OSError, AttributeError):
1775
+ signal.signal(
1776
+ signal.SIGTERM,
1777
+ lambda *_: threading.Thread(target=server.shutdown, daemon=True).start(),
1778
+ )
1779
+ watch_workspace(server, workspace)
1780
+ print(f"Dashboard: {url}", flush=True)
1781
+ if args.open:
1782
+ webbrowser.open(url)
1783
+ try:
1784
+ server.serve_forever()
1785
+ except KeyboardInterrupt:
1786
+ pass
1787
+ finally:
1788
+ server.server_close()
1789
+ if recordable:
1790
+ current = read_session(session_path)
1791
+ if current is not None and current.get("pid") == os.getpid():
1792
+ with contextlib.suppress(OSError):
1793
+ session_path.unlink()
1794
+ return 0
1795
+
1796
+
1797
+ if __name__ == "__main__":
1798
+ raise SystemExit(main())