brindle 0.3.3__tar.gz → 0.3.4__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 (129) hide show
  1. {brindle-0.3.3 → brindle-0.3.4}/.gitignore +2 -0
  2. {brindle-0.3.3 → brindle-0.3.4}/PKG-INFO +28 -9
  3. {brindle-0.3.3 → brindle-0.3.4}/README.md +27 -8
  4. {brindle-0.3.3 → brindle-0.3.4}/pyproject.toml +1 -1
  5. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/agents.py +237 -20
  6. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/antigravity.py +8 -1
  7. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/autopilot.py +324 -40
  8. brindle-0.3.4/src/brindle/builtin_agents/reviewer-codex.md +30 -0
  9. brindle-0.3.4/src/brindle/builtin_agents/reviewer-gemini.md +30 -0
  10. brindle-0.3.4/src/brindle/builtin_agents/reviewer-local.md +29 -0
  11. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/builtin_agents/reviewer.md +8 -3
  12. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/builtin_agents/supervisor.md +6 -4
  13. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/cli.py +112 -15
  14. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/config.py +2 -1
  15. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/db.py +58 -3
  16. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/git.py +68 -4
  17. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/keystore.py +14 -3
  18. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/mcp_server.py +37 -9
  19. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/model_availability.py +5 -2
  20. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/pane_auth.py +5 -4
  21. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/pipeline.py +35 -4
  22. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/providers.py +42 -2
  23. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/sessions.py +2 -2
  24. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/tasks.py +25 -1
  25. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/tmux.py +4 -1
  26. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/view.py +2 -0
  27. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/watch.py +4 -0
  28. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/workspaces.py +70 -3
  29. brindle-0.3.3/src/brindle/builtin_agents/reviewer-codex.md +0 -23
  30. brindle-0.3.3/src/brindle/builtin_agents/reviewer-local.md +0 -25
  31. {brindle-0.3.3 → brindle-0.3.4}/LICENSE +0 -0
  32. {brindle-0.3.3 → brindle-0.3.4}/SCHEDULE-A +0 -0
  33. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/__init__.py +0 -0
  34. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/__main__.py +0 -0
  35. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/account.py +0 -0
  36. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/airgap.py +0 -0
  37. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/budget.py +0 -0
  38. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/builtin_agents/__init__.py +0 -0
  39. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/builtin_agents/backend-auditor.md +0 -0
  40. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/builtin_agents/developer-codex.md +0 -0
  41. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/builtin_agents/developer-heavy.md +0 -0
  42. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/builtin_agents/developer-light.md +0 -0
  43. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/builtin_agents/developer-local.md +0 -0
  44. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/builtin_agents/developer.md +0 -0
  45. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/builtin_agents/qa.md +0 -0
  46. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/builtin_agents/subagent.md +0 -0
  47. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/builtin_rules/__init__.py +0 -0
  48. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/builtin_rules/security/backend.md +0 -0
  49. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
  50. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/builtin_rules/tests/only.md +0 -0
  51. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/ci_adapters.py +0 -0
  52. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/ci_client.py +0 -0
  53. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/ci_federation.py +0 -0
  54. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/ci_hosts.py +0 -0
  55. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/codemap.py +0 -0
  56. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/codex_hook.py +0 -0
  57. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/company_identity.py +0 -0
  58. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/company_login.py +0 -0
  59. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/conflicts.py +0 -0
  60. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/cost.py +0 -0
  61. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/cost_estimate.py +0 -0
  62. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/cull.py +0 -0
  63. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/demo.py +0 -0
  64. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/detect.py +0 -0
  65. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/doctor.py +0 -0
  66. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/escapes.py +0 -0
  67. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/events.py +0 -0
  68. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/gates.py +0 -0
  69. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/guardrails.py +0 -0
  70. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/history.py +0 -0
  71. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/identity_lock.py +0 -0
  72. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/inbox.py +0 -0
  73. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/keys_cmds.py +0 -0
  74. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/launch_health.py +0 -0
  75. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/learned_rules.py +0 -0
  76. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/learning.py +0 -0
  77. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/legacy.py +0 -0
  78. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/model_access.py +0 -0
  79. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/native/__init__.py +0 -0
  80. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/native/client.py +0 -0
  81. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/native/loop.py +0 -0
  82. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/native/permissions.py +0 -0
  83. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/native/runner.py +0 -0
  84. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/native/serve.py +0 -0
  85. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/native/tools.py +0 -0
  86. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/permissions.py +0 -0
  87. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/plugins.py +0 -0
  88. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/policy.py +0 -0
  89. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/pool.py +0 -0
  90. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/pricing.py +0 -0
  91. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/pro/__init__.py +0 -0
  92. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/pro/_files.py +0 -0
  93. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/pro/account.py +0 -0
  94. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/pro/audit_chain.py +0 -0
  95. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/pro/audit_export.py +0 -0
  96. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/pro/auth.py +0 -0
  97. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/pro/brief_learning.py +0 -0
  98. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/pro/cost_centers.py +0 -0
  99. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/pro/credentials.py +0 -0
  100. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/pro/features.py +0 -0
  101. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/pro/keys.py +0 -0
  102. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/pro/learning.py +0 -0
  103. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/pro/license.py +0 -0
  104. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/pro/loopback.py +0 -0
  105. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/pro/managed_models.py +0 -0
  106. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/pro/org_profiles.py +0 -0
  107. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/pro/orgkey.py +0 -0
  108. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/pro/rollout.py +0 -0
  109. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/pro/settings_sync.py +0 -0
  110. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/pro/status.py +0 -0
  111. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/pro/team_events.py +0 -0
  112. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/pro/team_policy.py +0 -0
  113. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/procs.py +0 -0
  114. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/profiles.py +0 -0
  115. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/quota.py +0 -0
  116. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/repo_cmds.py +0 -0
  117. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/repos.py +0 -0
  118. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/rewind.py +0 -0
  119. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/rule_checks.py +0 -0
  120. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/savings.py +0 -0
  121. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/scratch.py +0 -0
  122. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/secrets.py +0 -0
  123. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/self_serve.py +0 -0
  124. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/services.py +0 -0
  125. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/sidebar_follow.py +0 -0
  126. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/signin_pause.py +0 -0
  127. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/status_cache.py +0 -0
  128. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/usage.py +0 -0
  129. {brindle-0.3.3 → brindle-0.3.4}/src/brindle/watch_costs.py +0 -0
@@ -10,3 +10,5 @@ graphify-out/
10
10
  docs/
11
11
  .brindle/config.local.json
12
12
  .brindle/bench.json
13
+ .DS_Store
14
+ commit_msg.txt
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: brindle
3
- Version: 0.3.3
3
+ Version: 0.3.4
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
@@ -89,7 +89,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
89
89
  brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
90
90
 
91
91
  `brindle --version` prints the installed version; the tmux status bar of every brindle
92
- session shows it too (`brindle 0.3.3`). A session started before an upgrade keeps
92
+ session shows it too (`brindle 0.3.4`). A session started before an upgrade keeps
93
93
  running the old code, and shows the old number, until you restart it.
94
94
 
95
95
  ## Quick start
@@ -118,11 +118,11 @@ a writable home) and, in a repo, what's set up for it, and says what to do about
118
118
  anything missing. Bare `brindle` runs the essential ones (tmux, the chat's CLI)
119
119
  before it launches anything.
120
120
 
121
- That's it. `brindle` opens a supervisor chat (Claude Code) in your repo, with a
122
- narrow sidebar on the left showing every agent: who's working, who's idle, and
123
- who's waiting for your approval. Tell the supervisor what you want. It splits the
124
- work between workers, each on its own branch, then reviews and merges their
125
- branches. It starts in under a second.
121
+ That's it. Bare `brindle` opens a fresh interactive developer chat (Claude Code)
122
+ in your repo (with autopilot off), with a narrow sidebar on the left showing every
123
+ agent: who's working, who's idle, and who's waiting for your approval. To launch an
124
+ autopilot supervisor that splits work between parallel workers, reviews, and merges
125
+ their branches toward verified milestones, run `brindle supervisor`. It starts in under a second.
126
126
 
127
127
  **The sidebar follows you.** There's one sidebar pane per session root, not
128
128
  one per window: switch to any other brindle window or session (⏎ in the
@@ -242,7 +242,7 @@ for mechanical edits and a Codex reviewer for a second model's opinion.
242
242
 
243
243
  ## Autopilot
244
244
 
245
- `brindle` starts the supervisor with autopilot on. Tell it what we're building,
245
+ `brindle supervisor` starts the supervisor with autopilot on (or `brindle start -a supervisor`). Tell it what we're building,
246
246
  or put the goal in `.brindle/goals.md`, and it works like a project manager:
247
247
 
248
248
  1. **Goal and milestones.** The goal is split into milestones, and each one has
@@ -337,7 +337,8 @@ your own status line prints, so what you see doesn't change.
337
337
 
338
338
  | | |
339
339
  |---|---|
340
- | `brindle` | a fresh supervisor chat here, dashboard alongside |
340
+ | `brindle` | a fresh developer chat here (autopilot off), dashboard alongside |
341
+ | `brindle supervisor` | start an explicit autopilot supervisor chat to coordinate parallel workers and milestones |
341
342
  | `brindle init` | detect setup and test commands, write `.brindle/config.json`, check tools |
342
343
  | `brindle demo [--local]` | watch brindle finish a tiny practice repo: parallel workers, reviews, gated merges |
343
344
  | `brindle new BRANCH [-b BASE] [-a PROFILE] [-p PROMPT]` | worktree + branch + agent |
@@ -346,6 +347,7 @@ your own status line prints, so what you see doesn't change.
346
347
  | `brindle start [-a PROFILE] [-p PROMPT] [--no-watch] [--no-autopilot] [-b BRANCH] [-w PATH]` | the same, with options; `-b`/`-w` run it in that branch's worktree (created if needed, or the one you already made), which gets the repo's `.brindle` config |
347
348
  | `brindle handover --to BRANCH\|PATH [-n NOTE]` | hand the session to a new supervisor there: goal and milestones, workers, queued tasks and your note move across; the old one is paused |
348
349
  | `brindle autopilot [on\|off\|check]` | the goal's progress; turn autopilot on or off; run the checks now |
350
+ | `brindle audit-decision FINDING [-r RATIONALE]` | record acceptance of an audit finding with rationale so subsequent audits respect it |
349
351
  | `brindle delegation [conservative\|balanced\|fast]` | how readily the supervisor delegates: fewest tokens, the default, or quickest |
350
352
  | `brindle transfer [REPO] [--from SESSION] [-b BRANCH]` | move a scratch session's work into a real repo |
351
353
  | `brindle ls [--all]` | workspaces and agents |
@@ -407,6 +409,7 @@ knowing them helps when you tell the supervisor how to work.
407
409
  | `approve_plan` | supervisor | approve a worker's plan, or send it back with feedback (`approved=false`) |
408
410
  | `complete_subagent` | supervisor | record the result of a `subagent`-profile task |
409
411
  | `set_goal` / `get_progress` / `check_milestone` | supervisor | autopilot's goal, its progress, and running the checks |
412
+ | `record_audit_decision` | supervisor | record acceptance of an audit finding with rationale so subsequent audits respect it |
410
413
  | `need_user` | supervisor | stop autopilot and ask you a question |
411
414
  | `transfer_to_repo` | supervisor | move a scratch session's work into a repository |
412
415
  | `handover` | supervisor | hand the session to a new supervisor on another branch or worktree, with a note |
@@ -1437,6 +1440,22 @@ A profile chooses between the two with `auth`:
1437
1440
 
1438
1441
  Your org's `deny_personal_keys` (Enterprise) still applies on top of `auth`.
1439
1442
 
1443
+ #### Running unattended on API keys
1444
+
1445
+ When running long, unattended autopilot sessions with API keys:
1446
+
1447
+ - **Quota, token and cost measurements per provider**:
1448
+ - Autopilot's automatic usage pause (`usage_limit: 90`) relies on the percentage numbers emitted by Claude Code's status line under subscription plans. Under API-key billing (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`), the CLIs report no subscription percentage, so subscription usage pause thresholds do not trigger; manage spend via provider account budget limits or brindle budget policies.
1449
+ - Token and cost tracking: Token counts and cost estimates are captured from Claude and Codex transcripts. Antigravity (`agy`) currently has **no token/cost measurements**, so budget policies and cost tracking cannot monitor Antigravity spend; enforce usage caps directly in Google AI Studio or your Google Cloud project billing settings.
1450
+ - **Runtime auth failure handling and provider differences**:
1451
+ - **Claude and Antigravity**: Runtime authentication failures (such as 401/403, revoked API keys, or credit/billing errors) are intercepted via StopFailure and exit hooks. Brindle marks the worker as failed, alerts the supervisor, invalidates cached sign-in checks, and pauses autopilot with a `Credentials need attention` notice rather than cycling idle nudges.
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.
1453
+ - **Supervisor credential refresh and key rotation**:
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`).
1455
+ - **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`.
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.
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`).
1458
+
1440
1459
  ### Cheap workers
1441
1460
 
1442
1461
  By default a Claude Code worker loads everything your own `claude` does: your
@@ -59,7 +59,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
59
59
  brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
60
60
 
61
61
  `brindle --version` prints the installed version; the tmux status bar of every brindle
62
- session shows it too (`brindle 0.3.3`). A session started before an upgrade keeps
62
+ session shows it too (`brindle 0.3.4`). A session started before an upgrade keeps
63
63
  running the old code, and shows the old number, until you restart it.
64
64
 
65
65
  ## Quick start
@@ -88,11 +88,11 @@ a writable home) and, in a repo, what's set up for it, and says what to do about
88
88
  anything missing. Bare `brindle` runs the essential ones (tmux, the chat's CLI)
89
89
  before it launches anything.
90
90
 
91
- That's it. `brindle` opens a supervisor chat (Claude Code) in your repo, with a
92
- narrow sidebar on the left showing every agent: who's working, who's idle, and
93
- who's waiting for your approval. Tell the supervisor what you want. It splits the
94
- work between workers, each on its own branch, then reviews and merges their
95
- branches. It starts in under a second.
91
+ That's it. Bare `brindle` opens a fresh interactive developer chat (Claude Code)
92
+ in your repo (with autopilot off), with a narrow sidebar on the left showing every
93
+ agent: who's working, who's idle, and who's waiting for your approval. To launch an
94
+ autopilot supervisor that splits work between parallel workers, reviews, and merges
95
+ their branches toward verified milestones, run `brindle supervisor`. It starts in under a second.
96
96
 
97
97
  **The sidebar follows you.** There's one sidebar pane per session root, not
98
98
  one per window: switch to any other brindle window or session (⏎ in the
@@ -212,7 +212,7 @@ for mechanical edits and a Codex reviewer for a second model's opinion.
212
212
 
213
213
  ## Autopilot
214
214
 
215
- `brindle` starts the supervisor with autopilot on. Tell it what we're building,
215
+ `brindle supervisor` starts the supervisor with autopilot on (or `brindle start -a supervisor`). Tell it what we're building,
216
216
  or put the goal in `.brindle/goals.md`, and it works like a project manager:
217
217
 
218
218
  1. **Goal and milestones.** The goal is split into milestones, and each one has
@@ -307,7 +307,8 @@ your own status line prints, so what you see doesn't change.
307
307
 
308
308
  | | |
309
309
  |---|---|
310
- | `brindle` | a fresh supervisor chat here, dashboard alongside |
310
+ | `brindle` | a fresh developer chat here (autopilot off), dashboard alongside |
311
+ | `brindle supervisor` | start an explicit autopilot supervisor chat to coordinate parallel workers and milestones |
311
312
  | `brindle init` | detect setup and test commands, write `.brindle/config.json`, check tools |
312
313
  | `brindle demo [--local]` | watch brindle finish a tiny practice repo: parallel workers, reviews, gated merges |
313
314
  | `brindle new BRANCH [-b BASE] [-a PROFILE] [-p PROMPT]` | worktree + branch + agent |
@@ -316,6 +317,7 @@ your own status line prints, so what you see doesn't change.
316
317
  | `brindle start [-a PROFILE] [-p PROMPT] [--no-watch] [--no-autopilot] [-b BRANCH] [-w PATH]` | the same, with options; `-b`/`-w` run it in that branch's worktree (created if needed, or the one you already made), which gets the repo's `.brindle` config |
317
318
  | `brindle handover --to BRANCH\|PATH [-n NOTE]` | hand the session to a new supervisor there: goal and milestones, workers, queued tasks and your note move across; the old one is paused |
318
319
  | `brindle autopilot [on\|off\|check]` | the goal's progress; turn autopilot on or off; run the checks now |
320
+ | `brindle audit-decision FINDING [-r RATIONALE]` | record acceptance of an audit finding with rationale so subsequent audits respect it |
319
321
  | `brindle delegation [conservative\|balanced\|fast]` | how readily the supervisor delegates: fewest tokens, the default, or quickest |
320
322
  | `brindle transfer [REPO] [--from SESSION] [-b BRANCH]` | move a scratch session's work into a real repo |
321
323
  | `brindle ls [--all]` | workspaces and agents |
@@ -377,6 +379,7 @@ knowing them helps when you tell the supervisor how to work.
377
379
  | `approve_plan` | supervisor | approve a worker's plan, or send it back with feedback (`approved=false`) |
378
380
  | `complete_subagent` | supervisor | record the result of a `subagent`-profile task |
379
381
  | `set_goal` / `get_progress` / `check_milestone` | supervisor | autopilot's goal, its progress, and running the checks |
382
+ | `record_audit_decision` | supervisor | record acceptance of an audit finding with rationale so subsequent audits respect it |
380
383
  | `need_user` | supervisor | stop autopilot and ask you a question |
381
384
  | `transfer_to_repo` | supervisor | move a scratch session's work into a repository |
382
385
  | `handover` | supervisor | hand the session to a new supervisor on another branch or worktree, with a note |
@@ -1407,6 +1410,22 @@ A profile chooses between the two with `auth`:
1407
1410
 
1408
1411
  Your org's `deny_personal_keys` (Enterprise) still applies on top of `auth`.
1409
1412
 
1413
+ #### Running unattended on API keys
1414
+
1415
+ When running long, unattended autopilot sessions with API keys:
1416
+
1417
+ - **Quota, token and cost measurements per provider**:
1418
+ - Autopilot's automatic usage pause (`usage_limit: 90`) relies on the percentage numbers emitted by Claude Code's status line under subscription plans. Under API-key billing (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`), the CLIs report no subscription percentage, so subscription usage pause thresholds do not trigger; manage spend via provider account budget limits or brindle budget policies.
1419
+ - Token and cost tracking: Token counts and cost estimates are captured from Claude and Codex transcripts. Antigravity (`agy`) currently has **no token/cost measurements**, so budget policies and cost tracking cannot monitor Antigravity spend; enforce usage caps directly in Google AI Studio or your Google Cloud project billing settings.
1420
+ - **Runtime auth failure handling and provider differences**:
1421
+ - **Claude and Antigravity**: Runtime authentication failures (such as 401/403, revoked API keys, or credit/billing errors) are intercepted via StopFailure and exit hooks. Brindle marks the worker as failed, alerts the supervisor, invalidates cached sign-in checks, and pauses autopilot with a `Credentials need attention` notice rather than cycling idle nudges.
1422
+ - **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.
1423
+ - **Supervisor credential refresh and key rotation**:
1424
+ - **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`).
1425
+ - **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`.
1426
+ - **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.
1427
+ - **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`).
1428
+
1410
1429
  ### Cheap workers
1411
1430
 
1412
1431
  By default a Claude Code worker loads everything your own `claude` does: your
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "brindle"
3
- version = "0.3.3"
3
+ version = "0.3.4"
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"
@@ -190,13 +190,55 @@ def agent_env(ws: Workspace, agent_id: str, agent: Agent | None = None,
190
190
  company = {} if agent.provider != "claude" else company_login.stored_env()
191
191
  managed_env = {} if org else managed_models.agent_env(m, agent.provider)
192
192
  enforced = None if agent.provider != "claude" or org else company_identity.enforced_route_env()
193
+ session_creds = {}
194
+ try:
195
+ from brindle.db import DB
196
+ db_inst = DB()
197
+ root_id = agent.parent_id or agent_id if agent else agent_id
198
+ curr_id = root_id
199
+ while curr_id:
200
+ ca = db_inst.get_agent(curr_id)
201
+ if ca and ca.parent_id:
202
+ curr_id = ca.parent_id
203
+ else:
204
+ break
205
+ session_creds = db_inst.get_session_credentials_with_timestamps(curr_id or root_id)
206
+ except Exception:
207
+ pass
208
+
209
+ from brindle import keystore, secrets
210
+ guarded = _guarded_profile(agent, ws)
211
+ org_managed = agent.provider == "claude" and bool(providers.claude_org_managed())
212
+ deny = pane_auth.deny_names(agent.provider, getattr(guarded, "auth", "auto") if guarded is not None else "auto",
213
+ m, org_managed)
214
+ no_stored = set(deny) | (set(providers._ENV_AUTH["claude"]) if org_managed else set())
215
+
216
+ creds = set() if org else secrets.agent_credentials(agent.provider, agent.profile, ws.repo_root)
217
+ recovery_keys = {}
218
+ for k in creds:
219
+ if k in no_stored:
220
+ continue
221
+ sess_val, sess_time = session_creds.get(k, (None, 0.0))
222
+ env_val = os.environ.get(k)
223
+ if env_val and not secrets.is_job_secret(k) and agent and agent.status == "failed":
224
+ recovery_keys[k] = env_val
225
+ elif sess_val:
226
+ try:
227
+ ks_val, ks_time = keystore.get_key_info(k)
228
+ except Exception:
229
+ ks_val, ks_time = None, 0.0
230
+ if ks_val and ks_time > sess_time:
231
+ recovery_keys[k] = ks_val
232
+ else:
233
+ recovery_keys[k] = sess_val
234
+
193
235
  if enforced is None:
194
- env = {**company, **profile_env, **managed_env, **env}
236
+ env = {**company, **recovery_keys, **profile_env, **managed_env, **env}
195
237
  else:
196
238
  # An enforced company identity (Enterprise): the pane runs on the route
197
239
  # launch_problem checked, whatever a profile's env lines say.
198
240
  profile_env = {k: v for k, v in profile_env.items() if company_identity.profile_may_set(k)}
199
- env = {**profile_env, **managed_env, **enforced, **env}
241
+ env = {**recovery_keys, **profile_env, **managed_env, **enforced, **env}
200
242
  if agent is not None and preload_tools(agent, ws):
201
243
  # Claude Code defers MCP tools and loads them on demand, which costs a
202
244
  # worker an extra round trip at the moment it's told to report (and
@@ -717,8 +759,12 @@ def _launch(db: DB, agent: Agent, ws: Workspace, *, prompt: str | None,
717
759
  if why:
718
760
  raise AgentError(why)
719
761
  # The org's own key signs the agent in: there is no login to check.
762
+ prov_creds = set() if org_key else secrets.agent_credentials(provider.name, profile.name, ws.repo_root)
763
+ eff_env = _window_environment(agent, ws, m)[0]
764
+ probe_keys = {k: eff_env[k] for k in prov_creds if k in eff_env}
765
+ probe_env = {**launch_env, **probe_keys}
720
766
  why = None if org_key else signed_out(
721
- provider.name, {**launch_env, **{k: "" for k in secrets.pane_deny(deny, launch_env)}})
767
+ provider.name, {**probe_env, **{k: "" for k in secrets.pane_deny(deny, probe_env)}})
722
768
  if why:
723
769
  raise AgentError(why)
724
770
  # Here rather than in spawn, so a resume checks too: a directory can be
@@ -1044,19 +1090,61 @@ def latest_paused(db: DB, ws: Workspace) -> Agent | None:
1044
1090
 
1045
1091
 
1046
1092
  def resume(db: DB, root_id: str, *, watch_pane: bool = True,
1047
- only: set[str] | None = None) -> list[Agent]:
1093
+ only: set[str] | list[str] | None = None,
1094
+ recovery_env: dict[str, str] | None = None) -> list[Agent]:
1048
1095
  """Bring a paused session back: the supervisor and its paused workers
1049
1096
  restart in their own workspaces. Claude Code picks up its previous
1050
1097
  conversation (--resume); other CLIs restart on their original task.
1051
1098
  With ``only``, just those agents (if paused) restart."""
1052
- from brindle import identity_lock
1099
+ from brindle import identity_lock, providers
1100
+
1101
+ providers.invalidate_signed_in()
1102
+ from brindle import keystore, secrets
1103
+
1104
+ # Explicit recovery replacements (from recovery_env or current process environment)
1105
+ env_keys = recovery_env if recovery_env is not None else {
1106
+ k: os.environ[k] for k in keystore.known_names()
1107
+ if k in os.environ and os.environ[k] and not secrets.is_job_secret(k)
1108
+ }
1109
+
1110
+ session_creds = db.get_session_credentials_with_timestamps(root_id)
1111
+ for k in keystore.known_names():
1112
+ try:
1113
+ ks_val, ks_time = keystore.get_key_info(k)
1114
+ except Exception:
1115
+ ks_val, ks_time = None, 0.0
1116
+ sess_val, sess_time = session_creds.get(k, (None, 0.0))
1117
+ env_val = env_keys.get(k)
1118
+
1119
+ val = None
1120
+ if ks_val and (ks_time > sess_time or (sess_val and env_val == sess_val and ks_val != sess_val) or not env_val):
1121
+ val = ks_val
1122
+ elif env_val:
1123
+ val = env_val
1124
+ elif ks_val:
1125
+ val = ks_val
1126
+
1127
+ if val:
1128
+ db.set_session_credential(root_id, k, val)
1129
+ os.environ[k] = val
1053
1130
 
1054
1131
  candidates = [a for a in tree(db, root_id)
1055
- if a.status == "paused" and (only is None or a.id in only)]
1132
+ if a.status in ("paused", "failed") and (only is None or a.id in only)]
1056
1133
  for a in candidates: # all or nothing: a session never half-resumes under another identity
1057
1134
  identity_lock.ensure(a)
1058
1135
  resumed = []
1059
1136
  for a in candidates:
1137
+ if a.status == "failed":
1138
+ if a.tmux_window and same_server(a) and owns_pane(db, a):
1139
+ try:
1140
+ tmux.kill_window(a.tmux_window)
1141
+ except Exception:
1142
+ pass
1143
+ from brindle import procs
1144
+ try:
1145
+ procs.stop([a.id], grace=2.0)
1146
+ except Exception:
1147
+ continue
1060
1148
  ws = db.get_workspace(a.workspace_id)
1061
1149
  if ws is None or not os.path.isdir(ws.path):
1062
1150
  continue
@@ -1081,6 +1169,14 @@ def resume(db: DB, root_id: str, *, watch_pane: bool = True,
1081
1169
  _launch(db, a, ws, prompt=prompt, resume=ref,
1082
1170
  watch_pane=watch_pane and a.id == root_id, background_setup=True)
1083
1171
  resumed.append(a)
1172
+ ap = db.get_autopilot(root_id)
1173
+ failed_still = [a for a in candidates if db.get_agent(a.id) and db.get_agent(a.id).status == "failed"]
1174
+ if ap and ap.state == "blocked" and resumed and not failed_still:
1175
+ note = (ap.note or "").lower()
1176
+ auth_indicators = ("auth", "credential", "sign in", "signed out", "login", "api key", "key", "token", "unauthorized", "forbidden")
1177
+ if any(ind in note for ind in auth_indicators):
1178
+ db.update_autopilot(root_id, state="running", note=None)
1179
+
1084
1180
  return resumed
1085
1181
 
1086
1182
 
@@ -1642,8 +1738,9 @@ def detach(db: DB, agent_id: str) -> str | None:
1642
1738
  """Stop waiting synchronously on a handoff worker: from now on its result
1643
1739
  is forwarded to its parent as a message. Returns the result instead if it
1644
1740
  arrived in the meantime (so it's never lost between the two paths)."""
1645
- db.update_agent(agent_id, mode="handoff_detached")
1646
1741
  agent = db.get_agent(agent_id)
1742
+ if agent and agent.mode == "handoff":
1743
+ db.update_agent(agent_id, mode="handoff_detached")
1647
1744
  return agent.result if agent else None
1648
1745
 
1649
1746
 
@@ -1662,11 +1759,12 @@ def submit_review(db: DB, caller_id: str, approved: bool, summary: str) -> str:
1662
1759
 
1663
1760
  caller = db.get_agent(caller_id)
1664
1761
  ws = db.get_workspace(caller.workspace_id) if caller else None
1665
- if not caller or caller.mode != "review" or ws is None:
1762
+ root_id = autopilot.root_of(db, caller.id) if caller else None
1763
+ ap = db.get_autopilot(root_id) if root_id else None
1764
+ is_auditor = bool(ap and ap.auditor_id == caller_id)
1765
+ if not caller or (caller.mode != "review" and not is_auditor) or ws is None:
1666
1766
  return "Only a reviewer started with request_review can submit a review."
1667
- root_id = autopilot.root_of(db, caller.id)
1668
- ap = db.get_autopilot(root_id)
1669
- if ap and ap.auditor_id == caller.id: # the completion audit, not a branch review
1767
+ if is_auditor: # the completion audit, not a branch review
1670
1768
  text = autopilot.record_audit(db, root_id, approved, summary)
1671
1769
  report_result(db, caller.id, text, forward=False)
1672
1770
  try:
@@ -1879,6 +1977,14 @@ def default_review_profile(cfg: RepoConfig, worker: Agent | None, db: DB | None
1879
1977
  model on its own: a Claude worker reviewed by a Claude reviewer is fine."""
1880
1978
  if cfg.review_profile:
1881
1979
  return cfg.review_profile
1980
+ if cfg.reviewer and cfg.reviewer != "reviewer":
1981
+ return cfg.reviewer
1982
+ if unusable("antigravity") is None:
1983
+ try:
1984
+ load_profile("reviewer-gemini", repo_root)
1985
+ return "reviewer-gemini"
1986
+ except Exception:
1987
+ pass
1882
1988
  if db is None or not repo_root:
1883
1989
  return cfg.reviewer
1884
1990
  from brindle import learning
@@ -1886,8 +1992,18 @@ def default_review_profile(cfg: RepoConfig, worker: Agent | None, db: DB | None
1886
1992
  if learning.plugin(cfg, repo_root) is None:
1887
1993
  return cfg.reviewer # no learner: don't even probe the alternatives
1888
1994
  candidates = [cfg.reviewer]
1995
+ if unusable("antigravity") is None:
1996
+ try:
1997
+ load_profile("reviewer-gemini", repo_root)
1998
+ candidates.append("reviewer-gemini")
1999
+ except Exception:
2000
+ pass
1889
2001
  if unusable("codex") is None:
1890
- candidates.append("reviewer-codex")
2002
+ try:
2003
+ load_profile("reviewer-codex", repo_root)
2004
+ candidates.append("reviewer-codex")
2005
+ except Exception:
2006
+ pass
1891
2007
  if _local_reviewer_available():
1892
2008
  candidates.append("reviewer-local")
1893
2009
  candidates = list(dict.fromkeys(candidates))
@@ -2062,10 +2178,52 @@ def request_review(db: DB, caller: Agent | None, ws: Workspace, profile: str | N
2062
2178
  "it isn't pre-approved). "
2063
2179
  "Look for correctness bugs, missing tests, security problems and unclear code.")
2064
2180
 
2181
+ worker_files = []
2182
+ worker_done_when = getattr(worker, "done_when", None) if worker else None
2183
+ if worker:
2184
+ if getattr(worker, "files", None):
2185
+ worker_files = worker.files
2186
+ else:
2187
+ t = next((t for t in db.list_tasks(ws.repo_root) if t.agent_id == worker.id), None)
2188
+ if t:
2189
+ if t.files:
2190
+ import json
2191
+ try:
2192
+ worker_files = json.loads(t.files)
2193
+ except Exception:
2194
+ worker_files = [t.files]
2195
+ if not worker_done_when and t.done_when:
2196
+ worker_done_when = t.done_when
2065
2197
  if worker and worker.task:
2066
- task += f"\n\nThe worker's original task:\n{worker.task.strip()[:TASK_TRIM]}"
2067
- if worker and worker.done_when:
2068
- task += f"\n\nIts finish line: {worker.done_when.strip()}"
2198
+ task += f"\n\nThe worker's original task:\n{worker.task.strip()}"
2199
+ if worker:
2200
+ task += f"\n\nWorker profile: {worker.profile} (provider: {worker.provider})"
2201
+ try:
2202
+ from brindle.profiles import load_profile
2203
+ wp = load_profile(worker.profile, ws.repo_root)
2204
+ restrictions = []
2205
+ if wp.allowed_tools:
2206
+ restrictions.append(f"allowed_tools: {', '.join(wp.allowed_tools)}")
2207
+ if wp.permission_mode:
2208
+ restrictions.append(f"permission_mode: {wp.permission_mode}")
2209
+ if wp.permission_denies:
2210
+ restrictions.append(f"permission_denies: {', '.join(wp.permission_denies)}")
2211
+ if getattr(wp, "write_scope", None):
2212
+ restrictions.append(f"write_scope: {', '.join(wp.write_scope)}")
2213
+ if getattr(wp, "read_scope", None):
2214
+ restrictions.append(f"read_scope: {', '.join(wp.read_scope)}")
2215
+ if getattr(wp, "env_allow", None):
2216
+ restrictions.append(f"env_allow: {', '.join(wp.env_allow)}")
2217
+ if wp.strict_mcp:
2218
+ restrictions.append("strict_mcp: true")
2219
+ if restrictions:
2220
+ task += f"\nWorker restrictions:\n" + "\n".join(f"- {r}" for r in restrictions)
2221
+ except Exception:
2222
+ pass
2223
+ if worker_files:
2224
+ task += f"\n\nDeclared files: {', '.join(worker_files)}"
2225
+ if worker_done_when:
2226
+ task += f"\n\nIts finish line: {worker_done_when.strip()}"
2069
2227
 
2070
2228
  if check_summary:
2071
2229
  task += (f"\n\nThe repo's checks have already run on this commit:\n\n{check_summary}\n\n"
@@ -2085,6 +2243,19 @@ def request_review(db: DB, caller: Agent | None, ws: Workspace, profile: str | N
2085
2243
 
2086
2244
  if focus:
2087
2245
  task += f"\n\nFocus: {focus}"
2246
+
2247
+ task += (
2248
+ "\n\nClassify each finding into one of:\n"
2249
+ "- `blocker`: must be fixed before the branch can merge.\n"
2250
+ "- `minor`: style nits, minor comments or suggestions that do not block merging.\n"
2251
+ "- `needs supervisor action: <details>`: an issue the worker cannot fix within its allowed tools or permissions (e.g. network access, elevated migrations, scope decisions).\n\n"
2252
+ "Check whether your findings are actionable by the worker: if a fix requires capabilities outside the worker's brief, flag it as `needs supervisor action`. Check the finish line (`done_when`) for substance, not just placeholder presence (e.g. data populated, not merely keys existing)."
2253
+ )
2254
+ if check_summary:
2255
+ task += " If you judged checks without running Bash, state clearly which checks you ran and which you read from history."
2256
+ task += (
2257
+ "\n\nApprove (`approved=true`) only if there are no blockers and no supervisor-action items. If only minor findings remain, approve and list them under minor findings."
2258
+ )
2088
2259
  return spawn(db, ws, profile, prompt=task, parent_id=caller.id if caller else None,
2089
2260
  mode="review", background_setup=True, review_sha=sha)
2090
2261
 
@@ -2477,12 +2648,58 @@ def handle_hook(db: DB, agent_id: str, event: str, payload: dict) -> dict | None
2477
2648
  flush(db, agent_id)
2478
2649
  elif event == "stop-failure":
2479
2650
  # The turn ended on an API error; no Stop hook follows.
2480
- db.set_status(agent_id, "idle")
2481
- db.clear_permission_requests(agent_id)
2482
- if "rate_limit" in json.dumps(payload):
2483
- from brindle import autopilot as pilot
2651
+ err_fields = " ".join(
2652
+ str(payload.get(k) or "") for k in (
2653
+ "error", "error_type", "error_details", "last_assistant_message",
2654
+ "message", "reason", "details", "status", "status_code", "code"
2655
+ )
2656
+ ).lower()
2657
+ is_rate_limit = bool(re.search(r"\b(429|rate_limit_error|rate_limit|rate-limit|overloaded|resource_exhausted)\b", err_fields))
2658
+ auth_enums = {
2659
+ "oauth_org_not_allowed", "billing_error", "authentication_error",
2660
+ "permission_error", "invalid_api_key", "account_suspended", "revoked_api_key"
2661
+ }
2662
+ is_auth = not is_rate_limit and (
2663
+ payload.get("error_type") == "auth"
2664
+ or str(payload.get("error") or "").lower() in auth_enums
2665
+ or str(payload.get("error_type") or "").lower() in auth_enums
2666
+ or bool(re.search(r"\b(401|403|oauth_org_not_allowed|billing_error)\b", err_fields))
2667
+ or any(w in err_fields for w in (
2668
+ "unauthorized", "authentication", "auth_fail", "auth fail",
2669
+ "invalid api key", "invalid_api_key", "expired token", "expired_token",
2670
+ "credit balance", "credit-balance", "revoked", "forbidden",
2671
+ "oauth_org_not_allowed", "billing_error"
2672
+ ))
2673
+ )
2674
+ if is_auth:
2675
+ db.set_status(agent_id, "failed")
2676
+ db.clear_permission_requests(agent_id)
2677
+ from brindle import providers, autopilot as pilot
2678
+ providers.invalidate_signed_in(agent.provider)
2679
+ err_desc = payload.get("error_details") or payload.get("error") or payload.get("error_type") or "401/403"
2680
+ msg = (
2681
+ f"[brindle] Agent {agent_id} encountered an authentication failure "
2682
+ f"(invalid or expired credentials): {err_desc}. "
2683
+ "Check API keys or login status."
2684
+ )
2685
+ if agent.parent_id:
2686
+ try:
2687
+ send_message(db, agent.parent_id, msg, sender_id="brindle")
2688
+ except Exception:
2689
+ try:
2690
+ db.enqueue(agent.parent_id, msg, "brindle")
2691
+ except Exception:
2692
+ pass
2693
+ root_id = pilot.root_of(db, agent_id)
2694
+ if db.get_autopilot(root_id):
2695
+ pilot.need_user(db, root_id, f"Credentials need attention for agent {agent_id}: {err_desc}")
2696
+ else:
2697
+ db.set_status(agent_id, "idle")
2698
+ db.clear_permission_requests(agent_id)
2699
+ if is_rate_limit:
2700
+ from brindle import autopilot as pilot
2484
2701
 
2485
- pilot.limit_reached(db, agent)
2702
+ pilot.limit_reached(db, agent)
2486
2703
  elif event == "subagent-start":
2487
2704
  # A crash can skip SubagentStop, so this doesn't touch agent.status:
2488
2705
  # the sidebar hides a subagent that's been "running" too long instead.
@@ -254,6 +254,11 @@ def agent_from_parent(max_depth: int = 4) -> str | None:
254
254
  # -- hooks ---------------------------------------------------------------------------
255
255
 
256
256
  LIMIT_ERROR = re.compile(r"429|quota|rate.?limit|resource.?exhausted", re.I)
257
+ AUTH_ERROR = re.compile(
258
+ r"401|403|unauthorized|authentication|auth[-_ ]?fail|invalid[-_ ]?(?:api[-_ ]?)?key|"
259
+ r"expired[-_ ]?(?:token|key)?|credit[-_ ]?balance|billing|permission denied",
260
+ re.I,
261
+ )
257
262
 
258
263
 
259
264
  def handle_hook(db, agent_id: str, event: str, payload: dict) -> dict | None:
@@ -283,14 +288,16 @@ def handle_hook(db, agent_id: str, event: str, payload: dict) -> dict | None:
283
288
  error = str(payload.get("error") or "")
284
289
  if error:
285
290
  limited = bool(LIMIT_ERROR.search(error))
291
+ autherr = bool(AUTH_ERROR.search(error))
286
292
  if limited:
287
293
  from brindle import quota
288
294
  from brindle.config import RepoConfig, load_repo_config
289
295
 
290
296
  ws = db.get_workspace(agent.workspace_id)
291
297
  quota.record_limit("antigravity", load_repo_config(ws.repo_root) if ws else RepoConfig())
298
+ error_type = "rate_limit" if limited else ("auth" if autherr else "error")
292
299
  agents.handle_hook(db, agent_id, "stop-failure",
293
- {**base, "error_type": "rate_limit" if limited else "error"})
300
+ {**base, "error_type": error_type, "error": error})
294
301
  return None
295
302
  if db.pending_count(agent_id):
296
303
  # A new turn, not a continuation: only then does agy give the