Skip to content

Commit e677803

Browse files
jvalin17claude
andcommitted
Simplify restart: hook launches claude -p directly, no PID watching
Removed find_claude_pid, build_restarter_code, schedule_restart. New launch_new_session just spawns 'claude -p' as a detached process. continue_mode=True: writes HANDOFF.md + launches new session. continue_mode=False: warns only, session continues through compaction. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 100dbcb commit e677803

3 files changed

Lines changed: 107 additions & 120 deletions

File tree

hooks/auto_handoff.py

Lines changed: 26 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
"""Auto-handoff — hook-written HANDOFF.md for agent-independent continuation.
1+
"""Auto-handoff — hook-written HANDOFF.md and session restart.
22
3-
Called by session_monitor when hard stop triggers. The hook writes
4-
the handoff directly so the agent cannot prevent, delay, or fake it.
5-
When continue_mode is on, also schedules a background restart.
3+
Called by session_monitor when context pressure is high and continue_mode
4+
is on. Writes HANDOFF.md and launches a new claude session in the background.
65
"""
76

8-
import os
97
import re
108
import shutil
119
import subprocess
@@ -47,20 +45,10 @@ def write_auto_handoff(
4745
previous_handoff: str,
4846
git_log: str,
4947
) -> None:
50-
"""Write HANDOFF.md from hook — agent-independent handoff.
51-
52-
Args:
53-
handoff_path: Path to write HANDOFF.md
54-
state: SessionState (duck-typed — needs exchanges, tool_calls,
55-
cumulative_output_bytes, compactions, last_tool_sequence)
56-
stop_reason: Human-readable reason for the stop
57-
previous_handoff: Content of existing HANDOFF.md (empty string if none)
58-
git_log: Output of git log --oneline
59-
"""
48+
"""Write HANDOFF.md from hook — agent-independent handoff."""
6049
goal, session_number = parse_handoff_header(previous_handoff)
6150
next_session = session_number + 1
6251

63-
# Build last tool sequence summary
6452
tool_summary = ""
6553
if state.last_tool_sequence:
6654
tool_lines = []
@@ -127,110 +115,41 @@ def get_git_log(max_entries: int = 10) -> str:
127115
return ""
128116

129117

130-
def find_claude_pid() -> int:
131-
"""Walk up the process tree to find the claude process PID.
132-
133-
Hooks run as: claude → bash → python3 (this process).
134-
os.getppid() gives bash, which dies when the hook exits.
135-
We need the actual claude PID so the restarter waits for it.
136-
Returns 0 if not found.
137-
"""
138-
try:
139-
pid = os.getppid() # start from our parent
140-
for _ in range(10): # walk up max 10 levels
141-
result = subprocess.run(
142-
["ps", "-p", str(pid), "-o", "ppid=,comm="],
143-
capture_output=True, text=True, timeout=5,
144-
)
145-
if result.returncode != 0:
146-
break
147-
parts = result.stdout.strip().split(None, 1)
148-
if len(parts) < 2:
149-
break
150-
ppid, comm = int(parts[0]), parts[1]
151-
if "claude" in comm.lower():
152-
return pid # this PID is the claude process
153-
if ppid <= 1:
154-
break
155-
pid = ppid
156-
except (OSError, ValueError, subprocess.TimeoutExpired):
157-
pass
158-
return 0
159-
160-
161-
def build_restarter_code(
162-
wait_pid: int,
163-
session_dir: str,
164-
project_dir: str,
165-
claude_bin: str,
166-
) -> str:
167-
"""Build the Python source for the background restarter process.
168-
169-
Separated from schedule_restart for testability.
170-
"""
171-
return f"""
172-
import os, shutil, subprocess, sys, time
173-
# Wait for the claude process to exit
174-
wait_pid = {wait_pid}
175-
for _ in range(600):
176-
try:
177-
os.kill(wait_pid, 0)
178-
time.sleep(1)
179-
except OSError:
180-
break
181-
# Brief pause to let Claude fully shut down
182-
time.sleep(2)
183-
# Clean session state
184-
session_dir = {session_dir!r}
185-
if os.path.isdir(session_dir):
186-
shutil.rmtree(session_dir, ignore_errors=True)
187-
# Relaunch
188-
prompt = "Read HANDOFF.md and continue from where the previous session left off."
189-
cmd = [{claude_bin!r}, "-p", prompt, "--output-format", "json", "--dangerously-skip-permissions"]
190-
env = {{**os.environ, "AGENT_TOOLKIT_CONTINUE": "true"}}
191-
subprocess.run(cmd, cwd={project_dir!r}, env=env)
192-
"""
193-
194-
195-
def schedule_restart(project_dir: Path) -> None:
196-
"""Spawn a detached background process that restarts claude after this session exits.
118+
def launch_new_session(project_dir: Path) -> bool:
119+
"""Launch a new claude session in the background.
197120
198-
Finds the actual claude process (not the bash intermediary), then spawns
199-
a restarter that waits for it to exit, cleans .session/, and relaunches.
121+
Spawns `claude -p` as a detached process. The new session reads
122+
HANDOFF.md and continues. No PID-watching — just fire and forget.
123+
Returns True if launched, False if claude not found.
200124
"""
201125
claude_bin = shutil.which("claude")
202126
if not claude_bin:
203-
sys.stderr.write("auto_handoff: claude not on PATH, cannot schedule restart\n")
204-
return
205-
206-
claude_pid = find_claude_pid()
207-
if claude_pid == 0:
208-
sys.stderr.write("auto_handoff: could not find claude process in tree\n")
209-
return
210-
211-
restarter_code = build_restarter_code(
212-
wait_pid=claude_pid,
213-
session_dir=str(project_dir / ".session"),
214-
project_dir=str(project_dir),
215-
claude_bin=claude_bin,
216-
)
127+
sys.stderr.write("auto_handoff: claude not on PATH, cannot launch new session\n")
128+
return False
129+
130+
prompt = "Read HANDOFF.md and continue from where the previous session left off."
131+
cmd = [
132+
claude_bin, "-p", prompt,
133+
"--output-format", "json",
134+
"--dangerously-skip-permissions",
135+
]
136+
217137
try:
218138
subprocess.Popen(
219-
[sys.executable, "-c", restarter_code],
139+
cmd,
140+
cwd=str(project_dir),
220141
start_new_session=True,
221142
stdout=subprocess.DEVNULL,
222143
stderr=subprocess.DEVNULL,
223144
)
145+
return True
224146
except OSError as exc:
225-
sys.stderr.write(f"auto_handoff: failed to schedule restart: {{exc}}\n")
147+
sys.stderr.write(f"auto_handoff: failed to launch session: {exc}\n")
148+
return False
226149

227150

228151
def trigger_auto_handoff(state, stop_reason: str) -> None:
229-
"""Trigger hook-written handoff. Called when hard stop fires.
230-
231-
Reads existing HANDOFF.md, gets git log, writes the new handoff,
232-
and schedules a background restart if continue_mode is on.
233-
"""
152+
"""Write HANDOFF.md and launch a new session if continue_mode is on."""
234153
handoff_path = Path("HANDOFF.md")
235154
previous_handoff = ""
236155
if handoff_path.exists():
@@ -250,4 +169,4 @@ def trigger_auto_handoff(state, stop_reason: str) -> None:
250169
)
251170

252171
if getattr(state, "continue_mode", False):
253-
schedule_restart(Path.cwd())
172+
launch_new_session(Path.cwd())

hooks/session_limits.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
"""Session limit enforcement — warn only, never stop.
1+
"""Session limit enforcement.
22
3-
Sessions continue through compaction. Limits produce warnings
4-
so the agent prioritizes current work over starting new tasks.
5-
No handoff, no stop, no restart — compaction handles context pressure.
3+
continue_mode=True: write HANDOFF.md and launch a new session.
4+
continue_mode=False: warn only, session continues through compaction.
65
"""
76

7+
from auto_handoff import trigger_auto_handoff
88
from session_state import (
99
FALLBACK_MAX_EXCHANGES,
1010
HARD_THRESHOLD_BYTES,
@@ -17,18 +17,26 @@
1717
def apply_session_limits(state: SessionState) -> tuple:
1818
"""Apply byte/time limits. Returns (state, response_message).
1919
20-
Never stops the session. Warns once when thresholds are hit
21-
so the agent wraps up current work instead of starting new tasks.
22-
Compaction handles context pressure naturally.
20+
continue_mode=True: writes HANDOFF.md and launches new claude session.
21+
continue_mode=False: warns only, session continues through compaction.
2322
"""
2423
triggered, stop_reason = check_thresholds(state)
2524

25+
if triggered and state.continue_mode and not state.warned:
26+
state.warned = True
27+
trigger_auto_handoff(state, stop_reason)
28+
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."
32+
)
33+
2634
if triggered and not state.warned:
2735
state.warned = True
2836
return state, (
2937
f"SESSION LIMIT: {stop_reason}. "
30-
f"The session will continue (compaction handles context pressure). "
31-
f"Quality may degrade. Wrap up current work before starting new tasks."
38+
f"The session continues (compaction handles context pressure). "
39+
f"Quality may degrade. Wrap up current work."
3240
)
3341

3442
if should_warn(state) and not state.warned:

tests/test_session_monitor.py

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
sys.path.insert(0, str(Path(__file__).parent.parent / "hooks"))
1717

1818
from auto_handoff import (
19+
launch_new_session,
1920
parse_handoff_header,
21+
trigger_auto_handoff,
2022
write_auto_handoff,
2123
)
2224
from session_monitor import (
@@ -1297,21 +1299,38 @@ def test_compaction_does_not_write_handoff(self, tmp_path, monkeypatch):
12971299
# Handoff should NOT be rewritten
12981300
assert handoff_path.read_text() == original_content
12991301

1300-
def test_byte_limit_does_not_write_handoff(self, tmp_path, monkeypatch):
1301-
"""Byte limit warns but does NOT write handoff."""
1302+
def test_byte_limit_no_handoff_without_continue(self, tmp_path, monkeypatch):
1303+
"""Without continue_mode, byte limit warns but does NOT write handoff."""
13021304
handoff_path = tmp_path / "HANDOFF.md"
13031305
monkeypatch.chdir(tmp_path)
13041306

13051307
state = SessionState(
13061308
session_start=int(time.time()),
13071309
cumulative_output_bytes=HARD_THRESHOLD_BYTES + 1,
1310+
continue_mode=False,
13081311
)
13091312
state, response, blocked = handle_pre_tool_use(
13101313
state, tool_name="Read", file_path="foo.py", command=""
13111314
)
1312-
assert state.stopped == 0
13131315
assert blocked is False
1314-
assert not handoff_path.exists() # No handoff written
1316+
assert not handoff_path.exists()
1317+
1318+
def test_byte_limit_writes_handoff_with_continue(self, tmp_path, monkeypatch):
1319+
"""With continue_mode, byte limit writes HANDOFF.md and launches new session."""
1320+
monkeypatch.chdir(tmp_path)
1321+
1322+
state = SessionState(
1323+
session_start=int(time.time()),
1324+
cumulative_output_bytes=HARD_THRESHOLD_BYTES + 1,
1325+
continue_mode=True,
1326+
)
1327+
with patch("auto_handoff.launch_new_session"):
1328+
state, response, blocked = handle_pre_tool_use(
1329+
state, tool_name="Read", file_path="foo.py", command=""
1330+
)
1331+
assert blocked is False
1332+
assert (tmp_path / "HANDOFF.md").exists()
1333+
assert "new session" in response.lower() or "launched" in response.lower()
13151334

13161335

13171336
# --- Time-based session limit (F1) ---
@@ -1439,6 +1458,47 @@ def test_disabled_time_limit_skips_stale_check(self, tmp_path):
14391458
assert state.session_start == old_start
14401459

14411460

1461+
class TestLaunchNewSession:
1462+
"""launch_new_session spawns claude as a detached process."""
1463+
1464+
def test_launches_claude(self, tmp_path):
1465+
with patch("auto_handoff.shutil.which", return_value="/usr/bin/claude"), \
1466+
patch("auto_handoff.subprocess.Popen") as mock_popen:
1467+
result = launch_new_session(tmp_path)
1468+
assert result is True
1469+
mock_popen.assert_called_once()
1470+
cmd = mock_popen.call_args[0][0]
1471+
assert "/usr/bin/claude" in cmd
1472+
assert "-p" in cmd
1473+
assert mock_popen.call_args[1]["start_new_session"] is True
1474+
1475+
def test_skips_when_claude_missing(self, tmp_path):
1476+
with patch("auto_handoff.shutil.which", return_value=None), \
1477+
patch("auto_handoff.subprocess.Popen") as mock_popen:
1478+
result = launch_new_session(tmp_path)
1479+
assert result is False
1480+
mock_popen.assert_not_called()
1481+
1482+
def test_continue_mode_triggers_handoff_and_launch(self, tmp_path, monkeypatch):
1483+
"""When continue_mode=True and limit triggers, writes handoff and launches."""
1484+
monkeypatch.chdir(tmp_path)
1485+
(tmp_path / "HANDOFF.md").write_text("# HANDOFF\n\n## Goal\n\nGoal\n")
1486+
1487+
state = SessionState(session_start=int(time.time()), continue_mode=True)
1488+
with patch("auto_handoff.launch_new_session") as mock_launch:
1489+
trigger_auto_handoff(state, "test reason")
1490+
mock_launch.assert_called_once()
1491+
1492+
def test_no_continue_mode_skips_launch(self, tmp_path, monkeypatch):
1493+
monkeypatch.chdir(tmp_path)
1494+
(tmp_path / "HANDOFF.md").write_text("# HANDOFF\n\n## Goal\n\nGoal\n")
1495+
1496+
state = SessionState(session_start=int(time.time()), continue_mode=False)
1497+
with patch("auto_handoff.launch_new_session") as mock_launch:
1498+
trigger_auto_handoff(state, "test reason")
1499+
mock_launch.assert_not_called()
1500+
1501+
14421502
class TestTimeLimitWarnsOnly:
14431503
"""Time limits warn but never stop the session."""
14441504

0 commit comments

Comments
 (0)