agent-session-bridge 0.2.0__py3-none-any.whl

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 (37) hide show
  1. agent_session_bridge-0.2.0.dist-info/METADATA +288 -0
  2. agent_session_bridge-0.2.0.dist-info/RECORD +37 -0
  3. agent_session_bridge-0.2.0.dist-info/WHEEL +5 -0
  4. agent_session_bridge-0.2.0.dist-info/entry_points.txt +3 -0
  5. agent_session_bridge-0.2.0.dist-info/licenses/LICENSE +21 -0
  6. agent_session_bridge-0.2.0.dist-info/top_level.txt +1 -0
  7. session_bridge/__init__.py +0 -0
  8. session_bridge/_ids.py +43 -0
  9. session_bridge/cli.py +473 -0
  10. session_bridge/convert.py +130 -0
  11. session_bridge/handshake.py +175 -0
  12. session_bridge/ir.py +246 -0
  13. session_bridge/place.py +98 -0
  14. session_bridge/readers/__init__.py +0 -0
  15. session_bridge/readers/_content.py +42 -0
  16. session_bridge/readers/_jsonl.py +50 -0
  17. session_bridge/readers/_pending.py +63 -0
  18. session_bridge/readers/claude_code.py +226 -0
  19. session_bridge/readers/codex.py +203 -0
  20. session_bridge/readers/hermes.py +167 -0
  21. session_bridge/skill_install.py +134 -0
  22. session_bridge/skills/session-handoff/SKILL.md +119 -0
  23. session_bridge/tui/__init__.py +0 -0
  24. session_bridge/tui/actions.py +123 -0
  25. session_bridge/tui/app.py +65 -0
  26. session_bridge/tui/discovery.py +208 -0
  27. session_bridge/tui/options.py +86 -0
  28. session_bridge/tui/register.py +475 -0
  29. session_bridge/tui/screens.py +710 -0
  30. session_bridge/tui/summary.py +41 -0
  31. session_bridge/writers/__init__.py +0 -0
  32. session_bridge/writers/_common.py +288 -0
  33. session_bridge/writers/claude_code.py +152 -0
  34. session_bridge/writers/codex.py +154 -0
  35. session_bridge/writers/codex_db.py +302 -0
  36. session_bridge/writers/hermes.py +142 -0
  37. session_bridge/writers/hermes_db.py +294 -0
@@ -0,0 +1,134 @@
1
+ """Bootstrap the packaged agent skill into every harness on this machine.
2
+
3
+ Harnesses that support the SKILL.md convention read from a per-user skills
4
+ directory (``~/.claude/skills``, ``~/.codex/skills``, ``~/.hermes/skills``).
5
+ ``install_skill`` links (or copies) the packaged ``session-handoff`` skill
6
+ into each harness home that actually exists, so one install command makes the
7
+ handoff instructions available everywhere — the agent-skills bootstrap
8
+ pattern.
9
+
10
+ Symlink is the default (the skill tracks the installed package; re-installing
11
+ session-bridge updates every harness at once); ``copy`` decouples the skill
12
+ from the package at the cost of going stale. Existing foreign files are never
13
+ silently replaced — that requires ``force``, matching the no-silent-overwrite
14
+ rule everywhere else in this codebase.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import filecmp
20
+ import os
21
+ import shutil
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+
25
+ SKILL_NAME = "session-handoff"
26
+
27
+ #: Default per-user home of each supported harness. A harness is "available"
28
+ #: when this directory exists; its skills dir is created on demand.
29
+ HARNESS_HOMES: dict[str, str] = {
30
+ "claude-code": "~/.claude",
31
+ "codex": "~/.codex",
32
+ "hermes": "~/.hermes",
33
+ }
34
+
35
+
36
+ def packaged_skill_dir() -> Path:
37
+ """Filesystem path of the skill shipped inside this package."""
38
+ from importlib.resources import files
39
+
40
+ return Path(str(files("session_bridge") / "skills" / SKILL_NAME))
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class InstallResult:
45
+ harness: str
46
+ target: Path | None
47
+ action: str # "linked" | "copied" | "up-to-date" | "skipped" | "error"
48
+ detail: str
49
+
50
+
51
+ def _is_current(target: Path, source: Path) -> bool:
52
+ """Already installed and pointing at (or identical to) this source?"""
53
+ if target.is_symlink():
54
+ try:
55
+ return target.resolve() == source.resolve()
56
+ except OSError:
57
+ return False
58
+ manifest = target / "SKILL.md"
59
+ if manifest.is_file():
60
+ try:
61
+ return filecmp.cmp(manifest, source / "SKILL.md", shallow=False)
62
+ except OSError:
63
+ return False
64
+ return False
65
+
66
+
67
+ def install_skill(
68
+ harness_homes: dict[str, Path],
69
+ *,
70
+ source: Path | None = None,
71
+ copy: bool = False,
72
+ force: bool = False,
73
+ ) -> list[InstallResult]:
74
+ """Install the packaged skill into each existing harness home.
75
+
76
+ Never raises for a single harness: each one reports its own outcome so
77
+ one broken home cannot abort the others.
78
+ """
79
+ src = source if source is not None else packaged_skill_dir()
80
+ results: list[InstallResult] = []
81
+ if not (src / "SKILL.md").is_file():
82
+ return [
83
+ InstallResult(
84
+ harness="*",
85
+ target=None,
86
+ action="error",
87
+ detail=f"packaged skill not found at {src}",
88
+ )
89
+ ]
90
+
91
+ for harness, home in harness_homes.items():
92
+ home = Path(home).expanduser()
93
+ if not home.is_dir():
94
+ results.append(
95
+ InstallResult(harness, None, "skipped", f"{home} does not exist")
96
+ )
97
+ continue
98
+ target = home / "skills" / SKILL_NAME
99
+ try:
100
+ if target.is_symlink() or target.exists():
101
+ if _is_current(target, src):
102
+ results.append(
103
+ InstallResult(harness, target, "up-to-date", str(target))
104
+ )
105
+ continue
106
+ if not force:
107
+ results.append(
108
+ InstallResult(
109
+ harness,
110
+ target,
111
+ "error",
112
+ f"{target} already exists and differs; "
113
+ "re-run with --force to replace it",
114
+ )
115
+ )
116
+ continue
117
+ if target.is_symlink() or target.is_file():
118
+ target.unlink()
119
+ else:
120
+ shutil.rmtree(target)
121
+ target.parent.mkdir(parents=True, exist_ok=True)
122
+ if copy:
123
+ shutil.copytree(src, target)
124
+ results.append(InstallResult(harness, target, "copied", str(target)))
125
+ else:
126
+ os.symlink(src.resolve(), target, target_is_directory=True)
127
+ results.append(
128
+ InstallResult(
129
+ harness, target, "linked", f"{target} -> {src.resolve()}"
130
+ )
131
+ )
132
+ except OSError as exc:
133
+ results.append(InstallResult(harness, target, "error", str(exc)))
134
+ return results
@@ -0,0 +1,119 @@
1
+ ---
2
+ name: session-handoff
3
+ description: Hand off the CURRENT agent session to another harness (Claude Code, Codex, or Hermes) so the user can resume it there. Use when the user says "move/continue this session in <harness>", "convert this session", "hand off to codex/hermes/claude", or when a usage limit is about to stop this session. The source is the session this skill runs in; the destination is whatever harness the user names.
4
+ ---
5
+
6
+ # Session handoff: continue this session in another harness
7
+
8
+ You are running inside a coding-agent session. The user wants this session
9
+ (or another one they name) to become resumable in a different harness. Use
10
+ the `session-bridge` CLI from this repo to do it. Everything is local files;
11
+ no network.
12
+
13
+ ```
14
+ locate source file ─▶ inspect ─▶ convert or register ─▶ give resume command
15
+ ```
16
+
17
+ ## 0. Ensure session-bridge is installed
18
+
19
+ ```bash
20
+ session-bridge --help >/dev/null 2>&1 || python3 -m pip install -e <path-to-this-repo>
21
+ ```
22
+
23
+ (`<path-to-this-repo>` = the repo containing this skill file.)
24
+
25
+ ## 1. Locate the source session file (the session you are in)
26
+
27
+ | You are running in | Where the transcript lives |
28
+ |---|---|
29
+ | Claude Code | `~/.claude/projects/<encoded-cwd>/<session-uuid>.jsonl` |
30
+ | Codex | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` |
31
+ | Hermes | `~/.hermes/sessions/<ts>_<id>.jsonl` (exports; state.db is the truth) |
32
+
33
+ - **Claude Code:** encode the project cwd the way Claude Code does — resolve
34
+ the real path (`os.path.realpath`; macOS symlinks like `/tmp` →
35
+ `/private/tmp` matter), then replace every `/` with `-`. List that
36
+ directory's `*.jsonl` by mtime; the newest one being actively written is
37
+ the current session. Confirm by grepping it for a distinctive string from
38
+ the current conversation.
39
+ - **Codex / Hermes:** newest file under the sessions tree, same confirmation
40
+ grep.
41
+ - If the user names a *different* session ("yesterday's session about X"),
42
+ find it the same way — by store, mtime, and a content grep — or run
43
+ `session-bridge tui` and let the user pick from the discovery list.
44
+
45
+ ## 2. Inspect before converting
46
+
47
+ ```bash
48
+ session-bridge inspect --from <source-harness> <file>
49
+ ```
50
+
51
+ Check the pending state it prints. If there are **open tool calls** (the
52
+ session stopped mid-turn), plan to add `--stub-open-calls` in step 3 —
53
+ providers reject a transcript whose tool call has no result, so the flag
54
+ appends a synthetic interrupted result and the handshake still discloses it.
55
+
56
+ ## 3. Convert or register, by destination
57
+
58
+ Harness names: `claude-code`, `codex`, `hermes`.
59
+
60
+ **Destination Claude Code** — file placement is enough:
61
+
62
+ ```bash
63
+ session-bridge convert --from <source> --to claude-code <file> \
64
+ --place-claude-cwd <project-cwd> [--stub-open-calls]
65
+ ```
66
+
67
+ It prints the placed path and the exact resume command
68
+ (`cd <cwd> && claude --resume <uuid>`). The cwd you place for must be the
69
+ directory the user will launch `claude` from.
70
+
71
+ **Destination Hermes** — needs a SQLite registration (a file drop is NOT
72
+ resumable):
73
+
74
+ ```bash
75
+ session-bridge register --from <source> <file> \
76
+ --model <hermes-configured-model> --title "<short title>" [--stub-open-calls]
77
+ ```
78
+
79
+ `--model` matters: a cross-harness source id (e.g. `claude-*`) that Hermes
80
+ cannot route makes the resumed turn silently lose context. Ask the user
81
+ which Hermes model to use, or check `~/.hermes/` config. Resume:
82
+ `hermes --resume <printed sb_… id>`.
83
+
84
+ **Destination Codex** — rollout + SQLite index:
85
+
86
+ ```bash
87
+ session-bridge register-codex --from <source> <file> \
88
+ --cwd <project-cwd> --title "<short title>" [--stub-open-calls]
89
+ ```
90
+
91
+ The model defaults to the most recently used one for provider `openai`;
92
+ pass `--model`/`--model-provider` if the user wants otherwise. Resume:
93
+ `cd <cwd> && codex resume <uuid>`.
94
+
95
+ Both register commands back up the store first and print the backup path —
96
+ keep `--no-backup` off unless the user explicitly asks.
97
+
98
+ ## 4. Finish
99
+
100
+ - Relay the printed **resume command** to the user verbatim; that is the
101
+ deliverable.
102
+ - Conversion notes on stderr list what could not transfer losslessly
103
+ (thread forks, tool schemas, reasoning signatures, …). Summarize any that
104
+ matter; the same notes are embedded in the transcript's resume handshake,
105
+ so the receiving agent will see them too.
106
+ - A handoff of the CURRENT session captures it only up to the last flushed
107
+ turn — anything after the conversion runs is not carried over, so run the
108
+ handoff last, right before the user switches tools.
109
+
110
+ ## Pitfalls
111
+
112
+ - Never `--force`/overwrite an existing placed transcript unless the user
113
+ confirms — it may be a previously recovered session.
114
+ - Session ids must be UUID-shaped for Codex; let the tool generate them.
115
+ - Transcripts can contain secrets. Never commit converted session files;
116
+ write outputs outside the repo (`fixtures/real/` is gitignored for a
117
+ reason).
118
+ - Prefer `session-bridge tui` when the user should choose interactively;
119
+ prefer the plain CLI when you already know source, file, and destination.
File without changes
@@ -0,0 +1,123 @@
1
+ """Side-effect layer behind the TUI's convert flow.
2
+
3
+ Split in two so the TUI can preview before committing:
4
+
5
+ - ``run_conversion`` is PURE — it produces a ``ConversionResult`` (records,
6
+ loss report, handshake text) without touching the filesystem, so the TUI can
7
+ render a preview and let the user back out.
8
+ - ``execute_writes`` performs every write the CLI's ``convert`` command would
9
+ (output JSONL, optional handshake file, optional Claude Code placement) and
10
+ reports the outcome as data. It never raises: the TUI renders errors, and a
11
+ partial outcome (e.g. output written but placement refused) must survive the
12
+ failure so the user knows what actually landed on disk.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import shlex
18
+ import uuid
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ from .._ids import UnsafeSessionIdError
23
+ from ..convert import ConversionResult, convert, dump_jsonl, now_codex_timestamp
24
+ from ..place import SessionExistsError, UnsafeCwdError, place_claude_code
25
+ from .options import ConvertOptions, resolve_output
26
+
27
+
28
+ def run_conversion(opts: ConvertOptions) -> ConversionResult:
29
+ """Convert per ``opts`` without writing anything.
30
+
31
+ Stamps Codex output with the real current time, matching the CLI (the
32
+ writer's placeholder date would hide the session from Codex's recency
33
+ sort).
34
+ """
35
+ codex_ts = now_codex_timestamp() if opts.target == "codex" else None
36
+ return convert(
37
+ opts.source,
38
+ opts.target,
39
+ opts.path,
40
+ inject_handshake=not opts.no_handshake,
41
+ codex_timestamp=codex_ts,
42
+ stub_open_calls=opts.stub_open_calls,
43
+ )
44
+
45
+
46
+ @dataclass
47
+ class WriteOutcome:
48
+ output_path: Path | None
49
+ record_count: int
50
+ handshake_path: Path | None
51
+ placed_path: Path | None
52
+ resume_hint: str | None
53
+ error: str | None # None = full success
54
+
55
+
56
+ def execute_writes(
57
+ result: ConversionResult,
58
+ opts: ConvertOptions,
59
+ *,
60
+ claude_home: Path | None = None,
61
+ ) -> WriteOutcome:
62
+ """Perform all writes for a previously-run conversion.
63
+
64
+ Errors are returned, never raised. Paths that were successfully written
65
+ before the failure stay set in the outcome alongside ``error``.
66
+ """
67
+ output_path: Path | None = None
68
+ handshake_path: Path | None = None
69
+ placed_path: Path | None = None
70
+ resume_hint: str | None = None
71
+
72
+ def outcome(error: str | None) -> WriteOutcome:
73
+ return WriteOutcome(
74
+ output_path=output_path,
75
+ record_count=len(result.records),
76
+ handshake_path=handshake_path,
77
+ placed_path=placed_path,
78
+ resume_hint=resume_hint,
79
+ error=error,
80
+ )
81
+
82
+ try:
83
+ # A codex target's session_meta timestamp was stamped when the dry-run
84
+ # conversion ran; the user may sit on that screen indefinitely, and the
85
+ # CLI stamps immediately before writing. Re-run the (pure) conversion
86
+ # so the written timestamp is bound to the write, like the CLI's.
87
+ if opts.target == "codex":
88
+ result = run_conversion(opts)
89
+
90
+ out = resolve_output(opts)
91
+ dump_jsonl(result.records, out)
92
+ output_path = Path(out)
93
+
94
+ if opts.handshake_out:
95
+ hs = Path(opts.handshake_out)
96
+ hs.write_text(result.handshake, encoding="utf-8")
97
+ handshake_path = hs
98
+
99
+ if opts.place_claude_cwd:
100
+ session_id = opts.session_id or str(uuid.uuid4())
101
+ placed_path = place_claude_code(
102
+ result.records,
103
+ opts.place_claude_cwd,
104
+ session_id,
105
+ claude_home=claude_home,
106
+ overwrite=opts.force,
107
+ )
108
+ # Same hint the CLI prints after a successful placement.
109
+ resume_hint = (
110
+ f"(cd {shlex.quote(opts.place_claude_cwd)} "
111
+ f"&& claude --resume {shlex.quote(session_id)})"
112
+ )
113
+ except (
114
+ UnsafeSessionIdError,
115
+ UnsafeCwdError,
116
+ SessionExistsError,
117
+ OSError,
118
+ # The codex re-conversion re-reads the source file, which can have
119
+ # changed since the dry run: parse failures are ValueErrors.
120
+ ValueError,
121
+ ) as exc:
122
+ return outcome(str(exc))
123
+ return outcome(None)
@@ -0,0 +1,65 @@
1
+ """The session-bridge TUI application shell.
2
+
3
+ Importing this module requires textual (the ``[tui]`` extra); cli.py imports it
4
+ lazily and prints an install hint when the import fails.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from textual.app import App
13
+
14
+ from .screens import PickerScreen
15
+
16
+
17
+ class SessionBridgeApp(App):
18
+ """Linear convert wizard: pick a session, inspect, configure, dry-run, write."""
19
+
20
+ TITLE = "session-bridge"
21
+ CSS = """
22
+ #picker-status { padding: 0 1; color: $text-muted; }
23
+ #sessions { height: 1fr; }
24
+ VerticalScroll { padding: 1 2; }
25
+ .switch-row { height: auto; align-vertical: middle; }
26
+ .switch-row Label { padding: 1 0 0 1; }
27
+ #place-group { border: round $primary; padding: 0 1; margin: 1 0; }
28
+ #form-errors { color: $error; }
29
+ #dryrun-buttons { height: auto; padding: 0 2 1 2; }
30
+ #dryrun-buttons Button { margin-right: 2; }
31
+ #plan-buttons { height: auto; padding: 0 2 1 2; }
32
+ #plan-buttons Button { margin-right: 2; }
33
+ Label { margin-top: 1; }
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ *,
39
+ claude_home: Optional[Path] = None,
40
+ codex_home: Optional[Path] = None,
41
+ hermes_home: Optional[Path] = None,
42
+ ) -> None:
43
+ super().__init__()
44
+ # Explicit store overrides (tests and unusual layouts); None means the
45
+ # discovery module's real defaults (~/.claude, ~/.codex, ~/.hermes).
46
+ self.claude_home = claude_home
47
+ self.codex_home = codex_home
48
+ self.hermes_home = hermes_home
49
+
50
+ def on_mount(self) -> None:
51
+ self.push_screen(PickerScreen())
52
+
53
+
54
+ def run_tui(
55
+ *,
56
+ claude_home: Optional[Path] = None,
57
+ codex_home: Optional[Path] = None,
58
+ hermes_home: Optional[Path] = None,
59
+ ) -> int:
60
+ SessionBridgeApp(
61
+ claude_home=claude_home,
62
+ codex_home=codex_home,
63
+ hermes_home=hermes_home,
64
+ ).run()
65
+ return 0
@@ -0,0 +1,208 @@
1
+ """Cheap session discovery across the three harness stores.
2
+
3
+ The TUI needs a fast, fault-tolerant listing of every session on disk without
4
+ paying for a full parse of each transcript. Each scanner reads only the head of
5
+ a file (``_head_lines``) to extract identity metadata (session id, cwd) and a
6
+ one-line preview, and stats each file exactly once.
7
+
8
+ Design rules:
9
+ - Never raise for a single bad file: any candidate that cannot be read or
10
+ parsed is silently skipped so one corrupt transcript cannot blank the picker.
11
+ - Never decode Claude Code's dashed project directory names back into a cwd —
12
+ that encoding is lossy ("-" is both the separator and a literal). The cwd is
13
+ taken from the records themselves.
14
+ - This module imports only stdlib + session_bridge internals; it must stay
15
+ usable without the optional TUI framework installed.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+ _PREVIEW_LIMIT = 120
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class SessionEntry:
29
+ harness: str # one of convert.HARNESSES
30
+ path: Path
31
+ mtime: float
32
+ size: int
33
+ session_id: str | None
34
+ cwd: str | None
35
+ preview: str | None # first user-message text, truncated
36
+
37
+
38
+ # Real Claude Code transcripts open with a variable-length preamble of header
39
+ # records (file-history-snapshot, ai-title, last-prompt, ...) before the first
40
+ # user turn, so the head window must reach well past it to find cwd/preview.
41
+ _HEAD_WINDOW = 64
42
+ # Identity/preview metadata lives near the head in small records; cap the read
43
+ # so one pathological single-line file cannot exhaust memory mid-scan.
44
+ _MAX_LINE_BYTES = 1 << 20
45
+
46
+
47
+ def _head_lines(path: Path, n: int = _HEAD_WINDOW) -> list[dict]:
48
+ """Parse the first ``n`` JSON lines of a JSONL file, skipping bad lines."""
49
+ records: list[dict] = []
50
+ with open(path, encoding="utf-8", errors="replace") as fh:
51
+ for _ in range(n):
52
+ line = fh.readline(_MAX_LINE_BYTES)
53
+ if not line:
54
+ break
55
+ if len(line) >= _MAX_LINE_BYTES and not line.endswith("\n"):
56
+ break # over-long line: stop rather than misparse its remainder
57
+ try:
58
+ obj = json.loads(line)
59
+ except json.JSONDecodeError:
60
+ continue
61
+ if isinstance(obj, dict):
62
+ records.append(obj)
63
+ return records
64
+
65
+
66
+ def _truncate(text: str) -> str | None:
67
+ text = text.strip()
68
+ if not text:
69
+ return None
70
+ if len(text) > _PREVIEW_LIMIT:
71
+ return text[: _PREVIEW_LIMIT - 1] + "…"
72
+ return text
73
+
74
+
75
+ def _joined_text(content: object, text_key: str = "text") -> str:
76
+ """Flatten a message content value (str or list of blocks) to plain text."""
77
+ if isinstance(content, str):
78
+ return content
79
+ if isinstance(content, list):
80
+ parts = []
81
+ for block in content:
82
+ if isinstance(block, dict) and isinstance(block.get(text_key), str):
83
+ parts.append(block[text_key])
84
+ return "\n".join(parts)
85
+ return ""
86
+
87
+
88
+ def scan_claude_code(claude_home: Path | None = None) -> list[SessionEntry]:
89
+ home = Path(claude_home) if claude_home is not None else Path.home() / ".claude"
90
+ entries: list[SessionEntry] = []
91
+ for path in sorted(home.glob("projects/*/*.jsonl")):
92
+ try:
93
+ stat = path.stat()
94
+ head = _head_lines(path)
95
+ cwd = None
96
+ preview = None
97
+ for rec in head:
98
+ if cwd is None and isinstance(rec.get("cwd"), str):
99
+ cwd = rec["cwd"]
100
+ if preview is None and rec.get("type") == "user":
101
+ message = rec.get("message")
102
+ if isinstance(message, dict):
103
+ preview = _truncate(_joined_text(message.get("content")))
104
+ entries.append(
105
+ SessionEntry(
106
+ harness="claude-code",
107
+ path=path,
108
+ mtime=stat.st_mtime,
109
+ size=stat.st_size,
110
+ session_id=path.stem,
111
+ cwd=cwd,
112
+ preview=preview,
113
+ )
114
+ )
115
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError, KeyError, TypeError, ValueError):
116
+ continue
117
+ return entries
118
+
119
+
120
+ def scan_codex(codex_home: Path | None = None) -> list[SessionEntry]:
121
+ home = Path(codex_home) if codex_home is not None else Path.home() / ".codex"
122
+ entries: list[SessionEntry] = []
123
+ # Sessions nest under YYYY/MM/DD date directories.
124
+ for path in sorted(home.glob("sessions/*/*/*/rollout-*.jsonl")):
125
+ try:
126
+ stat = path.stat()
127
+ head = _head_lines(path)
128
+ session_id = None
129
+ cwd = None
130
+ preview = None
131
+ for rec in head:
132
+ payload = rec.get("payload")
133
+ if not isinstance(payload, dict):
134
+ continue
135
+ if rec.get("type") == "session_meta":
136
+ if session_id is None and isinstance(payload.get("id"), str):
137
+ session_id = payload["id"]
138
+ if cwd is None and isinstance(payload.get("cwd"), str):
139
+ cwd = payload["cwd"]
140
+ elif (
141
+ preview is None
142
+ and rec.get("type") == "response_item"
143
+ and payload.get("type") == "message"
144
+ and payload.get("role") == "user"
145
+ ):
146
+ preview = _truncate(_joined_text(payload.get("content")))
147
+ entries.append(
148
+ SessionEntry(
149
+ harness="codex",
150
+ path=path,
151
+ mtime=stat.st_mtime,
152
+ size=stat.st_size,
153
+ session_id=session_id,
154
+ cwd=cwd,
155
+ preview=preview,
156
+ )
157
+ )
158
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError, KeyError, TypeError, ValueError):
159
+ continue
160
+ return entries
161
+
162
+
163
+ def scan_hermes(hermes_home: Path | None = None) -> list[SessionEntry]:
164
+ home = Path(hermes_home) if hermes_home is not None else Path.home() / ".hermes"
165
+ entries: list[SessionEntry] = []
166
+ for path in sorted(home.glob("sessions/*.jsonl")):
167
+ try:
168
+ stat = path.stat()
169
+ # The Hermes session id IS the full filename stem (e.g.
170
+ # "20260416_004124_10f12c82") — it's what state.db sessions.id
171
+ # holds and what `hermes --resume` expects. Never split it.
172
+ session_id = path.stem
173
+ head = _head_lines(path)
174
+ preview = None
175
+ for rec in head:
176
+ if rec.get("role") == "user":
177
+ preview = _truncate(_joined_text(rec.get("content")))
178
+ break
179
+ entries.append(
180
+ SessionEntry(
181
+ harness="hermes",
182
+ path=path,
183
+ mtime=stat.st_mtime,
184
+ size=stat.st_size,
185
+ session_id=session_id or None,
186
+ cwd=None,
187
+ preview=preview,
188
+ )
189
+ )
190
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError, KeyError, TypeError, ValueError):
191
+ continue
192
+ return entries
193
+
194
+
195
+ def discover_sessions(
196
+ *,
197
+ claude_home: Path | None = None,
198
+ codex_home: Path | None = None,
199
+ hermes_home: Path | None = None,
200
+ ) -> list[SessionEntry]:
201
+ """All sessions across the three stores, newest first."""
202
+ entries = (
203
+ scan_claude_code(claude_home)
204
+ + scan_codex(codex_home)
205
+ + scan_hermes(hermes_home)
206
+ )
207
+ entries.sort(key=lambda entry: entry.mtime, reverse=True)
208
+ return entries