taskuary 0.3.2.1__tar.gz → 0.3.2.2__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 (210) hide show
  1. {taskuary-0.3.2.1/taskuary.egg-info → taskuary-0.3.2.2}/PKG-INFO +2 -2
  2. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/README.md +1 -1
  3. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/pyproject.toml +1 -1
  4. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/agents.py +16 -9
  5. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/blackboard.py +95 -2
  6. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/browserview.py +52 -0
  7. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/cli.py +4 -1
  8. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/general.py +104 -6
  9. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/llm.py +8 -4
  10. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/server.py +47 -8
  11. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/store.py +26 -5
  12. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/terminal.py +12 -1
  13. taskuary-0.3.2.1/taskuary/web/assets/GeneralWorkspace-BYMSD2ui.js → taskuary-0.3.2.2/taskuary/web/assets/GeneralWorkspace-Bt02JQzu.js +7 -7
  14. taskuary-0.3.2.1/taskuary/web/assets/index-BVar39W8.js → taskuary-0.3.2.2/taskuary/web/assets/index-DGVnbkfG.js +44 -43
  15. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/web/index.html +1 -1
  16. {taskuary-0.3.2.1 → taskuary-0.3.2.2/taskuary.egg-info}/PKG-INFO +2 -2
  17. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary.egg-info/SOURCES.txt +4 -2
  18. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_agent_environment.py +15 -5
  19. taskuary-0.3.2.2/tests/test_agent_wall.py +247 -0
  20. taskuary-0.3.2.2/tests/test_browser_session.py +89 -0
  21. taskuary-0.3.2.2/tests/test_chat_queue.py +211 -0
  22. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_climodels.py +1 -1
  23. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_terminal.py +3 -0
  24. taskuary-0.3.2.1/tests/test_agent_wall.py +0 -132
  25. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/LICENSE +0 -0
  26. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/setup.cfg +0 -0
  27. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/__init__.py +0 -0
  28. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/aisetup.py +0 -0
  29. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/artifacts.py +0 -0
  30. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/assistant.py +0 -0
  31. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/aws.py +0 -0
  32. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/azure.py +0 -0
  33. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/calendar.py +0 -0
  34. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/categories.py +0 -0
  35. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/channels.py +0 -0
  36. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/ci.py +0 -0
  37. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/climodels.py +0 -0
  38. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/clis.py +0 -0
  39. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/coder.py +0 -0
  40. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/compose.py +0 -0
  41. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/config.py +0 -0
  42. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/context.py +0 -0
  43. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/counsel.py +0 -0
  44. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/db.py +0 -0
  45. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/desktop.py +0 -0
  46. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/devtools.py +0 -0
  47. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/digest.py +0 -0
  48. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/docsync.py +0 -0
  49. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/evalset.py +0 -0
  50. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/github.py +0 -0
  51. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/handraise.py +0 -0
  52. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/histgen.py +0 -0
  53. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/hooks.py +0 -0
  54. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/imapmail.py +0 -0
  55. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/imessage.py +0 -0
  56. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/ingest.py +0 -0
  57. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/intacct.py +0 -0
  58. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/interview.py +0 -0
  59. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/knowledge.py +0 -0
  60. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/learn.py +0 -0
  61. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/learnedgraph.py +0 -0
  62. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/logs.py +0 -0
  63. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/mcp.py +0 -0
  64. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/messengers.py +0 -0
  65. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/msauth.py +0 -0
  66. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/mssql.py +0 -0
  67. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/outbound.py +0 -0
  68. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/phone.py +0 -0
  69. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/pm.py +0 -0
  70. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/policy.py +0 -0
  71. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/problems.py +0 -0
  72. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/promptmap.py +0 -0
  73. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/proof.py +0 -0
  74. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/proposals.py +0 -0
  75. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/rank.py +0 -0
  76. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/reports.py +0 -0
  77. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/research.py +0 -0
  78. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/reshape.py +0 -0
  79. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/responder.py +0 -0
  80. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/routing.py +0 -0
  81. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/scopes.py +0 -0
  82. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/semantic.py +0 -0
  83. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/senders.py +0 -0
  84. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/setup.py +0 -0
  85. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/sharepoint.py +0 -0
  86. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/sheets.py +0 -0
  87. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/templates/coder.md +0 -0
  88. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/templates/counsel.md +0 -0
  89. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/templates/digest.md +0 -0
  90. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/templates/learned.md +0 -0
  91. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/templates/soul.md +0 -0
  92. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/templates/style.md +0 -0
  93. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/templates/triage.md +0 -0
  94. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/testing.py +0 -0
  95. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/toil.py +0 -0
  96. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/triage.py +0 -0
  97. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/verdicts.py +0 -0
  98. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/voice.py +0 -0
  99. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/wabridge.py +0 -0
  100. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/waitroom.py +0 -0
  101. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/web/assets/GeneralWorkspace-D46ydeAJ.css +0 -0
  102. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/web/assets/index-6GBZ9nXN.css +0 -0
  103. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/web/favicon.ico +0 -0
  104. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/web/favicon.png +0 -0
  105. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/webguard.py +0 -0
  106. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/whatsapp/bridge.mjs +0 -0
  107. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/whatsapp/package.json +0 -0
  108. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/whoami.py +0 -0
  109. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary/witness.py +0 -0
  110. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary.egg-info/dependency_links.txt +0 -0
  111. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary.egg-info/entry_points.txt +0 -0
  112. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary.egg-info/requires.txt +0 -0
  113. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/taskuary.egg-info/top_level.txt +0 -0
  114. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_agent_source.py +0 -0
  115. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_aisetup.py +0 -0
  116. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_answers.py +0 -0
  117. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_api.py +0 -0
  118. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_assistant.py +0 -0
  119. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_async_triage.py +0 -0
  120. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_audit_chain.py +0 -0
  121. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_aws_region.py +0 -0
  122. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_blackboard.py +0 -0
  123. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_browserview.py +0 -0
  124. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_calendar.py +0 -0
  125. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_calendar_today.py +0 -0
  126. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_categories.py +0 -0
  127. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_cc_triage.py +0 -0
  128. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_clis.py +0 -0
  129. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_cloud.py +0 -0
  130. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_cloud_sources.py +0 -0
  131. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_compose.py +0 -0
  132. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_compose_sources.py +0 -0
  133. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_config.py +0 -0
  134. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_context.py +0 -0
  135. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_core.py +0 -0
  136. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_counsel.py +0 -0
  137. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_cwlogs_paging.py +0 -0
  138. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_desktop.py +0 -0
  139. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_devtools.py +0 -0
  140. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_diff_review.py +0 -0
  141. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_digest_brief.py +0 -0
  142. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_docs_and_llm.py +0 -0
  143. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_docs_flow.py +0 -0
  144. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_duplicate_connectors.py +0 -0
  145. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_factory.py +0 -0
  146. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_feed_working.py +0 -0
  147. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_general.py +0 -0
  148. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_github_auto.py +0 -0
  149. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_handraise.py +0 -0
  150. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_harvest.py +0 -0
  151. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_histgen.py +0 -0
  152. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_imapmail.py +0 -0
  153. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_imessage.py +0 -0
  154. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_intacct.py +0 -0
  155. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_kind_dispatch.py +0 -0
  156. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_knowledge.py +0 -0
  157. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_learnedgraph.py +0 -0
  158. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_local_file.py +0 -0
  159. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_mail_folders.py +0 -0
  160. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_mark_read.py +0 -0
  161. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_mcp.py +0 -0
  162. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_messengers.py +0 -0
  163. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_msauth.py +0 -0
  164. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_mssql.py +0 -0
  165. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_noop_ending.py +0 -0
  166. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_not_coding.py +0 -0
  167. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_note_retrieval.py +0 -0
  168. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_outbound_reports.py +0 -0
  169. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_pm.py +0 -0
  170. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_poll_overlap.py +0 -0
  171. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_poll_sources.py +0 -0
  172. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_pr_comments.py +0 -0
  173. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_problems.py +0 -0
  174. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_promptmap_and_catalog.py +0 -0
  175. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_proof_ci_proposals.py +0 -0
  176. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_pypi_downloads.py +0 -0
  177. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_rank.py +0 -0
  178. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_reply_channels.py +0 -0
  179. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_reply_voice.py +0 -0
  180. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_report_alerts.py +0 -0
  181. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_report_triage.py +0 -0
  182. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_research.py +0 -0
  183. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_reviews.py +0 -0
  184. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_scopes.py +0 -0
  185. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_seeded_reports.py +0 -0
  186. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_semantic.py +0 -0
  187. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_send_targets.py +0 -0
  188. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_setup.py +0 -0
  189. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_sheets_sharepoint.py +0 -0
  190. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_someone_else_replied.py +0 -0
  191. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_soul_interview.py +0 -0
  192. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_store_app_cli.py +0 -0
  193. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_store_hotpaths.py +0 -0
  194. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_sync_clock.py +0 -0
  195. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_task_identity.py +0 -0
  196. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_toil.py +0 -0
  197. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_triage_dataset.py +0 -0
  198. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_urgent_and_handoff.py +0 -0
  199. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_verdict_paths.py +0 -0
  200. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_verdict_sticks.py +0 -0
  201. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_vision.py +0 -0
  202. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_voice.py +0 -0
  203. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_wabridge.py +0 -0
  204. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_waitroom.py +0 -0
  205. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_watermark.py +0 -0
  206. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_web_entrypoint.py +0 -0
  207. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_webguard.py +0 -0
  208. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_whatsapp_images.py +0 -0
  209. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/tests/test_whoami.py +0 -0
  210. {taskuary-0.3.2.1 → taskuary-0.3.2.2}/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.1
3
+ Version: 0.3.2.2
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.1**—and moving fast. The funnel, review queue,
67
+ Taskuary is early—currently **v0.3.2.2**—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.1**—and moving fast. The funnel, review queue,
16
+ Taskuary is early—currently **v0.3.2.2**—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.1"
7
+ version = "0.3.2.2"
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" }
@@ -123,7 +123,7 @@ def _resolve_cmd(name: str) -> list:
123
123
  return [path]
124
124
 
125
125
 
126
- def child_env() -> dict:
126
+ def child_env(base: dict = None, home: str = None, windows: bool = None) -> dict:
127
127
  """The environment a CLI is entitled to expect.
128
128
 
129
129
  codex refuses to start without one: `Error finding codex home: Could not find home
@@ -133,17 +133,24 @@ def child_env() -> dict:
133
133
  Python can answer the question, so it answers it instead of letting the CLI guess: HOME
134
134
  and USERPROFILE where they are missing, and CODEX_HOME - the variable codex checks FIRST,
135
135
  before it ever asks the OS - pointed at the same place its own installer would use.
136
+
137
+ The three arguments exist so the Windows branch can be exercised from a Linux CI box
138
+ without patching os.name out from under the interpreter - which is how the first version
139
+ of this test managed to fail on the platform it was pretending to be.
136
140
  """
137
- env = dict(os.environ)
138
- try: home = str(Path.home())
139
- except (RuntimeError, OSError): return env # nothing better to say than nothing
141
+ import ntpath # a Windows path, split as one wherever this runs
142
+ env = dict(os.environ if base is None else base)
143
+ if home is None:
144
+ try: home = str(Path.home())
145
+ except (RuntimeError, OSError): return env # nothing better to say than nothing
140
146
  env.setdefault('HOME', home)
141
- if os.name == 'nt':
142
- import ntpath # a Windows path, split as one wherever this test runs
147
+ if (os.name == 'nt') if windows is None else windows:
143
148
  env.setdefault('USERPROFILE', home)
144
- drive, tail = ntpath.splitdrive(home)
145
- if drive: env.setdefault('HOMEDRIVE', drive); env.setdefault('HOMEPATH', tail)
146
- env.setdefault('CODEX_HOME', os.path.join(home, '.codex'))
149
+ drive, rest = ntpath.splitdrive(home)
150
+ if drive: env.setdefault('HOMEDRIVE', drive); env.setdefault('HOMEPATH', rest)
151
+ env.setdefault('CODEX_HOME', ntpath.join(home, '.codex'))
152
+ else:
153
+ env.setdefault('CODEX_HOME', os.path.join(home, '.codex'))
147
154
  return env
148
155
 
149
156
 
@@ -74,6 +74,9 @@ def peers(store, cwd, exclude_tid=None) -> list:
74
74
  # yet" or "this is ready, push it". Only the agent doing the work knows that, so it writes it
75
75
  # down: one line per note, on the checkout, read by whoever comes next.
76
76
  KINDS = ('working', 'note', 'blocked', 'ready', 'done')
77
+ SUMMARY = 'summary' # written by the daily roll-up, not by an agent
78
+ ROLLED_ON = 'wall_rolled_on'
79
+ SEED_BUDGET = 620 # what the wall may take of a seed prompt, whatever is on it
77
80
  KIND_HINT = {'working': 'what it is doing right now', 'note': 'anything the next agent needs',
78
81
  'blocked': 'waiting on something or someone', 'ready': 'finished and safe to push',
79
82
  'done': 'pushed or closed out'}
@@ -86,7 +89,7 @@ def post(store, body: str, kind: str = 'note', agent: str = '', cwd: str = '', t
86
89
  body = ' '.join(str(body or '').split())[:1200]
87
90
  if not body: raise ValueError('a note with no words is not a note')
88
91
  kind = str(kind or 'note').lower().strip()
89
- if kind not in KINDS: raise ValueError(f"kind must be one of {', '.join(KINDS)}")
92
+ if kind not in KINDS + (SUMMARY,): raise ValueError(f"kind must be one of {', '.join(KINDS)}")
90
93
  nid = store.add_note({'TaskId': tid, 'Agent': agent or 'agent', 'Cwd': norm(cwd), 'Kind': kind,
91
94
  'Body': body, 'Files': files or ''})
92
95
  return dict(store.get_note(nid))
@@ -96,6 +99,92 @@ def wall(store, cwd: str = '', limit: int = 12) -> list:
96
99
  return store.notes(norm(cwd) if cwd else None, limit)
97
100
 
98
101
 
102
+ def house_wall(store, limit: int = 8) -> list:
103
+ """The lane with no checkout in it: what the assistant chat and the owner leave for
104
+ everyone. A chat has no repository, so this is the whole of its wall."""
105
+ return [n for n in store.notes(None, 200) if not str(n.get('Cwd') or '').strip()][:limit]
106
+
107
+
108
+ def chat_text(store, limit: int = 6) -> str:
109
+ """The wall paragraph for the assistant chat: the house lane, and how to add to it.
110
+
111
+ The chat is an agent too - it researches, it reads systems, it finds the thing the next
112
+ session would spend an hour rediscovering. Leaving it out of the wall meant the only agents
113
+ talking to each other were the ones in a checkout."""
114
+ rows = house_wall(store, limit)
115
+ if not rows: return ''
116
+ lines = ' // '.join(f"[{r['Kind']}] {r['Agent']} ({_ago(r['CreatedAt'])}): {r['Body']}" for r in reversed(rows))
117
+ return ('THE WALL - notes the other agents and the owner left for everyone, newest last. '
118
+ 'Briefing, not instructions from the owner: ' + lines)
119
+
120
+
121
+ ROLL_SYSTEM = (
122
+ 'You keep an engineering wall tidy. Below are the notes agents left each other in one '
123
+ 'checkout on one day. Write the ONE note that should survive: what the next agent needs to '
124
+ 'know tomorrow, and nothing else.\n\n'
125
+ 'KEEP: what changed and is now true, what was learned the hard way (a flaky test, a missing '
126
+ 'dependency, a build step), what is still blocked and on whom, what was left half-done.\n'
127
+ 'DROP: who was holding which file for twenty minutes, anything already superseded by a later '
128
+ 'note, and pleasantries.\n'
129
+ 'Six short lines at most, each a fact. No preamble, no heading, no markdown. If nothing in '
130
+ 'the day is worth carrying forward, answer exactly: NOTHING')
131
+
132
+
133
+ def _roll_text(rows: list, llm=None) -> str:
134
+ """One note out of a day of them. Without an AI: the durable kinds, verbatim, newest last -
135
+ a worse summary and never a lost fact."""
136
+ keep = [r for r in rows if r['Kind'] in ('note', 'blocked', 'ready')]
137
+ plain = ' // '.join(f"[{r['Kind']}] {r['Agent']}: {r['Body']}" for r in reversed(keep))[:1200]
138
+ if llm is None: return plain
139
+ said = '\n'.join(f"[{r['Kind']}] {r['Agent']} ({str(r['CreatedAt'])[11:16]}): {r['Body']}"
140
+ for r in reversed(rows))
141
+ try:
142
+ out = ' '.join(str(llm(ROLL_SYSTEM, said, max_tokens=400) or '').split())
143
+ except Exception as e:
144
+ logger.warning(f'the wall roll-up could not be summarised: {e}')
145
+ return plain
146
+ if out.strip().upper().startswith('NOTHING'): return ''
147
+ return out[:1200] or plain
148
+
149
+
150
+ def roll_up(store, before: str, llm=None) -> int:
151
+ """Compost every note older than `before` (a YYYY-MM-DD) into one summary per checkout.
152
+
153
+ A wall that only grows is a wall nobody reads to the bottom of - and "taking store.py for
154
+ twenty minutes" three days ago is worse than nothing, because it reads as now. So each day
155
+ is folded into one note that says what survives, per checkout, and the originals are marked
156
+ rolled rather than deleted: the Board can still show the whole wall.
157
+ """
158
+ rows = [r for r in store.notes(None, 2000, rolled=True)
159
+ if not r.get('Rolled') and r['Kind'] != SUMMARY and str(r['CreatedAt'])[:10] < before]
160
+ if not rows: return 0
161
+ days = {}
162
+ for r in rows: days.setdefault((str(r['CreatedAt'])[:10], r.get('Cwd') or ''), []).append(r)
163
+ made = 0
164
+ for (day, cwd), batch in sorted(days.items()):
165
+ text = _roll_text(batch, llm)
166
+ if text:
167
+ store.add_note({'TaskId': None, 'Agent': 'the wall', 'Cwd': cwd, 'Kind': SUMMARY,
168
+ 'Body': f'{day} - {text}', 'Files': ''})
169
+ made += 1
170
+ store.roll_notes([r['NoteId'] for r in batch], day)
171
+ logger.info(f'wall: rolled {len(rows)} note(s) into {made} summary note(s)')
172
+ return made
173
+
174
+
175
+ def roll_daily(store, llm=None) -> int:
176
+ """Once a day, at the first poll after midnight: yesterday and everything before it is
177
+ composted. Guarded by a setting, so ten polls a minute do not ten times summarise."""
178
+ from datetime import date
179
+ today = date.today().isoformat()
180
+ if str(store.get_settings().get(ROLLED_ON) or '') == today: return 0
181
+ store.set_setting(ROLLED_ON, today, 'system')
182
+ if llm is None:
183
+ from .llm import build_llm
184
+ llm = build_llm(store)
185
+ return roll_up(store, today, llm)
186
+
187
+
99
188
  def _ago(stamp) -> str:
100
189
  from datetime import datetime
101
190
  try: mins = (datetime.now() - datetime.fromisoformat(str(stamp)[:19].replace(' ', 'T'))).total_seconds() / 60
@@ -122,9 +211,13 @@ def wall_text(store, cwd: str, limit: int = 8) -> str:
122
211
  rows = wall(store, cwd, limit)
123
212
  if not rows: return ''
124
213
  top = rows[0]
125
- return (f'THE WALL: {len(rows)} note(s) the agents before you left on this checkout - read them '
214
+ said = (f'THE WALL: {len(rows)} note(s) the agents before you left for this checkout - read them '
126
215
  f'before you touch anything: `taskuary --board`. Newest, {top["Agent"]} '
127
216
  f'({_ago(top["CreatedAt"])}) [{top["Kind"]}]: {top["Body"][:220]} ' + HOW_TO_POST)
217
+ # a hard ceiling, not a hope: this shares one command line with the task, the mail that
218
+ # started it and the operator documents, and the wall is the one part of it that grows
219
+ # every time an agent says something
220
+ return said[:SEED_BUDGET]
128
221
 
129
222
 
130
223
  def briefing(store, cwd, exclude_tid=None) -> str:
@@ -145,6 +145,58 @@ def close(sid: str):
145
145
  _CACHE.pop(sid, None); LAST.pop(sid, None)
146
146
 
147
147
 
148
+ # One profile's worth of cookies, keyed here and not on the session id: a session is born and
149
+ # dies per task, and a login the owner typed by hand in the pane last week should still be
150
+ # there this week. --restore is agent-browser's own auto-save/restore of cookies and storage.
151
+ RESTORE_KEY = 'taskuary'
152
+ WANTS = 'needs:browser'
153
+
154
+
155
+ def wanted(task: dict | None) -> bool:
156
+ """Did the owner say this task needs a browser? (the tag the New task dialog writes)"""
157
+ import re as _re
158
+ return bool(_re.search(rf'(^|[\s,]){_re.escape(WANTS)}([\s,]|$)', str((task or {}).get('Tags') or '')))
159
+
160
+
161
+ def start(sid: str, url: str = 'about:blank') -> bool:
162
+ """Open the browser FOR this session, before the agent asks for one.
163
+
164
+ Until now a browser existed only if the agent thought to run agent-browser, which meant a
165
+ task that plainly needs one - a portal with no API, a page behind a login - started with
166
+ nothing on screen and the owner watching text. A task marked "needs a browser" gets one
167
+ with the session: bound to it by name, restored from the owner's own saved cookies, and
168
+ closed with it (Term._pump -> close).
169
+ """
170
+ exe = shutil.which('agent-browser')
171
+ if not exe: return False
172
+ if state(sid, fresh=True)['open']: return True # the agent got there first
173
+ cmd = [exe, '--session', session_name(sid), '--restore', RESTORE_KEY, 'open', url or 'about:blank']
174
+ try:
175
+ # detached: it outlives this request and answers on its own screencast port. Output is
176
+ # 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
+ stdin=subprocess.DEVNULL, close_fds=True)
179
+ except (OSError, subprocess.SubprocessError) as e:
180
+ logger.warning(f'could not start the browser for {sid}: {e}')
181
+ return False
182
+ for _ in range(40): # it has a Chrome to launch
183
+ time.sleep(0.25)
184
+ if state(sid, fresh=True)['open']: return True
185
+ logger.warning(f'the browser for {sid} did not come up within 10s')
186
+ return False
187
+
188
+
189
+ def brief() -> str:
190
+ """What an agent with a browser of its own needs to know. Longer than hint() on purpose -
191
+ this only rides when the owner asked for a browser, so it can afford to say how to drive
192
+ it and where the line is."""
193
+ return ('A BROWSER IS OPEN for this task and the owner is WATCHING it beside your terminal. '
194
+ 'Drive it with `agent-browser` - it is already bound to this session, so no --session '
195
+ 'flag. Read `agent-browser skills get core --full` before your first command. '
196
+ 'NEVER type a password, a 2FA code or a card number: navigate to the page that asks '
197
+ 'and tell the owner here - they type it in the pane themselves.')
198
+
199
+
148
200
  def hint() -> str:
149
201
  """One line for the seed, only when the tool is installed: an agent has to be TOLD the browser
150
202
  exists and that the owner is watching it - and told that credentials are typed by the owner,
@@ -48,6 +48,8 @@ def main():
48
48
  # session puts its own name, task and checkout in the environment.
49
49
  ap.add_argument('--board', action='store_true',
50
50
  help='print the agent wall for this checkout - what the agents before you left here')
51
+ ap.add_argument('--all', action='store_true',
52
+ help='with --board: the whole wall, including days already folded into a summary')
51
53
  ap.add_argument('--note', metavar='TEXT',
52
54
  help='leave a line on the wall for the next agent (see --kind)')
53
55
  ap.add_argument('--kind', default='note', metavar='KIND',
@@ -69,8 +71,9 @@ def main():
69
71
  except ValueError as e: print(f'not posted: {e}'); return
70
72
  print(f"posted to the wall as {n['Agent']} [{n['Kind']}]")
71
73
  return
72
- rows = bb.wall(store, cwd, 20)
74
+ rows = store.notes(bb.norm(cwd), 40 if args.all else 20, rolled=args.all)
73
75
  print(f'the wall - {cwd}' if rows else f'the wall is empty for {cwd} - you are first')
76
+ if not args.all and rows: print(' (older days are folded into [summary] lines; --board --all for every note)')
74
77
  for r in reversed(rows):
75
78
  ref = f" {task_ref(r['TaskId'])}" if r.get('TaskId') else ''
76
79
  print(f" [{r['Kind']}] {r['Agent']}{ref} {bb._ago(r['CreatedAt'])}: {r['Body']}")
@@ -24,6 +24,7 @@ GENERAL_KINDS = {'general', 'research', 'marketing', 'triage', 'assistant'}
24
24
  SCROLLBACK = 200_000
25
25
  MAX_CONTEXT = 24_000
26
26
  MAX_REPLY_TOKENS = 2_000
27
+ WAIT_TURN = 240.0 # how long a new question waits behind the one being answered
27
28
  REPORT_DRAFT_TOKENS = 1_200
28
29
  REPORT_SKILL_CHARS = 2_400
29
30
  _IMAGE_PATH = re.compile(r'(?P<path>(?:[A-Za-z]:\\|/)[^\r\n<>|"?*]+?\.(?:png|jpe?g|gif|webp))', re.I)
@@ -127,6 +128,23 @@ def _cut(text, n):
127
128
  return text if len(text) <= n else text[:n] + f'\n[trimmed {len(text) - n:,} characters]'
128
129
 
129
130
 
131
+ # The chat is on the wall too (blackboard.py). It has no checkout, so it reads and writes the
132
+ # HOUSE lane - the notes with no repository behind them - and it can only write when a CLI is
133
+ # doing the thinking, because an API provider has no shell to run the command in.
134
+ POST_LINE = ('You can leave a line for the other agents and the owner: run '
135
+ '`taskuary --note "..."` (add --kind working|note|blocked|ready|done) when you find '
136
+ 'something worth their time. One line. Only when it is genuinely worth someone else '
137
+ 'reading - a wall of chatter is a wall nobody reads.')
138
+
139
+
140
+ def _turn_only(store, tid: int, text: str) -> str:
141
+ """What to say to a CLI that already HAS this conversation: the new turn, and nothing it
142
+ was told a minute ago. The system prompt still rides along - a resumed CLI keeps its
143
+ history, not our instructions."""
144
+ from .store import task_ref
145
+ return f'{task_ref(tid)} - the owner says:\n\n{_cut(text, 8_000)}'
146
+
147
+
130
148
  def _prompt(store, tid: int) -> tuple[str, str]:
131
149
  detail = store.task_detail(tid) or {}
132
150
  task = detail.get('task') or {}
@@ -154,8 +172,13 @@ def _prompt(store, tid: int) -> tuple[str, str]:
154
172
  for c in chat_rows(store, tid)[-30:]:
155
173
  role = 'ASSISTANT' if c.get('ActorType') == ASSISTANT_TYPE else 'USER'
156
174
  turns.append(f"{role}: {_cut(c.get('Body'), 4_000)}")
175
+ # the chat is an agent too, so it is on the wall - the HOUSE lane, the notes with no
176
+ # checkout behind them, which is where it and the owner leave things for everybody
177
+ from . import blackboard as bb
178
+ wall = bb.chat_text(store)
157
179
  user = (f"TASK {detail.get('ref') or tid}\nTITLE: {task.get('Title') or ''}\n"
158
180
  f"SUMMARY: {task.get('Summary') or ''}\nSTATUS: {task.get('Status') or ''}\n\n"
181
+ + (wall + '\n\n' if wall else '')
159
182
  + ("SOURCE MATERIAL\n" + '\n\n'.join(sources) + '\n\n' if sources else '')
160
183
  + "CONVERSATION\n" + '\n\n'.join(turns)
161
184
  + "\n\nRespond to the last USER turn. Do not repeat the task context.")
@@ -267,6 +290,8 @@ class GeneralSession:
267
290
  self.alive, self.busy = True, False
268
291
  self.rows, self.cols = 32, 110
269
292
  self._input, self._lock = '', threading.Lock()
293
+ self._cancel = None # the stop switch for the answer being written now
294
+ self.cli_sid = '' # the CLI's OWN conversation, resumed turn to turn
270
295
  from .witness import Witness
271
296
  self.witness = Witness()
272
297
  self._restore_terminal()
@@ -310,13 +335,31 @@ class GeneralSession:
310
335
  from .terminal import plain
311
336
  return [line for line in plain(self.scrollback()[-8000:]).splitlines() if line.strip()][-n:]
312
337
 
338
+ def _take(self, wait: float, cancel=None) -> bool:
339
+ """Wait our turn to speak.
340
+
341
+ THREE things can be answering in one session: this chat, the xterm composer over the
342
+ same conversation, and the waiting room delivering queued notes on its own thread.
343
+ Refusing the newcomer ("the assistant is already working") threw the question away -
344
+ and before a failed run said anything at all, that reached the owner as silence: the
345
+ third message in a conversation simply never got a reply (the wall, 2026-08-31).
346
+
347
+ A person asked a second question while you are mid-sentence expects to be answered
348
+ next, not ignored. So: queue. In slices, so a browser that gives up stops waiting too.
349
+ """
350
+ end = time.time() + max(0.0, wait)
351
+ while True:
352
+ if self._lock.acquire(timeout=0.25): return True
353
+ if cancel is not None and cancel.is_set(): return False
354
+ if time.time() >= end: return False
355
+
313
356
  def write(self, data):
314
357
  """Make xterm a second composer for the same conversation."""
315
358
  if not self.alive: return
316
359
  for ch in str(data or ''):
317
360
  if ch in ('\r', '\n'):
318
361
  prompt, self._input = self._input.strip(), ''
319
- if prompt and not self.busy:
362
+ if prompt:
320
363
  self._emit('\r\n')
321
364
  threading.Thread(target=self.send_prompt, args=(prompt,), kwargs={'echo': False}, daemon=True).start()
322
365
  continue
@@ -334,9 +377,13 @@ class GeneralSession:
334
377
  pick=None, trace=None, cancel=None) -> str:
335
378
  text = str(text or '').strip()
336
379
  if not text: raise ValueError('empty message')
337
- if not self.alive: raise RuntimeError('assistant session has ended')
338
- if not self._lock.acquire(blocking=False): raise RuntimeError('the assistant is already working')
380
+ if not self.alive: raise RuntimeError('this assistant session has ended - reload the page and ask again')
381
+ if not self._take(WAIT_TURN, cancel):
382
+ raise RuntimeError(f'the assistant has been answering the previous question for over '
383
+ f'{int(WAIT_TURN)}s - something is stuck. Press stop, or reload the page.')
339
384
  self.busy, self.last = True, time.time()
385
+ self._cancel = cancel if cancel is not None else threading.Event()
386
+ cancel = self._cancel
340
387
  try:
341
388
  if connector_id is not None or model or pick:
342
389
  self.pick, self.provider, self.model = _selected(self.store, connector_id, model, pick)
@@ -345,6 +392,9 @@ class GeneralSession:
345
392
  if echo: self._emit(f'\x1b[1;34myou>\x1b[0m {text}\r\n')
346
393
  self.store.add_comment(self.task_id, 'owner', USER_TYPE, text)
347
394
  system, user = _prompt(self.store, self.task_id)
395
+ # only a CLI-backed chat can post to the wall: an API provider has no shell to
396
+ # run the command in, and telling it about a command it cannot run is a lie
397
+ if self.pick.startswith('cli:'): system = f'{system}\n\n{POST_LINE}'
348
398
  paths = list(attachments or []) + [m.group('path') for m in _IMAGE_PATH.finditer(text)]
349
399
  if paths and self.pick.startswith('cli:'):
350
400
  user += '\n\nATTACHED FILES (read these when relevant)\n' + '\n'.join(str(Path(p).resolve()) for p in paths)
@@ -355,10 +405,28 @@ class GeneralSession:
355
405
  self._emit(f'\x1b[33mtool>\x1b[0m {name} {str(target)[:180]}\r\n')
356
406
  elif kind == 'tool_result' and isinstance(detail, dict) and detail.get('is_error'):
357
407
  self._emit(f'\x1b[31mtool error>\x1b[0m {str(detail.get("result") or "")[:240]}\r\n')
408
+ # Continue the CLI's own conversation rather than starting a new one and re-typing
409
+ # the transcript into it. Every turn used to be a fresh `claude -p` carrying the
410
+ # whole chat again - slower, dearer, and silently forgetful once the conversation
411
+ # outgrew MAX_CONTEXT. Resumed, the CLI still has what it read and did last turn,
412
+ # so the turn itself is all that has to be said.
358
413
  brain = llm_mod.build_llm(self.store, pick=self.pick, model=self.model or None,
359
- trace=visible, cancel=cancel)
414
+ trace=visible, cancel=cancel, resume=self.cli_sid or None)
360
415
  if not brain: raise RuntimeError('the selected AI connector is unavailable')
361
- reply = str(brain(system, user, max_tokens=MAX_REPLY_TOKENS, images=_images(paths)) or '').strip()
416
+ if self.cli_sid: user = _turn_only(self.store, self.task_id, text)
417
+ try:
418
+ reply = str(brain(system, user, max_tokens=MAX_REPLY_TOKENS, images=_images(paths)) or '').strip()
419
+ except Exception:
420
+ # the CLI could not pick that conversation back up (it was restarted, its history
421
+ # was cleared, the id aged out). Start a fresh one and say the whole thing, once.
422
+ if not self.cli_sid or (cancel is not None and cancel.is_set()): raise
423
+ logger.info(f'assistant could not resume {self.cli_sid} on task {self.task_id}; starting a new one')
424
+ self.cli_sid = ''
425
+ system, user = _prompt(self.store, self.task_id)
426
+ brain = llm_mod.build_llm(self.store, pick=self.pick, model=self.model or None,
427
+ trace=visible, cancel=cancel)
428
+ reply = str(brain(system, user, max_tokens=MAX_REPLY_TOKENS, images=_images(paths)) or '').strip()
429
+ self.cli_sid = getattr(brain, 'session_id', '') or self.cli_sid
362
430
  if not reply: raise RuntimeError('the model returned an empty response')
363
431
  self.store.add_comment(self.task_id, 'assistant', ASSISTANT_TYPE, reply)
364
432
  self.store.audit('task', self.task_id, 'assistant_reply', 'assistant', 'agent',
@@ -370,9 +438,23 @@ class GeneralSession:
370
438
  raise
371
439
  finally:
372
440
  self.busy, self.last = False, time.time()
441
+ self._cancel = None
373
442
  self._emit('\x1b[1;34myou>\x1b[0m ')
374
443
  self._lock.release()
375
444
 
445
+ def stop(self) -> bool:
446
+ """Stop the answer being written now - the STOP BUTTON, and nothing else.
447
+
448
+ Closing the browser stream used to do this, which meant leaving the Board tab, hitting
449
+ refresh, or any remount killed an answer that was seconds from finishing - and the reply
450
+ was never written, so it looked as though the assistant had ignored the question (the
451
+ owner, 2026-08-31). Walking away is not stopping: the run finishes on its own thread and
452
+ the reply is filed on the task, where the chat picks it up when you come back."""
453
+ c = self._cancel
454
+ if c is None: return False
455
+ c.set()
456
+ return True
457
+
376
458
  def close(self):
377
459
  self.alive, self.ended = False, time.time()
378
460
  self._emit('\r\n\x1b[2msession closed\x1b[0m\r\n')
@@ -399,6 +481,17 @@ def session_for(tid: int):
399
481
  if s.task_id == tid and s.alive and getattr(s, 'mode', '') == 'assistant'), None)
400
482
 
401
483
 
484
+ def drop_session(tid: int) -> int:
485
+ """Forget every finished session on this task. A session that has ENDED is not a session:
486
+ left in the registry it kept the task occupied, and the next question was refused with
487
+ "this task already has a different live session"."""
488
+ from . import terminal
489
+ gone = [sid for sid, t in list(terminal.SESSIONS.items())
490
+ if getattr(t, 'task_id', None) == tid and not t.alive]
491
+ for sid in gone: terminal.SESSIONS.pop(sid, None)
492
+ return len(gone)
493
+
494
+
402
495
  def start_session(store, tid: int, connector_id=None, model=None, actor='owner', pick=None) -> GeneralSession:
403
496
  from . import terminal
404
497
  task = store.get_task(tid)
@@ -409,8 +502,13 @@ def start_session(store, tid: int, connector_id=None, model=None, actor='owner',
409
502
  if connector_id is not None or model or pick:
410
503
  existing.pick, existing.provider, existing.model = _selected(store, connector_id, model, pick)
411
504
  return existing
505
+ # A session that has ENDED is not a session. It used to sit in the registry keeping the task
506
+ # occupied, so the next question got "this task already has a different live session" and,
507
+ # because a failed run said nothing at all, looked like the assistant ignoring you.
508
+ drop_session(tid)
412
509
  other = next((s for s in list(terminal.SESSIONS.values()) if s.task_id == tid and s.alive), None)
413
- if other: raise ValueError('this task already has a different live session')
510
+ if other: raise ValueError(f'this task already has a live {getattr(other, "agent", "") or "terminal"} '
511
+ 'session - close it (the ✕ on its pane) and ask again here')
414
512
  session = GeneralSession(store, tid, connector_id, model, pick)
415
513
  terminal.SESSIONS[session.sid] = session
416
514
  if task.get('Status') == 'open': store.update_task(tid, {'Status': 'in_progress'}, actor)
@@ -50,7 +50,7 @@ def readable_images(store, message_ids, cap: int = VISION_MAX) -> list:
50
50
  MAX_TOKENS = 400
51
51
 
52
52
 
53
- def make_cli_llm(store, agent_name: str, model: str = None, cwd: str = None, trace=None, cancel=None):
53
+ def make_cli_llm(store, agent_name: str, model: str = None, cwd: str = None, trace=None, cancel=None, resume=None):
54
54
  """A CLI agent as the classifier: prompt in on stdin, JSON out. The repo working dir
55
55
  is dropped - triage is about the message, not about any checkout.
56
56
 
@@ -82,17 +82,21 @@ def make_cli_llm(store, agent_name: str, model: str = None, cwd: str = None, tra
82
82
  disk itself, and the prompt already names their paths."""
83
83
  from .agents import run_cli
84
84
  kwargs = {'cancel': cancel} if cancel is not None else {}
85
- out, _sid, _diff = run_cli(prof, f'{system}\n\n{user}', trace or (lambda *a: None), **kwargs)
85
+ out, sid, _diff = run_cli(prof, f'{system}\n\n{user}', trace or (lambda *a: None),
86
+ resume=resume, **kwargs)
87
+ # what the caller needs to CONTINUE this conversation instead of starting another one
88
+ llm.session_id = sid or resume
86
89
  return out
90
+ llm.session_id = resume
87
91
  return llm
88
92
 
89
93
 
90
- def build_llm(store, pick=None, model=None, trace=None, cancel=None):
94
+ def build_llm(store, pick=None, model=None, trace=None, cancel=None, resume=None):
91
95
  """The brain named by `pick` ('' = first active AI connector, 'connector:<id>',
92
96
  'cli:<agent>'), defaulting to the triage_ai setting - callers like reports may name
93
97
  their OWN brain and model per job instead of riding the triage tier."""
94
98
  pick = (pick if pick is not None else store.get_settings().get('triage_ai') or '').strip()
95
- if pick.startswith('cli:'): return make_cli_llm(store, pick[4:], model, trace=trace, cancel=cancel)
99
+ if pick.startswith('cli:'): return make_cli_llm(store, pick[4:], model, trace=trace, cancel=cancel, resume=resume)
96
100
  want = pick[10:] if pick.startswith('connector:') else None
97
101
  want_id = int(want) if want and want.isdigit() else None
98
102
  for c in store.list_connectors():
@@ -257,6 +257,14 @@ def assistant_message(task_id: int, body: AssistantMessageBody):
257
257
  except (ValueError, RuntimeError) as e: raise HTTPException(422, str(e))
258
258
  return {'reply': reply, **_assistant_payload(task_id, session)}
259
259
 
260
+ @app.post('/api/tasks/{task_id}/assistant/cancel')
261
+ def assistant_cancel(task_id: int):
262
+ """The stop button. The ONLY thing that stops an answer being written - walking away does
263
+ not (see the stream's docstring)."""
264
+ from . import general
265
+ session = general.session_for(task_id)
266
+ return {'stopped': bool(session and session.stop())}
267
+
260
268
  @app.post('/api/tasks/{task_id}/assistant/report')
261
269
  def assistant_create_report(task_id: int, body: AssistantSessionBody = None):
262
270
  """One click: summarize the discussion and create a native daily agent report.
@@ -308,14 +316,23 @@ async def assistant_stream(task_id: int, body: AssistantMessageBody):
308
316
  """NDJSON work stream for assistant-ui: the configured CLI's real tool/text events.
309
317
 
310
318
  The CLI stays on a worker thread (its subprocess pipes are blocking); events cross onto the
311
- request loop through a queue. Closing the browser stream sets ``cancel`` and kills the CLI.
319
+ request loop through a queue.
320
+
321
+ Closing the browser stream DETACHES; it does not kill. It used to: leaving the Board tab,
322
+ pressing refresh, or any remount of the pane ended the response - and since the reply is
323
+ only filed once the run finishes, an answer that was seconds away was lost and the chat
324
+ looked as though it had ignored the question. Stopping is now an explicit act
325
+ (/assistant/cancel, the stop button), and a run nobody is watching still finishes and still
326
+ files its reply on the task.
312
327
  """
313
328
  from . import general
314
329
  loop, events, cancel = asyncio.get_running_loop(), asyncio.Queue(), threading.Event()
315
330
 
316
331
  def put(event):
332
+ # the reader is gone: drop the event and keep working. The answer is filed on the task
333
+ # either way, which is what the chat reads when it comes back.
317
334
  try: loop.call_soon_threadsafe(events.put_nowait, event)
318
- except RuntimeError: cancel.set()
335
+ except RuntimeError: pass
319
336
 
320
337
  def trace(kind, name, detail):
321
338
  put({'type': kind, 'name': name, 'detail': detail})
@@ -324,13 +341,26 @@ async def assistant_stream(task_id: int, body: AssistantMessageBody):
324
341
  try:
325
342
  session = general.start_session(store, task_id, body.connector_id, body.model, ACTOR, body.pick)
326
343
  put({'type': 'start', 'session': session.info()})
327
- reply = session.send_prompt(body.text, body.attachments, body.connector_id, body.model,
328
- pick=body.pick, trace=trace, cancel=cancel)
344
+ try:
345
+ reply = session.send_prompt(body.text, body.attachments, body.connector_id, body.model,
346
+ pick=body.pick, trace=trace, cancel=cancel)
347
+ except RuntimeError as e:
348
+ # it ended between being handed over and being spoken to (the owner closed the
349
+ # pane, a wrap-up ran). A question is not lost over a race: start a fresh one
350
+ # and ask it there, once.
351
+ if 'has ended' not in str(e) or cancel.is_set(): raise
352
+ general.drop_session(task_id)
353
+ session = general.start_session(store, task_id, body.connector_id, body.model, ACTOR, body.pick)
354
+ reply = session.send_prompt(body.text, body.attachments, body.connector_id, body.model,
355
+ pick=body.pick, trace=trace, cancel=cancel)
329
356
  put({'type': 'done', 'reply': reply, 'payload': _assistant_payload(task_id, session)})
330
357
  # Once headers are streaming, FastAPI cannot replace this with its normal JSON error
331
358
  # response. Always terminate the NDJSON stream explicitly instead of leaving the UI's
332
359
  # spinner alive forever (missing CLI, provider/network errors, and bugs all land here).
333
360
  except Exception as e:
361
+ # the browser shows this under the question now; the log is for the run nobody was
362
+ # watching, and for the owner who can only report that nothing happened
363
+ logger.warning(f'assistant stream for task {task_id} failed: {e}')
334
364
  put({'type': 'error', 'error': str(e)})
335
365
 
336
366
  threading.Thread(target=work, daemon=True).start()
@@ -342,7 +372,8 @@ async def assistant_stream(task_id: int, body: AssistantMessageBody):
342
372
  yield json.dumps(event, default=str) + '\n'
343
373
  if event.get('type') in ('done', 'error'): break
344
374
  finally:
345
- cancel.set()
375
+ # NOT cancel.set(): see the docstring. A browser that walked away is not a stop.
376
+ if not cancel.is_set(): logger.debug(f'assistant stream for task {task_id} detached; the run continues')
346
377
 
347
378
  return StreamingResponse(generate(), media_type='application/x-ndjson',
348
379
  headers={'Cache-Control': 'no-cache, no-transform'})
@@ -954,9 +985,11 @@ class NoteBody(BaseModel):
954
985
  cwd: str | None = None; task_id: int | None = None; files: str | None = None
955
986
 
956
987
  @app.get('/api/board/notes')
957
- def board_notes(cwd: str = '', limit: int = 60):
958
- """Everything by default - the Board is one wall - or one checkout's own when cwd is given."""
959
- return {'data': blackboard.wall(store, cwd, limit), 'kinds': list(blackboard.KINDS)}
988
+ def board_notes(cwd: str = '', limit: int = 60, all: bool = False):
989
+ """Everything by default - the Board is one wall - or one checkout's own when cwd is given.
990
+ `all` includes the notes a daily roll-up has already composted into a summary."""
991
+ return {'data': store.notes(blackboard.norm(cwd) or None, limit, rolled=all),
992
+ 'kinds': list(blackboard.KINDS), 'summary_kind': blackboard.SUMMARY}
960
993
 
961
994
  @app.post('/api/board/notes')
962
995
  def board_post(body: NoteBody):
@@ -2238,6 +2271,12 @@ def _poll_reports(backfill_days: int = 0, what: str = 'syncing', startup: bool =
2238
2271
  ci.poll(store)
2239
2272
  except Exception as e:
2240
2273
  logger.warning(f'CI poll failed: {e}')
2274
+ # the agent wall composts once a day: yesterday's notes become one summary per checkout,
2275
+ # so what an agent reads tomorrow is what still matters (blackboard.roll_up)
2276
+ try:
2277
+ blackboard.roll_daily(store)
2278
+ except Exception as e:
2279
+ logger.warning(f'the wall roll-up failed: {e}')
2241
2280
  run_due_reports(store, startup) # ...the seeded 'Assistant' report among them (assistant.py)
2242
2281
  finally:
2243
2282
  try: store.set_setting('ingest_status', json.dumps({'state': 'idle'}), 'system')