comodor 1.1.0__tar.gz → 1.1.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.
- {comodor-1.1.0 → comodor-1.1.2}/.gitignore +19 -0
- {comodor-1.1.0 → comodor-1.1.2}/PKG-INFO +2 -1
- {comodor-1.1.0 → comodor-1.1.2}/docs/README.md +1 -0
- {comodor-1.1.0 → comodor-1.1.2}/pyproject.toml +18 -1
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/_version.py +2 -2
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/agent/verify.py +65 -3
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/browser/launch.py +81 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/channels/settings.py +6 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/cli.py +7 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/config.py +251 -1
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/desktop/guard.py +27 -6
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/desktop/overlay.py +203 -5
- comodor-1.1.2/src/comodor/github/__init__.py +53 -0
- comodor-1.1.2/src/comodor/github/api.py +446 -0
- comodor-1.1.2/src/comodor/github/commands.py +358 -0
- comodor-1.1.2/src/comodor/github/connect.py +386 -0
- comodor-1.1.2/src/comodor/github/identity.py +304 -0
- comodor-1.1.2/src/comodor/github/repos.py +140 -0
- comodor-1.1.2/src/comodor/github/tokens.py +153 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/help.py +23 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/telegram/bot.py +138 -29
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/telegram/keyboard.py +13 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/computer.py +37 -1
- comodor-1.1.2/src/comodor/tools/github.py +385 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/registry.py +12 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_background_delegates.py +36 -18
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_bench.py +75 -3
- comodor-1.1.2/tests/test_capability_map.py +264 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_cron.py +42 -15
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_discover.py +4 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_facts.py +4 -0
- comodor-1.1.2/tests/test_github.py +669 -0
- comodor-1.1.2/tests/test_github_identity.py +618 -0
- comodor-1.1.2/tests/test_github_tool.py +333 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_headless.py +11 -3
- comodor-1.1.2/tests/test_nothing_is_left_running.py +234 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_overlay.py +1 -0
- comodor-1.1.2/tests/test_overlay_buttons.py +632 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_performance.py +39 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_project_check.py +43 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_real_desktop.py +34 -9
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_telegram.py +261 -8
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_verify.py +6 -1
- {comodor-1.1.0 → comodor-1.1.2}/LICENSE +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/README.md +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/bench/README.md +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/bench/tasks/careful-unknowable/repo/README.md +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/docs/AR/README.md +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/docs/DE/README.md +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/docs/ES/README.md +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/docs/FA/README.md +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/docs/FR/README.md +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/docs/RU/README.md +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/docs/TR/README.md +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/docs/ZH/README.md +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/docs/upgrade-specs/README.md +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/__main__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/acp/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/acp/agent.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/acp/commands.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/acp/jsonrpc.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/agent/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/agent/background.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/agent/claims.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/agent/constraints.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/agent/context.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/agent/loop.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/agent/plan.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/agent/prompts.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/agent/spawn.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/agent/staleness.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/agent/tokens.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/agent/tool_bridge.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/api/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/api/schema.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/api/server.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/api/session_map.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/browser/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/browser/cdp.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/browser/page.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/catalogue.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/channels/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/channels/breaker.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/channels/busy.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/channels/daemon.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/channels/ledger.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/channels/markdown.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/channels/unit.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/context_refs.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/cron/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/cron/commands.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/cron/deliver.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/cron/jobs.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/cron/parse.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/cron/runner.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/cron/scheduler.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/desktop/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/desktop/keys.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/desktop/png.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/desktop/quartz.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/desktop/screen.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/desktop/win32.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/desktop/x11.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/discord/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/discord/api.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/discord/bot.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/discord/commands.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/discord/gateway.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/discord/service.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/discord/unit.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/doctor.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/events.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/image_gen/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/image_gen/registry.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/insights.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/associations.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/bm25.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/commands.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/curator.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/facts.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/hotindex.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/journey.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/journey_commands.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/memory.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/progress.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/providers/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/providers/base.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/providers/commands.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/providers/http_generic.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/reflect.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/render.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/review.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/rules.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/signals.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/store.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/writer.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/local/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/local/catalogue.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/local/commands.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/local/download.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/local/models.json +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/local/provider.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/local/runtime.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/local/store.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/mcp/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/mcp/catalogue.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/mcp/commands.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/mcp/http.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/mcp/manager.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/mcp/protocol.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/media/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/media/ingest.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/media/route.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/migrate.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/net/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/net/http.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/net/sse.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/net/ws.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/paths.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/plugins/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/plugins/api.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/plugins/commands.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/plugins/manager.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/providers/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/providers/anthropic.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/providers/base.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/providers/caching.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/providers/discover.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/providers/fake.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/providers/gateway.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/providers/models.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/providers/oauth.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/providers/openai_compat.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/providers/pool.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/providers/profile.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/providers/registry.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/questions.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/safety/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/safety/backends.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/safety/checkpoints.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/safety/mining.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/safety/permissions.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/safety/redact.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/safety/smart.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/safety/ssrf.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/session/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/session/search.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/session/store.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/setup.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/skills/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/skills/catalogue.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/skills/commands.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/skills/examples.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/skills/ledger.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/skills/linter.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/skills/loader.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/skills/propose.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/skills/registry.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/skills/threats.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/skills/usage.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/slack/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/slack/api.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/slack/blocks.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/slack/bot.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/slack/commands.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/slack/service.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/slack/socket.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/slack/unit.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/telegram/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/telegram/api.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/telegram/commands.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/telegram/service.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/telegram/unit.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/ask.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/base.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/browse.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/browser.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/cronjob.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/delegate.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/fs.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/history.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/image_gen.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/matching.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/mcp.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/mcp_resources.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/memory.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/overflow.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/plugin.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/propose_mode.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/search.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/shell.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/skill_manage.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/skills.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/todo.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/verify.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/vision.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/web.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/app.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/banner.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/bidi.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/chooser.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/clipboard.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/console.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/input/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/input/keys.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/input/reader.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/layout.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/markdown.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/screen.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/theme.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/widgets/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/widgets/buttons.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/widgets/chat.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/widgets/history.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/widgets/overlay.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/widgets/panel.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/widgets/progress.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/widgets/prompt.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/widgets/questions.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/widgets/statusbar.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/widgets/toast.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/widgets/welcome.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/uninstall.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/update.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/vision.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/voice/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/voice/stt.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/voice/tts.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/web/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/web/commands.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/web/index.html +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/web/server.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/web/session.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/web/ui.css +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/web/ui.js +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/web/vazirmatn-OFL.txt +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/web/vazirmatn.woff2 +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/webhook/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/webhook/commands.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/webhook/server.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/webhook/subs.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/whatsapp/__init__.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/whatsapp/api.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/whatsapp/bot.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/whatsapp/commands.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/whatsapp/guide.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/whatsapp/menu.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/whatsapp/service.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/whatsapp/tunnel.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/whatsapp/unit.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/whatsapp/webhook.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/src/comodor/workspace.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/conftest.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/support/fake_mcp_server.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_acp.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_agent_loop.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_api.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_app.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_approval_mining.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_associations.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_backends.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_banner.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_bidi.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_blind_writes.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_breaker.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_browser.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_busy_modes.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_caching.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_channel_service.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_chooser.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_claims.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_computer_tool.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_config_save.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_constraints.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_context_refs.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_copy_and_bands.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_cronjob.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_curator.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_delegate.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_delivery_ledger.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_desktop.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_discord.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_docker.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_doctor.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_help.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_history.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_image_gen.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_import_command.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_input.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_insights.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_journey.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_key_pool.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_layout.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_learning.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_live_settings.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_local.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_markdown.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_matching.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_mcp.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_mcp_http.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_mcp_resources.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_media.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_memory_provider.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_memory_tool.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_migrate.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_oauth.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_overflow.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_plan_memory.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_plugins.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_prefix_stability.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_profile.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_profiles.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_progress.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_propose.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_propose_mode.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_providers.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_quartz_backend.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_questions.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_real_browser.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_real_web_ui.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_reflex.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_release_workflow.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_revoked_writes.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_run_loop.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_setup.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_setup_import.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_skill_budget.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_skill_catalogue.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_skill_manage.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_skills.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_slack.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_smart_approvals.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_source_hygiene.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_ssrf.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_staleness.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_switching_model.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_tool_bridge.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_tools.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_uninstall.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_update.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_vision_tool.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_voice.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_web.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_webhook.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_welcome.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_whatsapp.py +0 -0
- {comodor-1.1.0 → comodor-1.1.2}/tests/test_x11_backend.py +0 -0
|
@@ -51,3 +51,22 @@ PROJECT.md
|
|
|
51
51
|
# Editor and agent settings that are this machine, not this project.
|
|
52
52
|
.claude/
|
|
53
53
|
*-in-git.png
|
|
54
|
+
|
|
55
|
+
# Whatever a machine that runs the benchmark keeps locally: the credentials
|
|
56
|
+
# it needs, its run logs, and the script that drives it.
|
|
57
|
+
#
|
|
58
|
+
# None of that is in this repository on purpose. Publishing the runner would
|
|
59
|
+
# publish where a key is kept and what is reachable from the machine holding
|
|
60
|
+
# it, on a repository anybody can read — so it lives only on the machine that
|
|
61
|
+
# uses it, inside a clone, and these rules keep it from being committed back
|
|
62
|
+
# by accident. A key that reaches a public repository has to be revoked, not
|
|
63
|
+
# deleted.
|
|
64
|
+
bench/on-a-server/
|
|
65
|
+
|
|
66
|
+
# The capability map: what exists, where it lives, and which parts answered
|
|
67
|
+
# when they were last run. Generated by `tools/capability-map.py`, which is
|
|
68
|
+
# committed — the map itself is not, because it is a photograph of one
|
|
69
|
+
# machine at one moment: the "does it run" table records this desk's state,
|
|
70
|
+
# and a committed copy would go stale the day after and be believed anyway.
|
|
71
|
+
# Anybody can rebuild it in ten seconds.
|
|
72
|
+
CAPABILITIES.md
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: comodor
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.2
|
|
4
4
|
Summary: Comodor — a self-improving terminal coding agent with a Rich TUI
|
|
5
5
|
Project-URL: Homepage, https://comodor.ai
|
|
6
6
|
Project-URL: Repository, https://github.com/ifekri/Comodor
|
|
@@ -24,6 +24,7 @@ Classifier: Topic :: Software Development :: Code Generators
|
|
|
24
24
|
Requires-Python: >=3.9
|
|
25
25
|
Requires-Dist: rich>=13.7
|
|
26
26
|
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest-xdist>=3.5; extra == 'dev'
|
|
27
28
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
28
29
|
Requires-Dist: pyyaml>=6.0; extra == 'dev'
|
|
29
30
|
Requires-Dist: ruff>=0.14; extra == 'dev'
|
|
@@ -36,6 +36,7 @@ ends with the agent doing something useful.
|
|
|
36
36
|
| [In your editor](acp.md) | Drive Comodor from Zed or any Agent Client Protocol client |
|
|
37
37
|
| [In Docker](docker.md) | One command, in a container |
|
|
38
38
|
| [MCP servers](mcp.md) | Tools from the Model Context Protocol |
|
|
39
|
+
| [Repositories on GitHub](github.md) | Read and change repositories that are not checked out here |
|
|
39
40
|
|
|
40
41
|
### Understand it
|
|
41
42
|
|
|
@@ -40,7 +40,10 @@ Changelog = "https://github.com/ifekri/Comodor/blob/main/CHANGELOG.md"
|
|
|
40
40
|
# travel with it. PyYAML is here so the tests can read `docker-compose.yml` and
|
|
41
41
|
# the workflows as documents rather than as strings; asserting on the text of a
|
|
42
42
|
# YAML file is how a test ends up agreeing with a broken one.
|
|
43
|
-
|
|
43
|
+
# pytest-xdist because the suite is mostly waiting: sockets, subprocesses
|
|
44
|
+
# and deliberate timeouts, none of which compete for a CPU. Serially it
|
|
45
|
+
# ran for an hour on every CI job; the work itself was minutes.
|
|
46
|
+
dev = ["pytest>=8.0", "pytest-xdist>=3.5", "ruff>=0.14", "pyyaml>=6.0"]
|
|
44
47
|
|
|
45
48
|
[project.scripts]
|
|
46
49
|
comodor = "comodor.cli:main"
|
|
@@ -95,3 +98,17 @@ include = ["src/comodor", "tests", "pyproject.toml", "README.md", "LICENSE"]
|
|
|
95
98
|
[tool.pytest.ini_options]
|
|
96
99
|
testpaths = ["tests"]
|
|
97
100
|
pythonpath = ["src"]
|
|
101
|
+
# One process per core, and each test file kept whole on one worker. The
|
|
102
|
+
# grouping matters: several files build a real server or a browser in a
|
|
103
|
+
# module-scoped fixture, and splitting a file across workers would build it
|
|
104
|
+
# once per worker rather than once.
|
|
105
|
+
addopts = "-n auto --dist loadfile -m 'not performance'"
|
|
106
|
+
markers = [
|
|
107
|
+
# Anything whose assertion is about elapsed time: a ceiling, a deadline
|
|
108
|
+
# for a background job, or an ordering that comes from one thing being
|
|
109
|
+
# deliberately slower than another. Under `-n auto` these measure a
|
|
110
|
+
# machine with fifteen other workers on it, and fail in a different file
|
|
111
|
+
# on a different platform every few runs. Run alone instead, in the same
|
|
112
|
+
# CI step, with the budgets untouched.
|
|
113
|
+
"performance: measures wall-clock time, so it runs alone. `pytest -m performance -n 0`; the CI job does.",
|
|
114
|
+
]
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '1.1.
|
|
22
|
-
__version_tuple__ = version_tuple = (1, 1,
|
|
21
|
+
__version__ = version = '1.1.2'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 1, 2)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -32,6 +32,7 @@ switch off.
|
|
|
32
32
|
from __future__ import annotations
|
|
33
33
|
|
|
34
34
|
import os
|
|
35
|
+
import signal
|
|
35
36
|
import subprocess
|
|
36
37
|
from dataclasses import dataclass
|
|
37
38
|
from pathlib import Path
|
|
@@ -56,6 +57,38 @@ class Outcome:
|
|
|
56
57
|
#: Set when the command could not be run at all, as opposed to failing.
|
|
57
58
|
unusable: str = ""
|
|
58
59
|
|
|
60
|
+
def _end_the_group(child: "subprocess.Popen") -> None:
|
|
61
|
+
"""Kill the check and everything it started. Never raises.
|
|
62
|
+
|
|
63
|
+
`pytest` starting workers, `npm` starting a bundler: killing only the
|
|
64
|
+
shell leaves those behind, running against the user's project after the
|
|
65
|
+
turn that started them has been abandoned.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
if hasattr(os, "killpg"):
|
|
69
|
+
group = os.getpgid(child.pid)
|
|
70
|
+
# Only a group the check has to itself. `start_new_session` above
|
|
71
|
+
# gives it one, but if that ever stops happening the child shares
|
|
72
|
+
# ours, and killing that group would end the agent along with the
|
|
73
|
+
# command it was running. The signal cannot be taken back, so the
|
|
74
|
+
# condition is checked rather than trusted.
|
|
75
|
+
if group != os.getpgid(0):
|
|
76
|
+
os.killpg(group, signal.SIGKILL)
|
|
77
|
+
return
|
|
78
|
+
except (OSError, AttributeError, ProcessLookupError):
|
|
79
|
+
pass
|
|
80
|
+
try:
|
|
81
|
+
# Windows: taskkill walks the tree, which Popen.kill does not.
|
|
82
|
+
subprocess.run(["taskkill", "/F", "/T", "/PID", str(child.pid)],
|
|
83
|
+
capture_output=True, timeout=10)
|
|
84
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
85
|
+
pass
|
|
86
|
+
try:
|
|
87
|
+
child.kill()
|
|
88
|
+
except OSError:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
59
92
|
def run(command: str, cwd: Path, patience: float = PATIENCE) -> Outcome:
|
|
60
93
|
"""Run the project's check and say what happened. Never raises."""
|
|
61
94
|
if not command.strip():
|
|
@@ -65,18 +98,47 @@ def run(command: str, cwd: Path, patience: float = PATIENCE) -> Outcome:
|
|
|
65
98
|
# A check that shells out to the agent would be a loop with a bill on it.
|
|
66
99
|
environment["COMODOR_VERIFYING"] = "1"
|
|
67
100
|
|
|
101
|
+
# `shell=True` means the process started here is a shell, and the check is
|
|
102
|
+
# its child. Killing the shell on a timeout leaves that child running and
|
|
103
|
+
# holding the pipes open, so the read below goes on blocking — `patience`
|
|
104
|
+
# was a ceiling on nothing, and the message said "no result within 2s"
|
|
105
|
+
# after waiting twenty. The whole group has to go, which needs the platform
|
|
106
|
+
# to have been told to make one.
|
|
107
|
+
# Written out rather than gathered into a dict and splatted: this argument
|
|
108
|
+
# is the difference between ending the check and ending the agent, and it
|
|
109
|
+
# should be readable as such at the call — by a person, and by anything
|
|
110
|
+
# checking that every `Popen` beside a `killpg` has one.
|
|
68
111
|
try:
|
|
69
|
-
|
|
112
|
+
child = subprocess.Popen(
|
|
70
113
|
command, shell=True, cwd=str(cwd), env=environment,
|
|
71
|
-
|
|
72
|
-
errors="replace",
|
|
114
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
115
|
+
encoding="utf-8", errors="replace",
|
|
116
|
+
start_new_session=(os.name != "nt"),
|
|
117
|
+
creationflags=(subprocess.CREATE_NEW_PROCESS_GROUP
|
|
118
|
+
if os.name == "nt" else 0))
|
|
119
|
+
except (OSError, ValueError) as problem:
|
|
120
|
+
return Outcome(ran=False, passed=True, output="",
|
|
121
|
+
unusable=f"{type(problem).__name__}: {problem}")
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
finished_out, finished_err = child.communicate(timeout=patience)
|
|
73
125
|
except subprocess.TimeoutExpired:
|
|
126
|
+
_end_the_group(child)
|
|
127
|
+
# Drained after the group is gone, so nothing is still writing into a
|
|
128
|
+
# pipe nobody will read. It cannot block now: every writer is dead.
|
|
129
|
+
try:
|
|
130
|
+
child.communicate(timeout=10)
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
74
133
|
return Outcome(ran=True, passed=False,
|
|
75
134
|
output=f"(no result within {patience:.0f}s)")
|
|
76
135
|
except (OSError, ValueError) as problem:
|
|
136
|
+
_end_the_group(child)
|
|
77
137
|
return Outcome(ran=False, passed=True, output="",
|
|
78
138
|
unusable=f"{type(problem).__name__}: {problem}")
|
|
79
139
|
|
|
140
|
+
finished = subprocess.CompletedProcess(
|
|
141
|
+
command, child.returncode, finished_out, finished_err)
|
|
80
142
|
output = ((finished.stdout or "") + (finished.stderr or "")).strip()
|
|
81
143
|
if len(output) > MOST:
|
|
82
144
|
output = "…\n" + output[-MOST:]
|
|
@@ -16,8 +16,10 @@ performs on purpose survives, and obviously separate from the browser they use.
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
+
import atexit
|
|
19
20
|
import os
|
|
20
21
|
import shutil
|
|
22
|
+
import signal
|
|
21
23
|
import socket
|
|
22
24
|
import subprocess
|
|
23
25
|
import sys
|
|
@@ -140,6 +142,33 @@ def _first_line(output: str) -> str:
|
|
|
140
142
|
return f" It said: {first.strip()[:300]}"
|
|
141
143
|
|
|
142
144
|
|
|
145
|
+
#: Every browser started and not yet stopped. A `finally` covers the ordinary
|
|
146
|
+
#: path, and nothing covers the one that actually leaks: a run interrupted —
|
|
147
|
+
#: Ctrl-C, a test-runner timeout, a session killed — never reaches its cleanup,
|
|
148
|
+
#: and Chrome is a dozen processes that outlive the Python that started them.
|
|
149
|
+
#: They hold their profile directory and their share of a gigabyte, and nothing
|
|
150
|
+
#: in a later run knows to clean up an earlier one's. They accumulate for days
|
|
151
|
+
#: until somebody notices their machine is full of browsers nobody opened.
|
|
152
|
+
_LIVE: "set[Browser]" = set()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _remember(browser: "Browser") -> None:
|
|
156
|
+
_LIVE.add(browser)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _stop_everything_still_open() -> None:
|
|
160
|
+
"""Registered with `atexit`, so an abandoned browser still goes away."""
|
|
161
|
+
for browser in list(_LIVE):
|
|
162
|
+
try:
|
|
163
|
+
browser.stop()
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
_LIVE.clear()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
atexit.register(_stop_everything_still_open)
|
|
170
|
+
|
|
171
|
+
|
|
143
172
|
class Browser:
|
|
144
173
|
"""A browser process we started, and are responsible for stopping."""
|
|
145
174
|
|
|
@@ -209,6 +238,11 @@ class Browser:
|
|
|
209
238
|
stdin=subprocess.DEVNULL,
|
|
210
239
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
211
240
|
if os.name == "nt" else 0,
|
|
241
|
+
# A group of its own, so ending the browser's group ends the
|
|
242
|
+
# browser. Without this the child inherits *our* group, and
|
|
243
|
+
# `killpg` on it kills whatever started us as well — the whole
|
|
244
|
+
# test run, the whole agent session.
|
|
245
|
+
start_new_session=(os.name != "nt"),
|
|
212
246
|
)
|
|
213
247
|
except OSError as error:
|
|
214
248
|
complaints.close()
|
|
@@ -216,6 +250,7 @@ class Browser:
|
|
|
216
250
|
|
|
217
251
|
browser = cls(binary, profile, port, headless, process,
|
|
218
252
|
sandboxed=sandboxed)
|
|
253
|
+
_remember(browser)
|
|
219
254
|
try:
|
|
220
255
|
browser._await_port()
|
|
221
256
|
except BrowserError as error:
|
|
@@ -270,9 +305,28 @@ class Browser:
|
|
|
270
305
|
info = version(self.port)
|
|
271
306
|
return info.get("Browser") or Path(self.executable).stem or "browser"
|
|
272
307
|
|
|
308
|
+
def _forget(self) -> None:
|
|
309
|
+
_LIVE.discard(self)
|
|
310
|
+
|
|
273
311
|
def stop(self) -> None:
|
|
312
|
+
"""Close the browser and everything it started.
|
|
313
|
+
|
|
314
|
+
A browser is not one process. Chrome starts a renderer per tab, a GPU
|
|
315
|
+
process, a crashpad handler and more, and `terminate` on the one we
|
|
316
|
+
launched leaves every one of them running — holding its profile
|
|
317
|
+
directory, and its share of a gigabyte of memory. Eight strays per
|
|
318
|
+
run, and nothing in a later run cleans up an earlier one's: they
|
|
319
|
+
accumulate until somebody notices their machine is full of browsers
|
|
320
|
+
nobody opened.
|
|
321
|
+
"""
|
|
274
322
|
if not self.ours or self.process is None:
|
|
275
323
|
return
|
|
324
|
+
|
|
325
|
+
# The tree first, while the parent is still alive to name it. Killing
|
|
326
|
+
# the parent before asking leaves orphans whose parent id points at
|
|
327
|
+
# nothing, and then there is no tree left to walk.
|
|
328
|
+
self._end_the_tree()
|
|
329
|
+
|
|
276
330
|
try:
|
|
277
331
|
self.process.terminate()
|
|
278
332
|
self.process.wait(timeout=8)
|
|
@@ -282,3 +336,30 @@ class Browser:
|
|
|
282
336
|
except OSError:
|
|
283
337
|
pass
|
|
284
338
|
self.process = None
|
|
339
|
+
self._forget()
|
|
340
|
+
|
|
341
|
+
def _end_the_tree(self) -> None:
|
|
342
|
+
"""The browser and every process it started. Never raises."""
|
|
343
|
+
pid = getattr(self.process, "pid", None)
|
|
344
|
+
if pid is None:
|
|
345
|
+
return
|
|
346
|
+
try:
|
|
347
|
+
if hasattr(os, "killpg"):
|
|
348
|
+
group = os.getpgid(pid)
|
|
349
|
+
# Only ever a group the browser has to itself. A child started
|
|
350
|
+
# without `start_new_session` inherits ours, and killing that
|
|
351
|
+
# group kills whoever started us — the test run, the agent, the
|
|
352
|
+
# shell. Checked rather than trusted: this signal cannot be
|
|
353
|
+
# taken back, and the cost of being wrong is the whole session.
|
|
354
|
+
if group != os.getpgid(0):
|
|
355
|
+
os.killpg(group, signal.SIGKILL)
|
|
356
|
+
return
|
|
357
|
+
except (OSError, AttributeError, ProcessLookupError):
|
|
358
|
+
pass
|
|
359
|
+
try:
|
|
360
|
+
# `taskkill /T` walks the tree from a living parent; `Popen.kill`
|
|
361
|
+
# only ever ends the one process.
|
|
362
|
+
subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)],
|
|
363
|
+
capture_output=True, timeout=15)
|
|
364
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
365
|
+
pass
|
|
@@ -158,6 +158,12 @@ def hold_the_line(config: Any, section: str, talk: Any) -> bool:
|
|
|
158
158
|
settings = getattr(config, section, None)
|
|
159
159
|
if settings is None or getattr(settings, "allow_writes", False):
|
|
160
160
|
return False
|
|
161
|
+
# A conversation that was granted Act by its own approval keeps it. The
|
|
162
|
+
# channel-wide flag being off is the normal state for those chats — it is
|
|
163
|
+
# what makes the approval prompt appear in the first place — so treating
|
|
164
|
+
# it as a revocation would end the grant on the very next message.
|
|
165
|
+
if getattr(talk, "may_write", False):
|
|
166
|
+
return False
|
|
161
167
|
session = getattr(talk, "session", None)
|
|
162
168
|
if session is None:
|
|
163
169
|
return False
|
|
@@ -122,6 +122,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
122
122
|
from .acp.commands import register as register_acp
|
|
123
123
|
from .cron.commands import register as register_cron
|
|
124
124
|
from .discord.commands import register as register_discord
|
|
125
|
+
from .github.commands import register as register_github
|
|
125
126
|
from .learning.commands import register as register_curator
|
|
126
127
|
from .learning.journey_commands import register as register_journey
|
|
127
128
|
from .learning.providers.commands import register as register_memory_provider
|
|
@@ -143,6 +144,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
143
144
|
register_whatsapp(sub)
|
|
144
145
|
register_slack(sub)
|
|
145
146
|
register_discord(sub)
|
|
147
|
+
register_github(sub)
|
|
146
148
|
register_skills(sub)
|
|
147
149
|
register_web(sub)
|
|
148
150
|
register_webhook(sub)
|
|
@@ -1058,6 +1060,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1058
1060
|
from .slack.commands import run as run_slack
|
|
1059
1061
|
|
|
1060
1062
|
return run_slack(config, args)
|
|
1063
|
+
if args.command == "github":
|
|
1064
|
+
from .github.commands import run as run_github
|
|
1065
|
+
|
|
1066
|
+
return run_github(config, args)
|
|
1067
|
+
|
|
1061
1068
|
if args.command == "discord":
|
|
1062
1069
|
from .discord.commands import run as run_discord
|
|
1063
1070
|
|
|
@@ -23,6 +23,7 @@ from __future__ import annotations
|
|
|
23
23
|
|
|
24
24
|
import json
|
|
25
25
|
import os
|
|
26
|
+
import re
|
|
26
27
|
import stat
|
|
27
28
|
from dataclasses import dataclass, field, fields, is_dataclass
|
|
28
29
|
from pathlib import Path
|
|
@@ -636,6 +637,212 @@ class DiscordConfig:
|
|
|
636
637
|
return bool(user_id) and int(user_id) in {int(x) for x in self.allowed}
|
|
637
638
|
|
|
638
639
|
|
|
640
|
+
#: The grant format this agent can use.
|
|
641
|
+
#:
|
|
642
|
+
#: Version 1 grants said which key owned which installation and nothing more,
|
|
643
|
+
#: so an endpoint holding one could not tell whether the person who made the
|
|
644
|
+
#: connection still had access to it. Version 2 names them. There is no
|
|
645
|
+
#: upgrade path - the missing field is not something either side can look up -
|
|
646
|
+
#: so an old grant is reported as needing a reconnection rather than tried.
|
|
647
|
+
GRANT_FORMAT = "g2"
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
@dataclass
|
|
651
|
+
class GitHubInstallation:
|
|
652
|
+
"""One GitHub App installation, as this machine knows it.
|
|
653
|
+
|
|
654
|
+
An installation is the unit GitHub actually grants against: somebody
|
|
655
|
+
installs the app onto an account — their own or an organisation's — and
|
|
656
|
+
chooses which repositories it may see. One person can have several, and a
|
|
657
|
+
repository belongs to exactly one, so `github_connected = true` would be a
|
|
658
|
+
lie about a shape that is genuinely plural.
|
|
659
|
+
|
|
660
|
+
What is *not* here is any credential. An installation access token lasts an
|
|
661
|
+
hour and is minted from the app's private key when it is needed; writing
|
|
662
|
+
one to a config file would leave a working key on disk long after the
|
|
663
|
+
session that fetched it, for no gain over asking again.
|
|
664
|
+
|
|
665
|
+
The grant is not a credential either, and that is worth being precise
|
|
666
|
+
about because it looks like one. It is a signed statement that this
|
|
667
|
+
installation belongs to a particular public key, and it is useless without
|
|
668
|
+
the private half - which is not in this file, and is not in any file this
|
|
669
|
+
one links to. Somebody who copies their config into a gist has published
|
|
670
|
+
nothing that can be used.
|
|
671
|
+
"""
|
|
672
|
+
|
|
673
|
+
installation_id: int = 0
|
|
674
|
+
#: The account it was installed on, and what kind. Kept because it is what
|
|
675
|
+
#: a person recognises — "ifekri" or "comodor-ai" — where the id is not.
|
|
676
|
+
account_id: int = 0
|
|
677
|
+
account_login: str = ""
|
|
678
|
+
account_type: str = "" # "User" or "Organization"
|
|
679
|
+
#: "all" or "selected", as GitHub reports it. Worth storing: it is the
|
|
680
|
+
#: difference between "this will see the next repository you make" and
|
|
681
|
+
#: "it will not", which somebody deciding whether to widen it needs.
|
|
682
|
+
repository_selection: str = ""
|
|
683
|
+
#: What the app was granted, verbatim from GitHub. A capability check
|
|
684
|
+
#: reads this rather than assuming, so a narrowed permission is a clear
|
|
685
|
+
#: refusal instead of a 403 from the middle of an operation.
|
|
686
|
+
permissions: dict[str, str] = field(default_factory=dict)
|
|
687
|
+
#: Seconds since the epoch. Two timestamps rather than one because
|
|
688
|
+
#: "connected in March, last confirmed yesterday" and "connected in March,
|
|
689
|
+
#: never seen since" are different situations.
|
|
690
|
+
created_at: float = 0.0
|
|
691
|
+
updated_at: float = 0.0
|
|
692
|
+
#: The Worker's signed statement that this installation belongs to this
|
|
693
|
+
#: machine's key for it, and to the person who connected it. Sent with
|
|
694
|
+
#: every request that could do something, and the only place the
|
|
695
|
+
#: installation id is read from - a request that merely names an id proves
|
|
696
|
+
#: nothing, which is what an earlier version of this got wrong.
|
|
697
|
+
#:
|
|
698
|
+
#: It is not a licence. The endpoint asks GitHub whether the person named
|
|
699
|
+
#: in it is *still* entitled before it hands anything over, so a grant kept
|
|
700
|
+
#: after somebody loses access stops working rather than outliving it.
|
|
701
|
+
grant: str = ""
|
|
702
|
+
|
|
703
|
+
def to_json(self) -> dict[str, Any]:
|
|
704
|
+
return {
|
|
705
|
+
"installation_id": int(self.installation_id),
|
|
706
|
+
"account_id": int(self.account_id),
|
|
707
|
+
"account_login": self.account_login,
|
|
708
|
+
"account_type": self.account_type,
|
|
709
|
+
"repository_selection": self.repository_selection,
|
|
710
|
+
"permissions": dict(self.permissions),
|
|
711
|
+
"created_at": float(self.created_at),
|
|
712
|
+
"updated_at": float(self.updated_at),
|
|
713
|
+
"grant": self.grant,
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
@property
|
|
717
|
+
def usable(self) -> bool:
|
|
718
|
+
"""Whether this connection can still do anything.
|
|
719
|
+
|
|
720
|
+
Two ways it cannot. No grant at all is a leftover from a version that
|
|
721
|
+
did not have them, or a connection whose key was lost. A `g1.` grant is
|
|
722
|
+
from before the grant named the person it was issued to, which means
|
|
723
|
+
the endpoint has no way to re-check whether that person still has
|
|
724
|
+
access - so it refuses them, and reconnecting is the fix.
|
|
725
|
+
|
|
726
|
+
Both read as connected and fail at the first request, which is why
|
|
727
|
+
`status` says so up front instead.
|
|
728
|
+
"""
|
|
729
|
+
return bool(self.installation_id and self.grant
|
|
730
|
+
and self.grant.startswith(f"{GRANT_FORMAT}."))
|
|
731
|
+
|
|
732
|
+
def may(self, permission: str, level: str = "read") -> bool:
|
|
733
|
+
"""Whether this installation was granted `permission` at `level`.
|
|
734
|
+
|
|
735
|
+
GitHub reports "read" or "write"; write implies read. Asked before an
|
|
736
|
+
operation so a missing grant is a sentence naming what to change,
|
|
737
|
+
rather than a 403 from four calls into a sequence.
|
|
738
|
+
"""
|
|
739
|
+
granted = str(self.permissions.get(permission, "")).lower()
|
|
740
|
+
if not granted:
|
|
741
|
+
return False
|
|
742
|
+
return granted == "write" or level == "read"
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
@dataclass
|
|
746
|
+
class GitHubConfig:
|
|
747
|
+
"""Repositories on GitHub, reached through a GitHub App.
|
|
748
|
+
|
|
749
|
+
A provider like any other, and deliberately not load-bearing: everything
|
|
750
|
+
Comodor does to a local checkout works with none of this configured. What
|
|
751
|
+
it adds is the ability to open a pull request on a repository this machine
|
|
752
|
+
has never cloned.
|
|
753
|
+
|
|
754
|
+
No token lives here. The app holds a private key on the server that mints
|
|
755
|
+
short-lived installation tokens, and this file holds only which
|
|
756
|
+
installations exist and what they cover. Nothing in it is a secret, which
|
|
757
|
+
is why it can sit in a config file people read and paste from.
|
|
758
|
+
"""
|
|
759
|
+
|
|
760
|
+
enabled: bool = False
|
|
761
|
+
#: The installations this machine has connected, newest last. Plural
|
|
762
|
+
#: because a person with a personal account and two organisations has
|
|
763
|
+
#: three, and a repository resolves to exactly one of them.
|
|
764
|
+
installations: list[GitHubInstallation] = field(default_factory=list)
|
|
765
|
+
#: Where the installation flow is served. Overridable for a self-hosted
|
|
766
|
+
#: deployment; the default is the one this project runs.
|
|
767
|
+
endpoint: str = "https://comodor.ai"
|
|
768
|
+
#: What a branch the agent opens is called. The kind is appended:
|
|
769
|
+
#: `comodor/fix-...`, `comodor/feature-...`.
|
|
770
|
+
branch_prefix: str = "comodor/"
|
|
771
|
+
#: Whether a turn may write to a repository at all. Off until somebody
|
|
772
|
+
#: says otherwise, for the same reason the channels are: a pull request
|
|
773
|
+
#: opened by mistake is public and cannot be unseen.
|
|
774
|
+
allow_writes: bool = False
|
|
775
|
+
|
|
776
|
+
def to_json(self) -> dict[str, Any]:
|
|
777
|
+
return {
|
|
778
|
+
"enabled": self.enabled,
|
|
779
|
+
"installations": [one.to_json() for one in self.installations],
|
|
780
|
+
"endpoint": self.endpoint,
|
|
781
|
+
"branch_prefix": self.branch_prefix,
|
|
782
|
+
"allow_writes": self.allow_writes,
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
def find(self, owner: str) -> GitHubInstallation | None:
|
|
786
|
+
"""The installation covering `owner`, or None.
|
|
787
|
+
|
|
788
|
+
By login rather than by id because that is what a person types and
|
|
789
|
+
what a repository string carries: `ifekri/Comodor` names the owner in
|
|
790
|
+
the half before the slash.
|
|
791
|
+
"""
|
|
792
|
+
wanted = (owner or "").strip().lower()
|
|
793
|
+
for one in self.installations:
|
|
794
|
+
if one.account_login.lower() == wanted:
|
|
795
|
+
return one
|
|
796
|
+
return None
|
|
797
|
+
|
|
798
|
+
def find_by_id(self, installation_id: int) -> GitHubInstallation | None:
|
|
799
|
+
"""The installation with this id, or None.
|
|
800
|
+
|
|
801
|
+
By id rather than by login, because this is what the signing path
|
|
802
|
+
needs: it is handed an id and has to find the grant that goes with it.
|
|
803
|
+
Returning None means there is no grant, which means no request, rather
|
|
804
|
+
than a request that would be refused.
|
|
805
|
+
"""
|
|
806
|
+
try:
|
|
807
|
+
wanted = int(installation_id)
|
|
808
|
+
except (TypeError, ValueError):
|
|
809
|
+
return None
|
|
810
|
+
for one in self.installations:
|
|
811
|
+
if one.installation_id == wanted:
|
|
812
|
+
return one
|
|
813
|
+
return None
|
|
814
|
+
|
|
815
|
+
def remember(self, installation: GitHubInstallation) -> None:
|
|
816
|
+
"""Add or replace one installation, keyed by its id.
|
|
817
|
+
|
|
818
|
+
Replacing matters: reinstalling, or widening the repository
|
|
819
|
+
selection, produces the same installation id with different contents,
|
|
820
|
+
and appending would leave the old permissions to be found first.
|
|
821
|
+
"""
|
|
822
|
+
for index, existing in enumerate(self.installations):
|
|
823
|
+
if existing.installation_id == installation.installation_id:
|
|
824
|
+
installation.created_at = (existing.created_at
|
|
825
|
+
or installation.created_at)
|
|
826
|
+
# The grant is carried over only when the new record has none
|
|
827
|
+
# - a `verify` refreshes permissions and has no grant to give,
|
|
828
|
+
# and dropping it there would break the connection it just
|
|
829
|
+
# confirmed. A `connect` brings its own, newer, and matching a
|
|
830
|
+
# key that has just replaced the old one on disk, so it wins.
|
|
831
|
+
if not installation.grant:
|
|
832
|
+
installation.grant = existing.grant
|
|
833
|
+
self.installations[index] = installation
|
|
834
|
+
return
|
|
835
|
+
self.installations.append(installation)
|
|
836
|
+
|
|
837
|
+
def forget(self, installation_id: int) -> bool:
|
|
838
|
+
"""Drop one. True if it was there — a revocation nobody had is not an
|
|
839
|
+
error, but it is worth being able to say so."""
|
|
840
|
+
before = len(self.installations)
|
|
841
|
+
self.installations = [one for one in self.installations
|
|
842
|
+
if one.installation_id != int(installation_id)]
|
|
843
|
+
return len(self.installations) != before
|
|
844
|
+
|
|
845
|
+
|
|
639
846
|
@dataclass
|
|
640
847
|
class MCPConfig:
|
|
641
848
|
"""Servers, and the master switch over all of them."""
|
|
@@ -957,7 +1164,7 @@ DEFAULT_DENY: tuple[str, ...] = (
|
|
|
957
1164
|
SECTIONS = ("ui", "agent", "gateway", "learning", "curator", "skills", "safety",
|
|
958
1165
|
"browser", "computer", "telegram", "whatsapp", "slack", "discord",
|
|
959
1166
|
"cron", "media", "voice", "delegation", "shell", "api", "webhook",
|
|
960
|
-
"image_gen")
|
|
1167
|
+
"image_gen", "github")
|
|
961
1168
|
|
|
962
1169
|
|
|
963
1170
|
@dataclass
|
|
@@ -976,6 +1183,7 @@ class Config:
|
|
|
976
1183
|
whatsapp: WhatsAppConfig = field(default_factory=WhatsAppConfig)
|
|
977
1184
|
slack: SlackConfig = field(default_factory=SlackConfig)
|
|
978
1185
|
discord: DiscordConfig = field(default_factory=DiscordConfig)
|
|
1186
|
+
github: GitHubConfig = field(default_factory=GitHubConfig)
|
|
979
1187
|
mcp: MCPConfig = field(default_factory=MCPConfig)
|
|
980
1188
|
cron: CronConfig = field(default_factory=CronConfig)
|
|
981
1189
|
media: MediaConfig = field(default_factory=MediaConfig)
|
|
@@ -1370,6 +1578,43 @@ def _coerce(key: str, value: Any, current: Any) -> tuple[bool, Any, str]:
|
|
|
1370
1578
|
return False, None, "must be an object"
|
|
1371
1579
|
return True, value, ""
|
|
1372
1580
|
|
|
1581
|
+
def _list_of_dataclasses(field_info: Any, value: Any) -> list[Any] | None:
|
|
1582
|
+
"""Rebuild `list[SomeDataclass]` from the dicts JSON gives back.
|
|
1583
|
+
|
|
1584
|
+
`_apply` understood a nested dataclass and a list of scalars, and a list of
|
|
1585
|
+
dataclasses fell between them: the dicts were stored as dicts, and the
|
|
1586
|
+
first attribute access on one raised `AttributeError` from wherever it
|
|
1587
|
+
happened to be read. Nothing had this shape until now, which is why the
|
|
1588
|
+
gap survived.
|
|
1589
|
+
|
|
1590
|
+
Returns None when the field is not that shape, so the caller falls through
|
|
1591
|
+
to the ordinary coercion.
|
|
1592
|
+
"""
|
|
1593
|
+
if not isinstance(value, list):
|
|
1594
|
+
return None
|
|
1595
|
+
|
|
1596
|
+
annotation = field_info.type
|
|
1597
|
+
# `from __future__ import annotations` makes these strings, so the type is
|
|
1598
|
+
# read from the text rather than from the object.
|
|
1599
|
+
text = annotation if isinstance(annotation, str) else str(annotation)
|
|
1600
|
+
match = re.fullmatch(r"list\[([A-Za-z_][A-Za-z0-9_]*)\]", text.strip())
|
|
1601
|
+
if not match:
|
|
1602
|
+
return None
|
|
1603
|
+
|
|
1604
|
+
kind = globals().get(match.group(1))
|
|
1605
|
+
if not (isinstance(kind, type) and is_dataclass(kind)):
|
|
1606
|
+
return None
|
|
1607
|
+
|
|
1608
|
+
rebuilt = []
|
|
1609
|
+
for entry in value:
|
|
1610
|
+
if not isinstance(entry, dict):
|
|
1611
|
+
continue # a malformed row is dropped, not fatal
|
|
1612
|
+
made = kind()
|
|
1613
|
+
_apply(made, entry)
|
|
1614
|
+
rebuilt.append(made)
|
|
1615
|
+
return rebuilt
|
|
1616
|
+
|
|
1617
|
+
|
|
1373
1618
|
def _apply(section: Any, values: dict[str, Any], where: str = "",
|
|
1374
1619
|
complaints: list[str] | None = None) -> None:
|
|
1375
1620
|
"""Copy known keys onto a dataclass, ignoring unknown ones.
|
|
@@ -1384,6 +1629,7 @@ def _apply(section: Any, values: dict[str, Any], where: str = "",
|
|
|
1384
1629
|
as a string that is true.
|
|
1385
1630
|
"""
|
|
1386
1631
|
valid = {f.name for f in fields(section)}
|
|
1632
|
+
annotated = {f.name: f for f in fields(section)}
|
|
1387
1633
|
for key, value in values.items():
|
|
1388
1634
|
if key not in valid:
|
|
1389
1635
|
continue
|
|
@@ -1391,6 +1637,10 @@ def _apply(section: Any, values: dict[str, Any], where: str = "",
|
|
|
1391
1637
|
if is_dataclass(current) and isinstance(value, dict):
|
|
1392
1638
|
_apply(current, value, f"{where}{key}.", complaints)
|
|
1393
1639
|
continue
|
|
1640
|
+
rebuilt = _list_of_dataclasses(annotated[key], value)
|
|
1641
|
+
if rebuilt is not None:
|
|
1642
|
+
setattr(section, key, rebuilt)
|
|
1643
|
+
continue
|
|
1394
1644
|
ok, coerced, why = _coerce(key, value, current)
|
|
1395
1645
|
if not ok:
|
|
1396
1646
|
if complaints is not None:
|