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.
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/CHANGELOG.md +6 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/PKG-INFO +1 -1
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/README.md +2 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/docs/configuration.md +10 -1
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/docs/user-guide.md +32 -3
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/pyproject.toml +1 -1
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/_version.py +2 -2
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/app.py +238 -9
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/cli.py +42 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/commands.py +5 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/services/agent_sessions.py +34 -12
- tasktree_manager-2.20.0/tasktree_manager/services/claude_bg.py +138 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/services/config.py +12 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/services/epic_ask.py +2 -11
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/widgets/create_modal.py +6 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/conftest.py +14 -1
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_agent_sessions.py +20 -0
- tasktree_manager-2.20.0/tests/test_claude_bg.py +533 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_epic_ask.py +1 -1
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.claude/skills/textual-tui/SKILL.md +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.claude/skills/textual-tui/assets/README.md +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.claude/skills/textual-tui/references/layouts.md +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.claude/skills/textual-tui/references/styling.md +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.claude/skills/textual-tui/references/widgets.md +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.github/templates/CHANGELOG.md.j2 +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.github/workflows/ci.yml +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.github/workflows/release.yml +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/.gitignore +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/CONTRIBUTING.md +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/LICENSE +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/ROADMAP.md +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/docs/claude-code-integration-spec.md +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/docs/installation.md +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/docs/troubleshooting.md +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/mise.toml +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/scripts/bump_version.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/__init__.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/services/__init__.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/services/claude_hooks.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/services/claude_sessions.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/services/epic_log.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/services/forge.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/services/git_ops.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/services/models.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/services/task_manager.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/themes.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/widgets/__init__.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/widgets/app_header.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/widgets/messages_panel.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/widgets/setup_modal.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/widgets/status_panel.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/widgets/task_list.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tasktree_manager/widgets/worktree_list.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/__init__.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_agent_sessions_decode.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_app.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_app_review_fixes.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_bump_version.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_claude_hooks.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_claude_sessions.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_cli.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_commands.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_config.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_epic_claude_md.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_epic_log.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_epic_tui.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_epics.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_forge.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_git_ops.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_integration_git.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_list_selection.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_medium_fixes.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_memory.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_messages_panel.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_modals.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_review_findings.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_safety_fixes.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_setup_modal.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_status_panel.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_task_manager.py +0 -0
- {tasktree_manager-2.19.0 → tasktree_manager-2.20.0}/tests/test_terminal_tabs.py +0 -0
- {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
|
|
@@ -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
|
|
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 |
|
|
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`
|
|
460
|
-
|
|
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.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (2,
|
|
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
|
-
|
|
1415
|
-
|
|
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
|
-
|
|
2148
|
-
|
|
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.
|
|
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",
|