app
Shotlog - Screenshot Log
Public Made by Adomby adom
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 and on which surface, and ingests Adom Bridge child-window arrays via --meta.
← Commit history
Publish 0.5.0
13 files changed
+242−16
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. -- ## Gallery — desktop @@ -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;