usage-cli 0.29.32__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.
- adapters/__init__.py +5 -0
- adapters/agy.py +68 -0
- adapters/claude.py +215 -0
- adapters/codex.py +209 -0
- adapters/rate_limits.py +76 -0
- adapters/registry.py +17 -0
- adapters/types.py +139 -0
- agy_disk_cache.py +135 -0
- agy_loader.py +416 -0
- agy_quota_probe.py +748 -0
- agy_window_keeper.py +185 -0
- analyzer/__init__.py +5 -0
- analyzer/aggregator.py +139 -0
- analyzer/blocks.py +80 -0
- analyzer/diagnoser.py +638 -0
- analyzer/insights.py +277 -0
- analyzer/persona_loader.py +199 -0
- analyzer/reporter.py +989 -0
- analyzer/subscription.py +108 -0
- burn_rate.py +75 -0
- cache_quarantine.py +50 -0
- codex_disk_cache.py +227 -0
- codex_events.py +136 -0
- codex_fork_replay.py +111 -0
- codex_loader.py +1426 -0
- codex_paths.py +20 -0
- critter_frames.py +26 -0
- discussion_bridge.py +1196 -0
- discussion_cli.py +844 -0
- discussion_session.py +622 -0
- discussion_usage.py +13 -0
- discussion_window.py +955 -0
- disk_cache_common.py +132 -0
- disk_cache_lifecycle.py +39 -0
- doctor.py +452 -0
- fsevents_watch.py +207 -0
- history_disk_cache.py +110 -0
- history_loader.py +416 -0
- i18n.py +88 -0
- jsonl_limits.py +17 -0
- jsonl_utils.py +40 -0
- login_item.py +154 -0
- main.py +387 -0
- menubar.py +1201 -0
- menubar_actions.py +204 -0
- menubar_agy.py +193 -0
- menubar_chrome.py +156 -0
- menubar_menu.py +169 -0
- menubar_notify.py +102 -0
- menubar_popover.py +233 -0
- menubar_prefs.py +118 -0
- menubar_refresh.py +285 -0
- menubar_state.py +1200 -0
- menubar_title.py +157 -0
- menubar_update.py +123 -0
- panel_window.py +78 -0
- panel_window_state.py +159 -0
- panels/__init__.py +186 -0
- panels/base.py +83 -0
- panels/dynamic_height.py +140 -0
- panels/payload.py +178 -0
- panels/web_panel.py +513 -0
- panels/window_drag.py +56 -0
- prefs.py +44 -0
- pricing.py +452 -0
- project_resolver.py +112 -0
- service_status.py +383 -0
- session_hooks.py +1154 -0
- setup_app.py +171 -0
- setup_hook.py +1011 -0
- statusline_settings.py +160 -0
- talent_market_bridge.py +243 -0
- time_utils.py +24 -0
- tui.py +288 -0
- tui_sprite.py +206 -0
- ui/__init__.py +5 -0
- ui/html_report.py +923 -0
- ui/report_scripts.py +251 -0
- ui/report_styles.py +370 -0
- ui/tables.py +888 -0
- update_checker.py +156 -0
- update_gate.py +66 -0
- update_release_notes.py +49 -0
- usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
- usage_cli-0.29.32.dist-info/METADATA +223 -0
- usage_cli-0.29.32.dist-info/RECORD +109 -0
- usage_cli-0.29.32.dist-info/WHEEL +5 -0
- usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
- usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
- usage_cli-0.29.32.dist-info/top_level.txt +80 -0
- usage_cli.py +827 -0
- usage_client.py +487 -0
- usage_diagnosis_snapshot.py +143 -0
- usage_dir_sweeper.py +100 -0
- usage_lang.py +79 -0
- usage_logging.py +75 -0
- usage_notifications.py +96 -0
- usage_rate.py +97 -0
- usage_session_resume.py +913 -0
- usage_statusline.py +810 -0
- usage_statusline_agy.py +397 -0
- usage_statusline_forwarder.py +88 -0
- usage_terse_mode.py +223 -0
- usage_terse_reminder.py +151 -0
- win_login_item.py +53 -0
- window_keeper.py +264 -0
- windows_watch.py +443 -0
- wintray.py +2014 -0
- wintray_menu.py +136 -0
session_hooks.py
ADDED
|
@@ -0,0 +1,1154 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
"""Install, configure, and repair usage's session companion hooks."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import io
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import stat
|
|
18
|
+
import sys
|
|
19
|
+
from datetime import UTC, datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import setup_hook
|
|
24
|
+
from codex_paths import codex_home
|
|
25
|
+
from i18n import t as _t
|
|
26
|
+
from setup_hook import (
|
|
27
|
+
BACKUP_KEY,
|
|
28
|
+
HOOK_VERSION,
|
|
29
|
+
_atomic_write_text,
|
|
30
|
+
_copy_forwarder_script,
|
|
31
|
+
_copy_hook_script,
|
|
32
|
+
_detect_current_state,
|
|
33
|
+
_ensure_table_line,
|
|
34
|
+
_find_system_python,
|
|
35
|
+
_forwarder_command,
|
|
36
|
+
_installed_hook_version,
|
|
37
|
+
_load_settings,
|
|
38
|
+
_read_codex_config,
|
|
39
|
+
_save_settings,
|
|
40
|
+
_shell_arg,
|
|
41
|
+
_statusline_command,
|
|
42
|
+
_statusline_command_target_exists,
|
|
43
|
+
_uses_bundled_app_python,
|
|
44
|
+
is_setup,
|
|
45
|
+
needs_update,
|
|
46
|
+
setup,
|
|
47
|
+
update_hook,
|
|
48
|
+
)
|
|
49
|
+
from usage_statusline import _exclusive_lock
|
|
50
|
+
|
|
51
|
+
CLAUDE_SETTINGS = setup_hook.CLAUDE_SETTINGS
|
|
52
|
+
CODEX_CONFIG = setup_hook.CODEX_CONFIG
|
|
53
|
+
|
|
54
|
+
# Ceiling C — opt-in SessionStart hook that injects "where you left off" into a new
|
|
55
|
+
# session. Off by default: enabled only via the menu toggle, never by self_heal.
|
|
56
|
+
RESUME_HOOK_TARGET = Path(os.path.expanduser("~/.claude/usage-session-resume.py"))
|
|
57
|
+
RESUME_PROMPT_SIDECAR = Path(os.path.expanduser("~/.claude/usage-resume-prompt.json"))
|
|
58
|
+
RESUME_HOOK_VERSION = "1.6"
|
|
59
|
+
RESUME_MATCHER = "startup|clear"
|
|
60
|
+
RESUME_LANGS = ("zh-TW", "zh-CN", "en", "ja", "ko")
|
|
61
|
+
_RESUME_MARKER = "usage-session-resume"
|
|
62
|
+
_RESUME_MARKERS = (_RESUME_MARKER, "usage_session_resume")
|
|
63
|
+
_RESUME_DIAGNOSIS_CAUSE_KEYS = (
|
|
64
|
+
"repeated_reads",
|
|
65
|
+
"polluter_dirs",
|
|
66
|
+
"anomaly_session",
|
|
67
|
+
"noisy_bash",
|
|
68
|
+
"repeated_bash",
|
|
69
|
+
)
|
|
70
|
+
TERSE_HOOK_TARGET = Path(os.path.expanduser("~/.claude/usage-terse-mode.py"))
|
|
71
|
+
TERSE_PROMPT_SIDECAR = Path(os.path.expanduser("~/.claude/usage-terse-prompt.json"))
|
|
72
|
+
TERSE_HOOK_VERSION = "1.0"
|
|
73
|
+
TERSE_MATCHER = "startup|clear"
|
|
74
|
+
TERSE_LANGS = ("zh-TW", "zh-CN", "en", "ja", "ko")
|
|
75
|
+
_TERSE_MARKER = "usage-terse-mode"
|
|
76
|
+
_TERSE_MARKERS = (_TERSE_MARKER, "usage_terse_mode")
|
|
77
|
+
# Codex CLI shares the same terse-mode script (its SessionStart hook I/O schema matches Claude
|
|
78
|
+
# Code's). Installed into the user-global ~/.codex so one toggle covers both tools.
|
|
79
|
+
CODEX_TERSE_HOOK_TARGET = codex_home() / "usage-terse-mode.py"
|
|
80
|
+
CODEX_HOOKS_JSON = codex_home() / "hooks.json"
|
|
81
|
+
CODEX_TERSE_MATCHER = "startup|resume|clear"
|
|
82
|
+
_FEATURES_HOOKS_REGEX = re.compile(r"(?m)^[ \t]*hooks\s*=\s*[A-Za-z0-9_]+")
|
|
83
|
+
# Per-message tail reminder — a UserPromptSubmit hook installed alongside the SessionStart
|
|
84
|
+
# terse hook. Re-injects a one-line nudge on every prompt so the terse style holds across a
|
|
85
|
+
# long conversation. Claude Code only — Codex CLI has no UserPromptSubmit equivalent.
|
|
86
|
+
TERSE_REMINDER_HOOK_TARGET = Path(os.path.expanduser("~/.claude/usage-terse-reminder.py"))
|
|
87
|
+
TERSE_REMINDER_MATCHER = ""
|
|
88
|
+
_TERSE_REMINDER_MARKER = "usage-terse-reminder"
|
|
89
|
+
_TERSE_REMINDER_MARKERS = (_TERSE_REMINDER_MARKER, "usage_terse_reminder")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _migrate_bundled_python_commands_if_needed(
|
|
93
|
+
settings: dict[str, Any] | None = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
data = _load_settings() if settings is None else settings
|
|
96
|
+
changed = False
|
|
97
|
+
details: list[str] = []
|
|
98
|
+
|
|
99
|
+
sl = data.get("statusLine")
|
|
100
|
+
if isinstance(sl, dict):
|
|
101
|
+
command = sl.get("command")
|
|
102
|
+
if isinstance(command, str) and _uses_bundled_app_python(command):
|
|
103
|
+
if "usage-statusline-forwarder" in command:
|
|
104
|
+
new_command = _forwarder_command()
|
|
105
|
+
if command != new_command:
|
|
106
|
+
sl["command"] = new_command
|
|
107
|
+
changed = True
|
|
108
|
+
details.append("statusLine=forwarder")
|
|
109
|
+
elif "usage-statusline" in command:
|
|
110
|
+
new_command = _statusline_command()
|
|
111
|
+
if command != new_command:
|
|
112
|
+
sl["command"] = new_command
|
|
113
|
+
changed = True
|
|
114
|
+
details.append("statusLine=direct")
|
|
115
|
+
|
|
116
|
+
entries = _session_start_list(data)
|
|
117
|
+
if entries:
|
|
118
|
+
new_command = _resume_command()
|
|
119
|
+
resume_changed = False
|
|
120
|
+
for entry in entries:
|
|
121
|
+
if not isinstance(entry, dict) or not _is_resume_entry(entry):
|
|
122
|
+
continue
|
|
123
|
+
hooks = entry.get("hooks")
|
|
124
|
+
if not isinstance(hooks, list):
|
|
125
|
+
continue
|
|
126
|
+
for hook in hooks:
|
|
127
|
+
if not isinstance(hook, dict):
|
|
128
|
+
continue
|
|
129
|
+
command = hook.get("command")
|
|
130
|
+
if not isinstance(command, str) or not any(
|
|
131
|
+
marker in command for marker in _RESUME_MARKERS
|
|
132
|
+
):
|
|
133
|
+
continue
|
|
134
|
+
if command != new_command:
|
|
135
|
+
hook["command"] = new_command
|
|
136
|
+
changed = True
|
|
137
|
+
resume_changed = True
|
|
138
|
+
if resume_changed:
|
|
139
|
+
details.append("resume")
|
|
140
|
+
|
|
141
|
+
new_command = _terse_command()
|
|
142
|
+
terse_changed = False
|
|
143
|
+
for entry in entries:
|
|
144
|
+
if not isinstance(entry, dict) or not _is_terse_entry(entry):
|
|
145
|
+
continue
|
|
146
|
+
hooks = entry.get("hooks")
|
|
147
|
+
if not isinstance(hooks, list):
|
|
148
|
+
continue
|
|
149
|
+
for hook in hooks:
|
|
150
|
+
if not isinstance(hook, dict):
|
|
151
|
+
continue
|
|
152
|
+
command = hook.get("command")
|
|
153
|
+
if not isinstance(command, str) or not any(
|
|
154
|
+
marker in command for marker in _TERSE_MARKERS
|
|
155
|
+
):
|
|
156
|
+
continue
|
|
157
|
+
if command != new_command:
|
|
158
|
+
hook["command"] = new_command
|
|
159
|
+
changed = True
|
|
160
|
+
terse_changed = True
|
|
161
|
+
if terse_changed:
|
|
162
|
+
details.append("terse")
|
|
163
|
+
|
|
164
|
+
hooks_data = data.get("hooks")
|
|
165
|
+
prompt_entries = hooks_data.get("UserPromptSubmit") if isinstance(hooks_data, dict) else None
|
|
166
|
+
if isinstance(prompt_entries, list):
|
|
167
|
+
new_command = _terse_reminder_command()
|
|
168
|
+
reminder_changed = False
|
|
169
|
+
for entry in prompt_entries:
|
|
170
|
+
if not isinstance(entry, dict) or not _is_terse_reminder_entry(entry):
|
|
171
|
+
continue
|
|
172
|
+
hooks = entry.get("hooks")
|
|
173
|
+
if not isinstance(hooks, list):
|
|
174
|
+
continue
|
|
175
|
+
for hook in hooks:
|
|
176
|
+
if not isinstance(hook, dict):
|
|
177
|
+
continue
|
|
178
|
+
command = hook.get("command")
|
|
179
|
+
if not isinstance(command, str) or not any(
|
|
180
|
+
marker in command for marker in _TERSE_REMINDER_MARKERS
|
|
181
|
+
):
|
|
182
|
+
continue
|
|
183
|
+
if command != new_command:
|
|
184
|
+
hook["command"] = new_command
|
|
185
|
+
changed = True
|
|
186
|
+
reminder_changed = True
|
|
187
|
+
if reminder_changed:
|
|
188
|
+
details.append("terse_reminder")
|
|
189
|
+
|
|
190
|
+
if not changed:
|
|
191
|
+
return
|
|
192
|
+
_save_settings(data)
|
|
193
|
+
_append_self_heal_log("migrate_bundled_python", ", ".join(details))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _resolve_resume_source() -> Path:
|
|
197
|
+
paths = [
|
|
198
|
+
Path(__file__).resolve().parent / "usage_session_resume.py",
|
|
199
|
+
Path(sys.executable).resolve().parent.parent / "Resources" / "usage_session_resume.py",
|
|
200
|
+
]
|
|
201
|
+
for path in paths:
|
|
202
|
+
if path.exists():
|
|
203
|
+
return path
|
|
204
|
+
tried = ", ".join(str(path) for path in paths)
|
|
205
|
+
raise SystemExit(_t("setup_resume_source_missing", tried=tried))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _resolve_terse_source() -> Path:
|
|
209
|
+
paths = [
|
|
210
|
+
Path(__file__).resolve().parent / "usage_terse_mode.py",
|
|
211
|
+
Path(sys.executable).resolve().parent.parent / "Resources" / "usage_terse_mode.py",
|
|
212
|
+
]
|
|
213
|
+
for path in paths:
|
|
214
|
+
if path.exists():
|
|
215
|
+
return path
|
|
216
|
+
tried = ", ".join(str(path) for path in paths)
|
|
217
|
+
raise SystemExit(_t("setup_terse_source_missing", tried=tried))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _resolve_terse_reminder_source() -> Path:
|
|
221
|
+
paths = [
|
|
222
|
+
Path(__file__).resolve().parent / "usage_terse_reminder.py",
|
|
223
|
+
(
|
|
224
|
+
Path(sys.executable).resolve().parent.parent
|
|
225
|
+
/ "Resources"
|
|
226
|
+
/ "usage_terse_reminder.py"
|
|
227
|
+
),
|
|
228
|
+
]
|
|
229
|
+
for path in paths:
|
|
230
|
+
if path.exists():
|
|
231
|
+
return path
|
|
232
|
+
tried = ", ".join(str(path) for path in paths)
|
|
233
|
+
raise SystemExit(_t("setup_terse_source_missing", tried=tried))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _resume_command() -> str:
|
|
237
|
+
python = _find_system_python()
|
|
238
|
+
return f"{_shell_arg(python)} {_shell_arg(str(RESUME_HOOK_TARGET))}"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _terse_command() -> str:
|
|
242
|
+
python = _find_system_python()
|
|
243
|
+
return f"{_shell_arg(python)} {_shell_arg(str(TERSE_HOOK_TARGET))}"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _terse_reminder_command() -> str:
|
|
247
|
+
python = _find_system_python()
|
|
248
|
+
return f"{_shell_arg(python)} {_shell_arg(str(TERSE_REMINDER_HOOK_TARGET))}"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _copy_resume_script() -> None:
|
|
252
|
+
source = _resolve_resume_source()
|
|
253
|
+
RESUME_HOOK_TARGET.parent.mkdir(parents=True, exist_ok=True)
|
|
254
|
+
shutil.copyfile(source, RESUME_HOOK_TARGET)
|
|
255
|
+
RESUME_HOOK_TARGET.chmod(
|
|
256
|
+
RESUME_HOOK_TARGET.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _copy_terse_script() -> None:
|
|
261
|
+
source = _resolve_terse_source()
|
|
262
|
+
TERSE_HOOK_TARGET.parent.mkdir(parents=True, exist_ok=True)
|
|
263
|
+
shutil.copyfile(source, TERSE_HOOK_TARGET)
|
|
264
|
+
TERSE_HOOK_TARGET.chmod(
|
|
265
|
+
TERSE_HOOK_TARGET.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _copy_terse_reminder_script() -> None:
|
|
270
|
+
source = _resolve_terse_reminder_source()
|
|
271
|
+
TERSE_REMINDER_HOOK_TARGET.parent.mkdir(parents=True, exist_ok=True)
|
|
272
|
+
shutil.copyfile(source, TERSE_REMINDER_HOOK_TARGET)
|
|
273
|
+
TERSE_REMINDER_HOOK_TARGET.chmod(
|
|
274
|
+
TERSE_REMINDER_HOOK_TARGET.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _write_resume_sidecar() -> None:
|
|
279
|
+
"""Mirror i18n.json's rw_prompt/rw_none into a sidecar the stdlib hook can read,
|
|
280
|
+
so the injected wording stays single-sourced and the hook needs no app imports."""
|
|
281
|
+
from i18n import I18N_PATH
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
bundle = json.loads(I18N_PATH.read_text(encoding="utf-8"))
|
|
285
|
+
except (OSError, json.JSONDecodeError):
|
|
286
|
+
return
|
|
287
|
+
if not isinstance(bundle, dict):
|
|
288
|
+
return
|
|
289
|
+
en_raw = bundle.get("en")
|
|
290
|
+
en: dict[str, Any] = en_raw if isinstance(en_raw, dict) else {}
|
|
291
|
+
out: dict[str, dict[str, Any]] = {}
|
|
292
|
+
for lang in RESUME_LANGS:
|
|
293
|
+
table_raw = bundle.get(lang)
|
|
294
|
+
table: dict[str, Any] = table_raw if isinstance(table_raw, dict) else {}
|
|
295
|
+
prompt = table.get("report_rw_prompt") or en.get("report_rw_prompt")
|
|
296
|
+
none_label = table.get("report_rw_none") or en.get("report_rw_none")
|
|
297
|
+
lead = table.get("report_rw_inject_lead") or en.get("report_rw_inject_lead") or ""
|
|
298
|
+
empty = table.get("report_rw_empty") or en.get("report_rw_empty") or ""
|
|
299
|
+
uncommitted = table.get("report_rw_uncommitted") or en.get("report_rw_uncommitted") or ""
|
|
300
|
+
diagnosis_reminder = (
|
|
301
|
+
table.get("report_rw_diagnosis_reminder")
|
|
302
|
+
or en.get("report_rw_diagnosis_reminder")
|
|
303
|
+
or ""
|
|
304
|
+
)
|
|
305
|
+
diagnosis_reminder_explain = (
|
|
306
|
+
table.get("report_rw_diagnosis_reminder_explain")
|
|
307
|
+
or en.get("report_rw_diagnosis_reminder_explain")
|
|
308
|
+
or ""
|
|
309
|
+
)
|
|
310
|
+
diagnosis_default_cause = (
|
|
311
|
+
table.get("report_rw_diagnosis_cause_default")
|
|
312
|
+
or en.get("report_rw_diagnosis_cause_default")
|
|
313
|
+
or ""
|
|
314
|
+
)
|
|
315
|
+
if isinstance(prompt, str) and isinstance(none_label, str):
|
|
316
|
+
diagnosis_causes = {
|
|
317
|
+
key: (
|
|
318
|
+
table.get(f"report_rw_diagnosis_cause_{key}")
|
|
319
|
+
or en.get(f"report_rw_diagnosis_cause_{key}")
|
|
320
|
+
or diagnosis_default_cause
|
|
321
|
+
)
|
|
322
|
+
for key in _RESUME_DIAGNOSIS_CAUSE_KEYS
|
|
323
|
+
}
|
|
324
|
+
out[lang] = {
|
|
325
|
+
"prompt": prompt,
|
|
326
|
+
"none": none_label,
|
|
327
|
+
"lead": lead,
|
|
328
|
+
"empty": empty,
|
|
329
|
+
"uncommitted": uncommitted,
|
|
330
|
+
"diagnosis_reminder": diagnosis_reminder,
|
|
331
|
+
"diagnosis_reminder_explain": diagnosis_reminder_explain,
|
|
332
|
+
"diagnosis_default_cause": diagnosis_default_cause,
|
|
333
|
+
"diagnosis_causes": diagnosis_causes,
|
|
334
|
+
}
|
|
335
|
+
if out:
|
|
336
|
+
RESUME_PROMPT_SIDECAR.parent.mkdir(parents=True, exist_ok=True)
|
|
337
|
+
_atomic_write_text(
|
|
338
|
+
RESUME_PROMPT_SIDECAR, json.dumps(out, ensure_ascii=False, indent=2) + "\n"
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _write_terse_sidecar() -> None:
|
|
343
|
+
from i18n import I18N_PATH
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
bundle = json.loads(I18N_PATH.read_text(encoding="utf-8"))
|
|
347
|
+
except (OSError, json.JSONDecodeError):
|
|
348
|
+
return
|
|
349
|
+
if not isinstance(bundle, dict):
|
|
350
|
+
return
|
|
351
|
+
en_raw = bundle.get("en")
|
|
352
|
+
en: dict[str, Any] = en_raw if isinstance(en_raw, dict) else {}
|
|
353
|
+
out: dict[str, dict[str, str]] = {}
|
|
354
|
+
for lang in TERSE_LANGS:
|
|
355
|
+
table_raw = bundle.get(lang)
|
|
356
|
+
table: dict[str, Any] = table_raw if isinstance(table_raw, dict) else {}
|
|
357
|
+
instruction = table.get("terse_mode_instruction") or en.get("terse_mode_instruction")
|
|
358
|
+
reminder = table.get("terse_reminder_instruction") or en.get("terse_reminder_instruction")
|
|
359
|
+
if isinstance(instruction, str) and instruction:
|
|
360
|
+
entry = {"instruction": instruction}
|
|
361
|
+
if isinstance(reminder, str) and reminder:
|
|
362
|
+
entry["reminder"] = reminder
|
|
363
|
+
out[lang] = entry
|
|
364
|
+
if out:
|
|
365
|
+
TERSE_PROMPT_SIDECAR.parent.mkdir(parents=True, exist_ok=True)
|
|
366
|
+
_atomic_write_text(
|
|
367
|
+
TERSE_PROMPT_SIDECAR, json.dumps(out, ensure_ascii=False, indent=2) + "\n"
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _is_hook_entry(entry: object, markers: tuple[str, ...]) -> bool:
|
|
372
|
+
if not isinstance(entry, dict):
|
|
373
|
+
return False
|
|
374
|
+
hooks = entry.get("hooks")
|
|
375
|
+
if not isinstance(hooks, list):
|
|
376
|
+
return False
|
|
377
|
+
return any(
|
|
378
|
+
isinstance(h, dict)
|
|
379
|
+
and isinstance(h.get("command"), str)
|
|
380
|
+
and any(marker in h["command"] for marker in markers)
|
|
381
|
+
for h in hooks
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _strip_hook_entries(entry: object, markers: tuple[str, ...]) -> object | None:
|
|
386
|
+
if not isinstance(entry, dict):
|
|
387
|
+
return entry
|
|
388
|
+
hooks = entry.get("hooks")
|
|
389
|
+
if not isinstance(hooks, list):
|
|
390
|
+
return entry
|
|
391
|
+
kept = [
|
|
392
|
+
h
|
|
393
|
+
for h in hooks
|
|
394
|
+
if not (
|
|
395
|
+
isinstance(h, dict)
|
|
396
|
+
and isinstance(h.get("command"), str)
|
|
397
|
+
and any(marker in h["command"] for marker in markers)
|
|
398
|
+
)
|
|
399
|
+
]
|
|
400
|
+
if len(kept) == len(hooks):
|
|
401
|
+
return entry
|
|
402
|
+
if not kept:
|
|
403
|
+
return None
|
|
404
|
+
return {**entry, "hooks": kept}
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _is_resume_entry(entry: object) -> bool:
|
|
408
|
+
return _is_hook_entry(entry, _RESUME_MARKERS)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _strip_resume_hooks(entry: object) -> object | None:
|
|
412
|
+
"""Return ``entry`` with usage-owned resume hooks removed.
|
|
413
|
+
|
|
414
|
+
Removes only the resume hook *item*, not the whole entry, so a user who put their
|
|
415
|
+
own hook in the same SessionStart entry doesn't lose it when we disable. Returns
|
|
416
|
+
``None`` when nothing but our hook was in the entry, ``entry`` unchanged when it
|
|
417
|
+
held no resume hook.
|
|
418
|
+
"""
|
|
419
|
+
return _strip_hook_entries(entry, _RESUME_MARKERS)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _is_terse_entry(entry: object) -> bool:
|
|
423
|
+
return _is_hook_entry(entry, _TERSE_MARKERS)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _strip_terse_hooks(entry: object) -> object | None:
|
|
427
|
+
return _strip_hook_entries(entry, _TERSE_MARKERS)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _user_prompt_submit_list(settings: dict[str, Any]) -> list[Any] | None:
|
|
431
|
+
hooks = settings.get("hooks")
|
|
432
|
+
if not isinstance(hooks, dict):
|
|
433
|
+
return None
|
|
434
|
+
ups = hooks.get("UserPromptSubmit")
|
|
435
|
+
return ups if isinstance(ups, list) else None
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _is_terse_reminder_entry(entry: object) -> bool:
|
|
439
|
+
return _is_hook_entry(entry, _TERSE_REMINDER_MARKERS)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _strip_terse_reminder_hooks(entry: object) -> object | None:
|
|
443
|
+
return _strip_hook_entries(entry, _TERSE_REMINDER_MARKERS)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _register_terse_reminder(settings: dict[str, Any]) -> None:
|
|
447
|
+
"""Idempotently add the per-message UserPromptSubmit reminder entry to ``settings``
|
|
448
|
+
(in memory — caller persists). Strips any prior reminder entry first so a repeat
|
|
449
|
+
enable never duplicates it."""
|
|
450
|
+
hooks = settings.get("hooks")
|
|
451
|
+
if not isinstance(hooks, dict):
|
|
452
|
+
hooks = {}
|
|
453
|
+
settings["hooks"] = hooks
|
|
454
|
+
ups = hooks.get("UserPromptSubmit")
|
|
455
|
+
if not isinstance(ups, list):
|
|
456
|
+
ups = []
|
|
457
|
+
hooks["UserPromptSubmit"] = ups
|
|
458
|
+
ups[:] = [e for e in (_strip_terse_reminder_hooks(e) for e in ups) if e is not None]
|
|
459
|
+
ups.append(
|
|
460
|
+
{
|
|
461
|
+
"matcher": TERSE_REMINDER_MATCHER,
|
|
462
|
+
"hooks": [{"type": "command", "command": _terse_reminder_command()}],
|
|
463
|
+
}
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _codex_terse_command() -> str:
|
|
468
|
+
python = _find_system_python()
|
|
469
|
+
return f"{_shell_arg(python)} {_shell_arg(str(CODEX_TERSE_HOOK_TARGET))}"
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _load_codex_hooks() -> dict[str, Any] | None:
|
|
473
|
+
if not CODEX_HOOKS_JSON.exists():
|
|
474
|
+
return {}
|
|
475
|
+
try:
|
|
476
|
+
data = json.loads(CODEX_HOOKS_JSON.read_text(encoding="utf-8"))
|
|
477
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
478
|
+
return None
|
|
479
|
+
return data if isinstance(data, dict) else None
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _save_codex_hooks(data: dict[str, Any]) -> None:
|
|
483
|
+
_atomic_write_text(
|
|
484
|
+
CODEX_HOOKS_JSON, json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _codex_session_start_list(data: dict[str, Any]) -> list[Any] | None:
|
|
489
|
+
hooks = data.get("hooks")
|
|
490
|
+
if not isinstance(hooks, dict):
|
|
491
|
+
return None
|
|
492
|
+
session_start = hooks.get("SessionStart")
|
|
493
|
+
return session_start if isinstance(session_start, list) else None
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _is_codex_terse_installed() -> bool:
|
|
497
|
+
data = _load_codex_hooks()
|
|
498
|
+
if data is None:
|
|
499
|
+
return False
|
|
500
|
+
entries = _codex_session_start_list(data)
|
|
501
|
+
if not entries:
|
|
502
|
+
return False
|
|
503
|
+
return any(_is_terse_entry(e) for e in entries)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _copy_codex_terse_script() -> None:
|
|
507
|
+
source = _resolve_terse_source()
|
|
508
|
+
CODEX_TERSE_HOOK_TARGET.parent.mkdir(parents=True, exist_ok=True)
|
|
509
|
+
shutil.copyfile(source, CODEX_TERSE_HOOK_TARGET)
|
|
510
|
+
CODEX_TERSE_HOOK_TARGET.chmod(
|
|
511
|
+
CODEX_TERSE_HOOK_TARGET.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _setup_codex_terse() -> None:
|
|
516
|
+
"""Install the terse hook into the user-global ~/.codex. No-op when config.toml can't be
|
|
517
|
+
read — the [features] hooks flag is the gate, so without a readable config the hook
|
|
518
|
+
wouldn't fire anyway. Stays silent: Codex is an opt-in add-on to the Claude toggle."""
|
|
519
|
+
result = _read_codex_config()
|
|
520
|
+
if result is None:
|
|
521
|
+
return
|
|
522
|
+
content, parsed = result
|
|
523
|
+
data = _load_codex_hooks()
|
|
524
|
+
if data is None:
|
|
525
|
+
print(_t("terse_codex_hooks_unreadable"))
|
|
526
|
+
return
|
|
527
|
+
_copy_codex_terse_script()
|
|
528
|
+
|
|
529
|
+
features = parsed.get("features")
|
|
530
|
+
if not (isinstance(features, dict) and features.get("hooks") is True):
|
|
531
|
+
new_content = _ensure_table_line(
|
|
532
|
+
content, "features", _FEATURES_HOOKS_REGEX, "hooks = true"
|
|
533
|
+
)
|
|
534
|
+
if new_content != content:
|
|
535
|
+
_atomic_write_text(CODEX_CONFIG, new_content)
|
|
536
|
+
|
|
537
|
+
hooks = data.get("hooks")
|
|
538
|
+
if not isinstance(hooks, dict):
|
|
539
|
+
hooks = {}
|
|
540
|
+
data["hooks"] = hooks
|
|
541
|
+
session_start = hooks.get("SessionStart")
|
|
542
|
+
if not isinstance(session_start, list):
|
|
543
|
+
session_start = []
|
|
544
|
+
hooks["SessionStart"] = session_start
|
|
545
|
+
session_start[:] = [
|
|
546
|
+
e for e in (_strip_terse_hooks(e) for e in session_start) if e is not None
|
|
547
|
+
]
|
|
548
|
+
session_start.append(
|
|
549
|
+
{
|
|
550
|
+
"matcher": CODEX_TERSE_MATCHER,
|
|
551
|
+
"hooks": [{"type": "command", "command": _codex_terse_command(), "timeout": 5}],
|
|
552
|
+
}
|
|
553
|
+
)
|
|
554
|
+
_save_codex_hooks(data)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _teardown_codex_terse() -> None:
|
|
558
|
+
"""Remove only usage's own Codex SessionStart entry; leave ``[features] hooks = true``
|
|
559
|
+
and any user-installed Codex hooks intact. Deletes hooks.json when nothing remains."""
|
|
560
|
+
data = _load_codex_hooks()
|
|
561
|
+
if data is not None:
|
|
562
|
+
entries = _codex_session_start_list(data)
|
|
563
|
+
if entries is not None:
|
|
564
|
+
kept = [e for e in (_strip_terse_hooks(e) for e in entries) if e is not None]
|
|
565
|
+
hooks = data.get("hooks")
|
|
566
|
+
if isinstance(hooks, dict):
|
|
567
|
+
if kept:
|
|
568
|
+
hooks["SessionStart"] = kept
|
|
569
|
+
else:
|
|
570
|
+
hooks.pop("SessionStart", None)
|
|
571
|
+
if not hooks:
|
|
572
|
+
data.pop("hooks", None)
|
|
573
|
+
if data:
|
|
574
|
+
_save_codex_hooks(data)
|
|
575
|
+
else:
|
|
576
|
+
CODEX_HOOKS_JSON.unlink(missing_ok=True)
|
|
577
|
+
CODEX_TERSE_HOOK_TARGET.unlink(missing_ok=True)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _installed_codex_terse_version() -> str | None:
|
|
581
|
+
try:
|
|
582
|
+
with CODEX_TERSE_HOOK_TARGET.open(encoding="utf-8") as f:
|
|
583
|
+
for line in f:
|
|
584
|
+
if line.startswith("__version__"):
|
|
585
|
+
return line.split("=", 1)[1].strip().strip("\"'")
|
|
586
|
+
except OSError:
|
|
587
|
+
pass
|
|
588
|
+
return None
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _self_heal_codex_terse() -> None:
|
|
592
|
+
"""Restore the Codex side of terse mode — but only when Claude's side is already on and
|
|
593
|
+
Codex is installed. Re-runs the idempotent installer for any missing/stale artifact."""
|
|
594
|
+
if not CODEX_CONFIG.exists():
|
|
595
|
+
return
|
|
596
|
+
result = _read_codex_config()
|
|
597
|
+
if result is None:
|
|
598
|
+
return
|
|
599
|
+
_, parsed = result
|
|
600
|
+
features = parsed.get("features")
|
|
601
|
+
hooks_enabled = isinstance(features, dict) and features.get("hooks") is True
|
|
602
|
+
|
|
603
|
+
script_missing = not CODEX_TERSE_HOOK_TARGET.exists()
|
|
604
|
+
entry_missing = not _is_codex_terse_installed()
|
|
605
|
+
old_version = _installed_codex_terse_version()
|
|
606
|
+
version_stale = CODEX_TERSE_HOOK_TARGET.exists() and old_version != TERSE_HOOK_VERSION
|
|
607
|
+
|
|
608
|
+
if not (script_missing or entry_missing or version_stale or not hooks_enabled):
|
|
609
|
+
return
|
|
610
|
+
|
|
611
|
+
_setup_codex_terse()
|
|
612
|
+
if script_missing or entry_missing or not hooks_enabled:
|
|
613
|
+
parts: list[str] = []
|
|
614
|
+
if script_missing:
|
|
615
|
+
parts.append("script")
|
|
616
|
+
if entry_missing:
|
|
617
|
+
parts.append("hooks_entry")
|
|
618
|
+
if not hooks_enabled:
|
|
619
|
+
parts.append("features_flag")
|
|
620
|
+
_append_self_heal_log("restore_terse_hook_codex", f"missing={','.join(parts)}")
|
|
621
|
+
else:
|
|
622
|
+
_append_self_heal_log(
|
|
623
|
+
"update_terse_hook_codex", f"{old_version or 'unknown'} -> {TERSE_HOOK_VERSION}"
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _session_start_list(settings: dict[str, Any]) -> list[Any] | None:
|
|
628
|
+
hooks = settings.get("hooks")
|
|
629
|
+
if not isinstance(hooks, dict):
|
|
630
|
+
return None
|
|
631
|
+
session_start = hooks.get("SessionStart")
|
|
632
|
+
return session_start if isinstance(session_start, list) else None
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def is_resume_enabled() -> bool:
|
|
636
|
+
try:
|
|
637
|
+
settings = _load_settings()
|
|
638
|
+
except SystemExit:
|
|
639
|
+
return False
|
|
640
|
+
entries = _session_start_list(settings)
|
|
641
|
+
if not entries:
|
|
642
|
+
return False
|
|
643
|
+
return any(_is_resume_entry(e) for e in entries)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def is_terse_mode_enabled() -> bool:
|
|
647
|
+
try:
|
|
648
|
+
settings = _load_settings()
|
|
649
|
+
except SystemExit:
|
|
650
|
+
return False
|
|
651
|
+
entries = _session_start_list(settings)
|
|
652
|
+
if not entries:
|
|
653
|
+
return False
|
|
654
|
+
return any(_is_terse_entry(e) for e in entries)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def is_terse_reminder_enabled() -> bool:
|
|
658
|
+
try:
|
|
659
|
+
settings = _load_settings()
|
|
660
|
+
except SystemExit:
|
|
661
|
+
return False
|
|
662
|
+
entries = _user_prompt_submit_list(settings)
|
|
663
|
+
if not entries:
|
|
664
|
+
return False
|
|
665
|
+
return any(_is_terse_reminder_entry(e) for e in entries)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def enable_session_resume() -> int:
|
|
669
|
+
setup_hook.configure_windows_utf8_output()
|
|
670
|
+
if not CLAUDE_SETTINGS.parent.exists():
|
|
671
|
+
print(_t("setup_no_agents"), file=sys.stderr)
|
|
672
|
+
return 1
|
|
673
|
+
_copy_resume_script()
|
|
674
|
+
_write_resume_sidecar()
|
|
675
|
+
settings = _load_settings()
|
|
676
|
+
hooks = settings.get("hooks")
|
|
677
|
+
if not isinstance(hooks, dict):
|
|
678
|
+
hooks = {}
|
|
679
|
+
settings["hooks"] = hooks
|
|
680
|
+
session_start = hooks.get("SessionStart")
|
|
681
|
+
if not isinstance(session_start, list):
|
|
682
|
+
session_start = []
|
|
683
|
+
hooks["SessionStart"] = session_start
|
|
684
|
+
session_start[:] = [e for e in (_strip_resume_hooks(e) for e in session_start) if e is not None]
|
|
685
|
+
session_start.append(
|
|
686
|
+
{"matcher": RESUME_MATCHER, "hooks": [{"type": "command", "command": _resume_command()}]}
|
|
687
|
+
)
|
|
688
|
+
_save_settings(settings)
|
|
689
|
+
print(_t("setup_resume_enabled", path=_resolve_resume_source()))
|
|
690
|
+
print(_t("setup_claude_restart_required"))
|
|
691
|
+
return 0
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def enable_terse_mode() -> int:
|
|
695
|
+
setup_hook.configure_windows_utf8_output()
|
|
696
|
+
if not CLAUDE_SETTINGS.parent.exists():
|
|
697
|
+
print(_t("setup_no_agents"), file=sys.stderr)
|
|
698
|
+
return 1
|
|
699
|
+
_copy_terse_script()
|
|
700
|
+
_copy_terse_reminder_script()
|
|
701
|
+
_write_terse_sidecar()
|
|
702
|
+
settings = _load_settings()
|
|
703
|
+
hooks = settings.get("hooks")
|
|
704
|
+
if not isinstance(hooks, dict):
|
|
705
|
+
hooks = {}
|
|
706
|
+
settings["hooks"] = hooks
|
|
707
|
+
session_start = hooks.get("SessionStart")
|
|
708
|
+
if not isinstance(session_start, list):
|
|
709
|
+
session_start = []
|
|
710
|
+
hooks["SessionStart"] = session_start
|
|
711
|
+
session_start[:] = [e for e in (_strip_terse_hooks(e) for e in session_start) if e is not None]
|
|
712
|
+
session_start.append(
|
|
713
|
+
{"matcher": TERSE_MATCHER, "hooks": [{"type": "command", "command": _terse_command()}]}
|
|
714
|
+
)
|
|
715
|
+
_register_terse_reminder(settings)
|
|
716
|
+
_save_settings(settings)
|
|
717
|
+
if CODEX_CONFIG.exists():
|
|
718
|
+
_setup_codex_terse()
|
|
719
|
+
print(_t("terse_mode_enabled_msg"))
|
|
720
|
+
print(_t("setup_claude_restart_required"))
|
|
721
|
+
return 0
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def disable_session_resume() -> int:
|
|
725
|
+
setup_hook.configure_windows_utf8_output()
|
|
726
|
+
if CLAUDE_SETTINGS.parent.exists():
|
|
727
|
+
settings = _load_settings()
|
|
728
|
+
session_start = _session_start_list(settings)
|
|
729
|
+
if session_start is not None:
|
|
730
|
+
kept = [e for e in (_strip_resume_hooks(e) for e in session_start) if e is not None]
|
|
731
|
+
if kept != session_start:
|
|
732
|
+
hooks = settings["hooks"]
|
|
733
|
+
if kept:
|
|
734
|
+
hooks["SessionStart"] = kept
|
|
735
|
+
else:
|
|
736
|
+
hooks.pop("SessionStart", None)
|
|
737
|
+
if not hooks:
|
|
738
|
+
settings.pop("hooks", None)
|
|
739
|
+
_save_settings(settings)
|
|
740
|
+
print(_t("setup_resume_disabled"))
|
|
741
|
+
for path in (RESUME_HOOK_TARGET, RESUME_PROMPT_SIDECAR):
|
|
742
|
+
if path.exists():
|
|
743
|
+
path.unlink()
|
|
744
|
+
return 0
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def disable_terse_mode() -> int:
|
|
748
|
+
setup_hook.configure_windows_utf8_output()
|
|
749
|
+
if CLAUDE_SETTINGS.parent.exists():
|
|
750
|
+
settings = _load_settings()
|
|
751
|
+
changed = False
|
|
752
|
+
|
|
753
|
+
session_start = _session_start_list(settings)
|
|
754
|
+
if session_start is not None:
|
|
755
|
+
kept = [e for e in (_strip_terse_hooks(e) for e in session_start) if e is not None]
|
|
756
|
+
if kept != session_start:
|
|
757
|
+
hooks = settings["hooks"]
|
|
758
|
+
if kept:
|
|
759
|
+
hooks["SessionStart"] = kept
|
|
760
|
+
else:
|
|
761
|
+
hooks.pop("SessionStart", None)
|
|
762
|
+
changed = True
|
|
763
|
+
|
|
764
|
+
ups = _user_prompt_submit_list(settings)
|
|
765
|
+
if ups is not None:
|
|
766
|
+
kept_ups = [
|
|
767
|
+
e for e in (_strip_terse_reminder_hooks(e) for e in ups) if e is not None
|
|
768
|
+
]
|
|
769
|
+
if kept_ups != ups:
|
|
770
|
+
hooks = settings["hooks"]
|
|
771
|
+
if kept_ups:
|
|
772
|
+
hooks["UserPromptSubmit"] = kept_ups
|
|
773
|
+
else:
|
|
774
|
+
hooks.pop("UserPromptSubmit", None)
|
|
775
|
+
changed = True
|
|
776
|
+
|
|
777
|
+
if changed:
|
|
778
|
+
hooks = settings.get("hooks")
|
|
779
|
+
if isinstance(hooks, dict) and not hooks:
|
|
780
|
+
settings.pop("hooks", None)
|
|
781
|
+
_save_settings(settings)
|
|
782
|
+
print(_t("terse_mode_disabled_msg"))
|
|
783
|
+
for path in (TERSE_HOOK_TARGET, TERSE_REMINDER_HOOK_TARGET, TERSE_PROMPT_SIDECAR):
|
|
784
|
+
if path.exists():
|
|
785
|
+
path.unlink()
|
|
786
|
+
_teardown_codex_terse()
|
|
787
|
+
return 0
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _installed_resume_version() -> str | None:
|
|
791
|
+
try:
|
|
792
|
+
with RESUME_HOOK_TARGET.open(encoding="utf-8") as f:
|
|
793
|
+
for line in f:
|
|
794
|
+
if line.startswith("__version__"):
|
|
795
|
+
return line.split("=", 1)[1].strip().strip("\"'")
|
|
796
|
+
except OSError:
|
|
797
|
+
pass
|
|
798
|
+
return None
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def _installed_terse_version() -> str | None:
|
|
802
|
+
try:
|
|
803
|
+
with TERSE_HOOK_TARGET.open(encoding="utf-8") as f:
|
|
804
|
+
for line in f:
|
|
805
|
+
if line.startswith("__version__"):
|
|
806
|
+
return line.split("=", 1)[1].strip().strip("\"'")
|
|
807
|
+
except OSError:
|
|
808
|
+
pass
|
|
809
|
+
return None
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _self_heal_resume() -> None:
|
|
813
|
+
"""Keep the opt-in resume hook healthy *only if already enabled* — restore a missing
|
|
814
|
+
script/sidecar and update a stale script. Never enables it on its own."""
|
|
815
|
+
if not is_resume_enabled():
|
|
816
|
+
return
|
|
817
|
+
_migrate_resume_command_if_needed()
|
|
818
|
+
missing = _missing_resume_artifacts()
|
|
819
|
+
if missing:
|
|
820
|
+
detail = _resume_restore_context(missing)
|
|
821
|
+
_copy_resume_script()
|
|
822
|
+
_write_resume_sidecar()
|
|
823
|
+
_append_self_heal_log("restore_resume_hook", detail)
|
|
824
|
+
elif _installed_resume_version() != RESUME_HOOK_VERSION:
|
|
825
|
+
old = _installed_resume_version()
|
|
826
|
+
_copy_resume_script()
|
|
827
|
+
_write_resume_sidecar()
|
|
828
|
+
_append_self_heal_log("update_resume_hook", f"{old or 'unknown'} -> {RESUME_HOOK_VERSION}")
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _missing_terse_artifacts() -> list[str]:
|
|
832
|
+
missing: list[str] = []
|
|
833
|
+
if not TERSE_HOOK_TARGET.exists():
|
|
834
|
+
missing.append("script")
|
|
835
|
+
if not TERSE_PROMPT_SIDECAR.exists():
|
|
836
|
+
missing.append("sidecar")
|
|
837
|
+
return missing
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _self_heal_terse_mode() -> None:
|
|
841
|
+
if not is_terse_mode_enabled():
|
|
842
|
+
return
|
|
843
|
+
missing = _missing_terse_artifacts()
|
|
844
|
+
if missing:
|
|
845
|
+
_copy_terse_script()
|
|
846
|
+
_write_terse_sidecar()
|
|
847
|
+
_append_self_heal_log("restore_terse_hook", f"missing={','.join(missing)}")
|
|
848
|
+
else:
|
|
849
|
+
old = _installed_terse_version()
|
|
850
|
+
if old != TERSE_HOOK_VERSION:
|
|
851
|
+
_copy_terse_script()
|
|
852
|
+
_write_terse_sidecar()
|
|
853
|
+
_append_self_heal_log(
|
|
854
|
+
"update_terse_hook", f"{old or 'unknown'} -> {TERSE_HOOK_VERSION}"
|
|
855
|
+
)
|
|
856
|
+
_self_heal_terse_reminder()
|
|
857
|
+
_self_heal_codex_terse()
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _self_heal_terse_reminder() -> None:
|
|
861
|
+
"""Backfill the per-message reminder hook when terse mode is on but the
|
|
862
|
+
UserPromptSubmit entry or its script is missing — e.g. a user who enabled terse
|
|
863
|
+
mode before this hook existed upgrades and self-heal installs it."""
|
|
864
|
+
script_missing = not TERSE_REMINDER_HOOK_TARGET.exists()
|
|
865
|
+
entry_missing = not is_terse_reminder_enabled()
|
|
866
|
+
if not (script_missing or entry_missing):
|
|
867
|
+
return
|
|
868
|
+
_copy_terse_reminder_script()
|
|
869
|
+
settings = _load_settings()
|
|
870
|
+
_register_terse_reminder(settings)
|
|
871
|
+
_save_settings(settings)
|
|
872
|
+
parts: list[str] = []
|
|
873
|
+
if script_missing:
|
|
874
|
+
parts.append("script")
|
|
875
|
+
if entry_missing:
|
|
876
|
+
parts.append("entry")
|
|
877
|
+
_append_self_heal_log("restore_terse_reminder_hook", f"missing={','.join(parts)}")
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _migrate_resume_command_if_needed() -> None:
|
|
881
|
+
settings = _load_settings()
|
|
882
|
+
entries = _session_start_list(settings)
|
|
883
|
+
if not entries:
|
|
884
|
+
return
|
|
885
|
+
new_command = _resume_command()
|
|
886
|
+
changed = False
|
|
887
|
+
for entry in entries:
|
|
888
|
+
if not isinstance(entry, dict) or not _is_resume_entry(entry):
|
|
889
|
+
continue
|
|
890
|
+
hooks = entry.get("hooks")
|
|
891
|
+
if not isinstance(hooks, list):
|
|
892
|
+
continue
|
|
893
|
+
for hook in hooks:
|
|
894
|
+
if not isinstance(hook, dict):
|
|
895
|
+
continue
|
|
896
|
+
command = hook.get("command")
|
|
897
|
+
if (
|
|
898
|
+
not isinstance(command, str)
|
|
899
|
+
or not any(marker in command for marker in _RESUME_MARKERS)
|
|
900
|
+
or command == new_command
|
|
901
|
+
):
|
|
902
|
+
continue
|
|
903
|
+
hook["command"] = new_command
|
|
904
|
+
changed = True
|
|
905
|
+
if not changed:
|
|
906
|
+
return
|
|
907
|
+
_save_settings(settings)
|
|
908
|
+
_append_self_heal_log(
|
|
909
|
+
"migrate_resume_command",
|
|
910
|
+
f"{_resolve_resume_source()} -> {RESUME_HOOK_TARGET}",
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _missing_resume_artifacts() -> list[str]:
|
|
915
|
+
missing: list[str] = []
|
|
916
|
+
if not RESUME_HOOK_TARGET.exists():
|
|
917
|
+
missing.append("script")
|
|
918
|
+
if not RESUME_PROMPT_SIDECAR.exists():
|
|
919
|
+
missing.append("sidecar")
|
|
920
|
+
return missing
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def _resume_restore_context(missing: list[str]) -> str:
|
|
924
|
+
parts = [f"missing={','.join(missing)}"]
|
|
925
|
+
elapsed = _seconds_since_last_self_heal("restore_resume_hook")
|
|
926
|
+
if elapsed is not None:
|
|
927
|
+
parts.append(f"seconds_since_previous_restore={elapsed}")
|
|
928
|
+
command = _installed_resume_command()
|
|
929
|
+
if command:
|
|
930
|
+
source = _resolve_resume_source().as_posix()
|
|
931
|
+
target = RESUME_HOOK_TARGET.as_posix()
|
|
932
|
+
if source in command:
|
|
933
|
+
parts.append("registered=source")
|
|
934
|
+
elif target in command:
|
|
935
|
+
parts.append("registered=target")
|
|
936
|
+
else:
|
|
937
|
+
parts.append("registered=other")
|
|
938
|
+
recent = _recent_claude_dir_changes()
|
|
939
|
+
if recent:
|
|
940
|
+
parts.append(f"recent_claude_entries={recent}")
|
|
941
|
+
return "; ".join(parts)
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _seconds_since_last_self_heal(action: str) -> int | None:
|
|
945
|
+
try:
|
|
946
|
+
settings = _load_settings()
|
|
947
|
+
except SystemExit:
|
|
948
|
+
return None
|
|
949
|
+
usage_settings = settings.get(BACKUP_KEY)
|
|
950
|
+
if not isinstance(usage_settings, dict):
|
|
951
|
+
return None
|
|
952
|
+
log = usage_settings.get("selfHealLog")
|
|
953
|
+
if not isinstance(log, list):
|
|
954
|
+
return None
|
|
955
|
+
for entry in reversed(log):
|
|
956
|
+
if not isinstance(entry, dict) or entry.get("action") != action:
|
|
957
|
+
continue
|
|
958
|
+
timestamp = entry.get("timestamp")
|
|
959
|
+
if not isinstance(timestamp, str):
|
|
960
|
+
continue
|
|
961
|
+
try:
|
|
962
|
+
parsed = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
|
963
|
+
except ValueError:
|
|
964
|
+
continue
|
|
965
|
+
return max(0, int((datetime.now(UTC) - parsed).total_seconds()))
|
|
966
|
+
return None
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _installed_resume_command() -> str:
|
|
970
|
+
try:
|
|
971
|
+
settings = _load_settings()
|
|
972
|
+
except SystemExit:
|
|
973
|
+
return ""
|
|
974
|
+
entries = _session_start_list(settings)
|
|
975
|
+
if not entries:
|
|
976
|
+
return ""
|
|
977
|
+
for entry in entries:
|
|
978
|
+
if not isinstance(entry, dict):
|
|
979
|
+
continue
|
|
980
|
+
hooks = entry.get("hooks")
|
|
981
|
+
if not isinstance(hooks, list):
|
|
982
|
+
continue
|
|
983
|
+
for hook in hooks:
|
|
984
|
+
if not isinstance(hook, dict):
|
|
985
|
+
continue
|
|
986
|
+
command = hook.get("command")
|
|
987
|
+
if isinstance(command, str) and any(marker in command for marker in _RESUME_MARKERS):
|
|
988
|
+
return command
|
|
989
|
+
return ""
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def _recent_claude_dir_changes(limit: int = 6) -> str:
|
|
993
|
+
root = CLAUDE_SETTINGS.parent
|
|
994
|
+
try:
|
|
995
|
+
entries = sorted(
|
|
996
|
+
(entry for entry in root.iterdir()),
|
|
997
|
+
key=lambda entry: entry.stat().st_mtime,
|
|
998
|
+
reverse=True,
|
|
999
|
+
)
|
|
1000
|
+
except OSError:
|
|
1001
|
+
return ""
|
|
1002
|
+
result: list[str] = []
|
|
1003
|
+
now = datetime.now(UTC).timestamp()
|
|
1004
|
+
for entry in entries[:limit]:
|
|
1005
|
+
try:
|
|
1006
|
+
stat_result = entry.stat()
|
|
1007
|
+
except OSError:
|
|
1008
|
+
continue
|
|
1009
|
+
age = max(0, int(now - stat_result.st_mtime))
|
|
1010
|
+
kind = "dir" if entry.is_dir() else "file"
|
|
1011
|
+
result.append(f"{entry.name}:{kind}:{age}s")
|
|
1012
|
+
return ",".join(result)
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def _append_self_heal_log(action: str, detail: str) -> None:
|
|
1016
|
+
target_dir = CLAUDE_SETTINGS.parent
|
|
1017
|
+
# Not usage-status.lock: that one guards the high-frequency statusline
|
|
1018
|
+
# writes to a different file, and sharing it would serialize them here.
|
|
1019
|
+
lock_file = target_dir / "usage-settings.lock"
|
|
1020
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
1021
|
+
lock_fd = os.open(lock_file, os.O_CREAT | os.O_RDWR, 0o600)
|
|
1022
|
+
try:
|
|
1023
|
+
with _exclusive_lock(lock_fd):
|
|
1024
|
+
settings = _load_settings()
|
|
1025
|
+
usage_settings = settings.get(BACKUP_KEY)
|
|
1026
|
+
if not isinstance(usage_settings, dict):
|
|
1027
|
+
usage_settings = {}
|
|
1028
|
+
settings[BACKUP_KEY] = usage_settings
|
|
1029
|
+
log = usage_settings.get("selfHealLog")
|
|
1030
|
+
if not isinstance(log, list):
|
|
1031
|
+
log = []
|
|
1032
|
+
log.append(
|
|
1033
|
+
{
|
|
1034
|
+
"timestamp": (
|
|
1035
|
+
datetime.now(UTC)
|
|
1036
|
+
.replace(microsecond=0)
|
|
1037
|
+
.isoformat()
|
|
1038
|
+
.replace("+00:00", "Z")
|
|
1039
|
+
),
|
|
1040
|
+
"action": action,
|
|
1041
|
+
"detail": detail,
|
|
1042
|
+
}
|
|
1043
|
+
)
|
|
1044
|
+
usage_settings["selfHealLog"] = log[-20:]
|
|
1045
|
+
_save_settings(settings)
|
|
1046
|
+
finally:
|
|
1047
|
+
os.close(lock_fd)
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _run_quietly(func: Any, *args: Any, **kwargs: Any) -> Any:
|
|
1051
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
1052
|
+
return func(*args, **kwargs)
|
|
1053
|
+
output = io.StringIO()
|
|
1054
|
+
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output):
|
|
1055
|
+
return func(*args, **kwargs)
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def _debug_self_heal_failure(action: str, exc: BaseException) -> None:
|
|
1059
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
1060
|
+
print(f"usage self-heal {action} failed: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def self_heal() -> None:
|
|
1064
|
+
"""Best-effort startup repair for usage-owned Claude statusLine hooks."""
|
|
1065
|
+
try:
|
|
1066
|
+
settings = _load_settings()
|
|
1067
|
+
state = _detect_current_state(settings)
|
|
1068
|
+
if state in {"external", "legacy-tt"}:
|
|
1069
|
+
return
|
|
1070
|
+
_migrate_bundled_python_commands_if_needed(settings)
|
|
1071
|
+
setup_hook._migrate_windows_statusline_command_if_needed(settings)
|
|
1072
|
+
if not is_setup() and "statusLine" not in settings:
|
|
1073
|
+
exit_code = _run_quietly(setup)
|
|
1074
|
+
if exit_code == 0:
|
|
1075
|
+
_append_self_heal_log("install_hook", "initial setup")
|
|
1076
|
+
return
|
|
1077
|
+
except BaseException as exc:
|
|
1078
|
+
if isinstance(exc, KeyboardInterrupt):
|
|
1079
|
+
raise
|
|
1080
|
+
_debug_self_heal_failure("install_hook", exc)
|
|
1081
|
+
|
|
1082
|
+
try:
|
|
1083
|
+
state = _detect_current_state()
|
|
1084
|
+
if state in {"external", "legacy-tt"}:
|
|
1085
|
+
return
|
|
1086
|
+
old_version = _installed_hook_version()
|
|
1087
|
+
if needs_update():
|
|
1088
|
+
_run_quietly(update_hook)
|
|
1089
|
+
detail = f"{old_version or 'not installed'} -> {HOOK_VERSION}"
|
|
1090
|
+
_append_self_heal_log("update_hook", detail)
|
|
1091
|
+
except BaseException as exc:
|
|
1092
|
+
if isinstance(exc, KeyboardInterrupt):
|
|
1093
|
+
raise
|
|
1094
|
+
_debug_self_heal_failure("update_hook", exc)
|
|
1095
|
+
|
|
1096
|
+
try:
|
|
1097
|
+
state = _detect_current_state()
|
|
1098
|
+
if state in {"external", "legacy-tt"}:
|
|
1099
|
+
return
|
|
1100
|
+
if not _statusline_command_target_exists() and state in {"us-direct", "us-forwarder"}:
|
|
1101
|
+
_copy_hook_script()
|
|
1102
|
+
_copy_forwarder_script()
|
|
1103
|
+
_append_self_heal_log("restore_hook_scripts", "statusLine command target missing")
|
|
1104
|
+
except BaseException as exc:
|
|
1105
|
+
if isinstance(exc, KeyboardInterrupt):
|
|
1106
|
+
raise
|
|
1107
|
+
_debug_self_heal_failure("restore_hook_scripts", exc)
|
|
1108
|
+
|
|
1109
|
+
try:
|
|
1110
|
+
import statusline_settings
|
|
1111
|
+
|
|
1112
|
+
if (
|
|
1113
|
+
setup_hook.AGY_SETTINGS.is_file()
|
|
1114
|
+
and statusline_settings._statusline_enabled()
|
|
1115
|
+
and (not setup_hook.is_agy_setup() or setup_hook._agy_hook_script_is_stale())
|
|
1116
|
+
and setup_hook._setup_agy()
|
|
1117
|
+
):
|
|
1118
|
+
_append_self_heal_log("setup_agy", "installed or updated Antigravity status line")
|
|
1119
|
+
except BaseException as exc:
|
|
1120
|
+
if isinstance(exc, KeyboardInterrupt):
|
|
1121
|
+
raise
|
|
1122
|
+
_debug_self_heal_failure("setup_agy", exc)
|
|
1123
|
+
|
|
1124
|
+
try:
|
|
1125
|
+
result = _read_codex_config() if setup_hook.CODEX_CONFIG.is_file() else None
|
|
1126
|
+
if (
|
|
1127
|
+
result is not None
|
|
1128
|
+
and setup_hook._codex_status_line(result[1]) in setup_hook.LEGACY_CODEX_STATUS_LINES
|
|
1129
|
+
):
|
|
1130
|
+
setup_hook._setup_codex()
|
|
1131
|
+
result = _read_codex_config()
|
|
1132
|
+
if (
|
|
1133
|
+
result is not None
|
|
1134
|
+
and setup_hook._codex_status_line(result[1]) == setup_hook.CODEX_STATUS_LINE
|
|
1135
|
+
):
|
|
1136
|
+
_append_self_heal_log("setup_codex", "upgraded Codex status line segments")
|
|
1137
|
+
except BaseException as exc:
|
|
1138
|
+
if isinstance(exc, KeyboardInterrupt):
|
|
1139
|
+
raise
|
|
1140
|
+
_debug_self_heal_failure("setup_codex", exc)
|
|
1141
|
+
|
|
1142
|
+
try:
|
|
1143
|
+
_self_heal_resume()
|
|
1144
|
+
except BaseException as exc:
|
|
1145
|
+
if isinstance(exc, KeyboardInterrupt):
|
|
1146
|
+
raise
|
|
1147
|
+
_debug_self_heal_failure("resume_hook", exc)
|
|
1148
|
+
|
|
1149
|
+
try:
|
|
1150
|
+
_self_heal_terse_mode()
|
|
1151
|
+
except BaseException as exc:
|
|
1152
|
+
if isinstance(exc, KeyboardInterrupt):
|
|
1153
|
+
raise
|
|
1154
|
+
_debug_self_heal_failure("terse_hook", exc)
|