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.
- agent_session_bridge-0.2.0.dist-info/METADATA +288 -0
- agent_session_bridge-0.2.0.dist-info/RECORD +37 -0
- agent_session_bridge-0.2.0.dist-info/WHEEL +5 -0
- agent_session_bridge-0.2.0.dist-info/entry_points.txt +3 -0
- agent_session_bridge-0.2.0.dist-info/licenses/LICENSE +21 -0
- agent_session_bridge-0.2.0.dist-info/top_level.txt +1 -0
- session_bridge/__init__.py +0 -0
- session_bridge/_ids.py +43 -0
- session_bridge/cli.py +473 -0
- session_bridge/convert.py +130 -0
- session_bridge/handshake.py +175 -0
- session_bridge/ir.py +246 -0
- session_bridge/place.py +98 -0
- session_bridge/readers/__init__.py +0 -0
- session_bridge/readers/_content.py +42 -0
- session_bridge/readers/_jsonl.py +50 -0
- session_bridge/readers/_pending.py +63 -0
- session_bridge/readers/claude_code.py +226 -0
- session_bridge/readers/codex.py +203 -0
- session_bridge/readers/hermes.py +167 -0
- session_bridge/skill_install.py +134 -0
- session_bridge/skills/session-handoff/SKILL.md +119 -0
- session_bridge/tui/__init__.py +0 -0
- session_bridge/tui/actions.py +123 -0
- session_bridge/tui/app.py +65 -0
- session_bridge/tui/discovery.py +208 -0
- session_bridge/tui/options.py +86 -0
- session_bridge/tui/register.py +475 -0
- session_bridge/tui/screens.py +710 -0
- session_bridge/tui/summary.py +41 -0
- session_bridge/writers/__init__.py +0 -0
- session_bridge/writers/_common.py +288 -0
- session_bridge/writers/claude_code.py +152 -0
- session_bridge/writers/codex.py +154 -0
- session_bridge/writers/codex_db.py +302 -0
- session_bridge/writers/hermes.py +142 -0
- 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
|