session-ls 0.2.0__tar.gz → 0.2.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: session-ls
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: List and search session history across coding agents (pi, codex, claude, cursor)
5
5
  License: MIT License
6
6
 
@@ -37,6 +37,10 @@ Classifier: Topic :: Utilities
37
37
  Requires-Python: >=3.9
38
38
  Description-Content-Type: text/markdown
39
39
  License-File: LICENSE
40
+ Provides-Extra: dev
41
+ Requires-Dist: pytest<10,>=8; extra == "dev"
42
+ Requires-Dist: build<2,>=1; extra == "dev"
43
+ Requires-Dist: ruff<0.17,>=0.16; extra == "dev"
40
44
  Dynamic: license-file
41
45
 
42
46
  # session-ls
@@ -64,15 +68,18 @@ codex `<recommended_plugins>` or `AGENTS.md` instructions is skipped).
64
68
  - **Plain search, no semantics.** No index, no embeddings, no network.
65
69
  Matching is literal substring comparison. Decide what's relevant
66
70
  yourself - or hand the file paths to an LLM.
67
- - **Lightweight, zero dependencies.** Pure stdlib, one module.
71
+ - **Lightweight, zero dependencies.** Pure stdlib: a legacy `session-ls` CLI, and
72
+ a versioned typed API (`session_ls.api`) for programs such as 4top.
68
73
  - **Extensible.** Adding another agent is one `REGISTRY` entry plus two
69
74
  small functions (see below).
70
75
 
71
76
  ## Install
72
77
 
73
78
  ```bash
74
- pip install . # from a checkout
75
- pipx install . # recommended: isolated environment
79
+ uv tool install session-ls # or:
80
+ pipx install session-ls # isolated environment
81
+ pip install session-ls # into the current environment
82
+ pip install -e '.[dev]' # from a checkout, to work on it
76
83
  ```
77
84
 
78
85
  Requires Python >= 3.9. A man page (`session-ls(1)`) is installed alongside;
@@ -118,7 +125,7 @@ session-ls websocket --json | jq -r .file # feed paths to other tools
118
125
 
119
126
  ## Adding an agent
120
127
 
121
- Append an entry to `REGISTRY` in `packages/session-ls/src/session_ls/__init__.py`:
128
+ Append an entry to `REGISTRY` in `src/session_ls/__init__.py`:
122
129
 
123
130
  1. a glob of session files
124
131
  2. `meta_parser(f, head) -> (cwd, started_iso) | None`
@@ -23,15 +23,18 @@ codex `<recommended_plugins>` or `AGENTS.md` instructions is skipped).
23
23
  - **Plain search, no semantics.** No index, no embeddings, no network.
24
24
  Matching is literal substring comparison. Decide what's relevant
25
25
  yourself - or hand the file paths to an LLM.
26
- - **Lightweight, zero dependencies.** Pure stdlib, one module.
26
+ - **Lightweight, zero dependencies.** Pure stdlib: a legacy `session-ls` CLI, and
27
+ a versioned typed API (`session_ls.api`) for programs such as 4top.
27
28
  - **Extensible.** Adding another agent is one `REGISTRY` entry plus two
28
29
  small functions (see below).
29
30
 
30
31
  ## Install
31
32
 
32
33
  ```bash
33
- pip install . # from a checkout
34
- pipx install . # recommended: isolated environment
34
+ uv tool install session-ls # or:
35
+ pipx install session-ls # isolated environment
36
+ pip install session-ls # into the current environment
37
+ pip install -e '.[dev]' # from a checkout, to work on it
35
38
  ```
36
39
 
37
40
  Requires Python >= 3.9. A man page (`session-ls(1)`) is installed alongside;
@@ -77,7 +80,7 @@ session-ls websocket --json | jq -r .file # feed paths to other tools
77
80
 
78
81
  ## Adding an agent
79
82
 
80
- Append an entry to `REGISTRY` in `packages/session-ls/src/session_ls/__init__.py`:
83
+ Append an entry to `REGISTRY` in `src/session_ls/__init__.py`:
81
84
 
82
85
  1. a glob of session files
83
86
  2. `meta_parser(f, head) -> (cwd, started_iso) | None`
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "session-ls"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "List and search session history across coding agents (pi, codex, claude, cursor)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -25,7 +25,7 @@ Repository = "https://github.com/4ier/session-ls"
25
25
  Issues = "https://github.com/4ier/session-ls/issues"
26
26
 
27
27
  [project.scripts]
28
- session-ls = "session_ls:cli"
28
+ session-ls = "session_ls:cli" # cli() installs the SIGPIPE policy, then runs main()
29
29
 
30
30
  [tool.setuptools]
31
31
  package-dir = { "" = "src" }
@@ -34,5 +34,15 @@ packages = ["session_ls"]
34
34
  [tool.setuptools.data-files]
35
35
  "share/man/man1" = ["man/session-ls.1"]
36
36
 
37
+ [project.optional-dependencies]
38
+ dev = ["pytest>=8,<10", "build>=1,<2", "ruff>=0.16,<0.17"]
39
+
37
40
  [tool.pytest.ini_options]
38
41
  testpaths = ["tests"]
42
+
43
+ [tool.ruff]
44
+ target-version = "py39"
45
+ line-length = 100
46
+
47
+ [tool.ruff.lint]
48
+ select = ["E4", "E7", "E9", "F", "I"]
@@ -11,7 +11,17 @@ Add a new agent by appending one entry to REGISTRY:
11
11
  - user-text-extractor(line) -> first real user message text or ''
12
12
  ('' = keep scanning; drives the early-exit read)
13
13
  """
14
- import argparse, glob, json, os, re, shutil, signal, subprocess, tempfile
14
+ import argparse
15
+ import glob
16
+ import json
17
+ import os
18
+ import re
19
+ import signal
20
+
21
+ # The CLI tests replace this module attribute to prove that nothing shells out, so
22
+ # the name has to exist even though the parser itself never spawns a process.
23
+ import subprocess # noqa: F401
24
+ import tempfile
15
25
  from datetime import datetime, timezone
16
26
 
17
27
  HOME = os.path.expanduser("~")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: session-ls
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: List and search session history across coding agents (pi, codex, claude, cursor)
5
5
  License: MIT License
6
6
 
@@ -37,6 +37,10 @@ Classifier: Topic :: Utilities
37
37
  Requires-Python: >=3.9
38
38
  Description-Content-Type: text/markdown
39
39
  License-File: LICENSE
40
+ Provides-Extra: dev
41
+ Requires-Dist: pytest<10,>=8; extra == "dev"
42
+ Requires-Dist: build<2,>=1; extra == "dev"
43
+ Requires-Dist: ruff<0.17,>=0.16; extra == "dev"
40
44
  Dynamic: license-file
41
45
 
42
46
  # session-ls
@@ -64,15 +68,18 @@ codex `<recommended_plugins>` or `AGENTS.md` instructions is skipped).
64
68
  - **Plain search, no semantics.** No index, no embeddings, no network.
65
69
  Matching is literal substring comparison. Decide what's relevant
66
70
  yourself - or hand the file paths to an LLM.
67
- - **Lightweight, zero dependencies.** Pure stdlib, one module.
71
+ - **Lightweight, zero dependencies.** Pure stdlib: a legacy `session-ls` CLI, and
72
+ a versioned typed API (`session_ls.api`) for programs such as 4top.
68
73
  - **Extensible.** Adding another agent is one `REGISTRY` entry plus two
69
74
  small functions (see below).
70
75
 
71
76
  ## Install
72
77
 
73
78
  ```bash
74
- pip install . # from a checkout
75
- pipx install . # recommended: isolated environment
79
+ uv tool install session-ls # or:
80
+ pipx install session-ls # isolated environment
81
+ pip install session-ls # into the current environment
82
+ pip install -e '.[dev]' # from a checkout, to work on it
76
83
  ```
77
84
 
78
85
  Requires Python >= 3.9. A man page (`session-ls(1)`) is installed alongside;
@@ -118,7 +125,7 @@ session-ls websocket --json | jq -r .file # feed paths to other tools
118
125
 
119
126
  ## Adding an agent
120
127
 
121
- Append an entry to `REGISTRY` in `packages/session-ls/src/session_ls/__init__.py`:
128
+ Append an entry to `REGISTRY` in `src/session_ls/__init__.py`:
122
129
 
123
130
  1. a glob of session files
124
131
  2. `meta_parser(f, head) -> (cwd, started_iso) | None`
@@ -11,6 +11,8 @@ src/session_ls.egg-info/PKG-INFO
11
11
  src/session_ls.egg-info/SOURCES.txt
12
12
  src/session_ls.egg-info/dependency_links.txt
13
13
  src/session_ls.egg-info/entry_points.txt
14
+ src/session_ls.egg-info/requires.txt
14
15
  src/session_ls.egg-info/top_level.txt
16
+ tests/test_api.py
15
17
  tests/test_cli.py
16
18
  tests/test_session_ls.py
@@ -0,0 +1,5 @@
1
+
2
+ [dev]
3
+ pytest<10,>=8
4
+ build<2,>=1
5
+ ruff<0.17,>=0.16
@@ -0,0 +1,338 @@
1
+ import json
2
+ import os
3
+ import subprocess
4
+ import sys
5
+ import threading
6
+ import uuid
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from session_ls.api import (
12
+ HistoryIndex,
13
+ Root,
14
+ clean_text,
15
+ contains_literal_file,
16
+ excerpt,
17
+ history_key,
18
+ open_source,
19
+ query_terms,
20
+ read_metadata,
21
+ search_full,
22
+ )
23
+ from session_ls.storage import StorageError, atomic_json, file_lock, read_json
24
+
25
+
26
+ def make_pi(tmp_path, title="中文 [red]literal.*", native=None):
27
+ root = tmp_path / "pi"
28
+ file = root / "sessions/cwd/run.jsonl"
29
+ file.parent.mkdir(parents=True, exist_ok=True)
30
+ records = [{"type": "session", "id": native or str(uuid.uuid4()), "cwd": str(tmp_path),
31
+ "timestamp": "2026-09-24T00:00:00Z"},
32
+ {"type": "message", "message": {"role": "user", "content": [{"type": "text", "text": title}]}}]
33
+ file.write_text("\n".join(json.dumps(value) for value in records) + "\n")
34
+ return Root("pi", str(root)), file
35
+
36
+
37
+ def test_import_does_not_change_sigpipe_or_touch_files(tmp_path):
38
+ code = "import signal; a=signal.getsignal(signal.SIGPIPE); import session_ls,session_ls.api; assert signal.getsignal(signal.SIGPIPE)==a"
39
+ result = subprocess.run([sys.executable, "-c", code], env={**os.environ, "HOME": str(tmp_path)}, capture_output=True)
40
+ assert result.returncode == 0
41
+ assert list(tmp_path.iterdir()) == []
42
+
43
+
44
+ def test_api_identity_unicode_and_literal_search(tmp_path):
45
+ root, file = make_pi(tmp_path)
46
+ index = HistoryIndex([root], tmp_path / "cache/data.json", "host")
47
+ result = index.scan()
48
+ assert len(result.records) == 1 and not result.issues
49
+ row = result.records[0]
50
+ assert row.native_id and row.cwd_quality == "native" and row.can_resume
51
+ assert row.key == history_key("host", "pi", root.path, row.native_id)
52
+ assert search_full(result.records, "中文").records
53
+ assert search_full(result.records, '"literal.*"').records
54
+ assert not search_full(result.records, '"literal.+"').records
55
+ assert contains_literal_file(str(file), "中文")
56
+ assert not contains_literal_file(str(file), "no-such-term")
57
+ assert "[red]" in excerpt(row).lines[0]
58
+
59
+
60
+ def test_query_terms_are_literal_and_quoted():
61
+ assert query_terms('retry "HELLO WORLD" 中文') == ["retry", "hello world", "中文"]
62
+ with pytest.raises(ValueError):
63
+ query_terms('"unfinished')
64
+ assert query_terms('"unfinished', tolerant=True)
65
+
66
+
67
+ @pytest.mark.parametrize("value", ["\x1b[31mred\x1b[0m", "a\x1b]52;c;YWJj\x07b", "a\u202eb", "a\x00b", "a\x1bPbad\x1b\\b"])
68
+ def test_control_sequences_are_inert(value):
69
+ cleaned = clean_text(value)
70
+ assert "\x1b" not in cleaned and "\u202e" not in cleaned and "\x00" not in cleaned
71
+ assert "YWJj" not in cleaned
72
+
73
+
74
+ def test_half_lines_blank_lines_and_cache_invalidation(tmp_path):
75
+ root, file = make_pi(tmp_path)
76
+ file.write_text(file.read_text().replace('\n', '\n\n') + '{"incomplete":')
77
+ index = HistoryIndex([root], tmp_path / "cache/data.json", "host")
78
+ first = index.scan()
79
+ assert len(first.records) == 1
80
+ cache = tmp_path / "cache/data.json"
81
+ stamp = cache.stat().st_mtime_ns
82
+ assert index.scan().records == first.records
83
+ assert cache.stat().st_mtime_ns == stamp
84
+ file.unlink()
85
+ assert index.scan().records == []
86
+
87
+
88
+ def test_bad_cache_rebuilds(tmp_path):
89
+ root, file = make_pi(tmp_path)
90
+ cache = tmp_path / "cache/data.json"
91
+ index = HistoryIndex([root], cache, "host")
92
+ index.scan()
93
+ cache.write_text("{broken")
94
+ result = index.scan()
95
+ assert len(result.records) == 1
96
+ assert isinstance(read_json(cache), dict)
97
+
98
+
99
+ def test_partial_budget_never_fabricates_absence(tmp_path):
100
+ root, file = make_pi(tmp_path)
101
+ record, _ = read_metadata(root, str(file), "host", max_bytes=60, max_lines=1)
102
+ assert record is not None and record.status == "partial"
103
+
104
+
105
+ def test_source_symlink_and_escape_rejected(tmp_path):
106
+ root, file = make_pi(tmp_path)
107
+ outside = tmp_path / "outside.jsonl"
108
+ outside.write_text("secret")
109
+ link = file.parent / "linked.jsonl"
110
+ link.symlink_to(outside)
111
+ with pytest.raises((OSError, ValueError)):
112
+ with open_source(str(link), root.path):
113
+ pass
114
+ with pytest.raises((OSError, ValueError)):
115
+ with open_source(str(outside), root.path):
116
+ pass
117
+ result = HistoryIndex([root], None, "host").scan()
118
+ assert len(result.records) == 1
119
+
120
+
121
+ def test_same_native_id_in_different_roots_stays_distinct(tmp_path):
122
+ native = str(uuid.uuid4())
123
+ root1, _ = make_pi(tmp_path / "a", native=native)
124
+ root2, _ = make_pi(tmp_path / "b", native=native)
125
+ result = HistoryIndex([root1, root2], None, "host").scan()
126
+ assert len({r.key for r in result.records}) == 2
127
+
128
+
129
+ def test_atomic_permissions_symlinks_and_locks(tmp_path):
130
+ target = tmp_path / "private/state.json"
131
+ atomic_json(target, {"ok": 1})
132
+ assert target.stat().st_mode & 0o777 == 0o600
133
+ assert target.parent.stat().st_mode & 0o777 == 0o700
134
+ assert read_json(target) == {"ok": 1}
135
+ linked_parent = tmp_path / "alias"
136
+ linked_parent.symlink_to(target.parent, target_is_directory=True)
137
+ with pytest.raises(StorageError):
138
+ atomic_json(linked_parent / "other.json", {})
139
+ lock = target.parent / "x.lock"
140
+ with file_lock(lock):
141
+ with pytest.raises(StorageError):
142
+ with file_lock(lock, timeout=.05):
143
+ pass
144
+
145
+
146
+ def test_parallel_cache_writers_preserve_valid_json(tmp_path):
147
+ root, _ = make_pi(tmp_path)
148
+ cache = tmp_path / "cache/index.json"
149
+ failures = []
150
+ def writer():
151
+ try:
152
+ for _ in range(4):
153
+ assert len(HistoryIndex([root], cache, "host").scan().records) == 1
154
+ except Exception as exc:
155
+ failures.append(exc)
156
+ threads = [threading.Thread(target=writer) for _ in range(4)]
157
+ for thread in threads:
158
+ thread.start()
159
+ for thread in threads:
160
+ thread.join()
161
+ assert not failures
162
+ assert isinstance(read_json(cache), dict)
163
+
164
+
165
+ def test_cancelled_search_is_marked_partial(tmp_path):
166
+ root, _ = make_pi(tmp_path)
167
+ records = HistoryIndex([root], None, "host").scan().records
168
+ cancel = threading.Event()
169
+ cancel.set()
170
+ assert search_full(records, "中文", cancel).cancelled
171
+
172
+
173
+ def test_fifo_source_is_rejected_without_blocking(tmp_path):
174
+ root, _ = make_pi(tmp_path)
175
+ fifo = Path(root.path) / "sessions/cwd/fifo.jsonl"
176
+ os.mkfifo(fifo)
177
+ with pytest.raises(PermissionError):
178
+ with open_source(str(fifo), root.path):
179
+ pass
180
+ result = HistoryIndex([root], None, "host").scan()
181
+ assert result.issues and len(result.records) == 1
182
+
183
+
184
+ def test_source_not_written_and_no_network(tmp_path, monkeypatch):
185
+ import socket
186
+ root, source = make_pi(tmp_path)
187
+ before = source.read_bytes()
188
+ def forbidden(*args, **kwargs):
189
+ raise AssertionError("network access")
190
+ monkeypatch.setattr(socket, "socket", forbidden)
191
+ result = HistoryIndex([root], None, "host").scan()
192
+ search_full(result.records, "中文")
193
+ excerpt(result.records[0])
194
+ assert source.read_bytes() == before
195
+
196
+
197
+ def test_cache_write_failure_does_not_hide_history(tmp_path, monkeypatch):
198
+ import session_ls.api as api
199
+ root, _ = make_pi(tmp_path)
200
+ def disk_full(*args, **kwargs):
201
+ raise OSError(28, "disk full")
202
+ monkeypatch.setattr(api, "atomic_json", disk_full)
203
+ result = HistoryIndex([root], tmp_path / "cache/data.json", "host").scan()
204
+ assert len(result.records) == 1
205
+ assert any("Cannot save" in issue for issue in result.issues)
206
+
207
+
208
+ def test_unreadable_child_directory_is_reported_not_silently_hidden(tmp_path, monkeypatch):
209
+ import session_ls.api as api
210
+ root, _ = make_pi(tmp_path)
211
+ (Path(root.path) / "sessions" / "denied").mkdir()
212
+ original = api.os.open
213
+ def guarded(path, *args, **kwargs):
214
+ if str(path) == "denied" and kwargs.get("dir_fd") is not None:
215
+ raise PermissionError("injected unreadable directory")
216
+ return original(path, *args, **kwargs)
217
+ monkeypatch.setattr(api.os, "open", guarded)
218
+ result = HistoryIndex([root]).scan()
219
+ assert len(result.records) == 1
220
+ assert any("denied" in issue and "PermissionError" in issue for issue in result.issues)
221
+
222
+
223
+ def test_cancellation_during_last_file_is_reported(tmp_path, monkeypatch):
224
+ import session_ls.api as api
225
+ root, _ = make_pi(tmp_path)
226
+ rows = HistoryIndex([root]).scan().records
227
+ stop = threading.Event()
228
+ def cancelled(*args):
229
+ stop.set()
230
+ return False, False
231
+ monkeypatch.setattr(api, "_search_stream", cancelled)
232
+ assert search_full(rows, "anything", stop).cancelled
233
+
234
+
235
+ CLAUDE_NATIVE = "11112222-3333-4444-5555-666677778888"
236
+
237
+
238
+ def make_claude(tmp_path, records):
239
+ root = tmp_path / "claude"
240
+ file = root / "projects/-tmp-project" / f"{CLAUDE_NATIVE}.jsonl"
241
+ file.parent.mkdir(parents=True, exist_ok=True)
242
+ file.write_text("\n".join(json.dumps(value) for value in records) + "\n")
243
+ return Root("claude", str(root)), file
244
+
245
+
246
+ def test_claude_metadata_only_session_is_recognized(tmp_path):
247
+ # Claude 2.1.x prepends metadata records; a session that was opened and quit
248
+ # never writes a user/assistant message. It must still list, not error out.
249
+ root, file = make_claude(tmp_path, [
250
+ {"type": "last-prompt", "leafUuid": "c450dde6", "sessionId": CLAUDE_NATIVE},
251
+ {"type": "mode", "mode": "normal", "sessionId": CLAUDE_NATIVE},
252
+ {"type": "attachment", "cwd": str(tmp_path), "timestamp": "2026-09-23T03:25:31.930Z",
253
+ "sessionId": CLAUDE_NATIVE},
254
+ ])
255
+ record, _ = read_metadata(root, str(file))
256
+ assert record.native_id == CLAUDE_NATIVE
257
+ assert record.cwd == str(tmp_path)
258
+ assert record.started == "2026-09-23T03:25:31.930000+00:00"
259
+ assert record.status == "partial" # no user text, but a real session
260
+
261
+
262
+ def test_claude_native_title_is_the_fallback(tmp_path):
263
+ root, file = make_claude(tmp_path, [
264
+ {"type": "ai-title", "aiTitle": "review sandbox changes", "sessionId": CLAUDE_NATIVE},
265
+ {"type": "agent-name", "agentName": "reviewer", "sessionId": CLAUDE_NATIVE},
266
+ ])
267
+ record, _ = read_metadata(root, str(file))
268
+ assert record.title == "review sandbox changes"
269
+
270
+
271
+ def test_claude_user_text_outranks_native_title(tmp_path):
272
+ root, file = make_claude(tmp_path, [
273
+ {"type": "ai-title", "aiTitle": "generated title", "sessionId": CLAUDE_NATIVE},
274
+ {"type": "user", "cwd": str(tmp_path), "timestamp": "2026-09-24T00:00:00Z",
275
+ "sessionId": CLAUDE_NATIVE, "message": {"role": "user", "content": "my own words"}},
276
+ ])
277
+ record, _ = read_metadata(root, str(file))
278
+ assert record.title == "my own words"
279
+
280
+
281
+ def test_unrecognized_format_still_reports_the_reason(tmp_path):
282
+ root, file = make_claude(tmp_path, [{"type": "something-else", "payload": 1}])
283
+ with pytest.raises(ValueError, match="Unrecognized history format"):
284
+ read_metadata(root, str(file))
285
+
286
+
287
+ def test_scan_reports_no_issue_for_metadata_only_claude(tmp_path):
288
+ root, file = make_claude(tmp_path, [
289
+ {"type": "last-prompt", "leafUuid": "c450dde6", "sessionId": CLAUDE_NATIVE},
290
+ {"type": "attachment", "cwd": str(tmp_path), "sessionId": CLAUDE_NATIVE},
291
+ ])
292
+ result = HistoryIndex([root], tmp_path / "cache/data.json", "host").scan()
293
+ assert [record.native_id for record in result.records] == [CLAUDE_NATIVE]
294
+ assert result.issues == []
295
+
296
+
297
+ def test_scan_issue_names_the_reason_not_only_the_exception_type(tmp_path):
298
+ root, file = make_claude(tmp_path, [{"type": "something-else", "payload": 1}])
299
+ result = HistoryIndex([root], tmp_path / "cache/data.json", "host").scan()
300
+ assert result.records == []
301
+ assert result.issues and "ValueError" in result.issues[0]
302
+ assert "Unrecognized history format" in result.issues[0]
303
+ assert str(tmp_path) not in result.issues[0] # never leak the private path
304
+
305
+
306
+ def test_a_file_matching_the_pattern_is_skipped_silently(tmp_path):
307
+ # Real stores contain marker files next to project directories. Matching one is
308
+ # not a degraded source, so it must not be reported on every scan.
309
+ root, file = make_pi(tmp_path)
310
+ (file.parent.parent.parent / "sessions" / "not-a-project").write_text("marker\n")
311
+ result = HistoryIndex([Root("pi", str(root.path))], tmp_path / "cache/data.json", "host").scan()
312
+ assert len(result.records) == 1
313
+ assert result.issues == []
314
+
315
+
316
+ def test_a_root_that_is_not_a_directory_is_reported_once(tmp_path):
317
+ target = tmp_path / "not-a-dir"
318
+ target.write_text("marker\n")
319
+ result = HistoryIndex([Root("pi", str(target))], tmp_path / "cache/data.json", "host").scan()
320
+ assert result.records == []
321
+ assert result.issues == ["pi: configured root is not a directory"]
322
+
323
+
324
+ def test_unreadable_directory_is_still_reported(tmp_path, monkeypatch):
325
+ root, file = make_pi(tmp_path)
326
+ (file.parent.parent.parent / "sessions" / "closed").mkdir()
327
+ real_open = os.open
328
+ target = str(file.parent.parent.parent / "sessions" / "closed")
329
+
330
+ def refusing(path, *args, **kwargs):
331
+ if str(path) == "closed" or str(path) == target:
332
+ raise PermissionError(13, "Permission denied")
333
+ return real_open(path, *args, **kwargs)
334
+
335
+ monkeypatch.setattr(os, "open", refusing)
336
+ index = HistoryIndex([Root("pi", str(root.path))], tmp_path / "cache/data.json", "host")
337
+ result = index.scan()
338
+ assert any("PermissionError" in issue for issue in result.issues), result.issues
@@ -1,12 +1,18 @@
1
1
  #!/usr/bin/env python3
2
2
  """End-to-end CLI tests for session-ls: run `pytest tests/` or
3
3
  `python3 -m pytest` (plain asserts, also runnable one file at a time)."""
4
- import contextlib, io, json, os, sys, tempfile
5
- from datetime import datetime, timezone
4
+ import contextlib
5
+ import io
6
+ import json
7
+ import os
8
+ import sys
9
+ import tempfile
10
+ from datetime import datetime
6
11
 
7
12
  sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(__file__)), "src"))
8
13
  import session_ls as s
9
14
 
15
+
10
16
  def _pi_session(cwd, ts, msgs):
11
17
  lines = [json.dumps({"type": "session", "id": "x", "timestamp": ts, "cwd": cwd})]
12
18
  for i, (role, text) in enumerate(msgs):
@@ -102,7 +108,7 @@ def test_list_and_json_output():
102
108
  reg = _pi_registry(d)
103
109
  paths = _run(["-l", "web"], reg).splitlines()
104
110
  assert len(paths) == 1 and paths[0].endswith("c.jsonl")
105
- rows = [json.loads(l) for l in _run(["--json"], reg).splitlines()]
111
+ rows = [json.loads(line) for line in _run(["--json"], reg).splitlines()]
106
112
  assert [r["title"] for r in rows] == ["deploy web app", "tune postgres pool", "fix login loop"]
107
113
  assert set(rows[0]) == {"agent", "cwd", "started", "last", "title", "file"}
108
114
  # --json + -l: --json wins (checked first in main())
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env python3
2
2
  """Parser checks for session-ls: run `pytest tests/` or `python3 -m pytest`."""
3
- import json, os, sys, tempfile
3
+ import json
4
+ import os
5
+ import sys
6
+ import tempfile
4
7
 
5
8
  sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(__file__)), "src"))
6
9
  import session_ls as s
File without changes
File without changes
File without changes
File without changes