← Commit history
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(