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.12
11 files changed
+104−8
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-shotlog"-version = "0.23.11"+version = "0.23.12" dependencies = [ "axum", "base64",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.23.11"+version = "0.23.12" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
SKILL.md+12@@ -99,6 +99,18 @@ Canvas rules the broker enforces (do not fight them): pinned first). You never open one surface per channel anymore - plain `shotlog open -c <ch>` opens/deep-links THE app with that tab active. +## ONE window rule: never open your own shotlog surface++The human runs ONE shotlog window (webview or pup) with a tab per channel+INSIDE the app. `adom-shotlog open -c CH` handles this: if the app is already+open anywhere, it switches that window's internal tab (the human sees a toast+explaining the consolidation) and opens nothing new. Only when NOTHING is open+does it create a surface, on the human's default canvas.++NEVER load the viewer URL into your own pup session or a new window/tab+(`browser_open_window`/`browser_open_tab` with a /log/ URL) - that gives the+human a confusing second shotlog. Always go through `adom-shotlog open`.+ ## Inject - one command ```bash
VERSION+1−1@@ -1 +1 @@-0.23.11+0.23.12
bin/adom-shotlog⋯ 1 unchanged line ⋯
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-shotlog",- "version": "0.23.11",+ "version": "0.23.12", "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.11",+ "version": "0.23.12", "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/html/index.html+10@@ -1354,6 +1354,16 @@ header { ws.onmessage = ev => { try { const msg = JSON.parse(ev.data);+ if (msg.type==='show' && msg.channel) {+ // Consolidation: an AI asked to "open" a channel while this window is+ // already up - switch OUR tab instead of letting a second surface+ // spawn, and tell the human why.+ ensureChan(msg.channel);+ activate(msg.channel);+ renderTabs();+ showToast(msg.toast || ("Switched to '"+msg.channel+"' (consolidated - one shotlog window, tabs inside)."), 12000);+ return;+ } if (msg.type==='entry' && msg.entry) { const e = msg.entry, chName = msg.channel || channel; const c = ensureChan(chName);
src/main.rs+18@@ -431,6 +431,24 @@ 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).+ if std::env::var("SHOTLOG_FORCE_SURFACE").is_err() {+ if let Ok(v) = http_post(port, "/api/show", &serde_json::json!({"channel": channel})) {+ 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).");+ 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))
src/server.rs+53−3@@ -123,6 +123,7 @@ pub fn create_router(state: Arc<AppState>) -> Router { .route("/api/prefs", post(set_prefs)) .route("/api/log", post(post_log)) .route("/api/notice", post(post_notice))+ .route("/api/show", post(post_show)) .route("/prompt", get(serve_prompt)) .route("/api/paste", post(post_paste)) .route("/api/open", post(open_in_vscode))@@ -346,6 +347,42 @@ async fn post_log(Json(req): Json<LogRequest>) -> Json<serde_json::Value> { Json(serde_json::json!({"ok": true})) } +#[derive(Deserialize)]+struct ShowRequest {+ channel: 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.+async fn post_show(+ State(state): State<Arc<AppState>>,+ Json(req): Json<ShowRequest>,+) -> Json<serde_json::Value> {+ if req.channel.contains("..") || req.channel.contains('/') || req.channel.is_empty() {+ return Json(serde_json::json!({"ok": false, "error": "invalid channel"}));+ }+ let infos = state.store.viewer_infos("_all").await;+ if infos.is_empty() {+ return Json(serde_json::json!({"ok": true, "consolidated": false, "viewers": 0}));+ }+ let prefs = state.store.app_prefs().await;+ 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),+ });+ 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());+ 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<_>>(),+ }))+}+ #[derive(Deserialize)] struct NoticeRequest { text: String,@@ -484,7 +521,7 @@ async fn canvas_open( let v = tokio::task::spawn_blocking(move || { let exe = std::env::current_exe().unwrap_or_else(|_| "adom-shotlog".into()); let flag = if req.to == "pup" { "--pup" } else { "--skinny" };- match std::process::Command::new(exe).args(["open", "-c", &req.channel, flag]).output() {+ match std::process::Command::new(exe).env("SHOTLOG_FORCE_SURFACE", "1").args(["open", "-c", &req.channel, flag]).output() { Ok(o) => serde_json::json!({"ok": o.status.success(), "detail": format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))}), Err(e) => serde_json::json!({"ok": false, "error": e.to_string()}),@@ -607,7 +644,8 @@ async fn canvas_switch( let flag = if to == "pup" { "--pup" } else { "--skinny" }; let _ = port; // (port defaults to 8820 inside the child) match std::process::Command::new(exe)- .args(["open", "-c", &channel, flag])+ .env("SHOTLOG_FORCE_SURFACE", "1")+ .args(["open", "-c", &channel, flag]) .output() { Ok(o) => serde_json::json!({"ok": o.status.success(),@@ -760,6 +798,7 @@ fn force_open_pup(channel: &str, fallback_ratio: f64) { let exe = std::env::current_exe().unwrap_or_else(|_| "adom-shotlog".into()); for attempt in 1..=tries { let ok = std::process::Command::new(&exe)+ .env("SHOTLOG_FORCE_SURFACE", "1") .args(["open", "-c", &channel, "--pup"]) .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()) .status().map(|s| s.success()).unwrap_or(false);@@ -793,7 +832,8 @@ fn recover_pup_from_fallback(channel: &str, port: u16) { println!("{} DECISION default canvas is pup but only a webview is connected (fallback residue) - retrying pup.", ts()); let exe = std::env::current_exe().unwrap_or_else(|_| "adom-shotlog".into()); let ok = std::process::Command::new(&exe)- .args(["open", "-c", &channel, "--pup"])+ .env("SHOTLOG_FORCE_SURFACE", "1")+ .args(["open", "-c", &channel, "--pup"]) .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()) .status().map(|s| s.success()).unwrap_or(false); if !ok {@@ -1342,6 +1382,7 @@ async fn ws_all_handler( async fn ws_all_connection(mut socket: WebSocket, state: Arc<AppState>) { let mut rx = state.store.all_tx.subscribe();+ let mut ctrl_rx = state.store.ctrl_tx.subscribe(); let vid = state.next_viewer_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let mut conn_surface: String = "browser".into(); state.store.register_viewer("_all", vid, "browser".into(), true, None).await;@@ -1364,6 +1405,15 @@ async fn ws_all_connection(mut socket: WebSocket, state: Arc<AppState>) { Err(_) => break, } }+ ctrl = ctrl_rx.recv() => {+ match ctrl {+ Ok(frame) => {+ if socket.send(Message::Text(frame.into())).await.is_err() { break; }+ }+ Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,+ Err(_) => break,+ }+ } msg = socket.recv() => { match msg { Some(Ok(Message::Close(_))) | None => break,
src/store.rs+6@@ -159,6 +159,10 @@ pub struct Store { /// Global feed: every new entry from EVERY channel, for the single-window /// multi-tab viewer (/ws/_all). pub all_tx: broadcast::Sender<(String, Entry)>,+ /// Control frames for the app (raw JSON strings), e.g. {type:"show",channel}+ /// - the consolidation path: switch the existing window's internal tab+ /// instead of spawning another surface.+ pub ctrl_tx: broadcast::Sender<String>, base_dir: PathBuf, /// Live viewer presence: channel -> (connection id -> viewer info). /// Surface is what the viewer reported on connect: webview | pup | mobile | browser.@@ -191,9 +195,11 @@ impl Store { if app_prefs.read_hook { let _ = fs::remove_file(&flag); } else { let _ = fs::write(&flag, "disabled via shotlog Preferences\n"); } let (all_tx, _) = broadcast::channel(256);+ let (ctrl_tx, _) = broadcast::channel(64); let store = Arc::new(Self { channels: RwLock::new(HashMap::new()), all_tx,+ ctrl_tx, base_dir, presence: RwLock::new(HashMap::new()), canvas_prefs: RwLock::new(prefs),