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.
- session_handoff-0.1.0/MANIFEST.in +1 -0
- session_handoff-0.1.0/PKG-INFO +85 -0
- session_handoff-0.1.0/README.md +78 -0
- session_handoff-0.1.0/pyproject.toml +16 -0
- session_handoff-0.1.0/setup.cfg +4 -0
- session_handoff-0.1.0/src/handoff_cli/__init__.py +3 -0
- session_handoff-0.1.0/src/handoff_cli/__main__.py +4 -0
- session_handoff-0.1.0/src/handoff_cli/agents.py +22 -0
- session_handoff-0.1.0/src/handoff_cli/cli.py +110 -0
- session_handoff-0.1.0/src/handoff_cli/launcher.py +41 -0
- session_handoff-0.1.0/src/handoff_cli/models.py +25 -0
- session_handoff-0.1.0/src/handoff_cli/paths.py +20 -0
- session_handoff-0.1.0/src/handoff_cli/prompt.py +13 -0
- session_handoff-0.1.0/src/handoff_cli/sources/__init__.py +35 -0
- session_handoff-0.1.0/src/handoff_cli/sources/base.py +63 -0
- session_handoff-0.1.0/src/handoff_cli/sources/claude.py +91 -0
- session_handoff-0.1.0/src/handoff_cli/sources/codex.py +76 -0
- session_handoff-0.1.0/src/handoff_cli/sources/pi.py +74 -0
- session_handoff-0.1.0/src/session_handoff.egg-info/PKG-INFO +85 -0
- session_handoff-0.1.0/src/session_handoff.egg-info/SOURCES.txt +26 -0
- session_handoff-0.1.0/src/session_handoff.egg-info/dependency_links.txt +1 -0
- session_handoff-0.1.0/src/session_handoff.egg-info/entry_points.txt +2 -0
- session_handoff-0.1.0/src/session_handoff.egg-info/top_level.txt +1 -0
- session_handoff-0.1.0/tests/fixtures/claude/session.jsonl +3 -0
- session_handoff-0.1.0/tests/fixtures/codex/session.jsonl +2 -0
- session_handoff-0.1.0/tests/fixtures/pi/session.jsonl +2 -0
- session_handoff-0.1.0/tests/test_cli.py +194 -0
- 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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
handoff_cli
|
|
@@ -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,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()
|