app
Adom Shotlog
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
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021
mod describe;
mod image;
mod open;
mod server;
mod store;
use clap::{CommandFactory, Parser, Subcommand};
use std::path::PathBuf;
use std::sync::Arc;
fn ok(msg: &str) { println!("OK: {msg}"); }
fn err(msg: &str) { eprintln!("ERROR: {msg}"); }
fn warn(msg: &str) { eprintln!("WARN: {msg}"); }
fn hint(msg: &str) { eprintln!("Hint: {msg}"); }
/// De-duplicate viewer surfaces into a compact label, e.g. ["pup","pup","mobile"] -> "pup ×2, mobile".
fn surface_label(surfaces: &[String]) -> String {
let mut counts: Vec<(String, usize)> = Vec::new();
for s in surfaces {
if let Some(e) = counts.iter_mut().find(|(k, _)| k == s) { e.1 += 1; }
else { counts.push((s.clone(), 1)); }
}
counts.iter()
.map(|(k, n)| if *n > 1 { format!("{k} ×{n}") } else { k.clone() })
.collect::<Vec<_>>()
.join(", ")
}
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")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Start the shotlog HTTP server
Serve {
/// Port to listen on
#[arg(short, long, default_value_t = 8820)]
port: u16,
/// Data directory for screenshot storage
#[arg(short, long, default_value = "./screenshots/shotlog")]
data_dir: PathBuf,
},
/// Inject a screenshot into the log (posts to running server).
///
/// shotlog must always know the ORIGINAL capture resolution. Pass a full-res
/// image (shotlog resizes >1500px itself and records the original), or for a
/// pre-shrunk image declare the original via --orig-file / --orig-w+--orig-h,
/// or assert it was never resized with --native. An inject that establishes
/// none of these is REJECTED — so the viewer never has to show "orig missing".
Inject {
/// Channel name
#[arg(short, long)]
channel: String,
/// Description of why this screenshot was taken (becomes the filename)
#[arg(short, long)]
desc: Option<String>,
/// Source identifier (e.g., av_capture, pup_screenshot)
#[arg(short, long, default_value = "cli")]
source: String,
/// Port of the running shotlog server
#[arg(short, long, default_value_t = 8820)]
port: u16,
/// Path to the ORIGINAL (pre-shrink) image — shotlog reads its
/// dimensions and records them. Use this when FILE is already resized.
#[arg(long)]
orig_file: Option<String>,
/// Original width before your resize (alternative to --orig-file)
#[arg(long)]
orig_w: Option<u32>,
/// Original height before your resize (alternative to --orig-file)
#[arg(long)]
orig_h: Option<u32>,
/// Assert FILE is at original capture resolution (never resized). Use
/// only when true — it records the original as the image's own size.
#[arg(long)]
native: bool,
/// Path to an Adom Desktop screenshot-response JSON (from
/// desktop_screenshot_window / _screen). shotlog reads coordMap + DPI +
/// origWidth/Height + the screenshots[] CHILD WINDOWS (owned popups —
/// dropdowns/dialogs of the parent window, by localSafePath) and stores
/// them on this entry: the child windows become viewable thumbnails and
/// 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>,
/// WHY the shot exists: the user prompt being served (the read-hook
/// fills this automatically; pass your task summary when injecting).
#[arg(long)]
context: Option<String>,
/// PNG file path (use - for stdin)
file: String,
},
/// Open a shotlog webview tab in the Adom workspace
Open {
/// Channel name
#[arg(short, long)]
channel: String,
/// Display name for the tab
#[arg(short, long)]
name: Option<String>,
/// MDI icon for the tab
#[arg(short, long)]
icon: Option<String>,
/// Port of the running shotlog server
#[arg(short, long, default_value_t = 8820)]
port: u16,
/// Open in a slim right-side panel (the preferred shotlog layout): a
/// ~20%-wide auto-updating column. Extra `open --skinny` calls stack
/// their tabs in the same panel (they auto-switch/blink on update).
#[arg(long)]
skinny: bool,
/// Width of the skinny panel as a fraction of the workspace. Default
/// comes from the broker's webview_width preference (20% unless changed).
#[arg(long)]
ratio: Option<f64>,
/// Explicitly open the WEBVIEW canvas (same as --skinny). Without any
/// canvas flag, `open` follows the human's default_canvas preference.
#[arg(long)]
webview: bool,
/// Open in the FULL-BROWSER pup canvas instead: one shared pup window
/// ('shotlog') with a tab per channel. Real zoom at near-full screen,
/// and the broker flashes the window's taskbar orange on every update.
#[arg(long)]
pup: bool,
/// SWAP canvases: with --pup, also close the channel's webview tab;
/// without --pup, also close the channel's pup tab. One surface at a time.
#[arg(long)]
swap: bool,
},
/// Resize (if needed) and inject a screenshot (like inject but applies 1400px max-width)
Paste {
/// Channel name
#[arg(short, long)]
channel: String,
/// Description (becomes the filename)
#[arg(short, long)]
desc: Option<String>,
/// Source identifier
#[arg(short, long, default_value = "cli")]
source: String,
/// Port of the running shotlog server
#[arg(short, long, default_value_t = 8820)]
port: u16,
/// PNG file path (use - for stdin)
file: String,
},
/// Resize an image to max 1400px wide with maximum PNG compression (for AI submission)
Resize {
/// Input PNG file
file: String,
/// Output file (default: overwrites input)
#[arg(short, long)]
output: Option<String>,
/// Max width in pixels
#[arg(short, long, default_value_t = 1400)]
width: u32,
},
/// Check if the shotlog server is running
Health {
/// Port of the running shotlog server
#[arg(short, long, default_value_t = 8820)]
port: u16,
},
/// Verify a HUMAN is actually watching a channel's viewer (live WebSocket
/// presence + Page Visibility). Exit 0 = ≥1 viewer ON-SCREEN, 4 = viewer(s)
/// connected but ALL backgrounded/minimized, 3 = nobody connected — so an AI
/// can gate precisely without guessing. Prints the surfaces (webview / pup /
/// mobile / browser).
Viewers {
/// Channel name
#[arg(short, long)]
channel: String,
/// Port of the running shotlog server
#[arg(short, long, default_value_t = 8820)]
port: u16,
/// Optional viewer endpoint the AI opened (e.g. a pup/proxy URL) —
/// shotlog HTTP-GETs it and reports whether it's reachable, on top of
/// the authoritative WebSocket presence check.
#[arg(long)]
url: Option<String>,
},
/// Overview of ALL channels — shot counts, last activity, source, and who's
/// watching. See how other AI threads are using shotlog on an ongoing basis.
Channels {
/// Port of the running shotlog server
#[arg(short, long, default_value_t = 8820)]
port: u16,
/// Only channels with activity in the last N minutes
#[arg(long)]
since_min: Option<i64>,
/// Max rows to show (default 25)
#[arg(long, default_value_t = 25)]
limit: usize,
},
/// Install everything: skill, bash completions
Install,
/// Generate shell completions (bash, zsh, fish)
Completions {
/// Shell type
shell: clap_complete::Shell,
},
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Serve { port, data_dir } => {
run_server(port, data_dir).await;
}
Commands::Inject {
channel, desc, source, port, orig_file, orig_w, orig_h, native, meta, thread, context,
file,
} => {
let desc = match desc {
Some(d) if d.len() > 5 => d,
Some(d) => {
err(&format!("Description too short: \"{d}\""));
hint("Write a specific description of what this screenshot shows and why you took it.");
hint("Example: --desc \"PCB board layout after routing power traces\"");
hint("The description becomes the filename, so make it meaningful.");
std::process::exit(1);
}
None => {
err("Missing --desc flag. Every screenshot needs a description.");
hint("The description becomes the filename and helps the user understand what they're looking at.");
hint("Example: shotlog inject -c my-project -d \"Homepage after nav redesign\" -s pup_screenshot file.png");
std::process::exit(1);
}
};
// --meta: ingest an Adom Desktop screenshot-response JSON. Pulls the
// coordMap/DPI/screen metadata, the original W×H (so the shot is never
// "orig missing"), and the child-window array (reads each child PNG by
// its localSafePath and attaches it). All additive.
let (cap_meta, popups, meta_ow, meta_oh) = match &meta {
None => (None, None, None, None),
Some(mp) => {
let raw = std::fs::read_to_string(mp).unwrap_or_else(|e| {
err(&format!("Failed to read --meta {mp}: {e}")); std::process::exit(1);
});
let ad: serde_json::Value = serde_json::from_str(&raw).unwrap_or_else(|e| {
err(&format!("--meta {mp} is not valid JSON: {e}")); std::process::exit(1);
});
let cm = serde_json::json!({
"shotId": ad.get("shotId"), "coordMap": ad.get("coordMap"),
"ownedPopupCount": ad.get("ownedPopupCount"),
"width": ad.get("width"), "height": ad.get("height"),
"origWidth": ad.get("origWidth"), "origHeight": ad.get("origHeight"),
"note": ad.get("_popups_note"),
});
// Child windows (owned popups) → read each PNG by localSafePath, base64 it.
let mut pops = Vec::new();
if let Some(arr) = ad.get("screenshots").and_then(|v| v.as_array()) {
for s in arr {
let path = s.get("localSafePath").or_else(|| s.get("localFullPath")).and_then(|v| v.as_str());
let Some(path) = path else { continue };
match std::fs::read(path) {
Ok(b) => {
let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &b);
pops.push(serde_json::json!({
"image": format!("data:image/png;base64,{b64}"),
"hwnd": s.get("hwnd"), "title": s.get("title"),
"kind": s.get("kind"), "shotId": s.get("shotId"),
"coordMap": s.get("coordMap"),
}));
}
Err(e) => err(&format!("WARN: child-window img {path} unreadable ({e}) — skipped")),
}
}
}
let ow = ad.get("origWidth").and_then(|v| v.as_u64()).map(|n| n as u32);
let oh = ad.get("origHeight").and_then(|v| v.as_u64()).map(|n| n as u32);
(Some(cm), if pops.is_empty() { None } else { Some(pops) }, ow, oh)
}
};
// --meta's origWidth/Height count as the declared original (so it
// satisfies the orig-resolution requirement below).
let (orig_w, orig_h) = (orig_w.or(meta_ow), orig_h.or(meta_oh));
// Resolve the original dimensions. Two ways to supply them for a
// pre-shrunk image: --orig-file (measure it) or --orig-w/--orig-h.
let orig_path_keep = orig_file.clone();
let (orig_w, orig_h) = match (orig_file, orig_w, orig_h) {
(Some(of), _, _) => {
let bytes = std::fs::read(&of).unwrap_or_else(|e| {
err(&format!("Failed to read --orig-file {of}: {e}"));
std::process::exit(1);
});
let (w, h) = image::read_png_dimensions(&bytes);
if w == 0 {
err(&format!("--orig-file {of} is not a readable PNG."));
std::process::exit(1);
}
(Some(w), Some(h))
}
(None, Some(w), Some(h)) => (Some(w), Some(h)),
(None, None, None) => (None, None),
(None, _, _) => {
err("Give BOTH --orig-w and --orig-h, or use --orig-file, or --native.");
hint("These record the original resolution of a pre-shrunk screenshot.");
std::process::exit(1);
}
};
// Enforce: shotlog must always know the original resolution, so the
// viewer never shows "orig missing".
// - explicit --orig-* already resolved above → trust it
// - stdin: can't peek the file → trust caller
// - otherwise read FILE's dims:
// --native → record orig = the image's own size
// >1500px (any edge) → leave None; the server resizes + records orig
// ≤1500px, nothing → REJECT (forces the AI to declare the original)
let (orig_w, orig_h) = if orig_w.is_some() || file == "-" {
(orig_w, orig_h)
} else {
let bytes = std::fs::read(&file).unwrap_or_else(|e| {
err(&format!("Failed to read {file}: {e}"));
std::process::exit(1);
});
let (fw, fh) = image::read_png_dimensions(&bytes);
if native {
if fw == 0 { (None, None) } else { (Some(fw), Some(fh)) }
} else if fw != 0 && fw <= 1500 && fh <= 1500 {
err(&format!("Original resolution required — refusing to inject {fw}x{fh} blind."));
hint("Resizing before inject is fine and expected — oversized images crash Claude's");
hint("vision analysis, so most screenshot verbs return a shrunk copy. The original");
hint("size just has to travel WITH the shot. Provide it, ONE of:");
hint(" --orig-w <W> --orig-h <H> (the original dimensions)");
hint(" --orig-file <the original image> (shotlog measures it — keep the original around)");
hint(" --native (only if this image was NOT resized)");
hint("If your screenshot tool doesn't surface the original W×H yet, fix it to report");
hint("the pre-resize dimensions (and keep the full-res file available for --orig-file).");
std::process::exit(1);
} else {
(None, None) // >1500 → server records the original on resize
}
};
run_inject(&channel, &desc, &source, port, &file, false, orig_w, orig_h, cap_meta, popups, thread.as_deref(), context.as_deref(), orig_path_keep.as_deref());
}
Commands::Open {
channel, name, icon, port, skinny, ratio, pup, swap, webview,
} => {
// NOTE: a CLI open does NOT record a canvas choice. Only the human's
// 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))
.unwrap_or_else(|| "webview".into());
// Canvas resolution: explicit flag wins; otherwise the HUMAN'S
// default. A plain `open` must never bypass their setting.
let use_pup = pup || (!skinny && !webview && default_canvas == "pup");
let _ = http_post(port, "/api/log", &serde_json::json!({"line": format!(
"CLI open '{}' canvas={} ({})", channel,
if use_pup {"pup"} else {"webview"},
if pup {"--pup"} else if skinny || webview {"explicit webview"} else {"followed default"})}));
if use_pup {
open::open_pup(&channel, port, swap);
} else {
// Webview is ALWAYS the managed slim panel at the preferred
// width - an unmanaged add-tab once landed at 50% of the screen.
let ratio = ratio.unwrap_or_else(|| {
prefs.as_ref()
.and_then(|v| v.get("webview_width").and_then(|w| w.as_u64()))
.map(|w| (w as f64 / 100.0).clamp(0.10, 0.50))
.unwrap_or(0.2)
});
open::open_webview(&channel, name.as_deref(), icon.as_deref(), port, true, ratio);
if swap {
if open::close_pup_tab(&channel) {
ok(&format!("closed the pup tab for '{channel}' - webview is now this channel's canvas."));
}
}
hint("Skinny webview too small for the human to inspect images? `shotlog open -c <ch> --pup` opens the full-browser pup canvas (add --swap to close the webview tab). Zoom there uses nearly the whole screen.");
}
}
Commands::Paste {
channel, desc, source, port, file,
} => {
let desc = match desc {
Some(d) if d.len() > 5 => d,
Some(d) => {
err(&format!("Description too short: \"{d}\""));
hint("Write a specific description. Example: --desc \"Schematic after adding decoupling caps\"");
std::process::exit(1);
}
None => {
err("Missing --desc flag. Every screenshot needs a description.");
hint("Example: shotlog paste -c my-project -d \"Full page screenshot resized for AI\" file.png");
std::process::exit(1);
}
};
run_inject(&channel, &desc, &source, port, &file, true, None, None, None, None, None, None, None);
}
Commands::Resize { file, output, width } => {
run_resize(&file, output.as_deref(), width);
}
Commands::Health { port } => {
run_health(port);
}
Commands::Viewers { channel, port, url } => {
run_viewers(&channel, port, url.as_deref());
}
Commands::Channels { port, since_min, limit } => {
run_channels(port, since_min, limit);
}
Commands::Install => {
run_install();
}
Commands::Completions { shell } => {
let mut cmd = Cli::command();
clap_complete::generate(shell, &mut cmd, "adom-shotlog", &mut std::io::stdout());
}
}
}
async fn run_server(port: u16, data_dir: PathBuf) {
let store = store::Store::new(data_dir);
store.recover().await;
let channel_count = store.channel_names().await.len();
ok(&format!("Recovered {channel_count} channel(s) from disk"));
// Retention: cap shots per channel on disk (SHOTLOG_MAX_SHOTS, default 200,
// enforced on every inject + at recovery) and prune idle channels
// (SHOTLOG_RETENTION_DAYS, default 30, 0 disables) at startup + every 6h.
{
let sweeper = store.clone();
tokio::spawn(async move {
loop {
let pruned = sweeper.retention_sweep().await;
if !pruned.is_empty() {
ok(&format!("Retention: pruned {} idle channel(s): {}", pruned.len(), pruned.join(", ")));
}
tokio::time::sleep(std::time::Duration::from_secs(6 * 3600)).await;
}
});
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 = 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");
}
#[allow(clippy::too_many_arguments)]
fn run_inject(
channel: &str,
desc: &str,
source: &str,
port: u16,
file: &str,
resize: bool,
orig_w: Option<u32>,
orig_h: Option<u32>,
cap_meta: Option<serde_json::Value>,
popups: Option<Vec<serde_json::Value>>,
thread: Option<&str>,
context: Option<&str>,
orig_path: Option<&str>,
) {
let bytes = if file == "-" {
use std::io::Read;
let mut buf = Vec::new();
std::io::stdin()
.read_to_end(&mut buf)
.expect("Failed to read stdin");
buf
} else {
std::fs::read(file).unwrap_or_else(|e| {
err(&format!("Failed to read {file}: {e}"));
std::process::exit(1);
})
};
let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes);
let data_url = format!("data:image/png;base64,{b64}");
let body = if resize {
serde_json::json!({
"channel": channel,
"dataUrl": data_url,
"description": desc,
})
} else {
let mut b = serde_json::json!({
"channel": channel,
"image": data_url,
"description": desc,
"source": source,
"thread": thread,
"context": context,
"orig_path": orig_path.map(|p| std::fs::canonicalize(p).map(|c| c.to_string_lossy().to_string()).unwrap_or_else(|_| p.to_string())),
});
if let (Some(ow), Some(oh)) = (orig_w, orig_h) {
b["orig_w"] = serde_json::json!(ow);
b["orig_h"] = serde_json::json!(oh);
}
if let Some(m) = cap_meta { b["meta"] = m; }
if let Some(p) = popups { b["popups"] = serde_json::json!(p); }
b
};
let endpoint = if resize { "/api/paste" } else { "/api/shots" };
match http_post(port, endpoint, &body) {
Ok(val) => {
if val.get("ok").and_then(|v| v.as_bool()) == Some(true) {
let w = val["w"].as_u64().unwrap_or(0);
let h = val["h"].as_u64().unwrap_or(0);
let ow = val["original_w"].as_u64();
let oh = val["original_h"].as_u64();
let dim_str = match (ow, oh) {
(Some(ow), Some(oh)) => format!("{w}x{h}, orig {ow}x{oh}"),
_ => format!("{w}x{h}"),
};
ok(&format!(
"Injected {} ({dim_str}, {} KB) into channel {channel}\n Saved to: {}",
val["name"].as_str().unwrap_or("?"),
val["kb"],
val["path"].as_str().unwrap_or("?"),
));
// shotlog resized a full-res image itself and recorded the original.
if let (Some(ow), Some(oh)) = (ow, oh) {
if orig_w.is_none() {
hint(&format!("Auto-resized from {ow}x{oh} → {w}x{h}; original size recorded."));
}
}
// Presence check — is the human ACTUALLY seeing this, and where?
let getvec = |key: &str| -> Vec<String> {
val.get(key).and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect())
.unwrap_or_default()
};
if let Some(viewers) = val.get("viewers").and_then(|v| v.as_u64()) {
let surfaces = getvec("surfaces");
let visible = val.get("visible_viewers").and_then(|v| v.as_u64()).unwrap_or(0);
let vis_surf = getvec("visible_surfaces");
// Two reminders ride on EVERY inject:
let verify = "Are you sure the human is seeing the shotlog? Is it open in a webview or pup? Have you verified it's truly showing?";
// The core reason shotlog exists — image-blindness in text threads.
let cant_show = "You're in Claude Code / a text thread: you CANNOT display images to the human. Reading or capturing a screenshot loads it into YOUR context, not their view — so NEVER write \"see the screenshot above\" / \"look at this image\". This viewer is the ONLY way they see it: point them to the viewer or a URL.";
// Status line (state-specific), then the two universal reminders,
// then any state-specific follow-up.
if visible > 0 {
let s = if vis_surf.is_empty() { String::new() } else { format!(" [{}]", surface_label(&vis_surf)) };
ok(&format!("{visible} of {viewers} viewer(s) ON-SCREEN{s} — a human can see this shot."));
} else if viewers > 0 {
let s = if surfaces.is_empty() { String::new() } else { format!(" [{}]", surface_label(&surfaces)) };
warn(&format!("{viewers} viewer(s) connected to '{channel}'{s} but ALL are BACKGROUNDED / minimized — the human is NOT looking right now."));
} else {
warn(&format!("NO live viewer is connected to '{channel}' — the human is NOT seeing this shot."));
}
// What the BROKER is doing about it — so the calling AI never
// duplicates surfacing work or foregrounds anything itself.
if let Some(acts) = val.get("surfacing").and_then(|s| s.get("actions")).and_then(|a| a.as_array()) {
for a in acts {
match a.get("action").and_then(|x| x.as_str()) {
Some("webview_surface") => ok(&format!(
"Broker: '{}' webview: {} — surfacing is handled, do NOT open/foreground anything yourself.",
a.get("tab").and_then(|t| t.as_str()).unwrap_or("Shots"),
a.get("cue").and_then(|c| c.as_str()).unwrap_or("tab pulled to front"))),
Some("pup_flash") => ok(&format!(
"Broker: flashing the pup taskbar orange (session{}) — the human's gentle cue is handled.",
a.get("sessions").and_then(|s| s.as_array()).map(|v| format!(" {}", v.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "))).unwrap_or_default())),
Some("force_open") => ok(&format!(
"Broker: nobody was watching — force-opening this channel's chosen canvas ({}) now. Re-check with `shotlog viewers` before assuming the human sees it.",
a.get("canvas").and_then(|c| c.as_str()).unwrap_or("webview"))),
_ => {}
}
}
}
// Progressive mini-skill hints from the broker (shown a few
// times each, then quiet).
if let Some(hs) = val.get("hints").and_then(|h| h.as_array()) {
for h in hs { if let Some(t) = h.as_str() { hint(t); } }
}
hint(cant_show);
hint(verify);
// Anything the broker did NOT handle is the caller's job.
let broker_actions: Vec<String> = val.get("surfacing")
.and_then(|s| s.get("actions")).and_then(|a| a.as_array())
.map(|a| a.iter().filter_map(|x| x.get("action").and_then(|v| v.as_str()).map(String::from)).collect())
.unwrap_or_default();
let has_pup = surfaces.iter().any(|s| s == "pup");
if visible == 0 && viewers > 0 {
// Backgrounded — surface it GENTLY. NEVER force the window to the front.
if has_pup && !broker_actions.iter().any(|a| a == "pup_flash") {
hint("A pup viewer is backgrounded and I could NOT flash it (no ?session= handle). Nudge it yourself: `adom-desktop browser_alert_window {sessionId}` — never force the pup window to the front.");
}
}
if viewers == 0 && !broker_actions.iter().any(|a| a == "force_open") {
hint(&format!("Open it, then RE-CHECK: `shotlog open -c {channel} --skinny` (preferred: a slim right-side auto-updating panel) — or load it in pup / on the phone — then `shotlog viewers -c {channel}`."));
}
}
} else {
err(&format!("Server error: {}", serde_json::to_string(&val).unwrap_or_default()));
}
}
Err(e) => {
err(&e);
hint("Is `shotlog serve` running? Start with: shotlog serve &");
}
}
}
fn run_resize(file: &str, output: Option<&str>, max_width: u32) {
let bytes = std::fs::read(file).unwrap_or_else(|e| {
err(&format!("Failed to read {file}: {e}"));
std::process::exit(1);
});
let (orig_w, orig_h) = image::read_png_dimensions(&bytes);
let orig_kb = bytes.len() / 1024;
match image::resize_png(bytes, max_width) {
Ok((resized, new_w, new_h)) => {
let new_kb = resized.len() / 1024;
let out_path = output.unwrap_or(file);
std::fs::write(out_path, &resized).unwrap_or_else(|e| {
err(&format!("Failed to write {out_path}: {e}"));
std::process::exit(1);
});
if orig_w != new_w {
ok(&format!(
"Resized {file}: {orig_w}x{orig_h} ({orig_kb} KB) -> {new_w}x{new_h} ({new_kb} KB) saved to {out_path}"
));
} else {
ok(&format!(
"Already within {max_width}px: {file} {orig_w}x{orig_h} ({new_kb} KB)"
));
}
}
Err(e) => {
err(&format!("Resize failed: {e}"));
std::process::exit(1);
}
}
}
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 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}"));
hint("Start with: shotlog serve &");
eprintln!("{e}");
std::process::exit(1);
}
}
}
/// Verify a human is actually watching. Exit 0 = someone's live viewer is
/// connected; exit 3 = nobody is (the shot is landing unseen). Lets an AI gate
/// on "did the human actually see it" in one call instead of guessing.
fn run_viewers(channel: &str, port: u16, url: Option<&str>) {
let endpoint = format!("/api/viewer-status?channel={}", channel.replace(' ', "%20"));
let val = match http_get(port, &endpoint) {
Ok(v) => v,
Err(e) => {
err(&format!("Cannot connect to shotlog on port {port}: {e}"));
hint("Start it first: shotlog serve &");
std::process::exit(1);
}
};
let viewers = val["viewers"].as_u64().unwrap_or(0);
let visible = val["visible"].as_u64().unwrap_or(0);
let getvec = |key: &str| -> Vec<String> {
val[key].as_array()
.map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect())
.unwrap_or_default()
};
let surfaces = getvec("surfaces");
let vis_surf = getvec("visible_surfaces");
// Optional: the AI passes the endpoint it opened; we confirm it's reachable.
if let Some(u) = url {
match std::process::Command::new("curl")
.args(["-sS", "-o", "/dev/null", "-m", "8", "-w", "%{http_code}", u])
.output()
{
Ok(o) => {
let code = String::from_utf8_lossy(&o.stdout);
let code = code.trim();
if code.starts_with('2') || code.starts_with('3') {
ok(&format!("Endpoint reachable (HTTP {code}): {u}"));
} else {
warn(&format!("Endpoint NOT reachable (HTTP {code}): {u} — that surface can't be showing the viewer."));
}
}
Err(e) => warn(&format!("Could not probe endpoint {u}: {e}")),
}
}
// Three states: on-screen (0), open-but-backgrounded (4), nobody (3).
if visible > 0 {
let surf = if vis_surf.is_empty() { String::new() } else { format!(" [{}]", surface_label(&vis_surf)) };
ok(&format!("{visible} of {viewers} viewer(s) ON-SCREEN{surf} watching '{channel}' — a human can see it."));
std::process::exit(0);
} else if viewers > 0 {
let surf = if surfaces.is_empty() { String::new() } else { format!(" [{}]", surface_label(&surfaces)) };
warn(&format!("{viewers} viewer(s) connected to '{channel}'{surf} but ALL are BACKGROUNDED / minimized — the human isn't looking right now."));
hint(&format!("Bring the viewer to the FOREGROUND (pup window / webview tab / phone), then re-run `shotlog viewers -c {channel}`."));
std::process::exit(4);
} else {
warn(&format!("NO live viewer connected to '{channel}' — nothing you inject here is being seen."));
hint(&format!("Open it: `shotlog open -c {channel}` (Hydrogen webview), or load the viewer in pup / on the phone, then re-run `shotlog viewers -c {channel}`."));
std::process::exit(3);
}
}
/// Print an overview of every channel: last activity, shot count, who's watching,
/// and the source of the last shot. Newest-active first.
fn run_channels(port: u16, since_min: Option<i64>, limit: usize) {
let val = match http_get(port, "/api/channels") {
Ok(v) => v,
Err(e) => { err(&format!("Cannot connect to shotlog on port {port}: {e}")); hint("Start it: shotlog serve &"); std::process::exit(1); }
};
let rows = val.as_array().cloned().unwrap_or_default();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
let fmt_ago = |secs: i64| -> String {
let m = secs / 60;
if m < 1 { "just now".into() } else if m < 60 { format!("{m}m ago") }
else if m < 1440 { format!("{}h ago", m / 60) } else { format!("{}d ago", m / 1440) }
};
println!("{:>9} {:>5} {:<20} {}", "ACTIVITY", "SHOTS", "WATCHING", "CHANNEL");
let (mut shown, mut watched, mut total) = (0usize, 0usize, 0usize);
for row in &rows {
total += 1;
let last_epoch = row["last_epoch"].as_i64().unwrap_or(0);
let age = now - last_epoch;
if let Some(mins) = since_min { if last_epoch == 0 || age > mins * 60 { break; } } // sorted newest-first
let viewers = row["viewers"].as_u64().unwrap_or(0);
let visible = row["visible"].as_u64().unwrap_or(0);
if viewers > 0 { watched += 1; }
if shown >= limit { continue; }
let surfaces: Vec<String> = row["surfaces"].as_array()
.map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect()).unwrap_or_default();
let watching = if viewers > 0 {
let s = if surfaces.is_empty() { String::new() } else { format!(" [{}]", surface_label(&surfaces)) };
format!("● {visible}/{viewers} on-screen{s}")
} else { "— nobody".into() };
let ch = row["channel"].as_str().unwrap_or("?");
let shots = row["shots"].as_u64().unwrap_or(0);
let ago = if last_epoch == 0 { "—".into() } else { fmt_ago(age) };
println!("{ago:>9} {shots:>5} {watching:<20} {ch}");
shown += 1;
}
println!("\n{total} channels · {watched} with a live viewer right now.");
if watched == 0 && total > 0 {
hint("No channel is being watched. Shots injected now would auto-open a slim webview (or set SHOTLOG_NO_FORCE_OPEN=1 to suppress).");
}
}
fn run_install() {
ensure_read_hook();
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".to_string());
// 1. Install skill
let skill_dir = format!("{home}/.claude/skills/adom-shotlog");
let skill_file = format!("{skill_dir}/SKILL.md");
std::fs::create_dir_all(&skill_dir).ok();
match std::fs::write(&skill_file, SKILL_MD) {
Ok(_) => ok(&format!("Skill installed to {skill_file}")),
Err(e) => err(&format!("Failed to write skill: {e}")),
}
// 2. Install bash completions
let comp_dir = format!("{home}/.local/share/bash-completion/completions");
std::fs::create_dir_all(&comp_dir).ok();
let comp_file = format!("{comp_dir}/adom-shotlog");
let mut cmd = Cli::command();
let mut buf = Vec::new();
clap_complete::generate(clap_complete::Shell::Bash, &mut cmd, "adom-shotlog", &mut buf);
match std::fs::write(&comp_file, &buf) {
Ok(_) => ok(&format!("Bash completions installed to {comp_file}")),
Err(e) => err(&format!("Failed to write completions: {e}")),
}
// 3. Add to .bashrc if not there
let bashrc = format!("{home}/.bashrc");
let bashrc_content = std::fs::read_to_string(&bashrc).unwrap_or_default();
let marker = "# adom-shotlog completions";
if !bashrc_content.contains(marker) {
let line = format!("\n{marker}\ncommand -v adom-shotlog >/dev/null && eval \"$(adom-shotlog completions bash 2>/dev/null)\"\n");
match std::fs::OpenOptions::new().append(true).open(&bashrc) {
Ok(mut f) => {
use std::io::Write;
f.write_all(line.as_bytes()).ok();
ok("Bash completions added to .bashrc");
}
Err(e) => err(&format!("Failed to update .bashrc: {e}")),
}
}
// 4. Back-compat `shotlog` alias → symlink next to the adom-shotlog binary.
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let alias = dir.join("shotlog");
if !alias.exists() {
#[cfg(unix)]
match std::os::unix::fs::symlink(&exe, &alias) {
Ok(_) => ok(&format!("`shotlog` alias linked to {}", alias.display())),
Err(e) => err(&format!("Could not create `shotlog` alias ({e}) — run: sudo ln -s {} {}", exe.display(), alias.display())),
}
}
}
}
println!("\nInstall complete.");
}
// ── HTTP client (raw TCP) ───────────────────────────────────────
fn http_post(port: u16, endpoint: &str, body: &serde_json::Value) -> Result<serde_json::Value, String> {
let body_str = body.to_string();
let request = format!(
"POST {endpoint} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body_str}",
body_str.len(),
);
send_request(port, &request)
}
fn http_get(port: u16, endpoint: &str) -> Result<serde_json::Value, String> {
let request = format!(
"GET {endpoint} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n"
);
send_request(port, &request)
}
fn send_request(port: u16, request: &str) -> Result<serde_json::Value, String> {
use std::io::{Read, Write};
let mut stream = std::net::TcpStream::connect(format!("127.0.0.1:{port}"))
.map_err(|e| format!("Connection refused: {e}"))?;
let timeout = std::time::Duration::from_secs(10);
stream.set_read_timeout(Some(timeout)).ok();
stream.set_write_timeout(Some(timeout)).ok();
stream.write_all(request.as_bytes()).map_err(|e| e.to_string())?;
let mut response = String::new();
stream.read_to_string(&mut response).unwrap_or(0);
if response.is_empty() {
return Ok(serde_json::json!({ "ok": true }));
}
let body_start = match response.find("\r\n\r\n") {
Some(pos) => pos,
None => return Ok(serde_json::json!({ "ok": true })),
};
let raw_body = &response[body_start + 4..];
// Slice out the JSON value — an OBJECT {…} or an ARRAY […] (the /api/channels
// response is an array; the old object-only slice mangled it).
let start = raw_body.find(|c| c == '{' || c == '[');
let end = raw_body.rfind(|c| c == '}' || c == ']');
let body = match (start, end) {
(Some(s), Some(e)) if e >= s => &raw_body[s..=e],
_ => raw_body.trim(),
};
if body.is_empty() {
return Ok(serde_json::json!({ "ok": true }));
}
serde_json::from_str(body).map_err(|e| format!("Invalid JSON: {e}"))
}