session-handoff 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. session_handoff-0.1.0/MANIFEST.in +1 -0
  2. session_handoff-0.1.0/PKG-INFO +85 -0
  3. session_handoff-0.1.0/README.md +78 -0
  4. session_handoff-0.1.0/pyproject.toml +16 -0
  5. session_handoff-0.1.0/setup.cfg +4 -0
  6. session_handoff-0.1.0/src/handoff_cli/__init__.py +3 -0
  7. session_handoff-0.1.0/src/handoff_cli/__main__.py +4 -0
  8. session_handoff-0.1.0/src/handoff_cli/agents.py +22 -0
  9. session_handoff-0.1.0/src/handoff_cli/cli.py +110 -0
  10. session_handoff-0.1.0/src/handoff_cli/launcher.py +41 -0
  11. session_handoff-0.1.0/src/handoff_cli/models.py +25 -0
  12. session_handoff-0.1.0/src/handoff_cli/paths.py +20 -0
  13. session_handoff-0.1.0/src/handoff_cli/prompt.py +13 -0
  14. session_handoff-0.1.0/src/handoff_cli/sources/__init__.py +35 -0
  15. session_handoff-0.1.0/src/handoff_cli/sources/base.py +63 -0
  16. session_handoff-0.1.0/src/handoff_cli/sources/claude.py +91 -0
  17. session_handoff-0.1.0/src/handoff_cli/sources/codex.py +76 -0
  18. session_handoff-0.1.0/src/handoff_cli/sources/pi.py +74 -0
  19. session_handoff-0.1.0/src/session_handoff.egg-info/PKG-INFO +85 -0
  20. session_handoff-0.1.0/src/session_handoff.egg-info/SOURCES.txt +26 -0
  21. session_handoff-0.1.0/src/session_handoff.egg-info/dependency_links.txt +1 -0
  22. session_handoff-0.1.0/src/session_handoff.egg-info/entry_points.txt +2 -0
  23. session_handoff-0.1.0/src/session_handoff.egg-info/top_level.txt +1 -0
  24. session_handoff-0.1.0/tests/fixtures/claude/session.jsonl +3 -0
  25. session_handoff-0.1.0/tests/fixtures/codex/session.jsonl +2 -0
  26. session_handoff-0.1.0/tests/fixtures/pi/session.jsonl +2 -0
  27. session_handoff-0.1.0/tests/test_cli.py +194 -0
  28. session_handoff-0.1.0/tests/test_sources.py +179 -0
@@ -0,0 +1 @@
1
+ recursive-include tests/fixtures *.jsonl
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: session-handoff
3
+ Version: 0.1.0
4
+ Summary: Continue a coding-agent session in Claude Code, Codex, or Pi
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+
8
+ # Session Handoff
9
+
10
+ > **note**: this project was prototyped rapidly (in about 15 minutes) by gpt-5.6-sol. use at your own discretion.
11
+
12
+ Continue a coding task in another coding agent by pointing it to the previous agent's session file.
13
+
14
+ Supported agents:
15
+
16
+ - Claude Code
17
+ - Codex CLI
18
+ - Pi
19
+
20
+ Session Handoff does not summarize the conversations. It only searches for the file path of latest coding session transcript belonging to the current working directory.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ uv tool install session-handoff
26
+ # or
27
+ pipx install session-handoff
28
+ ```
29
+
30
+ From a local checkout:
31
+
32
+ ```bash
33
+ uv tool install .
34
+ ```
35
+
36
+ ## Use
37
+
38
+ Run `handoff` inside the project you want to continue:
39
+
40
+ ```bash
41
+ # Launch Codex with the latest Claude Code session.
42
+ handoff claude codex
43
+
44
+ # Print the prompt instead of launching the destination.
45
+ handoff claude codex --prompt
46
+
47
+ # All directions are supported.
48
+ handoff codex pi
49
+ handoff pi claude
50
+ ```
51
+
52
+ The generated prompt is:
53
+
54
+ ```text
55
+ Read the previous coding session at <path>, understand the task, inspect the current working directory, and continue from where the previous agent stopped.
56
+ Ask clarifying questions if required.
57
+ ```
58
+
59
+ ## Session selection
60
+
61
+ Handoff matches the current working directory against metadata in each agent's local history and chooses the matching file with the newest modification time.
62
+
63
+ | Agent | Default history location |
64
+ | --- | --- |
65
+ | Claude Code | `~/.claude/projects/*/*.jsonl` |
66
+ | Codex | `~/.codex/sessions/**/rollout-*.jsonl` |
67
+ | Pi | `~/.pi/agent/sessions/**/*.jsonl` |
68
+
69
+ It fails when no session matches the current directory.
70
+
71
+ Native location overrides are supported:
72
+
73
+ - `CLAUDE_CONFIG_DIR`
74
+ - `CODEX_HOME`
75
+ - `PI_CODING_AGENT_SESSION_DIR`
76
+ - `PI_CODING_AGENT_DIR`
77
+
78
+ ## Development
79
+
80
+ Run the tests:
81
+
82
+ ```bash
83
+ PYTHONPATH=src python -m unittest discover -s tests -v
84
+ ```
85
+
@@ -0,0 +1,78 @@
1
+ # Session Handoff
2
+
3
+ > **note**: this project was prototyped rapidly (in about 15 minutes) by gpt-5.6-sol. use at your own discretion.
4
+
5
+ Continue a coding task in another coding agent by pointing it to the previous agent's session file.
6
+
7
+ Supported agents:
8
+
9
+ - Claude Code
10
+ - Codex CLI
11
+ - Pi
12
+
13
+ Session Handoff does not summarize the conversations. It only searches for the file path of latest coding session transcript belonging to the current working directory.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ uv tool install session-handoff
19
+ # or
20
+ pipx install session-handoff
21
+ ```
22
+
23
+ From a local checkout:
24
+
25
+ ```bash
26
+ uv tool install .
27
+ ```
28
+
29
+ ## Use
30
+
31
+ Run `handoff` inside the project you want to continue:
32
+
33
+ ```bash
34
+ # Launch Codex with the latest Claude Code session.
35
+ handoff claude codex
36
+
37
+ # Print the prompt instead of launching the destination.
38
+ handoff claude codex --prompt
39
+
40
+ # All directions are supported.
41
+ handoff codex pi
42
+ handoff pi claude
43
+ ```
44
+
45
+ The generated prompt is:
46
+
47
+ ```text
48
+ Read the previous coding session at <path>, understand the task, inspect the current working directory, and continue from where the previous agent stopped.
49
+ Ask clarifying questions if required.
50
+ ```
51
+
52
+ ## Session selection
53
+
54
+ Handoff matches the current working directory against metadata in each agent's local history and chooses the matching file with the newest modification time.
55
+
56
+ | Agent | Default history location |
57
+ | --- | --- |
58
+ | Claude Code | `~/.claude/projects/*/*.jsonl` |
59
+ | Codex | `~/.codex/sessions/**/rollout-*.jsonl` |
60
+ | Pi | `~/.pi/agent/sessions/**/*.jsonl` |
61
+
62
+ It fails when no session matches the current directory.
63
+
64
+ Native location overrides are supported:
65
+
66
+ - `CLAUDE_CONFIG_DIR`
67
+ - `CODEX_HOME`
68
+ - `PI_CODING_AGENT_SESSION_DIR`
69
+ - `PI_CODING_AGENT_DIR`
70
+
71
+ ## Development
72
+
73
+ Run the tests:
74
+
75
+ ```bash
76
+ PYTHONPATH=src python -m unittest discover -s tests -v
77
+ ```
78
+
@@ -0,0 +1,16 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "session-handoff"
7
+ version = "0.1.0"
8
+ description = "Continue a coding-agent session in Claude Code, Codex, or Pi"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+
12
+ [project.scripts]
13
+ handoff = "handoff_cli.cli:main"
14
+
15
+ [tool.setuptools.packages.find]
16
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Handoff coding-agent sessions by passing their local path."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,22 @@
1
+ """Agent names and destination launch metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .models import Agent, AgentId
6
+
7
+ AGENTS: dict[AgentId, Agent] = {
8
+ "claude": Agent(id="claude", display_name="Claude Code", executable="claude"),
9
+ "codex": Agent(id="codex", display_name="Codex", executable="codex"),
10
+ "pi": Agent(id="pi", display_name="Pi", executable="pi"),
11
+ }
12
+
13
+
14
+ def get_agent(agent_id: str) -> Agent:
15
+ """Return an agent or raise a user-facing ValueError."""
16
+ try:
17
+ return AGENTS[agent_id] # type: ignore[index]
18
+ except KeyError as error:
19
+ supported = ", ".join(AGENTS)
20
+ raise ValueError(
21
+ f"Unknown agent {agent_id!r}. Supported agents: {supported}."
22
+ ) from error
@@ -0,0 +1,110 @@
1
+ """Command-line entry point for Handoff."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from collections.abc import Callable, Mapping, Sequence
9
+ from importlib.metadata import PackageNotFoundError, version
10
+ from pathlib import Path
11
+ from typing import TextIO
12
+
13
+ from .agents import AGENTS
14
+ from .launcher import DestinationNotFoundError, launch_destination
15
+ from .models import AgentId
16
+ from .paths import canonical_path
17
+ from .prompt import render_prompt
18
+ from .sources import make_locator
19
+
20
+
21
+ def package_version() -> str:
22
+ try:
23
+ return version("session-handoff")
24
+ except PackageNotFoundError:
25
+ return "0.1.0"
26
+
27
+
28
+ def build_parser() -> argparse.ArgumentParser:
29
+ parser = argparse.ArgumentParser(
30
+ prog="handoff",
31
+ description=(
32
+ "Continue the latest project session from Claude Code, Codex, or Pi "
33
+ "in another agent."
34
+ ),
35
+ epilog=(
36
+ "Examples:\n"
37
+ " handoff claude codex\n"
38
+ " handoff pi claude --prompt\n"
39
+ " handoff codex pi --prompt | pbcopy"
40
+ ),
41
+ formatter_class=argparse.RawDescriptionHelpFormatter,
42
+ )
43
+ parser.add_argument("source", choices=tuple(AGENTS), help="agent to hand off from")
44
+ parser.add_argument(
45
+ "destination", choices=tuple(AGENTS), help="agent to hand off to"
46
+ )
47
+ parser.add_argument(
48
+ "--prompt",
49
+ action="store_true",
50
+ dest="print_prompt",
51
+ help="print the handoff prompt instead of launching the destination",
52
+ )
53
+ parser.add_argument(
54
+ "--version", action="version", version=f"%(prog)s {package_version()}"
55
+ )
56
+ return parser
57
+
58
+
59
+ def run(
60
+ argv: Sequence[str] | None = None,
61
+ *,
62
+ cwd: Path | None = None,
63
+ environ: Mapping[str, str] | None = None,
64
+ stdout: TextIO | None = None,
65
+ stderr: TextIO | None = None,
66
+ launch: Callable[[AgentId, str, Path], int] = launch_destination,
67
+ ) -> int:
68
+ args = build_parser().parse_args(argv)
69
+ output = sys.stdout if stdout is None else stdout
70
+ errors = sys.stderr if stderr is None else stderr
71
+ environment = os.environ if environ is None else environ
72
+
73
+ try:
74
+ working_directory = canonical_path(Path.cwd() if cwd is None else cwd)
75
+ source: AgentId = args.source
76
+ destination: AgentId = args.destination
77
+ locator = make_locator(source, environ=environment)
78
+ artifact = locator.find_latest_for_cwd(working_directory)
79
+ if artifact is None:
80
+ display_name = AGENTS[source].display_name
81
+ raise RuntimeError(
82
+ f"No {display_name} session was found for:\n"
83
+ f"{working_directory}\n\n"
84
+ "Handoff will not use a session from another working directory."
85
+ )
86
+
87
+ # Opening the file catches stale paths and unreadable stores before the
88
+ # destination starts. Reading one byte does not load the transcript.
89
+ try:
90
+ with artifact.path.open("rb") as stream:
91
+ stream.read(1)
92
+ except OSError as error:
93
+ raise RuntimeError(
94
+ f"The session file is not readable: {artifact.path}\n{error}"
95
+ ) from error
96
+
97
+ prompt = render_prompt(artifact)
98
+ if args.print_prompt:
99
+ output.write(prompt)
100
+ output.write("\n")
101
+ return 0
102
+
103
+ return launch(destination, prompt, working_directory)
104
+ except (DestinationNotFoundError, RuntimeError, ValueError) as error:
105
+ errors.write(f"handoff: error: {error}\n")
106
+ return 1
107
+
108
+
109
+ def main() -> None:
110
+ raise SystemExit(run())
@@ -0,0 +1,41 @@
1
+ """Shell-free destination process launching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ from .agents import AGENTS
10
+ from .models import AgentId
11
+
12
+
13
+ class DestinationNotFoundError(RuntimeError):
14
+ pass
15
+
16
+
17
+ def launch_destination(destination: AgentId, prompt: str, cwd: Path) -> int:
18
+ """Launch a destination with inherited terminal streams and return its code."""
19
+ agent = AGENTS[destination]
20
+ executable = shutil.which(agent.executable)
21
+ if executable is None:
22
+ raise DestinationNotFoundError(
23
+ f"Cannot launch {agent.display_name}: {agent.executable!r} was not found "
24
+ "on PATH. Install it or use --prompt."
25
+ )
26
+
27
+ try:
28
+ completed = subprocess.run(
29
+ [executable, prompt],
30
+ cwd=cwd,
31
+ check=False,
32
+ )
33
+ except KeyboardInterrupt:
34
+ return 130
35
+ except OSError as error:
36
+ raise RuntimeError(f"Could not launch {agent.display_name}: {error}") from error
37
+
38
+ # Shell convention for a child terminated by a signal.
39
+ if completed.returncode < 0:
40
+ return 128 + abs(completed.returncode)
41
+ return completed.returncode
@@ -0,0 +1,25 @@
1
+ """Small shared data types for Handoff."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ AgentId = Literal["claude", "codex", "pi"]
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Agent:
14
+ id: AgentId
15
+ display_name: str
16
+ executable: str
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class SessionArtifact:
21
+ agent: AgentId
22
+ path: Path
23
+ cwd: Path
24
+ session_id: str | None
25
+ modified_at: float
@@ -0,0 +1,20 @@
1
+ """Filesystem path helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ def canonical_path(path: str | os.PathLike[str]) -> Path:
10
+ """Return a normalized absolute path, resolving existing symlinks.
11
+
12
+ ``strict=False`` keeps this useful for metadata referring to a path that has
13
+ since moved, while ``normcase`` makes comparisons correct on Windows.
14
+ """
15
+ expanded = Path(path).expanduser().resolve(strict=False)
16
+ return Path(os.path.normcase(str(expanded)))
17
+
18
+
19
+ def same_path(left: str | os.PathLike[str], right: str | os.PathLike[str]) -> bool:
20
+ return canonical_path(left) == canonical_path(right)
@@ -0,0 +1,13 @@
1
+ """Render the prompt passed to the destination agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .models import SessionArtifact
6
+
7
+
8
+ def render_prompt(artifact: SessionArtifact) -> str:
9
+ return (
10
+ f"Read the previous coding session at {artifact.path}, understand the task, "
11
+ "inspect the current working directory, and continue from where the previous "
12
+ "agent stopped.\nAsk clarifying questions if required."
13
+ )
@@ -0,0 +1,35 @@
1
+ """Read-only locators for supported agent session stores."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+
8
+ from ..models import AgentId
9
+ from .base import SourceLocator
10
+ from .claude import ClaudeLocator
11
+ from .codex import CodexLocator
12
+ from .pi import PiLocator
13
+
14
+
15
+ def make_locator(
16
+ agent: AgentId,
17
+ *,
18
+ environ: Mapping[str, str] | None = None,
19
+ home: Path | None = None,
20
+ ) -> SourceLocator:
21
+ locators = {
22
+ "claude": ClaudeLocator,
23
+ "codex": CodexLocator,
24
+ "pi": PiLocator,
25
+ }
26
+ return locators[agent](environ=environ, home=home)
27
+
28
+
29
+ __all__ = [
30
+ "ClaudeLocator",
31
+ "CodexLocator",
32
+ "PiLocator",
33
+ "SourceLocator",
34
+ "make_locator",
35
+ ]
@@ -0,0 +1,63 @@
1
+ """Shared source-locator helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from collections.abc import Iterable, Iterator, Mapping
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from ..models import SessionArtifact
12
+
13
+ # Metadata appears near the beginning of all supported formats. The limits keep
14
+ # one malformed or unexpected file from making discovery expensive.
15
+ MAX_METADATA_LINES = 512
16
+ MAX_METADATA_BYTES = 64 * 1024 * 1024
17
+
18
+
19
+ class SourceLocator:
20
+ """Base class for a read-only session locator."""
21
+
22
+ def find_latest_for_cwd(self, cwd: Path) -> SessionArtifact | None:
23
+ raise NotImplementedError
24
+
25
+
26
+ def environment_path(environ: Mapping[str, str], name: str, default: Path) -> Path:
27
+ value = environ.get(name)
28
+ return Path(value).expanduser() if value else default
29
+
30
+
31
+ def json_records(
32
+ path: Path,
33
+ *,
34
+ max_lines: int = MAX_METADATA_LINES,
35
+ max_bytes: int = MAX_METADATA_BYTES,
36
+ ) -> Iterator[dict[str, Any]]:
37
+ """Yield bounded JSON-object records, skipping malformed lines."""
38
+ consumed = 0
39
+ try:
40
+ with path.open("rb") as stream:
41
+ for index, raw_line in enumerate(stream):
42
+ if index >= max_lines:
43
+ return
44
+ consumed += len(raw_line)
45
+ if consumed > max_bytes:
46
+ return
47
+ try:
48
+ value = json.loads(raw_line)
49
+ except (json.JSONDecodeError, UnicodeDecodeError):
50
+ continue
51
+ if isinstance(value, dict):
52
+ yield value
53
+ except (OSError, ValueError):
54
+ return
55
+
56
+
57
+ def newest(candidates: Iterable[SessionArtifact]) -> SessionArtifact | None:
58
+ """Choose deterministically by mtime, then path."""
59
+ return max(
60
+ candidates,
61
+ key=lambda item: (item.modified_at, os.fspath(item.path)),
62
+ default=None,
63
+ )
@@ -0,0 +1,91 @@
1
+ """Locate Claude Code project sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Iterator, Mapping
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from ..models import SessionArtifact
11
+ from ..paths import canonical_path, same_path
12
+ from .base import SourceLocator, json_records, newest
13
+
14
+
15
+ class ClaudeLocator(SourceLocator):
16
+ def __init__(
17
+ self,
18
+ *,
19
+ root: Path | None = None,
20
+ environ: Mapping[str, str] | None = None,
21
+ home: Path | None = None,
22
+ ) -> None:
23
+ self.environ = os.environ if environ is None else environ
24
+ user_home = Path.home() if home is None else home
25
+ if root is not None:
26
+ self.root = root
27
+ elif config_dir := self.environ.get("CLAUDE_CONFIG_DIR"):
28
+ self.root = Path(config_dir).expanduser() / "projects"
29
+ else:
30
+ self.root = user_home / ".claude" / "projects"
31
+
32
+ def find_latest_for_cwd(self, cwd: Path) -> SessionArtifact | None:
33
+ expected_cwd = canonical_path(cwd)
34
+ candidates: list[SessionArtifact] = []
35
+ for path in self._session_files(expected_cwd):
36
+ metadata = self._metadata(path)
37
+ if metadata is None or not same_path(metadata["cwd"], expected_cwd):
38
+ continue
39
+ try:
40
+ modified_at = path.stat().st_mtime
41
+ except OSError:
42
+ continue
43
+ candidates.append(
44
+ SessionArtifact(
45
+ agent="claude",
46
+ path=path.resolve(strict=False),
47
+ cwd=expected_cwd,
48
+ session_id=metadata.get("session_id") or path.stem,
49
+ modified_at=modified_at,
50
+ )
51
+ )
52
+ return newest(candidates)
53
+
54
+ def _session_files(self, cwd: Path) -> Iterator[Path]:
55
+ if not self.root.is_dir():
56
+ return
57
+
58
+ # Claude currently encodes a project path by replacing separators with
59
+ # hyphens. Check that directory first for speed, but validate the cwd
60
+ # stored inside every file and scan other directories as a fallback.
61
+ encoded = str(cwd).replace("\\", "-").replace("/", "-")
62
+ preferred = self.root / encoded
63
+ directories: list[Path] = []
64
+ if preferred.is_dir():
65
+ directories.append(preferred)
66
+ try:
67
+ directories.extend(
68
+ child
69
+ for child in self.root.iterdir()
70
+ if child.is_dir() and child != preferred
71
+ )
72
+ except OSError:
73
+ pass
74
+
75
+ for directory in directories:
76
+ try:
77
+ yield from directory.glob("*.jsonl")
78
+ except OSError:
79
+ continue
80
+
81
+ @staticmethod
82
+ def _metadata(path: Path) -> dict[str, Any] | None:
83
+ session_id: str | None = None
84
+ for record in json_records(path):
85
+ value = record.get("sessionId") or record.get("session_id")
86
+ if isinstance(value, str) and value:
87
+ session_id = value
88
+ recorded_cwd = record.get("cwd")
89
+ if isinstance(recorded_cwd, str) and recorded_cwd:
90
+ return {"cwd": recorded_cwd, "session_id": session_id}
91
+ return None
@@ -0,0 +1,76 @@
1
+ """Locate Codex rollout sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Iterator, Mapping
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from ..models import SessionArtifact
11
+ from ..paths import canonical_path, same_path
12
+ from .base import SourceLocator, json_records, newest
13
+
14
+
15
+ class CodexLocator(SourceLocator):
16
+ def __init__(
17
+ self,
18
+ *,
19
+ root: Path | None = None,
20
+ environ: Mapping[str, str] | None = None,
21
+ home: Path | None = None,
22
+ ) -> None:
23
+ self.environ = os.environ if environ is None else environ
24
+ user_home = Path.home() if home is None else home
25
+ if root is not None:
26
+ self.root = root
27
+ elif codex_home := self.environ.get("CODEX_HOME"):
28
+ self.root = Path(codex_home).expanduser() / "sessions"
29
+ else:
30
+ self.root = user_home / ".codex" / "sessions"
31
+
32
+ def find_latest_for_cwd(self, cwd: Path) -> SessionArtifact | None:
33
+ expected_cwd = canonical_path(cwd)
34
+ candidates: list[SessionArtifact] = []
35
+ for path in self._session_files():
36
+ metadata = self._metadata(path)
37
+ if metadata is None or not same_path(metadata["cwd"], expected_cwd):
38
+ continue
39
+ try:
40
+ modified_at = path.stat().st_mtime
41
+ except OSError:
42
+ continue
43
+ candidates.append(
44
+ SessionArtifact(
45
+ agent="codex",
46
+ path=path.resolve(strict=False),
47
+ cwd=expected_cwd,
48
+ session_id=metadata.get("session_id"),
49
+ modified_at=modified_at,
50
+ )
51
+ )
52
+ return newest(candidates)
53
+
54
+ def _session_files(self) -> Iterator[Path]:
55
+ if not self.root.is_dir():
56
+ return
57
+ for directory, _, filenames in os.walk(self.root):
58
+ for filename in filenames:
59
+ if filename.startswith("rollout-") and filename.endswith(".jsonl"):
60
+ yield Path(directory) / filename
61
+
62
+ @staticmethod
63
+ def _metadata(path: Path) -> dict[str, Any] | None:
64
+ for record in json_records(path):
65
+ if record.get("type") != "session_meta":
66
+ continue
67
+ payload = record.get("payload")
68
+ if not isinstance(payload, dict):
69
+ continue
70
+ recorded_cwd = payload.get("cwd")
71
+ if not isinstance(recorded_cwd, str) or not recorded_cwd:
72
+ return None
73
+ value = payload.get("id") or payload.get("session_id")
74
+ session_id = value if isinstance(value, str) and value else None
75
+ return {"cwd": recorded_cwd, "session_id": session_id}
76
+ return None
@@ -0,0 +1,74 @@
1
+ """Locate Pi JSONL sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Iterator, Mapping
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from ..models import SessionArtifact
11
+ from ..paths import canonical_path, same_path
12
+ from .base import SourceLocator, json_records, newest
13
+
14
+
15
+ class PiLocator(SourceLocator):
16
+ def __init__(
17
+ self,
18
+ *,
19
+ root: Path | None = None,
20
+ environ: Mapping[str, str] | None = None,
21
+ home: Path | None = None,
22
+ ) -> None:
23
+ self.environ = os.environ if environ is None else environ
24
+ user_home = Path.home() if home is None else home
25
+ if root is not None:
26
+ self.root = root
27
+ elif session_dir := self.environ.get("PI_CODING_AGENT_SESSION_DIR"):
28
+ self.root = Path(session_dir).expanduser()
29
+ elif agent_dir := self.environ.get("PI_CODING_AGENT_DIR"):
30
+ self.root = Path(agent_dir).expanduser() / "sessions"
31
+ else:
32
+ self.root = user_home / ".pi" / "agent" / "sessions"
33
+
34
+ def find_latest_for_cwd(self, cwd: Path) -> SessionArtifact | None:
35
+ expected_cwd = canonical_path(cwd)
36
+ candidates: list[SessionArtifact] = []
37
+ for path in self._session_files():
38
+ metadata = self._metadata(path)
39
+ if metadata is None or not same_path(metadata["cwd"], expected_cwd):
40
+ continue
41
+ try:
42
+ modified_at = path.stat().st_mtime
43
+ except OSError:
44
+ continue
45
+ candidates.append(
46
+ SessionArtifact(
47
+ agent="pi",
48
+ path=path.resolve(strict=False),
49
+ cwd=expected_cwd,
50
+ session_id=metadata.get("session_id"),
51
+ modified_at=modified_at,
52
+ )
53
+ )
54
+ return newest(candidates)
55
+
56
+ def _session_files(self) -> Iterator[Path]:
57
+ if not self.root.is_dir():
58
+ return
59
+ for directory, _, filenames in os.walk(self.root):
60
+ for filename in filenames:
61
+ if filename.endswith(".jsonl"):
62
+ yield Path(directory) / filename
63
+
64
+ @staticmethod
65
+ def _metadata(path: Path) -> dict[str, Any] | None:
66
+ record = next(json_records(path, max_lines=1), None)
67
+ if record is None or record.get("type") != "session":
68
+ return None
69
+ recorded_cwd = record.get("cwd")
70
+ if not isinstance(recorded_cwd, str) or not recorded_cwd:
71
+ return None
72
+ value = record.get("id")
73
+ session_id = value if isinstance(value, str) and value else None
74
+ return {"cwd": recorded_cwd, "session_id": session_id}
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: session-handoff
3
+ Version: 0.1.0
4
+ Summary: Continue a coding-agent session in Claude Code, Codex, or Pi
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+
8
+ # Session Handoff
9
+
10
+ > **note**: this project was prototyped rapidly (in about 15 minutes) by gpt-5.6-sol. use at your own discretion.
11
+
12
+ Continue a coding task in another coding agent by pointing it to the previous agent's session file.
13
+
14
+ Supported agents:
15
+
16
+ - Claude Code
17
+ - Codex CLI
18
+ - Pi
19
+
20
+ Session Handoff does not summarize the conversations. It only searches for the file path of latest coding session transcript belonging to the current working directory.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ uv tool install session-handoff
26
+ # or
27
+ pipx install session-handoff
28
+ ```
29
+
30
+ From a local checkout:
31
+
32
+ ```bash
33
+ uv tool install .
34
+ ```
35
+
36
+ ## Use
37
+
38
+ Run `handoff` inside the project you want to continue:
39
+
40
+ ```bash
41
+ # Launch Codex with the latest Claude Code session.
42
+ handoff claude codex
43
+
44
+ # Print the prompt instead of launching the destination.
45
+ handoff claude codex --prompt
46
+
47
+ # All directions are supported.
48
+ handoff codex pi
49
+ handoff pi claude
50
+ ```
51
+
52
+ The generated prompt is:
53
+
54
+ ```text
55
+ Read the previous coding session at <path>, understand the task, inspect the current working directory, and continue from where the previous agent stopped.
56
+ Ask clarifying questions if required.
57
+ ```
58
+
59
+ ## Session selection
60
+
61
+ Handoff matches the current working directory against metadata in each agent's local history and chooses the matching file with the newest modification time.
62
+
63
+ | Agent | Default history location |
64
+ | --- | --- |
65
+ | Claude Code | `~/.claude/projects/*/*.jsonl` |
66
+ | Codex | `~/.codex/sessions/**/rollout-*.jsonl` |
67
+ | Pi | `~/.pi/agent/sessions/**/*.jsonl` |
68
+
69
+ It fails when no session matches the current directory.
70
+
71
+ Native location overrides are supported:
72
+
73
+ - `CLAUDE_CONFIG_DIR`
74
+ - `CODEX_HOME`
75
+ - `PI_CODING_AGENT_SESSION_DIR`
76
+ - `PI_CODING_AGENT_DIR`
77
+
78
+ ## Development
79
+
80
+ Run the tests:
81
+
82
+ ```bash
83
+ PYTHONPATH=src python -m unittest discover -s tests -v
84
+ ```
85
+
@@ -0,0 +1,26 @@
1
+ MANIFEST.in
2
+ README.md
3
+ pyproject.toml
4
+ src/handoff_cli/__init__.py
5
+ src/handoff_cli/__main__.py
6
+ src/handoff_cli/agents.py
7
+ src/handoff_cli/cli.py
8
+ src/handoff_cli/launcher.py
9
+ src/handoff_cli/models.py
10
+ src/handoff_cli/paths.py
11
+ src/handoff_cli/prompt.py
12
+ src/handoff_cli/sources/__init__.py
13
+ src/handoff_cli/sources/base.py
14
+ src/handoff_cli/sources/claude.py
15
+ src/handoff_cli/sources/codex.py
16
+ src/handoff_cli/sources/pi.py
17
+ src/session_handoff.egg-info/PKG-INFO
18
+ src/session_handoff.egg-info/SOURCES.txt
19
+ src/session_handoff.egg-info/dependency_links.txt
20
+ src/session_handoff.egg-info/entry_points.txt
21
+ src/session_handoff.egg-info/top_level.txt
22
+ tests/test_cli.py
23
+ tests/test_sources.py
24
+ tests/fixtures/claude/session.jsonl
25
+ tests/fixtures/codex/session.jsonl
26
+ tests/fixtures/pi/session.jsonl
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ handoff = handoff_cli.cli:main
@@ -0,0 +1,3 @@
1
+ {"type":"mode","sessionId":"claude-session"}
2
+ {"type":"user","sessionId":"claude-session","cwd":"__CWD__","uuid":"user-1","parentUuid":null,"message":{"role":"user","content":"Sanitized fixture task"}}
3
+ {"type":"assistant","sessionId":"claude-session","cwd":"__CWD__","uuid":"assistant-1","parentUuid":"user-1","message":{"role":"assistant","content":[{"type":"text","text":"Sanitized fixture response"}]}}
@@ -0,0 +1,2 @@
1
+ {"timestamp":"2026-01-01T00:00:00Z","type":"session_meta","payload":{"id":"codex-session","cwd":"__CWD__","cli_version":"0.0.0-fixture","source":"cli"}}
2
+ {"timestamp":"2026-01-01T00:00:01Z","type":"event_msg","payload":{"type":"user_message","message":"Sanitized fixture task"}}
@@ -0,0 +1,2 @@
1
+ {"type":"session","version":3,"id":"pi-session","timestamp":"2026-01-01T00:00:00Z","cwd":"__CWD__"}
2
+ {"type":"message","id":"abcd1234","parentId":null,"timestamp":"2026-01-01T00:00:01Z","message":{"role":"user","content":"Sanitized fixture task","timestamp":1767225601000}}
@@ -0,0 +1,194 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import subprocess
5
+ import tempfile
6
+ import unittest
7
+ from pathlib import Path
8
+ from unittest.mock import patch
9
+
10
+ from handoff_cli.cli import run
11
+ from handoff_cli.launcher import DestinationNotFoundError, launch_destination
12
+ from handoff_cli.models import SessionArtifact
13
+ from handoff_cli.prompt import render_prompt
14
+
15
+ from test_sources import fixture, write
16
+
17
+
18
+ class CliTests(unittest.TestCase):
19
+ def make_environment(self, root: Path, cwd: Path) -> dict[str, str]:
20
+ claude_config = root / "claude"
21
+ codex_home = root / "codex"
22
+ pi_sessions = root / "pi"
23
+ write(
24
+ claude_config / "projects" / "project" / "claude.jsonl",
25
+ fixture("claude", cwd),
26
+ mtime=10,
27
+ )
28
+ write(
29
+ codex_home / "sessions" / "2026" / "01" / "rollout-codex.jsonl",
30
+ fixture("codex", cwd),
31
+ mtime=10,
32
+ )
33
+ write(pi_sessions / "project" / "pi.jsonl", fixture("pi", cwd), mtime=10)
34
+ return {
35
+ "CLAUDE_CONFIG_DIR": str(claude_config),
36
+ "CODEX_HOME": str(codex_home),
37
+ "PI_CODING_AGENT_SESSION_DIR": str(pi_sessions),
38
+ }
39
+
40
+ def test_prompt_mode_prints_only_prompt_and_does_not_launch(self) -> None:
41
+ with tempfile.TemporaryDirectory() as temporary:
42
+ root = Path(temporary)
43
+ cwd = root / "project with spaces"
44
+ cwd.mkdir()
45
+ environment = self.make_environment(root, cwd)
46
+ stdout = io.StringIO()
47
+ stderr = io.StringIO()
48
+
49
+ def should_not_launch(*_args: object) -> int:
50
+ self.fail("prompt mode launched a destination")
51
+
52
+ result = run(
53
+ ["claude", "codex", "--prompt"],
54
+ cwd=cwd,
55
+ environ=environment,
56
+ stdout=stdout,
57
+ stderr=stderr,
58
+ launch=should_not_launch,
59
+ )
60
+
61
+ self.assertEqual(result, 0)
62
+ self.assertEqual(stderr.getvalue(), "")
63
+ self.assertTrue(stdout.getvalue().endswith("\n"))
64
+ self.assertIn("Read the previous coding session at", stdout.getvalue())
65
+ self.assertIn("claude.jsonl", stdout.getvalue())
66
+ self.assertIn("Ask clarifying questions if required.", stdout.getvalue())
67
+ self.assertNotIn("Sanitized fixture task", stdout.getvalue())
68
+
69
+ def test_all_nine_routes_use_one_prompt_argument_and_same_cwd(self) -> None:
70
+ with tempfile.TemporaryDirectory() as temporary:
71
+ root = Path(temporary)
72
+ cwd = root / "project"
73
+ cwd.mkdir()
74
+ environment = self.make_environment(root, cwd)
75
+
76
+ for source in ("claude", "codex", "pi"):
77
+ for destination in ("claude", "codex", "pi"):
78
+ with self.subTest(source=source, destination=destination):
79
+ calls: list[tuple[str, str, Path]] = []
80
+
81
+ def fake_launch(
82
+ agent: str, prompt: str, launch_cwd: Path
83
+ ) -> int:
84
+ calls.append((agent, prompt, launch_cwd))
85
+ return 23
86
+
87
+ result = run(
88
+ [source, destination],
89
+ cwd=cwd,
90
+ environ=environment,
91
+ stdout=io.StringIO(),
92
+ stderr=io.StringIO(),
93
+ launch=fake_launch, # type: ignore[arg-type]
94
+ )
95
+
96
+ self.assertEqual(result, 23)
97
+ self.assertEqual(len(calls), 1)
98
+ self.assertEqual(calls[0][0], destination)
99
+ self.assertEqual(calls[0][2], cwd.resolve())
100
+ self.assertIn("Read the previous coding session", calls[0][1])
101
+
102
+ def test_missing_project_session_fails_without_launching(self) -> None:
103
+ with tempfile.TemporaryDirectory() as temporary:
104
+ root = Path(temporary)
105
+ cwd = root / "project"
106
+ cwd.mkdir()
107
+ errors = io.StringIO()
108
+
109
+ result = run(
110
+ ["claude", "codex"],
111
+ cwd=cwd,
112
+ environ={"CLAUDE_CONFIG_DIR": str(root / "missing")},
113
+ stdout=io.StringIO(),
114
+ stderr=errors,
115
+ launch=lambda *_: self.fail("destination launched"),
116
+ )
117
+
118
+ self.assertEqual(result, 1)
119
+ self.assertIn("No Claude Code session was found", errors.getvalue())
120
+ self.assertIn("will not use a session from another", errors.getvalue())
121
+
122
+ def test_destination_error_is_distinct(self) -> None:
123
+ with tempfile.TemporaryDirectory() as temporary:
124
+ root = Path(temporary)
125
+ cwd = root / "project"
126
+ cwd.mkdir()
127
+ environment = self.make_environment(root, cwd)
128
+ errors = io.StringIO()
129
+
130
+ def missing_destination(*_args: object) -> int:
131
+ raise DestinationNotFoundError("codex was not found on PATH")
132
+
133
+ result = run(
134
+ ["pi", "codex"],
135
+ cwd=cwd,
136
+ environ=environment,
137
+ stdout=io.StringIO(),
138
+ stderr=errors,
139
+ launch=missing_destination,
140
+ )
141
+
142
+ self.assertEqual(result, 1)
143
+ self.assertIn("codex was not found on PATH", errors.getvalue())
144
+ self.assertNotIn("No Pi session", errors.getvalue())
145
+
146
+
147
+ class PromptTests(unittest.TestCase):
148
+ def test_prompt_contains_path_not_transcript_and_handles_quotes(self) -> None:
149
+ cwd = Path('/tmp/a project with "quotes"')
150
+ session = cwd / "session's file.jsonl"
151
+ artifact = SessionArtifact(
152
+ agent="pi",
153
+ path=session,
154
+ cwd=cwd,
155
+ session_id="fixture",
156
+ modified_at=1,
157
+ )
158
+
159
+ prompt = render_prompt(artifact)
160
+
161
+ self.assertEqual(
162
+ prompt,
163
+ f"Read the previous coding session at {session}, understand the task, "
164
+ "inspect the current working directory, and continue from where the "
165
+ "previous agent stopped.\nAsk clarifying questions if required.",
166
+ )
167
+
168
+
169
+ class LauncherTests(unittest.TestCase):
170
+ @patch("handoff_cli.launcher.subprocess.run")
171
+ @patch("handoff_cli.launcher.shutil.which", return_value="/tools/codex")
172
+ def test_launcher_uses_argv_without_a_shell(
173
+ self, _which: object, run_process: object
174
+ ) -> None:
175
+ mocked = run_process
176
+ mocked.return_value = subprocess.CompletedProcess([], 17) # type: ignore[attr-defined]
177
+ cwd = Path('/tmp/project with "quotes"')
178
+ prompt = "one argument with spaces, 'quotes', and $shell syntax"
179
+
180
+ result = launch_destination("codex", prompt, cwd)
181
+
182
+ self.assertEqual(result, 17)
183
+ mocked.assert_called_once_with( # type: ignore[attr-defined]
184
+ ["/tools/codex", prompt], cwd=cwd, check=False
185
+ )
186
+
187
+ @patch("handoff_cli.launcher.shutil.which", return_value=None)
188
+ def test_missing_executable_suggests_prompt_mode(self, _which: object) -> None:
189
+ with self.assertRaisesRegex(DestinationNotFoundError, "--prompt"):
190
+ launch_destination("codex", "prompt", Path("/tmp"))
191
+
192
+
193
+ if __name__ == "__main__":
194
+ unittest.main()
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import tempfile
5
+ import unittest
6
+ from pathlib import Path
7
+
8
+ from handoff_cli.sources.claude import ClaudeLocator
9
+ from handoff_cli.sources.codex import CodexLocator
10
+ from handoff_cli.sources.pi import PiLocator
11
+
12
+ FIXTURES = Path(__file__).parent / "fixtures"
13
+
14
+
15
+ def fixture(agent: str, cwd: Path) -> str:
16
+ text = (FIXTURES / agent / "session.jsonl").read_text(encoding="utf-8")
17
+ return text.replace("__CWD__", str(cwd).replace("\\", "\\\\"))
18
+
19
+
20
+ def write(path: Path, content: str, *, mtime: float) -> Path:
21
+ path.parent.mkdir(parents=True, exist_ok=True)
22
+ path.write_text(content, encoding="utf-8")
23
+ os.utime(path, (mtime, mtime))
24
+ return path
25
+
26
+
27
+ class ClaudeLocatorTests(unittest.TestCase):
28
+ def test_selects_newest_root_session_and_ignores_newer_subagent(self) -> None:
29
+ with tempfile.TemporaryDirectory() as temporary:
30
+ root = Path(temporary)
31
+ cwd = root / "project"
32
+ cwd.mkdir()
33
+ project = root / "claude" / str(cwd).replace("/", "-")
34
+ older = write(project / "older.jsonl", fixture("claude", cwd), mtime=10)
35
+ latest = write(project / "latest.jsonl", fixture("claude", cwd), mtime=20)
36
+ write(
37
+ project / "latest" / "subagents" / "agent-newer.jsonl",
38
+ fixture("claude", cwd),
39
+ mtime=30,
40
+ )
41
+
42
+ found = ClaudeLocator(root=root / "claude").find_latest_for_cwd(cwd)
43
+
44
+ self.assertIsNotNone(found)
45
+ assert found is not None
46
+ self.assertEqual(found.path, latest.resolve())
47
+ self.assertNotEqual(found.path, older.resolve())
48
+ self.assertEqual(found.session_id, "claude-session")
49
+
50
+ def test_rejects_globally_newer_session_from_another_cwd(self) -> None:
51
+ with tempfile.TemporaryDirectory() as temporary:
52
+ root = Path(temporary)
53
+ cwd = root / "project"
54
+ other = root / "other"
55
+ cwd.mkdir()
56
+ other.mkdir()
57
+ write(
58
+ root / "claude" / "other-project" / "newest.jsonl",
59
+ fixture("claude", other),
60
+ mtime=100,
61
+ )
62
+
63
+ found = ClaudeLocator(root=root / "claude").find_latest_for_cwd(cwd)
64
+
65
+ self.assertIsNone(found)
66
+
67
+ def test_skips_malformed_file(self) -> None:
68
+ with tempfile.TemporaryDirectory() as temporary:
69
+ root = Path(temporary)
70
+ cwd = root / "project"
71
+ cwd.mkdir()
72
+ project = root / "claude" / str(cwd).replace("/", "-")
73
+ write(project / "broken.jsonl", "not-json\n", mtime=20)
74
+ valid = write(project / "valid.jsonl", fixture("claude", cwd), mtime=10)
75
+
76
+ found = ClaudeLocator(root=root / "claude").find_latest_for_cwd(cwd)
77
+
78
+ self.assertIsNotNone(found)
79
+ assert found is not None
80
+ self.assertEqual(found.path, valid.resolve())
81
+
82
+
83
+ class CodexLocatorTests(unittest.TestCase):
84
+ def test_reads_session_meta_and_selects_newest_matching_rollout(self) -> None:
85
+ with tempfile.TemporaryDirectory() as temporary:
86
+ root = Path(temporary)
87
+ cwd = root / "project"
88
+ cwd.mkdir()
89
+ sessions = root / "codex"
90
+ write(
91
+ sessions / "2026" / "01" / "01" / "rollout-old.jsonl",
92
+ fixture("codex", cwd),
93
+ mtime=10,
94
+ )
95
+ latest = write(
96
+ sessions / "2026" / "01" / "02" / "rollout-latest.jsonl",
97
+ fixture("codex", cwd),
98
+ mtime=20,
99
+ )
100
+ write(
101
+ sessions / "2026" / "01" / "03" / "notes.jsonl",
102
+ fixture("codex", cwd),
103
+ mtime=30,
104
+ )
105
+
106
+ found = CodexLocator(root=sessions).find_latest_for_cwd(cwd)
107
+
108
+ self.assertIsNotNone(found)
109
+ assert found is not None
110
+ self.assertEqual(found.path, latest.resolve())
111
+ self.assertEqual(found.session_id, "codex-session")
112
+
113
+ def test_rejects_different_cwd(self) -> None:
114
+ with tempfile.TemporaryDirectory() as temporary:
115
+ root = Path(temporary)
116
+ cwd = root / "project"
117
+ other = root / "other"
118
+ cwd.mkdir()
119
+ other.mkdir()
120
+ write(
121
+ root / "codex" / "2026" / "rollout-other.jsonl",
122
+ fixture("codex", other),
123
+ mtime=10,
124
+ )
125
+
126
+ self.assertIsNone(
127
+ CodexLocator(root=root / "codex").find_latest_for_cwd(cwd)
128
+ )
129
+
130
+
131
+ class PiLocatorTests(unittest.TestCase):
132
+ def test_reads_header_and_selects_newest_matching_session(self) -> None:
133
+ with tempfile.TemporaryDirectory() as temporary:
134
+ root = Path(temporary)
135
+ cwd = root / "project"
136
+ cwd.mkdir()
137
+ sessions = root / "pi"
138
+ write(sessions / "project" / "old.jsonl", fixture("pi", cwd), mtime=10)
139
+ latest = write(
140
+ sessions / "project" / "latest.jsonl", fixture("pi", cwd), mtime=20
141
+ )
142
+
143
+ found = PiLocator(root=sessions).find_latest_for_cwd(cwd)
144
+
145
+ self.assertIsNotNone(found)
146
+ assert found is not None
147
+ self.assertEqual(found.path, latest.resolve())
148
+ self.assertEqual(found.session_id, "pi-session")
149
+
150
+ def test_rejects_different_cwd(self) -> None:
151
+ with tempfile.TemporaryDirectory() as temporary:
152
+ root = Path(temporary)
153
+ cwd = root / "project"
154
+ other = root / "other"
155
+ cwd.mkdir()
156
+ other.mkdir()
157
+ write(root / "pi" / "other.jsonl", fixture("pi", other), mtime=10)
158
+
159
+ self.assertIsNone(PiLocator(root=root / "pi").find_latest_for_cwd(cwd))
160
+
161
+ @unittest.skipIf(os.name == "nt", "symlink creation needs privileges on Windows")
162
+ def test_matches_symlinked_working_directory(self) -> None:
163
+ with tempfile.TemporaryDirectory() as temporary:
164
+ root = Path(temporary)
165
+ real_cwd = root / "real-project"
166
+ link_cwd = root / "linked-project"
167
+ real_cwd.mkdir()
168
+ link_cwd.symlink_to(real_cwd, target_is_directory=True)
169
+ write(root / "pi" / "session.jsonl", fixture("pi", real_cwd), mtime=10)
170
+
171
+ found = PiLocator(root=root / "pi").find_latest_for_cwd(link_cwd)
172
+
173
+ self.assertIsNotNone(found)
174
+ assert found is not None
175
+ self.assertEqual(found.cwd, real_cwd.resolve())
176
+
177
+
178
+ if __name__ == "__main__":
179
+ unittest.main()