Skip to content

Commit fe0b5e7

Browse files
jvalin17claude
andcommitted
Two-layer session limits: breadcrumb at 70min, hard stop at 200min
Layer 1 (compact_at_minutes=70): writes HANDOFF.md as breadcrumb, session continues through compaction. Layer 2 (max_session_minutes=200 or 1 compaction or 700KB bytes): hard stop with restart prompt user can paste into next session for seamless continuation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a55e2ee commit fe0b5e7

9 files changed

Lines changed: 191 additions & 70 deletions

File tree

gates.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"profile": "minimal",
55
"mode": "normal",
66
"eval_threshold": 95,
7-
"max_session_minutes": 0,
7+
"compact_at_minutes": 70,
8+
"max_session_minutes": 200,
89
"auto": false,
910
"continue": true,
1011
"tdd": true,

hooks/gates.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"enforcement": "block",
77
"profile": "minimal",
88
"eval_threshold": 95,
9-
"max_session_minutes": 0,
9+
"compact_at_minutes": 70,
10+
"max_session_minutes": 200,
1011
"auto": false,
1112
"continue": false,
1213
"tdd": true,

hooks/session_init.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ def load_session_config(project_dir: Path) -> dict:
5353
config = load_gate_config(project_dir)
5454
return {
5555
"mode": get_config_value(config, "mode", "normal"),
56-
"max_session_minutes": get_config_value(config, "max_session_minutes", 0),
56+
"compact_at_minutes": get_config_value(config, "compact_at_minutes", 70),
57+
"max_session_minutes": get_config_value(config, "max_session_minutes", 200),
5758
"tdd": get_config_value(config, "tdd", True),
5859
"skill_routing": get_config_value(config, "skill_routing", True),
5960
"auto": get_config_value(config, "auto", False),
@@ -102,7 +103,8 @@ def scan_project_files(project_dir: Path) -> Tuple[List[str], int]:
102103
def init_session_state(
103104
session_dir: Path,
104105
mode: str = "normal",
105-
max_session_minutes: int = 0,
106+
compact_at_minutes: int = 70,
107+
max_session_minutes: int = 200,
106108
gate_protect: bool = True,
107109
report_protect: bool = True,
108110
continue_mode: bool = False,
@@ -112,6 +114,7 @@ def init_session_state(
112114
state = SessionState(
113115
session_start=int(time.time()),
114116
mode=mode,
117+
compact_at_minutes=compact_at_minutes,
115118
max_session_minutes=max_session_minutes,
116119
gate_protect=gate_protect,
117120
report_protect=report_protect,
@@ -290,7 +293,8 @@ def build_context(
290293
cfg_items = [
291294
f"tdd={session_config.get('tdd', True)}",
292295
f"skill_routing={session_config.get('skill_routing', True)}",
293-
f"max_session_minutes={session_config.get('max_session_minutes', 0)}",
296+
f"compact_at_minutes={session_config.get('compact_at_minutes', 70)}",
297+
f"max_session_minutes={session_config.get('max_session_minutes', 200)}",
294298
f"model={session_config.get('model', 'auto')}",
295299
f"gate_protect={session_config.get('gate_protect', True)}",
296300
f"report_protect={session_config.get('report_protect', True)}",
@@ -444,6 +448,7 @@ def main() -> int:
444448
init_session_state(
445449
session_dir,
446450
mode=mode,
451+
compact_at_minutes=session_config["compact_at_minutes"],
447452
max_session_minutes=session_config["max_session_minutes"],
448453
gate_protect=session_config["gate_protect"],
449454
report_protect=session_config["report_protect"],

hooks/session_limits.py

Lines changed: 93 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,120 @@
1-
"""Session limit enforcement.
1+
"""Session limit enforcement — two-layer system.
22
3-
continue_mode=True: write HANDOFF.md and launch a new session.
4-
continue_mode=False: warn only, session continues through compaction.
3+
Layer 1 (compact_at_minutes, default 70): Write HANDOFF.md breadcrumb,
4+
session continues through compaction. No restart needed.
5+
6+
Layer 2 (max_session_minutes, default 200): Hard stop. Write final
7+
HANDOFF.md with restart prompt for the user to paste into new session.
58
"""
69

7-
from auto_handoff import trigger_auto_handoff
10+
from auto_handoff import trigger_auto_handoff, write_auto_handoff, get_git_log
11+
from pathlib import Path
812
from session_state import (
913
FALLBACK_MAX_EXCHANGES,
1014
HARD_THRESHOLD_BYTES,
1115
SessionState,
16+
check_compact_threshold,
1217
check_thresholds,
1318
should_warn,
1419
)
1520

1621

22+
RESTART_PROMPT_TEMPLATE = """
23+
24+
## Restart Prompt
25+
26+
Copy-paste the following into your next Claude session to continue seamlessly:
27+
28+
---
29+
30+
Read HANDOFF.md first. You are continuing a multi-session task. The previous session hit its time/context limit. Pick up exactly where it left off:
31+
1. Read HANDOFF.md for goal, progress, and next steps
32+
2. Read project-state.md for overall project context
33+
3. Continue the work — do NOT re-do completed items listed above
34+
35+
---
36+
"""
37+
38+
39+
def _write_breadcrumb_handoff(state: SessionState, reason: str) -> None:
40+
"""Layer 1: Write HANDOFF.md as a breadcrumb. Session continues."""
41+
handoff_path = Path("HANDOFF.md")
42+
previous = ""
43+
if handoff_path.exists():
44+
try:
45+
previous = handoff_path.read_text(encoding="utf-8")
46+
except OSError:
47+
pass
48+
49+
git_log = get_git_log()
50+
write_auto_handoff(
51+
handoff_path=handoff_path,
52+
state=state,
53+
stop_reason=f"[BREADCRUMB] {reason}",
54+
previous_handoff=previous,
55+
git_log=git_log,
56+
)
57+
58+
59+
def _write_final_handoff(state: SessionState, reason: str) -> None:
60+
"""Layer 2: Write final HANDOFF.md with restart prompt."""
61+
handoff_path = Path("HANDOFF.md")
62+
previous = ""
63+
if handoff_path.exists():
64+
try:
65+
previous = handoff_path.read_text(encoding="utf-8")
66+
except OSError:
67+
pass
68+
69+
git_log = get_git_log()
70+
write_auto_handoff(
71+
handoff_path=handoff_path,
72+
state=state,
73+
stop_reason=f"[HARD STOP] {reason}",
74+
previous_handoff=previous,
75+
git_log=git_log,
76+
)
77+
78+
# Append restart prompt to HANDOFF.md
79+
content = handoff_path.read_text(encoding="utf-8")
80+
handoff_path.write_text(content + RESTART_PROMPT_TEMPLATE, encoding="utf-8")
81+
82+
1783
def apply_session_limits(state: SessionState) -> tuple:
18-
"""Apply byte/time limits. Returns (state, response_message).
84+
"""Apply two-layer time/byte limits. Returns (state, response_message).
1985
20-
continue_mode=True: writes HANDOFF.md and launches new claude session.
21-
continue_mode=False: warns only, session continues through compaction.
86+
Layer 1 (compact_at): Write breadcrumb HANDOFF.md, session continues.
87+
Layer 2 (max_session_minutes): Hard stop with restart prompt.
2288
"""
89+
# --- Layer 2: Hard stop ---
2390
triggered, stop_reason = check_thresholds(state)
2491

25-
if triggered and state.continue_mode and not state.warned:
26-
state.warned = True
27-
trigger_auto_handoff(state, stop_reason)
92+
if triggered and state.stopped == 0:
93+
state.stopped = 2
94+
_write_final_handoff(state, stop_reason)
2895
return state, (
29-
f"SESSION LIMIT: {stop_reason}. "
30-
f"HANDOFF.md written. A new session is being launched. "
31-
f"Wrap up current work."
96+
f"SESSION HARD STOP: {stop_reason}.\n\n"
97+
f"HANDOFF.md has been written with a restart prompt.\n"
98+
f"This session can no longer make meaningful progress.\n\n"
99+
f"To continue, start a new session and paste:\n"
100+
f" \"Read HANDOFF.md first. You are continuing a multi-session task.\"\n\n"
101+
f"Or run: python3 scripts/auto_continue.py"
32102
)
33103

34-
if triggered and not state.warned:
104+
# --- Layer 1: Breadcrumb (write HANDOFF.md, keep going) ---
105+
compact_triggered, compact_reason = check_compact_threshold(state)
106+
107+
if compact_triggered and not state.warned:
35108
state.warned = True
109+
_write_breadcrumb_handoff(state, compact_reason)
36110
return state, (
37-
f"SESSION LIMIT: {stop_reason}. "
38-
f"The session continues (compaction handles context pressure). "
39-
f"Quality may degrade. Wrap up current work."
111+
f"SESSION CHECKPOINT ({compact_reason}): "
112+
f"HANDOFF.md updated as a breadcrumb in case of crash/compaction. "
113+
f"Session continues — no restart needed. "
114+
f"After compaction, re-read HANDOFF.md to re-orient."
40115
)
41116

117+
# --- Warning (approaching limits) ---
42118
if should_warn(state) and not state.warned:
43119
state.warned = True
44120
return state, (

hooks/session_monitor.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
STATE_FILENAME,
3535
WARN_THRESHOLD_BYTES,
3636
SessionState,
37+
check_compact_threshold,
3738
check_thresholds,
3839
load_state,
3940
make_hook_response,
@@ -309,6 +310,7 @@ def main() -> int:
309310
"check_gates_blocked",
310311
"check_reports_blocked",
311312
"check_session_blocked",
313+
"check_compact_threshold",
312314
"check_thresholds",
313315
"handle_post_compact",
314316
"handle_post_tool_use",

hooks/session_state.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
HARD_THRESHOLD_BYTES = 700_000 # ~85% → handoff trigger
1111
FALLBACK_MAX_EXCHANGES = 30 # Raised from 20
1212
GRACE_TOOL_CALLS = 10 # Tool calls allowed after stop triggers
13-
DEFAULT_MAX_SESSION_MINUTES = 0 # Time-based hard stop (0 = disabled)
13+
DEFAULT_COMPACT_AT_MINUTES = 70 # Layer 1: write HANDOFF.md breadcrumb, session continues
14+
DEFAULT_MAX_SESSION_MINUTES = 200 # Layer 2: hard stop, user must restart
1415

1516
SESSION_DIR = ".session"
1617
STATE_FILENAME = "state.json"
@@ -33,7 +34,8 @@ class SessionState:
3334
slabs_without_data: int = 0
3435
last_tool_sequence: list = field(default_factory=list)
3536
has_queried_this_slab: bool = False
36-
max_session_minutes: int = 0 # 0 = disabled, set via gates.json
37+
compact_at_minutes: int = 70 # Layer 1: write breadcrumb, continue
38+
max_session_minutes: int = 200 # Layer 2: hard stop
3739
gate_protect: bool = True # G-GATE-1: block agent writes to .gates/
3840
report_protect: bool = True # G-REPORT-1: block agent writes to reports/
3941
last_test_edits: list = field(default_factory=list) # F2.6: recent test file paths
@@ -88,11 +90,15 @@ def make_hook_response(event_name: str, context: str) -> str:
8890

8991

9092
def check_thresholds(state: SessionState) -> tuple:
91-
"""Check if any hard-stop threshold is met. Returns (triggered, reason)."""
93+
"""Check if any hard-stop threshold is met. Returns (triggered, reason).
94+
95+
Layer 2 only — the final hard stop. Layer 1 (compact_at) is checked
96+
separately by check_compact_threshold().
97+
"""
9298
if state.compactions >= 1:
9399
return True, (
94100
f"Context compacted ({state.compactions} time(s)) "
95-
f"— context window is full"
101+
f"— restart required"
96102
)
97103

98104
if state.max_session_minutes > 0:
@@ -113,6 +119,27 @@ def check_thresholds(state: SessionState) -> tuple:
113119
return False, ""
114120

115121

122+
def check_compact_threshold(state: SessionState) -> tuple:
123+
"""Check if Layer 1 (compact/breadcrumb) threshold is met.
124+
125+
Returns (triggered, reason). This does NOT stop the session —
126+
it writes HANDOFF.md as a breadcrumb and the session continues.
127+
"""
128+
if state.compact_at_minutes <= 0:
129+
return False, ""
130+
131+
elapsed_seconds = int(time.time()) - state.session_start
132+
elapsed_minutes = elapsed_seconds / 60
133+
134+
if elapsed_minutes >= state.compact_at_minutes:
135+
return True, (
136+
f"Session time ({int(elapsed_minutes)} min) reached "
137+
f"compact threshold ({int(state.compact_at_minutes)} min)"
138+
)
139+
140+
return False, ""
141+
142+
116143
def should_warn(state: SessionState) -> bool:
117144
"""True when approaching byte limit (exchange count is diagnostic only)."""
118145
if state.warned:

templates/gates.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"profile": "minimal",
55
"mode": "normal",
66
"eval_threshold": 95,
7-
"max_session_minutes": 0,
7+
"compact_at_minutes": 70,
8+
"max_session_minutes": 200,
89
"auto": false,
910
"continue": true,
1011
"tdd": true,

tests/test_session_init.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,8 @@ def test_missing_gates_json_returns_defaults(self, project_dir):
362362
"""If gates.json doesn't exist, return defaults."""
363363
config = load_session_config(project_dir)
364364
assert config["mode"] == "normal"
365-
assert config["max_session_minutes"] == 0
365+
assert config["compact_at_minutes"] == 70
366+
assert config["max_session_minutes"] == 200
366367
assert config["tdd"] is True
367368
assert config["skill_routing"] is True
368369
assert config["model"] == "auto"

0 commit comments

Comments
 (0)