Skip to main content

ninox_core/
inbox.rs

1//! File-based inbox for orchestrator↔worker messaging (opt-in, see
2//! `config::InboxMessagingConfig`). One file per message under
3//! `{dir}/{session_id}.inbox/` — same shape as `hooks::append_work_request`'s
4//! `{dir}/{session_id}.requests/`, and for the same reason: every writer
5//! stays single-owner, so nothing here ever read-modify-writes a file
6//! another process could be touching concurrently.
7//!
8//! Drained by the Stop/UserPromptSubmit hooks installed in a worker's
9//! worktree settings (`ninox_app::spawn_util::ensure_statusline_settings`)
10//! via [`drain_for_stop`]/[`drain_for_prompt_submit`] — see `crate::messaging`
11//! for the write side (`ninox send` / `Engine::send_to_session`).
12
13use anyhow::Result;
14use std::path::{Path, PathBuf};
15
16/// Shared cap for both hook payloads: `hookSpecificOutput.additionalContext`
17/// is capped at ~10k chars by Claude Code, and `drain_for_stop`'s `reason`
18/// uses the same budget for consistency (no equivalent published limit for
19/// Stop, but an unbounded `reason` string is exactly the same risk).
20pub const MAX_HOOK_PAYLOAD_CHARS: usize = 10_000;
21
22#[derive(Debug, Clone, PartialEq)]
23pub struct InboxMessage {
24    pub id:      String,
25    pub text:    String,
26    pub sent_at: i64,
27}
28
29/// Write `text` as a new pending message for `session_id`. Atomic
30/// tmp-then-rename, mirroring `hooks::append_work_request`.
31pub fn write_message(dir: &Path, session_id: &str, text: &str) -> Result<InboxMessage> {
32    use std::sync::atomic::{AtomicU32, Ordering};
33    static SEQ: AtomicU32 = AtomicU32::new(0);
34
35    let sent_at = std::time::SystemTime::now()
36        .duration_since(std::time::UNIX_EPOCH)
37        .map(|d| d.as_millis() as i64)
38        .unwrap_or(0);
39    // pid + per-process sequence make the id unique even when two callers
40    // (orchestrator send + a poller reaction) write in the same millisecond.
41    // The sequence is zero-padded: `read_pending_messages` breaks a
42    // same-`sent_at` tie with a plain string compare on the id, and
43    // unpadded `...-10` sorts before `...-9` lexicographically — padding
44    // keeps that tiebreak in actual write order.
45    let message = InboxMessage {
46        id: format!(
47            "msg-{sent_at}-{}-{:010}",
48            std::process::id(),
49            SEQ.fetch_add(1, Ordering::Relaxed),
50        ),
51        text: text.to_string(),
52        sent_at,
53    };
54
55    let dir = inbox_dir(dir, session_id);
56    std::fs::create_dir_all(&dir)?;
57    let path = dir.join(format!("{}.json", message.id));
58    let body = serde_json::json!({
59        "id":     message.id,
60        "text":   message.text,
61        "sentAt": message.sent_at,
62    });
63    let tmp = path.with_extension("tmp");
64    std::fs::write(&tmp, serde_json::to_string_pretty(&body)?)?;
65    std::fs::rename(&tmp, &path)?;
66    Ok(message)
67}
68
69/// Messages not yet delivered, oldest first.
70pub fn read_pending_messages(dir: &Path, session_id: &str) -> Result<Vec<InboxMessage>> {
71    let dir = inbox_dir(dir, session_id);
72    let entries = match std::fs::read_dir(&dir) {
73        Ok(e)  => e,
74        Err(_) => return Ok(Vec::new()), // no directory → nothing pending
75    };
76    let mut messages: Vec<InboxMessage> = entries
77        .filter_map(|e| e.ok())
78        .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
79        .filter_map(|e| {
80            let raw = std::fs::read_to_string(e.path()).ok()?;
81            let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
82            Some(InboxMessage {
83                id:      v.get("id")?.as_str()?.to_string(),
84                text:    v.get("text")?.as_str()?.to_string(),
85                sent_at: v.get("sentAt").and_then(|t| t.as_i64()).unwrap_or(0),
86            })
87        })
88        .collect();
89    messages.sort_by(|a, b| a.sent_at.cmp(&b.sent_at).then(a.id.cmp(&b.id)));
90    Ok(messages)
91}
92
93/// Mark messages delivered by renaming their file out of the pending set
94/// (`.json` → `.json.delivered`), kept on disk as an audit trail. Best-effort
95/// per id, mirroring `hooks::mark_work_requests_delivered`: one failed
96/// rename must not leave later ids pending.
97pub fn mark_messages_delivered(dir: &Path, session_id: &str, ids: &[String]) -> Result<()> {
98    let dir = inbox_dir(dir, session_id);
99    let mut first_err: Option<std::io::Error> = None;
100    for id in ids {
101        let path = dir.join(format!("{id}.json"));
102        if path.exists() {
103            let delivered = dir.join(format!("{id}.json.delivered"));
104            if let Err(e) = std::fs::rename(&path, &delivered) {
105                first_err.get_or_insert(e);
106            }
107        }
108    }
109    match first_err {
110        Some(e) => Err(e.into()),
111        None    => Ok(()),
112    }
113}
114
115/// `session_id` is trusted, ninox-controlled input interpolated directly
116/// into a path component — same trust boundary as
117/// `hooks::work_requests_dir`'s `{session_id}.requests`. Every caller here
118/// is fed a session id ninox itself generated (`slugify`) or already has on
119/// record in the store; nothing here parses an id from outside that.
120fn inbox_dir(dir: &Path, session_id: &str) -> PathBuf {
121    dir.join(format!("{session_id}.inbox"))
122}
123
124/// Greedily join `pending` message bodies (oldest first, blank-line
125/// separated) up to `max_chars`, returning the joined text and exactly the
126/// ids of the messages that made it in. A message that alone exceeds
127/// `max_chars` is still included, truncated, rather than left permanently
128/// stuck — but nothing after it is added. Anything left out stays pending
129/// for the next drain, rather than being silently discarded.
130fn drain_batch(pending: Vec<InboxMessage>, max_chars: usize) -> (String, Vec<String>) {
131    let mut joined = String::new();
132    let mut ids = Vec::new();
133    for msg in pending {
134        let mut candidate = joined.clone();
135        if !candidate.is_empty() {
136            candidate.push_str("\n\n");
137        }
138        candidate.push_str(&msg.text);
139        if candidate.chars().count() > max_chars {
140            if ids.is_empty() {
141                joined = candidate.chars().take(max_chars).collect();
142                ids.push(msg.id);
143            }
144            break;
145        }
146        joined = candidate;
147        ids.push(msg.id);
148    }
149    (joined, ids)
150}
151
152/// Build the Stop hook's response for `session_id`, marking whichever
153/// messages made it into the response as delivered. `None` when nothing is
154/// pending — the caller should let Claude Code stop normally rather than
155/// print anything.
156///
157/// Repeat Stop invocations in the same turn (Claude Code sets
158/// `stop_hook_active` on the hook's stdin payload when it does) need no
159/// special handling here: messages are removed from the pending set the
160/// moment they're marked delivered, so a second call only ever sees
161/// messages that arrived after the first block — never the same batch
162/// re-blocking forever.
163///
164/// Marking happens before this JSON is ever printed by the CLI wrapper
165/// (`ninox inbox drain-stop`) — a crash or kill between the two would lose
166/// the message despite it being marked delivered here. Delivery is
167/// at-most-once, not "never lossy" in the face of a process crash mid-hook;
168/// the property this DOES guarantee is that a marking-*failure* (the
169/// common case — a concurrent rename losing a race, disk hiccup) never
170/// discards a response whose content is already in hand.
171pub fn drain_for_stop(dir: &Path, session_id: &str) -> Result<Option<serde_json::Value>> {
172    let pending = read_pending_messages(dir, session_id)?;
173    if pending.is_empty() {
174        return Ok(None);
175    }
176    let (reason, ids) = drain_batch(pending, MAX_HOOK_PAYLOAD_CHARS);
177    // Best-effort: see the "at-most-once" note above — a marking failure
178    // must never discard a response whose content is already in hand.
179    if let Err(e) = mark_messages_delivered(dir, session_id, &ids) {
180        tracing::warn!("failed to mark inbox messages delivered for {session_id}: {e}");
181    }
182    Ok(Some(serde_json::json!({ "decision": "block", "reason": reason })))
183}
184
185/// Build the UserPromptSubmit hook's response for `session_id`, marking
186/// whichever messages made it into the response as delivered. `None` when
187/// nothing is pending — the human's submitted prompt must pass through
188/// completely untouched in that case. Same at-most-once caveat as
189/// `drain_for_stop` regarding a process crash between marking and printing.
190pub fn drain_for_prompt_submit(dir: &Path, session_id: &str) -> Result<Option<serde_json::Value>> {
191    let pending = read_pending_messages(dir, session_id)?;
192    if pending.is_empty() {
193        return Ok(None);
194    }
195    let (context, ids) = drain_batch(pending, MAX_HOOK_PAYLOAD_CHARS);
196    // Best-effort, same reasoning as drain_for_stop: never drop an
197    // already-built response over a marking failure.
198    if let Err(e) = mark_messages_delivered(dir, session_id, &ids) {
199        tracing::warn!("failed to mark inbox messages delivered for {session_id}: {e}");
200    }
201    Ok(Some(serde_json::json!({
202        "hookSpecificOutput": {
203            "hookEventName": "UserPromptSubmit",
204            "additionalContext": context,
205        }
206    })))
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use tempfile::tempdir;
213
214    #[test]
215    fn write_then_read_returns_the_message() {
216        let dir = tempdir().unwrap();
217        let written = write_message(dir.path(), "sess-1", "hello worker").unwrap();
218        let pending = read_pending_messages(dir.path(), "sess-1").unwrap();
219        assert_eq!(pending, vec![written]);
220    }
221
222    #[test]
223    fn read_pending_on_missing_dir_returns_empty() {
224        let dir = tempdir().unwrap();
225        assert!(read_pending_messages(dir.path(), "no-such-session").unwrap().is_empty());
226    }
227
228    #[test]
229    fn messages_are_returned_oldest_first() {
230        let dir = tempdir().unwrap();
231        let a = write_message(dir.path(), "sess-1", "first").unwrap();
232        let b = write_message(dir.path(), "sess-1", "second").unwrap();
233        let pending = read_pending_messages(dir.path(), "sess-1").unwrap();
234        assert_eq!(pending, vec![a, b]);
235    }
236
237    #[test]
238    fn delivered_messages_are_no_longer_pending() {
239        let dir = tempdir().unwrap();
240        let msg = write_message(dir.path(), "sess-1", "hello").unwrap();
241        mark_messages_delivered(dir.path(), "sess-1", std::slice::from_ref(&msg.id)).unwrap();
242        assert!(read_pending_messages(dir.path(), "sess-1").unwrap().is_empty());
243        // Kept on disk as an audit trail, just renamed out of the pending set.
244        let dir_path = inbox_dir(dir.path(), "sess-1");
245        assert!(dir_path.join(format!("{}.json.delivered", msg.id)).exists());
246    }
247
248    #[test]
249    fn messages_for_different_sessions_do_not_collide() {
250        let dir = tempdir().unwrap();
251        write_message(dir.path(), "sess-1", "for one").unwrap();
252        write_message(dir.path(), "sess-2", "for two").unwrap();
253        assert_eq!(read_pending_messages(dir.path(), "sess-1").unwrap().len(), 1);
254        assert_eq!(read_pending_messages(dir.path(), "sess-2").unwrap().len(), 1);
255    }
256
257    #[test]
258    fn drain_for_stop_returns_none_when_nothing_pending() {
259        let dir = tempdir().unwrap();
260        assert!(drain_for_stop(dir.path(), "sess-1").unwrap().is_none());
261    }
262
263    #[test]
264    fn drain_for_stop_blocks_with_joined_reason_and_delivers() {
265        let dir = tempdir().unwrap();
266        write_message(dir.path(), "sess-1", "first").unwrap();
267        write_message(dir.path(), "sess-1", "second").unwrap();
268
269        let response = drain_for_stop(dir.path(), "sess-1").unwrap().unwrap();
270        assert_eq!(response["decision"], "block");
271        assert_eq!(response["reason"], "first\n\nsecond");
272        assert!(read_pending_messages(dir.path(), "sess-1").unwrap().is_empty());
273    }
274
275    #[test]
276    fn drain_for_stop_is_self_limiting_across_repeat_invocations() {
277        // Simulates Claude Code calling Stop again with stop_hook_active —
278        // the second call must not re-block on the same already-delivered
279        // batch.
280        let dir = tempdir().unwrap();
281        write_message(dir.path(), "sess-1", "only message").unwrap();
282        assert!(drain_for_stop(dir.path(), "sess-1").unwrap().is_some());
283        assert!(drain_for_stop(dir.path(), "sess-1").unwrap().is_none());
284    }
285
286    #[test]
287    fn drain_for_prompt_submit_returns_none_when_nothing_pending() {
288        let dir = tempdir().unwrap();
289        assert!(drain_for_prompt_submit(dir.path(), "sess-1").unwrap().is_none());
290    }
291
292    #[test]
293    fn drain_for_prompt_submit_shapes_hook_specific_output_and_delivers() {
294        let dir = tempdir().unwrap();
295        write_message(dir.path(), "sess-1", "context for the next turn").unwrap();
296
297        let response = drain_for_prompt_submit(dir.path(), "sess-1").unwrap().unwrap();
298        assert_eq!(response["hookSpecificOutput"]["hookEventName"], "UserPromptSubmit");
299        assert_eq!(
300            response["hookSpecificOutput"]["additionalContext"],
301            "context for the next turn"
302        );
303        assert!(read_pending_messages(dir.path(), "sess-1").unwrap().is_empty());
304    }
305
306    #[test]
307    fn drain_for_prompt_submit_truncates_a_single_oversized_message_and_delivers_it() {
308        let dir = tempdir().unwrap();
309        let long = "x".repeat(MAX_HOOK_PAYLOAD_CHARS + 500);
310        write_message(dir.path(), "sess-1", &long).unwrap();
311
312        let response = drain_for_prompt_submit(dir.path(), "sess-1").unwrap().unwrap();
313        let context = response["hookSpecificOutput"]["additionalContext"].as_str().unwrap();
314        assert_eq!(context.chars().count(), MAX_HOOK_PAYLOAD_CHARS);
315        // A message too big to ever fit must still be delivered (truncated)
316        // rather than left stuck pending forever.
317        assert!(read_pending_messages(dir.path(), "sess-1").unwrap().is_empty());
318    }
319
320    #[test]
321    fn drain_for_prompt_submit_leaves_overflow_messages_pending_instead_of_discarding_them() {
322        let dir = tempdir().unwrap();
323        let big = "x".repeat(MAX_HOOK_PAYLOAD_CHARS);
324        write_message(dir.path(), "sess-1", &big).unwrap();
325        write_message(dir.path(), "sess-1", "second message").unwrap();
326
327        let response = drain_for_prompt_submit(dir.path(), "sess-1").unwrap().unwrap();
328        let context = response["hookSpecificOutput"]["additionalContext"].as_str().unwrap();
329        assert_eq!(context, big, "only the first message should be in this batch");
330
331        let still_pending = read_pending_messages(dir.path(), "sess-1").unwrap();
332        assert_eq!(still_pending.len(), 1, "the overflow message must stay pending, not be lost");
333        assert_eq!(still_pending[0].text, "second message");
334    }
335
336    #[test]
337    fn drain_for_stop_leaves_overflow_messages_pending_instead_of_discarding_them() {
338        let dir = tempdir().unwrap();
339        let big = "x".repeat(MAX_HOOK_PAYLOAD_CHARS);
340        write_message(dir.path(), "sess-1", &big).unwrap();
341        write_message(dir.path(), "sess-1", "second message").unwrap();
342
343        let response = drain_for_stop(dir.path(), "sess-1").unwrap().unwrap();
344        assert_eq!(response["reason"], big);
345
346        let still_pending = read_pending_messages(dir.path(), "sess-1").unwrap();
347        assert_eq!(still_pending.len(), 1, "the overflow message must stay pending, not be lost");
348        assert_eq!(still_pending[0].text, "second message");
349    }
350
351    #[test]
352    fn drain_batch_includes_everything_that_fits() {
353        let pending = vec![
354            InboxMessage { id: "a".into(), text: "first".into(), sent_at: 1 },
355            InboxMessage { id: "b".into(), text: "second".into(), sent_at: 2 },
356        ];
357        let (joined, ids) = drain_batch(pending, 100);
358        assert_eq!(joined, "first\n\nsecond");
359        assert_eq!(ids, vec!["a".to_string(), "b".to_string()]);
360    }
361
362    #[test]
363    fn drain_batch_stops_before_a_message_that_would_overflow() {
364        let pending = vec![
365            InboxMessage { id: "a".into(), text: "12345".into(), sent_at: 1 },
366            InboxMessage { id: "b".into(), text: "67890".into(), sent_at: 2 },
367        ];
368        // "12345" fits; adding "\n\n67890" would exceed the cap.
369        let (joined, ids) = drain_batch(pending, 5);
370        assert_eq!(joined, "12345");
371        assert_eq!(ids, vec!["a".to_string()]);
372    }
373
374    #[test]
375    fn drain_batch_truncates_a_single_message_that_alone_exceeds_the_cap() {
376        let pending = vec![InboxMessage { id: "a".into(), text: "1234567890".into(), sent_at: 1 }];
377        let (joined, ids) = drain_batch(pending, 5);
378        assert_eq!(joined, "12345");
379        assert_eq!(ids, vec!["a".to_string()]);
380    }
381}