cc-continuity 0.1.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 (97) hide show
  1. cc_continuity-0.1.0/LICENSE +21 -0
  2. cc_continuity-0.1.0/PKG-INFO +7 -0
  3. cc_continuity-0.1.0/README.md +192 -0
  4. cc_continuity-0.1.0/cc_continuity/__init__.py +7 -0
  5. cc_continuity-0.1.0/cc_continuity/__main__.py +8 -0
  6. cc_continuity-0.1.0/cc_continuity/atomic.py +62 -0
  7. cc_continuity-0.1.0/cc_continuity/bootstrap.py +274 -0
  8. cc_continuity-0.1.0/cc_continuity/checkpoint.py +555 -0
  9. cc_continuity-0.1.0/cc_continuity/cli.py +901 -0
  10. cc_continuity-0.1.0/cc_continuity/config.py +258 -0
  11. cc_continuity-0.1.0/cc_continuity/doctor.py +270 -0
  12. cc_continuity-0.1.0/cc_continuity/gitsnap.py +141 -0
  13. cc_continuity-0.1.0/cc_continuity/hooks/__init__.py +17 -0
  14. cc_continuity-0.1.0/cc_continuity/hooks/common.py +159 -0
  15. cc_continuity-0.1.0/cc_continuity/hooks/pre_compact.py +44 -0
  16. cc_continuity-0.1.0/cc_continuity/hooks/pre_tool_use.py +59 -0
  17. cc_continuity-0.1.0/cc_continuity/hooks/session_end.py +174 -0
  18. cc_continuity-0.1.0/cc_continuity/hooks/session_start.py +182 -0
  19. cc_continuity-0.1.0/cc_continuity/hooks/stop.py +155 -0
  20. cc_continuity-0.1.0/cc_continuity/hooks/task.py +33 -0
  21. cc_continuity-0.1.0/cc_continuity/hooks/tool_use.py +192 -0
  22. cc_continuity-0.1.0/cc_continuity/hooks/user_prompt.py +114 -0
  23. cc_continuity-0.1.0/cc_continuity/install.py +479 -0
  24. cc_continuity-0.1.0/cc_continuity/launch.py +197 -0
  25. cc_continuity-0.1.0/cc_continuity/ledger.py +67 -0
  26. cc_continuity-0.1.0/cc_continuity/lock.py +139 -0
  27. cc_continuity-0.1.0/cc_continuity/managed.py +91 -0
  28. cc_continuity-0.1.0/cc_continuity/naming.py +81 -0
  29. cc_continuity-0.1.0/cc_continuity/notify.py +324 -0
  30. cc_continuity-0.1.0/cc_continuity/paths.py +64 -0
  31. cc_continuity-0.1.0/cc_continuity/power_plug.py +404 -0
  32. cc_continuity-0.1.0/cc_continuity/progress_pane.py +484 -0
  33. cc_continuity-0.1.0/cc_continuity/project.py +110 -0
  34. cc_continuity-0.1.0/cc_continuity/redact.py +48 -0
  35. cc_continuity-0.1.0/cc_continuity/rotator.py +476 -0
  36. cc_continuity-0.1.0/cc_continuity/settings_merge.py +164 -0
  37. cc_continuity-0.1.0/cc_continuity/skills.py +94 -0
  38. cc_continuity-0.1.0/cc_continuity/state.py +191 -0
  39. cc_continuity-0.1.0/cc_continuity/statusline.py +722 -0
  40. cc_continuity-0.1.0/cc_continuity/thresholds.py +234 -0
  41. cc_continuity-0.1.0/cc_continuity/validate.py +142 -0
  42. cc_continuity-0.1.0/cc_continuity/watchdog.py +526 -0
  43. cc_continuity-0.1.0/cc_continuity/watchdog_digest.py +550 -0
  44. cc_continuity-0.1.0/cc_continuity/watchdogd.py +1931 -0
  45. cc_continuity-0.1.0/cc_continuity.egg-info/PKG-INFO +7 -0
  46. cc_continuity-0.1.0/cc_continuity.egg-info/SOURCES.txt +95 -0
  47. cc_continuity-0.1.0/cc_continuity.egg-info/dependency_links.txt +1 -0
  48. cc_continuity-0.1.0/cc_continuity.egg-info/entry_points.txt +2 -0
  49. cc_continuity-0.1.0/cc_continuity.egg-info/top_level.txt +1 -0
  50. cc_continuity-0.1.0/pyproject.toml +22 -0
  51. cc_continuity-0.1.0/setup.cfg +4 -0
  52. cc_continuity-0.1.0/tests/test_atomic.py +73 -0
  53. cc_continuity-0.1.0/tests/test_bootstrap.py +359 -0
  54. cc_continuity-0.1.0/tests/test_checkpoint.py +791 -0
  55. cc_continuity-0.1.0/tests/test_cli.py +665 -0
  56. cc_continuity-0.1.0/tests/test_config.py +268 -0
  57. cc_continuity-0.1.0/tests/test_doctor.py +261 -0
  58. cc_continuity-0.1.0/tests/test_gitsnap.py +174 -0
  59. cc_continuity-0.1.0/tests/test_hooks_common.py +242 -0
  60. cc_continuity-0.1.0/tests/test_hooks_pre_compact.py +51 -0
  61. cc_continuity-0.1.0/tests/test_hooks_pre_tool_use.py +125 -0
  62. cc_continuity-0.1.0/tests/test_hooks_session_end.py +433 -0
  63. cc_continuity-0.1.0/tests/test_hooks_session_start.py +569 -0
  64. cc_continuity-0.1.0/tests/test_hooks_stop.py +353 -0
  65. cc_continuity-0.1.0/tests/test_hooks_task.py +100 -0
  66. cc_continuity-0.1.0/tests/test_hooks_tool_use.py +403 -0
  67. cc_continuity-0.1.0/tests/test_hooks_user_prompt.py +214 -0
  68. cc_continuity-0.1.0/tests/test_hygiene.py +75 -0
  69. cc_continuity-0.1.0/tests/test_install.py +330 -0
  70. cc_continuity-0.1.0/tests/test_integration_phase5.py +96 -0
  71. cc_continuity-0.1.0/tests/test_integration_phase6.py +134 -0
  72. cc_continuity-0.1.0/tests/test_integration_phase7.py +166 -0
  73. cc_continuity-0.1.0/tests/test_launch.py +310 -0
  74. cc_continuity-0.1.0/tests/test_ledger.py +83 -0
  75. cc_continuity-0.1.0/tests/test_lock.py +126 -0
  76. cc_continuity-0.1.0/tests/test_managed.py +72 -0
  77. cc_continuity-0.1.0/tests/test_naming.py +96 -0
  78. cc_continuity-0.1.0/tests/test_notify.py +307 -0
  79. cc_continuity-0.1.0/tests/test_notify_integration.py +156 -0
  80. cc_continuity-0.1.0/tests/test_paths.py +41 -0
  81. cc_continuity-0.1.0/tests/test_power_plug.py +521 -0
  82. cc_continuity-0.1.0/tests/test_progress_pane.py +626 -0
  83. cc_continuity-0.1.0/tests/test_project.py +68 -0
  84. cc_continuity-0.1.0/tests/test_redact.py +80 -0
  85. cc_continuity-0.1.0/tests/test_rotator.py +708 -0
  86. cc_continuity-0.1.0/tests/test_settings_merge.py +189 -0
  87. cc_continuity-0.1.0/tests/test_skills.py +69 -0
  88. cc_continuity-0.1.0/tests/test_state.py +278 -0
  89. cc_continuity-0.1.0/tests/test_status.py +187 -0
  90. cc_continuity-0.1.0/tests/test_statusline.py +1175 -0
  91. cc_continuity-0.1.0/tests/test_thresholds.py +217 -0
  92. cc_continuity-0.1.0/tests/test_uninstall.py +281 -0
  93. cc_continuity-0.1.0/tests/test_validate.py +200 -0
  94. cc_continuity-0.1.0/tests/test_watchdog.py +761 -0
  95. cc_continuity-0.1.0/tests/test_watchdog_digest.py +730 -0
  96. cc_continuity-0.1.0/tests/test_watchdog_integration.py +276 -0
  97. cc_continuity-0.1.0/tests/test_watchdogd.py +2710 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eralp Erat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: cc-continuity
3
+ Version: 0.1.0
4
+ Summary: Zero-touch session rotation for Claude Code (local large-context model)
5
+ Requires-Python: >=3.11
6
+ License-File: LICENSE
7
+ Dynamic: license-file
@@ -0,0 +1,192 @@
1
+ # cc-continuity
2
+
3
+ Zero-touch session rotation and supervision for Claude Code.
4
+
5
+ Long Claude Code sessions die when the context window fills up: the model
6
+ forgets the plan and the user re-types it. cc-continuity watches the
7
+ context ratio and, when it crosses a threshold, rotates the session with
8
+ no user action:
9
+
10
+ 1. the agent finishes the current step,
11
+ 2. cc-continuity makes it write a validated handoff,
12
+ 3. the git tree is snapshotted non-invasively (`refs/continuity/*` refs —
13
+ no commits, no tags, no branches touched),
14
+ 4. the context is cleared and the session renamed,
15
+ 5. a continuation prompt is injected and the successor session starts
16
+ with `Continuing from cp-<NNN> (<slug>-<NNN+1>).`
17
+
18
+ An optional launchd **watchdog** keeps long unattended runs recoverable
19
+ when the model server (for example a local vLLM box) dies or freezes, and
20
+ optional **ntfy** pushes let you watch from your phone.
21
+
22
+ ## How it works
23
+
24
+ The whole flow in one picture — the automatic session loop, the optional
25
+ watchdog, and the opt-in notifications:
26
+
27
+ ![cc-continuity flow: session loop, watchdog, notifications](docs/diagrams/flow.en.png)
28
+
29
+ ## Requirements
30
+
31
+ - macOS (v1 — Linux/systemd is a documented seam, not yet implemented)
32
+ - Python ≥ 3.11 (standard library only at runtime)
33
+ - tmux, zsh, curl on PATH
34
+ - Claude Code ≥ 2.1.209
35
+
36
+ ## Quick start
37
+
38
+ From Git:
39
+
40
+ ```bash
41
+ git clone https://github.com/pqant/claude-continuity
42
+ cd claude-continuity
43
+ python3 -m cc_continuity.cli install --window 262144
44
+ exec zsh # reload the shell (claude() wrapper)
45
+ cc-continuity doctor # everything should be green
46
+ ```
47
+
48
+ From PyPI:
49
+
50
+ ```bash
51
+ pip install cc-continuity
52
+ cc-continuity install
53
+ exec zsh
54
+ cc-continuity doctor
55
+ ```
56
+
57
+ The installer touches (all as identifiable managed blocks, all reversible
58
+ via `cc-continuity uninstall`): hooks + status line in
59
+ `~/.claude/settings.json`, a managed block in `~/.claude/CLAUDE.md`, the
60
+ `claude()` wrapper in `~/.zshrc`, six `/continuity-*` skills, the
61
+ `cc-continuity` launcher on PATH, and the app copy in
62
+ `~/.claude-continuity/app/`. A timestamped backup of
63
+ `settings.json` is written before any merge.
64
+
65
+ ## Daily use
66
+
67
+ ```bash
68
+ cd ~/anything
69
+ claude # auto-tmux, session named <project>-001
70
+ ```
71
+
72
+ Watch the status line: the context ratio climbs (default thresholds
73
+ **55% warn → 62% rotate → 72% hard ceiling**). Rotation is zero-touch
74
+ (quick-start list above). After a crash, sleep, or reboot: just run
75
+ `claude` in the project and say "continue".
76
+
77
+ In-session commands: `/continuity-status`, `/continuity-checkpoint`,
78
+ `/continuity-rotate`, `/continuity-off` / `/continuity-on`,
79
+ `/continuity-history`. Full CLI reference: `docs/USAGE.md`.
80
+
81
+ ## Configuration
82
+
83
+ Global: `~/.claude-continuity/config.json` — start from
84
+ `config.example.json` (all keys, placeholder values). Per-project
85
+ override: `.claude/continuity.json` (e.g. `{"enabled": false}` to opt one
86
+ project out). `CC_CONTINUITY=0` disables everything for one launch.
87
+
88
+ ## Notifications (ntfy, opt-in)
89
+
90
+ cc-continuity can push lifecycle events to any ntfy-compatible server, so
91
+ you can watch long unattended runs from your phone:
92
+
93
+ ```bash
94
+ cc-continuity configure --ntfy https://ntfy.sh/your-topic # or at install: --ntfy <url>
95
+ cc-continuity configure --ntfy off
96
+ ```
97
+
98
+ `cc-continuity doctor` sends a test message to verify the endpoint.
99
+
100
+ | Event | Title | Meaning |
101
+ |---|---|---|
102
+ | start | `▶ session started` | a session registered itself (first hook or resume claim) |
103
+ | warn | `⚠ context warning` | ratio crossed the warning threshold |
104
+ | pending | `◆ rotation pending` | ratio crossed the hard threshold; rotates at turn end |
105
+ | verified | `✓ handoff written & verified` | checkpoint finalized, sha256 verified |
106
+ | complete | `🔄 rotation complete` | the successor session is running |
107
+ | resumed | `↻ resumed` | successor consumed the checkpoint |
108
+ | blocked | `🛑 BLOCKED — waiting for you` | agent hit a needs-human point (urgent) |
109
+ | done | `✅ task complete` | agent finished the task you gave it |
110
+ | error | `✗ … failed` | a lifecycle step failed (urgent) |
111
+ | end | `■ session ended` | SessionEnd (rotation, /clear, or close) |
112
+
113
+ **Privacy:** a push is a title + a body of at most 200 characters
114
+ containing only the project slug, session number, context ratio, a short
115
+ reason/summary you control, and the hostname. No file contents, no
116
+ prompts, no secrets — recorded text is secret-redacted on the way out.
117
+ Messages only ever go to the endpoint YOU configure, and only when
118
+ `notify_enabled` is on.
119
+
120
+ ## Unattended mode (opt-in)
121
+
122
+ > **⚠️ Loud warning:** `launch --unattended` (or
123
+ > `config.json: "unattended": true` for a whole project) spawns the
124
+ > session with `claude --dangerously-skip-permissions`: the agent edits
125
+ > files and runs commands **without per-action approval**. Use it only
126
+ > on machines/projects you trust with that. The `needs-human` mechanism
127
+ > (below) is the declared safety valve, and the default mode is
128
+ > unchanged — nothing skips permissions unless you opt in.
129
+
130
+ ```bash
131
+ claude --unattended # via the zsh wrapper (one launch)
132
+ cc-continuity launch --unattended
133
+ cc-continuity configure --unattended # project setting (whole chain)
134
+ ```
135
+
136
+ Because a rotation is a `/clear` inside the same process, one flag (or
137
+ one project setting) carries the entire rotation chain — every successor
138
+ session stays unattended.
139
+
140
+ ## When it blocks on you
141
+
142
+ The agent signals you when it cannot safely continue (a
143
+ destructive/irreversible operation, an ambiguous requirement, the same
144
+ fix failing three times):
145
+
146
+ ```bash
147
+ cc-continuity signal needs-human "<short reason>"
148
+ ```
149
+
150
+ That pushes an **urgent** notification and marks the status line
151
+ `⛔ BLOCKED` (machine-side truth, visible on any later attach). Remote
152
+ in to the pane, reply in the session; the block clears on your next
153
+ prompt. On completion the agent runs:
154
+
155
+ ```bash
156
+ cc-continuity signal done "<one-line summary>"
157
+ ```
158
+
159
+ ## Watchdog (opt-in)
160
+
161
+ A launchd daemon that keeps long runs recoverable: a health probe for
162
+ your model server (2-fail debounce), per-session liveness classification,
163
+ a bounded nudge state machine (the session is told WHY its turn stopped),
164
+ an exit-hang killer, frozen-turn escalation, and optional auto
165
+ power-cycle of the box + a log digest relayed into the active session.
166
+ Off by default; the health URL is yours to supply. Details:
167
+ `docs/WATCHDOG.md`.
168
+
169
+ ## Security
170
+
171
+ Read `SECURITY.md` before installing — it states the trust model: what
172
+ code runs with your rights, what the installer merges and how it
173
+ uninstalls, and what data ever leaves the machine.
174
+
175
+ ## Limitations
176
+
177
+ See `LIMITATIONS.md` (an honest list).
178
+
179
+ ## Development
180
+
181
+ ```bash
182
+ python3 -m pytest -q # unit + contract tests
183
+ ```
184
+
185
+ Tests redirect all state through `CC_CONTINUITY_HOME` (a temp dir) and
186
+ use throwaway git repos; they never touch the real
187
+ `~/.claude-continuity` or `~/.claude`. Architecture:
188
+ `docs/ARCHITECTURE.md`.
189
+
190
+ ## License
191
+
192
+ MIT — see `LICENSE`.
@@ -0,0 +1,7 @@
1
+ """cc-continuity — zero-touch session rotation for Claude Code.
2
+
3
+ Spec: docs/PRD.md (v1.0). Platform facts verified in docs/VERIFICATION.md.
4
+ Standard library only at runtime; pytest is dev-only.
5
+ """
6
+
7
+ __version__ = "0.1.0"
@@ -0,0 +1,8 @@
1
+ """``python -m cc_continuity`` → the cc-continuity CLI (PRD §16)."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -0,0 +1,62 @@
1
+ """Race-safe file writes (PRD §14: write temp → fsync → os.replace).
2
+
3
+ Hooks run concurrently (a single model message can fire many parallel tool
4
+ calls — Phase 0, S1/N1), so every write goes through a unique temp file in the
5
+ same directory, is fsync'd, then swapped into place with ``os.replace``. A
6
+ reader always sees either the old or the new complete content, never a torn
7
+ mix. A crash may leave a ``.<name>.*.tmp`` residue; ``doctor`` cleans those.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import tempfile
15
+ from pathlib import Path
16
+
17
+
18
+ def fsync_dir(path: Path) -> None:
19
+ """fsync a directory so the rename itself is durable (best effort)."""
20
+ try:
21
+ dfd = os.open(path, os.O_RDONLY)
22
+ except OSError:
23
+ return
24
+ try:
25
+ os.fsync(dfd)
26
+ except OSError:
27
+ pass
28
+ finally:
29
+ os.close(dfd)
30
+
31
+
32
+ def atomic_write(path: str | Path, data: bytes) -> None:
33
+ """Atomically write ``data`` to ``path``. Parent directory must exist."""
34
+ path = Path(path)
35
+ parent = path.parent
36
+ fd, tmp_name = tempfile.mkstemp(
37
+ prefix=f".{path.name}.", suffix=".tmp", dir=parent
38
+ )
39
+ try:
40
+ with os.fdopen(fd, "wb") as f:
41
+ f.write(data)
42
+ f.flush()
43
+ os.fsync(f.fileno())
44
+ os.replace(tmp_name, path)
45
+ except BaseException:
46
+ # "Crash" mid-write: never leave the target half-updated; drop the temp.
47
+ try:
48
+ os.unlink(tmp_name)
49
+ except OSError:
50
+ pass
51
+ raise
52
+ fsync_dir(parent)
53
+
54
+
55
+ def atomic_write_text(path: str | Path, text: str, encoding: str = "utf-8") -> None:
56
+ atomic_write(path, text.encode(encoding))
57
+
58
+
59
+ def atomic_write_json(path: str | Path, obj: object, indent: int = 2) -> None:
60
+ """Write a JSON document atomically (2-space indent, trailing newline)."""
61
+ text = json.dumps(obj, indent=indent, ensure_ascii=False) + "\n"
62
+ atomic_write_text(path, text)
@@ -0,0 +1,274 @@
1
+ """Bootstrap / claim in the new session (PRD §9 SessionStart, §12, §18).
2
+
3
+ ``try_claim`` (caller holds the project lock) finds the latest session in a
4
+ claimable state (``ROTATING`` / ``AWAITING_MANUAL_CLEAR`` /
5
+ ``CHECKPOINT_VERIFIED``, ``successor`` unset — plan P6-D2), creates the
6
+ successor session record (``BOOTSTRAPPING``), marks the old session claimed
7
+ (``successor`` set) and moves it to ``ROTATED`` (terminal), consumes the
8
+ matching checkpoint, runs ``validate_checkpoint`` (PRD §12) and returns the
9
+ C.4 bootstrap text. Called from the ``SessionStart(clear)`` hook and from
10
+ the ``UserPromptSubmit`` ``CONTINUITY RESUME`` fallback (P6-D5).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import subprocess
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+
21
+ from . import (atomic, checkpoint, ledger, naming, notify, paths,
22
+ project, state, thresholds, validate)
23
+
24
+
25
+ def _now() -> str:
26
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
27
+
28
+
29
+ #: States from which SessionStart(clear) may claim a rotation (P6-D2: CV
30
+ #: included so a manual /clear after checkpoint verification — e.g. after a
31
+ #: failed injection, PRD §18 — still bootstraps).
32
+ CLAIMABLE_STATES = frozenset({
33
+ "ROTATING", "AWAITING_MANUAL_CLEAR", "CHECKPOINT_VERIFIED",
34
+ })
35
+
36
+
37
+ def find_claimable(key: str) -> dict | None:
38
+ """The latest project session a claim may take over (P6-D2).
39
+
40
+ Caller holds the project lock. Returns the state record, or None.
41
+ A project with no record yet (first /clear before any registration,
42
+ P6-D3) and sessions whose state file is missing (crash residue) are
43
+ skipped.
44
+ """
45
+ pfile = paths.project_dir(key) / "project.json"
46
+ try:
47
+ pref = json.loads(pfile.read_text(encoding="utf-8"))
48
+ except (OSError, json.JSONDecodeError):
49
+ return None
50
+ best = None
51
+ for s in pref.get("sessions") or []:
52
+ try:
53
+ rec = state.load_state(key, int(s["nnn"]))
54
+ except (state.StateNotFoundError, ValueError, KeyError):
55
+ continue
56
+ if rec["state"] in CLAIMABLE_STATES and rec.get("successor") is None:
57
+ best = rec
58
+ return best
59
+
60
+
61
+ def write_started(session_id: str) -> Path:
62
+ """``runtime/<sid>.started`` marker for the rotator (PRD §14)."""
63
+ path = paths.runtime_dir() / f"{session_id}.started"
64
+ atomic.atomic_write_text(path, "started\n")
65
+ return path
66
+
67
+
68
+ def pending_checkpoint(key: str) -> dict | None:
69
+ """``latest_checkpoint`` while ``pending_consumption`` (else None)."""
70
+ try:
71
+ rec = json.loads(
72
+ (paths.project_dir(key) / "project.json").read_text(encoding="utf-8"))
73
+ except (OSError, json.JSONDecodeError):
74
+ return None
75
+ cp = rec.get("latest_checkpoint")
76
+ if isinstance(cp, dict) and cp.get("pending_consumption"):
77
+ return cp
78
+ return None
79
+
80
+
81
+ def _decide_name(key: str, slug: str,
82
+ allow_env: bool = True) -> tuple[str, int]:
83
+ """Wrapper-supplied name wins when it ends in ``-<digits>`` (PRD §13).
84
+
85
+ ``allow_env=False`` (Phase 27 resume/fork, Phase 28 CLI late attach):
86
+ the env name belongs to the ABANDONED startup process — the counter
87
+ name wins, or the dead startup's name would be reused.
88
+ """
89
+ if allow_env:
90
+ env_name = os.environ.get("CC_CONTINUITY_NAME", "").strip()
91
+ _base, seq = naming.split_name(env_name)
92
+ if seq is not None:
93
+ return env_name, project.next_counter(key, requested=seq)
94
+ nnn = project.next_counter(key)
95
+ return naming.logical_name(slug, nnn), nnn
96
+
97
+
98
+ def _tmux_bin() -> str:
99
+ return os.environ.get("CC_CONTINUITY_TMUX_BIN") or "tmux"
100
+
101
+
102
+ def record_tmux_session(key: str, nnn: int) -> None:
103
+ """P10-D9.1: record the pane's tmux session name (the watchdog's
104
+ primary nudge target).
105
+
106
+ Only when ``$TMUX`` is set; one fast subprocess (2 s timeout — the
107
+ hook keeps its ~100 ms budget in the common case); every failure
108
+ path is swallowed — this must never break the hook (P10-D12).
109
+ """
110
+ if not os.environ.get("TMUX"):
111
+ return
112
+ try:
113
+ proc = subprocess.run(
114
+ [_tmux_bin(), "display-message", "-p", "#{session_name}"],
115
+ capture_output=True, timeout=2)
116
+ except (OSError, subprocess.SubprocessError):
117
+ return
118
+ if proc.returncode != 0:
119
+ return
120
+ name = proc.stdout.decode(errors="replace").strip()
121
+ if not name:
122
+ return
123
+ try:
124
+ state.update_fields(key, nnn, tmux_session=name)
125
+ except Exception:
126
+ pass
127
+
128
+
129
+ def register_session(cwd, session_id: str, *, cfg: dict,
130
+ source: str = "manual",
131
+ allow_env: bool = False) -> tuple[str, int]:
132
+ """Register a conversation that has no record (late attach).
133
+
134
+ Phase 28: ``cc-continuity checkpoint`` / ``request-rotation`` run in
135
+ a session whose conversation was never registered (e.g. a process
136
+ started before the Phase 27 resume registration — 2026-10-03
137
+ connector a7c36f64, unmonitored at 72%). Creates its record the way
138
+ the SessionStart hook does — per-operation locking, since the helpers
139
+ take the project lock themselves and a wrapping ``lock.held`` would
140
+ self-deadlock (flock is not re-entrant): counter name (unless
141
+ ``allow_env``), state ACTIVE (BOOTSTRAPPING when a checkpoint is
142
+ pending), one ``session_start`` ledger entry, the
143
+ ``session_started`` push, tmux re-record.
144
+ Returns ``(key, nnn)``.
145
+ """
146
+ root = project.resolve_root(Path(cwd))
147
+ key = project.project_key_for_root(root)
148
+ record = project.ensure_project(str(cwd))
149
+ sessions = record.get("sessions") or []
150
+ predecessor = sessions[-1]["name"] if sessions else None
151
+ name, nnn = _decide_name(key, record["slug"], allow_env=allow_env)
152
+ initial = "BOOTSTRAPPING" if pending_checkpoint(key) else "ACTIVE"
153
+ state.create_session_state(key, name, nnn, session_id,
154
+ predecessor=predecessor,
155
+ initial_state=initial)
156
+ project.record_session(key, name, session_id, nnn)
157
+ ledger.append(paths.session_dir(key, nnn),
158
+ {"type": "session_start", "source": source,
159
+ "name": name, "nnn": nnn})
160
+ notify.safe_send(cfg, "session_started",
161
+ slug=notify.project_slug(key), nnn=nnn, name=name,
162
+ host=notify.host())
163
+ record_tmux_session(key, nnn)
164
+ return key, nnn
165
+
166
+
167
+ def _new_work_since_handoff(cp_dir: Path, key: str,
168
+ old_nnn: int) -> list[str]:
169
+ """The user prompts typed AFTER the checkpoint snapshot was written —
170
+ the rotation window (handoff finalizing / old session still live):
171
+ they land in the OLD session's live ledger, but the handoff is already
172
+ frozen and can no longer contain them, so they would be lost to the
173
+ successor (the rotation-window bug: new prompts landing after the handoff froze).
174
+
175
+ Marker: the snapshot's ``created`` (epoch; falls back to the
176
+ checkpoint dir mtime when missing). The snapshot's ``user_prompts``
177
+ marks what the handoff already captured, so a prompt present in BOTH
178
+ (``ts >= created`` and captured — the same-second race) is not
179
+ re-sent: no double-count. ``CONTINUITY RESUME`` prompts (bootstrap
180
+ hand-off) and subagent prompts (``agent_id``) are not user work.
181
+ Bounded: the last 5, each cut to the snapshot's own prompt width.
182
+ Never raises."""
183
+ try:
184
+ snap = json.loads((cp_dir / "snapshot.json")
185
+ .read_text(encoding="utf-8"))
186
+ except (OSError, ValueError):
187
+ snap = {}
188
+ captured = {str(p) for p in snap.get("user_prompts") or []}
189
+ cut = int(getattr(checkpoint, "PROMPT_CHARS", 500))
190
+ try:
191
+ created = datetime.fromisoformat(str(snap["created"])).timestamp()
192
+ except (KeyError, TypeError, ValueError):
193
+ try:
194
+ created = cp_dir.stat().st_mtime
195
+ except OSError:
196
+ return []
197
+ out: list[str] = []
198
+ try:
199
+ entries = ledger.read(paths.session_dir(key, old_nnn))
200
+ except OSError:
201
+ return []
202
+ for e in entries:
203
+ if e.get("type") != "user_prompt" or e.get("agent_id"):
204
+ continue
205
+ ts = e.get("ts")
206
+ if not isinstance(ts, (int, float)) or ts < created:
207
+ continue
208
+ text = str(e.get("text", ""))
209
+ if text.startswith("CONTINUITY RESUME"):
210
+ continue
211
+ if text[:cut] in captured:
212
+ continue
213
+ out.append(text[:cut])
214
+ return out[-5:]
215
+
216
+
217
+ def try_claim(key: str, *, new_session_id: str, cfg: dict) -> dict | None:
218
+ """Claim the latest claimable session for a fresh session (PRD §9).
219
+
220
+ Caller holds the project lock. Returns ``None`` when nothing is
221
+ claimable (idempotent: a second claim sees ``successor`` set). On
222
+ success returns ``{"old_name", "old_nnn", "new_name", "new_nnn",
223
+ "validation", "details", "bootstrap_text"}`` and has: created the
224
+ successor state (BOOTSTRAPPING, predecessor = old name, nnn = old+1 at
225
+ least), set the old session's ``successor`` and moved it to ROTATED,
226
+ cleared ``pending_consumption`` on the matching latest_checkpoint,
227
+ appended one ``session_start`` ledger entry to the successor, and
228
+ updated project.json (counter / sessions / checkpoint) in one atomic
229
+ write.
230
+ """
231
+ old = find_claimable(key)
232
+ if old is None:
233
+ return None
234
+ pfile = paths.project_dir(key) / "project.json"
235
+ pref = json.loads(pfile.read_text(encoding="utf-8"))
236
+ root = Path(pref["root"])
237
+ old_nnn = int(old["nnn"])
238
+ nnn_new = max(int(pref["counter"]), old_nnn) + 1
239
+ name_new = naming.logical_name(naming.split_name(old["name"])[0], nnn_new)
240
+ state.create_session_state(key, name_new, nnn_new, new_session_id,
241
+ predecessor=old["name"],
242
+ initial_state="BOOTSTRAPPING")
243
+ state.update_fields_unlocked(key, old_nnn, successor=name_new)
244
+ state._transition_unlocked(key, old_nnn, "ROTATED")
245
+ cp = pref.get("latest_checkpoint")
246
+ if isinstance(cp, dict) and cp.get("nnn") == old_nnn \
247
+ and cp.get("pending_consumption"):
248
+ cp = dict(cp)
249
+ cp["pending_consumption"] = False
250
+ cp["consumed_by"] = name_new
251
+ pref["latest_checkpoint"] = cp
252
+ pref["counter"] = nnn_new
253
+ pref["sessions"].append({"name": name_new, "session_id": new_session_id,
254
+ "nnn": nnn_new, "created": _now()})
255
+ atomic.atomic_write_json(pfile, pref)
256
+ cp_dir = paths.session_dir(key, old_nnn) / "checkpoint"
257
+ status, details = validate.validate_checkpoint(cp_dir, cfg, root=root)
258
+ handoff_file = cp_dir / "handoff.md"
259
+ try:
260
+ handoff = handoff_file.read_text(encoding="utf-8")
261
+ except OSError:
262
+ handoff = ""
263
+ text = thresholds.c4_text(
264
+ new_name=name_new, old_name=old["name"], status=status,
265
+ details=details, handoff=handoff, handoff_path=str(handoff_file),
266
+ inline_max=int(cfg.get("inline_handoff_max_chars", 12000)),
267
+ new_work=_new_work_since_handoff(cp_dir, key, old_nnn))
268
+ ledger.append(paths.session_dir(key, nnn_new),
269
+ {"type": "session_start", "source": "clear",
270
+ "claimed": old["name"], "validation": status})
271
+ return {"old_name": old["name"], "old_nnn": old_nnn,
272
+ "new_name": name_new, "new_nnn": nnn_new,
273
+ "validation": status, "details": details,
274
+ "bootstrap_text": text}