cli-agent-runner 0.1.42__tar.gz → 0.2.1__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 (247) hide show
  1. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/CHANGELOG.md +22 -1
  2. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/PKG-INFO +2 -2
  3. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/README.md +1 -1
  4. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/README.zh.md +1 -1
  5. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/__init__.py +4 -0
  6. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/_version.py +2 -2
  7. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/agent_runtime.py +47 -2
  8. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/api.py +6 -0
  9. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/api_types.py +13 -0
  10. cli_agent_runner-0.2.1/agent_runner/builtin_plugins/default_dirty_handler.py +83 -0
  11. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/config.py +15 -1
  12. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/events.py +1 -0
  13. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/hooks.py +67 -0
  14. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/presets/claude.toml +2 -1
  15. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/runner.py +27 -45
  16. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/vcs_state.py +15 -17
  17. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/architecture.md +26 -2
  18. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/configuration.md +21 -2
  19. cli_agent_runner-0.2.1/docs/migrations/0.2.0.md +166 -0
  20. cli_agent_runner-0.2.1/docs/migrations/0.2.1.md +77 -0
  21. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/plugins.md +122 -0
  22. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/runbook.md +27 -0
  23. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/pyproject.toml +3 -0
  24. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/_test_helpers.py +64 -5
  25. cli_agent_runner-0.2.1/tests/integration/test_dirty_handler_seam.py +74 -0
  26. cli_agent_runner-0.2.1/tests/integration/test_prompt_delivery_stdin.py +103 -0
  27. cli_agent_runner-0.2.1/tests/unit/test_agent_runtime_signal_name.py +21 -0
  28. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_api_types.py +27 -0
  29. cli_agent_runner-0.2.1/tests/unit/test_catalogs.py +10 -0
  30. cli_agent_runner-0.2.1/tests/unit/test_config_prompt_delivery.py +33 -0
  31. cli_agent_runner-0.2.1/tests/unit/test_default_dirty_handler.py +77 -0
  32. cli_agent_runner-0.2.1/tests/unit/test_dirty_handlers.py +49 -0
  33. cli_agent_runner-0.2.1/tests/unit/test_exit_cause.py +15 -0
  34. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_init_entry_points.py +3 -2
  35. cli_agent_runner-0.2.1/tests/unit/test_network_blip_signal_guard.py +47 -0
  36. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_presets.py +19 -3
  37. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_vcs_state.py +27 -4
  38. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/.codecov.yml +0 -0
  39. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  40. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  41. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  42. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  43. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/.github/workflows/ci.yml +0 -0
  44. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/.github/workflows/release.yml +0 -0
  45. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/.gitignore +0 -0
  46. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/.vulture-whitelist.py +0 -0
  47. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/CODE_OF_CONDUCT.md +0 -0
  48. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/CONTRIBUTING.md +0 -0
  49. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/LICENSE +0 -0
  50. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/SECURITY.md +0 -0
  51. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/_docgen.py +0 -0
  52. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/_emit.py +0 -0
  53. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/_redact.py +0 -0
  54. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/_registry.py +0 -0
  55. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/_substrate.py +0 -0
  56. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/_throttle.py +0 -0
  57. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/builtin_plugins/__init__.py +0 -0
  58. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/builtin_plugins/_constants.py +0 -0
  59. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/builtin_plugins/claude_rate_limit.py +0 -0
  60. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/builtin_plugins/codewhale.py +0 -0
  61. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/builtin_plugins/gemini.py +0 -0
  62. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/cli/__init__.py +0 -0
  63. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/cli/__main__.py +0 -0
  64. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/cli/common.py +0 -0
  65. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/cli/events_cmd.py +0 -0
  66. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/cli/init_cmd.py +0 -0
  67. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/cli/install_cmd.py +0 -0
  68. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/cli/monitor_cmd.py +0 -0
  69. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/cli/peek_cmd.py +0 -0
  70. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/cli/round_cmd.py +0 -0
  71. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/cli/serve_cmd.py +0 -0
  72. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/cli/service_cmd.py +0 -0
  73. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/cli/upgrade_cmd.py +0 -0
  74. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/context_store.py +0 -0
  75. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/defenses.py +0 -0
  76. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/detector_helpers.py +0 -0
  77. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/http_progress.py +0 -0
  78. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/lifecycle.py +0 -0
  79. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/metrics.py +0 -0
  80. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/monitor.py +0 -0
  81. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/presets/__init__.py +0 -0
  82. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/presets/aider.toml +0 -0
  83. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/presets/codewhale.toml +0 -0
  84. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/presets/gemini.toml +0 -0
  85. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/prompt_loader.py +0 -0
  86. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/round_log.py +0 -0
  87. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/round_view.py +0 -0
  88. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/scaffold.py +0 -0
  89. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/service_unit.py +0 -0
  90. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/agent_runner/startup_check.py +0 -0
  91. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/build.sh +0 -0
  92. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/deploy/example-agent-runner.toml +0 -0
  93. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/deploy/launchd.plist.tmpl +0 -0
  94. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/deploy/run-loop.sh +0 -0
  95. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/deploy/systemd.service.tmpl +0 -0
  96. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/README.md +0 -0
  97. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/commands.md +0 -0
  98. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/events.md +0 -0
  99. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/long-running-agents.md +0 -0
  100. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/marketing/README.md +0 -0
  101. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/marketing/promo-cn.html +0 -0
  102. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/migrations/0.1.16.md +0 -0
  103. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/migrations/0.1.17.md +0 -0
  104. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/migrations/0.1.19.md +0 -0
  105. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/migrations/0.1.20.md +0 -0
  106. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/migrations/0.1.21.md +0 -0
  107. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/migrations/0.1.22.md +0 -0
  108. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/migrations/0.1.23.md +0 -0
  109. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/migrations/0.1.24.md +0 -0
  110. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/migrations/0.1.25.md +0 -0
  111. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/migrations/0.1.26.md +0 -0
  112. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/migrations/0.1.27.md +0 -0
  113. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/migrations/0.1.28.md +0 -0
  114. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/migrations/0.1.29.md +0 -0
  115. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/migrations/0.1.30.md +0 -0
  116. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/migrations/0.1.31.md +0 -0
  117. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/migrations/0.1.32.md +0 -0
  118. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/migrations/0.1.33.md +0 -0
  119. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/migrations/0.1.34.md +0 -0
  120. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/migrations/0.1.35.md +0 -0
  121. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/migrations/0.1.36.md +0 -0
  122. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/migrations/0.1.37.md +0 -0
  123. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/migrations/0.1.38.md +0 -0
  124. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/migrations/0.1.39.md +0 -0
  125. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/migrations/0.1.40.md +0 -0
  126. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/migrations/0.1.42.md +0 -0
  127. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/quickstart.md +0 -0
  128. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/recipes/aider.md +0 -0
  129. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/recipes/codewhale.md +0 -0
  130. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/docs/thesis.md +0 -0
  131. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/__init__.py +0 -0
  132. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/conftest.py +0 -0
  133. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/contract/__init__.py +0 -0
  134. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/contract/test_public_api_surface.py +0 -0
  135. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/e2e/__init__.py +0 -0
  136. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/e2e/conftest.py +0 -0
  137. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/e2e/test_e2e_graceful_stop.py +0 -0
  138. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/e2e/test_e2e_install_systemd.py +0 -0
  139. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/e2e/test_e2e_monitor_remote.py +0 -0
  140. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/e2e/test_e2e_round_lifecycle.py +0 -0
  141. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/fixtures/cli-real-output/claude-2.1.143-assistant-tool-use.jsonl +0 -0
  142. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/fixtures/cli-real-output/claude-2.1.143-result-event.jsonl +0 -0
  143. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/fixtures/cli-real-output/gemini-0.42.0-result-event.jsonl +0 -0
  144. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/integration/__init__.py +0 -0
  145. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/integration/test_bounded_run.py +0 -0
  146. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/integration/test_context_enricher_namespacing.py +0 -0
  147. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/integration/test_fresh_eyes_signal.py +0 -0
  148. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/integration/test_grace_kill_emission.py +0 -0
  149. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/integration/test_install_dry_run.py +0 -0
  150. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/integration/test_monitor_seeded.py +0 -0
  151. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/integration/test_plugin_detector_loaded.py +0 -0
  152. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/integration/test_plugin_owned_paths.py +0 -0
  153. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/integration/test_plugin_real_flow.py +0 -0
  154. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/integration/test_run_one_round_with_fake_agent.py +0 -0
  155. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/integration/test_scaffold_presets.py +0 -0
  156. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/integration/test_serve_loop.py +0 -0
  157. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/integration/test_substrate_fingerprint.py +0 -0
  158. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/integration/test_transient_error_backoff.py +0 -0
  159. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/invariants/__init__.py +0 -0
  160. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/invariants/test_architecture.py +0 -0
  161. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/invariants/test_atomic_write_enforced.py +0 -0
  162. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/invariants/test_catalogs.py +0 -0
  163. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/invariants/test_classification_ssot.py +0 -0
  164. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/invariants/test_doc_claims_match_ssot.py +0 -0
  165. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/invariants/test_docs_generated.py +0 -0
  166. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/invariants/test_entry_points_resolve.py +0 -0
  167. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/invariants/test_event_kind_registry.py +0 -0
  168. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/invariants/test_event_kinds_ssot.py +0 -0
  169. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/invariants/test_events_doc_contract.py +0 -0
  170. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/invariants/test_layer_2_loop_size.py +0 -0
  171. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/invariants/test_module_boundaries.py +0 -0
  172. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/invariants/test_module_sizes.py +0 -0
  173. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/invariants/test_no_ai_signatures.py +0 -0
  174. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/invariants/test_no_pytest_skip_on_parse_fail.py +0 -0
  175. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/invariants/test_peek_schema_version.py +0 -0
  176. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/invariants/test_repo_constants_patched_in_tests.py +0 -0
  177. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/invariants/test_round_result_stable.py +0 -0
  178. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/invariants/test_stash_uses_sha_not_index.py +0 -0
  179. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/invariants/test_upstream_schema_canary.py +0 -0
  180. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/literate/__init__.py +0 -0
  181. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/literate/parser.py +0 -0
  182. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/literate/test_parser.py +0 -0
  183. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/literate/test_quickstart.py +0 -0
  184. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/__init__.py +0 -0
  185. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_agent_runtime.py +0 -0
  186. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_agent_runtime_grace.py +0 -0
  187. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_agent_runtime_progress.py +0 -0
  188. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_api_assemble_prompt.py +0 -0
  189. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_api_events_stream.py +0 -0
  190. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_api_install.py +0 -0
  191. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_api_observation.py +0 -0
  192. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_api_read_round_num.py +0 -0
  193. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_api_resolve_phase.py +0 -0
  194. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_api_service.py +0 -0
  195. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_auto_stop_gating.py +0 -0
  196. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_claude_error_detector.py +0 -0
  197. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_cli.py +0 -0
  198. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_cli_common.py +0 -0
  199. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_cli_init_install.py +0 -0
  200. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_cli_monitor_http.py +0 -0
  201. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_cli_service_peek_monitor.py +0 -0
  202. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_cli_upgrade.py +0 -0
  203. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_codewhale_plugin.py +0 -0
  204. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_config.py +0 -0
  205. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_config_fresh_eyes.py +0 -0
  206. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_config_max_rounds.py +0 -0
  207. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_config_stop_file.py +0 -0
  208. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_config_substrate_fingerprint_paths.py +0 -0
  209. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_config_transient_error_action.py +0 -0
  210. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_context_store.py +0 -0
  211. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_defenses.py +0 -0
  212. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_detector_helpers.py +0 -0
  213. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_detector_protocol.py +0 -0
  214. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_docgen.py +0 -0
  215. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_events.py +0 -0
  216. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_events_cmd.py +0 -0
  217. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_fresh_eyes_trigger.py +0 -0
  218. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_gemini_plugin.py +0 -0
  219. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_hook_failure_isolation.py +0 -0
  220. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_hooks.py +0 -0
  221. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_http_progress.py +0 -0
  222. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_lifecycle.py +0 -0
  223. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_metrics.py +0 -0
  224. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_monitor_assembly.py +0 -0
  225. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_monitor_detect_anomaly_repetitive.py +0 -0
  226. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_monitor_detect_rate_limit.py +0 -0
  227. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_monitor_detect_supervisor_stale.py +0 -0
  228. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_monitor_detectors.py +0 -0
  229. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_monitor_remote.py +0 -0
  230. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_peek_argparse.py +0 -0
  231. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_peek_select.py +0 -0
  232. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_prompt_loader.py +0 -0
  233. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_redact.py +0 -0
  234. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_round_log_helpers.py +0 -0
  235. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_round_view.py +0 -0
  236. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_runner.py +0 -0
  237. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_runner_throttle.py +0 -0
  238. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_scaffold.py +0 -0
  239. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_serve_cmd_bounded.py +0 -0
  240. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_serve_config_broken.py +0 -0
  241. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_serve_crash_loop.py +0 -0
  242. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_serve_round_log.py +0 -0
  243. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_serve_sentinel.py +0 -0
  244. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_serve_startup_hooks.py +0 -0
  245. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_service_unit.py +0 -0
  246. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_startup_check.py +0 -0
  247. {cli_agent_runner-0.1.42 → cli_agent_runner-0.2.1}/tests/unit/test_substrate.py +0 -0
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.1] - 2026-07-16
9
+
10
+ ### Added
11
+ - `[agent] prompt_delivery = "argv" | "stdin"` (default `argv`). `stdin` pipes the prompt to the agent's stdin instead of placing it in argv, closing a `pkill -f <token>` self-kill footgun (a background worker matching on a token from the prompt could match the agent's own argv). The `claude` preset now defaults to `stdin`; existing configs are unaffected.
12
+ - `agent_exit` gains `exit_cause` (`clean` / `error` / `signal:<NAME>` / `timeout`) so monitors can distinguish a clean exit from a signal-kill without a heuristic.
13
+
14
+ ### Fixed
15
+ - A signal-killed round is no longer misclassified as `agent_network_blip` when its log happens to contain a network-error string; signal deaths are terminations, not network blips.
16
+
17
+ See `docs/migrations/0.2.1.md`.
18
+
19
+ ## [0.2.0] - 2026-07-02
20
+
21
+ ### Changed
22
+ - Dirty-tree handling is now pluggable. The clean-exit-dirty policy moved out of core into a `dirty_handler` hook; `stash` / `ignore` / `auto_commit` now ship as the bundled, default-on `default_dirty_handler` plugin. **No behavior or config change for existing users** — `[vcs] dirty_action` works exactly as before.
23
+
24
+ ### Added
25
+ - `dirty_handler` plugin seam (4th lifecycle hook) + `DirtyOutcome` on `RoundResult`; `stash_orphan` / `try_auto_commit` are now public `api` primitives. Consumers can fully own dirty-tree policy — see `docs/migrations/0.2.0.md`.
26
+ - `dirty_auto_committed` event (emitted when a handler auto-commits).
27
+
8
28
  ## [0.1.42] - 2026-06-25
9
29
 
10
30
  ### Added
@@ -866,7 +886,8 @@ Initial public release on PyPI as `cli-agent-runner`.
866
886
  - Tag-triggered release publishing to PyPI via Trusted Publishing OIDC,
867
887
  gated by a manual approval on the `pypi` GitHub environment.
868
888
 
869
- [Unreleased]: https://github.com/wan9yu/cli-agent-runner/compare/v0.1.26...HEAD
889
+ [Unreleased]: https://github.com/wan9yu/cli-agent-runner/compare/v0.2.0...HEAD
890
+ [0.2.0]: https://github.com/wan9yu/cli-agent-runner/compare/v0.1.42...v0.2.0
870
891
  [0.1.26]: https://github.com/wan9yu/cli-agent-runner/compare/v0.1.25...v0.1.26
871
892
  [0.1.25]: https://github.com/wan9yu/cli-agent-runner/compare/v0.1.24...v0.1.25
872
893
  [0.1.24]: https://github.com/wan9yu/cli-agent-runner/compare/v0.1.23...v0.1.24
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli-agent-runner
3
- Version: 0.1.42
3
+ Version: 0.2.1
4
4
  Summary: Restart-on-exit supervisor for autonomous CLI agents
5
5
  Project-URL: Homepage, https://github.com/wan9yu/cli-agent-runner
6
6
  Project-URL: Documentation, https://github.com/wan9yu/cli-agent-runner#readme
@@ -109,7 +109,7 @@ Full list and rationale: [`docs/architecture.md`](docs/architecture.md).
109
109
  ## Monitor: 11 detectors
110
110
 
111
111
  Notify only: `timeout_rate`, `hung`, `orphan_chain`, `disk_warning`,
112
- `mem_pressure`, `smoke_fail_rate`, `network_fail`, `rate_limit_active`,
112
+ `mem_pressure`, `network_fail`, `rate_limit_active`,
113
113
  `anomaly_repetitive_active`, `supervisor_stale`.
114
114
 
115
115
  **Auto-stop the service** (continuing is harmful):
@@ -72,7 +72,7 @@ Full list and rationale: [`docs/architecture.md`](docs/architecture.md).
72
72
  ## Monitor: 11 detectors
73
73
 
74
74
  Notify only: `timeout_rate`, `hung`, `orphan_chain`, `disk_warning`,
75
- `mem_pressure`, `smoke_fail_rate`, `network_fail`, `rate_limit_active`,
75
+ `mem_pressure`, `network_fail`, `rate_limit_active`,
76
76
  `anomaly_repetitive_active`, `supervisor_stale`.
77
77
 
78
78
  **Auto-stop the service** (continuing is harmful):
@@ -99,7 +99,7 @@ agent-runner monitor # 实时异常检测,OAuth/磁盘 critical
99
99
 
100
100
  **只告警**(warning 级,服务继续跑):
101
101
  `timeout_rate` / `hung` / `orphan_chain` / `disk_warning` /
102
- `mem_pressure` / `smoke_fail_rate` / `network_fail` / `rate_limit_active` /
102
+ `mem_pressure` / `network_fail` / `rate_limit_active` /
103
103
  `anomaly_repetitive_active` / `supervisor_stale`
104
104
 
105
105
  **自动停服**(critical 级,继续是 net negative):
@@ -14,6 +14,7 @@ _HOOK_GROUPS = (
14
14
  "agent_runner.context_enrichers",
15
15
  "agent_runner.post_round_hooks",
16
16
  "agent_runner.serve_startup_hooks",
17
+ "agent_runner.dirty_handler_hooks",
17
18
  )
18
19
 
19
20
  # Tracks the names passed to the most recent ``apply_plugin_disable`` call.
@@ -111,6 +112,9 @@ def apply_plugin_disable(names: list[str]) -> None:
111
112
  # Serve-startup hooks
112
113
  _prune_by_name(hooks._SERVE_STARTUP_HOOKS, desired, found)
113
114
 
115
+ # Dirty handlers
116
+ _prune_by_name(hooks._DIRTY_HANDLERS, desired, found)
117
+
114
118
  # Plugin event kinds
115
119
  for name in list(events._PLUGIN_KINDS):
116
120
  if name in desired:
@@ -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 = '0.1.42'
22
- __version_tuple__ = version_tuple = (0, 1, 42)
21
+ __version__ = version = '0.2.1'
22
+ __version_tuple__ = version_tuple = (0, 2, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -14,6 +14,7 @@ import os
14
14
  import re
15
15
  import signal
16
16
  import subprocess # noqa: TID251 — sanctioned subprocess caller
17
+ import threading
17
18
  import time
18
19
  from collections.abc import Callable
19
20
  from dataclasses import dataclass, field
@@ -24,6 +25,25 @@ import psutil
24
25
  REAP_GRACE_S = 5
25
26
 
26
27
 
28
+ def signal_name(exit_code: int) -> str | None:
29
+ """Signal name for a signal death, else None.
30
+
31
+ Handles both the Python-negative form (``-15``) and the shell ``128+N``
32
+ form (``143``). Returns None for a non-signal exit — including a legitimate
33
+ ``> 128`` code (e.g. 200) that is not a valid signal number.
34
+ """
35
+ if exit_code < 0:
36
+ n = -exit_code
37
+ elif exit_code > 128:
38
+ n = exit_code - 128
39
+ else:
40
+ return None
41
+ try:
42
+ return signal.Signals(n).name
43
+ except ValueError:
44
+ return None
45
+
46
+
27
47
  @dataclass(frozen=True)
28
48
  class RunResult:
29
49
  exit_code: int
@@ -116,6 +136,7 @@ def run(
116
136
  command: list[str],
117
137
  prompt_arg_template: list[str],
118
138
  prompt: str,
139
+ prompt_delivery: str = "argv",
119
140
  timeout_s: int,
120
141
  log_path: Path,
121
142
  env_extra: dict[str, str],
@@ -145,7 +166,16 @@ def run(
145
166
  matching any pattern (re.search) are excluded from the liveness count
146
167
  (persistent helpers that aren't real workers). None = no filtering.
147
168
  """
148
- argv = _build_argv(command, prompt_arg_template, prompt)
169
+ stdin_mode = prompt_delivery == "stdin"
170
+ # Defense-in-depth: config validation already rejects {prompt} in the
171
+ # template for stdin mode, but run() must be safe even if called
172
+ # directly with a mismatched template. In stdin mode, never substitute
173
+ # {prompt} into argv — build it verbatim so the prompt cannot reach argv.
174
+ argv = (
175
+ list(command) + list(prompt_arg_template)
176
+ if stdin_mode
177
+ else _build_argv(command, prompt_arg_template, prompt)
178
+ )
149
179
  env = {**os.environ, **env_extra}
150
180
  log_path.parent.mkdir(parents=True, exist_ok=True)
151
181
  log_file = log_path.open("w", encoding="utf-8")
@@ -154,11 +184,26 @@ def run(
154
184
  proc = subprocess.Popen(
155
185
  argv,
156
186
  env=env,
157
- stdin=subprocess.DEVNULL,
187
+ stdin=subprocess.PIPE if stdin_mode else subprocess.DEVNULL,
158
188
  stdout=log_file,
159
189
  stderr=subprocess.STDOUT,
160
190
  start_new_session=True,
161
191
  )
192
+ if stdin_mode and proc.stdin is not None:
193
+ # Write+close on a daemon thread so the wall-clock timeout loop below
194
+ # is never blocked by a write >64KB (OS pipe buffer) to an agent that
195
+ # doesn't drain stdin (R1128: no unbounded-hang path).
196
+ stdin_pipe = proc.stdin
197
+ stdin_data = prompt.encode("utf-8")
198
+
199
+ def _write_stdin():
200
+ try:
201
+ stdin_pipe.write(stdin_data)
202
+ stdin_pipe.close()
203
+ except (BrokenPipeError, OSError):
204
+ pass # agent exited before reading stdin; the poll loop handles exit
205
+
206
+ threading.Thread(target=_write_stdin, daemon=True).start()
162
207
  result_seen_at: float | None = None
163
208
  grace_extended_emitted = False
164
209
  try:
@@ -799,6 +799,12 @@ from agent_runner._emit import ( # noqa: E402,F401 — intentional bottom re-ex
799
799
  emit_transient_error_recovered,
800
800
  )
801
801
 
802
+ # Re-export git primitives so external callers can reach them via the api facade.
803
+ from agent_runner.vcs_state import ( # noqa: E402,F401 — public primitives
804
+ stash_orphan,
805
+ try_auto_commit,
806
+ )
807
+
802
808
 
803
809
  def narrate_events(log_dir: Path, *, poll_interval_s: float = 0.5) -> Iterator[str]:
804
810
  """Tail events-*.jsonl files in log_dir, yielding one formatted line per event.
@@ -28,6 +28,18 @@ class ServiceMode(StrEnum):
28
28
  NONE = "none"
29
29
 
30
30
 
31
+ @dataclass(frozen=True)
32
+ class DirtyOutcome:
33
+ """What a dirty tree resolved to, reported on RoundResult and via events.
34
+
35
+ ``ref`` is the stash SHA (kind="stashed") or commit SHA (kind="committed");
36
+ None for "ignored".
37
+ """
38
+
39
+ kind: Literal["stashed", "committed", "ignored"]
40
+ ref: str | None = None
41
+
42
+
31
43
  @dataclass(frozen=True)
32
44
  class RateLimitState:
33
45
  """Public: surfaced via peek --json when supervisor is currently throttled."""
@@ -158,6 +170,7 @@ class RoundResult:
158
170
  log_path: Path
159
171
  dirty_files: list[str]
160
172
  stashed: bool
173
+ dirty_outcome: DirtyOutcome | None = None
161
174
  killed_for_grace: bool = False
162
175
 
163
176
 
@@ -0,0 +1,83 @@
1
+ """Bundled default dirty-tree handler — reproduces the pre-0.2.0 built-in
2
+ behavior (stash / ignore / auto_commit per [vcs] dirty_action) as a plugin.
3
+ Ships enabled; disable via [plugins] disable = ["default_dirty_handler"]."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from agent_runner import api, context_store, events, vcs_state
8
+ from agent_runner.api_types import DirtyOutcome
9
+ from agent_runner.events import now_iso_ms # match the helper runner.py uses
10
+ from agent_runner.hooks import HookContext, register_dirty_handler
11
+ from agent_runner.vcs_state import AutoCommitError
12
+
13
+
14
+ class DefaultDirtyHandler:
15
+ name = "default_dirty_handler"
16
+ priority = 1000 # last-resort fallback
17
+
18
+ def handle_dirty(self, ctx: HookContext, dirty_files) -> DirtyOutcome | None:
19
+ action = ctx.vcs.dirty_action if ctx.vcs else "stash"
20
+ if action == "ignore":
21
+ return DirtyOutcome(kind="ignored")
22
+ if action == "auto_commit":
23
+ try:
24
+ sha = api.try_auto_commit(
25
+ ctx.work_dir, ctx.round_num, ctx.phase, log_dir=ctx.log_dir
26
+ )
27
+ except AutoCommitError as exc:
28
+ # Parity with runner.py: emit failure event, leave tree dirty
29
+ # (no stash fallback). Dirty tree carries into the next round.
30
+ events.emit(
31
+ ctx.log_dir,
32
+ events.DIRTY_COMMIT_FAILED,
33
+ round_num=ctx.round_num,
34
+ phase=ctx.phase,
35
+ reason=str(exc),
36
+ )
37
+ return DirtyOutcome(kind="ignored")
38
+ if not sha:
39
+ return DirtyOutcome(kind="ignored")
40
+ events.emit(
41
+ ctx.log_dir,
42
+ events.DIRTY_AUTO_COMMITTED,
43
+ round_num=ctx.round_num,
44
+ files=dirty_files[:20],
45
+ ref=sha,
46
+ )
47
+ return DirtyOutcome(kind="committed", ref=sha)
48
+ return self._stash(ctx, dirty_files)
49
+
50
+ def _stash(self, ctx: HookContext, dirty_files) -> DirtyOutcome:
51
+ # via vcs_state (not api): test_runner.py monkeypatches vcs_state.stash_orphan;
52
+ # the api re-export is a separate binding a patch wouldn't intercept.
53
+ ref = vcs_state.stash_orphan(
54
+ ctx.work_dir,
55
+ round_num=ctx.round_num,
56
+ phase=ctx.phase,
57
+ idempotency_s=ctx.vcs.stash_idempotency_s if ctx.vcs else 5,
58
+ log_dir=ctx.log_dir,
59
+ )
60
+ if ref is None:
61
+ return DirtyOutcome(kind="ignored")
62
+ context_store.write_orphan_state(
63
+ ctx.log_dir,
64
+ context_store.OrphanState(
65
+ round_num=ctx.round_num,
66
+ files=dirty_files,
67
+ stashed_ref=ref.sha,
68
+ stash_message=ref.message,
69
+ timestamp=now_iso_ms(),
70
+ phase=ctx.phase,
71
+ ),
72
+ )
73
+ events.emit(
74
+ ctx.log_dir,
75
+ events.ORPHAN_STASHED,
76
+ round_num=ctx.round_num,
77
+ ref=ref.sha,
78
+ reason="clean_exit_with_dirty_tree",
79
+ )
80
+ return DirtyOutcome(kind="stashed", ref=ref.sha)
81
+
82
+
83
+ register_dirty_handler(DefaultDirtyHandler())
@@ -11,6 +11,7 @@ from typing import Any, Literal
11
11
  _VALID_INJECTION_MODES: frozenset[str] = frozenset({"prepend", "file", "none"})
12
12
  _VALID_DIRTY_ACTIONS: frozenset[str] = frozenset({"stash", "ignore", "auto_commit"})
13
13
  _VALID_TRANSIENT_ERROR_ACTIONS: frozenset[str] = frozenset({"back_off", "skip", "stop"})
14
+ _VALID_PROMPT_DELIVERY: frozenset[str] = frozenset({"argv", "stdin"})
14
15
 
15
16
 
16
17
  class ConfigError(ValueError):
@@ -23,6 +24,7 @@ class AgentConfig:
23
24
  prompt_arg_template: list[str]
24
25
  name: str | None = None
25
26
  env: dict[str, str] = field(default_factory=dict)
27
+ prompt_delivery: Literal["argv", "stdin"] = "argv"
26
28
 
27
29
 
28
30
  @dataclass(frozen=True)
@@ -338,11 +340,23 @@ def load_config(toml_path: Path) -> Config:
338
340
  raw = tomllib.load(f)
339
341
 
340
342
  agent_d = raw.get("agent", {})
343
+ prompt_delivery = str(agent_d.get("prompt_delivery", "argv"))
344
+ prompt_arg_template = list(_require(agent_d, "prompt_arg_template"))
345
+ if prompt_delivery not in _VALID_PROMPT_DELIVERY:
346
+ raise ValueError(
347
+ f'invalid [agent] prompt_delivery {prompt_delivery!r}: use "argv" or "stdin"'
348
+ )
349
+ if prompt_delivery == "stdin" and any("{prompt}" in a for a in prompt_arg_template):
350
+ raise ValueError(
351
+ "stdin delivery: remove {prompt} from [agent] prompt_arg_template "
352
+ "(the prompt is piped to stdin, not placed in argv)"
353
+ )
341
354
  agent = AgentConfig(
342
355
  command=list(_require(agent_d, "command")),
343
- prompt_arg_template=list(_require(agent_d, "prompt_arg_template")),
356
+ prompt_arg_template=prompt_arg_template,
344
357
  name=agent_d.get("name"),
345
358
  env={str(k): str(v) for k, v in agent_d.get("env", {}).items()},
359
+ prompt_delivery=prompt_delivery,
346
360
  )
347
361
  raw_work_dir = str(_require(raw, "runtime", "work_dir"))
348
362
  work_dir = _expand_path(raw_work_dir, "").resolve()
@@ -34,6 +34,7 @@ AGENT_SPAWN = "agent_spawn"
34
34
  AGENT_USAGE_RECORDED = "agent_usage_recorded"
35
35
  CONFIG_BROKEN = "config_broken"
36
36
  CRASH_LOOP = "crash_loop"
37
+ DIRTY_AUTO_COMMITTED = "dirty_auto_committed"
37
38
  DIRTY_COMMIT_FAILED = "dirty_commit_failed"
38
39
  DIRTY_DETECTED = "dirty_detected"
39
40
  FRESH_EYES_ROUND_TRIGGERED = "fresh_eyes_round_triggered"
@@ -30,6 +30,7 @@ Public API:
30
30
  from __future__ import annotations
31
31
 
32
32
  import sys
33
+ import traceback as tb_mod
33
34
  from dataclasses import dataclass
34
35
  from pathlib import Path
35
36
  from typing import Any, Protocol, runtime_checkable
@@ -43,6 +44,14 @@ _TAIL_BYTES = 1024
43
44
  _TRUNC_MARKER = "\n... [truncated] ...\n"
44
45
 
45
46
 
47
+ @dataclass(frozen=True)
48
+ class VcsHookView:
49
+ """Minimal vcs config a dirty handler needs (keeps HookContext narrow)."""
50
+
51
+ dirty_action: str
52
+ stash_idempotency_s: int
53
+
54
+
46
55
  @dataclass(frozen=True)
47
56
  class HookContext:
48
57
  """Narrow per-round context passed to all hook callbacks.
@@ -86,6 +95,26 @@ class HookContext:
86
95
  """Count threshold for repetitive-tool anomaly (0 = disabled).
87
96
  Populated from ``[monitor] anomaly_repetitive_threshold`` (0.1.31+).
88
97
  """
98
+ vcs: VcsHookView | None = None
99
+ """Narrowed vcs config slice populated by the runner for all per-round hooks
100
+ (pre-round, enrichers, post-round, dirty-handler) (0.2.0+).
101
+ May be None only when a HookContext is constructed without it (e.g. tests).
102
+ """
103
+
104
+
105
+ @runtime_checkable
106
+ class DirtyHandler(Protocol):
107
+ """Resolves a clean-exit dirty tree. Runs ascending by ``priority``; the
108
+ first to return a DirtyOutcome wins. Return None to pass to the next."""
109
+
110
+ name: str
111
+ priority: int
112
+
113
+ def handle_dirty(
114
+ self,
115
+ ctx: HookContext,
116
+ dirty_files: list[str],
117
+ ) -> Any | None: ...
89
118
 
90
119
 
91
120
  @runtime_checkable
@@ -148,6 +177,7 @@ _PRE_ROUND_HOOKS: list[PreRoundHook] = []
148
177
  _CONTEXT_ENRICHERS: list[ContextEnricher] = []
149
178
  _POST_ROUND_HOOKS: list[PostRoundHook] = []
150
179
  _SERVE_STARTUP_HOOKS: list[ServeStartupHook] = []
180
+ _DIRTY_HANDLERS: list[DirtyHandler] = []
151
181
 
152
182
 
153
183
  def register_pre_round_hook(hook: PreRoundHook) -> None:
@@ -191,6 +221,43 @@ def plugin_context_enrichers() -> list[str]:
191
221
  return sorted(e.name for e in _CONTEXT_ENRICHERS)
192
222
 
193
223
 
224
+ def register_dirty_handler(handler: DirtyHandler) -> None:
225
+ ensure_unique(handler.name, _DIRTY_HANDLERS, "dirty_handler")
226
+ _DIRTY_HANDLERS.append(handler)
227
+
228
+
229
+ def dirty_handlers() -> list[DirtyHandler]:
230
+ return list(_DIRTY_HANDLERS)
231
+
232
+
233
+ def dispatch_dirty(
234
+ ctx: HookContext,
235
+ dirty_files: list[str],
236
+ log_dir: Path,
237
+ ) -> Any | None:
238
+ """Run registered DirtyHandlers ascending by priority; first non-None wins.
239
+
240
+ A handler that raises is isolated — emits ``hook_failed`` with
241
+ ``hook_kind="dirty_handler"`` and treated as pass (continue to next).
242
+ """
243
+ ordered = sorted(_DIRTY_HANDLERS, key=lambda h: getattr(h, "priority", 0))
244
+ for h in ordered:
245
+ try:
246
+ outcome = h.handle_dirty(ctx, dirty_files)
247
+ except Exception as exc: # noqa: BLE001 — isolate; fall through to next
248
+ events.emit(
249
+ log_dir,
250
+ events.HOOK_FAILED,
251
+ hook_name=h.name,
252
+ hook_kind="dirty_handler",
253
+ **_summarize_error(exc, tb=tb_mod.format_exc()),
254
+ )
255
+ continue
256
+ if outcome is not None:
257
+ return outcome
258
+ return None
259
+
260
+
194
261
  def run_serve_startup_hooks(cfg: Any, log_dir: Path) -> bool:
195
262
  """Run all serve_startup_hooks. Returns True on success, False on abort.
196
263
 
@@ -4,7 +4,8 @@
4
4
  command = ["claude", "--model", "claude-opus-4-7",
5
5
  "--dangerously-skip-permissions",
6
6
  "--verbose", "--output-format", "stream-json"]
7
- prompt_arg_template = ["-p", "{prompt}"]
7
+ prompt_arg_template = ["-p"]
8
+ prompt_delivery = "stdin"
8
9
  name = "claude"
9
10
 
10
11
  [agent.env]
@@ -28,6 +28,7 @@ from agent_runner import (
28
28
  startup_check,
29
29
  vcs_state,
30
30
  )
31
+ from agent_runner.agent_runtime import signal_name
31
32
  from agent_runner.api import _primary_prompt_file, resolve_runtime_for_phase
32
33
  from agent_runner.api import assemble_prompt as _api_assemble_prompt
33
34
  from agent_runner.api_types import RoundResult, TransientErrorState
@@ -217,6 +218,20 @@ def _round_context_for_prompt(
217
218
  return ctx
218
219
 
219
220
 
221
+ def _exit_cause(result: agent_runtime.RunResult) -> str:
222
+ """Classify a round's exit for the ``agent_exit`` event.
223
+
224
+ Precedence: timeout > signal:<NAME> > clean > error. Timeout wins even
225
+ when agent-runner signal-killed the process to enforce the timeout.
226
+ """
227
+ if result.timed_out:
228
+ return "timeout"
229
+ s = signal_name(result.exit_code)
230
+ if s is not None:
231
+ return f"signal:{s}"
232
+ return "clean" if result.exit_code == 0 else "error"
233
+
234
+
220
235
  def _scan_round_log_for_network_blip(
221
236
  *,
222
237
  log_dir: Path,
@@ -233,6 +248,8 @@ def _scan_round_log_for_network_blip(
233
248
  # Skip the I/O on the success path.
234
249
  if result.exit_code == 0 and not result.timed_out:
235
250
  return
251
+ if signal_name(result.exit_code) is not None:
252
+ return # signal death is a termination, not a network blip
236
253
  try:
237
254
  text = log_path.read_text(encoding="utf-8", errors="replace")
238
255
  except FileNotFoundError:
@@ -423,6 +440,10 @@ def _run_one_round_inner(cfg: Config, *, phase_override: str | None = None) -> R
423
440
  dry_run=cfg.runtime.dry_run,
424
441
  anomaly_repetitive_window=cfg.monitor.anomaly_repetitive_window,
425
442
  anomaly_repetitive_threshold=cfg.monitor.anomaly_repetitive_threshold,
443
+ vcs=hooks.VcsHookView(
444
+ dirty_action=cfg.vcs.dirty_action,
445
+ stash_idempotency_s=cfg.vcs.stash_idempotency_s,
446
+ ),
426
447
  )
427
448
  _run_pre_round_hooks(
428
449
  hook_ctx,
@@ -482,6 +503,7 @@ def _run_one_round_inner(cfg: Config, *, phase_override: str | None = None) -> R
482
503
  command=cfg.agent.command,
483
504
  prompt_arg_template=cfg.agent.prompt_arg_template,
484
505
  prompt=prompt,
506
+ prompt_delivery=cfg.agent.prompt_delivery,
485
507
  timeout_s=timeout_s,
486
508
  log_path=log_path,
487
509
  env_extra={**framework_env, **dict(cfg.agent.env)},
@@ -498,6 +520,7 @@ def _run_one_round_inner(cfg: Config, *, phase_override: str | None = None) -> R
498
520
  exit_code=result.exit_code,
499
521
  duration_s=result.duration_s,
500
522
  timed_out=result.timed_out,
523
+ exit_cause=_exit_cause(result),
501
524
  )
502
525
 
503
526
  _scan_round_log_for_network_blip(
@@ -512,54 +535,12 @@ def _run_one_round_inner(cfg: Config, *, phase_override: str | None = None) -> R
512
535
  if dirty:
513
536
  events.emit(log_dir, "dirty_detected", round_num=round_num, files=dirty[:20])
514
537
 
515
- stashed = False
516
- action = cfg.vcs.dirty_action
538
+ dirty_outcome = None
517
539
  if dirty and not result.timed_out and result.exit_code == 0:
518
- if action == "stash":
519
- ref = vcs_state.stash_orphan(
520
- cfg.runtime.work_dir,
521
- round_num=round_num,
522
- phase=phase,
523
- idempotency_s=cfg.vcs.stash_idempotency_s,
524
- log_dir=cfg.runtime.log_dir,
525
- )
526
- if ref is not None:
527
- context_store.write_orphan_state(
528
- log_dir,
529
- context_store.OrphanState(
530
- round_num=round_num,
531
- files=dirty,
532
- stashed_ref=ref.sha,
533
- stash_message=ref.message,
534
- timestamp=now_iso_ms(),
535
- phase=phase,
536
- ),
537
- )
538
- events.emit(
539
- log_dir,
540
- "orphan_stashed",
541
- round_num=round_num,
542
- ref=ref.sha,
543
- reason="clean_exit_with_dirty_tree",
544
- )
545
- stashed = True
546
- elif action == "ignore":
547
- # Leave tree dirty for next round; dirty_detected already emitted
548
- pass
549
- elif action == "auto_commit":
550
- err = vcs_state.try_auto_commit(
551
- cfg.runtime.work_dir, round_num, phase, log_dir=cfg.runtime.log_dir
552
- )
553
- if err is not None:
554
- events.emit(
555
- log_dir,
556
- events.DIRTY_COMMIT_FAILED,
557
- round_num=round_num,
558
- phase=phase,
559
- reason=err,
560
- )
540
+ dirty_outcome = hooks.dispatch_dirty(hook_ctx, dirty, log_dir=log_dir)
561
541
  elif not dirty:
562
542
  context_store.clear_orphan_state(log_dir)
543
+ stashed = bool(dirty_outcome and dirty_outcome.kind == "stashed")
563
544
 
564
545
  if result.killed_for_grace:
565
546
  api.emit_round_grace_kill(
@@ -609,6 +590,7 @@ def _run_one_round_inner(cfg: Config, *, phase_override: str | None = None) -> R
609
590
  log_path=log_path,
610
591
  dirty_files=dirty,
611
592
  stashed=stashed,
593
+ dirty_outcome=dirty_outcome,
612
594
  killed_for_grace=result.killed_for_grace,
613
595
  )
614
596
  _run_post_round_hooks(hook_ctx, round_result, log_dir)
@@ -72,6 +72,10 @@ def _matches_owned_path(path: str) -> bool:
72
72
  return False
73
73
 
74
74
 
75
+ class AutoCommitError(RuntimeError):
76
+ """git add/commit failed during try_auto_commit (reason capped at 200 chars)."""
77
+
78
+
75
79
  @dataclass(frozen=True)
76
80
  class StashRef:
77
81
  sha: str # full commit SHA — IMMUTABLE under concurrent stash
@@ -322,20 +326,13 @@ def try_auto_commit(
322
326
  phase: str | None,
323
327
  *,
324
328
  log_dir: Path | None = None,
325
- ) -> str | None:
326
- """Auto-commit dirty tree with hardcoded subject. Return None on success, error on failure.
327
-
328
- Subject: ``agent-runner auto-commit: R<N> <phase>`` (phase part omitted if None).
329
- Uses ``git -c commit.gpgsign=false`` to skip GPG; honors pre-commit hooks
330
- (no ``--no-verify``). DOES NOT push — local commit only.
329
+ ) -> str:
330
+ """Auto-commit the dirty tree with a hardcoded subject; return the commit SHA.
331
331
 
332
- ``log_dir`` (when under ``work_dir``) is excluded from the add so a zero-work
333
- round that only churned the runner's own bookkeeping (lock/pid/event logs)
334
- does not advance ``git_head``. The agent's work and ``.evolving/`` live
335
- outside ``log_dir`` and are still committed. If nothing remains staged after
336
- the exclusion, this is a no-op (returns None, leaves HEAD untouched).
337
-
338
- Callers (runner.py) emit ``dirty_commit_failed`` event when return value is not None.
332
+ Returns "" when nothing remained staged after excluding log_dir (no-op;
333
+ HEAD untouched). Raises AutoCommitError on git failure. DOES NOT push.
334
+ Subject: ``agent-runner auto-commit: R<N> <phase>``. Uses
335
+ ``git -c commit.gpgsign=false``; honors pre-commit hooks (no --no-verify).
339
336
  """
340
337
  phase_part = f" {phase}" if phase else ""
341
338
  subject = f"agent-runner auto-commit: R{round_num}{phase_part}"
@@ -343,13 +340,13 @@ def try_auto_commit(
343
340
  exclude = _log_dir_exclude_pathspec(work_dir, log_dir)
344
341
  add_result = _git(work_dir, "add", "-A", *exclude)
345
342
  if add_result.returncode != 0:
346
- return (add_result.stderr or "git add failed")[:200]
343
+ raise AutoCommitError((add_result.stderr or "git add failed")[:200])
347
344
 
348
345
  # Only the exclusion can leave nothing staged (a zero-work round that churned
349
346
  # only log_dir); without it the tree was dirty so there is always something to
350
347
  # commit. Skip the extra git call on the common (no-exclusion) path.
351
348
  if exclude and _git(work_dir, "diff", "--cached", "--quiet").returncode == 0:
352
- return None
349
+ return ""
353
350
 
354
351
  commit_result = _git(
355
352
  work_dir,
@@ -359,6 +356,7 @@ def try_auto_commit(
359
356
  pre_flags=("-c", "commit.gpgsign=false"),
360
357
  )
361
358
  if commit_result.returncode != 0:
362
- return (commit_result.stderr or "git commit failed")[:200]
359
+ raise AutoCommitError((commit_result.stderr or "git commit failed")[:200])
363
360
 
364
- return None
361
+ head = _git(work_dir, "rev-parse", "HEAD")
362
+ return head.stdout.strip()