brindle 0.3.18__tar.gz → 0.3.20__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 (141) hide show
  1. {brindle-0.3.18 → brindle-0.3.20}/PKG-INFO +29 -3
  2. {brindle-0.3.18 → brindle-0.3.20}/README.md +28 -2
  3. {brindle-0.3.18 → brindle-0.3.20}/pyproject.toml +1 -1
  4. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/agents.py +70 -19
  5. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/autopilot.py +34 -1
  6. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/cli.py +112 -5
  7. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/config.py +19 -1
  8. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/cull.py +18 -0
  9. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/db.py +16 -3
  10. brindle-0.3.20/src/brindle/key_check.py +66 -0
  11. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/keys_cmds.py +18 -1
  12. brindle-0.3.20/src/brindle/local_setup.py +259 -0
  13. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/mcp_server.py +6 -4
  14. brindle-0.3.20/src/brindle/picker.py +284 -0
  15. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pipeline.py +60 -38
  16. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/team_policy.py +86 -0
  17. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/providers.py +9 -1
  18. brindle-0.3.20/src/brindle/worker_models.py +176 -0
  19. {brindle-0.3.18 → brindle-0.3.20}/.gitignore +0 -0
  20. {brindle-0.3.18 → brindle-0.3.20}/LICENSE +0 -0
  21. {brindle-0.3.18 → brindle-0.3.20}/SCHEDULE-A +0 -0
  22. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/__init__.py +0 -0
  23. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/__main__.py +0 -0
  24. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/account.py +0 -0
  25. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/airgap.py +0 -0
  26. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/antigravity.py +0 -0
  27. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/budget.py +0 -0
  28. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/__init__.py +0 -0
  29. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/backend-auditor.md +0 -0
  30. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/developer-codex.md +0 -0
  31. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/developer-fable.md +0 -0
  32. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/developer-gemini.md +0 -0
  33. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/developer-heavy.md +0 -0
  34. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/developer-light.md +0 -0
  35. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/developer-local.md +0 -0
  36. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/developer.md +0 -0
  37. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/qa.md +0 -0
  38. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
  39. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/reviewer-gemini.md +0 -0
  40. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/reviewer-local.md +0 -0
  41. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/reviewer.md +0 -0
  42. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/subagent.md +0 -0
  43. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/supervisor.md +0 -0
  44. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_rules/__init__.py +0 -0
  45. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_rules/security/backend.md +0 -0
  46. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
  47. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_rules/tests/only.md +0 -0
  48. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/ci_adapters.py +0 -0
  49. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/ci_client.py +0 -0
  50. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/ci_federation.py +0 -0
  51. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/ci_hosts.py +0 -0
  52. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/cloud_credential.py +0 -0
  53. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/cloud_login.py +0 -0
  54. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/codemap.py +0 -0
  55. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/codemap_docs.py +0 -0
  56. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/codex_hook.py +0 -0
  57. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/company_identity.py +0 -0
  58. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/company_login.py +0 -0
  59. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/conflicts.py +0 -0
  60. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/cost.py +0 -0
  61. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/cost_estimate.py +0 -0
  62. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/demo.py +0 -0
  63. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/detect.py +0 -0
  64. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/doctor.py +0 -0
  65. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/escapes.py +0 -0
  66. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/events.py +0 -0
  67. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/gates.py +0 -0
  68. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/git.py +0 -0
  69. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/guardrails.py +0 -0
  70. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/history.py +0 -0
  71. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/identity_lock.py +0 -0
  72. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/inbox.py +0 -0
  73. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/keystore.py +0 -0
  74. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/launch_health.py +0 -0
  75. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/learned_rules.py +0 -0
  76. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/learning.py +0 -0
  77. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/legacy.py +0 -0
  78. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/login_pause.py +0 -0
  79. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/model_access.py +0 -0
  80. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/model_availability.py +0 -0
  81. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/native/__init__.py +0 -0
  82. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/native/client.py +0 -0
  83. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/native/loop.py +0 -0
  84. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/native/permissions.py +0 -0
  85. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/native/runner.py +0 -0
  86. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/native/serve.py +0 -0
  87. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/native/tools.py +0 -0
  88. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pane_auth.py +0 -0
  89. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/permissions.py +0 -0
  90. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/plugins.py +0 -0
  91. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/policy.py +0 -0
  92. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pool.py +0 -0
  93. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pricing.py +0 -0
  94. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/__init__.py +0 -0
  95. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/_files.py +0 -0
  96. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/account.py +0 -0
  97. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/audit_chain.py +0 -0
  98. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/audit_export.py +0 -0
  99. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/auth.py +0 -0
  100. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/brief_learning.py +0 -0
  101. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/cost_centers.py +0 -0
  102. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/credentials.py +0 -0
  103. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/features.py +0 -0
  104. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/keys.py +0 -0
  105. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/learning.py +0 -0
  106. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/license.py +0 -0
  107. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/loopback.py +0 -0
  108. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/managed_models.py +0 -0
  109. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/org_profiles.py +0 -0
  110. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/orgkey.py +0 -0
  111. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/rollout.py +0 -0
  112. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/route_health.py +0 -0
  113. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/settings_sync.py +0 -0
  114. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/status.py +0 -0
  115. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/team_events.py +0 -0
  116. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/procs.py +0 -0
  117. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/profiles.py +0 -0
  118. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/prompt_answer.py +0 -0
  119. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/quota.py +0 -0
  120. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/repo_cmds.py +0 -0
  121. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/repos.py +0 -0
  122. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/rewind.py +0 -0
  123. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/rule_checks.py +0 -0
  124. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/savings.py +0 -0
  125. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/scratch.py +0 -0
  126. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/secrets.py +0 -0
  127. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/self_serve.py +0 -0
  128. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/services.py +0 -0
  129. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/sessions.py +0 -0
  130. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/sidebar_follow.py +0 -0
  131. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/signin_pause.py +0 -0
  132. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/status_cache.py +0 -0
  133. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/tasks.py +0 -0
  134. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/tmux.py +0 -0
  135. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/updates.py +0 -0
  136. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/upgrade.py +0 -0
  137. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/usage.py +0 -0
  138. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/view.py +0 -0
  139. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/watch.py +0 -0
  140. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/watch_costs.py +0 -0
  141. {brindle-0.3.18 → brindle-0.3.20}/src/brindle/workspaces.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: brindle
3
- Version: 0.3.18
3
+ Version: 0.3.20
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
@@ -94,7 +94,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
94
94
  brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
95
95
 
96
96
  `brindle --version` prints the installed version; the tmux status bar of every brindle
97
- session shows it too (`brindle 0.3.18`). A session started before an upgrade keeps
97
+ session shows it too (`brindle 0.3.20`). A session started before an upgrade keeps
98
98
  running the old code, and shows the old number, until you restart it.
99
99
 
100
100
  ## Quick start
@@ -215,6 +215,31 @@ brindle pr fix-login # push + gh pr create
215
215
  brindle rm fix-login # deletes the branch only if it's merged (-K keeps it)
216
216
  ```
217
217
 
218
+ ### Choosing the supervisor and workers
219
+
220
+ The first time you run `brindle` it asks which CLI runs the supervisor (the
221
+ agent that plans the work and hands it out), and remembers your pick. After
222
+ that it starts straight away and prints one line naming the CLI. It asks again
223
+ only when your pick is no longer signed in or no longer allowed.
224
+
225
+ - `brindle -s` opens the picker; `brindle -s codex` switches directly. Either
226
+ is remembered.
227
+ - In the picker, `w` opens the workers screen: a model for each task size
228
+ (light, medium, heavy). Models are grouped by how they run: through a CLI
229
+ you sign in to, locally with Ollama, or hosted with an API key. Picking one
230
+ that isn't ready sets it up right there: signing in, installing Ollama and
231
+ downloading the model after a memory check, or asking for the API key.
232
+ - API keys are checked with the provider before they're stored, and stay in
233
+ this machine's keychain.
234
+ - The picker and the workers screen list only the CLIs and models you're
235
+ allowed to use. In an org that means its allowed providers and models for
236
+ your role, its managed routes, and whether personal API keys are allowed.
237
+ `-s` with anything else is refused, and saved picks are re-checked at every
238
+ launch.
239
+ - Without a terminal (scripts, CI), with `--yes`, or in `brindle demo`, nothing
240
+ asks: brindle uses your last pick, else the supervisor profile's CLI (Claude
241
+ Code unless you changed it).
242
+
218
243
  ## Recommended use
219
244
 
220
245
  brindle pays off when work splits into pieces that can proceed in parallel, or
@@ -343,6 +368,7 @@ your own status line prints, so what you see doesn't change.
343
368
  | | |
344
369
  |---|---|
345
370
  | `brindle` | a fresh supervisor chat here, dashboard alongside |
371
+ | `brindle -s [CLI]` | pick the supervisor's CLI (no value: open the picker; `w` there picks worker models); a picker choice is remembered, a named CLI is for this run only |
346
372
  | `brindle supervisor` | the same, with `--branch`/`--worktree` to run it in another worktree |
347
373
  | `brindle init` | detect setup and test commands, write `.brindle/config.json`, check tools |
348
374
  | `brindle demo [--local]` | watch brindle finish a tiny practice repo: parallel workers, reviews, gated merges |
@@ -536,7 +562,7 @@ Autopilot, merge gates and cleanup:
536
562
  | `pre_commit` | `true` | run [pre-commit](https://pre-commit.com) over the branch, if the repo uses it |
537
563
  | `max_agents` | `4` | workers running at once per session (`0`: no cap) |
538
564
  | `check_timeout` | `900` | seconds each check may take |
539
- | `check_concurrency` | `2` | check runs at once on this machine, across branches; the rest wait their turn (`0`: no cap). A run far past its last duration is reported to the supervisor |
565
+ | `check_concurrency` | `4` | check runs at once on this machine, across branches; the rest wait their turn (`0`: no cap). A run far past its last duration is reported to the supervisor |
540
566
  | `fable_escalation` | on, unless the worker runs on a cloud account (Bedrock, Vertex or Foundry) | retry a failed heavy task once on `developer-heavy` before handing it to you; `true` turns it on for a cloud account, `false` off |
541
567
  | `usage_limit` | `90` | autopilot stops pushing on at this % of your Claude usage limit |
542
568
  | `limit_cooldown_minutes` | `300` for Antigravity | how long a provider that hit its limit counts as unavailable |
@@ -63,7 +63,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
63
63
  brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
64
64
 
65
65
  `brindle --version` prints the installed version; the tmux status bar of every brindle
66
- session shows it too (`brindle 0.3.18`). A session started before an upgrade keeps
66
+ session shows it too (`brindle 0.3.20`). A session started before an upgrade keeps
67
67
  running the old code, and shows the old number, until you restart it.
68
68
 
69
69
  ## Quick start
@@ -184,6 +184,31 @@ brindle pr fix-login # push + gh pr create
184
184
  brindle rm fix-login # deletes the branch only if it's merged (-K keeps it)
185
185
  ```
186
186
 
187
+ ### Choosing the supervisor and workers
188
+
189
+ The first time you run `brindle` it asks which CLI runs the supervisor (the
190
+ agent that plans the work and hands it out), and remembers your pick. After
191
+ that it starts straight away and prints one line naming the CLI. It asks again
192
+ only when your pick is no longer signed in or no longer allowed.
193
+
194
+ - `brindle -s` opens the picker; `brindle -s codex` switches directly. Either
195
+ is remembered.
196
+ - In the picker, `w` opens the workers screen: a model for each task size
197
+ (light, medium, heavy). Models are grouped by how they run: through a CLI
198
+ you sign in to, locally with Ollama, or hosted with an API key. Picking one
199
+ that isn't ready sets it up right there: signing in, installing Ollama and
200
+ downloading the model after a memory check, or asking for the API key.
201
+ - API keys are checked with the provider before they're stored, and stay in
202
+ this machine's keychain.
203
+ - The picker and the workers screen list only the CLIs and models you're
204
+ allowed to use. In an org that means its allowed providers and models for
205
+ your role, its managed routes, and whether personal API keys are allowed.
206
+ `-s` with anything else is refused, and saved picks are re-checked at every
207
+ launch.
208
+ - Without a terminal (scripts, CI), with `--yes`, or in `brindle demo`, nothing
209
+ asks: brindle uses your last pick, else the supervisor profile's CLI (Claude
210
+ Code unless you changed it).
211
+
187
212
  ## Recommended use
188
213
 
189
214
  brindle pays off when work splits into pieces that can proceed in parallel, or
@@ -312,6 +337,7 @@ your own status line prints, so what you see doesn't change.
312
337
  | | |
313
338
  |---|---|
314
339
  | `brindle` | a fresh supervisor chat here, dashboard alongside |
340
+ | `brindle -s [CLI]` | pick the supervisor's CLI (no value: open the picker; `w` there picks worker models); a picker choice is remembered, a named CLI is for this run only |
315
341
  | `brindle supervisor` | the same, with `--branch`/`--worktree` to run it in another worktree |
316
342
  | `brindle init` | detect setup and test commands, write `.brindle/config.json`, check tools |
317
343
  | `brindle demo [--local]` | watch brindle finish a tiny practice repo: parallel workers, reviews, gated merges |
@@ -505,7 +531,7 @@ Autopilot, merge gates and cleanup:
505
531
  | `pre_commit` | `true` | run [pre-commit](https://pre-commit.com) over the branch, if the repo uses it |
506
532
  | `max_agents` | `4` | workers running at once per session (`0`: no cap) |
507
533
  | `check_timeout` | `900` | seconds each check may take |
508
- | `check_concurrency` | `2` | check runs at once on this machine, across branches; the rest wait their turn (`0`: no cap). A run far past its last duration is reported to the supervisor |
534
+ | `check_concurrency` | `4` | check runs at once on this machine, across branches; the rest wait their turn (`0`: no cap). A run far past its last duration is reported to the supervisor |
509
535
  | `fable_escalation` | on, unless the worker runs on a cloud account (Bedrock, Vertex or Foundry) | retry a failed heavy task once on `developer-heavy` before handing it to you; `true` turns it on for a cloud account, `false` off |
510
536
  | `usage_limit` | `90` | autopilot stops pushing on at this % of your Claude usage limit |
511
537
  | `limit_cooldown_minutes` | `300` for Antigravity | how long a provider that hit its limit counts as unavailable |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "brindle"
3
- version = "0.3.18"
3
+ version = "0.3.20"
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"
@@ -1418,15 +1418,34 @@ def unread_notice(db: DB, messages: list) -> str:
1418
1418
  f"(from {', '.join(senders)}). Call read_messages.")
1419
1419
 
1420
1420
 
1421
+ def one_line(db: DB, message_id: int, body: str, sender_id: str | None) -> str:
1422
+ """A message as the single line a supervisor sees: sender, head line (the
1423
+ first line of ``body``) and how to fetch the rest."""
1424
+ if sender_id:
1425
+ sender = db.get_agent(sender_id)
1426
+ who = f"{sender.profile} {sender_id}" if sender else sender_id
1427
+ else:
1428
+ who = "brindle"
1429
+ head = first_line(body, 300)
1430
+ if not sender_id:
1431
+ head = head.removeprefix("[brindle] ") # the sender tag already says it
1432
+ return (f"[{who}] {head} "
1433
+ f"(full text: read_messages(message_id={message_id}))")
1434
+
1435
+
1436
+ def full_message(db: DB, agent_id: str, message_id: int) -> str:
1437
+ """The whole text of one message to ``agent_id``, as read_messages(message_id=...) returns it."""
1438
+ m = db.get_message(agent_id, message_id)
1439
+ if m is None:
1440
+ return f"No message {message_id}."
1441
+ if m.sender_id:
1442
+ return format_message(db, m.body, m.sender_id)
1443
+ return f"<details><summary>Message from brindle</summary>\n\n{m.body}\n</details>"
1444
+
1445
+
1421
1446
  def render_unread(db: DB, messages: list) -> str:
1422
- """What read_messages returns: each message with its trust lead line."""
1423
- parts = []
1424
- for m in messages:
1425
- if m.sender_id:
1426
- parts.append(m.body) # stored through format_message: sender and reply hint
1427
- else:
1428
- parts.append(f"<details><summary>Message from brindle</summary>\n\n{m.body}\n</details>")
1429
- return "\n\n".join(parts)
1447
+ """What read_messages returns: one line per message."""
1448
+ return "\n".join(one_line(db, m.id, m.body, m.sender_id) for m in messages)
1430
1449
 
1431
1450
 
1432
1451
  def send_message(db: DB, to_id: str, body: str, sender_id: str | None = None,
@@ -1440,20 +1459,25 @@ def send_message(db: DB, to_id: str, body: str, sender_id: str | None = None,
1440
1459
  identity_lock.ensure(agent)
1441
1460
  if not person and runs_process(agent) and is_alive(agent) and pulls_messages(db, agent):
1442
1461
  # Keep the message unread and deliver only a notice, once for everything unread.
1443
- db.enqueue_held(agent.id, format_message(db, body, sender_id), sender_id)
1462
+ db.enqueue_held(agent.id, body, sender_id)
1444
1463
  claimed = db.claim_notice(agent.id)
1445
1464
  if claimed is None:
1446
1465
  return "queued"
1447
1466
  try:
1448
- outcome = _send_message(db, agent, unread_notice(db, claimed), None)
1467
+ outcome = _send_message(db, agent, unread_notice(db, claimed), None, notice=True)
1449
1468
  except BaseException:
1450
1469
  db.reset_notice(agent.id)
1451
1470
  raise
1452
1471
  return outcome
1453
- return _send_message(db, agent, body, sender_id)
1472
+ # A supervisor sees a message from an agent or brindle as one line; the
1473
+ # person's own input is always delivered whole.
1474
+ return _send_message(db, agent, body, sender_id,
1475
+ condensed=agent.mode == "interactive" and not person
1476
+ and agent.provider != "shell")
1454
1477
 
1455
1478
 
1456
- def _send_message(db: DB, agent: Agent, body: str, sender_id: str | None) -> str:
1479
+ def _send_message(db: DB, agent: Agent, body: str, sender_id: str | None,
1480
+ condensed: bool = False, notice: bool = False) -> str:
1457
1481
  if not runs_process(agent):
1458
1482
  raise AgentError(
1459
1483
  f"agent {agent.id} is a subagent run by its supervisor's own Agent tool, so brindle "
@@ -1466,8 +1490,14 @@ def _send_message(db: DB, agent: Agent, body: str, sender_id: str | None) -> str
1466
1490
  "can resume its session with `brindle continue`; the message isn't kept.")
1467
1491
  raise AgentError(f"agent {agent.id} is not running")
1468
1492
  provider = get_provider(agent.provider)
1469
- text = format_message(db, body, sender_id)
1470
- if agent.provider == "antigravity":
1493
+ if condensed:
1494
+ # The full text is kept, already read, for read_messages(message_id=...).
1495
+ kept = db.enqueue_held(agent.id, body, sender_id)
1496
+ db.mark_delivered(kept)
1497
+ text = one_line(db, kept, body, sender_id)
1498
+ else:
1499
+ text = format_message(db, body, sender_id)
1500
+ if agent.provider == "antigravity" and not (condensed or notice):
1471
1501
  from brindle.antigravity import format_delivered_message
1472
1502
  text = format_delivered_message(text)
1473
1503
  if agent.headless:
@@ -1479,7 +1509,7 @@ def _send_message(db: DB, agent: Agent, body: str, sender_id: str | None) -> str
1479
1509
  tmux.paste(agent.tmux_window, text, lead=message_lead(db, agent, sender_id),
1480
1510
  server=server_of(agent))
1481
1511
  return "delivered"
1482
- message_id = db.enqueue(agent.id, text, sender_id)
1512
+ message_id = db.enqueue(agent.id, text, sender_id, notice=notice)
1483
1513
  from brindle import login_pause
1484
1514
 
1485
1515
  if login_pause.holding(db, agent.id):
@@ -1644,7 +1674,8 @@ def flush(db: DB, agent_id: str) -> bool:
1644
1674
  delivered = True
1645
1675
  launch_health.delivered(db, agent, baseline)
1646
1676
  else:
1647
- db.enqueue(agent_id, msg.body, msg.sender_id) # back in the queue, for the pane
1677
+ db.enqueue(agent_id, msg.body, msg.sender_id, # back in the queue, for the pane
1678
+ notice=msg.noticed_at is not None)
1648
1679
  break
1649
1680
  if delivered:
1650
1681
  return True
@@ -1743,6 +1774,19 @@ def complete_subagent(db: DB, agent_id: str, result: str) -> Agent:
1743
1774
  return db.get_agent(agent.id) or agent
1744
1775
 
1745
1776
 
1777
+ def first_line(text: str, limit: int = 160) -> str:
1778
+ """The first non-empty line of ``text``, cut to ``limit`` characters."""
1779
+ line = next((ln.strip() for ln in text.splitlines() if ln.strip()), "")
1780
+ return line if len(line) <= limit else line[: limit - 1].rstrip() + "…"
1781
+
1782
+
1783
+ def condense(head: str, *body: str) -> str:
1784
+ """A message that opens with the one-line ``head`` and keeps the long
1785
+ ``body`` sections inside a collapsible block."""
1786
+ return (f"{head}\n\n<details><summary>Details</summary>\n\n"
1787
+ + "\n\n".join(b for b in body if b) + "\n\n</details>")
1788
+
1789
+
1746
1790
  def report_result(db: DB, agent_id: str, result: str, forward: bool = True,
1747
1791
  removed: tuple[Agent, Workspace] | None = None) -> str:
1748
1792
  """Record a worker's or reviewer's result. A piped worker's report goes
@@ -1794,7 +1838,7 @@ def report_result(db: DB, agent_id: str, result: str, forward: bool = True,
1794
1838
  where = f" on branch `{ws.branch}` (workspace {ws.id})" if ws else ""
1795
1839
  send_message(
1796
1840
  db, agent.parent_id,
1797
- f"Assigned task finished{where}.\n\n{forwarded}",
1841
+ condense(f"Assigned task finished{where}: {first_line(result)}", forwarded),
1798
1842
  sender_id=agent.id,
1799
1843
  )
1800
1844
  return "result recorded and sent to your supervisor"
@@ -2712,7 +2756,10 @@ def _stop_decision(db: DB, agent: Agent, payload: dict, via_inbox: bool = False)
2712
2756
  decision = pilot.on_stop(db, agent, payload)
2713
2757
  if decision:
2714
2758
  db.set_status(agent.id, "processing")
2715
- return decision
2759
+ # Claude Code shows the reason in the chat: one line, like any brindle message.
2760
+ kept = db.enqueue_held(agent.id, decision["reason"], None)
2761
+ db.mark_delivered(kept)
2762
+ return {**decision, "reason": one_line(db, kept, decision["reason"], None)}
2716
2763
  db.set_status(agent.id, "idle")
2717
2764
  return None
2718
2765
 
@@ -2868,7 +2915,11 @@ def handle_hook(db: DB, agent_id: str, event: str, payload: dict) -> dict | None
2868
2915
 
2869
2916
  decision = pilot.on_stop(db, agent, payload)
2870
2917
  if decision:
2871
- db.enqueue(agent_id, decision["reason"], None)
2918
+ # Like any message from brindle: one line (or a notice), the rest via read_messages.
2919
+ try:
2920
+ send_message(db, agent_id, decision["reason"])
2921
+ except AgentError:
2922
+ pass # gone since its turn ended: nobody to nudge
2872
2923
  if (agent.mode in REPORTING_MODES and agent.result is None
2873
2924
  and not db.pending_count(agent_id)):
2874
2925
  # No Stop hook can remind it to report, and no hook says when
@@ -101,6 +101,9 @@ to be asked each step.
101
101
  the checks pass, merges it and removes the worktree; you get one message
102
102
  per branch: merged, or "needs you" with the details. Don't request_review
103
103
  or merge_workspace a reported branch yourself unless brindle says so.
104
+ - Messages reach you as one line each; read_messages(message_id=N) gives the
105
+ full text only when you need it. Relay worker results to the user in one line.
106
+ Reply to an agent with send_message(to_agent_id=<sender id>).
104
107
  - After merging, call `check_milestone`. Only brindle's check marks a milestone
105
108
  done: never claim one is done yourself. When every milestone passes, brindle
106
109
  audits the work against the goal; the goal is reached only when that audit
@@ -1293,7 +1296,7 @@ def check_milestones(db: DB, root_id: str, ws: Workspace, position: int | None =
1293
1296
  if m.output and m.status == "failed":
1294
1297
  lines.append(f"\nMilestone {m.position} check output:\n{m.output}")
1295
1298
  if done and audit is None:
1296
- lines.append("\nEvery milestone's check passes: the goal is reached. Tell the user.")
1299
+ lines.append("\n" + GOAL_REACHED)
1297
1300
  clear_finished_goal(db, root_id)
1298
1301
  elif done:
1299
1302
  lines.append("\n" + AUDIT_HOLD[audit].format(note=(ap.note if ap else None) or ""))
@@ -1307,6 +1310,7 @@ def check_milestones(db: DB, root_id: str, ws: Workspace, position: int | None =
1307
1310
  # adversarial reviewer compares the work with the original goal before the
1308
1311
  # goal counts as reached (``goal_audit`` in .brindle/config.json turns it off).
1309
1312
 
1313
+ GOAL_REACHED = "Every milestone's check passes: the goal is reached. Tell the user."
1310
1314
  AUDIT_HOLD = {
1311
1315
  "start": ("Every milestone's check passes. Before the goal counts as reached, an independent "
1312
1316
  "auditor is checking the work against the original goal; its verdict arrives as a "
@@ -1561,6 +1565,35 @@ def progress(db: DB, root_id: str) -> str:
1561
1565
  return "\n".join(lines)
1562
1566
 
1563
1567
 
1568
+ def check_summary(db: DB, root_id: str, position: int | None, text: str) -> str:
1569
+ """The one line pushed after a milestone check: the result, how many
1570
+ milestones are verified, and a REGRESSED note. ``text`` is what
1571
+ check_milestones returned; the full progress stays in get_progress."""
1572
+ from brindle.agents import first_line
1573
+
1574
+ ms = db.milestones(root_id)
1575
+ done, total = counts(db, root_id)
1576
+ if not ms:
1577
+ return first_line(text)
1578
+ chosen = [m for m in ms if position is None or m.position == position]
1579
+ parts = []
1580
+ for m in chosen if position is not None else [m for m in ms if m.status == "failed"]:
1581
+ if m.status == "failed":
1582
+ parts.append(f"Milestone {m.position} ✗ failed: {first_line(m.output or 'no output')}")
1583
+ else:
1584
+ parts.append(f"Milestone {m.position} {MARK.get(m.status, '·')} {m.status}")
1585
+ head = "; ".join(parts) or "Milestone check"
1586
+ head += f" ({done} of {total} verified)"
1587
+ first = text.splitlines()[0] if text else ""
1588
+ if first.startswith("REGRESSED:"):
1589
+ head += f". {first}"
1590
+ if GOAL_REACHED in text.splitlines():
1591
+ head += ". Goal reached: tell the user"
1592
+ elif ms and done == total:
1593
+ head += ". Not done yet: completion audit pending, has gaps, or needs a commit"
1594
+ return head
1595
+
1596
+
1564
1597
  # -- workers and the cap ----------------------------------------------------------
1565
1598
 
1566
1599
 
@@ -34,7 +34,34 @@ class _App(typer.Typer):
34
34
  raise SystemExit(1)
35
35
 
36
36
 
37
- app = _App(add_completion=False, help="""brindle: run coding agents in parallel, each on its own git branch.
37
+ SUPERVISOR_FLAGS = ("-s", "--supervisor")
38
+
39
+
40
+ def _bare_supervisor_flag(args: list[str], commands) -> list[str]:
41
+ """``args`` with picker.PICK after each ``-s``/``--supervisor`` that has
42
+ no CLI after it (it's last, or an option or a command follows), so a bare
43
+ ``brindle -s`` opens the picker: a Typer option can't take an optional
44
+ value."""
45
+ from brindle.picker import PICK
46
+
47
+ out = []
48
+ for i, a in enumerate(args):
49
+ out.append(a)
50
+ if a == "--":
51
+ return out + list(args[i + 1:])
52
+ if a in SUPERVISOR_FLAGS:
53
+ nxt = args[i + 1] if i + 1 < len(args) else None
54
+ if nxt is None or nxt.startswith("-") or nxt in commands:
55
+ out.append(PICK)
56
+ return out
57
+
58
+
59
+ class _PickerGroup(typer.core.TyperGroup):
60
+ def parse_args(self, ctx, args):
61
+ return super().parse_args(ctx, _bare_supervisor_flag(args, self.commands))
62
+
63
+
64
+ app = _App(cls=_PickerGroup, add_completion=False, help="""brindle: run coding agents in parallel, each on its own git branch.
38
65
 
39
66
  Run `brindle` with no arguments to open (or reopen) a supervisor chat here, with
40
67
  the live dashboard underneath. Outside a git repo it starts a scratch session;
@@ -177,7 +204,7 @@ def demo(
177
204
  typer.echo(f"demo repo: {root}")
178
205
  os.chdir(root)
179
206
  start(prompt=None, provider=None, attach=attach, watch=True,
180
- autopilot=True, branch=None, worktree=None)
207
+ autopilot=True, branch=None, worktree=None, demo=True)
181
208
 
182
209
 
183
210
  @app.command()
@@ -193,6 +220,7 @@ def new(
193
220
  no_fetch: bool = typer.Option(False, "--no-fetch", help="Don't fetch the base branch first."),
194
221
  no_setup: bool = typer.Option(False, "--no-setup", help="Skip setup commands."),
195
222
  attach: bool = typer.Option(False, "--attach", help="Attach to the tmux session afterward."),
223
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Don't open the supervisor picker: use your last pick, else the default.")] = False,
196
224
  provider_alias: Annotated[Optional[str], typer.Option("--provider", hidden=True)] = None,
197
225
  ) -> None:
198
226
  """Create a worktree on a new branch and start a supervisor in it."""
@@ -209,6 +237,12 @@ def new(
209
237
  _fail("--base can't be combined with --pr (the PR's base branch is used)")
210
238
  elif not branch:
211
239
  _fail("missing BRANCH (or pass --pr <number>)")
240
+ if not no_agent:
241
+ try:
242
+ root = git.main_repo_root(os.getcwd())
243
+ except git.GitError:
244
+ root = None
245
+ provider = _resolve_supervisor(root, provider, yes=yes)
212
246
  db = DB()
213
247
  if pr is not None:
214
248
  created = _run(workspaces.create_from_pr, db, os.getcwd(), pr, run_setup=not no_setup)
@@ -258,7 +292,9 @@ def start(
258
292
  autopilot: Optional[bool] = typer.Option(None, "--autopilot/--no-autopilot", help="The supervisor drives toward a goal until it's verified (default: on, or `autopilot` in .brindle/config.json)."),
259
293
  branch: Optional[str] = typer.Option(None, "--branch", "-b", help="Run in the worktree for this branch, creating the branch and worktree if needed."),
260
294
  worktree: Optional[str] = typer.Option(None, "--worktree", "-w", help="Run in the worktree at this path (created with --branch if it doesn't exist)."),
295
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Don't open the supervisor picker: use your last pick, else the default.")] = False,
261
296
  provider_alias: Annotated[Optional[str], typer.Option("--provider", hidden=True)] = None,
297
+ demo: Annotated[bool, typer.Option("--demo-run", hidden=True)] = False,
262
298
  ) -> None:
263
299
  """Start a fresh supervisor chat here, with the dashboard alongside. A launch only ever starts a supervisor; it starts the other agents itself.
264
300
 
@@ -312,6 +348,7 @@ def start(
312
348
  if provider and provider in NOT_SUPERVISOR:
313
349
  _fail(f"the {provider} provider can't run the supervisor; use one of: "
314
350
  f"{', '.join(SUPERVISOR_PROVIDERS)}")
351
+ provider = _resolve_supervisor(ws.repo_root, provider, yes=yes, demo=demo)
315
352
  _preflight(agents.SUPERVISOR, provider, ws.repo_root)
316
353
  _trust_codex_hook(ws.repo_root)
317
354
  # Nothing slow before the chat starts: the paused session's leftover
@@ -366,6 +403,72 @@ def _preflight(agent: str, provider: Optional[str], repo_root: str) -> None:
366
403
  _fail("brindle can't start yet. `brindle doctor` checks everything else.")
367
404
 
368
405
 
406
+ def _is_ci_env() -> bool:
407
+ for key in ("CI", "GITHUB_ACTIONS", "GITLAB_CI", "BUILDKITE", "CIRCLECI", "TF_BUILD", "JENKINS_URL", "BRINDLE_CI"):
408
+ val = (os.environ.get(key) or "").strip().lower()
409
+ if val and val not in ("0", "false", "no", "off"):
410
+ return True
411
+ return False
412
+
413
+
414
+ def _can_prompt(yes: bool = False, demo: bool = False) -> bool:
415
+ return bool(sys.stdin.isatty() and yes is not True and demo is not True and not _is_ci_env())
416
+
417
+
418
+ def _resolve_supervisor(repo_root: str | None, provider: Optional[str], *,
419
+ yes: bool = False, demo: bool = False) -> Optional[str]:
420
+ """The CLI the supervisor runs on (None: its profile's). ``-s CLI`` picks
421
+ it for this run only; a bare ``-s`` opens the picker, whose choice is
422
+ remembered. Otherwise the
423
+ person's last pick, asked again in the picker only on the first launch or
424
+ when that pick isn't signed in or allowed, and only where brindle may ask.
425
+ A CLI the org doesn't allow this person is refused, whichever way it came."""
426
+ from brindle import doctor as doctor_mod, picker
427
+ from brindle.config import SUPERVISOR_KEY, set_user, supervisor_pick
428
+ from brindle.pro import team_policy
429
+
430
+ acc = team_policy.access(repo_root)
431
+ pick = supervisor_pick()
432
+
433
+ def ask() -> str:
434
+ chosen = picker.pick_supervisor(acc, pick)
435
+ if chosen is None:
436
+ typer.echo("No supervisor picked; nothing was started.")
437
+ raise typer.Exit(1)
438
+ return chosen
439
+
440
+ from_picker = False
441
+ if provider == picker.PICK:
442
+ if not _can_prompt(yes, demo):
443
+ _fail("`brindle -s` on its own opens the supervisor picker, which needs a terminal; "
444
+ "name the CLI instead, e.g. `brindle -s codex`")
445
+ provider = ask()
446
+ from_picker = True
447
+ elif provider:
448
+ why = acc.refusal(provider)
449
+ if why:
450
+ _fail(f"can't run the supervisor on {provider}: {why}")
451
+ if _can_prompt(yes, demo) and provider not in doctor_mod.signed_in_providers():
452
+ if not picker.sign_in(provider):
453
+ raise typer.Exit(1)
454
+ elif _can_prompt(yes, demo) and not (pick and acc.allows(pick)
455
+ and pick in doctor_mod.signed_in_providers()):
456
+ provider = ask()
457
+ from_picker = True
458
+ elif pick and acc.allows(pick):
459
+ provider = pick
460
+ final = provider or _run(load_profile, agents.SUPERVISOR, repo_root).provider
461
+ why = acc.refusal(final)
462
+ if why:
463
+ _fail(f"can't run the supervisor on {final}: {why}. Pick another with `brindle -s`.")
464
+ if provider:
465
+ # only the interactive picker saves a choice; a -s flag is this session's override
466
+ if from_picker and provider != pick:
467
+ set_user(SUPERVISOR_KEY, provider)
468
+ typer.echo(f"Supervisor: {provider} ({'your pick' if from_picker else 'this run'}) · brindle -s to change")
469
+ return provider
470
+
471
+
369
472
  def _trust_codex_hook(repo_root: str) -> None:
370
473
  """With the permission policy on and Codex installed, trust brindle's Codex
371
474
  hook if it isn't yet (or brindle moved), and say so in one line, so Codex
@@ -761,6 +864,7 @@ def default(
761
864
  None, "--supervisor", "-s", help="Run the supervisor on this CLI instead of Claude Code (codex, antigravity). Workers keep their own profiles' providers.",
762
865
  ),
763
866
  show_version: bool = typer.Option(False, "--version", help="Print brindle's version and exit."),
867
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Don't open the supervisor picker: use your last pick, else the default.")] = False,
764
868
  provider_alias: Annotated[Optional[str], typer.Option("--provider", hidden=True)] = None,
765
869
  ) -> None:
766
870
  """Bare `brindle`: a fresh supervisor chat here (a scratch session outside git)."""
@@ -782,7 +886,7 @@ def default(
782
886
  continue_cmd(session_id=None, attach=True)
783
887
  else:
784
888
  start(prompt=None, provider=provider, attach=True, watch=True,
785
- autopilot=autopilot, branch=None, worktree=None)
889
+ autopilot=autopilot, branch=None, worktree=None, yes=yes)
786
890
 
787
891
 
788
892
  @app.command()
@@ -796,12 +900,13 @@ def supervisor(
796
900
  autopilot: Optional[bool] = typer.Option(None, "--autopilot/--no-autopilot", help="The supervisor drives toward a goal until it's verified (default: on, or `autopilot` in .brindle/config.json)."),
797
901
  branch: Optional[str] = typer.Option(None, "--branch", "-b", help="Run in the worktree for this branch, creating the branch and worktree if needed."),
798
902
  worktree: Optional[str] = typer.Option(None, "--worktree", "-w", help="Run in the worktree at this path (created with --branch if it doesn't exist)."),
903
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Don't open the supervisor picker: use your last pick, else the default.")] = False,
799
904
  provider_alias: Annotated[Optional[str], typer.Option("--provider", hidden=True)] = None,
800
905
  ) -> None:
801
906
  """Start an explicit supervisor session to coordinate coding agents."""
802
907
  provider = provider or provider_alias
803
908
  start(prompt=prompt, provider=provider, attach=attach, watch=watch,
804
- autopilot=autopilot, branch=branch, worktree=worktree)
909
+ autopilot=autopilot, branch=branch, worktree=worktree, yes=yes)
805
910
 
806
911
 
807
912
  def _session_root(db: DB) -> str:
@@ -2850,8 +2955,10 @@ def check_milestones_cmd(root_id: str, workspace_id: str,
2850
2955
  try:
2851
2956
  text = autopilot.check_milestones(db, root_id, ws, position)
2852
2957
  record_milestone_changes(db, root_id, ws, before, root, position, text)
2958
+ head = autopilot.check_summary(db, root_id, position, text)
2853
2959
  except Exception as e: # noqa: BLE001 - the supervisor must hear about it either way
2854
2960
  text = f"The milestone check failed to run: {e}"
2961
+ head = agents.first_line(text)
2855
2962
  try:
2856
2963
  autopilot.start_audit(db, root_id, ws)
2857
2964
  except Exception as e: # noqa: BLE001
@@ -2863,7 +2970,7 @@ def check_milestones_cmd(root_id: str, workspace_id: str,
2863
2970
  db.update_autopilot(root_id, checking_since=None)
2864
2971
  if db.get_agent(root_id) is None:
2865
2972
  return
2866
- body = f"[brindle] Milestone check finished.\n\n{text}"
2973
+ body = agents.condense(f"[brindle] {head}", text + "\n\n(The same progress, any time: get_progress.)")
2867
2974
  try:
2868
2975
  agents.send_message(db, root_id, body)
2869
2976
  except (agents.AgentError, tmux.TmuxError):
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import json
12
12
  import os
13
+ import sys
13
14
  from dataclasses import dataclass, field
14
15
  from pathlib import Path
15
16
 
@@ -94,7 +95,7 @@ class RepoConfig:
94
95
  pre_commit: bool = True # run pre-commit (the framework) over a branch before merging
95
96
  max_agents: int = 5 # workers running at once per session; 0 means no cap
96
97
  check_timeout: int = 900 # seconds allowed for each check command
97
- check_concurrency: int = 2 # check runs at once on this machine, across branches; the rest queue (0: no cap)
98
+ check_concurrency: int = 4 # check runs at once on this machine, across branches; the rest queue (0: no cap)
98
99
  usage_limit: int = 90 # autopilot stops pushing on at this % of the Claude usage limit
99
100
  limit_cooldown_minutes: int | None = None # how long a provider that hit its limit counts as unavailable (default 300 for Antigravity)
100
101
  graphify: bool | None = None # point agents at graphify-out/graph.json (None: if it's there)
@@ -267,6 +268,9 @@ def load_repo_config(repo_root: str | Path) -> RepoConfig:
267
268
  if norm_tier in WEIGHTS and isinstance(names, list):
268
269
  cfg.routing[norm_tier] = [n for n in names if isinstance(n, str)]
269
270
  cfg.routing_sources[norm_tier] = str(path)
271
+ from brindle import worker_models
272
+
273
+ worker_models.apply(cfg, user, str(repo_root)) # the models picked on the picker's workers screen
270
274
  for source in (shared, local): # per weight, so a repo can override one and keep the rest
271
275
  second = source.get("second_review")
272
276
  if isinstance(second, dict):
@@ -311,9 +315,23 @@ def user_settings() -> dict:
311
315
  return _read_json(user_config_path())
312
316
 
313
317
 
318
+ SUPERVISOR_KEY = "supervisor" # ~/.brindle/config.json: the CLI the person last picked for the supervisor
319
+
320
+
321
+ def supervisor_pick() -> str | None:
322
+ """The supervisor CLI this person last picked (in the picker or with
323
+ ``-s``), or None before the first pick."""
324
+ pick = user_settings().get(SUPERVISOR_KEY)
325
+ return pick if isinstance(pick, str) and pick else None
326
+
327
+
314
328
  def set_user(key: str, value: object) -> Path:
315
329
  """Set one key in ``~/.brindle/config.json`` (other keys are kept)."""
316
330
  path = user_config_path()
331
+ if os.environ.get("BRINDLE_AGENT_ID"):
332
+ # A brindle agent's CLI call must not change the person's own settings.
333
+ print("brindle: running as an agent, so ~/.brindle/config.json was not changed", file=sys.stderr)
334
+ return path
317
335
  path.parent.mkdir(parents=True, exist_ok=True)
318
336
  data = _read_json(path)
319
337
  data[key] = value
@@ -49,6 +49,9 @@ from brindle.pro import route_health
49
49
 
50
50
  # An agent this new may not have its window recorded yet.
51
51
  LAUNCH_GRACE = 60.0
52
+ # How long a message waits for its normal delivery (a hook, the start-up
53
+ # flush) before a sweep retries it.
54
+ FLUSH_RETRY_AFTER = 30.0
52
55
 
53
56
 
54
57
  def _root(db: DB, a: Agent) -> str:
@@ -202,6 +205,21 @@ def sweep(db: DB, now: float | None = None) -> list[str]:
202
205
  done.append(f"closed {'idle' if finished else 'stopped'} worker {a.id} "
203
206
  f"after {int(idle_for // 60)} min")
204
207
 
208
+ # 2b. Messages a flush left queued because the pane wasn't ready (the
209
+ # person typing, say): an idle agent with no turn-end hook (agy), or one
210
+ # just waiting, would never get them otherwise. flush itself still waits
211
+ # for typed input, a held login, and claims the agent before pasting.
212
+ for a in db.list_agents():
213
+ oldest = db.oldest_pending(a.id)
214
+ if (oldest is not None and now - oldest >= FLUSH_RETRY_AFTER and a.status == "idle"
215
+ and not a.headless and a.dismissed_at is None and agents.runs_process(a)
216
+ and here(a) and alive(a)):
217
+ try:
218
+ if agents.flush(db, a.id):
219
+ done.append(f"delivered a queued message to {a.id}")
220
+ except (agents.AgentError, tmux.TmuxError):
221
+ pass # stays queued for the next sweep
222
+
205
223
  # 3. Launches stopped on a startup prompt; workers stuck on a prompt
206
224
  # nobody is answering, or gone silent.
207
225
  done.extend(settle_startups(db, now, panes))