2mw2lt 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 2mw2lt-0.1.0.dist-info/METADATA +33 -0
- 2mw2lt-0.1.0.dist-info/RECORD +10 -0
- 2mw2lt-0.1.0.dist-info/WHEEL +5 -0
- 2mw2lt-0.1.0.dist-info/entry_points.txt +2 -0
- 2mw2lt-0.1.0.dist-info/licenses/LICENSE +21 -0
- 2mw2lt-0.1.0.dist-info/top_level.txt +1 -0
- twomw2lt/__init__.py +3 -0
- twomw2lt/__main__.py +3 -0
- twomw2lt/cli.py +65 -0
- twomw2lt/sessions.py +144 -0
|
@@ -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,10 @@
|
|
|
1
|
+
2mw2lt-0.1.0.dist-info/licenses/LICENSE,sha256=r5JYxJ9uriQZAZHS_AwBEZkIN3ZwuIN9pVJgZUsRAAk,1064
|
|
2
|
+
twomw2lt/__init__.py,sha256=xhHk_LyNUEZS52yjLV2Qr_i-O6uKt4Lef9AzpORi25I,122
|
|
3
|
+
twomw2lt/__main__.py,sha256=j5lwipULQALDHnyH5fERAg3khOwgH1mcFcxipwiavdM,56
|
|
4
|
+
twomw2lt/cli.py,sha256=wspEZ06NPH3OkndIICrsYeW-I47sXcj7WfGnV1PogVs,2185
|
|
5
|
+
twomw2lt/sessions.py,sha256=sYlv0-Wf7Gm8bBIJhdd1TbsJ-jE1pznzTr8LWIiwqgs,5228
|
|
6
|
+
2mw2lt-0.1.0.dist-info/METADATA,sha256=cEowOd6xYIYrzJb4X6CZGMhlMQiMBvyZ4DEJDNK8wkk,1041
|
|
7
|
+
2mw2lt-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
2mw2lt-0.1.0.dist-info/entry_points.txt,sha256=cR4fGkqH-bGc86TFUqWhJrB_j7B9X5UE_JG5tqe0pEs,45
|
|
9
|
+
2mw2lt-0.1.0.dist-info/top_level.txt,sha256=oCQRcVKI7RMgBgd9Z4ip0QkPIlK_rbOFlZeXh5F52TY,9
|
|
10
|
+
2mw2lt-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
twomw2lt
|
twomw2lt/__init__.py
ADDED
twomw2lt/__main__.py
ADDED
twomw2lt/cli.py
ADDED
|
@@ -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
|
twomw2lt/sessions.py
ADDED
|
@@ -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)
|