← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-shotlog"-version = "0.4.2"+version = "0.5.0" dependencies = [  "axum",  "base64",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.4.5"+version = "0.5.0" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
README.md+24−3
@@ -51,8 +51,6 @@ adom-shotlog inject -c checkout -d "checkout form with autofill open" primary.pn  The gallery card gets a **+N popups** badge; the zoom modal shows each popup as a viewable thumbnail; and an **ⓘ meta** chip opens a copyable panel with shotId, screenRect, dpiScale, imageToScreen, and screen resolution. -![Popup array + capture metadata in the zoom modal](screenshots/popups.png)- ## Gallery — desktop  ![Desktop gallery](screenshots/gallery-desktop.png)@@ -67,14 +65,37 @@ Newest-first cards, each with a prominent description, dual-size metadata, sourc - **Gallery viewer** — newest-first, prominent descriptions, dual-size metadata, tap-to-zoom, mobile layout, tooltips - **Phone-ready** — open the same URL on your phone; the layout adapts - **Live updates + presence LED** — new shots stream in; a green dot shows you're connected+- **Presence verification** — `inject` reports whether a human is actually watching and on which surface (webview/pup/mobile/browser); `viewers` exits 0/3 so an AI can *gate* on "was it seen?" instead of guessing - **Force-open safety net** — an unwatched channel auto-opens a viewer - **Clipboard paste** — Ctrl+V anywhere pastes a screenshot +## Is the human actually seeing it?++An AI injecting shots into a channel nobody's watching is worse than useless — it *thinks* it showed you something. So shotlog answers "is a human seeing this?" **in the inject response**:++```+OK: Injected board-after-routing.png (1400x788, orig 1920x1080, 17 KB) into channel board-review+OK: 1 live viewer(s) connected to 'board-review' [pup]+```++…or, if nobody's watching:++```+WARN: NO live viewer is connected to 'board-review' — the human is NOT seeing this shot.+```++The live-viewer signal comes from the WebSocket the viewer holds open, so it's authoritative — and each viewer reports its **surface** (webview / pup / mobile / browser). To gate a workflow programmatically:++```+adom-shotlog viewers -c board-review          # exit 0 = a human is watching, 3 = nobody is+```+ ## CLI reference  | Command | Description | |---|---|-| `adom-shotlog inject` | Inject a screenshot into a channel (records the original resolution; `--meta` adds popups) |+| `adom-shotlog inject` | Inject a screenshot into a channel (records the original resolution; `--meta` adds popups; reports live viewers) |+| `adom-shotlog viewers` | Is a human watching? Exit 0/3 + connected surfaces (webview/pup/mobile/browser) | | `adom-shotlog open` | Open the gallery viewer as a Hydrogen webview tab | | `adom-shotlog serve` | Start the HTTP server (port 8820) | | `adom-shotlog health` | Check if the server is running |
SKILL.md+27
@@ -126,11 +126,38 @@ adom-shotlog open -c my-project          # opens the gallery as a Hydrogen webvi If a screenshot is injected into a channel with **no viewer connected**, the server auto-opens a viewer tab so the shot is never logged invisibly. +## CRITICAL: verify the HUMAN is actually seeing it++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.+- **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:++```bash+shotlog viewers -c my-project              # exit 0 = seen, 3 = unseen; prints surfaces+shotlog viewers -c my-project --url "$URL" # also HTTP-checks the endpoint you opened+```++Typical flow: `shotlog open -c ch` (or load it in pup / on the phone) → keep+injecting → if inject ever reports 0 viewers, stop and re-establish the viewer.+The viewer reports its surface automatically; when *you* open it in pup, append+`?surface=pup` to the URL so presence labels it correctly.+ ## Other commands  ```bash adom-shotlog serve                # start the server (port 8820) adom-shotlog health               # check server status+adom-shotlog viewers -c CHANNEL   # is a human watching? exit 0=yes, 3=no (+ surfaces) adom-shotlog resize FILE          # rarely needed — inject auto-resizes adom-shotlog install              # install binary + this skill + completions + `shotlog` alias ```
VERSION+1−1
@@ -1 +1 @@-0.4.5+0.5.0
bin/adom-shotlog
⋯ 1 unchanged line ⋯
package.json+2−2
@@ -1,9 +1,9 @@ {   "slug": "adom-shotlog",-  "version": "0.4.5",+  "version": "0.5.0",   "type": "app",   "title": "Adom Shotlog",-  "description": "See the shots your AI never showed you. Your AI screenshots your app while it tests but never surfaces them (it thinks in text) \u2014 adom-shotlog streams every shot it gathered into a live gallery you open in a webview, pup, your browser, or your phone. Now ingests Adom Desktop owned-popup arrays + coordMap via --meta.",+  "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.",   "license": "MIT",   "dependencies": {},   "scripts": {
page.json+2−2
@@ -1,9 +1,9 @@ {   "slug": "adom-shotlog",-  "version": "0.4.5",+  "version": "0.5.0",   "type": "app",   "title": "Adom Shotlog",-  "description": "See the shots your AI never showed you. Your AI screenshots your app while it tests but never surfaces them (it thinks in text) — adom-shotlog streams every shot it gathered into a live gallery you open in a webview, pup, your browser, or your phone. Now ingests Adom Desktop owned-popup arrays + coordMap via --meta.",+  "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.",   "license": "MIT",   "dependencies": {},   "scripts": {
src/html/index.html+12−1
@@ -289,6 +289,15 @@ header {     return (h%12||12)+':'+mm+(h<12?'am':'pm'); }   function fmtDate(ts){ const d=new Date(ts), mo=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][d.getMonth()];     return mo+' '+d.getDate()+', '+fmtTime(ts); }+  // Which surface is showing this viewer? Explicit ?surface= wins (set by+  // `shotlog open`/force-open = webview, or an opener that knows it's pup);+  // otherwise infer mobile from the UA, else a plain browser.+  function detectSurface(){+    const q=new URLSearchParams(location.search).get('surface');+    if(q) return q;+    if(/Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent||'')) return 'mobile';+    return 'browser';+  }   function imgUrl(e){ return 'shots/'+encodeURIComponent(channel)+'/'+encodeURIComponent(e.filename); }   function popUrl(fn){ return 'shots/'+encodeURIComponent(channel)+'/'+encodeURIComponent(fn); }   // Owned popups can be stored as a bare array (current) or wrapped — normalize to an array.@@ -479,7 +488,9 @@ header {     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.'); };+    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 {} };     ws.onmessage = ev => {       try {         const msg = JSON.parse(ev.data);
src/main.rs+101−1
@@ -10,8 +10,22 @@ use std::sync::Arc;  fn ok(msg: &str) { println!("OK: {msg}"); } fn err(msg: &str) { eprintln!("ERROR: {msg}"); }+fn warn(msg: &str) { eprintln!("WARN: {msg}"); } fn hint(msg: &str) { eprintln!("Hint: {msg}"); } +/// De-duplicate viewer surfaces into a compact label, e.g. ["pup","pup","mobile"] -> "pup ×2, mobile".+fn surface_label(surfaces: &[String]) -> String {+    let mut counts: Vec<(String, usize)> = Vec::new();+    for s in surfaces {+        if let Some(e) = counts.iter_mut().find(|(k, _)| k == s) { e.1 += 1; }+        else { counts.push((s.clone(), 1)); }+    }+    counts.iter()+        .map(|(k, n)| if *n > 1 { format!("{k} ×{n}") } else { k.clone() })+        .collect::<Vec<_>>()+        .join(", ")+}+ const SKILL_MD: &str = include_str!("../SKILL.md");  #[derive(Parser)]@@ -125,6 +139,23 @@ enum Commands {         #[arg(short, long, default_value_t = 8820)]         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).+    Viewers {+        /// Channel name+        #[arg(short, long)]+        channel: String,+        /// Port of the running shotlog server+        #[arg(short, long, default_value_t = 8820)]+        port: u16,+        /// Optional viewer endpoint the AI opened (e.g. a pup/proxy URL) —+        /// shotlog HTTP-GETs it and reports whether it's reachable, on top of+        /// the authoritative WebSocket presence check.+        #[arg(long)]+        url: Option<String>,+    },     /// Install everything: skill, bash completions     Install,     /// Generate shell completions (bash, zsh, fish)@@ -300,6 +331,9 @@ async fn main() {         Commands::Health { port } => {             run_health(port);         }+        Commands::Viewers { channel, port, url } => {+            run_viewers(&channel, port, url.as_deref());+        }         Commands::Install => {             run_install();         }@@ -317,7 +351,7 @@ async fn run_server(port: u16, data_dir: PathBuf) {     let channel_count = store.channel_names().await.len();     ok(&format!("Recovered {channel_count} channel(s) from disk")); -    let state = Arc::new(server::AppState { store, port });+    let state = Arc::new(server::AppState { store, port, next_viewer_id: std::sync::atomic::AtomicU64::new(1) });     let app = server::create_router(state);      let addr = format!("0.0.0.0:{port}");@@ -407,6 +441,23 @@ fn run_inject(                         hint(&format!("Auto-resized from {ow}x{oh} → {w}x{h}; original size recorded."));                     }                 }+                // 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())+                        .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.");+                    } 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("A viewer was force-opened as a fallback, but do NOT assume the human sees it — verify their actual surface.");+                    }+                }             } else {                 err(&format!("Server error: {}", serde_json::to_string(&val).unwrap_or_default()));             }@@ -470,6 +521,55 @@ fn run_health(port: u16) {     } } +/// Verify a human is actually watching. Exit 0 = someone's live viewer is+/// connected; exit 3 = nobody is (the shot is landing unseen). Lets an AI gate+/// on "did the human actually see it" in one call instead of guessing.+fn run_viewers(channel: &str, port: u16, url: Option<&str>) {+    let endpoint = format!("/api/viewer-status?channel={}", channel.replace(' ', "%20"));+    let val = match http_get(port, &endpoint) {+        Ok(v) => v,+        Err(e) => {+            err(&format!("Cannot connect to shotlog on port {port}: {e}"));+            hint("Start it first: shotlog serve &");+            std::process::exit(1);+        }+    };+    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();++    // Optional: the AI passes the endpoint it opened; we confirm it's reachable.+    if let Some(u) = url {+        match std::process::Command::new("curl")+            .args(["-sS", "-o", "/dev/null", "-m", "8", "-w", "%{http_code}", u])+            .output()+        {+            Ok(o) => {+                let code = String::from_utf8_lossy(&o.stdout);+                let code = code.trim();+                if code.starts_with('2') || code.starts_with('3') {+                    ok(&format!("Endpoint reachable (HTTP {code}): {u}"));+                } else {+                    warn(&format!("Endpoint NOT reachable (HTTP {code}): {u} — that surface can't be showing the viewer."));+                }+            }+            Err(e) => warn(&format!("Could not probe endpoint {u}: {e}")),+        }+    }++    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.");+        std::process::exit(0);+    } 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}`."));+        std::process::exit(3);+    }+}+ fn run_install() {     let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".to_string()); 
src/open.rs+2−1
@@ -16,7 +16,8 @@ pub fn open_webview(channel: &str, display_name: Option<&str>, icon: Option<&str         }     }; -    let url = format!("{proxy_uri}log/{channel}/");+    // ?surface=webview so live-viewer presence labels this correctly.+    let url = format!("{proxy_uri}log/{channel}/?surface=webview");      // Get the workspace layout to find a leaf node     let layout_output = Command::new("adom-cli")
src/server.rs+44−3
@@ -55,6 +55,8 @@ fn unique_filename(dir: &std::path::Path, filename: &str) -> String { pub struct AppState {     pub store: Arc<Store>,     pub port: u16,+    /// Monotonic id generator for viewer presence (one per WS connection).+    pub next_viewer_id: std::sync::atomic::AtomicU64, }  pub fn create_router(state: Arc<AppState>) -> Router {@@ -137,10 +139,12 @@ async fn viewer_status( ) -> 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;     Json(serde_json::json!({         "channel": channel,         "viewers": viewers,         "has_viewers": viewers > 0,+        "surfaces": surfaces,     })) } @@ -199,7 +203,8 @@ fn force_open_viewer(channel: &str, port: u16) {      let base = proxy_tmpl.replace("{{port}}", &port.to_string());     let base = base.trim_end_matches('/').to_string();-    let url = format!("{base}/log/{channel}/");+    // ?surface=webview so presence correctly labels this force-opened viewer.+    let url = format!("{base}/log/{channel}/?surface=webview");     let tab = format!("shotlog:{channel}");     let icon = format!("data:image/svg+xml;base64,{}", base64::Engine::encode(&base64::engine::general_purpose::STANDARD, ICON_SVG)); @@ -256,6 +261,10 @@ struct InjectResponse {     original_w: Option<u32>,     #[serde(skip_serializing_if = "Option::is_none")]     original_h: Option<u32>,+    /// Live viewers connected at inject time, and their surfaces — so the caller+    /// can immediately tell whether the human is actually seeing this shot.+    viewers: usize,+    surfaces: Vec<String>, }  async fn post_shots(@@ -315,9 +324,14 @@ async fn post_shots(         .add_entry(&req.channel, bytes, filename, req.description, source, w, h, orig_w, orig_h, req.meta, popups_stored)         .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;+     // 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.-    if state.store.viewer_count(&req.channel).await == 0 {+    if viewers == 0 {         force_open_viewer(&req.channel, state.port);     } @@ -332,6 +346,8 @@ async fn post_shots(         bytes: entry.bytes,         original_w: entry.orig_w,         original_h: entry.orig_h,+        viewers,+        surfaces,     })) } @@ -385,6 +401,9 @@ async fn post_paste(         .await;  +    let viewers = state.store.viewer_count(&req.channel).await;+    let surfaces = state.store.viewer_surfaces(&req.channel).await;+     Ok(Json(InjectResponse {         ok: true,         id: entry.id,@@ -396,6 +415,8 @@ async fn post_paste(         bytes: entry.bytes,         original_w: if was_resized { Some(orig_w) } else { None },         original_h: if was_resized { Some(orig_h) } else { None },+        viewers,+        surfaces,     })) } @@ -527,6 +548,14 @@ async fn ws_handler( async fn ws_connection(mut socket: WebSocket, channel: String, state: Arc<AppState>) {     let mut rx = state.store.subscribe(&channel).await; +    // Register this connection's presence. The client sends {type:'hello',surface}+    // right after connecting; until then we record it as an unknown surface so the+    // live-viewer COUNT is right immediately even before the label arrives.+    let vid = state+        .next_viewer_id+        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);+    state.store.register_viewer(&channel, vid, "browser".into()).await;+     loop {         tokio::select! {             result = rx.recv() => {@@ -547,11 +576,23 @@ async fn ws_connection(mut socket: WebSocket, channel: String, state: Arc<AppSta             msg = socket.recv() => {                 match msg {                     Some(Ok(Message::Close(_))) | None => break,-                    _ => {} // ignore client messages+                    Some(Ok(Message::Text(t))) => {+                        // {type:'hello', surface:'pup'|'webview'|'mobile'|'browser'}+                        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;+                                }+                            }+                        }+                    }+                    _ => {} // ignore other client messages                 }             }         }     }++    state.store.unregister_viewer(&channel, vid).await; }  async fn serve_image(
src/store.rs+25
@@ -48,6 +48,9 @@ const MAX_ENTRIES: usize = 200; pub struct Store {     channels: RwLock<HashMap<String, Channel>>,     base_dir: PathBuf,+    /// Live viewer presence: channel -> (connection id -> surface label).+    /// Surface is what the viewer reported on connect: webview | pup | mobile | browser.+    presence: RwLock<HashMap<String, HashMap<u64, String>>>, }  impl Store {@@ -58,6 +61,7 @@ impl Store {         let store = Arc::new(Self {             channels: RwLock::new(HashMap::new()),             base_dir,+            presence: RwLock::new(HashMap::new()),         });         store     }@@ -273,6 +277,27 @@ 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) {+        let mut p = self.presence.write().await;+        p.entry(channel_name.to_string()).or_default().insert(id, surface);+    }++    /// Drop a viewer on disconnect.+    pub async fn unregister_viewer(&self, channel_name: &str, id: u64) {+        let mut p = self.presence.write().await;+        if let Some(m) = p.get_mut(channel_name) {+            m.remove(&id);+            if m.is_empty() { p.remove(channel_name); }+        }+    }++    /// 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()+    }+     /// Get a broadcast receiver for a channel's SSE stream.     pub async fn subscribe(&self, channel_name: &str) -> broadcast::Receiver<Entry> {         let mut channels = self.channels.write().await;