taskuary 0.3.2__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/taskuary.egg-info → taskuary-0.3.2.2}/PKG-INFO +2 -2
  2. {taskuary-0.3.2 → taskuary-0.3.2.2}/README.md +1 -1
  3. {taskuary-0.3.2 → taskuary-0.3.2.2}/pyproject.toml +1 -1
  4. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/agents.py +16 -8
  5. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/blackboard.py +155 -1
  6. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/browserview.py +52 -0
  7. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/cli.py +34 -0
  8. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/general.py +104 -6
  9. taskuary-0.3.2.2/taskuary/interview.py +134 -0
  10. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/llm.py +8 -4
  11. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/messengers.py +23 -12
  12. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/server.py +100 -5
  13. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/store.py +47 -0
  14. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/templates/coder.md +15 -0
  15. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/terminal.py +30 -2
  16. taskuary-0.3.2.2/taskuary/web/assets/GeneralWorkspace-Bt02JQzu.js +10 -0
  17. taskuary-0.3.2/taskuary/web/assets/index-zLivezbi.js → taskuary-0.3.2.2/taskuary/web/assets/index-DGVnbkfG.js +101 -100
  18. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/web/index.html +1 -1
  19. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/whatsapp/bridge.mjs +17 -10
  20. {taskuary-0.3.2 → taskuary-0.3.2.2/taskuary.egg-info}/PKG-INFO +2 -2
  21. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary.egg-info/SOURCES.txt +8 -2
  22. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_agent_environment.py +15 -5
  23. taskuary-0.3.2.2/tests/test_agent_wall.py +247 -0
  24. taskuary-0.3.2.2/tests/test_browser_session.py +89 -0
  25. taskuary-0.3.2.2/tests/test_chat_queue.py +211 -0
  26. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_climodels.py +1 -1
  27. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_promptmap_and_catalog.py +2 -1
  28. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_pypi_downloads.py +45 -9
  29. taskuary-0.3.2.2/tests/test_soul_interview.py +124 -0
  30. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_terminal.py +3 -0
  31. taskuary-0.3.2.2/tests/test_whatsapp_images.py +118 -0
  32. taskuary-0.3.2/taskuary/web/assets/GeneralWorkspace-Dn17SMoR.js +0 -10
  33. {taskuary-0.3.2 → taskuary-0.3.2.2}/LICENSE +0 -0
  34. {taskuary-0.3.2 → taskuary-0.3.2.2}/setup.cfg +0 -0
  35. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/__init__.py +0 -0
  36. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/aisetup.py +0 -0
  37. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/artifacts.py +0 -0
  38. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/assistant.py +0 -0
  39. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/aws.py +0 -0
  40. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/azure.py +0 -0
  41. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/calendar.py +0 -0
  42. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/categories.py +0 -0
  43. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/channels.py +0 -0
  44. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/ci.py +0 -0
  45. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/climodels.py +0 -0
  46. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/clis.py +0 -0
  47. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/coder.py +0 -0
  48. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/compose.py +0 -0
  49. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/config.py +0 -0
  50. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/context.py +0 -0
  51. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/counsel.py +0 -0
  52. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/db.py +0 -0
  53. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/desktop.py +0 -0
  54. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/devtools.py +0 -0
  55. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/digest.py +0 -0
  56. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/docsync.py +0 -0
  57. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/evalset.py +0 -0
  58. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/github.py +0 -0
  59. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/handraise.py +0 -0
  60. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/histgen.py +0 -0
  61. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/hooks.py +0 -0
  62. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/imapmail.py +0 -0
  63. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/imessage.py +0 -0
  64. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/ingest.py +0 -0
  65. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/intacct.py +0 -0
  66. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/knowledge.py +0 -0
  67. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/learn.py +0 -0
  68. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/learnedgraph.py +0 -0
  69. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/logs.py +0 -0
  70. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/mcp.py +0 -0
  71. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/msauth.py +0 -0
  72. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/mssql.py +0 -0
  73. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/outbound.py +0 -0
  74. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/phone.py +0 -0
  75. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/pm.py +0 -0
  76. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/policy.py +0 -0
  77. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/problems.py +0 -0
  78. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/promptmap.py +0 -0
  79. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/proof.py +0 -0
  80. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/proposals.py +0 -0
  81. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/rank.py +0 -0
  82. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/reports.py +0 -0
  83. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/research.py +0 -0
  84. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/reshape.py +0 -0
  85. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/responder.py +0 -0
  86. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/routing.py +0 -0
  87. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/scopes.py +0 -0
  88. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/semantic.py +0 -0
  89. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/senders.py +0 -0
  90. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/setup.py +0 -0
  91. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/sharepoint.py +0 -0
  92. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/sheets.py +0 -0
  93. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/templates/counsel.md +0 -0
  94. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/templates/digest.md +0 -0
  95. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/templates/learned.md +0 -0
  96. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/templates/soul.md +0 -0
  97. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/templates/style.md +0 -0
  98. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/templates/triage.md +0 -0
  99. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/testing.py +0 -0
  100. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/toil.py +0 -0
  101. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/triage.py +0 -0
  102. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/verdicts.py +0 -0
  103. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/voice.py +0 -0
  104. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/wabridge.py +0 -0
  105. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/waitroom.py +0 -0
  106. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/web/assets/GeneralWorkspace-D46ydeAJ.css +0 -0
  107. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/web/assets/index-6GBZ9nXN.css +0 -0
  108. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/web/favicon.ico +0 -0
  109. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/web/favicon.png +0 -0
  110. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/webguard.py +0 -0
  111. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/whatsapp/package.json +0 -0
  112. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/whoami.py +0 -0
  113. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary/witness.py +0 -0
  114. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary.egg-info/dependency_links.txt +0 -0
  115. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary.egg-info/entry_points.txt +0 -0
  116. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary.egg-info/requires.txt +0 -0
  117. {taskuary-0.3.2 → taskuary-0.3.2.2}/taskuary.egg-info/top_level.txt +0 -0
  118. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_agent_source.py +0 -0
  119. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_aisetup.py +0 -0
  120. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_answers.py +0 -0
  121. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_api.py +0 -0
  122. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_assistant.py +0 -0
  123. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_async_triage.py +0 -0
  124. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_audit_chain.py +0 -0
  125. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_aws_region.py +0 -0
  126. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_blackboard.py +0 -0
  127. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_browserview.py +0 -0
  128. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_calendar.py +0 -0
  129. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_calendar_today.py +0 -0
  130. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_categories.py +0 -0
  131. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_cc_triage.py +0 -0
  132. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_clis.py +0 -0
  133. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_cloud.py +0 -0
  134. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_cloud_sources.py +0 -0
  135. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_compose.py +0 -0
  136. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_compose_sources.py +0 -0
  137. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_config.py +0 -0
  138. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_context.py +0 -0
  139. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_core.py +0 -0
  140. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_counsel.py +0 -0
  141. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_cwlogs_paging.py +0 -0
  142. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_desktop.py +0 -0
  143. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_devtools.py +0 -0
  144. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_diff_review.py +0 -0
  145. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_digest_brief.py +0 -0
  146. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_docs_and_llm.py +0 -0
  147. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_docs_flow.py +0 -0
  148. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_duplicate_connectors.py +0 -0
  149. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_factory.py +0 -0
  150. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_feed_working.py +0 -0
  151. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_general.py +0 -0
  152. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_github_auto.py +0 -0
  153. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_handraise.py +0 -0
  154. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_harvest.py +0 -0
  155. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_histgen.py +0 -0
  156. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_imapmail.py +0 -0
  157. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_imessage.py +0 -0
  158. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_intacct.py +0 -0
  159. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_kind_dispatch.py +0 -0
  160. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_knowledge.py +0 -0
  161. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_learnedgraph.py +0 -0
  162. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_local_file.py +0 -0
  163. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_mail_folders.py +0 -0
  164. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_mark_read.py +0 -0
  165. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_mcp.py +0 -0
  166. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_messengers.py +0 -0
  167. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_msauth.py +0 -0
  168. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_mssql.py +0 -0
  169. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_noop_ending.py +0 -0
  170. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_not_coding.py +0 -0
  171. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_note_retrieval.py +0 -0
  172. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_outbound_reports.py +0 -0
  173. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_pm.py +0 -0
  174. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_poll_overlap.py +0 -0
  175. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_poll_sources.py +0 -0
  176. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_pr_comments.py +0 -0
  177. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_problems.py +0 -0
  178. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_proof_ci_proposals.py +0 -0
  179. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_rank.py +0 -0
  180. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_reply_channels.py +0 -0
  181. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_reply_voice.py +0 -0
  182. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_report_alerts.py +0 -0
  183. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_report_triage.py +0 -0
  184. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_research.py +0 -0
  185. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_reviews.py +0 -0
  186. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_scopes.py +0 -0
  187. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_seeded_reports.py +0 -0
  188. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_semantic.py +0 -0
  189. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_send_targets.py +0 -0
  190. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_setup.py +0 -0
  191. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_sheets_sharepoint.py +0 -0
  192. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_someone_else_replied.py +0 -0
  193. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_store_app_cli.py +0 -0
  194. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_store_hotpaths.py +0 -0
  195. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_sync_clock.py +0 -0
  196. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_task_identity.py +0 -0
  197. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_toil.py +0 -0
  198. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_triage_dataset.py +0 -0
  199. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_urgent_and_handoff.py +0 -0
  200. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_verdict_paths.py +0 -0
  201. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_verdict_sticks.py +0 -0
  202. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_vision.py +0 -0
  203. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_voice.py +0 -0
  204. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_wabridge.py +0 -0
  205. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_waitroom.py +0 -0
  206. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_watermark.py +0 -0
  207. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_web_entrypoint.py +0 -0
  208. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_webguard.py +0 -0
  209. {taskuary-0.3.2 → taskuary-0.3.2.2}/tests/test_whoami.py +0 -0
  210. {taskuary-0.3.2 → 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
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**—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**—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"
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,16 +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':
147
+ if (os.name == 'nt') if windows is None else windows:
142
148
  env.setdefault('USERPROFILE', home)
143
- drive, tail = os.path.splitdrive(home)
144
- if drive: env.setdefault('HOMEDRIVE', drive); env.setdefault('HOMEPATH', tail)
145
- 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'))
146
154
  return env
147
155
 
148
156
 
@@ -11,7 +11,9 @@ from loguru import logger
11
11
  from .store import task_ref
12
12
 
13
13
 
14
- def norm(p): return os.path.normcase(os.path.normpath(p or ''))
14
+ # normpath('') is '.', which is a real relative path and would make every checkout-less note
15
+ # look like it belonged to whatever directory the reader happens to be standing in
16
+ def norm(p): return os.path.normcase(os.path.normpath(p)) if str(p or '').strip() else ''
15
17
 
16
18
 
17
19
  def dirty(cwd) -> set:
@@ -66,6 +68,158 @@ def peers(store, cwd, exclude_tid=None) -> list:
66
68
  return out
67
69
 
68
70
 
71
+ # ── the wall: what agents tell EACH OTHER ────────────────────────────────────────────────
72
+ # peers() and dirty() are facts read off git and the run trace - true, and about as expressive
73
+ # as a security camera. They cannot say "the migration is half-applied, do not run the tests
74
+ # yet" or "this is ready, push it". Only the agent doing the work knows that, so it writes it
75
+ # down: one line per note, on the checkout, read by whoever comes next.
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
80
+ KIND_HINT = {'working': 'what it is doing right now', 'note': 'anything the next agent needs',
81
+ 'blocked': 'waiting on something or someone', 'ready': 'finished and safe to push',
82
+ 'done': 'pushed or closed out'}
83
+
84
+
85
+ def post(store, body: str, kind: str = 'note', agent: str = '', cwd: str = '', tid: int = None,
86
+ files: str = '') -> dict:
87
+ """One note on the wall. Everything but the words is optional: an agent that knows only what
88
+ it wants to say still gets to say it."""
89
+ body = ' '.join(str(body or '').split())[:1200]
90
+ if not body: raise ValueError('a note with no words is not a note')
91
+ kind = str(kind or 'note').lower().strip()
92
+ if kind not in KINDS + (SUMMARY,): raise ValueError(f"kind must be one of {', '.join(KINDS)}")
93
+ nid = store.add_note({'TaskId': tid, 'Agent': agent or 'agent', 'Cwd': norm(cwd), 'Kind': kind,
94
+ 'Body': body, 'Files': files or ''})
95
+ return dict(store.get_note(nid))
96
+
97
+
98
+ def wall(store, cwd: str = '', limit: int = 12) -> list:
99
+ return store.notes(norm(cwd) if cwd else None, limit)
100
+
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
+
188
+ def _ago(stamp) -> str:
189
+ from datetime import datetime
190
+ try: mins = (datetime.now() - datetime.fromisoformat(str(stamp)[:19].replace(' ', 'T'))).total_seconds() / 60
191
+ except (TypeError, ValueError): return ''
192
+ if mins < 1: return 'just now'
193
+ if mins < 60: return f'{int(mins)}m ago'
194
+ if mins < 48 * 60: return f'{int(mins // 60)}h ago'
195
+ return f'{int(mins // 1440)}d ago'
196
+
197
+
198
+ HOW_TO_POST = ('Post your own with `taskuary --note "..."` (--kind working|note|blocked|ready|done): '
199
+ 'when you start, when you find something the next agent would waste an hour on, and '
200
+ '`--kind ready` before you push.')
201
+
202
+
203
+ def wall_text(store, cwd: str, limit: int = 8) -> str:
204
+ """The wall, for the seed prompt: a POINTER, not a transcript.
205
+
206
+ A seed is typed into a TUI on one line, and the whole document does not belong there - the
207
+ newest note plus the command that shows the rest is what makes an agent go and read it.
208
+ CODER.md carries the full etiquette; this is the nudge that says there is something to read
209
+ RIGHT NOW. Nothing at all when the wall is empty: a paragraph saying "no notes" is tokens
210
+ spent to say nothing."""
211
+ rows = wall(store, cwd, limit)
212
+ if not rows: return ''
213
+ top = rows[0]
214
+ said = (f'THE WALL: {len(rows)} note(s) the agents before you left for this checkout - read them '
215
+ f'before you touch anything: `taskuary --board`. Newest, {top["Agent"]} '
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]
221
+
222
+
69
223
  def briefing(store, cwd, exclude_tid=None) -> str:
70
224
  """The OTHER AGENTS paragraph of a new agent's prompt. Facts only - task, agent, the files
71
225
  it has modified so far - plus the one standing rule: whoever got there first has control."""
@@ -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,
@@ -43,7 +43,41 @@ def main():
43
43
  help='triage dataset: build (labelled cases from your verdicts -> ~/.taskuary/eval), '
44
44
  'evaluate (score the configured AI over them), ablate (score with and without memory), '
45
45
  'share (anonymised copy for tests/data)')
46
+ # THE WALL (blackboard.py). An agent working in a terminal has a shell and no API token, so
47
+ # this is how it talks to the next agent: two flags, no arguments it has to be told - the
48
+ # session puts its own name, task and checkout in the environment.
49
+ ap.add_argument('--board', action='store_true',
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')
53
+ ap.add_argument('--note', metavar='TEXT',
54
+ help='leave a line on the wall for the next agent (see --kind)')
55
+ ap.add_argument('--kind', default='note', metavar='KIND',
56
+ help='working | note | blocked | ready | done - "ready" is how the next agent '
57
+ 'learns the tree is safe to build on')
46
58
  args = ap.parse_args()
59
+ if args.board or args.note:
60
+ import os, sys
61
+ from . import blackboard as bb
62
+ from .store import SQLiteStore, task_ref
63
+ try: sys.stdout.reconfigure(encoding='utf-8', errors='replace')
64
+ except (AttributeError, OSError): pass
65
+ store = SQLiteStore(config.db_path())
66
+ cwd = os.environ.get('TASKUARY_CWD') or os.getcwd()
67
+ who = os.environ.get('TASKUARY_AGENT') or 'agent'
68
+ tid = os.environ.get('TASKUARY_TASK')
69
+ if args.note:
70
+ try: n = bb.post(store, args.note, args.kind, who, cwd, int(tid) if str(tid).isdigit() else None)
71
+ except ValueError as e: print(f'not posted: {e}'); return
72
+ print(f"posted to the wall as {n['Agent']} [{n['Kind']}]")
73
+ return
74
+ rows = store.notes(bb.norm(cwd), 40 if args.all else 20, rolled=args.all)
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)')
77
+ for r in reversed(rows):
78
+ ref = f" {task_ref(r['TaskId'])}" if r.get('TaskId') else ''
79
+ print(f" [{r['Kind']}] {r['Agent']}{ref} {bb._ago(r['CreatedAt'])}: {r['Body']}")
80
+ return
47
81
  if args.evalset:
48
82
  import sys
49
83
  from . import evalset
@@ -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)