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.10.0
11 files changed
+180−11
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,