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.8.0
21 files changed
+751−274
Cargo.lock+1−1Cargo.toml+1−1README.md+54−67SKILL.md+48−36VERSION+1−1bin/adom-shotloginstall.sh+11−20package.json+3−2page.json+3−2screenshots/child-windows.pngskills/adom-shotlog-child-windows/SKILL.md+11−10skills/adom-shotlog-dev/SKILL.md+118skills/adom-shotlog-publishing/SKILL.md+12−11skills/adom-shotlog-surfacing/SKILL.md+24−15src/html/index.html+42−11src/main.rs+96−17src/open.rs+6−4src/server.rs+235−74src/store.rs+15−2tools/publish.sh+45tools/ralph-check.cjs+25Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-shotlog"-version = "0.7.0"+version = "0.8.0" dependencies = [ "axum", "base64",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.7.1"+version = "0.8.0" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
README.md+54−67@@ -2,7 +2,7 @@ **See the shots your AI never showed you.** -Your AI screenshots your app constantly while it builds and tests — but it's text-centric, so those shots get buried in tool output and you never actually see them. adom-shotlog is the fix: a tiny Rust CLI + server where the AI drops each screenshot into a **channel**, and you watch them stream into a newest-first **gallery** — open it in a Hydrogen webview, a pup window, your native browser, or on your phone. Every shot records its description, source, and **original capture resolution**, so you always know exactly what the AI saw. It's the simplest, most-reached-for tool in the Adom toolbox: if your AI is doing anything visual, adom-shotlog lets you see it.+Your AI screenshots your app constantly while it builds and tests, but it is text-centric, so those shots get buried in tool output and you never actually see them. adom-shotlog is the fix: a tiny Rust CLI plus server where any AI thread drops each screenshot into a **channel**, and you watch them stream into a live, newest-first **gallery**, on whatever surface you are using: a Hydrogen webview, a pup window, your native browser, or your phone. ## Install @@ -12,114 +12,101 @@ adompkg install adom/adom-shotlog The CLI installs as `adom-shotlog` (with a back-compat `shotlog` alias). -## The on-the-road superpower: `/remote-control` + your phone+## One central broker, many AI threads -You're away from your desk. You ask your AI to do something long — build a board, run a setup, render a viewer. You tell it to **shotlog the whole thing**, and it sends you one URL. You open that URL **on your phone** and watch every step stream in as it happens — then scroll back through the whole run whenever you want.+There is **one shotlog server per container**, and the same binary is both the server and the CLI. `adom-shotlog serve` spawns the broker on **port 8820**; `inject`, `open`, `viewers`, and `channels` are CLI verbs that talk to it. -+Every AI thread in the container simply runs `adom-shotlog inject`, which posts to that one local server. So the server is a **central broker**: it owns every channel, tracks every viewer (which surface, and whether it is actually on-screen), and manages all the viewer surfaces. Many threads can be sending you shots at once, each into its own channel, and the broker keeps a live view/tab for each. Run `adom-shotlog channels` to see the whole picture: every channel, its shot count, when it last updated, and who is watching right now. -Combined with **`/remote-control`**, you can kick off real work from anywhere and keep vibe-coding from the passenger seat. Your AI does the work, shotlogs it, and hands you a link. No screen-share, no VNC, no "send me a screenshot" back-and-forth.+## It adapts to your surface -## Other things people use it for every day+shotlog notices which surface is watching a channel and behaves accordingly. It never steals your OS foreground. -- **Verifying long-running jobs without babysitting them.** Start a 20-minute build or a multi-step install, have the AI shotlog each milestone, then review the whole sequence at your own pace. We shotlog the **entire Hydrogen Desktop setup-step sequence** this way.-- **Capturing every state of a complex feature.** When building HD's screenshot pipeline we shotlogged **all of the screenshotting states** — full window, VS Code panel, wiki webview — side by side.-- **Gathering the images for public READMEs.** Point the AI at a channel, let it shotlog each view, then pick the best ones straight from the gallery.-- **Proof-of-work debug loops.** "Prove it's working" → the AI drives the UI, shotlogs each state, and hands you the gallery as evidence.+### Webview (the default in Hydrogen): a slim 20% right-side panel -## Quick start+The nicest setup, and the default when you run inside Hydrogen: a **~20%-wide webview panel pinned to the right** of your workspace. Open it that way: ```-adom-shotlog serve & # start the server (port 8820)-adom-shotlog open -c my-project # open the gallery viewer-adom-shotlog inject -c my-project \- -d "Board layout after routing power traces" \- -s pup_screenshot --orig-w 1920 --orig-h 1080 /tmp/shot.png+adom-shotlog open -c my-project --skinny # a ~20% right-side column ``` -## It refuses to log a shot blind+- It **auto-updates live** over a WebSocket: the moment any thread injects a shot, the card appears at the top of that channel, no refresh, no polling.+- You can keep **several channels as tabs in that one panel**. When a channel updates, the broker pulls that channel's tab to the front so the newest activity is what you see (targeted by the tab's unique name, so with many shotlogs open in parallel it never touches the wrong one).+- If a shot lands in a channel with **nobody watching**, the broker auto-opens the slim 20% webview for it, so a shot is never logged invisibly. (Opt out with `SHOTLOG_NO_FORCE_OPEN=1`.)+- The layout is **fully fluid** at that skinny width: the header wraps to rows, the child-window thumbnails reflow, and the outer page never shows a horizontal scrollbar. -adom-shotlog **always** records the original capture resolution, so the gallery never shows a mystery "what size was this really?" — it shows `1400×788 · orig 1920×1080`. Resizing before inject is expected (oversized images crash AI vision analysis), so most screenshot tools hand back a shrunk copy. adom-shotlog just requires the original size to travel with it — via `--orig-w/--orig-h`, `--orig-file`, `--native`, or `--meta`. A bare inject of a pre-shrunk image is rejected with a hint.+### Phone -## Captures the child windows too (`--meta`)+Ask your AI to `shotlog the whole thing`, and it hands you one URL. Open that URL **on your phone** and watch every step stream in as it happens, then scroll back through the whole run whenever you want. -Adom Desktop's `desktop_screenshot_window` returns the parent window **plus its child windows** — the owned dropdowns, confirm bubbles, and native dialogs (AD calls these `screenshots[]` / `ownedPopupCount`) — with coordMap / DPI / screen metadata. Pass the whole response through `--meta`:+ -```-adom-shotlog inject -c checkout -d "checkout form with autofill open" primary.png --meta ad-response.json-```+The layout adapts to the narrow screen, and while the tab is backgrounded the page title shows a `● N` unseen-shots badge so you know something arrived. Combined with `/remote-control`, you can kick off real work from anywhere and keep vibe-coding from the passenger seat. -The gallery card gets a **+N child windows** badge; each child window shows inline in a `child windows ▸` row (no zoom); and an **ⓘ meta** chip opens a copyable panel with shotId, screenRect, dpiScale, imageToScreen, and screen resolution.+### Pup and plain browser -## Gallery — desktop+If the viewer is a pup window, shotlog leaves your foreground alone; the gentle cue is a taskbar flash, not a window that jumps in front of you. In any plain browser tab, the same live gallery and unseen-shots title badge apply. -+## Is a human actually seeing it? -Newest-first cards, each with a prominent description, dual-size metadata, source, and KB. Tap any card to zoom to the full image. A green LED shows when a viewer is connected; new shots appear at the top instantly over WebSocket. If a shot lands in a channel nobody is watching, the server force-opens a viewer so nothing is ever logged invisibly.+An AI injecting shots into a channel nobody is watching is worse than useless, because it *thinks* it showed you something. So shotlog answers this **in the inject response** and lets a thread gate on it: -## Features+```+adom-shotlog viewers -c my-project # exit 0 = a human has it on-screen, 4 = open but backgrounded, 3 = nobody+``` -- **Channels** — group shots by project, task, or debug session-- **Enforced original-resolution tracking** — every shot's true capture size, never lost-- **Child-window arrays** — `--meta` ingests a parent-window capture + its child windows (owned dropdowns/dialogs) + coordMap; the viewer shows each child window inline + the metadata-- **Gallery viewer** — newest-first, prominent descriptions, dual-size metadata, tap-to-zoom, mobile layout, tooltips-- **Phone-ready** — open the same URL on your phone; the layout adapts-- **Live updates + presence LED** — new shots stream in; a green dot shows you're connected-- **Presence verification** — `inject` reports whether a human is actually watching and on which surface (webview/pup/mobile/browser); `viewers` exits 0/3 so an AI can *gate* on "was it seen?" instead of guessing-- **Force-open safety net** — an unwatched channel auto-opens a viewer-- **Clipboard paste** — Ctrl+V anywhere pastes a screenshot+The signal is authoritative: each viewer holds a WebSocket open and reports its **surface** (webview, pup, mobile, browser) and whether it is actually **on-screen** (Page Visibility) versus a background tab. -## Is the human actually seeing it?+## It refuses to log a shot blind -An AI injecting shots into a channel nobody's watching is worse than useless — it *thinks* it showed you something. So shotlog answers "is a human seeing this?" **in the inject response**:+adom-shotlog **always** records the original capture resolution, so the gallery never shows a mystery size. It shows `1400x788, orig 1920x1080`. Resizing before inject is expected (oversized images crash AI vision analysis), so most screenshot tools hand back a shrunk copy. shotlog just requires the original size to travel with it, via `--orig-w/--orig-h`, `--orig-file`, `--native`, or `--meta`. -```-OK: Injected board-after-routing.png (1400x788, orig 1920x1080, 17 KB) into channel board-review-OK: 1 live viewer(s) connected to 'board-review' [pup]-```+## Killer feature: a whole parent + child-window array in one card -…or, if nobody's watching:+A window capture is often not one image. A window with a dropdown, an autofill list, a confirm bubble, or a native dialog open is really a **parent window with child windows**: separate owned windows that a plain screenshot of the main window misses entirely.++Adom Desktop captures them for you. **`desktop_screenshot_window {hwnd}` returns the parent image PLUS every child window** in a `screenshots[]` array (with `ownedPopupCount`), each carrying its own hwnd, title, kind, rect, and coordMap. Pass that whole response to shotlog with `--meta`: ```-WARN: NO live viewer is connected to 'board-review' — the human is NOT seeing this shot.+adom-shotlog inject -c checkout -d "checkout form, autofill dropdown + confirm dialog open" primary.png --meta ad-response.json ``` -The live-viewer signal comes from the WebSocket the viewer holds open, so it's authoritative — each viewer reports its **surface** (webview / pup / mobile / browser) **and** whether it's actually **on-screen** (Page Visibility) vs a background tab / minimized window. To gate a workflow programmatically:+shotlog renders the entire array on one card, no zoom needed: -```-adom-shotlog viewers -c board-review # exit 0 = on-screen, 4 = open but backgrounded, 3 = nobody-```+++- a **+N child windows** badge on the card,+- a **child windows** row directly under the parent shot, every child window inline as its own thumbnail (labeled `kind, title, WxH`),+- an **ⓘ meta** chip that reveals the coordMap panel inline (shotId, screenRect, dpiScale, imageToScreen, screen resolution), copyable as JSON.++So one `desktop_screenshot_window` call, one `--meta` inject, and you see the whole window story: the form, the dropdown it opened, and the dialog it raised, all in a single card. Child-window PNGs are stored beside the parent and are excluded from the gallery as standalone shots. Plain single-image injects (no `--meta`) are unchanged; child windows are purely additive. ## CLI reference | Command | Description | |---|---|-| `adom-shotlog inject` | Inject a screenshot into a channel (records the original resolution; `--meta` adds child windows; reports live viewers) |-| `adom-shotlog viewers` | Is a human watching? Exit 0/3 + connected surfaces (webview/pup/mobile/browser) |-| `adom-shotlog open` | Open the gallery viewer as a Hydrogen webview tab |-| `adom-shotlog serve` | Start the HTTP server (port 8820) |+| `adom-shotlog inject` | Inject a screenshot into a channel (records the original resolution; `--meta` adds child windows; reports whether a human is watching) |+| `adom-shotlog open` | Open the gallery as a Hydrogen webview tab (`--skinny` for the 20% right panel) |+| `adom-shotlog viewers` | Is a human watching a channel? Exit 0 / 4 / 3, plus connected surfaces |+| `adom-shotlog channels` | Overview of every channel: shots, last activity, and who is watching |+| `adom-shotlog serve` | Start the HTTP server / broker (port 8820) | | `adom-shotlog health` | Check if the server is running |-| `adom-shotlog resize` | Resize an image (rarely needed — inject auto-resizes) |-| `adom-shotlog install` | Install skill + bash completions + the `shotlog` alias |+| `adom-shotlog install` | Install the skill, bash completions, and the `shotlog` alias | -## Skill pack (bundled sub-skills)+## Skill pack -Installing `adom/adom-shotlog` also deploys these sub-skills (they live in this-repo under `skills/`, not separate wiki pages):+Installing adom-shotlog also deploys its bundled skills (see the Skills tab): -- **adom-shotlog-surfacing** — surfacing a new shot to the human *gently*, per- surface (webview blink / pup taskbar flash / phone title badge), never stealing- foreground; plus the Adom Desktop window-discovery + gentle-cue verbs.-- **adom-shotlog-child-windows** — capturing a parent window **plus its child- windows** (owned dropdowns/dialogs) via `--meta`, and how they render inline.-- **adom-shotlog-publishing** — publishing this package to wiki.adom.inc (org- ownership, OSS source, hero, and the wiki traps).+- **adom-shotlog-surfacing** (user): surfacing a shot gently per surface, never stealing foreground.+- **adom-shotlog-child-windows** (user): capturing a parent window plus its child windows via `--meta`.+- **adom-shotlog-dev** (dev): build/deploy, the central-broker architecture, and the layout rules (ralph-test the skinny view, no outer-html scrollbars, pretty scrollbars).+- **adom-shotlog-publishing** (publish): publishing this package to wiki.adom.inc. ## Dependencies | Dependency | Required? | Why | |---|---|---|-| **adom-cli** | For the viewer | `adom-shotlog open` adds the gallery as a Hydrogen webview tab. |+| **adom-cli** | For the webview | `adom-shotlog open` adds the gallery as a Hydrogen webview tab and the broker manages it. | | **adom-desktop** | Optional | AI agents use it to capture screenshots (and child-window arrays) that get injected. Humans can Ctrl+V paste instead. | -No `adompkg` package dependencies — adom-shotlog is a self-contained Rust binary.+No `adompkg` package dependencies. adom-shotlog is a self-contained Rust binary.
SKILL.md+48−36@@ -3,7 +3,7 @@ name: adom-shotlog description: "Screenshot log viewer and injector. Use when: logging screenshots during debug loops, documenting visual progress, reviewing screenshot history, opening the screenshot log panel, injecting a screenshot into the log, ingesting an Adom Desktop screenshot response with child windows (owned popups). Trigger words: adom-shotlog, shotlog, screenshot log, shot log, visual log, screenshot history, log screenshot, open shotlog, screenshot viewer." --- -# adom-shotlog — Screenshot Log+# adom-shotlog - Screenshot Log A Rust CLI + HTTP server (port **8820**) that captures, stores, and displays screenshots in channels. AI agents inject screenshots as they work; you watch@@ -15,24 +15,22 @@ Binary: `/usr/local/bin/adom-shotlog` (the `shotlog` command is a back-compat al ## This skill pack (main + sub-skills) This is the **main** skill. Deeper topics are **sub-skills** shipped in the same-`adom/adom-shotlog` package (installed to `~/.claude/skills/`), not separate wiki-pages:--- **[adom-shotlog-surfacing](../adom-shotlog-surfacing/SKILL.md)** — how a new shot- is surfaced to the human *gently*, per surface (webview blink / pup taskbar flash- / phone title badge), never stealing foreground; plus the AD window-discovery +- gentle-cue verbs.-- **[adom-shotlog-child-windows](../adom-shotlog-child-windows/SKILL.md)** —- capturing a parent window **plus its child windows** (owned dropdowns/dialogs)- via `--meta`, and how they render inline.-- **[adom-shotlog-publishing](../adom-shotlog-publishing/SKILL.md)** — publishing- this package to wiki.adom.inc (org ownership, OSS source, hero, the wiki traps).+`adom/adom-shotlog` package (flat under `skills/<name>/`, installed to+`~/.claude/skills/`). Each declares its **tier** (User / Dev / Publish) for the+wiki Skills tab:++| Skill | Tier | What it's for |+|-------|------|---------------|+| [adom-shotlog-surfacing](skills/adom-shotlog-surfacing/SKILL.md) | user | surfacing a shot *gently* per surface (webview activate / pup taskbar flash / phone badge), never stealing foreground; plus AD window-discovery + cue verbs |+| [adom-shotlog-child-windows](skills/adom-shotlog-child-windows/SKILL.md) | user | capturing a parent window **plus its child windows** (owned dropdowns/dialogs) via `--meta`, and how they render inline |+| [adom-shotlog-dev](skills/adom-shotlog-dev/SKILL.md) | dev | build/deploy, the central-broker architecture, and the layout rules (ralph-test the skinny view, no outer-html scrollbars, pretty scrollbars) |+| [adom-shotlog-publishing](skills/adom-shotlog-publishing/SKILL.md) | publish | publishing to wiki.adom.inc (org ownership, OSS source, hero, the wiki traps) | ## ⚠ WHY THIS EXISTS: you can't show the human images in a text thread When you run as **Claude Code (VSCode / any text-only thread)** you **CANNOT display an image to the human.** Calling `Read` on a PNG, or taking a-`desktop_screenshot` / pup screenshot, loads the image into **your** context —+`desktop_screenshot` / pup screenshot, loads the image into **your** context - the human sees **nothing** in the thread. (Maybe someday Claude Code renders images inline; until then, assume it can't.) @@ -40,44 +38,58 @@ So, as an ironclad rule: - **NEVER** write "see the screenshot above", "look at this image", "as shown below", or any phrase that assumes the human can see a picture in the thread.- To them it's a blank reference — this exact failure is the whole reason shotlog+ To them it's a blank reference - this exact failure is the whole reason shotlog exists. - The **only** way the human sees an image is on a surface they actually have open: the **shotlog viewer** (webview / pup / phone), or a **URL you hand them**. So `inject` it into a channel they're viewing, **verify** they're watching- (`shotlog viewers -c <ch>` — see below), and refer them to *that surface*+ (`shotlog viewers -c <ch>` - see below), and refer them to *that surface* ("in the shotlog viewer" / "the pup window on your screen" / a link), never to an inline image. -## Inject — one command+## The CLI teaches you as you go (trust its output)++shotlog is hint-driven IN CODE, not just in this skill. Every `inject` reply+tells you two things:++- **`OK: Broker: ...` lines = what the broker is ALREADY doing** (pulling the+ channel's webview tab to the front with an orange alert, flashing a pup+ taskbar, force-opening a slim 20% webview). When you see one, surfacing is+ handled - do NOT open, refresh, or foreground anything yourself.+- **`Hint: mini-skill [...]` lines = progressive teaching**, revealed at the+ moment they're relevant (first shot in a channel, an AD capture missing+ `--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.++## Inject - one command ```bash adom-shotlog inject -c CHANNEL -d "what you see and why" -s SOURCE /path/to/image.png ``` -adom-shotlog **always** records the original capture resolution — and **rejects any+adom-shotlog **always** records the original capture resolution - and **rejects any inject that doesn't establish it**, so the viewer never shows "orig missing". **Resizing before inject is fine and expected.** Oversized images crash Claude's vision analysis, so most Adom screenshot verbs return a shrunk copy on purpose.-adom-shotlog doesn't fight that — it just requires the original size to travel with+adom-shotlog doesn't fight that - it just requires the original size to travel with the shot. Ways through: -1. **Pre-shrunk + the original dimensions** (the common case — your screenshot+1. **Pre-shrunk + the original dimensions** (the common case - your screenshot step reports the pre-resize W×H, you pass them through). ```bash adom-shotlog inject -c proj -d "…" --orig-w 1920 --orig-h 1080 /tmp/shrunk.png ```-2. **Pre-shrunk + the original image** — adom-shotlog measures it (keep the full-res+2. **Pre-shrunk + the original image** - adom-shotlog measures it (keep the full-res file around for this). ```bash adom-shotlog inject -c proj -d "…" --orig-file /tmp/fullres.png /tmp/shrunk.png ```-3. **`--native`** — assert the image is at original capture resolution (never+3. **`--native`** - assert the image is at original capture resolution (never resized). Use only when true.-4. **Hand it the full-res image** — adom-shotlog resizes anything over 1500px (either+4. **Hand it the full-res image** - adom-shotlog resizes anything over 1500px (either edge) down to 1400px and records the original automatically.-5. **`--meta <ad-response.json>`** — ingest an Adom Desktop screenshot response+5. **`--meta <ad-response.json>`** - ingest an Adom Desktop screenshot response (see below). The original W×H comes from the response's `origWidth/origHeight`, so this satisfies the original-resolution rule on its own. @@ -85,11 +97,11 @@ the shot. Ways through: none of `--orig-w`+`--orig-h` / `--orig-file` / `--native` / `--meta`, the inject is refused with exit 1 and a hint. -## `--meta` — Adom Desktop child-window arrays+## `--meta` - Adom Desktop child-window arrays As of adom-desktop 1.8.177, `desktop_screenshot_window {hwnd}` returns the primary-window image **plus its child windows** — the owned dropdowns, confirm bubbles, and-native dialogs — each captured separately, plus coordMap / DPI / screen metadata.+window image **plus its child windows** - the owned dropdowns, confirm bubbles, and+native dialogs - each captured separately, plus coordMap / DPI / screen metadata. Pass that whole response JSON via `--meta`: ```bash@@ -100,7 +112,7 @@ adom-shotlog will: - store the coordMap / DPI / screenRect / screen-resolution as a sidecar (revealed in the viewer's **⌗ capture metadata** panel, copyable as JSON), - read each child-window PNG by its `localSafePath`, save it next to the primary,- and attach it — the card shows a **+N child windows** badge and a `child windows ▸`+ and attach it - the card shows a **+N child windows** badge and a `child windows ▸` row rendering each child window inline (no zoom), - derive the original resolution from `origWidth/origHeight`. @@ -119,7 +131,7 @@ primary image + `screenshots[]` for child windows): } ``` -Plain single-image injects (no `--meta`) work exactly as before — child windows /+Plain single-image injects (no `--meta`) work exactly as before - child windows / metadata are additive. | Flag | Required | Description |@@ -165,15 +177,15 @@ server auto-opens a viewer tab so the shot is never logged invisibly. ## CRITICAL: verify the HUMAN is actually seeing it -Injecting a shot is not the job — the human *seeing* it is. shotlog tells you+Injecting a shot is not the job - the human *seeing* it is. shotlog tells you whether a live viewer is connected, and on which **surface** (webview / pup /-mobile / browser), **in the inject response itself** — no guessing, no extra turns:+mobile / browser), **in the inject response itself** - no guessing, no extra turns: -- **On-screen:** `OK: 1 of 1 viewer(s) ON-SCREEN [pup] — a human can see this shot.`+- **On-screen:** `OK: 1 of 1 viewer(s) ON-SCREEN [pup] - a human can see this shot.` - **Open but backgrounded:** `WARN: 1 viewer(s) connected … but ALL are- BACKGROUNDED / minimized — the human is NOT looking right now.` Bring the pup+ BACKGROUNDED / minimized - the human is NOT looking right now.` Bring the pup window / webview tab / phone to the FOREGROUND, then re-check.-- **Nobody watching:** `WARN: NO live viewer is connected — the human is NOT+- **Nobody watching:** `WARN: NO live viewer is connected - the human is NOT seeing this shot.` A viewer is force-opened as a fallback, but **do not assume** it worked. Open the viewer on the human's actual surface and re-check. @@ -181,7 +193,7 @@ The signal is real: each viewer holds a WebSocket open and reports its **surface (webview/pup/mobile/browser) AND whether it's actually **on-screen** (Page Visibility) vs a background tab / minimized window. -**Gate programmatically instead of burning turns** — `shotlog viewers` exit codes:+**Gate programmatically instead of burning turns** - `shotlog viewers` exit codes: `0` = a human has it on-screen, `4` = connected but all backgrounded, `3` = nobody: ```bash@@ -200,7 +212,7 @@ The viewer reports its surface automatically; when *you* open it in pup, append adom-shotlog serve # start the server (port 8820) adom-shotlog health # check server status adom-shotlog viewers -c CHANNEL # is a human watching? exit 0=yes, 3=no (+ surfaces)-adom-shotlog resize FILE # rarely needed — inject auto-resizes+adom-shotlog resize FILE # rarely needed - inject auto-resizes adom-shotlog install # install binary + this skill + completions + `shotlog` alias ```
VERSION+1−1@@ -1 +1 @@-0.7.1+0.8.0
bin/adom-shotlog⋯ 1 unchanged line ⋯
install.sh+11−20@@ -1,33 +1,24 @@ #!/bin/bash-# adompkg install hook for adom-shotlog. Places the binary and deploys the skill-# + completions via the binary's own `install` subcommand (idempotent).+# adompkg install hook for adom-shotlog: place the binary, deploy the main skill ++# completions via the binary's own `install`, then deploy every bundled sub-skill. set -e HERE="$(cd "$(dirname "$0")" && pwd)"+DEST="$HOME/.claude/skills" mkdir -p "$HOME/.local/bin" cp "$HERE/bin/adom-shotlog" "$HOME/.local/bin/adom-shotlog" chmod +x "$HOME/.local/bin/adom-shotlog"-# Back-compat `shotlog` alias-ln -sf "$HOME/.local/bin/adom-shotlog" "$HOME/.local/bin/shotlog"+ln -sf "$HOME/.local/bin/adom-shotlog" "$HOME/.local/bin/shotlog" # back-compat alias echo "OK: adom-shotlog binary at ~/.local/bin/adom-shotlog (alias: shotlog)" -# Deploy the embedded SKILL.md + bash completions + alias.+# main SKILL.md + bash completions + alias "$HOME/.local/bin/adom-shotlog" install || true -# Deploy the bundled sub-skills to ~/.claude/skills/<name>/:-# adom-shotlog-surfacing, adom-shotlog-child-windows, adom-shotlog-publishing-for name in adom-shotlog-surfacing adom-shotlog-child-windows adom-shotlog-publishing; do- [ -f "$HERE/skills/$name/SKILL.md" ] || continue- mkdir -p "$HOME/.claude/skills/$name"- cp "$HERE/skills/$name/SKILL.md" "$HOME/.claude/skills/$name/SKILL.md"- echo "OK: skill $name"-done-# Catch any additional sub-skills too.-for d in "$HERE"/skills/*/; do- [ -f "$d/SKILL.md" ] || continue- name="$(basename "$d")"- mkdir -p "$HOME/.claude/skills/$name"- cp "$d/SKILL.md" "$HOME/.claude/skills/$name/SKILL.md"-done+# Bundled sub-skills — listed EXPLICITLY (the publish linter rejects a glob).+install_skill() { mkdir -p "$DEST/$1"; cp "$HERE/$2" "$DEST/$1/SKILL.md"; echo "OK: skill $1"; }+install_skill adom-shotlog-surfacing skills/adom-shotlog-surfacing/SKILL.md # user+install_skill adom-shotlog-child-windows skills/adom-shotlog-child-windows/SKILL.md # user+install_skill adom-shotlog-dev skills/adom-shotlog-dev/SKILL.md # dev+install_skill adom-shotlog-publishing skills/adom-shotlog-publishing/SKILL.md # publish echo "OK: adom-shotlog installed. Run 'adom-shotlog serve &' then 'adom-shotlog open -c my-project'."
package.json+3−2@@ -1,6 +1,6 @@ { "slug": "adom-shotlog",- "version": "0.7.2",+ "version": "0.8.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.",@@ -24,7 +24,8 @@ "Cargo.lock", "build.sh", "VERSION",- "LICENSE"+ "LICENSE",+ "tools/**" ], "install": { "binary_name": "adom-shotlog"
page.json+3−2@@ -1,6 +1,6 @@ { "slug": "adom-shotlog",- "version": "0.7.2",+ "version": "0.8.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.",@@ -24,7 +24,8 @@ "Cargo.lock", "build.sh", "VERSION",- "LICENSE"+ "LICENSE",+ "tools/**" ], "install": { "binary_name": "adom-shotlog"
screenshots/child-windows.pngadded⋯ 1 unchanged line ⋯
skills/adom-shotlog-child-windows/SKILL.md+11−10@@ -1,23 +1,24 @@ --- name: adom-shotlog-child-windows-description: "Capture and display a parent window PLUS its CHILD WINDOWS in shotlog — the owned dropdowns, confirm bubbles, and native dialogs that Adom Desktop returns alongside a window shot, ingested via `inject --meta`. Use when: injecting a desktop_screenshot_window response, a captured window had a dropdown/dialog open, showing multi-window captures, or asking how child windows render. Trigger words: child windows, parent window, owned windows, owned popups, ownedPopupCount, window array, desktop_screenshot_window array, --meta, coordMap, dropdown capture, dialog capture, multi-window screenshot."+tier: user+description: "Capture and display a parent window PLUS its CHILD WINDOWS in shotlog - the owned dropdowns, confirm bubbles, and native dialogs that Adom Desktop returns alongside a window shot, ingested via `inject --meta`. Use when: injecting a desktop_screenshot_window response, a captured window had a dropdown/dialog open, showing multi-window captures, or asking how child windows render. Trigger words: child windows, parent window, owned windows, owned popups, ownedPopupCount, window array, desktop_screenshot_window array, --meta, coordMap, dropdown capture, dialog capture, multi-window screenshot." --- -# adom-shotlog — parent window + its CHILD WINDOWS (`--meta`)+# adom-shotlog - parent window + its CHILD WINDOWS (`--meta`) A window capture is often **not one image**. A window that has a dropdown, an autofill list, a confirm bubble, or a native dialog open is really a **parent-window with child windows** — separate top-level windows owned by the parent that+window with child windows** - separate top-level windows owned by the parent that a plain `PrintWindow` of the parent misses entirely. Adom Desktop surfaces them for you: **`desktop_screenshot_window {hwnd}` returns the parent image PLUS its child windows** as a `screenshots[]` array, with `ownedPopupCount` always present (0 = none). Each child window rides back with its own `hwnd`, `title`, `kind` (`dropdown`/`dialog`/…), `rect`, and `coordMap`. This-is AD's generic "find a window's child windows" — no app-specific verb.+is AD's generic "find a window's child windows" - no app-specific verb. > Terminology: AD's wire fields are named `ownedPopupCount` / `screenshots[]`-> (Win32 "owned windows"), but conceptually — and in the shotlog UI — these are+> (Win32 "owned windows"), but conceptually - and in the shotlog UI - these are > the parent window's **child windows**. Don't call them "popups" to the human. ## Inject the parent + child windows with `--meta`@@ -29,7 +30,7 @@ adom-shotlog inject -c CH -d "checkout form with autofill dropdown open" primary `--meta` takes the FULL `desktop_screenshot_window` response. shotlog: - stores coordMap / DPI / screenRect / screenResolution as a sidecar, - reads each child-window PNG by its `localSafePath`, saves it next to the primary- (as `{primary-stem}__popup{N}.png` on disk — a legacy filename, kept for data+ (as `{primary-stem}__popup{N}.png` on disk - a legacy filename, kept for data compatibility), and attaches it, - derives the original resolution from `origWidth/origHeight` (so `--meta` satisfies the original-resolution rule on its own).@@ -51,7 +52,7 @@ Response shape it reads (extras ignored; needs a primary image + `screenshots[]` `localSafePath` must be readable from wherever `inject` runs. -## How child windows DISPLAY — all on the card, no zoom+## How child windows DISPLAY - all on the card, no zoom The human never has to zoom. A shot with child windows renders it all inline: - a **`+N child windows`** badge in the meta row,@@ -60,16 +61,16 @@ The human never has to zoom. A shot with child windows renders it all inline: - an **`ⓘ meta`** chip that reveals the coordMap panel (shotId, screenRect, dpiScale, imageToScreen, screen resolution) **inline on the card**, copyable. -Child-window PNGs are excluded from recovery as standalone entries — they belong+Child-window PNGs are excluded from recovery as standalone entries - they belong to their parent shot. > In a slim right-side panel the primary image can push the `child windows ▸` row-> below the card fold — scroll the card slightly to bring it into view.+> below the card fold - scroll the card slightly to bring it into view. ## Getting a REAL capture (not a mock) `desktop_screenshot_window {hwnd}` reports `ownedPopupCount` for any window; the child windows only populate when a dropdown/dialog is actually open on that window. So: open the dropdown/dialog, capture the parent hwnd, and pass the response to-`--meta`. Plain single-image injects (no `--meta`) are unchanged — child windows+`--meta`. Plain single-image injects (no `--meta`) are unchanged - child windows are additive.
skills/adom-shotlog-dev/SKILL.mdadded+118@@ -0,0 +1,118 @@+---+name: adom-shotlog-dev+tier: dev+description: "DEV skill for building, debugging, and laying out adom-shotlog (the Rust CLI + server + embedded HTML viewer). Read before editing the viewer, the server, or the CLI. Covers the ralph-test-yourself loop, the hard fluid-layout rules (no outer-html scrollbars, ever; pretty scrollbars always), the build/deploy cycle, and the central-broker architecture. Trigger words: shotlog dev, build shotlog, shotlog layout, ralph test shotlog, viewer css, skinny webview layout, horizontal scrollbar, fluid layout, shotlog architecture, shotlog server."+---++# adom-shotlog - DEV (build, layout, architecture)++The app is one Rust binary: `src/main.rs` (CLI), `src/server.rs` (HTTP + WS ++webview brokering), `src/store.rs` (channels + presence), `src/open.rs` (Hydrogen+tabs), and `src/html/index.html` (the viewer, embedded via `include_str!`).++## ALWAYS ralph-test yourself after a layout change++Never trust a viewer change by eye at desktop width. Load it at the **skinny ~20%+webview width (≈360px)** - that's where the layout actually has to hold - screenshot+it, measure it, fix, repeat. The skinny view is the one that matters.++Headless ralph check (a real 360px VIEWPORT, so the CSS media queries fire - unlike+CSS-narrowing an element, which does not):++```js+// ralph-check.cjs - node ralph-check.cjs <live-viewer-url>+const puppeteer = require('/home/adom/project/node_modules/puppeteer-core');+(async()=>{+ const b = await puppeteer.launch({executablePath: process.env.CHROME, headless:'new',+ args:['--no-sandbox','--disable-gpu','--disable-dev-shm-usage']});+ const p = await b.newPage();+ await p.setViewport({width:360, height:820, deviceScaleFactor:2});+ await p.goto(process.argv[2], {waitUntil:'networkidle2', timeout:25000}).catch(()=>{});+ await new Promise(r=>setTimeout(r,1800));+ console.log(JSON.stringify(await p.evaluate(()=>{+ const de=document.documentElement, vw=de.clientWidth;+ const off=[...document.querySelectorAll('*')].map(el=>{const r=el.getBoundingClientRect();+ return {t:el.tagName+'.'+((''+el.className).split(' ')[0]||''), right:Math.round(r.right)};})+ .filter(x=>x.right>vw+1).sort((a,b)=>b.right-a.right).slice(0,10);+ return {vw, htmlScrollW:de.scrollWidth, outerHScroll: de.scrollWidth>vw, off};+ })));+ await b.close();+})();+```+`CHROME=$(ls -d ~/.cache/puppeteer/chrome/linux-*/chrome-linux64/chrome|head -1) node ralph-check.cjs <url>`+**Pass = `outerHScroll:false` AND `off:[]`.** If anything is in `off`, that element+is wider than the panel - make it fluid; don't paper over it with a scrollbar.++## HARD RULE: ONE designated scroller - the outer html NEVER shows a scrollbar++The outermost html/body never scroll, either axis: `html, body { overflow: hidden }`.+The `.feed` is the one designated scroller; overlays (modal) scroll themselves.+Nested outer+inner scrollbars are an instant audit failure.++Hard-won: `overflow-x: hidden` alone is NOT enough. At fractional devicePixelRatio+(a pup window at DPR 1.5), `100dvh` computes fractionally, `scrollHeight` rounds+1px past `clientHeight`, and a phantom OUTER vertical scrollbar appears beside the+feed's real one - even though a DPR-1 headless audit says everything fits. Root+`overflow: hidden` makes the phantom impossible. Audit BOTH axes in the ralph+check: `outerVScroll` and `outerHScroll` must both be false.++A skinny webview must never show a horizontal scrollbar on the outermost html. If+it does, the layout isn't fluid. Rules that keep it fluid:++- `html, body { overflow: hidden; max-width: 100%; }` (the guarantee), plus+ `img { max-width: 100%; }`.+- **No fixed-width children.** The classic offender was the child-window row: it+ used fixed `168px` cells + `overflow-x:auto`, which overflowed the ~360px panel+ and made an ugly scrollbar. Fix: `flex-wrap: wrap` with cells `flex: 1 1 130px;+ min-width: 0; max-width: 220px` so they share the row and wrap, never scroll.+- **Wrap the header at narrow widths** (`@media (max-width: 520px)`): stack it into+ rows (brand / channel + count / actions) so nothing truncates or overflows.+- Long values wrap: `word-break: break-word` / `pre-wrap`, never a fixed min-width+ that forces overflow.++## HARD RULE: scrollbars are ALWAYS pretty++Every scrollable area gets styled - thin, rounded, themed, transparent track,+teal on hover. Never ship a default OS scrollbar.++```css+* { scrollbar-width: thin; scrollbar-color: rgba(139,148,158,.38) transparent; }+::-webkit-scrollbar { width: 10px; height: 10px; }+::-webkit-scrollbar-track { background: transparent; }+::-webkit-scrollbar-thumb { background: rgba(139,148,158,.32); border-radius: 999px;+ border: 2px solid transparent; background-clip: padding-box; }+::-webkit-scrollbar-thumb:hover { background: rgba(0,184,176,.55); background-clip: padding-box; }+```++## Build + deploy cycle++```bash+cargo build --release+# targeted restart of the ONE server (never a broad pkill):+kill "$(pgrep -f '[a]dom-shotlog serve' | head -1)"; sleep 1+sudo cp target/release/adom-shotlog /usr/local/bin/adom-shotlog+adom-shotlog serve & # data dir: ./screenshots/shotlog+```+`SKILL.md` (and the viewer HTML) are embedded via `include_str!` - **you must+rebuild** after editing them. Bump `Cargo.toml` + `VERSION` together.++## Architecture: one server = the central broker (per container)++- The **same binary** is both the server and the CLI. `adom-shotlog serve` spawns+ ONE broker on **port 8820**; `inject`/`open`/`viewers`/`channels` are CLI verbs+ that HTTP-POST/GET to it.+- **Every AI thread in the container** runs `adom-shotlog inject`, which posts to+ that one local server. So the server is the central broker: it owns all channels,+ tracks every viewer's surface + on-screen state (presence over the WebSocket),+ and manages all the Hydrogen webviews (auto-open a slim 20% panel, bring the+ right channel's tab forward - by its unique `Shots: <channel>` name so parallel+ shotlogs are never cross-touched).+- **Webview activate = the BOUNCE.** Hydrogen has no "activate tab" verb;+ `webview open-or-refresh` does NOT change `activeTabIndex`, and `move-tab`+ rejects same-pane moves. The working recipe (in `bring_webview_forward`): find+ the tab by its `panelState` URL (`/log/<ch>/`), `move-tab` it to any OTHER pane,+ then `move-tab` back to `--target-index <orig>` with `--alert` - move-tab+ auto-activates in its target pane, so the tab surfaces to top with the orange+ update highlight, order preserved.+- It's **per-container**: threads in a different container get their own server.+- `shotlog channels` prints the live overview - how every thread is using it.
skills/adom-shotlog-publishing/SKILL.md+12−11@@ -1,11 +1,12 @@ --- name: adom-shotlog-publishing-description: "How to publish adom-shotlog (and apps like it) to wiki.adom.inc with adompkg — org ownership, OSS source, MIT license, hero, and the wiki traps that waste hours (visibility re-privatization, hero caching, binary overwrite, title vs slug). Use when: publishing/republishing adom-shotlog, cutting a release, fixing an unlisted/privatized page, a stale hero, or bundling sub-skills. Trigger words: publish shotlog, adompkg publish, republish, wiki release, hero not updating, page went private, visibility trap, bundle sub-skills, org owned, publish rules."+tier: publish+description: "How to publish adom-shotlog (and apps like it) to wiki.adom.inc with adompkg - org ownership, OSS source, MIT license, hero, and the wiki traps that waste hours (visibility re-privatization, hero caching, binary overwrite, title vs slug). Use when: publishing/republishing adom-shotlog, cutting a release, fixing an unlisted/privatized page, a stale hero, or bundling sub-skills. Trigger words: publish shotlog, adompkg publish, republish, wiki release, hero not updating, page went private, visibility trap, bundle sub-skills, org owned, publish rules." --- # Publishing adom-shotlog to wiki.adom.inc -The whole app — CLI binary, source, README, main skill, AND sub-skills — ships as+The whole app - CLI binary, source, README, main skill, AND sub-skills - ships as **one package**: `adom/adom-shotlog`. Do NOT create separate wiki pages/repos for the skills; they live in THIS repo and ride along. @@ -18,23 +19,23 @@ adompkg publish --org adom --public --version <next> --yes - **`--org adom`** → the page is **org-owned** (`/adom/adom-shotlog`), not user-owned. (The `author_name` sidebar shows the publishing employee and is- **immutable via the API** — that's attribution, not ownership.)+ **immutable via the API** - that's attribution, not ownership.) - **`--public`** → anyone can install/view.-- **Version must bump every publish** — you cannot republish the same version.+- **Version must bump every publish** - you cannot republish the same version. Keep `Cargo.toml`, `VERSION`, and `package.json` version in lockstep, and rebuild so the binary's `--version` matches what the page advertises. ## package.json essentials - `slug: "adom-shotlog"` (install name) vs `title: "Adom Shotlog"` (display heading).- They're different on purpose — slug stays kebab, title is human.+ They're different on purpose - slug stays kebab, title is human. - `license: "MIT"` + ship a real **`LICENSE`** file (© Adom Industries) + set `license`/`authors` in `Cargo.toml`. Metadata alone isn't enough for OSS.-- `hero: { path: "screenshots/hero.png" }` — REQUIRED; publish refuses without it.+- `hero: { path: "screenshots/hero.png" }` - REQUIRED; publish refuses without it. - `bundled: true`, a `tags` array, and `files` **must whitelist everything that should be browseable/installed**, including source + sub-skills: `["SKILL.md","skills/**","README.md","package.json","install.sh","uninstall.sh","bin/**","screenshots/**","src/**","Cargo.toml","Cargo.lock","build.sh","VERSION","LICENSE"]`- (Only what's in `files` reaches the git repo / Files tab — omit `src/**` and the+ (Only what's in `files` reaches the git repo / Files tab - omit `src/**` and the source is NOT browseable.) - `install.sh` deploys the binary AND loops `skills/*/` to install every sub-skill. @@ -42,7 +43,7 @@ adompkg publish --org adom --public --version <next> --yes 1. **Visibility re-privatization.** A `POST /files` or a metadata `PUT` on a public org page silently flips `visibility` back to private. **Fix: don't poke files- piecemeal — do a bumped `adompkg publish --org adom --public`,** which+ piecemeal - do a bumped `adompkg publish --org adom --public`,** which re-publicizes. After publishing, **verify anonymously**: ```bash curl -s -o /dev/null -w "%{http_code}\n" https://wiki.adom.inc/adom/adom-shotlog@@ -51,14 +52,14 @@ adompkg publish --org adom --public --version <next> --yes Both must be `200`. 2. **Hero caching by URL.** The hero blob URL (`/blob/app/adom-shotlog/screenshots/hero.png`) is cached by browser + CDN; overwriting the SAME path serves the stale image.- You should NOT have to rename the file — the platform should cache-bust by+ You should NOT have to rename the file - the platform should cache-bust by content hash. Until it does, if a hero looks stale, confirm the served bytes (`curl … | md5sum`) match your new file; it's a cache, not a regression.- (Logged for Colby — don't normalize the rename-the-file workaround.)+ (Logged for Colby - don't normalize the rename-the-file workaround.) 3. **Binary overwrite.** Overwriting a binary at the same repo path can no-op (`commit:null`). A version bump + republish sidesteps it. 4. **Overview vs Files lag.** The Overview tab renders a `readme` DB field set from- the **tarball** README at publish time — a bare `git push` of README updates the+ the **tarball** README at publish time - a bare `git push` of README updates the Files tab but not the Overview. Republish to refresh Overview. ## After EVERY publish
skills/adom-shotlog-surfacing/SKILL.md+24−15@@ -1,11 +1,12 @@ --- name: adom-shotlog-surfacing-description: "How shotlog should SURFACE a new shot to the human — gently, per surface, without stealing focus. Use when: an AI is injecting shots and deciding how to notify the human, a shot lands unseen, choosing between pup and webview, setting up the preferred slim right-side shotlog panel, or a viewer is backgrounded. Trigger words: shotlog surfacing, don't foreground, taskbar flash, blink the tab, skinny panel, shotlog notification, shotlog update, backgrounded viewer, auto-switch shotlog, shove in my face."+tier: user+description: "How shotlog should SURFACE a new shot to the human - gently, per surface, without stealing focus. Use when: an AI is injecting shots and deciding how to notify the human, a shot lands unseen, choosing between pup and webview, setting up the preferred slim right-side shotlog panel, or a viewer is backgrounded. Trigger words: shotlog surfacing, don't foreground, taskbar flash, blink the tab, skinny panel, shotlog notification, shotlog update, backgrounded viewer, auto-switch shotlog, shove in my face." --- -# adom-shotlog — surfacing updates (don't shove it in the human's face)+# adom-shotlog - surfacing updates (don't shove it in the human's face) -Injecting a shot is not the goal — the human **noticing** it is. But noticing is+Injecting a shot is not the goal - the human **noticing** it is. But noticing is not the same as being **interrupted**. The rule for every update: > **Never steal the human's foreground.** Surface the shot on the surface they're@@ -16,7 +17,7 @@ surface and use the gentle nudge, never a focus-steal. ## The surfaces, and how each should update -### Webview — the preferred layout: a slim right-side panel+### Webview - the preferred layout: a slim right-side panel The nicest setup: a **~20%-wide webview panel pinned to the right** of the Hydrogen page. It auto-updates over WebSocket, and you can keep **several shotlog@@ -28,12 +29,20 @@ shotlog open -c other-task --skinny # stacks as another tab in the sa shotlog open -c my-project --skinny --ratio 0.25 # custom width fraction ``` -On a new shot, the server **blinks that channel's tab** (`hydrogen alert set`) —+On a new shot, the server **blinks that channel's tab** (`hydrogen alert set`) - a gentle attention cue that auto-scrolls the tab into view **without** stealing window focus. If the user likes the panel to *jump* to whichever channel just updated, set `SHOTLOG_WEBVIEW_AUTOSWITCH=1` and the server also activates the tab. -### Pup — gentle orange taskbar flash, never foreground+### Pup - gentle orange taskbar flash, never foreground (AUTOMATIC)++Open pup viewers with `?surface=pup&session=<pup sessionId>` in the URL (e.g. one+pup window, session `shots`, with a tab per channel). The viewer reports that+session over its WebSocket, so on every new shot the broker AUTOMATICALLY runs+`adom-desktop browser_alert_window {sessionId}` - the orange taskbar flash -+debounced per session. Set `SHOTLOG_AD_TARGET=<desktop>` on the server when more+than one Adom Desktop is connected. No AI action needed; omit `session=` and the+flash falls back to being your job: If the viewer is a **pup** window, do **NOT** raise/foreground it. The gentle Windows-native cue is the **orange taskbar flash**:@@ -45,12 +54,12 @@ adom-desktop browser_alert_window '{"sessionId":"<the pup session>"}' `inject` will hint you to do this when a pup viewer is backgrounded. The viewer's own page title also shows a `● N` unseen-shot count in the pup tab. -### Phone / plain browser — in-page title badge+### Phone / plain browser - in-page title badge When the tab is backgrounded, the viewer sets its title to `● N · <channel>`, so the count of unseen shots shows in the browser/phone tab strip. Nothing to do. -### Nobody watching — force-open fallback+### Nobody watching - force-open fallback If **no** viewer is connected, the server force-opens a webview as a safety net (so a shot never lands invisibly). Opt out with `SHOTLOG_NO_FORCE_OPEN=1` for@@ -58,7 +67,7 @@ users who never want anything auto-opened. ## Decision flow for an AI injecting a shot -1. `inject` — read its presence report: how many viewers, which **surfaces**, and+1. `inject` - read its presence report: how many viewers, which **surfaces**, and how many are **on-screen** (visible) vs backgrounded. 2. If a viewer is **on-screen** → done; it's genuinely visible. Don't nudge. 3. If viewers exist but are **backgrounded** → nudge gently for that surface@@ -67,7 +76,7 @@ users who never want anything auto-opened. 4. If **nobody** is watching → open the preferred surface: `shotlog open -c <ch> --skinny`, or ask the human to load the URL / open it on their phone. Then re-verify.-5. Gate precisely with `shotlog viewers -c <ch>` — exit `0` = on-screen,+5. Gate precisely with `shotlog viewers -c <ch>` - exit `0` = on-screen, `4` = open but backgrounded, `3` = nobody. ## The exact gentle-cue verbs (Adom Desktop)@@ -78,7 +87,7 @@ just to show a shot. | Surface | Gentle cue | Verb | |---------|-----------|------|-| Webview tab | Blink the tab (auto-scrolls into view, no focus steal) | `adom-cli hydrogen alert set --name "Shots: <ch>"` — shotlog does this for you |+| Webview tab | Blink the tab (auto-scrolls into view, no focus steal) | `adom-cli hydrogen alert set --name "Shots: <ch>"` - shotlog does this for you | | Webview (backgrounded editor tab) | System notification | `adom-cli hydrogen notify …` | | Pup / any window | Orange taskbar flash (FlashWindowEx) | `adom-desktop browser_alert_window {sessionId}` (pup) or `adom-desktop desktop_flash_window {hwnd}` (any window) | | Any window | Glanceable taskbar status (progress bar + overlay icon) | `adom-desktop desktop_taskbar {hwnd, …}` |@@ -87,20 +96,20 @@ just to show a shot. You rarely need this for shotlog itself (the server handles surfacing), but when you must locate a viewer window to flash/screenshot it, these AD verbs do it-without stealing focus — do NOT shell out to PowerShell, AD already exposes it:+without stealing focus - do NOT shell out to PowerShell, AD already exposes it: | Need | Verb | Notes | |------|------|-------| | List all top-level windows | `desktop_list_windows` | each entry: `hwnd`, `title`, `className`, `rect`, `z` (0 = topmost) | | Resolve an hwnd by title/class | `desktop_find_window {title\|class}` | topmost match wins |-| Parent → child subtree of a window | `desktop_ui_tree {hwnd}` | the "window list with parent/child": nested nodes with `role`, `rect{centerX,centerY}`, `automationId`, `invokable/settable` — each individually targetable in the BACKGROUND. `maxDepth`/`maxNodes`/`scope:"popups"` to narrow. |-| Capture a specific window (even occluded) | `desktop_screenshot_window {hwnd}` | WGC — grabs the window even if it's behind others; won't fire if the window is truly not visible |+| Parent → child subtree of a window | `desktop_ui_tree {hwnd}` | the "window list with parent/child": nested nodes with `role`, `rect{centerX,centerY}`, `automationId`, `invokable/settable` - each individually targetable in the BACKGROUND. `maxDepth`/`maxNodes`/`scope:"popups"` to narrow. |+| Capture a specific window (even occluded) | `desktop_screenshot_window {hwnd}` | WGC - grabs the window even if it's behind others; won't fire if the window is truly not visible | | Change window state w/o foregrounding | `desktop_set_window_state` | minimize/maximize/restore without raising | Parent/child example (real): `desktop_list_windows` gives the top-level Edge window `hwnd=5769356`; `desktop_ui_tree {hwnd:5769356}` returns its child subtree (`[Window] → [Pane] → [Pane] …`), each node with a screen-px rect you can-target — no foreground, no cursor move.+target - no foreground, no cursor move. ## Environment knobs
src/html/index.html+42−11@@ -21,9 +21,24 @@ --accent: #00b8b0; --accent-bright: #00e6dc; --purple: #8C6BF7; --danger: #f85149; } *, *::before, *::after { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }-html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--text); font: 13px/1.4 'Satoshi', -apple-system, system-ui, sans-serif; }-body { display: flex; flex-direction: column; height: 100dvh; }-body.paste-active { outline: 2px solid var(--accent); outline-offset: -2px; }+/* The OUTERMOST html/body are NEVER scrollers — no bars, either axis, ever.+ The .feed is the one designated scroller (modal overlays scroll themselves).+ overflow:hidden also kills the phantom 1px outer bar that fractional-DPR+ (e.g. 1.5) rounding of 100dvh otherwise creates. */+html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--text);+ font: 13px/1.4 'Satoshi', -apple-system, system-ui, sans-serif;+ overflow: hidden; max-width: 100%; }+body { display: flex; flex-direction: column; height: 100dvh; width: 100%; }+img { max-width: 100%; }++/* Pretty scrollbars everywhere (thin, rounded, themed; only where needed). */+* { scrollbar-width: thin; scrollbar-color: rgba(139,148,158,.38) transparent; }+::-webkit-scrollbar { width: 10px; height: 10px; }+::-webkit-scrollbar-track { background: transparent; }+::-webkit-scrollbar-thumb { background: rgba(139,148,158,.32); border-radius: 999px;+ border: 2px solid transparent; background-clip: padding-box; }+::-webkit-scrollbar-thumb:hover { background: rgba(0,184,176,.55); background-clip: padding-box; }+::-webkit-scrollbar-corner { background: transparent; } /* Global tooltip — body-appended fixed (human-ui-patterns §1d) */ #global-tooltip {@@ -55,7 +70,22 @@ header { color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .subject-meta { color: var(--text3); font-family: 'JetBrains Mono', monospace; font-size: 10px; white-space: nowrap; flex-shrink: 0; }-@media (max-width: 480px) { .subject-meta { display: none; } .brand .sep, .brand .app-subtitle { display: none; } }+/* Skinny view (e.g. a ~20%-wide side panel): WRAP the header into rows so nothing+ truncates — brand on row 1, the (full) channel name + shot count on row 2, the+ actions (LED / to-top / clear) on row 3. Fluidly shows everything. */+@media (max-width: 520px) {+ header { height: auto; min-height: 44px; flex-wrap: wrap; align-items: flex-start;+ padding: 6px 10px; row-gap: 3px; column-gap: 8px; }+ .vsep { display: none; }+ .brand { width: 100%; }+ .subject { width: 100%; flex: 1 1 100%; }+ .subject-name { white-space: normal; overflow: visible; text-overflow: clip;+ word-break: break-word; line-height: 1.2; }+ .subject-meta { display: inline; }+ .brand .sep, .brand .app-subtitle { display: inline; }+ .actions { width: 100%; margin-left: 0; justify-content: flex-start; }+ .ws-led { order: 0; }+} .actions { display: flex; align-items: center; gap: 8px; margin-left: auto; flex-shrink: 0; } .hdr-btn { background: transparent; color: var(--text2); border: 1px solid var(--border); padding: 3px 8px; border-radius: 5px; font-size: 11px; cursor: pointer;@@ -111,11 +141,12 @@ header { border-radius: 4px; padding: 0 5px; user-select: none; } .shot-card-meta .badge-nerd:hover { color: var(--accent); border-color: rgba(0,184,176,.4); } .shot-card-meta .badge-nerd.active { color: var(--accent); border-color: rgba(0,184,176,.5); background: rgba(0,184,176,.10); }-/* Child-window row, inline on the card — all child windows in ONE row, no zoom needed */-.card-popups { display: flex; gap: 8px; padding: 9px 13px 4px; overflow-x: auto; flex-shrink: 0; }-.card-popups .ph { flex: 0 0 auto; align-self: center; font: 600 10px 'Familjen Grotesk', sans-serif;- color: var(--text2); white-space: nowrap; padding-right: 2px; }-.card-popups .pc { flex: 0 0 auto; width: 168px; background: #000; border: 1px solid var(--border);+/* Child-window row, inline on the card. FLUID: cells share the available width+ and wrap when the card is skinny — NO horizontal scrollbar, ever. */+.card-popups { display: flex; flex-wrap: wrap; gap: 8px; padding: 9px 13px 6px; flex-shrink: 0; }+.card-popups .ph { flex: 0 0 100%; font: 600 10px 'Familjen Grotesk', sans-serif;+ color: var(--text2); margin-bottom: -2px; }+.card-popups .pc { flex: 1 1 130px; min-width: 0; max-width: 220px; background: #000; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; cursor: zoom-in; transition: border-color .12s; } .card-popups .pc:hover { border-color: rgba(0,184,176,.5); } .card-popups .pc img { width: 100%; height: 96px; object-fit: contain; background: #111; display: block; }@@ -508,7 +539,8 @@ header { activeWs = ws; // Tell the server WHICH surface is watching + whether it's on-screen right now, // so `inject`/`viewers` can distinguish "watching" from "open but backgrounded".- try { ws.send(JSON.stringify({type:'hello', surface:detectSurface(), visible: document.visibilityState==='visible'})); } catch {} };+ try { const sess=new URLSearchParams(location.search).get('session');+ ws.send(JSON.stringify({type:'hello', surface:detectSurface(), visible: document.visibilityState==='visible', session:sess||undefined})); } catch {} }; ws.onmessage = ev => { try { const msg = JSON.parse(ev.data);@@ -540,7 +572,6 @@ header { // ── Clipboard paste (Ctrl+V anywhere) ──────────────────────────────────── const pasteOverlay=$('pasteOverlay'), pasteMsg=$('pasteMsg'); document.body.addEventListener('click',()=>document.body.focus());- document.body.classList.add('paste-active'); document.addEventListener('paste', async e => { const items=e.clipboardData?.items; if(!items) return; for (const it of items) if (it.type.startsWith('image/')) {
src/main.rs+96−17@@ -166,6 +166,19 @@ enum Commands { #[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)@@ -344,6 +357,9 @@ async fn main() { 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(); }@@ -476,25 +492,43 @@ fn run_inject( } 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: pulling the '{}' webview tab to the front with an ORANGE update alert — surfacing is handled, do NOT open/foreground anything yourself.",+ a.get("tab").and_then(|t| t.as_str()).unwrap_or("Shots"))),+ 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("Broker: nobody was watching — force-opening a slim 20% right-side webview now. Re-check with `shotlog viewers` before assuming the human sees it."),+ _ => {}+ }+ }+ }+ // 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");- let has_webview = surfaces.iter().any(|s| s == "webview"); if visible == 0 && viewers > 0 { // Backgrounded — surface it GENTLY. NEVER force the window to the front.- if has_webview {- hint("shotlog blinked the webview tab — the human sees it update in their panel. Do NOT foreground their window.");- }- if has_pup {- hint("Nudge the pup viewer WITHOUT foregrounding it: `adom-desktop browser_alert_window` (gentle orange taskbar flash). Never force the pup window to the front.");- }- if !has_webview && !has_pup {- hint("The viewer is backgrounded — flag it gently (taskbar flash / notification); do NOT steal the human's foreground.");+ 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 {+ 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}`."));- hint("A webview was force-opened as a fallback (opt out with SHOTLOG_NO_FORCE_OPEN=1), but do NOT assume the human sees it — verify their surface."); } } } else {@@ -619,6 +653,50 @@ fn run_viewers(channel: &str, port: u16, url: Option<&str>) { } } +/// 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() { let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".to_string()); @@ -718,12 +796,13 @@ fn send_request(port: u16, request: &str) -> Result<serde_json::Value, String> { }; let raw_body = &response[body_start + 4..]; - // Handle chunked transfer encoding- let body = if let Some(json_start) = raw_body.find('{') {- let json_end = raw_body.rfind('}').unwrap_or(raw_body.len() - 1);- &raw_body[json_start..=json_end]- } else {- raw_body.trim()+ // 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() {
src/open.rs+6−4@@ -72,14 +72,16 @@ pub fn open_webview( eprintln!("URL: {url}"); return; };+ // Idempotent create-or-refresh: with --panel-id it CREATES the tab if the+ // name doesn't exist, else refreshes it. (Without --panel-id it can only+ // refresh — the bug that made auto-open silently fail.) let out = Command::new("adom-cli") .args([- "hydrogen", "workspace", "add-tab",+ "hydrogen", "webview", "open-or-refresh",+ "--name", name,+ "--url", &url, "--panel-id", &leaf_id,- "--panel-type", WEBVIEW_PANEL_TYPE,- "--display-name", name, "--display-icon", icon,- "--initial-state", &initial_state, ]) .output(); report(out, name, channel, &url);
src/server.rs+235−74@@ -74,6 +74,7 @@ pub fn create_router(state: Arc<AppState>) -> Router { .route("/api/shots", get(get_shots)) .route("/api/shots", post(post_shots)) .route("/api/viewer-status", get(viewer_status))+ .route("/api/channels", get(get_channels)) .route("/prompt", get(serve_prompt)) .route("/api/paste", post(post_paste)) .route("/api/open", post(open_in_vscode))@@ -153,6 +154,30 @@ async fn viewer_status( })) } +/// Overview of every channel: shot count, last-activity time + source, and live+/// viewer presence. Lets a human (or an AI) see how OTHER threads are using+/// shotlog on an ongoing basis. Sorted newest-activity first.+async fn get_channels(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {+ let mut rows: Vec<serde_json::Value> = Vec::new();+ for (name, shots, last, source) in state.store.channel_summaries().await {+ let infos = state.store.viewer_infos(&name).await;+ let visible = infos.iter().filter(|i| i.visible).count();+ let surfaces: Vec<String> = infos.iter().map(|i| i.surface.clone()).collect();+ rows.push(serde_json::json!({+ "channel": name,+ "shots": shots,+ "last": last.map(|t| t.to_rfc3339()),+ "last_epoch": last.map(|t| t.timestamp()).unwrap_or(0),+ "source": source,+ "viewers": infos.len(),+ "visible": visible,+ "surfaces": surfaces,+ }));+ }+ rows.sort_by(|a, b| b["last_epoch"].as_i64().unwrap_or(0).cmp(&a["last_epoch"].as_i64().unwrap_or(0)));+ Json(serde_json::json!(rows))+}+ /// Paste-prompt text served at /prompt — guidance for AI agents on how to inject. async fn serve_prompt() -> impl IntoResponse { let body = "\@@ -181,6 +206,20 @@ The description becomes the filename -- say what you are looking at and why. ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], body) } +/// Progressive-disclosure gate: a teaching hint is shown at most `max` times per+/// broker lifetime, then goes quiet. The broker is the long-lived stateful side+/// (CLI processes are one-shot), so the counters live here.+fn hint_gate(key: &str, max: u32) -> bool {+ use std::sync::Mutex;+ use std::collections::HashMap;+ static SEEN: Mutex<Option<HashMap<String, u32>>> = Mutex::new(None);+ let mut guard = SEEN.lock().unwrap();+ let map = guard.get_or_insert_with(HashMap::new);+ let c = map.entry(key.to_string()).or_insert(0);+ *c += 1;+ *c <= max+}+ /// Per-key cooldown so a burst of injects can't spam adom-cli. Returns true if /// the action for `key` may run now (and stamps it), false if still cooling down. fn debounce_ok(key: &str, secs: u64) -> bool {@@ -199,103 +238,178 @@ fn debounce_ok(key: &str, secs: u64) -> bool { true } -/// Decide how to GENTLY surface a new shot, based on which surface(s) are-/// currently watching. The rule (see the adom-shotlog-surfacing skill): never-/// steal the human's foreground.-/// - **webview** watching → BLINK that channel's tab (the user's skinny-/// right-side panel notices without a focus-steal). `SHOTLOG_WEBVIEW_AUTOSWITCH=1`-/// additionally activates the tab (auto-switch to the updated channel).-/// - **only pup / phone** (no webview) → do NOTHING intrusive here; the viewer's-/// own in-page title/favicon cue flags the backgrounded tab, and the AI is-/// hinted to flash the pup taskbar (`browser_alert_window`) — never foreground.-/// - **nobody** watching → force-open a webview as a fallback, unless+/// Decide how to surface a new shot, based on which surface(s) are watching.+/// Never steals the human's OS foreground; all webview targeting is by the tab's+/// UNIQUE name (`Shots: <channel>`) so, with many shotlogs open in parallel, only+/// THIS channel's tab is ever touched.+/// - **webview** open for this channel → bring THAT tab **forward** (activate it)+/// so the update is seen. Done in code, on by default.+/// - **only pup / phone** (no webview) → leave their foreground alone; the+/// viewer's own in-page title cue flags the backgrounded tab and the AI is+/// hinted to flash the pup taskbar.+/// - **nobody** watching → force-open a slim ~20% webview, unless /// `SHOTLOG_NO_FORCE_OPEN` is set.-fn notify_surface(channel: &str, port: u16, surfaces: &[String], viewers: usize) {- let has_webview = surfaces.iter().any(|s| s == "webview");+fn notify_surface(channel: &str, port: u16, infos: &[crate::store::ViewerInfo]) {+ // Pup viewers get an automatic ORANGE TASKBAR FLASH (FlashWindowEx) on their+ // hosting window — a gentle "update" cue, never a foreground steal. Works when+ // the viewer URL carried ?session=<pup sessionId> (reported in the WS hello).+ let pup_sessions: std::collections::HashSet<String> = infos+ .iter()+ .filter(|i| i.surface == "pup")+ .filter_map(|i| i.session.clone())+ .collect();+ for s in pup_sessions {+ flash_pup_session(&s);+ }++ let has_webview = infos.iter().any(|i| i.surface == "webview"); if has_webview {- blink_webview_tab(channel);- if env_truthy("SHOTLOG_WEBVIEW_AUTOSWITCH") {- refresh_webview_tab(channel, port);- }+ bring_webview_forward(channel, port); return; }- // Only pup / phone watching → leave their foreground alone (in-page cue + AI hint).- if viewers == 0 && std::env::var("SHOTLOG_NO_FORCE_OPEN").is_err() {+ if infos.is_empty() && std::env::var("SHOTLOG_NO_FORCE_OPEN").is_err() { force_open_viewer(channel, port); } } -fn env_truthy(k: &str) -> bool {- matches!(std::env::var(k).as_deref(), Ok("1") | Ok("true") | Ok("yes"))-}--/// Blink the channel's webview tab (non-intrusive attention cue — no focus steal,-/// no reload). Debounced so a burst blinks once.-fn blink_webview_tab(channel: &str) {- if !debounce_ok(&format!("blink:{channel}"), 3) {+/// Orange taskbar flash on the pup window hosting a viewer. Debounced per session+/// so a burst of injects flashes once, not strobe.+fn flash_pup_session(session: &str) {+ if !debounce_ok(&format!("pupflash:{session}"), 8) { return; }- let tab = format!("Shots: {channel}");+ let session = session.to_string(); std::thread::spawn(move || {- let _ = std::process::Command::new("adom-cli")- .args(["hydrogen", "alert", "set", "--name", &tab, "-d", "8000"])+ let mut cmd = std::process::Command::new("adom-desktop");+ if let Ok(t) = std::env::var("SHOTLOG_AD_TARGET") {+ cmd.args(["--target", &t]);+ }+ let _ = cmd+ .args(["browser_alert_window", &format!("{{\"sessionId\":\"{session}\"}}")]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status(); }); } -/// Activate (auto-switch to) the channel's webview tab. Opt-in via-/// SHOTLOG_WEBVIEW_AUTOSWITCH — some users want the panel to jump to whichever-/// shotlog just updated. Debounced to avoid reload-thrash on bursts.-fn refresh_webview_tab(channel: &str, port: u16) {- if !debounce_ok(&format!("switch:{channel}"), 3) {+/// Bring THIS channel's already-open webview tab to the front. Finds the tab by+/// its panelState URL (`/log/<channel>/` — unique per channel, so parallel+/// shotlog tabs are never cross-touched), then `move-tab` to its own pane:+/// Hydrogen auto-activates it (surfaces to top) and `--alert` paints the ORANGE+/// update highlight on the tab. Falls back to create-or-refresh if not found.+fn bring_webview_forward(channel: &str, port: u16) {+ if !debounce_ok(&format!("fwd:{channel}"), 2) { return; }- let Ok(proxy_tmpl) = std::env::var("VSCODE_PROXY_URI") else { return };- let base = proxy_tmpl.replace("{{port}}", &port.to_string());- let base = base.trim_end_matches('/').to_string();- let url = format!("{base}/log/{channel}/?surface=webview");- let tab = format!("Shots: {channel}");+ let channel = channel.to_string(); std::thread::spawn(move || {- let _ = std::process::Command::new("adom-cli")- .args(["hydrogen", "webview", "open-or-refresh", "--name", &tab, "--url", &url])- .stdout(std::process::Stdio::null())- .stderr(std::process::Stdio::null())- .status();+ if let Some(hit) = find_webview_tab(&channel) {+ // Hydrogen has no "activate tab" verb, and move-tab rejects same-pane+ // moves — but move-tab AUTO-ACTIVATES in its target pane. So: BOUNCE.+ // Move to any other pane, then straight back to the original position+ // with --alert: net effect = tab surfaces to top with the ORANGE+ // update highlight, order preserved via --target-index.+ if let Some(other) = hit.other_pane {+ let m1 = std::process::Command::new("adom-cli")+ .args(["hydrogen", "workspace", "move-tab",+ "--from-panel-id", &hit.pane, "--tab-id", &hit.tab,+ "--to-panel-id", &other])+ .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())+ .status().map(|s| s.success()).unwrap_or(false);+ if m1 {+ let m2 = std::process::Command::new("adom-cli")+ .args(["hydrogen", "workspace", "move-tab",+ "--from-panel-id", &other, "--tab-id", &hit.tab,+ "--to-panel-id", &hit.pane,+ "--target-index", &hit.index.to_string(),+ "--alert", "--alert-duration", "6000"])+ .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())+ .status().map(|s| s.success()).unwrap_or(false);+ if m2 {+ return;+ }+ }+ }+ // Single-pane workspace (or a move failed): at least blink the tab.+ let _ = std::process::Command::new("adom-cli")+ .args(["hydrogen", "alert", "set", "--name",+ &format!("Shots: {channel}"), "-d", "6000"])+ .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())+ .status();+ return;+ }+ // Tab not found → create/refresh it instead.+ crate::open::open_webview(&channel, None, None, port, false, 0.2); }); } +/// A located webview tab: its pane, tab id, index within the pane, and some+/// OTHER pane usable for the activate-bounce (None in a single-pane workspace).+struct TabHit {+ pane: String,+ tab: String,+ index: usize,+ other_pane: Option<String>,+}++/// Locate the webview tab whose panelState URL contains `/log/<channel>/`.+fn find_webview_tab(channel: &str) -> Option<TabHit> {+ let out = std::process::Command::new("adom-cli")+ .args(["hydrogen", "workspace", "get"])+ .output()+ .ok()?;+ let v: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;+ let needle = format!("/log/{channel}/");+ // Collect all leaves in order, then find the tab.+ fn leaves<'a>(n: &'a serde_json::Value, acc: &mut Vec<&'a serde_json::Value>) {+ match n.get("type").and_then(|t| t.as_str()) {+ Some("leaf") => acc.push(n),+ Some("split") => {+ if let Some(c) = n.get("first") { leaves(c, acc); }+ if let Some(c) = n.get("second") { leaves(c, acc); }+ }+ _ => {}+ }+ }+ let root = v.get("root").unwrap_or(&v);+ let mut ls = Vec::new();+ leaves(root, &mut ls);+ let empty = Vec::new();+ for leaf in &ls {+ let pane = leaf.get("id").and_then(|i| i.as_str())?.to_string();+ for (idx, t) in leaf.get("tabs").and_then(|x| x.as_array()).unwrap_or(&empty).iter().enumerate() {+ // panelState may be a JSON object or a stringified one — either way+ // its serialization contains the viewer URL.+ let ps = t.get("panelState").map(|p| p.to_string()).unwrap_or_default();+ if ps.contains(&needle) {+ let tab = t.get("id").and_then(|i| i.as_str())?.to_string();+ let other_pane = ls.iter()+ .filter_map(|l| l.get("id").and_then(|i| i.as_str()))+ .find(|id| *id != pane)+ .map(String::from);+ return Some(TabHit { pane, tab, index: idx, other_pane });+ }+ }+ }+ None+}+ /// Force-open the viewer for a channel when a shot lands with NOBODY watching.-/// Fallback only (see notify_surface). Debounced per-channel.+/// Fallback only (see notify_surface). Debounced per-channel. Defaults to a slim+/// ~20%-wide right-side panel so cross-thread shots auto-appear without taking+/// over the workspace — and CRUCIALLY passes a --panel-id (via open_webview) so+/// the tab is actually CREATED, not silently skipped. fn force_open_viewer(channel: &str, port: u16) {- let proxy_tmpl = match std::env::var("VSCODE_PROXY_URI") {- Ok(v) => v,- Err(_) => return, // not in an Adom container; nothing to open- };+ if std::env::var("VSCODE_PROXY_URI").is_err() {+ return; // not in an Adom container; nothing to open+ } if !debounce_ok(&format!("open:{channel}"), 60) { return; // opened recently }-- let base = proxy_tmpl.replace("{{port}}", &port.to_string());- let base = base.trim_end_matches('/').to_string();- // ?surface=webview so presence correctly labels this force-opened viewer.- let url = format!("{base}/log/{channel}/?surface=webview");- let tab = format!("Shots: {channel}");- let icon = format!("data:image/svg+xml;base64,{}", base64::Engine::encode(&base64::engine::general_purpose::STANDARD, ICON_SVG));-- // Best-effort; don't block the inject response on it.+ let channel = channel.to_string();+ // Best-effort; don't block the inject response on the adom-cli round-trip. std::thread::spawn(move || {- let _ = std::process::Command::new("adom-cli")- .args([- "hydrogen", "webview", "open-or-refresh",- "--name", &tab,- "--url", &url,- "--display-icon", &icon,- ])- .stdout(std::process::Stdio::null())- .stderr(std::process::Stdio::null())- .status();+ crate::open::open_webview(&channel, None, None, port, true, 0.2); }); } @@ -345,6 +459,14 @@ struct InjectResponse { /// background tab / minimized window, and which surfaces those are. visible_viewers: usize, visible_surfaces: Vec<String>,+ /// What the broker is DOING about this shot (webview_surface / pup_flash /+ /// force_open / none) — so the calling AI knows surfacing is handled and+ /// never foregrounds anything itself.+ #[serde(skip_serializing_if = "Option::is_none")]+ surfacing: Option<serde_json::Value>,+ /// Progressive teaching hints (mini-skills), revealed a few times then quiet.+ #[serde(skip_serializing_if = "Vec::is_empty", default)]+ hints: Vec<String>, } async fn post_shots(@@ -399,6 +521,9 @@ async fn post_shots( if out.is_empty() { None } else { Some(serde_json::Value::Array(out)) } } else { None }; + let had_meta = req.meta.is_some() || popups_stored.is_some();+ let source_str = source.clone();+ let entry = state .store .add_entry(&req.channel, bytes, filename, req.description, source, w, h, orig_w, orig_h, req.meta, popups_stored)@@ -412,10 +537,41 @@ async fn post_shots( let visible_viewers = infos.iter().filter(|i| i.visible).count(); let visible_surfaces: Vec<String> = infos.iter().filter(|i| i.visible).map(|i| i.surface.clone()).collect(); - // Gently surface the new shot to whatever surface is watching — never steal- // the human's foreground. (webview → blink tab; pup/phone → leave it; none →- // force-open fallback.) See notify_surface + the adom-shotlog-surfacing skill.- notify_surface(&req.channel, state.port, &surfaces, viewers);+ // Decide + REPORT the surfacing action (so the calling AI knows it's handled),+ // then fire it in the background. Never steals the human's foreground.+ let has_webview = infos.iter().any(|i| i.surface == "webview");+ let pup_sessions: Vec<String> = {+ let s: std::collections::HashSet<String> = infos.iter()+ .filter(|i| i.surface == "pup").filter_map(|i| i.session.clone()).collect();+ s.into_iter().collect()+ };+ let force_open_on = std::env::var("SHOTLOG_NO_FORCE_OPEN").is_err();+ let mut actions: Vec<serde_json::Value> = Vec::new();+ if !pup_sessions.is_empty() {+ actions.push(serde_json::json!({"action":"pup_flash","sessions":pup_sessions,"cue":"orange taskbar flash"}));+ }+ if has_webview {+ actions.push(serde_json::json!({"action":"webview_surface","tab":format!("Shots: {}", req.channel),"cue":"tab pulled to front + orange alert"}));+ } else if infos.is_empty() && force_open_on {+ actions.push(serde_json::json!({"action":"force_open","layout":"slim 20% right-side webview"}));+ }+ let surfacing = Some(serde_json::json!({"actions": actions}));+ notify_surface(&req.channel, state.port, &infos);++ // Progressive mini-skill hints — taught at the moment they're relevant, then+ // quiet (hint_gate caps how many times each lesson repeats).+ let mut hints: Vec<String> = Vec::new();+ if entry.id == 1 && hint_gate("teach:new-channel", 6) {+ hints.push(format!(+ "mini-skill [surfacing]: first shot in '{ch}'. The human watches via a slim right-side webview (`shotlog open -c {ch} --skinny`), a pup window, or their phone. I auto-open a 20% webview when nobody is watching, pull the channel's tab to the front with an orange alert on updates, and flash pup taskbars — never foreground anything yourself. Gate on visibility with `shotlog viewers -c {ch}` (exit 0=on-screen, 4=backgrounded, 3=nobody); see every channel with `shotlog channels`.",+ ch = req.channel));+ }+ if source_str.contains("desktop_screenshot") && !had_meta && hint_gate("teach:meta", 3) {+ hints.push("mini-skill [child-windows]: this source looks like an Adom Desktop window capture. AD returns the window's CHILD WINDOWS (owned dropdowns/dialogs, `screenshots[]`/`ownedPopupCount`) + coordMap in the SAME response — pass that whole JSON via `--meta` and they all render inline on this card, and the original resolution comes free.".into());+ }+ if infos.iter().any(|i| i.surface == "pup" && i.session.is_none()) && hint_gate("teach:pup-session", 3) {+ hints.push("mini-skill [surfacing]: a pup viewer is watching WITHOUT a ?session= handle, so I cannot flash its taskbar for you. Open pup viewers with `?surface=pup&session=<pup sessionId>` and I flash that window orange automatically on every update.".into());+ } Ok(Json(InjectResponse { ok: true,@@ -432,6 +588,8 @@ async fn post_shots( surfaces, visible_viewers, visible_surfaces,+ surfacing,+ hints, })) } @@ -506,6 +664,8 @@ async fn post_paste( surfaces, visible_viewers, visible_surfaces,+ surfacing: None,+ hints: Vec::new(), })) } @@ -643,7 +803,7 @@ async fn ws_connection(mut socket: WebSocket, channel: String, state: Arc<AppSta let vid = state .next_viewer_id .fetch_add(1, std::sync::atomic::Ordering::Relaxed);- state.store.register_viewer(&channel, vid, "browser".into(), true).await;+ state.store.register_viewer(&channel, vid, "browser".into(), true, None).await; loop { tokio::select! {@@ -673,7 +833,8 @@ async fn ws_connection(mut socket: WebSocket, channel: String, state: Arc<AppSta Some("hello") => { let surface = v.get("surface").and_then(|x| x.as_str()).unwrap_or("browser").to_string(); let visible = v.get("visible").and_then(|x| x.as_bool()).unwrap_or(true);- state.store.register_viewer(&channel, vid, surface, visible).await;+ let session = v.get("session").and_then(|x| x.as_str()).map(String::from);+ state.store.register_viewer(&channel, vid, surface, visible, session).await; } Some("vis") => { let visible = v.get("visible").and_then(|x| x.as_bool()).unwrap_or(true);
src/store.rs+15−2@@ -51,6 +51,9 @@ const MAX_ENTRIES: usize = 200; pub struct ViewerInfo { pub surface: String, pub visible: bool,+ /// Optional host handle the viewer reported (e.g. the pup sessionId from+ /// ?session=... in the viewer URL) so the broker can cue that exact window.+ pub session: Option<String>, } pub struct Store {@@ -287,11 +290,11 @@ impl Store { /// Register (or update) a connected viewer with the surface it reported /// (webview/pup/mobile/browser) and whether it's currently on-screen.- pub async fn register_viewer(&self, channel_name: &str, id: u64, surface: String, visible: bool) {+ pub async fn register_viewer(&self, channel_name: &str, id: u64, surface: String, visible: bool, session: Option<String>) { let mut p = self.presence.write().await; p.entry(channel_name.to_string()) .or_default()- .insert(id, ViewerInfo { surface, visible });+ .insert(id, ViewerInfo { surface, visible, session }); } /// Update just the on-screen (Page Visibility) state of an existing viewer.@@ -405,6 +408,16 @@ impl Store { channels.keys().cloned().collect() } + /// Lightweight per-channel summary for the `channels` overview:+ /// (name, shot count, last timestamp, last source). No entry cloning.+ pub async fn channel_summaries(&self) -> Vec<(String, usize, Option<DateTime<Utc>>, Option<String>)> {+ let channels = self.channels.read().await;+ channels.iter().map(|(k, ch)| {+ let last = ch.entries.last();+ (k.clone(), ch.entries.len(), last.map(|e| e.timestamp), last.map(|e| e.source.clone()))+ }).collect()+ }+ /// Get the base directory path. pub fn base_dir(&self) -> &Path { &self.base_dir
tools/publish.shadded+45@@ -0,0 +1,45 @@+#!/bin/bash+# Publish adom/adom-shotlog — the publish RULES as code (see skills/adom-shotlog-publishing).+# Usage: tools/publish.sh <staging-dir> (staging holds package.json, bin/, src/, skills/, ...)+set -e+ST="${1:?usage: tools/publish.sh <staging-dir>}"+REPO="$(cd "$(dirname "$0")/.." && pwd)"++# Rule: versions move in LOCKSTEP (Cargo.toml == VERSION == package.json).+VC=$(grep -m1 '^version' "$REPO/Cargo.toml" | sed 's/.*"\(.*\)".*/\1/')+VF=$(tr -d '[:space:]' < "$REPO/VERSION")+VP=$(python3 -c "import json;print(json.load(open('$ST/package.json'))['version'])")+if [ "$VC" != "$VF" ] || [ "$VC" != "$VP" ]; then+ echo "ERROR: version mismatch: Cargo.toml=$VC VERSION=$VF package.json=$VP"+ echo "Hint: bump all three together, rebuild, restage."+ exit 1+fi+# Rule: the binary in staging must BE that version.+BV=$("$ST/bin/adom-shotlog" --version | awk '{print $2}')+[ "$BV" = "$VC" ] || { echo "ERROR: staged binary is $BV, package is $VC. Rebuild + restage."; exit 1; }+# Rule: no em/en-dashes in human-facing wiki strings.+python3 - "$ST" <<'PY'+import sys,json,pathlib+st=pathlib.Path(sys.argv[1]); bad=[]+for f in ["README.md","package.json","SKILL.md"]:+ t=(st/f).read_text(encoding="utf-8",errors="replace")+ for ch in ["—","–"]:+ if ch in t: bad.append(f)+if bad: print("ERROR: em/en-dash found in:",sorted(set(bad))); sys.exit(1)+PY++echo "== publishing adom/adom-shotlog@$VC (org adom, public) =="+( cd "$ST" && adompkg publish --org adom --public --version "$VC" --yes )++# Rule: verify ANONYMOUSLY (a 200 with auth proves nothing) + hero bytes.+for u in "https://wiki.adom.inc/adom/adom-shotlog" \+ "https://wiki.adom.inc/api/packages/adom/adom-shotlog/$VC/manifest"; do+ code=$(curl -s -o /dev/null -w "%{http_code}" "$u")+ [ "$code" = "200" ] || { echo "ERROR: anon $code for $u (visibility trap? bumped --org republish re-publicizes)"; exit 1; }+ echo "anon 200 $u"+done+H_LOCAL=$(md5sum "$ST/screenshots/hero.png" | awk '{print $1}')+H_SERVED=$(curl -s "https://wiki.adom.inc/blob/app/adom-shotlog/screenshots/hero.png" | md5sum | awk '{print $1}')+[ "$H_LOCAL" = "$H_SERVED" ] && echo "hero bytes verified" || echo "WARN: served hero != staged (CDN cache or clobber — re-assert with: adom-wiki page hero adom/adom-shotlog --image screenshots/hero.png)"+echo "OK: published $VC."+echo "Hint: now SHOW it — refresh the wiki page in pup and LOOK at it. A 200 is not a render."
tools/ralph-check.cjsadded+25@@ -0,0 +1,25 @@+#!/usr/bin/env node+// Ralph layout audit for the shotlog viewer (see skills/adom-shotlog-dev).+// Usage: CHROME=<chrome> node tools/ralph-check.cjs <viewer-url> [width] [dpr]+// PASS = outerHScroll:false AND outerVScroll:false AND off:[]+const puppeteer = require('/home/adom/project/node_modules/puppeteer-core');+(async()=>{+ const url = process.argv[2], W = +(process.argv[3]||360), DPR = +(process.argv[4]||1.5);+ const b = await puppeteer.launch({executablePath: process.env.CHROME, headless:'new',+ args:['--no-sandbox','--disable-gpu','--disable-dev-shm-usage']});+ const p = await b.newPage();+ await p.setViewport({width:W, height:701, deviceScaleFactor:DPR});+ await p.goto(url, {waitUntil:'networkidle2', timeout:25000}).catch(()=>{});+ await new Promise(r=>setTimeout(r,1800));+ const res = await p.evaluate(()=>{+ const de=document.documentElement, vw=de.clientWidth;+ const off=[...document.querySelectorAll('*')].map(el=>{const r=el.getBoundingClientRect();+ return {t:el.tagName+'.'+((''+el.className).split(' ')[0]||''), right:Math.round(r.right)};})+ .filter(x=>x.right>vw+1).sort((a,b)=>b.right-a.right).slice(0,10);+ return {vw, dpr:devicePixelRatio, outerHScroll: de.scrollWidth>de.clientWidth,+ outerVScroll: de.scrollHeight>de.clientHeight, off};+ });+ console.log(JSON.stringify(res));+ await b.close();+ process.exit((res.outerHScroll||res.outerVScroll||res.off.length)?1:0);+})().catch(e=>{console.error(String(e).slice(0,200));process.exit(2)});