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.
Files changed (391) hide show
  1. {comodor-1.1.0 → comodor-1.1.2}/.gitignore +19 -0
  2. {comodor-1.1.0 → comodor-1.1.2}/PKG-INFO +2 -1
  3. {comodor-1.1.0 → comodor-1.1.2}/docs/README.md +1 -0
  4. {comodor-1.1.0 → comodor-1.1.2}/pyproject.toml +18 -1
  5. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/_version.py +2 -2
  6. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/agent/verify.py +65 -3
  7. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/browser/launch.py +81 -0
  8. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/channels/settings.py +6 -0
  9. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/cli.py +7 -0
  10. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/config.py +251 -1
  11. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/desktop/guard.py +27 -6
  12. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/desktop/overlay.py +203 -5
  13. comodor-1.1.2/src/comodor/github/__init__.py +53 -0
  14. comodor-1.1.2/src/comodor/github/api.py +446 -0
  15. comodor-1.1.2/src/comodor/github/commands.py +358 -0
  16. comodor-1.1.2/src/comodor/github/connect.py +386 -0
  17. comodor-1.1.2/src/comodor/github/identity.py +304 -0
  18. comodor-1.1.2/src/comodor/github/repos.py +140 -0
  19. comodor-1.1.2/src/comodor/github/tokens.py +153 -0
  20. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/help.py +23 -0
  21. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/telegram/bot.py +138 -29
  22. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/telegram/keyboard.py +13 -0
  23. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/computer.py +37 -1
  24. comodor-1.1.2/src/comodor/tools/github.py +385 -0
  25. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/registry.py +12 -0
  26. {comodor-1.1.0 → comodor-1.1.2}/tests/test_background_delegates.py +36 -18
  27. {comodor-1.1.0 → comodor-1.1.2}/tests/test_bench.py +75 -3
  28. comodor-1.1.2/tests/test_capability_map.py +264 -0
  29. {comodor-1.1.0 → comodor-1.1.2}/tests/test_cron.py +42 -15
  30. {comodor-1.1.0 → comodor-1.1.2}/tests/test_discover.py +4 -0
  31. {comodor-1.1.0 → comodor-1.1.2}/tests/test_facts.py +4 -0
  32. comodor-1.1.2/tests/test_github.py +669 -0
  33. comodor-1.1.2/tests/test_github_identity.py +618 -0
  34. comodor-1.1.2/tests/test_github_tool.py +333 -0
  35. {comodor-1.1.0 → comodor-1.1.2}/tests/test_headless.py +11 -3
  36. comodor-1.1.2/tests/test_nothing_is_left_running.py +234 -0
  37. {comodor-1.1.0 → comodor-1.1.2}/tests/test_overlay.py +1 -0
  38. comodor-1.1.2/tests/test_overlay_buttons.py +632 -0
  39. {comodor-1.1.0 → comodor-1.1.2}/tests/test_performance.py +39 -0
  40. {comodor-1.1.0 → comodor-1.1.2}/tests/test_project_check.py +43 -0
  41. {comodor-1.1.0 → comodor-1.1.2}/tests/test_real_desktop.py +34 -9
  42. {comodor-1.1.0 → comodor-1.1.2}/tests/test_telegram.py +261 -8
  43. {comodor-1.1.0 → comodor-1.1.2}/tests/test_verify.py +6 -1
  44. {comodor-1.1.0 → comodor-1.1.2}/LICENSE +0 -0
  45. {comodor-1.1.0 → comodor-1.1.2}/README.md +0 -0
  46. {comodor-1.1.0 → comodor-1.1.2}/bench/README.md +0 -0
  47. {comodor-1.1.0 → comodor-1.1.2}/bench/tasks/careful-unknowable/repo/README.md +0 -0
  48. {comodor-1.1.0 → comodor-1.1.2}/docs/AR/README.md +0 -0
  49. {comodor-1.1.0 → comodor-1.1.2}/docs/DE/README.md +0 -0
  50. {comodor-1.1.0 → comodor-1.1.2}/docs/ES/README.md +0 -0
  51. {comodor-1.1.0 → comodor-1.1.2}/docs/FA/README.md +0 -0
  52. {comodor-1.1.0 → comodor-1.1.2}/docs/FR/README.md +0 -0
  53. {comodor-1.1.0 → comodor-1.1.2}/docs/RU/README.md +0 -0
  54. {comodor-1.1.0 → comodor-1.1.2}/docs/TR/README.md +0 -0
  55. {comodor-1.1.0 → comodor-1.1.2}/docs/ZH/README.md +0 -0
  56. {comodor-1.1.0 → comodor-1.1.2}/docs/upgrade-specs/README.md +0 -0
  57. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/__init__.py +0 -0
  58. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/__main__.py +0 -0
  59. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/acp/__init__.py +0 -0
  60. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/acp/agent.py +0 -0
  61. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/acp/commands.py +0 -0
  62. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/acp/jsonrpc.py +0 -0
  63. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/agent/__init__.py +0 -0
  64. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/agent/background.py +0 -0
  65. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/agent/claims.py +0 -0
  66. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/agent/constraints.py +0 -0
  67. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/agent/context.py +0 -0
  68. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/agent/loop.py +0 -0
  69. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/agent/plan.py +0 -0
  70. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/agent/prompts.py +0 -0
  71. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/agent/spawn.py +0 -0
  72. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/agent/staleness.py +0 -0
  73. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/agent/tokens.py +0 -0
  74. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/agent/tool_bridge.py +0 -0
  75. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/api/__init__.py +0 -0
  76. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/api/schema.py +0 -0
  77. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/api/server.py +0 -0
  78. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/api/session_map.py +0 -0
  79. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/browser/__init__.py +0 -0
  80. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/browser/cdp.py +0 -0
  81. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/browser/page.py +0 -0
  82. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/catalogue.py +0 -0
  83. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/channels/__init__.py +0 -0
  84. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/channels/breaker.py +0 -0
  85. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/channels/busy.py +0 -0
  86. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/channels/daemon.py +0 -0
  87. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/channels/ledger.py +0 -0
  88. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/channels/markdown.py +0 -0
  89. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/channels/unit.py +0 -0
  90. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/context_refs.py +0 -0
  91. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/cron/__init__.py +0 -0
  92. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/cron/commands.py +0 -0
  93. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/cron/deliver.py +0 -0
  94. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/cron/jobs.py +0 -0
  95. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/cron/parse.py +0 -0
  96. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/cron/runner.py +0 -0
  97. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/cron/scheduler.py +0 -0
  98. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/desktop/__init__.py +0 -0
  99. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/desktop/keys.py +0 -0
  100. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/desktop/png.py +0 -0
  101. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/desktop/quartz.py +0 -0
  102. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/desktop/screen.py +0 -0
  103. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/desktop/win32.py +0 -0
  104. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/desktop/x11.py +0 -0
  105. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/discord/__init__.py +0 -0
  106. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/discord/api.py +0 -0
  107. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/discord/bot.py +0 -0
  108. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/discord/commands.py +0 -0
  109. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/discord/gateway.py +0 -0
  110. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/discord/service.py +0 -0
  111. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/discord/unit.py +0 -0
  112. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/doctor.py +0 -0
  113. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/events.py +0 -0
  114. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/image_gen/__init__.py +0 -0
  115. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/image_gen/registry.py +0 -0
  116. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/insights.py +0 -0
  117. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/__init__.py +0 -0
  118. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/associations.py +0 -0
  119. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/bm25.py +0 -0
  120. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/commands.py +0 -0
  121. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/curator.py +0 -0
  122. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/facts.py +0 -0
  123. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/hotindex.py +0 -0
  124. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/journey.py +0 -0
  125. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/journey_commands.py +0 -0
  126. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/memory.py +0 -0
  127. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/progress.py +0 -0
  128. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/providers/__init__.py +0 -0
  129. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/providers/base.py +0 -0
  130. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/providers/commands.py +0 -0
  131. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/providers/http_generic.py +0 -0
  132. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/reflect.py +0 -0
  133. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/render.py +0 -0
  134. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/review.py +0 -0
  135. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/rules.py +0 -0
  136. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/signals.py +0 -0
  137. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/store.py +0 -0
  138. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/learning/writer.py +0 -0
  139. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/local/__init__.py +0 -0
  140. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/local/catalogue.py +0 -0
  141. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/local/commands.py +0 -0
  142. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/local/download.py +0 -0
  143. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/local/models.json +0 -0
  144. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/local/provider.py +0 -0
  145. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/local/runtime.py +0 -0
  146. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/local/store.py +0 -0
  147. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/mcp/__init__.py +0 -0
  148. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/mcp/catalogue.py +0 -0
  149. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/mcp/commands.py +0 -0
  150. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/mcp/http.py +0 -0
  151. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/mcp/manager.py +0 -0
  152. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/mcp/protocol.py +0 -0
  153. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/media/__init__.py +0 -0
  154. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/media/ingest.py +0 -0
  155. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/media/route.py +0 -0
  156. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/migrate.py +0 -0
  157. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/net/__init__.py +0 -0
  158. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/net/http.py +0 -0
  159. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/net/sse.py +0 -0
  160. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/net/ws.py +0 -0
  161. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/paths.py +0 -0
  162. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/plugins/__init__.py +0 -0
  163. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/plugins/api.py +0 -0
  164. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/plugins/commands.py +0 -0
  165. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/plugins/manager.py +0 -0
  166. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/providers/__init__.py +0 -0
  167. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/providers/anthropic.py +0 -0
  168. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/providers/base.py +0 -0
  169. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/providers/caching.py +0 -0
  170. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/providers/discover.py +0 -0
  171. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/providers/fake.py +0 -0
  172. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/providers/gateway.py +0 -0
  173. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/providers/models.py +0 -0
  174. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/providers/oauth.py +0 -0
  175. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/providers/openai_compat.py +0 -0
  176. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/providers/pool.py +0 -0
  177. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/providers/profile.py +0 -0
  178. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/providers/registry.py +0 -0
  179. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/questions.py +0 -0
  180. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/safety/__init__.py +0 -0
  181. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/safety/backends.py +0 -0
  182. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/safety/checkpoints.py +0 -0
  183. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/safety/mining.py +0 -0
  184. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/safety/permissions.py +0 -0
  185. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/safety/redact.py +0 -0
  186. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/safety/smart.py +0 -0
  187. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/safety/ssrf.py +0 -0
  188. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/session/__init__.py +0 -0
  189. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/session/search.py +0 -0
  190. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/session/store.py +0 -0
  191. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/setup.py +0 -0
  192. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/skills/__init__.py +0 -0
  193. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/skills/catalogue.py +0 -0
  194. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/skills/commands.py +0 -0
  195. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/skills/examples.py +0 -0
  196. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/skills/ledger.py +0 -0
  197. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/skills/linter.py +0 -0
  198. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/skills/loader.py +0 -0
  199. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/skills/propose.py +0 -0
  200. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/skills/registry.py +0 -0
  201. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/skills/threats.py +0 -0
  202. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/skills/usage.py +0 -0
  203. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/slack/__init__.py +0 -0
  204. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/slack/api.py +0 -0
  205. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/slack/blocks.py +0 -0
  206. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/slack/bot.py +0 -0
  207. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/slack/commands.py +0 -0
  208. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/slack/service.py +0 -0
  209. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/slack/socket.py +0 -0
  210. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/slack/unit.py +0 -0
  211. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/telegram/__init__.py +0 -0
  212. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/telegram/api.py +0 -0
  213. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/telegram/commands.py +0 -0
  214. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/telegram/service.py +0 -0
  215. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/telegram/unit.py +0 -0
  216. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/__init__.py +0 -0
  217. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/ask.py +0 -0
  218. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/base.py +0 -0
  219. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/browse.py +0 -0
  220. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/browser.py +0 -0
  221. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/cronjob.py +0 -0
  222. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/delegate.py +0 -0
  223. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/fs.py +0 -0
  224. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/history.py +0 -0
  225. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/image_gen.py +0 -0
  226. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/matching.py +0 -0
  227. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/mcp.py +0 -0
  228. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/mcp_resources.py +0 -0
  229. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/memory.py +0 -0
  230. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/overflow.py +0 -0
  231. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/plugin.py +0 -0
  232. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/propose_mode.py +0 -0
  233. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/search.py +0 -0
  234. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/shell.py +0 -0
  235. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/skill_manage.py +0 -0
  236. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/skills.py +0 -0
  237. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/todo.py +0 -0
  238. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/verify.py +0 -0
  239. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/vision.py +0 -0
  240. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/tools/web.py +0 -0
  241. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/__init__.py +0 -0
  242. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/app.py +0 -0
  243. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/banner.py +0 -0
  244. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/bidi.py +0 -0
  245. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/chooser.py +0 -0
  246. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/clipboard.py +0 -0
  247. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/console.py +0 -0
  248. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/input/__init__.py +0 -0
  249. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/input/keys.py +0 -0
  250. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/input/reader.py +0 -0
  251. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/layout.py +0 -0
  252. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/markdown.py +0 -0
  253. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/screen.py +0 -0
  254. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/theme.py +0 -0
  255. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/widgets/__init__.py +0 -0
  256. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/widgets/buttons.py +0 -0
  257. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/widgets/chat.py +0 -0
  258. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/widgets/history.py +0 -0
  259. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/widgets/overlay.py +0 -0
  260. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/widgets/panel.py +0 -0
  261. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/widgets/progress.py +0 -0
  262. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/widgets/prompt.py +0 -0
  263. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/widgets/questions.py +0 -0
  264. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/widgets/statusbar.py +0 -0
  265. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/widgets/toast.py +0 -0
  266. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/ui/widgets/welcome.py +0 -0
  267. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/uninstall.py +0 -0
  268. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/update.py +0 -0
  269. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/vision.py +0 -0
  270. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/voice/__init__.py +0 -0
  271. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/voice/stt.py +0 -0
  272. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/voice/tts.py +0 -0
  273. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/web/__init__.py +0 -0
  274. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/web/commands.py +0 -0
  275. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/web/index.html +0 -0
  276. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/web/server.py +0 -0
  277. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/web/session.py +0 -0
  278. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/web/ui.css +0 -0
  279. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/web/ui.js +0 -0
  280. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/web/vazirmatn-OFL.txt +0 -0
  281. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/web/vazirmatn.woff2 +0 -0
  282. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/webhook/__init__.py +0 -0
  283. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/webhook/commands.py +0 -0
  284. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/webhook/server.py +0 -0
  285. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/webhook/subs.py +0 -0
  286. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/whatsapp/__init__.py +0 -0
  287. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/whatsapp/api.py +0 -0
  288. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/whatsapp/bot.py +0 -0
  289. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/whatsapp/commands.py +0 -0
  290. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/whatsapp/guide.py +0 -0
  291. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/whatsapp/menu.py +0 -0
  292. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/whatsapp/service.py +0 -0
  293. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/whatsapp/tunnel.py +0 -0
  294. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/whatsapp/unit.py +0 -0
  295. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/whatsapp/webhook.py +0 -0
  296. {comodor-1.1.0 → comodor-1.1.2}/src/comodor/workspace.py +0 -0
  297. {comodor-1.1.0 → comodor-1.1.2}/tests/conftest.py +0 -0
  298. {comodor-1.1.0 → comodor-1.1.2}/tests/support/fake_mcp_server.py +0 -0
  299. {comodor-1.1.0 → comodor-1.1.2}/tests/test_acp.py +0 -0
  300. {comodor-1.1.0 → comodor-1.1.2}/tests/test_agent_loop.py +0 -0
  301. {comodor-1.1.0 → comodor-1.1.2}/tests/test_api.py +0 -0
  302. {comodor-1.1.0 → comodor-1.1.2}/tests/test_app.py +0 -0
  303. {comodor-1.1.0 → comodor-1.1.2}/tests/test_approval_mining.py +0 -0
  304. {comodor-1.1.0 → comodor-1.1.2}/tests/test_associations.py +0 -0
  305. {comodor-1.1.0 → comodor-1.1.2}/tests/test_backends.py +0 -0
  306. {comodor-1.1.0 → comodor-1.1.2}/tests/test_banner.py +0 -0
  307. {comodor-1.1.0 → comodor-1.1.2}/tests/test_bidi.py +0 -0
  308. {comodor-1.1.0 → comodor-1.1.2}/tests/test_blind_writes.py +0 -0
  309. {comodor-1.1.0 → comodor-1.1.2}/tests/test_breaker.py +0 -0
  310. {comodor-1.1.0 → comodor-1.1.2}/tests/test_browser.py +0 -0
  311. {comodor-1.1.0 → comodor-1.1.2}/tests/test_busy_modes.py +0 -0
  312. {comodor-1.1.0 → comodor-1.1.2}/tests/test_caching.py +0 -0
  313. {comodor-1.1.0 → comodor-1.1.2}/tests/test_channel_service.py +0 -0
  314. {comodor-1.1.0 → comodor-1.1.2}/tests/test_chooser.py +0 -0
  315. {comodor-1.1.0 → comodor-1.1.2}/tests/test_claims.py +0 -0
  316. {comodor-1.1.0 → comodor-1.1.2}/tests/test_computer_tool.py +0 -0
  317. {comodor-1.1.0 → comodor-1.1.2}/tests/test_config_save.py +0 -0
  318. {comodor-1.1.0 → comodor-1.1.2}/tests/test_constraints.py +0 -0
  319. {comodor-1.1.0 → comodor-1.1.2}/tests/test_context_refs.py +0 -0
  320. {comodor-1.1.0 → comodor-1.1.2}/tests/test_copy_and_bands.py +0 -0
  321. {comodor-1.1.0 → comodor-1.1.2}/tests/test_cronjob.py +0 -0
  322. {comodor-1.1.0 → comodor-1.1.2}/tests/test_curator.py +0 -0
  323. {comodor-1.1.0 → comodor-1.1.2}/tests/test_delegate.py +0 -0
  324. {comodor-1.1.0 → comodor-1.1.2}/tests/test_delivery_ledger.py +0 -0
  325. {comodor-1.1.0 → comodor-1.1.2}/tests/test_desktop.py +0 -0
  326. {comodor-1.1.0 → comodor-1.1.2}/tests/test_discord.py +0 -0
  327. {comodor-1.1.0 → comodor-1.1.2}/tests/test_docker.py +0 -0
  328. {comodor-1.1.0 → comodor-1.1.2}/tests/test_doctor.py +0 -0
  329. {comodor-1.1.0 → comodor-1.1.2}/tests/test_help.py +0 -0
  330. {comodor-1.1.0 → comodor-1.1.2}/tests/test_history.py +0 -0
  331. {comodor-1.1.0 → comodor-1.1.2}/tests/test_image_gen.py +0 -0
  332. {comodor-1.1.0 → comodor-1.1.2}/tests/test_import_command.py +0 -0
  333. {comodor-1.1.0 → comodor-1.1.2}/tests/test_input.py +0 -0
  334. {comodor-1.1.0 → comodor-1.1.2}/tests/test_insights.py +0 -0
  335. {comodor-1.1.0 → comodor-1.1.2}/tests/test_journey.py +0 -0
  336. {comodor-1.1.0 → comodor-1.1.2}/tests/test_key_pool.py +0 -0
  337. {comodor-1.1.0 → comodor-1.1.2}/tests/test_layout.py +0 -0
  338. {comodor-1.1.0 → comodor-1.1.2}/tests/test_learning.py +0 -0
  339. {comodor-1.1.0 → comodor-1.1.2}/tests/test_live_settings.py +0 -0
  340. {comodor-1.1.0 → comodor-1.1.2}/tests/test_local.py +0 -0
  341. {comodor-1.1.0 → comodor-1.1.2}/tests/test_markdown.py +0 -0
  342. {comodor-1.1.0 → comodor-1.1.2}/tests/test_matching.py +0 -0
  343. {comodor-1.1.0 → comodor-1.1.2}/tests/test_mcp.py +0 -0
  344. {comodor-1.1.0 → comodor-1.1.2}/tests/test_mcp_http.py +0 -0
  345. {comodor-1.1.0 → comodor-1.1.2}/tests/test_mcp_resources.py +0 -0
  346. {comodor-1.1.0 → comodor-1.1.2}/tests/test_media.py +0 -0
  347. {comodor-1.1.0 → comodor-1.1.2}/tests/test_memory_provider.py +0 -0
  348. {comodor-1.1.0 → comodor-1.1.2}/tests/test_memory_tool.py +0 -0
  349. {comodor-1.1.0 → comodor-1.1.2}/tests/test_migrate.py +0 -0
  350. {comodor-1.1.0 → comodor-1.1.2}/tests/test_oauth.py +0 -0
  351. {comodor-1.1.0 → comodor-1.1.2}/tests/test_overflow.py +0 -0
  352. {comodor-1.1.0 → comodor-1.1.2}/tests/test_plan_memory.py +0 -0
  353. {comodor-1.1.0 → comodor-1.1.2}/tests/test_plugins.py +0 -0
  354. {comodor-1.1.0 → comodor-1.1.2}/tests/test_prefix_stability.py +0 -0
  355. {comodor-1.1.0 → comodor-1.1.2}/tests/test_profile.py +0 -0
  356. {comodor-1.1.0 → comodor-1.1.2}/tests/test_profiles.py +0 -0
  357. {comodor-1.1.0 → comodor-1.1.2}/tests/test_progress.py +0 -0
  358. {comodor-1.1.0 → comodor-1.1.2}/tests/test_propose.py +0 -0
  359. {comodor-1.1.0 → comodor-1.1.2}/tests/test_propose_mode.py +0 -0
  360. {comodor-1.1.0 → comodor-1.1.2}/tests/test_providers.py +0 -0
  361. {comodor-1.1.0 → comodor-1.1.2}/tests/test_quartz_backend.py +0 -0
  362. {comodor-1.1.0 → comodor-1.1.2}/tests/test_questions.py +0 -0
  363. {comodor-1.1.0 → comodor-1.1.2}/tests/test_real_browser.py +0 -0
  364. {comodor-1.1.0 → comodor-1.1.2}/tests/test_real_web_ui.py +0 -0
  365. {comodor-1.1.0 → comodor-1.1.2}/tests/test_reflex.py +0 -0
  366. {comodor-1.1.0 → comodor-1.1.2}/tests/test_release_workflow.py +0 -0
  367. {comodor-1.1.0 → comodor-1.1.2}/tests/test_revoked_writes.py +0 -0
  368. {comodor-1.1.0 → comodor-1.1.2}/tests/test_run_loop.py +0 -0
  369. {comodor-1.1.0 → comodor-1.1.2}/tests/test_setup.py +0 -0
  370. {comodor-1.1.0 → comodor-1.1.2}/tests/test_setup_import.py +0 -0
  371. {comodor-1.1.0 → comodor-1.1.2}/tests/test_skill_budget.py +0 -0
  372. {comodor-1.1.0 → comodor-1.1.2}/tests/test_skill_catalogue.py +0 -0
  373. {comodor-1.1.0 → comodor-1.1.2}/tests/test_skill_manage.py +0 -0
  374. {comodor-1.1.0 → comodor-1.1.2}/tests/test_skills.py +0 -0
  375. {comodor-1.1.0 → comodor-1.1.2}/tests/test_slack.py +0 -0
  376. {comodor-1.1.0 → comodor-1.1.2}/tests/test_smart_approvals.py +0 -0
  377. {comodor-1.1.0 → comodor-1.1.2}/tests/test_source_hygiene.py +0 -0
  378. {comodor-1.1.0 → comodor-1.1.2}/tests/test_ssrf.py +0 -0
  379. {comodor-1.1.0 → comodor-1.1.2}/tests/test_staleness.py +0 -0
  380. {comodor-1.1.0 → comodor-1.1.2}/tests/test_switching_model.py +0 -0
  381. {comodor-1.1.0 → comodor-1.1.2}/tests/test_tool_bridge.py +0 -0
  382. {comodor-1.1.0 → comodor-1.1.2}/tests/test_tools.py +0 -0
  383. {comodor-1.1.0 → comodor-1.1.2}/tests/test_uninstall.py +0 -0
  384. {comodor-1.1.0 → comodor-1.1.2}/tests/test_update.py +0 -0
  385. {comodor-1.1.0 → comodor-1.1.2}/tests/test_vision_tool.py +0 -0
  386. {comodor-1.1.0 → comodor-1.1.2}/tests/test_voice.py +0 -0
  387. {comodor-1.1.0 → comodor-1.1.2}/tests/test_web.py +0 -0
  388. {comodor-1.1.0 → comodor-1.1.2}/tests/test_webhook.py +0 -0
  389. {comodor-1.1.0 → comodor-1.1.2}/tests/test_welcome.py +0 -0
  390. {comodor-1.1.0 → comodor-1.1.2}/tests/test_whatsapp.py +0 -0
  391. {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.0
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
- dev = ["pytest>=8.0", "ruff>=0.14", "pyyaml>=6.0"]
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.0'
22
- __version_tuple__ = version_tuple = (1, 1, 0)
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
- finished = subprocess.run(
112
+ child = subprocess.Popen(
70
113
  command, shell=True, cwd=str(cwd), env=environment,
71
- capture_output=True, text=True, encoding="utf-8",
72
- errors="replace", timeout=patience)
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: