brindle 0.4.2__tar.gz → 0.4.3__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 (163) hide show
  1. {brindle-0.4.2 → brindle-0.4.3}/PKG-INFO +2 -2
  2. {brindle-0.4.2 → brindle-0.4.3}/README.md +1 -1
  3. {brindle-0.4.2 → brindle-0.4.3}/pyproject.toml +1 -1
  4. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/agents.py +32 -7
  5. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/autopilot.py +94 -7
  6. brindle-0.4.3/src/brindle/builtin_agents/developer-sonnet.md +7 -0
  7. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/reviewer-codex.md +1 -1
  8. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/reviewer-gemini.md +1 -1
  9. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/reviewer-local.md +1 -1
  10. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/reviewer-opencode.md +1 -1
  11. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/reviewer.md +1 -1
  12. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/codex_hook.py +52 -11
  13. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/config.py +2 -2
  14. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/db.py +40 -6
  15. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/launch_health.py +34 -7
  16. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/mcp_server.py +24 -0
  17. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/providers.py +3 -2
  18. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/view.py +1 -0
  19. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/watch.py +2 -0
  20. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/worker_models.py +1 -1
  21. {brindle-0.4.2 → brindle-0.4.3}/.gitignore +0 -0
  22. {brindle-0.4.2 → brindle-0.4.3}/LICENSE +0 -0
  23. {brindle-0.4.2 → brindle-0.4.3}/SCHEDULE-A +0 -0
  24. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/__init__.py +0 -0
  25. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/__main__.py +0 -0
  26. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/account.py +0 -0
  27. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/airgap.py +0 -0
  28. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/antigravity.py +0 -0
  29. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/assembler.py +0 -0
  30. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/assets/braid_dark.png +0 -0
  31. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/assets/braid_light.png +0 -0
  32. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/budget.py +0 -0
  33. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/__init__.py +0 -0
  34. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/backend-auditor.md +0 -0
  35. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/coordinator.md +0 -0
  36. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/developer-codex.md +0 -0
  37. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/developer-fable.md +0 -0
  38. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/developer-gemini.md +0 -0
  39. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/developer-heavy.md +0 -0
  40. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/developer-light.md +0 -0
  41. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/developer-local.md +0 -0
  42. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/developer-opencode.md +0 -0
  43. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/developer.md +0 -0
  44. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/qa.md +0 -0
  45. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/subagent.md +0 -0
  46. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/supervisor.md +0 -0
  47. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_rules/__init__.py +0 -0
  48. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_rules/security/backend.md +0 -0
  49. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
  50. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_rules/tests/only.md +0 -0
  51. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/ci_adapters.py +0 -0
  52. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/ci_client.py +0 -0
  53. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/ci_federation.py +0 -0
  54. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/ci_hosts.py +0 -0
  55. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/cli.py +0 -0
  56. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/cloud_credential.py +0 -0
  57. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/cloud_login.py +0 -0
  58. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/codemap.py +0 -0
  59. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/codemap_docs.py +0 -0
  60. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/company_identity.py +0 -0
  61. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/company_login.py +0 -0
  62. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/conflicts.py +0 -0
  63. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/coordinator.py +0 -0
  64. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/cost.py +0 -0
  65. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/cost_estimate.py +0 -0
  66. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/cull.py +0 -0
  67. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/demo.py +0 -0
  68. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/detect.py +0 -0
  69. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/doctor.py +0 -0
  70. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/escapes.py +0 -0
  71. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/events.py +0 -0
  72. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/gates.py +0 -0
  73. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/git.py +0 -0
  74. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/guardrails.py +0 -0
  75. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/history.py +0 -0
  76. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/identity_lock.py +0 -0
  77. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/inbox.py +0 -0
  78. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/kb.py +0 -0
  79. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/key_check.py +0 -0
  80. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/keys_cmds.py +0 -0
  81. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/keystore.py +0 -0
  82. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/knowledge.py +0 -0
  83. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/learned_rules.py +0 -0
  84. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/learning.py +0 -0
  85. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/legacy.py +0 -0
  86. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/local_setup.py +0 -0
  87. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/login_pause.py +0 -0
  88. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/model_access.py +0 -0
  89. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/model_availability.py +0 -0
  90. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/native/__init__.py +0 -0
  91. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/native/catalog.py +0 -0
  92. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/native/client.py +0 -0
  93. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/native/loop.py +0 -0
  94. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/native/lsp.py +0 -0
  95. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/native/permissions.py +0 -0
  96. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/native/runner.py +0 -0
  97. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/native/serve.py +0 -0
  98. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/native/tools.py +0 -0
  99. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/opencode.py +0 -0
  100. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pane_auth.py +0 -0
  101. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/permissions.py +0 -0
  102. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/picker.py +0 -0
  103. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pipeline.py +0 -0
  104. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/plugins.py +0 -0
  105. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/policy.py +0 -0
  106. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pool.py +0 -0
  107. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pricing.py +0 -0
  108. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/__init__.py +0 -0
  109. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/_files.py +0 -0
  110. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/account.py +0 -0
  111. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/audit_chain.py +0 -0
  112. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/audit_export.py +0 -0
  113. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/auth.py +0 -0
  114. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/brief_learning.py +0 -0
  115. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/cost_centers.py +0 -0
  116. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/credentials.py +0 -0
  117. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/features.py +0 -0
  118. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/keys.py +0 -0
  119. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/learning.py +0 -0
  120. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/license.py +0 -0
  121. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/loopback.py +0 -0
  122. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/managed_models.py +0 -0
  123. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/org_profiles.py +0 -0
  124. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/orgkey.py +0 -0
  125. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/rollout.py +0 -0
  126. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/route_health.py +0 -0
  127. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/settings_sync.py +0 -0
  128. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/status.py +0 -0
  129. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/team_events.py +0 -0
  130. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/team_policy.py +0 -0
  131. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/procs.py +0 -0
  132. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/profiles.py +0 -0
  133. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/prompt_answer.py +0 -0
  134. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/quota.py +0 -0
  135. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/repo_cmds.py +0 -0
  136. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/repos.py +0 -0
  137. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/rewind.py +0 -0
  138. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/rule_checks.py +0 -0
  139. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/savings.py +0 -0
  140. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/scratch.py +0 -0
  141. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/secrets.py +0 -0
  142. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/self_serve.py +0 -0
  143. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/services.py +0 -0
  144. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/sessions.py +0 -0
  145. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/sidebar_follow.py +0 -0
  146. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/sidebar_logo.py +0 -0
  147. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/signin_pause.py +0 -0
  148. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/status_cache.py +0 -0
  149. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/tasks.py +0 -0
  150. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/tmux.py +0 -0
  151. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/updates.py +0 -0
  152. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/upgrade.py +0 -0
  153. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/usage.py +0 -0
  154. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/wake.py +0 -0
  155. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/watch_costs.py +0 -0
  156. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/web/__init__.py +0 -0
  157. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/web/chat.py +0 -0
  158. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/web/dashboard.py +0 -0
  159. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/web/diff.py +0 -0
  160. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/web/server.py +0 -0
  161. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/web/static/app.js +0 -0
  162. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/web/static/index.html +0 -0
  163. {brindle-0.4.2 → brindle-0.4.3}/src/brindle/workspaces.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: brindle
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: Run coding agents in parallel, each on its own git branch, with a supervisor that delegates, reviews, and merges
5
5
  Project-URL: Homepage, https://pawdelta.com/brindle/
6
6
  Project-URL: Documentation, https://pawdelta.com/brindle/docs/recommended-use
@@ -101,7 +101,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
101
101
  brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
102
102
 
103
103
  `brindle --version` prints the installed version; the tmux status bar of every brindle
104
- session shows it too (`brindle 0.4.2`). A session started before an upgrade keeps
104
+ session shows it too (`brindle 0.4.3`). A session started before an upgrade keeps
105
105
  running the old code, and shows the old number, until you restart it.
106
106
 
107
107
  ## Quick start
@@ -70,7 +70,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
70
70
  brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
71
71
 
72
72
  `brindle --version` prints the installed version; the tmux status bar of every brindle
73
- session shows it too (`brindle 0.4.2`). A session started before an upgrade keeps
73
+ session shows it too (`brindle 0.4.3`). A session started before an upgrade keeps
74
74
  running the old code, and shows the old number, until you restart it.
75
75
 
76
76
  ## Quick start
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "brindle"
3
- version = "0.4.2"
3
+ version = "0.4.3"
4
4
  description = "Run coding agents in parallel, each on its own git branch, with a supervisor that delegates, reviews, and merges"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -901,6 +901,13 @@ def _launch(db: DB, agent: Agent, ws: Workspace, *, prompt: str | None,
901
901
  argv = provider.command(LaunchContext(agent.id, profile, prompt, resume=resume, cwd=ws.path,
902
902
  mode=agent.mode, plan_first=bool(agent.plan_first),
903
903
  mcp_recovery=mcp_recovery))
904
+ if provider.name == "codex":
905
+ from brindle import codex_hook
906
+
907
+ try:
908
+ codex_hook.tell_untrusted(db, agent, ws.path) # launched without the hook it couldn't trust
909
+ except Exception: # noqa: BLE001 - never stop a launch over this
910
+ pass
904
911
  if prompt or db.pending_count(agent.id):
905
912
  _prepare_launch_health(db, agent, ws, profile, m, argv_task=bool(prompt and provider.name != "shell"),
906
913
  submitted_prompt=argv[-1] if prompt and provider.name == "codex" else (prompt or ""))
@@ -1801,7 +1808,10 @@ def flush(db: DB, agent_id: str) -> bool:
1801
1808
  break
1802
1809
  if delivered:
1803
1810
  return True
1804
- if db.pending_count(agent_id) == 0 or not db.claim_idle(agent_id):
1811
+ if db.pending_count(agent_id) == 0:
1812
+ return False
1813
+ claimed_at = db.claim_idle(agent_id)
1814
+ if claimed_at is None:
1805
1815
  return False
1806
1816
  if _paste_blocked(agent):
1807
1817
  # Leave it queued: the next flush (a later message, or a resumed
@@ -1816,9 +1826,15 @@ def flush(db: DB, agent_id: str) -> bool:
1816
1826
  assert agent is not None
1817
1827
  from brindle import antigravity
1818
1828
  warmup = agent.provider == "antigravity" and antigravity.WARMUP_END in msg.body
1819
- baseline = launch_health.before_delivery(db, agent, msg.body) if not warmup else ""
1820
- tmux.paste(agent.tmux_window, msg.body, lead=message_lead(db, agent, msg.sender_id),
1821
- server=server_of(agent))
1829
+ try:
1830
+ baseline = launch_health.before_delivery(db, agent, msg.body) if not warmup else ""
1831
+ tmux.paste(agent.tmux_window, msg.body, lead=message_lead(db, agent, msg.sender_id),
1832
+ server=server_of(agent))
1833
+ except BaseException:
1834
+ # pop_pending claimed this row before the external send. Release the
1835
+ # same row and our idle claim so the next flush can retry it once.
1836
+ db.retry_pending(msg.id, agent_id, claimed_at)
1837
+ raise
1822
1838
  if not warmup:
1823
1839
  launch_health.verify_delivery(db, agent, msg.body, baseline, message_lead(db, agent, msg.sender_id))
1824
1840
  return True
@@ -2914,7 +2930,9 @@ def permission_request_decision(db: DB, agent: Agent, payload: dict,
2914
2930
  """Claude Code's or Codex's PermissionRequest hook: brindle's permission
2915
2931
  policy (brindle.permissions) answers allow or deny, or None to let the CLI
2916
2932
  prompt the person as usual (ask). Off unless the repo or user config
2917
- sets ``permission_policy: "on"``. Never raises: any failure is ask."""
2933
+ sets ``permission_policy: "on"``, except for a Codex reviewer, whose hook
2934
+ allows what its profile's allowed_tools cover and leaves the rest to
2935
+ Codex (#241). Never raises: any failure is ask."""
2918
2936
  try:
2919
2937
  from brindle import permissions
2920
2938
  from brindle.config import load_repo_config
@@ -2924,7 +2942,11 @@ def permission_request_decision(db: DB, agent: Agent, payload: dict,
2924
2942
  if ws is None:
2925
2943
  return None
2926
2944
  cfg = load_repo_config(ws.repo_root)
2927
- if cfg.permission_policy != "on":
2945
+ # Policy off: only a Codex reviewer has the hook (codex_hook.launch_flags).
2946
+ # Nobody watches it, so it gets what its allowed_tools cover and
2947
+ # nothing else of brindle's: every other answer is Codex's own.
2948
+ review_only = cfg.permission_policy != "on"
2949
+ if review_only and not (provider == "codex" and agent.mode == "review"):
2928
2950
  return None
2929
2951
  profile_rules = profile_rules_for(agent.profile, ws.repo_root)
2930
2952
  if provider == "codex":
@@ -2945,7 +2967,8 @@ def permission_request_decision(db: DB, agent: Agent, payload: dict,
2945
2967
  profile = None # deleted profile: no extra allowances
2946
2968
  if profile is not None:
2947
2969
  rules += permissions.profile_bash_allows(profile)
2948
- decision = permissions.decide_all(reqs, checks=cfg.checks, rules=rules)
2970
+ decision = permissions.Decision("ask", "not in the profile's allowed_tools") if review_only \
2971
+ else permissions.decide_all(reqs, checks=cfg.checks, rules=rules)
2949
2972
  if decision.decision == "ask" and len(reqs) == 1:
2950
2973
  # A deny on one part of a compound command (cd x && <denied>)
2951
2974
  # wins before any part-by-part allow below.
@@ -2977,6 +3000,8 @@ def permission_request_decision(db: DB, agent: Agent, payload: dict,
2977
3000
  "deny", f"{decision.reason.removesuffix('; asking the user')}, and this profile "
2978
3001
  "never asks (permission_mode dontAsk). Don't retry it: do without it, or ask "
2979
3002
  "your supervisor with the send_message tool (brindle MCP server)")
3003
+ if review_only and decision.decision != "allow":
3004
+ return None # as Codex would without brindle: its own prompt or refusal
2980
3005
  summary = reqs[0].summary() if len(reqs) == 1 else (
2981
3006
  f"{reqs[0].tool}: " + ", ".join(r.path or "?" for r in reqs))[:200]
2982
3007
  _record_permission(db, agent, ws, summary, f"{decision.decision}: {decision.reason}")
@@ -12,6 +12,7 @@ is running, its Stop hook tells it to keep going. It stops pushing when:
12
12
  - the supervisor calls ``need_user`` (blocked on a decision only the user can
13
13
  make), until the user next types something or, for its own question,
14
14
  until the supervisor says it's answered,
15
+ - the supervisor calls ``wait_on`` for an outside job, until it ends,
15
16
  - it has nudged MAX_NUDGES times in a row with no progress (stalled), or
16
17
  - Claude usage is near its limit.
17
18
 
@@ -38,6 +39,7 @@ from __future__ import annotations
38
39
 
39
40
  import json
40
41
  import logging
42
+ import math
41
43
  import os
42
44
  import re
43
45
  import subprocess
@@ -57,6 +59,7 @@ GOALS_DIR = "goals" # .brindle/goals/<session id>.md: each session's ow
57
59
  MAX_NUDGES = 3
58
60
  NUDGE_BACKOFF = 900.0 # seconds before the same reminder is repeated when nothing has changed
59
61
  NUDGE_HEAD = "[brindle autopilot] The goal isn't reached yet, and no workers are running."
62
+ WAIT_ENDED_HEAD = "[brindle autopilot] The job you were waiting on has ended"
60
63
  MAX_GOAL_CHARS = 4000 # Claude Code's limit for a /goal condition
61
64
  OUTPUT_TAIL_LINES = 30
62
65
 
@@ -118,6 +121,12 @@ to be asked each step.
118
121
  depend on it: call `need_user` with the question, ask it, keep working the
119
122
  rest, and report the blocked item with it. Stop only when nothing else is
120
123
  left; once the user answers, call `need_user(answered=true)`.
124
+ - If the next step waits on an outside job, call `wait_on(pid=..., note=...)`
125
+ or `wait_on(until=..., note=...)` (Unix timestamp in seconds); `path=...`
126
+ can watch for a completion file. Autopilot holds reminders and stall counting,
127
+ then wakes you once when the process exits, the time arrives, or the file
128
+ appears. If several are given, the first to finish ends the wait. An existing
129
+ wait is kept if you call again. Keep working on independent steps meanwhile.
121
130
  - When a milestone passes, tell the user in one line.
122
131
  - If a message says autopilot is off, stop driving: wait for the user's
123
132
  instructions.
@@ -853,6 +862,8 @@ def profile_weight(name: str, cfg: RepoConfig | None = None, repo_root: str | No
853
862
  m = effective_profile_model(p, repo_root).lower()
854
863
  if any(h in m for h in ("fable", "opus", "o3", "deepseek-r1", "astra")):
855
864
  return "heavy"
865
+ if "sonnet" in m:
866
+ return "medium"
856
867
  if getattr(p, "effort", None) in ("high", "xhigh", "max"):
857
868
  return "heavy"
858
869
  if "-heavy" in name:
@@ -890,8 +901,9 @@ def choose_profile(db: DB, caller_id: str, repo_root: str, requested: str | None
890
901
  weight: str | None = None, why: list[str] | None = None,
891
902
  decision: dict | None = None) -> tuple[str, bool]:
892
903
  """The worker profile for a delegation, and whether learning chose it:
893
- ``requested`` if given, else the first unverified milestone's profile in
894
- the caller's session, else the repo's routing for ``weight`` (available
904
+ ``requested`` if given, else (when no ``weight`` is given) the first
905
+ unverified milestone's profile in the caller's session, else the repo's
906
+ routing for ``weight`` (available
895
907
  candidates only, the hosted learner choosing among them), else the hosted
896
908
  learner's pick among ``learning_candidates``, else the repo's
897
909
  ``default_agent``. Raises AutopilotError if it doesn't exist. An
@@ -916,7 +928,10 @@ def choose_profile(db: DB, caller_id: str, repo_root: str, requested: str | None
916
928
  routed: dict = {}
917
929
  if not name:
918
930
  pending = next((m for m in db.milestones(root_of(db, caller_id)) if m.status != "passed"), None)
919
- if pending and pending.profile:
931
+ if pending and pending.profile and weight:
932
+ if why is not None:
933
+ why.append(f"weight {weight} overrides milestone profile {pending.profile.strip()}")
934
+ elif pending and pending.profile:
920
935
  m_profile = pending.profile.strip()
921
936
  if ceiling and weight_order[profile_weight(m_profile, cfg, repo_root)] > weight_order[ceiling]:
922
937
  candidates = cfg.routing.get(ceiling, [cfg.default_agent])
@@ -1048,6 +1063,52 @@ def need_user(db: DB, root_id: str, question: str, ask: bool = False) -> None:
1048
1063
  question=note if ask else None)
1049
1064
 
1050
1065
 
1066
+ def wait_on(db: DB, root_id: str, pid: int | None = None, until: float | None = None,
1067
+ note: str = "", path: str | None = None) -> None:
1068
+ """Declare a session wait; any supplied completion condition ends it."""
1069
+ if pid is None and until is None and not path:
1070
+ raise AutopilotError("Give a pid, an until Unix timestamp, or a completion file path.")
1071
+ if pid is not None and not 0 < pid <= 2**31 - 1:
1072
+ raise AutopilotError("pid must be a positive process id.")
1073
+ if until is not None and (not math.isfinite(until) or until < 0):
1074
+ raise AutopilotError("until must be a finite Unix timestamp in seconds.")
1075
+ db.set_external_wait(root_id, json.dumps({"pid": pid, "until": until,
1076
+ "path": path or None, "note": note.strip()[:500]}))
1077
+
1078
+
1079
+ def wait_summary(ap: Autopilot) -> str | None:
1080
+ if not ap.external_wait:
1081
+ return None
1082
+ wait = json.loads(ap.external_wait)
1083
+ parts = []
1084
+ if wait["pid"] is not None:
1085
+ parts.append(f"pid {wait['pid']}")
1086
+ if wait["until"] is not None:
1087
+ parts.append(f"until {wait['until']:g}")
1088
+ if wait["path"]:
1089
+ parts.append(f"file {wait['path']}")
1090
+ return f"waiting on {wait['note'] or 'outside job'} ({', '.join(parts)})"
1091
+
1092
+
1093
+ def wait_ended(db: DB, ap: Autopilot, now: float | None = None) -> str | None:
1094
+ """Queue one durable completion notice from either a Stop hook or sweep."""
1095
+ from brindle import agents, identity_lock, procs
1096
+
1097
+ if not ap.external_wait:
1098
+ return None
1099
+ now = time.time() if now is None else now
1100
+ wait = json.loads(ap.external_wait)
1101
+ ended = ((wait["until"] is not None and now >= wait["until"])
1102
+ or (wait["path"] is not None and Path(wait["path"]).exists())
1103
+ or (wait["pid"] is not None and not procs.alive(wait["pid"])))
1104
+ if not ended:
1105
+ return None
1106
+ identity_lock.ensure(agents.get(db, ap.root_id))
1107
+ note = f": {wait['note']}" if wait["note"] else ""
1108
+ message = f"{WAIT_ENDED_HEAD}{note}. Continue with the next step."
1109
+ return message if db.finish_external_wait(ap.root_id, ap.external_wait, now, message) else None
1110
+
1111
+
1051
1112
  def held(ap: Autopilot) -> bool:
1052
1113
  """Autopilot waits on the supervisor's open question, and nothing has
1053
1114
  blocked it since (that would have replaced the note)."""
@@ -1517,7 +1578,7 @@ def work_state(db: DB, root_id: str) -> str | None:
1517
1578
  ap = db.get_autopilot(root_id)
1518
1579
  if ap is not None and checking(ap):
1519
1580
  return "checking"
1520
- if (ap is not None and ap.state == "usage_paused") or login_pause.paused(db, root_id):
1581
+ if (ap is not None and (ap.state == "usage_paused" or ap.external_wait)) or login_pause.paused(db, root_id):
1521
1582
  return "waiting"
1522
1583
  live = active_workers(db, root_id)
1523
1584
  if any(a.status != "waiting" for a in live):
@@ -1543,6 +1604,8 @@ def progress(db: DB, root_id: str, *, in_flight: bool = True) -> str:
1543
1604
  "user's request (ask what we're building only if there is no request).")
1544
1605
  done, total = counts(db, root_id)
1545
1606
  lines = [f"{head}. Goal: {ap.goal}", f"{done} of {total} milestones verified."]
1607
+ if waiting := wait_summary(ap):
1608
+ lines.append(waiting)
1546
1609
  ms = db.milestones(root_id)
1547
1610
  w = work_state(db, root_id) if in_flight and any(m.status == "failed" for m in ms) else None
1548
1611
  for m in ms:
@@ -1787,7 +1850,10 @@ def usage_resume(db: DB, root_id: str, now: float | None = None) -> str | None:
1787
1850
 
1788
1851
  def usage_sweep(db: DB, now: float | None = None) -> list[str]:
1789
1852
  """One pass over every autopilot session: stop workers at the limit,
1790
- resume them after the reset. Called from the cull sweep."""
1853
+ resume them after the reset, and wake completed outside waits.
1854
+ Called from the cull sweep."""
1855
+ from brindle import agents, identity_lock, login_pause, tmux
1856
+
1791
1857
  done = []
1792
1858
  for root in db.list_agents():
1793
1859
  if root.mode != "interactive" or root.status in ("paused", "done"):
@@ -1800,6 +1866,20 @@ def usage_sweep(db: DB, now: float | None = None) -> list[str]:
1800
1866
  cfg = load_repo_config(ws.repo_root) if ws else RepoConfig()
1801
1867
  if over_limit(db, root.id, cfg):
1802
1868
  done.append(f"usage limit reached: {root.id} is {db.get_autopilot(root.id).state}")
1869
+ elif ((ap.external_wait or db.outstanding(root.id, WAIT_ENDED_HEAD))
1870
+ and not login_pause.paused(db, root.id) and not proposing(ap)):
1871
+ try:
1872
+ message = wait_ended(db, ap, now)
1873
+ if db.outstanding(root.id, WAIT_ENDED_HEAD):
1874
+ identity_lock.ensure(root)
1875
+ if agents.is_alive(root):
1876
+ agents.flush(db, root.id)
1877
+ if message:
1878
+ done.append(message)
1879
+ except (agents.AgentError, tmux.TmuxError):
1880
+ # Keep the wait, or its already queued notice, for retry.
1881
+ # Other sessions and the cull sweep's login recovery must run.
1882
+ log.debug("outside wait delivery deferred for %s", root.id, exc_info=True)
1803
1883
  elif ap.state == "usage_paused":
1804
1884
  line = usage_resume(db, root.id, now)
1805
1885
  if line:
@@ -1849,6 +1929,12 @@ def on_stop(db: DB, agent: Agent, payload: dict) -> dict | None:
1849
1929
 
1850
1930
  if over_limit(db, agent.id, cfg):
1851
1931
  return None # workers included: they'd keep burning usage
1932
+ if ap.external_wait:
1933
+ try:
1934
+ wait_ended(db, ap)
1935
+ except agents.AgentError:
1936
+ log.debug("outside wait completion deferred for %s", agent.id, exc_info=True)
1937
+ return None # the durable inbox notice wakes it, including after a failed delivery
1852
1938
  if checking(ap):
1853
1939
  return None # the check's result arrives as a message and wakes it up
1854
1940
  working, stalled = split_workers(db, agent.id, screen=True)
@@ -1857,7 +1943,7 @@ def on_stop(db: DB, agent: Agent, payload: dict) -> dict | None:
1857
1943
  if any(agents.runs_process(a) for a in working
1858
1944
  if getattr(a, "plan_state", None) != "proposed"):
1859
1945
  return None # their results arrive as messages and wake it up
1860
- if db.outstanding(agent.id, NUDGE_HEAD):
1946
+ if db.outstanding(agent.id, NUDGE_HEAD) or db.outstanding(agent.id, WAIT_ENDED_HEAD):
1861
1947
  return None # (#209) the last reminder is still queued or unread: never pile up another
1862
1948
  now = time.time()
1863
1949
  if (ap.nudged_at == ap.progress and ap.nudged_time is not None
@@ -1908,7 +1994,8 @@ def nudge(db: DB, ap: Autopilot, cfg: RepoConfig,
1908
1994
  f"unverified milestone and assign workers (at most {cfg.max_agents or 'any number'} "
1909
1995
  "at once), get finished branches reviewed and merged, and call check_milestone. "
1910
1996
  "If you need a decision only the user can make, call need_user with the question, "
1911
- "then ask it."
1997
+ "then ask it. If the next step waits on an outside job, call wait_on with its pid, "
1998
+ "an until Unix timestamp, or a completion file path, and a note."
1912
1999
  )
1913
2000
 
1914
2001
 
@@ -0,0 +1,7 @@
1
+ ---
2
+ name: developer-sonnet
3
+ description: Implements a light or medium task on its own branch, on Sonnet with high effort (the baseline of the light and medium tiers of weight routing)
4
+ extends: developer
5
+ model: sonnet
6
+ effort: high
7
+ ---
@@ -3,7 +3,7 @@ name: reviewer-codex
3
3
  description: Reviews a branch's changes for bugs and risks without editing code, using Codex for a second model's view
4
4
  provider: codex
5
5
  permission_mode: dontAsk # nobody watches a reviewer: refuse what allowed_tools doesn't cover, never prompt
6
- allowed_tools: Bash(git status:*), Bash(git ls-files:*), Bash(git stash list), Bash(git diff:*), Bash(git log:*), Bash(git show:*), Bash(git merge-base:*), Bash(pytest:*), Bash(python -m pytest:*), Bash(python3 -m pytest:*), Bash(python -m unittest:*), Bash(python3 -m unittest:*), Bash(uv run:*), Bash(npm test:*), Bash(npm run:*), Bash(pnpm test:*), Bash(pnpm run:*), Bash(yarn test:*), Bash(yarn run:*), Bash(cargo test:*), Bash(cargo check:*), Bash(cargo clippy:*), Bash(go test:*), Bash(go vet:*), Bash(make:*), Bash(swift test:*), Bash(ls:*), Bash(pwd), Bash(cat:*), Bash(tail:*), Bash(head:*), Bash(grep:*), Bash(wc:*)
6
+ allowed_tools: Bash(git status:*), Bash(git ls-files:*), Bash(git stash list), Bash(git diff:*), Bash(git log:*), Bash(git show:*), Bash(git merge-base:*), Bash(gh issue view:*), Bash(gh pr view:*), Bash(gh pr diff:*), Bash(pytest:*), Bash(python -m pytest:*), Bash(python3 -m pytest:*), Bash(python -m unittest:*), Bash(python3 -m unittest:*), Bash(uv run:*), Bash(npm test:*), Bash(npm run:*), Bash(pnpm test:*), Bash(pnpm run:*), Bash(yarn test:*), Bash(yarn run:*), Bash(cargo test:*), Bash(cargo check:*), Bash(cargo clippy:*), Bash(go test:*), Bash(go vet:*), Bash(make:*), Bash(swift test:*), Bash(ls:*), Bash(pwd), Bash(cat:*), Bash(tail:*), Bash(head:*), Bash(grep:*), Bash(wc:*)
7
7
  ---
8
8
  You are a code reviewer running under brindle. Your prompt gives you the
9
9
  worker's original task and its finish line, and, if the repo has checks
@@ -5,7 +5,7 @@ provider: antigravity
5
5
  model: gemini-4-argon
6
6
  effort: high
7
7
  permission_mode: dontAsk # nobody watches a reviewer: refuse what allowed_tools doesn't cover, never prompt
8
- allowed_tools: Bash(git status:*), Bash(git ls-files:*), Bash(git stash list), Bash(git diff:*), Bash(git log:*), Bash(git show:*), Bash(git merge-base:*), Bash(git ls-tree:*), Bash(git rev-parse:*), Bash(git cat-file:*), Bash(git grep:*), Bash(git blame:*), Bash(pytest:*), Bash(python -m pytest:*), Bash(python3 -m pytest:*), Bash(python -m unittest:*), Bash(python3 -m unittest:*), Bash(uv run:*), Bash(npm test:*), Bash(npm run:*), Bash(pnpm test:*), Bash(pnpm run:*), Bash(yarn test:*), Bash(yarn run:*), Bash(cargo test:*), Bash(cargo check:*), Bash(cargo clippy:*), Bash(go test:*), Bash(go vet:*), Bash(make:*), Bash(swift test:*), Bash(ls:*), Bash(pwd), Bash(cat:*), Bash(tail:*), Bash(head:*), Bash(grep:*), Bash(rg:*), Bash(wc:*), Bash(bq show:*), Bash(bq ls:*), Bash(bq query --use_legacy_sql=false --dry_run:*), Bash(bq query --dry_run:*)
8
+ allowed_tools: Bash(git status:*), Bash(git ls-files:*), Bash(git stash list), Bash(git diff:*), Bash(git log:*), Bash(git show:*), Bash(git merge-base:*), Bash(gh issue view:*), Bash(gh pr view:*), Bash(gh pr diff:*), Bash(git ls-tree:*), Bash(git rev-parse:*), Bash(git cat-file:*), Bash(git grep:*), Bash(git blame:*), Bash(pytest:*), Bash(python -m pytest:*), Bash(python3 -m pytest:*), Bash(python -m unittest:*), Bash(python3 -m unittest:*), Bash(uv run:*), Bash(npm test:*), Bash(npm run:*), Bash(pnpm test:*), Bash(pnpm run:*), Bash(yarn test:*), Bash(yarn run:*), Bash(cargo test:*), Bash(cargo check:*), Bash(cargo clippy:*), Bash(go test:*), Bash(go vet:*), Bash(make:*), Bash(swift test:*), Bash(ls:*), Bash(pwd), Bash(cat:*), Bash(tail:*), Bash(head:*), Bash(grep:*), Bash(rg:*), Bash(wc:*), Bash(bq show:*), Bash(bq ls:*), Bash(bq query --use_legacy_sql=false --dry_run:*), Bash(bq query --dry_run:*)
9
9
  ---
10
10
  You are a code reviewer running under brindle. Your prompt gives you the
11
11
  worker's original task and its finish line, and, if the repo has checks
@@ -8,7 +8,7 @@ model: qwen3-coder:30b
8
8
  context_tokens: 32k
9
9
  fallback: reviewer # hosted, when Ollama isn't running or the model isn't pulled
10
10
  permission_mode: dontAsk # nobody watches a reviewer: refuse what allowed_tools doesn't cover
11
- allowed_tools: Bash(git status:*), Bash(git ls-files:*), Bash(git stash list), Bash(git diff:*), Bash(git log:*), Bash(git show:*), Bash(git merge-base:*), Bash(pytest:*), Bash(python -m pytest:*), Bash(python3 -m pytest:*), Bash(python -m unittest:*), Bash(python3 -m unittest:*), Bash(uv run:*), Bash(npm test:*), Bash(npm run:*), Bash(pnpm test:*), Bash(pnpm run:*), Bash(yarn test:*), Bash(yarn run:*), Bash(cargo test:*), Bash(cargo check:*), Bash(cargo clippy:*), Bash(go test:*), Bash(go vet:*), Bash(make:*), Bash(swift test:*), Bash(ls:*), Bash(pwd), Bash(cat:*), Bash(tail:*), Bash(head:*), Bash(grep:*), Bash(wc:*)
11
+ allowed_tools: Bash(git status:*), Bash(git ls-files:*), Bash(git stash list), Bash(git diff:*), Bash(git log:*), Bash(git show:*), Bash(git merge-base:*), Bash(gh issue view:*), Bash(gh pr view:*), Bash(gh pr diff:*), Bash(pytest:*), Bash(python -m pytest:*), Bash(python3 -m pytest:*), Bash(python -m unittest:*), Bash(python3 -m unittest:*), Bash(uv run:*), Bash(npm test:*), Bash(npm run:*), Bash(pnpm test:*), Bash(pnpm run:*), Bash(yarn test:*), Bash(yarn run:*), Bash(cargo test:*), Bash(cargo check:*), Bash(cargo clippy:*), Bash(go test:*), Bash(go vet:*), Bash(make:*), Bash(swift test:*), Bash(ls:*), Bash(pwd), Bash(cat:*), Bash(tail:*), Bash(head:*), Bash(grep:*), Bash(wc:*)
12
12
  ---
13
13
  You are a code reviewer running under brindle. Your prompt gives you the
14
14
  worker's original task and its finish line, and, if the repo has checks
@@ -3,7 +3,7 @@ name: reviewer-opencode
3
3
  description: Reviews a branch's changes for bugs and risks without editing code, using OpenCode for another model's view
4
4
  provider: opencode
5
5
  permission_mode: dontAsk # nobody watches a reviewer: refuse what allowed_tools doesn't cover, never prompt
6
- allowed_tools: Bash(git status:*), Bash(git ls-files:*), Bash(git stash list), Bash(git diff:*), Bash(git log:*), Bash(git show:*), Bash(git merge-base:*), Bash(pytest:*), Bash(python -m pytest:*), Bash(python3 -m pytest:*), Bash(python -m unittest:*), Bash(python3 -m unittest:*), Bash(uv run:*), Bash(npm test:*), Bash(npm run:*), Bash(pnpm test:*), Bash(pnpm run:*), Bash(yarn test:*), Bash(yarn run:*), Bash(cargo test:*), Bash(cargo check:*), Bash(cargo clippy:*), Bash(go test:*), Bash(go vet:*), Bash(make:*), Bash(swift test:*), Bash(ls:*), Bash(pwd), Bash(cat:*), Bash(tail:*), Bash(head:*), Bash(grep:*), Bash(wc:*)
6
+ allowed_tools: Bash(git status:*), Bash(git ls-files:*), Bash(git stash list), Bash(git diff:*), Bash(git log:*), Bash(git show:*), Bash(git merge-base:*), Bash(gh issue view:*), Bash(gh pr view:*), Bash(gh pr diff:*), Bash(pytest:*), Bash(python -m pytest:*), Bash(python3 -m pytest:*), Bash(python -m unittest:*), Bash(python3 -m unittest:*), Bash(uv run:*), Bash(npm test:*), Bash(npm run:*), Bash(pnpm test:*), Bash(pnpm run:*), Bash(yarn test:*), Bash(yarn run:*), Bash(cargo test:*), Bash(cargo check:*), Bash(cargo clippy:*), Bash(go test:*), Bash(go vet:*), Bash(make:*), Bash(swift test:*), Bash(ls:*), Bash(pwd), Bash(cat:*), Bash(tail:*), Bash(head:*), Bash(grep:*), Bash(wc:*)
7
7
  ---
8
8
  You are a code reviewer running under brindle. Your prompt gives you the
9
9
  worker's original task and its finish line, and, if the repo has checks
@@ -7,7 +7,7 @@ effort: medium
7
7
  strict_mcp: true
8
8
  setting_sources: project,local
9
9
  permission_mode: dontAsk # nobody watches a reviewer: refuse what allowed_tools doesn't cover, never prompt
10
- allowed_tools: Bash(git status:*), Bash(git ls-files:*), Bash(git stash list), Bash(git diff:*), Bash(git log:*), Bash(git show:*), Bash(git merge-base:*), Bash(git ls-tree:*), Bash(git rev-parse:*), Bash(git cat-file:*), Bash(git grep:*), Bash(git blame:*), Bash(pytest:*), Bash(python -m pytest:*), Bash(python3 -m pytest:*), Bash(python -m unittest:*), Bash(python3 -m unittest:*), Bash(uv run:*), Bash(npm test:*), Bash(npm run:*), Bash(pnpm test:*), Bash(pnpm run:*), Bash(yarn test:*), Bash(yarn run:*), Bash(cargo test:*), Bash(cargo check:*), Bash(cargo clippy:*), Bash(go test:*), Bash(go vet:*), Bash(make:*), Bash(swift test:*), Bash(ls:*), Bash(pwd), Bash(cat:*), Bash(tail:*), Bash(head:*), Bash(grep:*), Bash(rg:*), Bash(wc:*), Bash(bq show:*), Bash(bq ls:*), Bash(bq query --use_legacy_sql=false --dry_run:*), Bash(bq query --dry_run:*)
10
+ allowed_tools: Bash(git status:*), Bash(git ls-files:*), Bash(git stash list), Bash(git diff:*), Bash(git log:*), Bash(git show:*), Bash(git merge-base:*), Bash(gh issue view:*), Bash(gh pr view:*), Bash(gh pr diff:*), Bash(git ls-tree:*), Bash(git rev-parse:*), Bash(git cat-file:*), Bash(git grep:*), Bash(git blame:*), Bash(pytest:*), Bash(python -m pytest:*), Bash(python3 -m pytest:*), Bash(python -m unittest:*), Bash(python3 -m unittest:*), Bash(uv run:*), Bash(npm test:*), Bash(npm run:*), Bash(pnpm test:*), Bash(pnpm run:*), Bash(yarn test:*), Bash(yarn run:*), Bash(cargo test:*), Bash(cargo check:*), Bash(cargo clippy:*), Bash(go test:*), Bash(go vet:*), Bash(make:*), Bash(swift test:*), Bash(ls:*), Bash(pwd), Bash(cat:*), Bash(tail:*), Bash(head:*), Bash(grep:*), Bash(rg:*), Bash(wc:*), Bash(bq show:*), Bash(bq ls:*), Bash(bq query --use_legacy_sql=false --dry_run:*), Bash(bq query --dry_run:*)
11
11
  ---
12
12
  You are a code reviewer running under brindle. Your prompt gives you the
13
13
  worker's original task and its finish line, and, if the repo has checks
@@ -15,7 +15,8 @@ and only brindle's own Codex agents get it. The key is then always
15
15
  same for every agent (the hook finds its agent from ``BRINDLE_AGENT_ID``, which
16
16
  Codex passes through), so one trust covers every worktree brindle ever creates.
17
17
  A changed command (brindle installed somewhere else, a different BRINDLE_HOME)
18
- is a new hook to Codex.
18
+ or timeout (another build of brindle) is a new hook to Codex, so brindle
19
+ records the whole definition it trusted, not just the command.
19
20
 
20
21
  brindle trusts its own hook the first time a Codex agent needs it (``ensure``),
21
22
  and again after it moves, so there's no setup step; it says so once at
@@ -88,10 +89,11 @@ TRUSTED, MOVED, NEW, REMOVED = "trusted", "moved", "new", "removed"
88
89
 
89
90
 
90
91
  def status(command: str | None = None) -> str:
91
- """Where brindle's hook stands with Codex: TRUSTED (this exact command, and
92
- Codex's config still says so), MOVED (trusted, but brindle's command has
93
- changed since: a reinstall, another BRINDLE_HOME), NEW (never trusted) or
94
- REMOVED (trusted once, then the trust was taken out of Codex's config)."""
92
+ """Where brindle's hook stands with Codex: TRUSTED (this exact hook, and
93
+ Codex's config still says so), MOVED (trusted, but brindle's hook has
94
+ changed since: a reinstall, another BRINDLE_HOME, another timeout), NEW
95
+ (never trusted) or REMOVED (trusted once, then the trust was taken out of
96
+ Codex's config)."""
95
97
  import tomllib
96
98
 
97
99
  from brindle import permissions
@@ -111,7 +113,11 @@ def status(command: str | None = None) -> str:
111
113
  entry = state.get(rec["key"]) if isinstance(state, dict) else None
112
114
  if not (isinstance(entry, dict) and entry.get("trusted_hash") == rec["hash"]):
113
115
  return REMOVED
114
- return TRUSTED if rec["command"] == command else MOVED
116
+ # Codex's hash covers the whole definition, not just the command: a trust
117
+ # recorded for another timeout (another build of brindle) is for another
118
+ # hook, and so is one recorded before brindle kept the definition (it can't
119
+ # tell what that was for), so ensure() trusts it again once.
120
+ return TRUSTED if rec.get("definition") == config_flags(command)[1] else MOVED
115
121
 
116
122
 
117
123
  def trusted(command: str | None = None) -> bool:
@@ -148,13 +154,22 @@ def policy_on(cwd: str | None) -> bool:
148
154
  return False
149
155
 
150
156
 
151
- def launch_flags(cwd: str | None) -> list[str]:
157
+ def launch_flags(cwd: str | None, review: bool = False) -> list[str]:
152
158
  """The flags a Codex agent in ``cwd`` launches with: brindle's hook when the
153
- repo's policy is on and the person trusted the hook; else none."""
159
+ repo's policy is on and the person trusted the hook; else none. A reviewer
160
+ gets it with the policy off too (nobody watches its prompts; the hook only
161
+ allows what its allowed_tools cover, #241), but only a hook trusted
162
+ already: with the policy off brindle never trusts a NEW one itself, and a
163
+ REMOVED trust stays removed."""
154
164
  try:
155
165
  if not policy_on(cwd):
156
- return []
157
- command = hook_command()
166
+ if not review:
167
+ return []
168
+ command = hook_command()
169
+ if status(command) not in (TRUSTED, MOVED):
170
+ return []
171
+ else:
172
+ command = hook_command()
158
173
  return config_flags(command) if ensure(command=command) == TRUSTED else []
159
174
  except Exception: # noqa: BLE001 - never stop a launch over this
160
175
  return []
@@ -178,6 +193,31 @@ def launch_warning(provider: str, cwd: str | None) -> str | None:
178
193
  f"({why}), so Codex asks for approvals itself. Ask the user to run `{INSTALL}`")
179
194
 
180
195
 
196
+ UNTRUSTED = ("brindle couldn't trust its own Codex permission hook (new, or changed by an upgrade), "
197
+ "so Codex agents start without it and Codex asks for approvals itself. "
198
+ f"Ask the user to run `{INSTALL}`.")
199
+
200
+
201
+ def tell_untrusted(db, agent, cwd: str | None) -> bool:
202
+ """Tell ``agent``'s supervisor, once, that brindle couldn't trust its hook,
203
+ so this Codex agent started without it: nobody may watch a reviewer's pane
204
+ to find out otherwise. A trust the person removed stays theirs to give
205
+ back (launch_warning, doctor), not news."""
206
+ if agent.provider != "codex" or not agent.parent_id or not policy_on(cwd) \
207
+ or status() not in (NEW, MOVED):
208
+ return False
209
+ if db.conn.execute("SELECT 1 FROM inbox WHERE agent_id=? AND instr(body, ?) > 0",
210
+ (agent.parent_id, UNTRUSTED)).fetchone():
211
+ return False
212
+ from brindle import agents
213
+
214
+ try:
215
+ agents.send_message(db, agent.parent_id, UNTRUSTED)
216
+ except agents.AgentError:
217
+ db.enqueue(agent.parent_id, UNTRUSTED, None)
218
+ return True
219
+
220
+
181
221
  # -- asking Codex itself ---------------------------------------------------------------
182
222
 
183
223
 
@@ -290,5 +330,6 @@ def trust(binary: str, state: HookState) -> HookState:
290
330
  if after.status != "trusted":
291
331
  raise CodexHookError(f"Codex still reports the hook as {after.status}")
292
332
  with permissions.editing() as store:
293
- store.codex_hook = {"command": after.command, "key": after.key, "hash": after.hash}
333
+ store.codex_hook = {"command": after.command, "key": after.key, "hash": after.hash,
334
+ "definition": config_flags(after.command)[1]}
294
335
  return after
@@ -80,8 +80,8 @@ def normalize_weight(weight: str | None) -> str | None:
80
80
  # Nothing here prefers another provider for its own sake: all-Claude, all-Codex
81
81
  # or all-Gemini may well be best, and that's the learner's call.
82
82
  DEFAULT_ROUTING = {
83
- "light": ["developer-light", "developer", "developer-codex", "developer-local"],
84
- "medium": ["developer", "developer-codex", "developer-heavy"],
83
+ "light": ["developer-sonnet", "developer-light", "developer", "developer-codex", "developer-local"],
84
+ "medium": ["developer-sonnet", "developer", "developer-codex", "developer-heavy"],
85
85
  "heavy": ["developer-heavy", "developer-gemini", "developer", "developer-codex"],
86
86
  "advanced": ["developer-fable", "developer-heavy", "developer-gemini"],
87
87
  }
@@ -107,7 +107,8 @@ CREATE TABLE IF NOT EXISTS autopilot (
107
107
  audit_history TEXT, -- persistent audit notes and prior accepted decisions
108
108
  question TEXT, -- the supervisor's open need_user question (held while note matches)
109
109
  nudged_time REAL, -- when the last nudge was sent
110
- proposal TEXT -- JSON: checks awaiting the user's answer in brindle's dialog
110
+ proposal TEXT, -- JSON: checks awaiting the user's answer in brindle's dialog
111
+ external_wait TEXT -- JSON {pid, until, path, note}: an outside job
111
112
  );
112
113
  CREATE TABLE IF NOT EXISTS milestones (
113
114
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -607,6 +608,7 @@ class Autopilot:
607
608
  question: str | None = None # the open need_user question the user hasn't answered
608
609
  nudged_time: float | None = None # when the last nudge was sent; never cleared, the backoff ignores it once progress moves past nudged_at
609
610
  proposal: str | None = None # JSON {checks, rules, at}: checks awaiting the user's answer
611
+ external_wait: str | None = None # JSON {pid, until, path, note}: an outside job
610
612
 
611
613
 
612
614
  @dataclass
@@ -849,7 +851,7 @@ class DB:
849
851
  self.conn.execute("ALTER TABLE autopilot ADD COLUMN usage_paused_ids TEXT")
850
852
  for col, kind in (("goal_sha", "TEXT"), ("auditor_id", "TEXT"), ("audit_sha", "TEXT"),
851
853
  ("audit_ok", "INTEGER"), ("audit_history", "TEXT"), ("question", "TEXT"),
852
- ("nudged_time", "REAL"), ("proposal", "TEXT")):
854
+ ("nudged_time", "REAL"), ("proposal", "TEXT"), ("external_wait", "TEXT")):
853
855
  if col not in cols:
854
856
  self.conn.execute(f"ALTER TABLE autopilot ADD COLUMN {col} {kind}")
855
857
  cols = {r["name"] for r in self.conn.execute("PRAGMA table_info(milestones)")}
@@ -1166,15 +1168,17 @@ class DB:
1166
1168
  with self.tx() as c:
1167
1169
  c.execute(f"UPDATE agents SET {cols} WHERE id=?", (*fields.values(), agent_id))
1168
1170
 
1169
- def claim_idle(self, agent_id: str) -> bool:
1171
+ def claim_idle(self, agent_id: str) -> float | None:
1170
1172
  """Atomically flip idle -> processing. Only the caller that wins may type
1171
- into the agent's terminal, so two senders never interleave keystrokes."""
1173
+ into the agent's terminal, so two senders never interleave keystrokes.
1174
+ Return this claim's status_since for rollback, or None if already busy."""
1172
1175
  with self.tx() as c:
1176
+ claimed_at = time.time()
1173
1177
  cur = c.execute(
1174
1178
  "UPDATE agents SET status='processing', status_since=? WHERE id=? AND status='idle'",
1175
- (time.time(), agent_id),
1179
+ (claimed_at, agent_id),
1176
1180
  )
1177
- return cur.rowcount == 1
1181
+ return claimed_at if cur.rowcount == 1 else None
1178
1182
 
1179
1183
  _STAMP = "status_since = CASE WHEN status = ? THEN status_since ELSE ? END"
1180
1184
 
@@ -1250,6 +1254,19 @@ class DB:
1250
1254
  c.execute("UPDATE inbox SET delivered_at=? WHERE id=?", (time.time(), row["id"]))
1251
1255
  return _load(Message, row)
1252
1256
 
1257
+ def retry_pending(self, message_id: int, agent_id: str, idle_since: float) -> None:
1258
+ """Release the caller's failed delivery claim without creating a second message.
1259
+
1260
+ Restore idle only if this delivery still owns the processing status;
1261
+ a newer hook or pause must keep its status. Both releases are atomic.
1262
+ """
1263
+ with self.tx() as c:
1264
+ c.execute("UPDATE inbox SET delivered_at=NULL WHERE id=? AND agent_id=?",
1265
+ (message_id, agent_id))
1266
+ c.execute("UPDATE agents SET status='idle', status_since=? "
1267
+ "WHERE id=? AND status='processing' AND status_since=?",
1268
+ (time.time(), agent_id, idle_since))
1269
+
1253
1270
  def drop_pending(self, agent_id: str, sender_id: str) -> int:
1254
1271
  with self.tx() as c:
1255
1272
  cur = c.execute(
@@ -1432,6 +1449,23 @@ class DB:
1432
1449
  with self.tx() as c:
1433
1450
  c.execute(f"UPDATE autopilot SET {cols} WHERE root_id=?", (*fields.values(), root_id))
1434
1451
 
1452
+ def set_external_wait(self, root_id: str, wait: str) -> None:
1453
+ """A repeated declaration must leave the existing wait intact."""
1454
+ with self.tx() as c:
1455
+ c.execute("UPDATE autopilot SET external_wait=? WHERE root_id=? AND external_wait IS NULL",
1456
+ (wait, root_id))
1457
+
1458
+ def finish_external_wait(self, root_id: str, wait: str, now: float, message: str) -> bool:
1459
+ """Atomically replace a wait with its durable completion notice."""
1460
+ with self.tx() as c:
1461
+ changed = c.execute(
1462
+ "UPDATE autopilot SET external_wait=NULL, nudges=0, nudged_at=progress, nudged_time=? "
1463
+ "WHERE root_id=? AND external_wait=?", (now, root_id, wait)).rowcount
1464
+ if changed:
1465
+ c.execute("INSERT INTO inbox (agent_id, sender_id, body, created_at) VALUES (?,NULL,?,?)",
1466
+ (root_id, message, now))
1467
+ return bool(changed)
1468
+
1435
1469
  def bump_progress(self, root_id: str) -> None:
1436
1470
  with self.tx() as c:
1437
1471
  c.execute("UPDATE autopilot SET progress=progress+1, nudges=0 WHERE root_id=?", (root_id,))