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.
- {brindle-0.0.3 → brindle-0.0.4}/PKG-INFO +38 -4
- {brindle-0.0.3 → brindle-0.0.4}/README.md +37 -3
- {brindle-0.0.3 → brindle-0.0.4}/pyproject.toml +1 -1
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/ci_adapters.py +133 -4
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/ci_client.py +296 -36
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/cli.py +5 -2
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/account.py +1 -1
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/providers.py +118 -13
- {brindle-0.0.3 → brindle-0.0.4}/.gitignore +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/LICENSE +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/SCHEDULE-A +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/__init__.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/__main__.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/account.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/agents.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/airgap.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/antigravity.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/autopilot.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/builtin_agents/__init__.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/builtin_agents/developer-codex.md +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/builtin_agents/developer-heavy.md +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/builtin_agents/developer-local.md +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/builtin_agents/developer.md +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/builtin_agents/reviewer-local.md +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/builtin_agents/reviewer.md +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/builtin_agents/subagent.md +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/builtin_agents/supervisor.md +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/codemap.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/codex_hook.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/config.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/cull.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/db.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/demo.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/detect.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/doctor.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/events.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/gates.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/git.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/history.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/inbox.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/learning.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/legacy.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/mcp_server.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/native/__init__.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/native/client.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/native/loop.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/native/permissions.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/native/runner.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/native/serve.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/native/tools.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/permissions.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pipeline.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/plugins.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/policy.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pool.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/__init__.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/_files.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/audit_chain.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/auth.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/credentials.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/features.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/keys.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/learning.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/license.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/loopback.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/orgkey.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/settings_sync.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/team_events.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/pro/team_policy.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/procs.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/profiles.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/quota.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/repo_cmds.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/repos.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/savings.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/scratch.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/services.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/sessions.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/sidebar_follow.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/status_cache.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/tasks.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/tmux.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/usage.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/view.py +0 -0
- {brindle-0.0.3 → brindle-0.0.4}/src/brindle/watch.py +0 -0
- {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
|
+
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.
|
|
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 |
|
|
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.
|
|
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 |
|
|
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
|
|
@@ -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",
|
|
660
|
+
"repo_is_org", "usable", "claude_env", "drop_empty_keys", "API_KEY", "CLOUD", "ENDPOINT",
|
|
661
|
+
"SUBSCRIPTION"]
|
|
533
662
|
|