taskuary 0.3.2.5__tar.gz → 0.3.2.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. {taskuary-0.3.2.5/taskuary.egg-info → taskuary-0.3.2.7}/PKG-INFO +2 -2
  2. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/README.md +1 -1
  3. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/pyproject.toml +1 -1
  4. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/agents.py +4 -3
  5. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/browserview.py +3 -2
  6. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/channels.py +11 -1
  7. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/guard.py +1 -0
  8. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/histgen.py +46 -9
  9. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/imapmail.py +103 -0
  10. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/intacct.py +69 -0
  11. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/mcp.py +2 -1
  12. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/proof.py +2 -1
  13. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/reports.py +28 -2
  14. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/scopes.py +4 -0
  15. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/server.py +22 -6
  16. taskuary-0.3.2.7/taskuary/spawn.py +34 -0
  17. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/store.py +5 -1
  18. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/terminal.py +26 -0
  19. taskuary-0.3.2.7/taskuary/update.py +179 -0
  20. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/wabridge.py +4 -3
  21. taskuary-0.3.2.5/taskuary/web/assets/GeneralWorkspace-D46ydeAJ.css → taskuary-0.3.2.7/taskuary/web/assets/GeneralWorkspace-BpkuvgFU.css +1 -1
  22. taskuary-0.3.2.5/taskuary/web/assets/GeneralWorkspace-vtyO6pPt.js → taskuary-0.3.2.7/taskuary/web/assets/GeneralWorkspace-Cmu6KGGe.js +2 -2
  23. taskuary-0.3.2.7/taskuary/web/assets/SocialView-kma4W7JV.js +1 -0
  24. taskuary-0.3.2.7/taskuary/web/assets/index-rF2UKXaH.js +1014 -0
  25. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/web/index.html +1 -1
  26. {taskuary-0.3.2.5 → taskuary-0.3.2.7/taskuary.egg-info}/PKG-INFO +2 -2
  27. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary.egg-info/SOURCES.txt +8 -4
  28. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_browser_session.py +4 -4
  29. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_climodels.py +1 -1
  30. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_core.py +4 -4
  31. taskuary-0.3.2.7/tests/test_imap_history.py +245 -0
  32. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_intacct.py +91 -0
  33. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_terminal.py +45 -0
  34. taskuary-0.3.2.7/tests/test_update.py +185 -0
  35. taskuary-0.3.2.5/taskuary/web/assets/SocialView-T_GUmkia.js +0 -1
  36. taskuary-0.3.2.5/taskuary/web/assets/index-BGJoDxsJ.js +0 -1014
  37. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/LICENSE +0 -0
  38. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/setup.cfg +0 -0
  39. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/__init__.py +0 -0
  40. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/aisetup.py +0 -0
  41. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/artifacts.py +0 -0
  42. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/assistant.py +0 -0
  43. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/aws.py +0 -0
  44. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/azure.py +0 -0
  45. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/blackboard.py +0 -0
  46. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/calendar.py +0 -0
  47. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/categories.py +0 -0
  48. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/ci.py +0 -0
  49. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/cli.py +0 -0
  50. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/climodels.py +0 -0
  51. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/clis.py +0 -0
  52. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/coder.py +0 -0
  53. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/compose.py +0 -0
  54. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/config.py +0 -0
  55. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/context.py +0 -0
  56. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/counsel.py +0 -0
  57. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/db.py +0 -0
  58. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/demo.py +0 -0
  59. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/desktop.py +0 -0
  60. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/devtools.py +0 -0
  61. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/digest.py +0 -0
  62. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/docsync.py +0 -0
  63. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/evalset.py +0 -0
  64. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/general.py +0 -0
  65. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/github.py +0 -0
  66. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/handbook.py +0 -0
  67. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/handraise.py +0 -0
  68. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/hooks.py +0 -0
  69. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/imessage.py +0 -0
  70. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/ingest.py +0 -0
  71. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/interview.py +0 -0
  72. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/knowledge.py +0 -0
  73. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/learn.py +0 -0
  74. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/learnedgraph.py +0 -0
  75. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/llm.py +0 -0
  76. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/logs.py +0 -0
  77. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/messengers.py +0 -0
  78. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/msauth.py +0 -0
  79. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/mssql.py +0 -0
  80. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/outbound.py +0 -0
  81. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/outbox.py +0 -0
  82. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/ownwork.py +0 -0
  83. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/phone.py +0 -0
  84. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/playbooks.py +0 -0
  85. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/pm.py +0 -0
  86. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/policy.py +0 -0
  87. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/problems.py +0 -0
  88. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/promptmap.py +0 -0
  89. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/proposals.py +0 -0
  90. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/quickbooks.py +0 -0
  91. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/rank.py +0 -0
  92. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/research.py +0 -0
  93. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/reshape.py +0 -0
  94. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/responder.py +0 -0
  95. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/routing.py +0 -0
  96. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/selfclose.py +0 -0
  97. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/semantic.py +0 -0
  98. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/senders.py +0 -0
  99. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/setup.py +0 -0
  100. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/sharepoint.py +0 -0
  101. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/sheets.py +0 -0
  102. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/teller.py +0 -0
  103. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/templates/coder.md +0 -0
  104. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/templates/counsel.md +0 -0
  105. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/templates/digest.md +0 -0
  106. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/templates/learned.md +0 -0
  107. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/templates/playbook.md +0 -0
  108. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/templates/soul.md +0 -0
  109. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/templates/style.md +0 -0
  110. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/templates/triage.md +0 -0
  111. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/testing.py +0 -0
  112. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/toil.py +0 -0
  113. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/triage.py +0 -0
  114. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/verdicts.py +0 -0
  115. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/voice.py +0 -0
  116. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/waitroom.py +0 -0
  117. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/web/assets/index-6GBZ9nXN.css +0 -0
  118. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/web/favicon.ico +0 -0
  119. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/web/favicon.png +0 -0
  120. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/webguard.py +0 -0
  121. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/whatsapp/bridge.mjs +0 -0
  122. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/whatsapp/package.json +0 -0
  123. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/whoami.py +0 -0
  124. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/witness.py +0 -0
  125. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary.egg-info/dependency_links.txt +0 -0
  126. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary.egg-info/entry_points.txt +0 -0
  127. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary.egg-info/requires.txt +0 -0
  128. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary.egg-info/top_level.txt +0 -0
  129. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_agent_environment.py +0 -0
  130. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_agent_source.py +0 -0
  131. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_agent_wall.py +0 -0
  132. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_aisetup.py +0 -0
  133. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_answered_yourself.py +0 -0
  134. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_answers.py +0 -0
  135. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_api.py +0 -0
  136. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_assistant.py +0 -0
  137. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_assistant_verdicts.py +0 -0
  138. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_async_triage.py +0 -0
  139. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_audit_chain.py +0 -0
  140. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_aws_region.py +0 -0
  141. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_blackboard.py +0 -0
  142. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_browserview.py +0 -0
  143. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_calendar.py +0 -0
  144. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_calendar_today.py +0 -0
  145. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_categories.py +0 -0
  146. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_cc_triage.py +0 -0
  147. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_chat_doors.py +0 -0
  148. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_chat_queue.py +0 -0
  149. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_clis.py +0 -0
  150. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_cloud.py +0 -0
  151. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_cloud_sources.py +0 -0
  152. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_compose.py +0 -0
  153. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_compose_sources.py +0 -0
  154. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_config.py +0 -0
  155. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_context.py +0 -0
  156. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_counsel.py +0 -0
  157. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_cwlogs_paging.py +0 -0
  158. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_demo.py +0 -0
  159. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_desktop.py +0 -0
  160. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_devtools.py +0 -0
  161. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_diff_review.py +0 -0
  162. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_digest_brief.py +0 -0
  163. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_docs_and_llm.py +0 -0
  164. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_docs_flow.py +0 -0
  165. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_duplicate_connectors.py +0 -0
  166. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_factory.py +0 -0
  167. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_feed_working.py +0 -0
  168. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_finished_chat_stays_finished.py +0 -0
  169. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_general.py +0 -0
  170. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_github_auto.py +0 -0
  171. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_guard.py +0 -0
  172. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_handbook.py +0 -0
  173. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_handbook_topics.py +0 -0
  174. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_handoff_channels.py +0 -0
  175. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_handraise.py +0 -0
  176. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_harvest.py +0 -0
  177. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_histgen.py +0 -0
  178. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_home_is_never_yours.py +0 -0
  179. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_imapmail.py +0 -0
  180. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_imessage.py +0 -0
  181. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_kind_dispatch.py +0 -0
  182. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_knowledge.py +0 -0
  183. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_learnedgraph.py +0 -0
  184. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_local_file.py +0 -0
  185. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_mail_folders.py +0 -0
  186. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_mark_read.py +0 -0
  187. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_mcp.py +0 -0
  188. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_meeting_prep.py +0 -0
  189. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_message_thread.py +0 -0
  190. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_messengers.py +0 -0
  191. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_msauth.py +0 -0
  192. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_mssql.py +0 -0
  193. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_noop_ending.py +0 -0
  194. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_not_coding.py +0 -0
  195. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_note_retrieval.py +0 -0
  196. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_outbound_reports.py +0 -0
  197. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_own_rows_no_reply.py +0 -0
  198. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_playbooks.py +0 -0
  199. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_pm.py +0 -0
  200. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_poll_overlap.py +0 -0
  201. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_poll_sources.py +0 -0
  202. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_pr_comments.py +0 -0
  203. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_problems.py +0 -0
  204. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_promptmap_and_catalog.py +0 -0
  205. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_proof_ci_proposals.py +0 -0
  206. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_pypi_downloads.py +0 -0
  207. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_quickbooks.py +0 -0
  208. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_rank.py +0 -0
  209. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_reply_channels.py +0 -0
  210. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_reply_voice.py +0 -0
  211. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_report_alerts.py +0 -0
  212. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_report_triage.py +0 -0
  213. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_research.py +0 -0
  214. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_reviews.py +0 -0
  215. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_scopes.py +0 -0
  216. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_seed_fits_a_tty.py +0 -0
  217. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_seeded_reports.py +0 -0
  218. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_semantic.py +0 -0
  219. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_send_targets.py +0 -0
  220. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_setup.py +0 -0
  221. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_sheets_sharepoint.py +0 -0
  222. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_someone_else_replied.py +0 -0
  223. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_soul_interview.py +0 -0
  224. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_stay_open.py +0 -0
  225. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_stay_open_doors.py +0 -0
  226. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_store_app_cli.py +0 -0
  227. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_store_hotpaths.py +0 -0
  228. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_sync_clock.py +0 -0
  229. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_task_identity.py +0 -0
  230. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_teller.py +0 -0
  231. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_toil.py +0 -0
  232. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_triage_dataset.py +0 -0
  233. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_urgent_and_handoff.py +0 -0
  234. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_verdict_paths.py +0 -0
  235. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_verdict_sticks.py +0 -0
  236. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_vision.py +0 -0
  237. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_voice.py +0 -0
  238. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_wabridge.py +0 -0
  239. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_waitroom.py +0 -0
  240. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_watermark.py +0 -0
  241. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_web_entrypoint.py +0 -0
  242. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_webguard.py +0 -0
  243. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_whatsapp_documents.py +0 -0
  244. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_whatsapp_images.py +0 -0
  245. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_whoami.py +0 -0
  246. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_withdrawn.py +0 -0
  247. {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_witness.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taskuary
3
- Version: 0.3.2.5
3
+ Version: 0.3.2.7
4
4
  Summary: Automate your job - the local-first operating system for your work: AI triage in, coding agents out.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/ldbumble/taskuary
@@ -64,7 +64,7 @@ and replies wait for your approval. Nothing sends or ships without you.
64
64
 
65
65
  ![The Taskuary Studio: work arrives, triage decides what needs action, and agents take seats to work it.](https://raw.githubusercontent.com/ldbumble/taskuary/master/docs/hero.gif)
66
66
 
67
- Taskuary is early—currently **v0.3.2.5**—and moving fast. The funnel, review queue,
67
+ Taskuary is early—currently **v0.3.2.7**—and moving fast. The funnel, review queue,
68
68
  agent sessions, and reports pipeline are in daily use; breaking changes are still possible
69
69
  before 1.0.
70
70
 
@@ -13,7 +13,7 @@ and replies wait for your approval. Nothing sends or ships without you.
13
13
 
14
14
  ![The Taskuary Studio: work arrives, triage decides what needs action, and agents take seats to work it.](https://raw.githubusercontent.com/ldbumble/taskuary/master/docs/hero.gif)
15
15
 
16
- Taskuary is early—currently **v0.3.2.5**—and moving fast. The funnel, review queue,
16
+ Taskuary is early—currently **v0.3.2.7**—and moving fast. The funnel, review queue,
17
17
  agent sessions, and reports pipeline are in daily use; breaking changes are still possible
18
18
  before 1.0.
19
19
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "taskuary"
7
- version = "0.3.2.5"
7
+ version = "0.3.2.7"
8
8
  description = "Automate your job - the local-first operating system for your work: AI triage in, coding agents out."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -9,13 +9,14 @@ from datetime import datetime
9
9
  from pathlib import Path
10
10
  from loguru import logger
11
11
 
12
+ from . import spawn
12
13
  from .store import task_ref
13
14
  from .clis import preset_args
14
15
 
15
16
 
16
17
  def _git(cwd, *args):
17
18
  try:
18
- p = subprocess.run(['git', '-C', cwd or os.getcwd(), *args], capture_output=True, text=True,
19
+ p = spawn.run(['git', '-C', cwd or os.getcwd(), *args], capture_output=True, text=True,
19
20
  encoding='utf-8', errors='replace', timeout=30)
20
21
  return p.stdout.strip() if p.returncode == 0 else ''
21
22
  except Exception:
@@ -50,7 +51,7 @@ def _fresh_path() -> str:
50
51
 
51
52
 
52
53
  def _shim_target(path: str) -> list:
53
- """What an npm .CMD shim actually runs. The shim is four lines of batch around one real
54
+ r"""What an npm .CMD shim actually runs. The shim is four lines of batch around one real
54
55
  program - claude.CMD ends with "%dp0%\node_modules\@anthropic-ai\claude-code\bin\claude.exe" %* -
55
56
  and going through cmd /c to reach it is what costs us the prompt: cmd.exe owns & | < > and
56
57
  stray quotes, so the first prompt cannot be passed as an ARGUMENT and has to be TYPED into
@@ -303,7 +304,7 @@ def run_cli(profile: dict, prompt: str, trace, resume: str = None, cancel=None):
303
304
  trace('prompt', 'prompt_sent_to_agent', prompt)
304
305
  trace('tool', 'cli', f'{name} cwd={cwd or os.getcwd()}' + (f' resume={resume}' if resume else ''))
305
306
  try:
306
- p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
307
+ p = spawn.popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
307
308
  text=True, encoding='utf-8', errors='replace', cwd=cwd, shell=False,
308
309
  env=child_env())
309
310
  except PermissionError as e:
@@ -25,6 +25,7 @@ import base64, json, os, re, shutil, socket, subprocess, time
25
25
  from datetime import datetime
26
26
  from pathlib import Path
27
27
  from loguru import logger
28
+ from . import spawn
28
29
 
29
30
  MAX_FPS = 12 # a watched page, not a game: 12 frames/s at ~50KB each is plenty and easy on a LAN tab
30
31
  MAX_FRAME = 8 * 1024 * 1024 # a 1280x720 jpeg is ~54KB; this is the ceiling for a huge viewport, not a target
@@ -140,7 +141,7 @@ def close(sid: str):
140
141
  headless Chrome per finished task sits idle for an hour each."""
141
142
  exe = shutil.which('agent-browser')
142
143
  if not exe or not _read(session_name(sid), 'stream'): return
143
- try: subprocess.run([exe, '--session', session_name(sid), 'close'], timeout=20, capture_output=True)
144
+ try: spawn.run([exe, '--session', session_name(sid), 'close'], timeout=20, capture_output=True)
144
145
  except (OSError, subprocess.SubprocessError) as e: logger.debug(f'could not close the browser of {sid}: {e}')
145
146
  _CACHE.pop(sid, None); LAST.pop(sid, None)
146
147
 
@@ -174,7 +175,7 @@ def start(sid: str, url: str = 'about:blank') -> bool:
174
175
  try:
175
176
  # detached: it outlives this request and answers on its own screencast port. Output is
176
177
  # dropped - the pane IS the output, and a full pipe would block the browser.
177
- subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
178
+ spawn.popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
178
179
  stdin=subprocess.DEVNULL, close_fds=True)
179
180
  except (OSError, subprocess.SubprocessError) as e:
180
181
  logger.warning(f'could not start the browser for {sid}: {e}')
@@ -10,6 +10,7 @@ from datetime import datetime, timedelta
10
10
  import requests
11
11
  from loguru import logger
12
12
 
13
+ from . import spawn
13
14
  from .github import _h as gh_headers, list_accessible_repos
14
15
  from .ingest import ingest_message
15
16
  from .counsel import is_invite
@@ -272,7 +273,7 @@ def test_connector(store, cid: int) -> dict:
272
273
  import subprocess
273
274
  host = cfg.get('host')
274
275
  if not host: raise RuntimeError('no host set - enter the machine name (e.g. AZWEB01)')
275
- p = subprocess.run(['powershell', '-NoProfile', '-NonInteractive', '-Command',
276
+ p = spawn.run(['powershell', '-NoProfile', '-NonInteractive', '-Command',
276
277
  f'Test-WSMan -ComputerName {host} -ErrorAction Stop | Out-Null; '
277
278
  f'Invoke-Command -ComputerName {host} -ScriptBlock {{ $env:COMPUTERNAME }}'],
278
279
  capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=60)
@@ -795,6 +796,15 @@ def poll_channels(store, backfill_days: int = 0, progress=None, only=None) -> in
795
796
  try: llm = build_llm(store)
796
797
  except Exception: llm = None
797
798
  read_it = wants_read(store) # asked once per run, not once per message
799
+ # a mailbox card whose source row was never created polls nothing at all, and looks connected
800
+ # the whole time it is doing so - so it heals itself here, before the source list is read
801
+ for c in store.list_connectors():
802
+ if c['Type'] in ('imap', 'gmail') and c['Active']:
803
+ try:
804
+ from . import imapmail
805
+ imapmail.ensure_source(store, store.get_connector(c['ConnectorId']))
806
+ except Exception as e:
807
+ logger.debug(f"imap: could not heal the source row for {c.get('Name')} - {e}")
798
808
  n = 0
799
809
  for c in store.list_connectors():
800
810
  if not c['Active'] or c['Type'] not in CH2SRC: continue
@@ -57,6 +57,7 @@ DENIED = (
57
57
  (r'POST|PUT|PATCH', r'^/api/settings', 'the settings decide what agents may do'),
58
58
  (r'POST|PUT|PATCH|DELETE', r'^/api/(policies|agents)', 'the rules and the agent profiles'),
59
59
  (r'POST|PUT|PATCH|DELETE', r'^/api/(docs?|playbooks)(/|$)', 'SOUL.md, the playbooks and the rest are the owner\'s word, not an agent\'s - propose, do not write'),
60
+ (r'POST', r'^/api/update$', 'replacing the program is the owner\'s decision'),
60
61
  )
61
62
  _DENIED = tuple((re.compile(f'^({m})$', re.I), re.compile(p), why) for m, p, why in DENIED)
62
63
 
@@ -26,6 +26,10 @@ def _status(state, what='', doc=None, evidence=None):
26
26
  if doc is not None: STATUS['doc'] = doc
27
27
  if evidence is not None: STATUS['evidence'] = evidence
28
28
  SENT_CAP, INBOX_CAP = 300, 500 # per mailbox; enough signal, bounded Graph bill
29
+ # IMAP costs one round trip PER MESSAGE (Graph pages 50 at a time), so the same 300 would be a
30
+ # minute of silence. STYLE_SAMPLES caps what reaches the model at 60 regardless, so a smaller
31
+ # read loses nothing worth having.
32
+ IMAP_SENT_CAP = 120
29
33
  STYLE_SAMPLES, TRIAGE_LINES = 60, 240
30
34
  TOPIC_MIN = 3 # a subject seen this often is routine work, not a one-off
31
35
  GUIDE_TOKENS = 1400
@@ -112,6 +116,31 @@ def _graph_mail(store, days):
112
116
  return sent, inbox, n
113
117
 
114
118
 
119
+ def _imap_sent(store, days):
120
+ """The same history, for a mailbox connected over IMAP. Graph is not the only way mail gets
121
+ here, but every history reader assumed it was - so "Generate my reply style" on a working
122
+ IMAP card said "connect the Outlook card", which is advice the owner cannot act on."""
123
+ from . import imapmail
124
+ _status('running', 'reading your Sent folder over IMAP...')
125
+ try: rows = imapmail.sent_history(store, days, cap=IMAP_SENT_CAP, progress=_status)
126
+ except Exception as e:
127
+ logger.warning(f'history: no IMAP sent mail read - {e}')
128
+ return []
129
+ # shaped like a Graph message so one reader serves both
130
+ return [{'subject': m.get('subject'), 'receivedDateTime': (m.get('sent_at') or '').replace(' ', 'T'),
131
+ 'conversationId': m.get('conversation_id'),
132
+ 'body': {'contentType': 'text', 'content': m.get('body') or ''}} for m in rows]
133
+
134
+
135
+ def sent_mail(store, days):
136
+ """(messages, mailboxes, where they came from). Graph first because it can also pair the
137
+ inbox; IMAP when Outlook is not the mailbox on this install."""
138
+ sent, inbox, n = _graph_mail(store, days)
139
+ if sent or inbox: return sent, inbox, n, 'Graph'
140
+ rows = _imap_sent(store, days)
141
+ return rows, [], (1 if rows else 0), 'IMAP'
142
+
143
+
115
144
  def _db_window(store, days):
116
145
  since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d %H:%M:%S')
117
146
  return [m for m in store.scan_messages() if str(m.get('SentAt') or '') >= since]
@@ -121,20 +150,21 @@ def gen_style(store, llm, days):
121
150
  """Sent mail -> how the owner writes. Graph sentitems first; Taskuary's own record
122
151
  (your context replies + drafts you approved) fills in or stands alone."""
123
152
  from .channels import _body
124
- sent, _, n = _graph_mail(store, days)
153
+ sent, _inbox, n, how = sent_mail(store, days)
125
154
  samples = []
126
155
  for m in sent:
127
156
  t = cut_quoted(_body(m))
128
157
  if len(t) >= 30: samples.append(f"--- sent {str(m.get('receivedDateTime') or '')[:10]} · \"{(m.get('subject') or '')[:60]}\"\n{t[:800]}")
129
- src = f'{len(samples)} sent mails from the last {days} days across {n} mailbox(es)'
158
+ src = f'{len(samples)} sent mails from the last {days} days across {n} mailbox(es), read over {how}'
130
159
  if not samples:
131
160
  msgs = [m for m in _db_window(store, days) if m.get('Status') == 'context']
132
161
  finals = [r for r in store.list_reviews() if r.get('FinalText') and r['Status'] in ('approved', 'edited')]
133
162
  samples = [f"--- your reply · \"{(m.get('Subject') or '')[:60]}\"\n{cut_quoted(str(m.get('BodyText') or ''))[:800]}" for m in msgs]
134
163
  samples += [f'--- approved draft\n{str(r["FinalText"])[:800]}' for r in finals]
135
- src = f'no Graph mailbox history - used {len(samples)} replies Taskuary itself has seen'
164
+ src = f'no mailbox history to read - used {len(samples)} replies Taskuary itself has seen'
136
165
  if not samples:
137
- raise RuntimeError('no sent mail to learn from - connect the Outlook card (or approve a few drafts) first')
166
+ raise RuntimeError('no sent mail to learn from - connect a mailbox (Outlook, Gmail or IMAP) and let one sync '
167
+ 'run, or approve a few drafts first')
138
168
  step = max(1, len(samples) // STYLE_SAMPLES) # spread across the window, not just last week
139
169
  picked = samples[::step][:STYLE_SAMPLES]
140
170
  # receipts: which replies the model actually saw - each one is a vote on greeting,
@@ -153,21 +183,28 @@ def gen_triage(store, llm, days):
153
183
  """Inbox paired with sentitems by conversation: ANSWERED is the ground truth for what
154
184
  matters. Falls back to Taskuary's own outcomes (task/replied vs filed/ignored/skipped)."""
155
185
  lines, doms = [], {}
156
- sent, inbox, n = _graph_mail(store, days)
186
+ sent, inbox, n, how = sent_mail(store, days)
157
187
  if inbox:
158
188
  answered = {m.get('conversationId') for m in sent if m.get('conversationId')}
159
189
  rows = [((((m.get('from') or {}).get('emailAddress') or {}).get('address') or '?').lower(),
160
190
  str(m.get('receivedDateTime') or '')[:10], (m.get('subject') or '')[:70],
161
191
  m.get('conversationId') in answered) for m in inbox]
162
- src = f'{len(rows)} inbound + {len(sent)} sent from the last {days} days across {n} mailbox(es)'
192
+ src = f'{len(rows)} inbound + {len(sent)} sent from the last {days} days across {n} mailbox(es), read over {how}'
163
193
  else:
164
194
  msgs = _db_window(store, days)
195
+ # what the owner actually WROTE BACK beats "a task was made": the sent mail names the
196
+ # threads they answered, and answering is the ground truth this document is about
197
+ answered = {m.get('conversationId') for m in sent if m.get('conversationId')}
165
198
  rows = [((m.get('FromEmail') or '?').lower(), str(m.get('SentAt') or '')[:10], (m.get('Subject') or '')[:70],
166
- m['Status'] == 'routed' and bool(m.get('TaskId')))
199
+ (m.get('ConversationId') in answered) if answered
200
+ else (m['Status'] == 'routed' and bool(m.get('TaskId'))))
167
201
  for m in msgs if m.get('Status') != 'context']
168
- src = f'no Graph mailbox history - used {len(rows)} messages Taskuary itself has ingested'
202
+ src = (f'{len(rows)} messages Taskuary has ingested'
203
+ + (f', answered checked against {len(sent)} sent mails read over {how}' if answered
204
+ else ' - no mailbox sent history, so "answered" is whether a task was made'))
169
205
  if not rows:
170
- raise RuntimeError('no inbound history to learn from - connect the Outlook card (or let a few syncs run) first')
206
+ raise RuntimeError('no inbound history to learn from - connect a mailbox (Outlook, Gmail or IMAP) and let a '
207
+ 'few syncs run first')
171
208
  from .routing import subject_topic
172
209
  tops = {}
173
210
  for addr, _, subj, ans in rows:
@@ -105,6 +105,109 @@ def _body_and_attachments(msg) -> tuple:
105
105
  return (text or _clean(html) or '').strip(), atts
106
106
 
107
107
 
108
+ # ── the Sent folder ─────────────────────────────────────────────────────────────────────
109
+ # Outlook has one name for it and Graph exposes it as `sentitems`; IMAP servers agree on nothing,
110
+ # and Gmail hides it under a namespace. Without it an IMAP install has NO record of what the owner
111
+ # writes, which is why "Generate my reply style" on a perfectly connected IMAP card answered "no
112
+ # sent mail to learn from - connect the Outlook card" (owner, 2026-09-02). Ask the server instead
113
+ # of guessing: RFC 6154 marks the folder with \Sent, and the familiar names are the fallback.
114
+ SENT_NAMES = ('Sent', 'Sent Items', 'Sent Mail', 'INBOX.Sent', 'INBOX.Sent Items', '[Gmail]/Sent Mail')
115
+ # (flags) "separator" name - the name may be quoted or bare, and may itself contain the separator
116
+ _LIST_LINE = re.compile(r'^\((?P<flags>[^)]*)\)\s+(?:"(?P<sep>[^"]*)"|NIL)\s+(?P<name>.*)$')
117
+
118
+
119
+ def _list_line(raw) -> tuple:
120
+ line = raw.decode('utf-8', 'replace') if isinstance(raw, (bytes, bytearray)) else str(raw)
121
+ m = _LIST_LINE.match(line.strip())
122
+ if not m: return '', ''
123
+ return m.group('flags') or '', (m.group('name') or '').strip().strip('"')
124
+
125
+
126
+ def sent_folder(M) -> str:
127
+ """The mailbox's Sent folder, as this server spells it. '' when there is none to be found."""
128
+ try: typ, data = M.list()
129
+ except Exception: return ''
130
+ if typ != 'OK': return ''
131
+ named = []
132
+ for raw in data or []:
133
+ flags, name = _list_line(raw)
134
+ if not name: continue
135
+ if '\\Sent' in flags: return name # the server said so: no guessing needed
136
+ named.append(name)
137
+ lower = {n.lower(): n for n in named}
138
+ return next((lower[w.lower()] for w in SENT_NAMES if w.lower() in lower), '')
139
+
140
+
141
+ def sent_window(c, days: int, cap: int = 300, progress=None) -> list:
142
+ """What this mailbox has SENT in the last `days`, oldest first, each as
143
+ {subject, body, sent_at, to, conversation_id, external_id}. [] when there is no Sent folder.
144
+
145
+ Read-only by construction - the owner's own outbox is never modified, and nothing here is
146
+ triaged: sent mail is evidence about how they write, never work arriving."""
147
+ M, user = _login(c)
148
+ try:
149
+ box = sent_folder(M)
150
+ if not box: return []
151
+ typ, _d = M.select(_quoted(box), readonly=True)
152
+ if typ != 'OK': return []
153
+ since = (datetime.now() - timedelta(days=max(1, days))).strftime('%d-%b-%Y')
154
+ typ, data = M.uid('search', None, f'(SINCE {since})')
155
+ if typ != 'OK': return []
156
+ uids = [int(u) for u in (data[0] or b'').split()][-cap:]
157
+ out = []
158
+ for uid in uids:
159
+ # one fetch per message: say how far along, or the button sits silent for a minute
160
+ if progress and len(out) % 20 == 0:
161
+ progress('running', f'reading {user} - {len(out)} of {len(uids)} sent mails')
162
+ try:
163
+ typ, parts = M.uid('fetch', str(uid), '(RFC822)')
164
+ if typ != 'OK' or not parts or parts[0] is None: continue
165
+ msg = email.message_from_bytes(parts[0][1])
166
+ body, _atts = _body_and_attachments(msg)
167
+ try: when = email.utils.parsedate_to_datetime(msg.get('Date')).astimezone().strftime('%Y-%m-%d %H:%M:%S')
168
+ except Exception: when = ''
169
+ out.append({'external_id': f'imap-sent:{user}:{uid}', 'subject': _dec(msg.get('Subject')),
170
+ 'body': body[:20000], 'sent_at': when, 'to': _hdr_addrs(msg, 'To'),
171
+ 'from_email': user,
172
+ 'conversation_id': (msg.get('References') or msg.get('Message-ID') or '').split()[0][:200] or None})
173
+ except Exception as e:
174
+ logger.debug(f'imap: sent uid {uid} skipped - {e}')
175
+ return out
176
+ finally:
177
+ try: M.logout()
178
+ except Exception: pass
179
+
180
+
181
+ def _quoted(name: str) -> str:
182
+ """A folder with a space in it ('Sent Items', '[Gmail]/Sent Mail') must reach SELECT quoted."""
183
+ return f'"{name}"' if any(ch in name for ch in ' ()') else name
184
+
185
+
186
+ def sent_history(store, days: int, cap: int = 300, progress=None) -> list:
187
+ """Every IMAP/Gmail card's Sent folder for the window. The card list is the point: an install
188
+ can have two mailboxes, and the owner writes the same way in both."""
189
+ out = []
190
+ for c in store.list_connectors():
191
+ if c['Type'] not in ('imap', 'gmail') or not c['Active']: continue
192
+ try: out += sent_window(store.get_connector(c['ConnectorId'], with_secret=True), days, cap, progress)
193
+ except Exception as e:
194
+ logger.warning(f"imap: could not read sent mail from {c.get('Name') or c['Type']} - {e}")
195
+ return sorted(out, key=lambda m: m.get('sent_at') or '')
196
+
197
+
198
+ def ensure_source(store, c) -> bool:
199
+ """Give an IMAP/Gmail card the source row the poller reads it through, if it has none. True
200
+ when one was just made. The mailbox address IS the source name and it is already saved on the
201
+ card, so there is nothing to ask the owner and nothing to guess."""
202
+ user = (_cfg(c).get('address') or '').strip()
203
+ if not user: return False
204
+ if any(s['Channel'] == 'email' and (s.get('Address') or '').lower() == user.lower()
205
+ for s in store.list_sources(active_only=False)): return False
206
+ store.save_source({'Channel': 'email', 'Address': user, 'ConnectorId': c['ConnectorId'], 'Active': 1}, 'self-heal')
207
+ logger.info(f'imap: {user} had no source row - added one so it is actually polled')
208
+ return True
209
+
210
+
108
211
  def poll_imap(store, c, sources: list, llm=None, file_only=False, backfill_days: int = 0) -> int:
109
212
  """UIDs are IMAP's own cursor: strictly increasing per mailbox, so the watermark on the
110
213
  connector never re-ingests - and a backfill just lowers the SINCE date, with dedupe
@@ -189,6 +189,75 @@ def fields_of(cfg, obj):
189
189
  for f in res.iter('Field')]
190
190
 
191
191
 
192
+ # ── writing ─────────────────────────────────────────────────────────────────────────────
193
+ # The gateway writes the same way it reads: generically, by object. So there is no create_bill,
194
+ # create_vendor, create_journal_entry here for the same reason there are no per-object read
195
+ # wrappers - each would be a hardcoded field list that goes stale, and `fields_of` already asks
196
+ # the company's own schema. One create, one update, any object.
197
+ #
198
+ # What keeps this safe is NOT this module: the Intacct card ships at scope `read` (scopes.py), so
199
+ # an agent calling these through /api/tools/run is refused. It reaches them only by PROPOSING
200
+ # (proposals.py, run_tool), and the owner approves the proposal in Review. That is the same road
201
+ # a QuickBooks bill takes, and it is deliberate: nothing posts to the books on an agent's say-so.
202
+
203
+ def _record_xml(parent, fields):
204
+ """A record as the gateway wants it. A scalar becomes an element; a dict becomes a nested
205
+ element (`{'VENDORID': 'V1'}`); a list becomes REPEATED children named by dropping one
206
+ trailing S from the key, which is how Intacct spells line items - APBILLITEMS holds
207
+ APBILLITEM. Nest a dict explicitly when an object does not follow that rule."""
208
+ for k, v in (fields or {}).items():
209
+ if isinstance(v, dict):
210
+ _record_xml(_el(parent, k), v)
211
+ elif isinstance(v, (list, tuple)):
212
+ holder = _el(parent, k)
213
+ child = k[:-1] if k.upper().endswith('S') else k
214
+ for item in v:
215
+ if isinstance(item, dict): _record_xml(_el(holder, child), item)
216
+ else: _el(holder, child, item)
217
+ else:
218
+ _el(parent, k, '' if v is None else v)
219
+
220
+
221
+ def _written(res, obj, verb):
222
+ """What came back: the key Intacct assigned and the record it echoed, so the owner's receipt
223
+ names a real document number rather than 'success'."""
224
+ rows = _rows(res)
225
+ rec = rows[0] if rows else {}
226
+ key = res.findtext('.//result/key') or rec.get('RECORDNO') or rec.get('RECORDKEY') or ''
227
+ return {'verb': verb, 'object': obj, 'key': str(key), 'record': rec}
228
+
229
+
230
+ def create(cfg, obj, record):
231
+ """Create ONE record of `obj` (APBILL, VENDOR, GLBATCH…) from a field dict. Returns the key
232
+ Intacct assigned. Raises IntacctError on the usual 200-with-failure."""
233
+ if not str(obj or '').strip(): raise IntacctError('no Intacct object to create')
234
+ if not record: raise IntacctError(f'nothing to put in the new {obj} - give it fields')
235
+ sid, end = login(cfg)
236
+ root, fn = _envelope(cfg, sid)
237
+ _record_xml(_el(_el(fn, 'create'), obj), record)
238
+ res = _post(end, root)
239
+ _check(res)
240
+ logger.info(f"intacct: created {obj} in company {cfg['company_id']}")
241
+ return _written(res, obj, 'create')
242
+
243
+
244
+ def update(cfg, obj, record):
245
+ """Change ONE existing record. The record MUST carry the key Intacct knows it by (RECORDNO,
246
+ or the object's own id field such as VENDORID) - without one the gateway would have to guess
247
+ which row is meant, and a write that guesses is the one thing this must never do."""
248
+ if not str(obj or '').strip(): raise IntacctError('no Intacct object to update')
249
+ keys = [k for k in (record or {}) if k.upper() in ('RECORDNO', 'RECORDKEY', 'KEY', f'{obj.upper()}ID')]
250
+ if not keys:
251
+ raise IntacctError(f'an update to {obj} needs the record it is changing - include RECORDNO or {obj.upper()}ID')
252
+ sid, end = login(cfg)
253
+ root, fn = _envelope(cfg, sid)
254
+ _record_xml(_el(_el(fn, 'update'), obj), record)
255
+ res = _post(end, root)
256
+ _check(res)
257
+ logger.info(f"intacct: updated {obj} {record.get(keys[0])} in company {cfg['company_id']}")
258
+ return _written(res, obj, 'update')
259
+
260
+
192
261
  def probe(cfg):
193
262
  """One real call for the Test button: log in, then read the company's own record. A green
194
263
  card that only proves the password was accepted hides the usual failure, which is an API
@@ -4,6 +4,7 @@ on the timeline. Config: {"cmd": "npx", "args": [...], "tool": "query", "tool_ar
4
4
  SDK dependency - keeps the single-exe desktop build lean. Spec: modelcontextprotocol.io.
5
5
  """
6
6
  import json, os, subprocess, threading, queue
7
+ from . import spawn
7
8
 
8
9
  PROTOCOL = '2025-06-18'
9
10
 
@@ -13,7 +14,7 @@ class MCPClient:
13
14
 
14
15
  def __init__(self, cmd, args=None, env=None, timeout=60):
15
16
  self.timeout, self._id = timeout, 0
16
- self.p = subprocess.Popen([cmd] + list(args or []), stdin=subprocess.PIPE, stdout=subprocess.PIPE,
17
+ self.p = spawn.popen([cmd] + list(args or []), stdin=subprocess.PIPE, stdout=subprocess.PIPE,
17
18
  stderr=subprocess.DEVNULL, text=True, encoding='utf-8',
18
19
  env={**os.environ, **(env or {})}, shell=False)
19
20
  self.q = queue.Queue()
@@ -10,6 +10,7 @@ not.
10
10
  import re
11
11
  from datetime import datetime
12
12
  from loguru import logger
13
+ from . import spawn
13
14
 
14
15
  # What a test run looks like on screen, per runner. Deliberately narrow: a line that only
15
16
  # MENTIONS pytest is not a test result, and counting it as one would turn the card into a
@@ -88,7 +89,7 @@ def _git(cwd, *args, ok=(0,)):
88
89
  the files DIFFER, which is the entire question we are asking it."""
89
90
  import subprocess
90
91
  try:
91
- p = subprocess.run(['git', '-C', cwd, *args], capture_output=True, text=True,
92
+ p = spawn.run(['git', '-C', cwd, *args], capture_output=True, text=True,
92
93
  encoding='utf-8', errors='replace', timeout=30)
93
94
  return p.stdout if p.returncode in ok else ''
94
95
  except Exception as e:
@@ -10,6 +10,7 @@ entry - PRs welcome.
10
10
  import io, json, re, sqlite3, time
11
11
  from datetime import datetime, timedelta
12
12
  from loguru import logger
13
+ from . import spawn
13
14
 
14
15
  PLANNED = ['graphql', 'smb_file',
15
16
  # systems of record. Intacct is BUILT (see run_intacct); the rest are named because
@@ -85,6 +86,26 @@ def run_intacct_fields(cfg):
85
86
  return rows_out(fields_of(cfg, obj), lim, unit='fields', mine=mine)
86
87
 
87
88
 
89
+ def run_intacct_create(cfg):
90
+ """{"object": "APBILL", "record": {"VENDORID": "V100", "WHENCREATED": "09/01/2026",
91
+ "APBILLITEMS": [{"ACCOUNTNO": "6120", "AMOUNT": "412.50"}]}} - create one record.
92
+
93
+ A WRITE. The Intacct card ships at scope `read`, so an agent cannot run this directly: it
94
+ PROPOSES it (TASKUARY-PROPOSE run_tool) and the owner approves it in Review."""
95
+ from .intacct import create
96
+ out = create(cfg, (cfg.get('object') or '').strip(), cfg.get('record') or cfg.get('fields'))
97
+ return f"{out['object']} created — key {out['key'] or '(none returned)'}", json.dumps(out, indent=1, default=str)
98
+
99
+
100
+ def run_intacct_update(cfg):
101
+ """{"object": "APBILL", "record": {"RECORDNO": "1042", "DESCRIPTION": "corrected memo"}} -
102
+ change one existing record. The record must name itself (RECORDNO, or the object's own id).
103
+ A WRITE, gated exactly like run_intacct_create."""
104
+ from .intacct import update
105
+ out = update(cfg, (cfg.get('object') or '').strip(), cfg.get('record') or cfg.get('fields'))
106
+ return f"{out['object']} {out['key'] or ''} updated".replace(' ', ' '), json.dumps(out, indent=1, default=str)
107
+
108
+
88
109
  def run_metric(cfg):
89
110
  """{"name": "<metric>", "scope": "<what names one row>", "period": "2026-07"} - ONE certified number.
90
111
 
@@ -215,7 +236,7 @@ def run_winrm(cfg):
215
236
  it once (elevated)."""
216
237
  import subprocess
217
238
  host, script = cfg['host'], cfg['script']
218
- p = subprocess.run(['powershell', '-NoProfile', '-NonInteractive', '-Command',
239
+ p = spawn.run(['powershell', '-NoProfile', '-NonInteractive', '-Command',
219
240
  f'Invoke-Command -ComputerName {host} -ScriptBlock {{ {script} }}'],
220
241
  capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=180)
221
242
  if p.returncode != 0: raise RuntimeError((p.stderr or p.stdout or 'remote run failed')[:500])
@@ -488,6 +509,9 @@ REGISTRY = {'sqlite': run_sqlite, 'mssql': run_mssql, 'database': run_database,
488
509
  'prometheus': run_prometheus, 'datadog': run_datadog,
489
510
  'winrm': run_winrm, 'mcp': run_mcp, 'rest': run_rest,
490
511
  'intacct': run_intacct, 'intacct_fields': run_intacct_fields,
512
+ # ...and the two WRITES: generic by object, like the reads. Gated by the card's scope
513
+ # (it ships at read), so an agent proposes them and the owner approves.
514
+ 'intacct_create': run_intacct_create, 'intacct_update': run_intacct_update,
491
515
  # QuickBooks Online: three reads, and the first two WRITES a Corporate system has here -
492
516
  # a bill and a paid expense, gated by the card's scope (proposals below write)
493
517
  'quickbooks': _lazy('quickbooks', 'run_quickbooks'), 'quickbooks_vendors': _lazy('quickbooks', 'run_quickbooks_vendors'),
@@ -514,7 +538,8 @@ REGISTRY = {'sqlite': run_sqlite, 'mssql': run_mssql, 'database': run_database,
514
538
  # card's keys, the blob/logs types on the azure card's app - roles and creds resolve there.
515
539
  CARD_OF = {'s3_object': 'aws', 'cloudwatch_logs': 'aws', 'azure_blob': 'azure', 'azure_logs': 'azure', 'calendar': 'outlook',
516
540
  'entra_users': 'azure', 'entra_groups': 'azure', 'entra_signins': 'azure', 'entra_licenses': 'azure',
517
- 'intacct_fields': 'intacct', 'sharepoint_list': 'sharepoint', 'sharepoint_file': 'sharepoint',
541
+ 'intacct_fields': 'intacct', 'intacct_create': 'intacct', 'intacct_update': 'intacct',
542
+ 'sharepoint_list': 'sharepoint', 'sharepoint_file': 'sharepoint',
518
543
  'quickbooks_vendors': 'quickbooks', 'quickbooks_accounts': 'quickbooks', 'quickbooks_bill': 'quickbooks', 'quickbooks_expense': 'quickbooks',
519
544
  'teller_accounts': 'teller', 'teller_transactions': 'teller', 'teller_balances': 'teller',
520
545
  'kb_search': 'knowledge', 'kb_reindex': 'knowledge',
@@ -628,6 +653,7 @@ CONNECTION_OF = {'mssql': mssql_connection, 'winrm': winrm_connection, 'database
628
653
  'entra_signins': azure_connection, 'entra_licenses': azure_connection,
629
654
  'prometheus': prometheus_connection, 'datadog': datadog_connection,
630
655
  'intacct': intacct_connection, 'intacct_fields': intacct_connection,
656
+ 'intacct_create': intacct_connection, 'intacct_update': intacct_connection,
631
657
  **{t: _quickbooks_connection for t in ('quickbooks', 'quickbooks_vendors', 'quickbooks_accounts', 'quickbooks_bill', 'quickbooks_expense')},
632
658
  **{t: _teller_connection for t in ('teller_accounts', 'teller_transactions', 'teller_balances')},
633
659
  # both borrow: SharePoint the Outlook tenant app, Sheets the Gmail card's Google client
@@ -35,6 +35,10 @@ ACTIONS = {
35
35
  # run_tool) and the owner approves it in Review. Raising the card to 'write' is the owner's call.
36
36
  'quickbooks': 'read', 'quickbooks_vendors': 'read', 'quickbooks_accounts': 'read',
37
37
  'quickbooks_bill': 'write', 'quickbooks_expense': 'write',
38
+ # Intacct writes the same way it reads - generically, by object - so ONE pair covers posting a
39
+ # bill, adding a vendor and correcting a memo. The card ships at 'read' like QuickBooks', which
40
+ # is what makes a bill an agent's PROPOSAL rather than an agent's decision.
41
+ 'intacct_create': 'write', 'intacct_update': 'write',
38
42
  'teller_accounts': 'read', 'teller_transactions': 'read', 'teller_balances': 'read', # a feed cannot move money
39
43
  # the semantic layer (semantic.py) reaches the ERP only through those same reads. The check
40
44
  # DOES write - a metric it cannot reconcile is demoted, a verified one is frozen to a skill -
@@ -197,6 +197,23 @@ _started = datetime.now().isoformat(sep=' ', timespec='seconds')
197
197
  @app.get('/api/version')
198
198
  def version(): return {'version': _ver, 'started': _started}
199
199
 
200
+ # ── update in place (update.py): Settings → Updates ─────────────────────────────────────
201
+ @app.get('/api/update')
202
+ def update_check(force: int = 0):
203
+ from . import update
204
+ return update.check(force=bool(force))
205
+
206
+ @app.post('/api/update')
207
+ def update_apply():
208
+ """Fetch the latest release and swap it in. The answer goes out first; the process ends a
209
+ moment later so the new build can start. Owner-only: guard.py refuses an agent token."""
210
+ from . import update
211
+ try: out = update.apply()
212
+ except Exception as e: raise HTTPException(422, str(e))
213
+ store.audit('app', 0, 'update', ACTOR, detail={k: v for k, v in out.items() if k != 'pip'})
214
+ if out.get('restarting'): update.exit_soon()
215
+ return out
216
+
200
217
  def _can_send(channel, has_message=True, gh_ok=None) -> bool:
201
218
  """Can an approved reply actually LEAVE on this channel? One answer for the whole app -
202
219
  outbound.can_reply - so the Approve button, triage and the coder wrap-up cannot
@@ -2989,13 +3006,12 @@ async def terminal_ws(ws: WebSocket, sid: str):
2989
3006
  await asyncio.to_thread(t.write, ''.join(chunks))
2990
3007
  input_pump = asyncio.create_task(to_pty())
2991
3008
  try:
2992
- # scrubbed: a replayed scrollback that still contains the TUI's terminal queries makes
2993
- # xterm answer them AGAIN, and the answers land in the CLI as typed junk - see terminal.py
2994
- # flagged as a REPLAY so the browser can hold the curtain over it: writing a long
2995
- # scrollback runs the viewport from the top of the session down to the bottom, and
2996
- # watching a week of coding scroll past every time you reopen a task is not a feature
3009
+ # RENDERED, not raw (terminal.replay_text): the raw bytes of a full-screen TUI replay as
3010
+ # debris in a fresh xterm, and the live repaint then appends to that debris. Flagged as a
3011
+ # REPLAY so the browser holds the curtain over it until the live screen is up.
2997
3012
  if t.scrollback():
2998
- await send_frame({'type': 'out', 'replay': True, 'data': hub_term.scrub_queries(t.scrollback())})
3013
+ snap = hub_term.replay_text(t)
3014
+ if snap: await send_frame({'type': 'out', 'replay': True, 'data': snap})
2999
3015
  first_resize = True
3000
3016
  while True:
3001
3017
  m = await ws.receive_json()
@@ -0,0 +1,34 @@
1
+ """Child processes that do not open a console window.
2
+
3
+ Taskuary ships as a WINDOWED app - `taskuary.spec` builds Taskuary.exe with `console=False`, and
4
+ the desktop launcher is a pywebview window. On Windows a process with no console of its own gives
5
+ every child it starts a BRAND NEW one: a black window that pops up, flashes, and sometimes stays.
6
+ Nothing was wrong with the work being done; it was `git status`, a PowerShell report, a headless
7
+ agent run, each politely allocating a terminal nobody asked for. ("It keeps on opening terminal
8
+ windows which doesn't happen on this machine" - the owner, 2026-09-02, on a machine running the
9
+ exe. The machine where it never happened launches from a terminal, whose console the children
10
+ quietly inherit, which is exactly why this went unnoticed for so long.)
11
+
12
+ CREATE_NO_WINDOW says "run, but allocate no console". It is Windows-only and a no-op elsewhere.
13
+
14
+ What must NOT come through here: the interactive pty sessions. ConPTY (pywinpty) manages its own
15
+ hidden console and the flag has no business there - and the WhatsApp bridge, which already asks
16
+ for DETACHED_PROCESS, a stronger statement of the same intent.
17
+ """
18
+ import os
19
+ import subprocess
20
+
21
+ # defined on Windows Python 3.7+; the literal keeps this importable (and testable) anywhere
22
+ CREATE_NO_WINDOW = getattr(subprocess, 'CREATE_NO_WINDOW', 0x08000000)
23
+ WINDOWS = os.name == 'nt'
24
+
25
+
26
+ def flags(**kw) -> dict:
27
+ """The caller's keyword arguments, plus the flag that keeps the console shut on Windows.
28
+ An explicit creationflags is kept and OR-ed, never replaced."""
29
+ if not WINDOWS: return kw
30
+ return {**kw, 'creationflags': kw.get('creationflags', 0) | CREATE_NO_WINDOW}
31
+
32
+
33
+ def run(*args, **kw): return subprocess.run(*args, **flags(**kw))
34
+ def popen(*args, **kw): return subprocess.Popen(*args, **flags(**kw))