brindle 0.0.3__tar.gz → 0.0.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 (87) hide show
  1. {brindle-0.0.3 → brindle-0.0.4}/PKG-INFO +38 -4
  2. {brindle-0.0.3 → brindle-0.0.4}/README.md +37 -3
  3. {brindle-0.0.3 → brindle-0.0.4}/pyproject.toml +1 -1
  4. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/ci_adapters.py +133 -4
  5. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/ci_client.py +296 -36
  6. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/cli.py +5 -2
  7. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/account.py +1 -1
  8. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/providers.py +118 -13
  9. {brindle-0.0.3 → brindle-0.0.4}/.gitignore +0 -0
  10. {brindle-0.0.3 → brindle-0.0.4}/LICENSE +0 -0
  11. {brindle-0.0.3 → brindle-0.0.4}/SCHEDULE-A +0 -0
  12. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/__init__.py +0 -0
  13. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/__main__.py +0 -0
  14. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/account.py +0 -0
  15. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/agents.py +0 -0
  16. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/airgap.py +0 -0
  17. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/antigravity.py +0 -0
  18. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/autopilot.py +0 -0
  19. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/builtin_agents/__init__.py +0 -0
  20. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/builtin_agents/developer-codex.md +0 -0
  21. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/builtin_agents/developer-heavy.md +0 -0
  22. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/builtin_agents/developer-local.md +0 -0
  23. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/builtin_agents/developer.md +0 -0
  24. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
  25. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/builtin_agents/reviewer-local.md +0 -0
  26. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/builtin_agents/reviewer.md +0 -0
  27. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/builtin_agents/subagent.md +0 -0
  28. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/builtin_agents/supervisor.md +0 -0
  29. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/codemap.py +0 -0
  30. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/codex_hook.py +0 -0
  31. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/config.py +0 -0
  32. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/cull.py +0 -0
  33. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/db.py +0 -0
  34. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/demo.py +0 -0
  35. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/detect.py +0 -0
  36. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/doctor.py +0 -0
  37. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/events.py +0 -0
  38. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/gates.py +0 -0
  39. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/git.py +0 -0
  40. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/history.py +0 -0
  41. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/inbox.py +0 -0
  42. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/learning.py +0 -0
  43. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/legacy.py +0 -0
  44. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/mcp_server.py +0 -0
  45. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/native/__init__.py +0 -0
  46. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/native/client.py +0 -0
  47. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/native/loop.py +0 -0
  48. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/native/permissions.py +0 -0
  49. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/native/runner.py +0 -0
  50. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/native/serve.py +0 -0
  51. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/native/tools.py +0 -0
  52. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/permissions.py +0 -0
  53. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pipeline.py +0 -0
  54. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/plugins.py +0 -0
  55. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/policy.py +0 -0
  56. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pool.py +0 -0
  57. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/__init__.py +0 -0
  58. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/_files.py +0 -0
  59. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/audit_chain.py +0 -0
  60. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/auth.py +0 -0
  61. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/credentials.py +0 -0
  62. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/features.py +0 -0
  63. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/keys.py +0 -0
  64. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/learning.py +0 -0
  65. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/license.py +0 -0
  66. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/loopback.py +0 -0
  67. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/orgkey.py +0 -0
  68. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/settings_sync.py +0 -0
  69. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/team_events.py +0 -0
  70. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/team_policy.py +0 -0
  71. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/procs.py +0 -0
  72. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/profiles.py +0 -0
  73. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/quota.py +0 -0
  74. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/repo_cmds.py +0 -0
  75. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/repos.py +0 -0
  76. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/savings.py +0 -0
  77. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/scratch.py +0 -0
  78. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/services.py +0 -0
  79. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/sessions.py +0 -0
  80. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/sidebar_follow.py +0 -0
  81. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/status_cache.py +0 -0
  82. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/tasks.py +0 -0
  83. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/tmux.py +0 -0
  84. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/usage.py +0 -0
  85. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/view.py +0 -0
  86. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/watch.py +0 -0
  87. {brindle-0.0.3 → brindle-0.0.4}/src/brindle/workspaces.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: brindle
3
- Version: 0.0.3
3
+ Version: 0.0.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.0.3`). A session started before an upgrade keeps
92
+ session shows it too (`brindle 0.0.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
@@ -672,7 +672,7 @@ how to use them, and the next step to get the rest:
672
672
  | per-worktree services | Pro | `"services"` in `.brindle/config.json` |
673
673
  | several repos in one session | Pro | `brindle repo add <path>` |
674
674
  | org policies + audit feed | Team | `brindle account org policy` |
675
- | Brindle-CI | Team | back in a later release |
675
+ | Brindle-CI | Team | `brindle ci init` |
676
676
  | audit log, air-gap | Enterprise | `brindle audit verify`, `"airgap": true` |
677
677
 
678
678
  ```sh
@@ -816,7 +816,11 @@ hosted control plane decides what each run does and what it reports. The
816
816
  workflows call these commands:
817
817
 
818
818
  - `brindle ci init`: set a repository up in one go (the GitHub App, the secrets,
819
- the workflows as a pull request, then a doctor check).
819
+ the workflows as a pull request, then a doctor check). Run it inside a
820
+ checkout of the repository. For Claude it asks whether to use an API key
821
+ (the `ANTHROPIC_API_KEY` secret) or Anthropic
822
+ [workload identity federation](https://platform.claude.com/docs/en/manage-claude/wif-reference)
823
+ (`--credential key|federation` answers without asking).
820
824
  - `brindle ci doctor`: which provider CLIs and credential names a runner has, and
821
825
  which providers CI may use on this repository.
822
826
  - `brindle ci start`: start a run for an issue, a goal text or a dispatched run,
@@ -828,6 +832,36 @@ workflows call these commands:
828
832
  Credential names are reported, never values. On a repository owned by a GitHub
829
833
  organization, a provider signed in only with a personal subscription is not used.
830
834
 
835
+ An Anthropic API key is either a workspace key (scoped to one workspace) or an
836
+ organization-level key (not scoped to a workspace). An organization-level key
837
+ needs the workspace in every request, so after the key `brindle ci init` asks for
838
+ its workspace ID (`wrkspc_...`; leave it blank for a workspace key, or pass
839
+ `--workspace-id`, default `$ANTHROPIC_WORKSPACE_ID`) and stores it as the Actions
840
+ variable `ANTHROPIC_WORKSPACE_ID`. The workflow then sets
841
+ `ANTHROPIC_CUSTOM_HEADERS="anthropic-workspace-id: <id>"`, which reaches every
842
+ Claude Code process the run starts.
843
+
844
+ With identity federation the repository stores no Anthropic key: the workflow
845
+ exchanges GitHub's OIDC token once for a short-lived Anthropic token, as a
846
+ Claude Console service account (billed as API, so it is allowed on
847
+ organization repositories), and gives the job `ANTHROPIC_AUTH_TOKEN`. `brindle
848
+ ci init` stores the rule, organization, service account and optional workspace
849
+ IDs as the Actions variables `ANTHROPIC_FEDERATION_RULE_ID`,
850
+ `ANTHROPIC_ORGANIZATION_ID`, `ANTHROPIC_SERVICE_ACCOUNT_ID` and
851
+ `ANTHROPIC_WORKSPACE_ID`. Create the federation rule in the Claude Console with
852
+ subject prefix `repo:<owner>/<name>:*`, the condition
853
+
854
+ ```text
855
+ claims.repository == "<owner>/<name>" && claims.workflow_ref.startsWith("<owner>/<name>/.github/workflows/brindle-ci-")
856
+ ```
857
+
858
+ audience `https://api.anthropic.com`, and a token lifetime of at least 7200
859
+ seconds. The condition lets only brindle's workflows mint tokens. On pull
860
+ requests the pull request's own copy of the validate workflow runs, so give
861
+ write access only to people you trust (forks never get a token). Leave the `ANTHROPIC_API_KEY` secret
862
+ unset: a key takes precedence (brindle drops an empty one before Claude Code
863
+ starts).
864
+
831
865
  **Closing and cleaning up.** Press `x` on an agent in the sidebar (twice for one
832
866
  that's still running) or run `brindle close <id>` to stop it and hide it. Stopping means
833
867
  every process of the agent, not just its window: Claude Code can host a session in its
@@ -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.0.3`). A session started before an upgrade keeps
62
+ session shows it too (`brindle 0.0.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
@@ -642,7 +642,7 @@ how to use them, and the next step to get the rest:
642
642
  | per-worktree services | Pro | `"services"` in `.brindle/config.json` |
643
643
  | several repos in one session | Pro | `brindle repo add <path>` |
644
644
  | org policies + audit feed | Team | `brindle account org policy` |
645
- | Brindle-CI | Team | back in a later release |
645
+ | Brindle-CI | Team | `brindle ci init` |
646
646
  | audit log, air-gap | Enterprise | `brindle audit verify`, `"airgap": true` |
647
647
 
648
648
  ```sh
@@ -786,7 +786,11 @@ hosted control plane decides what each run does and what it reports. The
786
786
  workflows call these commands:
787
787
 
788
788
  - `brindle ci init`: set a repository up in one go (the GitHub App, the secrets,
789
- the workflows as a pull request, then a doctor check).
789
+ the workflows as a pull request, then a doctor check). Run it inside a
790
+ checkout of the repository. For Claude it asks whether to use an API key
791
+ (the `ANTHROPIC_API_KEY` secret) or Anthropic
792
+ [workload identity federation](https://platform.claude.com/docs/en/manage-claude/wif-reference)
793
+ (`--credential key|federation` answers without asking).
790
794
  - `brindle ci doctor`: which provider CLIs and credential names a runner has, and
791
795
  which providers CI may use on this repository.
792
796
  - `brindle ci start`: start a run for an issue, a goal text or a dispatched run,
@@ -798,6 +802,36 @@ workflows call these commands:
798
802
  Credential names are reported, never values. On a repository owned by a GitHub
799
803
  organization, a provider signed in only with a personal subscription is not used.
800
804
 
805
+ An Anthropic API key is either a workspace key (scoped to one workspace) or an
806
+ organization-level key (not scoped to a workspace). An organization-level key
807
+ needs the workspace in every request, so after the key `brindle ci init` asks for
808
+ its workspace ID (`wrkspc_...`; leave it blank for a workspace key, or pass
809
+ `--workspace-id`, default `$ANTHROPIC_WORKSPACE_ID`) and stores it as the Actions
810
+ variable `ANTHROPIC_WORKSPACE_ID`. The workflow then sets
811
+ `ANTHROPIC_CUSTOM_HEADERS="anthropic-workspace-id: <id>"`, which reaches every
812
+ Claude Code process the run starts.
813
+
814
+ With identity federation the repository stores no Anthropic key: the workflow
815
+ exchanges GitHub's OIDC token once for a short-lived Anthropic token, as a
816
+ Claude Console service account (billed as API, so it is allowed on
817
+ organization repositories), and gives the job `ANTHROPIC_AUTH_TOKEN`. `brindle
818
+ ci init` stores the rule, organization, service account and optional workspace
819
+ IDs as the Actions variables `ANTHROPIC_FEDERATION_RULE_ID`,
820
+ `ANTHROPIC_ORGANIZATION_ID`, `ANTHROPIC_SERVICE_ACCOUNT_ID` and
821
+ `ANTHROPIC_WORKSPACE_ID`. Create the federation rule in the Claude Console with
822
+ subject prefix `repo:<owner>/<name>:*`, the condition
823
+
824
+ ```text
825
+ claims.repository == "<owner>/<name>" && claims.workflow_ref.startsWith("<owner>/<name>/.github/workflows/brindle-ci-")
826
+ ```
827
+
828
+ audience `https://api.anthropic.com`, and a token lifetime of at least 7200
829
+ seconds. The condition lets only brindle's workflows mint tokens. On pull
830
+ requests the pull request's own copy of the validate workflow runs, so give
831
+ write access only to people you trust (forks never get a token). Leave the `ANTHROPIC_API_KEY` secret
832
+ unset: a key takes precedence (brindle drops an empty one before Claude Code
833
+ starts).
834
+
801
835
  **Closing and cleaning up.** Press `x` on an agent in the sidebar (twice for one
802
836
  that's still running) or run `brindle close <id>` to stop it and hide it. Stopping means
803
837
  every process of the agent, not just its window: Claude Code can host a session in its
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "brindle"
3
- version = "0.0.3"
3
+ version = "0.0.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"
@@ -9,7 +9,9 @@ An adapter answers four questions and does three things:
9
9
  * ``credential(env)``: which credential *names* are present (never values) and
10
10
  what kind they are: an API key, a cloud sign-in (Bedrock, Vertex, ...), an
11
11
  endpoint without a key, or a personal subscription (``CLAUDE_CODE_OAUTH_TOKEN``;
12
- a ChatGPT login for Codex).
12
+ a ChatGPT login for Codex). Anthropic workload identity federation reaches
13
+ here as an API key: the workflow exchanges GitHub's OIDC token once and
14
+ gives the job ``ANTHROPIC_AUTH_TOKEN``.
13
15
  * ``available(env)``: installed and holding some credential.
14
16
  * ``launch``: start a brindle autopilot supervisor with the plan's instructions.
15
17
  * ``review``: ask the model one question (the plan's instructions) and return
@@ -29,6 +31,7 @@ detection, no rendering. The server does that.
29
31
  from __future__ import annotations
30
32
 
31
33
  import json
34
+ import logging
32
35
  import os
33
36
  import re
34
37
  import shutil
@@ -37,11 +40,13 @@ import tempfile
37
40
  import time
38
41
  from dataclasses import dataclass, field
39
42
  from pathlib import Path
40
- from typing import Mapping
43
+ from typing import Mapping, MutableMapping
41
44
 
42
45
 
43
46
  from brindle import providers
44
47
 
48
+ log = logging.getLogger(__name__)
49
+
45
50
  API_KEY = "api_key"
46
51
  CLOUD = "cloud"
47
52
  ENDPOINT = "endpoint"
@@ -65,6 +70,12 @@ BASE_URL_RE = re.compile(r"^(?P<scheme>https?)://(?P<host>[A-Za-z0-9]([A-Za-z0-9
65
70
  PROVIDER_KEYS = frozenset((*CLAUDE_API_KEYS, *CLAUDE_SUBSCRIPTION, *CODEX_API_KEYS,
66
71
  "GEMINI_API_KEY", "GOOGLE_API_KEY"))
67
72
  REVIEW_TIMEOUT = 1200.0
73
+ FIRST_RUN_WAIT = 20.0 # how long a new claude supervisor's pane is watched for a first-run screen
74
+ # An API error Claude Code retries itself (429, 5xx, overloaded) ends the run
75
+ # only once a pane has shown it this long; one it won't retry, at once.
76
+ TRANSIENT_API_ERROR_S = 120.0
77
+ API_ERROR_MAX = 300
78
+ SECRET_LIKE = re.compile(r"\bsk-[A-Za-z0-9_-]+|\b(?:Bearer|Basic)\s+\S+|[A-Za-z0-9_+/=-]{40,}", re.I)
68
79
  DEFAULT_PROFILE = "supervisor"
69
80
 
70
81
 
@@ -163,6 +174,11 @@ class Adapter:
163
174
 
164
175
  return agents.is_alive(root)
165
176
 
177
+ def stuck_screen(self, db, root) -> str | None:
178
+ """Why the supervisor can't make progress on its own (a screen only a
179
+ person answers), or None. Default: never."""
180
+ return None
181
+
166
182
  def review(self, instructions: str, cwd: str, env: Mapping[str, str], *,
167
183
  timeout: float = REVIEW_TIMEOUT, profile: str | None = None) -> Review:
168
184
  raise AdapterError(f"{self.name} can't review")
@@ -209,10 +225,41 @@ def _run(argv: list[str], cwd: str, env: Mapping[str, str], stdin: str, timeout:
209
225
  return Review(proc.stdout or "", exit=proc.returncode)
210
226
 
211
227
 
228
+ def drop_empty_keys(env: MutableMapping[str, str]) -> list[str]:
229
+ """Remove Claude key variables set to an empty string from ``env`` in
230
+ place (a workflow's ``${{ secrets.ANTHROPIC_API_KEY }}`` when the secret
231
+ isn't set): an empty key still wins Claude Code's precedence, over the
232
+ ``ANTHROPIC_AUTH_TOKEN`` that identity federation gives the job.
233
+ Returns the names removed."""
234
+ gone = [k for k in CLAUDE_API_KEYS if k in env and not env[k]]
235
+ for k in gone:
236
+ del env[k]
237
+ return gone
238
+
239
+
240
+ def claude_env(env: Mapping[str, str]) -> dict[str, str]:
241
+ """The environment the claude CLI runs in: ``env`` without empty key
242
+ variables."""
243
+ out = dict(env)
244
+ drop_empty_keys(out)
245
+ return out
246
+
247
+
248
+ def api_error_line(error: str) -> str:
249
+ """An API error as a run's note may carry it: printable ASCII, anything
250
+ key- or token-like redacted, at most API_ERROR_MAX characters."""
251
+ text = SECRET_LIKE.sub("[redacted]", error)
252
+ return re.sub(r"[^\x20-\x7e]", "?", text)[:API_ERROR_MAX]
253
+
254
+
212
255
  class ClaudeAdapter(Adapter):
213
256
  name = "claude"
214
257
  cli = "claude"
215
258
 
259
+ def __init__(self, clock=time.monotonic) -> None:
260
+ self.clock = clock
261
+ self._api_errors: dict[str, tuple[str, float]] = {} # agent id -> (error, first seen)
262
+
216
263
  def binary(self) -> str | None:
217
264
  return providers.claude_binary()
218
265
 
@@ -228,10 +275,91 @@ class ClaudeAdapter(Adapter):
228
275
  return Credential(SUBSCRIPTION, sub)
229
276
  return Credential(None, ())
230
277
 
278
+ @staticmethod
279
+ def prepare(cwd: str, env: Mapping[str, str]) -> None:
280
+ """Answer Claude Code's first-run screens ahead of time (a fresh
281
+ runner has no Claude Code state, and nobody is there to answer them),
282
+ trusting the checkout ``cwd``. brindle's own worktrees are trusted
283
+ when they're made (providers.trust_folder), now that the file exists.
284
+ Only on a CI runner (``GITHUB_ACTIONS`` or ``CI`` is ``"true"`` in
285
+ ``env``): anywhere else Claude Code's state is the person's own, and
286
+ is left alone."""
287
+ if "true" not in (env.get("GITHUB_ACTIONS"), env.get("CI")):
288
+ log.info("brindle ci: not on a CI runner, leaving Claude Code's state alone")
289
+ return
290
+ try:
291
+ providers.seed_ci_config([cwd], env.get("ANTHROPIC_API_KEY") or None)
292
+ except (OSError, ValueError) as e:
293
+ raise AdapterError(f"couldn't set up Claude Code's state: {e}") from None
294
+
295
+ def launch(self, db, ws, instructions: str, profile: str | None):
296
+ """Start the supervisor, then fail fast if it sits on a first-run
297
+ screen anyway (it would wait there for good, spending nothing)."""
298
+ self.prepare(ws.path, os.environ)
299
+ root = super().launch(db, ws, instructions, profile)
300
+ deadline = time.time() + FIRST_RUN_WAIT
301
+ while True:
302
+ screen = self._screen(root)
303
+ why = providers.ClaudeCode.first_run_screen(screen or "")
304
+ if why:
305
+ self.stop(db, root.id)
306
+ raise AdapterError(f"Claude Code stopped on {why}, which nobody in CI can answer")
307
+ if screen is None or providers.ClaudeCode.READY.search(screen) or time.time() >= deadline:
308
+ return root
309
+ time.sleep(1)
310
+
311
+ def stuck_screen(self, db, root) -> str | None:
312
+ """A first-run screen on the supervisor's pane, or an API error the
313
+ supervisor's or a reviewer's last reply ended on: at once when Claude
314
+ Code won't retry it, else once it has been there TRANSIENT_API_ERROR_S
315
+ (it would otherwise sit at its prompt, spending nothing, until the
316
+ server's stall timer)."""
317
+ from brindle import agents
318
+
319
+ screen = self._screen(root) or ""
320
+ why = providers.ClaudeCode.first_run_screen(screen)
321
+ if why:
322
+ return f"Claude Code is on {why}, which nobody in CI can answer"
323
+ panes = [("Claude Code", root, screen)]
324
+ if root is not None:
325
+ panes += [(f"reviewer {a.id}'s Claude Code", a, self._screen(a) or "")
326
+ for a in agents.tree(db, root.id)
327
+ if a.id != root.id and a.mode == "review" and a.provider == self.name]
328
+ now = self.clock()
329
+ for who, agent, text in panes:
330
+ if agent is None:
331
+ continue
332
+ error = providers.ClaudeCode.api_error(text)
333
+ if error is None:
334
+ self._api_errors.pop(agent.id, None)
335
+ continue
336
+ seen, since = self._api_errors.get(agent.id, (None, now))
337
+ if seen != error:
338
+ since = now
339
+ self._api_errors[agent.id] = (error, since)
340
+ line = api_error_line(error)
341
+ if providers.ClaudeCode.FATAL_API_ERROR.match(error):
342
+ return f"{who} stopped on an error it won't retry: {line}"
343
+ if now - since >= TRANSIENT_API_ERROR_S:
344
+ return f"{who} has been stopped on an API error for {int(now - since)}s: {line}"
345
+ return None
346
+
347
+ @staticmethod
348
+ def _screen(root) -> str | None:
349
+ from brindle import agents, tmux
350
+
351
+ if root is None or not getattr(root, "tmux_window", None):
352
+ return None
353
+ try:
354
+ return tmux.capture(root.tmux_window, lines=60, server=agents.server_of(root))
355
+ except tmux.TmuxError:
356
+ return None
357
+
231
358
  def review(self, instructions: str, cwd: str, env: Mapping[str, str], *,
232
359
  timeout: float = REVIEW_TIMEOUT, profile: str | None = None) -> Review:
360
+ self.prepare(cwd, env)
233
361
  argv = [self.binary() or "claude", "-p", "--output-format", "json"]
234
- rev = _run(argv, cwd, env, instructions, timeout)
362
+ rev = _run(argv, cwd, claude_env(env), instructions, timeout)
235
363
  try:
236
364
  data = json.loads(rev.reply)
237
365
  except ValueError:
@@ -529,5 +657,6 @@ def format_doctor(rows: list[dict], repo: str | None, org: bool | None) -> str:
529
657
 
530
658
  __all__ = ["Adapter", "AdapterError", "ClaudeAdapter", "CodexAdapter", "Credential", "NativeAdapter",
531
659
  "Review", "default_adapters", "doctor", "format_doctor", "merge_usage", "providers_available",
532
- "repo_is_org", "usable", "API_KEY", "CLOUD", "ENDPOINT", "SUBSCRIPTION"]
660
+ "repo_is_org", "usable", "claude_env", "drop_empty_keys", "API_KEY", "CLOUD", "ENDPOINT",
661
+ "SUBSCRIPTION"]
533
662