tasktree-manager 2.16.1__tar.gz → 2.18.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.16.1 → tasktree_manager-2.18.0}/CHANGELOG.md +12 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/PKG-INFO +1 -1
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/README.md +22 -3
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/docs/user-guide.md +25 -1
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/pyproject.toml +1 -1
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tasktree_manager/_version.py +2 -2
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tasktree_manager/cli.py +326 -3
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tasktree_manager/services/agent_sessions.py +7 -1
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tasktree_manager/services/claude_sessions.py +94 -9
- tasktree_manager-2.18.0/tasktree_manager/services/epic_ask.py +233 -0
- tasktree_manager-2.18.0/tasktree_manager/services/epic_log.py +124 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tasktree_manager/services/task_manager.py +200 -2
- tasktree_manager-2.18.0/tests/test_epic_ask.py +400 -0
- tasktree_manager-2.18.0/tests/test_epic_log.py +438 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/.claude/skills/textual-tui/SKILL.md +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/.claude/skills/textual-tui/assets/README.md +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/.claude/skills/textual-tui/references/layouts.md +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/.claude/skills/textual-tui/references/styling.md +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/.claude/skills/textual-tui/references/widgets.md +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/.github/templates/CHANGELOG.md.j2 +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/.github/workflows/ci.yml +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/.github/workflows/release.yml +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/.gitignore +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/CONTRIBUTING.md +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/LICENSE +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/ROADMAP.md +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/docs/claude-code-integration-spec.md +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/docs/configuration.md +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/docs/installation.md +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/docs/troubleshooting.md +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/mise.toml +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/scripts/bump_version.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tasktree_manager/__init__.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tasktree_manager/app.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tasktree_manager/commands.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tasktree_manager/services/__init__.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tasktree_manager/services/claude_hooks.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tasktree_manager/services/config.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tasktree_manager/services/forge.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tasktree_manager/services/git_ops.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tasktree_manager/services/models.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tasktree_manager/themes.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tasktree_manager/widgets/__init__.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tasktree_manager/widgets/app_header.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tasktree_manager/widgets/create_modal.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tasktree_manager/widgets/messages_panel.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tasktree_manager/widgets/setup_modal.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tasktree_manager/widgets/status_panel.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tasktree_manager/widgets/task_list.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tasktree_manager/widgets/worktree_list.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/__init__.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/conftest.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/test_agent_sessions.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/test_agent_sessions_decode.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/test_app.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/test_app_review_fixes.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/test_bump_version.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/test_claude_hooks.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/test_claude_sessions.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/test_cli.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/test_commands.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/test_config.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/test_epic_claude_md.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/test_epic_tui.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/test_epics.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/test_forge.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/test_git_ops.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/test_integration_git.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/test_list_selection.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/test_medium_fixes.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/test_memory.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/test_messages_panel.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/test_modals.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/test_safety_fixes.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/test_setup_modal.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/test_status_panel.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/test_task_manager.py +0 -0
- {tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tests/test_worktree_badges.py +0 -0
|
@@ -15,6 +15,18 @@ 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.18.0] - 2026-10-20
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- **epic**: Ask another task's session with epic ask (#14)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
## [2.17.0] - 2026-10-20
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- **epic**: Add an epic log for notes shared across task sessions (#13)
|
|
28
|
+
|
|
29
|
+
|
|
18
30
|
## [2.16.1] - 2026-10-19
|
|
19
31
|
|
|
20
32
|
### Fixed
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
- 🛰️ **Agent Dashboard** - Live Claude Code session state per worktree (from `claude agents`), plus one-key background-agent dispatch — the multi-repo agent view Claude Code lacks natively
|
|
23
23
|
- ✻ **Session Recap** - The Info panel shows each task's latest Claude Code session: title, last turn duration, and Claude's own idle recap of what was done and what comes next — read straight from the transcript, no CLI call
|
|
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
|
-
- 🗂️ **Epics** - Group related tasks under an epic: a foldable row with rolled-up dirty and Claude state, `E` to attach tasks
|
|
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
|
- 📦 **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
|
|
27
27
|
|
|
28
28
|
## 📊 Status Indicators
|
|
@@ -100,15 +100,34 @@ tasktree-manager epic add DIC-2466-pcaps-epic DIC-2862-pcaps-s3 # attach or mov
|
|
|
100
100
|
tasktree-manager epic remove DIC-2862-pcaps-s3 # detach
|
|
101
101
|
tasktree-manager epic mark DIC-2466-pcaps-epic # turn an existing task into an epic; unmark reverts
|
|
102
102
|
tasktree-manager epic list # epics and their tasks; also --json
|
|
103
|
+
tasktree-manager epic note "chose layout A for the S3 keys" # add a note to this task's file in the epic log
|
|
104
|
+
tasktree-manager epic log # notes and latest session recap of every task in the epic
|
|
105
|
+
tasktree-manager epic log DIC-2862-pcaps-s3 --json # one task (or a whole epic by its name), as JSON
|
|
106
|
+
tasktree-manager epic ask DIC-2862-pcaps-s3 "which bucket?" # a read-only copy of that task's latest session answers
|
|
107
|
+
tasktree-manager epic sync # refresh the CLAUDE.md epic block after an upgrade
|
|
103
108
|
```
|
|
104
109
|
|
|
105
110
|
Each task's `CLAUDE.md` gets a managed block naming its epic and how to find sibling
|
|
106
|
-
tasks; it follows every epic change and leaves the rest of the file alone.
|
|
111
|
+
tasks; it follows every epic change and leaves the rest of the file alone. The block
|
|
112
|
+
also tells the session about the epic log: one notes file per task in `epic-log/` in the
|
|
113
|
+
epic folder, written with `epic note` and read with `epic log`, which adds the recap of each
|
|
114
|
+
task's latest Claude session (in the task folder or one of its worktrees). Inside a task
|
|
115
|
+
folder, `epic note` writes only that task's notes and `epic log` reads only its epic;
|
|
116
|
+
`epic note -` reads the text from stdin. Notes of a task that left the epic stay readable.
|
|
117
|
+
`epic ask TASK "question"` resumes a copy of that task's latest Claude session to answer:
|
|
118
|
+
forked and not saved, hooks off, only the Read/Grep/Glob tools, no MCP servers, so the
|
|
119
|
+
task's own session and status stay untouched; credential files (`~/.ssh`, `~/.aws`, `.env`
|
|
120
|
+
and similar) are denied to it. It exits 3 without asking when the task's session is
|
|
121
|
+
running; the calling session then messages it with SendMessage (`--fork` asks a copy
|
|
122
|
+
anyway). Each turn of the copy is a model call on that session's context, at most
|
|
123
|
+
`--max-turns` (default 8); `--model sonnet` makes it cheaper.
|
|
124
|
+
Epics don't
|
|
107
125
|
nest. `list --json` and `status --json` carry `kind` (`task` or `epic`) and
|
|
108
126
|
`epic` (the parent epic or `null`). `delete` and `finish` refuse an epic that still has
|
|
109
127
|
tasks, even with `--force`.
|
|
110
128
|
|
|
111
|
-
Exit code is 0 on success, 1 on failure with the reason on stderr
|
|
129
|
+
Exit code is 0 on success, 1 on failure with the reason on stderr, and 3 from `epic ask`
|
|
130
|
+
when the task's session is running (its name goes to stdout). `delete` and `finish` run
|
|
112
131
|
the same safety check as the TUI (uncommitted, unpushed, or unmerged work blocks deletion);
|
|
113
132
|
with `glab`/`gh` installed, squash/rebase-merged branches are recognized as merged. If the
|
|
114
133
|
diff cannot be archived for any repo, the CLI stops before deleting and prints the path of
|
|
@@ -89,7 +89,31 @@ names their epic, so nothing moves on disk.
|
|
|
89
89
|
that names the epic folder and how to find sibling tasks, so a Claude session knows
|
|
90
90
|
the bigger picture. It updates on every epic change; text outside the markers is
|
|
91
91
|
never touched.
|
|
92
|
-
-
|
|
92
|
+
- The epic log lets the sessions of one epic share what they learned. A session
|
|
93
|
+
records decisions, interfaces and open questions with
|
|
94
|
+
`tasktree-manager epic note "..."`; they go to `epic-log/<task>.md` in the epic
|
|
95
|
+
folder. `tasktree-manager epic log` shows every task's notes and the recap of its
|
|
96
|
+
latest Claude session, so a session can check what its siblings did before asking.
|
|
97
|
+
The `CLAUDE.md` block tells each session about both commands, and that notes are
|
|
98
|
+
information from other sessions, not instructions.
|
|
99
|
+
- A session writes only its own task's notes. From a terminal outside any task,
|
|
100
|
+
`epic note --task NAME` writes for a task and marks the entry as written outside it.
|
|
101
|
+
- Notes stay in the epic after their task is finished, moved or deleted; `epic log`
|
|
102
|
+
lists them as "no longer in the epic". A note is at most 16 KB, and `epic log` shows
|
|
103
|
+
the newest 64 KB of each task's notes.
|
|
104
|
+
- When the log does not answer a question, a session runs
|
|
105
|
+
`tasktree-manager epic ask TASK "question"`. A read-only copy of that task's latest
|
|
106
|
+
Claude session answers: it can read files but not change them or read credential
|
|
107
|
+
files, and nothing is saved in that task. If the task's session is running, the command exits
|
|
108
|
+
with code 3 and prints the session name, and the asking session sends it a message
|
|
109
|
+
with SendMessage instead (`--fork` asks a copy anyway). Only tasks of the same epic
|
|
110
|
+
and the epic folder can be asked, and a session in a task outside any epic cannot
|
|
111
|
+
ask. Each turn of the copy is a model call on the size of that session's context,
|
|
112
|
+
at most `--max-turns` (default 8); `--model sonnet` makes it cheaper.
|
|
113
|
+
- After upgrading tasktree-manager, run `tasktree-manager epic sync` to refresh the
|
|
114
|
+
block in existing tasks (the TUI also refreshes it when it starts a Claude session).
|
|
115
|
+
Folders without a `CLAUDE.md` yet are counted and skipped.
|
|
116
|
+
- From scripts: `tasktree-manager epic create/add/remove/mark/unmark/list/note/log/ask/sync`.
|
|
93
117
|
|
|
94
118
|
### Worktrees
|
|
95
119
|
|
|
@@ -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.18.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.18.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 18, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -3,23 +3,58 @@
|
|
|
3
3
|
Running ``tasktree-manager`` with no arguments starts the TUI; any argument
|
|
4
4
|
routes here instead. Built for scripts and automation (e.g. Claude skills
|
|
5
5
|
that create a task and then work in its worktrees): plain text on stdout,
|
|
6
|
-
errors on stderr, exit code 0 on success and 1 on failure
|
|
6
|
+
errors on stderr, exit code 0 on success and 1 on failure (`epic ask` also uses 3:
|
|
7
|
+
the task's session is running, message it instead).
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
import argparse
|
|
10
11
|
import json
|
|
11
12
|
import os
|
|
13
|
+
import re
|
|
12
14
|
import shutil
|
|
13
15
|
import sys
|
|
16
|
+
from datetime import timezone
|
|
14
17
|
from pathlib import Path
|
|
15
18
|
|
|
16
19
|
from . import __version__
|
|
17
20
|
from .services import forge
|
|
21
|
+
from .services.agent_sessions import AgentSession, list_agent_sessions
|
|
18
22
|
from .services.config import Config, ConfigError
|
|
23
|
+
from .services.epic_ask import (
|
|
24
|
+
ASK_MAX_TURNS,
|
|
25
|
+
ASK_QUESTION_MAX_BYTES,
|
|
26
|
+
ASK_TIMEOUT,
|
|
27
|
+
ASK_TIMEOUT_MAX,
|
|
28
|
+
LOOP_GUARD_ENV,
|
|
29
|
+
AskError,
|
|
30
|
+
AskTarget,
|
|
31
|
+
build_ask_command,
|
|
32
|
+
live_sessions,
|
|
33
|
+
pick_session,
|
|
34
|
+
resolve_ask_task,
|
|
35
|
+
run_ask,
|
|
36
|
+
)
|
|
37
|
+
from .services.epic_log import (
|
|
38
|
+
EPIC_ROLE,
|
|
39
|
+
FORMER_ROLE,
|
|
40
|
+
EpicLogEntry,
|
|
41
|
+
read_epic_log,
|
|
42
|
+
resolve_log_target,
|
|
43
|
+
)
|
|
19
44
|
from .services.git_ops import GitOps
|
|
20
|
-
from .services.models import
|
|
45
|
+
from .services.models import (
|
|
46
|
+
EPIC_KIND,
|
|
47
|
+
TASK_KIND,
|
|
48
|
+
ArchiveResult,
|
|
49
|
+
Task,
|
|
50
|
+
parent_epic_name,
|
|
51
|
+
same_folder,
|
|
52
|
+
)
|
|
21
53
|
from .services.task_manager import ARCHIVE_NOTES_SHOWN, ArchiveIncompleteError, TaskManager
|
|
22
54
|
|
|
55
|
+
# C0/C1 controls except tab; notes are split on newlines before this runs
|
|
56
|
+
_CONTROL_CHARS = re.compile(r"[\x00-\x08\x0a-\x1f\x7f-\x9f]")
|
|
57
|
+
|
|
23
58
|
|
|
24
59
|
def build_parser() -> argparse.ArgumentParser:
|
|
25
60
|
"""Build the argument parser for the headless CLI."""
|
|
@@ -79,6 +114,58 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
79
114
|
epic_list.add_argument("--json", action="store_true", dest="as_json", help="Output JSON")
|
|
80
115
|
epic_list.set_defaults(func=cmd_epic_list)
|
|
81
116
|
|
|
117
|
+
epic_note = epic_sub.add_parser("note", help="Add a note to this task's file in the epic log")
|
|
118
|
+
epic_note.add_argument("text", help='Note text ("-" reads it from stdin)')
|
|
119
|
+
epic_note.add_argument(
|
|
120
|
+
"--task", default=None, help="Task that writes the note (default: inferred from $PWD)"
|
|
121
|
+
)
|
|
122
|
+
epic_note.set_defaults(func=cmd_epic_note)
|
|
123
|
+
|
|
124
|
+
epic_log = epic_sub.add_parser(
|
|
125
|
+
"log", help="Show the notes and latest session recap of tasks in an epic"
|
|
126
|
+
)
|
|
127
|
+
epic_log.add_argument(
|
|
128
|
+
"task",
|
|
129
|
+
nargs="?",
|
|
130
|
+
default=None,
|
|
131
|
+
help="One task of the epic (default: the epic of $PWD and all its tasks)",
|
|
132
|
+
)
|
|
133
|
+
epic_log.add_argument("--json", action="store_true", dest="as_json", help="Output JSON")
|
|
134
|
+
epic_log.set_defaults(func=cmd_epic_log)
|
|
135
|
+
|
|
136
|
+
epic_ask = epic_sub.add_parser(
|
|
137
|
+
"ask", help="Ask another task of the epic: a read-only copy of its latest session answers"
|
|
138
|
+
)
|
|
139
|
+
epic_ask.add_argument("task", help="Task (or the epic) to ask")
|
|
140
|
+
epic_ask.add_argument("question", help='Question ("-" reads it from stdin)')
|
|
141
|
+
epic_ask.add_argument("--session", default=None, help="Session id to ask (default: the newest)")
|
|
142
|
+
epic_ask.add_argument(
|
|
143
|
+
"--fork",
|
|
144
|
+
action="store_true",
|
|
145
|
+
help="Ask a copy even when the task's session is running (default: exit 3)",
|
|
146
|
+
)
|
|
147
|
+
epic_ask.add_argument("--model", default=None, help="Model for the answer (default: Claude's)")
|
|
148
|
+
epic_ask.add_argument(
|
|
149
|
+
"--timeout",
|
|
150
|
+
type=_ask_timeout,
|
|
151
|
+
default=ASK_TIMEOUT,
|
|
152
|
+
help=f"Seconds to wait for the answer (default {ASK_TIMEOUT})",
|
|
153
|
+
)
|
|
154
|
+
epic_ask.add_argument(
|
|
155
|
+
"--max-turns",
|
|
156
|
+
type=_ask_max_turns,
|
|
157
|
+
default=ASK_MAX_TURNS,
|
|
158
|
+
help=f"Turns the copy may take; each is a model call (default {ASK_MAX_TURNS})",
|
|
159
|
+
)
|
|
160
|
+
epic_ask.add_argument("--json", action="store_true", dest="as_json", help="Output JSON")
|
|
161
|
+
epic_ask.set_defaults(func=cmd_epic_ask)
|
|
162
|
+
|
|
163
|
+
epic_sync = epic_sub.add_parser(
|
|
164
|
+
"sync", help="Refresh the epic block in the CLAUDE.md of epics and their tasks"
|
|
165
|
+
)
|
|
166
|
+
epic_sync.add_argument("name", nargs="?", default=None, help="Epic name (default: all)")
|
|
167
|
+
epic_sync.set_defaults(func=cmd_epic_sync)
|
|
168
|
+
|
|
82
169
|
list_parser = sub.add_parser("list", help="List tasks with their repos and dirty state")
|
|
83
170
|
list_parser.add_argument("--json", action="store_true", dest="as_json", help="Output JSON")
|
|
84
171
|
list_parser.set_defaults(func=cmd_list)
|
|
@@ -335,6 +422,241 @@ def cmd_epic_list(manager: TaskManager, config: Config, args: argparse.Namespace
|
|
|
335
422
|
return 0
|
|
336
423
|
|
|
337
424
|
|
|
425
|
+
def cmd_epic_note(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
|
|
426
|
+
"""Append a note to the task's file in its epic log.
|
|
427
|
+
|
|
428
|
+
Inside a task folder a session writes only its own notes; from outside
|
|
429
|
+
any task, `--task` writes and the heading records that.
|
|
430
|
+
"""
|
|
431
|
+
caller = _find_task_by_cwd(manager)
|
|
432
|
+
source = None
|
|
433
|
+
if args.task:
|
|
434
|
+
task = manager.get_task(args.task)
|
|
435
|
+
if task is None:
|
|
436
|
+
return _error(f"no such task: {args.task}")
|
|
437
|
+
if caller is None:
|
|
438
|
+
source = "written outside the task folder"
|
|
439
|
+
elif not same_folder(caller.path, task.path):
|
|
440
|
+
return _error(f"inside task {caller.name}: notes go to its own file, not {task.name}")
|
|
441
|
+
elif caller is None:
|
|
442
|
+
return _error("not inside a task directory; pass --task")
|
|
443
|
+
else:
|
|
444
|
+
task = caller
|
|
445
|
+
text = sys.stdin.read() if args.text == "-" else args.text
|
|
446
|
+
path = manager.append_epic_note(task, text, source=source)
|
|
447
|
+
print(f"Noted in {path}")
|
|
448
|
+
return 0
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def cmd_epic_log(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
|
|
452
|
+
"""Show notes and the latest session recap of one task or a whole epic."""
|
|
453
|
+
epic, only = resolve_log_target(manager, _find_task_by_cwd(manager), args.task)
|
|
454
|
+
entries = read_epic_log(manager, epic, only)
|
|
455
|
+
for entry in entries:
|
|
456
|
+
if entry.problem:
|
|
457
|
+
print(f"warning: {entry.task}: {entry.problem}", file=sys.stderr)
|
|
458
|
+
if args.as_json:
|
|
459
|
+
print(json.dumps([_epic_log_json(entry) for entry in entries], indent=2))
|
|
460
|
+
return 0
|
|
461
|
+
for index, entry in enumerate(entries):
|
|
462
|
+
if index:
|
|
463
|
+
print()
|
|
464
|
+
_print_epic_log_entry(entry)
|
|
465
|
+
return 0
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _epic_log_json(entry: EpicLogEntry) -> dict:
|
|
469
|
+
session = None
|
|
470
|
+
if entry.session is not None:
|
|
471
|
+
recap = entry.recap
|
|
472
|
+
session = {
|
|
473
|
+
"id": entry.session.session_id,
|
|
474
|
+
"cwd": str(entry.session.cwd),
|
|
475
|
+
"title": recap.title if recap else None,
|
|
476
|
+
"finished_at": recap.finished_at.isoformat() if recap and recap.finished_at else None,
|
|
477
|
+
"summary": recap.summary if recap else None,
|
|
478
|
+
# The typed prompt only stands in for a missing recap, as in the TUI
|
|
479
|
+
"last_prompt": recap.last_prompt if recap and not recap.summary else None,
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
"task": entry.task,
|
|
483
|
+
"role": entry.role,
|
|
484
|
+
"notes_path": str(entry.notes_path),
|
|
485
|
+
"notes": entry.notes,
|
|
486
|
+
"notes_truncated": entry.notes_truncated,
|
|
487
|
+
"session": session,
|
|
488
|
+
"problem": entry.problem,
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _print_epic_log_entry(entry: EpicLogEntry) -> None:
|
|
493
|
+
role = {EPIC_ROLE: " (epic)", FORMER_ROLE: " (no longer in the epic)"}.get(entry.role, "")
|
|
494
|
+
print(f"== {entry.task}{role} ==")
|
|
495
|
+
recap = entry.recap
|
|
496
|
+
if entry.role == FORMER_ROLE:
|
|
497
|
+
pass
|
|
498
|
+
elif recap is None:
|
|
499
|
+
print("Last session: none recorded")
|
|
500
|
+
else:
|
|
501
|
+
when = "time unknown"
|
|
502
|
+
if recap.finished_at:
|
|
503
|
+
when = recap.finished_at.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
504
|
+
print(f"Last session: {recap.title} ({when})")
|
|
505
|
+
if recap.summary:
|
|
506
|
+
print(f" Recap: {recap.summary}")
|
|
507
|
+
elif recap.last_prompt:
|
|
508
|
+
print(f" Last prompt: {recap.last_prompt}")
|
|
509
|
+
if not entry.notes:
|
|
510
|
+
print("Notes: none yet")
|
|
511
|
+
return
|
|
512
|
+
cut = ", older notes cut" if entry.notes_truncated else ""
|
|
513
|
+
print(f"Notes ({entry.notes_path}{cut}):")
|
|
514
|
+
# Indented, so a note cannot forge a "== task ==" line; controls dropped
|
|
515
|
+
for line in _safe_lines(entry.notes.rstrip()):
|
|
516
|
+
print(f" {line}" if line else "")
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _safe_lines(text: str) -> list[str]:
|
|
520
|
+
return [_CONTROL_CHARS.sub("", line) for line in text.split("\n")]
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def cmd_epic_ask(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
|
|
524
|
+
"""Ask another task of the epic; exit 3 when its session is running.
|
|
525
|
+
|
|
526
|
+
A running session is asked with SendMessage by the calling agent: only
|
|
527
|
+
a fork is asked here, unless --fork.
|
|
528
|
+
"""
|
|
529
|
+
if os.environ.get(LOOP_GUARD_ENV):
|
|
530
|
+
return _error("epic ask cannot run inside another epic ask")
|
|
531
|
+
question = (sys.stdin.read() if args.question == "-" else args.question).strip()
|
|
532
|
+
if not question:
|
|
533
|
+
return _error("the question is empty")
|
|
534
|
+
size = len(question.encode())
|
|
535
|
+
if size > ASK_QUESTION_MAX_BYTES:
|
|
536
|
+
return _error(f"the question is {size} bytes; the limit is {ASK_QUESTION_MAX_BYTES}")
|
|
537
|
+
caller = _find_task_by_cwd(manager)
|
|
538
|
+
epic, task = resolve_ask_task(manager, caller, args.task)
|
|
539
|
+
|
|
540
|
+
# Before the session lookup: a running session may not have a transcript yet
|
|
541
|
+
if not args.fork:
|
|
542
|
+
sessions = list_agent_sessions(config.claude_path)
|
|
543
|
+
if sessions is None:
|
|
544
|
+
print("warning: could not list running Claude sessions", file=sys.stderr)
|
|
545
|
+
live = live_sessions(task, sessions or [])
|
|
546
|
+
if live:
|
|
547
|
+
_print_live_sessions(task, live, args.as_json)
|
|
548
|
+
return 3
|
|
549
|
+
|
|
550
|
+
target = AskTarget(epic=epic, task=task, session=pick_session(task, args.session))
|
|
551
|
+
command = build_ask_command(
|
|
552
|
+
config.claude_path,
|
|
553
|
+
target,
|
|
554
|
+
question,
|
|
555
|
+
caller.name if caller else None,
|
|
556
|
+
args.model,
|
|
557
|
+
args.max_turns,
|
|
558
|
+
)
|
|
559
|
+
try:
|
|
560
|
+
answer = run_ask(command, target.session.cwd, args.timeout)
|
|
561
|
+
except AskError as e:
|
|
562
|
+
return _error(_CONTROL_CHARS.sub(" ", f"{task.name} did not answer: {e}"))
|
|
563
|
+
if args.as_json:
|
|
564
|
+
payload = {
|
|
565
|
+
"task": task.name,
|
|
566
|
+
"status": "answered",
|
|
567
|
+
"session_id": target.session.session_id,
|
|
568
|
+
"cwd": str(target.session.cwd),
|
|
569
|
+
"answer": answer.text,
|
|
570
|
+
"usage": answer.usage,
|
|
571
|
+
}
|
|
572
|
+
print(json.dumps(payload, indent=2))
|
|
573
|
+
return 0
|
|
574
|
+
print(f"== {task.name} (copy of session {target.session.session_id}) ==")
|
|
575
|
+
for line in _safe_lines(answer.text.rstrip()):
|
|
576
|
+
print(line)
|
|
577
|
+
return 0
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _print_live_sessions(task: Task, live: list[AgentSession], as_json: bool) -> None:
|
|
581
|
+
if as_json:
|
|
582
|
+
rows = [
|
|
583
|
+
{
|
|
584
|
+
"name": s.name,
|
|
585
|
+
"session_id": s.session_id,
|
|
586
|
+
"status": s.status,
|
|
587
|
+
"cwd": str(s.directory),
|
|
588
|
+
}
|
|
589
|
+
for s in live
|
|
590
|
+
]
|
|
591
|
+
print(json.dumps({"task": task.name, "status": "running", "sessions": rows}, indent=2))
|
|
592
|
+
return
|
|
593
|
+
# SendMessage addresses a session by name; an unnamed one only by its id
|
|
594
|
+
names = ", ".join(
|
|
595
|
+
f'"{_CONTROL_CHARS.sub("", s.name or s.session_id)}" ({s.status})' for s in live
|
|
596
|
+
)
|
|
597
|
+
print(f"{task.name} has a running Claude session: {names}.")
|
|
598
|
+
print("Message it with SendMessage (to: that name); the answer arrives later as a message.")
|
|
599
|
+
print("If the message is held or refused, pass --fork to ask a read-only copy instead.")
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _ask_timeout(value: str) -> float:
|
|
603
|
+
try:
|
|
604
|
+
seconds = float(value)
|
|
605
|
+
except ValueError:
|
|
606
|
+
seconds = float("nan")
|
|
607
|
+
if not 1 <= seconds <= ASK_TIMEOUT_MAX:
|
|
608
|
+
raise argparse.ArgumentTypeError(f"must be between 1 and {ASK_TIMEOUT_MAX} seconds")
|
|
609
|
+
return seconds
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _ask_max_turns(value: str) -> int:
|
|
613
|
+
try:
|
|
614
|
+
turns = int(value)
|
|
615
|
+
except ValueError:
|
|
616
|
+
turns = 0
|
|
617
|
+
if not 1 <= turns <= 50:
|
|
618
|
+
raise argparse.ArgumentTypeError("must be between 1 and 50")
|
|
619
|
+
return turns
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def cmd_epic_sync(manager: TaskManager, config: Config, args: argparse.Namespace) -> int:
|
|
623
|
+
"""Refresh the CLAUDE.md epic block of epics and their tasks."""
|
|
624
|
+
if args.name:
|
|
625
|
+
epic = manager.get_task(args.name)
|
|
626
|
+
if epic is None:
|
|
627
|
+
return _error(f"no such epic: {args.name}")
|
|
628
|
+
if manager.epic_of(epic) != epic.name:
|
|
629
|
+
return _error(f"{epic.name} is not an epic")
|
|
630
|
+
epics = [epic.name]
|
|
631
|
+
else:
|
|
632
|
+
epics = [task.name for task in manager.list_tasks() if manager.epic_of(task) == task.name]
|
|
633
|
+
synced = missing = failed = 0
|
|
634
|
+
for name in epics:
|
|
635
|
+
for member in [name, *manager.epic_child_names(name)]:
|
|
636
|
+
try:
|
|
637
|
+
task = manager.get_task(member)
|
|
638
|
+
except (OSError, ValueError) as e:
|
|
639
|
+
print(f"warning: {member}: {e}", file=sys.stderr)
|
|
640
|
+
failed += 1
|
|
641
|
+
continue
|
|
642
|
+
if task is None:
|
|
643
|
+
continue
|
|
644
|
+
if not (task.path / "CLAUDE.md").is_file():
|
|
645
|
+
missing += 1
|
|
646
|
+
continue
|
|
647
|
+
problem = manager.sync_epic_block(task)
|
|
648
|
+
if problem:
|
|
649
|
+
print(f"warning: {problem}", file=sys.stderr)
|
|
650
|
+
failed += 1
|
|
651
|
+
else:
|
|
652
|
+
synced += 1
|
|
653
|
+
print(
|
|
654
|
+
f"Synced {synced} folder(s); {missing} without CLAUDE.md "
|
|
655
|
+
f"(written when a Claude session starts there from the TUI); {failed} failed"
|
|
656
|
+
)
|
|
657
|
+
return 1 if failed else 0
|
|
658
|
+
|
|
659
|
+
|
|
338
660
|
def _refuse_epic_with_children(manager: TaskManager, task: Task, verb: str) -> bool:
|
|
339
661
|
"""Print an error and return True if `task` is an epic that still has tasks.
|
|
340
662
|
|
|
@@ -545,7 +867,8 @@ def cmd_add_repo(manager: TaskManager, config: Config, args: argparse.Namespace)
|
|
|
545
867
|
def _find_task_by_cwd(manager: TaskManager) -> Task | None:
|
|
546
868
|
"""Find the task whose directory contains the current working directory."""
|
|
547
869
|
cwd = Path.cwd().resolve()
|
|
548
|
-
|
|
870
|
+
# A symlink alias resolves to the same folder: the real folder wins
|
|
871
|
+
for task in sorted(manager.list_tasks(), key=lambda task: task.path.is_symlink()):
|
|
549
872
|
task_path = task.path.resolve()
|
|
550
873
|
if cwd == task_path or cwd.is_relative_to(task_path):
|
|
551
874
|
return task
|
{tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tasktree_manager/services/agent_sessions.py
RENAMED
|
@@ -43,6 +43,9 @@ _STATUS_MAP = {
|
|
|
43
43
|
# When several sessions run in one worktree, the most urgent state wins
|
|
44
44
|
_STATUS_PRECEDENCE = {"needs_input": 0, "working": 1, "ready": 2}
|
|
45
45
|
|
|
46
|
+
# Name of the read-only copies `epic ask` runs: not work, so never listed
|
|
47
|
+
ASK_COPY_PREFIX = "epic-ask-"
|
|
48
|
+
|
|
46
49
|
_DIRECTORY_KEYS = ("cwd", "directory", "workdir", "projectPath", "path")
|
|
47
50
|
_STATUS_KEYS = ("status", "state")
|
|
48
51
|
|
|
@@ -110,6 +113,9 @@ def parse_agent_sessions(payload: str) -> list[AgentSession] | None:
|
|
|
110
113
|
# unknown statuses (dead/exited sessions) must not render as working
|
|
111
114
|
if not directory or raw_status is None:
|
|
112
115
|
continue
|
|
116
|
+
name = str(record.get("name", ""))
|
|
117
|
+
if name.startswith(ASK_COPY_PREFIX):
|
|
118
|
+
continue
|
|
113
119
|
status = _STATUS_MAP.get(raw_status.lower())
|
|
114
120
|
if status is None:
|
|
115
121
|
continue
|
|
@@ -118,7 +124,7 @@ def parse_agent_sessions(payload: str) -> list[AgentSession] | None:
|
|
|
118
124
|
directory=Path(directory).resolve(),
|
|
119
125
|
status=status,
|
|
120
126
|
session_id=str(record.get("sessionId", "")),
|
|
121
|
-
name=
|
|
127
|
+
name=name,
|
|
122
128
|
)
|
|
123
129
|
)
|
|
124
130
|
return sessions
|
{tasktree_manager-2.16.1 → tasktree_manager-2.18.0}/tasktree_manager/services/claude_sessions.py
RENAMED
|
@@ -14,6 +14,7 @@ costs one bounded read regardless of its length.
|
|
|
14
14
|
import json
|
|
15
15
|
import os
|
|
16
16
|
import re
|
|
17
|
+
from collections.abc import Iterable
|
|
17
18
|
from dataclasses import dataclass
|
|
18
19
|
from datetime import datetime, tzinfo
|
|
19
20
|
from pathlib import Path
|
|
@@ -23,6 +24,8 @@ from .claude_hooks import project_dir_candidates
|
|
|
23
24
|
RECAP_SUFFIX = "(disable recaps in /config)"
|
|
24
25
|
TAIL_WINDOW = 1 << 20
|
|
25
26
|
TAIL_WINDOW_RETRY = 4 << 20
|
|
27
|
+
# Most a session-folder lookup reads from the start of a transcript
|
|
28
|
+
HEAD_BUDGET = 16 << 20
|
|
26
29
|
# Turns that span a laptop sleep report days of "duration"; anything above
|
|
27
30
|
# an hour is noise rather than work time
|
|
28
31
|
DURATION_CAP_MS = 3_600_000
|
|
@@ -56,13 +59,22 @@ class ClaudeRecap:
|
|
|
56
59
|
last_prompt: str | None
|
|
57
60
|
|
|
58
61
|
|
|
59
|
-
|
|
60
|
-
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class SessionRef:
|
|
64
|
+
"""A transcript and the folder its session was started in."""
|
|
61
65
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
path: Path
|
|
67
|
+
cwd: Path
|
|
68
|
+
mtime_ns: int
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def session_id(self) -> str:
|
|
72
|
+
return self.path.stem
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _transcripts(folder: Path) -> list[tuple[int, int, Path]]:
|
|
76
|
+
"""``(mtime_ns, size, path)`` of every transcript in a folder's project dirs."""
|
|
77
|
+
found = []
|
|
66
78
|
for project_dir in project_dir_candidates(folder):
|
|
67
79
|
try:
|
|
68
80
|
entries = list(os.scandir(project_dir))
|
|
@@ -77,15 +89,88 @@ def transcript_key(folder: Path) -> tuple[Path, int, int] | None:
|
|
|
77
89
|
# Claude prunes old transcripts at startup; one can vanish
|
|
78
90
|
# between the scandir and the stat
|
|
79
91
|
continue
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
92
|
+
found.append((stat.st_mtime_ns, stat.st_size, Path(entry.path)))
|
|
93
|
+
return found
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def transcript_key(folder: Path) -> tuple[Path, int, int] | None:
|
|
97
|
+
"""Return ``(path, mtime_ns, size)`` of the newest transcript for a folder.
|
|
98
|
+
|
|
99
|
+
One ``scandir`` per candidate project dir and no file reads, so callers
|
|
100
|
+
can poll it to detect changes cheaply.
|
|
101
|
+
"""
|
|
102
|
+
newest = max(_transcripts(folder), key=lambda found: found[0], default=None)
|
|
83
103
|
if newest is None:
|
|
84
104
|
return None
|
|
85
105
|
mtime_ns, size, path = newest
|
|
86
106
|
return path, mtime_ns, size
|
|
87
107
|
|
|
88
108
|
|
|
109
|
+
def transcript_cwd(path: Path) -> Path | None:
|
|
110
|
+
"""The folder a session started in: the first ``cwd`` in its transcript.
|
|
111
|
+
|
|
112
|
+
Whole records are read, since ``cwd`` follows the message body; a first
|
|
113
|
+
prompt bigger than HEAD_BUDGET leaves the cwd unknown.
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
with open(path, "rb") as f:
|
|
117
|
+
budget = HEAD_BUDGET
|
|
118
|
+
while budget > 0:
|
|
119
|
+
line = f.readline(budget)
|
|
120
|
+
if not line:
|
|
121
|
+
return None
|
|
122
|
+
budget -= len(line)
|
|
123
|
+
if b'"cwd"' not in line:
|
|
124
|
+
continue
|
|
125
|
+
try:
|
|
126
|
+
record = json.loads(line)
|
|
127
|
+
except ValueError:
|
|
128
|
+
continue
|
|
129
|
+
cwd = record.get("cwd") if isinstance(record, dict) else None
|
|
130
|
+
if isinstance(cwd, str) and cwd:
|
|
131
|
+
return Path(cwd)
|
|
132
|
+
except OSError:
|
|
133
|
+
return None
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def latest_session(folders: Iterable[Path]) -> SessionRef | None:
|
|
138
|
+
"""The newest session started in one of `folders` (a task and its worktrees).
|
|
139
|
+
|
|
140
|
+
Project dir names fold every non-alphanumeric character, so `X/1` and `X-1`
|
|
141
|
+
share one dir; a transcript counts only when its recorded cwd is the folder.
|
|
142
|
+
"""
|
|
143
|
+
candidates = [
|
|
144
|
+
(mtime_ns, str(path), path, folder)
|
|
145
|
+
for folder in folders
|
|
146
|
+
for mtime_ns, _size, path in _transcripts(folder)
|
|
147
|
+
]
|
|
148
|
+
for mtime_ns, _key, path, folder in sorted(candidates, reverse=True):
|
|
149
|
+
cwd = transcript_cwd(path)
|
|
150
|
+
if cwd is not None and _same_dir(cwd, folder):
|
|
151
|
+
return SessionRef(path=path, cwd=folder, mtime_ns=mtime_ns)
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def find_session(folders: Iterable[Path], session_id: str) -> SessionRef | None:
|
|
156
|
+
"""The session `session_id` if it was started in one of `folders`."""
|
|
157
|
+
for folder in folders:
|
|
158
|
+
for mtime_ns, _size, path in _transcripts(folder):
|
|
159
|
+
if path.stem != session_id:
|
|
160
|
+
continue
|
|
161
|
+
cwd = transcript_cwd(path)
|
|
162
|
+
if cwd is not None and _same_dir(cwd, folder):
|
|
163
|
+
return SessionRef(path=path, cwd=folder, mtime_ns=mtime_ns)
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _same_dir(a: Path, b: Path) -> bool:
|
|
168
|
+
try:
|
|
169
|
+
return os.path.samefile(a, b)
|
|
170
|
+
except OSError:
|
|
171
|
+
return os.path.realpath(a) == os.path.realpath(b)
|
|
172
|
+
|
|
173
|
+
|
|
89
174
|
def read_claude_recap(path: Path) -> ClaudeRecap | None:
|
|
90
175
|
"""Read the recap from one transcript (see ``transcript_key`` to pick it)."""
|
|
91
176
|
try:
|