← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-shotlog"-version = "0.23.10"+version = "0.23.11" dependencies = [  "axum",  "base64",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.23.10"+version = "0.23.11" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
VERSION+1−1
@@ -1 +1 @@-0.23.10+0.23.11
bin/adom-shotlog
⋯ 1 unchanged line ⋯
dev-skills/adom-shotlog-dev/SKILL.md+12
@@ -281,3 +281,15 @@ handshake stamp is version+boot-epoch precisely because same-version redeploys (0.23.5b/c/d) once shipped invisible changes: the human stared at a stale UI. A deploy is NOT done until the human's open surfaces are re-verified live on the new stamp (browser_evaluate the page's APP_VERSION - read it, don't assume).++## "Why did it open in webview when default is pup?" playbook (2026-07-19)+The answer is ALWAYS in prod-srv.log - read it before theorizing. Chain seen+live: (1) AD truly offline -> 6 patient pup retries fail -> documented webview+FALLBACK ("no choice recorded"); (2) fallback webview stays connected, so every+later shot routes to it and "nobody watching" never re-fires (fixed 0.23.9:+recover_pup_from_fallback - default=pup + webview-only -> retry pup per shot,+5 min cooldown, close fallback only after pup CONNECTS); (3) AD back online but+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.
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-shotlog",-  "version": "0.23.10",+  "version": "0.23.11",   "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.10",+  "version": "0.23.11",   "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+12−2
@@ -1327,8 +1327,18 @@ header {     // from an older deploy reloads ITSELF (once) so no surface ever goes stale.     ws.addEventListener('message', ev => {       try { const m = JSON.parse(ev.data);-        if (m.type === 'welcome' && m.version && APP_VERSION.indexOf('__') !== 0 && m.version !== APP_VERSION) {-          if (!sessionStorage.getItem('slReloaded-' + m.version)) {+        if (m.type === 'welcome') {+          const stale = m.version && APP_VERSION.indexOf('__') !== 0 && m.version !== APP_VERSION+            && !sessionStorage.getItem('slReloaded-' + m.version);+          if (m.notice) {+            // One-shot narration from the broker (pup fallback/recovery, AI+            // notices). If this page is about to reload, hand the notice back+            // so the fresh page shows it instead of it dying with us.+            if (stale) fetch('api/notice',{method:'POST',headers:{'Content-Type':'application/json'},+              body:JSON.stringify({text:m.notice}),keepalive:true}).catch(()=>{});+            else showToast(m.notice, 12000);+          }+          if (stale) {             sessionStorage.setItem('slReloaded-' + m.version, '1');             location.reload();           }
src/server.rs+64−3
@@ -122,6 +122,7 @@ pub fn create_router(state: Arc<AppState>) -> Router {         .route("/api/prefs", get(get_prefs))         .route("/api/prefs", post(set_prefs))         .route("/api/log", post(post_log))+        .route("/api/notice", post(post_notice))         .route("/prompt", get(serve_prompt))         .route("/api/paste", post(post_paste))         .route("/api/open", post(open_in_vscode))@@ -145,6 +146,27 @@ async fn log_redirect(Path(channel): Path<String>) -> Redirect {     Redirect::permanent(&format!("/log/{channel}/")) } +/// One-shot human-facing notice, delivered as a toast by the next page that+/// completes the WS welcome (fresh for 10 min, single delivery). Set by the+/// broker itself (pup fallback / recovery) or by any AI via POST /api/notice -+/// the toast is the narration channel to the human ("tried pup, it was down,+/// fell back to webview").+fn notice_cell() -> &'static std::sync::Mutex<Option<(String, std::time::Instant)>> {+    static CELL: std::sync::OnceLock<std::sync::Mutex<Option<(String, std::time::Instant)>>> = std::sync::OnceLock::new();+    CELL.get_or_init(|| std::sync::Mutex::new(None))+}+fn set_notice(text: &str) {+    *notice_cell().lock().unwrap() = Some((text.to_string(), std::time::Instant::now()));+    println!("{} NOTICE queued for the human: {text}", ts());+}+fn take_notice() -> Option<String> {+    let mut g = notice_cell().lock().unwrap();+    match g.take() {+        Some((t, at)) if at.elapsed().as_secs() < 600 => Some(t),+        _ => None,+    }+}+ fn icon_response(bytes: &'static [u8], mime: &'static str) -> Response {     let mut res = bytes.into_response();     res.headers_mut()@@ -324,6 +346,24 @@ async fn post_log(Json(req): Json<LogRequest>) -> Json<serde_json::Value> {     Json(serde_json::json!({"ok": true})) } +#[derive(Deserialize)]+struct NoticeRequest {+    text: String,+}++/// Queue a one-shot toast for the human: the next page to complete the WS+/// welcome shows it (10 min freshness). The broker uses this for pup+/// fallback/recovery narration; AIs may POST here to explain a surface change.+async fn post_notice(Json(req): Json<NoticeRequest>) -> Json<serde_json::Value> {+    let mut text = req.text.replace(['\n', '\r'], " ");+    text.truncate(240);+    if text.is_empty() {+        return Json(serde_json::json!({"ok": false, "error": "empty text"}));+    }+    set_notice(&text);+    Json(serde_json::json!({"ok": true, "delivered": "on next viewer welcome (10 min freshness)"}))+}+ #[derive(Deserialize)] struct CanvasPrefRequest {     channel: String,@@ -736,6 +776,7 @@ fn force_open_pup(channel: &str, fallback_ratio: f64) {             }         }         println!("{} WARN: pup never came up for '{channel}' - falling back to a webview so the shots aren't invisible (no choice recorded).", ts());+        set_notice("Tried to open pup (your default) - it was unreachable, so I fell back to this webview so shots stay visible. I'll keep retrying and move you back to pup automatically once it's up.");         crate::open::open_webview(&channel, None, None, 8820, true, fallback_ratio);     }); }@@ -763,10 +804,22 @@ fn recover_pup_from_fallback(channel: &str, port: u16) {             std::thread::sleep(std::time::Duration::from_secs(2));             if crate::open::broker_has_pup_viewer(port) {                 println!("{} DECISION pup is back and CONNECTED - closing the fallback webview (it was never a choice).", ts());+                set_notice("pup is back - your shotlog moved here and the fallback webview was closed.");                 let out = std::process::Command::new("adom-cli")                     .args(["hydrogen", "workspace", "remove-tab", "--name", "Shotlog"])                     .output();-                let closed = out.map(|o| o.status.success()).unwrap_or(false);+                let mut closed = out.as_ref().map(|o| o.status.success()+                    && !String::from_utf8_lossy(&o.stdout).contains("\"error\"")).unwrap_or(false);+                if !closed {+                    // Last tab in its pane: Hydrogen requires closing the pane.+                    let text = out.map(|o| format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))).unwrap_or_default();+                    if text.contains("Cannot remove all tabs") {+                        closed = std::process::Command::new("adom-cli")+                            .args(["hydrogen", "workspace", "remove-tab", "--name", "Shotlog", "--mode", "all"])+                            .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())+                            .status().map(|s| s.success()).unwrap_or(false);+                    }+                }                 if !closed {                     println!("{} WARN: fallback webview tab close failed - pup is live, human may see both surfaces.", ts());                 }@@ -1293,7 +1346,11 @@ async fn ws_all_connection(mut socket: WebSocket, state: Arc<AppState>) {     let mut conn_surface: String = "browser".into();     state.store.register_viewer("_all", vid, "browser".into(), true, None).await;     let _ = socket.send(Message::Text(-        serde_json::json!({"type": "welcome", "version": boot_stamp()}).to_string().into()+        {+        let mut w = serde_json::json!({"type": "welcome", "version": boot_stamp()});+        if let Some(n) = take_notice() { w["notice"] = serde_json::json!(n); }+        w.to_string().into()+    }     )).await;     loop {         tokio::select! {@@ -1388,7 +1445,11 @@ async fn ws_connection(mut socket: WebSocket, channel: String, state: Arc<AppSta     state.store.register_viewer(&channel, vid, "browser".into(), true, None).await;     // Version handshake: stale pages reload themselves on mismatch.     let _ = socket.send(Message::Text(-        serde_json::json!({"type": "welcome", "version": boot_stamp()}).to_string().into()+        {+        let mut w = serde_json::json!({"type": "welcome", "version": boot_stamp()});+        if let Some(n) = take_notice() { w["notice"] = serde_json::json!(n); }+        w.to_string().into()+    }     )).await;      loop {