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.23.13
10 files changed
+106−37
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-shotlog"-version = "0.23.12"+version = "0.23.13" dependencies = [ "axum", "base64",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.23.12"+version = "0.23.13" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
VERSION+1−1@@ -1 +1 @@-0.23.12+0.23.13
bin/adom-shotlog⋯ 1 unchanged line ⋯
dev-skills/adom-shotlog-dev/SKILL.md+15@@ -293,3 +293,18 @@ browser_open_window says status:"bridge_starting"/pending:true while Chrome auto-starts - that is NOT a failure, WAIT and retry (fixed 0.23.10). Every step must log a DECISION line; if you can't answer "why this surface" from the log alone, add the missing DECISION line before doing anything else.++## Canvas notes must EXPIRE, and consolidation must respect the desired surface (2026-07-22)+Two bugs that both stranded the human on webview when their default was pup:+1. The pup-close DETECTOR set the "_app" canvas note to webview "until restart".+ A vanished pup window is WEAK evidence (Chrome/bridge crash looks identical),+ so it must NEVER permanently override a setting the human chose in the prefs+ dialog. Now it's a TRANSIENT note (30-min TTL via note_expiry); after that the+ configured default_canvas resumes. Notes only override the default briefly.+2. CONSOLIDATION (/api/show) blindly switched the open window's internal tab even+ when the connected surface DIDN'T match the desired canvas - so `open --pup`+ while a fallback webview was up just switched the webview's tab and never moved+ to pup. Now /api/show takes `desired` and only consolidates on a surface MATCH;+ a mismatch returns consolidated:false so the caller opens the right surface.+ALSO: post_shots now logs a `CANVAS '<ch>' -> <canvas> (<why>; default=<d>)` line+every time, so "why did it open there?" is answerable from the log, never a guess.
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-shotlog",- "version": "0.23.12",+ "version": "0.23.13", "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 and on which surface, and ingests Adom Desktop child-window arrays via --meta.",
page.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-shotlog",- "version": "0.23.12",+ "version": "0.23.13", "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 and on which surface, and ingests Adom Desktop child-window arrays via --meta.",
src/main.rs+19−16@@ -431,31 +431,34 @@ async fn main() { // explicit clicks in the viewer (canvas buttons) set the in-memory // note; everything else follows the global default. AI threads // opening channels must never masquerade as user preference.- //- // CONSOLIDATION FIRST: if the app is already open ANYWHERE, "open a- // channel" means "switch that window's internal tab" - one shotlog- // window for the human, tabs inside, never a second surface. The- // broker bypasses this (SHOTLOG_FORCE_SURFACE=1) for its own- // surface management (canvas switches, force-opens, recovery).+ let prefs = http_get(port, "/api/prefs").ok();+ let default_canvas = prefs.as_ref()+ .and_then(|v| v.get("default_canvas").and_then(|c| c.as_str()).map(String::from))+ .unwrap_or_else(|| "webview".into());+ // Resolve the DESIRED canvas first (explicit flag wins, else default),+ // so consolidation can tell whether the open surface already matches.+ let want_pup = pup || (!skinny && !webview && default_canvas == "pup");+ let desired = if want_pup { "pup" } else { "webview" };+ // CONSOLIDATION: if the app is already open ON THE SURFACE we want,+ // "open a channel" means "switch that window's internal tab" - one+ // shotlog window, tabs inside, never a second surface. A surface+ // MISMATCH (want pup, only a fallback webview up) is NOT consolidated+ // (that was the bug that trapped the human in webview) - it falls+ // through and opens pup for real. The broker bypasses this entirely+ // (SHOTLOG_FORCE_SURFACE=1) for its own surface management. if std::env::var("SHOTLOG_FORCE_SURFACE").is_err() {- if let Ok(v) = http_post(port, "/api/show", &serde_json::json!({"channel": channel})) {+ if let Ok(v) = http_post(port, "/api/show", &serde_json::json!({"channel": channel, "desired": desired})) { if v.get("consolidated").and_then(|c| c.as_bool()).unwrap_or(false) { let surfaces = v.get("surfaces").and_then(|s| s.as_array()) .map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", ")) .unwrap_or_default();- ok(&format!("'{channel}' shown in the ALREADY-OPEN shotlog window ({surfaces}) - switched its internal tab, no new surface."));- hint("One shotlog window holds every channel as a tab. The human sees a toast explaining the switch. To move the whole window between webview and pup, the human uses the header button (or changes the default in prefs).");+ ok(&format!("'{channel}' shown in the ALREADY-OPEN shotlog {desired} window ({surfaces}) - switched its internal tab, no new surface."));+ hint("One shotlog window holds every channel as a tab. To move the whole window between webview and pup, the human uses the header button (or changes the default in prefs)."); return; } } }- let prefs = http_get(port, "/api/prefs").ok();- let default_canvas = prefs.as_ref()- .and_then(|v| v.get("default_canvas").and_then(|c| c.as_str()).map(String::from))- .unwrap_or_else(|| "webview".into());- // Canvas resolution: explicit flag wins; otherwise the HUMAN'S- // default. A plain `open` must never bypass their setting.- let use_pup = pup || (!skinny && !webview && default_canvas == "pup");+ let use_pup = want_pup; let _ = http_post(port, "/api/log", &serde_json::json!({"line": format!( "CLI open '{}' canvas={} ({})", channel, if use_pup {"pup"} else {"webview"},
src/server.rs+47−16@@ -350,13 +350,18 @@ async fn post_log(Json(req): Json<LogRequest>) -> Json<serde_json::Value> { #[derive(Deserialize)] struct ShowRequest { channel: String,+ /// The canvas the caller WANTS (pup|webview). Consolidation only switches an+ /// internal tab when a connected surface already MATCHES this. A mismatch+ /// (want pup, only a fallback webview is up) must NOT be swallowed - the+ /// caller falls through and actually opens the right surface.+ desired: Option<String>, } -/// CONSOLIDATION: "open channel X" while the app is already on screen means-/// "switch the existing window's internal tab", never "spawn another surface".-/// (One human, one shotlog window, tabs inside - two windows was confusing.)-/// Returns consolidated:false when nothing is connected, so the caller falls-/// through to the normal canvas-open machinery.+/// CONSOLIDATION: "open channel X" while the app is already showing on the+/// SURFACE the caller wants means "switch the existing window's internal tab",+/// never "spawn another surface". (One human, one shotlog window, tabs inside.)+/// Returns consolidated:false when nothing is connected OR when the connected+/// surface doesn't match the desired canvas, so the caller opens the right one. async fn post_show( State(state): State<Arc<AppState>>, Json(req): Json<ShowRequest>,@@ -366,20 +371,37 @@ async fn post_show( } let infos = state.store.viewer_infos("_all").await; if infos.is_empty() {- return Json(serde_json::json!({"ok": true, "consolidated": false, "viewers": 0}));+ return Json(serde_json::json!({"ok": true, "consolidated": false, "viewers": 0, "reason": "nothing open"})); }+ let surfaces: Vec<String> = infos.iter().map(|i| i.surface.clone()).collect();+ // A caller may want a specific canvas; if not, the human's default is the+ // canvas we should already be on. let prefs = state.store.app_prefs().await;+ let desired = req.desired.clone().unwrap_or_else(|| prefs.default_canvas.clone());+ // Only consolidate when the surface the caller wants is ACTUALLY on screen.+ // (The app's own tabs are surface-agnostic, but a "webview" viewer is a+ // browser panel and a "pup" viewer is a full window - switching a tab in a+ // webview does NOT satisfy a request to be in pup.)+ let matches = surfaces.iter().any(|s| s == &desired)+ // "browser"/"mobile" are plain page loads that count as the webview family.+ || (desired == "webview" && surfaces.iter().any(|s| s == "browser" || s == "mobile"));+ if !matches {+ println!("{} DECISION show '{}' NOT consolidated: want {desired}, connected={surfaces:?} - opening the right surface.", ts(), req.channel);+ return Json(serde_json::json!({+ "ok": true, "consolidated": false, "viewers": infos.len(),+ "surfaces": surfaces, "desired": desired, "reason": "surface mismatch",+ }));+ } let frame = serde_json::json!({ "type": "show", "channel": req.channel,- "toast": format!("Consolidated: an AI asked to open '{}' - switched this tab instead of opening another shotlog window.", req.channel),+ "toast": format!("Switched to '{}' in your open shotlog window (one window, tabs inside).", req.channel), }); let _ = state.store.ctrl_tx.send(frame.to_string());- println!("{} DECISION show '{}' consolidated into the open app window ({} viewer(s)) - no new surface.", ts(), req.channel, infos.len());+ println!("{} DECISION show '{}' consolidated into the open {desired} window ({} viewer(s)) - no new surface.", ts(), req.channel, infos.len()); notify_surface(&req.channel, state.port, &infos, &prefs.default_canvas, prefs.update_alerts, prefs.webview_width as f64 / 100.0); Json(serde_json::json!({- "ok": true, "consolidated": true, "viewers": infos.len(),- "surfaces": infos.iter().map(|i| i.surface.clone()).collect::<Vec<_>>(),+ "ok": true, "consolidated": true, "viewers": infos.len(), "surfaces": surfaces, })) } @@ -1095,13 +1117,16 @@ async fn post_shots( let force_open_on = std::env::var("SHOTLOG_NO_FORCE_OPEN").is_err(); let app_prefs = state.store.app_prefs().await; let alerts_on = app_prefs.update_alerts;- let canvas_pref = match state.store.canvas_pref("_app").await {- Some(c) => c,+ // Resolve the canvas AND record WHY, so "why did it open there?" is always+ // answerable from the log alone (never a guess).+ let (canvas_pref, canvas_why) = match state.store.canvas_pref("_app").await {+ Some(c) => (c, "transient app note (pup window vanished; expires)"), None => match state.store.canvas_pref(&req.channel).await {- Some(c) => c,- None => app_prefs.default_canvas,+ Some(c) => (c, "in-memory note from the human's click on this channel"),+ None => (app_prefs.default_canvas.clone(), "the human's default_canvas setting"), }, };+ println!("{} CANVAS '{}' -> {canvas_pref} ({canvas_why}; default={})", ts(), req.channel, app_prefs.default_canvas); let mut actions: Vec<serde_json::Value> = Vec::new(); if !pup_sessions.is_empty() && alerts_on { actions.push(serde_json::json!({"action":"pup_flash","sessions":pup_sessions,"cue":"orange taskbar flash"}));@@ -1467,8 +1492,14 @@ async fn ws_all_connection(mut socket: WebSocket, state: Arc<AppState>) { } }).await.unwrap_or(None); if verdict == Some(true) && !store.viewer_infos("_all").await.iter().any(|i| i.surface == "pup") {- store.set_canvas_pref("_app", "webview").await;- println!("{} DECISION shotlog pup window closed by the user - app note set to webview until restart.", ts());+ // TRANSIENT, not permanent: a vanished pup window is weak+ // evidence (a Chrome/bridge crash looks identical to a+ // deliberate close). Honor it for 30 min, then the human's+ // configured default rules again - a setting they set in the+ // prefs dialog must never be silently overridden for a day.+ store.set_canvas_note_transient("_app", "webview", std::time::Duration::from_secs(1800)).await;+ println!("{} DECISION shotlog pup window vanished - webview note for 30 min, then the pup default resumes.", ts());+ set_notice("Your shotlog pup window closed, so I'm using this webview for now. Your default is still pup - I'll go back to it automatically in 30 minutes, or use the Pup button any time."); } }); }
src/store.rs+20@@ -173,6 +173,8 @@ pub struct Store { /// must consult THIS, not just presence - or it reopens the wrong canvas /// and steamrolls the user's choice. canvas_prefs: RwLock<HashMap<String, String>>,+ /// Expiry times for TRANSIENT notes (pup-close detector); absent = no expiry.+ note_expiry: RwLock<HashMap<String, std::time::Instant>>, app_prefs: RwLock<AppPrefs>, } @@ -203,6 +205,7 @@ impl Store { base_dir, presence: RwLock::new(HashMap::new()), canvas_prefs: RwLock::new(prefs),+ note_expiry: RwLock::new(HashMap::new()), app_prefs: RwLock::new(app_prefs), }); store@@ -611,6 +614,15 @@ impl Store { /// Forget a channel's note so it follows the global default again. pub async fn clear_canvas_pref(&self, channel: &str) { self.canvas_prefs.write().await.remove(channel);+ self.note_expiry.write().await.remove(channel);+ }++ /// A TRANSIENT note from the pup-close detector: a window disappearing is+ /// weak evidence of intent (Chrome/bridge crashes look identical), so it+ /// only overrides the human's default briefly, never "until restart".+ pub async fn set_canvas_note_transient(&self, channel: &str, canvas: &str, ttl: std::time::Duration) {+ self.canvas_prefs.write().await.insert(channel.to_string(), canvas.to_string());+ self.note_expiry.write().await.insert(channel.to_string(), std::time::Instant::now() + ttl); } /// Forget EVERY in-memory canvas note. Used when the human changes the@@ -620,10 +632,18 @@ impl Store { let mut m = self.canvas_prefs.write().await; let n = m.len(); m.clear();+ self.note_expiry.write().await.clear(); n } pub async fn canvas_pref(&self, channel: &str) -> Option<String> {+ if let Some(exp) = self.note_expiry.read().await.get(channel) {+ if std::time::Instant::now() > *exp {+ self.canvas_prefs.write().await.remove(channel);+ self.note_expiry.write().await.remove(channel);+ return None;+ }+ } self.canvas_prefs.read().await.get(channel).cloned() }