Skip to content

Commit a6e4e58

Browse files
authored
feat: add rename_session (#668)
## Changes - NEW: `rename_session(session_id, title, directory=None)` — appends a `{type:'custom-title',customTitle:<title>,sessionId:<id>}` JSONL entry to the session file. `list_sessions()` reads the LAST custom-title from the file tail, so repeated calls are safe — most recent wins. - New `_internal/session_mutations.py` module with shared append infrastructure (`_append_to_session`, `_try_append`) for the mutation function stack. ## Port notes **O_APPEND handling:** - Uses `os.open(O_WRONLY | O_APPEND)` — **no `O_CREAT`** — so missing files fail with ENOENT, enabling TOCTOU-free search across candidate directories. - **No Windows/Bun workaround needed.** CPython's `os.open` with `O_APPEND` maps correctly to `FILE_APPEND_DATA` on Windows and is kernel-atomic on POSIX. Unlike Bun's position-0 bug (which requires the TS SDK to `fstat`-then-write-at-explicit-position on Windows), CPython handles this natively. **Other behavior:** - Title is `.strip()`ed before storing; empty/whitespace-only titles rejected with `ValueError` (matches CLI guard). - Skips 0-byte stubs during search — matches reader search protocol (`_read_session_lite` already treats 0-byte as "not here"). - Worktree fallback when `directory` is provided; searches all project dirs when omitted. - Compact JSON output (`separators=(',',':')`) matching CLI format. - Parameter named `directory` (not `dir` — Python builtin), matching existing `list_sessions`/`get_session_messages`. - Raises `ValueError` for bad input, `FileNotFoundError` for missing session (Python-idiomatic). <!-- CHANGELOG:START --> - Add `rename_session()` for setting a session's custom title <!-- CHANGELOG:END --> ## Tests 15 tests (TestTryAppend, TestRenameSession). 281 total pass. Ruff + mypy clean. ## Stack - **B1 (this): `rename_session`** - B2: `tag_session` — stacked on this
1 parent 2d5c3cb commit a6e4e58

3 files changed

Lines changed: 445 additions & 0 deletions

File tree

src/claude_agent_sdk/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
CLINotFoundError,
1414
ProcessError,
1515
)
16+
from ._internal.session_mutations import rename_session
1617
from ._internal.sessions import get_session_messages, list_sessions
1718
from ._internal.transport import Transport
1819
from ._version import __version__
@@ -420,6 +421,8 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
420421
"get_session_messages",
421422
"SDKSessionInfo",
422423
"SessionMessage",
424+
# Session mutations
425+
"rename_session",
423426
# Beta support
424427
"SdkBeta",
425428
# Sandbox support
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
"""Portable session mutation functions for the Agent SDK.
2+
3+
Ported from TypeScript SDK (sessionMutationsImpl.ts).
4+
5+
Rename/tag append typed metadata entries to the session's JSONL (matching
6+
the CLI pattern); delete removes the JSONL and the per-session directory.
7+
Safe to call from any SDK host process — see concurrent-writer note below.
8+
9+
Directory resolution matches list_sessions / get_session_messages:
10+
``directory`` is the project path (not the storage dir); when omitted, all
11+
project directories are searched for the session file.
12+
13+
Concurrent writers: if the target session is currently open in a CLI
14+
process, the CLI's reAppendSessionMetadata() tail-reads before re-appending
15+
its cached metadata. If an SDK write (e.g. a custom-title entry) is in the
16+
tail scan window, the CLI absorbs it into its cache and re-appends the SDK
17+
value — not the stale CLI value.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import errno
23+
import json
24+
import os
25+
from pathlib import Path
26+
27+
from .sessions import (
28+
_canonicalize_path,
29+
_find_project_dir,
30+
_get_projects_dir,
31+
_get_worktree_paths,
32+
_validate_uuid,
33+
)
34+
35+
# ---------------------------------------------------------------------------
36+
# Public API
37+
# ---------------------------------------------------------------------------
38+
39+
40+
def rename_session(
41+
session_id: str,
42+
title: str,
43+
directory: str | None = None,
44+
) -> None:
45+
"""Rename a session by appending a custom-title entry.
46+
47+
``list_sessions`` reads the LAST custom-title from the file tail, so
48+
repeated calls are safe — the most recent wins.
49+
50+
Args:
51+
session_id: UUID of the session to rename.
52+
title: New session title. Leading/trailing whitespace is stripped.
53+
Must be non-empty after stripping.
54+
directory: Project directory path (same semantics as
55+
``list_sessions(directory=...)``). When omitted, all project
56+
directories are searched for the session file.
57+
58+
Raises:
59+
ValueError: If ``session_id`` is not a valid UUID, or if ``title``
60+
is empty/whitespace-only.
61+
FileNotFoundError: If the session file cannot be found.
62+
63+
Example:
64+
Rename a session in a specific project::
65+
66+
rename_session(
67+
"550e8400-e29b-41d4-a716-446655440000",
68+
"My refactoring session",
69+
directory="/path/to/project",
70+
)
71+
"""
72+
if not _validate_uuid(session_id):
73+
raise ValueError(f"Invalid session_id: {session_id}")
74+
# Matches CLI guard — empty/whitespace titles are rejected rather than
75+
# overloaded as "clear title".
76+
stripped = title.strip()
77+
if not stripped:
78+
raise ValueError("title must be non-empty")
79+
80+
data = (
81+
json.dumps(
82+
{
83+
"type": "custom-title",
84+
"customTitle": stripped,
85+
"sessionId": session_id,
86+
},
87+
separators=(",", ":"),
88+
)
89+
+ "\n"
90+
)
91+
92+
_append_to_session(session_id, data, directory)
93+
94+
95+
# ---------------------------------------------------------------------------
96+
# Helpers
97+
# ---------------------------------------------------------------------------
98+
99+
100+
def _append_to_session(
101+
session_id: str,
102+
data: str,
103+
directory: str | None,
104+
) -> None:
105+
"""Append data to an existing session file.
106+
107+
Searches candidate paths and tries the append directly — no existence
108+
check. Uses O_WRONLY | O_APPEND (without O_CREAT) so the open fails with
109+
ENOENT for missing files, avoiding TOCTOU.
110+
"""
111+
file_name = f"{session_id}.jsonl"
112+
113+
if directory:
114+
canonical = _canonicalize_path(directory)
115+
116+
# Try the exact/prefix-matched project directory first.
117+
project_dir = _find_project_dir(canonical)
118+
if project_dir is not None and _try_append(project_dir / file_name, data):
119+
return
120+
121+
# Worktree fallback — matches list_sessions/get_session_messages.
122+
# Sessions may live under a different worktree root.
123+
try:
124+
worktree_paths = _get_worktree_paths(canonical)
125+
except Exception:
126+
worktree_paths = []
127+
for wt in worktree_paths:
128+
if wt == canonical:
129+
continue # already tried above
130+
wt_project_dir = _find_project_dir(wt)
131+
if wt_project_dir is not None and _try_append(
132+
wt_project_dir / file_name, data
133+
):
134+
return
135+
136+
raise FileNotFoundError(
137+
f"Session {session_id} not found in project directory for {directory}"
138+
)
139+
140+
# No directory — search all project directories by trying each directly.
141+
projects_dir = _get_projects_dir()
142+
try:
143+
dirents = list(projects_dir.iterdir())
144+
except OSError as e:
145+
raise FileNotFoundError(
146+
f"Session {session_id} not found (no projects directory)"
147+
) from e
148+
for entry in dirents:
149+
if _try_append(entry / file_name, data):
150+
return
151+
raise FileNotFoundError(f"Session {session_id} not found in any project directory")
152+
153+
154+
def _try_append(path: Path, data: str) -> bool:
155+
"""Try appending to a path.
156+
157+
Opens with O_WRONLY | O_APPEND (no O_CREAT), so the open fails with
158+
ENOENT if the file does not exist — no separate existence check.
159+
160+
Returns ``True`` on successful write, ``False`` if the file does not
161+
exist (ENOENT/ENOTDIR) or is 0-byte. A 0-byte ``.jsonl`` is a "session
162+
not here, keep searching" signal that readers (``_read_session_lite``)
163+
already honor; without this guard the search would stop at an empty stub
164+
in one project dir while the real file lives in a worktree. Re-raises all
165+
other errors (ENOSPC, EACCES, EIO, etc.) so real write failures surface.
166+
167+
O_APPEND semantics: Python's ``os.open`` with ``os.O_APPEND`` maps to the
168+
kernel's append mode on all platforms. On POSIX, O_APPEND makes the kernel
169+
atomically seek-to-EOF on every write (race-free). On Windows, CPython's
170+
``os.open`` translates O_APPEND to ``FILE_APPEND_DATA`` (also atomic).
171+
Unlike the TS SDK's Bun/Windows workaround, CPython handles this correctly
172+
so no explicit-position fallback is needed.
173+
"""
174+
try:
175+
fd = os.open(path, os.O_WRONLY | os.O_APPEND)
176+
except OSError as e:
177+
if e.errno in (errno.ENOENT, errno.ENOTDIR):
178+
return False
179+
raise
180+
try:
181+
stat = os.fstat(fd)
182+
if stat.st_size == 0:
183+
return False
184+
os.write(fd, data.encode("utf-8"))
185+
return True
186+
finally:
187+
os.close(fd)

0 commit comments

Comments
 (0)