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.
Files changed (109) hide show
  1. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/PKG-INFO +1 -1
  2. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/__init__.py +1 -1
  3. cc_continuity-0.3.0/cc_continuity/blink.py +278 -0
  4. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/config.py +35 -0
  5. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/progress_pane.py +41 -2
  6. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/statusline.py +24 -8
  7. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity.egg-info/PKG-INFO +1 -1
  8. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity.egg-info/SOURCES.txt +2 -0
  9. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/pyproject.toml +1 -1
  10. cc_continuity-0.3.0/tests/test_blink.py +349 -0
  11. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_config.py +15 -0
  12. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_progress_pane.py +97 -0
  13. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_statusline.py +43 -0
  14. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/LICENSE +0 -0
  15. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/README.md +0 -0
  16. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/__main__.py +0 -0
  17. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/atomic.py +0 -0
  18. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/bootstrap.py +0 -0
  19. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/checkpoint.py +0 -0
  20. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/cli.py +0 -0
  21. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/daemon_supervisor.py +0 -0
  22. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/doctor.py +0 -0
  23. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/gitsnap.py +0 -0
  24. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/hooks/__init__.py +0 -0
  25. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/hooks/common.py +0 -0
  26. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/hooks/pre_compact.py +0 -0
  27. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/hooks/pre_tool_use.py +0 -0
  28. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/hooks/session_end.py +0 -0
  29. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/hooks/session_start.py +0 -0
  30. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/hooks/stop.py +0 -0
  31. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/hooks/task.py +0 -0
  32. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/hooks/tool_use.py +0 -0
  33. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/hooks/user_prompt.py +0 -0
  34. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/hosting.py +0 -0
  35. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/install.py +0 -0
  36. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/launch.py +0 -0
  37. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/ledger.py +0 -0
  38. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/lock.py +0 -0
  39. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/managed.py +0 -0
  40. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/naming.py +0 -0
  41. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/notify.py +0 -0
  42. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/paths.py +0 -0
  43. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/power_plug.py +0 -0
  44. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/project.py +0 -0
  45. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/redact.py +0 -0
  46. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/rotator.py +0 -0
  47. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/settings_merge.py +0 -0
  48. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/skills.py +0 -0
  49. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/state.py +0 -0
  50. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/thresholds.py +0 -0
  51. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/transcript.py +0 -0
  52. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/validate.py +0 -0
  53. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/watchdog.py +0 -0
  54. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/watchdog_digest.py +0 -0
  55. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity/watchdogd.py +0 -0
  56. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity.egg-info/dependency_links.txt +0 -0
  57. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity.egg-info/entry_points.txt +0 -0
  58. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/cc_continuity.egg-info/top_level.txt +0 -0
  59. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/setup.cfg +0 -0
  60. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_atomic.py +0 -0
  61. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_bootstrap.py +0 -0
  62. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_checkpoint.py +0 -0
  63. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_cli.py +0 -0
  64. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_daemon_supervisor.py +0 -0
  65. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_doctor.py +0 -0
  66. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_gitsnap.py +0 -0
  67. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_hooks_common.py +0 -0
  68. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_hooks_pre_compact.py +0 -0
  69. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_hooks_pre_tool_use.py +0 -0
  70. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_hooks_session_end.py +0 -0
  71. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_hooks_session_start.py +0 -0
  72. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_hooks_stop.py +0 -0
  73. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_hooks_task.py +0 -0
  74. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_hooks_tool_use.py +0 -0
  75. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_hooks_user_prompt.py +0 -0
  76. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_hosting.py +0 -0
  77. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_hygiene.py +0 -0
  78. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_install.py +0 -0
  79. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_integration_phase5.py +0 -0
  80. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_integration_phase6.py +0 -0
  81. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_integration_phase7.py +0 -0
  82. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_launch.py +0 -0
  83. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_ledger.py +0 -0
  84. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_lock.py +0 -0
  85. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_managed.py +0 -0
  86. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_naming.py +0 -0
  87. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_notify.py +0 -0
  88. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_notify_integration.py +0 -0
  89. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_paths.py +0 -0
  90. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_power_plug.py +0 -0
  91. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_project.py +0 -0
  92. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_redact.py +0 -0
  93. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_rotator.py +0 -0
  94. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_settings_merge.py +0 -0
  95. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_skills.py +0 -0
  96. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_state.py +0 -0
  97. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_status.py +0 -0
  98. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_thresholds.py +0 -0
  99. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_tmux_host.py +0 -0
  100. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_transcript.py +0 -0
  101. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_transcript_finalize.py +0 -0
  102. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_uninstall.py +0 -0
  103. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_validate.py +0 -0
  104. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_watchdog.py +0 -0
  105. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_watchdog_digest.py +0 -0
  106. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_watchdog_integration.py +0 -0
  107. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_watchdogd.py +0 -0
  108. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_wsl_doctor.py +0 -0
  109. {cc_continuity-0.2.0 → cc_continuity-0.3.0}/tests/test_wsl_install.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cc-continuity
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Zero-touch session rotation for Claude Code (local large-context model)
5
5
  Requires-Python: >=3.11
6
6
  License-File: LICENSE
@@ -4,4 +4,4 @@ Spec: docs/PRD.md (v1.0). Platform facts verified in docs/VERIFICATION.md.
4
4
  Standard library only at runtime; pytest is dev-only.
5
5
  """
6
6
 
7
- __version__ = "0.2.0"
7
+ __version__ = "0.3.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) -> None:
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
- blocked_text = f" ⛔ BLOCKED" if blocked else None
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cc-continuity
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Zero-touch session rotation for Claude Code (local large-context model)
5
5
  Requires-Python: >=3.11
6
6
  License-File: LICENSE
@@ -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.2.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)