brindle 0.3.17__tar.gz → 0.3.18__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 (137) hide show
  1. {brindle-0.3.17 → brindle-0.3.18}/PKG-INFO +9 -6
  2. {brindle-0.3.17 → brindle-0.3.18}/README.md +8 -5
  3. {brindle-0.3.17 → brindle-0.3.18}/pyproject.toml +1 -1
  4. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/agents.py +58 -6
  5. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/autopilot.py +82 -17
  6. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/cli.py +29 -5
  7. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/company_identity.py +9 -3
  8. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/company_login.py +13 -1
  9. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/cull.py +96 -0
  10. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/db.py +21 -5
  11. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/git.py +40 -0
  12. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/launch_health.py +77 -4
  13. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/mcp_server.py +14 -6
  14. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pipeline.py +2 -1
  15. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pro/account.py +6 -0
  16. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pro/managed_models.py +6 -2
  17. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pro/team_policy.py +16 -2
  18. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/prompt_answer.py +25 -1
  19. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/providers.py +10 -0
  20. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/view.py +5 -2
  21. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/watch.py +2 -1
  22. {brindle-0.3.17 → brindle-0.3.18}/.gitignore +0 -0
  23. {brindle-0.3.17 → brindle-0.3.18}/LICENSE +0 -0
  24. {brindle-0.3.17 → brindle-0.3.18}/SCHEDULE-A +0 -0
  25. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/__init__.py +0 -0
  26. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/__main__.py +0 -0
  27. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/account.py +0 -0
  28. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/airgap.py +0 -0
  29. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/antigravity.py +0 -0
  30. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/budget.py +0 -0
  31. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/builtin_agents/__init__.py +0 -0
  32. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/builtin_agents/backend-auditor.md +0 -0
  33. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/builtin_agents/developer-codex.md +0 -0
  34. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/builtin_agents/developer-fable.md +0 -0
  35. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/builtin_agents/developer-gemini.md +0 -0
  36. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/builtin_agents/developer-heavy.md +0 -0
  37. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/builtin_agents/developer-light.md +0 -0
  38. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/builtin_agents/developer-local.md +0 -0
  39. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/builtin_agents/developer.md +0 -0
  40. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/builtin_agents/qa.md +0 -0
  41. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
  42. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/builtin_agents/reviewer-gemini.md +0 -0
  43. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/builtin_agents/reviewer-local.md +0 -0
  44. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/builtin_agents/reviewer.md +0 -0
  45. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/builtin_agents/subagent.md +0 -0
  46. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/builtin_agents/supervisor.md +0 -0
  47. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/builtin_rules/__init__.py +0 -0
  48. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/builtin_rules/security/backend.md +0 -0
  49. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
  50. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/builtin_rules/tests/only.md +0 -0
  51. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/ci_adapters.py +0 -0
  52. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/ci_client.py +0 -0
  53. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/ci_federation.py +0 -0
  54. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/ci_hosts.py +0 -0
  55. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/cloud_credential.py +0 -0
  56. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/cloud_login.py +0 -0
  57. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/codemap.py +0 -0
  58. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/codemap_docs.py +0 -0
  59. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/codex_hook.py +0 -0
  60. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/config.py +0 -0
  61. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/conflicts.py +0 -0
  62. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/cost.py +0 -0
  63. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/cost_estimate.py +0 -0
  64. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/demo.py +0 -0
  65. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/detect.py +0 -0
  66. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/doctor.py +0 -0
  67. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/escapes.py +0 -0
  68. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/events.py +0 -0
  69. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/gates.py +0 -0
  70. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/guardrails.py +0 -0
  71. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/history.py +0 -0
  72. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/identity_lock.py +0 -0
  73. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/inbox.py +0 -0
  74. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/keys_cmds.py +0 -0
  75. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/keystore.py +0 -0
  76. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/learned_rules.py +0 -0
  77. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/learning.py +0 -0
  78. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/legacy.py +0 -0
  79. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/login_pause.py +0 -0
  80. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/model_access.py +0 -0
  81. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/model_availability.py +0 -0
  82. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/native/__init__.py +0 -0
  83. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/native/client.py +0 -0
  84. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/native/loop.py +0 -0
  85. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/native/permissions.py +0 -0
  86. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/native/runner.py +0 -0
  87. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/native/serve.py +0 -0
  88. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/native/tools.py +0 -0
  89. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pane_auth.py +0 -0
  90. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/permissions.py +0 -0
  91. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/plugins.py +0 -0
  92. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/policy.py +0 -0
  93. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pool.py +0 -0
  94. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pricing.py +0 -0
  95. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pro/__init__.py +0 -0
  96. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pro/_files.py +0 -0
  97. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pro/audit_chain.py +0 -0
  98. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pro/audit_export.py +0 -0
  99. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pro/auth.py +0 -0
  100. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pro/brief_learning.py +0 -0
  101. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pro/cost_centers.py +0 -0
  102. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pro/credentials.py +0 -0
  103. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pro/features.py +0 -0
  104. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pro/keys.py +0 -0
  105. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pro/learning.py +0 -0
  106. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pro/license.py +0 -0
  107. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pro/loopback.py +0 -0
  108. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pro/org_profiles.py +0 -0
  109. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pro/orgkey.py +0 -0
  110. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pro/rollout.py +0 -0
  111. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pro/route_health.py +0 -0
  112. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pro/settings_sync.py +0 -0
  113. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pro/status.py +0 -0
  114. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/pro/team_events.py +0 -0
  115. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/procs.py +0 -0
  116. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/profiles.py +0 -0
  117. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/quota.py +0 -0
  118. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/repo_cmds.py +0 -0
  119. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/repos.py +0 -0
  120. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/rewind.py +0 -0
  121. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/rule_checks.py +0 -0
  122. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/savings.py +0 -0
  123. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/scratch.py +0 -0
  124. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/secrets.py +0 -0
  125. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/self_serve.py +0 -0
  126. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/services.py +0 -0
  127. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/sessions.py +0 -0
  128. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/sidebar_follow.py +0 -0
  129. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/signin_pause.py +0 -0
  130. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/status_cache.py +0 -0
  131. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/tasks.py +0 -0
  132. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/tmux.py +0 -0
  133. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/updates.py +0 -0
  134. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/upgrade.py +0 -0
  135. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/usage.py +0 -0
  136. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/watch_costs.py +0 -0
  137. {brindle-0.3.17 → brindle-0.3.18}/src/brindle/workspaces.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: brindle
3
- Version: 0.3.17
3
+ Version: 0.3.18
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.17`). A session started before an upgrade keeps
97
+ session shows it too (`brindle 0.3.18`). 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
@@ -415,7 +415,7 @@ knowing them helps when you tell the supervisor how to work.
415
415
  | `complete_subagent` | supervisor | record the result of a `subagent`-profile task |
416
416
  | `set_goal` / `get_progress` / `check_milestone` | supervisor | autopilot's goal, its progress, and running the checks |
417
417
  | `record_audit_decision` | supervisor | record acceptance of an audit finding with rationale so subsequent audits respect it |
418
- | `need_user` | supervisor | stop autopilot and ask you a question |
418
+ | `need_user` | supervisor | ask you a question (a decision, a denied action, a stalled external job such as a CI run no runner picks up); only the steps that depend on it wait, the rest keeps going; `answered=true` once you've replied |
419
419
  | `transfer_to_repo` | supervisor | move a scratch session's work into a repository |
420
420
  | `handover` | supervisor | hand the session to a new supervisor on another branch or worktree, with a note |
421
421
 
@@ -641,6 +641,8 @@ wins. When every candidate is out, the repo's `default_agent` runs. The reply sa
641
641
  what was picked and why, e.g. `weight medium -> developer (Codex at 93%, skipped developer-codex)`.
642
642
  An `agent_profile` you pass, or a milestone's `profile`, always wins over weight.
643
643
 
644
+ The supervisor runs on your Claude Code model. If your organisation's Claude Code managed settings pin the model (e.g. to Sonnet), that applies to the brindle supervisor too. Supervising goes best on Opus (or Fable where available), so if your org allows it, switch in the supervisor's own session with `/model`. Worker profiles set their own model and aren't affected.
645
+
644
646
  Provisioning and routing also check explicit models against supported read-only
645
647
  catalogs, with the managed model, stored keys and the worker's filtered pane
646
648
  environment. A model absent from a complete authoritative catalog is rejected
@@ -1419,7 +1421,7 @@ change files under a directory without prompts.
1419
1421
 
1420
1422
  ### Which provider can do what
1421
1423
 
1422
- | Provider | Supervisor (`brindle --provider …`) | Worker / reviewer |
1424
+ | Provider | Supervisor (`brindle --supervisor …`) | Worker / reviewer |
1423
1425
  |---|---|---|
1424
1426
  | `claude` (Claude Code) | yes (the default) | yes |
1425
1427
  | `codex` | yes | yes |
@@ -1481,7 +1483,7 @@ When running long, unattended autopilot sessions with API keys:
1481
1483
  - **Codex (unsupported runtime auth detection)**: Codex CLI turn completion (`agent-turn-complete`) does not classify auth errors at runtime. If an OpenAI API key expires or is rejected, Codex workers will not automatically transition to `failed` or trigger an autopilot credential pause. When running Codex workers unattended, monitor their progress via `brindle watch` or check tmux panes; if an auth failure occurs, manually restart or rotate keys.
1482
1484
  - **Supervisor credential refresh and key rotation**:
1483
1485
  - **Recovering failed/blocked workers**: When an agent fails on an expired key or autopilot pauses with a credential error, export the fresh key (or update `brindle keys set`), then run `brindle continue`. This unpauses the session and relaunches paused/failed agents with the new credentials. The replacement credentials are automatically synced to the session credential store, dynamically refreshed by the supervisor's launcher on all subsequent worker delegations (`assign`/`handoff`).
1484
- - **Proactive rotation in live sessions**: Exporting a new key while an active session is healthy does not restart running workers or the supervisor (running `brindle continue` on an active healthy session simply reattaches to tmux without restarting workers). To proactively rotate credentials for the entire session and all running workers: run `brindle stop` -> update credentials in your environment or via `brindle keys set` -> run `brindle continue`.
1486
+ - **Proactive rotation in live sessions**: For Claude keys rotated outside brindle, set Claude Code's `apiKeyHelper` in `~/.claude/settings.json` or the project's `.claude/settings.json`. Claude Code re-runs the helper every 5 minutes (configurable with `CLAUDE_CODE_API_KEY_HELPER_TTL_MS`) and after a 401, so running workers pick up the new key. Don't also export `ANTHROPIC_API_KEY` or set a Claude key with `brindle keys set`, since an environment key takes precedence over the helper. Codex and Antigravity keys still need workers stopped, then `brindle keys set`, then `brindle continue`. Exporting a new key while an active session is healthy does not restart running workers or the supervisor (running `brindle continue` on an active healthy session simply reattaches to tmux without restarting workers). To proactively rotate credentials for the entire session and all running workers: run `brindle stop` -> update credentials in your environment or via `brindle keys set` -> run `brindle continue`.
1485
1487
  - **Precedence**: Newly stored keys (`brindle keys set`) or session credentials updated during recovery take effect dynamically for all new panes, overriding stale launcher environments. For profile-level overrides, an explicit profile key (`env.NAME`) takes precedence over default provider credentials.
1486
1488
  - **Antigravity permissions prerequisite**: Antigravity (`agy`) does not support approving shell commands solely via external hooks. With `GEMINI_API_KEY`, unattended agy workers will still stall on approval prompts unless permissions are pre-populated in `settings.json` (see Permissions) or run with `agy_approvals: "brindle"` under `permission_policy: "on"` (`--dangerously-skip-permissions`).
1487
1489
 
@@ -1966,7 +1968,8 @@ you to sign in. `agy` takes no other key or token from the environment; its
1966
1968
  Google Cloud, Workforce Identity and Application Default Credentials sign-ins are
1967
1969
  chosen on its sign-in screen.
1968
1970
 
1969
- Then run the whole session on it with `brindle --provider antigravity`, or mix models:
1971
+ Then run the supervisor on it with `brindle --supervisor antigravity`.
1972
+ Workers keep their own profiles' providers. To mix models,
1970
1973
  give a profile `provider: antigravity` (for example a `gemini-reviewer` for a second
1971
1974
  model's review) and the supervisor can hand it tasks.
1972
1975
 
@@ -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.17`). A session started before an upgrade keeps
66
+ session shows it too (`brindle 0.3.18`). 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
@@ -384,7 +384,7 @@ knowing them helps when you tell the supervisor how to work.
384
384
  | `complete_subagent` | supervisor | record the result of a `subagent`-profile task |
385
385
  | `set_goal` / `get_progress` / `check_milestone` | supervisor | autopilot's goal, its progress, and running the checks |
386
386
  | `record_audit_decision` | supervisor | record acceptance of an audit finding with rationale so subsequent audits respect it |
387
- | `need_user` | supervisor | stop autopilot and ask you a question |
387
+ | `need_user` | supervisor | ask you a question (a decision, a denied action, a stalled external job such as a CI run no runner picks up); only the steps that depend on it wait, the rest keeps going; `answered=true` once you've replied |
388
388
  | `transfer_to_repo` | supervisor | move a scratch session's work into a repository |
389
389
  | `handover` | supervisor | hand the session to a new supervisor on another branch or worktree, with a note |
390
390
 
@@ -610,6 +610,8 @@ wins. When every candidate is out, the repo's `default_agent` runs. The reply sa
610
610
  what was picked and why, e.g. `weight medium -> developer (Codex at 93%, skipped developer-codex)`.
611
611
  An `agent_profile` you pass, or a milestone's `profile`, always wins over weight.
612
612
 
613
+ The supervisor runs on your Claude Code model. If your organisation's Claude Code managed settings pin the model (e.g. to Sonnet), that applies to the brindle supervisor too. Supervising goes best on Opus (or Fable where available), so if your org allows it, switch in the supervisor's own session with `/model`. Worker profiles set their own model and aren't affected.
614
+
613
615
  Provisioning and routing also check explicit models against supported read-only
614
616
  catalogs, with the managed model, stored keys and the worker's filtered pane
615
617
  environment. A model absent from a complete authoritative catalog is rejected
@@ -1388,7 +1390,7 @@ change files under a directory without prompts.
1388
1390
 
1389
1391
  ### Which provider can do what
1390
1392
 
1391
- | Provider | Supervisor (`brindle --provider …`) | Worker / reviewer |
1393
+ | Provider | Supervisor (`brindle --supervisor …`) | Worker / reviewer |
1392
1394
  |---|---|---|
1393
1395
  | `claude` (Claude Code) | yes (the default) | yes |
1394
1396
  | `codex` | yes | yes |
@@ -1450,7 +1452,7 @@ When running long, unattended autopilot sessions with API keys:
1450
1452
  - **Codex (unsupported runtime auth detection)**: Codex CLI turn completion (`agent-turn-complete`) does not classify auth errors at runtime. If an OpenAI API key expires or is rejected, Codex workers will not automatically transition to `failed` or trigger an autopilot credential pause. When running Codex workers unattended, monitor their progress via `brindle watch` or check tmux panes; if an auth failure occurs, manually restart or rotate keys.
1451
1453
  - **Supervisor credential refresh and key rotation**:
1452
1454
  - **Recovering failed/blocked workers**: When an agent fails on an expired key or autopilot pauses with a credential error, export the fresh key (or update `brindle keys set`), then run `brindle continue`. This unpauses the session and relaunches paused/failed agents with the new credentials. The replacement credentials are automatically synced to the session credential store, dynamically refreshed by the supervisor's launcher on all subsequent worker delegations (`assign`/`handoff`).
1453
- - **Proactive rotation in live sessions**: Exporting a new key while an active session is healthy does not restart running workers or the supervisor (running `brindle continue` on an active healthy session simply reattaches to tmux without restarting workers). To proactively rotate credentials for the entire session and all running workers: run `brindle stop` -> update credentials in your environment or via `brindle keys set` -> run `brindle continue`.
1455
+ - **Proactive rotation in live sessions**: For Claude keys rotated outside brindle, set Claude Code's `apiKeyHelper` in `~/.claude/settings.json` or the project's `.claude/settings.json`. Claude Code re-runs the helper every 5 minutes (configurable with `CLAUDE_CODE_API_KEY_HELPER_TTL_MS`) and after a 401, so running workers pick up the new key. Don't also export `ANTHROPIC_API_KEY` or set a Claude key with `brindle keys set`, since an environment key takes precedence over the helper. Codex and Antigravity keys still need workers stopped, then `brindle keys set`, then `brindle continue`. Exporting a new key while an active session is healthy does not restart running workers or the supervisor (running `brindle continue` on an active healthy session simply reattaches to tmux without restarting workers). To proactively rotate credentials for the entire session and all running workers: run `brindle stop` -> update credentials in your environment or via `brindle keys set` -> run `brindle continue`.
1454
1456
  - **Precedence**: Newly stored keys (`brindle keys set`) or session credentials updated during recovery take effect dynamically for all new panes, overriding stale launcher environments. For profile-level overrides, an explicit profile key (`env.NAME`) takes precedence over default provider credentials.
1455
1457
  - **Antigravity permissions prerequisite**: Antigravity (`agy`) does not support approving shell commands solely via external hooks. With `GEMINI_API_KEY`, unattended agy workers will still stall on approval prompts unless permissions are pre-populated in `settings.json` (see Permissions) or run with `agy_approvals: "brindle"` under `permission_policy: "on"` (`--dangerously-skip-permissions`).
1456
1458
 
@@ -1935,7 +1937,8 @@ you to sign in. `agy` takes no other key or token from the environment; its
1935
1937
  Google Cloud, Workforce Identity and Application Default Credentials sign-ins are
1936
1938
  chosen on its sign-in screen.
1937
1939
 
1938
- Then run the whole session on it with `brindle --provider antigravity`, or mix models:
1940
+ Then run the supervisor on it with `brindle --supervisor antigravity`.
1941
+ Workers keep their own profiles' providers. To mix models,
1939
1942
  give a profile `provider: antigravity` (for example a `gemini-reviewer` for a second
1940
1943
  model's review) and the supervisor can hand it tasks.
1941
1944
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "brindle"
3
- version = "0.3.17"
3
+ version = "0.3.18"
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"
@@ -300,9 +300,11 @@ def decorate_worker_prompt(task: str, agent_id: str, ws: Workspace, done_when: s
300
300
  prompt += PLAN_FIRST_NOTE
301
301
  prompt += WORKER_FOOTER.format(agent_id=agent_id, branch=ws.branch,
302
302
  guidance=worker_guidance(ws))
303
- if done_when and provider.name == "claude" and not headless:
304
- # /goal is an interactive command; headless workers get the finish
305
- # line above and the Stop hook's reminder to report.
303
+ if (done_when and provider.name == "claude" and not headless
304
+ and not provider.hooks_disabled(ws.path)):
305
+ # /goal is an interactive command that Claude Code refuses when hooks
306
+ # are disabled (#133), and headless workers get the finish line above
307
+ # and the Stop hook's reminder to report either way.
306
308
  prompt = pilot.worker_goal(prompt, done_when, ws.branch) or prompt
307
309
  return prompt
308
310
 
@@ -2812,6 +2814,11 @@ def handle_hook(db: DB, agent_id: str, event: str, payload: dict) -> dict | None
2812
2814
  db.set_status(agent_id, "processing", only_if="waiting")
2813
2815
  db.set_status(agent_id, "processing", only_if="idle") # see "pre-tool"
2814
2816
  note_permission_outcome(db, agent, payload)
2817
+ from brindle import launch_health
2818
+ try:
2819
+ launch_health.check_mcp(db, agent)
2820
+ except Exception: # noqa: BLE001
2821
+ log.exception("brindle: couldn't check the brindle MCP server's start")
2815
2822
  elif event == "stop":
2816
2823
  from brindle import inbox, tasks
2817
2824
 
@@ -3114,6 +3121,47 @@ def reopen_reviewer(db: DB, agent: Agent, ws: Workspace,
3114
3121
  return True
3115
3122
 
3116
3123
 
3124
+ def review_moot(db: DB, agent: Agent) -> bool:
3125
+ """A reviewer whose branch already merged at its current commit: there is
3126
+ nothing left for it to review or report. A reviewer of commits made after
3127
+ the merge is not moot."""
3128
+ if agent.mode != "review":
3129
+ return False
3130
+ ws = db.get_workspace(agent.workspace_id)
3131
+ if ws is None:
3132
+ return False
3133
+ from brindle import pipeline
3134
+
3135
+ return (pipeline._settled(db, ws) or "").startswith(pipeline.ALREADY_MERGED)
3136
+
3137
+
3138
+ def liveness(db: DB, a: Agent, ws: Workspace | None, panes: dict[str, bool] | None = None) -> str:
3139
+ """What brindle can see of an agent for free, as one line for its
3140
+ supervisor: whether it's running, when it last did anything, and what
3141
+ its branch holds, so deciding what to do about it takes no digging.
3142
+ Never raises: a fact it can't read is left out."""
3143
+ parts = []
3144
+ try:
3145
+ parts.append("running" if is_alive(a, panes) else "not running")
3146
+ path = transcript_file(a)
3147
+ last = (os.stat(path).st_mtime if path else
3148
+ tmux.window_activity(a.tmux_window, server=server_of(a)) if a.tmux_window else None)
3149
+ if last:
3150
+ parts[-1] += f", last active {int((time.time() - last) // 60)} min ago"
3151
+ if ws is not None and os.path.isdir(ws.path):
3152
+ base = git.base_ref(ws.path, ws.base_branch or git.default_branch(ws.repo_root))
3153
+ ahead = git.ahead_behind(ws.path, base)[1]
3154
+ stat = git.out(["diff", "--stat", git.merge_base(ws.path, base)], ws.path).splitlines()
3155
+ files = ", ".join(ln.split("|")[0].strip() for ln in stat[:-1][:5])
3156
+ more = f" and {len(stat) - 6} more" if len(stat) > 6 else ""
3157
+ parts.append(f"{ahead} commit(s) ahead of {base}"
3158
+ + (f" ({files}{more}: {stat[-1].strip()})" if stat else ""))
3159
+ parts.append(f"{len(git.dirty_files(ws.path))} uncommitted file(s)")
3160
+ except Exception: # noqa: BLE001
3161
+ pass
3162
+ return "; ".join(parts)
3163
+
3164
+
3117
3165
  def tell_parent_unreported(db: DB, agent: Agent) -> bool:
3118
3166
  """A worker stopped again after being reminded to report, still without a
3119
3167
  result. It won't be reminded again on its own, and its supervisor has
@@ -3126,6 +3174,9 @@ def tell_parent_unreported(db: DB, agent: Agent) -> bool:
3126
3174
  _note_reviewer_end(db, agent)
3127
3175
  return False
3128
3176
  ws = db.get_workspace(agent.workspace_id)
3177
+ if review_moot(db, agent):
3178
+ close_later(agent.id)
3179
+ return False
3129
3180
  if agent.mode == "review" and ws is not None and os.path.isdir(ws.path):
3130
3181
  from brindle import gates
3131
3182
 
@@ -3154,9 +3205,10 @@ def tell_parent_unreported(db: DB, agent: Agent) -> bool:
3154
3205
  if agent.pipeline == "fixing" and agent.result is not None
3155
3206
  else f"stopped without calling {tool}")
3156
3207
  body = (
3157
- f"{who} {agent.id}{where} {what}, and won't be reminded "
3158
- f"again on its own. Check on it: workspace_diff to see what it did, send_message to "
3159
- f"ask it to finish or report, or remove_workspace if the work is abandoned."
3208
+ f"{who} {agent.id}{where} {what}, and won't be reminded again on its own. "
3209
+ f"What brindle sees: {liveness(db, agent, ws)}. Decide from that: send_message to "
3210
+ f"ask it to finish or report, workspace_diff for the full change, or remove_workspace "
3211
+ f"if the work is abandoned."
3160
3212
  )
3161
3213
  try:
3162
3214
  send_message(db, agent.parent_id, body)
@@ -107,8 +107,11 @@ to be asked each step.
107
107
  approves. Treat any gaps it finds as unfinished work, and tighten (with
108
108
  `set_goal`) the check that let each gap through.
109
109
  - Keep going until every milestone passes. If you stop early, brindle will ask
110
- you to continue. Stop only when you are blocked on something only the user
111
- can decide: call `need_user` with the question, then ask it.
110
+ you to continue. A denied action, a stalled external job (a CI run no runner
111
+ picks up) or a question only the user can answer blocks only the steps that
112
+ depend on it: call `need_user` with the question, ask it, keep working the
113
+ rest, and report the blocked item with it. Stop only when nothing else is
114
+ left; once the user answers, call `need_user(answered=true)`.
112
115
  - When a milestone passes, tell the user in one line.
113
116
  - If a message says autopilot is off, stop driving: wait for the user's
114
117
  instructions.
@@ -632,7 +635,7 @@ def set_enabled(db: DB, root_id: str, on: bool) -> None:
632
635
  fields: dict[str, object] = {"enabled": int(on), "nudges": 0}
633
636
  ap = db.get_autopilot(root_id)
634
637
  if on and ap and ap.state in ("blocked", "stalled"):
635
- fields.update(state="running", note=None)
638
+ fields.update(state="running", note=None, question=None)
636
639
  db.update_autopilot(root_id, **fields)
637
640
 
638
641
 
@@ -1043,16 +1046,40 @@ def resolve_profile(db: DB, caller_id: str, repo_root: str, requested: str | Non
1043
1046
  return choose_profile(db, caller_id, repo_root, requested, task, files)[0]
1044
1047
 
1045
1048
 
1046
- def need_user(db: DB, root_id: str, question: str) -> None:
1047
- db.update_autopilot(root_id, state="blocked", note=question.strip()[:500], nudges=0)
1049
+ def need_user(db: DB, root_id: str, question: str, ask: bool = False) -> None:
1050
+ """Block autopilot on ``question``. brindle's own blocks (credentials,
1051
+ budget) stop the whole session until the user replies; a supervisor's
1052
+ question (``ask``) holds only the work that depends on it, until the
1053
+ supervisor says it's answered."""
1054
+ note = question.strip()[:500]
1055
+ fields: dict[str, object] = {"state": "blocked", "note": note, "nudges": 0,
1056
+ "question": note if ask else None}
1057
+ if ask:
1058
+ ap = db.get_autopilot(root_id)
1059
+ fields["nudged_at"] = ap.progress if ap else None
1060
+ db.update_autopilot(root_id, **fields)
1061
+
1062
+
1063
+ def held(ap: Autopilot) -> bool:
1064
+ """Autopilot waits on the supervisor's open question, and nothing has
1065
+ blocked it since (that would have replaced the note)."""
1066
+ return ap.state == "blocked" and bool(ap.question) and ap.question == ap.note
1067
+
1068
+
1069
+ def answered(db: DB, root_id: str) -> None:
1070
+ db.update_autopilot(root_id, state="running", note=None, question=None, nudges=0)
1048
1071
 
1049
1072
 
1050
1073
  def user_spoke(db: DB, agent: Agent) -> None:
1051
1074
  """The user typed into the supervisor's chat: whatever blocked autopilot
1052
- is theirs to have answered, so it may drive again."""
1075
+ is theirs to have answered, so it may drive again. An open question stays
1076
+ open (the message may be about something else), but the supervisor gets
1077
+ one more reminder of it."""
1053
1078
  ap = db.get_autopilot(agent.id)
1054
- if ap and ap.state in ("blocked", "stalled"):
1055
- db.update_autopilot(agent.id, state="running", note=None, nudges=0)
1079
+ if ap and held(ap):
1080
+ db.update_autopilot(agent.id, nudges=0, nudged_at=None)
1081
+ elif ap and ap.state in ("blocked", "stalled"):
1082
+ db.update_autopilot(agent.id, state="running", note=None, question=None, nudges=0)
1056
1083
  elif ap:
1057
1084
  db.update_autopilot(agent.id, nudges=0)
1058
1085
 
@@ -1476,7 +1503,7 @@ def record_audit(db: DB, root_id: str, approved: bool, summary: str) -> str:
1476
1503
 
1477
1504
  # -- progress -----------------------------------------------------------------
1478
1505
 
1479
- MARK = {"passed": "✓", "failed": "✗", "pending": "○"}
1506
+ MARK = {"passed": "✓", "failed": "✗", "pending": "○", "working": "◐", "waiting": "◌"}
1480
1507
 
1481
1508
 
1482
1509
  def counts(db: DB, root_id: str) -> tuple[int, int]:
@@ -1484,6 +1511,27 @@ def counts(db: DB, root_id: str) -> tuple[int, int]:
1484
1511
  return sum(m.status == "passed" for m in ms), len(ms)
1485
1512
 
1486
1513
 
1514
+ def work_state(db: DB, root_id: str) -> str | None:
1515
+ """Display-only: "working" or "waiting" while the session has work in
1516
+ flight, else None. A failed milestone's last check is stale then, so it
1517
+ shouldn't read as a plain failure. Never stored on the milestone.
1518
+
1519
+ shortcut: work isn't tied to a milestone, so any in-flight work marks every
1520
+ failed milestone; upgrade if tasks get a milestone link."""
1521
+ from brindle import login_pause
1522
+
1523
+ ap = db.get_autopilot(root_id)
1524
+ if (ap is not None and ap.state == "usage_paused") or login_pause.paused(db, root_id):
1525
+ return "waiting"
1526
+ live = active_workers(db, root_id)
1527
+ if any(a.status != "waiting" for a in live):
1528
+ return "working"
1529
+ if live or any(root_of(db, t.caller_id) == root_id
1530
+ for t in db.list_tasks(state="pending") if t.caller_id):
1531
+ return "waiting"
1532
+ return None
1533
+
1534
+
1487
1535
  def progress(db: DB, root_id: str) -> str:
1488
1536
  ap = db.get_autopilot(root_id)
1489
1537
  if ap is None:
@@ -1494,12 +1542,16 @@ def progress(db: DB, root_id: str) -> str:
1494
1542
  "user's request (ask what we're building only if there is no request).")
1495
1543
  done, total = counts(db, root_id)
1496
1544
  lines = [f"{head}. Goal: {ap.goal}", f"{done} of {total} milestones verified."]
1497
- for m in db.milestones(root_id):
1545
+ ms = db.milestones(root_id)
1546
+ w = work_state(db, root_id) if any(m.status == "failed" for m in ms) else None
1547
+ for m in ms:
1498
1548
  where = f"@{m.repo}: " if m.repo else ""
1499
1549
  how = f"check: `{where}{m.check_cmd}`" if m.check_cmd else "NO CHECK: propose one with set_goal"
1500
1550
  when = f", last checked {time.strftime('%H:%M', time.localtime(m.checked_at))}" if m.checked_at else ""
1501
1551
  who = f", profile: {m.profile}" if m.profile else ""
1502
- lines.append(f" {MARK.get(m.status, '·')} {m.position}. {m.title} ({how}{who}{when})")
1552
+ shown = w if m.status == "failed" and w else m.status
1553
+ stale = ", last check failed" if shown != m.status else ""
1554
+ lines.append(f" {MARK.get(shown, '·')} {m.position}. {m.title} ({how}{who}{when}{stale})")
1503
1555
  if ap.state in ("blocked", "stalled") and ap.note:
1504
1556
  lines.append(f"{ap.state.capitalize()}: {ap.note}")
1505
1557
  elif ap.state == "auditing":
@@ -1526,7 +1578,8 @@ def active_workers(db: DB, root_id: str, *, reviewers: bool = True) -> list[Agen
1526
1578
  return [a for a in agents.tree(db, root_id)[1:]
1527
1579
  if a.mode in agents.REPORTING_MODES and (reviewers or a.mode != "review")
1528
1580
  and (a.result is None or a.status in BUSY or a.pipeline)
1529
- and a.status not in ("paused", "done") and agents.is_alive(a)]
1581
+ and a.status not in ("paused", "done") and agents.is_alive(a)
1582
+ and not agents.review_moot(db, a)]
1530
1583
 
1531
1584
 
1532
1585
  def split_workers(db: DB, root_id: str, *, screen: bool = False) -> tuple[list[Agent], list[Agent]]:
@@ -1743,6 +1796,9 @@ def on_stop(db: DB, agent: Agent, payload: dict) -> dict | None:
1743
1796
  return None
1744
1797
  else:
1745
1798
  return None
1799
+ elif held(ap):
1800
+ if ap.nudged_at == ap.progress:
1801
+ return None # reminded of the open question since the last step or user message
1746
1802
  elif ap.state != "running":
1747
1803
  return None
1748
1804
 
@@ -1763,6 +1819,10 @@ def on_stop(db: DB, agent: Agent, payload: dict) -> dict | None:
1763
1819
  if any(agents.runs_process(a) for a in working
1764
1820
  if getattr(a, "plan_state", None) != "proposed"):
1765
1821
  return None # their results arrive as messages and wake it up
1822
+ if held(ap):
1823
+ # One reminder per step: never counts toward stalling.
1824
+ db.update_autopilot(agent.id, nudges=0, nudged_at=ap.progress)
1825
+ return {"decision": "block", "reason": nudge(db, ap, cfg, working, stalled)}
1766
1826
  nudges = ap.nudges if ap.nudged_at == ap.progress else 0
1767
1827
  if nudges >= MAX_NUDGES:
1768
1828
  db.update_autopilot(agent.id, state="stalled", nudges=0,
@@ -1789,18 +1849,23 @@ def nudge(db: DB, ap: Autopilot, cfg: RepoConfig,
1789
1849
  "approved=false to ask for changes; the worker is waiting on you.\n\n")
1790
1850
  stuck = ""
1791
1851
  if stalled:
1792
- names = ", ".join(a.id for a in stalled)
1852
+ seen = "\n".join(f"- {a.id}: {agents.liveness(db, a, db.get_workspace(a.workspace_id))}"
1853
+ for a in stalled)
1793
1854
  stuck = (
1794
- f"{names} went idle without ever calling report_result and won't be reminded "
1795
- "again on their own. Check on them: workspace_diff to see what they did, "
1796
- "send_message asking them to report or finish, or remove_workspace if the work "
1797
- "is abandoned.\n\n"
1855
+ "These went idle without ever calling report_result and won't be reminded "
1856
+ f"again on their own. What brindle sees of each:\n{seen}\nDecide from that: "
1857
+ "send_message asking them to report or finish, workspace_diff for the full change, "
1858
+ "or remove_workspace if the work is abandoned.\n\n"
1798
1859
  )
1799
1860
  ms = db.milestones(ap.root_id)
1800
1861
  if ms and all(m.status == "passed" for m in ms):
1801
1862
  pending += ("Every milestone's check passes, but the completion audit hasn't approved "
1802
1863
  "the current commit. Close any gaps it found, commit, and call "
1803
1864
  "check_milestone: that starts a fresh audit.\n\n")
1865
+ if held(ap):
1866
+ pending += (f"Waiting on the user for: {ap.question}. Work only on what doesn't depend "
1867
+ "on it; if nothing is left, stop. When they've answered, call "
1868
+ "need_user(answered=true).\n\n")
1804
1869
  return (
1805
1870
  "[brindle autopilot] The goal isn't reached yet, and no workers are running.\n"
1806
1871
  f"{progress(db, ap.root_id)}\n\n{pending}{stuck}"
@@ -9,7 +9,7 @@ import subprocess
9
9
  import sys
10
10
  import time
11
11
  from pathlib import Path
12
- from typing import Optional
12
+ from typing import Annotated, Optional
13
13
 
14
14
  import typer
15
15
 
@@ -187,14 +187,18 @@ def new(
187
187
  pr: Optional[int] = typer.Option(None, "--pr", help="Check out this GitHub PR's head branch (via `gh`), based on the PR's base branch. Don't also pass BRANCH or --base; the head branch is always fetched."),
188
188
  no_agent: bool = typer.Option(False, "--no-agent", help="Only create the worktree; start no supervisor."),
189
189
  prompt: Optional[str] = typer.Option(None, "--prompt", "-p", help="First message for the supervisor."),
190
- provider: Optional[str] = typer.Option(None, help="Run the supervisor on this CLI instead of Claude Code (codex, antigravity)."),
190
+ provider: Optional[str] = typer.Option(
191
+ None, "--supervisor", "-s", help="Run the supervisor on this CLI instead of Claude Code (codex, antigravity). Workers keep their own profiles' providers.",
192
+ ),
191
193
  no_fetch: bool = typer.Option(False, "--no-fetch", help="Don't fetch the base branch first."),
192
194
  no_setup: bool = typer.Option(False, "--no-setup", help="Skip setup commands."),
193
195
  attach: bool = typer.Option(False, "--attach", help="Attach to the tmux session afterward."),
196
+ provider_alias: Annotated[Optional[str], typer.Option("--provider", hidden=True)] = None,
194
197
  ) -> None:
195
198
  """Create a worktree on a new branch and start a supervisor in it."""
196
199
  from brindle.providers import NOT_SUPERVISOR, SUPERVISOR_PROVIDERS
197
200
 
201
+ provider = provider or provider_alias
198
202
  if provider and provider in NOT_SUPERVISOR:
199
203
  _fail(f"the {provider} provider can't run the supervisor; use one of: "
200
204
  f"{', '.join(SUPERVISOR_PROVIDERS)}")
@@ -246,12 +250,15 @@ def new(
246
250
  @app.command()
247
251
  def start(
248
252
  prompt: Optional[str] = typer.Option(None, "--prompt", "-p"),
249
- provider: Optional[str] = typer.Option(None),
253
+ provider: Optional[str] = typer.Option(
254
+ None, "--supervisor", "-s", help="Run the supervisor on this CLI instead of Claude Code (codex, antigravity). Workers keep their own profiles' providers.",
255
+ ),
250
256
  attach: bool = typer.Option(True, "--attach/--no-attach"),
251
257
  watch: bool = typer.Option(True, "--watch/--no-watch", help="Show the brindle watch dashboard in a pane under the agent."),
252
258
  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)."),
253
259
  branch: Optional[str] = typer.Option(None, "--branch", "-b", help="Run in the worktree for this branch, creating the branch and worktree if needed."),
254
260
  worktree: Optional[str] = typer.Option(None, "--worktree", "-w", help="Run in the worktree at this path (created with --branch if it doesn't exist)."),
261
+ provider_alias: Annotated[Optional[str], typer.Option("--provider", hidden=True)] = None,
255
262
  ) -> None:
256
263
  """Start a fresh supervisor chat here, with the dashboard alongside. A launch only ever starts a supervisor; it starts the other agents itself.
257
264
 
@@ -263,6 +270,7 @@ def start(
263
270
  from brindle.config import load_repo_config
264
271
  from brindle import scratch
265
272
 
273
+ provider = provider or provider_alias
266
274
  db = DB()
267
275
  if branch or worktree:
268
276
  ws = _run(workspaces.checkout_for, db, os.getcwd(), branch=branch, worktree=worktree)
@@ -290,6 +298,14 @@ def start(
290
298
  typer.secho(f"✓ Switched to '{default_b}'.", fg="green")
291
299
  except Exception:
292
300
  pass
301
+ elif curr_b and git.upstream_gone(ws.path, curr_b):
302
+ typer.secho(f"! Branch '{curr_b}' was deleted on origin but has unmerged changes.", fg="yellow")
303
+ try:
304
+ if curr_b and git.current_branch(ws.path) == git.default_branch(ws.repo_root):
305
+ if warning := git.fast_forward(ws.path, git.current_branch(ws.path)):
306
+ typer.secho(f"! {warning}", fg="yellow")
307
+ except git.GitError:
308
+ pass
293
309
 
294
310
  from brindle.providers import NOT_SUPERVISOR, SUPERVISOR_PROVIDERS
295
311
 
@@ -741,10 +757,14 @@ def default(
741
757
  ctx: typer.Context,
742
758
  cont: bool = typer.Option(False, "--continue", "-c", help="Pick up the most recent paused session instead of starting fresh."),
743
759
  autopilot: Optional[bool] = typer.Option(None, "--autopilot/--no-autopilot", help="Start with autopilot on or off (default: on, or `autopilot` in .brindle/config.json)."),
744
- provider: Optional[str] = typer.Option(None, "--provider", help="Run the supervisor on this CLI instead of Claude Code (codex, antigravity)."),
760
+ provider: Optional[str] = typer.Option(
761
+ None, "--supervisor", "-s", help="Run the supervisor on this CLI instead of Claude Code (codex, antigravity). Workers keep their own profiles' providers.",
762
+ ),
745
763
  show_version: bool = typer.Option(False, "--version", help="Print brindle's version and exit."),
764
+ provider_alias: Annotated[Optional[str], typer.Option("--provider", hidden=True)] = None,
746
765
  ) -> None:
747
766
  """Bare `brindle`: a fresh supervisor chat here (a scratch session outside git)."""
767
+ provider = provider or provider_alias
748
768
  if show_version:
749
769
  from brindle import __version__
750
770
 
@@ -768,14 +788,18 @@ def default(
768
788
  @app.command()
769
789
  def supervisor(
770
790
  prompt: Optional[str] = typer.Option(None, "--prompt", "-p"),
771
- provider: Optional[str] = typer.Option(None),
791
+ provider: Optional[str] = typer.Option(
792
+ None, "--supervisor", "-s", help="Run the supervisor on this CLI instead of Claude Code (codex, antigravity). Workers keep their own profiles' providers.",
793
+ ),
772
794
  attach: bool = typer.Option(True, "--attach/--no-attach"),
773
795
  watch: bool = typer.Option(True, "--watch/--no-watch", help="Show the brindle watch dashboard in a pane under the agent."),
774
796
  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)."),
775
797
  branch: Optional[str] = typer.Option(None, "--branch", "-b", help="Run in the worktree for this branch, creating the branch and worktree if needed."),
776
798
  worktree: Optional[str] = typer.Option(None, "--worktree", "-w", help="Run in the worktree at this path (created with --branch if it doesn't exist)."),
799
+ provider_alias: Annotated[Optional[str], typer.Option("--provider", hidden=True)] = None,
777
800
  ) -> None:
778
801
  """Start an explicit supervisor session to coordinate coding agents."""
802
+ provider = provider or provider_alias
779
803
  start(prompt=prompt, provider=provider, attach=attach, watch=watch,
780
804
  autopilot=autopilot, branch=branch, worktree=worktree)
781
805
 
@@ -278,10 +278,13 @@ class Identity:
278
278
  billed: str | None = None # who pays for this agent's use, when known
279
279
 
280
280
 
281
- def _unknown(provider: str, tool: str, why: str = "") -> Identity:
281
+ def _unknown(provider: str, tool: str, why: str = "", exe: str = "") -> Identity:
282
282
  install = company_login.INSTALL.get(tool)
283
283
  if why:
284
284
  return Identity(provider, f"{tool}: {why}", determined=False)
285
+ if company_login.which(exe or tool):
286
+ return Identity(provider, f"{tool} is installed but its sign-in check couldn't run, "
287
+ "so the identity can't be checked", determined=False)
285
288
  return Identity(provider, f"{tool} isn't installed, so the identity can't be checked"
286
289
  + (f" (install: {install})" if install else ""), determined=False)
287
290
 
@@ -306,9 +309,12 @@ def claude_identity(org_id: str, claude, cache: bool = True) -> Identity:
306
309
 
307
310
 
308
311
  def _claude_login(claude, route, cache) -> Identity:
309
- got = _probe(["claude", "auth", "status"], cache)
312
+ from brindle import providers
313
+
314
+ exe = providers.claude_binary()
315
+ got = _probe([exe, "auth", "status"], cache)
310
316
  if got is None:
311
- return _unknown("claude", "claude")
317
+ return _unknown("claude", "claude", exe=exe)
312
318
  status = _json(got[1])
313
319
  if status is None:
314
320
  return Identity("claude", "claude: status unreadable", determined=False)
@@ -484,7 +484,19 @@ def claude_model(model: str | None, repo_root: str | None = None) -> str | None:
484
484
  if got is None or got is UNAVAILABLE:
485
485
  return model
486
486
  c = got[1].claude
487
- if c is None or not c.model_ids or model in c.model_ids or not route_in_use(got[0], got[1]):
487
+ if c is None or not route_in_use(got[0], got[1]):
488
+ return model
489
+ return route_model(c, model)
490
+
491
+
492
+ def route_model(c, model: str | None) -> str | None:
493
+ """The model a Claude pane runs on route ``c`` (a ``ClaudeSetup``) for a
494
+ profile's ``model``: the profile's own when the route serves it, or when
495
+ it is an alias (opus, sonnet, haiku) the route pins in ``models`` (the
496
+ pane's ANTHROPIC_DEFAULT_*_MODEL maps it); else the route's default."""
497
+ if not c.model_ids or model in c.model_ids:
498
+ return model
499
+ if model in ("opus", "sonnet", "haiku") and c.models is not None and getattr(c.models, model):
488
500
  return model
489
501
  return c.default_model or c.model_ids[0]
490
502