agent-statusline 0.3.5__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,21 @@
1
+ """A dense, width-aware status line for coding agents.
2
+
3
+ Layered so that only the acquisition layer is host-specific:
4
+
5
+ paths where machine-local state lives
6
+ render colours, units, bars, and the width-fitting row packer
7
+ transcript incremental parsing of a session .jsonl
8
+ probes cached subprocess/file probes
9
+ ledger cross-session cost accounting
10
+ statusline the Claude Code status line built from all of the above
11
+
12
+ `render`, `probes`, `ledger` and `paths` carry no Claude-specific assumptions;
13
+ see docs/PORTING.md.
14
+ """
15
+
16
+ try: # populated by hatch-vcs at build time
17
+ from ._version import __version__
18
+ except Exception: # running straight from a checkout
19
+ __version__ = "0.0.0.dev0"
20
+
21
+ __all__ = ["__version__"]
@@ -0,0 +1,8 @@
1
+ """Expose the console-script interface through ``python -m``."""
2
+
3
+ import sys
4
+
5
+ from agent_statusline.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.3.5'
22
+ __version_tuple__ = version_tuple = (0, 3, 5)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env python3
2
+ """`agent-statusline` -- one entry point for rendering, installing, and hooks.
3
+
4
+ Dispatch is hand-rolled rather than argparse-based, and the no-argument case
5
+ returns before importing anything else it does not need. That case is the hot
6
+ one: Claude Code spawns this process several times a second to redraw, and every
7
+ avoided import is time the status line is not on screen (ADR 0006).
8
+ """
9
+ import sys
10
+
11
+ USAGE = """agent-statusline -- a dense, width-aware status line for coding agents
12
+
13
+ agent-statusline render a status line from a payload on stdin
14
+ agent-statusline install [--dry-run] wire it into Claude Code's settings.json
15
+ agent-statusline uninstall remove the settings entries and symlink
16
+ agent-statusline hook <name> run a hook (session-end, context-guard,
17
+ timestamp-user, timestamp-stop)
18
+ agent-statusline ledger [...] inspect or close cost-ledger rows
19
+ agent-statusline selftest verify rendering using isolated synthetic state
20
+ agent-statusline version print the version
21
+ """
22
+
23
+ HOOKS = {
24
+ "session-end": "session_end",
25
+ "context-guard": "context_guard",
26
+ "timestamp-user": "timestamp_user",
27
+ "timestamp-stop": "timestamp_stop",
28
+ }
29
+
30
+
31
+ def main(argv=None):
32
+ argv = list(sys.argv[1:] if argv is None else argv)
33
+
34
+ # Hot path: no arguments means render, and nothing else is imported.
35
+ if not argv:
36
+ from agent_statusline.statusline import main as render
37
+
38
+ render()
39
+ return 0
40
+
41
+ cmd, rest = argv[0], argv[1:]
42
+
43
+ if cmd in ("-h", "--help", "help"):
44
+ print(USAGE)
45
+ return 0
46
+
47
+ if cmd in ("-V", "--version", "version"):
48
+ from agent_statusline import __version__
49
+
50
+ print(__version__)
51
+ return 0
52
+
53
+ if cmd == "render":
54
+ from agent_statusline.statusline import main as render
55
+
56
+ render()
57
+ return 0
58
+
59
+ if cmd in ("install", "uninstall"):
60
+ from agent_statusline import installer
61
+
62
+ unknown = [a for a in rest if a != "--dry-run"]
63
+ if unknown:
64
+ print(f"unknown option: {unknown[0]}", file=sys.stderr)
65
+ return 2
66
+ return installer.run(dry_run="--dry-run" in rest, uninstall=cmd == "uninstall")
67
+
68
+ if cmd == "hook":
69
+ if not rest or rest[0] not in HOOKS:
70
+ print(f"usage: agent-statusline hook {{{','.join(HOOKS)}}}", file=sys.stderr)
71
+ return 2
72
+ mod = __import__(f"agent_statusline.hooks.{HOOKS[rest[0]]}", fromlist=["main"])
73
+ return mod.main()
74
+
75
+ if cmd == "ledger":
76
+ from agent_statusline import ledger
77
+
78
+ sys.argv = ["agent-statusline ledger", *rest]
79
+ return ledger._main()
80
+
81
+ if cmd == "selftest":
82
+ if rest:
83
+ print(f"unknown option: {rest[0]}", file=sys.stderr)
84
+ return 2
85
+ from agent_statusline import selftest
86
+
87
+ return selftest.run()
88
+
89
+ print(f"unknown command: {cmd}\n\n{USAGE}", file=sys.stderr)
90
+ return 2
91
+
92
+
93
+ if __name__ == "__main__":
94
+ sys.exit(main())
@@ -0,0 +1,76 @@
1
+ """Safe numeric coercion at the untrusted host-input boundary.
2
+
3
+ The host payload and transcript are decoded by the standard-library JSON
4
+ decoder, which accepts non-finite numeric literals. Keep that handling in one
5
+ small, host-agnostic module so renderers can degrade instead of raising while
6
+ retaining the distinction between fractional measurements and counts.
7
+ """
8
+
9
+ import math
10
+ from typing import Any, Union
11
+
12
+ # Values beyond this boundary cannot produce useful status-line output. It is
13
+ # comfortably above realistic token/byte/count values while bounding formatting
14
+ # work and avoiding a lossy conversion of arbitrary-size integers to float.
15
+ MAX_DISPLAY_VALUE = 10**15
16
+ Number = Union[int, float]
17
+
18
+
19
+ def _valid_default(default: Number) -> Number:
20
+ """Return a safe numeric default supplied by a caller."""
21
+ if isinstance(default, bool):
22
+ return 0
23
+ if isinstance(default, int):
24
+ return default if abs(default) <= MAX_DISPLAY_VALUE else 0
25
+ if isinstance(default, float) and math.isfinite(default) and abs(default) <= MAX_DISPLAY_VALUE:
26
+ return default
27
+ return 0
28
+
29
+
30
+ def finite_number(value: Any, default: Number = 0.0) -> Number:
31
+ """Return a bounded finite number, preserving exact integer inputs.
32
+
33
+ Numeric strings retain the existing ``float`` acceptance used by the
34
+ renderer, including surrounding whitespace and exponent notation. Bool,
35
+ null, invalid, non-finite, and out-of-bound values return ``default``.
36
+ """
37
+ fallback = _valid_default(default)
38
+ if value is None or isinstance(value, bool):
39
+ return fallback
40
+ if isinstance(value, int):
41
+ return value if abs(value) <= MAX_DISPLAY_VALUE else fallback
42
+ try:
43
+ number = float(value)
44
+ except (TypeError, ValueError, OverflowError):
45
+ return fallback
46
+ if not math.isfinite(number) or abs(number) > MAX_DISPLAY_VALUE:
47
+ return fallback
48
+ return number
49
+
50
+
51
+ def finite_integer(value: Any, default: int = 0) -> int:
52
+ """Return a bounded finite integer for count-like host values.
53
+
54
+ Numeric strings retain the existing ``int`` acceptance. Finite float
55
+ inputs retain Python's existing truncation semantics; booleans, null,
56
+ invalid, non-finite, and out-of-bound values return ``default``.
57
+ """
58
+ fallback = _valid_default(default)
59
+ if not isinstance(fallback, int):
60
+ fallback = int(fallback)
61
+ if value is None or isinstance(value, bool):
62
+ return fallback
63
+ if isinstance(value, int):
64
+ return value if abs(value) <= MAX_DISPLAY_VALUE else fallback
65
+ try:
66
+ if isinstance(value, float):
67
+ if not math.isfinite(value) or abs(value) > MAX_DISPLAY_VALUE:
68
+ return fallback
69
+ number = int(value)
70
+ elif isinstance(value, str):
71
+ number = int(value)
72
+ else:
73
+ return fallback
74
+ except (TypeError, ValueError, OverflowError):
75
+ return fallback
76
+ return number if abs(number) <= MAX_DISPLAY_VALUE else fallback
@@ -0,0 +1,21 @@
1
+ """Privacy-safe diagnostic evidence for failures hidden by the host."""
2
+
3
+ import datetime
4
+ import json
5
+
6
+ from agent_statusline.paths import state
7
+ from agent_statusline.storage import write_text
8
+
9
+ LAST_RENDER_ERROR = state("statusline-last-error.json")
10
+ ERROR_SCHEMA = 1
11
+
12
+
13
+ def record_render_failure(error):
14
+ """Record only fixed and non-sensitive failure metadata."""
15
+ record = {
16
+ "schema": ERROR_SCHEMA,
17
+ "occurred_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
18
+ "phase": "render",
19
+ "error_type": type(error).__name__,
20
+ }
21
+ write_text(LAST_RENDER_ERROR, json.dumps(record, sort_keys=True) + "\n")
File without changes
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env python3
2
+ """UserPromptSubmit: warn when the context window is filling up.
3
+
4
+ Live token usage is read from the transcript's most recent assistant message,
5
+ so it is never stale. Only the window SIZE comes from the status-line payload
6
+ (it is a constant for the session); it falls back to 1M if unavailable.
7
+ """
8
+ import json
9
+ import os
10
+ import sys
11
+ import time
12
+
13
+ if __package__ in (None, ""): # running as a plain script
14
+ sys.path.insert(
15
+ 0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
16
+ )
17
+
18
+ from agent_statusline.paths import state
19
+ from agent_statusline.storage import read_json, write_text
20
+
21
+ WARN, CRIT = 80, 90
22
+
23
+
24
+ def window_size():
25
+ try:
26
+ payload = read_json(state("statusline-last-payload.json"), {})
27
+ return int(payload["context_window"]["context_window_size"])
28
+ except Exception:
29
+ return 1_000_000
30
+
31
+
32
+ def live_tokens(path):
33
+ """Last assistant message's usage == current context occupancy."""
34
+ if not path or not os.path.exists(path):
35
+ return 0
36
+ try:
37
+ with open(path, "rb") as fh:
38
+ size = os.fstat(fh.fileno()).st_size
39
+ fh.seek(max(0, size - 400_000))
40
+ lines = fh.read().split(b"\n")
41
+ except Exception:
42
+ return 0
43
+ for line in reversed(lines):
44
+ if b'"assistant"' not in line:
45
+ continue
46
+ try:
47
+ u = json.loads(line)["message"]["usage"]
48
+ except Exception:
49
+ continue
50
+ return sum(
51
+ int(u.get(k) or 0)
52
+ for k in ("input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens")
53
+ )
54
+ return 0
55
+
56
+
57
+ def main():
58
+ try:
59
+ payload = json.loads(sys.stdin.read() or "{}")
60
+ except Exception:
61
+ payload = {}
62
+
63
+ try:
64
+ write_text(
65
+ state("hook-lastrun.json"),
66
+ json.dumps(
67
+ {
68
+ "hook": "UserPromptSubmit",
69
+ "at": time.time(),
70
+ "iso": time.strftime("%Y-%m-%d %H:%M:%S"),
71
+ "session": payload.get("session_id"),
72
+ }
73
+ ),
74
+ )
75
+ except Exception:
76
+ pass
77
+
78
+ size = window_size()
79
+ live = live_tokens(payload.get("transcript_path"))
80
+ if not live or not size:
81
+ return 0
82
+ pct = live / size * 100
83
+ if pct < WARN:
84
+ return 0
85
+
86
+ urgency = "CRITICAL" if pct >= CRIT else "WARNING"
87
+ human = f"{urgency}: context window {pct:.0f}% full ({live:,} of {size:,} tokens)."
88
+ print(
89
+ json.dumps(
90
+ {
91
+ "systemMessage": human + " Consider a focused /compact.",
92
+ "hookSpecificOutput": {
93
+ "hookEventName": "UserPromptSubmit",
94
+ "additionalContext": (
95
+ f"{human} Before continuing, offer the user a focused compaction: propose "
96
+ f"`/compact` with a summary instruction naming only what this task still "
97
+ f"needs (current decisions, open questions, active file paths, the "
98
+ f"immediate next step), and say explicitly what would be dropped. Do not "
99
+ f"compact without their approval, and do not repeat this offer if they "
100
+ f"have already declined it this session."
101
+ ),
102
+ },
103
+ }
104
+ )
105
+ )
106
+ return 0
107
+
108
+
109
+ if __name__ == "__main__":
110
+ sys.exit(main())
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env python3
2
+ """SessionEnd: finalize this session's entry in the cost ledger.
3
+
4
+ Marks the row `state: closed` and stamps an ISO 8601 `closed` time and a
5
+ `reason`, through `ledger.close_session` so the lifecycle is defined in one
6
+ place. Sessions that never reach this hook -- a killed process, a lost daemon --
7
+ are stamped out of band with `agent-statusline ledger close <id> --reason killed`.
8
+ """
9
+ import json
10
+ import os
11
+ import sys
12
+
13
+ if __package__ in (None, ""): # running as a plain script
14
+ sys.path.insert(
15
+ 0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
16
+ )
17
+
18
+ from agent_statusline import ledger
19
+
20
+
21
+ def main():
22
+ try:
23
+ payload = json.loads(sys.stdin.read() or "{}")
24
+ except Exception:
25
+ payload = {}
26
+
27
+ sid = payload.get("session_id")
28
+ if sid:
29
+ try:
30
+ ledger.close_session(
31
+ sid,
32
+ payload.get("reason") or payload.get("source") or "end",
33
+ transcript=payload.get("transcript_path"),
34
+ create=True,
35
+ )
36
+ except Exception:
37
+ pass
38
+ print("")
39
+ return 0
40
+
41
+
42
+ if __name__ == "__main__":
43
+ sys.exit(main())
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env python3
2
+ """TEMPORARY: stamp the time Claude finished a turn.
3
+
4
+ Stopgap until the server-side flag `tengu_silk_hinge` enables Claude Code's
5
+ native `showMessageTimestamps`. Delete this hook once timestamps appear
6
+ natively -- see docs/adrs/0015-timestamp-hooks-are-a-stopgap.md.
7
+ """
8
+ import json
9
+ import sys
10
+ import time
11
+
12
+
13
+ def main():
14
+ try:
15
+ sys.stdin.read()
16
+ except Exception:
17
+ pass
18
+ print(
19
+ json.dumps({"systemMessage": time.strftime("[%Y-%m-%d %H:%M:%S]"), "suppressOutput": True})
20
+ )
21
+ return 0
22
+
23
+
24
+ if __name__ == "__main__":
25
+ sys.exit(main())
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env python3
2
+ """TEMPORARY: stamp the time a user prompt was submitted.
3
+
4
+ Stopgap until the server-side flag `tengu_silk_hinge` enables Claude Code's
5
+ native `showMessageTimestamps`. Delete this hook once timestamps appear
6
+ natively -- see docs/adrs/0015-timestamp-hooks-are-a-stopgap.md.
7
+ """
8
+ import json
9
+ import sys
10
+ import time
11
+
12
+
13
+ def main():
14
+ try:
15
+ sys.stdin.read()
16
+ except Exception:
17
+ pass
18
+ print(json.dumps({"systemMessage": time.strftime("[%Y-%m-%d %H:%M:%S]")}))
19
+ return 0
20
+
21
+
22
+ if __name__ == "__main__":
23
+ sys.exit(main())