session-ls 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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 4ier
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.4
2
+ Name: session-ls
3
+ Version: 0.1.0
4
+ Summary: List and search session history across coding agents (pi, codex, claude, cursor)
5
+ License: MIT License
6
+
7
+ Copyright (c) 2026 4ier
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+
27
+ Project-URL: Repository, https://github.com/4ier/session-ls
28
+ Project-URL: Issues, https://github.com/4ier/session-ls/issues
29
+ Keywords: agent,sessions,search,pi,codex,claude,cursor
30
+ Classifier: Environment :: Console
31
+ Classifier: Intended Audience :: Developers
32
+ Classifier: License :: OSI Approved :: MIT License
33
+ Classifier: Operating System :: MacOS
34
+ Classifier: Operating System :: POSIX :: Linux
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Topic :: Utilities
37
+ Requires-Python: >=3.9
38
+ Description-Content-Type: text/markdown
39
+ License-File: LICENSE
40
+ Dynamic: license-file
41
+
42
+ # session-ls
43
+
44
+ List and search session history across all coding agents on your machine:
45
+ pi, codex, claude, cursor.
46
+
47
+ ```
48
+ $ session-ls -n 3
49
+ AGENT STARTED LAST CWD TITLE
50
+ codex 2026-07-29T00:03:17 2026-07-29T00:21:15 /home/alice/projects/website fix the login redirect loop
51
+ pi 2026-07-17T14:42:45 2026-07-18T08:45:13 /home/alice/projects/backend tune the postgres connection pool
52
+ claude 2026-06-15T03:08:57 2026-06-15T03:31:55 /home/alice/dotfiles migrate to starship prompt
53
+ ```
54
+
55
+ Sessions are read directly from each agent's local store, newest first. The
56
+ title of a session is its first real user message (injected context such as
57
+ codex `<recommended_plugins>` or `AGENTS.md` instructions is skipped).
58
+
59
+ ## Design
60
+
61
+ - **Fast.** Metadata is cached in `~/.cache/session_ls_cache.json`, keyed by
62
+ file size + mtime; unchanged files are never re-read. Listing ~1000
63
+ sessions takes milliseconds. Full-text search uses `ripgrep` when
64
+ available (fallback: `grep`).
65
+ - **Plain search, no semantics.** No index, no embeddings, no network.
66
+ Matching is literal substring comparison. Decide what's relevant
67
+ yourself - or hand the file paths to an LLM.
68
+ - **Lightweight, zero dependencies.** Pure stdlib, one module.
69
+ - **Extensible.** Adding another agent is one `REGISTRY` entry plus two
70
+ small functions (see below).
71
+
72
+ ## Install
73
+
74
+ ```bash
75
+ pip install . # from a checkout
76
+ pipx install . # recommended: isolated environment
77
+ ```
78
+
79
+ Requires Python >= 3.9. A man page (`session-ls(1)`) is installed alongside;
80
+ on macOS venvs, point `MANPATH` at the venv's `share/man` to see it.
81
+
82
+ ## Usage
83
+
84
+ ```
85
+ session-ls [KEYWORD] [OPTIONS]
86
+ ```
87
+
88
+ | Option | Meaning |
89
+ | --- | --- |
90
+ | `KEYWORD` | search titles (first user message), case-insensitive substring |
91
+ | `-f, --full` | search full session content instead of titles (slow, uses rg/grep) |
92
+ | `-a, --agent` | only this agent: `pi`, `codex`, `claude`, `cursor` |
93
+ | `-c, --cwd` | only sessions under a cwd substring |
94
+ | `--since DATE` | started on/after (YYYY-MM-DD) |
95
+ | `--until DATE` | last active on/before (YYYY-MM-DD) |
96
+ | `-n, --limit N` | show only the N newest |
97
+ | `-l, --list` | print file paths only (for piping) |
98
+ | `--json` | JSON Lines output (keys: agent, cwd, started, last, title, file) |
99
+
100
+ ### Examples
101
+
102
+ ```bash
103
+ session-ls -n 10 # ten most recent sessions
104
+ session-ls websocket # title search
105
+ session-ls "immich 2283" -f # full-content search
106
+ session-ls -a pi -c nemo --since 2026-08-01 # filters combine
107
+ session-ls websocket -l | xargs head -1 # inspect raw matches
108
+ session-ls websocket --json | jq -r .file # feed paths to other tools
109
+ ```
110
+
111
+ ## Supported agents
112
+
113
+ | Agent | Store | Timestamps |
114
+ | --- | --- | --- |
115
+ | pi | `~/.pi/agent/sessions/<encoded-cwd>/*.jsonl` | in file |
116
+ | codex | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` (+ `archived_sessions/`) | in file |
117
+ | claude | `~/.claude/projects/<encoded-cwd>/*.jsonl` | in file |
118
+ | cursor | `~/.cursor/projects/*/agent-transcripts/<id>/<id>.jsonl` | file mtime (none in file) |
119
+
120
+ ## Adding an agent
121
+
122
+ Append an entry to `REGISTRY` in `src/session_ls/__init__.py`:
123
+
124
+ 1. a glob of session files
125
+ 2. `meta_parser(f, head) -> (cwd, started_iso) | None`
126
+ 3. `user_text(line) -> first real user text | ''` (drives the early-exit read)
127
+
128
+ ```python
129
+ REGISTRY = [
130
+ # (name, glob, meta-parser, user-text-extractor)
131
+ ("myagent", os.path.join(HOME, ".myagent/sessions/*.jsonl"),
132
+ _myagent_meta, _myagent_user),
133
+ ]
134
+ ```
135
+
136
+ ## Development
137
+
138
+ ```bash
139
+ python -m pytest tests/ # plain asserts, also runnable via pytest
140
+ python -m session_ls ... # run from a checkout
141
+ ```
142
+
143
+ ## License
144
+
145
+ MIT
@@ -0,0 +1,104 @@
1
+ # session-ls
2
+
3
+ List and search session history across all coding agents on your machine:
4
+ pi, codex, claude, cursor.
5
+
6
+ ```
7
+ $ session-ls -n 3
8
+ AGENT STARTED LAST CWD TITLE
9
+ codex 2026-07-29T00:03:17 2026-07-29T00:21:15 /home/alice/projects/website fix the login redirect loop
10
+ pi 2026-07-17T14:42:45 2026-07-18T08:45:13 /home/alice/projects/backend tune the postgres connection pool
11
+ claude 2026-06-15T03:08:57 2026-06-15T03:31:55 /home/alice/dotfiles migrate to starship prompt
12
+ ```
13
+
14
+ Sessions are read directly from each agent's local store, newest first. The
15
+ title of a session is its first real user message (injected context such as
16
+ codex `<recommended_plugins>` or `AGENTS.md` instructions is skipped).
17
+
18
+ ## Design
19
+
20
+ - **Fast.** Metadata is cached in `~/.cache/session_ls_cache.json`, keyed by
21
+ file size + mtime; unchanged files are never re-read. Listing ~1000
22
+ sessions takes milliseconds. Full-text search uses `ripgrep` when
23
+ available (fallback: `grep`).
24
+ - **Plain search, no semantics.** No index, no embeddings, no network.
25
+ Matching is literal substring comparison. Decide what's relevant
26
+ yourself - or hand the file paths to an LLM.
27
+ - **Lightweight, zero dependencies.** Pure stdlib, one module.
28
+ - **Extensible.** Adding another agent is one `REGISTRY` entry plus two
29
+ small functions (see below).
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install . # from a checkout
35
+ pipx install . # recommended: isolated environment
36
+ ```
37
+
38
+ Requires Python >= 3.9. A man page (`session-ls(1)`) is installed alongside;
39
+ on macOS venvs, point `MANPATH` at the venv's `share/man` to see it.
40
+
41
+ ## Usage
42
+
43
+ ```
44
+ session-ls [KEYWORD] [OPTIONS]
45
+ ```
46
+
47
+ | Option | Meaning |
48
+ | --- | --- |
49
+ | `KEYWORD` | search titles (first user message), case-insensitive substring |
50
+ | `-f, --full` | search full session content instead of titles (slow, uses rg/grep) |
51
+ | `-a, --agent` | only this agent: `pi`, `codex`, `claude`, `cursor` |
52
+ | `-c, --cwd` | only sessions under a cwd substring |
53
+ | `--since DATE` | started on/after (YYYY-MM-DD) |
54
+ | `--until DATE` | last active on/before (YYYY-MM-DD) |
55
+ | `-n, --limit N` | show only the N newest |
56
+ | `-l, --list` | print file paths only (for piping) |
57
+ | `--json` | JSON Lines output (keys: agent, cwd, started, last, title, file) |
58
+
59
+ ### Examples
60
+
61
+ ```bash
62
+ session-ls -n 10 # ten most recent sessions
63
+ session-ls websocket # title search
64
+ session-ls "immich 2283" -f # full-content search
65
+ session-ls -a pi -c nemo --since 2026-08-01 # filters combine
66
+ session-ls websocket -l | xargs head -1 # inspect raw matches
67
+ session-ls websocket --json | jq -r .file # feed paths to other tools
68
+ ```
69
+
70
+ ## Supported agents
71
+
72
+ | Agent | Store | Timestamps |
73
+ | --- | --- | --- |
74
+ | pi | `~/.pi/agent/sessions/<encoded-cwd>/*.jsonl` | in file |
75
+ | codex | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` (+ `archived_sessions/`) | in file |
76
+ | claude | `~/.claude/projects/<encoded-cwd>/*.jsonl` | in file |
77
+ | cursor | `~/.cursor/projects/*/agent-transcripts/<id>/<id>.jsonl` | file mtime (none in file) |
78
+
79
+ ## Adding an agent
80
+
81
+ Append an entry to `REGISTRY` in `src/session_ls/__init__.py`:
82
+
83
+ 1. a glob of session files
84
+ 2. `meta_parser(f, head) -> (cwd, started_iso) | None`
85
+ 3. `user_text(line) -> first real user text | ''` (drives the early-exit read)
86
+
87
+ ```python
88
+ REGISTRY = [
89
+ # (name, glob, meta-parser, user-text-extractor)
90
+ ("myagent", os.path.join(HOME, ".myagent/sessions/*.jsonl"),
91
+ _myagent_meta, _myagent_user),
92
+ ]
93
+ ```
94
+
95
+ ## Development
96
+
97
+ ```bash
98
+ python -m pytest tests/ # plain asserts, also runnable via pytest
99
+ python -m session_ls ... # run from a checkout
100
+ ```
101
+
102
+ ## License
103
+
104
+ MIT
@@ -0,0 +1,150 @@
1
+ .TH SESSION-LS 1 "August 2026" "session-ls 0.1.0" "User Commands"
2
+ .SH NAME
3
+ session-ls \- list and search session history across coding agents
4
+ .SH SYNOPSIS
5
+ .B session-ls
6
+ [\fIKEYWORD\fR] [\fIOPTIONS\fR]
7
+ .SH DESCRIPTION
8
+ .B session-ls
9
+ scans the local session stores of coding agents (pi, codex, claude,
10
+ cursor) and lists every session, newest first. Each line shows the
11
+ agent, session start time, last activity, the working directory, and
12
+ the first real user message (the session "title").
13
+
14
+ Given a
15
+ .IR KEYWORD ,
16
+ only sessions whose title contains it (case-insensitive substring
17
+ match) are shown. With
18
+ .B \-f
19
+ the keyword is instead matched against the full session content.
20
+
21
+ .B session-ls
22
+ is a plain local command-line tool. It keeps no index and does no
23
+ semantic processing; matching is literal text comparison. Session
24
+ files are read directly from the agent stores. Parsed metadata is
25
+ cached in
26
+ \fI~/.cache/session_ls_cache.json\fR ,
27
+ keyed by file size and modification time, so repeated runs over
28
+ gigabytes of session logs are fast; the cache is rebuilt automatically
29
+ whenever a file changes.
30
+ .SH OPTIONS
31
+ .TP
32
+ .B KEYWORD
33
+ Search term. Matched case-insensitively against session titles (or,
34
+ with
35
+ .BR \-f ,
36
+ against full session content). Multiple words must be quoted; they are
37
+ matched as one phrase.
38
+ .TP
39
+ .B \-f, \-\-full
40
+ Search the full session content instead of titles only. Uses ripgrep
41
+ if available, otherwise plain grep; this can take a few seconds over
42
+ large session stores.
43
+ .TP
44
+ .B \-a, \-\-agent \fIAGENT\fR
45
+ Only show sessions from this agent. Valid values: pi, codex, claude,
46
+ cursor.
47
+ .TP
48
+ .B \-c, \-\-cwd \fISUBSTRING\fR
49
+ Only show sessions whose working directory contains SUBSTRING.
50
+ .TP
51
+ .B \-\-since \fIDATE\fR
52
+ Only show sessions started on or after DATE (YYYY\-MM\-DD).
53
+ .TP
54
+ .B \-\-until \fIDATE\fR
55
+ Only show sessions last active on or before DATE (YYYY\-MM\-DD).
56
+ .TP
57
+ .B \-n, \-\-limit \fIN\fR
58
+ Show only the N most recently active sessions.
59
+ .TP
60
+ .B \-l, \-\-list
61
+ Print only the session file paths, one per line (useful for piping to
62
+ other commands).
63
+ .TP
64
+ .B \-\-json
65
+ Print matching sessions as JSON Lines, one object per line, with keys:
66
+ agent, cwd, started, last, title, file.
67
+ .TP
68
+ .B \-h, \-\-help
69
+ Show usage and exit.
70
+ .SH EXIT STATUS
71
+ .B session-ls
72
+ returns 0 on success. No session files found is not an error.
73
+ .SH FILES
74
+ .TP
75
+ .I ~/.pi/agent/sessions/*/*.jsonl
76
+ pi session store, one directory per working directory.
77
+ .TP
78
+ .I ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
79
+ codex session store, grouped by date.
80
+ .TP
81
+ .I ~/.codex/archived_sessions/rollout-*.jsonl
82
+ codex archived sessions.
83
+ .TP
84
+ .I ~/.claude/projects/*/*.jsonl
85
+ claude code session store, one directory per project.
86
+ .TP
87
+ .I ~/.cursor/projects/*/agent-transcripts/*/*.jsonl
88
+ cursor agent transcripts (subagent transcripts are ignored).
89
+ .TP
90
+ .I ~/.cache/session_ls_cache.json
91
+ metadata cache. Safe to delete; it is rebuilt on the next run.
92
+ .SH ENVIRONMENT
93
+ .TP
94
+ .B HOME
95
+ Determines where agent stores and the cache are looked up.
96
+ .SH EXAMPLES
97
+ List the ten most recent sessions:
98
+ .PP
99
+ .RS
100
+ .nf
101
+ session-ls -n 10
102
+ .fi
103
+ .RE
104
+ .PP
105
+ Find sessions whose first user message mentions a LAN (title search):
106
+ .PP
107
+ .RS
108
+ .nf
109
+ session-ls websocket
110
+ .fi
111
+ .RE
112
+ .PP
113
+ Search the full content of all sessions for a phrase:
114
+ .PP
115
+ .RS
116
+ .nf
117
+ session-ls "immich 2283" -f
118
+ .fi
119
+ .RE
120
+ .PP
121
+ Find pi sessions in a project started this month, as JSON:
122
+ .PP
123
+ .RS
124
+ .nf
125
+ session-ls -a pi -c nemo -f --since 2026-08-01 --json
126
+ .fi
127
+ .RE
128
+ .PP
129
+ List file paths of matching sessions and show their first lines:
130
+ .PP
131
+ .RS
132
+ .nf
133
+ session-ls websocket -l | xargs head -1
134
+ .fi
135
+ .RE
136
+ .SH NOTES
137
+ The title of a session is the first real user message. Sessions whose
138
+ log starts with injected context (for example codex
139
+ <recommended_plugins> or AGENTS.md instructions blocks) skip that
140
+ context and use the first genuine user message instead; if a session
141
+ contains only injected context, the first user message is shown anyway.
142
+ Cursor transcripts carry no timestamps, so their start time equals the
143
+ file modification time. The cursor working directory is decoded from
144
+ the project directory name (dashes become slashes) and may be imprecise
145
+ for paths containing dashes; this affects display only, never search.
146
+ .SH SEE ALSO
147
+ .BR rg (1),
148
+ .BR grep (1)
149
+ .SH AUTHOR
150
+ The session-ls project
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "session-ls"
7
+ version = "0.1.0"
8
+ description = "List and search session history across coding agents (pi, codex, claude, cursor)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { file = "LICENSE" }
12
+ keywords = ["agent", "sessions", "search", "pi", "codex", "claude", "cursor"]
13
+ classifiers = [
14
+ "Environment :: Console",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: MacOS",
18
+ "Operating System :: POSIX :: Linux",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Utilities",
21
+ ]
22
+
23
+ [project.urls]
24
+ Repository = "https://github.com/4ier/session-ls"
25
+ Issues = "https://github.com/4ier/session-ls/issues"
26
+
27
+ [project.scripts]
28
+ session-ls = "session_ls:main"
29
+
30
+ [tool.setuptools]
31
+ package-dir = { "" = "src" }
32
+ packages = ["session_ls"]
33
+
34
+ [tool.setuptools.data-files]
35
+ "share/man/man1" = ["man/session-ls.1"]
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,240 @@
1
+ #!/usr/bin/env python3
2
+ """session-ls - list and search session history across coding agents.
3
+
4
+ Default: list every session (newest first). With a keyword: search session
5
+ titles (first real user message). Add --full to grep full content (slow).
6
+
7
+ Add a new agent by appending one entry to REGISTRY:
8
+ (name, glob, meta-parser, user-text-extractor).
9
+ - meta-parser(f, head) -> (cwd, started_iso) or None (started may be None
10
+ if the format has no timestamp; file mtime is used instead)
11
+ - user-text-extractor(line) -> first real user message text or ''
12
+ ('' = keep scanning; drives the early-exit read)
13
+ """
14
+ import argparse, glob, json, os, re, shutil, signal, subprocess
15
+ from datetime import datetime, timezone
16
+
17
+ signal.signal(signal.SIGPIPE, signal.SIG_DFL)
18
+ HOME = os.path.expanduser("~")
19
+ CACHE = os.path.join(HOME, ".cache", "session_ls_cache.json")
20
+
21
+ def _injected(t):
22
+ """True if this user message is injected context, not the user's own text."""
23
+ t = t.lstrip()
24
+ return t.startswith("<") or "AGENTS.md instructions" in t
25
+
26
+ # ---- user-text extractors: line -> first real user text or '' --------------
27
+
28
+ def _pi_user(line):
29
+ m = json.loads(line).get("message", {}) or {}
30
+ if m.get("role") != "user":
31
+ return ""
32
+ t = " ".join(p.get("text", "") for p in m.get("content", [])
33
+ if p.get("type") == "text").strip()
34
+ return t if t and not _injected(t) else ""
35
+
36
+ def _codex_user(line):
37
+ p = json.loads(line).get("payload", {}) or {}
38
+ if p.get("role") != "user":
39
+ return ""
40
+ t = " ".join(c.get("text", "") for c in p.get("content", [])
41
+ if c.get("type") in ("input_text", "text")).strip()
42
+ return t if t and not _injected(t) else ""
43
+
44
+ def _claude_user(line):
45
+ d = json.loads(line)
46
+ if d.get("type") != "user":
47
+ return ""
48
+ c = d.get("message", {}).get("content")
49
+ t = c if isinstance(c, str) else " ".join(
50
+ b.get("text", "") for b in c if isinstance(b, dict) and b.get("type") == "text")
51
+ return t.strip()
52
+
53
+ def _cursor_user(line):
54
+ d = json.loads(line)
55
+ if d.get("role") != "user":
56
+ return ""
57
+ t = " ".join(b.get("text", "") for b in d.get("message", {}).get("content", [])
58
+ if isinstance(b, dict) and b.get("type") == "text").strip()
59
+ # strip the <timestamp>...</timestamp> / <user_query> wrappers
60
+ return re.sub(r"^<timestamp>.*?</timestamp>\s*", "", t).replace(
61
+ "<user_query>", "").replace("</user_query>", "").strip()
62
+
63
+ # ---- meta-parsers: (f, head) -> (cwd, started_iso) or None ------------------
64
+
65
+ def _pi(f, head):
66
+ h = json.loads(head[0])
67
+ if h.get("type") != "session":
68
+ return None
69
+ return h.get("cwd"), h["timestamp"]
70
+
71
+ def _codex(f, head):
72
+ h = json.loads(head[0])
73
+ p = h.get("payload", {}) if h.get("type") == "session_meta" else None
74
+ if not p:
75
+ return None
76
+ return p.get("cwd"), p.get("timestamp") or h.get("timestamp")
77
+
78
+ def _claude(f, head):
79
+ for line in head:
80
+ d = json.loads(line)
81
+ if d.get("type") == "user":
82
+ return d.get("cwd"), d.get("timestamp")
83
+ return None
84
+
85
+ def _cursor(f, head):
86
+ # ~/.cursor/projects/<cwd-dir>/agent-transcripts/<id>/<id>.jsonl
87
+ name = os.path.basename(os.path.dirname(os.path.dirname(os.path.dirname(f))))
88
+ if not name:
89
+ return "", None # path too shallow (e.g. a fixture); cwd unknown
90
+ # ponytail: '-'->'/' decode is best-effort; dirs with '-' in a segment
91
+ # decode wrong. Only affects cwd display, harmless for search.
92
+ cwd = "/" + name.replace("-", "/") if not name[0].isdigit() else name
93
+ return cwd, None # no timestamps in cursor transcripts; use mtime
94
+
95
+ REGISTRY = [
96
+ # (name, glob, meta-parser, user-text-extractor)
97
+ ("pi", os.path.join(HOME, ".pi/agent/sessions/*/*.jsonl"), _pi, _pi_user),
98
+ ("codex", os.path.join(HOME, ".codex/sessions/*/*/*/rollout-*.jsonl"),
99
+ _codex, _codex_user),
100
+ ("codex", os.path.join(HOME, ".codex/archived_sessions/rollout-*.jsonl"),
101
+ _codex, _codex_user),
102
+ ("claude", os.path.join(HOME, ".claude/projects/*/*.jsonl"), _claude, _claude_user),
103
+ ("cursor", os.path.join(HOME, ".cursor/projects/*/agent-transcripts/*/*.jsonl"),
104
+ _cursor, _cursor_user),
105
+ ]
106
+
107
+ def collect():
108
+ files = []
109
+ for name, pattern, parse, user_text in REGISTRY:
110
+ for f in glob.glob(pattern):
111
+ if name == "cursor":
112
+ # keep only the main transcript, skip subagents/
113
+ if os.path.basename(f)[:-6] != os.path.basename(os.path.dirname(f)):
114
+ continue
115
+ files.append((name, parse, user_text, f))
116
+ return files
117
+
118
+ def _load_cache():
119
+ try:
120
+ with open(CACHE, encoding="utf-8") as f:
121
+ return json.load(f)
122
+ except Exception:
123
+ return {}
124
+
125
+ def _save_cache(cache):
126
+ os.makedirs(os.path.dirname(CACHE), exist_ok=True)
127
+ tmp = CACHE + ".tmp"
128
+ with open(tmp, "w", encoding="utf-8") as f:
129
+ json.dump(cache, f, ensure_ascii=False)
130
+ os.replace(tmp, CACHE)
131
+
132
+ def parse_all(files):
133
+ cache = _load_cache()
134
+ new_cache, dirty, rows = {}, False, []
135
+ for name, parse, user_text, f in files:
136
+ try:
137
+ st = os.stat(f)
138
+ sig = [st.st_size, st.st_mtime]
139
+ c = cache.get(f)
140
+ if c and c["sig"] == sig: # unchanged: reuse cached metadata
141
+ new_cache[f] = c
142
+ rows.append({k: v for k, v in c.items() if k != "sig"})
143
+ continue
144
+ except OSError:
145
+ continue
146
+ try:
147
+ with open(f, encoding="utf-8", errors="replace") as fh:
148
+ head, title = [], ""
149
+ for _ in range(2000):
150
+ line = fh.readline().strip()
151
+ if not line:
152
+ break
153
+ head.append(line)
154
+ title = user_text(line)
155
+ if title:
156
+ break # title found, stop reading
157
+ except Exception:
158
+ continue
159
+ r = parse(f, head) if head else None
160
+ if not r:
161
+ continue
162
+ cwd, started = r
163
+ if not title:
164
+ for line in head[1:]:
165
+ title = user_text(line)
166
+ if title:
167
+ break
168
+ mtime = datetime.fromtimestamp(st.st_mtime, timezone.utc).isoformat()
169
+ row = {"agent": name, "cwd": cwd or "", "started": started or mtime,
170
+ "last": mtime, "title": title, "file": f}
171
+ new_cache[f] = {**row, "sig": sig}
172
+ dirty = True
173
+ rows.append(row)
174
+ if dirty:
175
+ _save_cache(new_cache)
176
+ return rows
177
+
178
+ def main():
179
+ ap = argparse.ArgumentParser(description=__doc__,
180
+ formatter_class=argparse.RawDescriptionHelpFormatter)
181
+ ap.add_argument("keyword", nargs="?", default="",
182
+ help="search session titles (first user message)")
183
+ ap.add_argument("-f", "--full", action="store_true",
184
+ help="grep full session content (slow)")
185
+ ap.add_argument("-a", "--agent", help="only this agent")
186
+ ap.add_argument("-c", "--cwd", help="only sessions under this cwd substring")
187
+ ap.add_argument("--since", metavar="DATE", help="started on/after (YYYY-MM-DD)")
188
+ ap.add_argument("--until", metavar="DATE", help="last active on/before (YYYY-MM-DD)")
189
+ ap.add_argument("-n", "--limit", type=int, help="show only N newest")
190
+ ap.add_argument("-l", "--list", action="store_true", help="print file paths only")
191
+ ap.add_argument("--json", action="store_true", help="print rows as JSON lines")
192
+ args = ap.parse_args()
193
+
194
+ files = collect()
195
+
196
+ if args.full and args.keyword:
197
+ # ripgrep if available (much faster over GBs), else plain grep
198
+ rg = shutil.which("rg")
199
+ cmd = [rg, "-l", "-i", args.keyword, *[f for _, _, _, f in files]] if rg \
200
+ else ["grep", "-l", "-i", args.keyword, *[f for _, _, _, f in files]]
201
+ out = subprocess.run(cmd, capture_output=True, text=True).stdout
202
+ keep = set(out.splitlines())
203
+ files = [x for x in files if x[3] in keep]
204
+
205
+ rows = parse_all(files)
206
+
207
+ kw = args.keyword.lower()
208
+ if kw and not args.full:
209
+ rows = [r for r in rows if kw in r["title"].lower()]
210
+ if args.agent:
211
+ rows = [r for r in rows if r["agent"] == args.agent]
212
+ if args.cwd:
213
+ rows = [r for r in rows if args.cwd in r["cwd"]]
214
+ if args.since:
215
+ rows = [r for r in rows if r["started"][:10] >= args.since]
216
+ if args.until:
217
+ rows = [r for r in rows if r["last"][:10] <= args.until]
218
+
219
+ rows.sort(key=lambda r: r["last"], reverse=True)
220
+ if args.limit:
221
+ rows = rows[:args.limit]
222
+
223
+ if args.json:
224
+ for r in rows:
225
+ print(json.dumps(r, ensure_ascii=False))
226
+ elif args.list:
227
+ for r in rows:
228
+ print(r["file"])
229
+ else:
230
+ print(f"{'AGENT':<7} {'STARTED':<20} {'LAST':<20} "
231
+ f"{'CWD':<42} TITLE")
232
+ print("-" * 130)
233
+ for r in rows:
234
+ cwd = r["cwd"] or r["file"]
235
+ print(f"{r['agent']:<7} {r['started'][:19]:<20} {r['last'][:19]:<20} "
236
+ f"{cwd[:42]:<42} {r['title'][:60]}")
237
+ print(f"\n{len(rows)} sessions")
238
+
239
+ if __name__ == "__main__":
240
+ main()
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from session_ls import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.4
2
+ Name: session-ls
3
+ Version: 0.1.0
4
+ Summary: List and search session history across coding agents (pi, codex, claude, cursor)
5
+ License: MIT License
6
+
7
+ Copyright (c) 2026 4ier
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+
27
+ Project-URL: Repository, https://github.com/4ier/session-ls
28
+ Project-URL: Issues, https://github.com/4ier/session-ls/issues
29
+ Keywords: agent,sessions,search,pi,codex,claude,cursor
30
+ Classifier: Environment :: Console
31
+ Classifier: Intended Audience :: Developers
32
+ Classifier: License :: OSI Approved :: MIT License
33
+ Classifier: Operating System :: MacOS
34
+ Classifier: Operating System :: POSIX :: Linux
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Topic :: Utilities
37
+ Requires-Python: >=3.9
38
+ Description-Content-Type: text/markdown
39
+ License-File: LICENSE
40
+ Dynamic: license-file
41
+
42
+ # session-ls
43
+
44
+ List and search session history across all coding agents on your machine:
45
+ pi, codex, claude, cursor.
46
+
47
+ ```
48
+ $ session-ls -n 3
49
+ AGENT STARTED LAST CWD TITLE
50
+ codex 2026-07-29T00:03:17 2026-07-29T00:21:15 /home/alice/projects/website fix the login redirect loop
51
+ pi 2026-07-17T14:42:45 2026-07-18T08:45:13 /home/alice/projects/backend tune the postgres connection pool
52
+ claude 2026-06-15T03:08:57 2026-06-15T03:31:55 /home/alice/dotfiles migrate to starship prompt
53
+ ```
54
+
55
+ Sessions are read directly from each agent's local store, newest first. The
56
+ title of a session is its first real user message (injected context such as
57
+ codex `<recommended_plugins>` or `AGENTS.md` instructions is skipped).
58
+
59
+ ## Design
60
+
61
+ - **Fast.** Metadata is cached in `~/.cache/session_ls_cache.json`, keyed by
62
+ file size + mtime; unchanged files are never re-read. Listing ~1000
63
+ sessions takes milliseconds. Full-text search uses `ripgrep` when
64
+ available (fallback: `grep`).
65
+ - **Plain search, no semantics.** No index, no embeddings, no network.
66
+ Matching is literal substring comparison. Decide what's relevant
67
+ yourself - or hand the file paths to an LLM.
68
+ - **Lightweight, zero dependencies.** Pure stdlib, one module.
69
+ - **Extensible.** Adding another agent is one `REGISTRY` entry plus two
70
+ small functions (see below).
71
+
72
+ ## Install
73
+
74
+ ```bash
75
+ pip install . # from a checkout
76
+ pipx install . # recommended: isolated environment
77
+ ```
78
+
79
+ Requires Python >= 3.9. A man page (`session-ls(1)`) is installed alongside;
80
+ on macOS venvs, point `MANPATH` at the venv's `share/man` to see it.
81
+
82
+ ## Usage
83
+
84
+ ```
85
+ session-ls [KEYWORD] [OPTIONS]
86
+ ```
87
+
88
+ | Option | Meaning |
89
+ | --- | --- |
90
+ | `KEYWORD` | search titles (first user message), case-insensitive substring |
91
+ | `-f, --full` | search full session content instead of titles (slow, uses rg/grep) |
92
+ | `-a, --agent` | only this agent: `pi`, `codex`, `claude`, `cursor` |
93
+ | `-c, --cwd` | only sessions under a cwd substring |
94
+ | `--since DATE` | started on/after (YYYY-MM-DD) |
95
+ | `--until DATE` | last active on/before (YYYY-MM-DD) |
96
+ | `-n, --limit N` | show only the N newest |
97
+ | `-l, --list` | print file paths only (for piping) |
98
+ | `--json` | JSON Lines output (keys: agent, cwd, started, last, title, file) |
99
+
100
+ ### Examples
101
+
102
+ ```bash
103
+ session-ls -n 10 # ten most recent sessions
104
+ session-ls websocket # title search
105
+ session-ls "immich 2283" -f # full-content search
106
+ session-ls -a pi -c nemo --since 2026-08-01 # filters combine
107
+ session-ls websocket -l | xargs head -1 # inspect raw matches
108
+ session-ls websocket --json | jq -r .file # feed paths to other tools
109
+ ```
110
+
111
+ ## Supported agents
112
+
113
+ | Agent | Store | Timestamps |
114
+ | --- | --- | --- |
115
+ | pi | `~/.pi/agent/sessions/<encoded-cwd>/*.jsonl` | in file |
116
+ | codex | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` (+ `archived_sessions/`) | in file |
117
+ | claude | `~/.claude/projects/<encoded-cwd>/*.jsonl` | in file |
118
+ | cursor | `~/.cursor/projects/*/agent-transcripts/<id>/<id>.jsonl` | file mtime (none in file) |
119
+
120
+ ## Adding an agent
121
+
122
+ Append an entry to `REGISTRY` in `src/session_ls/__init__.py`:
123
+
124
+ 1. a glob of session files
125
+ 2. `meta_parser(f, head) -> (cwd, started_iso) | None`
126
+ 3. `user_text(line) -> first real user text | ''` (drives the early-exit read)
127
+
128
+ ```python
129
+ REGISTRY = [
130
+ # (name, glob, meta-parser, user-text-extractor)
131
+ ("myagent", os.path.join(HOME, ".myagent/sessions/*.jsonl"),
132
+ _myagent_meta, _myagent_user),
133
+ ]
134
+ ```
135
+
136
+ ## Development
137
+
138
+ ```bash
139
+ python -m pytest tests/ # plain asserts, also runnable via pytest
140
+ python -m session_ls ... # run from a checkout
141
+ ```
142
+
143
+ ## License
144
+
145
+ MIT
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ man/session-ls.1
5
+ src/session_ls/__init__.py
6
+ src/session_ls/__main__.py
7
+ src/session_ls.egg-info/PKG-INFO
8
+ src/session_ls.egg-info/SOURCES.txt
9
+ src/session_ls.egg-info/dependency_links.txt
10
+ src/session_ls.egg-info/entry_points.txt
11
+ src/session_ls.egg-info/top_level.txt
12
+ tests/test_session_ls.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ session-ls = session_ls:main
@@ -0,0 +1 @@
1
+ session_ls
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env python3
2
+ """Parser checks for session-ls: run `pytest tests/` or `python3 -m pytest`."""
3
+ import json, os, sys, tempfile
4
+
5
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(__file__)), "src"))
6
+ import session_ls as s
7
+
8
+ s.CACHE = os.path.join(tempfile.mkdtemp(), "cache.json") # don't touch real cache
9
+
10
+ def _write(fixtures):
11
+ d = tempfile.mkdtemp()
12
+ for name, lines in fixtures.items():
13
+ with open(os.path.join(d, name), "w") as f:
14
+ f.write("\n".join(lines) + "\n")
15
+ return d
16
+
17
+ def _pi_lines(msgs):
18
+ """Build a pi-style session file body from [(role, text), ...]."""
19
+ return [json.dumps({"type": "message", "id": str(i),
20
+ "message": {"role": r, "content": [{"type": "text", "text": t}]}})
21
+ for i, (r, t) in enumerate(msgs)]
22
+
23
+ def test_parsers():
24
+ d = _write({
25
+ "pi1.jsonl": [json.dumps({"type": "session", "id": "x", "timestamp": "2026-01-01T00:00:00Z",
26
+ "cwd": "/tmp/proj"})] + _pi_lines([("user", "hello world"),
27
+ ("assistant", "hi")]),
28
+ "codex1.jsonl": [json.dumps({"type": "session_meta", "payload": {"session_id": "y",
29
+ "timestamp": "2026-01-02T00:00:00Z", "cwd": "/tmp/other"}})] + [
30
+ json.dumps({"type": "response_item", "payload": {"type": "message", "role": "user",
31
+ "content": [{"type": "input_text", "text": "<recommended_plugins>boiler"}]}}),
32
+ json.dumps({"type": "response_item", "payload": {"type": "message", "role": "user",
33
+ "content": [{"type": "input_text", "text": "actual question here"}]}}),
34
+ ],
35
+ "claude1.jsonl": [
36
+ json.dumps({"type": "queue-operation", "operation": "enqueue", "sessionId": "s1",
37
+ "timestamp": "2026-01-03T00:00:00Z", "content": "x"}),
38
+ json.dumps({"type": "user", "timestamp": "2026-01-03T00:00:01Z", "cwd": "/tmp/claude-proj",
39
+ "message": {"role": "user", "content": "claude question"}}),
40
+ ],
41
+ "sess.jsonl": [
42
+ json.dumps({"role": "user", "message": {"content": [{"type": "text",
43
+ "text": "<timestamp>Wed, Jun 10, 2026 2:52 PM (UTC+8)</timestamp>\n<user_query>\ncursor question\n</user_query>"}]}}),
44
+ ],
45
+ "junk.jsonl": "not json at all\n",
46
+ })
47
+
48
+ rows = s.parse_all([
49
+ ("pi", s._pi, s._pi_user, os.path.join(d, "pi1.jsonl")),
50
+ ("codex", s._codex, s._codex_user, os.path.join(d, "codex1.jsonl")),
51
+ ("claude", s._claude, s._claude_user, os.path.join(d, "claude1.jsonl")),
52
+ ("cursor", s._cursor, s._cursor_user, os.path.join(d, "sess.jsonl")),
53
+ ("codex", s._codex, s._codex_user, os.path.join(d, "junk.jsonl")),
54
+ ])
55
+ assert len(rows) == 4, rows
56
+ by_name = {r["agent"]: r for r in rows}
57
+ assert by_name["pi"]["title"] == "hello world"
58
+ assert by_name["codex"]["title"] == "actual question here" # injected skipped
59
+ assert by_name["claude"]["title"] == "claude question"
60
+ assert by_name["claude"]["cwd"] == "/tmp/claude-proj"
61
+ assert by_name["cursor"]["title"] == "cursor question" # wrappers stripped
62
+ assert by_name["cursor"]["started"] == by_name["cursor"]["last"] # mtime fallback
63
+
64
+ # cache round-trip: second parse_all must reuse cached rows unchanged
65
+ rows2 = s.parse_all([("pi", s._pi, s._pi_user, os.path.join(d, "pi1.jsonl"))])
66
+ assert rows2[0] == next(r for r in rows if r["agent"] == "pi")