cc-continuity 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/PKG-INFO +1 -1
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/__init__.py +1 -1
- cc_continuity-0.3.0/cc_continuity/blink.py +278 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/config.py +35 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/progress_pane.py +41 -2
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/statusline.py +24 -8
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity.egg-info/PKG-INFO +1 -1
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity.egg-info/SOURCES.txt +2 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/pyproject.toml +1 -1
- cc_continuity-0.3.0/tests/test_blink.py +349 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_config.py +15 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_progress_pane.py +97 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_statusline.py +43 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/LICENSE +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/README.md +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/__main__.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/atomic.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/bootstrap.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/checkpoint.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/cli.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/daemon_supervisor.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/doctor.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/gitsnap.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/hooks/__init__.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/hooks/common.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/hooks/pre_compact.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/hooks/pre_tool_use.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/hooks/session_end.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/hooks/session_start.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/hooks/stop.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/hooks/task.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/hooks/tool_use.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/hooks/user_prompt.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/hosting.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/install.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/launch.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/ledger.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/lock.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/managed.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/naming.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/notify.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/paths.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/power_plug.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/project.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/redact.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/rotator.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/settings_merge.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/skills.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/state.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/thresholds.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/transcript.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/validate.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/watchdog.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/watchdog_digest.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/watchdogd.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity.egg-info/dependency_links.txt +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity.egg-info/entry_points.txt +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity.egg-info/top_level.txt +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/setup.cfg +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_atomic.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_bootstrap.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_checkpoint.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_cli.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_daemon_supervisor.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_doctor.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_gitsnap.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_hooks_common.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_hooks_pre_compact.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_hooks_pre_tool_use.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_hooks_session_end.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_hooks_session_start.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_hooks_stop.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_hooks_task.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_hooks_tool_use.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_hooks_user_prompt.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_hosting.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_hygiene.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_install.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_integration_phase5.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_integration_phase6.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_integration_phase7.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_launch.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_ledger.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_lock.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_managed.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_naming.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_notify.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_notify_integration.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_paths.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_power_plug.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_project.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_redact.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_rotator.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_settings_merge.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_skills.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_state.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_status.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_thresholds.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_tmux_host.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_transcript.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_transcript_finalize.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_uninstall.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_validate.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_watchdog.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_watchdog_digest.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_watchdog_integration.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_watchdogd.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_wsl_doctor.py +0 -0
- {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_wsl_install.py +0 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Waiting-blink primitives (issue #11).
|
|
2
|
+
|
|
3
|
+
Thin, self-contained layer: the OSC 6 tab-color escape sequences (spike
|
|
4
|
+
proven on iTerm2 3.7.6 and Terminal.app), the gate markers the blink
|
|
5
|
+
reacts to, and the tmux/tty helpers the ``Blinker`` state machine uses.
|
|
6
|
+
|
|
7
|
+
Import direction rule: ``progress_pane`` and ``statusline`` import from
|
|
8
|
+
here; this module only leans on leaf modules (``config``, ``paths``) and
|
|
9
|
+
never on ``progress_pane`` or ``statusline``, so a blink failure can
|
|
10
|
+
never drag the animator down.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import subprocess
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
from . import config, paths
|
|
17
|
+
|
|
18
|
+
OSC6_RESET = b"\x1b]6;1;bg;*;default\x07"
|
|
19
|
+
|
|
20
|
+
RED_FILL = (235, 20, 20)
|
|
21
|
+
BLACK_FILL = (0, 0, 0)
|
|
22
|
+
|
|
23
|
+
# Single source of truth for the gate markers. statusline.py composes its
|
|
24
|
+
# suffixes from these constants (composing its own leading spaces) so the
|
|
25
|
+
# writer and the gate can never drift apart silently.
|
|
26
|
+
MARKER_BLOCKED = "⛔ BLOCKED"
|
|
27
|
+
MARKER_ASK = "⚠ ASK"
|
|
28
|
+
GATE_MARKERS = (MARKER_BLOCKED, MARKER_ASK)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def osc6_fill(rgb: tuple[int, int, int]) -> bytes:
|
|
32
|
+
"""One write carrying the three OSC 6 background-channel fills.
|
|
33
|
+
|
|
34
|
+
``ESC ] 6 ; 1 ; bg ; <chan> ; brightness ; N BEL`` for red, green and
|
|
35
|
+
blue in order (byte order pinned by tests; spike-proven format).
|
|
36
|
+
"""
|
|
37
|
+
red, green, blue = rgb
|
|
38
|
+
out = bytearray()
|
|
39
|
+
for chan, val in (("red", red), ("green", green), ("blue", blue)):
|
|
40
|
+
out += (b"\x1b]6;1;bg;" + chan.encode("ascii")
|
|
41
|
+
+ b";brightness;" + str(int(val)).encode("ascii") + b"\x07")
|
|
42
|
+
return bytes(out)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def blink_gate(text: str | None) -> bool:
|
|
46
|
+
"""True when the per-window progress text carries a listed gate marker."""
|
|
47
|
+
if not text:
|
|
48
|
+
return False
|
|
49
|
+
return any(marker in text for marker in GATE_MARKERS)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _tmux(runner, bin_: str, argv_tail: list, timeout: float = 2.0):
|
|
53
|
+
"""Run one tmux command via ``runner``; stripped stdout or None on failure."""
|
|
54
|
+
try:
|
|
55
|
+
proc = runner([bin_, *argv_tail], capture_output=True, timeout=timeout)
|
|
56
|
+
except Exception:
|
|
57
|
+
return None
|
|
58
|
+
if proc.returncode != 0:
|
|
59
|
+
return None
|
|
60
|
+
return proc.stdout.decode("utf-8", "replace").strip()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def list_client_ttys(runner, bin_: str, target: str) -> list[str]:
|
|
64
|
+
"""ttys of the clients attached to ``target`` ([] when none/unreachable).
|
|
65
|
+
|
|
66
|
+
tmux prints the literal ``not a tty`` for clients without a tty; those
|
|
67
|
+
lines (and blanks) are dropped. One tty per line, never whitespace
|
|
68
|
+
split inside a path.
|
|
69
|
+
"""
|
|
70
|
+
out = _tmux(runner, bin_, ["list-clients", "-t", target,
|
|
71
|
+
"-F", "#{client_tty}"])
|
|
72
|
+
if out is None:
|
|
73
|
+
return []
|
|
74
|
+
return [line.strip() for line in out.splitlines()
|
|
75
|
+
if line.strip() and line.strip() != "not a tty"]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_window_name(runner, bin_: str, target: str) -> str | None:
|
|
79
|
+
"""Current window name for ``target``, None when unreadable."""
|
|
80
|
+
return _tmux(runner, bin_, ["display-message", "-p", "-t", target,
|
|
81
|
+
"#{window_name}"])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def rename_window(runner, bin_: str, target: str, name: str) -> bool:
|
|
85
|
+
"""Rename the window at ``target``; False on any tmux failure."""
|
|
86
|
+
return _tmux(runner, bin_, ["rename-window", "-t", target, name]) is not None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def write_tty(path: str, data: bytes, *, _open=open) -> bool:
|
|
90
|
+
"""Write raw bytes to a client tty; False (never raise) on OSError.
|
|
91
|
+
|
|
92
|
+
tmux never sees these bytes: the OSC sequences bypass the pane and go
|
|
93
|
+
straight to the terminal emulator on the client's own tty.
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
fh = _open(path, "wb")
|
|
97
|
+
except OSError:
|
|
98
|
+
return False
|
|
99
|
+
try:
|
|
100
|
+
fh.write(data)
|
|
101
|
+
return True
|
|
102
|
+
except OSError:
|
|
103
|
+
return False
|
|
104
|
+
finally:
|
|
105
|
+
try:
|
|
106
|
+
fh.close()
|
|
107
|
+
except OSError:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _debug_line(msg: str) -> None:
|
|
112
|
+
"""Append one diagnostic line to the blink log (never raises).
|
|
113
|
+
|
|
114
|
+
The package has no stdlib logging module; the rotator/watchdogd
|
|
115
|
+
file-line pattern is its convention, and the pane stdout is occupied
|
|
116
|
+
by animation frames. One file keeps a non-blinking tab diagnosable.
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
log_path = paths.logs_dir() / "blink.log"
|
|
120
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
with open(log_path, "a", encoding="utf-8") as fh:
|
|
122
|
+
fh.write(f"{time.strftime('%Y-%m-%dT%H:%M:%S')} {msg}\n")
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Blinker:
|
|
128
|
+
"""Waiting-blink state machine driven by the animator's frame tick.
|
|
129
|
+
|
|
130
|
+
``on_text(text, now)`` arrives every animator frame with the current
|
|
131
|
+
progress text and clock; ``on_exit()`` runs when the animator
|
|
132
|
+
returns. The clock rides in on the call so tests fake it per tick.
|
|
133
|
+
Every tmux/tty path swallows failures: the blink is decoration and
|
|
134
|
+
must never break the progress animation.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(self, window_id: str, cfg: dict, *, store,
|
|
138
|
+
runner=subprocess.run, bin_: str = "tmux", tty_open=open,
|
|
139
|
+
log=None):
|
|
140
|
+
self.window_id = window_id
|
|
141
|
+
self._store = store
|
|
142
|
+
self._runner = runner
|
|
143
|
+
self._bin_ = bin_
|
|
144
|
+
self._tty_open = tty_open
|
|
145
|
+
self._log = log if log is not None else _debug_line
|
|
146
|
+
self.enabled = config.blink_enabled(cfg)
|
|
147
|
+
self._interval = config.blink_interval(cfg)
|
|
148
|
+
self._fill = osc6_fill(
|
|
149
|
+
RED_FILL if config.blink_no_color(cfg) else BLACK_FILL)
|
|
150
|
+
self._blinking = False
|
|
151
|
+
self._phase_own = True # True: own color + original name showing
|
|
152
|
+
self._last_toggle = 0.0
|
|
153
|
+
self._original = None
|
|
154
|
+
self._ttys: list[str] = []
|
|
155
|
+
self._restored_logged = False
|
|
156
|
+
# the sweep is deliberately NOT gated on enabled: a blink stranded
|
|
157
|
+
# by a crash must heal even if blink was turned off since. With an
|
|
158
|
+
# empty store it is a zero-call no-op, so "disabled restores
|
|
159
|
+
# today's behavior exactly" still holds.
|
|
160
|
+
self._startup_sweep()
|
|
161
|
+
|
|
162
|
+
# -- internals ------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def _startup_sweep(self) -> None:
|
|
165
|
+
"""Self-heal a blink stranded by a crashed animator (once per start)."""
|
|
166
|
+
try:
|
|
167
|
+
stored = self._store("blink_name")
|
|
168
|
+
if not stored:
|
|
169
|
+
return
|
|
170
|
+
current = get_window_name(self._runner, self._bin_, self.window_id)
|
|
171
|
+
if current and current.startswith("WAIT"):
|
|
172
|
+
restored = rename_window(self._runner, self._bin_,
|
|
173
|
+
self.window_id, stored)
|
|
174
|
+
for tty in list_client_ttys(self._runner, self._bin_,
|
|
175
|
+
self.window_id):
|
|
176
|
+
write_tty(tty, OSC6_RESET, _open=self._tty_open)
|
|
177
|
+
if restored:
|
|
178
|
+
# keep the stored name on failure: the next animator
|
|
179
|
+
# start sweeps again
|
|
180
|
+
self._store("blink_name", None)
|
|
181
|
+
self._log(f"blink sweep window={self.window_id} "
|
|
182
|
+
f"restored stranded '{current}'")
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
def _start(self, now: float) -> None:
|
|
187
|
+
name = get_window_name(self._runner, self._bin_, self.window_id)
|
|
188
|
+
if name is None:
|
|
189
|
+
self._log(f"blink skip window={self.window_id} "
|
|
190
|
+
f"reason=window name unreadable")
|
|
191
|
+
return
|
|
192
|
+
self._original = name
|
|
193
|
+
self._store("blink_name", name)
|
|
194
|
+
# Resolved per blink START (never cached across blinks): clients
|
|
195
|
+
# attach and detach between waits.
|
|
196
|
+
self._ttys = list_client_ttys(self._runner, self._bin_,
|
|
197
|
+
self.window_id)
|
|
198
|
+
self._blinking = True
|
|
199
|
+
self._phase_own = True
|
|
200
|
+
self._last_toggle = now
|
|
201
|
+
self._restored_logged = False
|
|
202
|
+
if self._ttys:
|
|
203
|
+
self._log(f"blink start window={self.window_id} "
|
|
204
|
+
f"ttys={len(self._ttys)}")
|
|
205
|
+
else:
|
|
206
|
+
self._log(f"blink skip window={self.window_id} "
|
|
207
|
+
f"reason=no attached clients")
|
|
208
|
+
|
|
209
|
+
def _wait_name(self, text: str | None) -> str:
|
|
210
|
+
n = sum(text.count(m) for m in GATE_MARKERS) if text else 0
|
|
211
|
+
return "WAIT" if n <= 1 else f"WAIT[{n}]"
|
|
212
|
+
|
|
213
|
+
def _apply(self, text: str | None) -> None:
|
|
214
|
+
if self._phase_own:
|
|
215
|
+
rename_window(self._runner, self._bin_, self.window_id,
|
|
216
|
+
self._original)
|
|
217
|
+
for tty in self._ttys:
|
|
218
|
+
write_tty(tty, OSC6_RESET, _open=self._tty_open)
|
|
219
|
+
else:
|
|
220
|
+
rename_window(self._runner, self._bin_, self.window_id,
|
|
221
|
+
self._wait_name(text))
|
|
222
|
+
for tty in self._ttys:
|
|
223
|
+
write_tty(tty, self._fill, _open=self._tty_open)
|
|
224
|
+
|
|
225
|
+
def _restore(self) -> None:
|
|
226
|
+
if self._original is not None:
|
|
227
|
+
restored = rename_window(self._runner, self._bin_,
|
|
228
|
+
self.window_id, self._original)
|
|
229
|
+
for tty in self._ttys:
|
|
230
|
+
write_tty(tty, OSC6_RESET, _open=self._tty_open)
|
|
231
|
+
if restored:
|
|
232
|
+
# Ownership released: a window rename the user makes
|
|
233
|
+
# afterwards must survive animator exit. A FAILED rename
|
|
234
|
+
# keeps both the name and the stored value: the store is
|
|
235
|
+
# the only handle the startup sweep needs to heal the
|
|
236
|
+
# stranded WAIT later.
|
|
237
|
+
self._store("blink_name", None)
|
|
238
|
+
self._original = None
|
|
239
|
+
if not self._restored_logged:
|
|
240
|
+
self._log(f"blink restore window={self.window_id}")
|
|
241
|
+
self._restored_logged = True
|
|
242
|
+
self._blinking = False
|
|
243
|
+
|
|
244
|
+
# -- animator surface -------------------------------------------------
|
|
245
|
+
|
|
246
|
+
def on_text(self, text: str | None, now: float) -> None:
|
|
247
|
+
"""One animator tick: gate check, phase flip on the interval."""
|
|
248
|
+
if not self.enabled:
|
|
249
|
+
return
|
|
250
|
+
try:
|
|
251
|
+
gated = blink_gate(text)
|
|
252
|
+
if gated and not self._blinking:
|
|
253
|
+
self._start(now)
|
|
254
|
+
return
|
|
255
|
+
if not self._blinking:
|
|
256
|
+
return
|
|
257
|
+
if not gated:
|
|
258
|
+
self._restore()
|
|
259
|
+
return
|
|
260
|
+
if now - self._last_toggle >= self._interval:
|
|
261
|
+
self._last_toggle = now
|
|
262
|
+
self._phase_own = not self._phase_own
|
|
263
|
+
self._apply(text)
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
def on_exit(self) -> None:
|
|
268
|
+
"""Animator is leaving: restore name and tab color (idempotent)."""
|
|
269
|
+
if not self.enabled:
|
|
270
|
+
return
|
|
271
|
+
try:
|
|
272
|
+
if self._blinking or self._original is not None:
|
|
273
|
+
# covers the mid-blink exit AND the retry of a falling-edge
|
|
274
|
+
# rename that tmux refused; _restore releases ownership on
|
|
275
|
+
# success, so repeat calls are no-ops
|
|
276
|
+
self._restore()
|
|
277
|
+
except Exception:
|
|
278
|
+
pass
|
|
@@ -224,6 +224,41 @@ def progress_mode(cfg: dict) -> int:
|
|
|
224
224
|
return v if v in (0, 1, 2) else 0
|
|
225
225
|
|
|
226
226
|
|
|
227
|
+
def blink_enabled(cfg: dict) -> bool:
|
|
228
|
+
"""Whether the waiting blink is on (issue #11).
|
|
229
|
+
|
|
230
|
+
``blink``: explicit false disables the tab/window blink entirely and
|
|
231
|
+
restores today's behavior. A missing key or any non-bool value
|
|
232
|
+
degrades to True (on whenever the tmux pane animator runs).
|
|
233
|
+
"""
|
|
234
|
+
v = cfg.get("blink", True)
|
|
235
|
+
return v if isinstance(v, bool) else True
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def blink_interval(cfg: dict) -> float:
|
|
239
|
+
"""Seconds per blink phase (issue #11).
|
|
240
|
+
|
|
241
|
+
``blink_interval``: default 0.5 s. Values are clamped to [0.1, 5.0].
|
|
242
|
+
A missing key or any invalid value (non-numeric, bool) degrades to
|
|
243
|
+
0.5, so a bad edit can never freeze or hammer the animator.
|
|
244
|
+
"""
|
|
245
|
+
v = cfg.get("blink_interval", 0.5)
|
|
246
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
|
247
|
+
return 0.5
|
|
248
|
+
return min(max(float(v), 0.1), 5.0)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def blink_no_color(cfg: dict) -> bool:
|
|
252
|
+
"""Whether the blink uses the red fallback (issue #11).
|
|
253
|
+
|
|
254
|
+
``blink_no_color``: true switches the OFF phase from black to red
|
|
255
|
+
for profiles without a tab color of their own. A missing key or any
|
|
256
|
+
non-bool value degrades to False (black OFF phase).
|
|
257
|
+
"""
|
|
258
|
+
v = cfg.get("blink_no_color", False)
|
|
259
|
+
return v if isinstance(v, bool) else False
|
|
260
|
+
|
|
261
|
+
|
|
227
262
|
def session_end_push_suppress(cfg: dict) -> frozenset:
|
|
228
263
|
"""Validated Phase 30.1 declared-noise project keys.
|
|
229
264
|
|
|
@@ -38,7 +38,7 @@ import shutil
|
|
|
38
38
|
import subprocess
|
|
39
39
|
import time
|
|
40
40
|
|
|
41
|
-
from . import atomic, paths
|
|
41
|
+
from . import atomic, blink, config, paths
|
|
42
42
|
from .statusline import _RAINBOW, _SHIMMER
|
|
43
43
|
|
|
44
44
|
#: Window ids are tmux-generated (``@12``); restrict what we splice into
|
|
@@ -430,10 +430,14 @@ def _window_pane_count(window_id: str | None) -> int | None:
|
|
|
430
430
|
# --------------------------------------------------------------- run_animator
|
|
431
431
|
|
|
432
432
|
|
|
433
|
+
_BLINK_MISSING = object()
|
|
434
|
+
|
|
435
|
+
|
|
433
436
|
def run_animator(window_id: str | None = None, *, _frames=None,
|
|
434
437
|
_write=None, _now=None, _size=None, _text=None,
|
|
435
438
|
_mtime=None, _sleep=None, _live_check=None,
|
|
436
|
-
_check_every=40
|
|
439
|
+
_check_every=40, _blink=None, _tty_open=None,
|
|
440
|
+
_runner=None) -> None:
|
|
437
441
|
"""Animate the progress line at ~20 fps until any I/O fails.
|
|
438
442
|
|
|
439
443
|
Each frame is ``\\x1b[H\\x1b[2K`` + a full-width ``_frame``
|
|
@@ -472,6 +476,34 @@ def run_animator(window_id: str | None = None, *, _frames=None,
|
|
|
472
476
|
else (lambda: _progress_data_mtime(window_id)))
|
|
473
477
|
live_check = (_live_check if _live_check is not None
|
|
474
478
|
else (lambda: _window_pane_count(window_id)))
|
|
479
|
+
# Waiting blink (issue #11): one tick-driven state machine beside the
|
|
480
|
+
# shimmer. It swallows every failure internally; a broken tmux/tty
|
|
481
|
+
# path must never take the progress animation down with it.
|
|
482
|
+
blink_mod = _blink if _blink is not None else blink
|
|
483
|
+
try:
|
|
484
|
+
cfg = config.load_config()
|
|
485
|
+
except Exception:
|
|
486
|
+
cfg = {"blink": False}
|
|
487
|
+
|
|
488
|
+
def blink_store(key, value=_BLINK_MISSING):
|
|
489
|
+
try:
|
|
490
|
+
rec = _read_pane_record()
|
|
491
|
+
full = f"blink-{window_id}-{key}"
|
|
492
|
+
if value is _BLINK_MISSING:
|
|
493
|
+
return rec.get(full)
|
|
494
|
+
if value is None:
|
|
495
|
+
rec.pop(full, None)
|
|
496
|
+
else:
|
|
497
|
+
rec[full] = value
|
|
498
|
+
_write_pane_record(rec)
|
|
499
|
+
except Exception:
|
|
500
|
+
return None
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
blinker = blink_mod.Blinker(
|
|
504
|
+
window_id or "", cfg, store=blink_store,
|
|
505
|
+
runner=(_runner if _runner is not None else subprocess.run),
|
|
506
|
+
tty_open=(_tty_open if _tty_open is not None else open))
|
|
475
507
|
try:
|
|
476
508
|
cur_text = text_fn()
|
|
477
509
|
cur_mtime = mtime_fn()
|
|
@@ -504,6 +536,9 @@ def run_animator(window_id: str | None = None, *, _frames=None,
|
|
|
504
536
|
cur_text = text_fn()
|
|
505
537
|
cur_mtime = mt
|
|
506
538
|
t = now()
|
|
539
|
+
# outside the skipped guard: a skipped paint must not stall
|
|
540
|
+
# the blink phase clock.
|
|
541
|
+
blinker.on_text(cur_text, t)
|
|
507
542
|
if not skipped:
|
|
508
543
|
# \x1b[H homes to row 1 col 1 every frame: \r alone can't
|
|
509
544
|
# recover a pty cursor that a resize-race wrap dropped to
|
|
@@ -532,3 +567,7 @@ def run_animator(window_id: str | None = None, *, _frames=None,
|
|
|
532
567
|
sleep(max(0.0, 0.05 - (t2 - t)))
|
|
533
568
|
except Exception:
|
|
534
569
|
return
|
|
570
|
+
finally:
|
|
571
|
+
# every exit path (frame bound, orphan guard, any I/O failure)
|
|
572
|
+
# restores the window name and tab color.
|
|
573
|
+
blinker.on_exit()
|
|
@@ -30,7 +30,7 @@ import subprocess
|
|
|
30
30
|
import time
|
|
31
31
|
from pathlib import Path
|
|
32
32
|
|
|
33
|
-
from . import atomic, config, ledger, naming, paths
|
|
33
|
+
from . import atomic, blink, config, ledger, naming, paths
|
|
34
34
|
|
|
35
35
|
#: Hooks treat a metric older than this as unknown (PRD §7, METRIC_STALE).
|
|
36
36
|
STALE_SECONDS = 600
|
|
@@ -382,7 +382,7 @@ def render_segment(ratio: float | None, record: dict | None,
|
|
|
382
382
|
now: float | None = None,
|
|
383
383
|
shimmer: bool = False,
|
|
384
384
|
shimmer_pos: float | None = None,
|
|
385
|
-
breathing: bool = False) -> str:
|
|
385
|
+
breathing: bool = False, ask: bool = False) -> str:
|
|
386
386
|
"""Render our ``◆ …`` segment (PRD §7; Phase 11 styling, P11-D2/D3/D4).
|
|
387
387
|
|
|
388
388
|
Forms: ``◆ off`` (disabled / session off), ``◆ …`` (no metric),
|
|
@@ -394,6 +394,9 @@ def render_segment(ratio: float | None, record: dict | None,
|
|
|
394
394
|
``⛔ BLOCKED`` suffix (Phase 9, P9-P5): machine-side truth while a
|
|
395
395
|
needs_human ledger entry is unconsumed — visible on any later attach
|
|
396
396
|
(spec §7 risk note).
|
|
397
|
+
``⚠ ASK`` suffix (issue #11): an unsubmitted permission prompt sits in
|
|
398
|
+
the captured input box (same live gate as the ultrathink word); the
|
|
399
|
+
blink gate in ``blink.py`` reacts to both suffixes (shared constants).
|
|
397
400
|
Phase 11 (revision 4): with ``ansi`` (default, config
|
|
398
401
|
``statusline_ansi``) live segments render the ultrathink.sh
|
|
399
402
|
per-character 24-bit rainbow (7 base hues cycled ``i % 7`` —
|
|
@@ -407,7 +410,10 @@ def render_segment(ratio: float | None, record: dict | None,
|
|
|
407
410
|
legacy plain strings byte-for-byte.
|
|
408
411
|
"""
|
|
409
412
|
now = time.time() if now is None else now
|
|
410
|
-
|
|
413
|
+
# Suffix order BLOCKED then ASK (both must survive substring matching
|
|
414
|
+
# in blink.blink_gate; the constants are blink's, never copied here).
|
|
415
|
+
blocked_text = ((f" {blink.MARKER_BLOCKED}" if blocked else "")
|
|
416
|
+
+ (f" {blink.MARKER_ASK}" if ask else "")) or None
|
|
411
417
|
if not ansi:
|
|
412
418
|
if not enabled or (record is not None and record.get("off")):
|
|
413
419
|
return "◆ off"
|
|
@@ -461,6 +467,10 @@ def render_segment(ratio: float | None, record: dict | None,
|
|
|
461
467
|
#: Word-boundary, case-insensitive — ultrathink.sh's ``/\bultrathink\b/gi``.
|
|
462
468
|
ULTRATHINK_RE = re.compile(r"\bultrathink\b", re.IGNORECASE)
|
|
463
469
|
|
|
470
|
+
# Permission-prompt wording (issue #11): exact phrase only, so ordinary
|
|
471
|
+
# chat containing "allow"/"yes" can never strand the ASK marker.
|
|
472
|
+
_ASK_RE = re.compile(r"Do you want to allow", re.IGNORECASE)
|
|
473
|
+
|
|
464
474
|
#: CC's input box bottom border is a line of these (verified live, 014).
|
|
465
475
|
_INPUT_BORDER = "─"
|
|
466
476
|
|
|
@@ -666,6 +676,9 @@ def run(stdin_bytes: bytes, cfg: dict) -> str:
|
|
|
666
676
|
region = _input_box_lines() if (ansi and live) else None
|
|
667
677
|
word = bool(region) and bool(
|
|
668
678
|
ULTRATHINK_RE.search("\n".join(region)))
|
|
679
|
+
# issue #11: same live-capture gate as the ultrathink word: a dead
|
|
680
|
+
# pane must not strand an ASK marker.
|
|
681
|
+
ask = bool(region) and bool(_ASK_RE.search("\n".join(region)))
|
|
669
682
|
mode = config.progress_mode(cfg)
|
|
670
683
|
if mode == 1:
|
|
671
684
|
# Phase 12 fix (user bug A): in mode 1 the PANE is the ONLY
|
|
@@ -704,10 +717,12 @@ def run(stdin_bytes: bytes, cfg: dict) -> str:
|
|
|
704
717
|
# rewritten by a no-metric render (no downgrade, no re-flash).
|
|
705
718
|
if ratio is not None:
|
|
706
719
|
progress_pane.write_progress_data(win, render_segment(
|
|
707
|
-
ratio, record, enabled, blocked=blocked, ansi=False
|
|
720
|
+
ratio, record, enabled, blocked=blocked, ansi=False,
|
|
721
|
+
ask=ask))
|
|
708
722
|
elif enabled and not progress_pane.has_progress_data(win):
|
|
709
723
|
progress_pane.write_progress_data(win, render_segment(
|
|
710
|
-
0.0, record, enabled, blocked=blocked, ansi=False
|
|
724
|
+
0.0, record, enabled, blocked=blocked, ansi=False,
|
|
725
|
+
ask=ask))
|
|
711
726
|
segment = ""
|
|
712
727
|
elif (isinstance(result, dict)
|
|
713
728
|
and result.get("reason") in ("no-window", "error")):
|
|
@@ -721,14 +736,15 @@ def run(stdin_bytes: bytes, cfg: dict) -> str:
|
|
|
721
736
|
else:
|
|
722
737
|
segment = render_segment(ratio, record, enabled,
|
|
723
738
|
blocked=blocked, ansi=ansi, now=now,
|
|
724
|
-
shimmer=False)
|
|
739
|
+
shimmer=False, ask=ask)
|
|
725
740
|
elif mode == 2:
|
|
726
741
|
# Phase 12: breathing — the whole segment brightens and dims in
|
|
727
742
|
# a 2 s cycle (factor 1.0 == today's rainbow byte-for-byte). No
|
|
728
743
|
# sweep, so no shimmer-state write.
|
|
729
744
|
sweep_pos = None
|
|
730
745
|
segment = render_segment(ratio, record, enabled, blocked=blocked,
|
|
731
|
-
ansi=ansi, now=now, breathing=True
|
|
746
|
+
ansi=ansi, now=now, breathing=True,
|
|
747
|
+
ask=ask)
|
|
732
748
|
else:
|
|
733
749
|
# mode 0 (default): byte-identical to today. Revision 10:
|
|
734
750
|
# constant-speed sweep — the band advances SHIMMER_CELLS_PER_S
|
|
@@ -742,7 +758,7 @@ def run(stdin_bytes: bytes, cfg: dict) -> str:
|
|
|
742
758
|
_write_shimmer_state(now, sweep_pos)
|
|
743
759
|
segment = render_segment(ratio, record, enabled, blocked=blocked,
|
|
744
760
|
ansi=ansi, now=now, shimmer=shimmer,
|
|
745
|
-
shimmer_pos=sweep_pos)
|
|
761
|
+
shimmer_pos=sweep_pos, ask=ask)
|
|
746
762
|
_render_log(enabled, ansi, live, word, now, region, segment,
|
|
747
763
|
sweep_pos)
|
|
748
764
|
chained = chain(cfg.get("chained_statusline"), stdin_bytes)
|
|
@@ -4,6 +4,7 @@ pyproject.toml
|
|
|
4
4
|
cc_continuity/__init__.py
|
|
5
5
|
cc_continuity/__main__.py
|
|
6
6
|
cc_continuity/atomic.py
|
|
7
|
+
cc_continuity/blink.py
|
|
7
8
|
cc_continuity/bootstrap.py
|
|
8
9
|
cc_continuity/checkpoint.py
|
|
9
10
|
cc_continuity/cli.py
|
|
@@ -51,6 +52,7 @@ cc_continuity/hooks/task.py
|
|
|
51
52
|
cc_continuity/hooks/tool_use.py
|
|
52
53
|
cc_continuity/hooks/user_prompt.py
|
|
53
54
|
tests/test_atomic.py
|
|
55
|
+
tests/test_blink.py
|
|
54
56
|
tests/test_bootstrap.py
|
|
55
57
|
tests/test_checkpoint.py
|
|
56
58
|
tests/test_cli.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cc-continuity"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Zero-touch session rotation for Claude Code (local large-context model)"
|
|
9
9
|
requires-python = ">=3.11"
|
|
10
10
|
dependencies = [] # stdlib only at runtime (PRD §0.5)
|