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.15.0
9 files changed
+97−7
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-shotlog"-version = "0.14.0"+version = "0.15.0" dependencies = [ "axum", "base64",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.14.0"+version = "0.15.0" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
SKILL.md+20@@ -72,6 +72,26 @@ broker (from `$SHOTLOG_HOME`, default `~/project`) if it is down. If you see `read-captures` filling up: that is this hook working as designed, and retention keeps it bounded. +## Preferences and canvas control (AI-drivable)++Everything the human can click, YOU can do - and the human is told the matching+prompts in the UI, so expect requests like "turn the shotlog read-hook off" or+"make shotlog default to pup". The broker endpoints (port 8820):++```bash+curl -s localhost:8820/api/prefs # read prefs+curl -s -X POST localhost:8820/api/prefs -H 'Content-Type: application/json' \+ -d '{"read_hook": false}' # hook off/on+curl -s -X POST localhost:8820/api/prefs -H 'Content-Type: application/json' \+ -d '{"default_canvas": "pup"}' # global default+```++Canvas rules the broker enforces (do not fight them):+- Every `open`/switch RECORDS the channel's canvas choice; auto-open honors it.+- The user CLOSING a pup window reverts that channel to webview (a deliberate+ close is a choice, not a blip - the broker probes to tell them apart).+- `shotlog open -c <ch> --pup --swap` / `--skinny --swap` is the switch verb.+ ## Inject - one command ```bash
VERSION+1−1@@ -1 +1 @@-0.14.0+0.15.0
bin/adom-shotlog⋯ 1 unchanged line ⋯
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-shotlog",- "version": "0.14.0",+ "version": "0.15.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 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.14.0",+ "version": "0.15.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 and on which surface, and ingests Adom Desktop child-window arrays via --meta.",
src/html/index.html+9−1@@ -296,6 +296,12 @@ header { color: var(--text2); font: 600 12px 'Familjen Grotesk', sans-serif; padding: 6px 12px; cursor: pointer; } .seg button + button { border-left: 1px solid var(--border); } .seg button.on { background: rgba(0,184,176,.18); color: var(--accent-bright); }+.ai-chip { margin-top: 7px; padding: 6px 9px; border-radius: 6px; display: inline-block;+ background: rgba(0,184,176,.08); border: 1px solid rgba(0,184,176,.25);+ font-family: 'JetBrains Mono', monospace; font-size: 10.5px; line-height: 1.4;+ color: var(--accent-bright); }+.ai-chip .lbl { color: var(--text3); font-family: 'Satoshi', sans-serif; font-size: 9px;+ letter-spacing: .04em; text-transform: uppercase; margin-right: 6px; } /* Paste progress overlay */ .paste-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.7); display: none;@@ -371,13 +377,15 @@ header { <div class="pref-info"> <div class="pref-name">Auto-capture what AIs read</div> <div class="pref-desc">The read-hook copies every image any AI thread reads into the read-captures channel, so you always see what they saw. Turn off to stop auto-capturing (deliberate injects still work).</div>+ <div class="ai-chip"><span class="lbl">or say to your AI</span>turn the shotlog read-hook off</div> </div> <button class="toggle" id="prefHook" role="switch" aria-checked="true" data-tooltip="Toggle the read-hook on or off. Takes effect on the next image an AI reads."></button> </div> <div class="pref-row"> <div class="pref-info"> <div class="pref-name">Default canvas</div>- <div class="pref-desc">Where shotlog auto-opens a channel when nobody is watching. A channel you explicitly opened in pup or webview keeps ITS choice; this covers everything else. Pup needs Adom Desktop.</div>+ <div class="pref-desc">Where shotlog auto-opens a channel when nobody is watching. A channel you explicitly opened in pup or webview keeps ITS choice; this covers everything else. Closing a pup window reverts that channel to webview. Pup needs Adom Desktop.</div>+ <div class="ai-chip"><span class="lbl">or say to your AI</span>make shotlog default to pup</div> </div> <div class="seg" id="prefCanvas"> <button data-v="webview">Webview</button>
src/server.rs+63−1@@ -349,10 +349,15 @@ struct CanvasCloseRequest { /// STAGE 3 of the tracked switch (or a rollback): close ONE surface of a /// channel. surface = "webview" closes the Hydrogen tab; "pup" closes the tab /// in the shared shotlog pup window.-async fn canvas_close(Json(req): Json<CanvasCloseRequest>) -> Json<serde_json::Value> {+async fn canvas_close(+ State(state): State<Arc<AppState>>,+ Json(req): Json<CanvasCloseRequest>,+) -> 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 channel = req.channel.clone();+ let surface = req.surface.clone(); let v = tokio::task::spawn_blocking(move || { if req.surface == "webview" { let ok = std::process::Command::new("adom-cli")@@ -369,6 +374,14 @@ async fn canvas_close(Json(req): Json<CanvasCloseRequest>) -> Json<serde_json::V }) .await .unwrap_or_else(|_| serde_json::json!({"ok": false, "error": "spawn failed"}));+ // Closing one surface while the OTHER is live means the other is now the+ // channel's canvas - keep the pref honest (covers rollbacks too).+ if v.get("ok").and_then(|b| b.as_bool()).unwrap_or(false) {+ let other = if surface == "pup" { "webview" } else { "pup" };+ if state.store.viewer_infos(&channel).await.iter().any(|i| i.surface == other) {+ state.store.set_canvas_pref(&channel, other).await;+ }+ } Json(v) } @@ -1041,6 +1054,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);+ let mut conn_surface: String = "browser".into(); state.store.register_viewer(&channel, vid, "browser".into(), true, None).await; loop {@@ -1072,6 +1086,7 @@ async fn ws_connection(mut socket: WebSocket, channel: String, state: Arc<AppSta 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); let session = v.get("session").and_then(|x| x.as_str()).map(String::from);+ conn_surface = surface.clone(); state.store.register_viewer(&channel, vid, surface, visible, session).await; } Some("vis") => {@@ -1089,6 +1104,53 @@ async fn ws_connection(mut socket: WebSocket, channel: String, state: Arc<AppSta } state.store.unregister_viewer(&channel, vid).await;++ // USER-CLOSED-PUP DETECTION. A pup viewer vanishing can be a blip (reload,+ // network, laptop sleep) or the user deliberately closing the tab/window.+ // Grace-wait for a reconnect, then probe whether the tab still exists:+ // gone + no reconnect = the user closed it = their canvas choice CHANGED.+ // Revert the channel to webview so the next auto-open lands in the slim+ // panel instead of shoving a fresh pup window at them. (Swap flows are+ // immune: they re-point the pref BEFORE closing, so pref!=pup here.)+ if conn_surface == "pup" {+ let store = state.store.clone();+ let ch = channel.clone();+ tokio::spawn(async move {+ if store.canvas_pref(&ch).await.as_deref() != Some("pup") {+ return;+ }+ tokio::time::sleep(std::time::Duration::from_secs(8)).await;+ if store.viewer_infos(&ch).await.iter().any(|i| i.surface == "pup") {+ return; // reconnected - just a blip+ }+ let ch2 = ch.clone();+ let tab_gone = tokio::task::spawn_blocking(move || {+ let mut cmd = std::process::Command::new("adom-desktop");+ if let Ok(t) = std::env::var("SHOTLOG_AD_TARGET") {+ cmd.args(["--target", &t]);+ }+ match cmd.args(["browser_list_tabs", "{\"sessionId\":\"shotlog\"}"]).output() {+ Ok(o) => {+ let out = String::from_utf8_lossy(&o.stdout).to_string();+ match serde_json::from_str::<serde_json::Value>(&out) {+ Ok(v) => !v.get("tabs").and_then(|t| t.as_array())+ .map(|tabs| tabs.iter().any(|t| t.get("url").and_then(|u| u.as_str())+ .map(|u| u.contains(&format!("/log/{ch2}/"))).unwrap_or(false)))+ .unwrap_or(false),+ Err(_) => true, // session itself is gone -> tabs gone+ }+ }+ Err(_) => false, // cannot probe: never flip on uncertainty+ }+ })+ .await+ .unwrap_or(false);+ if tab_gone && !store.viewer_infos(&ch).await.iter().any(|i| i.surface == "pup") {+ store.set_canvas_pref(&ch, "webview").await;+ println!("OK: pup tab for '{ch}' was closed by the user - canvas preference reverted to webview (next auto-open uses the slim panel).");+ }+ });+ } } async fn serve_image(