diff --git a/crates/tty7-core/src/core/machine.rs b/crates/tty7-core/src/core/machine.rs index 794bcec9..1aec2d27 100644 --- a/crates/tty7-core/src/core/machine.rs +++ b/crates/tty7-core/src/core/machine.rs @@ -414,10 +414,10 @@ pub struct AgentFacts { /// flags (`--dangerously-skip-permissions`, …) instead of resuming bare. #[serde(default)] pub launch_argv: Option>, - /// Latest coarse status the hooks reported ("thinking", "idle", …). - /// Display only; never load-bearing. + /// Latest coarse status the daemon's sniffer folded from the agent's + /// hook events. Display only; never load-bearing. #[serde(default)] - pub status: Option, + pub status: Option, } /// The facts a client hands over when an operation introduces a pane the store @@ -1333,6 +1333,41 @@ fn load_machine(path: &Path) -> Machine { } } +// --------------------------------------------------------------------------- +// The daemon's own observations +// --------------------------------------------------------------------------- + +/// The store the running daemon's pane server publishes its observations into. +/// +/// A process-wide slot rather than a parameter threaded through `DaemonPane`, +/// for the same reason the control dialect's event observer is one: the +/// observers (every pane's reader thread) and the owner (the control listener +/// the daemon starts) come up independently in code that long predates the +/// tree, and each of the three pane-spawn paths would otherwise have to be +/// taught to carry an `Option>` it never reads. Last install +/// wins; `None` — a process serving panes with no tree, or a unit test — +/// simply drops observations. +static OBSERVED: Mutex>> = Mutex::new(None); + +/// Install `store` as where [`observe_pane`] lands. The daemon calls this once +/// while wiring its control services. +pub fn publish_observations(store: &Arc) { + *OBSERVED.lock().unwrap_or_else(|e| e.into_inner()) = Some(Arc::clone(store)); +} + +/// Record an observation about `pane` — a cwd the shell reported, an agent the +/// sniffer identified, a death — in the installed store, if there is one. +/// +/// Facts about panes the tree never adopted are dropped by the store itself +/// (see [`MachineStore::note_pane_facts`]), so callers report unconditionally +/// and pay nothing for a pane that is nobody's business. +pub fn observe_pane(pane: u64, f: impl FnOnce(&mut PaneRecord)) { + let store = OBSERVED.lock().unwrap_or_else(|e| e.into_inner()).clone(); + if let Some(store) = store { + store.note_pane_facts(pane, f); + } +} + /// Copy a file we are about to stop honouring somewhere the user can find it. fn quarantine(path: &Path) { let aside = path.with_extension("json.corrupt"); @@ -1857,6 +1892,23 @@ mod tests { let _ = ws; } + /// The pane server's side door: once a store is installed, an observation + /// lands on the record like any other fact — and before/without one, + /// observing is a quiet no-op, which is what lets the pane code report + /// unconditionally. + #[test] + fn published_observations_land_in_the_installed_store() { + observe_pane(1, |p| p.cwd = Some("/nowhere".into())); + + let (store, _dir, _ws, _tab) = store_with_tab(); + publish_observations(&store); + observe_pane(1, |p| p.cwd = Some("/observed/here".into())); + assert_eq!( + store.pane(1).unwrap().cwd.as_deref(), + Some("/observed/here") + ); + } + // ── Attachment ───────────────────────────────────────────────────────── #[test] diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index cfb1f238..a5a036bb 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -630,6 +630,12 @@ impl OutputGate { /// PTY master, writer, child) so a single `Mutex` guards everything the reader /// thread and the connection threads both touch. struct PaneState { + /// The registry id of the pane this state belongs to — [`DaemonPane::id`], + /// duplicated here so the code paths that only ever see the state (the + /// signal appliers, [`DeathReporter::report`]) can name the pane when + /// publishing an observation to the machine tree + /// ([`crate::core::machine::observe_pane`]). + id: u64, /// The replay ring: raw PTY bytes bounded to `RING_CAP`, segmented by the /// geometry they were recorded under so `attach` can replay each stretch /// at the width it was written for. Also the owner of the pane's current @@ -812,7 +818,14 @@ impl DeathReporter { } let mut st = state.lock().unwrap(); st.alive = false; + let pane = st.id; if shutting_down.load(Ordering::SeqCst) { + drop(st); + // Even a teardown the owner initiated is a death the tree must + // hear about: the record's `live == false` *is* the client-visible + // "awaiting revival" state, and it must not depend on which thread + // noticed the child go. + crate::core::machine::observe_pane(pane, |p| p.live = false); return; } let subscribed = st.subscriber.is_some(); @@ -820,6 +833,7 @@ impl DeathReporter { let _ = sub.send(DaemonMsg::Exited { code: None }); } drop(st); + crate::core::machine::observe_pane(pane, |p| p.live = false); // A subscriber's later detach reclaims the pane, so only an *unattached* // death needs `on_dead` — and it fires at most once. if subscribed { @@ -870,6 +884,7 @@ impl DaemonPane { let writer = pair.master.take_writer()?; let state = Arc::new(Mutex::new(PaneState { + id, ring: ReplayRing::new(size), subscriber: None, subscriber_epoch: 0, @@ -979,6 +994,7 @@ impl DaemonPane { }; let state = Arc::new(Mutex::new(PaneState { + id, ring: ReplayRing::new(size), subscriber: None, subscriber_epoch: 0, @@ -1241,6 +1257,7 @@ impl DaemonPane { let tr1 = trace.then(std::time::Instant::now); let mut st = state.lock().unwrap(); + let facts_before = observed_facts(&st); st.ring.append(bytes); if let Some(sub) = &st.subscriber { // A send error just means the client is gone; ignore @@ -1265,6 +1282,34 @@ impl DaemonPane { if let Some(tr1) = tr1 { tr_disp_t += tr1.elapsed(); } + // Publish what this chunk changed to the machine + // tree — outside the state lock, because a changed + // fact persists a file and this thread's stalls + // are the child's write stalls. Gated on a real + // change so the per-chunk cost is two clones and a + // compare, not a store mutation per chunk. + let pane = st.id; + let facts_after = observed_facts(&st); + drop(st); + if facts_after != facts_before { + let (cwd, agent) = facts_after; + crate::core::machine::observe_pane(pane, |p| { + // An unknown cwd never clears a seeded one: + // the spawn directory in the record is + // better revival information than nothing. + if cwd.is_some() { + p.cwd = cwd; + } + // The agent fact applies wholesale — its + // `None` means the agent left the + // foreground, and a revival must not + // resume a session that already ended. + p.agent = agent; + // Output is proof of life, whatever the + // record thought. + p.live = true; + }); + } } Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue, Err(_) => break, // EIO after hangup, etc. @@ -1882,6 +1927,31 @@ fn attach_subscriber(st: &mut PaneState, subscriber: Sender) -> u64 { st.subscriber_epoch } +/// The slice of a pane's state the machine tree records about it — the cwd a +/// successor would spawn in, and the agent facts a successor would resume. +/// Captured before and after a chunk's signal application so the (rare) change +/// is published outside the state lock; see the reader loop. +/// +/// The cwd crosses as a `String` because the tree's records do (the dialect's +/// path rule); the loss, if any, happens here where it can be seen next to the +/// path that caused it. +fn observed_facts(st: &PaneState) -> (Option, Option) { + let cwd = st.cwd.as_ref().map(|p| p.to_string_lossy().into_owned()); + let agent = st.agent.map(|agent| crate::core::machine::AgentFacts { + agent, + session_id: st.agent_session.as_ref().and_then(|s| s.session_id.clone()), + // The session's own argv record wins — it survives the chip clearing — + // with the identity poll's capture as the fallback until it is stamped. + launch_argv: st + .agent_session + .as_ref() + .and_then(|s| s.launch_argv.clone()) + .or_else(|| st.agent_argv.clone()), + status: st.agent_session.as_ref().map(|s| s.status), + }); + (cwd, agent) +} + /// Apply sniffed signals to the shared state and notify the subscriber of any cwd /// / prompt change. Called with the state lock held. fn apply_signals(st: &mut PaneState, signals: SniffSignals) { @@ -3649,6 +3719,9 @@ mod tests { /// A fresh `PaneState` for the PTY-less state-machine tests. fn test_state(alive: bool) -> PaneState { PaneState { + // Unit tests publish observations nowhere (no store is installed + // in this process), so the id is never consulted. + id: 0, ring: ReplayRing::new(ws(80, 24)), subscriber: None, subscriber_epoch: 0, @@ -3662,6 +3735,49 @@ mod tests { } } + /// What the machine tree is told about a pane is exactly what a successor + /// needs: the cwd as a string, the session's own argv over the poll's + /// capture (the session record survives chip churn), and the coarse + /// status. No agent, no facts — a revival must not resume a session that + /// was never there. + #[test] + fn observed_facts_prefer_the_sessions_argv_and_carry_its_status() { + use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent}; + + let mut st = test_state(true); + assert_eq!(observed_facts(&st), (None, None)); + + st.cwd = Some(PathBuf::from("/work/api")); + st.agent = Some(CLIAgent::Claude); + st.agent_argv = Some(vec!["claude".into()]); + st.agent_session = Some(AgentSessionState { + status: AgentStatus::Working, + session_id: Some("sess-1".into()), + launch_argv: Some(vec!["claude".into(), "--model".into(), "opus".into()]), + ..Default::default() + }); + + let (cwd, agent) = observed_facts(&st); + assert_eq!(cwd.as_deref(), Some("/work/api")); + let agent = agent.expect("an agent in the foreground is a fact"); + assert_eq!(agent.agent, CLIAgent::Claude); + assert_eq!(agent.session_id.as_deref(), Some("sess-1")); + assert_eq!( + agent.launch_argv.as_deref(), + Some(&["claude".to_string(), "--model".into(), "opus".into()][..]), + "the session's own argv outranks the identity poll's capture" + ); + assert_eq!(agent.status, Some(AgentStatus::Working)); + + // The poll's capture is the fallback until the session stamps its own. + st.agent_session = None; + let (_, agent) = observed_facts(&st); + assert_eq!( + agent.unwrap().launch_argv.as_deref(), + Some(&["claude".to_string()][..]) + ); + } + /// The full daemon-side rich-status path: sentinel OSC events sniffed out /// of the byte stream drive the pane's session state machine, identify the /// agent when argv detection hasn't, and stream every change to the diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index 413f0ddd..17c61876 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -174,6 +174,11 @@ pub fn control_services() -> crate::host::server::Services { match MachineStore::shared() { Ok(machine) => { log::info!("machine tree at {}", machine.path().display()); + // From here on the pane server's own observations — OSC 7 cwds, + // agent identities, deaths — land on the tree's pane records, so + // what a client revives from is what the machine saw, not what + // some client last remembered to write. + crate::core::machine::publish_observations(&machine); services.and_machine(machine) } Err(e) => {