brindle 0.4.3__tar.gz → 0.4.5__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 (163) hide show
  1. {brindle-0.4.3 → brindle-0.4.5}/PKG-INFO +1 -1
  2. {brindle-0.4.3 → brindle-0.4.5}/pyproject.toml +1 -1
  3. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/agents.py +42 -0
  4. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/cli.py +1 -4
  5. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/codex_hook.py +11 -1
  6. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/sessions.py +16 -1
  7. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/sidebar_follow.py +55 -4
  8. {brindle-0.4.3 → brindle-0.4.5}/.gitignore +0 -0
  9. {brindle-0.4.3 → brindle-0.4.5}/LICENSE +0 -0
  10. {brindle-0.4.3 → brindle-0.4.5}/README.md +0 -0
  11. {brindle-0.4.3 → brindle-0.4.5}/SCHEDULE-A +0 -0
  12. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/__init__.py +0 -0
  13. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/__main__.py +0 -0
  14. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/account.py +0 -0
  15. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/airgap.py +0 -0
  16. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/antigravity.py +0 -0
  17. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/assembler.py +0 -0
  18. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/assets/braid_dark.png +0 -0
  19. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/assets/braid_light.png +0 -0
  20. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/autopilot.py +0 -0
  21. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/budget.py +0 -0
  22. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/builtin_agents/__init__.py +0 -0
  23. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/builtin_agents/backend-auditor.md +0 -0
  24. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/builtin_agents/coordinator.md +0 -0
  25. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/builtin_agents/developer-codex.md +0 -0
  26. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/builtin_agents/developer-fable.md +0 -0
  27. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/builtin_agents/developer-gemini.md +0 -0
  28. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/builtin_agents/developer-heavy.md +0 -0
  29. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/builtin_agents/developer-light.md +0 -0
  30. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/builtin_agents/developer-local.md +0 -0
  31. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/builtin_agents/developer-opencode.md +0 -0
  32. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/builtin_agents/developer-sonnet.md +0 -0
  33. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/builtin_agents/developer.md +0 -0
  34. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/builtin_agents/qa.md +0 -0
  35. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
  36. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/builtin_agents/reviewer-gemini.md +0 -0
  37. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/builtin_agents/reviewer-local.md +0 -0
  38. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/builtin_agents/reviewer-opencode.md +0 -0
  39. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/builtin_agents/reviewer.md +0 -0
  40. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/builtin_agents/subagent.md +0 -0
  41. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/builtin_agents/supervisor.md +0 -0
  42. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/builtin_rules/__init__.py +0 -0
  43. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/builtin_rules/security/backend.md +0 -0
  44. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
  45. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/builtin_rules/tests/only.md +0 -0
  46. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/ci_adapters.py +0 -0
  47. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/ci_client.py +0 -0
  48. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/ci_federation.py +0 -0
  49. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/ci_hosts.py +0 -0
  50. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/cloud_credential.py +0 -0
  51. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/cloud_login.py +0 -0
  52. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/codemap.py +0 -0
  53. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/codemap_docs.py +0 -0
  54. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/company_identity.py +0 -0
  55. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/company_login.py +0 -0
  56. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/config.py +0 -0
  57. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/conflicts.py +0 -0
  58. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/coordinator.py +0 -0
  59. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/cost.py +0 -0
  60. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/cost_estimate.py +0 -0
  61. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/cull.py +0 -0
  62. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/db.py +0 -0
  63. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/demo.py +0 -0
  64. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/detect.py +0 -0
  65. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/doctor.py +0 -0
  66. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/escapes.py +0 -0
  67. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/events.py +0 -0
  68. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/gates.py +0 -0
  69. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/git.py +0 -0
  70. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/guardrails.py +0 -0
  71. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/history.py +0 -0
  72. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/identity_lock.py +0 -0
  73. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/inbox.py +0 -0
  74. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/kb.py +0 -0
  75. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/key_check.py +0 -0
  76. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/keys_cmds.py +0 -0
  77. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/keystore.py +0 -0
  78. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/knowledge.py +0 -0
  79. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/launch_health.py +0 -0
  80. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/learned_rules.py +0 -0
  81. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/learning.py +0 -0
  82. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/legacy.py +0 -0
  83. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/local_setup.py +0 -0
  84. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/login_pause.py +0 -0
  85. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/mcp_server.py +0 -0
  86. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/model_access.py +0 -0
  87. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/model_availability.py +0 -0
  88. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/native/__init__.py +0 -0
  89. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/native/catalog.py +0 -0
  90. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/native/client.py +0 -0
  91. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/native/loop.py +0 -0
  92. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/native/lsp.py +0 -0
  93. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/native/permissions.py +0 -0
  94. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/native/runner.py +0 -0
  95. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/native/serve.py +0 -0
  96. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/native/tools.py +0 -0
  97. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/opencode.py +0 -0
  98. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pane_auth.py +0 -0
  99. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/permissions.py +0 -0
  100. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/picker.py +0 -0
  101. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pipeline.py +0 -0
  102. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/plugins.py +0 -0
  103. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/policy.py +0 -0
  104. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pool.py +0 -0
  105. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pricing.py +0 -0
  106. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pro/__init__.py +0 -0
  107. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pro/_files.py +0 -0
  108. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pro/account.py +0 -0
  109. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pro/audit_chain.py +0 -0
  110. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pro/audit_export.py +0 -0
  111. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pro/auth.py +0 -0
  112. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pro/brief_learning.py +0 -0
  113. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pro/cost_centers.py +0 -0
  114. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pro/credentials.py +0 -0
  115. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pro/features.py +0 -0
  116. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pro/keys.py +0 -0
  117. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pro/learning.py +0 -0
  118. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pro/license.py +0 -0
  119. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pro/loopback.py +0 -0
  120. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pro/managed_models.py +0 -0
  121. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pro/org_profiles.py +0 -0
  122. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pro/orgkey.py +0 -0
  123. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pro/rollout.py +0 -0
  124. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pro/route_health.py +0 -0
  125. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pro/settings_sync.py +0 -0
  126. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pro/status.py +0 -0
  127. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pro/team_events.py +0 -0
  128. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/pro/team_policy.py +0 -0
  129. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/procs.py +0 -0
  130. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/profiles.py +0 -0
  131. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/prompt_answer.py +0 -0
  132. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/providers.py +0 -0
  133. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/quota.py +0 -0
  134. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/repo_cmds.py +0 -0
  135. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/repos.py +0 -0
  136. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/rewind.py +0 -0
  137. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/rule_checks.py +0 -0
  138. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/savings.py +0 -0
  139. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/scratch.py +0 -0
  140. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/secrets.py +0 -0
  141. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/self_serve.py +0 -0
  142. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/services.py +0 -0
  143. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/sidebar_logo.py +0 -0
  144. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/signin_pause.py +0 -0
  145. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/status_cache.py +0 -0
  146. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/tasks.py +0 -0
  147. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/tmux.py +0 -0
  148. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/updates.py +0 -0
  149. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/upgrade.py +0 -0
  150. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/usage.py +0 -0
  151. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/view.py +0 -0
  152. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/wake.py +0 -0
  153. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/watch.py +0 -0
  154. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/watch_costs.py +0 -0
  155. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/web/__init__.py +0 -0
  156. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/web/chat.py +0 -0
  157. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/web/dashboard.py +0 -0
  158. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/web/diff.py +0 -0
  159. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/web/server.py +0 -0
  160. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/web/static/app.js +0 -0
  161. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/web/static/index.html +0 -0
  162. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/worker_models.py +0 -0
  163. {brindle-0.4.3 → brindle-0.4.5}/src/brindle/workspaces.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: brindle
3
- Version: 0.4.3
3
+ Version: 0.4.5
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "brindle"
3
- version = "0.4.3"
3
+ version = "0.4.5"
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"
@@ -2159,9 +2159,45 @@ def finish_review(db: DB, reviewer_id: str, ws_id: str) -> None:
2159
2159
 
2160
2160
  learned_rules.refresh_later(ws.repo_root) # the finding just landed in the history
2161
2161
  finally:
2162
+ # A merge removes the workspace but leaves the session this reviewer
2163
+ # runs in; once the reviewer is gone nobody else would end it.
2164
+ if ws and db.get_workspace(ws_id) is None:
2165
+ _closing_session(reviewer_id).write_text(ws.tmux_session)
2162
2166
  close_later(reviewer_id)
2163
2167
 
2164
2168
 
2169
+ def _closing_session(agent_id: str):
2170
+ from brindle.config import brindle_home
2171
+
2172
+ d = brindle_home() / "closing"
2173
+ d.mkdir(parents=True, exist_ok=True)
2174
+ return d / agent_id
2175
+
2176
+
2177
+ def close_removed(db: DB, agent_id: str) -> None:
2178
+ """Kill an agent and, when its workspace was removed with the session left
2179
+ running (noted by ``finish_review``), that session too: only brindle's own,
2180
+ unattached session of a workspace that no longer exists."""
2181
+ note = _closing_session(agent_id)
2182
+ try:
2183
+ session = note.read_text().strip()
2184
+ note.unlink()
2185
+ except OSError:
2186
+ session = ""
2187
+ root_id = root_of(db, agent_id) # before kill drops the record that links it
2188
+ try:
2189
+ kill(db, agent_id)
2190
+ except AgentError:
2191
+ pass
2192
+ if session and db.workspace_by_tmux_session(session) is None \
2193
+ and not tmux.session_attached(session):
2194
+ # A sidebar left in this session would go down with it.
2195
+ sidebar = db.get_sidebar_pane(root_id)
2196
+ if sidebar and sidebar != SIDEBAR_DISMISSED and tmux.pane_session(sidebar) == session:
2197
+ sidebar_come_home(db, root_id, sidebar)
2198
+ tmux.kill_session(session)
2199
+
2200
+
2165
2201
  def close_later(agent_id: str, delay: float = 5.0) -> None:
2166
2202
  """Stop an agent shortly, from a detached process: used by an agent's own
2167
2203
  tool call, which must return before its CLI goes away."""
@@ -2182,6 +2218,12 @@ def _stop(db: DB, agent: Agent) -> None:
2182
2218
  # Only a window on the server brindle is using: another server's pane
2183
2219
  # ids mean other panes here (see same_server).
2184
2220
  window = agent.tmux_window if same_server(agent) and owns_pane(db, agent) else ""
2221
+ if window:
2222
+ from brindle.sidebar_follow import rescue_sidebar_from_window
2223
+
2224
+ # Before the pids are read: a moved pane keeps its process, and the
2225
+ # sidebar's would otherwise be stopped with the agent's.
2226
+ rescue_sidebar_from_window(db, agent)
2185
2227
  pane_pids = tmux.window_pids(window) if window else []
2186
2228
  if window:
2187
2229
  tmux.kill_window(window)
@@ -3080,10 +3080,7 @@ def quit_cmd(root_id: str) -> None:
3080
3080
  @app.command("_close", hidden=True)
3081
3081
  def close_cmd(agent_id: str, delay: float = typer.Option(0.0)) -> None:
3082
3082
  time.sleep(delay)
3083
- try:
3084
- agents.kill(_helper_db(), agent_id)
3085
- except agents.AgentError:
3086
- pass
3083
+ agents.close_removed(_helper_db(), agent_id)
3087
3084
 
3088
3085
 
3089
3086
  @app.command("_finish-review", hidden=True)
@@ -36,6 +36,7 @@ import json
36
36
  import os
37
37
  import queue
38
38
  import shlex
39
+ import signal
39
40
  import subprocess
40
41
  import threading
41
42
  import time
@@ -228,7 +229,8 @@ class _AppServer:
228
229
  self.timeout = timeout
229
230
  try:
230
231
  self.proc = subprocess.Popen([binary, "app-server", *flags], stdin=subprocess.PIPE,
231
- stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
232
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
233
+ start_new_session=True)
232
234
  except OSError as e:
233
235
  raise CodexHookError(f"couldn't run {binary}: {e}") from e
234
236
  self.lines: queue.Queue = queue.Queue()
@@ -279,6 +281,14 @@ class _AppServer:
279
281
  return result
280
282
 
281
283
  def close(self) -> None:
284
+ # The app-server leads its own process group (see __init__): kill the
285
+ # group so nothing it spawned outlives it.
286
+ pid = getattr(self.proc, "pid", None)
287
+ if pid:
288
+ try:
289
+ os.killpg(pid, signal.SIGKILL)
290
+ except OSError:
291
+ pass
282
292
  try:
283
293
  self.proc.kill()
284
294
  except OSError:
@@ -40,10 +40,22 @@ class Session:
40
40
 
41
41
 
42
42
  def paused(db: DB, repo_root: str) -> list[Session]:
43
- """Paused sessions whose chat ran in ``repo_root``, newest first."""
43
+ """Paused sessions whose chat ran in ``repo_root``, newest first. A
44
+ "failed" chat whose pane is still running isn't one: that status also
45
+ marks a live chat (a brief whose delivery wasn't confirmed, an auth error
46
+ on one turn), and dropping it (see enforce) would close it mid-session."""
44
47
  out = []
48
+ panes = owners = None
45
49
  for ws in db.find_workspaces(repo_root):
46
50
  for a in db.list_agents(ws.id):
51
+ if a.mode == "interactive" and a.status == "failed":
52
+ if panes is None:
53
+ panes = tmux.list_panes()
54
+ owners = agents.pane_owners(db, panes)
55
+ # On another server, its own server's panes say whose its pane is.
56
+ mine = owners if agents.same_server(a, panes) else None
57
+ if agents.is_alive(a, panes) and agents.owns_pane(db, a, mine):
58
+ continue
47
59
  if a.mode == "interactive" and a.status in ("paused", "failed"):
48
60
  members = agents.tree(db, a.id)
49
61
  branches = []
@@ -212,6 +224,9 @@ def _forget(db: DB, s: Session) -> None:
212
224
  # some other pane here (see agents.same_server).
213
225
  if (a.tmux_window and agents.same_server(a, panes) and agents.is_alive(a, panes)
214
226
  and agents.owns_pane(db, a, owners)):
227
+ from brindle.sidebar_follow import rescue_sidebar_from_window
228
+
229
+ rescue_sidebar_from_window(db, a)
215
230
  tmux.kill_window(a.tmux_window)
216
231
  ws = db.get_workspace(a.workspace_id)
217
232
  if ws and ws.kind == "worktree":
@@ -196,6 +196,54 @@ def rescue_sidebar(db: DB, ws: Workspace) -> None:
196
196
  pass
197
197
 
198
198
 
199
+ def _home_session(db: DB, root_id: str, sidebar: str) -> str | None:
200
+ """The brindle session whose agent window ``sidebar`` sits in. Not
201
+ ``tmux.pane_session``: a window the app linked into its own
202
+ ``brindle-view-*`` session reports that one, and that session being
203
+ attached says nothing about the person's terminal on the brindle session."""
204
+ window = tmux.pane_window(sidebar)
205
+ for agent in db.list_agents():
206
+ if (window and agent.tmux_window and root_of(db, agent.id) == root_id
207
+ and tmux.pane_window(agent.tmux_window) == window):
208
+ ws = db.get_workspace(agent.workspace_id)
209
+ if ws is not None:
210
+ return ws.tmux_session
211
+ return tmux.pane_session(sidebar)
212
+
213
+
214
+ def rescue_sidebar_from_window(db: DB, agent: Agent) -> None:
215
+ """Call before ``agent``'s tmux window is killed: if its root's sidebar is
216
+ a pane in that window, move it to a window of the root's session that
217
+ survives (the root's active one, else any other agent's there: a worker
218
+ sharing the root's workspace has its window in the same session). Never
219
+ raises."""
220
+ try:
221
+ if not agent.tmux_window:
222
+ return
223
+ root_id = root_of(db, agent.id)
224
+ root = db.get_agent(root_id)
225
+ root_ws = db.get_workspace(root.workspace_id) if root else None
226
+ if root is None or root_ws is None or agent.id == root_id:
227
+ return
228
+ with _sidebar_lock(root_id):
229
+ sidebar = db.get_sidebar_pane(root_id)
230
+ doomed = tmux.pane_window(agent.tmux_window)
231
+ if not (_valid_sidebar(sidebar, root_id) and doomed
232
+ and tmux.pane_window(sidebar) == doomed):
233
+ return
234
+ candidates = [tmux.active_window(root_ws.tmux_session)]
235
+ candidates += [tmux.pane_window(a.tmux_window) for a in db.list_agents(root_ws.id)
236
+ if a.tmux_window and a.status not in ("paused", "done")]
237
+ for window in candidates:
238
+ target_pane = window and window != doomed and tmux.agent_pane_in_window(window, sidebar)
239
+ if target_pane:
240
+ _note_sidebar_move(f"rescue to {root_ws.tmux_session}", sidebar, target_pane)
241
+ tmux.move_pane(sidebar, target_pane, SIDEBAR_COLUMNS, _sidebar_position(root_ws))
242
+ return
243
+ except Exception: # noqa: BLE001 - cleanup must not fail because of the sidebar
244
+ pass
245
+
246
+
199
247
  def sidebar_follow(db: DB, session: str) -> None:
200
248
  ws = db.workspace_by_tmux_session(session)
201
249
  if ws is None:
@@ -240,10 +288,13 @@ def sidebar_follow(db: DB, session: str) -> None:
240
288
  if tmux.pane_window(sidebar) == window:
241
289
  _repaint(sidebar)
242
290
  return
243
- if not tmux.session_attached(session):
244
- home = tmux.pane_session(sidebar)
245
- if home and home != session and tmux.session_attached(home):
246
- return
291
+ # Never take it out of another session someone still looks at: the
292
+ # person's own switch-client leaves that session first, so it reads
293
+ # unattached by now. A second client (the app's, attached to a
294
+ # worker's session) changing windows must not pull it from their terminal.
295
+ home = _home_session(db, root_id, sidebar)
296
+ if home and home != session and tmux.session_attached(home):
297
+ return
247
298
  target_pane = tmux.agent_pane_in_window(window, sidebar)
248
299
  if not target_pane:
249
300
  return
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes