Skip to main content

supercode_interchange/
watch.rs

1//! Passive, change-triggered following of local coding-harness sessions.
2//!
3//! This module deliberately observes persisted session state; it does not
4//! attach to, control, or infer the liveness of the process writing it.
5
6use std::fs::Metadata;
7use std::path::{Path, PathBuf};
8use std::time::UNIX_EPOCH;
9
10use serde_json::{json, Value};
11
12use crate::catalog::{SessionLocator, StorageLocator};
13use crate::native_store::load_native_store_family;
14use crate::session::{looks_like_sqlite, Session, SessionSource};
15use crate::{ChatMessage, Error, Fidelity, Result};
16
17/// Why a watcher emitted a complete session snapshot.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum SessionSnapshotReason {
20    /// The first event emitted after opening the follower.
21    Initial,
22    /// Existing normalized history changed, disappeared, or branched.
23    HistoryRewritten,
24    /// Session identity or other non-message state changed.
25    SourceChanged,
26}
27
28impl SessionSnapshotReason {
29    fn as_str(self) -> &'static str {
30        match self {
31            Self::Initial => "initial",
32            Self::HistoryRewritten => "history_rewritten",
33            Self::SourceChanged => "source_changed",
34        }
35    }
36}
37
38/// A normalized event emitted while following a local session.
39#[derive(Debug, Clone)]
40pub enum SessionWatchEvent {
41    /// A complete normalized view of the selected session.
42    SessionSnapshot {
43        /// Monotonically increasing sequence number, starting at one.
44        sequence: u64,
45        /// Why the full snapshot was necessary.
46        reason: SessionSnapshotReason,
47        /// The current normalized session.
48        session: Box<Session>,
49    },
50    /// Messages appended without changing existing normalized history.
51    MessagesAppended {
52        /// Monotonically increasing sequence number.
53        sequence: u64,
54        /// Selected session id, when the source records one.
55        session_id: Option<String>,
56        /// Newly appended normalized messages.
57        messages: Vec<ChatMessage>,
58        /// Total normalized messages in the source-side display projection.
59        total_message_count: usize,
60    },
61    /// A recoverable read or parse problem. The follower remains usable.
62    WatchError {
63        /// Monotonically increasing sequence number.
64        sequence: u64,
65        /// Human-readable description of the problem.
66        message: String,
67    },
68}
69
70impl SessionWatchEvent {
71    /// The event's monotonic sequence number.
72    pub fn sequence(&self) -> u64 {
73        match self {
74            Self::SessionSnapshot { sequence, .. }
75            | Self::MessagesAppended { sequence, .. }
76            | Self::WatchError { sequence, .. } => *sequence,
77        }
78    }
79
80    /// Render this event as one self-contained JSON value suitable for NDJSON.
81    pub fn to_json(&self) -> Value {
82        match self {
83            Self::SessionSnapshot {
84                sequence,
85                reason,
86                session,
87            } => json!({
88                "type": "session_snapshot",
89                "sequence": sequence,
90                "reason": reason.as_str(),
91                "session": normalized_session_json(session),
92            }),
93            Self::MessagesAppended {
94                sequence,
95                session_id,
96                messages,
97                total_message_count,
98            } => json!({
99                "type": "messages_appended",
100                "sequence": sequence,
101                "session_id": session_id,
102                "messages": messages.iter().map(message_json).collect::<Vec<_>>(),
103                "total_message_count": total_message_count,
104            }),
105            Self::WatchError { sequence, message } => json!({
106                "type": "watch_error",
107                "sequence": sequence,
108                "recoverable": true,
109                "message": message,
110            }),
111        }
112    }
113}
114
115/// Poll-based follower for one persisted Claude Code, Codex, Pi, OpenCode, or Grok
116/// session.
117///
118/// Polling first compares cheap filesystem stamps. The source is fully parsed
119/// only after a relevant file changes. This keeps idle polling cheap while
120/// retaining the existing, well-tested format loaders as the source of truth.
121pub struct SessionFollower {
122    path: PathBuf,
123    opencode_session: Option<String>,
124    goose_sqlite: bool,
125    fidelity: Fidelity,
126    include_subagents: bool,
127    message_limit: Option<usize>,
128    max_message_chars: Option<usize>,
129    display_history: bool,
130    current: Session,
131    fingerprint: Vec<PathStamp>,
132    initial_pending: bool,
133    next_sequence: u64,
134}
135
136#[derive(Clone, Copy)]
137struct FollowerView {
138    include_subagents: bool,
139    message_limit: Option<usize>,
140    max_message_chars: Option<usize>,
141    display_history: bool,
142}
143
144impl SessionFollower {
145    /// Open a persisted session using its durable catalog locator.
146    pub fn open_locator(locator: &SessionLocator) -> Result<Self> {
147        Self::open_locator_with_fidelity(locator, Fidelity::ByteLossless)
148    }
149
150    /// [`Self::open_locator`] at a declared fidelity.
151    ///
152    /// A read-only mirror follows at [`Fidelity::Semantic`] so a compacted
153    /// transcript keeps streaming instead of turning every poll into a
154    /// `watch_error`. See [`crate::Session::load_with_fidelity`].
155    pub fn open_locator_with_fidelity(
156        locator: &SessionLocator,
157        fidelity: Fidelity,
158    ) -> Result<Self> {
159        Self::open_locator_with_view(locator, fidelity, true, None, None, false)
160    }
161
162    /// Open a frontend-oriented follower whose snapshots contain only the
163    /// selected parent and at most `message_limit` trailing messages.
164    pub fn open_locator_with_view(
165        locator: &SessionLocator,
166        fidelity: Fidelity,
167        include_subagents: bool,
168        message_limit: Option<usize>,
169        max_message_chars: Option<usize>,
170        display_history: bool,
171    ) -> Result<Self> {
172        match &locator.storage {
173            StorageLocator::File { path } => Self::open_with_options(
174                path,
175                None,
176                false,
177                fidelity,
178                FollowerView {
179                    include_subagents,
180                    message_limit,
181                    max_message_chars,
182                    display_history,
183                },
184            ),
185            StorageLocator::Sqlite { path, selector } => Self::open_with_options(
186                path,
187                Some(selector),
188                locator.harness.as_str() == crate::HarnessId::GOOSE,
189                fidelity,
190                FollowerView {
191                    include_subagents,
192                    message_limit,
193                    max_message_chars,
194                    display_history,
195                },
196            ),
197        }
198    }
199
200    /// Open a local session for passive following.
201    ///
202    /// `opencode_session` is valid only for an OpenCode SQLite store. When it
203    /// is omitted, the initially selected session is pinned for all later
204    /// polls rather than following whichever database row becomes newest.
205    pub fn open(path: impl Into<PathBuf>, opencode_session: Option<&str>) -> Result<Self> {
206        Self::open_with_fidelity(path, opencode_session, Fidelity::ByteLossless)
207    }
208
209    /// [`Self::open`] at a declared fidelity.
210    pub fn open_with_fidelity(
211        path: impl Into<PathBuf>,
212        opencode_session: Option<&str>,
213        fidelity: Fidelity,
214    ) -> Result<Self> {
215        Self::open_with_options(
216            path,
217            opencode_session,
218            false,
219            fidelity,
220            FollowerView {
221                include_subagents: true,
222                message_limit: None,
223                max_message_chars: None,
224                display_history: false,
225            },
226        )
227    }
228
229    fn open_with_options(
230        path: impl Into<PathBuf>,
231        opencode_session: Option<&str>,
232        goose_sqlite: bool,
233        fidelity: Fidelity,
234        view: FollowerView,
235    ) -> Result<Self> {
236        let path = path.into();
237        let sqlite = looks_like_sqlite(&path);
238        if opencode_session.is_some() && !sqlite {
239            return Err(Error::Other(format!(
240                "an OpenCode session selector requires a SQLite store; {} is not one",
241                path.display()
242            )));
243        }
244
245        let mut selected = opencode_session.map(str::to_owned);
246        let mut current = load_selected(&path, selected.as_deref(), goose_sqlite, fidelity, view)?;
247        bound_session_view(&mut current, view.message_limit, view.max_message_chars);
248        if sqlite && selected.is_none() {
249            selected = current.meta.session_id.clone();
250        }
251        let fingerprint =
252            source_fingerprint(&path, &current, selected.as_deref(), view.include_subagents)?;
253
254        Ok(Self {
255            path,
256            opencode_session: selected,
257            goose_sqlite,
258            fidelity,
259            include_subagents: view.include_subagents,
260            message_limit: view.message_limit,
261            max_message_chars: view.max_message_chars,
262            display_history: view.display_history,
263            current,
264            fingerprint,
265            initial_pending: true,
266            next_sequence: 1,
267        })
268    }
269
270    /// Inspect the filesystem once and return the next event, if any.
271    ///
272    /// The first call always returns an initial snapshot. Later calls return
273    /// `None` while the relevant filesystem stamps are unchanged.
274    pub fn poll(&mut self) -> Result<Option<SessionWatchEvent>> {
275        if self.initial_pending {
276            self.initial_pending = false;
277            return Ok(Some(self.snapshot(SessionSnapshotReason::Initial)));
278        }
279
280        let observed = source_fingerprint(
281            &self.path,
282            &self.current,
283            self.opencode_session.as_deref(),
284            self.include_subagents,
285        )?;
286        if observed == self.fingerprint {
287            return Ok(None);
288        }
289
290        let loaded = load_selected(
291            &self.path,
292            self.opencode_session.as_deref(),
293            self.goose_sqlite,
294            self.fidelity,
295            FollowerView {
296                include_subagents: self.include_subagents,
297                message_limit: self.message_limit,
298                max_message_chars: self.max_message_chars,
299                display_history: self.display_history,
300            },
301        );
302        self.fingerprint = observed;
303        let next = match loaded {
304            Ok(session) if session.parse_error_lines > 0 => {
305                let count = session.parse_error_lines;
306                Some(self.watch_error(format!(
307                    "{} contains {count} malformed or truncated JSON line(s); retaining the last good snapshot",
308                    self.path.display()
309                )))
310            }
311            Err(error) => Some(self.watch_error(format!(
312                "could not reload {}: {error}; retaining the last good snapshot",
313                self.path.display()
314            ))),
315            Ok(mut session) => {
316                bound_session_view(&mut session, self.message_limit, self.max_message_chars);
317                self.event_for_session(session)
318            }
319        };
320        Ok(next)
321    }
322
323    fn event_for_session(&mut self, session: Session) -> Option<SessionWatchEvent> {
324        if normalized_session_eq(&self.current, &session) {
325            self.current = session;
326            return None;
327        }
328
329        let identity_same = session_identity_eq(&self.current, &session);
330        let subagents_same = normalized_subagents_eq(&self.current, &session);
331        let append_prefix = if identity_same && subagents_same {
332            append_prefix_len(&self.current.messages, &session.messages)
333        } else {
334            0
335        };
336        if append_prefix > 0 && session.messages.len() > append_prefix {
337            let messages = session.messages[append_prefix..].to_vec();
338            let session_id = session.meta.session_id.clone();
339            let total_message_count = session
340                .imported_message_count
341                .unwrap_or(session.messages.len())
342                .max(session.messages.len());
343            self.current = session;
344            return Some(SessionWatchEvent::MessagesAppended {
345                sequence: self.take_sequence(),
346                session_id,
347                messages,
348                total_message_count,
349            });
350        }
351
352        let reason = if identity_same {
353            SessionSnapshotReason::HistoryRewritten
354        } else {
355            SessionSnapshotReason::SourceChanged
356        };
357        self.current = session;
358        Some(self.snapshot(reason))
359    }
360
361    fn snapshot(&mut self, reason: SessionSnapshotReason) -> SessionWatchEvent {
362        SessionWatchEvent::SessionSnapshot {
363            sequence: self.take_sequence(),
364            reason,
365            session: Box::new(self.current.clone()),
366        }
367    }
368
369    fn watch_error(&mut self, message: String) -> SessionWatchEvent {
370        SessionWatchEvent::WatchError {
371            sequence: self.take_sequence(),
372            message,
373        }
374    }
375
376    fn take_sequence(&mut self) -> u64 {
377        let sequence = self.next_sequence;
378        self.next_sequence += 1;
379        sequence
380    }
381}
382
383fn load_selected(
384    path: &Path,
385    selected: Option<&str>,
386    goose_sqlite: bool,
387    fidelity: Fidelity,
388    view: FollowerView,
389) -> Result<Session> {
390    let sqlite = looks_like_sqlite(path);
391    if sqlite {
392        if goose_sqlite {
393            let selector = selected.ok_or_else(|| {
394                Error::Other("a Goose SQLite locator requires a session selector".to_string())
395            })?;
396            Ok(Session::from_goose_sqlite(path, selector)?)
397        } else {
398            Ok(Session::from_opencode_sqlite(path, selected)?)
399        }
400    } else if let Some(session) = load_native_store_family(path)? {
401        Ok(session)
402    } else if view.display_history {
403        Ok(Session::load_display_view(
404            path,
405            fidelity,
406            view.message_limit.unwrap_or(500),
407        )?)
408    } else if view.include_subagents {
409        Ok(Session::load_with_fidelity(path, fidelity)?)
410    } else {
411        Ok(Session::load_parent_with_fidelity(path, fidelity)?)
412    }
413}
414
415#[doc(hidden)]
416pub fn bound_session_view(
417    session: &mut Session,
418    message_limit: Option<usize>,
419    max_message_chars: Option<usize>,
420) {
421    if let Some(limit) = message_limit {
422        if session.messages.len() > limit {
423            session.messages.drain(..session.messages.len() - limit);
424        }
425    }
426
427    let Some(max_chars) = max_message_chars else {
428        return;
429    };
430    for message in &mut session.messages {
431        if let Some(content) = &mut message.content {
432            truncate_utf8(content, max_chars);
433        }
434        if let Some(parts) = &mut message.content_parts {
435            for part in parts {
436                truncate_value_strings(part, max_chars);
437            }
438        }
439        if let Some(tool_calls) = &mut message.tool_calls {
440            for call in tool_calls {
441                truncate_utf8(&mut call.function.arguments, max_chars);
442            }
443        }
444        for value in message.metadata.values_mut() {
445            truncate_utf8(value, max_chars);
446        }
447    }
448}
449
450fn truncate_value_strings(value: &mut Value, max_chars: usize) {
451    match value {
452        Value::String(text) => truncate_utf8(text, max_chars),
453        Value::Array(values) => {
454            for value in values {
455                truncate_value_strings(value, max_chars);
456            }
457        }
458        Value::Object(values) => {
459            for value in values.values_mut() {
460                truncate_value_strings(value, max_chars);
461            }
462        }
463        _ => {}
464    }
465}
466
467fn truncate_utf8(value: &mut String, max_chars: usize) {
468    let Some((byte_index, _)) = value.char_indices().nth(max_chars) else {
469        return;
470    };
471    value.truncate(byte_index);
472    value.push_str("\n…");
473}
474
475fn session_identity_eq(left: &Session, right: &Session) -> bool {
476    left.meta.source == right.meta.source
477        && left.meta.session_id == right.meta.session_id
478        && left.meta.model == right.meta.model
479        && left.meta.cwd == right.meta.cwd
480        && left.meta.system_prompt == right.meta.system_prompt
481        && left.meta.agent_id == right.meta.agent_id
482        && left.meta.parent_tool_use_id == right.meta.parent_tool_use_id
483        && left.meta.lineage == right.meta.lineage
484}
485
486fn normalized_session_eq(left: &Session, right: &Session) -> bool {
487    session_identity_eq(left, right)
488        && left.messages == right.messages
489        && normalized_subagents_eq(left, right)
490        && left.parse_error_lines == right.parse_error_lines
491        && left.load_residue == right.load_residue
492}
493
494fn normalized_subagents_eq(left: &Session, right: &Session) -> bool {
495    left.subagents.len() == right.subagents.len()
496        && left
497            .subagents
498            .iter()
499            .zip(&right.subagents)
500            .all(|(left, right)| normalized_session_eq(left, right))
501}
502
503/// Length of the already-known prefix in `next`. A bounded display window is
504/// either a plain sliding tail, or an anchored tail whose first user row stays
505/// pinned while records immediately after it slide. Both shapes prove that
506/// consumers can append the remaining suffix without replacing visible rows.
507fn append_prefix_len(current: &[ChatMessage], next: &[ChatMessage]) -> usize {
508    let plain = (1..=current.len().min(next.len()))
509        .rev()
510        .find(|&length| current[current.len() - length..] == next[..length])
511        .unwrap_or(0);
512    let anchored = if current.first() == next.first() && next.len() > 1 {
513        (1..=current.len().saturating_sub(1).min(next.len() - 1))
514            .rev()
515            .find(|&length| current[current.len() - length..] == next[1..1 + length])
516            .map(|length| length + 1)
517            .unwrap_or(0)
518    } else {
519        0
520    };
521    plain.max(anchored)
522}
523
524fn source_name(source: SessionSource) -> &'static str {
525    match source {
526        SessionSource::ClaudeCode => "claude_code",
527        SessionSource::Codex => "codex",
528        SessionSource::OpenCode => "opencode",
529        SessionSource::Pi => "pi",
530        SessionSource::Grok => "grok",
531        SessionSource::Gemini => "gemini",
532        SessionSource::Goose => "goose",
533        SessionSource::Native => "native",
534    }
535}
536
537#[doc(hidden)]
538pub fn message_json(message: &ChatMessage) -> Value {
539    let mut value = serde_json::to_value(message).unwrap_or_else(|_| json!({}));
540    if let Value::Object(object) = &mut value {
541        object.insert("metadata".to_string(), json!(message.metadata));
542    }
543    value
544}
545
546/// Render a normalized session as a language-neutral JSON value.
547pub fn normalized_session_json(session: &Session) -> Value {
548    json!({
549        "source": source_name(session.meta.source),
550        "session_id": session.meta.session_id,
551        "model": session.meta.model,
552        "cwd": session.meta.cwd,
553        "system_prompt": session.meta.system_prompt,
554        "agent_id": session.meta.agent_id,
555        "parent_tool_use_id": session.meta.parent_tool_use_id,
556        "lineage": session.meta.lineage,
557        "messages": session.messages.iter().map(message_json).collect::<Vec<_>>(),
558        "subagents": session.subagents.iter().map(normalized_session_json).collect::<Vec<_>>(),
559        "raw_record_count": session.raw.len(),
560        "total_message_count": session.imported_message_count.unwrap_or(session.messages.len()).max(session.messages.len()),
561        "parse_error_lines": session.parse_error_lines,
562        // Same pair `harness.v1.sessions.export` reports for an artifact: the
563        // level reached, and exactly what was given up to reach it. `semantic`
564        // with a non-empty residue means this is a read-only VIEW of a
565        // transcript that cannot be losslessly reconstructed.
566        "fidelity": session.load_fidelity(),
567        "residue": session.load_residue,
568    })
569}
570
571#[derive(Debug, Clone, PartialEq, Eq)]
572struct PathStamp {
573    path: PathBuf,
574    kind: StampKind,
575    len: u64,
576    modified_nanos: Option<u128>,
577}
578
579#[derive(Debug, Clone, Copy, PartialEq, Eq)]
580enum StampKind {
581    Missing,
582    File,
583    Directory,
584    Other,
585}
586
587fn source_fingerprint(
588    path: &Path,
589    session: &Session,
590    selected_session: Option<&str>,
591    include_subagents: bool,
592) -> Result<Vec<PathStamp>> {
593    let mut stamps = vec![path_stamp(path)?];
594    match session.meta.source {
595        SessionSource::ClaudeCode if include_subagents => {
596            if let (Some(parent), Some(stem)) = (path.parent(), path.file_stem()) {
597                collect_tree_stamps(&parent.join(stem).join("subagents"), &mut stamps)?;
598            }
599        }
600        SessionSource::OpenCode if looks_like_sqlite(path) => {
601            stamps.push(path_stamp(&path_with_suffix(path, "-wal"))?);
602            stamps.push(path_stamp(&path_with_suffix(path, "-shm"))?);
603            if let (Some(parent), Some(session_id)) = (path.parent(), selected_session) {
604                stamps.push(path_stamp(
605                    &parent
606                        .join("storage")
607                        .join("session_diff")
608                        .join(format!("{session_id}.json")),
609                )?);
610            }
611        }
612        SessionSource::Grok => {
613            if let Some(parent) = path.parent() {
614                // `chat_history.jsonl` is the resumable transcript. The
615                // adjacent update stream and summary are cheap companion
616                // stamps that make a running Grok session wake the follower
617                // even while it is between committed transcript turns.
618                stamps.push(path_stamp(&parent.join("updates.jsonl"))?);
619                stamps.push(path_stamp(&parent.join("summary.json"))?);
620            }
621        }
622        SessionSource::Native => {
623            if let (Some(parent), Some(stem)) = (path.parent(), path.file_stem()) {
624                stamps.push(path_stamp(
625                    &parent.join(format!("{}.sidecar.jsonl", stem.to_string_lossy())),
626                )?);
627                stamps.push(path_stamp(
628                    &parent.join(format!("{}.meta.json", stem.to_string_lossy())),
629                )?);
630                collect_tree_stamps(
631                    &parent.join(format!("{}.subagents", stem.to_string_lossy())),
632                    &mut stamps,
633                )?;
634            }
635        }
636        _ => {}
637    }
638    stamps.sort_by(|left, right| left.path.cmp(&right.path));
639    Ok(stamps)
640}
641
642fn collect_tree_stamps(path: &Path, out: &mut Vec<PathStamp>) -> Result<()> {
643    collect_tree_stamps_inner(path, out, true)
644}
645
646fn collect_tree_stamps_inner(path: &Path, out: &mut Vec<PathStamp>, follow: bool) -> Result<()> {
647    let stamp = if follow {
648        path_stamp(path)?
649    } else {
650        path_stamp_no_follow(path)?
651    };
652    let is_directory = stamp.kind == StampKind::Directory;
653    out.push(stamp);
654    if !is_directory {
655        return Ok(());
656    }
657
658    let mut children = std::fs::read_dir(path)?.collect::<std::io::Result<Vec<_>>>()?;
659    children.sort_by_key(|entry| entry.path());
660    for child in children {
661        collect_tree_stamps_inner(&child.path(), out, false)?;
662    }
663    Ok(())
664}
665
666fn path_stamp(path: &Path) -> Result<PathStamp> {
667    path_stamp_with(path, |path| std::fs::metadata(path))
668}
669
670fn path_stamp_no_follow(path: &Path) -> Result<PathStamp> {
671    path_stamp_with(path, |path| std::fs::symlink_metadata(path))
672}
673
674fn path_stamp_with(
675    path: &Path,
676    metadata: impl FnOnce(&Path) -> std::io::Result<Metadata>,
677) -> Result<PathStamp> {
678    match metadata(path) {
679        Ok(metadata) => Ok(stamp_from_metadata(path, &metadata)),
680        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(PathStamp {
681            path: path.to_path_buf(),
682            kind: StampKind::Missing,
683            len: 0,
684            modified_nanos: None,
685        }),
686        Err(error) => Err(error.into()),
687    }
688}
689
690fn path_with_suffix(path: &Path, suffix: &str) -> PathBuf {
691    let mut value = path.as_os_str().to_os_string();
692    value.push(suffix);
693    PathBuf::from(value)
694}
695
696fn stamp_from_metadata(path: &Path, metadata: &Metadata) -> PathStamp {
697    let file_type = metadata.file_type();
698    let kind = if file_type.is_file() {
699        StampKind::File
700    } else if file_type.is_dir() {
701        StampKind::Directory
702    } else {
703        StampKind::Other
704    };
705    PathStamp {
706        path: path.to_path_buf(),
707        kind,
708        len: metadata.len(),
709        modified_nanos: metadata
710            .modified()
711            .ok()
712            .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
713            .map(|duration| duration.as_nanos()),
714    }
715}
716
717#[cfg(test)]
718mod tests {
719    use super::append_prefix_len;
720    use crate::ChatMessage;
721
722    #[test]
723    fn bounded_append_overlap_handles_plain_and_user_anchored_windows() {
724        let user = ChatMessage::user("anchor");
725        let one = ChatMessage::assistant("one");
726        let two = ChatMessage::assistant("two");
727        let three = ChatMessage::assistant("three");
728        let newest = ChatMessage::user("newest");
729
730        assert_eq!(
731            append_prefix_len(
732                &[one.clone(), two.clone(), three.clone()],
733                &[two.clone(), three.clone(), newest.clone()],
734            ),
735            2,
736        );
737        assert_eq!(
738            append_prefix_len(
739                &[user.clone(), one, two.clone(), three.clone()],
740                &[user, two, three, newest],
741            ),
742            3,
743        );
744    }
745}