brindle 0.3.14__tar.gz → 0.3.16__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.3.14 → brindle-0.3.16}/PKG-INFO +2 -2
- {brindle-0.3.14 → brindle-0.3.16}/README.md +1 -1
- {brindle-0.3.14 → brindle-0.3.16}/pyproject.toml +1 -1
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/agents.py +166 -17
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/autopilot.py +7 -1
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/cli.py +6 -0
- brindle-0.3.16/src/brindle/cloud_login.py +225 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/cull.py +54 -3
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/db.py +56 -2
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/launch_health.py +1 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/learning.py +73 -7
- brindle-0.3.16/src/brindle/login_pause.py +223 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/mcp_server.py +5 -2
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/model_access.py +55 -4
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pipeline.py +268 -23
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/learning.py +16 -14
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/procs.py +21 -5
- brindle-0.3.16/src/brindle/prompt_answer.py +447 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/providers.py +73 -4
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/sessions.py +6 -2
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/tmux.py +10 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/workspaces.py +6 -0
- {brindle-0.3.14 → brindle-0.3.16}/.gitignore +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/LICENSE +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/SCHEDULE-A +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/__init__.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/__main__.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/account.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/airgap.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/antigravity.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/budget.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/__init__.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/backend-auditor.md +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/developer-codex.md +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/developer-fable.md +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/developer-gemini.md +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/developer-heavy.md +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/developer-light.md +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/developer-local.md +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/developer.md +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/qa.md +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/reviewer-gemini.md +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/reviewer-local.md +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/reviewer.md +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/subagent.md +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/supervisor.md +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_rules/__init__.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_rules/security/backend.md +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_rules/tests/only.md +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/ci_adapters.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/ci_client.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/ci_federation.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/ci_hosts.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/cloud_credential.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/codemap.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/codemap_docs.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/codex_hook.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/company_identity.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/company_login.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/config.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/conflicts.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/cost.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/cost_estimate.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/demo.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/detect.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/doctor.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/escapes.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/events.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/gates.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/git.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/guardrails.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/history.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/identity_lock.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/inbox.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/keys_cmds.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/keystore.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/learned_rules.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/legacy.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/model_availability.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/native/__init__.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/native/client.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/native/loop.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/native/permissions.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/native/runner.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/native/serve.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/native/tools.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pane_auth.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/permissions.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/plugins.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/policy.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pool.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pricing.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/__init__.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/_files.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/account.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/audit_chain.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/audit_export.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/auth.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/brief_learning.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/cost_centers.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/credentials.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/features.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/keys.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/license.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/loopback.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/managed_models.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/org_profiles.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/orgkey.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/rollout.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/route_health.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/settings_sync.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/status.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/team_events.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/team_policy.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/profiles.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/quota.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/repo_cmds.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/repos.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/rewind.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/rule_checks.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/savings.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/scratch.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/secrets.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/self_serve.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/services.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/sidebar_follow.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/signin_pause.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/status_cache.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/tasks.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/updates.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/upgrade.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/usage.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/view.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/watch.py +0 -0
- {brindle-0.3.14 → brindle-0.3.16}/src/brindle/watch_costs.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: brindle
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.16
|
|
4
4
|
Summary: Run coding agents in parallel, each on its own git branch, with a supervisor that delegates, reviews, and merges
|
|
5
5
|
Project-URL: Homepage, https://pawdelta.com/brindle/
|
|
6
6
|
Project-URL: Documentation, https://pawdelta.com/brindle/docs/recommended-use
|
|
@@ -94,7 +94,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
|
|
|
94
94
|
brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
|
|
95
95
|
|
|
96
96
|
`brindle --version` prints the installed version; the tmux status bar of every brindle
|
|
97
|
-
session shows it too (`brindle 0.3.
|
|
97
|
+
session shows it too (`brindle 0.3.16`). A session started before an upgrade keeps
|
|
98
98
|
running the old code, and shows the old number, until you restart it.
|
|
99
99
|
|
|
100
100
|
## Quick start
|
|
@@ -63,7 +63,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
|
|
|
63
63
|
brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
|
|
64
64
|
|
|
65
65
|
`brindle --version` prints the installed version; the tmux status bar of every brindle
|
|
66
|
-
session shows it too (`brindle 0.3.
|
|
66
|
+
session shows it too (`brindle 0.3.16`). A session started before an upgrade keeps
|
|
67
67
|
running the old code, and shows the old number, until you restart it.
|
|
68
68
|
|
|
69
69
|
## Quick start
|
|
@@ -134,7 +134,9 @@ PERMISSION_GUIDANCE = (
|
|
|
134
134
|
"Never `cd` into or read from the main checkout ({root}): it isn't yours, and it "
|
|
135
135
|
"pauses you for approval. This worktree is already your isolated copy: don't create "
|
|
136
136
|
"or enter another one (no EnterWorktree, no `git worktree add`). Write files with your Edit and Write tools, never with "
|
|
137
|
-
"shell heredocs or scripts, and don't write to /tmp
|
|
137
|
+
"shell heredocs or scripts, and don't write to /tmp.\n"
|
|
138
|
+
"Waiting: to wait on something (your supervisor, a long command), wait for brindle's "
|
|
139
|
+
"messages or use the Monitor tool; don't poll with short cron loops."
|
|
138
140
|
)
|
|
139
141
|
|
|
140
142
|
|
|
@@ -501,6 +503,10 @@ def _open_window(db: DB, agent: Agent, ws: Workspace, name: str, argv: list[str]
|
|
|
501
503
|
server = tmux.current_server()
|
|
502
504
|
db.update_agent(agent.id, tmux_window=target, tmux_server=server)
|
|
503
505
|
agent.tmux_window, agent.tmux_server = target, server
|
|
506
|
+
if get_provider(agent.provider).startup_prompts and not agent.headless:
|
|
507
|
+
from brindle import prompt_answer
|
|
508
|
+
|
|
509
|
+
prompt_answer.launched(db, agent)
|
|
504
510
|
if watch_pane:
|
|
505
511
|
try:
|
|
506
512
|
_ensure_sidebar(db, root_of(db, agent.id), ws, target)
|
|
@@ -881,11 +887,9 @@ def _launch(db: DB, agent: Agent, ws: Workspace, *, prompt: str | None,
|
|
|
881
887
|
env={**base, **agent_env(ws, agent.id)},
|
|
882
888
|
)
|
|
883
889
|
else:
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
from brindle import launch_health
|
|
888
|
-
launch_health.accepted(db, agent)
|
|
890
|
+
from brindle import prompt_answer
|
|
891
|
+
|
|
892
|
+
prompt_answer.finish_launch(db, agent)
|
|
889
893
|
|
|
890
894
|
|
|
891
895
|
def _prepare_launch_health(db: DB, agent: Agent, ws: Workspace, profile, m=_UNREAD,
|
|
@@ -1468,6 +1472,10 @@ def _send_message(db: DB, agent: Agent, body: str, sender_id: str | None) -> str
|
|
|
1468
1472
|
server=server_of(agent))
|
|
1469
1473
|
return "delivered"
|
|
1470
1474
|
message_id = db.enqueue(agent.id, text, sender_id)
|
|
1475
|
+
from brindle import login_pause
|
|
1476
|
+
|
|
1477
|
+
if login_pause.holding(db, agent.id):
|
|
1478
|
+
return "queued" # its session waits on an expired cloud login
|
|
1471
1479
|
if _deliver_to_inbox(db, agent, message_id, text, sender_id):
|
|
1472
1480
|
return "delivered"
|
|
1473
1481
|
reconcile(db, agent)
|
|
@@ -1559,10 +1567,13 @@ def flush(db: DB, agent_id: str) -> bool:
|
|
|
1559
1567
|
something was delivered."""
|
|
1560
1568
|
from brindle import inbox
|
|
1561
1569
|
from brindle import launch_health
|
|
1570
|
+
from brindle import login_pause
|
|
1562
1571
|
|
|
1563
1572
|
agent = db.get_agent(agent_id)
|
|
1564
1573
|
if agent is None or agent.headless:
|
|
1565
1574
|
return False # its runner takes messages from the inbox itself
|
|
1575
|
+
if login_pause.holding(db, agent_id):
|
|
1576
|
+
return False # its session waits on an expired cloud login; resuming flushes it
|
|
1566
1577
|
if inbox.usable(agent):
|
|
1567
1578
|
delivered = False
|
|
1568
1579
|
while (msg := db.pop_pending(agent_id)) is not None:
|
|
@@ -1759,6 +1770,7 @@ def wait_for_result(db: DB, agent_id: str, timeout: float, poll: float = 2.0) ->
|
|
|
1759
1770
|
if agent.result is not None:
|
|
1760
1771
|
return agent.result
|
|
1761
1772
|
if not is_alive(agent):
|
|
1773
|
+
_note_reviewer_end(db, agent)
|
|
1762
1774
|
screen = ""
|
|
1763
1775
|
try:
|
|
1764
1776
|
screen = tmux.capture(agent.tmux_window, lines=40, server=server_of(agent))
|
|
@@ -1885,8 +1897,25 @@ def pause_worker(db: DB, agent: Agent) -> None:
|
|
|
1885
1897
|
db.set_status(agent.id, "paused")
|
|
1886
1898
|
|
|
1887
1899
|
|
|
1900
|
+
def _note_reviewer_end(db: DB, agent: Agent, ws: Workspace | None = None) -> None:
|
|
1901
|
+
if agent.mode != "review" or agent.result is not None:
|
|
1902
|
+
return
|
|
1903
|
+
ws = ws or db.get_workspace(agent.workspace_id)
|
|
1904
|
+
if ws is None:
|
|
1905
|
+
return
|
|
1906
|
+
from brindle import learning
|
|
1907
|
+
from brindle.config import RepoConfig, load_repo_config
|
|
1908
|
+
|
|
1909
|
+
try:
|
|
1910
|
+
cfg = load_repo_config(ws.repo_root)
|
|
1911
|
+
except ValueError:
|
|
1912
|
+
cfg = RepoConfig()
|
|
1913
|
+
learning.note_reviewer(db, cfg, agent, ws)
|
|
1914
|
+
|
|
1915
|
+
|
|
1888
1916
|
def kill(db: DB, agent_id: str) -> None:
|
|
1889
1917
|
agent = get(db, agent_id)
|
|
1918
|
+
_note_reviewer_end(db, agent)
|
|
1890
1919
|
_stop(db, agent)
|
|
1891
1920
|
db.delete_agent(agent.id)
|
|
1892
1921
|
|
|
@@ -1898,6 +1927,7 @@ def close(db: DB, agent_id: str, panes: dict[str, bool] | None = None) -> Agent:
|
|
|
1898
1927
|
lists it, and a paused session can still be continued (which shows it
|
|
1899
1928
|
again). Returns the agent as it was before closing."""
|
|
1900
1929
|
agent = get(db, agent_id)
|
|
1930
|
+
_note_reviewer_end(db, agent)
|
|
1901
1931
|
if is_alive(agent, panes) and owns_pane(db, agent):
|
|
1902
1932
|
_stop(db, agent)
|
|
1903
1933
|
db.set_status(agent.id, "done" if agent.result is not None else "paused")
|
|
@@ -2045,8 +2075,10 @@ def default_review_profile(cfg: RepoConfig, worker: Agent | None, db: DB | None
|
|
|
2045
2075
|
if _local_reviewer_available():
|
|
2046
2076
|
candidates.append("reviewer-local")
|
|
2047
2077
|
candidates = list(dict.fromkeys(candidates))
|
|
2078
|
+
files, weight = learning._task_files(db, worker, repo_root) if worker else ((), None)
|
|
2048
2079
|
pick, _ = learning.choose_why(db, cfg, repo_root, task, candidates=candidates,
|
|
2049
|
-
default=cfg.reviewer)
|
|
2080
|
+
default=cfg.reviewer, files=list(files) or None,
|
|
2081
|
+
weight=weight)
|
|
2050
2082
|
return pick or cfg.reviewer
|
|
2051
2083
|
|
|
2052
2084
|
|
|
@@ -2629,6 +2661,12 @@ def handle_hook(db: DB, agent_id: str, event: str, payload: dict) -> dict | None
|
|
|
2629
2661
|
agent = db.get_agent(agent_id)
|
|
2630
2662
|
if agent is None:
|
|
2631
2663
|
return None
|
|
2664
|
+
# A hook runs only once the session is going, so its launch is past any
|
|
2665
|
+
# startup prompt: a menu on its pane from now on is the agent's own (a
|
|
2666
|
+
# question for the user), which no sweep may answer.
|
|
2667
|
+
from brindle import prompt_answer
|
|
2668
|
+
|
|
2669
|
+
prompt_answer.settled(db, agent)
|
|
2632
2670
|
if event == "codex-permission-request":
|
|
2633
2671
|
# Session/status writes are bookkeeping, never prerequisites for
|
|
2634
2672
|
# returning a policy decision (especially a deny).
|
|
@@ -2652,8 +2690,11 @@ def handle_hook(db: DB, agent_id: str, event: str, payload: dict) -> dict | None
|
|
|
2652
2690
|
|
|
2653
2691
|
if event in ("prompt-submit", "pre-tool", "tool-done") and agent.unreported_noted:
|
|
2654
2692
|
# It's working again, so a later stop without a result is a new one
|
|
2655
|
-
# its supervisor should hear about (see tell_parent_unreported).
|
|
2656
|
-
|
|
2693
|
+
# its supervisor should hear about (see tell_parent_unreported). A
|
|
2694
|
+
# tool call alone isn't proof: agy resumes briefly each time it polls
|
|
2695
|
+
# a running command, so only a prompt or a long-enough gap counts.
|
|
2696
|
+
if event == "prompt-submit" or unreported_note_expired(agent):
|
|
2697
|
+
db.update_agent(agent_id, unreported_noted=None)
|
|
2657
2698
|
|
|
2658
2699
|
if event == "session-start":
|
|
2659
2700
|
from brindle import inbox
|
|
@@ -2764,7 +2805,8 @@ def handle_hook(db: DB, agent_id: str, event: str, payload: dict) -> dict | None
|
|
|
2764
2805
|
# No Stop hook can remind it to report, and no hook says when
|
|
2765
2806
|
# it resumes: every turn it ends without a result is a stop
|
|
2766
2807
|
# its supervisor hears about.
|
|
2767
|
-
|
|
2808
|
+
if unreported_note_expired(agent):
|
|
2809
|
+
db.update_agent(agent_id, unreported_noted=None)
|
|
2768
2810
|
tell_parent_unreported(db, db.get_agent(agent_id) or agent)
|
|
2769
2811
|
if db.pending_count(agent_id) and not agent.headless:
|
|
2770
2812
|
flush(db, agent_id)
|
|
@@ -2803,6 +2845,7 @@ def handle_hook(db: DB, agent_id: str, event: str, payload: dict) -> dict | None
|
|
|
2803
2845
|
if is_auth or is_model:
|
|
2804
2846
|
db.set_status(agent_id, "failed")
|
|
2805
2847
|
db.clear_permission_requests(agent_id)
|
|
2848
|
+
_note_reviewer_end(db, agent)
|
|
2806
2849
|
from brindle import providers, autopilot as pilot
|
|
2807
2850
|
if is_auth:
|
|
2808
2851
|
providers.invalidate_signed_in(agent.provider)
|
|
@@ -2889,11 +2932,19 @@ def agent_for_session(db: DB, session_id: object) -> str | None:
|
|
|
2889
2932
|
return matches[-1].id if matches else None
|
|
2890
2933
|
|
|
2891
2934
|
|
|
2935
|
+
UNREPORTED_REALERT_SECONDS = 600
|
|
2936
|
+
|
|
2937
|
+
|
|
2938
|
+
def unreported_note_expired(agent: Agent) -> bool:
|
|
2939
|
+
"""Whether the stop alert recorded in ``unreported_noted`` (the time it
|
|
2940
|
+
was sent) is old enough that a new stop deserves its own alert."""
|
|
2941
|
+
noted = agent.unreported_noted
|
|
2942
|
+
return not noted or time.time() - noted > UNREPORTED_REALERT_SECONDS
|
|
2943
|
+
|
|
2944
|
+
|
|
2892
2945
|
def command_running(db: DB, agent: Agent) -> bool:
|
|
2893
2946
|
"""Whether a command the agent started (possibly in the background) is
|
|
2894
2947
|
still running under it. Never raises: when unsure, it says no."""
|
|
2895
|
-
if agent.provider != "claude":
|
|
2896
|
-
return False
|
|
2897
2948
|
try:
|
|
2898
2949
|
from brindle import procs
|
|
2899
2950
|
|
|
@@ -2905,6 +2956,102 @@ def command_running(db: DB, agent: Agent) -> bool:
|
|
|
2905
2956
|
return False
|
|
2906
2957
|
|
|
2907
2958
|
|
|
2959
|
+
TRANSCRIPT_ACTIVE_SECONDS = 45
|
|
2960
|
+
REOPEN_VERDICT_PROMPT = (
|
|
2961
|
+
"Submit your verdict now: call the brindle `submit_review` tool with "
|
|
2962
|
+
"`approved` and `summary` based on what you have already read."
|
|
2963
|
+
)
|
|
2964
|
+
|
|
2965
|
+
|
|
2966
|
+
def transcript_file(agent: Agent) -> str | None:
|
|
2967
|
+
"""The path to ``agent``'s own recorded transcript or rollout file, if one
|
|
2968
|
+
exists on disk; None when the agent has no recorded session."""
|
|
2969
|
+
if agent.transcript_path and os.path.isfile(agent.transcript_path):
|
|
2970
|
+
return agent.transcript_path
|
|
2971
|
+
ref = (agent.session_ref or "").strip()
|
|
2972
|
+
if not ref:
|
|
2973
|
+
return None
|
|
2974
|
+
if agent.provider == "claude":
|
|
2975
|
+
import glob
|
|
2976
|
+
|
|
2977
|
+
config = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~/.claude")
|
|
2978
|
+
found = glob.glob(os.path.join(glob.escape(config), "projects", "*", f"{glob.escape(ref)}.jsonl"))
|
|
2979
|
+
return found[0] if found else None
|
|
2980
|
+
if agent.provider == "codex":
|
|
2981
|
+
from brindle import usage
|
|
2982
|
+
|
|
2983
|
+
rollout = usage.find_codex_rollout(ref, None, agent.created_at)
|
|
2984
|
+
return str(rollout) if rollout and rollout.is_file() else None
|
|
2985
|
+
if agent.provider == "antigravity":
|
|
2986
|
+
candidate = os.path.expanduser(
|
|
2987
|
+
f"~/.gemini/antigravity-cli/brain/{ref}/.system_generated/logs/transcript.jsonl"
|
|
2988
|
+
)
|
|
2989
|
+
return candidate if os.path.isfile(candidate) else None
|
|
2990
|
+
if agent.provider == "native":
|
|
2991
|
+
return ref if os.path.isfile(ref) else None
|
|
2992
|
+
return None
|
|
2993
|
+
|
|
2994
|
+
|
|
2995
|
+
def transcript_active(agent: Agent, now: float | None = None) -> bool:
|
|
2996
|
+
"""Whether ``agent`` wrote to its transcript within
|
|
2997
|
+
``TRANSCRIPT_ACTIVE_SECONDS``. Never raises: when unsure, it says no."""
|
|
2998
|
+
try:
|
|
2999
|
+
path = transcript_file(agent)
|
|
3000
|
+
if not path:
|
|
3001
|
+
return False
|
|
3002
|
+
now = time.time() if now is None else now
|
|
3003
|
+
return now - os.stat(path).st_mtime < TRANSCRIPT_ACTIVE_SECONDS
|
|
3004
|
+
except Exception: # noqa: BLE001
|
|
3005
|
+
return False
|
|
3006
|
+
|
|
3007
|
+
|
|
3008
|
+
def recorded_session(agent: Agent) -> str | None:
|
|
3009
|
+
"""The CLI session id recorded for ``agent`` itself, if its provider can
|
|
3010
|
+
resume that conversation; None when no resumable session was recorded.
|
|
3011
|
+
Never guesses from the working directory (``--continue``, ``resume --last``),
|
|
3012
|
+
since a reviewer shares its worker's worktree."""
|
|
3013
|
+
provider = get_provider(agent.provider)
|
|
3014
|
+
ref = (agent.session_ref or "").strip()
|
|
3015
|
+
if not ref and agent.provider == "claude" and agent.transcript_path:
|
|
3016
|
+
if os.path.isfile(agent.transcript_path):
|
|
3017
|
+
return Path(agent.transcript_path).stem
|
|
3018
|
+
if not ref:
|
|
3019
|
+
return None
|
|
3020
|
+
if agent.provider == "claude" and agent.transcript_path and os.path.isfile(agent.transcript_path):
|
|
3021
|
+
return ref
|
|
3022
|
+
return ref if provider.can_resume(ref) else None
|
|
3023
|
+
|
|
3024
|
+
|
|
3025
|
+
def reopen_reviewer(db: DB, agent: Agent, ws: Workspace,
|
|
3026
|
+
prompt: str = REOPEN_VERDICT_PROMPT) -> bool:
|
|
3027
|
+
"""Resume a stalled reviewer once with a short verdict prompt: send the
|
|
3028
|
+
prompt as a message if the reviewer's process is still alive (or keep an
|
|
3029
|
+
already-queued reminder in flight), or reopen its own recorded CLI session
|
|
3030
|
+
if the process exited. Returns True if resumed or queued, False when it has
|
|
3031
|
+
no live pane or recorded session of its own."""
|
|
3032
|
+
if not os.path.isdir(ws.path):
|
|
3033
|
+
return False
|
|
3034
|
+
if is_alive(agent):
|
|
3035
|
+
if db.pending_count(agent.id) > 0:
|
|
3036
|
+
try:
|
|
3037
|
+
flush(db, agent.id)
|
|
3038
|
+
except tmux.TmuxError:
|
|
3039
|
+
db.set_status(agent.id, "idle", only_if="processing")
|
|
3040
|
+
return True
|
|
3041
|
+
try:
|
|
3042
|
+
return send_message(db, agent.id, prompt) in ("delivered", "queued")
|
|
3043
|
+
except (AgentError, tmux.TmuxError):
|
|
3044
|
+
db.set_status(agent.id, "idle", only_if="processing")
|
|
3045
|
+
return False
|
|
3046
|
+
ref = recorded_session(agent)
|
|
3047
|
+
if not ref:
|
|
3048
|
+
return False
|
|
3049
|
+
if agent.dismissed_at is not None:
|
|
3050
|
+
db.update_agent(agent.id, dismissed_at=None)
|
|
3051
|
+
_launch(db, agent, ws, prompt=prompt, resume=ref, watch_pane=False, background_setup=True)
|
|
3052
|
+
return True
|
|
3053
|
+
|
|
3054
|
+
|
|
2908
3055
|
def tell_parent_unreported(db: DB, agent: Agent) -> bool:
|
|
2909
3056
|
"""A worker stopped again after being reminded to report, still without a
|
|
2910
3057
|
result. It won't be reminded again on its own, and its supervisor has
|
|
@@ -2914,6 +3061,7 @@ def tell_parent_unreported(db: DB, agent: Agent) -> bool:
|
|
|
2914
3061
|
if agent.mode not in FORWARDING_MODES or not agent.parent_id:
|
|
2915
3062
|
return False # a synchronous handoff's caller is still waiting on it
|
|
2916
3063
|
if db.get_agent(agent.parent_id) is None:
|
|
3064
|
+
_note_reviewer_end(db, agent)
|
|
2917
3065
|
return False
|
|
2918
3066
|
ws = db.get_workspace(agent.workspace_id)
|
|
2919
3067
|
if agent.mode == "review" and ws is not None and os.path.isdir(ws.path):
|
|
@@ -2923,17 +3071,18 @@ def tell_parent_unreported(db: DB, agent: Agent) -> bool:
|
|
|
2923
3071
|
return False # its verdict for the current commit is already recorded
|
|
2924
3072
|
if agent.unreported_noted:
|
|
2925
3073
|
return False # the supervisor was told about this stop; cleared once it resumes
|
|
2926
|
-
if command_running(db, agent):
|
|
3074
|
+
if command_running(db, agent) or (is_alive(agent) and transcript_active(agent)):
|
|
2927
3075
|
# Its turn ended while a long or background command (a test run) is
|
|
2928
|
-
# still going
|
|
2929
|
-
# Not marked noted, so a real stop later still gets its
|
|
3076
|
+
# still going, or it is still writing to its transcript: it isn't
|
|
3077
|
+
# stopped. Not marked noted, so a real stop later still gets its alert.
|
|
2930
3078
|
return False
|
|
2931
|
-
db.update_agent(agent.id, unreported_noted=
|
|
3079
|
+
db.update_agent(agent.id, unreported_noted=int(time.time()))
|
|
2932
3080
|
if agent.mode == "review" and ws is not None:
|
|
2933
3081
|
from brindle import pipeline
|
|
2934
3082
|
|
|
2935
3083
|
if pipeline.on_reviewer_unreported(db, get(db, agent.id), ws):
|
|
2936
|
-
return True # a fallback reviewer took over, or the supervisor was told
|
|
3084
|
+
return True # resumed, a fallback reviewer took over, or the supervisor was told
|
|
3085
|
+
_note_reviewer_end(db, agent, ws)
|
|
2937
3086
|
where =f" (branch `{ws.branch}`, workspace {ws.id})" if ws else ""
|
|
2938
3087
|
tool = "submit_review" if agent.mode == "review" else "report_result"
|
|
2939
3088
|
who = "Reviewer" if agent.mode == "review" else "Worker"
|
|
@@ -151,6 +151,10 @@ Acting on what you find:
|
|
|
151
151
|
- Once you've laid out a plan, start it in the same turn. Don't end with
|
|
152
152
|
"shall I start?" or wait for a "yes" or "go": if a step needs a decision
|
|
153
153
|
only the user can make, ask that question and carry on with the rest.
|
|
154
|
+
- To wait for workers, checks or anything else brindle runs, wait for
|
|
155
|
+
brindle's messages (they wake you) or use the Monitor tool; don't poll with
|
|
156
|
+
short cron loops, which spend turns and keep running while brindle has
|
|
157
|
+
paused the session (an expired cloud login, say).
|
|
154
158
|
- Format your own verbose outputs or message transcripts in collapsible
|
|
155
159
|
`<details><summary>Title</summary>...</details>` blocks so they are not
|
|
156
160
|
sprawling in the chat UI."""
|
|
@@ -1501,8 +1505,10 @@ def on_stop(db: DB, agent: Agent, payload: dict) -> dict | None:
|
|
|
1501
1505
|
ap = db.get_autopilot(agent.id)
|
|
1502
1506
|
if ap is None or not ap.enabled or not ap.goal:
|
|
1503
1507
|
return None
|
|
1504
|
-
from brindle import agents
|
|
1508
|
+
from brindle import agents, login_pause
|
|
1505
1509
|
|
|
1510
|
+
if login_pause.paused(db, agent.id):
|
|
1511
|
+
return None # only the person can renew the login; a nudge would just fail
|
|
1506
1512
|
if ap.state == "auditing":
|
|
1507
1513
|
if ap.auditor_id:
|
|
1508
1514
|
auditor = db.get_agent(ap.auditor_id)
|
|
@@ -2608,14 +2608,20 @@ def after_launch_cmd(agent_id: str, token: Optional[str] = typer.Option(None)) -
|
|
|
2608
2608
|
return
|
|
2609
2609
|
a = db.get_agent(agent_id)
|
|
2610
2610
|
if a and a.tmux_window and a.status not in ("paused", "done") and a.result is None:
|
|
2611
|
+
from brindle import prompt_answer
|
|
2612
|
+
|
|
2613
|
+
prompt_answer.bind(db, a)
|
|
2611
2614
|
accepted = get_provider(a.provider).after_launch(a.tmux_window)
|
|
2612
2615
|
if not launch_health.current(db, agent_id, token):
|
|
2613
2616
|
return
|
|
2614
2617
|
if accepted:
|
|
2618
|
+
prompt_answer.settled(db, a)
|
|
2615
2619
|
agents.ready(db, agent_id)
|
|
2616
2620
|
# Carry the observed launch token into the delivery CAS.
|
|
2617
2621
|
a._launch_health_token = token
|
|
2618
2622
|
launch_health.accepted(db, a)
|
|
2623
|
+
else:
|
|
2624
|
+
prompt_answer.report_blocked(db, a)
|
|
2619
2625
|
launch_health.inspect(db, agent_id, wait=False, token=token)
|
|
2620
2626
|
|
|
2621
2627
|
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Cloud login state, read without calling the model.
|
|
2
|
+
|
|
3
|
+
``status(provider)`` says whether the Bedrock, Vertex or Foundry login is still
|
|
4
|
+
good, when it lapses, and the one command that renews it. It reads the CLIs'
|
|
5
|
+
own credential files (AWS SSO cache, Azure CLI token cache) and, for Google,
|
|
6
|
+
whose application-default credentials hold only a refresh token, asks gcloud
|
|
7
|
+
for an access token. Token values are never logged or returned.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import configparser
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
import time
|
|
18
|
+
import urllib.parse
|
|
19
|
+
import urllib.request
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Mapping
|
|
24
|
+
|
|
25
|
+
from brindle import model_access
|
|
26
|
+
|
|
27
|
+
CLI_TIMEOUT = 15.0
|
|
28
|
+
|
|
29
|
+
_FLAGS = {"bedrock": "CLAUDE_CODE_USE_BEDROCK", "vertex": "CLAUDE_CODE_USE_VERTEX",
|
|
30
|
+
"foundry": "CLAUDE_CODE_USE_FOUNDRY"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class LoginStatus:
|
|
35
|
+
ok: bool
|
|
36
|
+
expires_at: float | None
|
|
37
|
+
refresh_cmd: str
|
|
38
|
+
note: str
|
|
39
|
+
# The login lapsed and the CLI couldn't renew it: only ``refresh_cmd`` (the
|
|
40
|
+
# person) can. False for a login that is fine, missing or undetermined.
|
|
41
|
+
expired: bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def provider_from_env(env: Mapping[str, str] | None = None) -> str | None:
|
|
45
|
+
"""The cloud provider whose ``CLAUDE_CODE_USE_*`` flag is on, or None."""
|
|
46
|
+
env = os.environ if env is None else env
|
|
47
|
+
for provider, flag in _FLAGS.items():
|
|
48
|
+
if str(env.get(flag, "")).strip().lower() in ("1", "true", "yes", "on"):
|
|
49
|
+
return provider
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _epoch(value) -> float | None:
|
|
54
|
+
"""A timestamp as epoch seconds: a number, a numeric string or ISO 8601."""
|
|
55
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
56
|
+
return float(value)
|
|
57
|
+
if not isinstance(value, str) or not value.strip():
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
return float(value)
|
|
61
|
+
except ValueError:
|
|
62
|
+
pass
|
|
63
|
+
try:
|
|
64
|
+
dt = datetime.fromisoformat(value.strip().replace("UTC", "Z").replace("Z", "+00:00"))
|
|
65
|
+
except ValueError:
|
|
66
|
+
return None
|
|
67
|
+
if dt.tzinfo is None:
|
|
68
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
69
|
+
return dt.timestamp()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _json_files(paths) -> list:
|
|
73
|
+
out = []
|
|
74
|
+
for p in paths:
|
|
75
|
+
try:
|
|
76
|
+
out.append(json.loads(Path(p).read_text(encoding="utf-8")))
|
|
77
|
+
except (OSError, ValueError):
|
|
78
|
+
continue
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _from_expiry(expires_at: float | None, refresh_cmd: str, now: float, missing: str) -> LoginStatus:
|
|
83
|
+
if expires_at is None:
|
|
84
|
+
return LoginStatus(False, None, refresh_cmd, missing)
|
|
85
|
+
if expires_at <= now:
|
|
86
|
+
return LoginStatus(False, expires_at, refresh_cmd, "login expired", expired=True)
|
|
87
|
+
return LoginStatus(True, expires_at, refresh_cmd, "logged in")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _aws_token_key(profile: str, config_path: Path) -> str | None:
|
|
91
|
+
"""What the SSO cache file name is the SHA-1 of for ``profile``: its
|
|
92
|
+
sso_session name, or (legacy config) its sso_start_url; None when the
|
|
93
|
+
profile doesn't use SSO."""
|
|
94
|
+
cp = configparser.RawConfigParser()
|
|
95
|
+
try:
|
|
96
|
+
cp.read(config_path, encoding="utf-8")
|
|
97
|
+
except (OSError, configparser.Error):
|
|
98
|
+
return None
|
|
99
|
+
section = "default" if profile == "default" else f"profile {profile}"
|
|
100
|
+
if not cp.has_section(section):
|
|
101
|
+
return None
|
|
102
|
+
return cp.get(section, "sso_session", fallback=None) or cp.get(
|
|
103
|
+
section, "sso_start_url", fallback=None)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _aws(env: Mapping[str, str], home: Path, now: float) -> LoginStatus:
|
|
107
|
+
profile = env.get("AWS_PROFILE") or env.get("AWS_DEFAULT_PROFILE") or "default"
|
|
108
|
+
refresh = f"aws sso login --profile {profile}"
|
|
109
|
+
config = Path(env.get("AWS_CONFIG_FILE") or home / ".aws" / "config")
|
|
110
|
+
key = _aws_token_key(profile, config)
|
|
111
|
+
if key is None:
|
|
112
|
+
return LoginStatus(False, None, refresh, f"AWS profile {profile} is not an SSO profile")
|
|
113
|
+
token = home / ".aws" / "sso" / "cache" / (hashlib.sha1(key.strip().encode()).hexdigest() + ".json")
|
|
114
|
+
data = (_json_files([token]) or [None])[0]
|
|
115
|
+
expires = _epoch(data.get("expiresAt")) if isinstance(data, dict) and data.get("accessToken") else None
|
|
116
|
+
return _from_expiry(expires, refresh, now, "no AWS SSO login found for this profile")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _full_env(env: Mapping[str, str] | None) -> dict[str, str] | None:
|
|
120
|
+
"""The environment for a CLI call: the supplied one over the ambient one,
|
|
121
|
+
or None (inherit) when the caller supplied none."""
|
|
122
|
+
return None if env is None else {**os.environ, **env}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _azure_expiry(token: dict) -> float | None:
|
|
126
|
+
exp = _epoch(token.get("expires_on"))
|
|
127
|
+
if exp is not None:
|
|
128
|
+
return exp
|
|
129
|
+
try: # "expiresOn" is a naive local time
|
|
130
|
+
return datetime.fromisoformat(str(token.get("expiresOn")).strip()).timestamp()
|
|
131
|
+
except ValueError:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _azure(run, env, now: float) -> LoginStatus:
|
|
136
|
+
refresh = "az login"
|
|
137
|
+
try:
|
|
138
|
+
proc = run(["az", "account", "get-access-token", "--output", "json"],
|
|
139
|
+
capture_output=True, text=True, timeout=CLI_TIMEOUT, env=_full_env(env))
|
|
140
|
+
except subprocess.TimeoutExpired:
|
|
141
|
+
return LoginStatus(False, None, refresh, "az timed out fetching an access token")
|
|
142
|
+
except OSError:
|
|
143
|
+
return LoginStatus(False, None, refresh, "az is not installed")
|
|
144
|
+
if proc.returncode == 0:
|
|
145
|
+
try:
|
|
146
|
+
token = json.loads(proc.stdout or "")
|
|
147
|
+
except ValueError:
|
|
148
|
+
token = None
|
|
149
|
+
exp = _azure_expiry(token) if isinstance(token, dict) else None
|
|
150
|
+
if exp is not None:
|
|
151
|
+
return _from_expiry(exp, refresh, now, "")
|
|
152
|
+
if model_access.expired_login(f"{proc.stdout or ''}\n{proc.stderr or ''}") is not None:
|
|
153
|
+
return LoginStatus(False, None, refresh, "login expired", expired=True)
|
|
154
|
+
return LoginStatus(False, None, refresh, "no Azure CLI login found")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def tokeninfo_expiry(token: str, timeout: float = CLI_TIMEOUT) -> float | None:
|
|
158
|
+
"""Seconds the Google access ``token`` has left, from Google's tokeninfo
|
|
159
|
+
endpoint (the token goes in the POST body, never a URL), or None."""
|
|
160
|
+
req = urllib.request.Request("https://oauth2.googleapis.com/tokeninfo",
|
|
161
|
+
data=urllib.parse.urlencode({"access_token": token}).encode())
|
|
162
|
+
try:
|
|
163
|
+
with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 - fixed https URL
|
|
164
|
+
return float(json.load(r)["expires_in"])
|
|
165
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _google(run, env, now: float, expiry) -> LoginStatus:
|
|
170
|
+
refresh = "gcloud auth application-default login"
|
|
171
|
+
try:
|
|
172
|
+
proc = run(["gcloud", "auth", "application-default", "print-access-token"],
|
|
173
|
+
capture_output=True, text=True, timeout=CLI_TIMEOUT, env=_full_env(env))
|
|
174
|
+
except subprocess.TimeoutExpired:
|
|
175
|
+
return LoginStatus(False, None, refresh, "gcloud timed out refreshing the access token")
|
|
176
|
+
except OSError:
|
|
177
|
+
return LoginStatus(False, None, refresh, "gcloud is not installed")
|
|
178
|
+
token = (proc.stdout or "").strip()
|
|
179
|
+
if proc.returncode == 0 and token:
|
|
180
|
+
left = expiry(token)
|
|
181
|
+
if left is None:
|
|
182
|
+
return LoginStatus(True, None, refresh, "logged in; token expiry unknown")
|
|
183
|
+
return LoginStatus(True, now + left, refresh, "logged in")
|
|
184
|
+
if model_access.expired_login(f"{proc.stdout or ''}\n{proc.stderr or ''}") is not None:
|
|
185
|
+
return LoginStatus(False, None, refresh, "login expired (invalid_grant)", expired=True)
|
|
186
|
+
return LoginStatus(False, None, refresh, "gcloud could not produce an access token")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def status(provider: str | None = None, *, env: Mapping[str, str] | None = None,
|
|
190
|
+
home: str | Path | None = None, now: float | None = None,
|
|
191
|
+
run=subprocess.run, expiry=tokeninfo_expiry) -> LoginStatus:
|
|
192
|
+
"""The login state for ``provider`` (bedrock, vertex or foundry; by default
|
|
193
|
+
the one the environment's ``CLAUDE_CODE_USE_*`` flag selects)."""
|
|
194
|
+
supplied = env
|
|
195
|
+
env = os.environ if env is None else {**os.environ, **env}
|
|
196
|
+
provider = provider or provider_from_env(env)
|
|
197
|
+
home = Path(home) if home is not None else Path.home()
|
|
198
|
+
now = time.time() if now is None else now
|
|
199
|
+
if provider == "bedrock":
|
|
200
|
+
return _aws(env, home, now)
|
|
201
|
+
if provider == "vertex":
|
|
202
|
+
return _google(run, supplied, now, expiry)
|
|
203
|
+
if provider == "foundry":
|
|
204
|
+
return _azure(run, supplied, now)
|
|
205
|
+
return LoginStatus(True, None, "", "no cloud provider selected")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def refresh(provider: str | None = None, *, env: Mapping[str, str] | None = None,
|
|
209
|
+
home: str | Path | None = None, now: float | None = None,
|
|
210
|
+
run=subprocess.run, expiry=tokeninfo_expiry) -> LoginStatus:
|
|
211
|
+
"""Renew the login without the person where the CLI can, and return the
|
|
212
|
+
status after. Asking gcloud or az for an access token (as ``status`` does)
|
|
213
|
+
renews it from the stored refresh token; the AWS CLI renews an SSO token
|
|
214
|
+
from its sso-session refresh token on any call. What needs a browser
|
|
215
|
+
(invalid_rapt, a lapsed SSO session) stays ``expired``."""
|
|
216
|
+
full = os.environ if env is None else {**os.environ, **env}
|
|
217
|
+
provider = provider or provider_from_env(full)
|
|
218
|
+
if provider == "bedrock":
|
|
219
|
+
profile = full.get("AWS_PROFILE") or full.get("AWS_DEFAULT_PROFILE") or "default"
|
|
220
|
+
try:
|
|
221
|
+
run(["aws", "sts", "get-caller-identity", "--profile", profile, "--output", "json"],
|
|
222
|
+
capture_output=True, text=True, timeout=CLI_TIMEOUT, env=_full_env(env))
|
|
223
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
224
|
+
pass # the cache then still says what the login is
|
|
225
|
+
return status(provider, env=env, home=home, now=now, run=run, expiry=expiry)
|