brindle 0.3.12__tar.gz → 0.3.13__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 (133) hide show
  1. {brindle-0.3.12 → brindle-0.3.13}/PKG-INFO +2 -2
  2. {brindle-0.3.12 → brindle-0.3.13}/README.md +1 -1
  3. {brindle-0.3.12 → brindle-0.3.13}/pyproject.toml +1 -1
  4. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/agents.py +123 -45
  5. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/antigravity.py +54 -8
  6. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/autopilot.py +3 -0
  7. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/company_login.py +19 -2
  8. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/cull.py +58 -6
  9. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/db.py +24 -2
  10. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/doctor.py +29 -0
  11. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/launch_health.py +8 -3
  12. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/native/permissions.py +113 -14
  13. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/permissions.py +70 -12
  14. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pipeline.py +46 -0
  15. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/settings_sync.py +5 -4
  16. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/providers.py +24 -2
  17. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/workspaces.py +4 -3
  18. {brindle-0.3.12 → brindle-0.3.13}/.gitignore +0 -0
  19. {brindle-0.3.12 → brindle-0.3.13}/LICENSE +0 -0
  20. {brindle-0.3.12 → brindle-0.3.13}/SCHEDULE-A +0 -0
  21. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/__init__.py +0 -0
  22. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/__main__.py +0 -0
  23. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/account.py +0 -0
  24. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/airgap.py +0 -0
  25. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/budget.py +0 -0
  26. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/__init__.py +0 -0
  27. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/backend-auditor.md +0 -0
  28. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/developer-codex.md +0 -0
  29. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/developer-fable.md +0 -0
  30. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/developer-gemini.md +0 -0
  31. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/developer-heavy.md +0 -0
  32. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/developer-light.md +0 -0
  33. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/developer-local.md +0 -0
  34. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/developer.md +0 -0
  35. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/qa.md +0 -0
  36. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
  37. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/reviewer-gemini.md +0 -0
  38. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/reviewer-local.md +0 -0
  39. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/reviewer.md +0 -0
  40. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/subagent.md +0 -0
  41. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/supervisor.md +0 -0
  42. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_rules/__init__.py +0 -0
  43. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_rules/security/backend.md +0 -0
  44. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
  45. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_rules/tests/only.md +0 -0
  46. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/ci_adapters.py +0 -0
  47. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/ci_client.py +0 -0
  48. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/ci_federation.py +0 -0
  49. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/ci_hosts.py +0 -0
  50. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/cli.py +0 -0
  51. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/cloud_credential.py +0 -0
  52. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/codemap.py +0 -0
  53. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/codemap_docs.py +0 -0
  54. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/codex_hook.py +0 -0
  55. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/company_identity.py +0 -0
  56. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/config.py +0 -0
  57. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/conflicts.py +0 -0
  58. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/cost.py +0 -0
  59. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/cost_estimate.py +0 -0
  60. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/demo.py +0 -0
  61. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/detect.py +0 -0
  62. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/escapes.py +0 -0
  63. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/events.py +0 -0
  64. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/gates.py +0 -0
  65. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/git.py +0 -0
  66. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/guardrails.py +0 -0
  67. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/history.py +0 -0
  68. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/identity_lock.py +0 -0
  69. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/inbox.py +0 -0
  70. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/keys_cmds.py +0 -0
  71. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/keystore.py +0 -0
  72. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/learned_rules.py +0 -0
  73. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/learning.py +0 -0
  74. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/legacy.py +0 -0
  75. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/mcp_server.py +0 -0
  76. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/model_access.py +0 -0
  77. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/model_availability.py +0 -0
  78. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/native/__init__.py +0 -0
  79. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/native/client.py +0 -0
  80. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/native/loop.py +0 -0
  81. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/native/runner.py +0 -0
  82. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/native/serve.py +0 -0
  83. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/native/tools.py +0 -0
  84. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pane_auth.py +0 -0
  85. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/plugins.py +0 -0
  86. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/policy.py +0 -0
  87. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pool.py +0 -0
  88. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pricing.py +0 -0
  89. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/__init__.py +0 -0
  90. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/_files.py +0 -0
  91. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/account.py +0 -0
  92. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/audit_chain.py +0 -0
  93. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/audit_export.py +0 -0
  94. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/auth.py +0 -0
  95. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/brief_learning.py +0 -0
  96. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/cost_centers.py +0 -0
  97. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/credentials.py +0 -0
  98. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/features.py +0 -0
  99. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/keys.py +0 -0
  100. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/learning.py +0 -0
  101. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/license.py +0 -0
  102. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/loopback.py +0 -0
  103. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/managed_models.py +0 -0
  104. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/org_profiles.py +0 -0
  105. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/orgkey.py +0 -0
  106. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/rollout.py +0 -0
  107. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/route_health.py +0 -0
  108. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/status.py +0 -0
  109. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/team_events.py +0 -0
  110. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/team_policy.py +0 -0
  111. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/procs.py +0 -0
  112. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/profiles.py +0 -0
  113. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/quota.py +0 -0
  114. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/repo_cmds.py +0 -0
  115. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/repos.py +0 -0
  116. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/rewind.py +0 -0
  117. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/rule_checks.py +0 -0
  118. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/savings.py +0 -0
  119. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/scratch.py +0 -0
  120. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/secrets.py +0 -0
  121. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/self_serve.py +0 -0
  122. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/services.py +0 -0
  123. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/sessions.py +0 -0
  124. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/sidebar_follow.py +0 -0
  125. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/signin_pause.py +0 -0
  126. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/status_cache.py +0 -0
  127. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/tasks.py +0 -0
  128. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/tmux.py +0 -0
  129. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/updates.py +0 -0
  130. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/usage.py +0 -0
  131. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/view.py +0 -0
  132. {brindle-0.3.12 → brindle-0.3.13}/src/brindle/watch.py +0 -0
  133. {brindle-0.3.12 → brindle-0.3.13}/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.12
3
+ Version: 0.3.13
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.12`). A session started before an upgrade keeps
97
+ session shows it too (`brindle 0.3.13`). 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.12`). A session started before an upgrade keeps
66
+ session shows it too (`brindle 0.3.13`). 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.12"
3
+ version = "0.3.13"
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"
@@ -817,14 +817,12 @@ def _launch(db: DB, agent: Agent, ws: Workspace, *, prompt: str | None,
817
817
  _launch_headless(db, agent, ws, prompt=prompt, resume=resume, watch_pane=watch_pane, m=m)
818
818
  return
819
819
  if provider.prompt_after_ready:
820
- # Workers receive their instructions and real task in one delivery.
821
- # A fresh interactive chat without a task still needs its profile.
822
- if provider.name == "antigravity" and not resume and (prompt or agent.mode == "interactive"):
823
- from brindle import antigravity
824
- instructions = "\n\n".join(filter(None, (antigravity.TOOLS_NOTE, profile.prompt)))
825
- prompt = "\n\n".join(filter(None, (antigravity.format_delivered_message(instructions), prompt)))
826
- if prompt:
827
- db.enqueue(agent.id, prompt, None)
820
+ # Typed in once it's ready, after a warm-up carrying its instructions:
821
+ # agy connects its MCP servers only once a conversation has started,
822
+ # so the task must arrive as a second turn (see brindle.antigravity).
823
+ for text in ((provider.warmup(profile) if not resume else None), prompt):
824
+ if text:
825
+ db.enqueue(agent.id, text, None)
828
826
  prompt = None
829
827
  # Recorded before launching: the agent's hooks may fire within milliseconds.
830
828
  status = "processing" if prompt else "starting"
@@ -2025,12 +2023,6 @@ def default_review_profile(cfg: RepoConfig, worker: Agent | None, db: DB | None
2025
2023
  return cfg.review_profile
2026
2024
  if cfg.reviewer and cfg.reviewer != "reviewer":
2027
2025
  return cfg.reviewer
2028
- if unusable("antigravity") is None:
2029
- try:
2030
- load_profile("reviewer-gemini", repo_root)
2031
- return "reviewer-gemini"
2032
- except Exception:
2033
- pass
2034
2026
  if db is None or not repo_root:
2035
2027
  return cfg.reviewer
2036
2028
  from brindle import learning
@@ -2079,6 +2071,15 @@ def _review_kill_switch(ws: Workspace) -> None:
2079
2071
  raise AgentError(f"No reviewer started: {why_not}")
2080
2072
 
2081
2073
 
2074
+ def _review_worktree_present(ws: Workspace) -> None:
2075
+ """Raises ``AgentError`` when ``ws``'s worktree is gone from disk (removed
2076
+ outside brindle, so its record outlived it): every git call on the branch
2077
+ would fail there, and nothing is left to review."""
2078
+ if not os.path.isdir(ws.path):
2079
+ raise AgentError(f"No reviewer started: {ws.id}'s worktree {ws.path} no longer exists. "
2080
+ f"remove_workspace {ws.id} clears its record.")
2081
+
2082
+
2082
2083
  def _review_profile(db: DB, ws: Workspace, profile: str | None, cfg: RepoConfig) -> str:
2083
2084
  """The reviewer profile to use, checked to be one that can run: raises
2084
2085
  ``AgentError`` if it (explicit or picked) doesn't exist, or uses the codex
@@ -2144,6 +2145,7 @@ def start_review(db: DB, caller: Agent | None, ws: Workspace, profile: str | Non
2144
2145
  from brindle.config import load_repo_config
2145
2146
  from brindle.providers import brindle_invocation
2146
2147
 
2148
+ _review_worktree_present(ws)
2147
2149
  cfg = cfg or load_repo_config(ws.repo_root)
2148
2150
  if not cfg.checks:
2149
2151
  return request_review(db, caller, ws, profile, focus, cfg)
@@ -2222,12 +2224,15 @@ def request_review(db: DB, caller: Agent | None, ws: Workspace, profile: str | N
2222
2224
  from brindle import gates
2223
2225
  from brindle.config import load_repo_config
2224
2226
 
2227
+ _review_worktree_present(ws)
2225
2228
  _review_kill_switch(ws)
2226
2229
  cfg = cfg or load_repo_config(ws.repo_root)
2227
2230
  sha = gates.head(ws)
2228
2231
  for old in db.list_agents(ws.id):
2229
2232
  if old.mode != "review" or old.result is not None or not is_alive(old):
2230
2233
  continue
2234
+ if old.unreported_noted:
2235
+ continue # it stopped without a verdict: not the reviewer of anything
2231
2236
  if old.review_sha == sha:
2232
2237
  if profile and old.profile != profile:
2233
2238
  continue # another profile's review of this commit (a second review): keep both
@@ -2467,13 +2472,49 @@ def permission_request_decision(db: DB, agent: Agent, payload: dict,
2467
2472
  if not reqs:
2468
2473
  return None
2469
2474
  rules = [*permissions.all_rules(ws.repo_root), *profile_rules]
2475
+ from dataclasses import replace
2476
+
2477
+ from brindle.native.permissions import Permissions, split_commands, uncovered_part
2470
2478
  from brindle.profiles import load_profile
2471
2479
 
2472
2480
  try:
2473
- rules += permissions.profile_bash_allows(load_profile(agent.profile, ws.repo_root))
2481
+ profile = load_profile(agent.profile, ws.repo_root)
2474
2482
  except KeyError:
2475
- pass # deleted profile: no extra allowances
2483
+ profile = None # deleted profile: no extra allowances
2484
+ if profile is not None:
2485
+ rules += permissions.profile_bash_allows(profile)
2476
2486
  decision = permissions.decide_all(reqs, checks=cfg.checks, rules=rules)
2487
+ if decision.decision == "ask" and len(reqs) == 1:
2488
+ # A deny on one part of a compound command (cd x && <denied>)
2489
+ # wins before any part-by-part allow below.
2490
+ decision = permissions.deny_in_parts(reqs[0], checks=cfg.checks, rules=rules) or decision
2491
+ if decision.decision == "ask" and profile is not None:
2492
+ perms = Permissions(profile.permission_mode, profile.allowed_tools)
2493
+ command = reqs[0].command or ""
2494
+ specs = [spec for name, spec in perms.rules
2495
+ if name == "Bash" and spec not in (None, "", "*")]
2496
+ cwd = os.path.realpath(reqs[0].cwd or ws.path)
2497
+ root = os.path.realpath(ws.path)
2498
+ # Like pre_tool_decision: a compound command whose every part
2499
+ # allowed_tools covers (a cd within the worktree counts).
2500
+ if (len(reqs) == 1 and reqs[0].kind == "bash" and specs
2501
+ and (cwd == root or cwd.startswith(root + os.sep))
2502
+ and uncovered_part(specs, command, cd_root=ws.path) is None):
2503
+ decision = permissions.Decision(
2504
+ "allow", "every part of the command matches the profile's allowed_tools")
2505
+ # Deny each part as the allow just read it (quotes and a
2506
+ # harmless redirection gone), which deny_in_parts doesn't.
2507
+ for part in split_commands(command):
2508
+ denied = permissions.decide(replace(reqs[0], command=part), checks=cfg.checks, rules=rules)
2509
+ if denied.decision == "deny":
2510
+ decision = denied
2511
+ break
2512
+ elif perms.mode == "dontAsk":
2513
+ # Nobody watches this pane: a prompt would stall the agent.
2514
+ decision = permissions.Decision(
2515
+ "deny", f"{decision.reason.removesuffix('; asking the user')}, and this profile "
2516
+ "never asks (permission_mode dontAsk). Don't retry it: do without it, or ask "
2517
+ "your supervisor with the send_message tool (brindle MCP server)")
2477
2518
  summary = reqs[0].summary() if len(reqs) == 1 else (
2478
2519
  f"{reqs[0].tool}: " + ", ".join(r.path or "?" for r in reqs))[:200]
2479
2520
  _record_permission(db, agent, ws, summary, f"{decision.decision}: {decision.reason}")
@@ -2532,24 +2573,26 @@ def _stop_decision(db: DB, agent: Agent, payload: dict, via_inbox: bool = False)
2532
2573
  """What the Stop hook decides: a "block" with the reason to carry on, or
2533
2574
  None to let the agent stop. ``via_inbox``: the reason will be sent as an
2534
2575
  inbox message rather than block the stop, so ``stop_hook_active`` never
2535
- caps a repeat; the unread-mail reminder is then limited like a notice."""
2576
+ caps a repeat; the unread-mail reminder is then limited to one per
2577
+ NOTICE_TTL (DB.claim_stop_reminder)."""
2536
2578
  msg = db.pop_pending(agent.id)
2537
2579
  if msg:
2538
2580
  db.set_status(agent.id, "processing")
2539
2581
  return {"decision": "block", "reason": msg.body}
2540
- if agent.mode == "interactive" and db.unread_count(agent.id):
2582
+ if (agent.mode == "interactive" and db.unread_count(agent.id)
2583
+ and not payload.get("stop_hook_active")):
2584
+ # Stopping with mail unread, even moments after its notice went out:
2585
+ # that notice may have been lost, and nothing else will wake it.
2541
2586
  ap = db.get_autopilot(agent.id)
2542
- if (ap is not None and ap.enabled and not payload.get("stop_hook_active")
2543
- and (not via_inbox or db.claim_notice(agent.id))):
2544
- # An autopilot supervisor doesn't stop with mail it hasn't read.
2587
+ pilot = ap is not None and ap.enabled
2588
+ # An autopilot supervisor that would really be blocked doesn't stop
2589
+ # with mail it hasn't read; stop_hook_active caps that.
2590
+ claimed = db.unread(agent.id) if pilot and not via_inbox else db.claim_stop_reminder(agent.id)
2591
+ if claimed:
2545
2592
  db.set_status(agent.id, "processing")
2546
2593
  return {"decision": "block", "reason": (
2547
2594
  "[brindle autopilot] You have unread messages. Call read_messages "
2548
- "before you stop.")}
2549
- claimed = None if payload.get("stop_hook_active") else db.claim_notice(agent.id)
2550
- if claimed: # the notice never went out (or was lost): hand it over now
2551
- db.set_status(agent.id, "processing")
2552
- return {"decision": "block", "reason": unread_notice(db, claimed)}
2595
+ "before you stop." if pilot else unread_notice(db, claimed))}
2553
2596
  needs_report = agent.mode in REPORTING_MODES and agent.result is None
2554
2597
  if needs_report and not payload.get("stop_hook_active"):
2555
2598
  db.set_status(agent.id, "processing")
@@ -2607,6 +2650,11 @@ def handle_hook(db: DB, agent_id: str, event: str, payload: dict) -> dict | None
2607
2650
  if transcript and agent.provider == "claude" and transcript != agent.transcript_path:
2608
2651
  db.update_agent(agent_id, transcript_path=str(transcript))
2609
2652
 
2653
+ if event in ("prompt-submit", "pre-tool", "tool-done") and agent.unreported_noted:
2654
+ # 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)
2657
+
2610
2658
  if event == "session-start":
2611
2659
  from brindle import inbox
2612
2660
 
@@ -2711,6 +2759,13 @@ def handle_hook(db: DB, agent_id: str, event: str, payload: dict) -> dict | None
2711
2759
  decision = pilot.on_stop(db, agent, payload)
2712
2760
  if decision:
2713
2761
  db.enqueue(agent_id, decision["reason"], None)
2762
+ if (agent.mode in REPORTING_MODES and agent.result is None
2763
+ and not db.pending_count(agent_id)):
2764
+ # No Stop hook can remind it to report, and no hook says when
2765
+ # it resumes: every turn it ends without a result is a stop
2766
+ # its supervisor hears about.
2767
+ db.update_agent(agent_id, unreported_noted=None)
2768
+ tell_parent_unreported(db, db.get_agent(agent_id) or agent)
2714
2769
  if db.pending_count(agent_id) and not agent.headless:
2715
2770
  flush(db, agent_id)
2716
2771
  elif event == "stop-failure":
@@ -2738,17 +2793,32 @@ def handle_hook(db: DB, agent_id: str, event: str, payload: dict) -> dict | None
2738
2793
  "oauth_org_not_allowed", "billing_error"
2739
2794
  ))
2740
2795
  )
2741
- if is_auth:
2796
+ # Claude Code's line for a --model its backend doesn't serve: every
2797
+ # turn fails the same way, so the agent can't go on (launch_health
2798
+ # reads the same line on screen).
2799
+ from brindle import launch_health
2800
+
2801
+ is_model = not is_rate_limit and not is_auth and bool(
2802
+ re.search(launch_health.SELECTED_MODEL, err_fields))
2803
+ if is_auth or is_model:
2742
2804
  db.set_status(agent_id, "failed")
2743
2805
  db.clear_permission_requests(agent_id)
2744
2806
  from brindle import providers, autopilot as pilot
2745
- providers.invalidate_signed_in(agent.provider)
2746
- err_desc = payload.get("error_details") or payload.get("error") or payload.get("error_type") or "401/403"
2747
- msg = (
2748
- f"[brindle] Agent {agent_id} encountered an authentication failure "
2749
- f"(invalid or expired credentials): {err_desc}. "
2750
- "Check API keys or login status."
2751
- )
2807
+ if is_auth:
2808
+ providers.invalidate_signed_in(agent.provider)
2809
+ err_desc = payload.get("error_details") or payload.get("error") or payload.get("error_type") or "401/403"
2810
+ msg = (
2811
+ f"[brindle] Agent {agent_id} encountered an authentication failure "
2812
+ f"(invalid or expired credentials): {err_desc}. "
2813
+ "Check API keys or login status."
2814
+ )
2815
+ else:
2816
+ err_desc = "its model isn't available on this route"
2817
+ msg = (
2818
+ f"[brindle] Agent {agent_id} failed: Claude Code says there's an issue with "
2819
+ "the selected model (it may not exist, or the account or route may lack access). "
2820
+ "Check the profile's model and the Claude route (`brindle doctor`), then retry."
2821
+ )
2752
2822
  if agent.parent_id:
2753
2823
  try:
2754
2824
  send_message(db, agent.parent_id, msg, sender_id="brindle")
@@ -2759,7 +2829,8 @@ def handle_hook(db: DB, agent_id: str, event: str, payload: dict) -> dict | None
2759
2829
  pass
2760
2830
  root_id = pilot.root_of(db, agent_id)
2761
2831
  if db.get_autopilot(root_id):
2762
- pilot.need_user(db, root_id, f"Credentials need attention for agent {agent_id}: {err_desc}")
2832
+ what = "Credentials need" if is_auth else "The model needs"
2833
+ pilot.need_user(db, root_id, f"{what} attention for agent {agent_id}: {err_desc}")
2763
2834
  else:
2764
2835
  db.set_status(agent_id, "idle")
2765
2836
  db.clear_permission_requests(agent_id)
@@ -2834,30 +2905,36 @@ def command_running(db: DB, agent: Agent) -> bool:
2834
2905
  return False
2835
2906
 
2836
2907
 
2837
- def tell_parent_unreported(db: DB, agent: Agent) -> None:
2908
+ def tell_parent_unreported(db: DB, agent: Agent) -> bool:
2838
2909
  """A worker stopped again after being reminded to report, still without a
2839
2910
  result. It won't be reminded again on its own, and its supervisor has
2840
2911
  usually gone idle waiting for it with nothing left to wake it, so tell the
2841
- supervisor (queued if it's busy, delivered now if it's idle)."""
2912
+ supervisor (queued if it's busy, delivered now if it's idle). Returns
2913
+ whether the supervisor was told."""
2842
2914
  if agent.mode not in FORWARDING_MODES or not agent.parent_id:
2843
- return # a synchronous handoff's caller is still waiting on it
2915
+ return False # a synchronous handoff's caller is still waiting on it
2844
2916
  if db.get_agent(agent.parent_id) is None:
2845
- return
2917
+ return False
2846
2918
  ws = db.get_workspace(agent.workspace_id)
2847
- if agent.mode == "review" and ws is not None:
2919
+ if agent.mode == "review" and ws is not None and os.path.isdir(ws.path):
2848
2920
  from brindle import gates
2849
2921
 
2850
2922
  if db.latest_review(ws.id, gates.head(ws)):
2851
- return # its verdict for the current commit is already recorded
2923
+ return False # its verdict for the current commit is already recorded
2852
2924
  if agent.unreported_noted:
2853
- return # the supervisor was told once; no repeats
2925
+ return False # the supervisor was told about this stop; cleared once it resumes
2854
2926
  if command_running(db, agent):
2855
2927
  # Its turn ended while a long or background command (a test run) is
2856
2928
  # still going: it isn't stopped, and the command finishing wakes it.
2857
2929
  # Not marked noted, so a real stop later still gets its one alert.
2858
- return
2930
+ return False
2859
2931
  db.update_agent(agent.id, unreported_noted=1)
2860
- where = f" (branch `{ws.branch}`, workspace {ws.id})" if ws else ""
2932
+ if agent.mode == "review" and ws is not None:
2933
+ from brindle import pipeline
2934
+
2935
+ if pipeline.on_reviewer_unreported(db, get(db, agent.id), ws):
2936
+ return True # a fallback reviewer took over, or the supervisor was told
2937
+ where =f" (branch `{ws.branch}`, workspace {ws.id})" if ws else ""
2861
2938
  tool = "submit_review" if agent.mode == "review" else "report_result"
2862
2939
  who = "Reviewer" if agent.mode == "review" else "Worker"
2863
2940
  body = (
@@ -2870,7 +2947,8 @@ def tell_parent_unreported(db: DB, agent: Agent) -> None:
2870
2947
  except (AgentError, tmux.TmuxError):
2871
2948
  # The parent isn't running (or can't take messages): the notice is
2872
2949
  # deliberately dropped here, since there's nothing left to tell.
2873
- pass
2950
+ return False
2951
+ return True
2874
2952
 
2875
2953
 
2876
2954
  PERMISSION_EVENTS = ("permission-request", "codex-permission-request")
@@ -23,9 +23,9 @@ brindle sends it after that is typed in as a new turn once it's idle.
23
23
  agy's hooks, mapped onto brindle's:
24
24
  - ``PreInvocation`` before each model call: working. ``invocationNum`` 0 means
25
25
  a new turn, typed by the user unless brindle just delivered a message.
26
- - ``Stop``: a queued message is typed in as a new turn; otherwise brindle's Stop
27
- hook (the report reminder, autopilot), where ``{"decision": "continue",
28
- "reason": ...}`` keeps it going.
26
+ - ``Stop``: a queued message is typed in as a new turn, and so is what
27
+ brindle's Stop hook would block with (the report reminder, autopilot): a
28
+ ``{"decision": "continue"}`` turn would have no MCP tools to act on it.
29
29
  - ``PostToolUse``: a tool ran, so a permission prompt was answered.
30
30
  - ``PreToolUse`` (only with ``permission_policy: "on"``): brindle's permission
31
31
  policy (brindle.permissions) answers deny, or what agy would do anyway (allow
@@ -275,10 +275,12 @@ def handle_hook(db, agent_id: str, event: str, payload: dict) -> dict | None:
275
275
 
276
276
  if event == "agy-pre-invocation":
277
277
  db.set_status(agent_id, "processing")
278
- if payload.get("invocationNum", 0) == 0:
279
- # A new turn: the user's, unless brindle just typed a message in.
278
+ if payload.get("invocationNum", 0) == 0 and not db.delivered_since(agent_id, time.time() - DELIVERY_WINDOW):
279
+ # A new turn the user typed. One brindle typed in (the report
280
+ # reminder among them) keeps stop_blocked: the stop path resets
281
+ # it itself before delivering anything else.
280
282
  db.update_agent(agent_id, stop_blocked=0)
281
- if agent.mode == "interactive" and not db.delivered_since(agent_id, time.time() - DELIVERY_WINDOW):
283
+ if agent.mode == "interactive":
282
284
  autopilot.user_spoke(db, agent)
283
285
  return None
284
286
  if event == "agy-post-tool":
@@ -298,6 +300,12 @@ def handle_hook(db, agent_id: str, event: str, payload: dict) -> dict | None:
298
300
  error_type = "rate_limit" if limited else ("auth" if autherr else "error")
299
301
  agents.handle_hook(db, agent_id, "stop-failure",
300
302
  {**base, "error_type": error_type, "error": error})
303
+ failed = db.get_agent(agent_id)
304
+ if failed is not None and failed.status != "failed": # not a sign-in problem
305
+ db.update_agent(agent_id, stop_blocked=0)
306
+ if db.pending_count(agent_id):
307
+ _flush_soon(agent_id)
308
+ _tell_parent_stopped_on_error(db, failed, error_type)
301
309
  return None
302
310
  if db.pending_count(agent_id):
303
311
  # A new turn, not a continuation: only then does agy give the
@@ -309,13 +317,41 @@ def handle_hook(db, agent_id: str, event: str, payload: dict) -> dict | None:
309
317
  out = agents.handle_hook(db, agent_id, "stop",
310
318
  {**base, "stop_hook_active": bool(agent.stop_blocked)})
311
319
  if out and out.get("decision") == "block":
320
+ # Typed in as a new turn too: a turn the Stop hook continues has
321
+ # no MCP tools, so the agent couldn't call report_result in it.
322
+ # stop_blocked stays set through that turn (see agy-pre-invocation),
323
+ # so the reminder is sent once.
312
324
  db.update_agent(agent_id, stop_blocked=1)
313
- return {"decision": "continue", "reason": out["reason"]}
325
+ db.enqueue(agent_id, out["reason"], None)
326
+ db.set_status(agent_id, "idle")
327
+ _flush_soon(agent_id)
328
+ return None
314
329
  db.update_agent(agent_id, stop_blocked=0)
315
330
  return None
316
331
  return None
317
332
 
318
333
 
334
+ def _tell_parent_stopped_on_error(db, agent, error_type: str) -> None:
335
+ """A worker's turn ended on a provider error (not sign-in, which
336
+ stop-failure reports itself): no Stop hook follows, so it gets no report
337
+ reminder and its supervisor would wait on it with nothing to wake it."""
338
+ from brindle import agents, tmux
339
+
340
+ if (agent.mode not in agents.FORWARDING_MODES or not agent.parent_id or agent.result is not None
341
+ or db.get_agent(agent.parent_id) is None):
342
+ return
343
+ queued = " Its queued messages are being typed in as a new turn." if db.pending_count(agent.id) else ""
344
+ tool = "submit_review" if agent.mode == "review" else "report_result"
345
+ # The error type only: provider output can carry URLs, keys or prompt text.
346
+ body = (f"{'Reviewer' if agent.mode == 'review' else 'Worker'} {agent.id}'s turn ended on a "
347
+ f"provider error ({error_type}) before it called {tool}.{queued} Check on it: "
348
+ "send_message to have it carry on, or remove_workspace if the work is abandoned.")
349
+ try:
350
+ agents.send_message(db, agent.parent_id, body)
351
+ except (agents.AgentError, tmux.TmuxError):
352
+ db.enqueue(agent.parent_id, body, None)
353
+
354
+
319
355
  def _flush_soon(agent_id: str, delay: float = 1.5) -> None:
320
356
  """Type the agent's next queued message once agy has finished stopping."""
321
357
  from brindle.providers import brindle_invocation
@@ -353,6 +389,10 @@ def pre_tool_decision(db, agent_id: str | None, payload: object, strict: bool =
353
389
  except KeyError:
354
390
  pass
355
391
  decision = permissions.decide(req, checks=cfg.checks, rules=rules)
392
+ if strict and decision.decision == "ask":
393
+ # A compound or quoted command whose every part is allowed, read as
394
+ # Claude Code's hook reads a profile's allowed_tools.
395
+ decision = permissions.allowlist_decision(req, rules, cfg.checks, cd_root=ws.path) or decision
356
396
  spaces = payload.get("workspacePaths")
357
397
  spaces = [s for s in spaces if isinstance(s, str) and s] if isinstance(spaces, list) else []
358
398
  if strict:
@@ -368,7 +408,13 @@ def pre_tool_decision(db, agent_id: str | None, payload: object, strict: bool =
368
408
  out = {"decision": "deny", "reason": f"brindle: {ambiguous}, so it can't be checked; "
369
409
  "use one absolute path inside your worktree"}
370
410
  else:
371
- out = output(decision, req, spaces, protected_roots=[ws.path, ws.repo_root])
411
+ # agy keeps what it tells the model about brindle's MCP server
412
+ # (its instructions and tools) and its built-in skills here:
413
+ # readable, never writable. Other servers' folders are not.
414
+ agy_home = Path.home() / ".gemini" / "antigravity-cli"
415
+ out = output(decision, req, spaces, protected_roots=[ws.path, ws.repo_root],
416
+ readable=[str(agy_home / "mcp" / "brindle"), str(agy_home / "builtin")],
417
+ has_supervisor=bool(agent.parent_id))
372
418
  else:
373
419
  out = output(decision, req, spaces)
374
420
  if req is not None and out["decision"] == "deny":
@@ -148,6 +148,9 @@ Acting on what you find:
148
148
  what you fixed and which findings weren't real. Don't ask first. Ask only
149
149
  before outward-facing or hard-to-reverse actions (posting, merging into a
150
150
  shared branch, deleting) and for decisions that are the user's to make.
151
+ - Once you've laid out a plan, start it in the same turn. Don't end with
152
+ "shall I start?" or wait for a "yes" or "go": if a step needs a decision
153
+ only the user can make, ask that question and carry on with the rest.
151
154
  - Format your own verbose outputs or message transcripts in collapsible
152
155
  `<details><summary>Title</summary>...</details>` blocks so they are not
153
156
  sprawling in the chat UI."""
@@ -472,7 +472,9 @@ def claude_model(model: str | None, repo_root: str | None = None) -> str | None:
472
472
  the profile's CLI flag, just as it does in the company login env.
473
473
  Launch and policy checks share this resolution without changing the
474
474
  saved profile. Claude Code's own managed settings pick their own route.
475
- An unavailable setup leaves the profile alone, as company login does.
475
+ An unavailable setup leaves the profile alone, as company login does,
476
+ and so does a launch the route's env doesn't reach (``route_in_use``):
477
+ a route model id on the person's own Anthropic sign-in doesn't exist.
476
478
  """
477
479
  from brindle import providers
478
480
 
@@ -482,11 +484,26 @@ def claude_model(model: str | None, repo_root: str | None = None) -> str | None:
482
484
  if got is None or got is UNAVAILABLE:
483
485
  return model
484
486
  c = got[1].claude
485
- if c is None or not c.model_ids or model in c.model_ids:
487
+ if c is None or not c.model_ids or model in c.model_ids or not route_in_use(got[0], got[1]):
486
488
  return model
487
489
  return c.default_model or c.model_ids[0]
488
490
 
489
491
 
492
+ def route_in_use(org_id: str, setup) -> bool:
493
+ """Whether a Claude pane gets this org's route env: an enforced setup's
494
+ (``company_identity.enforced_route_env``), or the env company login
495
+ stored for this org (``stored_env``, what ``agents.agent_env`` applies)."""
496
+ if setup.enforce:
497
+ return True
498
+ if not stored_env():
499
+ return False
500
+ try:
501
+ got = config.user_settings().get(ENV_KEY)
502
+ except (OSError, ValueError):
503
+ return False
504
+ return isinstance(got, dict) and got.get("org") == org_id
505
+
506
+
490
507
  def org_setup(repo_root: str | None = None, store=None, client=None):
491
508
  """(org id, AgentSetup) for the signed-in org; personal policies supply
492
509
  only federated Claude setup. None when there is no applicable setup;
@@ -186,6 +186,7 @@ def sweep(db: DB, now: float | None = None) -> list[str]:
186
186
 
187
187
  # 3. Workers stuck on a prompt nobody is answering, or gone silent.
188
188
  done.extend(note_stuck(db, now, panes))
189
+ done.extend(note_idle_unreported(db, now, panes))
189
190
  done.extend(note_silent(db, now, panes))
190
191
  done.extend(note_model_refused(db, now, panes))
191
192
 
@@ -251,6 +252,49 @@ def note_stuck(db: DB, now: float, panes: dict[str, bool]) -> list[str]:
251
252
  return done
252
253
 
253
254
 
255
+ # How long a worker may sit idle without having reported before its supervisor
256
+ # is told (once per idle spell).
257
+ IDLE_UNREPORTED_AFTER = 300.0
258
+
259
+
260
+ def note_idle_unreported(db: DB, now: float, panes: dict[str, bool]) -> list[str]:
261
+ """Tell each supervisor, once per idle spell, about a worker that has sat
262
+ idle for IDLE_UNREPORTED_AFTER seconds without reporting, goal or not
263
+ (autopilot's Stop hook only checks on workers when a goal is set). It's
264
+ a reminder: the notice its stop sent may have gone unread, or been lost.
265
+
266
+ A worker whose provider has no hook for a turn starting (Codex) stays
267
+ 'idle' from its first finished turn on, through any later turns, so it
268
+ also needs a pane that has printed nothing for that long, and the spell
269
+ starts at its last output."""
270
+ from brindle.providers import get_provider
271
+
272
+ done = []
273
+ owners = agents.pane_owners(db, panes)
274
+ for a in db.list_agents():
275
+ # Only these are ever told (a synchronous handoff's caller is waiting).
276
+ if (a.mode not in agents.FORWARDING_MODES or not a.parent_id or a.result is not None
277
+ or a.dismissed_at is not None or a.status != "idle"
278
+ or not agents.runs_process(a) or not agents.same_server(a, panes)
279
+ or not agents.is_alive(a, panes) or not agents.owns_pane(db, a, owners)):
280
+ continue
281
+ since = a.status_since or a.created_at
282
+ provider = get_provider(a.provider)
283
+ if not (provider.uses_hooks and provider.announces_turns):
284
+ last = tmux.window_activity(a.tmux_window, server=agents.server_of(a))
285
+ if last is None:
286
+ continue # no telling whether a later turn is running
287
+ since = max(since, last)
288
+ if now - since < IDLE_UNREPORTED_AFTER or a.stuck_noted == since:
289
+ continue
290
+ db.update_agent(a.id, unreported_noted=None)
291
+ # Not told while a command it started is still running; tried again then.
292
+ if agents.tell_parent_unreported(db, db.get_agent(a.id) or a):
293
+ db.update_agent(a.id, stuck_noted=since)
294
+ done.append(f"told {a.parent_id} that worker {a.id} is idle without reporting")
295
+ return done
296
+
297
+
254
298
  # How long a worker no hook reports on (Codex) may show nothing new before its
255
299
  # supervisor is told. Such a CLI can sit on a startup error forever: signed in
256
300
  # without a plan that includes it, say (issue #42).
@@ -259,17 +303,23 @@ SILENT_AFTER = 600.0
259
303
 
260
304
  def note_silent(db: DB, now: float, panes: dict[str, bool]) -> list[str]:
261
305
  """Tell each supervisor, once per silence, about a worker whose status
262
- no hook reports ('unknown') or that never got past 'starting', and whose
263
- pane hasn't printed anything for SILENT_AFTER seconds without a result."""
306
+ no hook reports ('unknown'), that never got past 'starting', or that is
307
+ mid-turn ('processing': a running turn's spinner redraws the pane), and
308
+ whose pane hasn't printed anything for SILENT_AFTER seconds without a
309
+ result."""
264
310
  done = []
265
311
  owners = agents.pane_owners(db, panes)
266
312
  for a in db.list_agents():
267
313
  if (a.mode not in agents.REPORTING_MODES or not a.parent_id or a.result is not None
268
- or a.dismissed_at is not None or a.status not in ("unknown", "starting")
314
+ or a.dismissed_at is not None
315
+ or a.status not in ("unknown", "starting", "processing")
269
316
  or not agents.runs_process(a) or not agents.same_server(a, panes)
270
317
  or not agents.is_alive(a, panes) or not agents.owns_pane(db, a, owners)):
271
318
  continue
272
- last = tmux.window_activity(a.tmux_window, server=agents.server_of(a)) or a.created_at
319
+ activity = tmux.window_activity(a.tmux_window, server=agents.server_of(a))
320
+ if a.status == "processing" and (activity is None or a.headless):
321
+ continue # no screen to go by, and its turn may well be running
322
+ last = activity or a.created_at
273
323
  if now - last < SILENT_AFTER or a.stuck_noted == last:
274
324
  continue
275
325
  ws = db.get_workspace(a.workspace_id)
@@ -280,9 +330,11 @@ def note_silent(db: DB, now: float, panes: dict[str, bool]) -> list[str]:
280
330
  tail = "\n".join([ln for ln in screen.rstrip().splitlines() if ln.strip()][-12:])
281
331
  where = f" on branch `{ws.branch}`" if ws else ""
282
332
  look = f"Look with `brindle attach {ws.name}`, or" if ws else "Look at its pane, or"
333
+ why = ("Its CLI may be hung mid-turn." if a.status == "processing" else
334
+ "Its CLI may be stuck at startup (for example signed in without a plan "
335
+ "that includes it).")
283
336
  body = (f"Worker {a.id} ({a.profile}, {a.provider}){where} has shown nothing new for "
284
- f"{int((now - last) // 60)} min and hasn't reported. Its CLI may be stuck at "
285
- f"startup (for example signed in without a plan that includes it). {look} "
337
+ f"{int((now - last) // 60)} min and hasn't reported. {why} {look} "
286
338
  "cancel it and assign the task to another profile."
287
339
  f"\n\nIts screen:\n{tail}")
288
340
  try: