← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-shotlog"-version = "0.5.0"+version = "0.6.0" dependencies = [  "axum",  "base64",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.5.0"+version = "0.6.0" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
SKILL.md+11−6
@@ -132,18 +132,23 @@ Injecting a shot is not the job — the human *seeing* it is. shotlog tells you whether a live viewer is connected, and on which **surface** (webview / pup / mobile / browser), **in the inject response itself** — no guessing, no extra turns: -- **Viewer connected:** `OK: 1 live viewer(s) connected to 'ch' [pup]`. Connected-  ≠ guaranteed visible: if that surface might be a background tab or minimized-  window, confirm it's the human's foreground surface.+- **On-screen:** `OK: 1 of 1 viewer(s) ON-SCREEN [pup] — a human can see this shot.`+- **Open but backgrounded:** `WARN: 1 viewer(s) connected … but ALL are+  BACKGROUNDED / minimized — the human is NOT looking right now.` Bring the pup+  window / webview tab / phone to the FOREGROUND, then re-check. - **Nobody watching:** `WARN: NO live viewer is connected — the human is NOT   seeing this shot.` A viewer is force-opened as a fallback, but **do not assume**   it worked. Open the viewer on the human's actual surface and re-check. -**Gate programmatically instead of burning turns** — `shotlog viewers` exits `0`-when a human is watching, `3` when nobody is:+The signal is real: each viewer holds a WebSocket open and reports its **surface**+(webview/pup/mobile/browser) AND whether it's actually **on-screen** (Page+Visibility) vs a background tab / minimized window.++**Gate programmatically instead of burning turns** — `shotlog viewers` exit codes:+`0` = a human has it on-screen, `4` = connected but all backgrounded, `3` = nobody:  ```bash-shotlog viewers -c my-project              # exit 0 = seen, 3 = unseen; prints surfaces+shotlog viewers -c my-project              # 0=on-screen, 4=backgrounded, 3=none; prints surfaces shotlog viewers -c my-project --url "$URL" # also HTTP-checks the endpoint you opened ``` 
VERSION+1−1
@@ -1 +1 @@-0.5.0+0.6.0
bin/adom-shotlog
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-shotlog",-  "version": "0.5.0",+  "version": "0.6.0",   "type": "app",   "title": "Adom Shotlog",   "description": "See the shots your AI never showed you \u2014 and know it actually SAW them. adom-shotlog streams every screenshot the AI gathers into a live gallery (webview/pup/browser/phone), reports whether a human is really watching + on which surface, and ingests Adom Desktop owned-popup arrays via --meta.",
page.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-shotlog",-  "version": "0.5.0",+  "version": "0.6.0",   "type": "app",   "title": "Adom Shotlog",   "description": "See the shots your AI never showed you — and know it actually SAW them. adom-shotlog streams every screenshot the AI gathers into a live gallery (webview/pup/browser/phone), reports whether a human is really watching + on which surface, and ingests Adom Desktop owned-popup arrays via --meta.",
src/html/index.html+11−2
@@ -484,13 +484,22 @@ header {   }).catch(e=>{ feed.innerHTML='<div class="empty">load failed: '+esc(e.message)+'</div>'; });    // ── WebSocket live updates ({type:'entry', entry}) ───────────────────────+  let activeWs = null;+  // Report on-screen state whenever this tab/window is shown or hidden, so the+  // server can tell "watching" from "open but backgrounded/minimized".+  document.addEventListener('visibilitychange', () => {+    try { if (activeWs && activeWs.readyState===1)+      activeWs.send(JSON.stringify({type:'vis', visible: document.visibilityState==='visible'})); } catch {}+  });   function connectWs() {     const proto = location.protocol==='https:'?'wss:':'ws:';     const baseHref = document.querySelector('base')?.href.replace(location.origin,'') || '/';     const ws = new WebSocket(`${proto}//${location.host}${baseHref}ws/${encodeURIComponent(channel)}`);     ws.onopen = () => { wsLed.classList.add('live'); wsLed.setAttribute('data-tooltip','WebSocket live — the server knows you are watching.');-      // Tell the server WHICH surface is watching, so `inject`/`viewers` can report it.-      try { ws.send(JSON.stringify({type:'hello', surface:detectSurface()})); } catch {} };+      activeWs = ws;+      // Tell the server WHICH surface is watching + whether it's on-screen right now,+      // so `inject`/`viewers` can distinguish "watching" from "open but backgrounded".+      try { ws.send(JSON.stringify({type:'hello', surface:detectSurface(), visible: document.visibilityState==='visible'})); } catch {} };     ws.onmessage = ev => {       try {         const msg = JSON.parse(ev.data);
src/main.rs+42−20
@@ -140,9 +140,10 @@ enum Commands {         port: u16,     },     /// Verify a HUMAN is actually watching a channel's viewer (live WebSocket-    /// presence). Exits 0 if ≥1 viewer is connected, 3 if none — so an AI can-    /// gate on it without a round of guessing. Prints the connected surfaces-    /// (webview / pup / mobile / browser).+    /// presence + Page Visibility). Exit 0 = ≥1 viewer ON-SCREEN, 4 = viewer(s)+    /// connected but ALL backgrounded/minimized, 3 = nobody connected — so an AI+    /// can gate precisely without guessing. Prints the surfaces (webview / pup /+    /// mobile / browser).     Viewers {         /// Channel name         #[arg(short, long)]@@ -442,19 +443,30 @@ fn run_inject(                     }                 }                 // Presence check — is the human ACTUALLY seeing this, and where?-                if let Some(viewers) = val.get("viewers").and_then(|v| v.as_u64()) {-                    let surfaces: Vec<String> = val.get("surfaces").and_then(|v| v.as_array())+                let getvec = |key: &str| -> Vec<String> {+                    val.get(key).and_then(|v| v.as_array())                         .map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect())-                        .unwrap_or_default();-                    if viewers > 0 {-                        let surf = if surfaces.is_empty() { String::new() } else { format!(" [{}]", surface_label(&surfaces)) };-                        ok(&format!("{viewers} live viewer(s) connected to '{channel}'{surf}"));-                        hint("Are you sure the human is seeing the shotlog? Is it open in a webview or pup? Have you verified it's truly showing?");-                        hint("Connected ≠ necessarily visible: if the surface could be a background tab / minimized window, confirm it's the human's foreground surface.");+                        .unwrap_or_default()+                };+                if let Some(viewers) = val.get("viewers").and_then(|v| v.as_u64()) {+                    let surfaces = getvec("surfaces");+                    let visible = val.get("visible_viewers").and_then(|v| v.as_u64()).unwrap_or(0);+                    let vis_surf = getvec("visible_surfaces");+                    // The hint the human asked to ride on EVERY inject.+                    let verify = "Are you sure the human is seeing the shotlog? Is it open in a webview or pup? Have you verified it's truly showing?";+                    if visible > 0 {+                        let s = if vis_surf.is_empty() { String::new() } else { format!(" [{}]", surface_label(&vis_surf)) };+                        ok(&format!("{visible} of {viewers} viewer(s) ON-SCREEN{s} — a human can see this shot."));+                        hint(verify);+                    } else if viewers > 0 {+                        let s = if surfaces.is_empty() { String::new() } else { format!(" [{}]", surface_label(&surfaces)) };+                        warn(&format!("{viewers} viewer(s) connected to '{channel}'{s} but ALL are BACKGROUNDED / minimized — the human is NOT looking right now."));+                        hint(verify);+                        hint(&format!("Bring the viewer to the FOREGROUND (the pup window / webview tab / phone), then re-check: `shotlog viewers -c {channel}`."));                     } else {                         warn(&format!("NO live viewer is connected to '{channel}' — the human is NOT seeing this shot."));-                        hint("Are you sure the human is seeing the shotlog? Is it open in a webview or pup? Have you verified it's truly showing?");-                        hint(&format!("Open it, then RE-CHECK before continuing: `shotlog open -c {channel}` (Hydrogen webview) — or load the viewer in pup / on the phone — then `shotlog viewers -c {channel}` (exits non-zero until a viewer is live)."));+                        hint(verify);+                        hint(&format!("Open it, then RE-CHECK before continuing: `shotlog open -c {channel}` (Hydrogen webview) — or load the viewer in pup / on the phone — then `shotlog viewers -c {channel}`."));                         hint("A viewer was force-opened as a fallback, but do NOT assume the human sees it — verify their actual surface.");                     }                 }@@ -535,9 +547,14 @@ fn run_viewers(channel: &str, port: u16, url: Option<&str>) {         }     };     let viewers = val["viewers"].as_u64().unwrap_or(0);-    let surfaces: Vec<String> = val["surfaces"].as_array()-        .map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect())-        .unwrap_or_default();+    let visible = val["visible"].as_u64().unwrap_or(0);+    let getvec = |key: &str| -> Vec<String> {+        val[key].as_array()+            .map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect())+            .unwrap_or_default()+    };+    let surfaces = getvec("surfaces");+    let vis_surf = getvec("visible_surfaces");      // Optional: the AI passes the endpoint it opened; we confirm it's reachable.     if let Some(u) = url {@@ -558,11 +575,16 @@ fn run_viewers(channel: &str, port: u16, url: Option<&str>) {         }     } -    if viewers > 0 {-        let surf = if surfaces.is_empty() { String::new() } else { format!(" [{}]", surface_label(&surfaces)) };-        ok(&format!("{viewers} live viewer(s) watching '{channel}'{surf} — a human can see it."));-        hint("Connected ≠ necessarily visible: if a surface could be a background tab / minimized window, confirm it's foregrounded before trusting it.");+    // Three states: on-screen (0), open-but-backgrounded (4), nobody (3).+    if visible > 0 {+        let surf = if vis_surf.is_empty() { String::new() } else { format!(" [{}]", surface_label(&vis_surf)) };+        ok(&format!("{visible} of {viewers} viewer(s) ON-SCREEN{surf} watching '{channel}' — a human can see it."));         std::process::exit(0);+    } else if viewers > 0 {+        let surf = if surfaces.is_empty() { String::new() } else { format!(" [{}]", surface_label(&surfaces)) };+        warn(&format!("{viewers} viewer(s) connected to '{channel}'{surf} but ALL are BACKGROUNDED / minimized — the human isn't looking right now."));+        hint(&format!("Bring the viewer to the FOREGROUND (pup window / webview tab / phone), then re-run `shotlog viewers -c {channel}`."));+        std::process::exit(4);     } else {         warn(&format!("NO live viewer connected to '{channel}' — nothing you inject here is being seen."));         hint(&format!("Open it: `shotlog open -c {channel}` (Hydrogen webview), or load the viewer in pup / on the phone, then re-run `shotlog viewers -c {channel}`."));
src/server.rs+39−12
@@ -138,13 +138,18 @@ async fn viewer_status(     Query(q): Query<ChannelQuery>, ) -> Json<serde_json::Value> {     let channel = q.channel.unwrap_or_else(|| "default".into());-    let viewers = state.store.viewer_count(&channel).await;-    let surfaces = state.store.viewer_surfaces(&channel).await;+    let infos = state.store.viewer_infos(&channel).await;+    let viewers = infos.len();+    let surfaces: Vec<String> = infos.iter().map(|i| i.surface.clone()).collect();+    let visible: usize = infos.iter().filter(|i| i.visible).count();+    let visible_surfaces: Vec<String> = infos.iter().filter(|i| i.visible).map(|i| i.surface.clone()).collect();     Json(serde_json::json!({         "channel": channel,         "viewers": viewers,         "has_viewers": viewers > 0,         "surfaces": surfaces,+        "visible": visible,+        "visible_surfaces": visible_surfaces,     })) } @@ -265,6 +270,10 @@ struct InjectResponse {     /// can immediately tell whether the human is actually seeing this shot.     viewers: usize,     surfaces: Vec<String>,+    /// Of those, how many are actually ON-SCREEN (Page Visibility) vs a+    /// background tab / minimized window, and which surfaces those are.+    visible_viewers: usize,+    visible_surfaces: Vec<String>, }  async fn post_shots(@@ -325,9 +334,12 @@ async fn post_shots(         .await;      // Presence at inject time — the caller uses this to know if the human is-    // actually watching (and on which surface) without a round of guessing.-    let viewers = state.store.viewer_count(&req.channel).await;-    let surfaces = state.store.viewer_surfaces(&req.channel).await;+    // actually watching (and on which surface / on-screen) without guessing.+    let infos = state.store.viewer_infos(&req.channel).await;+    let viewers = infos.len();+    let surfaces: Vec<String> = infos.iter().map(|i| i.surface.clone()).collect();+    let visible_viewers = infos.iter().filter(|i| i.visible).count();+    let visible_surfaces: Vec<String> = infos.iter().filter(|i| i.visible).map(|i| i.surface.clone()).collect();      // If nobody is watching this channel, force-open a viewer so the user     // actually sees the shot instead of it landing invisibly in the log.@@ -348,6 +360,8 @@ async fn post_shots(         original_h: entry.orig_h,         viewers,         surfaces,+        visible_viewers,+        visible_surfaces,     })) } @@ -401,8 +415,11 @@ async fn post_paste(         .await;  -    let viewers = state.store.viewer_count(&req.channel).await;-    let surfaces = state.store.viewer_surfaces(&req.channel).await;+    let infos = state.store.viewer_infos(&req.channel).await;+    let viewers = infos.len();+    let surfaces: Vec<String> = infos.iter().map(|i| i.surface.clone()).collect();+    let visible_viewers = infos.iter().filter(|i| i.visible).count();+    let visible_surfaces: Vec<String> = infos.iter().filter(|i| i.visible).map(|i| i.surface.clone()).collect();      Ok(Json(InjectResponse {         ok: true,@@ -417,6 +434,8 @@ async fn post_paste(         original_h: if was_resized { Some(orig_h) } else { None },         viewers,         surfaces,+        visible_viewers,+        visible_surfaces,     })) } @@ -554,7 +573,7 @@ async fn ws_connection(mut socket: WebSocket, channel: String, state: Arc<AppSta     let vid = state         .next_viewer_id         .fetch_add(1, std::sync::atomic::Ordering::Relaxed);-    state.store.register_viewer(&channel, vid, "browser".into()).await;+    state.store.register_viewer(&channel, vid, "browser".into(), true).await;      loop {         tokio::select! {@@ -577,12 +596,20 @@ async fn ws_connection(mut socket: WebSocket, channel: String, state: Arc<AppSta                 match msg {                     Some(Ok(Message::Close(_))) | None => break,                     Some(Ok(Message::Text(t))) => {-                        // {type:'hello', surface:'pup'|'webview'|'mobile'|'browser'}+                        // {type:'hello', surface, visible} on connect; {type:'vis', visible}+                        // whenever the tab is shown/hidden (Page Visibility).                         if let Ok(v) = serde_json::from_str::<serde_json::Value>(&t) {-                            if v.get("type").and_then(|x| x.as_str()) == Some("hello") {-                                if let Some(s) = v.get("surface").and_then(|x| x.as_str()) {-                                    state.store.register_viewer(&channel, vid, s.to_string()).await;+                            match v.get("type").and_then(|x| x.as_str()) {+                                Some("hello") => {+                                    let surface = v.get("surface").and_then(|x| x.as_str()).unwrap_or("browser").to_string();+                                    let visible = v.get("visible").and_then(|x| x.as_bool()).unwrap_or(true);+                                    state.store.register_viewer(&channel, vid, surface, visible).await;                                 }+                                Some("vis") => {+                                    let visible = v.get("visible").and_then(|x| x.as_bool()).unwrap_or(true);+                                    state.store.set_viewer_visibility(&channel, vid, visible).await;+                                }+                                _ => {}                             }                         }                     }
src/store.rs+31−6
@@ -45,12 +45,20 @@ struct Channel {  const MAX_ENTRIES: usize = 200; +/// One connected viewer: which surface it's on, and whether that surface is+/// actually on-screen (Page Visibility) vs a background tab / minimized window.+#[derive(Clone)]+pub struct ViewerInfo {+    pub surface: String,+    pub visible: bool,+}+ pub struct Store {     channels: RwLock<HashMap<String, Channel>>,     base_dir: PathBuf,-    /// Live viewer presence: channel -> (connection id -> surface label).+    /// Live viewer presence: channel -> (connection id -> viewer info).     /// Surface is what the viewer reported on connect: webview | pup | mobile | browser.-    presence: RwLock<HashMap<String, HashMap<u64, String>>>,+    presence: RwLock<HashMap<String, HashMap<u64, ViewerInfo>>>, }  impl Store {@@ -277,10 +285,21 @@ impl Store {         channels.get(channel_name).map(|ch| ch.tx.receiver_count()).unwrap_or(0)     } -    /// Register a connected viewer with the surface it reported (webview/pup/mobile/browser).-    pub async fn register_viewer(&self, channel_name: &str, id: u64, surface: String) {+    /// Register (or update) a connected viewer with the surface it reported+    /// (webview/pup/mobile/browser) and whether it's currently on-screen.+    pub async fn register_viewer(&self, channel_name: &str, id: u64, surface: String, visible: bool) {+        let mut p = self.presence.write().await;+        p.entry(channel_name.to_string())+            .or_default()+            .insert(id, ViewerInfo { surface, visible });+    }++    /// Update just the on-screen (Page Visibility) state of an existing viewer.+    pub async fn set_viewer_visibility(&self, channel_name: &str, id: u64, visible: bool) {         let mut p = self.presence.write().await;-        p.entry(channel_name.to_string()).or_default().insert(id, surface);+        if let Some(v) = p.get_mut(channel_name).and_then(|m| m.get_mut(&id)) {+            v.visible = visible;+        }     }      /// Drop a viewer on disconnect.@@ -292,10 +311,16 @@ impl Store {         }     } +    /// Full presence for a channel — one entry per connected viewer.+    pub async fn viewer_infos(&self, channel_name: &str) -> Vec<ViewerInfo> {+        let p = self.presence.read().await;+        p.get(channel_name).map(|m| m.values().cloned().collect()).unwrap_or_default()+    }+     /// Surfaces of the live viewers for a channel, e.g. ["pup","mobile"].     pub async fn viewer_surfaces(&self, channel_name: &str) -> Vec<String> {         let p = self.presence.read().await;-        p.get(channel_name).map(|m| m.values().cloned().collect()).unwrap_or_default()+        p.get(channel_name).map(|m| m.values().map(|v| v.surface.clone()).collect()).unwrap_or_default()     }      /// Get a broadcast receiver for a channel's SSE stream.