← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-shotlog"-version = "0.19.1"+version = "0.20.0" dependencies = [  "axum",  "base64",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.19.1"+version = "0.20.0" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
SKILL.md+1
@@ -177,6 +177,7 @@ metadata are additive. | `--orig-file` | for pre-shrunk images | Path to the original full-res image; measured | | `--orig-w` / `--orig-h` | for pre-shrunk images | Original dims (alternative to `--orig-file`) | | `--native` | for unresized small images | Assert FILE is at original resolution |+| `--thread` | no | Which AI thread produced this shot (human-readable). The read-hook fills it automatically from the session transcript; pass your thread's name when injecting deliberately so the human sees WHO. | | `--meta` | optional | Adom Desktop screenshot-response JSON: child windows + coordMap + orig dims | | `FILE` | yes | Primary image path (use `-` for stdin) | 
VERSION+1−1
@@ -1 +1 @@-0.19.1+0.20.0
bin/adom-shotlog
⋯ 1 unchanged line ⋯
hooks/shotlog-on-read.sh+14−2
@@ -15,7 +15,8 @@ # Preferences dialog (broker writes this flag file). No-op when disabled. [ -f "$HOME/.claude/hooks/.shotlog-read-hook-off" ] && exit 0 -f="$(jq -r 'select(.tool_name=="Read") | (.tool_input.file_path // empty)' 2>/dev/null)"+INPUT="$(cat)"+f="$(echo "$INPUT" | jq -r 'select(.tool_name=="Read") | (.tool_input.file_path // empty)' 2>/dev/null)" [ -z "$f" ] && exit 0  case "$f" in@@ -37,6 +38,17 @@ shotlog health >/dev/null 2>&1 || { (cd "${SHOTLOG_HOME:-$HOME/project}" && shot  base="$(basename "$f")" +# Attribute the capture to the AI THREAD that read the image: the hook payload+# carries session_id + transcript_path; the transcript's ai-title records hold+# the thread's human-readable title.+SID="$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null)"+TP="$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null)"+THREAD=""+if [ -n "$TP" ] && [ -f "$TP" ]; then+  THREAD="$(grep '"type":"ai-title"' "$TP" 2>/dev/null | tail -1 | jq -r '.aiTitle // empty' 2>/dev/null)"+fi+[ -z "$THREAD" ] && [ -n "$SID" ] && THREAD="thread ${SID:0:8}"+ # Satisfy shotlog's original-resolution contract (a blind pre-shrunk inject is # REJECTED). AD saves resized captures as *.safe.png beside the *.full.png # original - pass the original when it exists; otherwise the file Claude read@@ -51,6 +63,6 @@ esac  shotlog inject -c read-captures \   -d "Auto-captured screenshot Claude read for analysis: ${base}" \-  -s read-hook $ORIG_ARGS "$f" >/dev/null 2>&1+  -s read-hook ${THREAD:+--thread "$THREAD"} $ORIG_ARGS "$f" >/dev/null 2>&1  exit 0
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-shotlog",-  "version": "0.19.1",+  "version": "0.20.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.19.1",+  "version": "0.20.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+5
@@ -154,6 +154,7 @@ header { .shot-card:hover, .shot-card:active { border-color: rgba(0,184,176,.5); } .shot-card-desc { padding: 10px 13px 5px; font-family: 'Satoshi', sans-serif; font-size: 13px;   font-weight: 500; color: var(--text); line-height: 1.4; max-height: 76px; overflow: hidden; flex-shrink: 0; }+.shot-card-meta .thr { color: var(--purple); font-style: italic; } .shot-card-desc.no-desc { color: var(--danger); font-size: 11px; font-weight: 600; max-height: none; } .shot-card-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; padding: 3px 13px 7px;   font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text3); }@@ -591,6 +592,9 @@ header {       const kb=document.createElement('span'); kb.textContent=e.kb+' KB'; meta.appendChild(kb); }     if (e.source && e.source!=='cli'){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);       const sr=document.createElement('span'); sr.className='src'; sr.textContent=e.source; meta.appendChild(sr); }+    if (e.thread){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);+      const th=document.createElement('span'); th.className='thr'; th.textContent=e.thread;+      th.setAttribute('data-tooltip','The AI thread that produced this shot.'); meta.appendChild(th); }     const pops=popList(e);     if (pops.length){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);       const b=document.createElement('span'); b.className='badge-pop'; b.textContent='+'+pops.length+' child window'+(pops.length>1?'s':'');@@ -641,6 +645,7 @@ header {       if(e.orig_w&&e.orig_h&&(e.orig_w!==e.w||e.orig_h!==e.h)){ sep(); add('','orig '+e.orig_w+'×'+e.orig_h); } }     if(e.kb){ sep(); add('',e.kb+' KB'); }     if(e.source&&e.source!=='cli'){ sep(); add('',e.source); }+    if(e.thread){ sep(); add('thr',e.thread,'The AI thread that produced this shot.'); }      // Child-window thumbnails — each opens full-size in its own modal entry.     const pops=popList(e);
src/main.rs+10−3
@@ -163,6 +163,10 @@ enum Commands {         /// the coordMap/screen metadata a hover panel.         #[arg(long)]         meta: Option<String>,+        /// Which AI thread produced this shot (human-readable title). The+        /// read-hook fills this automatically from the session transcript.+        #[arg(long)]+        thread: Option<String>,         /// PNG file path (use - for stdin)         file: String,     },@@ -286,7 +290,8 @@ async fn main() {             run_server(port, data_dir).await;         }         Commands::Inject {-            channel, desc, source, port, orig_file, orig_w, orig_h, native, meta, file,+            channel, desc, source, port, orig_file, orig_w, orig_h, native, meta, thread,+            file,         } => {             let desc = match desc {                 Some(d) if d.len() > 5 => d,@@ -412,7 +417,7 @@ async fn main() {                 }             }; -            run_inject(&channel, &desc, &source, port, &file, false, orig_w, orig_h, cap_meta, popups);+            run_inject(&channel, &desc, &source, port, &file, false, orig_w, orig_h, cap_meta, popups, thread.as_deref());         }         Commands::Open {             channel, name, icon, port, skinny, ratio, pup, swap, webview,@@ -468,7 +473,7 @@ async fn main() {                     std::process::exit(1);                 }             };-            run_inject(&channel, &desc, &source, port, &file, true, None, None, None, None);+            run_inject(&channel, &desc, &source, port, &file, true, None, None, None, None, None);         }         Commands::Resize { file, output, width } => {             run_resize(&file, output.as_deref(), width);@@ -569,6 +574,7 @@ fn run_inject(     orig_h: Option<u32>,     cap_meta: Option<serde_json::Value>,     popups: Option<Vec<serde_json::Value>>,+    thread: Option<&str>, ) {     let bytes = if file == "-" {         use std::io::Read;@@ -599,6 +605,7 @@ fn run_inject(             "image": data_url,             "description": desc,             "source": source,+        "thread": thread,         });         if let (Some(ow), Some(oh)) = (orig_w, orig_h) {             b["orig_w"] = serde_json::json!(ow);
src/server.rs+5−2
@@ -766,6 +766,9 @@ struct InjectRequest {     /// metadata (hwnd, title, kind, coordMap, shotId). The `image` is saved as its     /// own PNG and replaced with `filename` in the stored array.     popups: Option<Vec<serde_json::Value>>,+    /// Which AI thread produced this shot (human-readable title). The read-hook+    /// fills this from the session transcript; any injector may pass it.+    thread: Option<String>, }  /// Images larger than this on any edge are auto-resized (Claude crashes >1500px).@@ -862,7 +865,7 @@ async fn post_shots(      let entry = state         .store-        .add_entry(&req.channel, bytes, filename, req.description, source, w, h, orig_w, orig_h, req.meta, popups_stored)+        .add_entry(&req.channel, bytes, filename, req.description, source, w, h, orig_w, orig_h, req.meta, popups_stored, req.thread.clone())         .await;      // Presence at inject time — the caller uses this to know if the human is@@ -999,7 +1002,7 @@ async fn post_paste(             &req.channel, bytes, filename, description, "clipboard".into(), w, h,             if was_resized { Some(orig_w) } else { None },             if was_resized { Some(orig_h) } else { None },-            None, None,+            None, None, None,         )         .await; 
src/store.rs+13−4
@@ -35,6 +35,10 @@ pub struct Entry {     /// coordMap. Additive — absent for plain single-image injects.     #[serde(skip_serializing_if = "Option::is_none")]     pub popups: Option<serde_json::Value>,+    /// Which AI THREAD produced this shot (from the read-hook's session+    /// metadata, or any injector passing --thread). Human-readable title.+    #[serde(skip_serializing_if = "Option::is_none")]+    pub thread: Option<String>, }  struct Channel {@@ -231,7 +235,7 @@ impl Store {                  // Read sidecar metadata if it exists                 let meta_path = path.with_extension("json");-                let (description, source, timestamp, orig_w, orig_h, cap_meta, popups) = if let Ok(json_str) = fs::read_to_string(&meta_path) {+                let (description, source, timestamp, orig_w, orig_h, cap_meta, popups, thread) = if let Ok(json_str) = fs::read_to_string(&meta_path) {                     if let Ok(v) = serde_json::from_str::<serde_json::Value>(&json_str) {                         let desc = v["description"].as_str().unwrap_or("").to_string();                         let src = v["source"].as_str().unwrap_or("recovered").to_string();@@ -242,12 +246,13 @@ impl Store {                         let oh = v["orig_h"].as_u64().map(|n| n as u32);                         let cm = if v.get("meta").map(|m| !m.is_null()).unwrap_or(false) { Some(v["meta"].clone()) } else { None };                         let pp = if v.get("popups").map(|p| !p.is_null()).unwrap_or(false) { Some(v["popups"].clone()) } else { None };-                        (desc, src, ts, ow, oh, cm, pp)+                        let th = v.get("thread").and_then(|t| t.as_str()).map(String::from);+                        (desc, src, ts, ow, oh, cm, pp, th)                     } else {-                        (String::new(), "recovered".into(), modified, None, None, None, None)+                        (String::new(), "recovered".into(), modified, None, None, None, None, None)                     }                 } else {-                    (String::new(), "recovered".into(), modified, None, None, None, None)+                    (String::new(), "recovered".into(), modified, None, None, None, None, None)                 };                  let png_meta = read_png_meta(&bytes_raw);@@ -267,6 +272,7 @@ impl Store {                     png_meta,                     meta: cap_meta,                     popups,+                    thread,                 });                 next_id += 1;             }@@ -306,6 +312,7 @@ impl Store {         orig_h: Option<u32>,         meta: Option<serde_json::Value>,         popups: Option<serde_json::Value>,+        thread: Option<String>,     ) -> Entry {         let channel_dir = self.base_dir.join(channel_name);         fs::create_dir_all(&channel_dir).ok();@@ -331,6 +338,7 @@ impl Store {         }         if let Some(m) = &meta { meta_json["meta"] = m.clone(); }         if let Some(p) = &popups { meta_json["popups"] = p.clone(); }+        if let Some(t) = &thread { meta_json["thread"] = serde_json::json!(t); }         fs::write(&meta_path, meta_json.to_string()).ok();          let mut channels = self.channels.write().await;@@ -361,6 +369,7 @@ impl Store {             png_meta,             meta,             popups,+            thread,         };          ch.next_id += 1;