taskuary 0.3.2.5__tar.gz → 0.3.2.7__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {taskuary-0.3.2.5/taskuary.egg-info → taskuary-0.3.2.7}/PKG-INFO +2 -2
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/README.md +1 -1
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/pyproject.toml +1 -1
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/agents.py +4 -3
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/browserview.py +3 -2
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/channels.py +11 -1
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/guard.py +1 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/histgen.py +46 -9
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/imapmail.py +103 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/intacct.py +69 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/mcp.py +2 -1
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/proof.py +2 -1
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/reports.py +28 -2
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/scopes.py +4 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/server.py +22 -6
- taskuary-0.3.2.7/taskuary/spawn.py +34 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/store.py +5 -1
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/terminal.py +26 -0
- taskuary-0.3.2.7/taskuary/update.py +179 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/wabridge.py +4 -3
- taskuary-0.3.2.5/taskuary/web/assets/GeneralWorkspace-D46ydeAJ.css → taskuary-0.3.2.7/taskuary/web/assets/GeneralWorkspace-BpkuvgFU.css +1 -1
- taskuary-0.3.2.5/taskuary/web/assets/GeneralWorkspace-vtyO6pPt.js → taskuary-0.3.2.7/taskuary/web/assets/GeneralWorkspace-Cmu6KGGe.js +2 -2
- taskuary-0.3.2.7/taskuary/web/assets/SocialView-kma4W7JV.js +1 -0
- taskuary-0.3.2.7/taskuary/web/assets/index-rF2UKXaH.js +1014 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/web/index.html +1 -1
- {taskuary-0.3.2.5 → taskuary-0.3.2.7/taskuary.egg-info}/PKG-INFO +2 -2
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary.egg-info/SOURCES.txt +8 -4
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_browser_session.py +4 -4
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_climodels.py +1 -1
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_core.py +4 -4
- taskuary-0.3.2.7/tests/test_imap_history.py +245 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_intacct.py +91 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_terminal.py +45 -0
- taskuary-0.3.2.7/tests/test_update.py +185 -0
- taskuary-0.3.2.5/taskuary/web/assets/SocialView-T_GUmkia.js +0 -1
- taskuary-0.3.2.5/taskuary/web/assets/index-BGJoDxsJ.js +0 -1014
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/LICENSE +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/setup.cfg +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/__init__.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/aisetup.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/artifacts.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/assistant.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/aws.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/azure.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/blackboard.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/calendar.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/categories.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/ci.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/cli.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/climodels.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/clis.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/coder.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/compose.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/config.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/context.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/counsel.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/db.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/demo.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/desktop.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/devtools.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/digest.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/docsync.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/evalset.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/general.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/github.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/handbook.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/handraise.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/hooks.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/imessage.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/ingest.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/interview.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/knowledge.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/learn.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/learnedgraph.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/llm.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/logs.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/messengers.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/msauth.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/mssql.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/outbound.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/outbox.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/ownwork.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/phone.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/playbooks.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/pm.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/policy.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/problems.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/promptmap.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/proposals.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/quickbooks.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/rank.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/research.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/reshape.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/responder.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/routing.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/selfclose.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/semantic.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/senders.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/setup.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/sharepoint.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/sheets.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/teller.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/templates/coder.md +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/templates/counsel.md +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/templates/digest.md +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/templates/learned.md +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/templates/playbook.md +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/templates/soul.md +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/templates/style.md +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/templates/triage.md +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/testing.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/toil.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/triage.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/verdicts.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/voice.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/waitroom.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/web/assets/index-6GBZ9nXN.css +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/web/favicon.ico +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/web/favicon.png +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/webguard.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/whatsapp/bridge.mjs +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/whatsapp/package.json +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/whoami.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary/witness.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary.egg-info/dependency_links.txt +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary.egg-info/entry_points.txt +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary.egg-info/requires.txt +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/taskuary.egg-info/top_level.txt +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_agent_environment.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_agent_source.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_agent_wall.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_aisetup.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_answered_yourself.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_answers.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_api.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_assistant.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_assistant_verdicts.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_async_triage.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_audit_chain.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_aws_region.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_blackboard.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_browserview.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_calendar.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_calendar_today.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_categories.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_cc_triage.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_chat_doors.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_chat_queue.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_clis.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_cloud.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_cloud_sources.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_compose.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_compose_sources.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_config.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_context.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_counsel.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_cwlogs_paging.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_demo.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_desktop.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_devtools.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_diff_review.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_digest_brief.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_docs_and_llm.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_docs_flow.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_duplicate_connectors.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_factory.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_feed_working.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_finished_chat_stays_finished.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_general.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_github_auto.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_guard.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_handbook.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_handbook_topics.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_handoff_channels.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_handraise.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_harvest.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_histgen.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_home_is_never_yours.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_imapmail.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_imessage.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_kind_dispatch.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_knowledge.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_learnedgraph.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_local_file.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_mail_folders.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_mark_read.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_mcp.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_meeting_prep.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_message_thread.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_messengers.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_msauth.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_mssql.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_noop_ending.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_not_coding.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_note_retrieval.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_outbound_reports.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_own_rows_no_reply.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_playbooks.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_pm.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_poll_overlap.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_poll_sources.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_pr_comments.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_problems.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_promptmap_and_catalog.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_proof_ci_proposals.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_pypi_downloads.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_quickbooks.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_rank.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_reply_channels.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_reply_voice.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_report_alerts.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_report_triage.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_research.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_reviews.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_scopes.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_seed_fits_a_tty.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_seeded_reports.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_semantic.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_send_targets.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_setup.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_sheets_sharepoint.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_someone_else_replied.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_soul_interview.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_stay_open.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_stay_open_doors.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_store_app_cli.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_store_hotpaths.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_sync_clock.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_task_identity.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_teller.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_toil.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_triage_dataset.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_urgent_and_handoff.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_verdict_paths.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_verdict_sticks.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_vision.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_voice.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_wabridge.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_waitroom.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_watermark.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_web_entrypoint.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_webguard.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_whatsapp_documents.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_whatsapp_images.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_whoami.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_withdrawn.py +0 -0
- {taskuary-0.3.2.5 → taskuary-0.3.2.7}/tests/test_witness.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: taskuary
|
|
3
|
-
Version: 0.3.2.
|
|
3
|
+
Version: 0.3.2.7
|
|
4
4
|
Summary: Automate your job - the local-first operating system for your work: AI triage in, coding agents out.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/ldbumble/taskuary
|
|
@@ -64,7 +64,7 @@ and replies wait for your approval. Nothing sends or ships without you.
|
|
|
64
64
|
|
|
65
65
|

|
|
66
66
|
|
|
67
|
-
Taskuary is early—currently **v0.3.2.
|
|
67
|
+
Taskuary is early—currently **v0.3.2.7**—and moving fast. The funnel, review queue,
|
|
68
68
|
agent sessions, and reports pipeline are in daily use; breaking changes are still possible
|
|
69
69
|
before 1.0.
|
|
70
70
|
|
|
@@ -13,7 +13,7 @@ and replies wait for your approval. Nothing sends or ships without you.
|
|
|
13
13
|
|
|
14
14
|

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