1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
//! `autofork hook <event>`: the Claude Code hook entrypoint. Reads the hook
//! JSON from stdin and forwards it to the daemon.
//!
//! The Stop hook (`stop-wait`) is an asyncRewake command: it long-polls the
//! daemon and, when forks come due, prints the wake payload to stderr and
//! exits 2 so Claude Code wakes the idle session. In headless mode the same
//! exit-2 wake carries a continuing chain fork's report (the goal fast path —
//! see `runner`). Every other path — and every failure — exits 0 so a hook
//! never breaks or wedges the session.
use crate::client::{spawn_daemon_detached, Client};
use autofork_core::config::Paths;
use autofork_core::project::project_root;
use autofork_core::protocol::{Event, EventKind, RequestBody, ResponseBody};
use serde::Deserialize;
use std::path::PathBuf;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum HookKind {
SessionStart,
UserPromptSubmit,
/// The asyncRewake Stop hook (`stop-wait`): long-poll for due forks.
StopWait,
SessionEnd,
}
/// The subset of Claude Code hook stdin we consume. Unknown fields ignored.
#[derive(Debug, Deserialize)]
struct HookInput {
session_id: String,
#[serde(default)]
transcript_path: Option<PathBuf>,
#[serde(default)]
cwd: Option<PathBuf>,
#[serde(default)]
source: Option<String>,
/// The SessionEnd reason (`clear`/`logout`/`prompt_input_exit`/`other`),
/// forwarded so `session_end` lifecycle hooks can see it.
#[serde(default)]
reason: Option<String>,
#[serde(default)]
model: Option<String>,
/// The submitted prompt (UserPromptSubmit). Used to tell genuine user
/// activity from a non-waking continuation. Absent for other events.
#[serde(default)]
prompt: Option<String>,
}
pub fn run_hook(kind: HookKind) {
// Never break the session, whatever happens in here. (The stop-wait Wake
// path exits 2 inline; every other path returns and main exits 0.)
let _ = run_hook_inner(kind);
}
fn run_hook_inner(kind: HookKind) -> Option<()> {
// Recursion guard, kept as zero-cost defense in depth: fork subagents emit
// SubagentStop, not Stop, so they never reach the trigger path — but if a
// fork's environment ever carried these vars, do nothing.
if std::env::var_os("AUTOFORK_FORK").is_some()
|| std::env::var_os("AUTOFORK_SESSION_ID").is_some()
{
return Some(());
}
let mut raw = String::new();
use std::io::Read;
std::io::stdin().read_to_string(&mut raw).ok()?;
let input: HookInput = serde_json::from_str(&raw).ok()?;
let paths = Paths::from_env()?;
let cwd = input.cwd.clone().or_else(|| std::env::current_dir().ok())?;
let root = project_root(&cwd);
// Per-session tag filter, inherited from the Claude Code process env.
let enable_tags = tags_from_env("AUTOFORK_ENABLE_TAGS");
let disable_tags = tags_from_env("AUTOFORK_DISABLE_TAGS");
// The client process behind this hook — the session's liveness anchor,
// resolved once and reused (a parked poll outlives its parent, so it must
// remember who that parent WAS).
let harness = autofork_core::harness::client_process();
// The session's credential env, captured here where we are still a
// child of the user's Claude Code: the daemon cannot read it from its
// own environment (it may have been started by another harness, or by
// a shell whose token has since been rotated) and needs it to spawn
// flush-on-close runs that authenticate as THIS session does.
let run_env = autofork_core::runenv::capture();
let event = |ev: EventKind| Event {
event: ev,
session_id: input.session_id.clone(),
transcript_path: input.transcript_path.clone(),
cwd: cwd.clone(),
project_root: root.clone(),
source: input.source.clone(),
reason: input.reason.clone(),
model: input.model.clone(),
enable_tags: enable_tags.clone(),
disable_tags: disable_tags.clone(),
waking: None,
notif_tool_use_id: None,
notif_task_id: None,
notif_status: None,
notif_continue: None,
context_tokens: None,
context_window: None,
client: None,
busy: None,
harness: harness.clone(),
env: run_env.clone(),
};
match kind {
HookKind::SessionStart => {
// SessionStart has slack: spawn-and-wait, retire outdated daemons.
let client = Client::connect_or_spawn(&paths, Duration::from_secs(5)).ok()?;
let mut client = client.ensure_current_version(&paths).ok()?;
let _ = client.request(RequestBody::Event(event(EventKind::SessionStart)));
}
HookKind::UserPromptSubmit => {
// Hard budget; never wait on a daemon spawn here. This cancels any
// parked stop-wait so no fork fires mid-turn.
let Ok(mut client) = Client::connect(&paths, Duration::from_millis(1500)) else {
spawn_daemon_detached(&paths);
return Some(());
};
// Headless-runner reports spooled since the last prompt are
// delivered here, silently, as additionalContext (invisible in
// the transcript). Spooled under the CONVERSATION id (transcript
// stem) so a report finished after a session closed still reaches
// its resumed leg. Old daemons answer Error — treated as none.
let spool_key = input
.transcript_path
.as_deref()
.and_then(|p| p.file_stem())
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| input.session_id.clone());
if let Ok(ResponseBody::Reports { blocks }) = client.request(RequestBody::TakeReports {
session_id: spool_key,
wait_ms: None,
}) {
if !blocks.is_empty() {
print_additional_context(&blocks);
}
}
// Sniff the prompt. An asyncRewake wake reminder (our marker) is
// always a non-waking continuation. A task notification gets its
// envelope ids forwarded so the daemon can decide whether it is
// one of its own fork completions (non-waking) or some other
// background task finishing (genuine activity — a new pause); the
// coarse `waking: false` stays as the old-daemon fallback. `None`
// (no prompt text) lets the daemon decide via its post-wake grace
// window.
let mut ev = event(EventKind::PromptSubmit);
if let Some(p) = input.prompt.as_deref() {
if p.contains(autofork_core::wake::WAKE_MARKER) {
ev.waking = Some(false);
} else if let Some(n) = autofork_core::notification::parse_task_notification(p) {
ev.waking = Some(false);
ev.notif_tool_use_id = n.tool_use_id;
ev.notif_task_id = n.task_id;
ev.notif_status = n.status;
ev.notif_continue = Some(n.continue_requested);
} else {
ev.waking = Some(true);
}
}
let _ = client.request(RequestBody::Event(ev));
}
HookKind::StopWait => {
// This process outlives the turn: it parks a long poll (up to 4h)
// and, in headless mode, keeps re-parking. If Claude Code dies
// meanwhile — the exit path where no SessionEnd hook ever
// completes — nothing else would end it, and a poll that keeps
// re-parking would hold the session "alive" in the daemon
// forever. Watch the client and leave when it does.
crate::runner::watch_harness(harness.clone());
// Runs async (Claude Code doesn't block): fine to spawn + retire.
let client = Client::connect_or_spawn(&paths, Duration::from_secs(10)).ok()?;
let mut client = client.ensure_current_version(&paths).ok()?;
let headless = {
let (cfg, _warnings) =
autofork_core::config::load_config_at(Some(&root), &paths.user_config());
cfg.fork_runner == autofork_core::config::ForkRunner::Headless
};
if headless {
// The quiet mode: this parked hook process consumes wakes
// itself — no wake turn, no visible spawns. It runs each fork
// as a `claude -p --fork-session` subprocess, spools the
// report for silent delivery at the next prompt, and re-parks
// until the wait is cancelled by activity. The one exception
// is a chain run that asks to continue: that report IS the
// parent's next instruction, so it wakes the session (exit 2)
// like subagent mode does.
// Fork children run the PARENT Claude Code process's exact
// binary (captured now — the poll outlives reparenting).
crate::runner::set_harness_bin(crate::client::parent_exe());
let resume_target = input
.transcript_path
.as_deref()
.and_then(|p| p.file_stem())
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| input.session_id.clone());
let mut reports = std::collections::HashMap::new();
while let Ok(ResponseBody::Wake {
payload,
forks,
feed,
}) = client.stop_wait(event(EventKind::Stop))
{
// A feed wake carries text, not forks: a `deliver: wake`
// lifecycle hook's output, which the session is meant to
// react to. Same delivery as the goal fast path below —
// stderr + exit 2 — because a quiet spool would defeat
// the point of having asked for a wake. (Quiet feeds
// never reach here: they ride the spool the prompt hook
// drains, invisible to this loop.)
if feed.is_some_and(|f| !f.blocks.is_empty()) {
eprintln!("{payload}");
std::process::exit(2);
}
let outcome = crate::runner::execute_wake(
&paths,
&input.session_id,
&resume_target,
&cwd,
forks.unwrap_or_default(),
&mut reports,
);
if outcome.stale {
// The pause these runs were selected in ended while
// they ran (the user spoke). Whatever was worth
// keeping is spooled; a chain verdict was dropped.
// Do NOT re-park: this poll would be a Stop on a
// turn that is not idle, and the epoch bump re-armed
// every latch — an `idle: 0s` fork would fire
// mid-turn from it. The new pause parks its own poll
// at its own Stop (or already has).
return Some(());
}
let wake_blocks = outcome.wake_blocks;
if !wake_blocks.is_empty() {
// The goal fast path: a chain run asked to continue,
// so its report is work for the parent — deliver it by
// waking the session (stderr + exit 2) instead of
// re-parking and letting it rot in the spool until the
// user's next prompt. The daemon has already re-armed
// the fork; it fires again at the Stop that ends the
// turn this wake starts, which is the loop.
eprintln!(
"{}",
autofork_core::wake::build_chain_wake_payload(&wake_blocks)
);
std::process::exit(2);
}
// Re-park on a fresh connection (the wake resolved this
// one's poll); a brief pause guards against a misbehaving
// daemon spinning us. A client that died while the run was
// in flight gets no fresh poll: the run's work and its
// spooled report survive, the session does not.
std::thread::sleep(Duration::from_secs(1));
if harness.as_ref().is_some_and(|h| !h.alive()) {
return Some(());
}
let c = Client::connect_or_spawn(&paths, Duration::from_secs(10)).ok()?;
client = c.ensure_current_version(&paths).ok()?;
}
} else {
// Long-poll until forks are due or the wait is cancelled/
// retired. Waited / error / closed socket / proto skew: exit
// 0 silently.
if let Ok(ResponseBody::Wake { payload, .. }) =
client.stop_wait(event(EventKind::Stop))
{
// Wake the idle session: stderr shown as a system reminder.
eprintln!("{payload}");
std::process::exit(2);
}
}
}
HookKind::SessionEnd => {
let mut client = Client::connect_or_spawn(&paths, Duration::from_secs(5)).ok()?;
// `flush_on_close`: hand every idle fork that hadn't yet fired
// this pause to a detached end-runner BEFORE the close purges the
// roster.
let flush = {
let (cfg, _w) =
autofork_core::config::load_config_at(Some(&root), &paths.user_config());
cfg.flush_on_close
};
if flush {
if let Ok(ResponseBody::Due { forks }) =
client.request(RequestBody::TakeFinalRuns {
session_id: input.session_id.clone(),
})
{
let resume_target = input
.transcript_path
.as_deref()
.and_then(|p| p.file_stem())
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| input.session_id.clone());
crate::runner::spawn_final_runner(
&paths,
"claude-code",
&input.session_id,
&resume_target,
&cwd,
None,
None,
crate::client::parent_exe().as_deref(),
&forks,
);
}
}
let _ = client.request(RequestBody::Event(event(EventKind::SessionEnd)));
}
}
Some(())
}
/// Print spooled fork reports as UserPromptSubmit additionalContext (exit-0
/// JSON on stdout). Claude Code caps additionalContext at 10k characters;
/// truncate to fit rather than lose the delivery entirely.
fn print_additional_context(blocks: &[String]) {
const CAP: usize = 9_800;
let mut text = blocks.join("\n\n");
if text.len() > CAP {
let mut cut = CAP;
while !text.is_char_boundary(cut) {
cut -= 1;
}
text.truncate(cut);
text.push_str("\n[…report truncated to fit the context budget]");
}
let out = serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": text,
}
});
println!("{out}");
}
/// Read a comma-separated tag env var into a normalized list (trimmed,
/// empties dropped, deduped). An unset or all-empty value yields `None` so the
/// daemon falls back to the config default.
pub(crate) fn tags_from_env(var: &str) -> Option<Vec<String>> {
let raw = std::env::var(var).ok()?;
let mut out: Vec<String> = Vec::new();
for piece in raw.split(',') {
let t = piece.trim();
if !t.is_empty() && !out.iter().any(|e| e == t) {
out.push(t.to_string());
}
}
if out.is_empty() {
None
} else {
Some(out)
}
}