orcho-core 0.1.0__py3-none-any.whl

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 (403) hide show
  1. agents/__init__.py +41 -0
  2. agents/command_guard.py +336 -0
  3. agents/entities.py +70 -0
  4. agents/protocols.py +135 -0
  5. agents/pty_diagnostics.py +43 -0
  6. agents/registry.py +267 -0
  7. agents/runtimes/__init__.py +32 -0
  8. agents/runtimes/_failures.py +232 -0
  9. agents/runtimes/_strategy.py +1952 -0
  10. agents/runtimes/auth.py +180 -0
  11. agents/runtimes/claude.py +789 -0
  12. agents/runtimes/codex.py +790 -0
  13. agents/runtimes/codex_telemetry.py +225 -0
  14. agents/runtimes/gemini.py +494 -0
  15. agents/runtimes/identity.py +125 -0
  16. agents/stall_protocol.py +223 -0
  17. agents/stream.py +759 -0
  18. agents/stream_parsers/__init__.py +47 -0
  19. agents/stream_parsers/claude_jsonl.py +273 -0
  20. agents/stream_parsers/codex_jsonl.py +239 -0
  21. agents/stream_parsers/gemini_jsonl.py +226 -0
  22. agents/stream_parsers/skill_registry.py +94 -0
  23. agents/stream_parsers/tool_invocations.py +411 -0
  24. agents/stream_stall.py +131 -0
  25. cli/_formatters.py +1410 -0
  26. cli/_help.py +193 -0
  27. cli/_profile_menu.py +310 -0
  28. cli/_profile_prompt.py +371 -0
  29. cli/_repair_state.py +116 -0
  30. cli/_run.py +30 -0
  31. cli/_task_prompt.py +61 -0
  32. cli/install_guard.py +138 -0
  33. cli/orcho.py +1693 -0
  34. core/__init__.py +16 -0
  35. core/_config/config.defaults.json +121 -0
  36. core/_config/pipeline_profiles_v2.json +596 -0
  37. core/_config/pricing.openai.snapshot.json +9 -0
  38. core/_prompts/AGENTS.md +64 -0
  39. core/_prompts/CLAUDE.md +1 -0
  40. core/_prompts/README.md +192 -0
  41. core/_prompts/formats/bullets.md +2 -0
  42. core/_prompts/formats/compact.md +4 -0
  43. core/_prompts/formats/detailed.md +3 -0
  44. core/_prompts/formats/handoff.md +2 -0
  45. core/_prompts/formats/terse.md +2 -0
  46. core/_prompts/roles/code_reviewer.md +13 -0
  47. core/_prompts/roles/implementation_engineer.md +11 -0
  48. core/_prompts/roles/plan_reviewer.md +21 -0
  49. core/_prompts/roles/product_owner.md +11 -0
  50. core/_prompts/roles/release_manager.md +14 -0
  51. core/_prompts/roles/systems_architect.md +22 -0
  52. core/_prompts/tasks/code_review.md +17 -0
  53. core/_prompts/tasks/commit_message.md +30 -0
  54. core/_prompts/tasks/correction_triage.md +25 -0
  55. core/_prompts/tasks/cross_contract_bundle.md +11 -0
  56. core/_prompts/tasks/cross_final_acceptance.md +40 -0
  57. core/_prompts/tasks/cross_plan.md +28 -0
  58. core/_prompts/tasks/cross_replan.md +21 -0
  59. core/_prompts/tasks/cross_validate_plan.md +31 -0
  60. core/_prompts/tasks/decompose.md +21 -0
  61. core/_prompts/tasks/final_acceptance.md +59 -0
  62. core/_prompts/tasks/handoff_advice.md +32 -0
  63. core/_prompts/tasks/hypothesis.md +10 -0
  64. core/_prompts/tasks/implement.md +21 -0
  65. core/_prompts/tasks/plan.md +24 -0
  66. core/_prompts/tasks/readonly_plan.md +2 -0
  67. core/_prompts/tasks/repair_changes.md +20 -0
  68. core/_prompts/tasks/replan.md +18 -0
  69. core/_prompts/tasks/review_uncommitted.md +8 -0
  70. core/_prompts/tasks/validate_hypothesis.md +13 -0
  71. core/_prompts/tasks/validate_plan.md +25 -0
  72. core/context/__init__.py +330 -0
  73. core/contracts/__init__.py +0 -0
  74. core/contracts/commit_decision_schema.py +803 -0
  75. core/contracts/cross_plan_schema.py +259 -0
  76. core/contracts/plan_schema.py +249 -0
  77. core/contracts/release_schema.py +283 -0
  78. core/contracts/review_schema.py +153 -0
  79. core/contracts/subtask_attestation_schema.py +141 -0
  80. core/infra/__init__.py +20 -0
  81. core/infra/config.py +859 -0
  82. core/infra/lazy.py +43 -0
  83. core/infra/paths.py +72 -0
  84. core/infra/platform.py +223 -0
  85. core/io/__init__.py +5 -0
  86. core/io/ansi.py +165 -0
  87. core/io/git_helpers.py +364 -0
  88. core/io/journey_prompt.py +79 -0
  89. core/io/output_elision.py +200 -0
  90. core/io/pipeline_block.py +362 -0
  91. core/io/prompt_loader.py +246 -0
  92. core/io/retry.py +784 -0
  93. core/io/stdout_render.py +281 -0
  94. core/io/terminal_input.py +86 -0
  95. core/io/transcript.py +1584 -0
  96. core/io/verification_header.py +471 -0
  97. core/observability/__init__.py +4 -0
  98. core/observability/event_hub.py +535 -0
  99. core/observability/event_kinds.py +387 -0
  100. core/observability/events.py +504 -0
  101. core/observability/live_card.py +399 -0
  102. core/observability/logging.py +254 -0
  103. core/observability/metrics.py +1321 -0
  104. core/observability/phases.py +39 -0
  105. core/observability/pricing.py +273 -0
  106. core/observability/pricing_scrapers.py +481 -0
  107. core/observability/prompt_trace.py +105 -0
  108. core/observability/trace.py +100 -0
  109. orcho_core-0.1.0.dist-info/METADATA +282 -0
  110. orcho_core-0.1.0.dist-info/RECORD +403 -0
  111. orcho_core-0.1.0.dist-info/WHEEL +5 -0
  112. orcho_core-0.1.0.dist-info/entry_points.txt +19 -0
  113. orcho_core-0.1.0.dist-info/licenses/LICENSE +201 -0
  114. orcho_core-0.1.0.dist-info/top_level.txt +5 -0
  115. pipeline/__init__.py +0 -0
  116. pipeline/agent_resolver.py +136 -0
  117. pipeline/allowed_modifications.py +58 -0
  118. pipeline/argv.py +214 -0
  119. pipeline/artifacts/__init__.py +29 -0
  120. pipeline/artifacts/types.py +125 -0
  121. pipeline/attachment_inject.py +101 -0
  122. pipeline/attachment_loader.py +149 -0
  123. pipeline/checkpoint.py +281 -0
  124. pipeline/commit_message_parser.py +174 -0
  125. pipeline/control/__init__.py +155 -0
  126. pipeline/control/from_run_plan.py +185 -0
  127. pipeline/control/handoff_banners.py +254 -0
  128. pipeline/control/handoff_decisions.py +168 -0
  129. pipeline/control/handoff_labels.py +119 -0
  130. pipeline/control/handoff_prompt.py +868 -0
  131. pipeline/control/implement_handoff_digest.py +232 -0
  132. pipeline/control/operator_decisions.py +152 -0
  133. pipeline/control/resume_context.py +1015 -0
  134. pipeline/control/resume_preflight.py +242 -0
  135. pipeline/control/resume_prompt.py +361 -0
  136. pipeline/control/reviewed_loop.py +191 -0
  137. pipeline/cross_project/__init__.py +82 -0
  138. pipeline/cross_project/agent_setup.py +350 -0
  139. pipeline/cross_project/app.py +72 -0
  140. pipeline/cross_project/app_types.py +183 -0
  141. pipeline/cross_project/artifact_bundle.py +391 -0
  142. pipeline/cross_project/cfa_gate.py +656 -0
  143. pipeline/cross_project/checkpoint.py +88 -0
  144. pipeline/cross_project/cli.py +905 -0
  145. pipeline/cross_project/constants.py +27 -0
  146. pipeline/cross_project/contract_check.py +591 -0
  147. pipeline/cross_project/cross_delivery.py +430 -0
  148. pipeline/cross_project/final_acceptance.py +1498 -0
  149. pipeline/cross_project/finalization.py +639 -0
  150. pipeline/cross_project/gate_decisions.py +136 -0
  151. pipeline/cross_project/gate_entries.py +115 -0
  152. pipeline/cross_project/handoff.py +333 -0
  153. pipeline/cross_project/handoff_payloads.py +372 -0
  154. pipeline/cross_project/orchestrator.py +328 -0
  155. pipeline/cross_project/path_alias.py +133 -0
  156. pipeline/cross_project/plan_parser.py +338 -0
  157. pipeline/cross_project/planning_loop.py +1415 -0
  158. pipeline/cross_project/profile_projection.py +498 -0
  159. pipeline/cross_project/profile_setup.py +157 -0
  160. pipeline/cross_project/project_dispatch.py +538 -0
  161. pipeline/cross_project/prompts.py +348 -0
  162. pipeline/cross_project/rendering.py +299 -0
  163. pipeline/cross_project/run_setup.py +336 -0
  164. pipeline/cross_project/session_invoke.py +118 -0
  165. pipeline/cross_project/session_run.py +700 -0
  166. pipeline/cross_project/task_plan.py +98 -0
  167. pipeline/cross_project/terminal.py +92 -0
  168. pipeline/cross_project/types.py +317 -0
  169. pipeline/cross_project/usage.py +327 -0
  170. pipeline/dag_runner.py +1125 -0
  171. pipeline/engine/__init__.py +42 -0
  172. pipeline/engine/artifact_mirror.py +135 -0
  173. pipeline/engine/commit_delivery.py +1898 -0
  174. pipeline/engine/companion_scope.py +460 -0
  175. pipeline/engine/delivery_scope.py +534 -0
  176. pipeline/engine/diff_apply_check.py +476 -0
  177. pipeline/engine/hypothesis.py +457 -0
  178. pipeline/engine/pre_run_dirty.py +610 -0
  179. pipeline/engine/run_diff.py +674 -0
  180. pipeline/engine/run_logging.py +81 -0
  181. pipeline/engine/scope_expansion.py +624 -0
  182. pipeline/engine/session.py +57 -0
  183. pipeline/engine/worktree.py +836 -0
  184. pipeline/engine/worktree_bootstrap.py +425 -0
  185. pipeline/engine/worktree_source.py +204 -0
  186. pipeline/entry_points.py +179 -0
  187. pipeline/evidence/__init__.py +48 -0
  188. pipeline/evidence/bundle.py +159 -0
  189. pipeline/evidence/collector.py +1048 -0
  190. pipeline/evidence/prompt_render.py +136 -0
  191. pipeline/evidence/render_md.py +563 -0
  192. pipeline/evidence/schema.py +570 -0
  193. pipeline/evidence/verification_receipt.py +822 -0
  194. pipeline/json_contract.py +157 -0
  195. pipeline/lifecycle.py +651 -0
  196. pipeline/observability/__init__.py +10 -0
  197. pipeline/observability/context_clearing.py +406 -0
  198. pipeline/observability/context_growth.py +277 -0
  199. pipeline/observability/context_pressure.py +690 -0
  200. pipeline/observability/invocation_outcome.py +164 -0
  201. pipeline/observability/output_class.py +309 -0
  202. pipeline/observability/prompt_render.py +430 -0
  203. pipeline/observability/runtime_compaction.py +572 -0
  204. pipeline/participant_promotion.py +644 -0
  205. pipeline/participants.py +341 -0
  206. pipeline/phases/__init__.py +45 -0
  207. pipeline/phases/adapters.py +459 -0
  208. pipeline/phases/builtin/__init__.py +90 -0
  209. pipeline/phases/builtin/handlers/__init__.py +10 -0
  210. pipeline/phases/builtin/handlers/compliance_check.py +25 -0
  211. pipeline/phases/builtin/handlers/correction_triage.py +356 -0
  212. pipeline/phases/builtin/handlers/final_acceptance.py +396 -0
  213. pipeline/phases/builtin/handlers/implement.py +207 -0
  214. pipeline/phases/builtin/handlers/plan.py +491 -0
  215. pipeline/phases/builtin/handlers/repair_changes.py +367 -0
  216. pipeline/phases/builtin/handlers/review_changes.py +402 -0
  217. pipeline/phases/builtin/handlers/validate_plan.py +235 -0
  218. pipeline/phases/builtin/lifecycle.py +165 -0
  219. pipeline/phases/builtin/plan_artifact.py +375 -0
  220. pipeline/phases/builtin/prompt_parts.py +236 -0
  221. pipeline/phases/builtin/registry.py +103 -0
  222. pipeline/phases/builtin/review_support.py +661 -0
  223. pipeline/phases/builtin/scope_expansion_support.py +565 -0
  224. pipeline/phases/builtin/session_invoke.py +469 -0
  225. pipeline/phases/builtin/session_keys.py +504 -0
  226. pipeline/phases/builtin/session_observability.py +344 -0
  227. pipeline/phases/builtin/subtask_dag.py +944 -0
  228. pipeline/phases/builtin/subtask_dag_handoff.py +402 -0
  229. pipeline/phases/review_contract_recovery.py +102 -0
  230. pipeline/plan_artifacts.py +441 -0
  231. pipeline/plan_contract.py +100 -0
  232. pipeline/plan_markdown.py +137 -0
  233. pipeline/plan_parser.py +504 -0
  234. pipeline/plugins.py +485 -0
  235. pipeline/presentation.py +52 -0
  236. pipeline/profiles/__init__.py +31 -0
  237. pipeline/profiles/loader.py +1031 -0
  238. pipeline/profiles/session_split_override.py +93 -0
  239. pipeline/project/__init__.py +15 -0
  240. pipeline/project/app.py +234 -0
  241. pipeline/project/auto_detect.py +586 -0
  242. pipeline/project/bootstrap.py +644 -0
  243. pipeline/project/cli.py +1417 -0
  244. pipeline/project/constants.py +20 -0
  245. pipeline/project/correction_fixed_point.py +242 -0
  246. pipeline/project/correction_followup.py +554 -0
  247. pipeline/project/correction_gate_rerun.py +184 -0
  248. pipeline/project/correction_route.py +131 -0
  249. pipeline/project/correction_route_display.py +209 -0
  250. pipeline/project/finalization.py +2386 -0
  251. pipeline/project/followup_worktree.py +469 -0
  252. pipeline/project/gate_repair.py +909 -0
  253. pipeline/project/handoff.py +2047 -0
  254. pipeline/project/handoff_advice.py +713 -0
  255. pipeline/project/handoff_advice_artifact.py +165 -0
  256. pipeline/project/handoff_advice_ci.py +147 -0
  257. pipeline/project/handoff_advice_dispatch.py +166 -0
  258. pipeline/project/handoff_advice_evidence.py +799 -0
  259. pipeline/project/handoff_advice_policy.py +251 -0
  260. pipeline/project/handoff_waiver.py +110 -0
  261. pipeline/project/isolation_setup.py +589 -0
  262. pipeline/project/phase_config.py +114 -0
  263. pipeline/project/profile_dispatch.py +970 -0
  264. pipeline/project/profile_setup.py +451 -0
  265. pipeline/project/project_aliases.py +116 -0
  266. pipeline/project/project_discovery_prompt.py +231 -0
  267. pipeline/project/provider_recovery.py +141 -0
  268. pipeline/project/resume_artifacts.py +301 -0
  269. pipeline/project/resume_worktree.py +253 -0
  270. pipeline/project/retry_subject.py +146 -0
  271. pipeline/project/run.py +1486 -0
  272. pipeline/project/run_setup.py +574 -0
  273. pipeline/project/runtime_setup.py +438 -0
  274. pipeline/project/session_run.py +562 -0
  275. pipeline/project/state_setup.py +542 -0
  276. pipeline/project/types.py +211 -0
  277. pipeline/project/verification_autorun.py +475 -0
  278. pipeline/project/verification_timeline.py +902 -0
  279. pipeline/project/workspace_picker.py +194 -0
  280. pipeline/project_orchestrator.py +36 -0
  281. pipeline/project_testing.py +177 -0
  282. pipeline/prompts/__init__.py +103 -0
  283. pipeline/prompts/builders.py +2083 -0
  284. pipeline/prompts/composer.py +372 -0
  285. pipeline/prompts/contract_templates.py +724 -0
  286. pipeline/prompts/contracts.py +713 -0
  287. pipeline/prompts/delta.py +375 -0
  288. pipeline/prompts/envelope.py +264 -0
  289. pipeline/prompts/minimal_intents.py +283 -0
  290. pipeline/prompts/modes.py +112 -0
  291. pipeline/prompts/session.py +249 -0
  292. pipeline/prompts/spec.py +86 -0
  293. pipeline/prompts/subtask.py +294 -0
  294. pipeline/prompts/turn.py +402 -0
  295. pipeline/prompts/types.py +276 -0
  296. pipeline/quality_gates.py +337 -0
  297. pipeline/release_markdown.py +130 -0
  298. pipeline/release_parser.py +239 -0
  299. pipeline/repair_protocol.py +297 -0
  300. pipeline/review_markdown.py +75 -0
  301. pipeline/review_parser.py +140 -0
  302. pipeline/run_state/__init__.py +139 -0
  303. pipeline/run_state/consistency.py +258 -0
  304. pipeline/run_state/cross.py +398 -0
  305. pipeline/run_state/cross_repair.py +238 -0
  306. pipeline/run_state/handoff.py +289 -0
  307. pipeline/run_state/phase_outcome.py +63 -0
  308. pipeline/run_state/projector.py +56 -0
  309. pipeline/run_state/provider_runtime.py +118 -0
  310. pipeline/run_state/reducer.py +236 -0
  311. pipeline/run_state/release_verdict.py +76 -0
  312. pipeline/run_state/repair.py +426 -0
  313. pipeline/run_state/setup_failure.py +390 -0
  314. pipeline/run_state/stalled_command.py +113 -0
  315. pipeline/run_state/status_vocab.py +56 -0
  316. pipeline/run_state/subtask_progress.py +81 -0
  317. pipeline/run_state/terminal.py +388 -0
  318. pipeline/run_state/terminal_outcome.py +653 -0
  319. pipeline/run_state/types.py +213 -0
  320. pipeline/runtime/__init__.py +166 -0
  321. pipeline/runtime/handoff.py +684 -0
  322. pipeline/runtime/profile.py +549 -0
  323. pipeline/runtime/results.py +63 -0
  324. pipeline/runtime/roles.py +343 -0
  325. pipeline/runtime/run_shape.py +440 -0
  326. pipeline/runtime/runner.py +1192 -0
  327. pipeline/runtime/scope_expansion_sanction.py +292 -0
  328. pipeline/runtime/semantic_mode_defaults.py +83 -0
  329. pipeline/runtime/session_disposition.py +149 -0
  330. pipeline/runtime/state.py +105 -0
  331. pipeline/runtime/steps.py +388 -0
  332. pipeline/runtime/topology_detection.py +212 -0
  333. pipeline/runtime/work_kind_detection.py +634 -0
  334. pipeline/sandbox/__init__.py +64 -0
  335. pipeline/sandbox/backends/__init__.py +7 -0
  336. pipeline/sandbox/backends/_env_filter.py +70 -0
  337. pipeline/sandbox/backends/env_unix.py +132 -0
  338. pipeline/sandbox/backends/env_windows.py +148 -0
  339. pipeline/sandbox/backends/null_backend.py +45 -0
  340. pipeline/sandbox/capabilities.py +76 -0
  341. pipeline/sandbox/context.py +59 -0
  342. pipeline/sandbox/defaults.py +128 -0
  343. pipeline/sandbox/launcher.py +129 -0
  344. pipeline/sandbox/masking.py +100 -0
  345. pipeline/sandbox/policy.py +168 -0
  346. pipeline/sandbox/resolver.py +255 -0
  347. pipeline/session_adapters.py +780 -0
  348. pipeline/skills/__init__.py +56 -0
  349. pipeline/skills/discover.py +296 -0
  350. pipeline/skills/inject.py +202 -0
  351. pipeline/skills/loader.py +531 -0
  352. pipeline/skills/migrate.py +351 -0
  353. pipeline/skills/types.py +113 -0
  354. pipeline/subtask_attestation_parser.py +154 -0
  355. pipeline/subtask_attestation_repair.py +123 -0
  356. pipeline/subtask_substance_repair.py +169 -0
  357. pipeline/verification_command.py +206 -0
  358. pipeline/verification_contract.py +1002 -0
  359. pipeline/verification_delivery.py +605 -0
  360. pipeline/verification_dependencies.py +202 -0
  361. pipeline/verification_env.py +353 -0
  362. pipeline/verification_policy.py +256 -0
  363. pipeline/verification_readiness.py +1405 -0
  364. pipeline/verification_receipt_index.py +178 -0
  365. pipeline/verification_selection.py +408 -0
  366. pipeline/verification_waiver.py +154 -0
  367. sdk/__init__.py +312 -0
  368. sdk/_jsonable.py +41 -0
  369. sdk/_time.py +48 -0
  370. sdk/actions.py +526 -0
  371. sdk/cost.py +239 -0
  372. sdk/errors.py +94 -0
  373. sdk/events.py +36 -0
  374. sdk/evidence.py +105 -0
  375. sdk/evidence_slices.py +1069 -0
  376. sdk/fine_tune.py +278 -0
  377. sdk/handoff_advice.py +339 -0
  378. sdk/history.py +57 -0
  379. sdk/metrics.py +79 -0
  380. sdk/phase_handoff.py +837 -0
  381. sdk/pricing.py +91 -0
  382. sdk/prompts.py +62 -0
  383. sdk/run_control/__init__.py +74 -0
  384. sdk/run_control/commands.py +91 -0
  385. sdk/run_control/delivery.py +944 -0
  386. sdk/run_control/diagnosis.py +511 -0
  387. sdk/run_control/events.py +82 -0
  388. sdk/run_control/recovery_lineage.py +703 -0
  389. sdk/run_control/recovery_lineage_resolve.py +69 -0
  390. sdk/run_control/runtime_override.py +194 -0
  391. sdk/run_control/service.py +472 -0
  392. sdk/run_control/snapshots.py +225 -0
  393. sdk/run_control/types.py +425 -0
  394. sdk/run_diff.py +320 -0
  395. sdk/runner.py +230 -0
  396. sdk/runs.py +196 -0
  397. sdk/runtimes.py +70 -0
  398. sdk/status.py +195 -0
  399. sdk/types.py +340 -0
  400. sdk/verification_timeline.py +542 -0
  401. sdk/verify.py +516 -0
  402. sdk/workspace.py +818 -0
  403. sdk/workspace_scaffold.py +190 -0
agents/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """
2
+ agents — Provider wrappers, Protocols, entities, and the agent registry.
3
+
4
+ Public surface (re-exported here so callers can ``from agents import X``):
5
+
6
+ Protocol IAgentRuntime, SessionMode
7
+ Entities SubTask, TestResult
8
+ Registry AgentRegistry, PhaseAgentConfig
9
+ Runtimes ClaudeAgent, CodexAgent
10
+ Streaming _stream_run, set_agent_log, set_stdout_echo
11
+ subprocess re-exported for tests that monkeypatch agents.subprocess
12
+ """
13
+
14
+ import subprocess # re-exported so tests can `monkeypatch.setattr(agents.subprocess, ...)`
15
+
16
+ from agents.entities import SubTask, TestResult
17
+ from agents.protocols import IAgentRuntime, SessionMode
18
+ from agents.registry import AgentRegistry, PhaseAgentConfig
19
+ from agents.runtimes import ClaudeAgent, CodexAgent
20
+ from agents.stream import StreamAbort, _stream_run, set_agent_log, set_stdout_echo
21
+
22
+ __all__ = [
23
+ # Protocol
24
+ "IAgentRuntime",
25
+ "SessionMode",
26
+ # Entities
27
+ "SubTask",
28
+ "TestResult",
29
+ # Registry
30
+ "AgentRegistry",
31
+ "PhaseAgentConfig",
32
+ # Providers
33
+ "ClaudeAgent",
34
+ "CodexAgent",
35
+ # Streaming
36
+ "_stream_run",
37
+ "StreamAbort",
38
+ "set_agent_log",
39
+ "set_stdout_echo",
40
+ "subprocess",
41
+ ]
@@ -0,0 +1,336 @@
1
+ """Runtime guardrails for agent-issued shell commands.
2
+
3
+ The prompts tell write-capable agents not to discard user-owned work, but
4
+ prompts are advisory. This module is the runtime backstop for the specific
5
+ class of failure that live repeated-run testing exposed: destructive git
6
+ rollback commands issued through Claude's Bash tool.
7
+
8
+ A second, *diagnostic-only* guard flags unsafe free-text process polling
9
+ (``pgrep -f`` / ``pkill -f``). Unlike the destructive-git guard — which aborts
10
+ the call — the process-polling guard never aborts and never kills: it carries
11
+ the :class:`agents.stall_protocol.StallReason` ``unsafe_process_polling``
12
+ category so the runtime can emit a ``warn`` diagnostic and keep running.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ import shlex
19
+ from collections.abc import Iterator
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+
23
+ from agents.stall_protocol import StallReason
24
+
25
+ ORCHO_GUARDRAIL_BLOCKED = "ORCHO_GUARDRAIL_BLOCKED"
26
+
27
+ #: Guardrail category for the destructive-git guard (abort semantics).
28
+ GUARDRAIL_DESTRUCTIVE_GIT = "destructive_git"
29
+ #: Guardrail category for the unsafe free-text process-polling guard
30
+ #: (diagnostic ``warn`` only — never abort/kill). Equal to the
31
+ #: ``StallReason.unsafe_process_polling`` value so the two layers agree.
32
+ GUARDRAIL_UNSAFE_PROCESS_POLLING = str(StallReason.UNSAFE_PROCESS_POLLING)
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class BlockedCommand:
37
+ """A shell command flagged by a runtime guardrail.
38
+
39
+ ``guardrail`` names the category so callers can route the verdict: the
40
+ default ``destructive_git`` is an abort, while ``unsafe_process_polling``
41
+ is a diagnostic warn that must NOT abort or kill the subprocess.
42
+ """
43
+
44
+ command: str
45
+ reason: str
46
+ guardrail: str = GUARDRAIL_DESTRUCTIVE_GIT
47
+
48
+
49
+ _SEGMENT_SPLIT_RE = re.compile(r"(?:&&|\|\||;|\n)")
50
+ _ENV_ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
51
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
52
+ _TEXT_PREFIX_RE = re.compile(
53
+ r"^(?:tool\s+Bash:|Bash:|command:|cmd:|shell:|run:|exec:)\s*",
54
+ re.IGNORECASE,
55
+ )
56
+
57
+
58
+ def blocked_destructive_git_command(
59
+ command: str,
60
+ *,
61
+ worktree_cwd_path: str | Path | None = None,
62
+ ) -> BlockedCommand | None:
63
+ """Return a block reason when ``command`` contains risky git mutation.
64
+
65
+ The guard is intentionally small and targeted. It does not try to be a
66
+ general shell sandbox; it catches the rollback/switch commands that can
67
+ erase or hide pre-existing working-tree state during Orcho write phases.
68
+
69
+ When ``worktree_cwd_path`` is set, the agent's effective cwd is an
70
+ orcho-managed isolated worktree (ADR 0033). Destructive git inside the
71
+ worktree cannot harm user-owned work, so the block is lifted — the agent
72
+ has full git freedom inside its own sandbox. Outside the worktree (or
73
+ when the parameter is None) the existing detection stands unchanged.
74
+ """
75
+ if worktree_cwd_path is not None:
76
+ return None
77
+ for segment in _SEGMENT_SPLIT_RE.split(command or ""):
78
+ argv = _safe_split(segment)
79
+ if not argv:
80
+ continue
81
+ argv = _strip_prefixes(argv)
82
+ if len(argv) < 2 or argv[0] != "git":
83
+ continue
84
+ subcommand = argv[1]
85
+ if subcommand in {"reset", "restore", "clean", "revert", "switch"}:
86
+ return BlockedCommand(
87
+ command=command,
88
+ reason=f"git {subcommand} can discard or hide working-tree state",
89
+ )
90
+ if subcommand == "checkout":
91
+ return BlockedCommand(
92
+ command=command,
93
+ reason="git checkout can discard file changes or switch context",
94
+ )
95
+ return None
96
+
97
+
98
+ # ── unsafe free-text process polling guard ───────────────────────────────────
99
+
100
+ # A pgrep/pkill invocation, even when wrapped in command substitution or
101
+ # backticks. ``\b`` matches at the start of the binary name when preceded by
102
+ # ``$(`` or `` ` `` (both non-word) just as well as at a line/whitespace start.
103
+ _PGREP_PKILL_RE = re.compile(r"\b(?:pgrep|pkill)\b")
104
+ # A short-option cluster that includes the ``-f`` (full-command-line match)
105
+ # flag — ``-f``, ``-fl``, ``-lf``, ``-af`` … A leading boundary keeps this a
106
+ # flag rather than a substring of an argument.
107
+ _F_FLAG_RE = re.compile(r"(?:^|\s)-[A-Za-z]*f[A-Za-z]*\b")
108
+ # Characters that end the pgrep/pkill argument scope, so an unrelated ``-f``
109
+ # elsewhere in a compound line is not mis-attributed to the pgrep call.
110
+ _CMD_BOUNDARY_RE = re.compile(r"[;&|)`\n]")
111
+
112
+
113
+ def blocked_unsafe_process_polling(
114
+ command: str,
115
+ *,
116
+ worktree_cwd_path: str | Path | None = None,
117
+ ) -> BlockedCommand | None:
118
+ """Flag unsafe free-text process polling (``pgrep -f`` / ``pkill -f``).
119
+
120
+ Detects the full-command-line, free-text process matching an agent reaches
121
+ for when it wants to wait on a backgrounded command, including the compound
122
+ and loop forms:
123
+
124
+ * ``pgrep -f "pytest -q -m"`` / ``pkill -f ...``
125
+ * ``kill -0 $(pgrep -f "pytest -q -m")``
126
+ * ``while kill -0 $(pgrep -f ...); do sleep 1; done``
127
+
128
+ Free-text argv matching is unsafe because it can match *unrelated* host
129
+ processes — the dogfood hazard is a real ``pytest -q -m`` running elsewhere
130
+ on the machine being mistaken for the run's own command. Polling the run's
131
+ OWN child by PID (``kill -0 <pid>``) matches nothing here and is correctly
132
+ left alone.
133
+
134
+ Unlike :func:`blocked_destructive_git_command`, an orcho-managed worktree
135
+ does **not** relax this check — matching foreign processes by argv is a
136
+ hazard regardless of cwd. ``worktree_cwd_path`` is accepted only for
137
+ call-site symmetry and is intentionally ignored.
138
+
139
+ The verdict is derived purely from the command TEXT; this guard never
140
+ scans, signals, or kills any process. The returned ``BlockedCommand``
141
+ carries ``guardrail=unsafe_process_polling`` so the runtime routes it as a
142
+ diagnostic ``warn``, never an abort.
143
+ """
144
+ text = command or ""
145
+ for match in _PGREP_PKILL_RE.finditer(text):
146
+ tail = text[match.end():]
147
+ boundary = _CMD_BOUNDARY_RE.search(tail)
148
+ scope = tail[: boundary.start()] if boundary else tail
149
+ if _F_FLAG_RE.search(scope):
150
+ return BlockedCommand(
151
+ command=command,
152
+ reason=(
153
+ "free-text process polling (pgrep/pkill -f) can match "
154
+ "unrelated processes by command line; poll the run's own "
155
+ "child by PID instead"
156
+ ),
157
+ guardrail=GUARDRAIL_UNSAFE_PROCESS_POLLING,
158
+ )
159
+ return None
160
+
161
+
162
+ def claude_bash_commands_from_jsonl(line: str) -> Iterator[str]:
163
+ """Yield Bash command strings from one Claude stream-json line."""
164
+ line = (line or "").strip()
165
+ if not line.startswith("{"):
166
+ return
167
+ try:
168
+ payload = json.loads(line)
169
+ except json.JSONDecodeError:
170
+ return
171
+ if payload.get("type") != "assistant":
172
+ return
173
+ message = payload.get("message")
174
+ if not isinstance(message, dict):
175
+ return
176
+ for block in message.get("content", []) or []:
177
+ if not isinstance(block, dict):
178
+ continue
179
+ if block.get("type") != "tool_use" or block.get("name") != "Bash":
180
+ continue
181
+ tool_input = block.get("input")
182
+ if not isinstance(tool_input, dict):
183
+ continue
184
+ command = tool_input.get("command")
185
+ if isinstance(command, str) and command.strip():
186
+ yield command
187
+
188
+
189
+ def blocked_claude_write_tool_use(
190
+ line: str,
191
+ *,
192
+ worktree_cwd_path: str | Path | None = None,
193
+ ) -> BlockedCommand | None:
194
+ """Detect blocked Bash tool use in one Claude stream-json line."""
195
+ for command in claude_bash_commands_from_jsonl(line):
196
+ blocked = blocked_destructive_git_command(command, worktree_cwd_path=worktree_cwd_path)
197
+ if blocked is not None:
198
+ return blocked
199
+ return None
200
+
201
+
202
+ def gemini_shell_commands_from_jsonl(line: str) -> Iterator[str]:
203
+ """Yield shell command strings from one Gemini stream-json line.
204
+
205
+ Gemini CLI 0.40 emits ``tool_use`` events as top-level JSONL records
206
+ (not wrapped in an assistant content array like Claude). The
207
+ shell-executing tool is ``run_shell_command`` and the command lands
208
+ under ``parameters.command``. Tool names may change across CLI
209
+ releases — ``run_shell_command`` is the documented 0.40 name. The
210
+ yield is empty for every other tool (``read_file``, ``glob``, ...)
211
+ and for malformed input.
212
+ """
213
+ line = (line or "").strip()
214
+ if not line.startswith("{"):
215
+ return
216
+ try:
217
+ payload = json.loads(line)
218
+ except json.JSONDecodeError:
219
+ return
220
+ if not isinstance(payload, dict) or payload.get("type") != "tool_use":
221
+ return
222
+ if payload.get("tool_name") != "run_shell_command":
223
+ return
224
+ params = payload.get("parameters")
225
+ if not isinstance(params, dict):
226
+ return
227
+ command = params.get("command")
228
+ if isinstance(command, str) and command.strip():
229
+ yield command
230
+
231
+
232
+ def blocked_gemini_write_tool_use(
233
+ line: str,
234
+ *,
235
+ worktree_cwd_path: str | Path | None = None,
236
+ ) -> BlockedCommand | None:
237
+ """Detect blocked shell tool use in one Gemini stream-json line."""
238
+ for command in gemini_shell_commands_from_jsonl(line):
239
+ blocked = blocked_destructive_git_command(
240
+ command, worktree_cwd_path=worktree_cwd_path,
241
+ )
242
+ if blocked is not None:
243
+ return blocked
244
+ return None
245
+
246
+
247
+ def blocked_agent_stream_line(
248
+ line: str,
249
+ *,
250
+ worktree_cwd_path: str | Path | None = None,
251
+ ) -> BlockedCommand | None:
252
+ """Detect blocked commands in a write-agent stream line.
253
+
254
+ Claude and Gemini emit structured stream-json with tool calls;
255
+ Codex prints human-readable command lines. This function supports
256
+ every shape so provider wrappers can share one guardrail.
257
+ """
258
+ # Destructive-git guard first: it aborts the call and is worktree-relaxable.
259
+ structured = blocked_claude_write_tool_use(line, worktree_cwd_path=worktree_cwd_path)
260
+ if structured is not None:
261
+ return structured
262
+ structured = blocked_gemini_write_tool_use(line, worktree_cwd_path=worktree_cwd_path)
263
+ if structured is not None:
264
+ return structured
265
+ for candidate in shell_command_candidates_from_text(line):
266
+ blocked = blocked_destructive_git_command(candidate, worktree_cwd_path=worktree_cwd_path)
267
+ if blocked is not None:
268
+ return blocked
269
+ # Unsafe free-text process polling: a diagnostic warn (never abort/kill),
270
+ # provider-neutral over every command shape, and never worktree-relaxed.
271
+ for command in agent_commands_from_stream_line(line):
272
+ unsafe = blocked_unsafe_process_polling(
273
+ command, worktree_cwd_path=worktree_cwd_path,
274
+ )
275
+ if unsafe is not None:
276
+ return unsafe
277
+ return None
278
+
279
+
280
+ def _clean_stream_text(line: str) -> str:
281
+ """Normalise one human-readable stream line into a bare command string.
282
+
283
+ Strips ANSI, surrounding backticks, the ``tool Bash:`` / ``command:`` …
284
+ prefixes, and a leading shell-prompt sigil (``$`` / ``>`` / ``+``). Returns
285
+ ``""`` for an empty / whitespace-only line.
286
+ """
287
+ text = _ANSI_RE.sub("", line or "").strip()
288
+ if not text:
289
+ return ""
290
+ text = text.strip("`")
291
+ text = _TEXT_PREFIX_RE.sub("", text).strip()
292
+ for prefix in ("$", ">", "+"):
293
+ if text.startswith(prefix):
294
+ text = text[len(prefix):].strip()
295
+ if text.startswith("tool Bash:"):
296
+ text = text.removeprefix("tool Bash:").strip()
297
+ return text
298
+
299
+
300
+ def shell_command_candidates_from_text(line: str) -> Iterator[str]:
301
+ """Yield git-command-looking strings from one human-readable stream line."""
302
+ text = _clean_stream_text(line)
303
+ if text.startswith("git ") or text.startswith("command git ") or text.startswith("sudo git "):
304
+ yield text
305
+
306
+
307
+ def agent_commands_from_stream_line(line: str) -> Iterator[str]:
308
+ """Yield every candidate shell command from one stream line, any provider.
309
+
310
+ Unions the structured tool-use commands (Claude Bash, Gemini
311
+ ``run_shell_command``) with the cleaned human-readable text (Codex). Unlike
312
+ :func:`shell_command_candidates_from_text`, the text candidate is yielded
313
+ regardless of leading binary, because process-polling forms
314
+ (``pgrep -f`` / ``kill -0 $(pgrep -f ...)``) do not start with ``git``.
315
+ """
316
+ yield from claude_bash_commands_from_jsonl(line)
317
+ yield from gemini_shell_commands_from_jsonl(line)
318
+ text = _clean_stream_text(line)
319
+ if text:
320
+ yield text
321
+
322
+
323
+ def _safe_split(segment: str) -> list[str]:
324
+ try:
325
+ return shlex.split(segment)
326
+ except ValueError:
327
+ return segment.strip().split()
328
+
329
+
330
+ def _strip_prefixes(argv: list[str]) -> list[str]:
331
+ out = list(argv)
332
+ while out and _ENV_ASSIGN_RE.match(out[0]):
333
+ out.pop(0)
334
+ while out and out[0] in {"command", "sudo"}:
335
+ out.pop(0)
336
+ return out
agents/entities.py ADDED
@@ -0,0 +1,70 @@
1
+ """agents/entities.py — Domain entities for the agent pipeline.
2
+
3
+ Plan parsing now lives in :mod:`pipeline.plan_parser`; the canonical PLAN
4
+ object is ``ParsedPlan``. This module keeps the agent-adjacent entities that
5
+ do not depend on pipeline runtime wiring.
6
+ """
7
+
8
+ from dataclasses import dataclass, field
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class SubTask:
13
+ """A team-lead-decomposed unit of work with explicit DAG dependencies.
14
+
15
+ Produced by the PLAN phase when the architect emits the structured
16
+ ``## Task N`` / JSON-fence format. ``depends_on`` references other SubTask
17
+ ``id``s — the runner topo-sorts and (later) groups into parallel waves.
18
+ ``skill`` is an optional name resolved against the plugin's skill registry
19
+ (auto-discovered from the plugin folder); when empty, the runner falls
20
+ back to the per-phase build agent. ``done_criteria`` are checkable
21
+ conditions the per-task QA gate validates before marking the subtask done.
22
+
23
+ Phase 1 redesign extensions (additive):
24
+ * ``owned_files`` — glob patterns the subtask writes to. Used by
25
+ Milestone 9 wave executor for proactive conflict avoidance:
26
+ subtasks with overlapping owned_files are scheduled in different
27
+ waves rather than racing on merge.
28
+ * ``architectural_decision`` — flag for the artifact pipeline. When
29
+ ``ArtifactProfile.ADR`` (or higher) is selected, an ADR is emitted
30
+ per subtask carrying this flag (Milestone 11).
31
+ """
32
+ id: str
33
+ goal: str
34
+ spec: str = ""
35
+ files: tuple[str, ...] = field(default_factory=tuple)
36
+ skill: str | None = None
37
+ model: str | None = None
38
+ depends_on: tuple[str, ...] = field(default_factory=tuple)
39
+ done_criteria: tuple[str, ...] = field(default_factory=tuple)
40
+ owned_files: tuple[str, ...] = field(default_factory=tuple)
41
+ # Companion modifications the reviewer may accept for THIS task beyond
42
+ # the project-wide ``allowed_modifications`` (lockfiles, regenerated
43
+ # snapshots, derived artifacts). This is an *informational review
44
+ # allowance* surfaced in the Plan Contract — NOT a write-scope
45
+ # primitive for the wave planner, which schedules on ``owned_files``.
46
+ allowed_modifications: tuple[str, ...] = field(default_factory=tuple)
47
+ architectural_decision: bool = False
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class TestResult:
52
+ """Result of running the project's test suite as a shell step.
53
+
54
+ Produced by the orchestrator's ``run_tests()`` shell wrapper, not by an
55
+ LLM. ``output`` is the combined stdout+stderr fed back into the repair_changes prompt
56
+ when tests fail. ``skipped`` is True when the plugin defines no
57
+ ``run_command`` — the pipeline must treat that as success.
58
+ """
59
+ # Tell pytest not to collect this as a test class (the leading "Test"
60
+ # in the name would otherwise trigger PytestCollectionWarning).
61
+ __test__ = False
62
+
63
+ skipped: bool = False
64
+ passed: bool = True
65
+ output: str = ""
66
+ duration: float = 0.0
67
+
68
+ @property
69
+ def failed(self) -> bool:
70
+ return not self.skipped and not self.passed
agents/protocols.py ADDED
@@ -0,0 +1,135 @@
1
+ """
2
+ agents/protocols.py — Public agent contract.
3
+
4
+ :class:`IAgentRuntime` is the single Protocol every runtime implements. One
5
+ ``invoke()`` entry point: it receives a fully-composed prompt (the composer
6
+ builds it) plus per-call flags and returns raw text. Callers parse plans /
7
+ critiques / etc. from the returned string.
8
+
9
+ ``session_id`` is the bridge handle. A runtime instance carries it across
10
+ phase boundaries so subsequent invocations can ``--resume`` the same
11
+ conversation. Runtimes without resumable sessions leave it ``None``. The
12
+ orchestrator decides per-call whether to resume via ``continue_session=True``;
13
+ the runtime is mechanical and never raises on cross-mutation resume — that
14
+ policy lives in the orchestrator.
15
+
16
+ ``mutates_artifacts`` declares whether the call may modify project artifacts
17
+ on disk (code, configs, generated files in ``cwd``). Maps to the CLI write
18
+ flags. Default ``False`` is safe; ``build`` / ``fix`` phases opt in. The flag
19
+ is per-call, not per-session — one bridge can carry both mutating and
20
+ non-mutating invocations.
21
+
22
+ ``attachments`` is multimodal-only (IMAGE / BINARY). TEXT attachments are
23
+ rendered into the prompt outside the runtime by
24
+ ``builtin._plan_prompt_prefix`` via ``render_text_block``; passing TEXT into
25
+ ``invoke(attachments=...)`` is a caller bug and the runtime raises
26
+ ``ValueError`` to prevent double injection.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from enum import Enum
32
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
33
+
34
+ if TYPE_CHECKING:
35
+ from pipeline.runtime.steps import Attachment
36
+
37
+
38
+ class SessionMode(str, Enum): # noqa: UP042 # StrEnum changes __str__; appears in SDK schema snapshot, keep stable
39
+ """How the orchestrator should chain calls between phases."""
40
+
41
+ AUTO = "auto"
42
+ STATELESS = "stateless"
43
+ CHAIN = "chain"
44
+ HYBRID = "hybrid"
45
+
46
+
47
+ @runtime_checkable
48
+ class IAgentRuntime(Protocol):
49
+ """Unified agent runtime contract.
50
+
51
+ A concrete implementation backs one CLI / SDK (Claude Code, Codex CLI,
52
+ Gemini, …). It receives a fully-composed prompt and returns the agent's
53
+ raw textual response. Plan parsing, review verdict extraction, and any
54
+ other downstream interpretation happen in the caller.
55
+
56
+ Fields:
57
+ model: identifier of the underlying model.
58
+ session_id: bridge handle. ``None`` until the first successful call,
59
+ or for runtimes without resumable sessions. Same instance, same
60
+ session through the pipeline; reset with :meth:`reset_session`.
61
+ _followup_resume_pending: optional follow-up seed flag. When true and
62
+ ``session_id`` is set, the next ``invoke`` must pass that id to the
63
+ runtime resume flag even if the caller did not request continuation.
64
+ _last_resumed_session_id: the exact id passed to the runtime resume
65
+ flag on the most recent call, or ``None`` when the call was fresh.
66
+ _last_followup_parent_session_id: the parent-run session id consumed by
67
+ a follow-up seed on the most recent call, or ``None`` otherwise.
68
+ """
69
+
70
+ model: str
71
+ session_id: str | None
72
+ _followup_resume_pending: bool
73
+ _last_resumed_session_id: str | None
74
+ _last_followup_parent_session_id: str | None
75
+
76
+ def invoke(
77
+ self,
78
+ prompt: str,
79
+ cwd: str,
80
+ *,
81
+ mutates_artifacts: bool = False,
82
+ continue_session: bool = False,
83
+ attachments: tuple[Attachment, ...] = (),
84
+ ) -> str:
85
+ """Run the agent on ``prompt`` inside working directory ``cwd``.
86
+
87
+ Args:
88
+ prompt: fully-composed prompt text (composer built it).
89
+ cwd: project working directory.
90
+ mutates_artifacts: when ``True`` the call may modify files on
91
+ disk in ``cwd``. Maps to the underlying CLI's write flags.
92
+ Default ``False`` is read-only.
93
+ continue_session: when ``True`` and ``self.session_id`` is set,
94
+ resume that session (``--resume``) so this call inherits the
95
+ bridge's accumulated context. Otherwise start fresh and
96
+ capture a new ``session_id``.
97
+ attachments: multimodal attachments (IMAGE / BINARY) the runtime
98
+ must hand to the CLI. **TEXT attachments are forbidden here**
99
+ — they are rendered into ``prompt`` outside the runtime; if
100
+ a TEXT entry leaks in, the runtime raises ``ValueError``.
101
+
102
+ Returns:
103
+ Raw assistant text. Callers parse plans/critiques/etc. from it.
104
+ """
105
+ ...
106
+
107
+ def reset_session(self) -> None:
108
+ """Clear ``session_id`` so the next ``invoke()`` starts fresh.
109
+
110
+ Use to deliberately burn the bridge (e.g. when a human operator
111
+ requested a clean restart from a paused run).
112
+ """
113
+ ...
114
+
115
+ # ── Optional capability: account identity diagnostics ────────────────
116
+ #
117
+ # A runtime MAY implement ``probe_identity(self) -> RuntimeIdentity`` to
118
+ # report which provider account / organization it is executing under. It
119
+ # is intentionally NOT declared as a required method on this Protocol:
120
+ # the capability is structural, so a third-party runtime that omits it
121
+ # simply yields an ``unavailable`` identity via
122
+ # :func:`agents.runtimes.identity.probe_runtime_identity`.
123
+ #
124
+ # Contract for implementers:
125
+ # * Diagnostic only — never an authorization or delivery decision.
126
+ # * Best-effort and non-interactive: read a status surface the provider
127
+ # already shows users, with a short timeout. Any failure (missing
128
+ # binary, timeout, non-zero exit, unparsable output, no account field)
129
+ # returns ``RuntimeIdentity.unavailable(...)``; it must never raise.
130
+ # * Sanitized: populate only ``account_label`` / ``email`` (values the
131
+ # provider already surfaces). Never read or return tokens, cookies,
132
+ # auth-file paths, or raw auth JSON.
133
+ # * Lazy: must not be called from the constructor, profile listing, or
134
+ # dry-run rendering. Resolving the CLI binary here (first real use) is
135
+ # fine; the orchestrator only calls it during real run setup.
@@ -0,0 +1,43 @@
1
+ """Diagnostics for startup-time pseudo-terminal allocation failures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import errno
6
+
7
+ PTY_EXHAUSTED_SENTINEL = "ORCHO_SYSTEM_PTY_EXHAUSTED"
8
+
9
+
10
+ def is_pty_exhaustion(exc: OSError) -> bool:
11
+ """Return True when ``pty.openpty`` failed because no PTYs are available."""
12
+ message = str(exc).lower()
13
+ return (
14
+ exc.errno in {errno.ENOSPC, errno.EAGAIN}
15
+ or "out of pty devices" in message
16
+ or "no available pty" in message
17
+ or "device not configured" in message
18
+ )
19
+
20
+
21
+ def render_pty_exhaustion_diagnostic(exc: OSError) -> str:
22
+ """Human recovery text for a system PTY pool exhaustion blocker."""
23
+ return "\n".join((
24
+ f"{PTY_EXHAUSTED_SENTINEL}: PTY pool exhausted before agent startup.",
25
+ "",
26
+ "Orcho could not allocate a pseudo-terminal for the agent runtime.",
27
+ "This is a system resource blocker, not a task, plan, or code-review failure.",
28
+ "It is usually caused by external orphaned PTY holders, such as terminal",
29
+ "sessions or computer-use/browser automation clients that were not cleaned up.",
30
+ "",
31
+ "Diagnostics:",
32
+ " python -c \"import pty; print(pty.openpty())\"",
33
+ " lsof 2>/dev/null | grep -E '/dev/(ttys|ptmx|pty)' | "
34
+ "awk '{print $1, $2, $9}' | sort | uniq -c | sort -nr | head",
35
+ " ps aux | grep '[S]kyComputerUseClient'",
36
+ " ps aux | grep '[p]ty'",
37
+ "",
38
+ "Recovery:",
39
+ " close or restart the leaking client, or terminate orphaned PTY holders;",
40
+ " then rerun the same Orcho command.",
41
+ "",
42
+ f"Original error: {exc}",
43
+ ))