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