cctally 1.68.0 → 1.69.0

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/bin/_cctally_alerts.py +54 -2
  3. package/bin/_cctally_cache.py +644 -107
  4. package/bin/_cctally_cache_report.py +41 -17
  5. package/bin/_cctally_codex.py +109 -4
  6. package/bin/_cctally_config.py +368 -2
  7. package/bin/_cctally_core.py +168 -5
  8. package/bin/_cctally_dashboard.py +679 -5
  9. package/bin/_cctally_dashboard_conversation.py +9 -4
  10. package/bin/_cctally_dashboard_envelope.py +110 -1
  11. package/bin/_cctally_dashboard_share.py +557 -79
  12. package/bin/_cctally_db.py +303 -17
  13. package/bin/_cctally_diff.py +49 -16
  14. package/bin/_cctally_doctor.py +118 -0
  15. package/bin/_cctally_forecast.py +12 -4
  16. package/bin/_cctally_parser.py +298 -92
  17. package/bin/_cctally_project.py +24 -8
  18. package/bin/_cctally_quota.py +1381 -0
  19. package/bin/_cctally_record.py +319 -14
  20. package/bin/_cctally_refresh.py +105 -3
  21. package/bin/_cctally_reporting.py +7 -1
  22. package/bin/_cctally_setup.py +343 -113
  23. package/bin/_cctally_statusline.py +228 -0
  24. package/bin/_cctally_tui.py +787 -7
  25. package/bin/_lib_alert_axes.py +11 -0
  26. package/bin/_lib_codex_hooks.py +367 -0
  27. package/bin/_lib_conversation_query.py +156 -67
  28. package/bin/_lib_doctor.py +202 -0
  29. package/bin/_lib_jsonl.py +529 -162
  30. package/bin/_lib_quota.py +566 -0
  31. package/bin/_lib_share.py +152 -34
  32. package/bin/_lib_snapshot_cache.py +26 -0
  33. package/bin/_lib_source_identity.py +74 -0
  34. package/bin/cctally +324 -10
  35. package/dashboard/static/assets/index-CBbErI-P.js +80 -0
  36. package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
  37. package/dashboard/static/dashboard.html +2 -2
  38. package/package.json +5 -1
  39. package/dashboard/static/assets/index-BybNp_Di.js +0 -80
  40. package/dashboard/static/assets/index-DgAmLK65.css +0 -1
@@ -66,3 +66,14 @@ AXIS_REGISTRY: "tuple[AlertAxisDescriptor, ...]" = (
66
66
  )
67
67
 
68
68
  AXIS_BY_ID: "dict[str, AlertAxisDescriptor]" = {d.id: d for d in AXIS_REGISTRY}
69
+
70
+ # Durable notifier-only quota thresholds deliberately do not participate in
71
+ # the dashboard envelope. Keep their metadata available to backend callers
72
+ # without expanding the complete Python↔TypeScript dashboard registry.
73
+ BACKEND_ONLY_AXIS_REGISTRY: "tuple[AlertAxisDescriptor, ...]" = (
74
+ AlertAxisDescriptor("quota", "QUOTA", "Quota", "quota_threshold_events"),
75
+ )
76
+
77
+ BACKEND_ONLY_AXIS_BY_ID: "dict[str, AlertAxisDescriptor]" = {
78
+ d.id: d for d in BACKEND_ONLY_AXIS_REGISTRY
79
+ }
@@ -0,0 +1,367 @@
1
+ """Pure Codex native-hook planning and bounded lifecycle-lock primitives."""
2
+ from __future__ import annotations
3
+
4
+ import copy
5
+ import datetime as dt
6
+ import fcntl
7
+ import json
8
+ import os
9
+ import pathlib
10
+ import shlex
11
+ import shutil
12
+ from dataclasses import dataclass
13
+ from typing import Callable
14
+
15
+ from _lib_source_identity import source_root_key
16
+
17
+
18
+ CODEX_HOOK_EVENTS = ("Stop", "SubagentStop")
19
+ CODEX_HOOK_TIMEOUT_SECONDS = 30
20
+ CODEX_HOOK_THROTTLE_SECONDS = 15
21
+
22
+
23
+ class CodexHooksError(ValueError):
24
+ """A hooks.json shape is unsafe to modify."""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class CodexHookRoot:
29
+ source_root_key: str
30
+ codex_home: pathlib.Path
31
+ hooks_path: pathlib.Path
32
+
33
+
34
+ @dataclass
35
+ class CodexLifecycleLock:
36
+ root: CodexHookRoot
37
+ marker_path: pathlib.Path
38
+ lock_path: pathlib.Path
39
+ fd: int
40
+
41
+
42
+ def codex_hook_command(binary: str) -> str:
43
+ path = pathlib.Path(binary)
44
+ if not path.is_absolute():
45
+ raise CodexHooksError("Codex hook target must be an absolute path")
46
+ return f"{shlex.quote(str(path))} hook-tick --foreground --source codex"
47
+
48
+
49
+ def _command_tokens(command: object) -> list[str] | None:
50
+ if not isinstance(command, str) or not command.strip():
51
+ return None
52
+ try:
53
+ return shlex.split(command)
54
+ except ValueError:
55
+ return None
56
+
57
+
58
+ def is_owned_codex_hook_command(command: object, binary: str) -> bool:
59
+ # The absolute installed path changes across package-manager upgrades, so
60
+ # ownership is the exact argument tail plus an absolute ``cctally`` binary,
61
+ # not a string-equality check against today's install location.
62
+ tokens = _command_tokens(command)
63
+ return bool(
64
+ tokens
65
+ and len(tokens) == 5
66
+ and pathlib.Path(tokens[0]).is_absolute()
67
+ and pathlib.Path(tokens[0]).name == "cctally"
68
+ and tokens[1:] == ["hook-tick", "--foreground", "--source", "codex"]
69
+ )
70
+
71
+
72
+ def _validate_document(document: object) -> dict:
73
+ if not isinstance(document, dict):
74
+ raise CodexHooksError("hooks.json must be a JSON object")
75
+ hooks = document.get("hooks")
76
+ if hooks is None:
77
+ return document
78
+ if not isinstance(hooks, dict):
79
+ raise CodexHooksError("hooks.json: `hooks` is not an object")
80
+ for event, groups in hooks.items():
81
+ if not isinstance(event, str):
82
+ raise CodexHooksError("hooks.json: event name is not a string")
83
+ if not isinstance(groups, list):
84
+ raise CodexHooksError(f"hooks.json: `hooks.{event}` is not a list")
85
+ for index, group in enumerate(groups):
86
+ if not isinstance(group, dict):
87
+ raise CodexHooksError(
88
+ f"hooks.json: `hooks.{event}[{index}]` is not an object"
89
+ )
90
+ handlers = group.get("hooks")
91
+ if not isinstance(handlers, list):
92
+ raise CodexHooksError(
93
+ f"hooks.json: `hooks.{event}[{index}].hooks` is not a list"
94
+ )
95
+ if any(not isinstance(handler, dict) for handler in handlers):
96
+ raise CodexHooksError(
97
+ f"hooks.json: `hooks.{event}[{index}].hooks` has a non-object handler"
98
+ )
99
+ return document
100
+
101
+
102
+ def _owned_handler(command: str) -> dict:
103
+ return {"type": "command", "command": command, "timeout": CODEX_HOOK_TIMEOUT_SECONDS}
104
+
105
+
106
+ def _is_owned_handler(handler: dict, binary: str) -> bool:
107
+ return is_owned_codex_hook_command(handler.get("command"), binary)
108
+
109
+
110
+ def is_canonical_owned_codex_hook_handler(handler: object, binary: str) -> bool:
111
+ """Return true only for the one exact handler shape setup owns today."""
112
+ if not isinstance(handler, dict):
113
+ return False
114
+ return handler == _owned_handler(codex_hook_command(binary))
115
+
116
+
117
+ def _normalize_owned_handler(handler: dict, command: str) -> None:
118
+ handler.clear()
119
+ handler.update(_owned_handler(command))
120
+
121
+
122
+ def _hook_groups(document: dict) -> dict:
123
+ hooks = document.get("hooks")
124
+ if hooks is None:
125
+ hooks = {}
126
+ document["hooks"] = hooks
127
+ return hooks
128
+
129
+
130
+ def _plan_install(document: object, binary: str) -> tuple[dict, dict[str, int]]:
131
+ _validate_document(document)
132
+ planned = copy.deepcopy(document)
133
+ hooks = _hook_groups(planned)
134
+ command = codex_hook_command(binary)
135
+ added: dict[str, int] = {}
136
+ for event in CODEX_HOOK_EVENTS:
137
+ groups = hooks.setdefault(event, [])
138
+ found = False
139
+ kept_groups: list[dict] = []
140
+ for group in groups:
141
+ kept_handlers: list[dict] = []
142
+ for handler in group["hooks"]:
143
+ if _is_owned_handler(handler, binary):
144
+ if not found:
145
+ _normalize_owned_handler(handler, command)
146
+ kept_handlers.append(handler)
147
+ found = True
148
+ continue
149
+ kept_handlers.append(handler)
150
+ if kept_handlers:
151
+ group["hooks"] = kept_handlers
152
+ kept_groups.append(group)
153
+ hooks[event] = kept_groups
154
+ if not found:
155
+ kept_groups.append({"hooks": [_owned_handler(command)]})
156
+ added[event] = 1
157
+ else:
158
+ added[event] = 0
159
+ return planned, added
160
+
161
+
162
+ def _plan_uninstall(document: object, binary: str) -> tuple[dict, dict[str, int]]:
163
+ _validate_document(document)
164
+ planned = copy.deepcopy(document)
165
+ hooks = planned.get("hooks")
166
+ removed: dict[str, int] = {event: 0 for event in CODEX_HOOK_EVENTS}
167
+ if not isinstance(hooks, dict):
168
+ return planned, removed
169
+ for event in CODEX_HOOK_EVENTS:
170
+ groups = hooks.get(event)
171
+ if not isinstance(groups, list):
172
+ continue
173
+ kept_groups: list[dict] = []
174
+ for group in groups:
175
+ kept_handlers = [
176
+ handler for handler in group["hooks"]
177
+ if not _is_owned_handler(handler, binary)
178
+ ]
179
+ removed[event] += len(group["hooks"]) - len(kept_handlers)
180
+ if kept_handlers:
181
+ group["hooks"] = kept_handlers
182
+ kept_groups.append(group)
183
+ if kept_groups:
184
+ hooks[event] = kept_groups
185
+ else:
186
+ hooks.pop(event, None)
187
+ if not hooks:
188
+ planned.pop("hooks", None)
189
+ return planned, removed
190
+
191
+
192
+ def _read_hooks_document(path: pathlib.Path) -> dict:
193
+ if not path.exists():
194
+ return {}
195
+ try:
196
+ raw = path.read_text(encoding="utf-8")
197
+ except OSError as exc:
198
+ raise CodexHooksError(f"cannot read {path}: {exc}") from exc
199
+ if not raw.strip():
200
+ return {}
201
+ try:
202
+ document = json.loads(raw)
203
+ except json.JSONDecodeError as exc:
204
+ raise CodexHooksError(f"{path} is not valid JSON: {exc}") from exc
205
+ return _validate_document(document)
206
+
207
+
208
+ def _backup_path(path: pathlib.Path) -> pathlib.Path:
209
+ stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%d")
210
+ return path.with_name(path.name + f".cctally-backup-{stamp}")
211
+
212
+
213
+ def harden_hooks_permissions(path: pathlib.Path) -> None:
214
+ """Enforce the private-file posture even when a reinstall is content-idempotent."""
215
+ try:
216
+ os.chmod(path.parent, 0o700)
217
+ if path.exists():
218
+ os.chmod(path, 0o600)
219
+ except OSError as exc:
220
+ raise CodexHooksError(f"cannot secure {path}: {exc}") from exc
221
+
222
+
223
+ def _write_hooks_document_atomic(
224
+ path: pathlib.Path,
225
+ document: object | None = None,
226
+ *,
227
+ transform: Callable[[dict], tuple[dict, dict[str, int]]] | None = None,
228
+ harden_unchanged: bool = False,
229
+ ) -> pathlib.Path | None | tuple[dict, dict[str, int], bool, pathlib.Path | None]:
230
+ if (document is None) == (transform is None):
231
+ raise ValueError("provide exactly one of document or transform")
232
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
233
+ lock_path = path.with_name(path.name + ".cctally.lock")
234
+ try:
235
+ fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
236
+ except OSError as exc:
237
+ raise CodexHooksError(f"cannot lock {path}: {exc}") from exc
238
+ try:
239
+ fcntl.flock(fd, fcntl.LOCK_EX)
240
+ changes: dict[str, int] | None = None
241
+ changed = True
242
+ if transform is not None:
243
+ current = _read_hooks_document(path)
244
+ planned, changes = transform(current)
245
+ _validate_document(planned)
246
+ changed = planned != current
247
+ else:
248
+ planned = _validate_document(document)
249
+ # Snapshot only after holding the writer lock: otherwise two setup
250
+ # processes can both back up an obsolete pre-update document before
251
+ # either reaches the atomic replacement below.
252
+ backup: pathlib.Path | None = None
253
+ if changed and path.exists():
254
+ backup = _backup_path(path)
255
+ if not backup.exists():
256
+ try:
257
+ shutil.copy2(path, backup)
258
+ os.chmod(backup, 0o600)
259
+ except OSError as exc:
260
+ raise CodexHooksError(f"cannot back up {path}: {exc}") from exc
261
+ if changed:
262
+ tmp = path.with_name(path.name + f".tmp-{os.getpid()}")
263
+ try:
264
+ tmp.write_text(
265
+ json.dumps(planned, indent=2, ensure_ascii=False) + "\n",
266
+ encoding="utf-8",
267
+ )
268
+ os.chmod(tmp, 0o600)
269
+ os.replace(tmp, path)
270
+ harden_hooks_permissions(path)
271
+ except OSError as exc:
272
+ try:
273
+ tmp.unlink()
274
+ except OSError:
275
+ pass
276
+ raise CodexHooksError(f"cannot write {path}: {exc}") from exc
277
+ elif harden_unchanged and path.exists():
278
+ harden_hooks_permissions(path)
279
+ finally:
280
+ try:
281
+ fcntl.flock(fd, fcntl.LOCK_UN)
282
+ finally:
283
+ os.close(fd)
284
+ if transform is not None:
285
+ assert changes is not None
286
+ return planned, changes, changed, backup
287
+ return backup
288
+
289
+
290
+ def codex_hook_roots(paths: list[pathlib.Path]) -> list[CodexHookRoot]:
291
+ roots: dict[str, CodexHookRoot] = {}
292
+ for raw in paths:
293
+ try:
294
+ home = raw.resolve()
295
+ except OSError:
296
+ home = raw.absolute()
297
+ if not home.is_dir():
298
+ continue
299
+ key = source_root_key(str(home))
300
+ roots.setdefault(key, CodexHookRoot(key, home, home / "hooks.json"))
301
+ return [roots[key] for key in sorted(roots)]
302
+
303
+
304
+ def acquire_due_lifecycle_locks(
305
+ app_dir: pathlib.Path,
306
+ roots: list[CodexHookRoot],
307
+ *,
308
+ now: float,
309
+ throttle_seconds: float = CODEX_HOOK_THROTTLE_SECONDS,
310
+ ) -> list[CodexLifecycleLock]:
311
+ base = app_dir / "codex-hook-tick"
312
+ base.mkdir(parents=True, exist_ok=True, mode=0o700)
313
+ try:
314
+ os.chmod(base, 0o700)
315
+ except OSError:
316
+ pass
317
+ held: list[CodexLifecycleLock] = []
318
+ for root in sorted(roots, key=lambda item: item.source_root_key):
319
+ lock_path = base / f"{root.source_root_key}.lock"
320
+ marker_path = base / f"{root.source_root_key}.last-success"
321
+ fd = -1
322
+ try:
323
+ fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
324
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
325
+ except OSError:
326
+ try:
327
+ if fd >= 0:
328
+ os.close(fd)
329
+ except OSError:
330
+ pass
331
+ continue
332
+ try:
333
+ age = now - marker_path.stat().st_mtime
334
+ except FileNotFoundError:
335
+ age = float("inf")
336
+ except OSError:
337
+ age = float("inf")
338
+ if age < throttle_seconds:
339
+ try:
340
+ fcntl.flock(fd, fcntl.LOCK_UN)
341
+ finally:
342
+ os.close(fd)
343
+ continue
344
+ held.append(CodexLifecycleLock(root, marker_path, lock_path, fd))
345
+ return held
346
+
347
+
348
+ def mark_lifecycle_success(locks: list[CodexLifecycleLock]) -> None:
349
+ for lock in locks:
350
+ fd = os.open(lock.marker_path, os.O_CREAT | os.O_WRONLY, 0o600)
351
+ try:
352
+ os.utime(lock.marker_path, None)
353
+ os.chmod(lock.marker_path, 0o600)
354
+ finally:
355
+ os.close(fd)
356
+
357
+
358
+ def release_lifecycle_locks(locks: list[CodexLifecycleLock]) -> None:
359
+ for lock in reversed(locks):
360
+ try:
361
+ fcntl.flock(lock.fd, fcntl.LOCK_UN)
362
+ except OSError:
363
+ pass
364
+ try:
365
+ os.close(lock.fd)
366
+ except OSError:
367
+ pass