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.
- agent_statusline/__init__.py +21 -0
- agent_statusline/__main__.py +8 -0
- agent_statusline/_version.py +24 -0
- agent_statusline/cli.py +94 -0
- agent_statusline/coerce.py +76 -0
- agent_statusline/diagnostics.py +21 -0
- agent_statusline/hooks/__init__.py +0 -0
- agent_statusline/hooks/context_guard.py +110 -0
- agent_statusline/hooks/session_end.py +43 -0
- agent_statusline/hooks/timestamp_stop.py +25 -0
- agent_statusline/hooks/timestamp_user.py +23 -0
- agent_statusline/installer.py +996 -0
- agent_statusline/ledger.py +430 -0
- agent_statusline/paths.py +58 -0
- agent_statusline/probes.py +271 -0
- agent_statusline/render.py +251 -0
- agent_statusline/selftest.py +215 -0
- agent_statusline/statusline.py +677 -0
- agent_statusline/storage.py +341 -0
- agent_statusline/transcript.py +429 -0
- agent_statusline-0.3.5.dist-info/METADATA +163 -0
- agent_statusline-0.3.5.dist-info/RECORD +25 -0
- agent_statusline-0.3.5.dist-info/WHEEL +4 -0
- agent_statusline-0.3.5.dist-info/entry_points.txt +2 -0
- agent_statusline-0.3.5.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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
|
agent_statusline/cli.py
ADDED
|
@@ -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())
|