codex-statusline 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.
- codex_statusline-0.1.0.dist-info/METADATA +107 -0
- codex_statusline-0.1.0.dist-info/RECORD +15 -0
- codex_statusline-0.1.0.dist-info/WHEEL +5 -0
- codex_statusline-0.1.0.dist-info/entry_points.txt +2 -0
- codex_statusline-0.1.0.dist-info/licenses/LICENSE +21 -0
- codex_statusline-0.1.0.dist-info/top_level.txt +1 -0
- src/__init__.py +3 -0
- src/__main__.py +3 -0
- src/cli.py +120 -0
- src/data.py +269 -0
- src/entry.py +11 -0
- src/hook.py +75 -0
- src/launcher.py +377 -0
- src/manage.py +315 -0
- src/render.py +292 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: codex-statusline
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A tmux statusline for OpenAI Codex CLI: context usage, 5h and weekly quota, and session tokens at a glance. Local-only.
|
|
5
|
+
Author: Zimo Xu
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Moviw/codex-statusline
|
|
8
|
+
Project-URL: Issues, https://github.com/Moviw/codex-statusline/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/Moviw/codex-statusline/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: codex,codex-cli,openai,statusline,tmux,quota,tokens,terminal
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Operating System :: MacOS
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Terminals
|
|
16
|
+
Classifier: Topic :: Utilities
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# codex-statusline
|
|
23
|
+
|
|
24
|
+
**See your Codex context and quota at a glance.**
|
|
25
|
+
|
|
26
|
+
[](https://github.com/Moviw/codex-statusline/actions/workflows/tests.yml)
|
|
27
|
+
[](https://pypi.org/project/codex-statusline/)
|
|
28
|
+
[](LICENSE)
|
|
29
|
+
|
|
30
|
+
English | [简体中文](README.zh-CN.md)
|
|
31
|
+
|
|
32
|
+
A status bar for [OpenAI Codex CLI](https://github.com/openai/codex). It shows context used, 5-hour and weekly quota left with reset times, and session tokens. You keep running `codex` exactly as before.
|
|
33
|
+
|
|
34
|
+

|
|
35
|
+
|
|
36
|
+
```text
|
|
37
|
+
CTX USED ███░░░░░ 35% | 5h ████████░░ 78% 5:41pm | week ████░░░░░░ 39% Fri 3:41pm | tok 1.2M
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Why
|
|
41
|
+
|
|
42
|
+
- **No more `/status` interruptions.** Quota and context sit in view while you work.
|
|
43
|
+
- **Local only.** Never reads `auth.json`, never calls a quota API, never makes extra model calls.
|
|
44
|
+
- **Zero new habits.** Keep typing `codex`. `codex exec`, pipes, and scripts pass straight through to the official binary.
|
|
45
|
+
- **Honest numbers.** Unknown shows as `--` and stale quota shows as `refresh`. A missing value is never passed off as 0% or 100%.
|
|
46
|
+
- **Fits any width.** Segments collapse, then drop, as the terminal narrows.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
Requires macOS or Linux, [tmux](https://github.com/tmux/tmux), and Codex CLI ≥ 0.159.0. Bash, Zsh, and Fish are all detected automatically.
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
curl -fsSL https://raw.githubusercontent.com/Moviw/codex-statusline/main/install.sh | sh
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The script installs [uv](https://docs.astral.sh/uv/) if needed (uv also fetches Python 3.11+ for you), installs the tool, then shows you the exact shell/hook diff and asks before writing anything.
|
|
57
|
+
|
|
58
|
+
Prefer to do it yourself:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
uv tool install codex-statusline # or: pipx install codex-statusline
|
|
62
|
+
codex-statusline install # add --dry-run to only preview the diff
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Then **open a new terminal** and run `codex`. The first time, Codex asks you to review a hook: confirm it is `codex-statusline binding`.
|
|
66
|
+
|
|
67
|
+
Missing tmux? `brew install tmux` or `sudo apt install tmux`.
|
|
68
|
+
|
|
69
|
+
## Usage
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
codex # same as always, now with a status bar
|
|
73
|
+
codex resume --last
|
|
74
|
+
codex exec 'task' # non-interactive: passed straight through, no bar
|
|
75
|
+
|
|
76
|
+
codex-statusline doctor # check versions and dependencies
|
|
77
|
+
codex-statusline preview --demo --width 80 # try it without launching Codex
|
|
78
|
+
codex-statusline uninstall
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Uninstall removes only what this tool added and still matches exactly. If you edited its block, it stops instead of overwriting. Backups live in `~/.local/share/codex-statusline/`.
|
|
82
|
+
|
|
83
|
+
## Configuration
|
|
84
|
+
|
|
85
|
+
Optional. Create `~/.config/codex-statusline/config.toml`:
|
|
86
|
+
|
|
87
|
+
```toml
|
|
88
|
+
segments = ["ctx", "5h", "week", "tokens"] # pick and reorder
|
|
89
|
+
theme = "dark" # dark | light
|
|
90
|
+
ascii = false # true for terminals without block glyphs
|
|
91
|
+
warn_at = 20 # quota remaining % that turns yellow
|
|
92
|
+
crit_at = 5 # quota remaining % that turns red
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Invalid values are reported and fall back to defaults.
|
|
96
|
+
|
|
97
|
+
## How it works
|
|
98
|
+
|
|
99
|
+
`codex` becomes a small shell function that starts the official CLI inside a private tmux session and draws the bar at the bottom. Data comes from two places only: the terminal title Codex already emits (model, context, thread id), and the rollout log of *this* session, which a SessionStart hook pins by exact path. The official binary is not replaced or patched, and your tmux config is not touched. Details: [docs/architecture.md](docs/architecture.md).
|
|
100
|
+
|
|
101
|
+
## FAQ
|
|
102
|
+
|
|
103
|
+
**How do I scroll or copy?** It runs inside tmux: press `Ctrl-B` then `[` to scroll and copy. The mouse wheel may behave differently from bare Codex.
|
|
104
|
+
|
|
105
|
+
**Windows?** Not supported (tmux).
|
|
106
|
+
|
|
107
|
+
**Is this official?** No. It is a third-party tool built on a version-sensitive integration.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
codex_statusline-0.1.0.dist-info/licenses/LICENSE,sha256=jzDgwLLFaXpRdL0dDiuJXjpjU-sUFu0t25vl4H71_7s,1064
|
|
2
|
+
src/__init__.py,sha256=xB7hmFE2OX3Czlv6TtZvK5KrXEykajXgc3pVxVdvTKc,87
|
|
3
|
+
src/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
4
|
+
src/cli.py,sha256=VXeVQAQXDiJMQr44kYYWbtMyuEx9uz6BIibDGRJOjI8,4246
|
|
5
|
+
src/data.py,sha256=cKkSnPqGgC2XmhxxbDpDb7qMDeATboS3lbkTEcWe5KQ,10603
|
|
6
|
+
src/entry.py,sha256=SLB21OlZwY9MN6TTTr-DzAvpgcGQhyNT42bqexeLWUk,286
|
|
7
|
+
src/hook.py,sha256=NAab4kB2PoL5CsxGqpVmJCxN6hlPFZftnoAyVtkVPwA,2503
|
|
8
|
+
src/launcher.py,sha256=A8mCMsx3I4FvCbKlJrFw56Sveo4OFYPYKrfgO9vbRSU,12606
|
|
9
|
+
src/manage.py,sha256=xPWoQWKh9m21ak0sgZ3YyReCRs0erzqNuue9ktCSDEc,11285
|
|
10
|
+
src/render.py,sha256=hhFQS33q4yxXrOBkMytKk5fYVa5p3RQt9V758zv_fzg,10443
|
|
11
|
+
codex_statusline-0.1.0.dist-info/METADATA,sha256=qNc-OUl2icktQe5VU8AHtzRnLJlnw41lHyk7vCyiiGs,4804
|
|
12
|
+
codex_statusline-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
13
|
+
codex_statusline-0.1.0.dist-info/entry_points.txt,sha256=YocAJa23XIYJYNCNs2DaG2ggb1PiyjqQaz1DjrF9Q04,50
|
|
14
|
+
codex_statusline-0.1.0.dist-info/top_level.txt,sha256=74rtVfumQlgAPzR5_2CgYN24MB0XARCg0t-gzk6gTrM,4
|
|
15
|
+
codex_statusline-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zimo Xu
|
|
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
|
+
src
|
src/__init__.py
ADDED
src/__main__.py
ADDED
src/cli.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main():
|
|
13
|
+
args = sys.argv[1:]
|
|
14
|
+
from .launcher import child, launch, official, raw, version
|
|
15
|
+
|
|
16
|
+
if args and args[0] in ("launch", "raw"):
|
|
17
|
+
try:
|
|
18
|
+
return (launch if args[0] == "launch" else raw)(args[1:])
|
|
19
|
+
except KeyboardInterrupt:
|
|
20
|
+
return 130
|
|
21
|
+
if args and args[0] == "_child":
|
|
22
|
+
return child(args[1])
|
|
23
|
+
if args and args[0] == "hook":
|
|
24
|
+
from .hook import record
|
|
25
|
+
|
|
26
|
+
record()
|
|
27
|
+
return 0
|
|
28
|
+
parser = argparse.ArgumentParser(
|
|
29
|
+
description="Third-party local tmux statusline for the official Codex CLI."
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
32
|
+
sub = parser.add_subparsers(dest="action", required=True)
|
|
33
|
+
for name in ("install", "uninstall"):
|
|
34
|
+
s = sub.add_parser(name)
|
|
35
|
+
s.add_argument(
|
|
36
|
+
"--shell",
|
|
37
|
+
choices=["auto", "bash", "zsh", "fish", "both", "all"],
|
|
38
|
+
default="auto",
|
|
39
|
+
help="default: all supported shells found on PATH",
|
|
40
|
+
)
|
|
41
|
+
s.add_argument("--home", default=str(Path.home()), help="target home (for isolated tests)")
|
|
42
|
+
s.add_argument(
|
|
43
|
+
"--yes",
|
|
44
|
+
action="store_true",
|
|
45
|
+
help="approve printed diff without interactive prompt",
|
|
46
|
+
)
|
|
47
|
+
s.add_argument("--dry-run", action="store_true")
|
|
48
|
+
sub.add_parser("doctor")
|
|
49
|
+
p = sub.add_parser("preview")
|
|
50
|
+
p.add_argument("--width", type=int, default=120)
|
|
51
|
+
p.add_argument("--theme", choices=["dark", "light"])
|
|
52
|
+
p.add_argument("--ascii", action="store_true")
|
|
53
|
+
p.add_argument("--tmux", action="store_true", help="emit tmux style syntax, not ANSI")
|
|
54
|
+
p.add_argument("--demo", action="store_true", help="show explicitly synthetic example data")
|
|
55
|
+
ns = parser.parse_args(args)
|
|
56
|
+
try:
|
|
57
|
+
if ns.action in ("install", "uninstall"):
|
|
58
|
+
from .manage import installation
|
|
59
|
+
|
|
60
|
+
return installation(ns, ns.action == "uninstall")
|
|
61
|
+
if ns.action == "doctor":
|
|
62
|
+
path = official()
|
|
63
|
+
data = {
|
|
64
|
+
"python": sys.version.split()[0],
|
|
65
|
+
"codex": path,
|
|
66
|
+
"codex_version": ".".join(map(str, version(path))),
|
|
67
|
+
"tmux": shutil.which("tmux"),
|
|
68
|
+
"shell": os.environ.get("SHELL"),
|
|
69
|
+
"tested_codex": "0.159.0",
|
|
70
|
+
"credentials_read": False,
|
|
71
|
+
}
|
|
72
|
+
print(json.dumps(data, indent=2))
|
|
73
|
+
return 0 if data["tmux"] and version(path) >= (0, 159, 0) else 1
|
|
74
|
+
if ns.action == "preview":
|
|
75
|
+
from .render import load_config, render
|
|
76
|
+
|
|
77
|
+
cfg = load_config()
|
|
78
|
+
now = time.time()
|
|
79
|
+
state = {
|
|
80
|
+
"model": "Codex",
|
|
81
|
+
"cwd": os.getcwd(),
|
|
82
|
+
"context_used": None,
|
|
83
|
+
"quotas": {},
|
|
84
|
+
}
|
|
85
|
+
if ns.demo:
|
|
86
|
+
state.update(
|
|
87
|
+
model="DEMO GPT-6",
|
|
88
|
+
context_used=35,
|
|
89
|
+
cwd="~/project",
|
|
90
|
+
branch="main",
|
|
91
|
+
tokens=1_234_567,
|
|
92
|
+
quotas={
|
|
93
|
+
"5h": {
|
|
94
|
+
"remaining": 78,
|
|
95
|
+
"reset_at": now + 7200,
|
|
96
|
+
"observed_at": now,
|
|
97
|
+
},
|
|
98
|
+
"weekly": {
|
|
99
|
+
"remaining": 39,
|
|
100
|
+
"reset_at": now + 172800,
|
|
101
|
+
"observed_at": now,
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
print(
|
|
106
|
+
render(
|
|
107
|
+
state,
|
|
108
|
+
ns.width,
|
|
109
|
+
theme=ns.theme or cfg["theme"],
|
|
110
|
+
ascii_only=ns.ascii or cfg["ascii"],
|
|
111
|
+
tmux=ns.tmux,
|
|
112
|
+
segments=cfg["segments"],
|
|
113
|
+
warn_at=cfg["warn_at"],
|
|
114
|
+
crit_at=cfg["crit_at"],
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
return 0
|
|
118
|
+
except (OSError, ValueError, RuntimeError, KeyError) as error:
|
|
119
|
+
print(f"codex-statusline: {error}", file=sys.stderr)
|
|
120
|
+
return 1
|
src/data.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Bounded parsers for Codex native title and explicitly selected session logs.
|
|
2
|
+
|
|
3
|
+
This module deliberately does not discover log files. Callers must provide the
|
|
4
|
+
path for the session they already bound to the UI.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
_SESSION_HINT = re.compile(
|
|
17
|
+
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{5}\.\.\.$"
|
|
18
|
+
)
|
|
19
|
+
_FULL_SESSION = re.compile(
|
|
20
|
+
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
|
21
|
+
)
|
|
22
|
+
_CONTEXT = re.compile(r"^Context ([0-9]+(?:\.[0-9]+)?)% used$")
|
|
23
|
+
_MAX_READ_BYTES = 1024 * 1024
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_title(title: str) -> dict[str, Any]:
|
|
27
|
+
"""Parse Codex's native ``[model, context-used, thread-id]`` title.
|
|
28
|
+
|
|
29
|
+
The emitted thread ID is a UUID shortened to its first 29 characters and
|
|
30
|
+
``...``. A full UUID is also accepted for environments that render it
|
|
31
|
+
without truncation. A two-field ``model | session`` title is a valid
|
|
32
|
+
context-less variant; its context is returned as ``None``. Malformed or
|
|
33
|
+
ambiguous titles raise ``ValueError`` rather than guessing.
|
|
34
|
+
"""
|
|
35
|
+
if not isinstance(title, str):
|
|
36
|
+
raise TypeError("title must be a string")
|
|
37
|
+
parts = [part.strip() for part in title.split("|")]
|
|
38
|
+
if len(parts) not in (2, 3) or not parts[0]:
|
|
39
|
+
raise ValueError("not a supported Codex status title")
|
|
40
|
+
|
|
41
|
+
session_hint = re.sub(
|
|
42
|
+
r" [\u2800-\u28ff]$", "", parts[-1]
|
|
43
|
+
) # native thread-title progress spinner
|
|
44
|
+
if not (_SESSION_HINT.fullmatch(session_hint) or _FULL_SESSION.fullmatch(session_hint)):
|
|
45
|
+
raise ValueError("title does not end in a recognizable thread ID")
|
|
46
|
+
|
|
47
|
+
context_used: float | None = None
|
|
48
|
+
if len(parts) == 3:
|
|
49
|
+
match = _CONTEXT.fullmatch(parts[1])
|
|
50
|
+
if match:
|
|
51
|
+
parsed_context = float(match.group(1))
|
|
52
|
+
if math.isfinite(parsed_context) and 0.0 <= parsed_context <= 100.0:
|
|
53
|
+
context_used = parsed_context
|
|
54
|
+
elif parts[1] not in ("Context unknown", ""):
|
|
55
|
+
raise ValueError("unrecognized context field")
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
"model": parts[0],
|
|
59
|
+
"context_used": context_used,
|
|
60
|
+
"session_hint": session_hint,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _timestamp(value: Any) -> float | None:
|
|
65
|
+
"""Convert supported event timestamps to Unix seconds without guessing."""
|
|
66
|
+
if isinstance(value, bool):
|
|
67
|
+
return None
|
|
68
|
+
if isinstance(value, (int, float)):
|
|
69
|
+
number = float(value)
|
|
70
|
+
return number if math.isfinite(number) else None
|
|
71
|
+
if isinstance(value, str):
|
|
72
|
+
text = value.strip()
|
|
73
|
+
if not text:
|
|
74
|
+
return None
|
|
75
|
+
try:
|
|
76
|
+
number = float(text)
|
|
77
|
+
except ValueError:
|
|
78
|
+
try:
|
|
79
|
+
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
80
|
+
except ValueError:
|
|
81
|
+
return None
|
|
82
|
+
if parsed.tzinfo is None:
|
|
83
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
84
|
+
return parsed.timestamp()
|
|
85
|
+
return number if math.isfinite(number) else None
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _empty_quotas() -> dict[str, dict[str, float | None]]:
|
|
90
|
+
return {
|
|
91
|
+
"5h": {"remaining": None, "reset_at": None, "observed_at": None},
|
|
92
|
+
"weekly": {"remaining": None, "reset_at": None, "observed_at": None},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class LogReader:
|
|
97
|
+
"""Incrementally read quota metadata from one caller-selected JSONL file.
|
|
98
|
+
|
|
99
|
+
Each update reads at most 1 MiB. Only complete JSONL records are parsed;
|
|
100
|
+
an incomplete tail is held up to 1 MiB, then discarded through its newline.
|
|
101
|
+
State is reset on path changes, file rotation, or truncation. Persistent
|
|
102
|
+
state is quota metadata only; bounded transient input can include arbitrary
|
|
103
|
+
JSON fields while parsed, but content is not copied into reader state/results.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(self) -> None:
|
|
107
|
+
self._path: str | None = None
|
|
108
|
+
self._identity: tuple[int, int] | None = None
|
|
109
|
+
self._offset = 0
|
|
110
|
+
self._partial = b""
|
|
111
|
+
self._discard_until_newline = False
|
|
112
|
+
self._probe = b""
|
|
113
|
+
self._quotas = _empty_quotas()
|
|
114
|
+
self._tokens: float | None = None
|
|
115
|
+
|
|
116
|
+
def _reset(self, path: str) -> None:
|
|
117
|
+
self._path = path
|
|
118
|
+
self._identity = None
|
|
119
|
+
self._offset = 0
|
|
120
|
+
self._partial = b""
|
|
121
|
+
self._discard_until_newline = False
|
|
122
|
+
self._probe = b""
|
|
123
|
+
self._quotas = _empty_quotas()
|
|
124
|
+
self._tokens = None
|
|
125
|
+
|
|
126
|
+
def _consume_line(self, line: bytes) -> None:
|
|
127
|
+
try:
|
|
128
|
+
event = json.loads(line)
|
|
129
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
130
|
+
return
|
|
131
|
+
if not isinstance(event, dict) or event.get("type") != "event_msg":
|
|
132
|
+
return
|
|
133
|
+
observed_at = _timestamp(event.get("timestamp"))
|
|
134
|
+
if observed_at is None:
|
|
135
|
+
return
|
|
136
|
+
payload = event.get("payload")
|
|
137
|
+
if not isinstance(payload, dict) or payload.get("type") != "token_count":
|
|
138
|
+
return
|
|
139
|
+
info = payload.get("info")
|
|
140
|
+
total = info.get("total_token_usage") if isinstance(info, dict) else None
|
|
141
|
+
tokens = total.get("total_tokens") if isinstance(total, dict) else None
|
|
142
|
+
if (
|
|
143
|
+
isinstance(tokens, (int, float))
|
|
144
|
+
and not isinstance(tokens, bool)
|
|
145
|
+
and math.isfinite(tokens)
|
|
146
|
+
and tokens >= 0
|
|
147
|
+
):
|
|
148
|
+
self._tokens = float(tokens)
|
|
149
|
+
# Native versions may keep rate_limits inside info; quota-only events
|
|
150
|
+
# can instead carry rate_limits directly on payload (even if info is null).
|
|
151
|
+
rate_limits = info.get("rate_limits") if isinstance(info, dict) else None
|
|
152
|
+
if not isinstance(rate_limits, dict):
|
|
153
|
+
rate_limits = payload.get("rate_limits")
|
|
154
|
+
if not isinstance(rate_limits, dict):
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
for key, label, expected_window in (
|
|
158
|
+
("primary", "5h", 300),
|
|
159
|
+
("secondary", "weekly", 10080),
|
|
160
|
+
):
|
|
161
|
+
quota = rate_limits.get(key)
|
|
162
|
+
if not isinstance(quota, dict) or quota.get("window_minutes") != expected_window:
|
|
163
|
+
continue
|
|
164
|
+
used = quota.get("used_percent")
|
|
165
|
+
if isinstance(used, bool) or not isinstance(used, (int, float)):
|
|
166
|
+
continue
|
|
167
|
+
used = float(used)
|
|
168
|
+
if not math.isfinite(used):
|
|
169
|
+
continue
|
|
170
|
+
prior = self._quotas[label]
|
|
171
|
+
if prior["observed_at"] is not None and observed_at < float(prior["observed_at"]):
|
|
172
|
+
continue
|
|
173
|
+
reset_at = _timestamp(quota.get("resets_at"))
|
|
174
|
+
self._quotas[label] = {
|
|
175
|
+
"remaining": max(0.0, min(100.0, 100.0 - used)),
|
|
176
|
+
"reset_at": reset_at,
|
|
177
|
+
"observed_at": observed_at,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
def _consume_bytes(self, data: bytes) -> None:
|
|
181
|
+
if self._discard_until_newline:
|
|
182
|
+
newline = data.find(b"\n")
|
|
183
|
+
if newline < 0:
|
|
184
|
+
return
|
|
185
|
+
self._discard_until_newline = False
|
|
186
|
+
data = data[newline + 1 :]
|
|
187
|
+
data = self._partial + data
|
|
188
|
+
lines = data.split(b"\n")
|
|
189
|
+
tail = lines.pop() # last record is parsed only once terminated
|
|
190
|
+
for line in lines:
|
|
191
|
+
if line:
|
|
192
|
+
self._consume_line(line)
|
|
193
|
+
if len(tail) >= _MAX_READ_BYTES:
|
|
194
|
+
self._partial = b""
|
|
195
|
+
self._discard_until_newline = True
|
|
196
|
+
else:
|
|
197
|
+
self._partial = tail
|
|
198
|
+
|
|
199
|
+
def update(self, path: str) -> dict[str, Any]:
|
|
200
|
+
"""Read newly appended quota/token events from exactly ``path``."""
|
|
201
|
+
if not isinstance(path, str) or not path:
|
|
202
|
+
raise ValueError("path must be a non-empty string")
|
|
203
|
+
if self._path != path:
|
|
204
|
+
self._reset(path)
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
stat = os.stat(path)
|
|
208
|
+
identity = (stat.st_dev, stat.st_ino)
|
|
209
|
+
skip_partial_record = False
|
|
210
|
+
overwritten = False
|
|
211
|
+
if (
|
|
212
|
+
identity == self._identity
|
|
213
|
+
and self._identity is not None
|
|
214
|
+
and self._probe
|
|
215
|
+
and stat.st_size >= self._offset
|
|
216
|
+
):
|
|
217
|
+
with open(path, "rb") as probe_stream:
|
|
218
|
+
probe_stream.seek(self._offset - len(self._probe))
|
|
219
|
+
overwritten = probe_stream.read(len(self._probe)) != self._probe
|
|
220
|
+
if identity != self._identity or stat.st_size < self._offset or overwritten:
|
|
221
|
+
self._identity = identity
|
|
222
|
+
self._offset = 0
|
|
223
|
+
self._partial = b""
|
|
224
|
+
self._discard_until_newline = False
|
|
225
|
+
self._probe = b""
|
|
226
|
+
self._quotas = _empty_quotas()
|
|
227
|
+
self._tokens = None
|
|
228
|
+
if stat.st_size > _MAX_READ_BYTES:
|
|
229
|
+
# Start at a bounded tail boundary, discarding its possibly
|
|
230
|
+
# incomplete first line. Current quota events remain
|
|
231
|
+
# available without reading an arbitrarily large history.
|
|
232
|
+
self._offset = stat.st_size - _MAX_READ_BYTES
|
|
233
|
+
skip_partial_record = True
|
|
234
|
+
with open(path, "rb") as stream:
|
|
235
|
+
stream.seek(self._offset)
|
|
236
|
+
chunk = stream.read(_MAX_READ_BYTES)
|
|
237
|
+
self._offset = stream.tell()
|
|
238
|
+
if chunk:
|
|
239
|
+
self._probe = chunk[-64:]
|
|
240
|
+
if skip_partial_record:
|
|
241
|
+
# Initial tail read: do not interpret a fragment cut from the
|
|
242
|
+
# front of a JSON record.
|
|
243
|
+
newline = chunk.find(b"\n")
|
|
244
|
+
if newline < 0:
|
|
245
|
+
self._partial = b""
|
|
246
|
+
self._discard_until_newline = True
|
|
247
|
+
else:
|
|
248
|
+
chunk = chunk[newline + 1 :]
|
|
249
|
+
self._consume_bytes(chunk)
|
|
250
|
+
else:
|
|
251
|
+
self._consume_bytes(chunk)
|
|
252
|
+
except FileNotFoundError:
|
|
253
|
+
# A missing bound file has no metadata yet. Retain the selected
|
|
254
|
+
# path, but a later appearance will be picked up by identity reset.
|
|
255
|
+
self._identity = None
|
|
256
|
+
self._offset = 0
|
|
257
|
+
self._partial = b""
|
|
258
|
+
self._discard_until_newline = False
|
|
259
|
+
self._probe = b""
|
|
260
|
+
self._quotas = _empty_quotas()
|
|
261
|
+
self._tokens = None
|
|
262
|
+
except OSError:
|
|
263
|
+
# Avoid exporting filesystem details or turning transient I/O into
|
|
264
|
+
# fabricated quota data; previously observed metadata remains.
|
|
265
|
+
pass
|
|
266
|
+
return {
|
|
267
|
+
"quotas": {name: dict(value) for name, value in self._quotas.items()},
|
|
268
|
+
"tokens": self._tokens,
|
|
269
|
+
}
|
src/entry.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Absolute script entry for shell functions, hooks and private tmux panes."""
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
8
|
+
from src.cli import main
|
|
9
|
+
|
|
10
|
+
if __name__ == "__main__":
|
|
11
|
+
raise SystemExit(main())
|
src/hook.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Silent SessionStart metadata recorder; no credentials or conversation copies."""
|
|
2
|
+
|
|
3
|
+
import fcntl
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def atomic_json(path, data):
|
|
15
|
+
fd, temp = tempfile.mkstemp(prefix=".write-", dir=path.parent)
|
|
16
|
+
try:
|
|
17
|
+
with os.fdopen(fd, "w") as stream:
|
|
18
|
+
json.dump(data, stream)
|
|
19
|
+
os.replace(temp, path)
|
|
20
|
+
finally:
|
|
21
|
+
if os.path.exists(temp):
|
|
22
|
+
os.unlink(temp)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def record():
|
|
26
|
+
directory = os.environ.get("CODEX_STATUSLINE_STATE")
|
|
27
|
+
launch = os.environ.get("CODEX_STATUSLINE_LAUNCH")
|
|
28
|
+
if not directory or not launch:
|
|
29
|
+
return # Do not even read stdin for ordinary Codex sessions.
|
|
30
|
+
try:
|
|
31
|
+
uuid.UUID(launch)
|
|
32
|
+
root = Path(directory)
|
|
33
|
+
info = root.lstat()
|
|
34
|
+
if (
|
|
35
|
+
not root.is_absolute()
|
|
36
|
+
or not stat.S_ISDIR(info.st_mode)
|
|
37
|
+
or info.st_uid != os.getuid()
|
|
38
|
+
or info.st_mode & 0o077
|
|
39
|
+
):
|
|
40
|
+
return
|
|
41
|
+
config = json.loads((root / "launch.json").read_text())
|
|
42
|
+
if config["launch_id"] != launch:
|
|
43
|
+
return
|
|
44
|
+
raw = sys.stdin.buffer.read(65537)
|
|
45
|
+
if len(raw) > 65536:
|
|
46
|
+
return
|
|
47
|
+
event = json.loads(raw)
|
|
48
|
+
if event.get("hook_event_name") != "SessionStart":
|
|
49
|
+
return
|
|
50
|
+
sid = str(uuid.UUID(event["session_id"]))
|
|
51
|
+
transcript = event.get("transcript_path")
|
|
52
|
+
if not isinstance(transcript, str) or not Path(transcript).is_absolute():
|
|
53
|
+
return
|
|
54
|
+
binding = {k: event.get(k) for k in ("cwd", "model", "source")}
|
|
55
|
+
binding.update(
|
|
56
|
+
session_id=sid,
|
|
57
|
+
transcript_path=transcript,
|
|
58
|
+
launch_id=launch,
|
|
59
|
+
recorded_at=time.time(),
|
|
60
|
+
)
|
|
61
|
+
# Keep exact bindings for switching back to a still-open thread. Never scan logs.
|
|
62
|
+
with (root / "lock").open("a") as lock:
|
|
63
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
64
|
+
try:
|
|
65
|
+
bindings = json.loads((root / "bindings.json").read_text())
|
|
66
|
+
except (OSError, ValueError):
|
|
67
|
+
bindings = {}
|
|
68
|
+
bindings[sid] = binding
|
|
69
|
+
if len(bindings) > 64:
|
|
70
|
+
bindings = dict(
|
|
71
|
+
sorted(bindings.items(), key=lambda item: item[1]["recorded_at"])[-64:]
|
|
72
|
+
)
|
|
73
|
+
atomic_json(root / "bindings.json", bindings)
|
|
74
|
+
except (OSError, ValueError, TypeError, KeyError, AttributeError):
|
|
75
|
+
return
|