← Commit history
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.2.19"+version = "0.2.20" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.2.19"+version = "0.2.20" dependencies = [  "aiflow-board",  "serde",@@ -35,7 +35,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.2.19"+version = "0.2.20" dependencies = [  "roxmltree",  "serde",@@ -44,7 +44,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.2.19"+version = "0.2.20" dependencies = [  "aiflow-board",  "serde",@@ -53,7 +53,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.2.19"+version = "0.2.20" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.2.19"+version = "0.2.20" dependencies = [  "aiflow-board",  "serde",@@ -72,7 +72,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.2.19"+version = "0.2.20" dependencies = [  "aiflow-board",  "serde",@@ -81,7 +81,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.2.19"+version = "0.2.20" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.2.19"+version = "0.2.20" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.2.19"+version = "0.2.20" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.2.19"+version = "0.2.20" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+13−1
@@ -536,7 +536,19 @@ A drilled pad with no copper layer (an NPTH that lists only `*.Mask`, such as a  The main timeline uses the app-creator SSE channel: CLI workflow commands update durable run files and the server pushes state to the existing page. `timeline present --json '{"caption":"Current passed; thermal open","image":"review.png"}' --run <run> --ai-thread <name>` sends presentation changes through the server's HTTP control endpoint; it never changes engineering gates. Images must already exist in `<run>/timeline/`. Reconnect replays current state. Static exports and older servers retain polling fallback. -Do not reopen the webview to show progress. `timeline open` activates the existing named tab; a changed proxy URL navigates that tab. Repeated status/step/artifact calls reuse it. Keep one tab for this run. Tests must set `AIFLOW_DASHBOARD=off` unless Hydrogen is explicitly mocked; never let a temporary fixture open the user's workspace.+Do not reopen the webview to show progress. `timeline open` activates the existing named tab; a changed proxy URL navigates that tab. Repeated status/step/artifact calls reuse it. Tests must set `AIFLOW_DASHBOARD=off` unless Hydrogen is explicitly mocked (a test-owned `adom-cli` on PATH and its own `AIFLOW_HOME`); never let a temporary fixture open the user's workspace.++**Names.** The tab is `AI Flow · <label>`. The label says what the run is building, from the first of: the requirements title, the spec `title`, the project page (slug, plus its folder), the original board's file stem (the file the run started from, never a `board-N` snapshot), the brief's first line, the run folder's name. It is cut to about 40 characters with `...` and keeps only plain characters. When another run's tab already has that name, the AI thread is added (`AI Flow · Buck · astra`), then the run folder. The dashboard header shows the full label, then the board file, the lane (KiCad, Fusion or Altium), `AI thread: <name>` (the thread that last drove the run, recorded in run.json as `lastAiThread` on every command) and the run folder. A run whose label changes (a board attached, a requirements title added) has its tab renamed in place.++**One tab per run, one run per thread on screen.** Every AI Flow tab is in the registry `~/.adom/aiflow/dashboards.json` (`$AIFLOW_HOME/dashboards.json` when set): name, run, board, lane, AI thread, port, URL, opened, last active, finished. Before a run gets a new tab:+- When your thread already has an AI Flow tab for another run that is not finished, no tab opens. The warning names that tab and gives three choices: continue that run (`--run <its dir>`); show this run in that tab instead (`timeline open --reuse-tab "<name>"`, which renames it in place); or open a second tab with a reason the human would accept (`timeline open --new-tab --why "<why two boards at once>"`, recorded in the registry). Do not open a second tab without a reason.+- When that other run is finished or delivered (or its server is gone and it has been idle 12 h), its tab is reused for the new run automatically, renamed in the same pane, and its server stopped. The command says so.+- When another thread has an AI Flow tab for the same board (same original board or same label), the warning names the tab and the thread: coordinate with that thread or continue its run. This run gets its own tab only with `--new-tab --why`.+- A tab from an older release (`AI Flow · <8 hex characters>`), or this run's URL under another name, is replaced in place: the new name opens in the same pane, then the old tab is removed. Only tabs proven to be this run's (its recorded name or its URL) are touched.++None of this changes a board gate: a run without a tab works exactly the same, and a dashboard failure still prints `Dashboard unavailable`.++**List and close.** `adom-aiflow timeline list` shows every AI Flow tab with its board, lane, thread, run, step, finished or not, server alive or stopped and last activity, and flags AI Flow tabs missing from the registry as unknown. `adom-aiflow --ai-thread <you> timeline close --finished` closes finished runs' tabs; `--stale` closes unfinished runs whose server is gone and that idled 12 h; `--run <dir>` and `--name "AI Flow · ..."` close one. Close only removes tabs named `AI Flow · ...` and stops a run's timeline server only after the server proves it is that run's (its `/health` run id and process command line). Close your finished runs' tabs when you hand the board over.  After a live-channel upgrade, restart only the verified server for this run, preserving the tab and its URL. Inspect `/health` for the active version and `liveChannel: sse`; an installed new binary does not update a running old server. 
bin/adom-aiflow
⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/dashboard.rs+635−142
@@ -1,14 +1,26 @@ //! Engine-independent Hydrogen dashboard lifecycle. Never touches board acceptance.+//!+//! One tab per run, named for what the run is building: `AI Flow · <label>` (sequence::label). Every+//! AI Flow tab is recorded in the registry (`$AIFLOW_HOME/dashboards.json`, else+//! `~/.adom/aiflow/dashboards.json`) with its run, board, lane, AI thread and server, so a thread that+//! is about to open a second tab is challenged instead, a finished run's tab is reused in place, and+//! `timeline list` / `timeline close` see every AI Flow tab. Hydrogen has no rename verb: a rename is+//! open-or-refresh of the new name into the old tab's pane, then remove-tab of the old name. use serde_json::{json, Value}; use std::{     fs,     io::{Read, Write},     net::{SocketAddr, TcpStream},-    path::Path,+    path::{Path, PathBuf},     process::{Command, Stdio},     time::{Duration, Instant}, }; +/// Every AI Flow tab name starts with this.+pub const PREFIX: &str = "AI Flow \u{b7} ";+/// A run whose server is gone and that has been idle this long gives its tab up for reuse.+const IDLE_REUSE_SECS: u64 = 12 * 3600;+ pub fn run_id(dir: &Path) -> String {     let path = fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());     let mut hash = 0xcbf29ce484222325u64;@@ -63,6 +75,10 @@ fn command(args: &[&str], seconds: u64) -> Result<Value, String> { }  fn identity(port: u16) -> Option<String> {+    get_json(port, "/dashboard.json")?["runId"].as_str().map(str::to_owned)+}++fn get_json(port: u16, path: &str) -> Option<Value> {     let addr: SocketAddr = ([127, 0, 0, 1], port).into();     let mut stream = TcpStream::connect_timeout(&addr, Duration::from_millis(100)).ok()?;     stream@@ -72,40 +88,13 @@ fn identity(port: u16) -> Option<String> {         .set_write_timeout(Some(Duration::from_millis(200)))         .ok()?;     stream-        .write_all(b"GET /dashboard.json HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")+        .write_all(format!("GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n").as_bytes())         .ok()?;     let mut bytes = Vec::new();     stream.take(8192).read_to_end(&mut bytes).ok()?;     let body = String::from_utf8(bytes).ok()?;     let (_, json) = body.split_once("\r\n\r\n")?;-    serde_json::from_str::<Value>(json).ok()?["runId"]-        .as_str()-        .map(str::to_owned)-}--pub fn has_tab(value: &Value, name: &str, url: &str) -> bool {-    match value {-        Value::Object(o) => {-            if o.get("panelState")-                .map(|s| s["displayName"] == name && s["url"] == url)-                .unwrap_or(false)-            {-                return true;-            }-            o.values().any(|v| has_tab(v, name, url))-        }-        Value::Array(a) => a.iter().any(|v| has_tab(v, name, url)),-        _ => false,-    }-}--fn has_named_tab(value: &Value, name: &str) -> bool {-    match value {-        Value::Object(o) => o.get("panelState").map(|s| s["displayName"] == name).unwrap_or(false)-            || o.values().any(|v| has_named_tab(v, name)),-        Value::Array(a) => a.iter().any(|v| has_named_tab(v, name)),-        _ => false,-    }+    serde_json::from_str::<Value>(json).ok() }  pub fn proxy_url(template: Option<&str>, port: u16) -> String {@@ -115,61 +104,294 @@ pub fn proxy_url(template: Option<&str>, port: u16) -> String {         .unwrap_or_else(|| format!("http://127.0.0.1:{port}/")) } -struct Lock(std::path::PathBuf);+struct Lock(PathBuf); impl Drop for Lock {     fn drop(&mut self) {         let _ = fs::remove_file(&self.0);     } } -pub fn ensure(-    dir: &Path,-    ai_thread: &str,-    requested_port: Option<u16>,-    force_show: bool,-) -> Result<String, String> {-    let dir = fs::canonicalize(dir).map_err(|e| e.to_string())?;-    let tdir = dir.join("timeline");-    fs::create_dir_all(&tdir).map_err(|e| e.to_string())?;-    let lock = dir.join("dashboard.lock");-    if fs::metadata(&lock)+fn stale_lock(lock: &Path, secs: u64) -> bool {+    fs::metadata(lock)         .ok()         .and_then(|m| m.modified().ok())         .and_then(|t| t.elapsed().ok())-        .map(|d| d.as_secs() > 60)+        .map(|d| d.as_secs() > secs)         .unwrap_or(false)-    {-        let _ = fs::remove_file(&lock);+}++// ---------------------------------------------------------------- tabs++/// One Hydrogen workspace tab: its name, its pane and the URL it shows (when Hydrogen says).+#[derive(Clone, Debug, PartialEq)]+pub struct Tab {+    pub name: String,+    pub panel: Option<String>,+    pub url: Option<String>,+}++/// The tabs of `adom-cli hydrogen workspace tabs` (a flat list, or `{"tabs": [...]}`).+pub fn parse_tabs(v: &Value) -> Vec<Tab> {+    let list = v["tabs"].as_array().or(v.as_array()).or(v["data"]["tabs"].as_array());+    list.map(|a| {+        a.iter()+            .filter_map(|t| {+                let name = t["name"].as_str().or(t["displayName"].as_str()).or(t["panelState"]["displayName"].as_str())?;+                Some(Tab {+                    name: name.to_string(),+                    panel: t["panelId"].as_str().or(t["leafId"].as_str()).or(t["paneId"].as_str()).map(str::to_string),+                    url: t["url"].as_str().or(t["panelState"]["url"].as_str()).or(t["state"]["url"].as_str()).map(str::to_string),+                })+            })+            .collect()+    })+    .unwrap_or_default()+}++/// The tabs of `workspace get` (the layout tree: leaves with `id` and `tabs[].panelState`).+fn tree_tabs(v: &Value, leaf: Option<&str>, out: &mut Vec<Tab>) {+    match v {+        Value::Object(o) => {+            let here = if o.get("type").and_then(Value::as_str) == Some("leaf") { o.get("id").and_then(Value::as_str).or(leaf) } else { leaf };+            if let Some(s) = o.get("panelState") {+                if let Some(n) = s["displayName"].as_str() {+                    out.push(Tab { name: n.into(), panel: here.map(str::to_string), url: s["url"].as_str().map(str::to_string) });+                }+            }+            for (k, x) in o {+                if k != "panelState" {+                    tree_tabs(x, here, out);+                }+            }+        }+        Value::Array(a) => a.iter().for_each(|x| tree_tabs(x, leaf, out)),+        _ => {}     }-    fs::OpenOptions::new()-        .write(true)-        .create_new(true)-        .open(&lock)-        .map_err(|_| {-            "dashboard recovery is already running; next command will retry".to_string()-        })?;-    let _lock = Lock(lock);-    let id = run_id(&dir);-    fs::write(-        tdir.join("dashboard.json"),-        json!({"runId":id,"schemaVersion":1}).to_string(),+}++/// The live tabs. `workspace tabs` first; the layout tree fills a URL or pane the flat list lacks.+pub fn live_tabs() -> Result<Vec<Tab>, String> {+    let mut tabs = parse_tabs(&command(&["hydrogen", "workspace", "tabs"], 8)?);+    if tabs.iter().any(|t| t.name.starts_with(PREFIX) && (t.url.is_none() || t.panel.is_none())) {+        if let Ok(g) = command(&["hydrogen", "workspace", "get"], 8) {+            let mut tree = Vec::new();+            tree_tabs(&g, None, &mut tree);+            for t in tabs.iter_mut() {+                if let Some(x) = tree.iter().find(|x| x.name == t.name) {+                    if t.url.is_none() { t.url = x.url.clone(); }+                    if t.panel.is_none() { t.panel = x.panel.clone(); }+                }+            }+        }+    }+    Ok(tabs)+}++// ---------------------------------------------------------------- names++/// Plain words for a tab name: Hydrogen has duplicated tabs whose names carry punctuation such as+/// colons, plus signs or parentheses, so only letters, digits, spaces and `-_./#` stay. Longer than+/// `max` characters: cut with "...".+pub fn clean(s: &str, max: usize) -> String {+    let mapped: String = s.chars().map(|c| if c.is_alphanumeric() || " -_./#".contains(c) { c } else { ' ' }).collect();+    let words = mapped.split_whitespace().collect::<Vec<_>>().join(" ");+    if words.chars().count() > max {+        format!("{}...", words.chars().take(max.saturating_sub(3)).collect::<String>().trim_end())+    } else {+        words+    }+}++/// The names this run may use, best first: `AI Flow · <label>`, then with the AI thread, then with+/// the run folder, then with both.+pub fn name_candidates(label: &str, thread: Option<&str>, run_folder: &str) -> Vec<String> {+    let base = format!("{PREFIX}{}", clean(label, 40));+    let mut v = vec![base.clone()];+    let t = thread.map(|t| clean(t, 24)).filter(|t| !t.is_empty());+    let f = clean(run_folder, 24);+    if let Some(t) = &t { v.push(format!("{base} \u{b7} {t}")); }+    if !f.is_empty() { v.push(format!("{base} \u{b7} {f}")); }+    if let (Some(t), false) = (&t, f.is_empty()) { v.push(format!("{base} \u{b7} {t} \u{b7} {f}")); }+    v+}++/// The first candidate no other run's tab uses. A tab already named with one of the candidates+/// keeps its name (no churn when the collision that caused it goes away).+pub fn choose_name(cands: &[String], current: Option<&str>, taken: &dyn Fn(&str) -> bool) -> String {+    if let Some(c) = current.filter(|c| cands.iter().any(|x| x == c) && !taken(c)) {+        return c.to_string();+    }+    cands.iter().find(|c| !taken(c)).cloned().unwrap_or_else(|| cands[cands.len() - 1].clone())+}++// ---------------------------------------------------------------- registry++/// `$AIFLOW_HOME/dashboards.json`, else `~/.adom/aiflow/dashboards.json`.+pub fn registry_path() -> PathBuf {+    match std::env::var_os("AIFLOW_HOME").filter(|h| !h.is_empty()) {+        Some(h) => PathBuf::from(h).join("dashboards.json"),+        None => PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".adom/aiflow/dashboards.json"),+    }+}++fn lock_registry(path: &Path) -> Result<Lock, String> {+    if let Some(d) = path.parent() { fs::create_dir_all(d).map_err(|e| e.to_string())?; }+    let lock = path.with_extension("lock");+    if stale_lock(&lock, 30) { let _ = fs::remove_file(&lock); }+    let start = Instant::now();+    loop {+        if fs::OpenOptions::new().write(true).create_new(true).open(&lock).is_ok() {+            return Ok(Lock(lock));+        }+        if start.elapsed() > Duration::from_secs(5) {+            return Err(format!("the dashboard registry {} is busy; the next command retries", path.display()));+        }+        std::thread::sleep(Duration::from_millis(50));+    }+}++pub fn load_registry(path: &Path) -> Vec<Value> {+    fs::read(path).ok().and_then(|b| serde_json::from_slice::<Value>(&b).ok()).and_then(|v| v["dashboards"].as_array().cloned()).unwrap_or_default()+}++fn save_registry(path: &Path, entries: &[Value]) -> Result<(), String> {+    if let Some(d) = path.parent() { fs::create_dir_all(d).map_err(|e| e.to_string())?; }+    let tmp = path.with_extension(format!("tmp{}", std::process::id()));+    fs::write(&tmp, serde_json::to_vec_pretty(&json!({"schemaVersion": 1, "dashboards": entries})).unwrap()).map_err(|e| e.to_string())?;+    fs::rename(&tmp, path).map_err(|e| e.to_string())+}++/// What a run is, from its run.json: the registry's view of it.+#[derive(Clone, Debug)]+pub struct Facts {+    pub run_id: String,+    pub run_dir: String,+    pub run_folder: String,+    pub label: String,+    pub board: Option<String>,+    pub board_path: Option<String>,+    pub lane: String,+    pub thread: Option<String>,+    pub step: Option<String>,+    pub finished: bool,+}++pub fn facts(dir: &Path) -> Option<Facts> {+    let dir = fs::canonicalize(dir).ok()?;+    let r = aiflow_run::Run::open(&dir)?;+    let d = &r.data;+    Some(Facts {+        run_id: run_id(&dir),+        run_dir: dir.display().to_string(),+        run_folder: dir.file_name().and_then(|n| n.to_str()).unwrap_or("").to_string(),+        label: crate::sequence::label(&dir, d),+        board: crate::sequence::board_file(d),+        board_path: crate::sequence::original_board(d),+        lane: crate::sequence::lane_name(&dir, d).to_string(),+        thread: crate::sequence::driver_thread(d),+        step: d["currentStep"].as_str().map(str::to_string),+        finished: crate::sequence::finished(d),+    })+}++/// Bring an entry's run facts up to date from its run.json (label, board, step, finished, thread).+fn refresh_entry(e: &mut Value) {+    if let Some(f) = e["runDir"].as_str().and_then(|d| facts(Path::new(d))) {+        e["label"] = json!(f.label);+        e["board"] = json!(f.board);+        e["boardPath"] = json!(f.board_path);+        e["lane"] = json!(f.lane);+        e["step"] = json!(f.step);+        e["finished"] = json!(f.finished);+        if let Some(t) = f.thread { e["aiThread"] = json!(t); }+    }+}++fn run_gone(e: &Value) -> bool {+    e["runDir"].as_str().map(|d| !Path::new(d).join("run.json").is_file()).unwrap_or(true)+}++/// Drop entries whose tab no longer exists or whose run directory is gone; returns their names.+fn prune(reg: &mut Vec<Value>, tabs: &[Tab]) -> Vec<String> {+    let mut gone = Vec::new();+    reg.retain(|e| {+        let name = e["name"].as_str().unwrap_or("");+        let keep = tabs.iter().any(|t| t.name == name) && !run_gone(e);+        if !keep { gone.push(name.to_string()); }+        keep+    });+    gone+}++fn idle_secs(e: &Value) -> u64 {+    let now = aiflow_run::parse(&aiflow_run::now()).unwrap_or(0);+    e["lastActive"].as_str().and_then(aiflow_run::parse).map(|t| now.saturating_sub(t)).unwrap_or(u64::MAX)+}++fn ago(e: &Value) -> String {+    let s = idle_secs(e);+    if s == u64::MAX { "never".into() } else if s < 90 { "just now".into() } else if s < 5400 { format!("{} min ago", s / 60) } else if s < 172800 { format!("{} h ago", s / 3600) } else { format!("{} days ago", s / 86400) }+}++fn server_alive(e: &Value) -> bool {+    match (e["port"].as_u64().and_then(|p| u16::try_from(p).ok()), e["runId"].as_str()) {+        (Some(p), Some(id)) => identity(p).as_deref() == Some(id),+        _ => false,+    }+}++/// A run that gives its tab up: finished or delivered, or its server is gone and it idled 12 h.+fn reusable(e: &Value) -> bool {+    e["finished"] == true || (!server_alive(e) && idle_secs(e) > IDLE_REUSE_SECS)+}++fn describe(e: &Value) -> String {+    format!(+        "\"{}\" for run {} (board {}, step {}, last active {})",+        e["name"].as_str().unwrap_or(""),+        e["runDir"].as_str().unwrap_or("?"),+        e["board"].as_str().or(e["label"].as_str()).unwrap_or("not attached yet"),+        e["step"].as_str().unwrap_or("none yet"),+        ago(e)     )-    .map_err(|e| e.to_string())?;-    let config = dir.join("dashboard.json");-    let saved: Value = fs::read(&config)-        .ok()-        .and_then(|s| serde_json::from_slice(&s).ok())-        .unwrap_or(Value::Null);-    let saved_port = saved["port"].as_u64().and_then(|p| u16::try_from(p).ok());-    let mut port = None;-    for candidate in requested_port-        .into_iter()-        .chain(saved_port)-        .chain(8893..8993)-    {-        if identity(candidate).as_deref() == Some(&id) {-            port = Some(candidate);-            break;+}++fn latest<'a>(v: impl Iterator<Item = &'a Value>) -> Option<&'a Value> {+    v.max_by_key(|e| e["lastActive"].as_str().and_then(aiflow_run::parse).unwrap_or(0))+}++// ---------------------------------------------------------------- open++/// How the tab is asked for: automatic (every command) or `timeline open` with its choices.+#[derive(Default, Clone, Debug)]+pub struct OpenOpts {+    /// `timeline open` (the full challenge text every time, and a challenge is an error)+    pub explicit: bool,+    /// `--reuse-tab <name>`: show this run in that AI Flow tab, renamed in place+    pub reuse_tab: Option<String>,+    /// `--new-tab`: open a second tab for this thread, with `why`+    pub new_tab: bool,+    pub why: Option<String>,+}++/// What happened: the URL and tab name when the run is shown, notes for the AI (renames, reuse),+/// or the warning that challenged a redundant tab (no tab was opened).+#[derive(Default, Debug)]+pub struct Shown {+    pub url: Option<String>,+    pub name: Option<String>,+    pub notes: Vec<String>,+    pub warning: Option<String>,+}++fn quote_dir(dir: &str) -> String {+    if dir.chars().all(|c| c.is_alphanumeric() || "/._-".contains(c)) { dir.to_string() } else { format!("'{}'", dir.replace('\'', "'\\''")) }+}++fn start_server(dir: &Path, tdir: &Path, id: &str, requested: Option<u16>, saved: Option<u16>) -> Result<u16, String> {+    for candidate in requested.into_iter().chain(saved).chain(8893..8993) {+        if identity(candidate).as_deref() == Some(id) {+            return Ok(candidate);         }         if std::net::TcpListener::bind(("127.0.0.1", candidate)).is_err() {             continue;@@ -178,105 +400,376 @@ pub fn ensure(         let log = fs::File::create(tdir.join("serve.log")).map_err(|e| e.to_string())?;         let mut cmd = Command::new("setsid");         cmd.arg(exe)-            .args([-                "--run",-                &dir.to_string_lossy(),-                "timeline",-                "serve",-                "--port",-                &candidate.to_string(),-            ])+            .args(["--run", &dir.to_string_lossy(), "timeline", "serve", "--port", &candidate.to_string()])             .stdin(Stdio::null())             .stdout(log.try_clone().map_err(|e| e.to_string())?)             .stderr(log);         let mut child = cmd.spawn().map_err(|e| format!("dashboard server: {e}"))?;         for _ in 0..30 {-            if identity(candidate).as_deref() == Some(&id) {-                port = Some(candidate);-                break;+            if identity(candidate).as_deref() == Some(id) {+                return Ok(candidate);             }             if child.try_wait().ok().flatten().is_some() {                 break;             }             std::thread::sleep(Duration::from_millis(100));         }-        if port.is_some() {-            break;-        }         // Do not kill a process by port: it might belong to another run.-        return Err(format!(-            "dashboard server did not become ready on {candidate}; inspect {}",-            tdir.join("serve.log").display()-        ));+        return Err(format!("dashboard server did not become ready on {candidate}; inspect {}", tdir.join("serve.log").display()));+    }+    Err("no free dashboard port in 8893..8993".into())+}++/// Show this run in its own Hydrogen tab, or say why not. Never opens a second tab for a thread that+/// already has an unfinished run on screen, or for a board another thread is driving, without+/// `--new-tab --why`; reuses a finished run's tab in place; renames an old `AI Flow · <hex>` tab.+pub fn ensure(dir: &Path, ai_thread: &str, requested_port: Option<u16>, force_show: bool, opts: &OpenOpts) -> Result<Shown, String> {+    let dir = fs::canonicalize(dir).map_err(|e| e.to_string())?;+    let tdir = dir.join("timeline");+    fs::create_dir_all(&tdir).map_err(|e| e.to_string())?;+    let lock = dir.join("dashboard.lock");+    if stale_lock(&lock, 60) {+        let _ = fs::remove_file(&lock);+    }+    fs::OpenOptions::new()+        .write(true)+        .create_new(true)+        .open(&lock)+        .map_err(|_| "dashboard recovery is already running; next command will retry".to_string())?;+    let _lock = Lock(lock);+    let id = run_id(&dir);+    fs::write(tdir.join("dashboard.json"), json!({"runId":id,"schemaVersion":1}).to_string()).map_err(|e| e.to_string())?;+    let me = facts(&dir).ok_or("no run here")?;+    let thread: Option<String> = Some(ai_thread).filter(|t| !t.trim().is_empty() && *t != "adom-aiflow").map(str::to_string).or(me.thread.clone());+    let config_path = dir.join("dashboard.json");+    let mut saved: Value = fs::read(&config_path).ok().and_then(|s| serde_json::from_slice(&s).ok()).unwrap_or(json!({}));+    if !saved.is_object() { saved = json!({}); }++    let reg_path = registry_path();+    let _reg_lock = lock_registry(&reg_path)?;+    let tabs = live_tabs()?;+    let mut reg = load_registry(&reg_path);+    prune(&mut reg, &tabs);+    for e in reg.iter_mut() { refresh_entry(e); }+    let other_run = |n: &str, reg: &[Value]| reg.iter().any(|e| e["name"] == n && e["runId"] != json!(id));++    // this run's tab: its recorded name, its registry entry, or a tab showing its URL+    let saved_name = saved["name"].as_str().map(str::to_string);+    let saved_url = saved["url"].as_str().map(str::to_string);+    let owned: Option<Tab> = tabs.iter().find(|t| Some(&t.name) == saved_name.as_ref() && !other_run(&t.name, &reg))+        .or_else(|| reg.iter().find(|e| e["runId"] == json!(id)).and_then(|e| tabs.iter().find(|t| e["name"] == json!(t.name))))+        .or_else(|| saved_url.as_ref().and_then(|u| tabs.iter().find(|t| t.url.as_ref() == Some(u) && !other_run(&t.name, &reg))))+        .cloned();++    let mut shown = Shown::default();+    let mut replace: Option<Tab> = None; // another run's tab this run takes over in place+    let mut why: Option<String> = None;+    let me_dir = quote_dir(&me.run_dir);+    let me_thread = thread.as_deref().unwrap_or("<your thread>");+    if owned.is_none() {+        if let Some(name) = &opts.reuse_tab {+            let t = tabs.iter().find(|t| &t.name == name).ok_or_else(|| format!("no tab named \"{name}\" in Hydrogen; `adom-aiflow timeline list` shows the AI Flow tabs"))?;+            if !t.name.starts_with(PREFIX) {+                return Err(format!("\"{name}\" is not an AI Flow tab; --reuse-tab takes only a tab named \"{PREFIX}...\" (`timeline list`)"));+            }+            replace = Some(t.clone());+        } else if opts.new_tab {+            let w = opts.why.as_deref().map(str::trim).filter(|w| w.chars().count() >= 10).ok_or_else(|| {+                "--new-tab needs --why \"<why the human needs this run on screen beside the other>\" (10 characters or more); without a reason, continue the other run or use --reuse-tab".to_string()+            })?;+            why = Some(w.to_string());+        } else {+            let others: Vec<&Value> = reg.iter().filter(|e| e["runId"] != json!(id)).collect();+            let same_board = |e: &Value| {+                (me.board_path.is_some() && e["boardPath"].as_str() == me.board_path.as_deref())+                    || e["label"].as_str() == Some(me.label.as_str())+            };+            let driven = latest(others.iter().copied().filter(|e| e["aiThread"].as_str() != thread.as_deref() && e["finished"] != true && same_board(e)));+            let mine: Vec<&Value> = others.iter().copied().filter(|e| thread.is_some() && e["aiThread"].as_str() == thread.as_deref()).collect();+            let busy = latest(mine.iter().copied().filter(|e| !reusable(e)));+            let (blocker, full, short) = if let Some(e) = driven {+                let board = me.board.clone().unwrap_or_else(|| me.label.clone());+                let (n, u, other) = (e["name"].as_str().unwrap_or(""), e["aiThread"].as_str().unwrap_or("another thread"), e["runDir"].as_str().unwrap_or(""));+                (Some(n.to_string()),+                 format!("board {board} is already being driven by thread \"{u}\" in tab {}. Coordinate with that thread or continue its run (--run {}); two runs on one board overwrite each other's work, and one tab per run keeps the human's Hydrogen tidy. This run gets its own tab only with: adom-aiflow --run {me_dir} --ai-thread \"{me_thread}\" timeline open --new-tab --why \"<why a second run on this board>\"", describe(e), quote_dir(other)),+                 format!("no dashboard tab for this run: board {board} is being driven by thread \"{u}\" in \"{n}\" (run {}). Coordinate, or `timeline open --new-tab --why \"...\"`.", quote_dir(other)))+            } else if let Some(e) = busy {+                let (n, other) = (e["name"].as_str().unwrap_or(""), e["runDir"].as_str().unwrap_or(""));+                (Some(n.to_string()),+                 format!("no new AI Flow tab for this run: thread \"{me_thread}\" already has {}, and that run is not finished. One tab per run keeps the human's Hydrogen tidy. Choose one:\n  continue that run: pass --run {} to your commands\n  show this run in that tab instead: adom-aiflow --run {me_dir} --ai-thread \"{me_thread}\" timeline open --reuse-tab \"{n}\"\n  show both boards: adom-aiflow --run {me_dir} --ai-thread \"{me_thread}\" timeline open --new-tab --why \"<why the human needs both boards on screen>\"\nBoard commands and gates work the same without a tab.", describe(e), quote_dir(other)),+                 format!("this run has no dashboard tab: thread \"{me_thread}\" already shows unfinished run {} in \"{n}\". Continue that run, or `timeline open --reuse-tab \"{n}\"`, or `timeline open --new-tab --why \"...\"` (`timeline list` shows every AI Flow tab).", quote_dir(other)))+            } else {+                (None, String::new(), String::new())+            };+            if let Some(b) = blocker {+                let repeat = saved["challengedBy"].as_str() == Some(b.as_str()) && !opts.explicit;+                shown.warning = Some(if repeat { short } else { full });+                saved["challengedBy"] = json!(b);+                let _ = fs::write(&config_path, serde_json::to_vec_pretty(&saved).unwrap());+                save_registry(&reg_path, &reg)?;+                return Ok(shown);+            }+            // a finished (or long dead) run of this thread gives its tab to this run, in place+            if let Some(e) = latest(mine.iter().copied().filter(|e| reusable(e))) {+                let n = e["name"].as_str().unwrap_or("");+                if let Some(t) = tabs.iter().find(|t| t.name == n) {+                    shown.notes.push(format!("reused tab \"{n}\" of {} run {} for this run, renamed in place; one tab per run keeps the human's Hydrogen tidy.", if e["finished"] == true { "finished" } else { "idle" }, e["runDir"].as_str().unwrap_or("")));+                    replace = Some(t.clone());+                }+            }+        }     }-    let port = port.ok_or("no free dashboard port in 8893..8993")?;++    let saved_port = saved["port"].as_u64().and_then(|p| u16::try_from(p).ok());+    let port = start_server(&dir, &tdir, &id, requested_port, saved_port)?;     let template = std::env::var("VSCODE_PROXY_URI").ok();     let url = proxy_url(template.as_deref(), port);-    let name = format!("AI Flow · {}", &id[..8]);-    let workspace = command(&["hydrogen", "workspace", "get"], 8)?;-    if has_tab(&workspace, &name, &url) {-        if force_show {-            command(&["hydrogen", "workspace", "active-tab", "--name", &name], 8)?;++    let in_place = owned.clone().or(replace.clone());+    let cands = name_candidates(&me.label, thread.as_deref(), &me.run_folder);+    let keep = in_place.as_ref().map(|t| t.name.clone());+    let taken = |n: &str| tabs.iter().any(|t| t.name == n && Some(&t.name) != keep.as_ref()) || other_run(n, &reg);+    let name = choose_name(&cands, owned.as_ref().map(|t| t.name.as_str()), &taken);+    let quoted = format!("'{}'", dir.to_string_lossy().replace('\'', "'\\''"));+    let restart = format!("adom-aiflow timeline serve --run {quoted} --port {port}");+    let open = |panel: Option<&str>| -> Result<Value, String> {+        let mut args = vec!["hydrogen", "webview", "open-or-refresh", "--name", &name, "--url", &url, "--ai-thread", ai_thread, "--reason", "AIFlow design progress", "--restart", &restart];+        if let Some(p) = panel { args.extend(["--panel-id", p]); }+        command(&args, 12)+    };+    match &in_place {+        Some(t) if t.name == name => {+            if t.url.as_deref() != Some(url.as_str()) {+                // A changed proxy/port is navigation of this run's existing tab, never creation.+                command(&["hydrogen", "webview", "navigate", &url, "--name", &name], 8)?;+            } else if force_show {+                command(&["hydrogen", "workspace", "active-tab", "--name", &name], 8)?;+            }+        }+        Some(t) => {+            // rename in place: the new name into the old tab's pane first (a pane's last tab must+            // never be removed first), then the old tab goes+            open(t.panel.as_deref())?;+            command(&["hydrogen", "workspace", "remove-tab", "--name", &t.name], 8)?;+            if owned.is_some() {+                shown.notes.push(format!("renamed this run's tab \"{}\" to \"{name}\" in the same pane.", t.name));+            } else if opts.reuse_tab.is_some() {+                shown.notes.push(format!("tab \"{}\" now shows this run as \"{name}\", in the same pane.", t.name));+            } else if let Some(n) = shown.notes.last_mut() {+                *n = n.replace("renamed in place", &format!("renamed \"{name}\" in place"));+            }+            // a finished run that gave its tab up has no use for its server either+            if let Some(old) = reg.iter().find(|e| e["name"] == json!(t.name) && e["runId"] != json!(id) && reusable(e)).cloned() {+                if let Some(pid) = stop_server(&old) { shown.notes.push(format!("stopped the finished run's timeline server (pid {pid}).")); }+            }+            reg.retain(|e| e["name"] != json!(t.name));+        }+        None => {+            open(None)?;+            if let Some(w) = &why {+                shown.notes.push(format!("opened another AI Flow tab \"{name}\" for thread \"{me_thread}\"; reason recorded: \"{w}\". Close it when done: adom-aiflow --run {me_dir} timeline close --run {me_dir}"));+            }         }-    } else if has_named_tab(&workspace, &name) {-        // A changed proxy/port is navigation of this run's existing tab, never creation.-        command(&["hydrogen", "webview", "navigate", &url, "--name", &name], 8)?;-    } else {-        // Restart is a shell command by the Hydrogen API contract: quote run path safely.-        let quoted = format!("'{}'", dir.to_string_lossy().replace('\'', "'\\''"));-        let restart = format!("adom-aiflow timeline serve --run {quoted} --port {port}");-        command(-            &[-                "hydrogen",-                "webview",-                "open-or-refresh",-                "--name",-                &name,-                "--url",-                &url,-                "--ai-thread",-                ai_thread,-                "--reason",-                "AIFlow design progress",-                "--restart",-                &restart,-            ],-            12,-        )?;     }-    fs::write(-        config,-        serde_json::to_vec_pretty(&json!({"runId":id,"port":port,"url":url,"name":name})).unwrap(),-    )-    .map_err(|e| e.to_string())?;-    Ok(url)++    let now = aiflow_run::now();+    let prev = reg.iter().position(|e| e["runId"] == json!(id));+    let opened_at = prev.and_then(|i| reg[i]["openedAt"].as_str().map(str::to_string)).filter(|_| in_place.as_ref().map(|t| t.name == name).unwrap_or(false)).unwrap_or_else(|| now.clone());+    let kept_why = prev.and_then(|i| reg[i]["why"].as_str().map(str::to_string));+    let server_pid = get_json(port, "/health").and_then(|h| h["pid"].as_u64());+    let entry = json!({+        "name": name, "runId": id, "runDir": me.run_dir, "label": me.label, "board": me.board, "boardPath": me.board_path,+        "lane": me.lane, "aiThread": thread, "port": port, "url": url, "serverPid": server_pid, "openedAt": opened_at,+        "lastActive": now, "finished": me.finished, "step": me.step, "why": why.or(kept_why),+    });+    reg.retain(|e| e["runId"] != json!(id) && e["name"] != json!(name));+    reg.push(entry);+    save_registry(&reg_path, &reg)?;+    fs::write(&config_path, serde_json::to_vec_pretty(&json!({"runId":id,"port":port,"url":url,"name":name,"label":me.label})).unwrap()).map_err(|e| e.to_string())?;+    shown.url = Some(url);+    shown.name = Some(name);+    Ok(shown)+}++/// After a command: the run's registry entry follows its step, finish, delivery and thread. No+/// Hydrogen call; nothing happens for a run without a tab.+pub fn touch(dir: &Path) {+    let Some(f) = facts(dir) else { return };+    let path = registry_path();+    if !path.is_file() { return; }+    let Ok(_l) = lock_registry(&path) else { return };+    let mut reg = load_registry(&path);+    let Some(e) = reg.iter_mut().find(|e| e["runId"] == json!(f.run_id)) else { return };+    refresh_entry(e);+    e["lastActive"] = json!(aiflow_run::now());+    let _ = save_registry(&path, &reg);+}++// ---------------------------------------------------------------- list and close++/// `timeline list`: every AI Flow tab in the registry, reconciled with Hydrogen's tabs, and every+/// tab that looks like AI Flow but is not in the registry.+pub fn list() -> Result<(String, Vec<String>), String> {+    let path = registry_path();+    let _l = lock_registry(&path)?;+    let mut reg = load_registry(&path);+    let tabs = live_tabs();+    let mut lines = Vec::new();+    let pruned = match &tabs { Ok(t) => prune(&mut reg, t), Err(_) => Vec::new() };+    for e in reg.iter_mut() { refresh_entry(e); }+    for e in &reg {+        lines.push(format!(+            "{}\n    board {} ({}), label {}, AI thread {}, run {}, step {}, {}, server {}, last active {}{}",+            e["name"].as_str().unwrap_or(""),+            e["board"].as_str().unwrap_or("not attached yet"),+            e["lane"].as_str().unwrap_or("?"),+            e["label"].as_str().unwrap_or(""),+            e["aiThread"].as_str().unwrap_or("unknown"),+            e["runDir"].as_str().unwrap_or(""),+            e["step"].as_str().unwrap_or("none yet"),+            if e["finished"] == true { "finished" } else { "not finished" },+            if server_alive(e) { format!("alive on {}", e["port"]) } else { "stopped".into() },+            ago(e),+            e["why"].as_str().map(|w| format!(", opened beside another run because: {w}")).unwrap_or_default()+        ));+    }+    let mut unknown = 0;+    if let Ok(t) = &tabs {+        for t in t.iter().filter(|t| t.name.starts_with(PREFIX) && !reg.iter().any(|e| e["name"] == json!(t.name))) {+            unknown += 1;+            lines.push(format!("{}\n    unknown: not in the registry (an older release or another home); `timeline close --name \"{}\"` closes it", t.name, t.name));+        }+    }+    save_registry(&path, &reg)?;+    let mut head = format!("{} AI Flow tab(s) in the registry, {unknown} unknown AI Flow tab(s)", reg.len());+    if let Err(e) = &tabs { head.push_str(&format!("; Hydrogen tabs unavailable ({e}), so the registry is shown unreconciled")); }+    if !pruned.is_empty() { head.push_str(&format!("; dropped {} closed tab(s) from the registry", pruned.len())); }+    Ok((head, lines))+}++/// Which AI Flow tabs `timeline close` closes.+pub enum Close {+    Run(PathBuf),+    Name(String),+    Finished,+    Stale,+}++/// Stop a run's timeline server only when the server proves it is that run's: /health answers with+/// the run's id and a pid whose command line is `timeline serve` of that run directory.+fn stop_server(e: &Value) -> Option<u64> {+    let port = e["port"].as_u64().and_then(|p| u16::try_from(p).ok())?;+    let h = get_json(port, "/health")?;+    if h["runId"] != e["runId"] { return None; }+    let pid = h["pid"].as_u64()?;+    let cmd = String::from_utf8_lossy(&fs::read(format!("/proc/{pid}/cmdline")).ok()?).replace('\0', " ");+    let dir = e["runDir"].as_str()?;+    if !(cmd.contains("timeline") && cmd.contains("serve") && cmd.contains(dir)) { return None; }+    Command::new("kill").args(["-TERM", &pid.to_string()]).stdout(Stdio::null()).stderr(Stdio::null()).status().ok()?.success().then_some(pid)+}++/// `timeline close`: remove only AI Flow tabs (named `AI Flow · ...`), and stop their verified+/// servers. Returns what was closed, what was stopped and what was left alone.+pub fn close(sel: &Close) -> Result<(Vec<String>, Vec<String>), String> {+    if let Close::Name(n) = sel {+        if !n.starts_with(PREFIX) {+            return Err(format!("\"{n}\" is not an AI Flow tab; timeline close only closes tabs named \"{PREFIX}...\""));+        }+    }+    let path = registry_path();+    let _l = lock_registry(&path)?;+    let tabs = live_tabs()?;+    let mut reg = load_registry(&path);+    for e in reg.iter_mut() { refresh_entry(e); }+    let mut targets: Vec<Value> = match sel {+        Close::Run(d) => {+            let id = run_id(d);+            let mut v: Vec<Value> = reg.iter().filter(|e| e["runId"] == json!(id)).cloned().collect();+            if v.is_empty() {+                // a tab this run recorded before the registry existed+                let cfg: Value = fs::read(d.join("dashboard.json")).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or(Value::Null);+                if let Some(n) = cfg["name"].as_str() {+                    let dir = fs::canonicalize(d).unwrap_or_else(|_| d.clone());+                    v.push(json!({"name": n, "runId": id, "runDir": dir.display().to_string(), "port": cfg["port"]}));+                }+            }+            v+        }+        Close::Name(n) => {+            let v: Vec<Value> = reg.iter().filter(|e| e["name"] == json!(n)).cloned().collect();+            if v.is_empty() { vec![json!({"name": n})] } else { v }+        }+        Close::Finished => reg.iter().filter(|e| e["finished"] == true).cloned().collect(),+        Close::Stale => reg.iter().filter(|e| e["finished"] != true && !server_alive(e) && idle_secs(e) > IDLE_REUSE_SECS).cloned().collect(),+    };+    targets.retain(|e| e["name"].as_str().map(|n| n.starts_with(PREFIX)).unwrap_or(false));+    let mut closed = Vec::new();+    let mut stopped = Vec::new();+    for e in &targets {+        let n = e["name"].as_str().unwrap_or("");+        if tabs.iter().any(|t| t.name == n) {+            command(&["hydrogen", "workspace", "remove-tab", "--name", n], 8)?;+            closed.push(n.to_string());+        }+        if e["runId"].is_string() {+            if let Some(pid) = stop_server(e) { stopped.push(format!("{} (pid {pid}, port {})", e["runDir"].as_str().unwrap_or(""), e["port"])); }+        }+        reg.retain(|x| x["name"] != json!(n));+    }+    save_registry(&path, &reg)?;+    Ok((closed, stopped)) }  #[cfg(test)] mod tests {     use super::*;     #[test]-    fn tab_requires_run_name_and_url() {-        let ws = json!({"root":{"first":{"tabs":[{"panelState":{"displayName":"one","url":"http://one/"}}]}}});-        assert!(has_tab(&ws, "one", "http://one/"));-        assert!(!has_tab(&ws, "two", "http://one/"));-        assert!(!has_tab(&ws, "one", "http://other/"));-        assert!(!has_tab(&Value::Null, "one", "http://one/"));+    fn tabs_parse_from_the_flat_list_and_the_tree() {+        let flat = json!({"tabs":[{"name":"AI Flow \u{b7} Buck","panelId":"p1","url":"http://a/"},{"name":"Visual Studio Code","panelId":"p0"}]});+        let t = parse_tabs(&flat);+        assert_eq!(t[0], Tab { name: "AI Flow \u{b7} Buck".into(), panel: Some("p1".into()), url: Some("http://a/".into()) });+        assert_eq!(t[1].url, None);+        let tree = json!({"root":{"type":"split","children":[{"type":"leaf","id":"left","tabs":[{"panelState":{"displayName":"one","url":"http://one/"}}]}]}});+        let mut out = Vec::new();+        tree_tabs(&tree, None, &mut out);+        assert_eq!(out, vec![Tab { name: "one".into(), panel: Some("left".into()), url: Some("http://one/".into()) }]);+        assert!(parse_tabs(&Value::Null).is_empty());+    }+    #[test]+    fn names_are_plain_short_and_collide_into_the_thread() {+        assert_eq!(clean("Buck (12 V: 5 V) + LED", 40), "Buck 12 V 5 V LED");+        let long = clean("A very long board name that goes on and on past forty characters", 40);+        assert!(long.ends_with("...") && long.chars().count() <= 40, "{long}");+        let c = name_candidates("Buck 12V", Some("astra"), "run-7");+        assert_eq!(c[0], "AI Flow \u{b7} Buck 12V");+        assert_eq!(c[1], "AI Flow \u{b7} Buck 12V \u{b7} astra");+        assert_eq!(c[2], "AI Flow \u{b7} Buck 12V \u{b7} run-7");+        let taken = |n: &str| n == c[0];+        assert_eq!(choose_name(&c, None, &taken), c[1]);+        let taken2 = |n: &str| n == c[0] || n == c[1];+        assert_eq!(choose_name(&c, None, &taken2), c[2]);+        // a tab already on a fallback name keeps it; an old hex name is not a candidate+        assert_eq!(choose_name(&c, Some(&c[1]), &|_| false), c[1]);+        assert_eq!(choose_name(&c, Some("AI Flow \u{b7} 0123abcd"), &|_| false), c[0]);+        assert_eq!(name_candidates("x", None, "r").len(), 2);     }     #[test]     fn proxy_is_port_specific() {-        assert_eq!(-            proxy_url(Some("http://{{port}}.localhost:1/"), 8894),-            "http://8894.localhost:1/"-        );-        assert_eq!(-            proxy_url(Some("http://unrelated/"), 8894),-            "http://127.0.0.1:8894/"-        );+        assert_eq!(proxy_url(Some("http://{{port}}.localhost:1/"), 8894), "http://8894.localhost:1/");+        assert_eq!(proxy_url(Some("http://unrelated/"), 8894), "http://127.0.0.1:8894/");     }     #[test]     fn different_runs_have_different_ids() {         assert_ne!(run_id(Path::new("run-a")), run_id(Path::new("run-b")));         assert_eq!(run_id(Path::new("run-a")), run_id(Path::new("run-a")));     }+    #[test]+    fn reuse_needs_a_finished_or_long_idle_run() {+        let now = aiflow_run::now();+        assert!(reusable(&json!({"finished": true, "lastActive": now})));+        // no server on port 1 and idle for years: reusable; idle for a moment: not+        assert!(reusable(&json!({"finished": false, "port": 1, "runId": "x", "lastActive": "2020-01-01T00:00:00Z"})));+        assert!(!reusable(&json!({"finished": false, "port": 1, "runId": "x", "lastActive": now})));+        assert!(describe(&json!({"name": "AI Flow \u{b7} B", "runDir": "/r", "lastActive": now})).contains("just now"));+    } }
crates/adom-aiflow/src/main.rs+49−9
@@ -124,7 +124,17 @@ enum Cmd {     /// `outline round`: the rounded-corners step. Every sharp outside corner of the board outline becomes an arc of spec.outline.cornerRadius (default 1.0 mm): live in Fusion (MITER at each corner of the dimension layer), checked on the board after; `outline check` only reports. finish refuses a sharp corner.     Outline { #[arg(value_parser = ["round", "check"])] action: String, #[arg(long)] radius: Option<f64> },     /// `timeline open`: the flow's timeline dashboard in a Hydrogen webview (the right pane): every required step in order, the current one, how many to go, a check on each done step and an arc for every return. `timeline serve` serves <run>/timeline/ (open starts it when needed); `timeline write` refreshes timeline.json (every `step` does).-    Timeline { #[arg(value_parser = ["open", "serve", "write", "present"])] action: String, #[arg(long, default_value_t = 8893)] port: u16, #[arg(long)] json: Option<String> },+    /// `timeline list`: every AI Flow tab (registry ~/.adom/aiflow/dashboards.json reconciled with Hydrogen): name, board, lane, AI thread, run, step, finished, server, last active; AI Flow tabs not in the registry show as unknown.+    /// `timeline close --run R | --name N | --finished | --stale`: remove only AI Flow tabs and stop their own verified servers.+    /// One tab per run: `open --reuse-tab "<name>"` shows this run in another run's AI Flow tab (renamed in place); `open --new-tab --why "<reason>"` opens a second tab for the same thread.+    Timeline { #[arg(value_parser = ["open", "serve", "write", "present", "list", "close"])] action: String, #[arg(long, default_value_t = 8893)] port: u16, #[arg(long)] json: Option<String>,+    /// open: show this run in that AI Flow tab instead of a new one (the tab is renamed in place)+    #[arg(long)] reuse_tab: Option<String>, /// open: a second AI Flow tab for this thread (needs --why)+    #[arg(long)] new_tab: bool, /// open --new-tab: why the human needs this run on screen beside another (recorded in the registry)+    #[arg(long)] why: Option<String>, /// close: the AI Flow tab with this name+    #[arg(long)] name: Option<String>, /// close: every AI Flow tab whose run finished or was delivered+    #[arg(long)] finished: bool, /// close: every AI Flow tab whose run is not finished, has no live server and idled 12 h+    #[arg(long)] stale: bool },     /// `demo at <step|step@n|step@n+SECONDS|ISO>`: the run's dashboard as it stood at that moment, for filming     /// a finished build (the run itself is never written: the rewound state goes to <run>/demo/). `demo serve     /// --port N` serves <run>/demo/timeline/; every `demo at` updates an open page in place.@@ -293,6 +303,8 @@ fn refresh_dashboard(dir: &Path) {         let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();         let seq = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|v|v.as_str().map(str::to_owned)).collect()).unwrap_or_else(|| sequence::sequence_of(&flow, &sequence::sequence_key(dir, &r.data)));         sequence::write_timeline(dir, &sequence::timeline(dir, &r.data, &flow, &seq, &r.step_report()), TIMELINE_PAGE);+        // the registry follows the run's step, finish, delivery and thread (no Hydrogen call)+        if std::env::var("AIFLOW_DASHBOARD").as_deref() != Ok("off") { dashboard::touch(dir); }     } } @@ -300,8 +312,12 @@ fn ensure_dashboard(dir: &Path, ai_thread: &str, force_show: bool) {     if Run::open(dir).is_none() || std::env::var("AIFLOW_DASHBOARD").as_deref() == Ok("off") { return; }     let _ = DASHBOARD_ATTEMPTED.set(());     refresh_dashboard(dir);-    if let Err(e) = dashboard::ensure(dir, ai_thread, None, force_show) {-        eprintln!("Dashboard unavailable: {e}. Board results are unchanged. Retry `adom-aiflow timeline open --run {}` when Hydrogen is available.", dir.display());+    match dashboard::ensure(dir, ai_thread, None, force_show, &dashboard::OpenOpts::default()) {+        Ok(shown) => {+            for n in &shown.notes { eprintln!("Dashboard: {n}"); }+            if let Some(w) = &shown.warning { eprintln!("WARNING: {w}"); }+        }+        Err(e) => eprintln!("Dashboard unavailable: {e}. Board results are unchanged. Retry `adom-aiflow timeline open --run {}` when Hydrogen is available.", dir.display()),     } } @@ -2496,9 +2512,11 @@ fn main() {         ensure_dashboard(&dir, &ai_thread, matches!(cli.cmd, Cmd::Prompt { .. }));     }     // every command is a turn in the run's ledger; the gap before it is the AI's thinking time-    if !matches!(cli.cmd, Cmd::Start { .. } | Cmd::Intake { .. } | Cmd::Demo { .. }) && !matches!(&cli.cmd, Cmd::Timeline { action, .. } if action == "serve") {+    if !matches!(cli.cmd, Cmd::Start { .. } | Cmd::Intake { .. } | Cmd::Demo { .. }) && !matches!(&cli.cmd, Cmd::Timeline { action, .. } if action == "serve" || action == "list" || action == "close") {         if let Some(mut r) = Run::open(&dir) {             let idx = r.turn_begin(&command_line());+            // the thread that last drove the run: the dashboard header and the tab registry name it+            if let Some(t) = cli.ai_thread.as_deref().map(str::trim).filter(|t| !t.is_empty()) { r.data["lastAiThread"] = json!(t); r.data["lastAiThreadAt"] = json!(now()); }             let _ = r.save();             let _ = TURN.set((dir.clone(), idx));             // the over-budget nag: a run that sits in one step far past its expected minutes is most@@ -3078,7 +3096,7 @@ fn main() {             let stale = project_lib::stale(&parts, &bom_path, std::fs::read_to_string(&bom_path).ok().as_deref());             if !stale.is_empty() { err(&format!("the parts record is stale: {}", stale.join("; ")), &[format!("Run `parts` again (`parts --bom <current BOM>{}`), then `project-page` again.", parts["projectLib"].as_str().map(|d| format!(" --project-lib {d}")).unwrap_or_default())]); }             let (spec, req) = (spec_soft(&r), requirements_of(&r));-            let title = title.clone().or_else(|| req["title"].as_str().map(str::to_string)).or_else(|| spec["title"].as_str().map(str::to_string)).unwrap_or_else(|| sequence::title(&r.data));+            let title = title.clone().or_else(|| req["title"].as_str().map(str::to_string)).or_else(|| spec["title"].as_str().map(str::to_string)).unwrap_or_else(|| sequence::label(&dir, &r.data));             let brief = brief.as_deref().map(preboard::brief_text).or_else(|| req["summary"].as_str().or(req["description"].as_str()).map(str::to_string)).unwrap_or_else(|| title.clone());             let lane = sequence::lane(&dir, &r.data);             let cfg = json!({"run": dir.display().to_string(), "eda": lane, "page": page, "pageDir": page_dir, "title": title, "brief": brief, "summary": req["description"].as_str().unwrap_or(""), "parts": parts["parts"], "create": create, "push": !no_push, "private": private, "replaceReadme": replace_readme, "existing": existing, "version": "1.0.0",@@ -4709,7 +4727,24 @@ fn main() {             if !after.is_empty() { err(&format!("{} corner(s) still sharp after MITER: {}", after.len(), after.iter().map(|p| format!("({:.2}, {:.2})", p.0, p.1)).collect::<Vec<_>>().join(", ")), &["Look at the corner in Fusion: an outline drawn as one polyline may need the corner selected; run the MITER by hand there, then `outline check`.".into()]); }             ok(&format!("{done} board corner(s) rounded to {rad} mm; the outline has no sharp corner"), &["Copper keeps its edge clearance from the new arcs: refill the pours (`land pours` again) if a pour followed the old corner.".into()]);         }-        Cmd::Timeline { action, port, json: presentation } => {+        Cmd::Timeline { action, port, json: presentation, reuse_tab, new_tab, why, name, finished, stale } => {+            if action == "list" {+                let (head, lines) = dashboard::list().unwrap_or_else(|e| err(&e, &["`timeline list` reads Hydrogen's tabs; retry when Hydrogen is connected.".into()]));+                println!("OK: {head}");+                for l in &lines { println!("- {l}"); }+                println!("Hint: one tab per run keeps the human's Hydrogen tidy. Close finished runs' tabs with `adom-aiflow --ai-thread <you> timeline close --finished`; a thread reuses its own finished tab automatically.");+                return;+            }+            if action == "close" {+                thread(&cli);+                let picked = [cli.run.is_some(), name.is_some(), *finished, *stale].iter().filter(|x| **x).count();+                if picked != 1 { err("timeline close needs exactly one of --run <dir>, --name \"AI Flow · ...\", --finished, --stale", &["`timeline list` shows every AI Flow tab with its run, thread and whether it finished.".into()]); }+                let sel = if let Some(n) = name { dashboard::Close::Name(n.clone()) } else if *finished { dashboard::Close::Finished } else if *stale { dashboard::Close::Stale } else { dashboard::Close::Run(dir.clone()) };+                let (closed, stopped) = dashboard::close(&sel).unwrap_or_else(|e| err(&e, &["Only tabs named \"AI Flow · ...\" are ever closed; other tabs are the human's.".into()]));+                ok(&format!("closed {} AI Flow tab(s){}{}", closed.len(), if closed.is_empty() { String::new() } else { format!(": {}", closed.iter().map(|n| format!("\"{n}\"")).collect::<Vec<_>>().join(", ")) }, if stopped.is_empty() { String::new() } else { format!("; stopped timeline server(s) of {}", stopped.join(", ")) }),+                   &["Board runs and their files are unchanged; `timeline open --run <dir>` shows a run again.".into()]);+                return;+            }             let mut r = load_run(&cli);             let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();             {@@ -4728,15 +4763,20 @@ fn main() {                 "present" => {                     let caller=thread(&cli);                     let patch:Value=serde_json::from_str(presentation.as_deref().unwrap_or("{}")).unwrap_or_else(|e|err(&e.to_string(),&[]));-                    dashboard::ensure(&dir,&caller,None,false).unwrap_or_else(|e|err(&e,&[]));+                    let shown=dashboard::ensure(&dir,&caller,None,false,&dashboard::OpenOpts::default()).unwrap_or_else(|e|err(&e,&[]));+                    if let Some(w)=&shown.warning { err(w,&["The presentation goes to this run's own dashboard tab; settle which tab shows this run first.".into()]); }                     let config=read_json(&dir.join("dashboard.json").to_string_lossy());                     let reply=timeline_server::update(config["port"].as_u64().unwrap() as u16,&caller,&patch).unwrap_or_else(|e|err(&e,&["The existing server must support the current live channel. Restart only this run server after an upgrade.".into()]));                     ok(&format!("presentation updated in the existing dashboard: {}",reply["presentation"]), &["SSE updates the open page in place; engineering gates are unchanged.".into()]);                 }                 _ => {                     let ai_thread=cli.ai_thread.clone().or_else(||r.data["aiThread"].as_str().map(str::to_owned)).unwrap_or_else(||"adom-aiflow".into());-                    let url=dashboard::ensure(&dir,&ai_thread,Some(*port),true).unwrap_or_else(|e|err(&e,&["Dashboard failure does not change any board gate. Retry when Hydrogen is connected.".into()]));-                    ok(&format!("timeline open in Hydrogen: {url} (step {} of {})", tl["position"], tl["total"]), &["Keep it in the right pane beside the chat (the VS Code tab stays on the left; never widen panes). Every `step` updates it within a second.".into()]);+                    let opts=dashboard::OpenOpts{explicit:true,reuse_tab:reuse_tab.clone(),new_tab:*new_tab,why:why.clone()};+                    let shown=dashboard::ensure(&dir,&ai_thread,Some(*port),true,&opts).unwrap_or_else(|e|err(&e,&["Dashboard failure does not change any board gate. Retry when Hydrogen is connected.".into()]));+                    if let Some(w)=&shown.warning { err(w,&["`adom-aiflow timeline list` shows every AI Flow tab, its run, thread and whether it finished.".into()]); }+                    let mut hints: Vec<String> = shown.notes.clone();+                    hints.push("Keep it in the right pane beside the chat (the VS Code tab stays on the left; never widen panes). Every `step` updates it within a second.".into());+                    ok(&format!("timeline open in Hydrogen as \"{}\": {} (step {} of {})", shown.name.unwrap_or_default(), shown.url.unwrap_or_default(), tl["position"], tl["total"]), &hints);                 }             }         }
crates/adom-aiflow/src/sequence.rs+128−15
@@ -56,20 +56,76 @@ pub fn lane(dir: &Path, data: &Value) -> &'static str { }  /// The run's name for the timeline: the requirements' title, else the brief's first line.-pub fn title(data: &Value) -> String {-    let req = data["intake"]["requirements"].as_str().or(data["requirements"].as_str()).and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str::<Value>(&t).ok());-    if let Some(t) = req.as_ref().and_then(|v| v["title"].as_str().or(v["name"].as_str()).or(v["design"].as_str())) { return t.to_string(); }-    if let Some(b) = data["intake"]["brief"].as_str().and_then(|p| std::fs::read_to_string(p).ok()) {-        if let Some(l) = b.lines().map(str::trim).find(|l| !l.is_empty() && !l.starts_with('{')) {-            let l = l.trim_start_matches('#').trim();-            // the design, not the instructions after it: "Design a 12 V to 5 V buck..., and build it in Fusion"-            let l = l.split(", and ").next().unwrap_or(l).trim_end_matches('.');-            let l = l.strip_prefix("Design a ").or_else(|| l.strip_prefix("Design an ")).map(|x| { let mut c = x.chars(); c.next().map(|f| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or_default() }).unwrap_or(l.to_string());-            let l = l.as_str();-            return if l.chars().count() > 70 { format!("{}...", l.chars().take(67).collect::<String>()) } else { l.to_string() };-        }+fn usable(s: &str) -> Option<String> {+    let t = s.trim();+    let hex = t.len() >= 8 && t.chars().all(|c| c.is_ascii_hexdigit());+    if t.is_empty() || t.eq_ignore_ascii_case("adom-aiflow") || t.eq_ignore_ascii_case("aiflow") || hex { None } else { Some(t.to_string()) }+}++/// The board the run started from (never a board-N snapshot of the run): `sourceBoard`, else the+/// remote board (`fusion:Name`, `altium:C:/x/y.PcbDoc`), else the first board unless it is a snapshot.+pub fn original_board(data: &Value) -> Option<String> {+    if let Some(s) = data["sourceBoard"].as_str().filter(|s| !s.is_empty()) { return Some(s.to_string()); }+    if let Some(r) = data["remoteBoard"].as_str().filter(|s| !s.is_empty()) {+        let r = ["fusion:", "altium:", "kicad:"].iter().find_map(|p| r.strip_prefix(p)).unwrap_or(r);+        return Some(r.to_string());     }-    data["title"].as_str().unwrap_or("adom-aiflow").to_string()+    let b = data["boards"][0].as_str().or(data["board"].as_str())?;+    let stem = Path::new(b).file_stem()?.to_str()?;+    let snapshot = stem.strip_prefix("board-").map(|n| n.chars().all(|c| c.is_ascii_digit())).unwrap_or(false);+    if snapshot { None } else { Some(b.to_string()) }+}++/// The original board's file name (`buck.kicad_pcb`, `Buck-brd`), for the header.+pub fn board_file(data: &Value) -> Option<String> {+    let b = original_board(data)?;+    b.rsplit(['/', '\\']).next().map(str::to_string).filter(|s| !s.is_empty())+}++fn brief_line(data: &Value) -> Option<String> {+    let b = data["intake"]["brief"].as_str().and_then(|p| std::fs::read_to_string(p).ok())?;+    let l = b.lines().map(str::trim).find(|l| !l.is_empty() && !l.starts_with('{'))?;+    let l = l.trim_start_matches('#').trim();+    // the design, not the instructions after it: "Design a 12 V to 5 V buck..., and build it in Fusion"+    let l = l.split(", and ").next().unwrap_or(l).trim_end_matches('.');+    let l = l.strip_prefix("Design a ").or_else(|| l.strip_prefix("Design an ")).map(|x| { let mut c = x.chars(); c.next().map(|f| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or_default() }).unwrap_or(l.to_string());+    Some(if l.chars().count() > 70 { format!("{}...", l.chars().take(67).collect::<String>()) } else { l })+}++/// The run's label: what board or project it is, in this order: the requirements title, the spec+/// `title`, the project page (slug, plus its folder), the original board's file stem, the brief's+/// first line, the run directory's name. One function for the dashboard header, the tab name, the+/// registry and the project page title. Never "adom-aiflow" and never a hash.+pub fn label(dir: &Path, data: &Value) -> String {+    let read = |p: Option<&str>| p.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str::<Value>(&t).ok());+    let req = read(data["intake"]["requirements"].as_str().or(data["requirements"].as_str()));+    if let Some(t) = req.as_ref().and_then(|v| v["title"].as_str().or(v["name"].as_str()).or(v["design"].as_str())).and_then(usable) { return t; }+    if let Some(t) = read(data["spec"].as_str()).as_ref().and_then(|v| v["title"].as_str()).and_then(usable) { return t; }+    if let Some(page) = data["page"].as_str() {+        let slug = page.rsplit('/').next().unwrap_or(page);+        let full = match data["pageDir"].as_str().filter(|d| !d.is_empty() && *d != ".") { Some(d) => format!("{slug}/{d}"), None => slug.to_string() };+        if let Some(t) = usable(&full) { return t; }+    }+    if let Some(t) = original_board(data).and_then(|b| { let f = b.rsplit(['/', '\\']).next().unwrap_or(&b).to_string(); Path::new(&f).file_stem().and_then(|s| s.to_str()).map(str::to_string) }).and_then(|s| usable(&s)) { return t; }+    if let Some(t) = brief_line(data).and_then(|s| usable(&s)) { return t; }+    let canon = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());+    canon.file_name().and_then(|n| n.to_str()).and_then(usable).unwrap_or_else(|| "unnamed run".into())+}++/// The thread that last drove the run (`lastAiThread`, recorded on every command with --ai-thread),+/// else the thread that opened it.+pub fn driver_thread(data: &Value) -> Option<String> {+    data["lastAiThread"].as_str().or(data["aiThread"].as_str()).filter(|s| !s.is_empty() && *s != "adom-aiflow").map(str::to_string)+}++/// The lane's display name: KiCad, Fusion or Altium.+pub fn lane_name(dir: &Path, data: &Value) -> &'static str {+    match lane(dir, data) { "fusion" => "Fusion", "altium" => "Altium", _ => "KiCad" }+}++/// Finished or delivered: `finish` passed (clock.finishTime) or `deliver` ran (clock.deliveredTime).+pub fn finished(data: &Value) -> bool {+    !data["clock"]["finishTime"].is_null() || !data["clock"]["deliveredTime"].is_null() }  /// Which sequence a run follows: its lane, except a KiCad run that starts with no board (a board@@ -256,8 +312,13 @@ pub fn timeline(dir: &Path, data: &Value, flow: &Value, seq: &[String], minutes:     }     let idx = st.iter().position(|s| s.name == current).map(|i| i + 1).unwrap_or(st.iter().filter(|s| s.status == Status::Done).count() + if current.is_empty() && !st.is_empty() { 1 } else { 0 }).min(st.len());     json!({-        "title": title(data),-        "engine": match lane(dir, data) { "fusion" => "Fusion", "altium" => "Altium", _ => "KiCad" },+        "title": label(dir, data),+        "label": label(dir, data),+        "engine": lane_name(dir, data),+        "board": board_file(data),+        "aiThread": driver_thread(data),+        "runFolder": std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()).file_name().and_then(|n| n.to_str()).unwrap_or(""),+        "runDir": std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()).display().to_string(),         "finish": flow["finishLine"][lane(dir, data)].as_str().unwrap_or("Gerbers, BOM and CPL exported"),         "updated": aiflow_run::now(),         "current": if current.is_empty() { Value::Null } else { json!(current) },@@ -428,3 +489,55 @@ mod sequence_key_tests {         assert!(!s.iter().any(|x| x == "components" || x == "models"), "Altium's library step makes the parts; no KiCad components/models steps");     } }++#[cfg(test)]+mod label_tests {+    use super::*;+    use serde_json::json;+    #[test]+    fn the_label_falls_back_in_order_and_is_never_the_tool_or_a_hash() {+        let root = std::env::temp_dir().join(format!("aiflow-label-{}", std::process::id()));+        let run = root.join("buck-run-3");+        std::fs::create_dir_all(&run).unwrap();+        let req = root.join("req.json");+        let spec = root.join("spec.json");+        let brief = root.join("brief.md");+        std::fs::write(&req, r#"{"title": "Buck 12 V to 5 V"}"#).unwrap();+        std::fs::write(&spec, r#"{"title": "Spec title"}"#).unwrap();+        std::fs::write(&brief, "# Design a sensor carrier, and build it in KiCad\n").unwrap();+        let mut d = json!({"requirements": req, "spec": spec, "page": "john/buck-12v5v-molecule", "pageDir": "boards/rig",+            "sourceBoard": "/b/buck-main.kicad_pcb", "boards": [run.join("board-2.kicad_pcb")], "intake": {"brief": brief}, "title": "adom-aiflow"});+        assert_eq!(label(&run, &d), "Buck 12 V to 5 V");+        d["requirements"] = json!(null);+        assert_eq!(label(&run, &d), "Spec title");+        d["spec"] = json!(null);+        assert_eq!(label(&run, &d), "buck-12v5v-molecule/boards/rig");+        d["pageDir"] = json!(null);+        assert_eq!(label(&run, &d), "buck-12v5v-molecule");+        d["page"] = json!(null);+        // the ORIGINAL board's stem, not the run's board-N snapshot+        assert_eq!(label(&run, &d), "buck-main");+        assert_eq!(board_file(&d).as_deref(), Some("buck-main.kicad_pcb"));+        d["sourceBoard"] = json!(null);+        assert_eq!(original_board(&d), None, "a board-N snapshot is never the original");+        d["remoteBoard"] = json!("fusion:Buck-brd");+        assert_eq!(label(&run, &d), "Buck-brd");+        d["remoteBoard"] = json!("altium:C:/x/power.PcbDoc");+        assert_eq!(label(&run, &d), "power");+        d["remoteBoard"] = json!(null);+        assert_eq!(label(&run, &d), "Sensor carrier");+        d["intake"] = json!(null);+        assert_eq!(label(&run, &d), "buck-run-3");+        // a requirements title that is the tool's name or a hash is skipped+        std::fs::write(&req, r#"{"title": "adom-aiflow"}"#).unwrap();+        d["requirements"] = json!(req);+        assert_eq!(label(&run, &d), "buck-run-3");+        std::fs::write(&req, r#"{"title": "0123abcd4567ef89"}"#).unwrap();+        assert_eq!(label(&run, &d), "buck-run-3");+        assert_eq!(driver_thread(&json!({"aiThread": "astra", "lastAiThread": "nova"})).as_deref(), Some("nova"));+        assert_eq!(driver_thread(&json!({"aiThread": "adom-aiflow"})), None);+        assert!(finished(&json!({"clock": {"finishTime": null, "deliveredTime": "2026-01-01T00:00:00Z"}})));+        assert!(!finished(&json!({"clock": {"finishTime": null, "deliveredTime": null}})));+        let _ = std::fs::remove_dir_all(root);+    }+}
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.19",+  "version": "0.2.20",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.19",+  "version": "0.2.20",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
skills/adom-aiflow/SKILL.md+13−1
@@ -537,7 +537,19 @@ After adopting an already placed KiCad board, use `place verify` during the plac  The main timeline uses the app-creator SSE channel: CLI workflow commands update durable run files and the server pushes state to the existing page. `timeline present --json '{"caption":"Current passed; thermal open","image":"review.png"}' --run <run> --ai-thread <name>` sends presentation changes through the server's HTTP control endpoint; it never changes engineering gates. Images must already exist in `<run>/timeline/`. Reconnect replays current state. Static exports and older servers retain polling fallback. -Do not reopen the webview to show progress. `timeline open` activates the existing named tab; a changed proxy URL navigates that tab. Repeated status/step/artifact calls reuse it. Keep one tab for this run. Tests must set `AIFLOW_DASHBOARD=off` unless Hydrogen is explicitly mocked; never let a temporary fixture open the user's workspace.+Do not reopen the webview to show progress. `timeline open` activates the existing named tab; a changed proxy URL navigates that tab. Repeated status/step/artifact calls reuse it. Tests must set `AIFLOW_DASHBOARD=off` unless Hydrogen is explicitly mocked (a test-owned `adom-cli` on PATH and its own `AIFLOW_HOME`); never let a temporary fixture open the user's workspace.++**Names.** The tab is `AI Flow · <label>`. The label says what the run is building, from the first of: the requirements title, the spec `title`, the project page (slug, plus its folder), the original board's file stem (the file the run started from, never a `board-N` snapshot), the brief's first line, the run folder's name. It is cut to about 40 characters with `...` and keeps only plain characters. When another run's tab already has that name, the AI thread is added (`AI Flow · Buck · astra`), then the run folder. The dashboard header shows the full label, then the board file, the lane (KiCad, Fusion or Altium), `AI thread: <name>` (the thread that last drove the run, recorded in run.json as `lastAiThread` on every command) and the run folder. A run whose label changes (a board attached, a requirements title added) has its tab renamed in place.++**One tab per run, one run per thread on screen.** Every AI Flow tab is in the registry `~/.adom/aiflow/dashboards.json` (`$AIFLOW_HOME/dashboards.json` when set): name, run, board, lane, AI thread, port, URL, opened, last active, finished. Before a run gets a new tab:+- When your thread already has an AI Flow tab for another run that is not finished, no tab opens. The warning names that tab and gives three choices: continue that run (`--run <its dir>`); show this run in that tab instead (`timeline open --reuse-tab "<name>"`, which renames it in place); or open a second tab with a reason the human would accept (`timeline open --new-tab --why "<why two boards at once>"`, recorded in the registry). Do not open a second tab without a reason.+- When that other run is finished or delivered (or its server is gone and it has been idle 12 h), its tab is reused for the new run automatically, renamed in the same pane, and its server stopped. The command says so.+- When another thread has an AI Flow tab for the same board (same original board or same label), the warning names the tab and the thread: coordinate with that thread or continue its run. This run gets its own tab only with `--new-tab --why`.+- A tab from an older release (`AI Flow · <8 hex characters>`), or this run's URL under another name, is replaced in place: the new name opens in the same pane, then the old tab is removed. Only tabs proven to be this run's (its recorded name or its URL) are touched.++None of this changes a board gate: a run without a tab works exactly the same, and a dashboard failure still prints `Dashboard unavailable`.++**List and close.** `adom-aiflow timeline list` shows every AI Flow tab with its board, lane, thread, run, step, finished or not, server alive or stopped and last activity, and flags AI Flow tabs missing from the registry as unknown. `adom-aiflow --ai-thread <you> timeline close --finished` closes finished runs' tabs; `--stale` closes unfinished runs whose server is gone and that idled 12 h; `--run <dir>` and `--name "AI Flow · ..."` close one. Close only removes tabs named `AI Flow · ...` and stops a run's timeline server only after the server proves it is that run's (its `/health` run id and process command line). Close your finished runs' tabs when you hand the board over.  After a live-channel upgrade, restart only the verified server for this run, preserving the tab and its URL. Inspect `/health` for the active version and `liveChannel: sse`; an installed new binary does not update a running old server. 
tests/dashboard-workflow.py+144−37
@@ -1,53 +1,112 @@-"""Real CLI/server lifecycle with a fake Hydrogen API; no CAD or real tabs touched."""+"""Real CLI/server lifecycle with a fake Hydrogen API (a test-owned adom-cli on PATH); no CAD or real tabs touched.++Covers: default intake, reuse, closed-tab recovery, per-run server isolation, resume, stopped-server+recovery, SSE live channel and reconnect replay, run labels and tab names, the old hex-named tab+renamed in the same pane, the tab registry, the redundant-tab challenge (same thread, other thread),+finished-tab reuse, --new-tab --why, timeline list, timeline close, headless override, visible UI failure.+""" import json,os,pathlib,subprocess,sys,tempfile,time,urllib.request,urllib.error,signal binary=str(pathlib.Path(sys.argv[1]).resolve())+DOT='AI Flow · ' with tempfile.TemporaryDirectory(prefix='aiflow-dashboard-test-') as root:- p=pathlib.Path(root);bindir=p/'bin';bindir.mkdir();state=p/'hydrogen.json';state.write_text('{}');calls=p/'calls.jsonl'+ p=pathlib.Path(root);bindir=p/'bin';bindir.mkdir();state=p/'hydrogen.json';calls=p/'calls.jsonl';home=p/'home'+ # two panes; the human's own tab must never be touched+ state.write_text(json.dumps({'panes':{'left':[{'name':'Visual Studio Code','url':None}],'right':[{'name':'Human notes','url':'http://human/'}]}}))  mock=bindir/'adom-cli';mock.write_text('''#!/usr/bin/env python3 import sys,json,os,pathlib args=sys.argv[1:];p=pathlib.Path(os.environ['TEST_HYDROGEN']);d=json.loads(p.read_text()) with open(os.environ['TEST_CALLS'],'a')as f:f.write(json.dumps(args)+'\\n')-if args[:3]==['hydrogen','workspace','get']:print(json.dumps(d))+def opt(k):return args[args.index(k)+1] if k in args else None+def find(name):+ for pane,tabs in d['panes'].items():+  for t in tabs:+   if t['name']==name:return pane,t+ return None,None+def save():p.write_text(json.dumps(d))+if args[:3]==['hydrogen','workspace','tabs']:print(json.dumps({'tabs':[{'name':t['name'],'panelId':pane,'url':t['url']} for pane,tabs in d['panes'].items() for t in tabs]}))+elif args[:3]==['hydrogen','workspace','get']:print(json.dumps({'root':{'type':'split','children':[{'type':'leaf','id':pane,'tabs':[{'panelState':{'displayName':t['name'],'url':t['url']}} for t in tabs]} for pane,tabs in d['panes'].items()]}})) elif args[:3]==['hydrogen','webview','open-or-refresh']:- name=args[args.index('--name')+1];url=args[args.index('--url')+1]- d.setdefault('tabs',[]).append({'panelState':{'displayName':name,'url':url}});p.write_text(json.dumps(d));print(json.dumps({'ok':True,'action':'created'}))+ name=opt('--name');url=opt('--url');pane,t=find(name)+ if t:t['url']=url;save();print(json.dumps({'ok':True,'action':'refreshed'}))+ else:d['panes'].setdefault(opt('--panel-id') or 'right',[]).append({'name':name,'url':url});save();print(json.dumps({'ok':True,'action':'created'})) elif args[:3]==['hydrogen','webview','navigate']:- name=args[args.index('--name')+1]- for tab in d.get('tabs',[]):-  if tab['panelState']['displayName']==name:tab['panelState']['url']=args[3]- p.write_text(json.dumps(d));print(json.dumps({'ok':True}))+ pane,t=find(opt('--name'))+ if t:t['url']=args[3]+ save();print(json.dumps({'ok':True}))+elif args[:3]==['hydrogen','workspace','remove-tab']:+ pane,t=find(opt('--name'))+ if not t:print(json.dumps({'ok':False}));sys.exit(1)+ d['panes'][pane].remove(t);save();print(json.dumps({'ok':True})) else:print('{}') ''');mock.chmod(0o755)- env=dict(os.environ,PATH=str(bindir)+os.pathsep+os.environ['PATH'],TEST_HYDROGEN=str(state),TEST_CALLS=str(calls));env.pop('AIFLOW_DASHBOARD',None);env.pop('VSCODE_PROXY_URI',None)- runs=[p/'one',p/'two'];children=[]- def cmd(run,*args,extra=None):-  r=subprocess.run([binary,'--run',str(run),'--ai-thread','dashboard-test',*args],env=extra or env,capture_output=True,text=True,timeout=35)-  assert r.returncode==0,(r.stdout,r.stderr);return r- def opens():return sum('open-or-refresh'in json.loads(l)for l in calls.read_text().splitlines())+ env=dict(os.environ,PATH=str(bindir)+os.pathsep+os.environ['PATH'],TEST_HYDROGEN=str(state),TEST_CALLS=str(calls),AIFLOW_HOME=str(home));env.pop('AIFLOW_DASHBOARD',None);env.pop('VSCODE_PROXY_URI',None)+ runs={n:p/n for n in ['one','two','three','four']}+ def run(r,*args,thread='astra',extra=None,code=0):+  res=subprocess.run([binary,'--run',str(r),'--ai-thread',thread,*args],env=extra or env,capture_output=True,text=True,timeout=35)+  assert code is None or res.returncode==code,(args,res.returncode,res.stdout,res.stderr);return res+ def bare(*args,code=0):+  res=subprocess.run([binary,'--ai-thread','astra',*args],env=env,capture_output=True,text=True,timeout=35,cwd=root)+  assert code is None or res.returncode==code,(args,res.stdout,res.stderr);return res+ def log():return [json.loads(l) for l in calls.read_text().splitlines()]+ def opens():return sum(c[:3]==['hydrogen','webview','open-or-refresh'] for c in log())+ def tabs():return {t['name']:(pane,t['url']) for pane,ts in json.loads(state.read_text())['panes'].items() for t in ts}+ def flow_tabs():return {n:v for n,v in tabs().items() if n.startswith(DOT)}+ def cfg(r):return json.loads((r/'dashboard.json').read_text())+ def reg():return json.loads((home/'dashboards.json').read_text())['dashboards']+ def entry(r):return next(e for e in reg() if e['runDir']==str(r.resolve()))+ def edit(r,f):+  d=json.loads((r/'run.json').read_text());f(d);(r/'run.json').write_text(json.dumps(d))+ def alive(url):+  try:return json.load(urllib.request.urlopen(url+'dashboard.json',timeout=2))['runId']+  except Exception:return None  try:-  cmd(runs[0],'intake','--prompt-time','2026-10-09T10:00:00Z','--engine','codex');assert opens()==1-  a=json.loads((runs[0]/'dashboard.json').read_text());assert json.load(urllib.request.urlopen(a['url']+'dashboard.json'))['runId']==a['runId']-  cmd(runs[0],'status');assert opens()==1,'repeated command must reuse tab'-  drift=json.loads(state.read_text());drift['tabs'][0]['panelState']['url']='http://old-proxy/';state.write_text(json.dumps(drift));cmd(runs[0],'status');assert opens()==1,'proxy change must navigate the existing named tab'-  state.write_text('{}');cmd(runs[0],'status');assert opens()==2,'closed tab must reopen'-  cmd(runs[1],'intake','--prompt-time','2026-10-09T10:00:00Z','--engine','claude');b=json.loads((runs[1]/'dashboard.json').read_text());assert a['port']!=b['port'] and a['name']!=b['name'];assert opens()==3-  assert json.load(urllib.request.urlopen(a['url']+'dashboard.json'))['runId']==a['runId'],'second run stole first server'-  cmd(runs[0],'prompt','--text','resume');assert opens()==3,'resume must activate the existing tab without reopening'+  # --- default intake: one tab named for the run, never a hash or 'adom-aiflow'+  run(runs['one'],'intake','--prompt-time','2026-10-09T10:00:00Z','--engine','codex');assert opens()==1+  a=cfg(runs['one']);assert a['name']==DOT+'one',a;assert alive(a['url'])==a['runId']+  tl=json.loads((runs['one']/'timeline'/'timeline.json').read_text())+  assert tl['title']=='one' and tl['aiThread']=='astra' and tl['runFolder']=='one' and tl['engine']=='KiCad',tl+  assert 'adom-aiflow' not in json.dumps({k:tl[k] for k in ['title','label']})+  e=entry(runs['one']);assert e['aiThread']=='astra' and e['label']=='one' and e['finished'] is False and e['port']==a['port'],e+  run(runs['one'],'status');assert opens()==1,'repeated command must reuse tab'+  # --- proxy change navigates the named tab+  s=json.loads(state.read_text());s['panes']['right'][-1]['url']='http://old-proxy/';state.write_text(json.dumps(s))+  run(runs['one'],'status');assert opens()==1 and tabs()[DOT+'one'][1]==a['url'],'proxy change must navigate the existing named tab'+  # --- closed tab reopens+  s=json.loads(state.read_text());s['panes']['right']=[t for t in s['panes']['right'] if not t['name'].startswith(DOT)];state.write_text(json.dumps(s))+  run(runs['one'],'status');assert opens()==2,'closed tab must reopen'+  # --- the label follows the ORIGINAL board's stem; the tab is renamed in its pane+  edit(runs['one'],lambda d:(d.__setitem__('sourceBoard','/boards/buck-12v5v.kicad_pcb'),d.__setitem__('boards',[str(runs['one']/'board-3.kicad_pcb')])))+  run(runs['one'],'status');ft=flow_tabs();assert list(ft)==[DOT+'buck-12v5v'] and ft[DOT+'buck-12v5v'][0]=='right',ft+  tl=json.loads((runs['one']/'timeline'/'timeline.json').read_text());assert tl['title']=='buck-12v5v' and tl['board']=='buck-12v5v.kicad_pcb',tl+  # --- migration: a 0.2.19 tab "AI Flow · <hex>" in the left pane is replaced in the same pane+  hexname=DOT+a['runId'][:8]+  s=json.loads(state.read_text());s['panes']['right']=[t for t in s['panes']['right'] if not t['name'].startswith(DOT)];s['panes']['left'].append({'name':hexname,'url':a['url']});state.write_text(json.dumps(s))+  c=cfg(runs['one']);c['name']=hexname;(runs['one']/'dashboard.json').write_text(json.dumps(c))+  r=json.loads((home/'dashboards.json').read_text());r['dashboards']=[];(home/'dashboards.json').write_text(json.dumps(r))+  n0=len(log());run(runs['one'],'status');new=log()[n0:]+  o=[c for c in new if c[:3]==['hydrogen','webview','open-or-refresh']];rm=[c for c in new if c[:3]==['hydrogen','workspace','remove-tab']]+  assert len(o)==1 and o[0][o[0].index('--panel-id')+1]=='left' and o[0][o[0].index('--name')+1]==DOT+'buck-12v5v',new+  assert rm==[['hydrogen','workspace','remove-tab','--name',hexname]],rm+  assert flow_tabs()=={DOT+'buck-12v5v':('left',a['url'])},flow_tabs()+  assert cfg(runs['one'])['name']==DOT+'buck-12v5v' and entry(runs['one'])['name']==DOT+'buck-12v5v'+  a=cfg(runs['one'])+  # --- resume activates the existing tab without reopening+  count=opens();run(runs['one'],'prompt','--text','resume');assert opens()==count+  # --- stopped server is restarted behind the same tab   for line in subprocess.check_output(['ps','-eo','pid,args'],text=True).splitlines():-   if binary in line and str(runs[0]) in line and 'timeline serve' in line:os.kill(int(line.split()[0]),signal.SIGTERM)-  time.sleep(.2);cmd(runs[0],'status');assert opens()==3,'server restart should reuse the tab'-  assert json.load(urllib.request.urlopen(a['url']+'dashboard.json'))['runId']==a['runId']+   if binary in line and str(runs['one']) in line and 'timeline serve' in line:os.kill(int(line.split()[0]),signal.SIGTERM)+  time.sleep(.2);run(runs['one'],'status');assert opens()==count,'server restart should reuse the tab'+  assert alive(a['url'])==a['runId'] -  # CLI -> HTTP control -> SSE updates an existing view, and reconnect replays it.+  # --- CLI -> HTTP control -> SSE updates an existing view, and reconnect replays it   stream=urllib.request.urlopen(a['url']+'events',timeout=5)   assert stream.headers['X-Accel-Buffering']=='no'   def event(connection):    while True:     line=connection.readline().decode()     if line.startswith('data: '):return json.loads(line[6:])-  initial=event(stream);assert initial['timeline']['steps']-  count=opens();cmd(runs[0],'timeline','present','--json',json.dumps({'caption':'Live test update','title':'No extra tab'}));assert opens()==count-  # the command's own run-file writes can reach the watcher first; the presentation must follow+  initial=event(stream);assert initial['timeline']['steps'] and initial['timeline']['aiThread']=='astra'+  count=opens();run(runs['one'],'timeline','present','--json',json.dumps({'caption':'Live test update','title':'No extra tab'}));assert opens()==count   deadline=time.time()+5;received=event(stream)   while not (received.get('presentation') or {}).get('caption') and time.time()<deadline:received=event(stream)   assert received['presentation']['caption']=='Live test update'@@ -57,14 +116,62 @@ else:print('{}')   try:urllib.request.urlopen(req);raise AssertionError('anonymous mutation accepted')   except urllib.error.HTTPError as e:assert e.code==400 and 'caller_identity_required' in e.read().decode()   assert json.load(urllib.request.urlopen(a['url']+'state'))['presentation']['caption']=='Live test update'+  assert 'timeline serve'not in (runs['one']/'run.jsonl').read_text()++  # --- same thread, second unfinished run: challenged, no new tab+  count=opens();res=run(runs['two'],'intake','--prompt-time','2026-10-09T10:00:00Z','--engine','claude')+  assert opens()==count and 'name' not in cfg(runs['two']),res.stderr+  assert 'WARNING: no new AI Flow tab for this run' in res.stderr and DOT+'buck-12v5v' in res.stderr and '--reuse-tab' in res.stderr and '--new-tab --why' in res.stderr and 'keeps the human' in res.stderr,res.stderr+  res=run(runs['two'],'status');assert opens()==count and 'WARNING: this run has no dashboard tab' in res.stderr,res.stderr+  res=run(runs['two'],'timeline','open',code=2);assert 'ERROR' in res.stdout+res.stderr and '--reuse-tab' in res.stdout+res.stderr and opens()==count+  res=run(runs['two'],'timeline','open','--new-tab',code=2);assert '--why' in res.stdout+res.stderr and opens()==count+  # --- --new-tab --why opens and records the reason+  res=run(runs['two'],'timeline','open','--new-tab','--why','the human compares both layouts side by side');assert opens()==count+1,res+  b=cfg(runs['two']);assert b['name']==DOT+'two' and b['port']!=a['port'];assert entry(runs['two'])['why']=='the human compares both layouts side by side'+  assert alive(a['url'])==a['runId'] and alive(b['url'])==b['runId'],'second run stole first server'+  # --- close --run stops only that run's verified server and tab+  res=run(runs['two'],'timeline','close');assert DOT+'two' not in tabs() and 'stopped timeline server' in res.stdout,res.stdout+  time.sleep(.3);assert alive(b['url']) is None and alive(a['url'])==a['runId']+  assert all(e['runDir']!=str(runs['two'].resolve()) for e in reg())++  # --- a finished run's tab is reused (renamed in place) by the same thread's next run+  edit(runs['one'],lambda d:d['clock'].__setitem__('finishTime','2026-10-09T12:00:00Z'))+  n0=len(log());res=run(runs['three'],'intake','--prompt-time','2026-10-09T13:00:00Z','--engine','codex');new=log()[n0:]+  assert 'reused tab' in res.stderr and 'finished' in res.stderr,res.stderr+  assert flow_tabs()=={DOT+'three':('left',cfg(runs['three'])['url'])},flow_tabs()+  o=[c for c in new if c[:3]==['hydrogen','webview','open-or-refresh']];assert o and o[0][o[0].index('--panel-id')+1]=='left',new+  assert ['hydrogen','workspace','remove-tab','--name',DOT+'buck-12v5v'] in new+  time.sleep(.3);assert alive(a['url']) is None,'the finished run that gave its tab up keeps no server'+  assert all(e['runDir']!=str(runs['one'].resolve()) for e in reg())++  # --- another thread on the same board: warning, no tab; --new-tab --why adds the thread to the name+  reqs=p/'req.json';reqs.write_text(json.dumps({'title':'three'}))+  count=opens();res=run(runs['four'],'intake','--prompt-time','2026-10-09T14:00:00Z','--engine','claude','--requirements',str(reqs),thread='nova')+  assert opens()==count and 'is already being driven by thread "astra"' in res.stderr and DOT+'three' in res.stderr and '--new-tab --why' in res.stderr,res.stderr+  run(runs['four'],'timeline','open','--new-tab','--why','second opinion on the same board',thread='nova');assert opens()==count+1+  assert cfg(runs['four'])['name']==DOT+'three · nova',cfg(runs['four'])+  assert json.loads((runs['four']/'timeline'/'timeline.json').read_text())['aiThread']=='nova'++  # --- timeline list: registry reconciled with Hydrogen; stray AI Flow tabs are unknown; human tabs never listed+  s=json.loads(state.read_text());s['panes']['right'].append({'name':DOT+'stray','url':'http://stray/'});state.write_text(json.dumps(s))+  res=bare('timeline','list');out=res.stdout+  assert DOT+'three\n' in out and DOT+'three · nova' in out and 'AI thread nova' in out and 'AI thread astra' in out and 'not finished' in out and 'server alive' in out,out+  assert DOT+'stray' in out and 'unknown' in out and 'Human notes' not in out and 'second opinion on the same board' in out,out++  # --- timeline close: refuses non-AI-Flow names; --finished closes only finished AI Flow tabs+  res=bare('timeline','close','--name','Human notes',code=2);assert 'Human notes' in tabs()+  edit(runs['three'],lambda d:d['clock'].__setitem__('deliveredTime','2026-10-09T15:00:00Z'))+  before=set(tabs());res=bare('timeline','close','--finished');after=set(tabs())+  assert before-after=={DOT+'three'},(before-after,res.stdout)+  assert {'Human notes','Visual Studio Code',DOT+'stray',DOT+'three · nova'}<=after+  removed=[c[4] for c in log() if c[:3]==['hydrogen','workspace','remove-tab']];assert 'Human notes' not in removed and 'Visual Studio Code' not in removed+  bare('timeline','close','--name',DOT+'stray');assert DOT+'stray' not in tabs() and 'Human notes' in tabs() -  # Persistent server activity must not leave a never-ending ledger turn.-  d=json.loads((runs[0]/'run.json').read_text());assert 'timeline serve'not in (runs[0]/'run.jsonl').read_text()-  off=dict(env,AIFLOW_DASHBOARD='off');cmd(p/'headless','intake','--prompt-time','2026-10-09T10:00:00Z',extra=off);assert not (p/'headless'/'dashboard.json').exists()-  # Failed Hydrogen read is a visible warning, not a fake successful display or failed board command.-  mock.write_text('#!/bin/sh\nexit 1\n');cmd(runs[0],'status')-  r=cmd(runs[0],'status');assert 'Dashboard unavailable'in r.stderr-  print('PASS: default intake, reuse, closed-tab recovery, per-run server isolation, resume, stopped-server recovery, headless override, visible UI failure, server excluded from ledger')+  # --- headless override and visible UI failure+  off=dict(env,AIFLOW_DASHBOARD='off');run(p/'headless','intake','--prompt-time','2026-10-09T10:00:00Z',extra=off);assert not (p/'headless'/'dashboard.json').exists()+  mock.write_text('#!/bin/sh\nexit 1\n');run(runs['four'],'status',thread='nova')+  res=run(runs['four'],'status',thread='nova');assert 'Dashboard unavailable'in res.stderr+  print('PASS: intake tab named for the run, reuse, navigate, closed-tab recovery, label from the original board with rename in place, hex tab migrated in its pane, resume, server restart, SSE + replay, same-thread challenge, --new-tab --why, close --run with verified server stop, finished-tab reuse, other-thread warning, name collision adds the thread, list, close --finished/--name guard, headless override, visible UI failure')  finally:   # Only terminate test-owned timeline processes identified by exact temporary run path.   for line in subprocess.check_output(['ps','-eo','pid,args'],text=True).splitlines():
tools/timeline/README.md+6
@@ -20,7 +20,12 @@ If the file is missing or invalid the page shows "Waiting for the flow to start" ```json {   "title": "12 V to 5 V, 1 A buck converter",+  "label": "12 V to 5 V, 1 A buck converter",   "engine": "Fusion",+  "board": "buck-12v5v.brd",+  "aiThread": "astra",+  "runFolder": "buck-run-3",+  "runDir": "/home/adom/runs/buck-run-3",   "finish": "Gerbers, BOM and CPL exported",   "updated": "2026-10-07T14:02:11Z",   "current": "routing",@@ -39,6 +44,7 @@ If the file is missing or invalid the page shows "Waiting for the flow to start" - `visits` above 1 shows an "x2" style badge. `minutes` is AI time on that step, summed in the header. - `loops` are in time order; the last one is the "latest" loop shown in the callout. `from` is the later step that found the problem, `to` is the earlier step the AI went back to. When writing a loop, also set the steps from `to` onward to `redo`. - "Step N of M" is the current step's position; "to go" counts `todo` plus `redo` steps. When every step is `done` (and `current` is null) the finish line lights up.+- `title` is the run's label (requirements title, spec title, project page, original board stem, brief, run folder; never a hash). The line under it shows `board`, `engine`, `AI thread: <aiThread>` and `runFolder`; it never wraps: the thread ellipsizes, then the run folder, the lane and the board are dropped in that order. - Unknown fields are ignored, so the schema can grow.  ## Design notes
tools/timeline/index.html+39−5
@@ -3,7 +3,7 @@ <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1">-<title>AI Flow Timeline</title>+<title>AI Flow run</title> <link rel="icon" type="image/svg+xml" href="favicon.svg"> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>@@ -99,6 +99,11 @@ svg.i { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-widt   font-size: 26px; line-height: 1.12; letter-spacing: -.2px;   overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }+.runline { display: flex; align-items: baseline; gap: 0; margin-top: 4px; min-width: 0; overflow: hidden; white-space: nowrap; font-family: var(--f-mono); font-size: 12.5px; color: var(--text-2); }+.runline span { flex: none; white-space: nowrap; }+.runline span:not(.first)::before { content: "\00b7"; margin: 0 8px; color: var(--text-2); }+.runline span[hidden] { display: none; }+.runline .thread { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--text-body); } .finishline { display: flex; align-items: center; gap: 8px; margin-top: 5px; color: var(--text-2); font-size: 14px; min-width: 0; } .finishline svg.i { color: var(--text); width: 17px; height: 17px; } .finishline b { color: var(--text-body); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }@@ -472,7 +477,8 @@ body.wide .stage { margin-top: 14px; }  <section class="hero">   <div style="min-width:0">-    <h1 id="title">Flow</h1>+    <h1 id="title">AI Flow run</h1>+    <div class="runline" id="runline" data-tipkey="runline"><span id="rl-board" hidden></span><span id="rl-lane" hidden></span><span class="thread" id="rl-thread" hidden></span><span id="rl-folder" hidden></span></div>     <div class="finishline" data-tipkey="finish"><svg class="i"><use href="#i-flag"/></svg><span>Finish line:</span><b id="finish"></b></div>   </div>   <div class="stats" id="stats">@@ -754,7 +760,11 @@ body.wide .stage { margin-top: 14px; }       .map((l, i) => ({ from: String(l.from), to: String(l.to), why: String(l.why || ''), at: l.at || '', n: i + 1 }));     loops.forEach(l => { l.key = l.from + '>' + l.to + '@' + l.at + '#' + l.n; });     return {-      title: String(d.title || 'adom-aiflow'),+      title: String(d.title || d.label || d.runFolder || 'AI Flow run'),+      board: String(d.board || ''),+      aiThread: String(d.aiThread || ''),+      runFolder: String(d.runFolder || ''),+      runDir: String(d.runDir || ''),       engine: String(d.engine || ''),       finish: String(d.finish || 'Finished'),       updated: d.updated || '',@@ -847,8 +857,10 @@ body.wide .stage { margin-top: 14px; }     $('project-phase').textContent = presentation?.title || copy[0];     $('project-context').textContent = presentation?.summary || copy[1];     // header-    document.title = m.title + ' : Flow Timeline';+    document.title = (presentation?.projectTitle || m.title) + ' \u00b7 AI Flow';     $('title').textContent = presentation?.projectTitle || m.title;+    $('title').title = presentation?.projectTitle || m.title;+    runline(m);     $('engine').style.display = m.engine ? '' : 'none';     $('engine').querySelector('span').textContent = m.engine;     $('finish').textContent = m.finish;@@ -952,6 +964,27 @@ body.wide .stage { margin-top: 14px; }     firstRender = false;   } +  // the run line under the title: board file, lane, AI thread, run folder. It never wraps: the+  // thread ellipsizes, and when the line is still too wide the run folder, then the lane, then the+  // board are dropped (the AI thread always stays).+  function runline(m) {+    const items = [['rl-board', m.board ? 'Board ' + m.board : ''], ['rl-lane', m.engine], ['rl-thread', m.aiThread ? 'AI thread: ' + m.aiThread : ''], ['rl-folder', m.runFolder ? 'Run ' + m.runFolder : '']];+    items.forEach(([id, text]) => { const el = $(id); if (el.textContent !== text) el.textContent = text; el.hidden = !text; });+    $('runline').dataset.detail = [m.board, m.engine, m.aiThread && 'AI thread ' + m.aiThread, m.runDir].filter(Boolean).join(' \u00b7 ');+    fitRunline();+  }+  function fitRunline() {+    const line = $('runline');+    ['rl-board', 'rl-lane', 'rl-folder'].forEach(id => { $(id).hidden = !$(id).textContent; });+    const firstShown = () => { let f = true; line.querySelectorAll('span').forEach(el => { el.classList.toggle('first', f && !el.hidden); if (!el.hidden) f = false; }); };+    firstShown();+    for (const id of ['rl-folder', 'rl-lane', 'rl-board']) {+      if (line.scrollWidth <= line.clientWidth + 1) break;+      $(id).hidden = true;+      firstShown();+    }+  }+  window.addEventListener('resize', fitRunline);   function titleOf(m, name) { const i = m.idx[name]; return i == null ? name : m.steps[i].title; }   function setText(id, v) { const el = $(id); const s = String(v); if (el.textContent !== s) el.textContent = s; }   function flash(el, cls, ms) { el.classList.remove(cls); void el.offsetWidth; el.classList.add(cls); setTimeout(() => el.classList.remove(cls), ms); }@@ -1229,7 +1262,8 @@ body.wide .stage { margin-top: 14px; }       return { h: '<div class="tt">' + esc(g.phase || 'Steps') + ' phase</div><div>' + ss.map(s => esc(s.title)).join(', ') + '</div><div class="ft">' + ss.filter(s => s.status === 'done').length + ' of ' + ss.length + ' done</div>' };     }     const fixed = {-      brand: '<div class="tt">AI Flow Timeline</div><div>Live map of the adom-aiflow run: every step, where the AI is now, and every time it looped back to rework.</div><div class="ft">Engine ' + esc(m.engine || 'unknown') + ' \u00b7 reads ' + esc(SRC) + ' every 1 s</div>',+      brand: '<div class="tt">AI Flow Timeline</div><div>Live map of this AI Flow run: every step, where the AI is now, and every time it looped back to rework.</div><div class="ft">Engine ' + esc(m.engine || 'unknown') + ' \u00b7 reads ' + esc(SRC) + ' every 1 s</div>',+      runline: '<div class="tt">This run</div><div>' + esc($('runline').dataset.detail || 'No board attached yet') + '</div><div class="ft">The AI thread is the conversation that last drove this run.</div>',       engine: '<div class="tt">CAD engine</div><div>The design tool the AI is driving for this board.</div>',       live: '<div class="tt">Live</div><div>This page reads timeline.json every second. The time is the last update the flow wrote.</div>',       finish: '<div class="tt">Finish line</div><div>The flow is done when this is true: ' + esc(m.finish) + '.</div>',