manigent-server 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (1004) hide show
  1. manigent/_catalog/mcps/chess/README.md +38 -0
  2. manigent/_catalog/mcps/chess/mcp.json +9 -0
  3. manigent/_catalog/mcps/registry.json +9 -0
  4. manigent/_catalog/skills/brief/SKILL.md +339 -0
  5. manigent/_catalog/skills/broaden-options/SKILL.md +133 -0
  6. manigent/_catalog/skills/calendar/SKILL.md +44 -0
  7. manigent/_catalog/skills/calendar/starter_config.jsonc +25 -0
  8. manigent/_catalog/skills/gmail/SKILL.md +73 -0
  9. manigent/_catalog/skills/google-calendar/SKILL.md +44 -0
  10. manigent/_catalog/skills/google-drive/SKILL.md +42 -0
  11. manigent/_catalog/skills/google-drive-write/SKILL.md +45 -0
  12. manigent/_catalog/skills/google-drive-write/starter_config.jsonc +45 -0
  13. manigent/_catalog/skills/grill-me/SKILL.md +173 -0
  14. manigent/_catalog/skills/homekit/SKILL.md +37 -0
  15. manigent/_catalog/skills/http-api/SKILL.md +117 -0
  16. manigent/_catalog/skills/ib/SKILL.md +89 -0
  17. manigent/_catalog/skills/ib/starter_config.jsonc +35 -0
  18. manigent/_catalog/skills/mailbox/SKILL.md +47 -0
  19. manigent/_catalog/skills/mailbox/starter_config.jsonc +38 -0
  20. manigent/_catalog/skills/media/SKILL.md +93 -0
  21. manigent/_catalog/skills/media/starter_config.jsonc +63 -0
  22. manigent/_catalog/skills/osxphotos/SKILL.md +41 -0
  23. manigent/_catalog/skills/photo-story/SKILL.md +285 -0
  24. manigent/_catalog/skills/playwright-browser/SKILL.md +291 -0
  25. manigent/_catalog/skills/playwright-browser/starter_config.jsonc +44 -0
  26. manigent/_catalog/skills/playwright-save-skill/SKILL.md +149 -0
  27. manigent/_catalog/skills/registry.json +166 -0
  28. manigent/_catalog/skills/web-artifacts/SKILL.md +194 -0
  29. manigent/api/action_executor.py +404 -0
  30. manigent/api/action_validator.py +531 -0
  31. manigent/api/actions.py +221 -0
  32. manigent/api/client.py +558 -0
  33. manigent/api/control.py +806 -0
  34. manigent/api/edge_protocol.py +123 -0
  35. manigent/api/fs_protocol.py +450 -0
  36. manigent/api/host_protocol.py +298 -0
  37. manigent/api/managed_job_client.py +124 -0
  38. manigent/api/responses.py +342 -0
  39. manigent/api/retry_transport.py +178 -0
  40. manigent/api/turn_protocol.py +78 -0
  41. manigent/cli_lifecycle.py +467 -0
  42. manigent/cli_server.py +978 -0
  43. manigent/doctor.py +1167 -0
  44. manigent/hub_protocol/canonical.py +375 -0
  45. manigent/hub_protocol/codes.py +171 -0
  46. manigent/hub_protocol/constants.py +71 -0
  47. manigent/hub_protocol/envelope.py +218 -0
  48. manigent/hub_protocol/frames.py +788 -0
  49. manigent/hub_protocol/http.py +260 -0
  50. manigent/hub_protocol/ids.py +170 -0
  51. manigent/hub_protocol/keys.py +195 -0
  52. manigent/hub_protocol/text.py +75 -0
  53. manigent/llm/base.py +1072 -0
  54. manigent/llm/prompt_builder.py +2450 -0
  55. manigent/llm/triggers.py +68 -0
  56. manigent/models/agent.py +2905 -0
  57. manigent/models/agent_bundle.py +298 -0
  58. manigent/models/brief_tab.py +311 -0
  59. manigent/models/chat_message.py +231 -0
  60. manigent/models/chatroom.py +1108 -0
  61. manigent/models/context.py +1159 -0
  62. manigent/models/desk_label.py +836 -0
  63. manigent/models/desk_read_state.py +220 -0
  64. manigent/models/desk_sign_off.py +267 -0
  65. manigent/models/desk_sop.py +965 -0
  66. manigent/models/desk_todo.py +1603 -0
  67. manigent/models/desk_todo_link.py +404 -0
  68. manigent/models/file_cache.py +75 -0
  69. manigent/models/host.py +732 -0
  70. manigent/models/install_token.py +401 -0
  71. manigent/models/invitation_code.py +162 -0
  72. manigent/models/knowledge_pack.py +552 -0
  73. manigent/models/managed_job.py +295 -0
  74. manigent/models/model_config.py +564 -0
  75. manigent/models/model_defaults.py +238 -0
  76. manigent/models/onboarding_plan.py +161 -0
  77. manigent/models/participant.py +449 -0
  78. manigent/models/persistable.py +892 -0
  79. manigent/models/plan_markdown.py +1774 -0
  80. manigent/models/project.py +1343 -0
  81. manigent/models/project_plan.py +8104 -0
  82. manigent/models/project_report.py +145 -0
  83. manigent/models/provenance.py +261 -0
  84. manigent/models/provider_agents.py +101 -0
  85. manigent/models/public_request.py +108 -0
  86. manigent/models/reflection_schedule.py +191 -0
  87. manigent/models/scheduled_message.py +285 -0
  88. manigent/models/share_token.py +190 -0
  89. manigent/models/skill_version.py +31 -0
  90. manigent/models/sop_template.py +187 -0
  91. manigent/models/thinking_partner_plan.py +187 -0
  92. manigent/models/tunnel_binding.py +129 -0
  93. manigent/models/user.py +1276 -0
  94. manigent/models/work_tracker.py +510 -0
  95. manigent/runner/invocation_registry.py +187 -0
  96. manigent/server/agent_capabilities.py +119 -0
  97. manigent/server/agent_visibility.py +119 -0
  98. manigent/server/announcements.py +119 -0
  99. manigent/server/app.py +807 -0
  100. manigent/server/auth.py +355 -0
  101. manigent/server/batch_timeout.py +94 -0
  102. manigent/server/channels/access.py +194 -0
  103. manigent/server/channels/adapter.py +295 -0
  104. manigent/server/channels/config.py +693 -0
  105. manigent/server/channels/contacts.py +39 -0
  106. manigent/server/channels/email_adapter.py +436 -0
  107. manigent/server/channels/email_address.py +145 -0
  108. manigent/server/channels/email_form.py +242 -0
  109. manigent/server/channels/email_format.py +285 -0
  110. manigent/server/channels/email_mail.py +427 -0
  111. manigent/server/channels/email_mailgun.py +164 -0
  112. manigent/server/channels/email_text.py +280 -0
  113. manigent/server/channels/file_links.py +127 -0
  114. manigent/server/channels/gateway.py +2998 -0
  115. manigent/server/channels/hub_delivery.py +211 -0
  116. manigent/server/channels/hub_store.py +1730 -0
  117. manigent/server/channels/line.py +645 -0
  118. manigent/server/channels/line_format.py +589 -0
  119. manigent/server/channels/log_redaction.py +145 -0
  120. manigent/server/channels/store.py +3316 -0
  121. manigent/server/channels/telegram.py +422 -0
  122. manigent/server/channels/telegram_format.py +312 -0
  123. manigent/server/channels/webchat.py +261 -0
  124. manigent/server/channels/webchat_widget.js +1310 -0
  125. manigent/server/channels/whatsapp.py +707 -0
  126. manigent/server/channels/whatsapp_format.py +511 -0
  127. manigent/server/chat_search_index.py +1203 -0
  128. manigent/server/config.py +61 -0
  129. manigent/server/context.py +121 -0
  130. manigent/server/coordinator_rule.py +115 -0
  131. manigent/server/desk_todo_notify.py +300 -0
  132. manigent/server/edge_hub.py +577 -0
  133. manigent/server/email.py +354 -0
  134. manigent/server/errors.py +83 -0
  135. manigent/server/home_fs_relay.py +392 -0
  136. manigent/server/hub/adapter.py +1395 -0
  137. manigent/server/hub/agent_send_guard.py +196 -0
  138. manigent/server/hub/avatar.py +342 -0
  139. manigent/server/hub/browser.py +778 -0
  140. manigent/server/hub/client.py +859 -0
  141. manigent/server/hub/demand.py +49 -0
  142. manigent/server/hub/directory.py +385 -0
  143. manigent/server/hub/enroll.py +400 -0
  144. manigent/server/hub/http.py +134 -0
  145. manigent/server/hub/identity.py +61 -0
  146. manigent/server/hub/keys.py +588 -0
  147. manigent/server/hub/mirror.py +802 -0
  148. manigent/server/hub/operator.py +817 -0
  149. manigent/server/hub/retain.py +361 -0
  150. manigent/server/hub/room_bridge.py +741 -0
  151. manigent/server/hub/runtime.py +691 -0
  152. manigent/server/hub/trust.py +182 -0
  153. manigent/server/hub/views.py +316 -0
  154. manigent/server/idempotency.py +205 -0
  155. manigent/server/in_flight_restore.py +181 -0
  156. manigent/server/inspection.py +816 -0
  157. manigent/server/isolation_gate.py +516 -0
  158. manigent/server/isolation_notice.py +310 -0
  159. manigent/server/managed_job_events.py +242 -0
  160. manigent/server/mcp_registry.py +118 -0
  161. manigent/server/message_forward.py +78 -0
  162. manigent/server/message_scheduler.py +176 -0
  163. manigent/server/notification_assets.py +164 -0
  164. manigent/server/oauth_login/cli_state.py +120 -0
  165. manigent/server/oauth_login/config.py +90 -0
  166. manigent/server/oauth_login/errors.py +78 -0
  167. manigent/server/oauth_login/flow.py +223 -0
  168. manigent/server/oauth_login/handoff.py +63 -0
  169. manigent/server/oauth_login/providers.py +45 -0
  170. manigent/server/oauth_state.py +81 -0
  171. manigent/server/owner_isolation.py +466 -0
  172. manigent/server/participant_liveness.py +82 -0
  173. manigent/server/public_profile.py +149 -0
  174. manigent/server/public_requests/candidates.py +116 -0
  175. manigent/server/public_requests/dispatch.py +121 -0
  176. manigent/server/public_requests/ranking.py +153 -0
  177. manigent/server/recent_activity.py +250 -0
  178. manigent/server/reflection_completion_subscriber.py +151 -0
  179. manigent/server/reflection_scheduler.py +239 -0
  180. manigent/server/requester_key_migration.py +261 -0
  181. manigent/server/room_flow.py +139 -0
  182. manigent/server/routes/_batch.py +218 -0
  183. manigent/server/routes/_fd_routing.py +155 -0
  184. manigent/server/routes/_mentions.py +70 -0
  185. manigent/server/routes/agent_env.py +188 -0
  186. manigent/server/routes/agent_fs.py +450 -0
  187. manigent/server/routes/agents.py +1206 -0
  188. manigent/server/routes/announcements.py +79 -0
  189. manigent/server/routes/auth.py +387 -0
  190. manigent/server/routes/brief_tabs.py +404 -0
  191. manigent/server/routes/changelog.py +237 -0
  192. manigent/server/routes/channels.py +993 -0
  193. manigent/server/routes/chatrooms.py +871 -0
  194. manigent/server/routes/desk_labels.py +298 -0
  195. manigent/server/routes/desk_latest_messages.py +116 -0
  196. manigent/server/routes/desk_read_state.py +110 -0
  197. manigent/server/routes/desk_sign_off.py +88 -0
  198. manigent/server/routes/desk_sops.py +216 -0
  199. manigent/server/routes/desk_todos.py +692 -0
  200. manigent/server/routes/desk_tour.py +44 -0
  201. manigent/server/routes/files.py +572 -0
  202. manigent/server/routes/hosts.py +484 -0
  203. manigent/server/routes/hub.py +534 -0
  204. manigent/server/routes/hub_channels.py +234 -0
  205. manigent/server/routes/inbox.py +581 -0
  206. manigent/server/routes/install.ps1 +436 -0
  207. manigent/server/routes/install.py +324 -0
  208. manigent/server/routes/install.sh +566 -0
  209. manigent/server/routes/isolation_check.py +145 -0
  210. manigent/server/routes/knowledge_packs.py +477 -0
  211. manigent/server/routes/managed_jobs.py +196 -0
  212. manigent/server/routes/mcps.py +271 -0
  213. manigent/server/routes/messages.py +1972 -0
  214. manigent/server/routes/model_defaults.py +279 -0
  215. manigent/server/routes/oauth.py +237 -0
  216. manigent/server/routes/oauth_login.py +351 -0
  217. manigent/server/routes/owner_isolation.py +91 -0
  218. manigent/server/routes/project_plans.py +1521 -0
  219. manigent/server/routes/project_reports.py +235 -0
  220. manigent/server/routes/projects.py +2047 -0
  221. manigent/server/routes/public_agents.py +151 -0
  222. manigent/server/routes/public_requests.py +181 -0
  223. manigent/server/routes/reflection.py +93 -0
  224. manigent/server/routes/scheduled_messages.py +263 -0
  225. manigent/server/routes/search.py +94 -0
  226. manigent/server/routes/sharing.py +156 -0
  227. manigent/server/routes/skills.py +406 -0
  228. manigent/server/routes/templates.py +219 -0
  229. manigent/server/routes/turn_tokens.py +119 -0
  230. manigent/server/routes/webchat.py +406 -0
  231. manigent/server/routes/websocket.py +431 -0
  232. manigent/server/serving_only.py +200 -0
  233. manigent/server/signup_policy.py +120 -0
  234. manigent/server/skill_registry.py +164 -0
  235. manigent/server/socket_events.py +858 -0
  236. manigent/server/timeout_checker.py +446 -0
  237. manigent/server/tunnel_subscriber.py +1000 -0
  238. manigent/server/turn_tokens.py +285 -0
  239. manigent/server/ws_hub.py +556 -0
  240. manigent/server_cli.py +31 -0
  241. manigent/sync/changelog.py +885 -0
  242. manigent/sync/console_subscriber.py +222 -0
  243. manigent/sync/runloop.py +619 -0
  244. manigent/sync/runloop_manager.py +197 -0
  245. manigent/sync/subscriber.py +98 -0
  246. manigent/utils/agent_processes.py +276 -0
  247. manigent/utils/agent_storage.py +125 -0
  248. manigent/utils/email.py +563 -0
  249. manigent/utils/env_store.py +174 -0
  250. manigent/utils/extra_dirs.py +85 -0
  251. manigent/utils/file_io.py +485 -0
  252. manigent/utils/invoke_timeout.py +87 -0
  253. manigent/utils/jsonc.py +78 -0
  254. manigent/utils/knowledge_index.py +148 -0
  255. manigent/utils/nofollow_fs.py +279 -0
  256. manigent/utils/notification_center.py +67 -0
  257. manigent/utils/version.py +57 -0
  258. manigent/web/frontend/.design-sync-entry.tsx +74 -0
  259. manigent/web/frontend/.design-sync-provider.tsx +46 -0
  260. manigent/web/frontend/.eslintrc.cjs +183 -0
  261. manigent/web/frontend/.gitignore +6 -0
  262. manigent/web/frontend/app/page.tsx +39 -0
  263. manigent/web/frontend/hub-terms.html +192 -0
  264. manigent/web/frontend/index.html +15 -0
  265. manigent/web/frontend/landing.html +15 -0
  266. manigent/web/frontend/package-lock.json +8028 -0
  267. manigent/web/frontend/package.json +55 -0
  268. manigent/web/frontend/postcss.config.js +6 -0
  269. manigent/web/frontend/privacy.html +217 -0
  270. manigent/web/frontend/private-beta-announcement.html +19 -0
  271. manigent/web/frontend/public/apple-touch-icon.png +0 -0
  272. manigent/web/frontend/public/brand-hero-banner.png +0 -0
  273. manigent/web/frontend/public/brand-hero.png +0 -0
  274. manigent/web/frontend/public/favicon.png +0 -0
  275. manigent/web/frontend/public/favicon.svg +8 -0
  276. manigent/web/frontend/public/icon-transparent-sm.png +0 -0
  277. manigent/web/frontend/public/icon.png +0 -0
  278. manigent/web/frontend/public/manigent-mark.svg +7 -0
  279. manigent/web/frontend/public/vite.svg +5 -0
  280. manigent/web/frontend/public.html +15 -0
  281. manigent/web/frontend/src/App.activeAccountRedirect.test.tsx +236 -0
  282. manigent/web/frontend/src/App.hubHostDm.test.tsx +119 -0
  283. manigent/web/frontend/src/App.hubHostProject.test.tsx +142 -0
  284. manigent/web/frontend/src/App.hubRoutes.test.tsx +187 -0
  285. manigent/web/frontend/src/App.oauthAddCallback.test.tsx +263 -0
  286. manigent/web/frontend/src/App.tsx +246 -0
  287. manigent/web/frontend/src/api/attachments.ts +32 -0
  288. manigent/web/frontend/src/api/batchLoader.test.ts +212 -0
  289. manigent/web/frontend/src/api/batchLoader.ts +124 -0
  290. manigent/web/frontend/src/api/chatroomsBatch.test.tsx +392 -0
  291. manigent/web/frontend/src/api/client.ts +321 -0
  292. manigent/web/frontend/src/api/crossAccountAuth.test.ts +388 -0
  293. manigent/web/frontend/src/api/endpoints/agentEnv.ts +24 -0
  294. manigent/web/frontend/src/api/endpoints/agentFs.ts +125 -0
  295. manigent/web/frontend/src/api/endpoints/agentSearch.ts +19 -0
  296. manigent/web/frontend/src/api/endpoints/agents.ts +66 -0
  297. manigent/web/frontend/src/api/endpoints/announcements.ts +26 -0
  298. manigent/web/frontend/src/api/endpoints/auth.ts +361 -0
  299. manigent/web/frontend/src/api/endpoints/authLogin.test.ts +236 -0
  300. manigent/web/frontend/src/api/endpoints/batch.ts +64 -0
  301. manigent/web/frontend/src/api/endpoints/briefTabs.ts +81 -0
  302. manigent/web/frontend/src/api/endpoints/channels.ts +285 -0
  303. manigent/web/frontend/src/api/endpoints/computers.ts +212 -0
  304. manigent/web/frontend/src/api/endpoints/deskLabels.ts +151 -0
  305. manigent/web/frontend/src/api/endpoints/deskLatestMessages.ts +23 -0
  306. manigent/web/frontend/src/api/endpoints/deskReadState.ts +41 -0
  307. manigent/web/frontend/src/api/endpoints/deskSignOffs.ts +74 -0
  308. manigent/web/frontend/src/api/endpoints/deskSops.ts +53 -0
  309. manigent/web/frontend/src/api/endpoints/deskTodoAttachments.ts +69 -0
  310. manigent/web/frontend/src/api/endpoints/deskTodoProjects.test.ts +99 -0
  311. manigent/web/frontend/src/api/endpoints/deskTodoProjects.ts +114 -0
  312. manigent/web/frontend/src/api/endpoints/deskTodos.ts +164 -0
  313. manigent/web/frontend/src/api/endpoints/deskTour.ts +16 -0
  314. manigent/web/frontend/src/api/endpoints/dm.ts +137 -0
  315. manigent/web/frontend/src/api/endpoints/files.ts +55 -0
  316. manigent/web/frontend/src/api/endpoints/hub.contract.test.ts +94 -0
  317. manigent/web/frontend/src/api/endpoints/hub.keys.ts +260 -0
  318. manigent/web/frontend/src/api/endpoints/hub.test.ts +203 -0
  319. manigent/web/frontend/src/api/endpoints/hub.ts +654 -0
  320. manigent/web/frontend/src/api/endpoints/index.ts +15 -0
  321. manigent/web/frontend/src/api/endpoints/install.ts +74 -0
  322. manigent/web/frontend/src/api/endpoints/isolationCheck.ts +36 -0
  323. manigent/web/frontend/src/api/endpoints/knowledgePacks.ts +179 -0
  324. manigent/web/frontend/src/api/endpoints/mcps.ts +36 -0
  325. manigent/web/frontend/src/api/endpoints/messages.held.test.ts +43 -0
  326. manigent/web/frontend/src/api/endpoints/messages.ts +95 -0
  327. manigent/web/frontend/src/api/endpoints/modelConfigs.ts +232 -0
  328. manigent/web/frontend/src/api/endpoints/oauthApi.test.ts +110 -0
  329. manigent/web/frontend/src/api/endpoints/projectPlans.ts +608 -0
  330. manigent/web/frontend/src/api/endpoints/projectReports.ts +36 -0
  331. manigent/web/frontend/src/api/endpoints/projects.ts +124 -0
  332. manigent/web/frontend/src/api/endpoints/publicRequests.ts +75 -0
  333. manigent/web/frontend/src/api/endpoints/reflection.ts +32 -0
  334. manigent/web/frontend/src/api/endpoints/roomFlow.ts +14 -0
  335. manigent/web/frontend/src/api/endpoints/scheduledMessages.ts +60 -0
  336. manigent/web/frontend/src/api/endpoints/search.ts +27 -0
  337. manigent/web/frontend/src/api/endpoints/sharing.ts +42 -0
  338. manigent/web/frontend/src/api/endpoints/skills.ts +67 -0
  339. manigent/web/frontend/src/api/endpoints/templates.ts +72 -0
  340. manigent/web/frontend/src/api/loaders.ts +22 -0
  341. manigent/web/frontend/src/api/singleIssuer.test.ts +148 -0
  342. manigent/web/frontend/src/api/transientRetry.test.ts +176 -0
  343. manigent/web/frontend/src/api/transientRetry.ts +140 -0
  344. manigent/web/frontend/src/api/websocket.resync.test.ts +111 -0
  345. manigent/web/frontend/src/api/websocket.ts +335 -0
  346. manigent/web/frontend/src/components/agent/AgentNameLink.tsx +24 -0
  347. manigent/web/frontend/src/components/agent/AgentViewActions.test.tsx +41 -0
  348. manigent/web/frontend/src/components/agent/AgentViewActions.tsx +89 -0
  349. manigent/web/frontend/src/components/agent/CreateAgentPage.tsx +535 -0
  350. manigent/web/frontend/src/components/agent/JsonConfigEditor.tsx +33 -0
  351. manigent/web/frontend/src/components/agent/KnowledgePackList.tsx +103 -0
  352. manigent/web/frontend/src/components/agent/McpCatalog.tsx +261 -0
  353. manigent/web/frontend/src/components/agent/McpConfigModal.tsx +224 -0
  354. manigent/web/frontend/src/components/agent/PreConsentModal.tsx +172 -0
  355. manigent/web/frontend/src/components/agent/SkillCatalog.tsx +364 -0
  356. manigent/web/frontend/src/components/agent/SkillConfigModal.tsx +221 -0
  357. manigent/web/frontend/src/components/auth/AddAccountPage.test.tsx +181 -0
  358. manigent/web/frontend/src/components/auth/AddAccountPage.tsx +201 -0
  359. manigent/web/frontend/src/components/auth/AuthShell.tsx +462 -0
  360. manigent/web/frontend/src/components/auth/LoginPage.test.tsx +384 -0
  361. manigent/web/frontend/src/components/auth/LoginPage.tsx +129 -0
  362. manigent/web/frontend/src/components/auth/OAuthCallbackPage.test.tsx +185 -0
  363. manigent/web/frontend/src/components/auth/OAuthCallbackPage.tsx +154 -0
  364. manigent/web/frontend/src/components/auth/OAuthLinkedBanner.tsx +44 -0
  365. manigent/web/frontend/src/components/auth/ProviderButtons.tsx +94 -0
  366. manigent/web/frontend/src/components/auth/SignupPage.tsx +437 -0
  367. manigent/web/frontend/src/components/auth/StepProgress.tsx +60 -0
  368. manigent/web/frontend/src/components/auth/VerifyEmailPage.test.tsx +155 -0
  369. manigent/web/frontend/src/components/auth/VerifyEmailPage.tsx +211 -0
  370. manigent/web/frontend/src/components/auth/index.ts +3 -0
  371. manigent/web/frontend/src/components/auth/oauthErrors.test.ts +107 -0
  372. manigent/web/frontend/src/components/auth/oauthErrors.ts +82 -0
  373. manigent/web/frontend/src/components/auth/useUsernameCheck.test.tsx +68 -0
  374. manigent/web/frontend/src/components/auth/useUsernameCheck.ts +86 -0
  375. manigent/web/frontend/src/components/beta/BetaPage.tsx +21 -0
  376. manigent/web/frontend/src/components/beta/DayAtMyDesk.tsx +120 -0
  377. manigent/web/frontend/src/components/beta/Footer.tsx +82 -0
  378. manigent/web/frontend/src/components/beta/Hero.tsx +224 -0
  379. manigent/web/frontend/src/components/beta/Nav.tsx +51 -0
  380. manigent/web/frontend/src/components/beta/Pillars.tsx +172 -0
  381. manigent/web/frontend/src/components/beta/TwoModels.tsx +146 -0
  382. manigent/web/frontend/src/components/beta/constants.ts +3 -0
  383. manigent/web/frontend/src/components/briefing/BriefingFrame.test.tsx +208 -0
  384. manigent/web/frontend/src/components/briefing/BriefingFrame.tsx +151 -0
  385. manigent/web/frontend/src/components/briefing/BriefingPage.test.tsx +176 -0
  386. manigent/web/frontend/src/components/briefing/BriefingPage.tsx +157 -0
  387. manigent/web/frontend/src/components/briefing/BriefingPage.wsDelete.test.tsx +177 -0
  388. manigent/web/frontend/src/components/briefing/briefing.css +147 -0
  389. manigent/web/frontend/src/components/briefing/heightBootstrap.test.ts +121 -0
  390. manigent/web/frontend/src/components/briefing/heightBootstrap.ts +224 -0
  391. manigent/web/frontend/src/components/briefing/parseBriefMessage.test.ts +90 -0
  392. manigent/web/frontend/src/components/briefing/sampleBriefing.ts +148 -0
  393. manigent/web/frontend/src/components/channels/ChannelBadge.tsx +35 -0
  394. manigent/web/frontend/src/components/chatroom/ChannelThreadControls.tsx +127 -0
  395. manigent/web/frontend/src/components/chatroom/ChannelThreadNotice.test.tsx +207 -0
  396. manigent/web/frontend/src/components/chatroom/ChannelThreadNotice.tsx +80 -0
  397. manigent/web/frontend/src/components/chatroom/ChatroomView.channel.test.tsx +550 -0
  398. manigent/web/frontend/src/components/chatroom/ChatroomView.flow.test.tsx +186 -0
  399. manigent/web/frontend/src/components/chatroom/ChatroomView.isolation.test.tsx +70 -0
  400. manigent/web/frontend/src/components/chatroom/ChatroomView.tsx +450 -0
  401. manigent/web/frontend/src/components/chatroom/DelegationRow.test.tsx +170 -0
  402. manigent/web/frontend/src/components/chatroom/DelegationRow.tsx +217 -0
  403. manigent/web/frontend/src/components/chatroom/DeliveryStatusLine.test.tsx +33 -0
  404. manigent/web/frontend/src/components/chatroom/DeliveryStatusLine.tsx +46 -0
  405. manigent/web/frontend/src/components/chatroom/ForwardModal.test.tsx +109 -0
  406. manigent/web/frontend/src/components/chatroom/ForwardModal.tsx +304 -0
  407. manigent/web/frontend/src/components/chatroom/HeldNotice.tsx +77 -0
  408. manigent/web/frontend/src/components/chatroom/MentionDropdown.tsx +97 -0
  409. manigent/web/frontend/src/components/chatroom/MessageInput.tsx +408 -0
  410. manigent/web/frontend/src/components/chatroom/MessageItem.tsx +425 -0
  411. manigent/web/frontend/src/components/chatroom/MessageList.outside.test.tsx +98 -0
  412. manigent/web/frontend/src/components/chatroom/MessageList.test.tsx +245 -0
  413. manigent/web/frontend/src/components/chatroom/MessageList.tsx +406 -0
  414. manigent/web/frontend/src/components/chatroom/OfflineRemedyNote.test.tsx +211 -0
  415. manigent/web/frontend/src/components/chatroom/OfflineRemedyNote.tsx +182 -0
  416. manigent/web/frontend/src/components/chatroom/OutsideBadge.tsx +18 -0
  417. manigent/web/frontend/src/components/chatroom/RoomFlowControl.tsx +49 -0
  418. manigent/web/frontend/src/components/chatroom/RoomFlowStrip.test.tsx +47 -0
  419. manigent/web/frontend/src/components/chatroom/RoomFlowStrip.tsx +25 -0
  420. manigent/web/frontend/src/components/chatroom/SaveToPackModal.tsx +350 -0
  421. manigent/web/frontend/src/components/chatroom/StagedAttachments.test.tsx +56 -0
  422. manigent/web/frontend/src/components/chatroom/StagedAttachments.tsx +160 -0
  423. manigent/web/frontend/src/components/chatroom/SystemNote.tsx +18 -0
  424. manigent/web/frontend/src/components/chatroom/contactSender.test.tsx +130 -0
  425. manigent/web/frontend/src/components/chatroom/index.ts +3 -0
  426. manigent/web/frontend/src/components/chatroom/recipientStatus.test.ts +344 -0
  427. manigent/web/frontend/src/components/chatroom/recipientStatus.ts +197 -0
  428. manigent/web/frontend/src/components/common/AnnouncementBanners.test.tsx +221 -0
  429. manigent/web/frontend/src/components/common/AnnouncementBanners.tsx +180 -0
  430. manigent/web/frontend/src/components/common/Avatar.test.tsx +50 -0
  431. manigent/web/frontend/src/components/common/Avatar.tsx +102 -0
  432. manigent/web/frontend/src/components/common/Badge.tsx +30 -0
  433. manigent/web/frontend/src/components/common/BrandName.tsx +16 -0
  434. manigent/web/frontend/src/components/common/ClearHistoryButton.tsx +74 -0
  435. manigent/web/frontend/src/components/common/CollapsibleHeader.tsx +64 -0
  436. manigent/web/frontend/src/components/common/ConnectionStatus.test.tsx +56 -0
  437. manigent/web/frontend/src/components/common/ConnectionStatus.tsx +28 -0
  438. manigent/web/frontend/src/components/common/DropOverlay.tsx +27 -0
  439. manigent/web/frontend/src/components/common/InlineEditableTitle.test.tsx +369 -0
  440. manigent/web/frontend/src/components/common/InlineEditableTitle.tsx +287 -0
  441. manigent/web/frontend/src/components/common/IsolationUnavailablePanel.tsx +19 -0
  442. manigent/web/frontend/src/components/common/Loading.tsx +22 -0
  443. manigent/web/frontend/src/components/common/ModelSelector.tsx +248 -0
  444. manigent/web/frontend/src/components/common/Overlay.tsx +15 -0
  445. manigent/web/frontend/src/components/common/ResizeHandle.tsx +52 -0
  446. manigent/web/frontend/src/components/common/TagAutocompleteInput.test.tsx +92 -0
  447. manigent/web/frontend/src/components/common/TagAutocompleteInput.tsx +246 -0
  448. manigent/web/frontend/src/components/common/TagPillInput.tsx +181 -0
  449. manigent/web/frontend/src/components/common/TeamAndAgentPicker.tsx +83 -0
  450. manigent/web/frontend/src/components/common/Timestamp.tsx +61 -0
  451. manigent/web/frontend/src/components/common/VerifiedBadge.tsx +20 -0
  452. manigent/web/frontend/src/components/common/index.ts +8 -0
  453. manigent/web/frontend/src/components/composer/PhPopover.tsx +239 -0
  454. manigent/web/frontend/src/components/composer/PromptEditor.test.tsx +472 -0
  455. manigent/web/frontend/src/components/composer/PromptEditor.tsx +540 -0
  456. manigent/web/frontend/src/components/composer/caret.ts +159 -0
  457. manigent/web/frontend/src/components/composer/placeholders.test.ts +352 -0
  458. manigent/web/frontend/src/components/composer/placeholders.ts +354 -0
  459. manigent/web/frontend/src/components/composer/promptEditor.css +161 -0
  460. manigent/web/frontend/src/components/computer/ComputerPage.test.tsx +452 -0
  461. manigent/web/frontend/src/components/computer/ComputerPage.tsx +811 -0
  462. manigent/web/frontend/src/components/computer/ConnectComputerModal.tsx +135 -0
  463. manigent/web/frontend/src/components/computer/CopyCommand.tsx +77 -0
  464. manigent/web/frontend/src/components/computer/DisconnectComputerModal.tsx +121 -0
  465. manigent/web/frontend/src/components/computer/SoftwareCard.tsx +145 -0
  466. manigent/web/frontend/src/components/computer/TerminalCard.test.tsx +114 -0
  467. manigent/web/frontend/src/components/computer/TerminalCard.tsx +131 -0
  468. manigent/web/frontend/src/components/computer/TerminalPanel.tsx +226 -0
  469. manigent/web/frontend/src/components/computer/TrustPanel.test.tsx +58 -0
  470. manigent/web/frontend/src/components/computer/TrustPanel.tsx +143 -0
  471. manigent/web/frontend/src/components/computer/computerCopy.test.ts +322 -0
  472. manigent/web/frontend/src/components/computer/computerCopy.ts +413 -0
  473. manigent/web/frontend/src/components/computer/sections.ts +21 -0
  474. manigent/web/frontend/src/components/computer/terminalProtocol.test.ts +135 -0
  475. manigent/web/frontend/src/components/computer/terminalProtocol.ts +170 -0
  476. manigent/web/frontend/src/components/context/ContextGuide.test.tsx +26 -0
  477. manigent/web/frontend/src/components/context/ContextGuide.tsx +144 -0
  478. manigent/web/frontend/src/components/context/ContextLanding.tsx +46 -0
  479. manigent/web/frontend/src/components/desk/CardBand.a11y.test.tsx +138 -0
  480. manigent/web/frontend/src/components/desk/CardBand.tsx +132 -0
  481. manigent/web/frontend/src/components/desk/DashTile.tsx +81 -0
  482. manigent/web/frontend/src/components/desk/DeskAgentPicker.retained.test.tsx +79 -0
  483. manigent/web/frontend/src/components/desk/DeskAgentPicker.tsx +151 -0
  484. manigent/web/frontend/src/components/desk/DeskComposer.detachTodo.test.tsx +233 -0
  485. manigent/web/frontend/src/components/desk/DeskComposer.dispatch.test.tsx +208 -0
  486. manigent/web/frontend/src/components/desk/DeskComposer.loadTodoDraft.test.tsx +242 -0
  487. manigent/web/frontend/src/components/desk/DeskComposer.restage.test.tsx +258 -0
  488. manigent/web/frontend/src/components/desk/DeskComposer.saveTodo.test.tsx +265 -0
  489. manigent/web/frontend/src/components/desk/DeskComposer.tsx +828 -0
  490. manigent/web/frontend/src/components/desk/DeskPage.crossHighlight.test.tsx +427 -0
  491. manigent/web/frontend/src/components/desk/DeskPage.link.test.tsx +271 -0
  492. manigent/web/frontend/src/components/desk/DeskPage.tsx +1066 -0
  493. manigent/web/frontend/src/components/desk/DeskPage.welcomeTour.test.tsx +262 -0
  494. manigent/web/frontend/src/components/desk/DeskSideRail.tsx +100 -0
  495. manigent/web/frontend/src/components/desk/DeskWelcomeTour.tsx +513 -0
  496. manigent/web/frontend/src/components/desk/DeskZeroState.tsx +45 -0
  497. manigent/web/frontend/src/components/desk/LabelChip.tsx +147 -0
  498. manigent/web/frontend/src/components/desk/LabelFilter.tsx +474 -0
  499. manigent/web/frontend/src/components/desk/LabelInput.test.tsx +439 -0
  500. manigent/web/frontend/src/components/desk/LabelInput.tsx +477 -0
  501. manigent/web/frontend/src/components/desk/LabelManageSheet.test.tsx +384 -0
  502. manigent/web/frontend/src/components/desk/LabelManageSheet.tsx +498 -0
  503. manigent/web/frontend/src/components/desk/PhaseRail.test.tsx +170 -0
  504. manigent/web/frontend/src/components/desk/PhaseRail.tsx +120 -0
  505. manigent/web/frontend/src/components/desk/PlanBand.test.tsx +655 -0
  506. manigent/web/frontend/src/components/desk/PlanBand.tsx +216 -0
  507. manigent/web/frontend/src/components/desk/SaveTodoModal.tsx +126 -0
  508. manigent/web/frontend/src/components/desk/SopEditor.tsx +280 -0
  509. manigent/web/frontend/src/components/desk/SopItem.tsx +171 -0
  510. manigent/web/frontend/src/components/desk/SopRail.test.tsx +517 -0
  511. manigent/web/frontend/src/components/desk/SopRail.tsx +250 -0
  512. manigent/web/frontend/src/components/desk/TicketChip.tsx +51 -0
  513. manigent/web/frontend/src/components/desk/TodoItem.attachments.test.tsx +96 -0
  514. manigent/web/frontend/src/components/desk/TodoItem.menu.test.tsx +142 -0
  515. manigent/web/frontend/src/components/desk/TodoItem.tsx +573 -0
  516. manigent/web/frontend/src/components/desk/TodoRail.longpress.test.tsx +396 -0
  517. manigent/web/frontend/src/components/desk/TodoRail.test.tsx +1155 -0
  518. manigent/web/frontend/src/components/desk/TodoRail.tsx +918 -0
  519. manigent/web/frontend/src/components/desk/TodoRowMenu.tsx +147 -0
  520. manigent/web/frontend/src/components/desk/UpdateCard.bands.test.tsx +308 -0
  521. manigent/web/frontend/src/components/desk/UpdateCard.sendBack.test.tsx +444 -0
  522. manigent/web/frontend/src/components/desk/UpdateCard.tsx +943 -0
  523. manigent/web/frontend/src/components/desk/UpdateCard.untrusted.test.tsx +161 -0
  524. manigent/web/frontend/src/components/desk/desk.css +1921 -0
  525. manigent/web/frontend/src/components/desk/desk.css.test.ts +582 -0
  526. manigent/web/frontend/src/components/desk/deskBands.test.ts +389 -0
  527. manigent/web/frontend/src/components/desk/deskBands.ts +226 -0
  528. manigent/web/frontend/src/components/desk/m16Surfaces.test.ts +134 -0
  529. manigent/web/frontend/src/components/desk/sopSyntax.ts +73 -0
  530. manigent/web/frontend/src/components/discovery/AgentDirectory.tsx +71 -0
  531. manigent/web/frontend/src/components/discovery/AgentFilterBar.tsx +46 -0
  532. manigent/web/frontend/src/components/discovery/AgentSearchBar.tsx +56 -0
  533. manigent/web/frontend/src/components/dm/AgentSummaryCard.tsx +154 -0
  534. manigent/web/frontend/src/components/dm/ComposeFields.tsx +145 -0
  535. manigent/web/frontend/src/components/dm/DMChatView.flow.test.tsx +374 -0
  536. manigent/web/frontend/src/components/dm/DMChatView.tsx +841 -0
  537. manigent/web/frontend/src/components/dm/DMZeroState.tsx +204 -0
  538. manigent/web/frontend/src/components/dm/DmComposeModal.tsx +301 -0
  539. manigent/web/frontend/src/components/dm/PersonalizeModal.tsx +366 -0
  540. manigent/web/frontend/src/components/dm/index.ts +1 -0
  541. manigent/web/frontend/src/components/files/FileItem.tsx +138 -0
  542. manigent/web/frontend/src/components/files/FileList.outside.test.tsx +29 -0
  543. manigent/web/frontend/src/components/files/FileList.tsx +48 -0
  544. manigent/web/frontend/src/components/files/FilePreview.test.tsx +75 -0
  545. manigent/web/frontend/src/components/files/FilePreview.tsx +325 -0
  546. manigent/web/frontend/src/components/files/FileViewerModal.test.tsx +157 -0
  547. manigent/web/frontend/src/components/files/FileViewerModal.tsx +261 -0
  548. manigent/web/frontend/src/components/files/HighlightedCode.tsx +39 -0
  549. manigent/web/frontend/src/components/files/SaveToAgentPanel.test.tsx +156 -0
  550. manigent/web/frontend/src/components/files/SaveToAgentPanel.tsx +545 -0
  551. manigent/web/frontend/src/components/files/index.ts +3 -0
  552. manigent/web/frontend/src/components/hero/AgentAvatar.tsx +66 -0
  553. manigent/web/frontend/src/components/hero/Button.tsx +66 -0
  554. manigent/web/frontend/src/components/hero/Hero.tsx +133 -0
  555. manigent/web/frontend/src/components/homefs/HomeFolderBrowser.test.tsx +393 -0
  556. manigent/web/frontend/src/components/homefs/HomeFolderBrowser.tsx +792 -0
  557. manigent/web/frontend/src/components/hub/BridgeDeliveryLine.test.tsx +40 -0
  558. manigent/web/frontend/src/components/hub/BridgeDeliveryLine.tsx +36 -0
  559. manigent/web/frontend/src/components/hub/HubAvatar.test.tsx +93 -0
  560. manigent/web/frontend/src/components/hub/HubAvatar.tsx +33 -0
  561. manigent/web/frontend/src/components/hub/HubChatView.test.tsx +695 -0
  562. manigent/web/frontend/src/components/hub/HubChatView.tsx +504 -0
  563. manigent/web/frontend/src/components/hub/HubContactLabel.test.tsx +92 -0
  564. manigent/web/frontend/src/components/hub/HubContactLabel.tsx +47 -0
  565. manigent/web/frontend/src/components/hub/HubDeliveryLine.tsx +49 -0
  566. manigent/web/frontend/src/components/hub/HubDisclosure.tsx +49 -0
  567. manigent/web/frontend/src/components/hub/HubEnrollmentDialog.test.tsx +144 -0
  568. manigent/web/frontend/src/components/hub/HubEnrollmentDialog.tsx +244 -0
  569. manigent/web/frontend/src/components/hub/HubMessageBody.test.tsx +26 -0
  570. manigent/web/frontend/src/components/hub/HubMessageBody.tsx +14 -0
  571. manigent/web/frontend/src/components/hub/HubOfflineConfirm.tsx +45 -0
  572. manigent/web/frontend/src/components/hub/HubThreadBanner.tsx +33 -0
  573. manigent/web/frontend/src/components/hub/HubThreadGuard.tsx +59 -0
  574. manigent/web/frontend/src/components/hub/RemoteAgentCard.test.tsx +50 -0
  575. manigent/web/frontend/src/components/hub/RemoteAgentCard.tsx +174 -0
  576. manigent/web/frontend/src/components/hub/RemoteAgentSection.test.tsx +446 -0
  577. manigent/web/frontend/src/components/hub/RemoteAgentSection.tsx +219 -0
  578. manigent/web/frontend/src/components/hub/ReportDialog.test.tsx +96 -0
  579. manigent/web/frontend/src/components/hub/ReportDialog.tsx +162 -0
  580. manigent/web/frontend/src/components/hub/RetainButton.test.tsx +103 -0
  581. manigent/web/frontend/src/components/hub/RetainButton.tsx +114 -0
  582. manigent/web/frontend/src/components/hub/RetainDialog.test.tsx +68 -0
  583. manigent/web/frontend/src/components/hub/RetainDialog.tsx +115 -0
  584. manigent/web/frontend/src/components/hub/RetainedList.test.tsx +94 -0
  585. manigent/web/frontend/src/components/hub/RetainedList.tsx +32 -0
  586. manigent/web/frontend/src/components/hub/RetainedRow.tsx +129 -0
  587. manigent/web/frontend/src/components/hub/StandInChip.test.tsx +37 -0
  588. manigent/web/frontend/src/components/hub/StandInChip.tsx +43 -0
  589. manigent/web/frontend/src/components/hub/UnusualNameChip.test.tsx +81 -0
  590. manigent/web/frontend/src/components/hub/UnusualNameChip.tsx +22 -0
  591. manigent/web/frontend/src/components/hub/hubLint.test.ts +265 -0
  592. manigent/web/frontend/src/components/landing/ComparisonSection.tsx +92 -0
  593. manigent/web/frontend/src/components/landing/ConsoleSection.tsx +402 -0
  594. manigent/web/frontend/src/components/landing/CrewSection.tsx +224 -0
  595. manigent/web/frontend/src/components/landing/FAQ.tsx +68 -0
  596. manigent/web/frontend/src/components/landing/Footer.tsx +58 -0
  597. manigent/web/frontend/src/components/landing/HowItWorks.tsx +117 -0
  598. manigent/web/frontend/src/components/landing/KnowledgeSection.tsx +91 -0
  599. manigent/web/frontend/src/components/landing/LandingPage.test.tsx +134 -0
  600. manigent/web/frontend/src/components/landing/LearningSection.tsx +486 -0
  601. manigent/web/frontend/src/components/landing/Nav.tsx +68 -0
  602. manigent/web/frontend/src/components/landing/Pricing.tsx +263 -0
  603. manigent/web/frontend/src/components/landing/Section.tsx +98 -0
  604. manigent/web/frontend/src/components/landing/StaffingSection.tsx +122 -0
  605. manigent/web/frontend/src/components/landing/WhyNowSection.tsx +92 -0
  606. manigent/web/frontend/src/components/landing/WhyTeamSection.tsx +73 -0
  607. manigent/web/frontend/src/components/landing/earlyAccess.ts +23 -0
  608. manigent/web/frontend/src/components/layout/AppLayout.tsx +77 -0
  609. manigent/web/frontend/src/components/layout/Sidebar.tsx +172 -0
  610. manigent/web/frontend/src/components/layout/index.ts +2 -0
  611. manigent/web/frontend/src/components/project/ActivityFeed.tsx +247 -0
  612. manigent/web/frontend/src/components/project/CreateProjectPage.tsx +543 -0
  613. manigent/web/frontend/src/components/project/DeleteProjectButton.tsx +39 -0
  614. manigent/web/frontend/src/components/project/ProjectPlanTab.phase.test.tsx +107 -0
  615. manigent/web/frontend/src/components/project/ProjectPlanTab.test.tsx +994 -0
  616. manigent/web/frontend/src/components/project/ProjectPlanTab.tsx +166 -0
  617. manigent/web/frontend/src/components/project/ProjectReportTab.tsx +139 -0
  618. manigent/web/frontend/src/components/project/ProjectView.tsx +390 -0
  619. manigent/web/frontend/src/components/project/index.ts +2 -0
  620. manigent/web/frontend/src/components/project/plan/PlanAcceptance.test.tsx +460 -0
  621. manigent/web/frontend/src/components/project/plan/PlanAcceptance.tsx +111 -0
  622. manigent/web/frontend/src/components/project/plan/PlanBlockEdit.tsx +111 -0
  623. manigent/web/frontend/src/components/project/plan/PlanBlockEditing.test.tsx +1132 -0
  624. manigent/web/frontend/src/components/project/plan/PlanBody.test.tsx +495 -0
  625. manigent/web/frontend/src/components/project/plan/PlanBody.tsx +992 -0
  626. manigent/web/frontend/src/components/project/plan/PlanCollide.tsx +254 -0
  627. manigent/web/frontend/src/components/project/plan/PlanCommentList.test.tsx +420 -0
  628. manigent/web/frontend/src/components/project/plan/PlanCommentList.tsx +391 -0
  629. manigent/web/frontend/src/components/project/plan/PlanDecisionNudge.test.tsx +226 -0
  630. manigent/web/frontend/src/components/project/plan/PlanDecisionNudge.tsx +128 -0
  631. manigent/web/frontend/src/components/project/plan/PlanDestructiveAccept.test.tsx +209 -0
  632. manigent/web/frontend/src/components/project/plan/PlanDiff.test.tsx +126 -0
  633. manigent/web/frontend/src/components/project/plan/PlanDiff.tsx +131 -0
  634. manigent/web/frontend/src/components/project/plan/PlanDiffSeries.test.tsx +500 -0
  635. manigent/web/frontend/src/components/project/plan/PlanDiffSeries.tsx +125 -0
  636. manigent/web/frontend/src/components/project/plan/PlanEditor.mounts.test.tsx +504 -0
  637. manigent/web/frontend/src/components/project/plan/PlanEditor.tsx +2159 -0
  638. manigent/web/frontend/src/components/project/plan/PlanHistory.tsx +69 -0
  639. manigent/web/frontend/src/components/project/plan/PlanInlineNotes.test.tsx +2398 -0
  640. manigent/web/frontend/src/components/project/plan/PlanNoteBase.test.tsx +215 -0
  641. manigent/web/frontend/src/components/project/plan/PlanNoteRow.test.tsx +562 -0
  642. manigent/web/frontend/src/components/project/plan/PlanNoteRow.tsx +908 -0
  643. manigent/web/frontend/src/components/project/plan/PlanQuotePop.tsx +254 -0
  644. manigent/web/frontend/src/components/project/plan/PlanStaleReconcile.test.tsx +244 -0
  645. manigent/web/frontend/src/components/project/plan/PlanSubmitWindow.test.tsx +281 -0
  646. manigent/web/frontend/src/components/project/plan/PlanTray.tsx +184 -0
  647. manigent/web/frontend/src/components/project/plan/PlanTrayMirror.test.tsx +242 -0
  648. manigent/web/frontend/src/components/project/plan/plan.css +1151 -0
  649. manigent/web/frontend/src/components/project/plan/planAnchor.test.tsx +459 -0
  650. manigent/web/frontend/src/components/project/plan/planAnchor.tsx +277 -0
  651. manigent/web/frontend/src/components/project/plan/planBlocks.test.tsx +470 -0
  652. manigent/web/frontend/src/components/project/plan/planBlocks.ts +242 -0
  653. manigent/web/frontend/src/components/project/plan/planContrast.test.ts +229 -0
  654. manigent/web/frontend/src/components/project/plan/planDiffs.test.ts +455 -0
  655. manigent/web/frontend/src/components/project/plan/planDiffs.ts +358 -0
  656. manigent/web/frontend/src/components/project/plan/planTestServer.ts +741 -0
  657. manigent/web/frontend/src/components/project/plan/planTransport.test.ts +160 -0
  658. manigent/web/frontend/src/components/project/plan/planTrayLogic.test.ts +1201 -0
  659. manigent/web/frontend/src/components/project/plan/planTrayLogic.ts +1164 -0
  660. manigent/web/frontend/src/components/publicAgents/PublicAgentsAccount.tsx +162 -0
  661. manigent/web/frontend/src/components/publicAgents/api.ts +55 -0
  662. manigent/web/frontend/src/components/publicAgents/parts.tsx +54 -0
  663. manigent/web/frontend/src/components/publicAgents/publicAgents.css +53 -0
  664. manigent/web/frontend/src/components/publicAgents/publicAgents.test.tsx +86 -0
  665. manigent/web/frontend/src/components/publicAgents/widget.test.ts +148 -0
  666. manigent/web/frontend/src/components/publicAgents/widget.ts +40 -0
  667. manigent/web/frontend/src/components/publicRequests/AgentDrawer.tsx +101 -0
  668. manigent/web/frontend/src/components/publicRequests/AgentPanel.tsx +173 -0
  669. manigent/web/frontend/src/components/publicRequests/AgentRankList.tsx +61 -0
  670. manigent/web/frontend/src/components/publicRequests/MatchRetainButton.tsx +67 -0
  671. manigent/web/frontend/src/components/publicRequests/ProgressBoard.tsx +74 -0
  672. manigent/web/frontend/src/components/publicRequests/PublicRequestsPage.tsx +57 -0
  673. manigent/web/frontend/src/components/publicRequests/RequestHome.tsx +99 -0
  674. manigent/web/frontend/src/components/publicRequests/RequestRoom.tsx +126 -0
  675. manigent/web/frontend/src/components/publicRequests/agentView.ts +65 -0
  676. manigent/web/frontend/src/components/publicRequests/publicRequests.css +209 -0
  677. manigent/web/frontend/src/components/publicRequests/publicRequests.test.tsx +172 -0
  678. manigent/web/frontend/src/components/publicRequests/requestCopy.ts +121 -0
  679. manigent/web/frontend/src/components/sample-request/SampleIdeaList.test.tsx +190 -0
  680. manigent/web/frontend/src/components/sample-request/SampleIdeaList.tsx +180 -0
  681. manigent/web/frontend/src/components/sample-request/SampleRequestBody.tsx +276 -0
  682. manigent/web/frontend/src/components/sample-request/assistantStarterSamples.ts +62 -0
  683. manigent/web/frontend/src/components/sample-request/sampleIdeaList.css +106 -0
  684. manigent/web/frontend/src/components/scheduled/CronPicker.tsx +80 -0
  685. manigent/web/frontend/src/components/scheduled/ScheduleMessageModal.tsx +341 -0
  686. manigent/web/frontend/src/components/scheduled/ScheduledMessagesList.tsx +157 -0
  687. manigent/web/frontend/src/components/settings/AgentSettingsPage.tsx +366 -0
  688. manigent/web/frontend/src/components/settings/ConnectedAccounts.tsx +277 -0
  689. manigent/web/frontend/src/components/settings/DefaultModelSection.test.tsx +96 -0
  690. manigent/web/frontend/src/components/settings/DefaultModelSection.tsx +212 -0
  691. manigent/web/frontend/src/components/settings/DisconnectConfirmModal.tsx +93 -0
  692. manigent/web/frontend/src/components/settings/FrontDeskSettingsPage.isolation.test.tsx +66 -0
  693. manigent/web/frontend/src/components/settings/FrontDeskSettingsPage.tsx +134 -0
  694. manigent/web/frontend/src/components/settings/InvitableAgentsPicker.tsx +182 -0
  695. manigent/web/frontend/src/components/settings/KnowledgePacksSection.tsx +867 -0
  696. manigent/web/frontend/src/components/settings/ModelConfigDialog.catalogDriven.test.tsx +79 -0
  697. manigent/web/frontend/src/components/settings/ModelConfigDialog.tsx +343 -0
  698. manigent/web/frontend/src/components/settings/ModelConfigSection.smartRouting.test.tsx +109 -0
  699. manigent/web/frontend/src/components/settings/ModelConfigSection.tsx +599 -0
  700. manigent/web/frontend/src/components/settings/ReflectionScheduleSection.tsx +170 -0
  701. manigent/web/frontend/src/components/settings/UserSettingsPage.tsx +168 -0
  702. manigent/web/frontend/src/components/settings/agent/AgentStatusStrip.tsx +71 -0
  703. manigent/web/frontend/src/components/settings/agent/BlockedDeploymentsCard.test.tsx +81 -0
  704. manigent/web/frontend/src/components/settings/agent/BlockedDeploymentsCard.tsx +62 -0
  705. manigent/web/frontend/src/components/settings/agent/ChannelIsolationLine.tsx +21 -0
  706. manigent/web/frontend/src/components/settings/agent/ChannelsSection.test.tsx +879 -0
  707. manigent/web/frontend/src/components/settings/agent/ChannelsSection.tsx +1124 -0
  708. manigent/web/frontend/src/components/settings/agent/EnvVarsCard.test.ts +15 -0
  709. manigent/web/frontend/src/components/settings/agent/EnvVarsCard.tsx +301 -0
  710. manigent/web/frontend/src/components/settings/agent/ExtraDirsList.tsx +85 -0
  711. manigent/web/frontend/src/components/settings/agent/GeneralSection.tsx +88 -0
  712. manigent/web/frontend/src/components/settings/agent/HostDisclaimerSlot.test.tsx +32 -0
  713. manigent/web/frontend/src/components/settings/agent/HostDisclaimerSlot.tsx +30 -0
  714. manigent/web/frontend/src/components/settings/agent/HubChannelCard.test.tsx +449 -0
  715. manigent/web/frontend/src/components/settings/agent/HubChannelCard.tsx +312 -0
  716. manigent/web/frontend/src/components/settings/agent/HubInvitableAgentsCard.test.tsx +83 -0
  717. manigent/web/frontend/src/components/settings/agent/HubInvitableAgentsCard.tsx +64 -0
  718. manigent/web/frontend/src/components/settings/agent/HubSendUsageCard.test.tsx +69 -0
  719. manigent/web/frontend/src/components/settings/agent/HubSendUsageCard.tsx +42 -0
  720. manigent/web/frontend/src/components/settings/agent/HubTurnOffDialog.tsx +88 -0
  721. manigent/web/frontend/src/components/settings/agent/IsolationRecheckButton.test.tsx +26 -0
  722. manigent/web/frontend/src/components/settings/agent/IsolationRecheckButton.tsx +68 -0
  723. manigent/web/frontend/src/components/settings/agent/KnowledgeSection.tsx +68 -0
  724. manigent/web/frontend/src/components/settings/agent/LineChannelCard.test.tsx +428 -0
  725. manigent/web/frontend/src/components/settings/agent/LineChannelCard.tsx +343 -0
  726. manigent/web/frontend/src/components/settings/agent/RuntimeSection.test.tsx +164 -0
  727. manigent/web/frontend/src/components/settings/agent/RuntimeSection.tsx +118 -0
  728. manigent/web/frontend/src/components/settings/agent/StorageSection.tsx +78 -0
  729. manigent/web/frontend/src/components/settings/agent/WebChatChannelCard.test.tsx +253 -0
  730. manigent/web/frontend/src/components/settings/agent/WebChatChannelCard.tsx +401 -0
  731. manigent/web/frontend/src/components/settings/agent/WhatsAppChannelCard.test.tsx +462 -0
  732. manigent/web/frontend/src/components/settings/agent/WhatsAppChannelCard.tsx +289 -0
  733. manigent/web/frontend/src/components/settings/agent/agentSettings.test.tsx +109 -0
  734. manigent/web/frontend/src/components/settings/agent/channelSetupGuides.test.tsx +162 -0
  735. manigent/web/frontend/src/components/settings/agent/channelSetupGuides.tsx +119 -0
  736. manigent/web/frontend/src/components/settings/agent/channelsShared.ts +83 -0
  737. manigent/web/frontend/src/components/settings/agent/fields.tsx +67 -0
  738. manigent/web/frontend/src/components/settings/agent/sections.ts +36 -0
  739. manigent/web/frontend/src/components/settings/agent/useAgentSettingsDraft.ts +129 -0
  740. manigent/web/frontend/src/components/settings/agent/useInstallSelection.ts +38 -0
  741. manigent/web/frontend/src/components/settings/primitives.tsx +98 -0
  742. manigent/web/frontend/src/components/shared/ExpandToggle.tsx +45 -0
  743. manigent/web/frontend/src/components/shared/MarkdownBody.tsx +218 -0
  744. manigent/web/frontend/src/components/shared/MarkdownBody.untrusted.test.tsx +97 -0
  745. manigent/web/frontend/src/components/shared/RenderBody.tsx +90 -0
  746. manigent/web/frontend/src/components/shared/rehypeHideComments.test.tsx +115 -0
  747. manigent/web/frontend/src/components/shared/rehypeHideComments.ts +124 -0
  748. manigent/web/frontend/src/components/sharing/AcceptSharePage.tsx +141 -0
  749. manigent/web/frontend/src/components/sharing/ShareLandingPage.tsx +89 -0
  750. manigent/web/frontend/src/components/sidebar/AccountChip.test.tsx +337 -0
  751. manigent/web/frontend/src/components/sidebar/AccountChip.tsx +407 -0
  752. manigent/web/frontend/src/components/sidebar/ActiveWorkDot.tsx +9 -0
  753. manigent/web/frontend/src/components/sidebar/AgentProfile.tsx +44 -0
  754. manigent/web/frontend/src/components/sidebar/AgentRow.tsx +71 -0
  755. manigent/web/frontend/src/components/sidebar/AgentsPanel.retained.test.tsx +105 -0
  756. manigent/web/frontend/src/components/sidebar/AgentsPanel.tsx +231 -0
  757. manigent/web/frontend/src/components/sidebar/AssistantHero.tsx +48 -0
  758. manigent/web/frontend/src/components/sidebar/ChatroomItem.tsx +48 -0
  759. manigent/web/frontend/src/components/sidebar/CommandPalette.channelThreads.test.tsx +76 -0
  760. manigent/web/frontend/src/components/sidebar/CommandPalette.reopen.test.tsx +206 -0
  761. manigent/web/frontend/src/components/sidebar/CommandPalette.test.tsx +725 -0
  762. manigent/web/frontend/src/components/sidebar/CommandPalette.tsx +774 -0
  763. manigent/web/frontend/src/components/sidebar/ComputersPanel.tsx +144 -0
  764. manigent/web/frontend/src/components/sidebar/DmIcon.tsx +46 -0
  765. manigent/web/frontend/src/components/sidebar/DmsPanel.tsx +226 -0
  766. manigent/web/frontend/src/components/sidebar/FrontDeskPanel.channels.test.tsx +582 -0
  767. manigent/web/frontend/src/components/sidebar/FrontDeskPanel.test.tsx +151 -0
  768. manigent/web/frontend/src/components/sidebar/FrontDeskPanel.tsx +454 -0
  769. manigent/web/frontend/src/components/sidebar/HotkeysHelp.tsx +101 -0
  770. manigent/web/frontend/src/components/sidebar/MessageHitRow.test.tsx +84 -0
  771. manigent/web/frontend/src/components/sidebar/MessageHitRow.tsx +57 -0
  772. manigent/web/frontend/src/components/sidebar/MyDeskRailPanel.crossHighlight.test.tsx +247 -0
  773. manigent/web/frontend/src/components/sidebar/MyDeskRailPanel.delete.test.tsx +273 -0
  774. manigent/web/frontend/src/components/sidebar/MyDeskRailPanel.tsx +351 -0
  775. manigent/web/frontend/src/components/sidebar/PanelShell.tsx +48 -0
  776. manigent/web/frontend/src/components/sidebar/ProjectItem.tsx +161 -0
  777. manigent/web/frontend/src/components/sidebar/ProjectRow.tsx +151 -0
  778. manigent/web/frontend/src/components/sidebar/ProjectsPanel.tsx +80 -0
  779. manigent/web/frontend/src/components/sidebar/PublicPanel.counterpart.test.tsx +85 -0
  780. manigent/web/frontend/src/components/sidebar/PublicPanel.hub.test.tsx +147 -0
  781. manigent/web/frontend/src/components/sidebar/PublicPanel.tsx +271 -0
  782. manigent/web/frontend/src/components/sidebar/RecencyHeader.tsx +17 -0
  783. manigent/web/frontend/src/components/sidebar/ScheduledPanel.tsx +58 -0
  784. manigent/web/frontend/src/components/sidebar/SidebarAvatar.tsx +128 -0
  785. manigent/web/frontend/src/components/sidebar/SidebarRail.tsx +163 -0
  786. manigent/web/frontend/src/components/sidebar/TeamGroup.tsx +67 -0
  787. manigent/web/frontend/src/components/sidebar/dmPrefill.test.ts +69 -0
  788. manigent/web/frontend/src/components/sidebar/dmPrefill.ts +80 -0
  789. manigent/web/frontend/src/components/sidebar/index.ts +18 -0
  790. manigent/web/frontend/src/components/sidebar/railShortcuts.ts +18 -0
  791. manigent/web/frontend/src/components/today/lib.test.ts +70 -0
  792. manigent/web/frontend/src/components/today/lib.ts +296 -0
  793. manigent/web/frontend/src/components/today/today.css +113 -0
  794. manigent/web/frontend/src/components/welcome/RunnerSetup.css +253 -0
  795. manigent/web/frontend/src/components/welcome/RunnerSetup.tsx +148 -0
  796. manigent/web/frontend/src/components/welcome/WelcomePage.css +383 -0
  797. manigent/web/frontend/src/components/welcome/WelcomePage.tsx +329 -0
  798. manigent/web/frontend/src/components/welcome/welcomeCopy.test.ts +382 -0
  799. manigent/web/frontend/src/components/welcome/welcomeCopy.ts +313 -0
  800. manigent/web/frontend/src/config/llmProviders.test.ts +161 -0
  801. manigent/web/frontend/src/config/llmProviders.ts +109 -0
  802. manigent/web/frontend/src/config/providerIds.fixture.json +13 -0
  803. manigent/web/frontend/src/hooks/accountNavigation.test.tsx +138 -0
  804. manigent/web/frontend/src/hooks/index.ts +10 -0
  805. manigent/web/frontend/src/hooks/useActiveWorkBootstrap.ts +86 -0
  806. manigent/web/frontend/src/hooks/useAgentAccess.test.tsx +30 -0
  807. manigent/web/frontend/src/hooks/useAgentAccess.ts +204 -0
  808. manigent/web/frontend/src/hooks/useAgentSampleRequests.ts +27 -0
  809. manigent/web/frontend/src/hooks/useAgentSearch.ts +10 -0
  810. manigent/web/frontend/src/hooks/useAgents.ts +46 -0
  811. manigent/web/frontend/src/hooks/useAnnouncements.test.ts +112 -0
  812. manigent/web/frontend/src/hooks/useAnnouncements.ts +100 -0
  813. manigent/web/frontend/src/hooks/useAssistantAgent.ts +24 -0
  814. manigent/web/frontend/src/hooks/useAutoResizeTextarea.ts +18 -0
  815. manigent/web/frontend/src/hooks/useBridgeDeliveries.test.tsx +79 -0
  816. manigent/web/frontend/src/hooks/useBriefTabs.ts +122 -0
  817. manigent/web/frontend/src/hooks/useChannelThreads.ts +69 -0
  818. manigent/web/frontend/src/hooks/useComposeForm.ts +80 -0
  819. manigent/web/frontend/src/hooks/useComputers.ts +123 -0
  820. manigent/web/frontend/src/hooks/useContextFirstItem.ts +118 -0
  821. manigent/web/frontend/src/hooks/useCreateProject.ts +78 -0
  822. manigent/web/frontend/src/hooks/useDebouncedValue.ts +16 -0
  823. manigent/web/frontend/src/hooks/useDeleteProject.ts +27 -0
  824. manigent/web/frontend/src/hooks/useDeskDispatch.test.tsx +170 -0
  825. manigent/web/frontend/src/hooks/useDeskDispatch.ts +135 -0
  826. manigent/web/frontend/src/hooks/useDeskReadState.test.tsx +85 -0
  827. manigent/web/frontend/src/hooks/useDeskReadState.ts +150 -0
  828. manigent/web/frontend/src/hooks/useDeskRecentlyDone.test.tsx +222 -0
  829. manigent/web/frontend/src/hooks/useDeskRecentlyDone.ts +225 -0
  830. manigent/web/frontend/src/hooks/useDeskTour.ts +37 -0
  831. manigent/web/frontend/src/hooks/useDeskUpdates.channelThreads.test.tsx +91 -0
  832. manigent/web/frontend/src/hooks/useDeskUpdates.hubThreads.test.tsx +93 -0
  833. manigent/web/frontend/src/hooks/useDeskUpdates.ts +353 -0
  834. manigent/web/frontend/src/hooks/useDisconnectIntegration.ts +23 -0
  835. manigent/web/frontend/src/hooks/useDmThreads.ts +99 -0
  836. manigent/web/frontend/src/hooks/useFileDropzone.ts +93 -0
  837. manigent/web/frontend/src/hooks/useFileUpload.ts +38 -0
  838. manigent/web/frontend/src/hooks/useFiles.ts +98 -0
  839. manigent/web/frontend/src/hooks/useHub.test.tsx +237 -0
  840. manigent/web/frontend/src/hooks/useHub.ts +502 -0
  841. manigent/web/frontend/src/hooks/useInstall.ts +82 -0
  842. manigent/web/frontend/src/hooks/useLabels.test.tsx +294 -0
  843. manigent/web/frontend/src/hooks/useLabels.ts +287 -0
  844. manigent/web/frontend/src/hooks/useLogout.ts +49 -0
  845. manigent/web/frontend/src/hooks/useMentionDetection.ts +90 -0
  846. manigent/web/frontend/src/hooks/useMessageAttachments.deskLimit.test.ts +264 -0
  847. manigent/web/frontend/src/hooks/useMessageAttachments.ts +283 -0
  848. manigent/web/frontend/src/hooks/useMessageDeepLink.test.tsx +70 -0
  849. manigent/web/frontend/src/hooks/useMessageDeepLink.ts +32 -0
  850. manigent/web/frontend/src/hooks/useMessageSearch.ts +78 -0
  851. manigent/web/frontend/src/hooks/useModelConfigs.ts +178 -0
  852. manigent/web/frontend/src/hooks/useMyAgents.ts +18 -0
  853. manigent/web/frontend/src/hooks/usePointerReorder.ts +270 -0
  854. manigent/web/frontend/src/hooks/usePrefersReducedMotion.ts +18 -0
  855. manigent/web/frontend/src/hooks/useProjectPlan.ts +36 -0
  856. manigent/web/frontend/src/hooks/useProjectReport.ts +19 -0
  857. manigent/web/frontend/src/hooks/useProjects.ts +104 -0
  858. manigent/web/frontend/src/hooks/useProjectsWithChatrooms.ts +97 -0
  859. manigent/web/frontend/src/hooks/usePublicRequests.ts +89 -0
  860. manigent/web/frontend/src/hooks/useRecentAgents.ts +38 -0
  861. manigent/web/frontend/src/hooks/useRenameProject.test.tsx +185 -0
  862. manigent/web/frontend/src/hooks/useRenameProject.ts +192 -0
  863. manigent/web/frontend/src/hooks/useRoomTrust.ts +56 -0
  864. manigent/web/frontend/src/hooks/useSharing.ts +46 -0
  865. manigent/web/frontend/src/hooks/useSopRecents.ts +55 -0
  866. manigent/web/frontend/src/hooks/useSopSchedules.ts +25 -0
  867. manigent/web/frontend/src/hooks/useSops.ts +99 -0
  868. manigent/web/frontend/src/hooks/useSwitchAccount.ts +41 -0
  869. manigent/web/frontend/src/hooks/useTeamGroups.ts +48 -0
  870. manigent/web/frontend/src/hooks/useThreadOutbox.ts +68 -0
  871. manigent/web/frontend/src/hooks/useTodos.add.test.tsx +89 -0
  872. manigent/web/frontend/src/hooks/useTodos.linkProject.test.tsx +115 -0
  873. manigent/web/frontend/src/hooks/useTodos.saveDraft.test.tsx +188 -0
  874. manigent/web/frontend/src/hooks/useTodos.ts +396 -0
  875. manigent/web/frontend/src/hooks/useUntrustedBodies.test.tsx +87 -0
  876. manigent/web/frontend/src/hooks/useUntrustedBodies.ts +51 -0
  877. manigent/web/frontend/src/hooks/useWebSocket.briefTab.test.tsx +148 -0
  878. manigent/web/frontend/src/hooks/useWebSocket.channelThreads.test.tsx +82 -0
  879. manigent/web/frontend/src/hooks/useWebSocket.hostSync.test.tsx +92 -0
  880. manigent/web/frontend/src/hooks/useWebSocket.hub.test.tsx +114 -0
  881. manigent/web/frontend/src/hooks/useWebSocket.labelSync.test.tsx +168 -0
  882. manigent/web/frontend/src/hooks/useWebSocket.plan.test.tsx +168 -0
  883. manigent/web/frontend/src/hooks/useWebSocket.reconnect.test.tsx +166 -0
  884. manigent/web/frontend/src/hooks/useWebSocket.runnerVersions.test.tsx +167 -0
  885. manigent/web/frontend/src/hooks/useWebSocket.ts +495 -0
  886. manigent/web/frontend/src/hooks/useWindowFileDropGuard.ts +33 -0
  887. manigent/web/frontend/src/landing.tsx +11 -0
  888. manigent/web/frontend/src/main.tsx +17 -0
  889. manigent/web/frontend/src/private-beta.tsx +10 -0
  890. manigent/web/frontend/src/public.tsx +42 -0
  891. manigent/web/frontend/src/queryClient.ts +16 -0
  892. manigent/web/frontend/src/stores/activeWorkStore.ts +88 -0
  893. manigent/web/frontend/src/stores/announcementStore.test.ts +217 -0
  894. manigent/web/frontend/src/stores/announcementStore.ts +169 -0
  895. manigent/web/frontend/src/stores/authStore.multiSession.test.ts +304 -0
  896. manigent/web/frontend/src/stores/authStore.test.ts +235 -0
  897. manigent/web/frontend/src/stores/authStore.ts +693 -0
  898. manigent/web/frontend/src/stores/deskLabelViewStore.test.ts +162 -0
  899. manigent/web/frontend/src/stores/deskLabelViewStore.ts +234 -0
  900. manigent/web/frontend/src/stores/deskLinkStore.ts +54 -0
  901. manigent/web/frontend/src/stores/deskStore.test.ts +53 -0
  902. manigent/web/frontend/src/stores/deskStore.ts +93 -0
  903. manigent/web/frontend/src/stores/heldPostsStore.ts +78 -0
  904. manigent/web/frontend/src/stores/mcpAuthStore.ts +52 -0
  905. manigent/web/frontend/src/stores/modelSelectionStore.ts +33 -0
  906. manigent/web/frontend/src/stores/perUserStores.ts +79 -0
  907. manigent/web/frontend/src/stores/runnerVersionStore.test.ts +81 -0
  908. manigent/web/frontend/src/stores/runnerVersionStore.ts +60 -0
  909. manigent/web/frontend/src/stores/skillAuthStore.ts +54 -0
  910. manigent/web/frontend/src/stores/switchReset.test.ts +236 -0
  911. manigent/web/frontend/src/stores/tokenExp.ts +32 -0
  912. manigent/web/frontend/src/stores/uiStore.ts +186 -0
  913. manigent/web/frontend/src/stores/unreadStore.ts +204 -0
  914. manigent/web/frontend/src/stores/unsavedGuard.ts +22 -0
  915. manigent/web/frontend/src/styles/design/fonts/JetBrainsMono-Latin.woff2 +0 -0
  916. manigent/web/frontend/src/styles/design/fonts/Nunito-Latin.woff2 +0 -0
  917. manigent/web/frontend/src/styles/design/fonts/Nunito-LatinExt.woff2 +0 -0
  918. manigent/web/frontend/src/styles/design/tokens.css +160 -0
  919. manigent/web/frontend/src/styles/homepage.css +242 -0
  920. manigent/web/frontend/src/styles/index.css +126 -0
  921. manigent/web/frontend/src/styles/landing.css +52 -0
  922. manigent/web/frontend/src/test/authFixtures.ts +154 -0
  923. manigent/web/frontend/src/test/fetchHarness.ts +203 -0
  924. manigent/web/frontend/src/test/fixtures/channelThreads.smoke.json +42 -0
  925. manigent/web/frontend/src/test/hubFixtures.ts +212 -0
  926. manigent/web/frontend/src/test/queryWrapper.tsx +9 -0
  927. manigent/web/frontend/src/test/suiteHygiene.test.ts +212 -0
  928. manigent/web/frontend/src/types/channelThreadName.test.ts +16 -0
  929. manigent/web/frontend/src/types/index.ts +903 -0
  930. manigent/web/frontend/src/utils/agentAccessCopy.test.ts +53 -0
  931. manigent/web/frontend/src/utils/agentAccessCopy.ts +231 -0
  932. manigent/web/frontend/src/utils/agentDisplay.ts +81 -0
  933. manigent/web/frontend/src/utils/briefSchedule.ts +25 -0
  934. manigent/web/frontend/src/utils/channelThreads.test.ts +109 -0
  935. manigent/web/frontend/src/utils/channelThreads.ts +179 -0
  936. manigent/web/frontend/src/utils/chatroomDisplay.ts +11 -0
  937. manigent/web/frontend/src/utils/deskLinks.test.ts +155 -0
  938. manigent/web/frontend/src/utils/deskLinks.ts +115 -0
  939. manigent/web/frontend/src/utils/deskRecipient.ts +29 -0
  940. manigent/web/frontend/src/utils/formatDate.ts +102 -0
  941. manigent/web/frontend/src/utils/frontDesk.ts +65 -0
  942. manigent/web/frontend/src/utils/hubChannelView.test.ts +178 -0
  943. manigent/web/frontend/src/utils/hubChannelView.ts +307 -0
  944. manigent/web/frontend/src/utils/hubDelivery.test.ts +105 -0
  945. manigent/web/frontend/src/utils/hubDelivery.ts +137 -0
  946. manigent/web/frontend/src/utils/hubDisplay.test.ts +157 -0
  947. manigent/web/frontend/src/utils/hubDisplay.ts +221 -0
  948. manigent/web/frontend/src/utils/hubThread.test.ts +184 -0
  949. manigent/web/frontend/src/utils/hubThread.ts +247 -0
  950. manigent/web/frontend/src/utils/labelErrors.test.ts +127 -0
  951. manigent/web/frontend/src/utils/labelErrors.ts +123 -0
  952. manigent/web/frontend/src/utils/labelView.test.ts +846 -0
  953. manigent/web/frontend/src/utils/labelView.ts +760 -0
  954. manigent/web/frontend/src/utils/legalUrls.ts +11 -0
  955. manigent/web/frontend/src/utils/messageSender.test.ts +32 -0
  956. manigent/web/frontend/src/utils/messageSender.ts +29 -0
  957. manigent/web/frontend/src/utils/oauthScopeCopy.ts +73 -0
  958. manigent/web/frontend/src/utils/personalize.ts +49 -0
  959. manigent/web/frontend/src/utils/reachableAgents.test.ts +36 -0
  960. manigent/web/frontend/src/utils/reachableAgents.ts +24 -0
  961. manigent/web/frontend/src/utils/recencyBuckets.ts +61 -0
  962. manigent/web/frontend/src/utils/roomTrust.test.ts +94 -0
  963. manigent/web/frontend/src/utils/roomTrust.ts +111 -0
  964. manigent/web/frontend/src/utils/snippetHighlight.test.ts +63 -0
  965. manigent/web/frontend/src/utils/snippetHighlight.ts +42 -0
  966. manigent/web/frontend/src/utils/sopRecipient.ts +37 -0
  967. manigent/web/frontend/src/utils/sopRequest.test.ts +91 -0
  968. manigent/web/frontend/src/utils/sopRequest.ts +68 -0
  969. manigent/web/frontend/src/utils/sopSchedule.ts +26 -0
  970. manigent/web/frontend/src/utils/sopSection.test.ts +148 -0
  971. manigent/web/frontend/src/utils/sopSection.ts +143 -0
  972. manigent/web/frontend/src/utils/textSearch.test.ts +60 -0
  973. manigent/web/frontend/src/utils/textSearch.ts +43 -0
  974. manigent/web/frontend/src/utils/thoughtPartner.ts +26 -0
  975. manigent/web/frontend/src/utils/threadName.test.ts +131 -0
  976. manigent/web/frontend/src/utils/threadName.ts +122 -0
  977. manigent/web/frontend/src/utils/ticketView.test.ts +296 -0
  978. manigent/web/frontend/src/utils/ticketView.ts +175 -0
  979. manigent/web/frontend/src/utils/todoAttachments.desiredState.test.ts +180 -0
  980. manigent/web/frontend/src/utils/todoAttachments.ts +126 -0
  981. manigent/web/frontend/src/utils/todoDraft.test.ts +84 -0
  982. manigent/web/frontend/src/utils/todoDraft.ts +46 -0
  983. manigent/web/frontend/src/utils/todoSearch.test.ts +82 -0
  984. manigent/web/frontend/src/utils/todoSearch.ts +43 -0
  985. manigent/web/frontend/src/utils/versionMatch.test.ts +295 -0
  986. manigent/web/frontend/src/utils/versionMatch.ts +245 -0
  987. manigent/web/frontend/src/vite-env.d.ts +1 -0
  988. manigent/web/frontend/tailwind.config.js +86 -0
  989. manigent/web/frontend/tos.html +188 -0
  990. manigent/web/frontend/tsconfig.dts.json +25 -0
  991. manigent/web/frontend/tsconfig.json +26 -0
  992. manigent/web/frontend/tsconfig.node.json +11 -0
  993. manigent/web/frontend/vite.config.ts +41 -0
  994. manigent/web/frontend/vitest.config.ts +16 -0
  995. manigent/ws_edge/app.py +514 -0
  996. manigent/ws_edge/auth.py +104 -0
  997. manigent/ws_edge/link.py +282 -0
  998. manigent/ws_edge/registry.py +206 -0
  999. manigent/ws_edge/terminal_relay.py +269 -0
  1000. manigent_server-0.1.0.dist-info/METADATA +138 -0
  1001. manigent_server-0.1.0.dist-info/RECORD +1004 -0
  1002. manigent_server-0.1.0.dist-info/WHEEL +4 -0
  1003. manigent_server-0.1.0.dist-info/entry_points.txt +2 -0
  1004. manigent_server-0.1.0.dist-info/licenses/LICENSE +68 -0
@@ -0,0 +1,1603 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """
3
+ manigent/models/desk_todo.py
4
+
5
+ Desk to-do store — the My Desk right-rail "plate": tasks that still sit
6
+ with the manager. Every field on a to-do is one the owner can edit in the
7
+ web UI: the text, labels, a draft prompt and its recipient, and stored
8
+ attachments. Items arrive two ways — the owner captures one in the rail, or
9
+ an agent publishes one (``manigent todo publish``) carrying a suggested
10
+ prompt and recipient, written into the same ``draft_*`` fields the owner's
11
+ recipient picker writes. Nothing an agent publishes is shown in a place the
12
+ owner cannot edit it.
13
+
14
+ Clicking a to-do opens the Task take-over (a guided dispatch surface); the
15
+ plate is an *ordered* list the manager drags to reorder, so — unlike the
16
+ one-file-per-artifact brief-tab registry — every user's plate is a single
17
+ ordered JSON document. Reorder is then a plain array rewrite under one
18
+ lock, and agent-publish is a prepend.
19
+
20
+ A brand-new owner does not open an empty plate: ``SEED`` below is the
21
+ onboarding item every account starts with, materialized in memory on the first
22
+ read and written to disk by the first mutation. It is never re-asserted
23
+ afterwards — see the comment on the constant.
24
+
25
+ Storage::
26
+
27
+ {data_dir}/desk-todos/
28
+ <owner_user_id>.json # ordered list[DeskTodo], newest capture first
29
+ # ABSENT => the SEED, not an empty plate
30
+ {data_dir}/desk-seed-state/
31
+ <owner_user_id>.json # {"given": [seed id, ...]} — every starter row
32
+ # this account was EVER given; only ever grows
33
+
34
+ Mutations are broadcast to the owner via ``DESK_TODO_SYNC`` (see
35
+ ``server/routes/desk_todos.py``) so the desk refetches ``GET /me/desk/todos``.
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import asyncio
40
+ import logging
41
+ import mimetypes
42
+ import re
43
+ import secrets
44
+ import shutil
45
+ import unicodedata
46
+ from collections.abc import Iterable
47
+ from datetime import UTC, datetime
48
+ from pathlib import Path
49
+ from typing import Any
50
+
51
+ from pydantic import BaseModel, Field, model_validator
52
+
53
+ from manigent.models.desk_sop import CONVENE_PROCEDURE, SCHEDULED_DEAL_SOP_TITLE
54
+ from manigent.models.onboarding_plan import (
55
+ ONBOARDING_PLAN_TEMPLATE,
56
+ PLAN_TEMPLATE_END,
57
+ PLAN_TEMPLATE_START,
58
+ )
59
+ from manigent.models.provider_agents import MODEL_AGENT_DEFINITION, provider_agent_names
60
+ from manigent.models.thinking_partner_plan import THINKING_PARTNER_PLAN_TEMPLATE
61
+ from manigent.utils.file_io import FileUtil
62
+
63
+ logger = logging.getLogger("manigent.models.desk_todo")
64
+
65
+ _lock = asyncio.Lock()
66
+
67
+ TODOS_DIR = "desk-todos"
68
+
69
+ # The seeded-items record: which starter rows this account has EVER been given,
70
+ # kept apart from the plate so that deleting a row cannot erase the fact that
71
+ # it was given. It is what lets ``backfill_starter_todos`` add a NEW starter row
72
+ # to old plates without ever re-adding one the owner already threw away.
73
+ # Written by the first plate save and by the backfill; nothing removes an entry.
74
+ SEED_STATE_DIR = "desk-seed-state"
75
+
76
+ # Real attachment bytes, one directory per to-do::
77
+ #
78
+ # {data_dir}/desk-todo-files/<owner_user_id>/<todo_id>/<attachment_id>
79
+ #
80
+ # Deliberately NOT the message-attachment layout in ``server/routes/files.py``.
81
+ # That one keys on ``(project_id, chatroom_name, filename)`` and writes through
82
+ # a changelog every project participant's runner replays into its own sandbox —
83
+ # so reusing it would fan a user's PRIVATE to-do attachments out to agents
84
+ # before the user ever dispatched them. Three further blockers, independent of
85
+ # the privacy one: that path has no delete route at all (append-only changelog),
86
+ # filename is its identity so two to-dos each holding ``report.pdf`` collide,
87
+ # and 20 MB of private bytes would live permanently in a replay stream every
88
+ # runner reads on join.
89
+ #
90
+ # This is a sibling of the desk documents already here (``desk-labels/``,
91
+ # ``desk-sops/``, ``desk-read-state/``, ``brief-tabs/``), on the
92
+ # ``knowledge_pack.py`` precedent: raw bytes on disk, base64 on the wire, no
93
+ # changelog. Both path segments and the filename are SERVER-MINTED ids, so path
94
+ # traversal is structurally impossible here rather than merely checked for —
95
+ # no client-supplied string ever reaches a path segment.
96
+ TODO_FILES_DIR = "desk-todo-files"
97
+
98
+ # The display ``name`` is not a path segment, but it IS echoed back in a
99
+ # Content-Disposition header by the download route, so it is bounded and
100
+ # scrubbed on the way in.
101
+ MAX_ATTACHMENT_NAME_LEN = 200
102
+
103
+ # Labels. Deliberately looser than the rail's chip-overflow threshold — the
104
+ # backend limit should never be the thing that makes a layout decision for the
105
+ # UI.
106
+ MAX_LABELS_PER_TODO = 8
107
+ MAX_SLUG_LEN = 32
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # The starter plate
111
+ # ---------------------------------------------------------------------------
112
+ #
113
+ # What a brand-new owner finds on the plate. Ordinary to-dos — editable,
114
+ # archivable, deletable, no ``locked`` field and no guard anywhere in this
115
+ # module protecting one. A helpful start, not a fixture.
116
+ #
117
+ # THE SEED IS A FIRST-WRITE MATERIALIZATION AND IS NEVER RE-ASSERTED. It decides
118
+ # only what an owner who has never written a plate sees; the moment anything is
119
+ # written — a capture, an archive toggle, a label edit, an agent publish — the
120
+ # file is authoritative forever after. An implementation that re-adds missing
121
+ # rows on read is wrong, and it is wrong in the way that silently resurrects the
122
+ # onboarding item every time the owner ticks it off. Mirrors
123
+ # ``models/desk_sop.py`` and ``models/desk_label.py``, which carry the long-form
124
+ # version of this argument; all three make the same promise and must keep
125
+ # making it.
126
+ #
127
+ # The decision is made on ``path.exists()`` and on nothing else, because
128
+ # ``FileUtil.read`` answers the same ``None`` for a missing file and for a
129
+ # JSONDecodeError: deciding "seed?" on the parsed value would re-seed a CORRUPT
130
+ # plate, which is the one case where the owner's rows are still on disk and most
131
+ # need not to be written over.
132
+ #
133
+ # ``by_agent_id`` is null: no agent published this. ``drafted`` IS true,
134
+ # because the row genuinely arrives carrying a ready prompt — that is the field
135
+ # the plate reads to show a preview of it and the toast reads to say "edit it,
136
+ # then command" rather than telling the owner to write a draft that is already
137
+ # there.
138
+ #
139
+ # The recipient pair is left null on purpose. A null recipient already resolves
140
+ # to ``{username}-assistant`` (``utils/todoDraft.ts``), which is exactly who
141
+ # this item is addressed to — and naming it here instead would bake one user's
142
+ # assistant name into a constant. It also has to be null for a further reason
143
+ # specific to this item: at account creation the assistant agent does not exist
144
+ # yet (it is registered later, from the user's own machine), so there is no id
145
+ # to store.
146
+ SEED_TIMESTAMP = "1970-01-01T00:00:00+00:00"
147
+
148
+ ONBOARDING_SEED_ID = "t-seed-onboarding-know-me"
149
+ THINKING_PARTNER_SEED_ID = "t-seed-thinking-partner"
150
+ CONVENE_SEED_ID = "t-seed-convene-models"
151
+ SCHEDULED_DEAL_SEED_ID = "t-seed-scheduled-deal-monitor"
152
+
153
+ # The second starter row's prompt. Same shape as onboarding's: the assistant
154
+ # fills the one ``<…>`` line of a ready-made plan
155
+ # (``models/thinking_partner_plan.py``), creates one project with it and sends a
156
+ # quiet review round, so the owner's only click is dismissing the start gate.
157
+ # Inline rather than a pointer to the "Register new agent" SOP in
158
+ # ``models/desk_sop.py``: that SOP is the owner's to edit or delete, and this
159
+ # to-do has to keep working after they do.
160
+ #
161
+ # The to-do registers NOTHING. thought_partner is registered by the plan's
162
+ # first milestone, after the owner has dismissed the gate, exactly as
163
+ # onboarding registers its team. ``--agent thought_partner`` at create is safe
164
+ # before the agent exists: the allowlist is stored as names and matched against
165
+ # the roster every turn.
166
+ THINKING_PARTNER_PROMPT = (
167
+ "Add a thinking partner for me as ONE project that you coordinate, using "
168
+ "the ready-made plan below.\n"
169
+ "\n"
170
+ # Knowledge flows model agents -> assistant -> everyone else. Without
171
+ # USER.md the brain dump would be all "unknown", so this row waits for
172
+ # onboarding rather than running a second briefing of its own.
173
+ "First: if my USER.md does not exist yet, stop and tell me to run \"Get to "
174
+ "know me & staff my priorities\" first: the brain dump is built from it.\n"
175
+ "1. Fill in the plan. Replace only the <...> line under Goal with my areas "
176
+ "from USER.md. Leave everything else word for word, and save it as "
177
+ "./thinking-partner-plan.md.\n"
178
+ "2. Create the project with your create-project skill, passing the plan, "
179
+ "thought_partner, and the id of this conversation:\n"
180
+ " manigent project create add-thought-partner \"$MANIGENT_AGENT_ID\" "
181
+ "\"<request>\" --display-name \"Add a thinking partner for big "
182
+ "decisions\" --agent-pool owned --agent thought_partner --spawned-from "
183
+ "<the id of this conversation> --plan-file ./thinking-partner-plan.md\n"
184
+ " The request reads: \"The plan is predefined. Do not redraft it and do "
185
+ "not consult anyone on it. Run `manigent plan review <project>` once so "
186
+ "the start gate goes up, tell me to dismiss it to begin, and stop.\"\n"
187
+ "3. Tell me, in one line, that dismissing the project's start gate "
188
+ "registers thought_partner and begins its onboarding. Do not register "
189
+ "thought_partner or install anything here; the plan's first milestone "
190
+ "does that. Do nothing else here.\n"
191
+ "\n"
192
+ f"{PLAN_TEMPLATE_START}\n"
193
+ f"{THINKING_PARTNER_PLAN_TEMPLATE}"
194
+ f"{PLAN_TEMPLATE_END}"
195
+ )
196
+
197
+ # The third starter row's prompt: a model-agent panel, the demo of what several
198
+ # models together give that one model alone does not. Inline, like the row
199
+ # above, rather than a pointer to the "SYSTEM:Convene panel" SOP the owner may
200
+ # delete; the moderation steps are that SOP's own ``CONVENE_PROCEDURE``, so the
201
+ # two cannot drift. Capped at 2 rounds so the demo stays cheap (about 9 agent
202
+ # turns with three model agents).
203
+ CONVENE_PROMPT = (
204
+ f"{MODEL_AGENT_DEFINITION}\n"
205
+ "\n"
206
+ "Show me what my model agents can do together. From my USER.md and my "
207
+ "plate, propose 3 judgment questions I really face (not lookups), one "
208
+ "sentence each. I pick one or write my own.\n"
209
+ "\n"
210
+ "Then convene a panel on it:\n"
211
+ " Panel: every model agent on my roster\n"
212
+ " Max rounds: 2\n"
213
+ " Consensus: majority\n"
214
+ # The assistant moderates and never votes (CONVENE_PROCEDURE), so the
215
+ # fallback panel is made of other agents, never the assistant itself.
216
+ "If I have fewer than 2 model agents, tell me, and offer the model agent I "
217
+ "do have plus thought_partner (or another of my agents) as the panel "
218
+ "instead, saying plainly that they may run on the same model, so the "
219
+ "panel is less diverse.\n"
220
+ "Don't create the project until I've picked the question.\n"
221
+ "\n"
222
+ f"{CONVENE_PROCEDURE}\n"
223
+ "\n"
224
+ "Also report the one claim a single model alone would have got wrong, and "
225
+ "which model agent caught it."
226
+ )
227
+
228
+ SEED: tuple[dict[str, object], ...] = (
229
+ {
230
+ "id": ONBOARDING_SEED_ID,
231
+ # Phrased for the OWNER reading their own plate: the row names the
232
+ # thing that needs doing, not the party doing it. Clicking it loads
233
+ # ``draft_prompt`` into the composer already addressed to
234
+ # ``{username}-assistant``, which is where the second person belongs.
235
+ "text": "Get to know me & staff my priorities",
236
+ "drafted": True,
237
+ # Self-contained rather than a pointer to an SOP: the plan travels
238
+ # inside the prompt, so the to-do keeps working if the owner deletes
239
+ # every SOP. The project it creates is the one place a new account's
240
+ # USER.md gets written; there is no separate personalize item.
241
+ #
242
+ # One click by design: the assistant creates the project with the plan
243
+ # already written (``--plan-file``) and sends a quiet review round, so
244
+ # the only thing on the owner's screen is the start gate. Dismissing it
245
+ # is the acceptance that starts the work.
246
+ #
247
+ # PRODUCT RULE, pinned in tests/test_desk_todo_seed.py: onboarding
248
+ # staffs to-dos and never starts them. Nothing here may tell the
249
+ # assistant to run ``todo trigger`` — the owner's own first click on a
250
+ # staffed to-do is the activation event.
251
+ "draft_prompt": (
252
+ "Get me set up as ONE project that you coordinate, using the "
253
+ "ready-made plan below. You staff my to-dos; you never start them. "
254
+ "I fire each one myself.\n"
255
+ "\n"
256
+ # The model agents (models/provider_agents.py) are plain agents in
257
+ # code; mentoring the assistant is written down in the plan and
258
+ # nowhere else. Knowledge flows one way: model agents -> assistant
259
+ # -> thought_partner and every agent the plan registers.
260
+ f"{MODEL_AGENT_DEFINITION}\n"
261
+ "\n"
262
+ "1. Fill in the plan. Replace only the <...> lines under Goal:\n"
263
+ " - Model agents: the ones on my roster from this fixed list: "
264
+ f"{', '.join(provider_agent_names())}. If I have none, write "
265
+ "\"none\".\n"
266
+ " - Existing agents: my other agents, one line each (name - "
267
+ "job).\n"
268
+ " Leave everything else word for word, and save it as "
269
+ "./onboarding-plan.md.\n"
270
+ "2. Create the project with your create-project skill, passing the "
271
+ "plan, every model agent on my roster, and the id of this "
272
+ "conversation:\n"
273
+ " manigent project create get-to-know-me \"$MANIGENT_AGENT_ID\" "
274
+ "\"<request>\" --display-name \"Get to know me & staff my "
275
+ "priorities\" --agent-pool owned --agent <model agent> "
276
+ "--spawned-from <the id of this conversation> --plan-file "
277
+ "./onboarding-plan.md\n"
278
+ " The request reads: \"The plan is predefined. Do not redraft it "
279
+ "and do not consult anyone on it. Run `manigent plan review "
280
+ "<project>` once so the start gate goes up, tell me to dismiss it "
281
+ "to begin, and stop.\"\n"
282
+ "3. Tell me, in one line, that the project is ready and that "
283
+ "dismissing its start gate begins it. Do nothing else here.\n"
284
+ "\n"
285
+ f"{PLAN_TEMPLATE_START}\n"
286
+ f"{ONBOARDING_PLAN_TEMPLATE}"
287
+ f"{PLAN_TEMPLATE_END}"
288
+ ),
289
+ },
290
+ {
291
+ # Right after onboarding, which is what writes the USER.md the brain
292
+ # dump in this row's plan draws on. Same shape as the row above: a
293
+ # ready prompt whose plan rides in the project create, and a null
294
+ # recipient that resolves to the owner's own assistant — the one agent
295
+ # that can register another.
296
+ "id": THINKING_PARTNER_SEED_ID,
297
+ "text": "Add a thinking partner for big decisions",
298
+ "drafted": True,
299
+ "draft_prompt": THINKING_PARTNER_PROMPT,
300
+ },
301
+ {
302
+ # Third: it proposes questions from USER.md and the plate, which
303
+ # onboarding writes. Same shape as the rows above. Like them it never
304
+ # fires anything; it creates the panel project only after the owner
305
+ # picks the question.
306
+ "id": CONVENE_SEED_ID,
307
+ "text": "Get the best answer from all my models combined",
308
+ "drafted": True,
309
+ "draft_prompt": CONVENE_PROMPT,
310
+ },
311
+ {
312
+ # Last: a standalone everyday job that needs nothing onboarding writes.
313
+ # Unlike the rows above it POINTS at an SOP rather than carrying the
314
+ # procedure inline, so the owner edits the tracking steps in one place;
315
+ # if they delete or rename that SOP, the assistant says it cannot find
316
+ # it. Not in ``BACKFILL_SEED_IDS``: new accounts only.
317
+ "id": SCHEDULED_DEAL_SEED_ID,
318
+ "text": "Scheduled Deal Monitor",
319
+ "drafted": True,
320
+ "draft_prompt": (
321
+ f"Please invoke the SOP \"{SCHEDULED_DEAL_SOP_TITLE}\". Ask me "
322
+ "what product to track and how often to check it (for example "
323
+ "\"every day at 9am\" or \"every Monday\"). If any other parameter "
324
+ "is missing or you are not sure about one, ask me before you start."
325
+ ),
326
+ },
327
+ )
328
+
329
+ # Distinguishes "key absent from a PATCH body" (leave the stored value
330
+ # untouched) from an explicit JSON ``null`` (clear the field). Used only by
331
+ # ``patch_todo`` for the three-way ``draft_recipient_*`` merge; the route
332
+ # layer detects key presence off the raw request body (see
333
+ # ``server/routes/desk_todos.py``) since a non-serializable sentinel can't be
334
+ # a FastAPI ``Body`` default.
335
+ _UNSET: object = object()
336
+
337
+
338
+ class LabelError(ValueError):
339
+ """A coded label failure. ``str(e)`` is EXACTLY the wire format the label
340
+ data contract §9 froze::
341
+
342
+ "<code>: <human sentence>"
343
+ "labels.cap_exceeded: at most 8 labels per to-do (got 9)"
344
+
345
+ Subclasses ``ValueError`` on purpose: the three ``except ValueError`` handlers
346
+ already in ``server/routes/desk_todos.py`` map it to a 400 with ``str(e)`` as
347
+ ``detail``, which is byte-for-byte what §9 asks for — so the to-do write paths
348
+ need no new except clause. ``status`` is only read by
349
+ ``server/routes/desk_labels.py``, where 404 and 409 are also reachable.
350
+
351
+ Every §9 row gets a constructor here, so a code string is written once in the
352
+ codebase and cannot be mistyped at one call site and right at another. The
353
+ registry-only codes live here too even though only ``desk_label`` raises
354
+ them: one home for the codes beats a second, near-identical error class, and
355
+ ``desk_label`` may import from this module (never the reverse).
356
+
357
+ The code is frozen from first release; the sentence after it is free to
358
+ change forever, and nothing may parse it.
359
+ """
360
+
361
+ def __init__(self, code: str, sentence: str, status: int = 400) -> None:
362
+ super().__init__(f"{code}: {sentence}")
363
+ self.code = code
364
+ self.sentence = sentence
365
+ self.status = status
366
+
367
+ # --- to-do write paths ---------------------------------------------------
368
+ @classmethod
369
+ def cap_exceeded(cls, got: int) -> "LabelError":
370
+ return cls(
371
+ "labels.cap_exceeded",
372
+ f"at most {MAX_LABELS_PER_TODO} labels per to-do (got {got})",
373
+ )
374
+
375
+ @classmethod
376
+ def invalid_slug(cls, raw: str) -> "LabelError":
377
+ return cls(
378
+ "labels.invalid_slug",
379
+ f"{raw!r} is not a valid label — use letters, numbers and hyphens",
380
+ )
381
+
382
+ @classmethod
383
+ def slug_too_long(cls, slug: str) -> "LabelError":
384
+ return cls(
385
+ "labels.slug_too_long", f"{slug!r} exceeds {MAX_SLUG_LEN} characters"
386
+ )
387
+
388
+ @classmethod
389
+ def invalid_type(cls) -> "LabelError":
390
+ return cls("labels.invalid_type", "labels must be a list of strings")
391
+
392
+ # --- registry paths (raised by models/desk_label.py) ---------------------
393
+ @classmethod
394
+ def name_too_long(cls, limit: int) -> "LabelError":
395
+ return cls("labels.name_too_long", f"name exceeds {limit} characters")
396
+
397
+ @classmethod
398
+ def registry_full(cls, limit: int) -> "LabelError":
399
+ return cls("labels.registry_full", f"at most {limit} labels in your list")
400
+
401
+ @classmethod
402
+ def unknown_color(cls, token: str, palette: tuple[str, ...]) -> "LabelError":
403
+ return cls(
404
+ "labels.unknown_color",
405
+ f"{token!r} is not a known palette token — use one of: "
406
+ + ", ".join(palette),
407
+ )
408
+
409
+ @classmethod
410
+ def invalid_kind(cls) -> "LabelError":
411
+ # THE CODE STRING IS FROZEN. It is published in the to-do data contract
412
+ # and the browser branches on it at ``utils/labelErrors.ts`` to write
413
+ # its own copy; changing it here is a silent wire break. Only the
414
+ # SENTENCE moves, and it moves because reciting a one-member enum
415
+ # ("kind must be 'context'") answers a question nobody asked. What the
416
+ # caller actually did was ask for a state, and what they need to hear is
417
+ # that states are not a label any more — the lifecycle they want is
418
+ # derived on ``state`` and cannot be set by hand.
419
+ return cls(
420
+ "labels.invalid_kind",
421
+ "every label is a context now — the 'state' kind is retired, and "
422
+ "whether the work is running is derived on the to-do's `state`",
423
+ )
424
+
425
+ @classmethod
426
+ def kind_immutable(cls, slug: str, stored: str, wanted: str) -> "LabelError":
427
+ return cls(
428
+ "labels.kind_immutable",
429
+ f"{slug!r} is a {stored} and cannot become a {wanted} — "
430
+ f"delete it and add it again",
431
+ )
432
+
433
+ @classmethod
434
+ def kind_conflict(cls, slug: str, other_kind: str, wanted: str) -> "LabelError":
435
+ """One symmetric condition, one code, either direction — the sentence is
436
+ generated from the OTHER row's kind."""
437
+ return cls(
438
+ "labels.kind_conflict",
439
+ f"{slug!r} is a {other_kind}, not a {wanted}",
440
+ status=409,
441
+ )
442
+
443
+ @classmethod
444
+ def kind_mismatch(
445
+ cls, slug: str, slug_kind: str, into: str, into_kind: str
446
+ ) -> "LabelError":
447
+ return cls(
448
+ "labels.kind_mismatch",
449
+ f"{slug!r} is a {slug_kind} and {into!r} is a {into_kind} — "
450
+ f"labels can only be merged into the same kind",
451
+ )
452
+
453
+ @classmethod
454
+ def unknown_slug(cls, slug: str) -> "LabelError":
455
+ return cls(
456
+ "labels.unknown_slug",
457
+ f"label {slug!r} is not in your label list",
458
+ status=404,
459
+ )
460
+
461
+ @classmethod
462
+ def unknown_merge_target(cls, slug: str) -> "LabelError":
463
+ return cls(
464
+ "labels.unknown_merge_target",
465
+ f"label {slug!r} is not in your label list",
466
+ status=404,
467
+ )
468
+
469
+ @classmethod
470
+ def duplicate(cls, slug: str) -> "LabelError":
471
+ return cls("labels.duplicate", f"label {slug!r} already exists", status=409)
472
+
473
+ @classmethod
474
+ def merge_self(cls, slug: str) -> "LabelError":
475
+ return cls("labels.merge_self", f"cannot merge {slug!r} into itself")
476
+
477
+ @classmethod
478
+ def registry_unreadable(cls) -> "LabelError":
479
+ """The registry file exists and cannot be read, so a write that starts
480
+ from "zero rows" would replace rows nobody has seen.
481
+
482
+ 409, not 400 and not 500: the request is well-formed and the server is
483
+ healthy — it is the CURRENT STATE of the document that makes the write
484
+ unsafe, which is exactly what 409 means. A 400 would blame the caller
485
+ for a file they did not corrupt, and a 500 would invite a retry that is
486
+ guaranteed to fail the same way.
487
+
488
+ Only DIRECT registry mutations raise this. ``auto_register`` skips
489
+ instead: refusing to save someone's to-do because a label INDEX is
490
+ unreadable is a worse outcome than the corruption.
491
+ """
492
+ return cls(
493
+ "labels.registry_unreadable",
494
+ "your label list is on disk but cannot be read — it has not been "
495
+ "changed, and it will not be overwritten until it is repaired or "
496
+ "removed",
497
+ status=409,
498
+ )
499
+
500
+
501
+ # Runs of anything outside the stored charset collapse to a single "-". That is
502
+ # a REPLACE, not a drop, and it is what makes the contract's own worked example
503
+ # true ("Someday/Maybe" -> "someday-maybe", not "somedaymaybe"); it also matches
504
+ # the mock's `NORM` character-for-character, which is the other implementation
505
+ # of this grammar and the one @frontend ships against.
506
+ _NON_SLUG_RUN = re.compile(r"[^a-z0-9-]+")
507
+ _DASH_RUN = re.compile(r"-{2,}")
508
+ _VALID_SLUG = re.compile(r"[a-z0-9][a-z0-9-]*")
509
+
510
+
511
+ def normalize_label(raw: str) -> str:
512
+ """One label through the contract's normalization, in order: NFKC + strip;
513
+ drop ONE leading '@'; lowercase; collapse every run of non-``[a-z0-9-]`` to
514
+ a single '-'; collapse '-' runs and trim them; then validate.
515
+
516
+ Raises ``LabelError('labels.invalid_slug')`` when the result is empty or
517
+ fails ``^[a-z0-9][a-z0-9-]*$``, and ``labels.slug_too_long`` past 32 chars.
518
+ Length is checked AFTER normalization — a 40-char display name that
519
+ normalizes to 20 is fine.
520
+
521
+ ``'@Office'`` -> ``office`` · ``'Wait For'`` -> ``wait-for`` ·
522
+ ``'Someday/Maybe'`` -> ``someday-maybe`` · ``'@ '`` -> invalid_slug.
523
+ """
524
+ if not isinstance(raw, str):
525
+ raise LabelError.invalid_type()
526
+ text = unicodedata.normalize("NFKC", raw).strip()
527
+ if text.startswith("@"):
528
+ text = text[1:]
529
+ text = _NON_SLUG_RUN.sub("-", text.lower())
530
+ text = _DASH_RUN.sub("-", text).strip("-")
531
+ if not text or not _VALID_SLUG.fullmatch(text):
532
+ raise LabelError.invalid_slug(raw)
533
+ if len(text) > MAX_SLUG_LEN:
534
+ raise LabelError.slug_too_long(text)
535
+ return text
536
+
537
+
538
+ def normalize_labels(raw: object) -> list[str]:
539
+ """A whole label list, normalized and de-duplicated preserving FIRST-SEEN
540
+ order (chip order is meaningful; nothing reads it yet, but nothing may
541
+ scramble it either).
542
+
543
+ ``None`` -> ``[]``. A non-list, or a list holding a non-string, raises
544
+ ``labels.invalid_type`` — which is why every route reads the label keys
545
+ untyped: FastAPI's own 422 carries a LIST as ``detail`` and would break the
546
+ contract's client-side ``^labels\\.[a-z0-9_]+: `` parse rule.
547
+
548
+ Does NOT check the cap. The cap is a property of the RESULT of a write, not
549
+ of an input, and checking it here would reject the add-delta that swaps a
550
+ label at 8. See ``merge_label_keys``.
551
+ """
552
+ if raw is None:
553
+ return []
554
+ if not isinstance(raw, (list, tuple)):
555
+ raise LabelError.invalid_type()
556
+ out: list[str] = []
557
+ for item in raw:
558
+ if not isinstance(item, str):
559
+ raise LabelError.invalid_type()
560
+ slug = normalize_label(item)
561
+ if slug not in out:
562
+ out.append(slug)
563
+ return out
564
+
565
+
566
+ def merge_label_keys(
567
+ current: list[str],
568
+ *,
569
+ replace: object = _UNSET,
570
+ add: object = None,
571
+ remove: object = None,
572
+ ) -> list[str]:
573
+ """Apply the three label keys in their ruled precedence — replace, then add,
574
+ then remove — and enforce the cap ONCE, on the final set.
575
+
576
+ Three behaviours an implementer cannot infer from the endpoint list, and all
577
+ three have a named test:
578
+
579
+ * The cap is checked AFTER dedupe, so re-adding a label an 8-label item
580
+ already carries is a no-op, not a 400. The assistant retries commands,
581
+ and a rule that fails only on the second run is the worst kind.
582
+ * The cap is checked AFTER ``remove`` is applied, so
583
+ ``{add_labels:[x], remove_labels:[y]}`` on an 8-label item succeeds.
584
+ Checking between add and remove would refuse a legal swap.
585
+ * ``got`` in the error sentence is the size of the WOULD-BE result, not
586
+ the length of the payload. The sentence describes the to-do, which is
587
+ what the owner is looking at.
588
+
589
+ Raises ``labels.cap_exceeded`` and applies nothing — the caller has not saved
590
+ yet, so a rejected write leaves the plate byte-identical.
591
+ """
592
+ result = list(current) if replace is _UNSET else normalize_labels(replace)
593
+ for slug in normalize_labels(add):
594
+ if slug not in result:
595
+ result.append(slug)
596
+ removing = set(normalize_labels(remove))
597
+ if removing:
598
+ result = [s for s in result if s not in removing]
599
+ if len(result) > MAX_LABELS_PER_TODO:
600
+ raise LabelError.cap_exceeded(len(result))
601
+ return result
602
+
603
+
604
+ class AttachmentError(ValueError):
605
+ """An attachment failure that already knows its HTTP status.
606
+
607
+ Subclasses ``ValueError`` on purpose, exactly as ``LabelError`` does: it
608
+ rides the ``except ValueError`` handlers already in
609
+ ``server/routes/desk_todos.py`` if a caller forgets the narrower clause,
610
+ degrading to a 400 with the right sentence rather than a 500.
611
+
612
+ ``str(e)`` is EXACTLY the wire ``detail``: a plain string, never a list,
613
+ never a code prefix. Unlike ``LabelError`` there is no machine-readable
614
+ code, because nothing parses these — the contract froze the SENTENCES, and
615
+ the client renders them verbatim.
616
+
617
+ This module may not raise ``HTTPException``: ``models/`` ships in the runner
618
+ wheel and fastapi is not a runner dependency. Every limit it enforces
619
+ therefore arrives as a parameter rather than being read from the
620
+ environment here.
621
+ """
622
+
623
+ def __init__(self, status: int, sentence: str) -> None:
624
+ super().__init__(sentence)
625
+ self.status = status
626
+ self.sentence = sentence
627
+
628
+
629
+ class DeskTodoAttachment(BaseModel):
630
+ """One real, stored file on a to-do. The bytes live on disk at
631
+ ``{data_dir}/desk-todo-files/<owner>/<todo_id>/<id>``; this row is the ONLY
632
+ thing that names them, and a blob the plate does not name is garbage by
633
+ definition.
634
+
635
+ Written by the owner, on save, and nobody else — ``manigent todo
636
+ publish`` carries no attachments, so an agent can never park bytes on its
637
+ owner's plate.
638
+
639
+ ``name`` is the original filename, DISPLAY ONLY. It never becomes a path
640
+ segment: the file on disk is named by ``id``, which is why traversal is
641
+ structurally impossible here rather than merely checked for.
642
+ """
643
+
644
+ id: str
645
+ name: str
646
+ size: int
647
+ content_type: str
648
+ sha256: str
649
+ created_at: str
650
+
651
+
652
+ class DeskTodo(BaseModel):
653
+ """A single item on the manager's plate.
654
+
655
+ Only fields the owner can edit in the web UI live here (plus ids,
656
+ timestamps and the hidden ``by_agent_id``). Rows written by older servers
657
+ may carry keys that have since been removed — ``origin``, ``source``,
658
+ ``due``, ``by_agent_name``, ``suggest_agent_*``, ``context``, ``files``,
659
+ ``done_steps``, ``available``, ``linked``. They are not declared, so
660
+ pydantic's default ``extra="ignore"`` drops them on load and the next
661
+ ``_save`` persists the slim shape — the same strategy
662
+ ``_archived_read_shim`` describes for ``status``. No migration pass.
663
+ """
664
+
665
+ id: str
666
+ owner_user_id: str
667
+ text: str
668
+ # The owner filed it away. A DISPOSAL, never a lifecycle state — nothing
669
+ # project-driven writes it and nothing reads it as "the work finished".
670
+ # A bool rather than a renamed two-value string on purpose: a string is
671
+ # what lets a third value get added later and re-invent lifecycle state,
672
+ # which is the exact confusion this field exists to end.
673
+ archived: bool = False
674
+ created_at: str
675
+ updated_at: str
676
+
677
+ # Ids of the projects and DM threads this to-do spawned. ONE id space, so a
678
+ # DM-thread id and a regular project id are indistinguishable here — which
679
+ # is required, because the desk's **command** button spawns a DM thread and
680
+ # that is the feature's most common creation path.
681
+ #
682
+ # A REFERENCE, not a foreign key: an id whose project was deleted stays in
683
+ # the list, is legal, and simply stops contributing to the derived state.
684
+ # This module stores the ids and knows NOTHING about projects — exactly as
685
+ # it stores label slugs and knows nothing about the registry. The
686
+ # derivation lives in the third module, ``models/desk_todo_link.py``, which
687
+ # imports this one and is never imported by it.
688
+ project_ids: list[str] = Field(default_factory=list)
689
+
690
+ # Bare normalized slugs — the owner's GTD contexts and states. Presentation
691
+ # (display name, colour, which axis) lives in the SEPARATE registry document
692
+ # (``models/desk_label.py``); this list is NOT a foreign key into it. A slug
693
+ # with no registry row is valid and renders unregistered, which is the whole
694
+ # reason the plate stays readable with the registry gone. Default-empty, so
695
+ # every plate written before labels existed loads unchanged and there is no
696
+ # migration script.
697
+ labels: list[str] = Field(default_factory=list)
698
+
699
+ draft_prompt: str | None = None
700
+ # Recipient of the draft — picked in the take-over composer, or resolved
701
+ # from an agent publish's ``--suggest``. Both id and name are persisted so
702
+ # the plate pill still labels correctly after a rename/removal; the
703
+ # frontend re-resolves against the live roster and falls back if the agent
704
+ # is gone. A suggested name that resolved to nobody stores the name with a
705
+ # null id. Null until set.
706
+ draft_recipient_id: str | None = None
707
+ draft_recipient_name: str | None = None
708
+ # Real stored bytes the OWNER parked on this item. Always a list, never
709
+ # null — ``default_factory`` means every plate row written before
710
+ # attachments existed validates unchanged, so there is no migration.
711
+ attachments: list[DeskTodoAttachment] = Field(default_factory=list)
712
+
713
+ # True once the item carries a ready draft: set when the manager saves one
714
+ # in the take-over, or when an agent publishes with a draft prompt.
715
+ drafted: bool = False
716
+
717
+ # Hidden provenance, never displayed: the agent that published this item,
718
+ # kept ONLY so ``DELETE /me/desk/todos/{id}`` can let the publishing agent
719
+ # retract its own item. Null for anything the owner captured.
720
+ by_agent_id: str | None = None
721
+
722
+ @model_validator(mode="before")
723
+ @classmethod
724
+ def _archived_read_shim(cls, data: Any) -> Any:
725
+ """Read a plate written in the old ``status: "open" | "done"`` shape.
726
+
727
+ NOT compat-nostalgia, and not optional. Every plate on disk today
728
+ carries ``status``; without this line every to-do the owner ever filed
729
+ away springs back onto their open plate on first load, which is exactly
730
+ what AC-4.3 forbids::
731
+
732
+ legacy `status` present AND `archived` absent -> archived = (status == "done")
733
+ `archived` present -> it wins, always
734
+ neither -> False (the field default)
735
+
736
+ ``status`` is deliberately NOT redeclared as a field, so pydantic's
737
+ default ``extra="ignore"`` drops it once this validator has read it, and
738
+ the owner's next ORDINARY save — a rename, a label edit, an archive
739
+ toggle — persists the new shape as a side effect of a write they asked
740
+ for. There is no rewrite pass, no bulk write over anyone's plate, and no
741
+ schema-version bump: the plate is one JSON document per owner and
742
+ ``_save`` already rewrites the whole array, so the re-shape costs
743
+ nothing and touches exactly the one owner who wrote.
744
+
745
+ The shim is also what makes the change loadable in both directions
746
+ during a rollout: a row already carrying ``archived`` is untouched here.
747
+ """
748
+ if not isinstance(data, dict):
749
+ return data
750
+ if "archived" in data:
751
+ return data
752
+ if "status" in data:
753
+ data = dict(data)
754
+ data["archived"] = data.get("status") == "done"
755
+ return data
756
+
757
+
758
+ def gen_id() -> str:
759
+ return "t-" + secrets.token_hex(6)
760
+
761
+
762
+ def _now() -> str:
763
+ return datetime.now(UTC).isoformat()
764
+
765
+
766
+ def _path(data_dir: Path, owner_user_id: str) -> Path:
767
+ return Path(data_dir) / TODOS_DIR / f"{owner_user_id}.json"
768
+
769
+
770
+ def _seed_rows(owner_user_id: str) -> list[DeskTodo]:
771
+ """The starter plate, materialized in memory for one owner.
772
+
773
+ The timestamps are the fixed epoch constant rather than the clock. The seed
774
+ is materialized on every read of an unwritten plate, so minting ``now()``
775
+ here would make two consecutive GETs return different ``created_at`` values
776
+ for the same row — churn a client can legitimately notice, in a field
777
+ nothing needs. It is also simply more truthful: the owner did not capture
778
+ this. Rows keep the constant when the first write materializes them to
779
+ disk; anything captured or published afterwards is stamped with the real
780
+ clock and, being prepended, sorts ahead of them.
781
+ """
782
+ return [
783
+ DeskTodo(
784
+ owner_user_id=owner_user_id,
785
+ created_at=SEED_TIMESTAMP,
786
+ updated_at=SEED_TIMESTAMP,
787
+ **row,
788
+ )
789
+ for row in SEED
790
+ ]
791
+
792
+
793
+ def _load(data_dir: Path, owner_user_id: str) -> list[DeskTodo]:
794
+ """Every read and every write starts here, so "missing" vs "empty" vs
795
+ "unreadable" is decided in exactly one place.
796
+
797
+ NO FILE -> the SEED, in memory. A file that exists but holds no usable rows
798
+ -> ZERO rows, never the seed as a repair: an owner who cleared their last
799
+ to-do gets an empty plate and keeps it, and a corrupt document is not
800
+ written over with starter content.
801
+ """
802
+ path = _path(data_dir, owner_user_id)
803
+ if not path.exists():
804
+ return _seed_rows(owner_user_id)
805
+ raw = FileUtil.read(path, "json")
806
+ if not isinstance(raw, list):
807
+ return []
808
+ out: list[DeskTodo] = []
809
+ for row in raw:
810
+ if not isinstance(row, dict):
811
+ continue
812
+ try:
813
+ out.append(DeskTodo.model_validate(row))
814
+ except Exception:
815
+ continue
816
+ return out
817
+
818
+
819
+ def _save(data_dir: Path, owner_user_id: str, todos: list[DeskTodo]) -> None:
820
+ """Every write ends here. The FIRST one — no plate file yet — is the moment
821
+ the in-memory seed becomes real, so it also records every ``SEED`` id as
822
+ given. All of them, not just the ones still in ``todos``: the owner was
823
+ shown the whole seed, and a first write that deletes a starter row must
824
+ still count it as given or the backfill would hand it straight back."""
825
+ path = _path(data_dir, owner_user_id)
826
+ first_save = not path.exists()
827
+ FileUtil.write(path, [t.model_dump() for t in todos], "json")
828
+ if first_save:
829
+ record_given_seed_ids(data_dir, owner_user_id, (row["id"] for row in SEED))
830
+
831
+
832
+ # --- the seeded-items record ------------------------------------------------
833
+
834
+
835
+ def _seed_state_path(data_dir: Path, owner_user_id: str) -> Path:
836
+ return Path(data_dir) / SEED_STATE_DIR / f"{owner_user_id}.json"
837
+
838
+
839
+ def given_seed_ids(data_dir: Path, owner_user_id: str) -> set[str]:
840
+ """Seed ids this account has ever been given. A missing or unreadable
841
+ record reads as the empty set."""
842
+ raw = FileUtil.read(_seed_state_path(data_dir, owner_user_id), "json")
843
+ given = raw.get("given") if isinstance(raw, dict) else None
844
+ if not isinstance(given, list):
845
+ return set()
846
+ return {g for g in given if isinstance(g, str)}
847
+
848
+
849
+ def record_given_seed_ids(
850
+ data_dir: Path, owner_user_id: str, ids: Iterable[str],
851
+ ) -> None:
852
+ """Union ``ids`` into the record. Never removes an entry, and skips the
853
+ write when nothing is new."""
854
+ have = given_seed_ids(data_dir, owner_user_id)
855
+ merged = have | set(ids)
856
+ if merged == have:
857
+ return
858
+ FileUtil.write(
859
+ _seed_state_path(data_dir, owner_user_id),
860
+ {"given": sorted(merged)},
861
+ "json",
862
+ )
863
+
864
+
865
+ # --- the one-time starter backfill ------------------------------------------
866
+
867
+
868
+ class BackfillReport(BaseModel):
869
+ """Per-account outcome of ``backfill_starter_todos``, by user id.
870
+
871
+ ``would_add`` and ``added`` name the same accounts; ``added`` is filled
872
+ only when the run applied. ``skipped_unreadable`` is a plate file that
873
+ exists but is not a JSON list — it is left alone rather than written over,
874
+ and unlike a missing plate the new-account seed will NOT reach it."""
875
+
876
+ would_add: list[str] = Field(default_factory=list)
877
+ added: list[str] = Field(default_factory=list)
878
+ skipped_already_given: list[str] = Field(default_factory=list)
879
+ skipped_no_plate: list[str] = Field(default_factory=list)
880
+ skipped_unreadable: list[str] = Field(default_factory=list)
881
+
882
+ @property
883
+ def skipped(self) -> int:
884
+ return (
885
+ len(self.skipped_already_given)
886
+ + len(self.skipped_no_plate)
887
+ + len(self.skipped_unreadable)
888
+ )
889
+
890
+
891
+ # Starter rows added after launch, each given once to every existing plate by
892
+ # ``backfill_starter_todos``. Each is decided on its own: an owner who deleted
893
+ # one still gets the next.
894
+ BACKFILL_SEED_IDS: tuple[str, ...] = (THINKING_PARTNER_SEED_ID, CONVENE_SEED_ID)
895
+
896
+
897
+ def _seed_row(seed_id: str, owner_user_id: str) -> dict[str, Any]:
898
+ (row,) = (r for r in SEED if r["id"] == seed_id)
899
+ return DeskTodo(
900
+ owner_user_id=owner_user_id,
901
+ created_at=SEED_TIMESTAMP,
902
+ updated_at=SEED_TIMESTAMP,
903
+ **row,
904
+ ).model_dump()
905
+
906
+
907
+ def backfill_starter_todos(
908
+ data_dir: Path, user_ids: Iterable[str], *, apply: bool,
909
+ ) -> BackfillReport:
910
+ """Give every existing saved plate each ``BACKFILL_SEED_IDS`` row, once.
911
+
912
+ OFFLINE ONLY: plate writes are serialized by the in-process ``_lock``, which
913
+ this cannot share with a running server — the CLI wrapper refuses
914
+ ``apply=True`` while the server is up. Per account:
915
+
916
+ - no plate file -> skipped_no_plate (the new seed covers it)
917
+ - plate not a JSON list -> skipped_unreadable (never written over)
918
+
919
+ and then per starter row, each decided on its own:
920
+
921
+ - id in the given record -> left out (deleted stays deleted)
922
+ - id already on the plate-> left out (and recorded, on apply)
923
+ - otherwise -> inserted right after the nearest earlier
924
+ ``SEED`` row still on the plate, or at the top
925
+ if none is; on apply the record gains it and
926
+ the onboarding id (a pre-feature plate was
927
+ given onboarding at its first save)
928
+
929
+ The account lands in would_add (and added, on apply) if any row went in,
930
+ otherwise in skipped_already_given. The plate is edited as raw JSON rows so
931
+ every other row is written back exactly as it was read, including rows the
932
+ model would reject.
933
+ """
934
+ seed_order = [str(r["id"]) for r in SEED]
935
+ report = BackfillReport()
936
+ for uid in user_ids:
937
+ path = _path(data_dir, uid)
938
+ if not path.exists():
939
+ report.skipped_no_plate.append(uid)
940
+ continue
941
+ raw = FileUtil.read(path, "json")
942
+ if not isinstance(raw, list):
943
+ report.skipped_unreadable.append(uid)
944
+ continue
945
+ given = given_seed_ids(data_dir, uid)
946
+ inserted: list[str] = []
947
+ already_there: list[str] = []
948
+ for seed_id in BACKFILL_SEED_IDS:
949
+ if seed_id in given:
950
+ continue
951
+ ids = [r.get("id") if isinstance(r, dict) else None for r in raw]
952
+ if seed_id in ids:
953
+ already_there.append(seed_id)
954
+ continue
955
+ earlier = seed_order[: seed_order.index(seed_id)]
956
+ at = 0
957
+ for prev in reversed(earlier):
958
+ if prev in ids:
959
+ at = ids.index(prev) + 1
960
+ break
961
+ raw.insert(at, _seed_row(seed_id, uid))
962
+ inserted.append(seed_id)
963
+ if apply and already_there:
964
+ record_given_seed_ids(data_dir, uid, already_there)
965
+ if not inserted:
966
+ report.skipped_already_given.append(uid)
967
+ continue
968
+ report.would_add.append(uid)
969
+ if apply:
970
+ FileUtil.write(path, raw, "json")
971
+ record_given_seed_ids(data_dir, uid, [ONBOARDING_SEED_ID, *inserted])
972
+ report.added.append(uid)
973
+ return report
974
+
975
+
976
+ # --- the attachment blob store ----------------------------------------------
977
+ # The plate JSON is the ONLY source of truth. A blob it does not name is
978
+ # garbage by definition — which is what makes the orphan sweep below one
979
+ # ``listdir`` and a set difference, and what makes the ordering rule in
980
+ # ``patch_todo`` sufficient rather than merely hopeful.
981
+
982
+
983
+ def gen_attachment_id() -> str:
984
+ """``"a-" + token_hex(6)``. Sibling of ``gen_id``'s ``"t-"`` prefix, so an
985
+ id is self-describing in a log line and a to-do id can never be mistaken
986
+ for an attachment id in a URL."""
987
+ return "a-" + secrets.token_hex(6)
988
+
989
+
990
+ def normalize_attachment_name(raw: str) -> str:
991
+ """The stored display ``name``: strip control characters and path
992
+ separators, drop leading dots, collapse whitespace, bound the length, and
993
+ fall back to ``"file"`` if nothing survives.
994
+
995
+ Belt and braces over the route's ``_validate_filename``, which rejects
996
+ traversal outright. This one exists for a different reason: the name is
997
+ echoed back in a ``Content-Disposition`` header by the download route, so a
998
+ name carrying a quote, a CR or a NUL could inject a header parameter. It is
999
+ scrubbed once, on the way in, so every later reader of ``name`` — header,
1000
+ UI, log — gets an already-safe string.
1001
+ """
1002
+ text = raw or ""
1003
+ # Path separators first, so "a/b" collapses to two words rather than "ab".
1004
+ for sep in ("/", "\\"):
1005
+ text = text.replace(sep, " ")
1006
+ text = "".join(ch for ch in text if unicodedata.category(ch)[0] != "C")
1007
+ text = re.sub(r"\s+", " ", text).strip().lstrip(".").strip()
1008
+ text = text[:MAX_ATTACHMENT_NAME_LEN].strip()
1009
+ return text or "file"
1010
+
1011
+
1012
+ def guess_content_type(name: str) -> str:
1013
+ """The stored ``content_type``, derived from the NORMALIZED display name.
1014
+
1015
+ Derived server-side rather than trusted from the client — the inbound entry
1016
+ is exactly ``{filename, content_b64}`` and carries no type — so a caller
1017
+ cannot mislabel bytes into a browser-rendered type. Same
1018
+ ``mimetypes.guess_type`` + ``application/octet-stream`` fallback
1019
+ ``server/routes/files.py`` already applies on download. The type is
1020
+ DESCRIBED, never gated: there is no allowlist on this path, because a to-do
1021
+ exists to be dispatched and the message path that receives it has none
1022
+ either — a file you can send in a DM must not be one you cannot park.
1023
+ """
1024
+ return mimetypes.guess_type(name)[0] or "application/octet-stream"
1025
+
1026
+
1027
+ def _owner_files_dir(data_dir: Path, owner_user_id: str) -> Path:
1028
+ """``{data_dir}/desk-todo-files/<owner_user_id>/`` — the sweep's unit."""
1029
+ return Path(data_dir) / TODO_FILES_DIR / owner_user_id
1030
+
1031
+
1032
+ def todo_files_dir(data_dir: Path, owner_user_id: str, todo_id: str) -> Path:
1033
+ """``…/<todo_id>/`` — the delete's unit. Both segments are server-minted."""
1034
+ return _owner_files_dir(data_dir, owner_user_id) / todo_id
1035
+
1036
+
1037
+ def read_attachment_blob(
1038
+ data_dir: Path, owner_user_id: str, todo_id: str, attachment_id: str
1039
+ ) -> bytes | None:
1040
+ """The stored bytes, or ``None`` when the file is missing.
1041
+
1042
+ ``None`` is not an error here. A plate row naming vanished bytes is the
1043
+ deliberately-surfaced case: the route turns it into a 404 that renders as a
1044
+ broken chip, rather than a 500 or a silently-empty download.
1045
+ """
1046
+ return FileUtil.read(
1047
+ todo_files_dir(data_dir, owner_user_id, todo_id) / attachment_id, "bytes"
1048
+ )
1049
+
1050
+
1051
+ def write_attachment_blob(
1052
+ data_dir: Path, owner_user_id: str, todo_id: str, attachment_id: str, raw: bytes
1053
+ ) -> None:
1054
+ """Write one blob, through ``FileUtil.write(..., "bytes")`` so it gets the
1055
+ same atomic write + parent mkdir every other document on this desk gets.
1056
+
1057
+ Deliberately a module-level function rather than an inline ``FileUtil``
1058
+ call: this is the seam a test patches to prove that a failed blob write
1059
+ leaves the plate byte-identical. Monkeypatching ``FileUtil.write`` globally
1060
+ would break the plate write too and prove nothing.
1061
+ """
1062
+ FileUtil.write(
1063
+ todo_files_dir(data_dir, owner_user_id, todo_id) / attachment_id, raw, "bytes"
1064
+ )
1065
+
1066
+
1067
+ def _remove_dir_if_empty(path: Path) -> None:
1068
+ try:
1069
+ if path.is_dir() and not any(path.iterdir()):
1070
+ path.rmdir()
1071
+ except OSError as e: # pragma: no cover - filesystem edge
1072
+ logger.warning(f"Could not remove empty attachment dir {path}: {e}")
1073
+
1074
+
1075
+ def _write_new_blobs(
1076
+ data_dir: Path,
1077
+ owner_user_id: str,
1078
+ todo_id: str,
1079
+ minted: list[tuple[str, bytes]],
1080
+ ) -> None:
1081
+ """Write every newly-minted blob, BEFORE the plate row that will name them.
1082
+
1083
+ On ANY exception: unlink every blob THIS call already wrote, remove the
1084
+ to-do's directory if it is now empty, and re-raise unchanged. Without the
1085
+ rollback the plate would still be byte-identical (we never reached
1086
+ ``_save``) but a directory would exist on disk. The sweep would eventually
1087
+ take it; "eventually" is not the guarantee worth having when the whole
1088
+ point of writing bytes first is that a failure leaves nothing behind.
1089
+ """
1090
+ written: list[Path] = []
1091
+ try:
1092
+ for attachment_id, raw in minted:
1093
+ write_attachment_blob(data_dir, owner_user_id, todo_id, attachment_id, raw)
1094
+ written.append(
1095
+ todo_files_dir(data_dir, owner_user_id, todo_id) / attachment_id
1096
+ )
1097
+ except BaseException:
1098
+ for path in written:
1099
+ FileUtil.delete(path)
1100
+ _remove_dir_if_empty(todo_files_dir(data_dir, owner_user_id, todo_id))
1101
+ raise
1102
+
1103
+
1104
+ # --- the two-document escape hatch ------------------------------------------
1105
+ # ``desk_label.delete_label`` / ``merge_label`` mutate BOTH the plate and the
1106
+ # registry and must hold the plate lock across both writes, so they cannot call
1107
+ # ``patch_todo`` / ``reorder_todos`` — those take this same non-reentrant lock
1108
+ # and would deadlock. They get these three instead.
1109
+ #
1110
+ # LOCK ORDER, and it is the only rule preventing a deadlock here:
1111
+ # plate_lock (OUTER) -> desk_label._registry_lock (INNER). NEVER reversed.
1112
+ # Nothing in the type system enforces that, which is why it is written on both
1113
+ # modules.
1114
+ plate_lock = _lock
1115
+
1116
+
1117
+ def load_plate(data_dir: Path, owner_user_id: str) -> list[DeskTodo]:
1118
+ """The plate, unlocked. Callers MUST already hold ``plate_lock``."""
1119
+ return _load(data_dir, owner_user_id)
1120
+
1121
+
1122
+ def save_plate(data_dir: Path, owner_user_id: str, todos: list[DeskTodo]) -> None:
1123
+ """Write the plate, unlocked. Callers MUST already hold ``plate_lock``."""
1124
+ _save(data_dir, owner_user_id, todos)
1125
+
1126
+
1127
+ def sweep_orphan_todo_files(data_dir: Path, owner_user_id: str) -> int:
1128
+ """Delete every blob the plate does not name, for one owner. Returns the
1129
+ number of paths removed.
1130
+
1131
+ Two levels, because there are two ways to orphan bytes:
1132
+
1133
+ * an entire ``<todo_id>/`` directory whose to-do is gone from the plate,
1134
+ and
1135
+ * a single ``<attachment_id>`` file inside a LIVE to-do whose descriptor
1136
+ was reconciled away — which is also what a crash between the blob write
1137
+ and the plate write leaves behind, and is the real orphan risk that
1138
+ deleting a to-do never exercises.
1139
+
1140
+ CALLERS MUST ALREADY HOLD ``plate_lock`` — the same contract, for the same
1141
+ reason, that ``load_plate`` / ``save_plate`` carry. Both callers
1142
+ (``delete_todo``, ``patch_todo``) are already inside it, and taking the
1143
+ non-reentrant lock here would deadlock. It reads through ``load_plate``,
1144
+ never ``_load``-plus-lock.
1145
+
1146
+ Deliberately NOT scheduled. There is no cron and no background job, because
1147
+ a sweep that runs only from a scheduler is a sweep that silently is not
1148
+ running in someone's deployment. It can afford to be synchronous precisely
1149
+ because the plate is the only source of truth: a blob it does not name is
1150
+ garbage by definition, so this is one ``listdir`` of a small per-owner
1151
+ directory diffed against a list already in memory.
1152
+
1153
+ Best-effort by contract: a filesystem error is logged, never raised. The
1154
+ caller's write has already succeeded, and failing it on a bookkeeping side
1155
+ effect would leave the user with an item they cannot remove.
1156
+ """
1157
+ root = _owner_files_dir(data_dir, owner_user_id)
1158
+ if not root.is_dir():
1159
+ return 0
1160
+ referenced = {
1161
+ t.id: {a.id for a in t.attachments}
1162
+ for t in load_plate(data_dir, owner_user_id)
1163
+ }
1164
+ removed = 0
1165
+ try:
1166
+ entries = list(root.iterdir())
1167
+ except OSError as e: # pragma: no cover - filesystem edge
1168
+ logger.warning(f"Orphan sweep could not list {root}: {e}")
1169
+ return 0
1170
+ for entry in entries:
1171
+ try:
1172
+ if entry.name not in referenced:
1173
+ # The to-do is gone from the plate; everything under it is
1174
+ # unreachable by definition.
1175
+ if entry.is_dir():
1176
+ shutil.rmtree(entry, ignore_errors=True)
1177
+ else:
1178
+ entry.unlink()
1179
+ removed += 1
1180
+ continue
1181
+ if not entry.is_dir():
1182
+ continue
1183
+ keep = referenced[entry.name]
1184
+ for blob in entry.iterdir():
1185
+ if blob.name not in keep:
1186
+ if blob.is_dir():
1187
+ shutil.rmtree(blob, ignore_errors=True)
1188
+ else:
1189
+ blob.unlink()
1190
+ removed += 1
1191
+ _remove_dir_if_empty(entry)
1192
+ except OSError as e: # pragma: no cover - filesystem edge
1193
+ logger.warning(f"Orphan sweep could not remove {entry}: {e}")
1194
+ return removed
1195
+
1196
+
1197
+ def list_todos(data_dir: Path, owner_user_id: str) -> list[DeskTodo]:
1198
+ """Return the owner's plate in stored (manager-controlled) order."""
1199
+ return _load(data_dir, owner_user_id)
1200
+
1201
+
1202
+ def get_todo(data_dir: Path, owner_user_id: str, todo_id: str) -> DeskTodo | None:
1203
+ for t in _load(data_dir, owner_user_id):
1204
+ if t.id == todo_id:
1205
+ return t
1206
+ return None
1207
+
1208
+
1209
+ async def add_todo(
1210
+ data_dir: Path,
1211
+ owner_user_id: str,
1212
+ text: str,
1213
+ *,
1214
+ labels: object = None,
1215
+ ) -> DeskTodo:
1216
+ """Capture an owner task; prepended to the plate (newest first).
1217
+
1218
+ ``labels`` is normalized and capped BEFORE the row is built, so a rejected
1219
+ capture never reaches the document."""
1220
+ text = (text or "").strip()
1221
+ if not text:
1222
+ raise ValueError("Task text cannot be empty")
1223
+ slugs = merge_label_keys([], replace=labels)
1224
+ async with _lock:
1225
+ todos = _load(data_dir, owner_user_id)
1226
+ now = _now()
1227
+ todo = DeskTodo(
1228
+ id=gen_id(),
1229
+ owner_user_id=owner_user_id,
1230
+ text=text,
1231
+ created_at=now,
1232
+ updated_at=now,
1233
+ labels=slugs,
1234
+ )
1235
+ todos.insert(0, todo)
1236
+ _save(data_dir, owner_user_id, todos)
1237
+ return todo
1238
+
1239
+
1240
+ async def publish_agent_todo(
1241
+ data_dir: Path,
1242
+ owner_user_id: str,
1243
+ *,
1244
+ by_agent_id: str,
1245
+ text: str,
1246
+ draft_prompt: str | None = None,
1247
+ draft_recipient_id: str | None = None,
1248
+ draft_recipient_name: str | None = None,
1249
+ labels: object = None,
1250
+ ) -> DeskTodo:
1251
+ """Push an agent-published task onto the owner's plate (prepended).
1252
+
1253
+ The suggested prompt and recipient land in the same ``draft_*`` fields the
1254
+ owner's take-over writes, so what the agent suggests is exactly what the
1255
+ owner sees and edits. The caller (the route) resolves the recipient.
1256
+
1257
+ ``labels`` is validated identically to ``add_todo``. Note what does NOT
1258
+ happen here: no auto-registration. The item is the agent's to write; the
1259
+ vocabulary is the owner's to curate."""
1260
+ text = (text or "").strip()
1261
+ if not text:
1262
+ raise ValueError("Task text cannot be empty")
1263
+ slugs = merge_label_keys([], replace=labels)
1264
+ async with _lock:
1265
+ todos = _load(data_dir, owner_user_id)
1266
+ now = _now()
1267
+ todo = DeskTodo(
1268
+ id=gen_id(),
1269
+ owner_user_id=owner_user_id,
1270
+ text=text,
1271
+ created_at=now,
1272
+ updated_at=now,
1273
+ by_agent_id=by_agent_id,
1274
+ draft_prompt=draft_prompt or None,
1275
+ draft_recipient_id=draft_recipient_id,
1276
+ draft_recipient_name=draft_recipient_name,
1277
+ drafted=bool(draft_prompt),
1278
+ labels=slugs,
1279
+ )
1280
+ todos.insert(0, todo)
1281
+ _save(data_dir, owner_user_id, todos)
1282
+ return todo
1283
+
1284
+
1285
+ def _reconcile_attachments(
1286
+ data_dir: Path,
1287
+ owner_user_id: str,
1288
+ target: DeskTodo,
1289
+ desired: list[dict],
1290
+ total_limit: int | None,
1291
+ ) -> list[DeskTodoAttachment]:
1292
+ """Turn the desired-state array into the to-do's new attachment list.
1293
+
1294
+ Pure planning plus blob WRITES. It does not save the plate and it does not
1295
+ delete anything — deletion is the caller's step 3, strictly after the plate
1296
+ stops naming the bytes. The caller holds ``plate_lock``.
1297
+
1298
+ ``desired`` is the route's already-decoded form: ``{"keep": id}`` entries
1299
+ pass through, ``{"name", "raw", "content_type"}`` entries are new bytes.
1300
+ Order is preserved exactly as sent — the tray is an ordered thing on
1301
+ screen, and nothing here may scramble it.
1302
+
1303
+ The array is the COMPLETE desired state, which is what makes it
1304
+ structurally duplicate-proof: a client that re-sends bytes for an
1305
+ already-stored file instead of ``{keep: id}`` gets a new id and the old id
1306
+ is absent, so the worst case is churn (new id, same bytes) and never two
1307
+ rows for one file.
1308
+
1309
+ Three cases worth stating because they are not inferable from the shape:
1310
+
1311
+ * A ``{keep}`` naming an id that is not on THIS to-do raises
1312
+ ``AttachmentError(400, ...)``. Loud, not silent: absence-means-delete
1313
+ is only safe because the client holds the complete desired state, so a
1314
+ client that has demonstrably lost track of what is stored — a stale
1315
+ second tab — is told in the same request rather than having its
1316
+ confusion committed. It is also the only option consistent with the
1317
+ save being atomic; silently dropping the entry would half-honour it.
1318
+ * A duplicate ``{keep}`` of the same id collapses to one row.
1319
+ * The per-to-do total is checked HERE, on the post-reconcile result
1320
+ (kept + new), not on the input — the same discipline
1321
+ ``merge_label_keys`` applies to the label cap, and for the same reason:
1322
+ the sentence has to describe the to-do, which is what the owner is
1323
+ looking at. It raises having applied nothing, so the plate stays
1324
+ byte-identical and no byte reaches disk.
1325
+ """
1326
+ stored = {a.id: a for a in target.attachments}
1327
+ now = _now()
1328
+
1329
+ result: list[DeskTodoAttachment] = []
1330
+ seen_keeps: set[str] = set()
1331
+ minted: list[tuple[str, bytes]] = []
1332
+
1333
+ for entry in desired:
1334
+ keep_id = entry.get("keep")
1335
+ if keep_id is not None:
1336
+ existing = stored.get(keep_id)
1337
+ if existing is None:
1338
+ raise AttachmentError(
1339
+ 400, f"attachment {keep_id!r} is not on this to-do"
1340
+ )
1341
+ if keep_id in seen_keeps:
1342
+ continue
1343
+ seen_keeps.add(keep_id)
1344
+ result.append(existing)
1345
+ continue
1346
+
1347
+ raw: bytes = entry["raw"]
1348
+ attachment_id = gen_attachment_id()
1349
+ result.append(
1350
+ DeskTodoAttachment(
1351
+ id=attachment_id,
1352
+ name=entry["name"],
1353
+ size=len(raw),
1354
+ content_type=entry["content_type"],
1355
+ sha256=FileUtil.sha256(raw),
1356
+ created_at=now,
1357
+ )
1358
+ )
1359
+ minted.append((attachment_id, raw))
1360
+
1361
+ if total_limit is not None:
1362
+ total = sum(a.size for a in result)
1363
+ if total > total_limit:
1364
+ raise AttachmentError(
1365
+ 413,
1366
+ f"attachments total {total} bytes, exceeds the {total_limit}-byte "
1367
+ "per-to-do limit (raise MANIGENT_MAX_MESSAGE_ATTACHMENTS_BYTES)",
1368
+ )
1369
+
1370
+ # Only now, with every check passed, do bytes reach the disk — so a 413 or
1371
+ # a bad {keep} never leaves one behind.
1372
+ if minted:
1373
+ _write_new_blobs(data_dir, owner_user_id, target.id, minted)
1374
+ return result
1375
+
1376
+
1377
+ async def patch_todo(
1378
+ data_dir: Path,
1379
+ owner_user_id: str,
1380
+ todo_id: str,
1381
+ *,
1382
+ archived: bool | None = None,
1383
+ text: str | None = None,
1384
+ draft_prompt: str | None = None,
1385
+ draft_recipient_id: str | None = _UNSET, # _UNSET → leave untouched
1386
+ draft_recipient_name: str | None = _UNSET, # None → clear, str → set
1387
+ drafted: bool | None = None,
1388
+ labels: object = _UNSET, # _UNSET → leave untouched, None/[] → clear
1389
+ add_labels: object = None,
1390
+ remove_labels: object = None,
1391
+ attachments: object = _UNSET, # _UNSET → untouched, None/[] → remove all
1392
+ total_limit: int | None = None, # post-reconcile cap, supplied by the route
1393
+ ) -> DeskTodo | None:
1394
+ """Patch a task in place. Returns the updated task, or None if missing.
1395
+
1396
+ ``draft_recipient_id`` / ``draft_recipient_name`` are three-way: ``_UNSET``
1397
+ (the default, when the caller omits them) leaves the stored value alone,
1398
+ an explicit ``None`` clears it, and a string overwrites it. The older
1399
+ scalar fields collapse absent+None into "untouched" (a ``None`` never
1400
+ clears them).
1401
+
1402
+ ``labels`` is three-way in exactly the same way. ``add_labels`` /
1403
+ ``remove_labels`` need no three-way handling — absent and empty both mean
1404
+ no-op — and are idempotent in both directions: adding a label the item
1405
+ already carries and removing one it does not are 200s, not errors. All three
1406
+ are delegated to ``merge_label_keys``, which is where the cap is checked.
1407
+
1408
+ ``attachments`` is three-way in exactly the same way ``labels`` is:
1409
+ ``_UNSET`` (the caller omitted the key) leaves the stored list alone,
1410
+ ``None`` or ``[]`` removes all, and a list reconciles to exactly that
1411
+ desired state. The ABSENT case is the one that matters most — a done
1412
+ toggle, a rename or a label edit must never nuke a to-do's files, and every
1413
+ one of those goes through this function. ``total_limit`` is the per-to-do
1414
+ byte cap; the route supplies it, because this module ships in the runner
1415
+ wheel and may not read the server's environment for itself.
1416
+
1417
+ ORDER INSIDE THE LOCK, and it is the only rule that keeps a crash
1418
+ survivable::
1419
+
1420
+ 1. plan + write NEW blobs (_reconcile_attachments)
1421
+ 2. write the plate (_save)
1422
+ 3. delete now-unreferenced blobs (sweep_orphan_todo_files)
1423
+
1424
+ New bytes go down before the row that names them, so a failed blob write
1425
+ leaves nothing in the JSON. Deletions happen strictly AFTER the row stops
1426
+ naming them, because delete-then-failed-save would destroy bytes the plate
1427
+ still points at — the one failure the "plate is the only source of truth"
1428
+ invariant does not cover. Both halves stay inside the same lock, and the
1429
+ worst case (a crash in the gap) leaves an unreferenced directory that the
1430
+ next attachment-bearing save sweeps.
1431
+ """
1432
+ async with _lock:
1433
+ todos = _load(data_dir, owner_user_id)
1434
+ target: DeskTodo | None = None
1435
+ for t in todos:
1436
+ if t.id == todo_id:
1437
+ target = t
1438
+ break
1439
+ if target is None:
1440
+ return None
1441
+ if archived is not None:
1442
+ # THE ONE WRITER (AC-1.6). This assignment is the single place in
1443
+ # the product that records "the owner filed this away". Its three
1444
+ # callers are the desk checkbox, the CLI archive/unarchive verbs,
1445
+ # and the trigger's --consume option. Nothing project-driven writes
1446
+ # it and nothing reads it as "the work finished".
1447
+ #
1448
+ # A bool has no value to validate, so the old
1449
+ # `status must be 'open' or 'done'` branch is gone outright. None
1450
+ # still means "untouched", exactly as it did for `status`.
1451
+ target.archived = archived
1452
+ if text is not None:
1453
+ text = text.strip()
1454
+ if text:
1455
+ target.text = text
1456
+ if draft_prompt is not None:
1457
+ target.draft_prompt = draft_prompt
1458
+ if draft_recipient_id is not _UNSET:
1459
+ target.draft_recipient_id = draft_recipient_id
1460
+ if draft_recipient_name is not _UNSET:
1461
+ target.draft_recipient_name = draft_recipient_name
1462
+ if drafted is not None:
1463
+ target.drafted = drafted
1464
+ if labels is not _UNSET or add_labels is not None or remove_labels is not None:
1465
+ target.labels = merge_label_keys(
1466
+ target.labels,
1467
+ replace=labels,
1468
+ add=add_labels,
1469
+ remove=remove_labels,
1470
+ )
1471
+ if attachments is not _UNSET:
1472
+ desired = list(attachments) if isinstance(attachments, list) else []
1473
+ # Step 1: plan, then write the new bytes — before the row names them.
1474
+ target.attachments = _reconcile_attachments(
1475
+ data_dir, owner_user_id, target, desired, total_limit
1476
+ )
1477
+ target.updated_at = _now()
1478
+ # Step 2: the plate. From here on the new blobs are reachable and the
1479
+ # ones the desired-state array dropped are, by definition, garbage.
1480
+ _save(data_dir, owner_user_id, todos)
1481
+ if attachments is not _UNSET:
1482
+ # Step 3: collect that garbage — strictly after the row stopped
1483
+ # naming it, never before. This is the SAME primitive delete_todo
1484
+ # uses; the reconcile has no delete branch of its own to get wrong.
1485
+ sweep_orphan_todo_files(data_dir, owner_user_id)
1486
+ return target
1487
+
1488
+
1489
+ async def associate_todo(
1490
+ data_dir: Path, owner_user_id: str, todo_id: str, project_id: str
1491
+ ) -> DeskTodo | None:
1492
+ """Add one project (or DM-thread) id to a to-do. Returns the updated to-do,
1493
+ or None if the to-do is gone.
1494
+
1495
+ IDEMPOTENT: associating a project the to-do already carries is a no-op that
1496
+ still returns the to-do, not a 409. That matches ``add_labels`` /
1497
+ ``remove_labels`` and it matches them for the same reason — the owner's
1498
+ assistant retries commands, and a rule that succeeds on the first run and
1499
+ fails on the second is the worst kind of rule to hand an agent.
1500
+
1501
+ Stores the id VERBATIM. It does not resolve the project, does not validate
1502
+ it, and does not check who owns it: this module knows nothing about
1503
+ projects. Authorization is the ROUTE's job and lives there, because the
1504
+ visibility predicate it needs (``server/routes/_batch.py``) is not shipped
1505
+ in the runner wheel while this module is.
1506
+ """
1507
+ project_id = (project_id or "").strip()
1508
+ if not project_id:
1509
+ raise ValueError("project_id cannot be empty")
1510
+ async with _lock:
1511
+ todos = _load(data_dir, owner_user_id)
1512
+ for t in todos:
1513
+ if t.id == todo_id:
1514
+ if project_id not in t.project_ids:
1515
+ t.project_ids.append(project_id)
1516
+ t.updated_at = _now()
1517
+ _save(data_dir, owner_user_id, todos)
1518
+ return t
1519
+ return None
1520
+
1521
+
1522
+ async def dissociate_todo(
1523
+ data_dir: Path, owner_user_id: str, todo_id: str, project_id: str
1524
+ ) -> DeskTodo | None:
1525
+ """Remove one project id from a to-do. Returns the updated to-do, or None
1526
+ if the to-do is gone.
1527
+
1528
+ Idempotent in the other direction: removing an id the to-do does not carry
1529
+ is a no-op that still returns the to-do.
1530
+
1531
+ This is also the owner's ONLY way to clear a DANGLING id — one whose
1532
+ project has been deleted — which is why the route above it takes no
1533
+ ownership check on the way out. A deleted project resolves to nothing, so
1534
+ an ownership check would make exactly the rows that most need removing the
1535
+ ones that cannot be removed.
1536
+ """
1537
+ async with _lock:
1538
+ todos = _load(data_dir, owner_user_id)
1539
+ for t in todos:
1540
+ if t.id == todo_id:
1541
+ if project_id in t.project_ids:
1542
+ t.project_ids = [p for p in t.project_ids if p != project_id]
1543
+ t.updated_at = _now()
1544
+ _save(data_dir, owner_user_id, todos)
1545
+ return t
1546
+ return None
1547
+
1548
+
1549
+ async def reorder_todos(
1550
+ data_dir: Path, owner_user_id: str, ordered_ids: list[str]
1551
+ ) -> list[DeskTodo]:
1552
+ """Reorder the plate to match ``ordered_ids``. Ids not present are ignored;
1553
+ any todos omitted from ``ordered_ids`` are appended in their prior order so
1554
+ a partial list (e.g. only the open items) never drops the rest."""
1555
+ async with _lock:
1556
+ todos = _load(data_dir, owner_user_id)
1557
+ by_id = {t.id: t for t in todos}
1558
+ seen: set[str] = set()
1559
+ ordered: list[DeskTodo] = []
1560
+ for tid in ordered_ids:
1561
+ t = by_id.get(tid)
1562
+ if t is not None and tid not in seen:
1563
+ ordered.append(t)
1564
+ seen.add(tid)
1565
+ for t in todos:
1566
+ if t.id not in seen:
1567
+ ordered.append(t)
1568
+ _save(data_dir, owner_user_id, ordered)
1569
+ return ordered
1570
+
1571
+
1572
+ async def delete_todo(data_dir: Path, owner_user_id: str, todo_id: str) -> bool:
1573
+ """Remove a task, and its stored bytes with it. Returns True if it existed.
1574
+
1575
+ The plate write goes FIRST here, for the same reason blob deletions come
1576
+ last in ``patch_todo``: the row is what makes the bytes reachable, so it
1577
+ goes first and the bytes follow. A crash in the gap leaves an unreferenced
1578
+ directory, which is garbage by definition and which the sweep — run here,
1579
+ and on the next attachment-bearing save — takes.
1580
+
1581
+ ``rmtree`` is best-effort by contract: a filesystem error is logged, not
1582
+ raised. The to-do is already gone from the plate, and failing the delete
1583
+ would leave the user with an item they cannot remove; the sweep is the
1584
+ backstop. It runs over the whole OWNER directory rather than just this
1585
+ to-do's, because a delete is the cheapest moment to notice that some
1586
+ earlier crash left bytes behind.
1587
+ """
1588
+ async with _lock:
1589
+ todos = _load(data_dir, owner_user_id)
1590
+ kept = [t for t in todos if t.id != todo_id]
1591
+ if len(kept) == len(todos):
1592
+ return False
1593
+ _save(data_dir, owner_user_id, kept)
1594
+ try:
1595
+ shutil.rmtree(todo_files_dir(data_dir, owner_user_id, todo_id))
1596
+ except FileNotFoundError:
1597
+ pass
1598
+ except OSError as e: # pragma: no cover - filesystem edge
1599
+ logger.warning(
1600
+ f"Could not remove attachment dir for to-do {todo_id!r}: {e}"
1601
+ )
1602
+ sweep_orphan_todo_files(data_dir, owner_user_id)
1603
+ return True