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 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ 2mw2lt = twomw2lt.cli:main
@@ -0,0 +1 @@
1
+ twomw2lt
@@ -0,0 +1,3 @@
1
+ """Too Much Work, Too Little Time: see what every AI coding session on this machine is running."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from twomw2lt.cli import main
2
+
3
+ raise SystemExit(main())
@@ -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"