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.
- cc_continuity-0.1.0/LICENSE +21 -0
- cc_continuity-0.1.0/PKG-INFO +7 -0
- cc_continuity-0.1.0/README.md +192 -0
- cc_continuity-0.1.0/cc_continuity/__init__.py +7 -0
- cc_continuity-0.1.0/cc_continuity/__main__.py +8 -0
- cc_continuity-0.1.0/cc_continuity/atomic.py +62 -0
- cc_continuity-0.1.0/cc_continuity/bootstrap.py +274 -0
- cc_continuity-0.1.0/cc_continuity/checkpoint.py +555 -0
- cc_continuity-0.1.0/cc_continuity/cli.py +901 -0
- cc_continuity-0.1.0/cc_continuity/config.py +258 -0
- cc_continuity-0.1.0/cc_continuity/doctor.py +270 -0
- cc_continuity-0.1.0/cc_continuity/gitsnap.py +141 -0
- cc_continuity-0.1.0/cc_continuity/hooks/__init__.py +17 -0
- cc_continuity-0.1.0/cc_continuity/hooks/common.py +159 -0
- cc_continuity-0.1.0/cc_continuity/hooks/pre_compact.py +44 -0
- cc_continuity-0.1.0/cc_continuity/hooks/pre_tool_use.py +59 -0
- cc_continuity-0.1.0/cc_continuity/hooks/session_end.py +174 -0
- cc_continuity-0.1.0/cc_continuity/hooks/session_start.py +182 -0
- cc_continuity-0.1.0/cc_continuity/hooks/stop.py +155 -0
- cc_continuity-0.1.0/cc_continuity/hooks/task.py +33 -0
- cc_continuity-0.1.0/cc_continuity/hooks/tool_use.py +192 -0
- cc_continuity-0.1.0/cc_continuity/hooks/user_prompt.py +114 -0
- cc_continuity-0.1.0/cc_continuity/install.py +479 -0
- cc_continuity-0.1.0/cc_continuity/launch.py +197 -0
- cc_continuity-0.1.0/cc_continuity/ledger.py +67 -0
- cc_continuity-0.1.0/cc_continuity/lock.py +139 -0
- cc_continuity-0.1.0/cc_continuity/managed.py +91 -0
- cc_continuity-0.1.0/cc_continuity/naming.py +81 -0
- cc_continuity-0.1.0/cc_continuity/notify.py +324 -0
- cc_continuity-0.1.0/cc_continuity/paths.py +64 -0
- cc_continuity-0.1.0/cc_continuity/power_plug.py +404 -0
- cc_continuity-0.1.0/cc_continuity/progress_pane.py +484 -0
- cc_continuity-0.1.0/cc_continuity/project.py +110 -0
- cc_continuity-0.1.0/cc_continuity/redact.py +48 -0
- cc_continuity-0.1.0/cc_continuity/rotator.py +476 -0
- cc_continuity-0.1.0/cc_continuity/settings_merge.py +164 -0
- cc_continuity-0.1.0/cc_continuity/skills.py +94 -0
- cc_continuity-0.1.0/cc_continuity/state.py +191 -0
- cc_continuity-0.1.0/cc_continuity/statusline.py +722 -0
- cc_continuity-0.1.0/cc_continuity/thresholds.py +234 -0
- cc_continuity-0.1.0/cc_continuity/validate.py +142 -0
- cc_continuity-0.1.0/cc_continuity/watchdog.py +526 -0
- cc_continuity-0.1.0/cc_continuity/watchdog_digest.py +550 -0
- cc_continuity-0.1.0/cc_continuity/watchdogd.py +1931 -0
- cc_continuity-0.1.0/cc_continuity.egg-info/PKG-INFO +7 -0
- cc_continuity-0.1.0/cc_continuity.egg-info/SOURCES.txt +95 -0
- cc_continuity-0.1.0/cc_continuity.egg-info/dependency_links.txt +1 -0
- cc_continuity-0.1.0/cc_continuity.egg-info/entry_points.txt +2 -0
- cc_continuity-0.1.0/cc_continuity.egg-info/top_level.txt +1 -0
- cc_continuity-0.1.0/pyproject.toml +22 -0
- cc_continuity-0.1.0/setup.cfg +4 -0
- cc_continuity-0.1.0/tests/test_atomic.py +73 -0
- cc_continuity-0.1.0/tests/test_bootstrap.py +359 -0
- cc_continuity-0.1.0/tests/test_checkpoint.py +791 -0
- cc_continuity-0.1.0/tests/test_cli.py +665 -0
- cc_continuity-0.1.0/tests/test_config.py +268 -0
- cc_continuity-0.1.0/tests/test_doctor.py +261 -0
- cc_continuity-0.1.0/tests/test_gitsnap.py +174 -0
- cc_continuity-0.1.0/tests/test_hooks_common.py +242 -0
- cc_continuity-0.1.0/tests/test_hooks_pre_compact.py +51 -0
- cc_continuity-0.1.0/tests/test_hooks_pre_tool_use.py +125 -0
- cc_continuity-0.1.0/tests/test_hooks_session_end.py +433 -0
- cc_continuity-0.1.0/tests/test_hooks_session_start.py +569 -0
- cc_continuity-0.1.0/tests/test_hooks_stop.py +353 -0
- cc_continuity-0.1.0/tests/test_hooks_task.py +100 -0
- cc_continuity-0.1.0/tests/test_hooks_tool_use.py +403 -0
- cc_continuity-0.1.0/tests/test_hooks_user_prompt.py +214 -0
- cc_continuity-0.1.0/tests/test_hygiene.py +75 -0
- cc_continuity-0.1.0/tests/test_install.py +330 -0
- cc_continuity-0.1.0/tests/test_integration_phase5.py +96 -0
- cc_continuity-0.1.0/tests/test_integration_phase6.py +134 -0
- cc_continuity-0.1.0/tests/test_integration_phase7.py +166 -0
- cc_continuity-0.1.0/tests/test_launch.py +310 -0
- cc_continuity-0.1.0/tests/test_ledger.py +83 -0
- cc_continuity-0.1.0/tests/test_lock.py +126 -0
- cc_continuity-0.1.0/tests/test_managed.py +72 -0
- cc_continuity-0.1.0/tests/test_naming.py +96 -0
- cc_continuity-0.1.0/tests/test_notify.py +307 -0
- cc_continuity-0.1.0/tests/test_notify_integration.py +156 -0
- cc_continuity-0.1.0/tests/test_paths.py +41 -0
- cc_continuity-0.1.0/tests/test_power_plug.py +521 -0
- cc_continuity-0.1.0/tests/test_progress_pane.py +626 -0
- cc_continuity-0.1.0/tests/test_project.py +68 -0
- cc_continuity-0.1.0/tests/test_redact.py +80 -0
- cc_continuity-0.1.0/tests/test_rotator.py +708 -0
- cc_continuity-0.1.0/tests/test_settings_merge.py +189 -0
- cc_continuity-0.1.0/tests/test_skills.py +69 -0
- cc_continuity-0.1.0/tests/test_state.py +278 -0
- cc_continuity-0.1.0/tests/test_status.py +187 -0
- cc_continuity-0.1.0/tests/test_statusline.py +1175 -0
- cc_continuity-0.1.0/tests/test_thresholds.py +217 -0
- cc_continuity-0.1.0/tests/test_uninstall.py +281 -0
- cc_continuity-0.1.0/tests/test_validate.py +200 -0
- cc_continuity-0.1.0/tests/test_watchdog.py +761 -0
- cc_continuity-0.1.0/tests/test_watchdog_digest.py +730 -0
- cc_continuity-0.1.0/tests/test_watchdog_integration.py +276 -0
- 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,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
|
+

|
|
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,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}
|