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.
@@ -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
+ [![tests](https://github.com/Moviw/codex-statusline/actions/workflows/tests.yml/badge.svg)](https://github.com/Moviw/codex-statusline/actions/workflows/tests.yml)
27
+ [![PyPI](https://img.shields.io/pypi/v/codex-statusline)](https://pypi.org/project/codex-statusline/)
28
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](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
+ ![codex-statusline demo](docs/preview.svg)
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ codex-statusline = src.cli:main
@@ -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
@@ -0,0 +1,3 @@
1
+ """A local-only tmux status area for the official Codex CLI."""
2
+
3
+ __version__ = "0.1.0"
src/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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