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.
Files changed (137) hide show
  1. {brindle-0.3.14 → brindle-0.3.16}/PKG-INFO +2 -2
  2. {brindle-0.3.14 → brindle-0.3.16}/README.md +1 -1
  3. {brindle-0.3.14 → brindle-0.3.16}/pyproject.toml +1 -1
  4. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/agents.py +166 -17
  5. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/autopilot.py +7 -1
  6. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/cli.py +6 -0
  7. brindle-0.3.16/src/brindle/cloud_login.py +225 -0
  8. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/cull.py +54 -3
  9. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/db.py +56 -2
  10. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/launch_health.py +1 -0
  11. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/learning.py +73 -7
  12. brindle-0.3.16/src/brindle/login_pause.py +223 -0
  13. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/mcp_server.py +5 -2
  14. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/model_access.py +55 -4
  15. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pipeline.py +268 -23
  16. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/learning.py +16 -14
  17. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/procs.py +21 -5
  18. brindle-0.3.16/src/brindle/prompt_answer.py +447 -0
  19. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/providers.py +73 -4
  20. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/sessions.py +6 -2
  21. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/tmux.py +10 -0
  22. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/workspaces.py +6 -0
  23. {brindle-0.3.14 → brindle-0.3.16}/.gitignore +0 -0
  24. {brindle-0.3.14 → brindle-0.3.16}/LICENSE +0 -0
  25. {brindle-0.3.14 → brindle-0.3.16}/SCHEDULE-A +0 -0
  26. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/__init__.py +0 -0
  27. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/__main__.py +0 -0
  28. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/account.py +0 -0
  29. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/airgap.py +0 -0
  30. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/antigravity.py +0 -0
  31. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/budget.py +0 -0
  32. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/__init__.py +0 -0
  33. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/backend-auditor.md +0 -0
  34. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/developer-codex.md +0 -0
  35. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/developer-fable.md +0 -0
  36. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/developer-gemini.md +0 -0
  37. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/developer-heavy.md +0 -0
  38. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/developer-light.md +0 -0
  39. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/developer-local.md +0 -0
  40. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/developer.md +0 -0
  41. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/qa.md +0 -0
  42. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
  43. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/reviewer-gemini.md +0 -0
  44. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/reviewer-local.md +0 -0
  45. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/reviewer.md +0 -0
  46. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/subagent.md +0 -0
  47. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_agents/supervisor.md +0 -0
  48. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_rules/__init__.py +0 -0
  49. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_rules/security/backend.md +0 -0
  50. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
  51. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/builtin_rules/tests/only.md +0 -0
  52. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/ci_adapters.py +0 -0
  53. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/ci_client.py +0 -0
  54. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/ci_federation.py +0 -0
  55. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/ci_hosts.py +0 -0
  56. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/cloud_credential.py +0 -0
  57. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/codemap.py +0 -0
  58. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/codemap_docs.py +0 -0
  59. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/codex_hook.py +0 -0
  60. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/company_identity.py +0 -0
  61. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/company_login.py +0 -0
  62. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/config.py +0 -0
  63. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/conflicts.py +0 -0
  64. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/cost.py +0 -0
  65. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/cost_estimate.py +0 -0
  66. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/demo.py +0 -0
  67. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/detect.py +0 -0
  68. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/doctor.py +0 -0
  69. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/escapes.py +0 -0
  70. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/events.py +0 -0
  71. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/gates.py +0 -0
  72. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/git.py +0 -0
  73. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/guardrails.py +0 -0
  74. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/history.py +0 -0
  75. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/identity_lock.py +0 -0
  76. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/inbox.py +0 -0
  77. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/keys_cmds.py +0 -0
  78. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/keystore.py +0 -0
  79. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/learned_rules.py +0 -0
  80. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/legacy.py +0 -0
  81. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/model_availability.py +0 -0
  82. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/native/__init__.py +0 -0
  83. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/native/client.py +0 -0
  84. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/native/loop.py +0 -0
  85. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/native/permissions.py +0 -0
  86. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/native/runner.py +0 -0
  87. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/native/serve.py +0 -0
  88. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/native/tools.py +0 -0
  89. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pane_auth.py +0 -0
  90. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/permissions.py +0 -0
  91. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/plugins.py +0 -0
  92. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/policy.py +0 -0
  93. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pool.py +0 -0
  94. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pricing.py +0 -0
  95. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/__init__.py +0 -0
  96. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/_files.py +0 -0
  97. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/account.py +0 -0
  98. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/audit_chain.py +0 -0
  99. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/audit_export.py +0 -0
  100. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/auth.py +0 -0
  101. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/brief_learning.py +0 -0
  102. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/cost_centers.py +0 -0
  103. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/credentials.py +0 -0
  104. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/features.py +0 -0
  105. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/keys.py +0 -0
  106. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/license.py +0 -0
  107. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/loopback.py +0 -0
  108. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/managed_models.py +0 -0
  109. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/org_profiles.py +0 -0
  110. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/orgkey.py +0 -0
  111. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/rollout.py +0 -0
  112. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/route_health.py +0 -0
  113. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/settings_sync.py +0 -0
  114. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/status.py +0 -0
  115. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/team_events.py +0 -0
  116. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/pro/team_policy.py +0 -0
  117. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/profiles.py +0 -0
  118. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/quota.py +0 -0
  119. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/repo_cmds.py +0 -0
  120. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/repos.py +0 -0
  121. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/rewind.py +0 -0
  122. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/rule_checks.py +0 -0
  123. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/savings.py +0 -0
  124. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/scratch.py +0 -0
  125. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/secrets.py +0 -0
  126. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/self_serve.py +0 -0
  127. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/services.py +0 -0
  128. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/sidebar_follow.py +0 -0
  129. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/signin_pause.py +0 -0
  130. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/status_cache.py +0 -0
  131. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/tasks.py +0 -0
  132. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/updates.py +0 -0
  133. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/upgrade.py +0 -0
  134. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/usage.py +0 -0
  135. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/view.py +0 -0
  136. {brindle-0.3.14 → brindle-0.3.16}/src/brindle/watch.py +0 -0
  137. {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.14
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.14`). A session started before an upgrade keeps
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.14`). A session started before an upgrade keeps
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "brindle"
3
- version = "0.3.14"
3
+ version = "0.3.16"
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"
@@ -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
- accepted = provider.after_launch(target)
885
- if accepted:
886
- ready(db, agent.id)
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
- db.update_agent(agent_id, unreported_noted=None)
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
- db.update_agent(agent_id, unreported_noted=None)
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: it isn't stopped, and the command finishing wakes it.
2929
- # Not marked noted, so a real stop later still gets its one alert.
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=1)
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)