← Commit history

v1.9.41 — 2026-08 naming cutover per adom/definitions: pup surface now shells adom-bridge-cli with pup_* verbs (adom-desktop browser_* is gone), ADOM_BRIDGE_TARGET pins the target (legacy var read as fallback), all skills/hints say Bridge (ab)

John Lauer ·c24d7a01b0 ·2mo ago ·parent 462d653
9 files changed +63−68
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.40"+version = "1.9.41" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1
@@ -1 +1 @@-1.9.40+1.9.41
dev-skills/adom-tts-dev/SKILL.md+9−2
@@ -63,8 +63,8 @@ manually — deploy a new version and let supersede do the handoff. If you must - The drainer **adopts surface env** from `~/.adom/tts/env.json` at startup —   a `say` from a sanitized shell (cron/hook) once spawned a drainer with no   `VSCODE_PROXY_URI`, which silently killed ALL playback for every thread. The-  snapshot also carries **`ADOM_DESKTOP_TARGET`** (pup): with >1 Adom Desktop-  on the relay, an unpinned `browser_open_window` is AMBIGUOUS and fails, so+  snapshot also carries **`ADOM_BRIDGE_TARGET`** (pup; legacy ADOM_DESKTOP_TARGET still read as fallback): with >1 Bridge machine+  on the relay, an unpinned `pup_open_window` is AMBIGUOUS and fails, so   the drainer resolves the target FRESH on every open (env -> env.json -> the   sole connected desktop) and passes `--target`. A long-lived unpinned drainer   thus picks up a target that appears later without a restart.@@ -114,6 +114,13 @@ manually — deploy a new version and let supersede do the handoff. If you must   for the full platform facts. - **Settings, not preferences** (Adom-wide word choice; Microsoft/Google/Apple   all converged on Settings). `adom-tts settings` / `settings set surface pup`.+- **Naming per adom/definitions (2026-08 cutover).** The desktop relay is+  **Bridge** (ab, formal name Adom Bridge; formerly "Adom Desktop"): the CLI is+  `adom-bridge-cli`, the verb family is `pup_*` (browser_* is gone, no aliases),+  and the pin var is `ADOM_BRIDGE_TARGET` (legacy ADOM_DESKTOP_TARGET read as+  fallback only). Say ah for Hydrogen desktop, hc for Hydrogen Cloud; hd and ad+  are dead words. Check https://wiki.adom.inc/adom/definitions before naming+  anything user-visible. - **A flashing tab never outlives its reason** (user rule). The hydrogen tab   alert is set with `--duration 600` (hydrogen kills the blink at 10 min even   if the drainer dies) and is actively cleared the moment a clip's audio
dev-skills/adom-tts-test/SKILL.md+3−3
@@ -53,7 +53,7 @@ paths were "fine". Drive the real page for:   Date.now())` via eval), assert the pill text/color, fire the tooltip and run   the tooltips skill's MANDATORY on-screen assert (rect inside viewport). - **Trusted-click the controls**: play, pause, resume, seek, History ↻ — via-  `browser_click`, not eval (eval carries no user gesture; clicks do).+  `pup_click`, not eval (eval carries no user gesture; clicks do). - **Version badge** matches the shipped build.  ### Rig etiquette (each one is a user complaint — follow all of them)@@ -62,7 +62,7 @@ paths were "fine". Drive the real page for: 2. **One window.** Player tab + shotlog tab in ONE pup session; close it when    done. Never leave a second player window open next to the real one. 3. **Never touch the real player tab.** The rig drainer must run with PATH-   stripped of adom-cli/adom-desktop so its surface-open cannot reach the+   stripped of adom-cli/adom-bridge-cli so its surface-open cannot reach the    user's workspace. 4. **Shotlog every screenshot** (`shotlog paste -c <channel>`) — the user (and    you) can only SEE shots there.@@ -76,7 +76,7 @@ paths were "fine". Drive the real page for:   pup player window must be opened `foreground:true` — an audio surface the   user chose must be visible. - **`eval play()` is not a user gesture.** Autoplay unlock needs a TRUSTED-  click (`browser_click`) or the visible-window + drainer kick path.+  click (`pup_click`) or the visible-window + drainer kick path. - **Snapshot files lie about age.** The enqueue byte-snapshot is written at   enqueue time; audio age must ride in the queue entry (`audio_ms`), never be   re-read from the snapshot's mtime.
page.json+2−22
@@ -12,13 +12,13 @@     "audio",     "demo",     "accessibility",-    "cli",     "open-source",     "mit"   ],   "hero": {     "type": "image",-    "path": "screenshots/hero.png"+    "path": "screenshots/hero-v5.png",+    "thumbnail": "screenshots/hero-v5-thumb.png"   },   "discovery_pitch": "Hands-free in the car? Say 'read me your last answer, I'm driving' and adom-tts speaks it aloud. Making a demo video? Feed it your script and get a perfectly-timed narration track in the house voice, with no recording booth, no retakes. One shared edge-tts service with Adom pronunciation overrides and an instant source-hash cache.",   "discovery_triggers": [@@ -64,25 +64,5 @@   ],   "install": {     "binary_name": "adom-tts"-  },-  "description": "Adom's shared text-to-speech CLI + Playground. Reads your AI answers back to you while driving, and renders perfectly-timed voiceover tracks for demo videos.",-  "dependencies": {},-  "scripts": {-    "install": "./install.sh",-    "uninstall": "./uninstall.sh"-  },-  "needs_sudo": false,-  "files": [-    "package.json",-    "install.sh",-    "uninstall.sh",-    "bin/adom-tts",-    "skills/**",-    "completions/**"-  ],-  "org": "adom",-  "author": {-    "name": "John Lauer",-    "email": "[email protected]"   } }\ No newline at end of file
publish-skills/adom-tts-publish/SKILL.md+3−3
@@ -46,7 +46,7 @@ description: >-    the skill's own example says "a Linux consumer installs the package (CLI +    skills)". Release assets exist ONLY for artifacts fetched OUTSIDE the    install flow (a Windows installer a laptop downloads directly, pup's bridge-   zip that Adom Desktop fetches itself). adom-tts has no such consumer —+   zip that Bridge (ab) fetches itself). adom-tts has no such consumer —    every user is a Linux container running `pkg install` — so a release for    adom-tts makes NO sense. (Got this wrong twice: first shipped src+pngs+    binary in a fat tarball AND a duplicate release; then over-corrected to a@@ -81,8 +81,8 @@ description: >-  6. **Verify live — in pup, always** (never claim published without looking):    ```bash-   adom-desktop --target <laptop> browser_open_window '{"sessionId":"wiki-check","url":"https://wiki.adom.inc/adom/adom-tts?cb=<epoch>"}'-   adom-desktop --target <laptop> browser_screenshot '{"sessionId":"wiki-check"}'   # read the PNG it saves+   adom-bridge-cli --target <laptop> pup_open_window '{"sessionId":"wiki-check","url":"https://wiki.adom.inc/adom/adom-tts?cb=<epoch>"}'+   adom-bridge-cli --target <laptop> pup_screenshot '{"sessionId":"wiki-check"}'   # read the PNG it saves    ```    Check: version, hero renders, Skills tab shows User + Dev + Publish tiers,    README intact, Install command correct, and NO EMOJIS anywhere in the README
src/main.rs+1−1
@@ -171,7 +171,7 @@ enum Cmd {     /// closed/dead Hydrogen tab) without restarting playback or the queue.     Reopen,     /// Fully tear down the drainer and spawn a FRESH one that re-reads the-    /// current env (pins pup to ADOM_DESKTOP_TARGET). Use when the player is+    /// current env (pins pup to ADOM_BRIDGE_TARGET). Use when the player is     /// wedged (status shows STALLED / no sink). Queued clips survive. REFUSES     /// while a clip is playing or human-paused (--force overrides).     Restart {
src/play.rs+42−34
@@ -42,7 +42,7 @@ const SERVER_MAX_TIMEOUT_SECS: u64 = 300; /// VSCODE_PROXY_URI) and builds an unreachable 127.0.0.1 player URL, killing /// playback for EVERY thread until superseded. So: every caller that HAS the /// good env persists it, and the drainer adopts whatever it's missing.-const SURFACE_ENV_VARS: &[&str] = &["VSCODE_PROXY_URI", "VSCODE_IPC_HOOK_CLI", "ADOM_DESKTOP_TARGET"];+const SURFACE_ENV_VARS: &[&str] = &["VSCODE_PROXY_URI", "VSCODE_IPC_HOOK_CLI", "ADOM_BRIDGE_TARGET", "ADOM_DESKTOP_TARGET"];  fn persist_surface_env() {     let Ok(home) = tts_home() else { return };@@ -55,7 +55,7 @@ fn persist_surface_env() {     // Persist if we captured anything useful — either the proxy env (Hydrogen)     // or the desktop target (pup). Merge into any existing snapshot so a pup     // shell doesn't wipe a Hydrogen shell's VSCODE_PROXY_URI and vice-versa.-    if map.contains_key("VSCODE_PROXY_URI") || map.contains_key("ADOM_DESKTOP_TARGET") {+    if map.contains_key("VSCODE_PROXY_URI") || map.contains_key("ADOM_BRIDGE_TARGET") || map.contains_key("ADOM_DESKTOP_TARGET") {         let _ = std::fs::create_dir_all(home.join(".adom/tts"));         let path = home.join(".adom/tts/env.json");         let mut merged: serde_json::Map<String, serde_json::Value> =@@ -241,7 +241,7 @@ pub fn show_status() -> Result<()> {                     // not "playing". This misled a reporting thread into thinking                     // its clip vanished while the surface was actually never open.                     playing_line = format!(-                        "  ⚠ STALLED{what} on :{port} — 0s / {dur:.0}s, no audio sink (player surface not open, {}s silent).\n  # hint: likely an unpinned pup target with >1 desktop, or a dead bridge. Fix:\n  #       export ADOM_DESKTOP_TARGET=<host> && adom-tts restart   (see `adom-tts logs`).",+                        "  ⚠ STALLED{what} on :{port} — 0s / {dur:.0}s, no audio sink (player surface not open, {}s silent).\n  # hint: likely an unpinned pup target with >1 Bridge machine, or Bridge itself down. Fix:\n  #       export ADOM_BRIDGE_TARGET=<host> && adom-tts restart   (see `adom-tts logs`).",                         poll_age / 1000);                 } else if paused {                     // The HUMAN paused — say so loudly; "now playing" here misled@@ -368,7 +368,7 @@ pub fn check_clip(clip: &str) -> Result<()> {                 println!("#       the live player page IS at: {url}  (open it, or `adom-tts reopen`)");             }             println!("# hint: the DEFAULT 'hydrogen' webview surface is reliable — re-send WITHOUT `--surface pup`.");-            println!("#       (For pup: export ADOM_DESKTOP_TARGET=<host> && adom-tts restart, then re-send.)");+            println!("#       (For pup: export ADOM_BRIDGE_TARGET=<host> && adom-tts restart, then re-send.)");             std::process::exit(5);         }         Some(v) if v == "stale-audio" => {@@ -963,7 +963,7 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {                                         if surface == Surface::Pup {                                             eprintln!("#   Open that URL in pup, OR retry WITHOUT `--surface pup` — the DEFAULT");                                             eprintln!("#   'hydrogen' webview surface is reliable and was live.");-                                            eprintln!("#   (To pin pup: export ADOM_DESKTOP_TARGET=<host> && adom-tts restart.)");+                                            eprintln!("#   (To pin pup: export ADOM_BRIDGE_TARGET=<host> && adom-tts restart.)");                                         } else {                                             eprintln!("#   Open that URL, or `adom-tts reopen` to re-surface; `adom-tts logs` for the open error.");                                         }@@ -1432,32 +1432,39 @@ fn find_panel_id_for_tab(name: &str) -> Result<Option<String>> {  fn close_pup_window(session: &str) -> Result<()> {     let payload = serde_json::json!({"sessionId": session}).to_string();-    let _ = Command::new("adom-desktop")-        .args(["browser_close_window", &payload])+    let _ = Command::new("adom-bridge-cli")+        .args(["pup_close_window", &payload])         .output();     Ok(()) } -/// Which Adom Desktop should pup open on? A drainer is long-lived and may have-/// started before ADOM_DESKTOP_TARGET was set, so RESOLVE FRESH every call:-/// live env (possibly adopted) -> the persisted env.json -> if exactly one-/// desktop is connected, that one. Returns (target, connected_count) so the-/// caller can raise a precise AMBIGUOUS error when >1 desktop and none pinned.-fn resolve_desktop_target() -> (Option<String>, usize) {-    if let Ok(t) = std::env::var("ADOM_DESKTOP_TARGET") {-        let t = t.trim().to_string();-        if !t.is_empty() { return (Some(t), 0); }+/// Which Bridge machine should pup open on? (Bridge = ab, the app formerly+/// named "Adom Desktop"; the 2026-08 cutover renamed the CLI to+/// `adom-bridge-cli` and every browser_* verb to pup_*.) A drainer is+/// long-lived and may have started before ADOM_BRIDGE_TARGET was set, so+/// RESOLVE FRESH every call: live env (possibly adopted) -> the persisted+/// env.json -> if exactly one machine is connected, that one. Returns+/// (target, connected_count) so the caller can raise a precise AMBIGUOUS+/// error when >1 machine and none pinned. ADOM_DESKTOP_TARGET is read as a+/// fallback so pre-rename shells keep working.+fn resolve_bridge_target() -> (Option<String>, usize) {+    for var in ["ADOM_BRIDGE_TARGET", "ADOM_DESKTOP_TARGET"] {+        if let Ok(t) = std::env::var(var) {+            let t = t.trim().to_string();+            if !t.is_empty() { return (Some(t), 0); }+        }     }     if let Ok(home) = tts_home() {         if let Some(t) = std::fs::read_to_string(home.join(".adom/tts/env.json")).ok()             .and_then(|r| serde_json::from_str::<serde_json::Value>(&r).ok())-            .and_then(|v| v.get("ADOM_DESKTOP_TARGET").and_then(|x| x.as_str()).map(str::to_string))+            .and_then(|v| ["ADOM_BRIDGE_TARGET", "ADOM_DESKTOP_TARGET"].iter()+                .find_map(|k| v.get(*k).and_then(|x| x.as_str()).map(str::to_string)))             .filter(|t| !t.is_empty()) {             return (Some(t), 0);         }     }-    // Ask the bridge how many desktops are connected.-    let names: Vec<String> = Command::new("adom-desktop").arg("targets").output().ok()+    // Ask Bridge how many machines are connected.+    let names: Vec<String> = Command::new("adom-bridge-cli").arg("targets").output().ok()         .filter(|o| o.status.success())         .and_then(|o| serde_json::from_slice::<serde_json::Value>(&o.stdout).ok())         .and_then(|v| v.get("targets").and_then(|t| t.as_array()).map(|a|@@ -1467,12 +1474,13 @@ fn resolve_desktop_target() -> (Option<String>, usize) { }  fn open_pup_window(url: &str, session: &str, profile: &str) -> Result<()> {-    // PIN THE TARGET. With >1 desktop connected and no target, browser_open_window-    // is ambiguous and fails silently — the wedge this whole path guards against.-    let (target, count) = resolve_desktop_target();+    // PIN THE TARGET. With >1 Bridge machine connected and no target,+    // pup_open_window is ambiguous and fails silently — the wedge this whole+    // path guards against.+    let (target, count) = resolve_bridge_target();     if target.is_none() && count > 1 {-        anyhow::bail!("AMBIGUOUS pup target: {count} Adom Desktops connected and no ADOM_DESKTOP_TARGET set. \-Pin it so the drainer knows which screen: `export ADOM_DESKTOP_TARGET=<host>` then `adom-tts restart`");+        anyhow::bail!("AMBIGUOUS pup target: {count} Bridge machines connected and no ADOM_BRIDGE_TARGET set. \+Pin it so the drainer knows which screen: `export ADOM_BRIDGE_TARGET=<host>` then `adom-tts restart`");     }     let with_target = |c: &mut Command| { if let Some(t) = &target { c.arg("--target").arg(t); } }; @@ -1488,15 +1496,15 @@ Pin it so the drainer knows which screen: `export ADOM_DESKTOP_TARGET=<host>` th         // root of the original "--surface pup never plays" field report.         "foreground": true,     }).to_string();-    let mut open_cmd = Command::new("adom-desktop");+    let mut open_cmd = Command::new("adom-bridge-cli");     with_target(&mut open_cmd);     let out = open_cmd-        .arg("browser_open_window")+        .arg("pup_open_window")         .arg(&open_args)         .output()-        .context("invoke adom-desktop browser_open_window")?;+        .context("invoke adom-bridge-cli pup_open_window")?;     if !out.status.success() {-        anyhow::bail!("adom-desktop browser_open_window failed: {}",+        anyhow::bail!("adom-bridge-cli pup_open_window failed: {}",             String::from_utf8_lossy(&out.stderr));     } @@ -1510,10 +1518,10 @@ Pin it so the drainer knows which screen: `export ADOM_DESKTOP_TARGET=<host>` th         "sessionId": session,         "expr": "(function(){var el=document.querySelector('audio');if(el&&el.paused&&!el.ended&&el.currentTime===0)el.play();})()",     }).to_string();-    let mut eval_cmd = Command::new("adom-desktop");+    let mut eval_cmd = Command::new("adom-bridge-cli");     with_target(&mut eval_cmd);     let _ = eval_cmd-        .arg("browser_eval")+        .arg("pup_eval")         .arg(&eval_args)         .output();     Ok(())@@ -2009,7 +2017,7 @@ pub fn reopen() -> Result<()> { /// re-offers the tab but keeps a broken player; this replaces the player. /// Queued clips survive (they live in the queue dir, not the drainer). pub fn restart(force: bool) -> Result<()> {-    persist_surface_env();   // capture THIS shell's env (incl ADOM_DESKTOP_TARGET)+    persist_surface_env();   // capture THIS shell's env (incl ADOM_BRIDGE_TARGET)     // PAUSE-IS-SACRED, RESTART EDITION. Field incident: the human paused a clip     // (their kid asked a question), an ops restart killed the drainer 81s later,     // and the paused clip was swept — they could never resume it. A clip that is@@ -2065,10 +2073,10 @@ pub fn restart(force: bool) -> Result<()> {         Some(p) => println!("→ restarted: killed stale drainer {p}; spawned a fresh {} player.", surface.as_str()),         None => println!("→ restarted: no drainer owned the lock; spawned a fresh {} player.", surface.as_str()),     }-    let (t, n) = resolve_desktop_target();+    let (t, n) = resolve_bridge_target();     match (surface, &t) {-        (Surface::Pup, Some(t)) => println!("  pinned to Adom Desktop target: {t}"),-        (Surface::Pup, None) if n > 1 => println!("  ⚠ {n} desktops connected and NO target set — export ADOM_DESKTOP_TARGET=<host> and restart again."),+        (Surface::Pup, Some(t)) => println!("  pinned to Bridge target: {t}"),+        (Surface::Pup, None) if n > 1 => println!("  ⚠ {n} Bridge machines connected and NO target set — export ADOM_BRIDGE_TARGET=<host> and restart again."),         _ => {}     }     println!("# hint: queued clips survive and drain in order on the fresh player.");
tools/ralph-test.sh+1−1
@@ -31,7 +31,7 @@ PASS=0; FAIL=0 say()  { echo "== $*"; } ok()   { PASS=$((PASS+1)); echo "   PASS: $*"; } bad()  { FAIL=$((FAIL+1)); echo "   FAIL: $*"; }-rig()  { env -u ADOM_DESKTOP_TARGET HOME=$T PATH=/usr/bin:/bin ADOM_TTS_NO_HINTS=1 "$BIN" "$@"; }+rig()  { env -u ADOM_BRIDGE_TARGET -u ADOM_DESKTOP_TARGET HOME=$T PATH=/usr/bin:/bin ADOM_TTS_NO_HINTS=1 "$BIN" "$@"; } port() { python3 -c "import json;print(json.load(open('$T/.adom/tts/player.json'))['port'])" 2>/dev/null; } ev()   { grep -h "\"$1\"" $T/.adom/tts/events.jsonl 2>/dev/null; } # make a tiny valid-enough mp3 (the drainer never decodes; bytes just flow)