2mw2lt 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.
- 2mw2lt-0.1.0/LICENSE +21 -0
- 2mw2lt-0.1.0/PKG-INFO +33 -0
- 2mw2lt-0.1.0/README.md +22 -0
- 2mw2lt-0.1.0/pyproject.toml +19 -0
- 2mw2lt-0.1.0/setup.cfg +4 -0
- 2mw2lt-0.1.0/src/2mw2lt.egg-info/PKG-INFO +33 -0
- 2mw2lt-0.1.0/src/2mw2lt.egg-info/SOURCES.txt +13 -0
- 2mw2lt-0.1.0/src/2mw2lt.egg-info/dependency_links.txt +1 -0
- 2mw2lt-0.1.0/src/2mw2lt.egg-info/entry_points.txt +2 -0
- 2mw2lt-0.1.0/src/2mw2lt.egg-info/top_level.txt +1 -0
- 2mw2lt-0.1.0/src/twomw2lt/__init__.py +3 -0
- 2mw2lt-0.1.0/src/twomw2lt/__main__.py +3 -0
- 2mw2lt-0.1.0/src/twomw2lt/cli.py +65 -0
- 2mw2lt-0.1.0/src/twomw2lt/sessions.py +144 -0
- 2mw2lt-0.1.0/tests/test_sessions.py +62 -0
2mw2lt-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 endario
|
|
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.
|
2mw2lt-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: 2mw2lt
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Too Much Work, Too Little Time: see what every AI coding session on this machine is running.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Keywords: claude-code,codex,ai-agents,sessions
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# 2mw2lt
|
|
13
|
+
|
|
14
|
+
**Too Much Work, Too Little Time** — when several AI coding sessions run at once, see what each
|
|
15
|
+
one is doing.
|
|
16
|
+
|
|
17
|
+
```console
|
|
18
|
+
$ uvx 2mw2lt
|
|
19
|
+
HARNESS SESSION MODEL EFFORT ACTIVE PROJECT
|
|
20
|
+
claude e2583d33 claude-opus-5 high just now ~/code/app
|
|
21
|
+
codex 01a0b790 gpt-5.6-sol high 4m ago ~/code/app
|
|
22
|
+
claude 1242e7a2 claude-sonnet-5 medium 2h ago ~/code/api
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
It reads the transcripts Claude Code (`~/.claude`, or `$CLAUDE_CONFIG_DIR`) and Codex
|
|
26
|
+
(`~/.codex`, or `$CODEX_HOME`) already keep on disk. Nothing is sent anywhere and nothing is
|
|
27
|
+
written.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
2mw2lt [--since 24h] [--json]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`--since` takes `90m`, `24h`, `7d`. `--json` prints one object per session.
|
2mw2lt-0.1.0/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# 2mw2lt
|
|
2
|
+
|
|
3
|
+
**Too Much Work, Too Little Time** — when several AI coding sessions run at once, see what each
|
|
4
|
+
one is doing.
|
|
5
|
+
|
|
6
|
+
```console
|
|
7
|
+
$ uvx 2mw2lt
|
|
8
|
+
HARNESS SESSION MODEL EFFORT ACTIVE PROJECT
|
|
9
|
+
claude e2583d33 claude-opus-5 high just now ~/code/app
|
|
10
|
+
codex 01a0b790 gpt-5.6-sol high 4m ago ~/code/app
|
|
11
|
+
claude 1242e7a2 claude-sonnet-5 medium 2h ago ~/code/api
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
It reads the transcripts Claude Code (`~/.claude`, or `$CLAUDE_CONFIG_DIR`) and Codex
|
|
15
|
+
(`~/.codex`, or `$CODEX_HOME`) already keep on disk. Nothing is sent anywhere and nothing is
|
|
16
|
+
written.
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
2mw2lt [--since 24h] [--json]
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`--since` takes `90m`, `24h`, `7d`. `--json` prints one object per session.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "2mw2lt"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Too Much Work, Too Little Time: see what every AI coding session on this machine is running."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = ["claude-code", "codex", "ai-agents", "sessions"]
|
|
10
|
+
|
|
11
|
+
[project.scripts]
|
|
12
|
+
2mw2lt = "twomw2lt.cli:main"
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["setuptools>=77"]
|
|
16
|
+
build-backend = "setuptools.build_meta"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools.packages.find]
|
|
19
|
+
where = ["src"]
|
2mw2lt-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: 2mw2lt
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Too Much Work, Too Little Time: see what every AI coding session on this machine is running.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Keywords: claude-code,codex,ai-agents,sessions
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# 2mw2lt
|
|
13
|
+
|
|
14
|
+
**Too Much Work, Too Little Time** — when several AI coding sessions run at once, see what each
|
|
15
|
+
one is doing.
|
|
16
|
+
|
|
17
|
+
```console
|
|
18
|
+
$ uvx 2mw2lt
|
|
19
|
+
HARNESS SESSION MODEL EFFORT ACTIVE PROJECT
|
|
20
|
+
claude e2583d33 claude-opus-5 high just now ~/code/app
|
|
21
|
+
codex 01a0b790 gpt-5.6-sol high 4m ago ~/code/app
|
|
22
|
+
claude 1242e7a2 claude-sonnet-5 medium 2h ago ~/code/api
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
It reads the transcripts Claude Code (`~/.claude`, or `$CLAUDE_CONFIG_DIR`) and Codex
|
|
26
|
+
(`~/.codex`, or `$CODEX_HOME`) already keep on disk. Nothing is sent anywhere and nothing is
|
|
27
|
+
written.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
2mw2lt [--since 24h] [--json]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`--since` takes `90m`, `24h`, `7d`. `--json` prints one object per session.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/2mw2lt.egg-info/PKG-INFO
|
|
5
|
+
src/2mw2lt.egg-info/SOURCES.txt
|
|
6
|
+
src/2mw2lt.egg-info/dependency_links.txt
|
|
7
|
+
src/2mw2lt.egg-info/entry_points.txt
|
|
8
|
+
src/2mw2lt.egg-info/top_level.txt
|
|
9
|
+
src/twomw2lt/__init__.py
|
|
10
|
+
src/twomw2lt/__main__.py
|
|
11
|
+
src/twomw2lt/cli.py
|
|
12
|
+
src/twomw2lt/sessions.py
|
|
13
|
+
tests/test_sessions.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
twomw2lt
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from twomw2lt import __version__
|
|
10
|
+
from twomw2lt.sessions import scan
|
|
11
|
+
|
|
12
|
+
_UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def duration(text: str) -> float:
|
|
16
|
+
m = re.fullmatch(r"(\d+)([smhd])", text)
|
|
17
|
+
if not m:
|
|
18
|
+
raise argparse.ArgumentTypeError(f"{text!r} is not a duration like 90m, 24h or 7d")
|
|
19
|
+
return int(m.group(1)) * _UNITS[m.group(2)]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def ago(seconds: float) -> str:
|
|
23
|
+
for unit, size in (("d", 86400), ("h", 3600), ("m", 60)):
|
|
24
|
+
if seconds >= size:
|
|
25
|
+
return f"{int(seconds // size)}{unit} ago"
|
|
26
|
+
return "just now"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def project(path: str | None) -> str:
|
|
30
|
+
if not path:
|
|
31
|
+
return "-"
|
|
32
|
+
home = str(Path.home())
|
|
33
|
+
return "~" + path[len(home):] if path == home or path.startswith(home + "/") else path
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main(argv: list[str] | None = None) -> int:
|
|
37
|
+
ap = argparse.ArgumentParser(
|
|
38
|
+
prog="2mw2lt",
|
|
39
|
+
description="List the Claude Code and Codex sessions on this machine: where each works, "
|
|
40
|
+
"which model it runs and at what effort. Reads local transcripts only.",
|
|
41
|
+
)
|
|
42
|
+
ap.add_argument("--since", type=duration, default=duration("24h"),
|
|
43
|
+
help="only sessions active within this window (default 24h)")
|
|
44
|
+
ap.add_argument("--json", action="store_true", help="one JSON object per session")
|
|
45
|
+
ap.add_argument("--version", action="version", version=f"2mw2lt {__version__}")
|
|
46
|
+
args = ap.parse_args(argv)
|
|
47
|
+
|
|
48
|
+
now = time.time()
|
|
49
|
+
sessions = scan(args.since, now=now)
|
|
50
|
+
if args.json:
|
|
51
|
+
for s in sessions:
|
|
52
|
+
print(json.dumps(s.as_dict()))
|
|
53
|
+
return 0
|
|
54
|
+
if not sessions:
|
|
55
|
+
print("no sessions active in that window")
|
|
56
|
+
return 0
|
|
57
|
+
rows = [("HARNESS", "SESSION", "MODEL", "EFFORT", "ACTIVE", "PROJECT")] + [
|
|
58
|
+
(s.harness, s.id[:8], s.model or "-", s.effort or "-", ago(now - s.last_active),
|
|
59
|
+
project(s.project))
|
|
60
|
+
for s in sessions
|
|
61
|
+
]
|
|
62
|
+
widths = [max(len(r[i]) for r in rows) for i in range(len(rows[0]) - 1)]
|
|
63
|
+
for r in rows:
|
|
64
|
+
print(" ".join(c.ljust(w) for c, w in zip(r, widths)) + " " + r[-1])
|
|
65
|
+
return 0
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Read the AI coding sessions on this machine from the transcripts their harnesses write.
|
|
2
|
+
|
|
3
|
+
Nothing is sent anywhere and nothing is written: each harness already records, per session, the
|
|
4
|
+
directory it works in, the model it runs and the reasoning effort it was given. This folds those
|
|
5
|
+
records into one row per session.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import asdict, dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
# A transcript's latest state is at its end; reading only the tail keeps a scan of long sessions
|
|
17
|
+
# proportional to the number of sessions, not to how much each one said.
|
|
18
|
+
TAIL_BYTES = 512 * 1024
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Session:
|
|
23
|
+
harness: str
|
|
24
|
+
id: str
|
|
25
|
+
project: str | None
|
|
26
|
+
model: str | None
|
|
27
|
+
effort: str | None
|
|
28
|
+
last_active: float
|
|
29
|
+
transcript: str
|
|
30
|
+
|
|
31
|
+
def as_dict(self) -> dict:
|
|
32
|
+
return asdict(self)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _tail_lines(path: Path, size: int = TAIL_BYTES) -> list[str]:
|
|
36
|
+
with path.open("rb") as f:
|
|
37
|
+
f.seek(0, os.SEEK_END)
|
|
38
|
+
end = f.tell()
|
|
39
|
+
f.seek(max(0, end - size))
|
|
40
|
+
data = f.read()
|
|
41
|
+
lines = data.decode("utf-8", "replace").splitlines()
|
|
42
|
+
# A cut mid-file leaves a partial first line.
|
|
43
|
+
return lines[1:] if end > size else lines
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _records(lines: list[str]):
|
|
47
|
+
for line in lines:
|
|
48
|
+
try:
|
|
49
|
+
d = json.loads(line)
|
|
50
|
+
except ValueError:
|
|
51
|
+
continue
|
|
52
|
+
if isinstance(d, dict):
|
|
53
|
+
yield d
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def read_claude(path: Path) -> Session | None:
|
|
57
|
+
"""A Claude Code transcript: one JSON object per line, `assistant` lines carry the model."""
|
|
58
|
+
project = model = effort = sid = None
|
|
59
|
+
for d in _records(_tail_lines(path)):
|
|
60
|
+
sid = d.get("sessionId") or sid
|
|
61
|
+
project = d.get("cwd") or project
|
|
62
|
+
if d.get("type") == "assistant":
|
|
63
|
+
m = d.get("message")
|
|
64
|
+
if isinstance(m, dict) and m.get("model") and not m["model"].startswith("<"):
|
|
65
|
+
model = m["model"]
|
|
66
|
+
effort = d.get("effort") or effort
|
|
67
|
+
if sid is None:
|
|
68
|
+
return None
|
|
69
|
+
return Session("claude", sid, project, model, effort, path.stat().st_mtime, str(path))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def read_codex(path: Path) -> Session | None:
|
|
73
|
+
"""A Codex rollout: `session_meta` opens it, and every turn restates its `turn_context`."""
|
|
74
|
+
with path.open("rb") as f:
|
|
75
|
+
head = f.readline().decode("utf-8", "replace")
|
|
76
|
+
s = _fold_codex(path, [head] + _tail_lines(path))
|
|
77
|
+
if s is not None and s.model is None:
|
|
78
|
+
# A turn's context can sit before the tail when one turn produced a long transcript.
|
|
79
|
+
s = _fold_codex(path, path.read_text("utf-8", "replace").splitlines())
|
|
80
|
+
return s
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _fold_codex(path: Path, lines: list[str]) -> Session | None:
|
|
84
|
+
project = model = effort = sid = None
|
|
85
|
+
for d in _records(lines):
|
|
86
|
+
p = d.get("payload") if isinstance(d.get("payload"), dict) else {}
|
|
87
|
+
if d.get("type") == "session_meta":
|
|
88
|
+
sid = p.get("id") or p.get("session_id") or sid
|
|
89
|
+
project = p.get("cwd") or project
|
|
90
|
+
elif d.get("type") == "turn_context":
|
|
91
|
+
project = p.get("cwd") or project
|
|
92
|
+
model = p.get("model") or model
|
|
93
|
+
effort = p.get("effort") or effort
|
|
94
|
+
if sid is None:
|
|
95
|
+
return None
|
|
96
|
+
return Session("codex", sid, project, model, effort, path.stat().st_mtime, str(path))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def claude_homes() -> list[Path]:
|
|
100
|
+
homes = [Path.home() / ".claude"]
|
|
101
|
+
if os.environ.get("CLAUDE_CONFIG_DIR"):
|
|
102
|
+
homes.insert(0, Path(os.environ["CLAUDE_CONFIG_DIR"]).expanduser())
|
|
103
|
+
return homes
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def codex_home() -> Path:
|
|
107
|
+
return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex").expanduser()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def scan(
|
|
111
|
+
since: float,
|
|
112
|
+
claude: list[Path] | None = None,
|
|
113
|
+
codex: Path | None = None,
|
|
114
|
+
now: float | None = None,
|
|
115
|
+
) -> list[Session]:
|
|
116
|
+
"""Every session whose transcript changed in the last `since` seconds, most recent first."""
|
|
117
|
+
cutoff = (now if now is not None else time.time()) - since
|
|
118
|
+
found: dict[tuple[str, str], Session] = {}
|
|
119
|
+
|
|
120
|
+
def keep(s: Session | None) -> None:
|
|
121
|
+
if s is None:
|
|
122
|
+
return
|
|
123
|
+
held = found.get((s.harness, s.id))
|
|
124
|
+
if held is None:
|
|
125
|
+
found[(s.harness, s.id)] = s
|
|
126
|
+
return
|
|
127
|
+
# Codex starts a new rollout file for a resumed session; the newer file wins, and the
|
|
128
|
+
# older one fills what the newer has not recorded yet.
|
|
129
|
+
new, old = (s, held) if s.last_active > held.last_active else (held, s)
|
|
130
|
+
for field in ("project", "model", "effort"):
|
|
131
|
+
if getattr(new, field) is None:
|
|
132
|
+
setattr(new, field, getattr(old, field))
|
|
133
|
+
found[(s.harness, s.id)] = new
|
|
134
|
+
|
|
135
|
+
for home in claude if claude is not None else claude_homes():
|
|
136
|
+
# Subagent transcripts sit one level deeper and belong to their parent session.
|
|
137
|
+
for path in (home / "projects").glob("*/*.jsonl"):
|
|
138
|
+
if path.stat().st_mtime >= cutoff:
|
|
139
|
+
keep(read_claude(path))
|
|
140
|
+
root = (codex if codex is not None else codex_home()) / "sessions"
|
|
141
|
+
for path in root.glob("**/rollout-*.jsonl"):
|
|
142
|
+
if path.stat().st_mtime >= cutoff:
|
|
143
|
+
keep(read_codex(path))
|
|
144
|
+
return sorted(found.values(), key=lambda s: s.last_active, reverse=True)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from twomw2lt.cli import main
|
|
5
|
+
from twomw2lt.sessions import scan
|
|
6
|
+
|
|
7
|
+
NOW = 1_800_000_000.0
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def write(path, records, mtime):
|
|
11
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
12
|
+
path.write_text("".join(json.dumps(r) + "\n" for r in records))
|
|
13
|
+
os.utime(path, (mtime, mtime))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def claude_line(model, effort="high", cwd="/w/app"):
|
|
17
|
+
return {"type": "assistant", "sessionId": "c1", "cwd": cwd, "effort": effort,
|
|
18
|
+
"message": {"model": model}}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_a_claude_session_reports_its_latest_model_not_a_synthetic_reply(tmp_path):
|
|
22
|
+
write(tmp_path / "claude/projects/-w-app/c1.jsonl",
|
|
23
|
+
[claude_line("claude-sonnet-5", "low"), claude_line("claude-opus-5"),
|
|
24
|
+
claude_line("<synthetic>")], NOW - 60)
|
|
25
|
+
[s] = scan(3600, claude=[tmp_path / "claude"], codex=tmp_path / "codex", now=NOW)
|
|
26
|
+
# The model the session runs is the last real one, and the effort is the latest turn's.
|
|
27
|
+
assert (s.harness, s.id, s.model, s.effort, s.project) == (
|
|
28
|
+
"claude", "c1", "claude-opus-5", "high", "/w/app")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_a_resumed_codex_session_is_one_row_filled_from_its_earlier_rollout(tmp_path):
|
|
32
|
+
meta = {"type": "session_meta", "payload": {"id": "x1", "cwd": "/w/api"}}
|
|
33
|
+
turn = {"type": "turn_context", "payload": {"cwd": "/w/api", "model": "gpt-5.6-sol",
|
|
34
|
+
"effort": "low"}}
|
|
35
|
+
write(tmp_path / "codex/sessions/2026/09/18/rollout-a-x1.jsonl", [meta, turn], NOW - 600)
|
|
36
|
+
# The resumed file has not restated its turn context yet.
|
|
37
|
+
write(tmp_path / "codex/sessions/2026/09/19/rollout-b-x1.jsonl", [meta], NOW - 10)
|
|
38
|
+
[s] = scan(3600, claude=[], codex=tmp_path / "codex", now=NOW)
|
|
39
|
+
assert (s.id, s.model, s.effort, s.last_active) == ("x1", "gpt-5.6-sol", "low", NOW - 10)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_a_session_quiet_for_longer_than_the_window_is_left_out(tmp_path):
|
|
43
|
+
write(tmp_path / "claude/projects/-w-app/c1.jsonl", [claude_line("claude-opus-5")],
|
|
44
|
+
NOW - 7200)
|
|
45
|
+
assert scan(3600, claude=[tmp_path / "claude"], codex=tmp_path / "codex", now=NOW) == []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_a_subagent_transcript_is_not_its_own_session(tmp_path):
|
|
49
|
+
write(tmp_path / "claude/projects/-w-app/c1/subagents/agent-1.jsonl",
|
|
50
|
+
[claude_line("claude-haiku-4-5")], NOW - 60)
|
|
51
|
+
assert scan(3600, claude=[tmp_path / "claude"], codex=tmp_path / "codex", now=NOW) == []
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_json_output_is_one_object_per_session(tmp_path, monkeypatch, capsys):
|
|
55
|
+
write(tmp_path / ".claude/projects/-w-app/c1.jsonl", [claude_line("claude-opus-5")],
|
|
56
|
+
os.path.getmtime(tmp_path))
|
|
57
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
58
|
+
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
|
|
59
|
+
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "none"))
|
|
60
|
+
assert main(["--json", "--since", "7d"]) == 0
|
|
61
|
+
[line] = capsys.readouterr().out.splitlines()
|
|
62
|
+
assert json.loads(line)["model"] == "claude-opus-5"
|