taskuary 0.3.1__tar.gz → 0.3.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/PKG-INFO +165 -0
  2. taskuary-0.3.2/README.md +114 -0
  3. {taskuary-0.3.1 → taskuary-0.3.2}/pyproject.toml +3 -1
  4. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/__init__.py +1 -1
  5. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/agents.py +198 -10
  6. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/aisetup.py +3 -2
  7. taskuary-0.3.2/taskuary/assistant.py +850 -0
  8. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/blackboard.py +2 -2
  9. taskuary-0.3.2/taskuary/browserview.py +155 -0
  10. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/calendar.py +116 -12
  11. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/categories.py +1 -0
  12. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/channels.py +56 -9
  13. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/cli.py +1 -1
  14. taskuary-0.3.2/taskuary/climodels.py +57 -0
  15. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/clis.py +43 -4
  16. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/coder.py +38 -3
  17. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/compose.py +143 -38
  18. taskuary-0.3.2/taskuary/context.py +111 -0
  19. taskuary-0.3.2/taskuary/counsel.py +97 -0
  20. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/devtools.py +4 -2
  21. taskuary-0.3.2/taskuary/digest.py +286 -0
  22. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/docsync.py +13 -5
  23. taskuary-0.3.2/taskuary/general.py +417 -0
  24. taskuary-0.3.2/taskuary/handraise.py +59 -0
  25. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/histgen.py +91 -7
  26. taskuary-0.3.2/taskuary/hooks.py +83 -0
  27. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/imessage.py +3 -2
  28. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/ingest.py +52 -23
  29. taskuary-0.3.2/taskuary/knowledge.py +317 -0
  30. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/llm.py +16 -8
  31. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/messengers.py +103 -19
  32. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/outbound.py +96 -17
  33. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/phone.py +50 -12
  34. taskuary-0.3.2/taskuary/problems.py +29 -0
  35. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/promptmap.py +3 -1
  36. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/rank.py +1 -1
  37. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/reports.py +326 -37
  38. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/responder.py +42 -9
  39. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/scopes.py +8 -0
  40. taskuary-0.3.2/taskuary/semantic.py +370 -0
  41. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/server.py +817 -79
  42. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/setup.py +32 -9
  43. taskuary-0.3.2/taskuary/sharepoint.py +136 -0
  44. taskuary-0.3.2/taskuary/sheets.py +101 -0
  45. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/store.py +380 -23
  46. taskuary-0.3.2/taskuary/templates/counsel.md +29 -0
  47. taskuary-0.3.2/taskuary/templates/digest.md +6 -0
  48. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/templates/triage.md +15 -5
  49. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/terminal.py +135 -36
  50. taskuary-0.3.2/taskuary/testing.py +376 -0
  51. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/triage.py +57 -23
  52. taskuary-0.3.2/taskuary/voice.py +262 -0
  53. taskuary-0.3.2/taskuary/wabridge.py +145 -0
  54. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/waitroom.py +24 -5
  55. taskuary-0.3.2/taskuary/web/assets/GeneralWorkspace-D46ydeAJ.css +1 -0
  56. taskuary-0.3.2/taskuary/web/assets/GeneralWorkspace-Dn17SMoR.js +10 -0
  57. taskuary-0.3.2/taskuary/web/assets/index-zLivezbi.js +461 -0
  58. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/web/index.html +1 -1
  59. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/whatsapp/bridge.mjs +28 -6
  60. taskuary-0.3.2/taskuary/whoami.py +156 -0
  61. taskuary-0.3.2/taskuary/witness.py +200 -0
  62. taskuary-0.3.2/taskuary.egg-info/PKG-INFO +165 -0
  63. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary.egg-info/SOURCES.txt +54 -2
  64. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary.egg-info/requires.txt +4 -0
  65. taskuary-0.3.2/tests/test_agent_environment.py +111 -0
  66. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_agent_source.py +14 -0
  67. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_answers.py +37 -9
  68. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_api.py +36 -17
  69. taskuary-0.3.2/tests/test_assistant.py +568 -0
  70. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_blackboard.py +3 -1
  71. taskuary-0.3.2/tests/test_browserview.py +159 -0
  72. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_calendar.py +2 -2
  73. taskuary-0.3.2/tests/test_calendar_today.py +77 -0
  74. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_categories.py +9 -9
  75. taskuary-0.3.2/tests/test_climodels.py +48 -0
  76. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_clis.py +19 -0
  77. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_compose.py +2 -1
  78. taskuary-0.3.2/tests/test_compose_sources.py +178 -0
  79. taskuary-0.3.2/tests/test_context.py +87 -0
  80. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_core.py +67 -18
  81. taskuary-0.3.2/tests/test_counsel.py +92 -0
  82. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_devtools.py +8 -8
  83. taskuary-0.3.2/tests/test_digest_brief.py +115 -0
  84. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_docs_and_llm.py +1 -1
  85. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_docs_flow.py +1 -1
  86. taskuary-0.3.2/tests/test_duplicate_connectors.py +77 -0
  87. taskuary-0.3.2/tests/test_factory.py +226 -0
  88. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_feed_working.py +18 -11
  89. taskuary-0.3.2/tests/test_general.py +195 -0
  90. taskuary-0.3.2/tests/test_handraise.py +48 -0
  91. taskuary-0.3.2/tests/test_histgen.py +249 -0
  92. taskuary-0.3.2/tests/test_kind_dispatch.py +196 -0
  93. taskuary-0.3.2/tests/test_knowledge.py +205 -0
  94. taskuary-0.3.2/tests/test_mail_folders.py +83 -0
  95. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_messengers.py +40 -1
  96. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_msauth.py +4 -4
  97. taskuary-0.3.2/tests/test_noop_ending.py +97 -0
  98. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_not_coding.py +56 -19
  99. taskuary-0.3.2/tests/test_problems.py +32 -0
  100. taskuary-0.3.2/tests/test_pypi_downloads.py +126 -0
  101. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_reply_voice.py +2 -2
  102. taskuary-0.3.2/tests/test_report_alerts.py +87 -0
  103. taskuary-0.3.2/tests/test_report_triage.py +58 -0
  104. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_reviews.py +3 -5
  105. taskuary-0.3.2/tests/test_seeded_reports.py +103 -0
  106. taskuary-0.3.2/tests/test_semantic.py +311 -0
  107. taskuary-0.3.2/tests/test_send_targets.py +124 -0
  108. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_setup.py +42 -1
  109. taskuary-0.3.2/tests/test_sheets_sharepoint.py +120 -0
  110. taskuary-0.3.2/tests/test_store_app_cli.py +100 -0
  111. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_store_hotpaths.py +47 -66
  112. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_terminal.py +94 -1
  113. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_urgent_and_handoff.py +1 -1
  114. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_verdict_paths.py +2 -2
  115. taskuary-0.3.2/tests/test_voice.py +201 -0
  116. taskuary-0.3.2/tests/test_wabridge.py +103 -0
  117. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_waitroom.py +7 -1
  118. taskuary-0.3.2/tests/test_web_entrypoint.py +31 -0
  119. taskuary-0.3.2/tests/test_whoami.py +103 -0
  120. taskuary-0.3.2/tests/test_witness.py +106 -0
  121. taskuary-0.3.1/PKG-INFO +0 -526
  122. taskuary-0.3.1/README.md +0 -478
  123. taskuary-0.3.1/taskuary/digest.py +0 -163
  124. taskuary-0.3.1/taskuary/templates/digest.md +0 -5
  125. taskuary-0.3.1/taskuary/web/assets/index-fSY_EAQs.js +0 -441
  126. taskuary-0.3.1/taskuary.egg-info/PKG-INFO +0 -526
  127. taskuary-0.3.1/tests/test_histgen.py +0 -141
  128. {taskuary-0.3.1 → taskuary-0.3.2}/LICENSE +0 -0
  129. {taskuary-0.3.1 → taskuary-0.3.2}/setup.cfg +0 -0
  130. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/artifacts.py +0 -0
  131. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/aws.py +0 -0
  132. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/azure.py +0 -0
  133. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/ci.py +0 -0
  134. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/config.py +0 -0
  135. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/db.py +0 -0
  136. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/desktop.py +0 -0
  137. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/evalset.py +0 -0
  138. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/github.py +0 -0
  139. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/imapmail.py +0 -0
  140. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/intacct.py +0 -0
  141. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/learn.py +0 -0
  142. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/learnedgraph.py +0 -0
  143. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/logs.py +0 -0
  144. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/mcp.py +0 -0
  145. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/msauth.py +0 -0
  146. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/mssql.py +0 -0
  147. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/pm.py +0 -0
  148. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/policy.py +0 -0
  149. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/proof.py +0 -0
  150. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/proposals.py +0 -0
  151. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/research.py +0 -0
  152. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/reshape.py +0 -0
  153. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/routing.py +0 -0
  154. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/senders.py +0 -0
  155. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/templates/coder.md +0 -0
  156. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/templates/learned.md +0 -0
  157. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/templates/soul.md +0 -0
  158. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/templates/style.md +0 -0
  159. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/toil.py +0 -0
  160. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/verdicts.py +0 -0
  161. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/web/assets/index-6GBZ9nXN.css +0 -0
  162. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/web/favicon.ico +0 -0
  163. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/web/favicon.png +0 -0
  164. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/webguard.py +0 -0
  165. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary/whatsapp/package.json +0 -0
  166. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary.egg-info/dependency_links.txt +0 -0
  167. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary.egg-info/entry_points.txt +0 -0
  168. {taskuary-0.3.1 → taskuary-0.3.2}/taskuary.egg-info/top_level.txt +0 -0
  169. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_aisetup.py +0 -0
  170. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_async_triage.py +0 -0
  171. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_audit_chain.py +0 -0
  172. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_aws_region.py +0 -0
  173. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_cc_triage.py +0 -0
  174. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_cloud.py +0 -0
  175. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_cloud_sources.py +0 -0
  176. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_config.py +0 -0
  177. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_cwlogs_paging.py +0 -0
  178. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_desktop.py +0 -0
  179. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_diff_review.py +0 -0
  180. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_github_auto.py +0 -0
  181. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_harvest.py +0 -0
  182. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_imapmail.py +0 -0
  183. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_imessage.py +0 -0
  184. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_intacct.py +0 -0
  185. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_learnedgraph.py +0 -0
  186. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_local_file.py +0 -0
  187. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_mark_read.py +0 -0
  188. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_mcp.py +0 -0
  189. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_mssql.py +0 -0
  190. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_note_retrieval.py +0 -0
  191. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_outbound_reports.py +0 -0
  192. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_pm.py +0 -0
  193. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_poll_overlap.py +0 -0
  194. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_poll_sources.py +0 -0
  195. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_pr_comments.py +0 -0
  196. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_promptmap_and_catalog.py +0 -0
  197. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_proof_ci_proposals.py +0 -0
  198. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_rank.py +0 -0
  199. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_reply_channels.py +0 -0
  200. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_research.py +0 -0
  201. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_scopes.py +0 -0
  202. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_someone_else_replied.py +0 -0
  203. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_sync_clock.py +0 -0
  204. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_task_identity.py +0 -0
  205. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_toil.py +0 -0
  206. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_triage_dataset.py +0 -0
  207. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_verdict_sticks.py +0 -0
  208. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_vision.py +0 -0
  209. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_watermark.py +0 -0
  210. {taskuary-0.3.1 → taskuary-0.3.2}/tests/test_webguard.py +0 -0
@@ -0,0 +1,165 @@
1
+ Metadata-Version: 2.4
2
+ Name: taskuary
3
+ Version: 0.3.2
4
+ Summary: Automate your job - the local-first operating system for your work: AI triage in, coding agents out.
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/ldbumble/taskuary
7
+ Project-URL: Repository, https://github.com/ldbumble/taskuary
8
+ Project-URL: Issues, https://github.com/ldbumble/taskuary/issues
9
+ Project-URL: Changelog, https://github.com/ldbumble/taskuary/releases
10
+ Keywords: ai,agents,automation,claude,coding-agent,email,local-first,task-management
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Office/Business :: Scheduling
19
+ Classifier: Topic :: Software Development :: Build Tools
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: fastapi>=0.100
24
+ Requires-Dist: uvicorn>=0.23
25
+ Requires-Dist: pydantic>=2
26
+ Requires-Dist: requests>=2.28
27
+ Requires-Dist: loguru>=0.7
28
+ Requires-Dist: segno>=1.6
29
+ Requires-Dist: anthropic>=0.69
30
+ Requires-Dist: tomli>=2; python_version < "3.11"
31
+ Requires-Dist: websockets>=12
32
+ Requires-Dist: pywinpty>=2; sys_platform == "win32"
33
+ Requires-Dist: pyte>=0.8
34
+ Provides-Extra: mssql
35
+ Requires-Dist: pyodbc>=4; extra == "mssql"
36
+ Provides-Extra: db
37
+ Requires-Dist: sqlalchemy>=2; extra == "db"
38
+ Provides-Extra: aws
39
+ Requires-Dist: boto3>=1.28; extra == "aws"
40
+ Provides-Extra: voice
41
+ Requires-Dist: faster-whisper>=1.0; extra == "voice"
42
+ Provides-Extra: desktop
43
+ Requires-Dist: pywebview>=4; extra == "desktop"
44
+ Provides-Extra: build
45
+ Requires-Dist: pyinstaller>=6; extra == "build"
46
+ Provides-Extra: dev
47
+ Requires-Dist: pytest>=7; extra == "dev"
48
+ Requires-Dist: httpx>=0.24; extra == "dev"
49
+ Requires-Dist: openpyxl>=3; extra == "dev"
50
+ Dynamic: license-file
51
+
52
+ # Taskuary
53
+
54
+ [![CI](https://github.com/ldbumble/taskuary/actions/workflows/ci.yml/badge.svg)](https://github.com/ldbumble/taskuary/actions/workflows/ci.yml)
55
+ [![PyPI](https://img.shields.io/pypi/v/taskuary.svg)](https://pypi.org/project/taskuary/)
56
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-3776ab.svg)](https://github.com/ldbumble/taskuary)
57
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
58
+
59
+ ## Your inbox, staffed by AI agents.
60
+
61
+ Taskuary brings your mail, chats, issues, and scheduled reports onto one local timeline.
62
+ AI triage decides what is real work, your coding CLI works the tasks in your own repos,
63
+ and replies wait for your approval. Nothing sends or ships without you.
64
+
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
+
67
+ Taskuary is early—currently **v0.3.2**—and moving fast. The funnel, review queue,
68
+ agent sessions, and reports pipeline are in daily use; breaking changes are still possible
69
+ before 1.0.
70
+
71
+ ## One timeline for incoming work
72
+
73
+ Outlook, Gmail, Teams, Slack, Telegram, WhatsApp, GitHub, Jira, alerts, and reports all
74
+ arrive on the same day-grouped rail. Each row says what it is and whether it needs you.
75
+ Open one to see the full message, its attachments, why triage ruled that way, the drafted
76
+ reply, and every available next step.
77
+
78
+ ![The Timeline with mail, chats, reports, active tasks, and an Assistant post on one rail.](https://raw.githubusercontent.com/ldbumble/taskuary/master/docs/screenshot-timeline-crop.png)
79
+
80
+ Coding work can go straight to Claude Code, Codex, Gemini, Cursor, Copilot, or any CLI that
81
+ accepts a prompt on stdin. You watch the live terminal, answer questions, review the diff,
82
+ and approve what happens next. Research, marketing, and other general work belong in a
83
+ conversational workspace built with assistant-ui and your existing Taskuary AI connections.
84
+ Assistant and terminal are two views of the same session: model, history, queued instructions,
85
+ image attachments, and the side-by-side browser stay put when you switch. The same workspace
86
+ also appears on the Wall.
87
+
88
+ ## An assistant that notices what falls between tasks
89
+
90
+ The Assistant periodically reads what the hub can see and posts only when it has something
91
+ useful to say: a reply that went unanswered, a meeting that needs context, a task that went
92
+ quiet, or a pattern across the week's work. Every suggestion includes its evidence and can
93
+ be made into a task, dismissed, snoozed, or taught away with **Not this**.
94
+
95
+ ![An Assistant post showing two evidence-backed suggestions and what it reviewed.](https://raw.githubusercontent.com/ldbumble/taskuary/master/docs/screenshot-assistant.png)
96
+
97
+ It watches the systems you run on, not only the hub. Point it at a Sage Intacct query, a
98
+ database, a file, a REST or MCP tool, a cloud log group, or an agent skill—no saved report has
99
+ to stand behind any of them—and it reads each one silently on every check. That step would
100
+ otherwise ask you to know an object name and a list of field ids, so you can describe what it
101
+ should keep an eye on instead and the AI writes the source cards: it may only choose systems you
102
+ have actually connected, it reads the real schema before writing a query, and it asks rather
103
+ than guessing a filter. The same help sits on every source card in the report builder.
104
+
105
+ Its voice, schedule, model, and thresholds are yours to change. It leaves a note for its next
106
+ check, does not repeat itself, and stays silent when there is nothing worth interrupting you
107
+ for.
108
+
109
+ ## Install
110
+
111
+ ### Windows app
112
+
113
+ Download the latest single-file
114
+ [Taskuary.exe](https://github.com/ldbumble/taskuary/releases/latest/download/Taskuary.exe)
115
+ and open it. No Python or installer is required.
116
+
117
+ ### Python
118
+
119
+ Python 3.10 or newer works on Windows, macOS, and Linux:
120
+
121
+ ```bash
122
+ pip install taskuary
123
+ taskuary
124
+ ```
125
+
126
+ Taskuary opens at [http://127.0.0.1:7787](http://127.0.0.1:7787). For a native desktop
127
+ window instead, install `pip install "taskuary[desktop]"` and run `taskuary-desktop`.
128
+
129
+ ### Docker
130
+
131
+ ```bash
132
+ git clone https://github.com/ldbumble/taskuary
133
+ cd taskuary
134
+ docker compose up
135
+ ```
136
+
137
+ Then open [http://127.0.0.1:7787](http://127.0.0.1:7787). Docker runs the web app;
138
+ coding CLIs and the optional WhatsApp bridge remain on the host.
139
+
140
+ On first run, connect an AI provider or local Ollama model, add at least one inbound
141
+ channel, then choose the coding CLI that should receive tasks. The setup wizards test each
142
+ connection before it goes live.
143
+
144
+ ## Installs
145
+
146
+ ![Daily installs of taskuary from PyPI, mirror traffic excluded](https://raw.githubusercontent.com/ldbumble/taskuary/master/docs/downloads.svg)
147
+
148
+ Redrawn every morning by [`downloads.yml`](.github/workflows/downloads.yml) from PyPI's own
149
+ numbers, with mirror traffic excluded — a full PyPI mirror pulls every release, so counting it
150
+ would let a handful of real users read as hundreds. What it does **not** claim is that CI
151
+ installs have been removed: the field that would separate `pip install` in a build runner from
152
+ one on somebody's laptop is in PyPI's BigQuery dataset, not in the public API. The daily series
153
+ is [docs/downloads.csv](docs/downloads.csv).
154
+
155
+ ## Documentation
156
+
157
+ - [Getting started](docs/getting-started.md)—installation, first-run setup, Docker, and data
158
+ - [Product guide](docs/product-guide.md)—the workflow, learning loop, agents, and operator documents
159
+ - [Integrations](docs/integrations.md)—channels, AI providers, work systems, and report sources
160
+ - [Reports and the Assistant](docs/reports-and-assistant.md)—the report pipeline, letting the AI write the source cards, and what the Assistant watches
161
+ - [Status and roadmap](docs/roadmap.md)—what works today and what is next
162
+ - [Contributing](CONTRIBUTING.md)—development setup and contribution guide
163
+
164
+ Taskuary is free and open source under the [MIT License](LICENSE). Issues and pull requests
165
+ are welcome; security reports belong in [SECURITY.md](SECURITY.md).
@@ -0,0 +1,114 @@
1
+ # Taskuary
2
+
3
+ [![CI](https://github.com/ldbumble/taskuary/actions/workflows/ci.yml/badge.svg)](https://github.com/ldbumble/taskuary/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/taskuary.svg)](https://pypi.org/project/taskuary/)
5
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-3776ab.svg)](https://github.com/ldbumble/taskuary)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+
8
+ ## Your inbox, staffed by AI agents.
9
+
10
+ Taskuary brings your mail, chats, issues, and scheduled reports onto one local timeline.
11
+ AI triage decides what is real work, your coding CLI works the tasks in your own repos,
12
+ and replies wait for your approval. Nothing sends or ships without you.
13
+
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
+
16
+ Taskuary is early—currently **v0.3.2**—and moving fast. The funnel, review queue,
17
+ agent sessions, and reports pipeline are in daily use; breaking changes are still possible
18
+ before 1.0.
19
+
20
+ ## One timeline for incoming work
21
+
22
+ Outlook, Gmail, Teams, Slack, Telegram, WhatsApp, GitHub, Jira, alerts, and reports all
23
+ arrive on the same day-grouped rail. Each row says what it is and whether it needs you.
24
+ Open one to see the full message, its attachments, why triage ruled that way, the drafted
25
+ reply, and every available next step.
26
+
27
+ ![The Timeline with mail, chats, reports, active tasks, and an Assistant post on one rail.](https://raw.githubusercontent.com/ldbumble/taskuary/master/docs/screenshot-timeline-crop.png)
28
+
29
+ Coding work can go straight to Claude Code, Codex, Gemini, Cursor, Copilot, or any CLI that
30
+ accepts a prompt on stdin. You watch the live terminal, answer questions, review the diff,
31
+ and approve what happens next. Research, marketing, and other general work belong in a
32
+ conversational workspace built with assistant-ui and your existing Taskuary AI connections.
33
+ Assistant and terminal are two views of the same session: model, history, queued instructions,
34
+ image attachments, and the side-by-side browser stay put when you switch. The same workspace
35
+ also appears on the Wall.
36
+
37
+ ## An assistant that notices what falls between tasks
38
+
39
+ The Assistant periodically reads what the hub can see and posts only when it has something
40
+ useful to say: a reply that went unanswered, a meeting that needs context, a task that went
41
+ quiet, or a pattern across the week's work. Every suggestion includes its evidence and can
42
+ be made into a task, dismissed, snoozed, or taught away with **Not this**.
43
+
44
+ ![An Assistant post showing two evidence-backed suggestions and what it reviewed.](https://raw.githubusercontent.com/ldbumble/taskuary/master/docs/screenshot-assistant.png)
45
+
46
+ It watches the systems you run on, not only the hub. Point it at a Sage Intacct query, a
47
+ database, a file, a REST or MCP tool, a cloud log group, or an agent skill—no saved report has
48
+ to stand behind any of them—and it reads each one silently on every check. That step would
49
+ otherwise ask you to know an object name and a list of field ids, so you can describe what it
50
+ should keep an eye on instead and the AI writes the source cards: it may only choose systems you
51
+ have actually connected, it reads the real schema before writing a query, and it asks rather
52
+ than guessing a filter. The same help sits on every source card in the report builder.
53
+
54
+ Its voice, schedule, model, and thresholds are yours to change. It leaves a note for its next
55
+ check, does not repeat itself, and stays silent when there is nothing worth interrupting you
56
+ for.
57
+
58
+ ## Install
59
+
60
+ ### Windows app
61
+
62
+ Download the latest single-file
63
+ [Taskuary.exe](https://github.com/ldbumble/taskuary/releases/latest/download/Taskuary.exe)
64
+ and open it. No Python or installer is required.
65
+
66
+ ### Python
67
+
68
+ Python 3.10 or newer works on Windows, macOS, and Linux:
69
+
70
+ ```bash
71
+ pip install taskuary
72
+ taskuary
73
+ ```
74
+
75
+ Taskuary opens at [http://127.0.0.1:7787](http://127.0.0.1:7787). For a native desktop
76
+ window instead, install `pip install "taskuary[desktop]"` and run `taskuary-desktop`.
77
+
78
+ ### Docker
79
+
80
+ ```bash
81
+ git clone https://github.com/ldbumble/taskuary
82
+ cd taskuary
83
+ docker compose up
84
+ ```
85
+
86
+ Then open [http://127.0.0.1:7787](http://127.0.0.1:7787). Docker runs the web app;
87
+ coding CLIs and the optional WhatsApp bridge remain on the host.
88
+
89
+ On first run, connect an AI provider or local Ollama model, add at least one inbound
90
+ channel, then choose the coding CLI that should receive tasks. The setup wizards test each
91
+ connection before it goes live.
92
+
93
+ ## Installs
94
+
95
+ ![Daily installs of taskuary from PyPI, mirror traffic excluded](https://raw.githubusercontent.com/ldbumble/taskuary/master/docs/downloads.svg)
96
+
97
+ Redrawn every morning by [`downloads.yml`](.github/workflows/downloads.yml) from PyPI's own
98
+ numbers, with mirror traffic excluded — a full PyPI mirror pulls every release, so counting it
99
+ would let a handful of real users read as hundreds. What it does **not** claim is that CI
100
+ installs have been removed: the field that would separate `pip install` in a build runner from
101
+ one on somebody's laptop is in PyPI's BigQuery dataset, not in the public API. The daily series
102
+ is [docs/downloads.csv](docs/downloads.csv).
103
+
104
+ ## Documentation
105
+
106
+ - [Getting started](docs/getting-started.md)—installation, first-run setup, Docker, and data
107
+ - [Product guide](docs/product-guide.md)—the workflow, learning loop, agents, and operator documents
108
+ - [Integrations](docs/integrations.md)—channels, AI providers, work systems, and report sources
109
+ - [Reports and the Assistant](docs/reports-and-assistant.md)—the report pipeline, letting the AI write the source cards, and what the Assistant watches
110
+ - [Status and roadmap](docs/roadmap.md)—what works today and what is next
111
+ - [Contributing](CONTRIBUTING.md)—development setup and contribution guide
112
+
113
+ Taskuary is free and open source under the [MIT License](LICENSE). Issues and pull requests
114
+ are welcome; security reports belong in [SECURITY.md](SECURITY.md).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "taskuary"
7
- version = "0.3.1"
7
+ version = "0.3.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" }
@@ -27,6 +27,7 @@ dependencies = [
27
27
  "pydantic>=2",
28
28
  "requests>=2.28",
29
29
  "loguru>=0.7",
30
+ "segno>=1.6", # WhatsApp pairing QR drawn ON the card - pure Python, no Pillow
30
31
  "anthropic>=0.69",
31
32
  "tomli>=2; python_version<'3.11'",
32
33
  "websockets>=12", # uvicorn's ws transport - the terminal streams over it
@@ -38,6 +39,7 @@ dependencies = [
38
39
  mssql = ["pyodbc>=4"]
39
40
  db = ["sqlalchemy>=2"] # 'any database' URLs; add the engine driver too (psycopg2-binary, pymysql, ...)
40
41
  aws = ["boto3>=1.28"] # azure needs no extra: tokens ride plain requests
42
+ voice = ["faster-whisper>=1.0"] # Local Whisper: speech to text on this machine, no key, audio never leaves the box
41
43
  desktop = ["pywebview>=4"]
42
44
  build = ["pyinstaller>=6"]
43
45
  dev = ["pytest>=7", "httpx>=0.24", "openpyxl>=3"] # openpyxl READS xlsx in tests; the writer is ours
@@ -1,4 +1,4 @@
1
- """Taskuary - Automate your job: AI triage in, coding agents out, you in charge."""
1
+ """Taskuary - your work AI assistant: AI triage in, coding agents out, you in charge."""
2
2
  # ONE place holds the version and it is pyproject.toml, because that is the one the git tag has
3
3
  # to match (RELEASING.md). A second copy here said 0.2.0 through the whole of 0.2.1: the CLI
4
4
  # banner, the API and the header pill all reported it, and the header's own tooltip told the
@@ -6,6 +6,7 @@ run so code changes are first-class, every run traced + audited.
6
6
  """
7
7
  import json, os, re, shutil, subprocess, threading, time
8
8
  from datetime import datetime
9
+ from pathlib import Path
9
10
  from loguru import logger
10
11
 
11
12
  from .store import task_ref
@@ -75,26 +76,76 @@ def _shim_target(path: str) -> list:
75
76
  return []
76
77
 
77
78
 
79
+ def _programs_copy(base: str) -> str:
80
+ """The ordinary per-user install of a CLI that is ALSO published as a Store app - codex ships
81
+ both. which() returns whichever comes first on PATH, and on some machines that is a stub this
82
+ account may not execute at all."""
83
+ root = os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Programs')
84
+ want = base.lower()
85
+ for dirpath, dirs, files in os.walk(root): # a missing root simply yields nothing
86
+ for f in files:
87
+ if f.lower() == want: return os.path.join(dirpath, f)
88
+ if dirpath.count(os.sep) - root.count(os.sep) >= 3: dirs.clear() # vendor/app/bin is deep enough
89
+ return ''
90
+
91
+
78
92
  def _resolve_cmd(name: str) -> list:
79
93
  """Windows can't CreateProcess a bare 'claude': npm installs it as claude.cmd, which only
80
94
  PATH-resolves via which(). Reaching THROUGH the shim beats running it under cmd /c - see
81
95
  _shim_target for why that difference decides whether a prompt arrives whole."""
82
96
  path = shutil.which(name) or shutil.which(name, path=_fresh_path())
97
+ if not path and re.search(r'[\\/]', str(name)):
98
+ # A saved ABSOLUTE path that has moved. codex installs itself into
99
+ # ...\Codex\bin\<version hash>\codex.exe, so a profile pinned to one of those breaks on
100
+ # the next update and reads as "the CLI is gone". The NAME is the durable half: ask PATH
101
+ # again for it, which is what the owner would have done by hand.
102
+ base = re.split(r'[\\/]', str(name))[-1]
103
+ path = shutil.which(base) or shutil.which(base, path=_fresh_path())
104
+ if path: logger.info(f'{name} has moved; using {path} instead')
83
105
  if not path:
84
106
  raise FileNotFoundError(f"'{name}' not found on PATH - is the CLI installed?")
85
107
  if os.name == 'nt' and path.lower().endswith(('.cmd', '.bat')):
86
108
  return _shim_target(path) or ['cmd', '/c', path]
87
- if os.name == 'nt' and '\\windowsapps\\' in path.lower() and '\\microsoft\\windowsapps\\' not in path.lower():
109
+ if os.name == 'nt' and '\\windowsapps\\' in path.lower():
88
110
  # which() walked into C:\Program Files\WindowsApps\<package>\...\codex.EXE - a Store package
89
111
  # folder, which CreateProcess is refused ([WinError 5] Access is denied). The runnable
90
112
  # thing is the execution ALIAS in the user's own WindowsApps folder; fall back to the
91
113
  # shell, which resolves aliases the way a typed command does.
92
114
  import ntpath # a Windows path, split as one wherever this runs (CI is Linux and macOS)
93
- alias = os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Microsoft', 'WindowsApps', ntpath.basename(path))
115
+ base = ntpath.basename(path)
116
+ # An ordinary install beats either stub: the alias itself answers "Access is denied." when
117
+ # the package is not registered for this account, and cmd only forwards that refusal.
118
+ real = _programs_copy(base)
119
+ if real: return [real]
120
+ if '\\microsoft\\windowsapps\\' in path.lower(): return [path] # the alias, and nothing better
121
+ alias = os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Microsoft', 'WindowsApps', base)
94
122
  return [alias] if os.path.exists(alias) else ['cmd', '/c', name]
95
123
  return [path]
96
124
 
97
125
 
126
+ def child_env() -> dict:
127
+ """The environment a CLI is entitled to expect.
128
+
129
+ codex refuses to start without one: `Error finding codex home: Could not find home
130
+ directory` (an owner's machine, 2026-08-31), and it is not a codex bug - a Taskuary
131
+ launched from a service, a scheduled task, or a shortcut with a scrubbed environment does
132
+ not always pass USERPROFILE down, and the Rust `dirs` crate has nothing else to go on.
133
+ Python can answer the question, so it answers it instead of letting the CLI guess: HOME
134
+ and USERPROFILE where they are missing, and CODEX_HOME - the variable codex checks FIRST,
135
+ before it ever asks the OS - pointed at the same place its own installer would use.
136
+ """
137
+ env = dict(os.environ)
138
+ try: home = str(Path.home())
139
+ except (RuntimeError, OSError): return env # nothing better to say than nothing
140
+ env.setdefault('HOME', home)
141
+ if os.name == 'nt':
142
+ 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'))
146
+ return env
147
+
148
+
98
149
  def _fmt_input(inp) -> str:
99
150
  """The one field a human wants to see per tool call - command, path, pattern…"""
100
151
  if not isinstance(inp, dict): return str(inp)[:140]
@@ -146,16 +197,98 @@ def signed_out_msg(name: str, why: str) -> str:
146
197
  return f"{name} is signed out on this machine ({why.strip()[:160]}). Open a terminal, {how}, then come back here and try again."
147
198
 
148
199
 
149
- def run_cli(profile: dict, prompt: str, trace, resume: str = None):
200
+ # Windows refuses to START some installs rather than failing inside them, and says only
201
+ # "Access is denied." - which reads as an account or billing problem with the AI provider and is
202
+ # nothing of the kind: the CLI never ran. Reported on another owner's machine (2026-08-31) as
203
+ # `codex exit 1: Access is denied.` with no other output.
204
+ _DENIED = re.compile(r'access is denied|winerror 5|permission denied|operation not permitted', re.I)
205
+ _NO_HOME = re.compile(r'could not find home directory|finding codex home|HOME.{0,20}not set', re.I)
206
+
207
+
208
+ def no_home_msg(name: str) -> str:
209
+ return (f'{name} could not find a home directory to keep its own settings and sign-in in. '
210
+ 'Taskuary now hands every CLI a HOME, USERPROFILE and CODEX_HOME, so if this persists '
211
+ 'the account running Taskuary has no profile directory at all - which happens when it '
212
+ 'runs as a Windows service or a scheduled task under SYSTEM or a managed account. Run '
213
+ f'Taskuary as the same user who runs `{name}` in a terminal, or set CODEX_HOME '
214
+ 'explicitly for that account, then sign in once with `codex login`.')
215
+
216
+
217
+ def denied_msg(name: str, path: str, why: str) -> str:
218
+ where = f' ({path})' if path else ''
219
+ return (f'Windows would not start {name}{where}: "{str(why).strip()[:120]}". The CLI never ran, so '
220
+ f'this is not a sign-in or billing problem. Usual causes, in order: {name} came from the '
221
+ 'Microsoft Store and its app-execution alias does not work for this account (install the '
222
+ 'ordinary build instead, or reinstall it for this user); antivirus or an AppLocker policy is '
223
+ 'blocking the executable; the folder the agent works in is not readable by this account. '
224
+ f'`where {name}` shows which copy is being found - running that exact path by hand '
225
+ 'reproduces it in one line.')
226
+
227
+
228
+ def _cli_name(cmd: str) -> str:
229
+ """Executable name for either a bare command or a Windows/POSIX path."""
230
+ return re.split(r'[\\/]', str(cmd or ''))[-1].lower().rsplit('.', 1)[0]
231
+
232
+
233
+ def _codex_tool(item: dict):
234
+ """Turn a Codex JSONL item into the common visual tool name/input contract."""
235
+ typ = item.get('type')
236
+ if typ == 'command_execution': return 'shell', {'command': item.get('command') or ''}
237
+ if typ == 'file_change': return 'file change', {'changes': item.get('changes') or []}
238
+ if typ == 'mcp_tool_call':
239
+ return item.get('tool') or item.get('name') or 'MCP tool', item.get('arguments') or item.get('args') or {}
240
+ if typ == 'web_search': return 'web search', {'query': item.get('query') or ''}
241
+ return None
242
+
243
+
244
+ def runs_here(profile: dict) -> bool:
245
+ """Does this profile's command resolve to something we can start?"""
246
+ try: return bool(_resolve_cmd((profile or {}).get('cmd') or 'claude'))
247
+ except (FileNotFoundError, OSError): return False
248
+
249
+
250
+ def profiles(store) -> dict:
251
+ out = {}
252
+ for a in store.list_agents():
253
+ try: out[a['Name']] = json.loads(a.get('Config') or '{}')
254
+ except ValueError: out[a['Name']] = {}
255
+ return out
256
+
257
+
258
+ def default_agent(store) -> str:
259
+ """Which agent a task goes to when nobody picked one.
260
+
261
+ The owner's choice wins - unless its CLI is not on this machine, in which case one that IS
262
+ beats a name that can only fail. Taskuary ships `coder` = claude, so on a machine with only
263
+ codex installed every dispatch died on a CLI nobody had, and the Board showed it as the
264
+ agent's failure (an owner's machine, 2026-08-31)."""
265
+ want = str(store.get_settings().get('default_agent') or 'coder').strip()
266
+ profs = profiles(store)
267
+ if want not in profs or runs_here(profs[want]): return want
268
+ return next((n for n, prof in profs.items() if runs_here(prof)), want)
269
+
270
+
271
+ def run_cli(profile: dict, prompt: str, trace, resume: str = None, cancel=None):
150
272
  """One headless invocation of the configured CLI, output STREAMED line by line into
151
273
  the run trace so the Board shows the agent working live. claude's stream-json events
152
274
  render as readable tool/text lines; any other CLI's plain stdout streams as-is.
153
275
  Returns (result, session_id, diff)."""
154
276
  name = profile.get('cmd', 'claude')
155
- cmd = _resolve_cmd(name) + list(profile.get('args') or preset_args(name) or ['-p'])
277
+ args = list(profile.get('args') or preset_args(name) or ['-p'])
278
+ # Codex's normal exec output is human prose with no boundary between commands, searches,
279
+ # edits and the final answer. JSONL is an exec-only presentation flag, so add it here (not
280
+ # to the saved profile, which is also used to open the interactive terminal TUI).
281
+ is_codex = _cli_name(name) == 'codex' and bool(args) and args[0] in ('exec', 'e')
282
+ if is_codex and '--json' not in args: args.append('--json')
283
+ cmd = _resolve_cmd(name) + args
156
284
  # which model works it: profile default, or a per-run override from the UI. The flag
157
285
  # name is configurable because every CLI spells it differently (claude/codex: --model).
158
- if profile.get('model'): cmd += [profile.get('model_arg') or '--model', str(profile['model'])]
286
+ if profile.get('model'):
287
+ # 'gpt-5.4@high' spells a codex model and its reasoning level in one pick (climodels)
288
+ from .climodels import split_pick
289
+ m, eff = split_pick(profile['model'])
290
+ cmd += [profile.get('model_arg') or '--model', m]
291
+ if eff: cmd += ['-c', f'model_reasoning_effort={eff}']
159
292
  if resume and profile.get('resume_args'): cmd += list(profile['resume_args']) + [resume]
160
293
  cwd = profile.get('cwd')
161
294
  head0 = _git(cwd, 'rev-parse', 'HEAD')
@@ -163,22 +296,34 @@ def run_cli(profile: dict, prompt: str, trace, resume: str = None):
163
296
  trace('tool', 'cli', f'{name} cwd={cwd or os.getcwd()}' + (f' resume={resume}' if resume else ''))
164
297
  try:
165
298
  p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
166
- text=True, encoding='utf-8', errors='replace', cwd=cwd, shell=False)
299
+ text=True, encoding='utf-8', errors='replace', cwd=cwd, shell=False,
300
+ env=child_env())
167
301
  except PermissionError as e:
168
- # to the user this IS "not installed": whatever which() found, it cannot be run from here
169
- raise FileNotFoundError(f"'{name}' is not installed or not on PATH - install it, then try again.") from e
302
+ # which() found something that cannot be executed from here. "Not installed" sent people
303
+ # off to reinstall a CLI that was already there; the reason is in denied_msg.
304
+ raise FileNotFoundError(denied_msg(name, cmd[0] if cmd else '', e)) from e
170
305
  timed = threading.Event()
171
306
  killer = threading.Timer(profile.get('timeout', 1200), lambda: (timed.set(), p.kill()))
172
307
  killer.start()
308
+ # A browser Cancel closes the streaming response. Kill the CLI too; otherwise the UI says
309
+ # stopped while an invisible agent keeps using tools in the background.
310
+ if cancel is not None:
311
+ def _cancel():
312
+ cancel.wait()
313
+ if cancel.is_set() and p.poll() is None:
314
+ try: p.kill()
315
+ except Exception: pass
316
+ threading.Thread(target=_cancel, daemon=True).start()
173
317
  err_buf = []
174
- threading.Thread(target=lambda: err_buf.append(p.stderr.read()), daemon=True).start()
318
+ err_t = threading.Thread(target=lambda: err_buf.append(p.stderr.read()), daemon=True)
319
+ err_t.start()
175
320
  # stdin feed on its own thread: writing a big prompt while the child is already
176
321
  # emitting output can deadlock both pipes otherwise
177
322
  def _feed():
178
323
  try: p.stdin.write(prompt); p.stdin.close()
179
324
  except Exception: pass
180
325
  threading.Thread(target=_feed, daemon=True).start()
181
- raw, final = [], None
326
+ raw, final, streamed_out, streamed_sid, open_tools = [], None, '', None, set()
182
327
  try:
183
328
  for line in p.stdout:
184
329
  line = line.rstrip('\n')
@@ -188,17 +333,57 @@ def run_cli(profile: dict, prompt: str, trace, resume: str = None):
188
333
  except ValueError: trace('live', name, line[:400]); continue
189
334
  if isinstance(j, dict) and (j.get('type') == 'result' or ('result' in j and 'type' not in j)):
190
335
  final = j; continue
336
+ # Preserve the CLI's structured work for visual clients. The existing readable
337
+ # `live` line remains for Board traces and the terminal renderer.
338
+ if isinstance(j, dict) and j.get('type') == 'assistant':
339
+ for c in (j.get('message') or {}).get('content') or []:
340
+ if c.get('type') == 'tool_use':
341
+ trace('tool_call', c.get('name') or 'tool', {
342
+ 'tool_call_id': c.get('id') or f'tool-{len(raw)}', 'args': c.get('input') or {}})
343
+ elif c.get('type') == 'text' and str(c.get('text') or '').strip():
344
+ trace('progress', 'text', str(c['text']).strip())
345
+ elif isinstance(j, dict) and j.get('type') == 'user':
346
+ for c in (j.get('message') or {}).get('content') or []:
347
+ if isinstance(c, dict) and c.get('type') == 'tool_result':
348
+ trace('tool_result', c.get('tool_use_id') or 'tool', {
349
+ 'result': _result_text(c), 'is_error': bool(c.get('is_error'))})
350
+ # Codex `exec --json` speaks item lifecycle events instead of Claude content blocks.
351
+ # Normalize both into one stream so assistant-ui does not care which CLI is logged in.
352
+ if isinstance(j, dict) and j.get('type') == 'thread.started':
353
+ streamed_sid = j.get('thread_id') or streamed_sid
354
+ if isinstance(j, dict) and j.get('type') in ('item.started', 'item.updated', 'item.completed'):
355
+ item = j.get('item') or {}
356
+ iid = item.get('id') or f"item-{len(raw)}"
357
+ tool = _codex_tool(item)
358
+ if tool and iid not in open_tools:
359
+ trace('tool_call', tool[0], {'tool_call_id': iid, 'args': tool[1]})
360
+ open_tools.add(iid)
361
+ if tool and j.get('type') == 'item.completed':
362
+ result = item.get('aggregated_output') or item.get('output') or item.get('status') or ''
363
+ failed = item.get('status') == 'failed' or item.get('exit_code') not in (None, 0)
364
+ trace('tool_result', iid, {'result': str(result), 'is_error': failed})
365
+ if item.get('type') in ('agent_message', 'reasoning') and str(item.get('text') or '').strip():
366
+ text = str(item['text']).strip()
367
+ trace('progress', item.get('type'), text)
368
+ if item.get('type') == 'agent_message': streamed_out = text
191
369
  shown = _live_line(j) if isinstance(j, dict) else None
192
370
  if shown: trace('live', name, shown)
193
371
  p.wait()
194
372
  finally:
195
373
  killer.cancel()
374
+ err_t.join(5) # the exit code can land before the stderr reader has appended - 'boom' read as 'no output' on a fast CI box
196
375
  if p.returncode != 0:
376
+ if cancel is not None and cancel.is_set(): raise RuntimeError('cancelled')
197
377
  why = f'timed out after {profile.get("timeout", 1200)}s' if timed.is_set() else \
198
378
  ((err_buf[0] if err_buf else '') or '\n'.join(raw[-5:]) or 'no output')[:500]
199
379
  if _SIGNED_OUT.search(why): raise RuntimeError(signed_out_msg(name, why))
380
+ # a refusal to START, not a failed run: the CLI produced no output of its own and the
381
+ # only thing on stderr is the refusal
382
+ if _DENIED.search(why) and not raw: raise RuntimeError(denied_msg(name, cmd[0] if cmd else '', why))
383
+ if _NO_HOME.search(why): raise RuntimeError(no_home_msg(_cli_name(name) or name))
200
384
  raise RuntimeError(f'{name} exit {p.returncode}: {why}')
201
385
  if final is not None: out, sid = str(final.get('result') or '').strip(), final.get('session_id')
386
+ elif streamed_out: out, sid = streamed_out, streamed_sid
202
387
  else: out, sid = parse_cli_json('\n'.join(raw))
203
388
  trace('output', name, out[-1000:])
204
389
  diff = ''
@@ -222,6 +407,9 @@ def task_context(store, task_id: int) -> str:
222
407
  lines += ['', 'Thread:'] + [f"- {c.get('Actor')}: {str(c.get('Body'))[:300]}" for c in d['comments']]
223
408
  mem = memory_block(store, d['messages'])
224
409
  if mem: lines += ['', mem]
410
+ from . import knowledge
411
+ kb = knowledge.block(store, ' '.join(f"{m.get('Subject') or ''} {m.get('BodyText') or ''}" for m in d['messages'])[:4000])
412
+ if kb: lines += [kb.strip()]
225
413
  return '\n'.join(lines)
226
414
 
227
415
 
@@ -26,7 +26,7 @@ def live_for(store, cid: int):
26
26
  """The live setup session for this card, if one is open - the button reattaches instead of
27
27
  starting a second agent on the same form."""
28
28
  from . import terminal as term
29
- for t in term.SESSIONS.values():
29
+ for t in list(term.SESSIONS.values()):
30
30
  if not (t.alive and t.task_id): continue
31
31
  tk = store.get_task(t.task_id) or {}
32
32
  if tk.get('Kind') == KIND and tag(cid) in str(tk.get('Tags') or ''): return {**t.info(), 'taskId': t.task_id}
@@ -85,7 +85,8 @@ def start(store, server: dict, cid: int, guide: list, fields: list = None, secre
85
85
  if not c: raise ValueError('connector not found')
86
86
  live = live_for(store, cid)
87
87
  if live: return {**live, 'existing': True}
88
- agent = (agent or store.get_settings().get('default_agent') or 'coder').strip()
88
+ from . import agents as hub_agents
89
+ agent = (agent or hub_agents.default_agent(store)).strip()
89
90
  if not store.get_agent(agent): raise ValueError(f'no CLI agent named {agent!r} - add one under Connectors > AI CLI agents first')
90
91
  tid = store.create_task({'Title': f"Set up {c.get('Name') or c.get('Type')}", 'Kind': KIND, 'Status': 'in_progress', 'Tags': tag(cid),
91
92
  'Summary': f"{agent} walks the owner through the {c.get('Type')} guide in a live session on the card and saves what they give it."}, actor)