tasktree-manager 2.19.0__tar.gz → 2.20.0__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 (87) hide show
  1. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/CHANGELOG.md +6 -0
  2. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/PKG-INFO +1 -1
  3. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/README.md +2 -0
  4. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/docs/configuration.md +10 -1
  5. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/docs/user-guide.md +32 -3
  6. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/pyproject.toml +1 -1
  7. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/_version.py +2 -2
  8. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/app.py +238 -9
  9. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/cli.py +42 -0
  10. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/commands.py +5 -0
  11. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/services/agent_sessions.py +34 -12
  12. tasktree_manager-2.20.0/tasktree_manager/services/claude_bg.py +138 -0
  13. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/services/config.py +12 -0
  14. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/services/epic_ask.py +2 -11
  15. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/widgets/create_modal.py +6 -0
  16. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/conftest.py +14 -1
  17. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_agent_sessions.py +20 -0
  18. tasktree_manager-2.20.0/tests/test_claude_bg.py +533 -0
  19. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_epic_ask.py +1 -1
  20. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.claude/skills/textual-tui/SKILL.md +0 -0
  21. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.claude/skills/textual-tui/assets/README.md +0 -0
  22. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
  23. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
  24. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
  25. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
  26. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.claude/skills/textual-tui/references/layouts.md +0 -0
  27. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
  28. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.claude/skills/textual-tui/references/styling.md +0 -0
  29. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.claude/skills/textual-tui/references/widgets.md +0 -0
  30. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.github/templates/CHANGELOG.md.j2 +0 -0
  31. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.github/workflows/ci.yml +0 -0
  32. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.github/workflows/release.yml +0 -0
  33. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.gitignore +0 -0
  34. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/CONTRIBUTING.md +0 -0
  35. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/LICENSE +0 -0
  36. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/ROADMAP.md +0 -0
  37. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/docs/claude-code-integration-spec.md +0 -0
  38. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/docs/installation.md +0 -0
  39. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/docs/troubleshooting.md +0 -0
  40. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/mise.toml +0 -0
  41. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/scripts/bump_version.py +0 -0
  42. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/__init__.py +0 -0
  43. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/services/__init__.py +0 -0
  44. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/services/claude_hooks.py +0 -0
  45. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/services/claude_sessions.py +0 -0
  46. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/services/epic_log.py +0 -0
  47. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/services/forge.py +0 -0
  48. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/services/git_ops.py +0 -0
  49. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/services/models.py +0 -0
  50. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/services/task_manager.py +0 -0
  51. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/themes.py +0 -0
  52. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/widgets/__init__.py +0 -0
  53. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/widgets/app_header.py +0 -0
  54. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/widgets/messages_panel.py +0 -0
  55. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/widgets/setup_modal.py +0 -0
  56. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/widgets/status_panel.py +0 -0
  57. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/widgets/task_list.py +0 -0
  58. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/widgets/worktree_list.py +0 -0
  59. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/__init__.py +0 -0
  60. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_agent_sessions_decode.py +0 -0
  61. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_app.py +0 -0
  62. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_app_review_fixes.py +0 -0
  63. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_bump_version.py +0 -0
  64. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_claude_hooks.py +0 -0
  65. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_claude_sessions.py +0 -0
  66. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_cli.py +0 -0
  67. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_commands.py +0 -0
  68. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_config.py +0 -0
  69. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_epic_claude_md.py +0 -0
  70. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_epic_log.py +0 -0
  71. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_epic_tui.py +0 -0
  72. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_epics.py +0 -0
  73. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_forge.py +0 -0
  74. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_git_ops.py +0 -0
  75. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_integration_git.py +0 -0
  76. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_list_selection.py +0 -0
  77. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_medium_fixes.py +0 -0
  78. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_memory.py +0 -0
  79. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_messages_panel.py +0 -0
  80. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_modals.py +0 -0
  81. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_review_findings.py +0 -0
  82. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_safety_fixes.py +0 -0
  83. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_setup_modal.py +0 -0
  84. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_status_panel.py +0 -0
  85. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_task_manager.py +0 -0
  86. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_terminal_tabs.py +0 -0
  87. {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_worktree_badges.py +0 -0
@@ -15,6 +15,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
15
15
  - Git hooks support
16
16
  - Custom task metadata
17
17
 
18
+ ## [2.20.0] - 2026-10-29
19
+
20
+ ### Added
21
+ - Keep Claude running in the background when its tab closes (#17)
22
+
23
+
18
24
  ## [2.19.0] - 2026-10-29
19
25
 
20
26
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tasktree-manager
3
- Version: 2.19.0
3
+ Version: 2.20.0
4
4
  Summary: TUI application for managing worktree-based tasks
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.10
@@ -24,6 +24,7 @@
24
24
  - 🔀 **Forge-Aware Safety** - MR/PR and CI state via `glab`/`gh`; squash- and rebase-merged branches count as merged instead of blocking deletion forever
25
25
  - 🗂️ **Epics** - Group related tasks under an epic: a foldable row with rolled-up dirty and Claude state, `E` to attach tasks, and an epic log and `epic ask` so the Claude sessions of one epic share what they know
26
26
  - 🧷 **zellij Tabs** - Run the TUI inside zellij (or set `zellij_autostart`) and `o`/`e`/`c` open zellij tabs that keep running across a terminal restart; pressing the key again jumps to the open tab ([details](docs/user-guide.md#zellij))
27
+ - 💤 **Background Claude** - `c` runs Claude as a background session and the tab only shows it: close the tab (`x`) and Claude keeps working, press `c` to come back to the same conversation
27
28
  - 📦 **Finish Flow with Archives** - Ending a task saves its combined diff (committed-but-unmerged + uncommitted) and the task-folder files outside the worktrees (plans, ADRs, notes) to an archive before deleting
28
29
 
29
30
  ## 📊 Status Indicators
@@ -183,6 +184,7 @@ again before it deletes.
183
184
  | `p` | Push all | `o` | Open folder |
184
185
  | `P` | Pull all | `y` | Clone task |
185
186
  | `c` | Claude (resume) | `C` | Claude (new) |
187
+ | `x` | Close Claude tab | | |
186
188
  | `b` | Dispatch agent | `D` | Delete worktree |
187
189
  | `r` | Refresh | `s` | Sort tasks |
188
190
  | `S` | Group worktrees | `m` | Messages |
@@ -202,6 +202,12 @@ claude_repo_memory = true
202
202
  # Default: true
203
203
  claude_recap = true
204
204
 
205
+ # Run the `c` key's Claude as a background session (`claude --bg`) and show it
206
+ # in a tab running `claude attach`: closing the tab leaves Claude working, and
207
+ # `c` again reopens the same conversation. false runs Claude inside the tab.
208
+ # Default: true
209
+ claude_background = true
210
+
205
211
  # Path to glab executable (GitLab CLI, used for MR/CI status)
206
212
  # Default: "glab" (searches PATH)
207
213
  glab_path = "glab"
@@ -268,6 +274,7 @@ refresh = "r" # Refresh git status
268
274
 
269
275
  # Claude Code integration
270
276
  open_claude_resume = "c" # Open Claude CLI (resume if a session exists, else new)
277
+ close_claude_tab = "x" # Close the Claude tab (zellij); Claude keeps running
271
278
  open_claude_gui_code = "C" # Open Claude desktop on Code page in task folder (shift+c)
272
279
  dispatch_agent = "b" # Dispatch background Claude agent in selected worktree
273
280
 
@@ -427,6 +434,7 @@ refresh_interval = 15 # Refresh every 15 seconds
427
434
  | `hunk_path` | string | `"hunk"` | - | Path to hunk diff-viewer executable |
428
435
  | `glab_path` | string | `"glab"` | - | Path to GitLab CLI (MR/CI status) |
429
436
  | `gh_path` | string | `"gh"` | - | Path to GitHub CLI (PR/CI status) |
437
+ | `claude_background` | bool | `true` | - | `c` runs Claude in the background |
430
438
  | `zellij_path` | string | `"zellij"` | - | Path to zellij (tabs and autostart) |
431
439
  | `zellij_autostart` | bool | `false` | - | Re-launch the TUI inside zellij |
432
440
  | `zellij_session` | string | `"tasktree"` | - | zellij session for autostart |
@@ -604,7 +612,7 @@ blocklist = ["*"]
604
612
 
605
613
  ## Keybindings Reference
606
614
 
607
- All 27 customizable keybindings with descriptions:
615
+ All 28 customizable keybindings with descriptions:
608
616
 
609
617
  ### Application Control
610
618
 
@@ -642,6 +650,7 @@ All 27 customizable keybindings with descriptions:
642
650
  | Action | Default | Description | Customization Example |
643
651
  |-----------------------|---------|------------------------------------------------------|--------------------------------------|
644
652
  | `open_claude_resume` | `c` | Open Claude CLI (resume if session exists, else new) | `open_claude_resume = "ctrl+c"` |
653
+ | `close_claude_tab` | `x` | Close the Claude tab (zellij); Claude keeps running | `close_claude_tab = "ctrl+x"` |
645
654
  | `open_claude_gui_code`| `C` | Open Claude desktop on Code page in task folder | `open_claude_gui_code = "ctrl+shift+c"` |
646
655
  | `toggle_messages` | `m` | Show/hide activity messages panel | `toggle_messages = "ctrl+m"` |
647
656
  | `dispatch_agent` | `b` | Dispatch a background Claude agent into the worktree | `dispatch_agent = "ctrl+b"` |
@@ -451,13 +451,29 @@ Useful when you're spinning up several similar tasks that share the same set of
451
451
 
452
452
  | Key | Action | Description |
453
453
  |---------|-------------------------|------------------------------------------------------------|
454
- | `c` | Open Claude CLI | Resume the task's Claude Code session if one exists, otherwise start a new one |
454
+ | `c` | Open Claude CLI | Open the task's Claude session in a tab: reattach, resume the last one, or start a new one |
455
+ | `x` | Close Claude tab | Close the Claude tab (zellij only); Claude keeps working in the background |
455
456
  | `C` | Open Claude desktop | Open the Claude desktop app on the Code page at the task folder |
456
457
  | `b` | Dispatch agent | Run a background Claude agent (`claude --bg`) in the selected worktree |
457
458
 
458
459
  **How it works:**
459
- - `c` opens Claude Code CLI in a new terminal tab at the task directory (a zellij tab inside zellij, see [zellij](#zellij)).
460
- - If `~/.claude/projects/<task-folder>/` already has session transcripts, runs `claude -r` to pick from them; otherwise runs `claude` to start fresh.
460
+ - `c` runs Claude as a background session (`claude --bg`) in the task folder and opens a
461
+ terminal tab that only shows it (`claude attach`). A zellij tab inside zellij, else a
462
+ Ghostty tab; see [zellij](#zellij). Closing a zellij tab leaves Claude working; the
463
+ same with a Ghostty tab is not tested yet.
464
+ - A background session already in the task folder is attached, also a finished one.
465
+ - Otherwise the folder's last session is resumed in the background (`claude --bg
466
+ --resume`), or a new one starts. It waits for your first message; nothing runs before.
467
+ - With the worktree panel focused, `c` opens the agent `b` dispatched to that worktree,
468
+ if one exists.
469
+ - Claude already open in the task folder in another terminal is left alone.
470
+ - When `claude agents` or `claude --bg` fails (for example in an untrusted folder),
471
+ or with `claude_background = false`, Claude runs inside the tab: `claude -r` when
472
+ the folder has transcripts, else `claude`.
473
+ - Deleting a task asks first while a background session in it is working or waiting for
474
+ you, then stops and removes its sessions (`claude stop`, `claude rm`) before the
475
+ archive. Transcripts stay, so cancelling the delete later still lets `c` resume. A
476
+ session that will not stop keeps the task (the CLI goes on with `--force`).
461
477
  - `C` opens the Claude desktop app via the `claude://code/new?folder=<task-folder>` URL scheme.
462
478
  - The CLI variant (`c`) generates a task-level `CLAUDE.md` with task/worktree context, copies each repo's own `CLAUDE.md` into worktrees whose branch predates it (never generating stub content), and installs hooks that report session status back to tasktree-manager.
463
479
  - Session status (`⟳` running, `!` waiting, `✓` ended) appears next to the task name
@@ -919,6 +935,19 @@ After a terminal restart, run `tasktree-manager` (with autostart) or
919
935
  sessions included. The command after `--` runs only when the session is created, so a
920
936
  second `tasktree-manager` in another terminal tab shows the same session.
921
937
 
938
+ **Too many tabs.** The `c` tab only shows a background Claude session, so it can close
939
+ while Claude keeps working:
940
+
941
+ 1. Close the tab with `x` in tasktree-manager, or with zellij's own close-tab key.
942
+ 2. The task row keeps its Claude status (`⟳` running, `!` waiting). When a turn finishes
943
+ while the tab is closed, a notice says so.
944
+ 3. Press `c` on the task again: the tab reopens on the same conversation.
945
+
946
+ `x` closes only a tab whose panes run `claude attach` (zellij's pane list shows each
947
+ pane's launch command). A tab that runs Claude itself
948
+ (`claude_background = false`, or a tab from an older version) stays open, since closing
949
+ it would stop Claude.
950
+
922
951
  - A tab that runs a command (`e`, `c`) closes when the command exits.
923
952
  - The zellij tab runs the command with `sh -c`, so `editor` and `claude_path` must name
924
953
  programs on `PATH`. A shell alias or fish function works only in the Ghostty tab.
@@ -71,7 +71,7 @@ version-file = "tasktree_manager/_version.py"
71
71
  # Semantic Release Configuration
72
72
  [tool.semantic_release]
73
73
  version_toml = ["pyproject.toml:tool.semantic_release.version"]
74
- version = "2.19.0"
74
+ version = "2.20.0"
75
75
  tag_format = "v{version}"
76
76
  branch = "main"
77
77
  commit_message = "chore(release): {version} [skip ci]"
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '2.19.0'
22
- __version_tuple__ = version_tuple = (2, 19, 0)
21
+ __version__ = version = '2.20.0'
22
+ __version_tuple__ = version_tuple = (2, 20, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -20,7 +20,18 @@ from textual.worker import get_current_worker
20
20
 
21
21
  from . import __version__
22
22
  from .commands import TaskTreeCommands
23
- from .services.agent_sessions import list_agent_sessions, map_sessions_to_worktrees
23
+ from .services.agent_sessions import AgentSession, list_agent_sessions, map_sessions_to_worktrees
24
+ from .services.claude_bg import (
25
+ BackgroundError,
26
+ attach_command,
27
+ background_sessions,
28
+ busy,
29
+ end_sessions,
30
+ folder_session,
31
+ foreground_session,
32
+ launch_env,
33
+ start_background,
34
+ )
24
35
  from .services.claude_hooks import ensure_claude_hooks, has_claude_session
25
36
  from .services.claude_sessions import (
26
37
  ClaudeRecap,
@@ -214,6 +225,7 @@ class TaskTreeApp(App):
214
225
  Binding("e", "open_editor", "Editor", show=False),
215
226
  Binding("o", "open_folder", "Open", show=False),
216
227
  Binding("c", "open_claude_resume", "Claude", show=False),
228
+ Binding("x", "close_claude_tab", "Close Tab", show=False),
217
229
  Binding("p", "push_all", "Push", show=False),
218
230
  Binding("t", "cycle_theme", "Theme"),
219
231
  Binding("r", "refresh", "Refresh"),
@@ -272,6 +284,8 @@ class TaskTreeApp(App):
272
284
  self._ghostty_lock = threading.Lock()
273
285
  # Serializes the zellij tab lookup and open across workers
274
286
  self._zellij_lock = threading.Lock()
287
+ # Two quick `c` presses must not start two background sessions
288
+ self._claude_bg_lock = threading.Lock()
275
289
  # App-lifetime pool for forge lookups (a fresh executor per poll
276
290
  # would rebuild threads every tick)
277
291
  self._forge_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="forge")
@@ -299,6 +313,7 @@ class TaskTreeApp(App):
299
313
  Binding(kb.get("open_editor", "e"), "open_editor", "Editor", show=False),
300
314
  Binding(kb.get("open_folder", "o"), "open_folder", "Open", show=False),
301
315
  Binding(kb.get("open_claude_resume", "c"), "open_claude_resume", "Claude", show=False),
316
+ Binding(kb.get("close_claude_tab", "x"), "close_claude_tab", "Close Tab", show=False),
302
317
  Binding(kb.get("dispatch_agent", "b"), "dispatch_agent", "Agent", show=False),
303
318
  Binding(kb.get("push_all", "p"), "push_all", "Push", show=False),
304
319
  Binding(kb.get("cycle_theme", "t"), "cycle_theme", "Theme"),
@@ -363,6 +378,7 @@ class TaskTreeApp(App):
363
378
  (kb.get("set_epic", "E"), "set_epic", "Epic"),
364
379
  (kb.get("show_diff", "h"), "show_diff", "Diff"),
365
380
  (kb.get("open_claude_resume", "c"), "open_claude_resume", "Claude"),
381
+ (kb.get("close_claude_tab", "x"), "close_claude_tab", "Close Tab"),
366
382
  (kb.get("cycle_sort", "s"), "cycle_sort", "Sort"),
367
383
  ]
368
384
  worktree_panel_bindings = [
@@ -371,6 +387,8 @@ class TaskTreeApp(App):
371
387
  (kb.get("open_editor", "e"), "open_editor", "Editor"),
372
388
  (kb.get("open_folder", "o"), "open_folder", "Open"),
373
389
  (kb.get("dispatch_agent", "b"), "dispatch_agent", "Agent"),
390
+ (kb.get("open_claude_resume", "c"), "open_claude_resume", "Claude"),
391
+ (kb.get("close_claude_tab", "x"), "close_claude_tab", "Close Tab"),
374
392
  (kb.get("push_all", "p"), "push_all", "Push"),
375
393
  (kb.get("delete_worktree", "D"), "delete_worktree", "Del WT"),
376
394
  ]
@@ -687,6 +705,13 @@ class TaskTreeApp(App):
687
705
  statuses[task.name] = status
688
706
 
689
707
  if statuses != self._claude_statuses:
708
+ finished = [
709
+ name
710
+ for name, status in statuses.items()
711
+ if status == "waiting" and self._claude_statuses.get(name) == "running"
712
+ ]
713
+ if finished and self.config.claude_background and os.environ.get("ZELLIJ_SESSION_NAME"):
714
+ self._notify_closed_claude_tabs(finished)
690
715
  self._claude_statuses = statuses
691
716
  task_list.refresh_claude_indicators(statuses)
692
717
  if self.current_task:
@@ -1407,12 +1432,32 @@ class TaskTreeApp(App):
1407
1432
  def _check_task_safety_worker(self, task: Task) -> None:
1408
1433
  """Run task safety checks in a background thread."""
1409
1434
  safety_report = self.task_manager.check_task_safety(task)
1435
+ claude_busy = busy(self._background_sessions(task) or [])
1410
1436
  if get_current_worker().is_cancelled:
1411
1437
  return
1412
- self.call_from_thread(self._show_delete_task_dialog, task, safety_report)
1438
+ self.call_from_thread(self._show_delete_task_dialog, task, safety_report, claude_busy)
1439
+
1440
+ def _show_delete_task_dialog(
1441
+ self,
1442
+ task: Task,
1443
+ safety_report: TaskSafetyReport,
1444
+ claude_busy: list[AgentSession] | None = None,
1445
+ ) -> None:
1446
+ """Show the appropriate delete dialog for the safety report.
1447
+
1448
+ Background Claude sessions working or waiting in the task are confirmed
1449
+ first; the delete itself stops them.
1450
+ """
1451
+ if claude_busy:
1452
+ ids = ", ".join(session.agent_id for session in claude_busy)
1453
+ message = f"Claude is still active in '{task.name}' ({ids}). Stop it and continue?"
1413
1454
 
1414
- def _show_delete_task_dialog(self, task: Task, safety_report: TaskSafetyReport) -> None:
1415
- """Show the appropriate delete dialog for the safety report."""
1455
+ def handle_busy(confirmed):
1456
+ if confirmed:
1457
+ self._show_delete_task_dialog(task, safety_report)
1458
+
1459
+ self.push_screen(ConfirmModal("Claude Still Running", escape(message)), handle_busy)
1460
+ return
1416
1461
  if safety_report.is_safe():
1417
1462
  # When the verdict rests on a remote MR lookup (squash/rebase
1418
1463
  # merge), say so — the user deserves a chance to sanity-check it
@@ -1569,6 +1614,18 @@ class TaskTreeApp(App):
1569
1614
  """
1570
1615
  error: str | None = None
1571
1616
  archive: ArchiveResult | None = None
1617
+ # Stopped first, so no session writes into the folder being archived;
1618
+ # one that would not stop keeps the task
1619
+ sessions = self._background_sessions(task)
1620
+ if sessions is None:
1621
+ message = f"Could not check Claude sessions of '{task.name}'; none were stopped"
1622
+ self.call_from_thread(self.notify, escape(message), severity="warning")
1623
+ self.call_from_thread(self._log_activity, message, MessageLevel.WARNING, task.name)
1624
+ failed = end_sessions(self.config.claude_path, sessions or [])
1625
+ if failed:
1626
+ error = f"could not stop Claude session(s) {', '.join(failed)}; run `claude stop ID`"
1627
+ self.call_from_thread(self._apply_finish_task_result, task.name, force, error)
1628
+ return
1572
1629
  if not skip_archive:
1573
1630
  problem: str | None = None
1574
1631
  try:
@@ -2144,8 +2201,9 @@ class TaskTreeApp(App):
2144
2201
  def action_open_claude_resume(self) -> None:
2145
2202
  """Open Claude Code in a new terminal tab.
2146
2203
 
2147
- Resumes an existing session for the task folder if one exists,
2148
- otherwise starts a fresh session.
2204
+ With `claude_background` the session runs in the background and the
2205
+ tab only shows it (see _open_claude_background). Otherwise the tab
2206
+ runs Claude itself, resuming the folder's session if one exists.
2149
2207
  """
2150
2208
  if not self.current_task:
2151
2209
  self.notify("No task selected", severity="warning")
@@ -2156,14 +2214,16 @@ class TaskTreeApp(App):
2156
2214
  self.notify("Task directory not found", severity="error")
2157
2215
  return
2158
2216
 
2159
- self._open_claude_resume_worker(task)
2217
+ worktree = self.current_worktree if isinstance(self.focused, WorktreeList) else None
2218
+ self._open_claude_resume_worker(task, worktree)
2160
2219
 
2161
2220
  @work(thread=True, group="claude_prep")
2162
- def _open_claude_resume_worker(self, task: Task) -> None:
2221
+ def _open_claude_resume_worker(self, task: Task, worktree: Worktree | None = None) -> None:
2163
2222
  """Prepare the session off the UI thread, then open the terminal tab.
2164
2223
 
2165
2224
  A prep failure is reported but does not block the launch - stale
2166
- CLAUDE.md content is not worth failing the session over.
2225
+ CLAUDE.md content is not worth failing the session over. When no
2226
+ background session can start, Claude runs inside the tab as before.
2167
2227
  """
2168
2228
  try:
2169
2229
  self._prepare_claude_session(task)
@@ -2173,11 +2233,143 @@ class TaskTreeApp(App):
2173
2233
  escape(f"Claude session prep failed for '{task.name}': {type(e).__name__}: {e}"),
2174
2234
  severity="warning",
2175
2235
  )
2236
+ if self.config.claude_background:
2237
+ try:
2238
+ with self._claude_bg_lock:
2239
+ self._open_claude_background(task, worktree)
2240
+ return
2241
+ except BackgroundError as e:
2242
+ self.call_from_thread(
2243
+ self.notify,
2244
+ escape(f"No background Claude session ({e}); running Claude in the tab"),
2245
+ severity="warning",
2246
+ )
2176
2247
  resume = has_claude_session(task.path)
2177
2248
  if get_current_worker().is_cancelled:
2178
2249
  return
2179
2250
  self.call_from_thread(self._open_claude_resume_tab, task.path, resume)
2180
2251
 
2252
+ def _open_claude_background(self, task: Task, worktree: Worktree | None) -> None:
2253
+ """Show the background Claude session in a tab, starting one if needed.
2254
+
2255
+ Runs in the claude_prep worker. An open tab is reused before asking
2256
+ Claude anything. A dispatched agent of the focused worktree wins over
2257
+ the task folder's session. With no session the folder's last
2258
+ transcript is resumed; Claude open in another terminal is left alone.
2259
+ Raises BackgroundError when no session can be found or started.
2260
+ """
2261
+ claude = self.config.claude_path
2262
+ tabs = self._zellij_tab_names()
2263
+ sessions: list[AgentSession] | None = None
2264
+ for folder in [*([worktree.path] if worktree else []), task.path]:
2265
+ if self._zellij_tab_name(folder, "claude") in tabs:
2266
+ self.call_from_thread(self._open_terminal_tab, folder, kind="claude")
2267
+ return
2268
+ if sessions is None:
2269
+ sessions = list_agent_sessions(claude, include_done=True)
2270
+ if sessions is None:
2271
+ raise BackgroundError("'claude agents' is unavailable")
2272
+ session = folder_session(sessions, folder)
2273
+ if session:
2274
+ self._show_background_session(
2275
+ folder, session.agent_id, f"Opened Claude session {session.agent_id}"
2276
+ )
2277
+ return
2278
+ if foreground_session(sessions or [], task.path):
2279
+ self.call_from_thread(
2280
+ self.notify,
2281
+ escape(f"Claude is already open in '{task.name}' in another terminal"),
2282
+ severity="warning",
2283
+ )
2284
+ return
2285
+ latest = latest_session([task.path])
2286
+ resume_id = latest.session_id if latest else None
2287
+ try:
2288
+ agent_id = start_background(claude, task.path, task.name, resume_id)
2289
+ except BackgroundError:
2290
+ # A timed-out start may still have come up: attach to it, never start a second Claude
2291
+ started = folder_session(
2292
+ list_agent_sessions(claude, include_done=True) or [], task.path
2293
+ )
2294
+ if started is None:
2295
+ raise
2296
+ agent_id = started.agent_id
2297
+ verb = "Resumed the last" if resume_id else "Started a"
2298
+ self._show_background_session(
2299
+ task.path, agent_id, f"{verb} Claude session in the background ({agent_id})"
2300
+ )
2301
+
2302
+ def _show_background_session(self, folder: Path, agent_id: str, notice: str) -> None:
2303
+ """Open the tab that attaches to a background session (from a worker)."""
2304
+ command = attach_command(self.config.claude_path, agent_id)
2305
+ self.call_from_thread(
2306
+ self._open_terminal_tab, folder, command=command, kind="claude", notice=notice
2307
+ )
2308
+
2309
+ def _background_sessions(self, task: Task) -> list[AgentSession] | None:
2310
+ """The task's background Claude sessions; None when `claude agents` fails.
2311
+
2312
+ Without the claude CLI there are no sessions to find, so that is not a failure.
2313
+ """
2314
+ sessions = list_agent_sessions(self.config.claude_path, include_done=True)
2315
+ if sessions is None:
2316
+ return None if shutil.which(self.config.claude_path) else []
2317
+ return background_sessions(sessions, task.path)
2318
+
2319
+ def action_close_claude_tab(self) -> None:
2320
+ """Close the zellij Claude tab of the focused task or worktree."""
2321
+ if not os.environ.get("ZELLIJ_SESSION_NAME"):
2322
+ self.notify("Closing a Claude tab works inside zellij only", severity="warning")
2323
+ return
2324
+ if isinstance(self.focused, WorktreeList) and self.current_worktree:
2325
+ folder = self.current_worktree.path
2326
+ elif self.current_task:
2327
+ folder = self.current_task.path
2328
+ else:
2329
+ self.notify("No task selected", severity="warning")
2330
+ return
2331
+ self._close_claude_tab_worker(folder)
2332
+
2333
+ @work(thread=True, group="terminal_tab")
2334
+ def _close_claude_tab_worker(self, folder: Path) -> None:
2335
+ """Close the folder's Claude tab when it only runs `claude attach`.
2336
+
2337
+ A tab that runs Claude itself would take Claude with it, so it stays.
2338
+ """
2339
+ name = self._zellij_tab_name(folder, "claude")
2340
+ try:
2341
+ tab_id = self._zellij_attach_tab_id(name)
2342
+ except LookupError as e:
2343
+ self.call_from_thread(self.notify, escape(str(e)), severity="warning")
2344
+ return
2345
+ try:
2346
+ result = self._run_zellij(
2347
+ [self.config.zellij_path, "action", "close-tab-by-id", tab_id]
2348
+ )
2349
+ except (subprocess.SubprocessError, OSError) as e:
2350
+ result = subprocess.CompletedProcess([], 1, stdout="", stderr=str(e))
2351
+ if result.returncode != 0:
2352
+ detail = (result.stderr or result.stdout or "unknown error").strip()[:200]
2353
+ self.call_from_thread(
2354
+ self.notify, escape(f"Could not close '{name}': {detail}"), severity="warning"
2355
+ )
2356
+ return
2357
+ self.call_from_thread(
2358
+ self.notify, escape(f"Closed '{name}'; Claude keeps running in the background")
2359
+ )
2360
+
2361
+ @work(thread=True, group="claude_notice")
2362
+ def _notify_closed_claude_tabs(self, task_names: list[str]) -> None:
2363
+ """Say which tasks' Claude finished a turn while no tab shows it."""
2364
+ tabs = self._zellij_tab_names()
2365
+ key = self.config.keybindings.get("open_claude_resume", "c")
2366
+ for name in task_names:
2367
+ if self._zellij_tab_name(self.config.tasks_dir / name, "claude") in tabs:
2368
+ continue
2369
+ message = f"Claude is waiting in '{name}' (tab closed): press {key} to open it"
2370
+ self.call_from_thread(self.notify, escape(message))
2371
+ self.call_from_thread(self._log_activity, message, MessageLevel.INFO, name)
2372
+
2181
2373
  def _open_claude_resume_tab(self, task_path: Path, resume: bool) -> None:
2182
2374
  """Open the terminal tab for a prepared Claude session (main thread)."""
2183
2375
  if resume:
@@ -2249,6 +2441,7 @@ class TaskTreeApp(App):
2249
2441
  capture_output=True,
2250
2442
  text=True,
2251
2443
  timeout=30,
2444
+ env=launch_env(),
2252
2445
  )
2253
2446
  except FileNotFoundError:
2254
2447
  self.call_from_thread(
@@ -2460,6 +2653,42 @@ class TaskTreeApp(App):
2460
2653
  argv, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=10
2461
2654
  )
2462
2655
 
2656
+ def _zellij_tab_names(self) -> list[str]:
2657
+ """Names of the open zellij tabs; empty outside zellij or when the query fails."""
2658
+ if not os.environ.get("ZELLIJ_SESSION_NAME"):
2659
+ return []
2660
+ try:
2661
+ result = self._run_zellij([self.config.zellij_path, "action", "query-tab-names"])
2662
+ except (subprocess.SubprocessError, OSError):
2663
+ return []
2664
+ return result.stdout.splitlines() if result.returncode == 0 else []
2665
+
2666
+ def _zellij_attach_tab_id(self, name: str) -> str:
2667
+ """Stable id (for close-tab-by-id) of the tab `name` whose panes all run `claude attach`.
2668
+
2669
+ Raises LookupError, with the message to show, when the panes cannot
2670
+ be listed, the tab is not open, or it runs anything else.
2671
+ """
2672
+ argv = [self.config.zellij_path, "action", "list-panes", "--all", "--json"]
2673
+ try:
2674
+ result = self._run_zellij(argv)
2675
+ panes = json.loads(result.stdout) if result.returncode == 0 else None
2676
+ except (subprocess.SubprocessError, OSError, ValueError):
2677
+ panes = None
2678
+ if not isinstance(panes, list):
2679
+ raise LookupError("Could not list the zellij panes")
2680
+ # terminal_command is the pane's launch command, e.g. "sh -c claude attach ID"
2681
+ tab = [
2682
+ pane
2683
+ for pane in panes
2684
+ if isinstance(pane, dict) and pane.get("tab_name") == name and not pane.get("is_plugin")
2685
+ ]
2686
+ if not tab:
2687
+ raise LookupError(f"No open tab '{name}'")
2688
+ if not all(" attach " in str(pane.get("terminal_command") or "") for pane in tab):
2689
+ raise LookupError(f"'{name}' runs Claude itself: closing it would stop Claude")
2690
+ return str(tab[0].get("tab_id"))
2691
+
2463
2692
  @work(thread=True, group="ghostty_tab")
2464
2693
  def _open_ghostty_tab_worker(self, shell_cmd: str) -> None:
2465
2694
  """Drive Ghostty via clipboard paste in a background thread.
@@ -18,6 +18,7 @@ from pathlib import Path
18
18
  from . import __version__
19
19
  from .services import forge
20
20
  from .services.agent_sessions import AgentSession, list_agent_sessions
21
+ from .services.claude_bg import background_sessions, busy, end_sessions
21
22
  from .services.config import Config, ConfigError
22
23
  from .services.epic_ask import (
23
24
  ASK_MAX_TURNS,
@@ -780,6 +781,38 @@ def _archive_for_delete(
780
781
  return True, saved
781
782
 
782
783
 
784
+ def _claude_sessions_to_end(
785
+ config: Config, task: Task, force: bool, verb: str
786
+ ) -> list[AgentSession] | None:
787
+ """The task's background Claude sessions; None (after an error) when busy without --force."""
788
+ found = list_agent_sessions(config.claude_path, include_done=True)
789
+ if found is None and shutil.which(config.claude_path):
790
+ print(f"warning: could not check Claude sessions of task {task.name}", file=sys.stderr)
791
+ sessions = background_sessions(found or [], task.path)
792
+ working = busy(sessions)
793
+ if working and not force:
794
+ ids = ", ".join(session.agent_id for session in working)
795
+ print(
796
+ f"error: Claude is still active (working or waiting for you) in task {task.name}: {ids}",
797
+ file=sys.stderr,
798
+ )
799
+ print(f"use --force to stop it and {verb} anyway", file=sys.stderr)
800
+ return None
801
+ return sessions
802
+
803
+
804
+ def _end_claude_sessions(config: Config, sessions: list[AgentSession], force: bool) -> bool:
805
+ """Stop and remove sessions; one that will not stop keeps the task unless --force."""
806
+ failed = end_sessions(config.claude_path, sessions)
807
+ for agent_id in failed:
808
+ level = "warning" if force else "error"
809
+ print(f"{level}: could not stop Claude session {agent_id}", file=sys.stderr)
810
+ if failed and not force:
811
+ print("stop it with `claude stop ID`, or use --force", file=sys.stderr)
812
+ return False
813
+ return True
814
+
815
+
783
816
  def cmd_delete(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
784
817
  """Delete a task after a safety check (bypass with --force)."""
785
818
  task = manager.get_task(args.name)
@@ -795,6 +828,9 @@ def cmd_delete(manager: TaskManager, config: Config, args: argparse.Namespace) -
795
828
  print(f" {issue.repo_name}: {issue.details}", file=sys.stderr)
796
829
  print("use --force to delete anyway", file=sys.stderr)
797
830
  return 1
831
+ sessions = _claude_sessions_to_end(config, task, args.force, "delete")
832
+ if sessions is None or not _end_claude_sessions(config, sessions, args.force):
833
+ return 1
798
834
 
799
835
  # Same safety net as the TUI: the remaining diff and the task files are
800
836
  # archived before the worktrees and branches are removed, --force included
@@ -813,6 +849,10 @@ def cmd_finish(manager: TaskManager, config: Config, args: argparse.Namespace) -
813
849
  return _error(f"no such task: {args.name}")
814
850
  if _refuse_epic_with_children(manager, task, "finish"):
815
851
  return 1
852
+ # Before --push: a busy Claude refuses the finish without touching the remote
853
+ sessions = _claude_sessions_to_end(config, task, args.force, "finish")
854
+ if sessions is None:
855
+ return 1
816
856
 
817
857
  # Push unconditionally when asked (TUI parity: `git push -u origin HEAD`).
818
858
  # Gating on has_unpushed() would skip never-pushed --no-track branches,
@@ -839,6 +879,8 @@ def cmd_finish(manager: TaskManager, config: Config, args: argparse.Namespace) -
839
879
  print("use --push to push branches first", file=sys.stderr)
840
880
  print("use --force to finish anyway", file=sys.stderr)
841
881
  return 1
882
+ if not _end_claude_sessions(config, sessions, args.force):
883
+ return 1
842
884
 
843
885
  if not args.no_archive:
844
886
  notes = [f"{issue.repo_name}: {issue.details}" for issue in report.merged_via_forge]
@@ -36,6 +36,11 @@ APP_COMMANDS: list[tuple[str, str, str]] = [
36
36
  "Dispatch Claude Agent",
37
37
  "Run a background Claude session in the selected worktree",
38
38
  ),
39
+ (
40
+ "close_claude_tab",
41
+ "Close Claude Tab",
42
+ "Close the zellij tab of a background Claude session; Claude keeps running",
43
+ ),
39
44
  (
40
45
  "open_claude_gui_code",
41
46
  "Open Claude Desktop",