← Commit history
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),