← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-shotlog"-version = "0.9.1"+version = "0.10.0" dependencies = [  "axum",  "base64",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.9.1"+version = "0.10.0" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
README.md+8
@@ -20,6 +20,14 @@ Every AI thread in the container simply runs `adom-shotlog inject`, which posts  Disk stays bounded automatically: each channel keeps its newest **200 shots** on disk (`SHOTLOG_MAX_SHOTS`), older files are deleted as new ones arrive, and channels idle for **30 days** are pruned entirely (`SHOTLOG_RETENTION_DAYS`, `0` disables). +## The read-hook: every screenshot any AI reads, captured automatically++shotlog ships a Claude Code `PostToolUse(Read)` hook (`hooks/shotlog-on-read.sh`). Once installed, whenever ANY AI thread reads an image file to analyze it, the hook automatically injects that exact image into the shared **`read-captures`** channel. You see every screenshot every AI looked at, deterministically, with zero AI cooperation required. It is the purest form of the shotlog mission: the AI cannot forget to show you what it saw.++`adom-shotlog install` deploys the hook and registers it in `~/.claude/settings.json` (surgically, never touching your other hooks), and **every `serve` startup re-verifies both**, healing them if missing, so the pipeline cannot silently rot. The hook also self-heals the broker if it is not running.++Related guarantees: the broker is **single-instance per port** (a second `serve` detects the healthy broker and defers gracefully, warning if versions differ), and `read-captures` stays disk-bounded by the retention policy.+ ## It adapts to your surface  shotlog notices which surface is watching a channel and behaves accordingly. It never steals your OS foreground.
SKILL.md+11
@@ -61,6 +61,17 @@ tells you two things:   `--meta`, a pup viewer without a `?session=` handle) and then they go quiet.   Read and apply them; they are the condensed version of this skill pack. +## The read-hook: auto-capture of every image you Read++shotlog ships and manages a `PostToolUse(Read)` hook: any image file you `Read`+is automatically injected into the shared `read-captures` channel (source+`read-hook`), so the human sees every screenshot you analyze even if you never+call inject. Do not disable or duplicate it. It is installed by+`adom-shotlog install`, re-verified at every `serve` startup, and self-heals the+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.+ ## Inject - one command  ```bash
VERSION+1−1
@@ -1 +1 @@-0.9.1+0.10.0
bin/adom-shotlog
⋯ 1 unchanged line ⋯
hooks/shotlog-on-read.shadded+39
@@ -0,0 +1,39 @@+#!/bin/bash+# PostToolUse(Read) hook - SHIPPED AND MANAGED BY adom-shotlog.+# Deterministically injects any image file Claude Reads into the shared+# 'read-captures' channel, so the human sees every screenshot any AI analyzed,+# without the AI having to remember. Installed by `adom-shotlog install` and+# re-verified at every `adom-shotlog serve` startup; edits here are overwritten.+#+# Reads the hook JSON on stdin: {"tool_name":"Read","tool_input":{"file_path":"..."}}+# - Only acts on .png/.jpg/.jpeg+# - Skips files already inside a shotlog data dir (no re-inject loops)+# - Self-heals the broker from the canonical cwd if it is not running+# Always exits 0 so it can never block a Read.++f="$(jq -r 'select(.tool_name=="Read") | (.tool_input.file_path // empty)' 2>/dev/null)"+[ -z "$f" ] && exit 0++case "$f" in+  *.png|*.jpg|*.jpeg|*.PNG|*.JPG|*.JPEG) ;;+  *) exit 0 ;;+esac++case "$f" in+  */screenshots/shotlog/*) exit 0 ;;+esac++[ -f "$f" ] || exit 0++command -v shotlog >/dev/null 2>&1 || exit 0++# Self-heal the broker from the canonical project dir (a stray cwd once+# scattered data dirs across the container).+shotlog health >/dev/null 2>&1 || { (cd "${SHOTLOG_HOME:-$HOME/project}" && shotlog serve >/dev/null 2>&1 &) ; sleep 1; }++base="$(basename "$f")"+shotlog inject -c read-captures \+  -d "Auto-captured screenshot Claude read for analysis: ${base}" \+  -s read-hook "$f" >/dev/null 2>&1++exit 0
package.json+3−2
@@ -1,6 +1,6 @@ {   "slug": "adom-shotlog",-  "version": "0.9.1",+  "version": "0.10.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.",@@ -27,7 +27,8 @@     "LICENSE",     "tools/**",     "dev-skills/**",-    "publish-skills/**"+    "publish-skills/**",+    "hooks/**"   ],   "install": {     "binary_name": "adom-shotlog"
page.json+3−2
@@ -1,6 +1,6 @@ {   "slug": "adom-shotlog",-  "version": "0.9.1",+  "version": "0.10.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.",@@ -27,7 +27,8 @@     "LICENSE",     "tools/**",     "dev-skills/**",-    "publish-skills/**"+    "publish-skills/**",+    "hooks/**"   ],   "install": {     "binary_name": "adom-shotlog"
src/main.rs+111−4
@@ -27,6 +27,81 @@ fn surface_label(surfaces: &[String]) -> String { }  const SKILL_MD: &str = include_str!("../SKILL.md");+const READ_HOOK_SH: &str = include_str!("../hooks/shotlog-on-read.sh");++/// Install/verify the shipped PostToolUse(Read) hook: the hook FILE and its+/// REGISTRATION in ~/.claude/settings.json. Runs at `install` and at every+/// `serve` startup, so the auto-capture pipeline can never silently rot.+/// Surgical + idempotent: never touches other hooks; backs up settings once+/// per change to settings.json.shotlog-bak.+fn ensure_read_hook() {+    let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".to_string());+    let dir = format!("{home}/.claude/hooks");+    let path = format!("{dir}/shotlog-on-read.sh");+    let _ = std::fs::create_dir_all(&dir);+    let mut file_changed = false;+    if std::fs::read_to_string(&path).unwrap_or_default() != READ_HOOK_SH {+        if std::fs::write(&path, READ_HOOK_SH).is_ok() {+            use std::os::unix::fs::PermissionsExt;+            let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755));+            file_changed = true;+        }+    }++    let sp = format!("{home}/.claude/settings.json");+    let raw = std::fs::read_to_string(&sp).unwrap_or_else(|_| "{}".to_string());+    let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&raw) else {+        warn("read-hook: ~/.claude/settings.json is not valid JSON - hook file installed but NOT registered.");+        return;+    };+    let mut reg_changed = false;+    let mut registered = false;+    if let Some(obj) = v.as_object_mut() {+        let hooks = obj.entry("hooks").or_insert_with(|| serde_json::json!({}));+        if let Some(hobj) = hooks.as_object_mut() {+            let ptu = hobj.entry("PostToolUse").or_insert_with(|| serde_json::json!([]));+            if let Some(arr) = ptu.as_array_mut() {+                let mut found_read_group = false;+                for g in arr.iter_mut() {+                    if g.get("matcher").and_then(|m| m.as_str()) == Some("Read") {+                        found_read_group = true;+                        if let Some(gobj) = g.as_object_mut() {+                            let hs = gobj.entry("hooks").or_insert_with(|| serde_json::json!([]));+                            if let Some(list) = hs.as_array_mut() {+                                if list.iter().any(|h| h.get("command").and_then(|c| c.as_str())+                                    .map(|c| c.contains("shotlog-on-read.sh")).unwrap_or(false)) {+                                    registered = true;+                                } else {+                                    list.push(serde_json::json!({"type": "command", "command": path}));+                                    registered = true;+                                    reg_changed = true;+                                }+                            }+                        }+                        break;+                    }+                }+                if !found_read_group {+                    arr.push(serde_json::json!({"matcher": "Read",+                        "hooks": [{"type": "command", "command": path}]}));+                    registered = true;+                    reg_changed = true;+                }+            }+        }+    }+    if reg_changed {+        let _ = std::fs::write(format!("{sp}.shotlog-bak"), &raw);+        if let Ok(pretty) = serde_json::to_string_pretty(&v) {+            let _ = std::fs::write(&sp, pretty);+        }+    }+    match (file_changed || reg_changed, registered) {+        (true, _) => ok("read-hook installed/updated: every image any AI thread Reads is auto-captured to the 'read-captures' channel (PostToolUse hook + settings registration)."),+        (false, true) => ok("read-hook verified: auto-capture of every image any AI Reads is active."),+        (false, false) => warn("read-hook file present but could not verify registration in settings.json."),+    }+}  #[derive(Parser)] #[command(name = "adom-shotlog", version, about = "Screenshot log viewer and CLI")]@@ -408,15 +483,42 @@ async fn run_server(port: u16, data_dir: PathBuf) {         ok("Retention active: max 200 shots/channel on disk (SHOTLOG_MAX_SHOTS), idle channels pruned after 30d (SHOTLOG_RETENTION_DAYS, 0=off)");     } +    // The auto-capture read-hook is part of shotlog: verify (or heal) it at+    // every launch so the pipeline can never silently rot.+    ensure_read_hook();+     let state = Arc::new(server::AppState { store, port, next_viewer_id: std::sync::atomic::AtomicU64::new(1) });     let app = server::create_router(state);      let addr = format!("0.0.0.0:{port}");     ok(&format!("Listening on {addr}")); -    let listener = tokio::net::TcpListener::bind(&addr)-        .await-        .expect("Failed to bind port");+    let listener = match tokio::net::TcpListener::bind(&addr).await {+        Ok(l) => l,+        Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {+            // SINGLE-INSTANCE: exactly one broker per port. If a healthy broker+            // already owns it, defer gracefully (racing self-heals are normal).+            match http_get(port, "/health") {+                Ok(v) => {+                    let rv = v["version"].as_str().unwrap_or("unknown").to_string();+                    ok(&format!("Broker already running on port {port} (v{rv}) - single instance enforced, deferring to it."));+                    if rv != env!("CARGO_PKG_VERSION") {+                        warn(&format!("Running broker is v{rv}, this binary is v{}. To upgrade: kill that `adom-shotlog serve` pid (targeted, never pkill) and start serve again.", env!("CARGO_PKG_VERSION")));+                    }+                    std::process::exit(0);+                }+                Err(_) => {+                    err(&format!("Port {port} is held by something that is NOT a shotlog broker."));+                    hint(&format!("Identify it: ss -ltnp | grep {port}"));+                    std::process::exit(1);+                }+            }+        }+        Err(e) => {+            err(&format!("Failed to bind {addr}: {e}"));+            std::process::exit(1);+        }+    };     axum::serve(listener, app)         .await         .expect("Server error");@@ -612,9 +714,13 @@ fn run_health(port: u16) {     match http_get(port, "/health") {         Ok(val) => {             let channels = val["channels"].as_array().map(|a| a.len()).unwrap_or(0);+            let rv = val["version"].as_str().unwrap_or("unknown");             ok(&format!(-                "Shotlog server running on port {port} with {channels} channel(s)"+                "Shotlog broker v{rv} running on port {port} with {channels} channel(s)"             ));+            if rv != "unknown" && rv != env!("CARGO_PKG_VERSION") {+                warn(&format!("Broker is v{rv} but this CLI is v{} - restart the broker to match (kill its pid, then `adom-shotlog serve &`).", env!("CARGO_PKG_VERSION")));+            }         }         Err(e) => {             err(&format!("Cannot connect to shotlog on port {port}"));@@ -729,6 +835,7 @@ fn run_channels(port: u16, since_min: Option<i64>, limit: usize) { }  fn run_install() {+    ensure_read_hook();     let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".to_string());      // 1. Install skill
src/server.rs+2
@@ -835,6 +835,7 @@ async fn serve_image( #[derive(Serialize)] struct HealthResponse {     ok: bool,+    version: String,     port: u16,     data_dir: String,     channels: Vec<String>,@@ -843,6 +844,7 @@ struct HealthResponse { async fn health(State(state): State<Arc<AppState>>) -> Json<HealthResponse> {     Json(HealthResponse {         ok: true,+        version: env!("CARGO_PKG_VERSION").to_string(),         port: state.port,         data_dir: state.store.base_dir().to_string_lossy().to_string(),         channels: state.store.channel_names().await,