tasktree-manager 2.13.1__tar.gz → 2.13.2__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 (77) hide show
  1. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/.github/workflows/release.yml +17 -2
  2. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/.gitignore +1 -1
  3. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/CHANGELOG.md +15 -0
  4. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/PKG-INFO +1 -1
  5. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/README.md +4 -2
  6. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/docs/configuration.md +7 -0
  7. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/docs/user-guide.md +3 -2
  8. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/pyproject.toml +1 -1
  9. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/scripts/bump_version.py +17 -4
  10. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tasktree_manager/_version.py +2 -2
  11. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tasktree_manager/app.py +170 -34
  12. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tasktree_manager/cli.py +48 -15
  13. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tasktree_manager/services/agent_sessions.py +1 -0
  14. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tasktree_manager/services/claude_hooks.py +64 -26
  15. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tasktree_manager/services/config.py +54 -11
  16. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tasktree_manager/services/forge.py +1 -0
  17. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tasktree_manager/services/git_ops.py +14 -5
  18. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tasktree_manager/services/models.py +3 -1
  19. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tasktree_manager/services/task_manager.py +278 -53
  20. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tasktree_manager/widgets/create_modal.py +9 -4
  21. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tasktree_manager/widgets/setup_modal.py +8 -4
  22. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tasktree_manager/widgets/task_list.py +9 -12
  23. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tasktree_manager/widgets/worktree_list.py +9 -2
  24. tasktree_manager-2.13.2/tests/test_agent_sessions_decode.py +31 -0
  25. tasktree_manager-2.13.2/tests/test_app_review_fixes.py +511 -0
  26. tasktree_manager-2.13.2/tests/test_bump_version.py +135 -0
  27. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tests/test_claude_hooks.py +142 -0
  28. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tests/test_claude_sessions.py +21 -0
  29. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tests/test_cli.py +153 -0
  30. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tests/test_config.py +131 -1
  31. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tests/test_forge.py +21 -0
  32. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tests/test_git_ops.py +64 -0
  33. tasktree_manager-2.13.2/tests/test_list_selection.py +130 -0
  34. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tests/test_modals.py +78 -0
  35. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tests/test_task_manager.py +412 -1
  36. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tests/test_worktree_badges.py +1 -1
  37. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/.claude/skills/textual-tui/SKILL.md +0 -0
  38. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/.claude/skills/textual-tui/assets/README.md +0 -0
  39. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
  40. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
  41. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
  42. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
  43. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/.claude/skills/textual-tui/references/layouts.md +0 -0
  44. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
  45. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/.claude/skills/textual-tui/references/styling.md +0 -0
  46. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/.claude/skills/textual-tui/references/widgets.md +0 -0
  47. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/.github/templates/CHANGELOG.md.j2 +0 -0
  48. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/.github/workflows/ci.yml +0 -0
  49. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/CONTRIBUTING.md +0 -0
  50. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/LICENSE +0 -0
  51. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/OPENSPEC.md +0 -0
  52. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/ROADMAP.md +0 -0
  53. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/docs/claude-code-integration-spec.md +0 -0
  54. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/docs/installation.md +0 -0
  55. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/docs/troubleshooting.md +0 -0
  56. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/mise.toml +0 -0
  57. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tasktree_manager/__init__.py +0 -0
  58. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tasktree_manager/commands.py +0 -0
  59. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tasktree_manager/services/__init__.py +0 -0
  60. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tasktree_manager/services/claude_sessions.py +0 -0
  61. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tasktree_manager/themes.py +0 -0
  62. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tasktree_manager/widgets/__init__.py +0 -0
  63. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tasktree_manager/widgets/app_header.py +0 -0
  64. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tasktree_manager/widgets/messages_panel.py +0 -0
  65. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tasktree_manager/widgets/status_panel.py +0 -0
  66. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tests/__init__.py +0 -0
  67. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tests/conftest.py +0 -0
  68. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tests/test_agent_sessions.py +0 -0
  69. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tests/test_app.py +0 -0
  70. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tests/test_commands.py +0 -0
  71. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tests/test_integration_git.py +0 -0
  72. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tests/test_medium_fixes.py +0 -0
  73. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tests/test_memory.py +0 -0
  74. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tests/test_messages_panel.py +0 -0
  75. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tests/test_safety_fixes.py +0 -0
  76. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tests/test_setup_modal.py +0 -0
  77. {tasktree_manager-2.13.1 → tasktree_manager-2.13.2}/tests/test_status_panel.py +0 -0
@@ -14,6 +14,9 @@
14
14
  # - Manual dispatch: choose release type, pre-release, and draft options.
15
15
  # Both `draft` and `pre_release` skip the PyPI upload (the pipeline can
16
16
  # only produce final X.Y.Z versions) and only shape the GitHub release.
17
+ # workflow_dispatch can target ANY branch, not just main (branch
18
+ # protection also has enforce_admins=false), so prepare-release and
19
+ # tag-release both refuse to run off main — see their `if:` guards.
17
20
 
18
21
  name: Release
19
22
 
@@ -46,6 +49,12 @@ on:
46
49
  jobs:
47
50
  prepare-release:
48
51
  name: Prepare Release
52
+ # workflow_dispatch can target any branch; the push trigger is already
53
+ # restricted to main via `on.push.branches`, so this only closes the
54
+ # workflow_dispatch path. Everything downstream (lint/test/tag-release/
55
+ # build/publish-pypi) needs this job and is skipped by cascade when it
56
+ # is; github-release guards itself separately via needs.build.result.
57
+ if: github.ref == 'refs/heads/main'
49
58
  runs-on: ubuntu-latest
50
59
  outputs:
51
60
  new_version: ${{ steps.determine_version.outputs.new_version }}
@@ -174,6 +183,10 @@ jobs:
174
183
  tag-release:
175
184
  name: Commit and Tag
176
185
  needs: [prepare-release, lint, test]
186
+ # Belt and suspenders: this is the job that pushes to main and pushes
187
+ # the tag using the owner PAT, so it gets its own explicit ref guard
188
+ # rather than relying only on the cascade from prepare-release.
189
+ if: github.ref == 'refs/heads/main'
177
190
  runs-on: ubuntu-latest
178
191
  steps:
179
192
  - name: Checkout code
@@ -206,8 +219,10 @@ jobs:
206
219
  git diff --cached --quiet && echo "No changes to commit" || \
207
220
  git commit -m "chore(release): ${new_version} [skip ci]"
208
221
  git tag -a "v${new_version}" -m "Release v${new_version}"
209
- git push origin HEAD:main
210
- git push origin "v${new_version}"
222
+ # Atomic: one push carrying both refs, so a rejected tag (already
223
+ # exists, race with another push) can never leave main bumped
224
+ # without the tag that's supposed to go with it.
225
+ git push --atomic origin HEAD:main "v${new_version}"
211
226
 
212
227
  build:
213
228
  name: Build Package
@@ -49,7 +49,7 @@ Thumbs.db
49
49
 
50
50
  # Project specific
51
51
  *.log
52
- tasktree/_version.py
52
+ tasktree_manager/_version.py
53
53
 
54
54
  # Documentation (tracked separately)
55
55
  PRD.md
@@ -15,6 +15,21 @@ 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.13.2] - 2026-09-28
19
+
20
+ ### Changed
21
+ - Document list error state, config value types and task name rules
22
+ - **release**: Release only from main and push main and tag atomically
23
+
24
+ ### Fixed
25
+ - **tui**: Harden workers, refresh and selection handling
26
+ - **cli**: Report status errors, setup failures and missing git correctly
27
+ - **claude**: Resolve symlinked paths and write settings atomically
28
+ - **config**: Validate booleans and section tables in config.toml
29
+ - **git**: Count partially staged files as staged and modified
30
+ - **tasks**: Protect existing branches and unchecked worktrees on create/delete
31
+
32
+
18
33
  ## [2.13.1] - 2026-09-16
19
34
 
20
35
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tasktree-manager
3
- Version: 2.13.1
3
+ Version: 2.13.2
4
4
  Summary: TUI application for managing worktree-based tasks
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.10
@@ -78,7 +78,7 @@ workflows (e.g. a skill that creates a Jira ticket and then the matching task):
78
78
 
79
79
  ```bash
80
80
  tasktree-manager create DIC-1901-argocd-tls --repos backend,frontend # --base overrides the default base branch
81
- tasktree-manager list --json # tasks with repos and dirty state (plain text without --json)
81
+ tasktree-manager list --json # tasks with repos and state: clean / dirty / error (plain text without --json)
82
82
  tasktree-manager repos # available repos in REPOS_DIR
83
83
  tasktree-manager add-repo DIC-1901-argocd-tls infra
84
84
  tasktree-manager status # current task inferred from $PWD; also --json / --oneline / --forge
@@ -88,7 +88,9 @@ tasktree-manager delete DIC-1901-argocd-tls # archive diff -> delete; refuses
88
88
 
89
89
  Exit code is 0 on success, 1 on failure with the reason on stderr. `delete` and `finish` run
90
90
  the same safety check as the TUI (uncommitted, unpushed, or unmerged work blocks deletion);
91
- with `glab`/`gh` installed, squash/rebase-merged branches are recognized as merged.
91
+ with `glab`/`gh` installed, squash/rebase-merged branches are recognized as merged. If the
92
+ diff cannot be archived for any repo, the CLI stops before deleting and prints the path of
93
+ the partial archive; the TUI warns and deletes anyway.
92
94
  `status --oneline` emits a compact `TASK repoA✓ repoB●2↑1` summary that slots straight into
93
95
  a Claude Code statusline or shell prompt.
94
96
 
@@ -59,6 +59,13 @@ rm ~/.config/tasktree-manager/config.toml
59
59
  tasktree-manager
60
60
  ```
61
61
 
62
+ ### Value Types
63
+
64
+ Boolean settings take TOML `true`/`false`; the quoted strings `"true"`/`"yes"`/`"1"`
65
+ and `"false"`/`"no"`/`"0"` (any case) are accepted too. Any other value, or a section such
66
+ as `[keybindings]` set to a plain value instead of a table, stops startup with an error that
67
+ names the key.
68
+
62
69
  ## Complete Configuration Example
63
70
 
64
71
  Here's a fully annotated `config.toml` with all available options:
@@ -328,8 +328,9 @@ what is displayed. Press `?` for the full reference at any time.
328
328
  6. Tab to "Create" button, press `Enter`
329
329
 
330
330
  Task names may contain letters, numbers, `.`, `_` and `-`. The name becomes a
331
- single directory under your tasks dir (and a git branch), so `/`, `.`, `..` and a
332
- leading `-` are rejected; base branch names must be plain branch names too (no
331
+ single directory under your tasks dir (and a git branch), so `/`, a leading `.`
332
+ (hidden from the task list, and `.archive` is the archive dir) and a leading `-`
333
+ are rejected; base branch names must be plain branch names too (no
333
334
  leading `-`/`+`, no `:` refspecs, none of the characters git forbids in refs).
334
335
 
335
336
  **Clone task workflow:**
@@ -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.13.1"
74
+ version = "2.13.2"
75
75
  tag_format = "v{version}"
76
76
  branch = "main"
77
77
  commit_message = "chore(release): {version} [skip ci]"
@@ -117,8 +117,14 @@ def update_pyproject_version(current_version: str, new_version: str, dry_run: bo
117
117
  if in_section and re.match(
118
118
  r'^version\s*=\s*"' + re.escape(current_version) + '"', stripped
119
119
  ):
120
- line = line.replace(f'"{current_version}"', f'"{new_version}"')
121
- changed = True
120
+ new_line = line.replace(f'"{current_version}"', f'"{new_version}"')
121
+ # A `--set` to the version that's already current (e.g. a retried
122
+ # release re-running against an already-bumped commit) makes the
123
+ # regex match but shouldn't count as a change — only the text
124
+ # actually differing does.
125
+ if new_line != line:
126
+ changed = True
127
+ line = new_line
122
128
 
123
129
  updated_lines.append(line)
124
130
 
@@ -236,6 +242,15 @@ def update_changelog(new_version: str, message: str = "", dry_run: bool = False)
236
242
  "and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n"
237
243
  )
238
244
 
245
+ content = CHANGELOG_PATH.read_text() if CHANGELOG_PATH.exists() else ""
246
+
247
+ # A retried release (e.g. re-running after tag-release failed partway)
248
+ # can call this again for a version that's already documented — don't
249
+ # insert a second copy of its section.
250
+ if f"## [{new_version}]" in content:
251
+ print(f"CHANGELOG.md already has an entry for version {new_version}, skipping")
252
+ return False
253
+
239
254
  today = datetime.now().strftime("%Y-%m-%d")
240
255
 
241
256
  if message:
@@ -252,8 +267,6 @@ def update_changelog(new_version: str, message: str = "", dry_run: bool = False)
252
267
  print(f"Would add new entry to CHANGELOG.md for version {new_version}")
253
268
  return True
254
269
 
255
- content = CHANGELOG_PATH.read_text()
256
-
257
270
  # Insert after [Unreleased] section or at top after header
258
271
  if "## [Unreleased]" in content:
259
272
  # Find [Unreleased] section and insert new version after it
@@ -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.13.1'
22
- __version_tuple__ = version_tuple = (2, 13, 1)
21
+ __version__ = version = '2.13.2'
22
+ __version_tuple__ = version_tuple = (2, 13, 2)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -536,7 +536,7 @@ class TaskTreeApp(App):
536
536
  self.config.save()
537
537
  except OSError as e:
538
538
  self.push_screen(
539
- SetupModal(error_message=f"Could not set up directories: {e}"),
539
+ SetupModal(error_message=escape(f"Could not set up directories: {e}")),
540
540
  handle_setup,
541
541
  )
542
542
  return
@@ -628,6 +628,11 @@ class TaskTreeApp(App):
628
628
  if self._mutation_in_flight():
629
629
  self.notify("Another task operation is still running", severity="warning")
630
630
  return False
631
+ # A periodic scan already in flight would otherwise apply its
632
+ # pre-mutation snapshot after this mutation finishes, clobbering the
633
+ # loading indicators set below (see the worker check in
634
+ # _apply_refreshed_tasks, its belt-and-suspenders counterpart).
635
+ self.workers.cancel_group(self, "auto_refresh")
631
636
  for widget_id in loading_widget_ids:
632
637
  self.query_one(f"#{widget_id}").loading = True
633
638
  return True
@@ -642,12 +647,19 @@ class TaskTreeApp(App):
642
647
  statuses: dict[str, str] = {}
643
648
  for task in task_list.tasks:
644
649
  status_file = task.path / ".claude_status"
645
- if status_file.exists():
646
- try:
647
- data = json.loads(status_file.read_text())
648
- statuses[task.name] = data.get("status", "unknown")
649
- except (json.JSONDecodeError, OSError):
650
- pass
650
+ if not status_file.exists():
651
+ continue
652
+ try:
653
+ data = json.loads(status_file.read_text())
654
+ except (OSError, UnicodeDecodeError, ValueError):
655
+ # ValueError also catches json.JSONDecodeError (a subclass);
656
+ # UnicodeDecodeError covers a non-UTF-8 status file
657
+ continue
658
+ if not isinstance(data, dict):
659
+ # Valid JSON but not an object (e.g. "[]") - .get() below
660
+ # would raise AttributeError and kill the poll interval
661
+ continue
662
+ statuses[task.name] = data.get("status", "unknown")
651
663
 
652
664
  if statuses != self._claude_statuses:
653
665
  self._claude_statuses = statuses
@@ -880,7 +892,12 @@ class TaskTreeApp(App):
880
892
  entirely to avoid selection/status-panel flicker. A snapshot from a
881
893
  scan that was superseded while this call sat in the queue is dropped.
882
894
  """
883
- if worker is not None and worker.is_cancelled:
895
+ # `worker` is only passed by the background auto_refresh scan. Skip
896
+ # its apply if it was cancelled, or if a mutation started after the
897
+ # scan's git calls finished but before this callback reached the
898
+ # main thread - either way this is a pre-mutation snapshot that
899
+ # must not clobber the mutation's own loading state and reload.
900
+ if worker is not None and (worker.is_cancelled or self._mutation_in_flight()):
884
901
  return
885
902
  try:
886
903
  task_list = self.query_one("#task-list", TaskList)
@@ -896,7 +913,6 @@ class TaskTreeApp(App):
896
913
  fingerprint = self._tasks_fingerprint(tasks)
897
914
  if not force_ui and fingerprint == self._last_tasks_fingerprint:
898
915
  return
899
- self._last_tasks_fingerprint = fingerprint
900
916
  self._update_header_stats(tasks)
901
917
 
902
918
  # Prune forge entries for worktrees that no longer exist — a deleted
@@ -927,6 +943,7 @@ class TaskTreeApp(App):
927
943
  status_panel.clear_status()
928
944
  self._set_counter("task-panel-counter", "")
929
945
  self._set_counter("worktree-panel-counter", "")
946
+ self._last_tasks_fingerprint = fingerprint
930
947
  return
931
948
 
932
949
  # The worktree list is (re)loaded by the TaskHighlighted handler
@@ -939,8 +956,13 @@ class TaskTreeApp(App):
939
956
  self.current_task = t
940
957
  break
941
958
  self._push_badges_to_worktree_list()
959
+ self._last_tasks_fingerprint = fingerprint
942
960
  except Exception as e:
943
- # A worker error would exit the app; refresh glitches are not fatal
961
+ # A worker error would exit the app; refresh glitches are not
962
+ # fatal. Deliberately leave _last_tasks_fingerprint at its prior
963
+ # value: the UI never actually reflected this data, so a later
964
+ # refresh with the same (still-unapplied) snapshot must retry
965
+ # instead of being skipped as a no-op.
944
966
  self.log.error(f"Failed to apply refreshed tasks: {e}")
945
967
 
946
968
  def _run_external_command(
@@ -1288,6 +1310,8 @@ class TaskTreeApp(App):
1288
1310
  def _check_task_safety_worker(self, task: Task) -> None:
1289
1311
  """Run task safety checks in a background thread."""
1290
1312
  safety_report = self.task_manager.check_task_safety(task)
1313
+ if get_current_worker().is_cancelled:
1314
+ return
1291
1315
  self.call_from_thread(self._show_delete_task_dialog, task, safety_report)
1292
1316
 
1293
1317
  def _show_delete_task_dialog(self, task: Task, safety_report: TaskSafetyReport) -> None:
@@ -1365,11 +1389,17 @@ class TaskTreeApp(App):
1365
1389
  except Exception as e:
1366
1390
  # A failed archive must not block deletion, but it must be
1367
1391
  # loud — the archive is the safety net for the work the
1368
- # delete below is about to destroy
1392
+ # delete below is about to destroy (ArchiveIncompleteError's
1393
+ # message names the partial archive, if one was written)
1369
1394
  detail = f"Archive failed for '{task.name}': {type(e).__name__}: {e}"
1370
1395
  self.call_from_thread(self.notify, escape(detail), severity="warning", timeout=10)
1371
1396
  self.call_from_thread(self._log_activity, detail, MessageLevel.ERROR, task.name)
1372
1397
  self.task_manager.finish_task(task)
1398
+ except ValueError as e:
1399
+ # finish_task raises ValueError (nothing removed) when worktrees
1400
+ # on disk no longer match task.worktrees - the task changed
1401
+ # during the confirm dialog. Its message is already clear.
1402
+ error = str(e)
1373
1403
  except Exception as e:
1374
1404
  error = f"{type(e).__name__}: {e}"
1375
1405
  self.call_from_thread(self._apply_finish_task_result, task.name, force, error, archive_path)
@@ -1385,6 +1415,14 @@ class TaskTreeApp(App):
1385
1415
  if error:
1386
1416
  self.notify(escape(f"Failed to delete task: {error}"), severity="error")
1387
1417
  self._log_activity(f"Failed to delete task: {error}", MessageLevel.ERROR, task_name)
1418
+ if archive_path is not None:
1419
+ # The archive succeeded before the delete itself failed - the
1420
+ # user's work is not lost, but they should know where it is
1421
+ self._log_activity(
1422
+ f"Archived diff to {archive_path} before the failed delete",
1423
+ MessageLevel.INFO,
1424
+ task_name,
1425
+ )
1388
1426
  else:
1389
1427
  self.notify(escape(f"Deleted task: {task_name}"))
1390
1428
  suffix = " (force)" if force else ""
@@ -1420,6 +1458,8 @@ class TaskTreeApp(App):
1420
1458
  """Push all branches and re-run safety checks in a background thread."""
1421
1459
  success_repos, failed_repos = self.task_manager.push_all_branches(task)
1422
1460
  new_report = self.task_manager.check_task_safety(task)
1461
+ if get_current_worker().is_cancelled:
1462
+ return
1423
1463
  self.call_from_thread(
1424
1464
  self._apply_delete_push_results, task, success_repos, failed_repos, new_report
1425
1465
  )
@@ -1479,6 +1519,8 @@ class TaskTreeApp(App):
1479
1519
  def _check_worktree_safety_worker(self, task: Task, worktree: Worktree) -> None:
1480
1520
  """Run the safety check for a single worktree in a background thread."""
1481
1521
  report = self.task_manager.check_task_safety(self._worktree_view(task, worktree))
1522
+ if get_current_worker().is_cancelled:
1523
+ return
1482
1524
  self.call_from_thread(self._show_delete_worktree_dialog, task, worktree, report)
1483
1525
 
1484
1526
  def _show_delete_worktree_dialog(
@@ -1529,6 +1571,8 @@ class TaskTreeApp(App):
1529
1571
  view = self._worktree_view(task, worktree)
1530
1572
  success, failed = self.task_manager.push_all_branches(view)
1531
1573
  report = self.task_manager.check_task_safety(view)
1574
+ if get_current_worker().is_cancelled:
1575
+ return
1532
1576
  self.call_from_thread(
1533
1577
  self._apply_worktree_push_result, task, worktree, success, failed, report
1534
1578
  )
@@ -1603,6 +1647,12 @@ class TaskTreeApp(App):
1603
1647
  MessageLevel.ERROR,
1604
1648
  task_name,
1605
1649
  )
1650
+ if archive_path is not None:
1651
+ self._log_activity(
1652
+ f"Archived diff to {archive_path} before the failed delete",
1653
+ MessageLevel.INFO,
1654
+ task_name,
1655
+ )
1606
1656
  else:
1607
1657
  self.notify(escape(f"Deleted worktree: {worktree_name}"))
1608
1658
  suffix = " (force)" if force else ""
@@ -1828,13 +1878,20 @@ class TaskTreeApp(App):
1828
1878
  self._open_ghostty_tab(folder_path, command=f"{editor} .")
1829
1879
  self.notify(escape(f"Opened {editor} in new tab"))
1830
1880
 
1831
- def _prepare_claude_session(self, task_path: Path) -> None:
1832
- """Refresh CLAUDE.md files, worktree settings, and hooks before launching Claude."""
1833
- fresh_task = self.task_manager.get_task(self.current_task.name)
1881
+ def _prepare_claude_session(self, task: Task) -> None:
1882
+ """Refresh CLAUDE.md files, worktree settings, and hooks before launching Claude.
1883
+
1884
+ Must run off the UI thread: ensure_claude_md_files can shell out to
1885
+ git (symbolic-ref/show, each with a 10s timeout) once per worktree
1886
+ missing a CLAUDE.md. Every caller runs this inside a
1887
+ @work(thread=True) worker and passes the task explicitly rather than
1888
+ reading self.current_task, which is not safe to read from a thread.
1889
+ """
1890
+ fresh_task = self.task_manager.get_task(task.name)
1834
1891
  if fresh_task:
1835
1892
  self.task_manager.ensure_claude_md_files(fresh_task)
1836
1893
  self.task_manager.ensure_worktree_settings(fresh_task)
1837
- ensure_claude_hooks(task_path, self.config.claude_memory_dir)
1894
+ ensure_claude_hooks(task.path, self.config.claude_memory_dir)
1838
1895
 
1839
1896
  def action_open_claude_resume(self) -> None:
1840
1897
  """Open Claude Code in a new Ghostty tab.
@@ -1846,13 +1903,36 @@ class TaskTreeApp(App):
1846
1903
  self.notify("No task selected", severity="warning")
1847
1904
  return
1848
1905
 
1849
- task_path = self.current_task.path
1850
- if not task_path.exists():
1906
+ task = self.current_task
1907
+ if not task.path.exists():
1851
1908
  self.notify("Task directory not found", severity="error")
1852
1909
  return
1853
1910
 
1854
- self._prepare_claude_session(task_path)
1855
- if has_claude_session(task_path):
1911
+ self._open_claude_resume_worker(task)
1912
+
1913
+ @work(thread=True, group="claude_prep")
1914
+ def _open_claude_resume_worker(self, task: Task) -> None:
1915
+ """Prepare the session off the UI thread, then open the Ghostty tab.
1916
+
1917
+ A prep failure is reported but does not block the launch - stale
1918
+ CLAUDE.md content is not worth failing the session over.
1919
+ """
1920
+ try:
1921
+ self._prepare_claude_session(task)
1922
+ except Exception as e:
1923
+ self.call_from_thread(
1924
+ self.notify,
1925
+ escape(f"Claude session prep failed for '{task.name}': {type(e).__name__}: {e}"),
1926
+ severity="warning",
1927
+ )
1928
+ resume = has_claude_session(task.path)
1929
+ if get_current_worker().is_cancelled:
1930
+ return
1931
+ self.call_from_thread(self._open_claude_resume_tab, task.path, resume)
1932
+
1933
+ def _open_claude_resume_tab(self, task_path: Path, resume: bool) -> None:
1934
+ """Open the Ghostty tab for a prepared Claude session (main thread)."""
1935
+ if resume:
1856
1936
  self._open_ghostty_tab(task_path, command=f"{self.config.claude_path} -r")
1857
1937
  self.notify("Opened Claude Code in new tab (resume)")
1858
1938
  else:
@@ -1870,27 +1950,39 @@ class TaskTreeApp(App):
1870
1950
  self.notify("Worktree directory not found", severity="error")
1871
1951
  return
1872
1952
 
1873
- self._prepare_claude_session(task.path)
1874
1953
  session_name = f"{task.name}/{worktree.name}"
1875
1954
 
1876
1955
  def handle_prompt(prompt: str | None) -> None:
1877
1956
  if not prompt:
1878
1957
  return
1879
1958
  self.notify(escape(f"Dispatching agent in '{worktree.name}'…"))
1880
- self._dispatch_agent_worker(session_name, worktree.name, str(worktree.path), prompt)
1959
+ self._dispatch_agent_worker(
1960
+ task, session_name, worktree.name, str(worktree.path), prompt
1961
+ )
1881
1962
 
1882
1963
  self.push_screen(DispatchAgentModal(session_name), handle_prompt)
1883
1964
 
1884
1965
  @work(thread=True, group="agent_dispatch")
1885
1966
  def _dispatch_agent_worker(
1886
- self, session_name: str, wt_name: str, wt_path: str, prompt: str
1967
+ self, task: Task, session_name: str, wt_name: str, wt_path: str, prompt: str
1887
1968
  ) -> None:
1888
- """Run `claude --bg` in the worktree.
1969
+ """Prepare the session, then run `claude --bg` in the worktree.
1889
1970
 
1890
1971
  Deliberately not exclusive: dispatching agents to different worktrees
1891
1972
  in parallel is legitimate. `--bg` detaches and returns promptly; the
1892
- 30s timeout only guards against a hung CLI.
1973
+ 30s timeout only guards against a hung CLI. Session prep can shell
1974
+ out to git per worktree, so it belongs in this thread worker too -
1975
+ never on the UI thread. A prep failure is reported but does not
1976
+ block dispatch.
1893
1977
  """
1978
+ try:
1979
+ self._prepare_claude_session(task)
1980
+ except Exception as e:
1981
+ self.call_from_thread(
1982
+ self.notify,
1983
+ escape(f"Claude session prep failed for '{wt_name}': {type(e).__name__}: {e}"),
1984
+ severity="warning",
1985
+ )
1894
1986
  try:
1895
1987
  # "--" terminates option parsing: a prompt starting with "-"
1896
1988
  # must reach claude as the prompt, not be eaten as CLI flags
@@ -1946,27 +2038,62 @@ class TaskTreeApp(App):
1946
2038
 
1947
2039
  def action_open_claude_gui_code(self) -> None:
1948
2040
  """Open Claude desktop app on the Code page in the current task folder."""
1949
- from urllib.parse import quote
1950
-
1951
2041
  if not self.current_task:
1952
2042
  self.notify("No task selected", severity="warning")
1953
2043
  return
1954
2044
 
1955
- task_path = self.current_task.path
1956
- if not task_path.exists():
2045
+ task = self.current_task
2046
+ if not task.path.exists():
1957
2047
  self.notify("Task directory not found", severity="error")
1958
2048
  return
1959
2049
 
1960
- self._prepare_claude_session(task_path)
1961
- encoded_path = quote(str(task_path), safe="")
2050
+ self._open_claude_gui_code_worker(task)
2051
+
2052
+ @work(thread=True, group="claude_prep")
2053
+ def _open_claude_gui_code_worker(self, task: Task) -> None:
2054
+ """Prepare the session, then open Claude desktop, off the UI thread."""
2055
+ from urllib.parse import quote
2056
+
2057
+ try:
2058
+ self._prepare_claude_session(task)
2059
+ except Exception as e:
2060
+ self.call_from_thread(
2061
+ self.notify,
2062
+ escape(f"Claude session prep failed for '{task.name}': {type(e).__name__}: {e}"),
2063
+ severity="warning",
2064
+ )
2065
+
2066
+ encoded_path = quote(str(task.path), safe="")
1962
2067
  url = f"claude://code/new?folder={encoded_path}"
1963
2068
  try:
1964
- subprocess.run(["open", url], check=False)
1965
- self.notify("Opened Claude desktop on Code page")
2069
+ result = subprocess.run(["open", url], capture_output=True, text=True)
1966
2070
  except FileNotFoundError:
1967
- self.notify("'open' command not found (macOS only)", severity="error")
2071
+ self.call_from_thread(
2072
+ self.notify, "'open' command not found (macOS only)", severity="error"
2073
+ )
2074
+ return
1968
2075
  except Exception as e:
1969
- self.notify(escape(f"Failed to open Claude desktop: {e}"), severity="error")
2076
+ self.call_from_thread(
2077
+ self.notify, escape(f"Failed to open Claude desktop: {e}"), severity="error"
2078
+ )
2079
+ return
2080
+
2081
+ if get_current_worker().is_cancelled:
2082
+ return
2083
+
2084
+ if result.returncode != 0:
2085
+ detail = (result.stderr or result.stdout or "unknown error").strip()
2086
+ self.call_from_thread(
2087
+ self.notify, escape(f"Failed to open Claude desktop: {detail}"), severity="error"
2088
+ )
2089
+ self.call_from_thread(
2090
+ self._log_activity,
2091
+ f"Failed to open Claude desktop for '{task.name}': {detail}",
2092
+ MessageLevel.ERROR,
2093
+ task.name,
2094
+ )
2095
+ return
2096
+ self.call_from_thread(self.notify, "Opened Claude desktop on Code page")
1970
2097
 
1971
2098
  def action_open_folder(self) -> None:
1972
2099
  """Open current folder in a new terminal tab."""
@@ -2088,6 +2215,12 @@ class TaskTreeApp(App):
2088
2215
  if not self.current_task:
2089
2216
  self.notify("No task selected", severity="warning")
2090
2217
  return
2218
+ if self._mutation_in_flight():
2219
+ # A delete worker can be rmtree'ing these same worktrees right
2220
+ # now; pushing into that race would run git against a tree
2221
+ # that's disappearing under it.
2222
+ self.notify("Another task operation is still running", severity="warning")
2223
+ return
2091
2224
 
2092
2225
  worktree_list = self.query_one("#worktree-list", WorktreeList)
2093
2226
  worktree_list.loading = True
@@ -2108,6 +2241,9 @@ class TaskTreeApp(App):
2108
2241
  if not self.current_task:
2109
2242
  self.notify("No task selected", severity="warning")
2110
2243
  return
2244
+ if self._mutation_in_flight():
2245
+ self.notify("Another task operation is still running", severity="warning")
2246
+ return
2111
2247
 
2112
2248
  worktree_list = self.query_one("#worktree-list", WorktreeList)
2113
2249
  worktree_list.loading = True