cli-agent-runner 0.1.30__tar.gz → 0.1.32__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 (216) hide show
  1. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/CHANGELOG.md +20 -0
  2. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/PKG-INFO +6 -5
  3. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/README.md +5 -4
  4. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/_emit.py +75 -0
  5. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/_version.py +2 -2
  6. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/agent_runtime.py +60 -0
  7. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/api.py +6 -0
  8. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/api_types.py +1 -0
  9. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/builtin_plugins/claude_rate_limit.py +71 -8
  10. cli_agent_runner-0.1.32/agent_runner/cli/peek_cmd.py +156 -0
  11. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/config.py +49 -0
  12. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/events.py +3 -0
  13. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/hooks.py +14 -0
  14. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/monitor.py +63 -7
  15. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/runner.py +22 -1
  16. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/architecture.md +4 -0
  17. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/commands.md +1 -1
  18. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/configuration.md +33 -3
  19. cli_agent_runner-0.1.32/docs/migrations/0.1.31.md +89 -0
  20. cli_agent_runner-0.1.32/docs/migrations/0.1.32.md +102 -0
  21. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/runbook.md +5 -0
  22. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/_test_helpers.py +6 -0
  23. cli_agent_runner-0.1.32/tests/integration/test_grace_kill_emission.py +79 -0
  24. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/invariants/test_architecture.py +2 -1
  25. cli_agent_runner-0.1.32/tests/unit/test_agent_runtime_grace.py +72 -0
  26. cli_agent_runner-0.1.32/tests/unit/test_agent_runtime_progress.py +70 -0
  27. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_claude_error_detector.py +69 -0
  28. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_config.py +144 -0
  29. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_docgen.py +2 -2
  30. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_hooks.py +51 -0
  31. cli_agent_runner-0.1.32/tests/unit/test_monitor_detect_anomaly_repetitive.py +71 -0
  32. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_monitor_detectors.py +2 -1
  33. cli_agent_runner-0.1.32/tests/unit/test_peek_select.py +76 -0
  34. cli_agent_runner-0.1.30/agent_runner/cli/peek_cmd.py +0 -81
  35. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/.codecov.yml +0 -0
  36. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/.githooks/commit-msg +0 -0
  37. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  38. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  39. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  40. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  41. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/.github/workflows/ci.yml +0 -0
  42. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/.github/workflows/release.yml +0 -0
  43. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/.gitignore +0 -0
  44. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/.vulture-whitelist.py +0 -0
  45. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/CODE_OF_CONDUCT.md +0 -0
  46. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/CONTRIBUTING.md +0 -0
  47. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/LICENSE +0 -0
  48. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/README.zh.md +0 -0
  49. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/SECURITY.md +0 -0
  50. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/__init__.py +0 -0
  51. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/_docgen.py +0 -0
  52. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/_registry.py +0 -0
  53. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/_substrate.py +0 -0
  54. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/_throttle.py +0 -0
  55. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/builtin_plugins/__init__.py +0 -0
  56. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/builtin_plugins/_constants.py +0 -0
  57. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/builtin_plugins/gemini.py +0 -0
  58. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/cli/__init__.py +0 -0
  59. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/cli/__main__.py +0 -0
  60. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/cli/common.py +0 -0
  61. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/cli/init_cmd.py +0 -0
  62. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/cli/install_cmd.py +0 -0
  63. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/cli/monitor_cmd.py +0 -0
  64. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/cli/round_cmd.py +0 -0
  65. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/cli/serve_cmd.py +0 -0
  66. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/cli/service_cmd.py +0 -0
  67. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/cli/upgrade_cmd.py +0 -0
  68. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/context_store.py +0 -0
  69. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/defenses.py +0 -0
  70. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/detector_helpers.py +0 -0
  71. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/http_progress.py +0 -0
  72. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/lifecycle.py +0 -0
  73. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/metrics.py +0 -0
  74. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/presets/__init__.py +0 -0
  75. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/presets/aider.toml +0 -0
  76. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/presets/claude.toml +0 -0
  77. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/presets/gemini.toml +0 -0
  78. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/prompt_loader.py +0 -0
  79. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/round_log.py +0 -0
  80. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/round_view.py +0 -0
  81. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/scaffold.py +0 -0
  82. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/service_unit.py +0 -0
  83. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/startup_check.py +0 -0
  84. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/agent_runner/vcs_state.py +0 -0
  85. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/build.sh +0 -0
  86. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/deploy/example-agent-runner.toml +0 -0
  87. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/deploy/launchd.plist.tmpl +0 -0
  88. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/deploy/run-loop.sh +0 -0
  89. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/deploy/systemd.service.tmpl +0 -0
  90. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/README.md +0 -0
  91. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/events.md +0 -0
  92. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/long-running-agents.md +0 -0
  93. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/marketing/README.md +0 -0
  94. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/marketing/promo-cn.html +0 -0
  95. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/migrations/0.1.16.md +0 -0
  96. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/migrations/0.1.17.md +0 -0
  97. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/migrations/0.1.19.md +0 -0
  98. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/migrations/0.1.20.md +0 -0
  99. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/migrations/0.1.21.md +0 -0
  100. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/migrations/0.1.22.md +0 -0
  101. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/migrations/0.1.23.md +0 -0
  102. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/migrations/0.1.24.md +0 -0
  103. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/migrations/0.1.25.md +0 -0
  104. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/migrations/0.1.26.md +0 -0
  105. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/migrations/0.1.27.md +0 -0
  106. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/migrations/0.1.28.md +0 -0
  107. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/migrations/0.1.29.md +0 -0
  108. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/migrations/0.1.30.md +0 -0
  109. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/plugins.md +0 -0
  110. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/quickstart.md +0 -0
  111. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/recipes/aider.md +0 -0
  112. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/docs/thesis.md +0 -0
  113. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/pyproject.toml +0 -0
  114. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/__init__.py +0 -0
  115. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/conftest.py +0 -0
  116. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/contract/__init__.py +0 -0
  117. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/contract/test_public_api_surface.py +0 -0
  118. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/e2e/__init__.py +0 -0
  119. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/e2e/conftest.py +0 -0
  120. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/e2e/test_e2e_graceful_stop.py +0 -0
  121. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/e2e/test_e2e_install_systemd.py +0 -0
  122. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/e2e/test_e2e_monitor_remote.py +0 -0
  123. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/e2e/test_e2e_round_lifecycle.py +0 -0
  124. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/fixtures/cli-real-output/claude-2.1.143-assistant-tool-use.jsonl +0 -0
  125. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/fixtures/cli-real-output/claude-2.1.143-result-event.jsonl +0 -0
  126. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/fixtures/cli-real-output/gemini-0.42.0-result-event.jsonl +0 -0
  127. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/integration/__init__.py +0 -0
  128. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/integration/test_bounded_run.py +0 -0
  129. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/integration/test_context_enricher_namespacing.py +0 -0
  130. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/integration/test_fresh_eyes_signal.py +0 -0
  131. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/integration/test_install_dry_run.py +0 -0
  132. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/integration/test_monitor_seeded.py +0 -0
  133. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/integration/test_plugin_detector_loaded.py +0 -0
  134. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/integration/test_plugin_owned_paths.py +0 -0
  135. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/integration/test_plugin_real_flow.py +0 -0
  136. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/integration/test_run_one_round_with_fake_agent.py +0 -0
  137. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/integration/test_scaffold_presets.py +0 -0
  138. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/integration/test_serve_loop.py +0 -0
  139. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/integration/test_substrate_fingerprint.py +0 -0
  140. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/integration/test_transient_error_backoff.py +0 -0
  141. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/invariants/__init__.py +0 -0
  142. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/invariants/test_atomic_write_enforced.py +0 -0
  143. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/invariants/test_catalogs.py +0 -0
  144. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/invariants/test_classification_ssot.py +0 -0
  145. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/invariants/test_docs_generated.py +0 -0
  146. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/invariants/test_entry_points_resolve.py +0 -0
  147. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/invariants/test_event_kind_registry.py +0 -0
  148. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/invariants/test_event_kinds_ssot.py +0 -0
  149. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/invariants/test_events_doc_contract.py +0 -0
  150. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/invariants/test_layer_2_loop_size.py +0 -0
  151. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/invariants/test_module_boundaries.py +0 -0
  152. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/invariants/test_module_sizes.py +0 -0
  153. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/invariants/test_no_ai_signatures.py +0 -0
  154. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/invariants/test_no_pytest_skip_on_parse_fail.py +0 -0
  155. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/invariants/test_peek_schema_version.py +0 -0
  156. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/invariants/test_repo_constants_patched_in_tests.py +0 -0
  157. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/invariants/test_round_result_stable.py +0 -0
  158. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/invariants/test_stash_uses_sha_not_index.py +0 -0
  159. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/invariants/test_upstream_schema_canary.py +0 -0
  160. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/literate/__init__.py +0 -0
  161. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/literate/parser.py +0 -0
  162. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/literate/test_parser.py +0 -0
  163. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/literate/test_quickstart.py +0 -0
  164. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/__init__.py +0 -0
  165. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_agent_runtime.py +0 -0
  166. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_api_assemble_prompt.py +0 -0
  167. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_api_events_stream.py +0 -0
  168. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_api_install.py +0 -0
  169. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_api_observation.py +0 -0
  170. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_api_read_round_num.py +0 -0
  171. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_api_resolve_phase.py +0 -0
  172. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_api_service.py +0 -0
  173. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_api_types.py +0 -0
  174. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_auto_stop_gating.py +0 -0
  175. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_cli.py +0 -0
  176. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_cli_common.py +0 -0
  177. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_cli_init_install.py +0 -0
  178. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_cli_monitor_http.py +0 -0
  179. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_cli_service_peek_monitor.py +0 -0
  180. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_cli_upgrade.py +0 -0
  181. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_config_fresh_eyes.py +0 -0
  182. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_config_max_rounds.py +0 -0
  183. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_config_stop_file.py +0 -0
  184. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_config_substrate_fingerprint_paths.py +0 -0
  185. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_config_transient_error_action.py +0 -0
  186. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_context_store.py +0 -0
  187. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_defenses.py +0 -0
  188. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_detector_helpers.py +0 -0
  189. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_detector_protocol.py +0 -0
  190. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_events.py +0 -0
  191. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_fresh_eyes_trigger.py +0 -0
  192. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_gemini_plugin.py +0 -0
  193. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_hook_failure_isolation.py +0 -0
  194. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_http_progress.py +0 -0
  195. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_init_entry_points.py +0 -0
  196. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_lifecycle.py +0 -0
  197. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_metrics.py +0 -0
  198. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_monitor_assembly.py +0 -0
  199. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_monitor_detect_rate_limit.py +0 -0
  200. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_monitor_remote.py +0 -0
  201. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_peek_argparse.py +0 -0
  202. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_presets.py +0 -0
  203. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_prompt_loader.py +0 -0
  204. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_round_log_helpers.py +0 -0
  205. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_round_view.py +0 -0
  206. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_runner.py +0 -0
  207. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_runner_throttle.py +0 -0
  208. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_scaffold.py +0 -0
  209. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_serve_cmd_bounded.py +0 -0
  210. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_serve_round_log.py +0 -0
  211. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_serve_sentinel.py +0 -0
  212. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_serve_startup_hooks.py +0 -0
  213. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_service_unit.py +0 -0
  214. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_startup_check.py +0 -0
  215. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_substrate.py +0 -0
  216. {cli_agent_runner-0.1.30 → cli_agent_runner-0.1.32}/tests/unit/test_vcs_state.py +0 -0
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.32] - 2026-05-18
11
+
12
+ ### Added
13
+ - `[monitor] round_progress_interval_s` — heartbeat `round_progress` event during a live round (log_size_kb, last_write_age_s, wall_age_s). Default 0 = disabled.
14
+ - `agent-runner peek --select events.<kind> [--window N]` — native selector for events.jsonl query (replaces `tail -F | grep | jq` patterns). Current-month only.
15
+ - `[monitor.host_health]` config section — `mem_avail_min_mb` / `disk_warning_pct` / `disk_critical_pct` thresholds for `mem_pressure` / `disk_warning` / `disk_critical` detectors. Defaults preserve prior behavior.
16
+ - New monitor detector `anomaly_repetitive_active` (notify) — alerts when `anomaly_repetitive_tool` events fire in recent rounds. Detector count 10 → 11.
17
+
18
+ See `docs/migrations/0.1.32.md`.
19
+
20
+ ## [0.1.31] - 2026-05-18
21
+
22
+ ### Added
23
+ - `[runtime] max_grace_after_result_s` — kill subprocess that emits `type=result` then sits silent for N seconds (0 = disabled). New `round_grace_kill` event distinguishes from regular `round_timeout_kill`.
24
+ - `[monitor] anomaly_repetitive_window` + `anomaly_repetitive_threshold` — claude plugin emits `anomaly_repetitive_tool` event when same (tool, target) appears ≥threshold times in window-sized tool stream. Specific scar pattern; both 0 = disabled.
25
+ - `[runtime] dry_run` boolean exposed via `HookContext.dry_run` for plugin authors to skip side-effect actions when set.
26
+ - Documentation: TOML changes require daemon restart (no hot-reload); phase rotation indexing clarification.
27
+
28
+ See `docs/migrations/0.1.31.md`.
29
+
10
30
  ## [0.1.30] - 2026-05-18
11
31
 
12
32
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli-agent-runner
3
- Version: 0.1.30
3
+ Version: 0.1.32
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
@@ -49,7 +49,7 @@ full disks, runaway memory.
49
49
 
50
50
  ```
51
51
  ┌──────────────────────────────────────────┐
52
- │ Layer 3: The Witness (monitor) │ 10 detectors + auto-stop
52
+ │ Layer 3: The Witness (monitor) │ 11 detectors + auto-stop
53
53
  ├──────────────────────────────────────────┤
54
54
  │ Layer 2: The Loop (serve, ~120 LOC) │ signal-trapping restart loop
55
55
  ├──────────────────────────────────────────┤
@@ -86,7 +86,7 @@ Full walkthrough: [`docs/quickstart.md`](docs/quickstart.md).
86
86
  |---|---|
87
87
  | `init` / `install` / `uninstall` | `peek` — state snapshot |
88
88
  | `start` / `stop` / `kill` / `cancel` | `watch` — peek in a refresh loop |
89
- | `restart` / `status` | `monitor` — 10 detectors, alerts, auto-stop |
89
+ | `restart` / `status` | `monitor` — 11 detectors, alerts, auto-stop |
90
90
  | `round` / `serve` | |
91
91
 
92
92
  Verb reference: [`docs/commands.md`](docs/commands.md).
@@ -106,10 +106,11 @@ guards it. Highlights:
106
106
 
107
107
  Full list and rationale: [`docs/architecture.md`](docs/architecture.md).
108
108
 
109
- ## Monitor: 10 detectors
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`, `smoke_fail_rate`, `network_fail`, `rate_limit_active`,
113
+ `anomaly_repetitive_active`.
113
114
 
114
115
  **Auto-stop the service** (continuing is harmful):
115
116
  - `oauth_fail` — burning API quota on auth-rejected rounds
@@ -12,7 +12,7 @@ full disks, runaway memory.
12
12
 
13
13
  ```
14
14
  ┌──────────────────────────────────────────┐
15
- │ Layer 3: The Witness (monitor) │ 10 detectors + auto-stop
15
+ │ Layer 3: The Witness (monitor) │ 11 detectors + auto-stop
16
16
  ├──────────────────────────────────────────┤
17
17
  │ Layer 2: The Loop (serve, ~120 LOC) │ signal-trapping restart loop
18
18
  ├──────────────────────────────────────────┤
@@ -49,7 +49,7 @@ Full walkthrough: [`docs/quickstart.md`](docs/quickstart.md).
49
49
  |---|---|
50
50
  | `init` / `install` / `uninstall` | `peek` — state snapshot |
51
51
  | `start` / `stop` / `kill` / `cancel` | `watch` — peek in a refresh loop |
52
- | `restart` / `status` | `monitor` — 10 detectors, alerts, auto-stop |
52
+ | `restart` / `status` | `monitor` — 11 detectors, alerts, auto-stop |
53
53
  | `round` / `serve` | |
54
54
 
55
55
  Verb reference: [`docs/commands.md`](docs/commands.md).
@@ -69,10 +69,11 @@ guards it. Highlights:
69
69
 
70
70
  Full list and rationale: [`docs/architecture.md`](docs/architecture.md).
71
71
 
72
- ## Monitor: 10 detectors
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`, `smoke_fail_rate`, `network_fail`, `rate_limit_active`,
76
+ `anomaly_repetitive_active`.
76
77
 
77
78
  **Auto-stop the service** (continuing is harmful):
78
79
  - `oauth_fail` — burning API quota on auth-rejected rounds
@@ -15,9 +15,12 @@ from pathlib import Path
15
15
 
16
16
  __all__ = [
17
17
  "emit_agent_usage_recorded",
18
+ "emit_anomaly_repetitive_tool",
18
19
  "emit_fresh_eyes_round_triggered",
19
20
  "emit_max_rounds_reached",
20
21
  "emit_rate_limit_stop",
22
+ "emit_round_grace_kill",
23
+ "emit_round_progress",
21
24
  "emit_round_substrate_after",
22
25
  "emit_round_substrate_before",
23
26
  "emit_stop_file_detected",
@@ -199,6 +202,78 @@ def emit_agent_usage_recorded(
199
202
  )
200
203
 
201
204
 
205
+ def emit_round_progress(
206
+ log_dir: Path,
207
+ *,
208
+ round_num: int,
209
+ log_size_kb: int,
210
+ last_write_age_s: int,
211
+ wall_age_s: int,
212
+ ) -> None:
213
+ """Mid-round heartbeat event when round_progress_interval_s > 0.
214
+
215
+ Emitted periodically during a live round to surface visibility on long
216
+ rounds: log_size_kb shows writing activity; last_write_age_s and wall_age_s
217
+ together distinguish "agent thinking" from "agent stuck".
218
+ """
219
+ from agent_runner.events import ROUND_PROGRESS, emit
220
+
221
+ emit(
222
+ log_dir,
223
+ ROUND_PROGRESS,
224
+ round_num=round_num,
225
+ log_size_kb=log_size_kb,
226
+ last_write_age_s=last_write_age_s,
227
+ wall_age_s=wall_age_s,
228
+ )
229
+
230
+
231
+ def emit_round_grace_kill(
232
+ log_dir: Path,
233
+ *,
234
+ round_num: int,
235
+ grace_s: int,
236
+ ) -> None:
237
+ """Emit when subprocess killed because grace-after-result timer expired.
238
+
239
+ Subprocess emitted type=result in JSONL log then sat silent for longer
240
+ than max_grace_after_result_s seconds. Distinguishes from round_timeout_kill
241
+ (wall-clock exceeded without result event).
242
+ """
243
+ from agent_runner.events import ROUND_GRACE_KILL, emit
244
+
245
+ emit(log_dir, ROUND_GRACE_KILL, round_num=round_num, grace_s=grace_s)
246
+
247
+
248
+ def emit_anomaly_repetitive_tool(
249
+ log_dir: Path,
250
+ *,
251
+ round_num: int,
252
+ tool_name: str,
253
+ target: str | None,
254
+ count: int,
255
+ window: int,
256
+ ) -> None:
257
+ """Emit when claude plugin detects the same (tool, target) tuple repeated
258
+ >= threshold times in a sliding window of tool-call events.
259
+
260
+ Claude-only (gemini JSONL stats summary does not expose per-tool events).
261
+ Default OFF: both anomaly_repetitive_window and anomaly_repetitive_threshold
262
+ must be > 0 in [monitor] config to activate.
263
+ """
264
+ from agent_runner.events import ANOMALY_REPETITIVE_TOOL, emit
265
+
266
+ emit(
267
+ log_dir,
268
+ ANOMALY_REPETITIVE_TOOL,
269
+ round_num=round_num,
270
+ tool_name=tool_name,
271
+ target=target,
272
+ count=count,
273
+ window=window,
274
+ )
275
+
276
+
202
277
  def emit_transient_error_backoff_capped(
203
278
  log_dir: Path,
204
279
  *,
@@ -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.30'
22
- __version_tuple__ = version_tuple = (0, 1, 30)
21
+ __version__ = version = '0.1.32'
22
+ __version_tuple__ = version_tuple = (0, 1, 32)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -27,6 +27,7 @@ class RunResult:
27
27
  duration_s: float
28
28
  timed_out: bool
29
29
  pid: int
30
+ killed_for_grace: bool = False
30
31
 
31
32
 
32
33
  def _build_argv(command: list[str], prompt_arg_template: list[str], prompt: str) -> list[str]:
@@ -53,6 +54,12 @@ def _kill_pgroup(proc: subprocess.Popen) -> None:
53
54
  pass
54
55
 
55
56
 
57
+ # Exact compact bytes — matches claude CLI's no-whitespace JSONL output.
58
+ # A future CLI variant emitting `{"type": "result", ...}` (with space) would
59
+ # bypass this scan; revisit if that happens.
60
+ _RESULT_MARKER = b'"type":"result"'
61
+
62
+
56
63
  def run(
57
64
  *,
58
65
  command: list[str],
@@ -61,16 +68,29 @@ def run(
61
68
  timeout_s: int,
62
69
  log_path: Path,
63
70
  env_extra: dict[str, str],
71
+ max_grace_after_result_s: int = 0,
72
+ progress_callback: Callable[[dict], None] | None = None,
73
+ progress_interval_s: int = 0,
64
74
  ) -> RunResult:
65
75
  """Spawn the agent subprocess and wait for exit or timeout.
66
76
 
67
77
  Wall-clock timeout (R1128). On timeout: SIGTERM pgroup → REAP_GRACE_S → SIGKILL.
78
+
79
+ max_grace_after_result_s: when > 0, start a countdown after the first
80
+ type=result event is detected in the log; kill if subprocess is still
81
+ running after this many seconds (HUNG defense). 0 = disabled.
82
+
83
+ progress_callback: when not None and progress_interval_s > 0, called every
84
+ progress_interval_s seconds with a dict of log stats (log_size_kb,
85
+ last_write_age_s, wall_age_s). Keeps agent_runtime event-free; callers
86
+ build the callback to emit events.
68
87
  """
69
88
  argv = _build_argv(command, prompt_arg_template, prompt)
70
89
  env = {**os.environ, **env_extra}
71
90
  log_path.parent.mkdir(parents=True, exist_ok=True)
72
91
  log_file = log_path.open("w", encoding="utf-8")
73
92
  start = time.time()
93
+ last_progress_at = start
74
94
  proc = subprocess.Popen(
75
95
  argv,
76
96
  env=env,
@@ -79,6 +99,7 @@ def run(
79
99
  stderr=subprocess.STDOUT,
80
100
  start_new_session=True,
81
101
  )
102
+ result_seen_at: float | None = None
82
103
  try:
83
104
  while True:
84
105
  ret = proc.poll()
@@ -93,6 +114,45 @@ def run(
93
114
  return RunResult(
94
115
  exit_code=exit_code, duration_s=duration, timed_out=True, pid=proc.pid
95
116
  )
117
+ # Grace kill: result emitted but subprocess still running
118
+ if max_grace_after_result_s > 0:
119
+ if result_seen_at is None:
120
+ # Cheap check: byte-scan log for marker substring
121
+ try:
122
+ with log_path.open("rb") as f:
123
+ if _RESULT_MARKER in f.read():
124
+ result_seen_at = now
125
+ except OSError:
126
+ pass # log not flushed yet; check next tick
127
+ if result_seen_at is not None and now - result_seen_at > max_grace_after_result_s:
128
+ _kill_pgroup(proc)
129
+ duration = time.time() - start
130
+ exit_code = proc.returncode if proc.returncode is not None else -1
131
+ return RunResult(
132
+ exit_code=exit_code,
133
+ duration_s=duration,
134
+ timed_out=True,
135
+ pid=proc.pid,
136
+ killed_for_grace=True,
137
+ )
138
+ # Progress heartbeat: call back if interval elapsed
139
+ if progress_callback is not None and progress_interval_s > 0:
140
+ if now - last_progress_at >= progress_interval_s:
141
+ try:
142
+ st = log_path.stat()
143
+ log_size_kb = st.st_size // 1024
144
+ last_write_age_s = max(0, int(now - st.st_mtime))
145
+ except OSError:
146
+ log_size_kb = 0
147
+ last_write_age_s = 0
148
+ progress_callback(
149
+ {
150
+ "log_size_kb": log_size_kb,
151
+ "last_write_age_s": last_write_age_s,
152
+ "wall_age_s": int(now - start),
153
+ }
154
+ )
155
+ last_progress_at = now
96
156
  time.sleep(0.2)
97
157
  finally:
98
158
  log_file.close()
@@ -455,6 +455,9 @@ def _poll_once(project: str | Path, *, host: str | None) -> list[monitor.Alert]:
455
455
  auth_fail_patterns=cfg.monitor.auth_fail_patterns,
456
456
  auth_fail_hint=cfg.monitor.auth_fail_hint,
457
457
  phases_overrides=cfg.phases.overrides if cfg.phases.overrides else None,
458
+ mem_avail_min_mb=cfg.monitor.host_health.mem_avail_min_mb,
459
+ disk_warning_pct=cfg.monitor.host_health.disk_warning_pct,
460
+ disk_critical_pct=cfg.monitor.host_health.disk_critical_pct,
458
461
  )
459
462
  if not monitor._PLUGIN_DETECTORS:
460
463
  return builtin # skip ProjectState assembly when no plugins to feed
@@ -725,9 +728,12 @@ def check_self_terminated_sentinel(log_dir: Path) -> bool:
725
728
  # Preserves the public import surface: `from agent_runner.api import emit_*` continues to work.
726
729
  from agent_runner._emit import ( # noqa: E402,F401 — intentional bottom re-export
727
730
  emit_agent_usage_recorded,
731
+ emit_anomaly_repetitive_tool,
728
732
  emit_fresh_eyes_round_triggered,
729
733
  emit_max_rounds_reached,
730
734
  emit_rate_limit_stop,
735
+ emit_round_grace_kill,
736
+ emit_round_progress,
731
737
  emit_round_substrate_after,
732
738
  emit_round_substrate_before,
733
739
  emit_stop_file_detected,
@@ -157,6 +157,7 @@ class RoundResult:
157
157
  log_path: Path
158
158
  dirty_files: list[str]
159
159
  stashed: bool
160
+ killed_for_grace: bool = False
160
161
 
161
162
 
162
163
  @dataclass(frozen=True)
@@ -23,6 +23,7 @@ from typing import Any
23
23
 
24
24
  from agent_runner.api import (
25
25
  emit_agent_usage_recorded,
26
+ emit_anomaly_repetitive_tool,
26
27
  emit_transient_error_detected,
27
28
  )
28
29
  from agent_runner.builtin_plugins._constants import (
@@ -45,7 +46,11 @@ class ClaudeErrorDetector:
45
46
  log_path = ctx.agent_log_path
46
47
  if log_path is None or not log_path.exists():
47
48
  return
48
- parsed = _parse_claude_log(log_path)
49
+ parsed = _parse_claude_log(
50
+ log_path,
51
+ anomaly_window=ctx.anomaly_repetitive_window,
52
+ anomaly_threshold=ctx.anomaly_repetitive_threshold,
53
+ )
49
54
 
50
55
  if parsed.get("transient_error"):
51
56
  te = parsed["transient_error"]
@@ -60,18 +65,68 @@ class ClaudeErrorDetector:
60
65
  **parsed["usage"],
61
66
  )
62
67
 
68
+ if parsed.get("anomaly"):
69
+ emit_anomaly_repetitive_tool(ctx.log_dir, round_num=ctx.round_num, **parsed["anomaly"])
70
+
71
+
72
+ def _extract_tool_target(tool_input: Any) -> str | None:
73
+ """Best-effort primary-input extraction for repetition detection."""
74
+ if not isinstance(tool_input, dict):
75
+ return None
76
+ for key in ("file_path", "path", "pattern", "command"):
77
+ v = tool_input.get(key)
78
+ if isinstance(v, str):
79
+ return v[:100] # truncate long commands
80
+ return None
81
+
82
+
83
+ def _detect_repetitive_tool(
84
+ tool_calls: list[tuple[str, str | None]],
85
+ *,
86
+ window: int,
87
+ threshold: int,
88
+ ) -> dict | None:
89
+ """Slide a window of size ``window`` over tool_calls; return anomaly dict
90
+ when any (tool_name, target) tuple appears >= threshold times."""
91
+ if window <= 0 or threshold <= 0 or len(tool_calls) < threshold:
92
+ return None
93
+ from collections import Counter
63
94
 
64
- def _parse_claude_log(log_path: Path) -> dict[str, Any]:
95
+ n = len(tool_calls)
96
+ for start in range(max(0, n - window), n - threshold + 1):
97
+ window_slice = tool_calls[start : start + window]
98
+ if not window_slice:
99
+ continue
100
+ counts = Counter(window_slice)
101
+ most_common_tuple, most_common_count = counts.most_common(1)[0]
102
+ if most_common_count >= threshold:
103
+ return {
104
+ "tool_name": most_common_tuple[0],
105
+ "target": most_common_tuple[1],
106
+ "count": most_common_count,
107
+ "window": window,
108
+ }
109
+ return None
110
+
111
+
112
+ def _parse_claude_log(
113
+ log_path: Path,
114
+ *,
115
+ anomaly_window: int = 0,
116
+ anomaly_threshold: int = 0,
117
+ ) -> dict[str, Any]:
65
118
  """Scan last _TAIL_LINES for rate_limit/result/assistant events.
66
119
 
67
- Returns dict with optional 'transient_error' and 'usage' keys.
120
+ Returns dict with optional 'transient_error', 'usage', and 'anomaly' keys.
121
+ anomaly_window/anomaly_threshold: when both > 0, slide a window over
122
+ (tool_name, target) tuples; populate 'anomaly' if threshold reached.
68
123
  """
69
124
  with log_path.open("r", encoding="utf-8", errors="replace") as f:
70
125
  tail = deque(f, maxlen=_TAIL_LINES)
71
126
  rate_limit_info: dict | None = None
72
127
  result_event: dict | None = None
73
128
  assistant_model: str | None = None
74
- tool_call_count = 0
129
+ tool_calls: list[tuple[str, str | None]] = []
75
130
  for line in tail:
76
131
  line = line.strip()
77
132
  if not line:
@@ -94,9 +149,11 @@ def _parse_claude_log(log_path: Path) -> dict[str, Any]:
94
149
  assistant_model = str(model_val)
95
150
  content = msg.get("content", []) if isinstance(msg, dict) else []
96
151
  if isinstance(content, list):
97
- tool_call_count += sum(
98
- 1 for c in content if isinstance(c, dict) and c.get("type") == "tool_use"
99
- )
152
+ for c in content:
153
+ if isinstance(c, dict) and c.get("type") == "tool_use":
154
+ tool_calls.append(
155
+ (str(c.get("name", "?")), _extract_tool_target(c.get("input", {})))
156
+ )
100
157
 
101
158
  out: dict[str, Any] = {}
102
159
 
@@ -106,11 +163,17 @@ def _parse_claude_log(log_path: Path) -> dict[str, Any]:
106
163
 
107
164
  if result_event is not None:
108
165
  usage_payload = _extract_usage(
109
- result_event, model=assistant_model, tool_call_count=tool_call_count
166
+ result_event, model=assistant_model, tool_call_count=len(tool_calls)
110
167
  )
111
168
  if usage_payload is not None:
112
169
  out["usage"] = usage_payload
113
170
 
171
+ anomaly = _detect_repetitive_tool(
172
+ tool_calls, window=anomaly_window, threshold=anomaly_threshold
173
+ )
174
+ if anomaly is not None:
175
+ out["anomaly"] = anomaly
176
+
114
177
  return out
115
178
 
116
179
 
@@ -0,0 +1,156 @@
1
+ """peek and watch subcommands — snapshot + auto-refresh."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ import time
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+
12
+ from agent_runner import api
13
+ from agent_runner.cli.common import emit, fail, work_dir_from_args
14
+ from agent_runner.config import load_config
15
+
16
+
17
+ def _round_arg(s: str) -> int | str:
18
+ if s == "latest":
19
+ return s
20
+ try:
21
+ return int(s)
22
+ except ValueError as e:
23
+ raise argparse.ArgumentTypeError(f"--round expects int or 'latest', got {s!r}") from e
24
+
25
+
26
+ def _positive_int(s: str) -> int:
27
+ try:
28
+ n = int(s)
29
+ except ValueError as e:
30
+ raise argparse.ArgumentTypeError(f"expects positive int, got {s!r}") from e
31
+ if n <= 0:
32
+ raise argparse.ArgumentTypeError(f"expects positive int (> 0), got {n}")
33
+ return n
34
+
35
+
36
+ def add_parser(sub, parent) -> None:
37
+ for verb, fn in (("peek", cmd_peek), ("watch", cmd_watch)):
38
+ p = sub.add_parser(
39
+ verb, parents=[parent], help=f"{verb} project state with optional drill-down"
40
+ )
41
+ p.add_argument(
42
+ "--round",
43
+ type=_round_arg,
44
+ default=None,
45
+ metavar="N",
46
+ help="Drill into round N (int or 'latest')",
47
+ )
48
+ p.add_argument("--log", action="store_true", help="Include current round's log tail")
49
+ p.add_argument(
50
+ "--events", type=int, default=None, metavar="N", help="Include last N events"
51
+ )
52
+ p.add_argument(
53
+ "--select",
54
+ type=str,
55
+ default=None,
56
+ help=(
57
+ "Selector: 'events.<kind>' queries events.jsonl (current month); "
58
+ "dot-path (e.g. system.disk_used_pct) extracts a subtree from peek state."
59
+ ),
60
+ )
61
+ p.add_argument(
62
+ "--window",
63
+ type=_positive_int,
64
+ default=10,
65
+ metavar="N",
66
+ help="Max entries returned by --select events.<kind> (default 10, must be > 0).",
67
+ )
68
+ if verb == "watch":
69
+ p.add_argument(
70
+ "--interval",
71
+ type=int,
72
+ default=2,
73
+ metavar="SECONDS",
74
+ help="Refresh interval (default 2)",
75
+ )
76
+ p.set_defaults(func=fn)
77
+
78
+
79
+ def _run_events_select(
80
+ log_dir: Path, *, kind: str, window: int, month_tag: str | None = None
81
+ ) -> list[dict]:
82
+ """Read current-month events.jsonl and return last ``window`` events of ``kind``.
83
+
84
+ Only the current month's file is scanned (documented limitation).
85
+ Returns an empty list when the file is missing or no matching events found.
86
+ """
87
+ if month_tag is None:
88
+ month_tag = datetime.now(UTC).strftime("%Y-%m")
89
+ events_file = log_dir / f"events-{month_tag}.jsonl"
90
+ if not events_file.exists():
91
+ return []
92
+
93
+ matches: list[dict] = []
94
+ with events_file.open("r", encoding="utf-8") as f:
95
+ for line in f:
96
+ line = line.strip()
97
+ if not line:
98
+ continue
99
+ try:
100
+ evt = json.loads(line)
101
+ except json.JSONDecodeError:
102
+ continue
103
+ if evt.get("event") == kind:
104
+ matches.append(evt)
105
+
106
+ return matches[-window:]
107
+
108
+
109
+ def cmd_peek(args) -> int:
110
+ select = args.select
111
+ # Intercept events.<kind> selector before passing to regular peek logic.
112
+ if select is not None and select.startswith("events."):
113
+ kind = select[len("events.") :]
114
+ if not kind:
115
+ print("Error: --select events.<kind> requires a non-empty kind", file=sys.stderr)
116
+ return 2
117
+ try:
118
+ cfg = load_config(work_dir_from_args(args) / "agent-runner.toml")
119
+ except FileNotFoundError as e:
120
+ return fail(f"config not found: {e}")
121
+ log_dir = cfg.runtime.log_dir
122
+ window = getattr(args, "window", 10) or 10
123
+ matches = _run_events_select(log_dir, kind=kind, window=window)
124
+ if getattr(args, "json", False):
125
+ print(json.dumps(matches, default=str))
126
+ else:
127
+ for m in matches:
128
+ print(json.dumps(m, default=str))
129
+ return 0
130
+
131
+ try:
132
+ result = api.peek(
133
+ work_dir_from_args(args),
134
+ round=args.round,
135
+ log=args.log,
136
+ events=args.events,
137
+ select=select,
138
+ )
139
+ except KeyError as e:
140
+ return fail(str(e))
141
+ except FileNotFoundError as e:
142
+ return fail(f"config not found: {e}")
143
+ emit(result, json_mode=getattr(args, "json", False))
144
+ return 0
145
+
146
+
147
+ def cmd_watch(args) -> int:
148
+ while True:
149
+ sys.stdout.write("\x1b[2J\x1b[H")
150
+ rc = cmd_peek(args)
151
+ if rc != 0:
152
+ return rc
153
+ try:
154
+ time.sleep(args.interval)
155
+ except KeyboardInterrupt:
156
+ return 0