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
//! `tm hook`'s push of a `Notification` to a configured inbox (#8392), and the
//! single best-effort daemon POST every non-`SubagentStop` event takes.
//!
//! Why: a fleet supervisor wants the "session needs user action" signal pushed
//! to it. Nothing names the supervisor, so the target is configuration only
//! (`TRUSTY_MPM_NOTIFY_INBOX`, else `[notification_hook] inbox`). A hook that
//! blocks or fails stalls the user's session, so the push is bounded and every
//! failure is one stderr line and exit 0. The POST moved here from
//! `commands::misc`, which sits over the 500-SLOC cap, so the hook grew no lines
//! there.
//! What: [`forward_notification`] resolves the target and appends one JSON line
//! to `<inbox>/events.jsonl` on a worker thread the hook waits on for at most
//! [`FORWARD_TIMEOUT`]; [`post_best_effort`] is the fire-and-forget POST.
//! Test: `tests` below; `tm_hook_notification_8392` drives the real binary.
use std::io::Write;
use std::path::Path;
use std::time::Duration;
use serde_json::Value;
use super::tmux_attach::tmux_pane_id_from_env;
use trusty_mpm::core::standalone::hooks::notification::{
INBOX_EVENTS_FILE, NOTIFICATION_EVENT, NOTIFY_INBOX_ENV, NotificationHookConfig, PushTarget,
resolve_push_target,
};
/// The longest `tm hook` waits on the inbox append: one second.
///
/// Why: the registered hook timeout is 5 s and the daemon POST after this
/// takes up to 2 s, so 1 s leaves 2 s of headroom. A local append takes
/// microseconds; only a stuck target (a FIFO with no reader, a hung mount)
/// reaches the bound.
pub(crate) const FORWARD_TIMEOUT: Duration = Duration::from_secs(1);
/// Push a `Notification` to the configured inbox, if one is configured.
///
/// Why (#8392): see the module doc. Returns nothing because no outcome may
/// change the hook's exit status.
/// What: no-op for any other event and for an unset target. Otherwise builds
/// the inbox line (the prototype `notify-supervisor.sh` shape the Architect's
/// poller reads) and runs [`forward_to_target`]; a failure prints one
/// `trusty-mpm: Notification forward failed` line to stderr.
/// Test: `tm_hook_notification_8392::a_notification_reaches_the_daemon_and_the_inbox`,
/// `tm_hook_notification_8392::a_stuck_inbox_costs_one_logged_failure_and_exit_zero`.
pub(crate) fn forward_notification(event: &str, stdin: Option<&Value>, cwd: &str) {
if event != NOTIFICATION_EVENT {
return;
}
let env_value = std::env::var_os(NOTIFY_INBOX_ENV).filter(|v| !v.is_empty());
let config = if env_value.is_some() {
NotificationHookConfig::default()
} else {
trusty_mpm::core::config::MpmConfig::load_default().notification_hook
};
let target = resolve_push_target(env_value, &config);
let context = LineContext {
payload: stdin.cloned().unwrap_or(Value::Null),
cwd: cwd.to_string(),
project_dir: std::env::var("CLAUDE_PROJECT_DIR").ok(),
// #8392: only a `%N` pane id is kept; tmux prefix-matches anything else.
tmux_pane: tmux_pane_id_from_env(std::env::var("TMUX_PANE").ok()),
};
if let Err(reason) = forward_to_target(&target, context, FORWARD_TIMEOUT) {
eprintln!("trusty-mpm: Notification forward failed (#8392): {reason}");
}
}
/// What the inbox line is built from, moved onto the worker thread.
pub(crate) struct LineContext {
/// The hook's stdin JSON.
pub(crate) payload: Value,
/// The hook process's cwd, used when the payload carries none.
pub(crate) cwd: String,
/// `CLAUDE_PROJECT_DIR`, the project root Claude Code reports.
pub(crate) project_dir: Option<String>,
/// `TMUX_PANE`, when the session runs in tmux and it is a `%N` pane id.
pub(crate) tmux_pane: Option<String>,
}
/// Append one line for `context` to the target, waiting at most `timeout`.
///
/// Why: the bound has to cover the open as well as the write — opening a FIFO
/// with no reader blocks forever — so the whole append runs on a detached
/// thread and the hook stops waiting at the bound. Process exit reaps it.
/// What: `Ok` for [`PushTarget::Unset`] and for a completed append; `Err` with a
/// reason for a relative target (the value is not echoed), an append error, or
/// no answer within `timeout`. The inbox directory is never created.
/// Test: `an_unset_target_forwards_nothing`, `a_missing_inbox_is_an_error_not_a_mkdir`,
/// `a_fifo_with_no_reader_times_out`.
pub(crate) fn forward_to_target(
target: &PushTarget,
context: LineContext,
timeout: Duration,
) -> Result<(), String> {
let dir = match target {
PushTarget::Unset => return Ok(()),
PushTarget::NotAbsolute => {
return Err("the configured inbox is not an absolute path; not forwarded".into());
}
PushTarget::Inbox(dir) => dir.clone(),
};
let file = dir.join(INBOX_EVENTS_FILE);
let (tx, rx) = std::sync::mpsc::channel();
let worker_file = file.clone();
std::thread::spawn(move || {
let tmux_session = context.tmux_pane.as_deref().and_then(tmux_session_of);
let line = inbox_line(&context, tmux_session, chrono::Utc::now());
let _ = tx.send(append_line(&worker_file, &line));
});
match rx.recv_timeout(timeout) {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(format!("{}: {e}", file.display())),
Err(_) => Err(format!(
"{}: no answer within {} ms",
file.display(),
timeout.as_millis()
)),
}
}
/// Append `line` plus a newline to `file` in one write, creating the file but
/// never its directory.
fn append_line(file: &Path, line: &str) -> std::io::Result<()> {
let mut out = std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(file)?;
out.write_all(format!("{line}\n").as_bytes())
}
/// The tmux session that owns `pane`, or `None`; `None` without running tmux
/// when `pane` is not a `%N` pane id.
///
/// Test: `only_a_percent_n_tmux_pane_is_a_tmux_target`.
fn tmux_session_of(pane: &str) -> Option<String> {
// #8392: the bare `-t pane` below is exact only because this gate admits
// an immutable `%N` and nothing else (tmux-exact-targets allowlist row).
let pane = tmux_pane_id_from_env(Some(pane.to_owned()))?;
let out = std::process::Command::new("tmux")
.args(["display-message", "-p", "-t", &pane, "#S"])
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.ok()?;
let name = String::from_utf8_lossy(&out.stdout).trim().to_string();
(out.status.success() && !name.is_empty()).then_some(name)
}
/// The inbox line: the fields the prototype `notify-supervisor.sh` wrote.
///
/// Why: the Architect's `fleet-poll.py` and `tm-fleet-check` read
/// `type`, `message`, `tmux_session` and `display` from these lines, so the
/// shape is the contract #8436 P3 consumes.
/// What: `ts` (UTC, second precision), `session_id`, `cwd`, `project` (basename
/// of `CLAUDE_PROJECT_DIR`, else of `cwd`), `tmux_session`, `tmux_pane`, `type`
/// (`notification_type`), `message`, and `display` = `[<tmux session or
/// project>]: <message>`.
/// Test: `the_inbox_line_carries_the_prototype_fields`.
pub(crate) fn inbox_line(
context: &LineContext,
tmux_session: Option<String>,
now: chrono::DateTime<chrono::Utc>,
) -> String {
let field = |key: &str| context.payload.get(key).and_then(Value::as_str);
let cwd = field("cwd").unwrap_or(&context.cwd).to_string();
let base = |p: &str| {
Path::new(p)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
};
let project = context
.project_dir
.as_deref()
.and_then(base)
.or_else(|| base(&cwd));
let message = field("message");
let label = tmux_session
.clone()
.or_else(|| project.clone())
.unwrap_or_else(|| "unknown-session".to_string());
serde_json::json!({
"ts": now.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
"session_id": field("session_id"),
"cwd": cwd,
"project": project,
"tmux_session": tmux_session,
"tmux_pane": context.tmux_pane,
"type": field("notification_type"),
"message": message,
"display": format!("[{label}]: {}", message.unwrap_or_default()),
})
.to_string()
}
/// One fire-and-forget POST of `body` to `<url>/hooks`.
///
/// Why: failing the hook would block the user's prompt, so every failure —
/// daemon down, network blip, malformed url — is dropped.
/// What: a 500 ms connect timeout and a 2 s total timeout; if the dedicated
/// client cannot be built, the shared `client` with the same 2 s total bound.
/// Test: `tm_hook_notification_8392::a_down_daemon_does_not_block_the_hook`.
pub(crate) async fn post_best_effort(client: &reqwest::Client, url: &str, body: &Value) {
let hook_client = reqwest::Client::builder()
.connect_timeout(Duration::from_millis(500))
.timeout(Duration::from_secs(2))
.build()
.unwrap_or_else(|_| client.clone());
let _ = hook_client
.post(format!("{url}/hooks"))
.timeout(Duration::from_secs(2))
.json(body)
.send()
.await;
}
/// Deliver a `SessionStart` or `SessionEnd` over the daemon socket (#8531,
/// #8980).
///
/// Why: only the socket proves the sender's pid, and the daemon binds the
/// session to the `claude` above that pid — the identity a later
/// `tm repair delegation` is checked against. Over HTTP the session
/// registers unbound, and its own repairs are refused. #8980: a `SessionEnd`
/// stales the session's live delegations only when it arrives over the
/// socket from under that bound `claude`.
/// What: [`post_session_start_via`] over the resolved daemon socket when
/// `url` names a loopback daemon; the plain [`post_best_effort`] otherwise.
/// Test: `tm_hook_session_start_8531::a_session_start_over_the_socket_binds_its_claude`,
/// `tm_hook_session_start_8531::a_session_start_with_no_socket_falls_back_to_http`,
/// `tm_hook_session_end_8980::the_owners_session_end_over_the_socket_stales_its_records`.
pub(crate) async fn post_lifecycle_via_socket(client: &reqwest::Client, url: &str, body: &Value) {
let socket = super::managed_merged_prs::is_loopback_url(url)
.then(trusty_mpm::client::http_client::resolve_daemon_socket)
.and_then(Result::ok);
post_session_start_via(socket.as_deref(), client, url, body).await;
}
/// [`post_lifecycle_via_socket`] over an explicit socket path.
///
/// What: one 2 s socket attempt. Only an unreachable socket — nothing
/// listening — falls back to HTTP; a delivered, refused or timed-out attempt
/// ends here, so the daemon never ingests the event twice.
/// Test: the two `tm_hook_session_start_8531` cases (#8531), through the
/// built `tm`.
pub(crate) async fn post_session_start_via(
socket: Option<&Path>,
client: &reqwest::Client,
url: &str,
body: &Value,
) {
use trusty_mpm::client::{DaemonCallError, DaemonClient};
if let Some(socket) = socket {
let sent = DaemonClient::over_socket(socket)
.post("/hooks")
.json(body)
.timeout(Duration::from_secs(2))
.send()
.await;
if !matches!(sent, Err(DaemonCallError::Unreachable { .. })) {
return;
}
}
post_best_effort(client, url, body).await;
}
#[cfg(test)]
#[path = "hook_notify_tests.rs"]
mod tests;