← Commit history
rust/Cargo.lock+3−3
@@ -319,7 +319,7 @@ dependencies = [  [[package]] name = "kicad-bridge"-version = "1.0.103"+version = "1.0.104" dependencies = [  "adom-eda-checks",  "kicad-core",@@ -331,7 +331,7 @@ dependencies = [  [[package]] name = "kicad-core"-version = "1.0.103"+version = "1.0.104" dependencies = [  "kicad-ipc-rs",  "kicad-platform",@@ -358,7 +358,7 @@ dependencies = [  [[package]] name = "kicad-platform"-version = "1.0.103"+version = "1.0.104" dependencies = [  "serde",  "serde_json",
rust/Cargo.toml+1−1
@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"]  [workspace.package]-version = "1.0.103"+version = "1.0.104" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/main.rs+1−1
@@ -74,7 +74,7 @@ pub fn caller_side(code: &str) -> bool {     const EXACT: &[&str] = &["bad_arg", "bad_args", "file_not_found", "unsaved_changes", "stale_board", "revision_required", "drc_rejected",         "no_model", "reason_required", "not_supported", "unsupported_platform", "unknown_verb", "unknown_layer", "unknown_rule", "unknown_key",         "unknown_action", "wrong_file_type", "file_exists", "footprint_exists", "ambiguous", "ambiguous_board", "already_there", "already_flagged",-        "needs_closed_board", "live_required", "board_open", "kicad_running", "project_open", "too_close", "same_point", "zero_length", "plan_invalid",+        "needs_closed_board", "documents_in_use", "live_required", "board_open", "kicad_running", "project_open", "too_close", "same_point", "zero_length", "plan_invalid",         "invalid_path", "invalid_scope", "name_required", "engine_required", "foreground_required", "nothing_to_route", "nothing_in_box",         "nothing_placed", "side_change_not_supported", "live_move_drops_units", "entry_off_bus", "bad_via_geometry"];     EXACT.contains(&code) || code.starts_with("missing_") || code.starts_with("no_such_") || code.ends_with("_not_found")
rust/crates/kicad-bridge/src/verbs.rs+21−1
@@ -41,7 +41,27 @@ pub(crate) fn call(state: &mut State, command: &str, args: &Value) -> Value {     run(state, command, args, &Caller { thread: "kicad-bridge".into(), container: String::new(), reason: "a step of a composite verb".into() }) } -fn run(state: &mut State, command: &str, args: &Value, _caller: &Caller) -> Value {+/// The verbs that close documents: they forget them in doc_users rather than noting a use.+const CLOSES: &[&str] = &["kicad_close", "kicad_close_document", "kicad_close_all_documents"];++/// Run one verb as `caller`, then note which documents it drove in the live editor (wiki #139:+/// kicad_close_all_documents and kicad_close keep another AI thread's document open).+fn run(state: &mut State, command: &str, args: &Value, caller: &Caller) -> Value {+    let who = kicad_core::ab::caller_from(args, &caller.thread, &caller.container, &caller.reason);+    // a composite verb's inner step keeps the outer caller as the asker+    let inner = caller.thread == "kicad-bridge";+    let _asker = (!inner).then(|| kicad_core::doc_users::set_asker(&who.thread, &who.reason));+    let out = run_verb(state, command, args, caller);+    // the live editor: window and IPC verbs, plus the standard's document verbs catalogued as File+    let live = catalog::find(command).is_some_and(|v| matches!(v.mechanism, catalog::Mechanism::Window | catalog::Mechanism::Ipc))+        || matches!(command, "kicad_open_document" | "kicad_open_project" | "kicad_activate_document");+    if !inner && live && !CLOSES.contains(&command) && out.get("success").and_then(Value::as_bool) == Some(true) {+        kicad_core::doc_users::note(&who.thread, args);+    }+    out+}++fn run_verb(state: &mut State, command: &str, args: &Value, _caller: &Caller) -> Value {     let Some(verb) = catalog::find(command) else {         let names: Vec<&str> = catalog::all().iter().map(|v| v.name).collect();         return fail("unknown_verb", format!("Unknown command: {command}"), format!("Valid commands: {}", names.join(", ")));
rust/crates/kicad-bridge/src/verbs_demo.rs+3−1
@@ -982,7 +982,9 @@ fn restart_for_new_footprint(state: &mut State, prep: &Prep) -> Value {     let closed = win::dispatch(state, "kicad_close", &json!({})).unwrap_or_else(|| json!({}));     adopt_identity();     let t0 = Instant::now();-    while t0.elapsed() < Duration::from_secs(20) {+    // another AI thread's document stops the close outright (wiki #139): nothing to wait for+    let refused = closed["errorCode"] == json!("documents_in_use");+    while !refused && t0.elapsed() < Duration::from_secs(20) {         win::invalidate();         if win::kicad_windows().is_empty() {             break;
rust/crates/kicad-bridge/src/verbs_windows.rs+147−11
@@ -151,11 +151,11 @@ pub static VERBS: &[Verb] = &[         name: "kicad_close",         summary: "Close KiCad (editors first, then the project manager).",         mechanism: Mechanism::Window, risk: "process", timeout_sec: 130,-        input: "{\"force\"?: bool, \"discardChanges\"?: bool}",+        input: "{\"force\"?: bool, \"discardChanges\"?: bool, \"reason\"?: \"...\"}",         example: "kicad_close {}",-        hint: "Closes KiCad gracefully (WM_CLOSE). A 'Save changes?' prompt is read and reported as errorCode unsaved_changes; discardChanges:true presses Discard for the caller. force:true is the ONLY path that kills a process. Read the kicad-interaction skill before blind-closing.",-        related: &["kicad_open_editors", "kicad_window_info"],-        pitfalls: &["closing with unsaved work triggers a save dialog; handle it, do not blind-close", "force:true discards silently"],+        hint: "Closes KiCad gracefully (WM_CLOSE). A 'Save changes?' prompt is read and reported as errorCode unsaved_changes; discardChanges:true presses Discard for the caller. force:true is the ONLY path that kills a process. While another AI thread has a board or schematic open (opened or driven in the last 12 h) nothing is closed: errorCode documents_in_use lists documentsInUse [{name, openedBy}]; force:true with a reason closes past it. Read the kicad-interaction skill before blind-closing.",+        related: &["kicad_open_editors", "kicad_window_info", "kicad_close_document", "kicad_close_all_documents"],+        pitfalls: &["closing with unsaved work triggers a save dialog; handle it, do not blind-close", "force:true discards silently", "KiCad is shared by every AI thread on this desktop: to close your own board use kicad_close_document {path}."],     },     Verb {         name: "kicad_close_symbol_editor",@@ -298,11 +298,11 @@ pub static VERBS: &[Verb] = &[         name: "kicad_close_all_documents",         summary: "Close every open board, schematic and library editor without a save dialog (the standard's close_all_documents); unsaved work stays open and is listed unless save or discardUnsaved says what to do.",         mechanism: Mechanism::Window, risk: "write", timeout_sec: 180,-        input: "{\"save\"?: false, \"discardUnsaved\"?: false, \"includeScratch\"?: false}",+        input: "{\"save\"?: false, \"discardUnsaved\"?: false, \"includeScratch\"?: false, \"force\"?: false, \"reason\"?: \"...\"}",         example: "kicad_close_all_documents {}",-        hint: "closed, saved and keptOpen [{name, why}]. KiCad's project manager stays up; kicad_close ends KiCad itself. A scratch document is one that has never been saved.",+        hint: "closed, saved and keptOpen [{name, why}]. KiCad's project manager stays up; kicad_close ends KiCad itself. A scratch document is one that has never been saved. A board or schematic another AI thread opened or drove in the last 12 h stays open, neither saved nor closed, with why \"opened by <thread>\" and openedBy; force:true with a reason closes it too (forcedPast lists them).",         related: &["kicad_close_document", "kicad_close", "kicad_save"],-        pitfalls: &["discardUnsaved loses changes that exist nowhere else: ask the person first."],+        pitfalls: &["discardUnsaved loses changes that exist nowhere else: ask the person first.", "To close only your own board, use kicad_close_document {path}: several AI threads often share one KiCad.", "force:true past another thread's document needs a reason and loses that thread's board: ask it or the person first."],     },     Verb {         name: "kicad_click",@@ -2800,12 +2800,29 @@ fn close_one(st: &mut CloseState, w: &Win) -> Option<bool> { fn close_kicad(_state: &mut State, args: &Value) -> Value {     let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);     let discard = args.get("discardChanges").and_then(Value::as_bool).unwrap_or(false);+    // Wiki #139: closing KiCad closes every thread's board, so another AI thread's open document+    // stops the close unless force:true comes with a reason.+    invalidate();+    let in_use = find_windows(false, true).map(|(w, _)| documents_of_other_threads(&w)).unwrap_or_default();+    if !in_use.is_empty() && (!force || force_reason(args).is_none()) {+        let names: Vec<&str> = in_use.iter().filter_map(|r| r["name"].as_str()).collect();+        let (code, error) = if force {+            ("reason_required", "force:true closes documents other AI threads are using: say why in reason".to_string())+        } else {+            ("documents_in_use", format!("another AI thread has {} open; nothing was closed", names.join(", ")))+        };+        let mut v = fail(code, error, OTHERS_HINT);+        v["documentsInUse"] = json!(in_use);+        return v;+    }     // Wiki #48: a close is the last moment these windows exist, so learn where they are     // before the geometry is gone. A kill in particular denies KiCad its own save.     remember_placement();     if force {-        return match force_kill_kicad() {-            Ok(k) if !k.is_empty() => json!({"success": true, "output": format!("Force-killed: {}", k.join(", ")), "data": {"forceKilled": k}}),+        let killed = force_kill_kicad();+        forget_closed();+        return match killed {+            Ok(k) if !k.is_empty() => json!({"success": true, "output": format!("Force-killed: {}", k.join(", ")), "data": {"forceKilled": k}, "forcedPast": in_use}),             Ok(_) => json!({"success": true, "output": "KiCad is not running (nothing to kill)."}),             Err(e) => e,         };@@ -2872,6 +2889,7 @@ fn close_kicad(_state: &mut State, args: &Value) -> Value {             "_hint": "A title starting with * has unsaved work: its editor is waiting on a prompt. dialogsSeen lists every dialog that is up right now; answer it (kicad_dismiss_dialogs, or discardChanges:true to press Discard) or close that editor with the verb in editorCloseVerbs, then call kicad_close again. force:true kills the process and discards unsaved work silently. kicad_screenshot_all shows the windows.",         });     }+    forget_closed();     let mut out = json!({"success": true, "output": format!("KiCad closed ({} window{}).", closed.len(), if closed.len() != 1 { "s" } else { "" }), "data": {}});     if !discard_log.is_empty() {         out["discard"] = json!(discard_log);@@ -3688,6 +3706,10 @@ fn rescan_libraries(state: &mut State, args: &Value) -> Value {         let mut closed = dispatch(state, "kicad_close", &json!({})).unwrap_or_else(|| json!({}));         close_how = "graceful";         if !closed["success"].as_bool().unwrap_or(false) {+            if closed["errorCode"] == json!("documents_in_use") {+                return json!({"success": false, "errorCode": "documents_in_use", "close": closed,+                    "_hint": "Another AI thread has a document open in this KiCad, so nothing was closed or killed. Ask that thread or the person before restarting KiCad."});+            }             if closed["errorCode"] == json!("unsaved_changes") {                 return json!({"success": false, "errorCode": "restart_close_failed", "close": closed,                     "_hint": "KiCad is asking whether to save the user's work, so nothing was closed or killed. Ask them, then save (or call kicad_close {\"discardChanges\": true}) before restarting."});@@ -3724,6 +3746,50 @@ fn rescan_libraries(state: &mut State, args: &Value) -> Value { }  +#[cfg(test)]+mod doc_user_tests {+    use super::Win;+    use kicad_core::doc_users;+    use serde_json::json;++    fn win(title: &str) -> Win {+        Win { hwnd: 1, title: title.into(), ..Default::default() }+    }++    /// Wiki #139: two AI threads share one KiCad; the asker's own check copy may close, the other+    /// thread's board (and its schematic, which shares the name) is reported, a document closed+    /// some other way drops out, and force needs a reason.+    #[test]+    fn another_threads_board_is_in_use_and_mine_is_not() {+        let p = std::env::temp_dir().join(format!("kb-doc-users-{}.json", std::process::id()));+        let _ = std::fs::remove_file(&p);+        doc_users::set_state_path(Some(p.clone()));+        doc_users::note("LED strip 500mm aiflow", &json!({"filePath": "C:/w/led-strip-500/led-strip-500.kicad_pcb"}));+        doc_users::note("noah", &json!({"filePath": "C:/w/am32/am32-esc-v1-aiflow-check.kicad_pcb"}));+        doc_users::note("gone thread", &json!({"filePath": "C:/w/old/closed-by-hand.kicad_pcb"}));+        let wins = [+            win("am32-esc-v1-aiflow-check \u{2014} PCB Editor"),+            win("*led-strip-500 \u{2014} PCB Editor"),+            win("led-strip-500 [led-strip-500/] \u{2014} Schematic Editor"),+        ];+        let _me = doc_users::set_asker("noah", "");+        let in_use = super::documents_of_other_threads(&wins);+        assert_eq!(in_use, vec![json!({"name": "led-strip-500", "openedBy": ["LED strip 500mm aiflow"]})]);+        assert!(doc_users::others("closed-by-hand", "noah").is_empty(), "a document no editor holds is forgotten");+        assert!(super::force_reason(&json!({"force": true})).is_none());+        assert_eq!(super::force_reason(&json!({"force": true, "reason": "the LED thread said it is done"})).as_deref(), Some("the LED thread said it is done"));+        {+            let _with_header_reason = doc_users::set_asker("noah", "close boards after the check");+            assert!(super::force_reason(&json!({"force": true})).is_some());+        }+        // the other thread itself sees nothing in its way+        let _led = doc_users::set_asker("LED strip 500mm aiflow", "");+        let theirs = super::documents_of_other_threads(&wins);+        assert_eq!(theirs, vec![json!({"name": "am32-esc-v1-aiflow-check", "openedBy": ["noah"]})]);+        let _ = std::fs::remove_file(&p);+    }+}+ #[cfg(test)] mod linked_refresh_tests {     #[test]@@ -3873,6 +3939,9 @@ fn close_document(args: &Value) -> Value {     let mut st = CloseState { discard: true, ..Default::default() };     let gone = close_one(&mut st, &win);     let discarded = gone.is_none();+    if gone != Some(false) {+        forget_closed();+    }     if gone == Some(false) {         let mut v = fail("close_failed", format!("{} did not close", win.title), "dialogsSeen lists what is up; kicad_check_dialogs reads them.");         v["dialogsSeen"] = json!(scan_dialogs().iter().map(|d| json!({"title": d.title, "body": d.body})).collect::<Vec<_>>());@@ -3882,15 +3951,63 @@ fn close_document(args: &Value) -> Value {     json!({"success": true, "data": {"closed": true, "path": path, "discarded": discarded}, "roundtripMs": started.elapsed().as_millis() as u64}) } +/// The name of every open board and schematic editor (its title's document name).+fn open_document_names(wins: &[Win]) -> Vec<String> {+    wins.iter()+        .filter(|w| w.owner == 0 && matches!(w.kind(), WindowKind::PcbEditor | WindowKind::SchematicEditor))+        .filter_map(|w| crate::verbs_std_more::parse_title(&w.title).map(|t| t.0))+        .collect()+}++/// Drop the notes of documents no editor holds any more (a board and its schematic share one note).+fn forget_closed() {+    invalidate();+    // a failed window listing proves nothing closed: keep every note+    if let Ok((wins, _)) = find_windows(false, true) {+        kicad_core::doc_users::keep_only(&open_document_names(&wins));+    }+}++/// Wiki #139: the open documents another AI thread opened or drove, as [{name, openedBy}], for the+/// asker of this verb. Records of documents no longer open are dropped on the way.+fn documents_of_other_threads(wins: &[Win]) -> Vec<Value> {+    let names = open_document_names(wins);+    kicad_core::doc_users::keep_only(&names);+    let me = kicad_core::doc_users::asker().thread;+    let mut out: Vec<Value> = Vec::new();+    for n in names {+        let threads = kicad_core::doc_users::others(&n, &me);+        if !threads.is_empty() && !out.iter().any(|v| v["name"] == json!(n)) {+            out.push(json!({"name": n, "openedBy": threads}));+        }+    }+    out+}++/// force:true past another thread's document needs a reason (the verb's own or the caller's).+fn force_reason(args: &Value) -> Option<String> {+    let own = arg_any(args, &["reason", "forceReason"]).map(str::to_string);+    own.or_else(|| Some(kicad_core::doc_users::asker().reason)).filter(|r| !r.trim().is_empty())+}++const OTHERS_HINT: &str = "Another AI thread opened or used these documents and may still be working on them. Close your own board with kicad_close_document {path}. To close theirs too, ask that thread or the person first, then pass force:true with a reason.";+ fn close_all_documents(state: &mut State, args: &Value) -> Value {     let flag = |k: &str| args.get(k).and_then(Value::as_bool).unwrap_or(false);-    let (save, discard, scratch) = (flag("save"), flag("discardUnsaved"), flag("includeScratch"));+    let (save, discard, scratch, force) = (flag("save"), flag("discardUnsaved"), flag("includeScratch"), flag("force"));     invalidate();     let wins = match find_windows(false, true) {         Ok((w, _)) => w,         Err(e) => return e,     };+    let in_use = documents_of_other_threads(&wins);+    if force && !in_use.is_empty() && force_reason(args).is_none() {+        let mut v = fail("reason_required", "force:true closes documents other AI threads are using: say why in reason", OTHERS_HINT);+        v["documentsInUse"] = json!(in_use);+        return v;+    }     let (mut closed, mut saved, mut kept) = (Vec::new(), Vec::new(), Vec::new());+    let mut forced: Vec<Value> = Vec::new();     for w in wins.iter().filter(|w| w.owner == 0 && matches!(w.kind(), WindowKind::PcbEditor | WindowKind::SchematicEditor | WindowKind::SymbolEditor | WindowKind::FootprintEditor)) {         let name = crate::verbs_std_more::parse_title(&w.title).map(|t| t.0).unwrap_or_else(|| w.title.clone());         let dirty = w.title.starts_with('*');@@ -3899,6 +4016,16 @@ fn close_all_documents(state: &mut State, args: &Value) -> Value {             kept.push(json!({"name": name, "why": "scratch"}));             continue;         }+        // Wiki #139: another thread's board is neither saved nor closed without force+        let doc = matches!(w.kind(), WindowKind::PcbEditor | WindowKind::SchematicEditor);+        if let Some(row) = in_use.iter().find(|r| doc && r["name"] == json!(name)) {+            let threads: Vec<&str> = row["openedBy"].as_array().map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();+            if !force {+                kept.push(json!({"name": name, "why": format!("opened by {}", threads.join(", ")), "openedBy": threads, "closeYours": "kicad_close_document {path}"}));+                continue;+            }+            forced.push(row.clone());+        }         if dirty && !never_saved && save {             match save_editor(state, w, None) {                 Ok(_) => saved.push(name.clone()),@@ -3917,7 +4044,16 @@ fn close_all_documents(state: &mut State, args: &Value) -> Value {             _ => closed.push(name),         }     }-    json!({"success": true, "closed": closed, "saved": saved, "keptOpen": kept})+    forget_closed();+    let mut out = json!({"success": true, "closed": closed, "saved": saved, "keptOpen": kept});+    if kept.iter().any(|k| k.get("openedBy").is_some()) {+        out["_hint"] = json!(OTHERS_HINT);+    }+    if !forced.is_empty() {+        out["forcedPast"] = json!(forced);+        out["forceReason"] = json!(force_reason(args));+    }+    out }  // ---------------------------------------------------------------------------
rust/crates/kicad-core/src/doc_users.rsadded+273
@@ -0,0 +1,273 @@+//! Which AI threads opened or drove each KiCad document (wiki #139).+//!+//! Several AI threads share one desktop KiCad. A thread that closes "everything" must not+//! close a board another thread is working on, so every verb that drives the live editor on+//! a named document leaves a note here: document -> {thread -> when}. `kicad_close_all_documents`+//! and `kicad_close` read it and keep another thread's document open unless the caller forces+//! it with a reason.+//!+//! Documents are keyed on their lower-cased file stem, because that is all KiCad's window+//! titles give (`led-strip-500 — PCB Editor`); a board and its schematic share a stem and so+//! share a key. The table is mirrored to `~/.adom/kicad-bridge-doc-users.json` so a bridge+//! restart does not forget who has what open. A note older than `MAX_AGE_SECS` no longer+//! counts, and a document that is not open any more is dropped whenever a guard looks.+//!+//! The asker of the verb being served lives in a thread-local: each request runs on its own+//! worker thread, while the process-wide `ab` caller can be overwritten by a read verb that+//! another thread sends at the same moment.++use serde_json::Value;+use std::cell::RefCell;+use std::collections::BTreeMap;+use std::path::{Path, PathBuf};+use std::sync::Mutex;+use std::time::{SystemTime, UNIX_EPOCH};++/// A note older than this (12 h) no longer protects a document.+pub const MAX_AGE_SECS: u64 = 12 * 3600;++/// The argument keys a verb names its document with.+const PATH_KEYS: &[&str] = &["filePath", "path", "project", "boardPath", "schematicPath", "pcbPath", "file"];+const DOC_EXTS: &[&str] = &["kicad_pcb", "kicad_sch", "kicad_pro"];++/// Who is asking for the verb being served on this worker thread.+#[derive(Clone, Debug, Default, PartialEq, Eq)]+pub struct Asker {+    pub thread: String,+    pub reason: String,+}++thread_local! {+    static ASKER: RefCell<Option<Asker>> = const { RefCell::new(None) };+}++/// Restores the previous asker when dropped, so a composite verb's inner call does not leak.+pub struct AskerScope(Option<Asker>);++impl Drop for AskerScope {+    fn drop(&mut self) {+        let prev = self.0.take();+        ASKER.with(|a| *a.borrow_mut() = prev);+    }+}++/// Make `thread` the asker on this worker thread until the returned scope drops.+pub fn set_asker(thread: &str, reason: &str) -> AskerScope {+    let prev = ASKER.with(|a| a.borrow_mut().replace(Asker { thread: thread.to_string(), reason: reason.to_string() }));+    AskerScope(prev)+}++/// The asker of the verb being served here (empty when none was set).+pub fn asker() -> Asker {+    ASKER.with(|a| a.borrow().clone()).unwrap_or_default()+}++/// A thread name that identifies nobody: no header, the bridge itself, or a placeholder.+pub fn is_anonymous(thread: &str) -> bool {+    let t = thread.trim();+    t.is_empty() || t == "?" || t.eq_ignore_ascii_case(crate::progress::ANONYMOUS) || t == "kicad-bridge" || t == crate::ab::SELF_THREAD+}++/// The key of a document path (`C:/p/led-strip-500.kicad_pcb`) or of a title's name (`led-strip-500`).+pub fn doc_key(path_or_name: &str) -> Option<String> {+    let s = path_or_name.trim().replace('\\', "/");+    if s.is_empty() {+        return None;+    }+    let p = Path::new(&s);+    let ext = p.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase);+    match ext.as_deref() {+        Some(e) if DOC_EXTS.contains(&e) => p.file_stem().map(|x| x.to_string_lossy().to_lowercase()),+        _ if !s.contains('/') => Some(s.to_lowercase()),+        _ => None,+    }+}++/// The documents a verb's arguments name (board, schematic or project paths only).+pub fn doc_keys_in(args: &Value) -> Vec<String> {+    let mut out: Vec<String> = Vec::new();+    for k in PATH_KEYS {+        if let Some(s) = args.get(*k).and_then(Value::as_str) {+            let has_ext = Path::new(&s.replace('\\', "/")).extension().and_then(|e| e.to_str()).is_some_and(|e| DOC_EXTS.contains(&e.to_ascii_lowercase().as_str()));+            if let (true, Some(key)) = (has_ext, doc_key(s)) {+                if !out.contains(&key) {+                    out.push(key);+                }+            }+        }+    }+    out+}++type Table = BTreeMap<String, BTreeMap<String, u64>>;++struct Store {+    table: Option<Table>,+    path: Option<PathBuf>,+}++static STORE: Mutex<Store> = Mutex::new(Store { table: None, path: None });++fn store() -> std::sync::MutexGuard<'static, Store> {+    STORE.lock().unwrap_or_else(|e| e.into_inner())+}++fn default_path() -> PathBuf {+    let base = std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME")).map(PathBuf::from).unwrap_or_else(std::env::temp_dir);+    base.join(".adom").join("kicad-bridge-doc-users.json")+}++/// Point the table at another file (tests); the next read comes from there.+pub fn set_state_path(path: Option<PathBuf>) {+    let mut s = store();+    s.path = path;+    s.table = None;+}++fn now() -> u64 {+    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)+}++fn with_table<R>(write: bool, f: impl FnOnce(&mut Table) -> R) -> R {+    let mut s = store();+    let path = s.path.clone().unwrap_or_else(default_path);+    if s.table.is_none() {+        let loaded: Table = std::fs::read_to_string(&path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();+        s.table = Some(loaded);+    }+    let table = s.table.as_mut().expect("loaded above");+    let r = f(table);+    if write {+        if let Some(dir) = path.parent() {+            let _ = std::fs::create_dir_all(dir);+        }+        if let Ok(text) = serde_json::to_string(table) {+            let _ = std::fs::write(&path, text);+        }+    }+    r+}++/// Record that `thread` drove the documents `args` names. Anonymous callers leave no note.+pub fn note(thread: &str, args: &Value) {+    let keys = doc_keys_in(args);+    if keys.is_empty() || is_anonymous(thread) {+        return;+    }+    let t = now();+    with_table(true, |tab| {+        for k in keys {+            tab.entry(k).or_default().insert(thread.to_string(), t);+        }+        tab.retain(|_, users| {+            users.retain(|_, at| t.saturating_sub(*at) < MAX_AGE_SECS);+            !users.is_empty()+        });+    });+}++/// The other named threads that opened or drove this document recently, most recent first.+pub fn others(name: &str, me: &str) -> Vec<String> {+    let Some(key) = doc_key(name) else { return Vec::new() };+    let t = now();+    with_table(false, |tab| {+        let mut v: Vec<(&String, &u64)> = tab.get(&key).map(|u| u.iter().filter(|(th, at)| th.as_str() != me && !is_anonymous(th) && t.saturating_sub(**at) < MAX_AGE_SECS).collect()).unwrap_or_default();+        v.sort_by(|a, b| b.1.cmp(a.1));+        v.into_iter().map(|(th, _)| th.clone()).collect()+    })+}++/// Forget a document (it was closed).+pub fn forget(name: &str) {+    if let Some(key) = doc_key(name) {+        with_table(true, |tab| {+            tab.remove(&key);+        });+    }+}++/// Forget every document that is not among `open` (closed some other way, by hand or by a crash).+pub fn keep_only(open: &[String]) {+    let keys: Vec<String> = open.iter().filter_map(|n| doc_key(n)).collect();+    with_table(true, |tab| tab.retain(|k, _| keys.contains(k)));+}++#[cfg(test)]+mod tests {+    use super::*;+    use serde_json::json;++    static TEST_LOCK: Mutex<()> = Mutex::new(());++    fn fresh(tag: &str) -> (std::sync::MutexGuard<'static, ()>, PathBuf) {+        let g = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());+        let p = std::env::temp_dir().join(format!("kicad-doc-users-{tag}-{}.json", std::process::id()));+        let _ = std::fs::remove_file(&p);+        set_state_path(Some(p.clone()));+        (g, p)+    }++    #[test]+    fn keys_come_from_document_paths_and_titles() {+        assert_eq!(doc_key("C:\\boards\\LED-Strip-500.kicad_pcb").as_deref(), Some("led-strip-500"));+        assert_eq!(doc_key("C:/p/x.kicad_sch").as_deref(), Some("x"));+        assert_eq!(doc_key("led-strip-500").as_deref(), Some("led-strip-500"));+        assert_eq!(doc_key("C:/p/notes.txt"), None);+        let keys = doc_keys_in(&json!({"filePath": "C:/a/b.kicad_pcb", "path": "C:/a/b.kicad_sch", "project": "C:/a/out.step", "x": "C:/a/c.kicad_pcb"}));+        assert_eq!(keys, vec!["b".to_string()]);+    }++    #[test]+    fn another_threads_document_is_reported_and_mine_is_not() {+        let (_g, p) = fresh("others");+        note("LED strip 500mm aiflow", &json!({"filePath": "C:/w/led-strip-500.kicad_pcb"}));+        note("noah check", &json!({"filePath": "C:/w/am32-esc-v1-aiflow-check.kicad_pcb"}));+        assert_eq!(others("led-strip-500", "noah check"), vec!["LED strip 500mm aiflow".to_string()]);+        assert!(others("am32-esc-v1-aiflow-check", "noah check").is_empty());+        assert!(others("led-strip-500", "LED strip 500mm aiflow").is_empty());+        // an anonymous asker sees every named thread as someone else+        assert_eq!(others("am32-esc-v1-aiflow-check", "?"), vec!["noah check".to_string()]);+        // it survives a bridge restart+        set_state_path(Some(p.clone()));+        assert_eq!(others("led-strip-500", "noah check"), vec!["LED strip 500mm aiflow".to_string()]);+        forget("led-strip-500");+        assert!(others("led-strip-500", "noah check").is_empty());+        let _ = std::fs::remove_file(&p);+    }++    #[test]+    fn anonymous_callers_leave_no_note_and_closed_documents_drop_out() {+        let (_g, p) = fresh("anon");+        note("?", &json!({"filePath": "C:/w/a.kicad_pcb"}));+        note("kicad-bridge", &json!({"filePath": "C:/w/a.kicad_pcb"}));+        assert!(others("a", "me").is_empty());+        note("t1", &json!({"filePath": "C:/w/a.kicad_pcb"}));+        note("t2", &json!({"filePath": "C:/w/b.kicad_sch"}));+        keep_only(&["b".to_string()]);+        assert!(others("a", "me").is_empty());+        assert_eq!(others("b", "me"), vec!["t2".to_string()]);+        let _ = std::fs::remove_file(&p);+    }++    #[test]+    fn an_old_note_no_longer_counts() {+        let (_g, p) = fresh("age");+        let old = now() - MAX_AGE_SECS - 5;+        std::fs::write(&p, json!({"a": {"t-old": old, "t-new": now()}}).to_string()).unwrap();+        set_state_path(Some(p.clone()));+        assert_eq!(others("a", "me"), vec!["t-new".to_string()]);+        let _ = std::fs::remove_file(&p);+    }++    #[test]+    fn the_asker_is_per_thread_and_restored_after_an_inner_call() {+        let _outer = set_asker("thread A", "close mine");+        {+            let _inner = set_asker("thread B", "");+            assert_eq!(asker().thread, "thread B");+        }+        assert_eq!(asker(), Asker { thread: "thread A".into(), reason: "close mine".into() });+        let other = std::thread::spawn(asker).join().unwrap();+        assert_eq!(other, Asker::default());+    }+}
rust/crates/kicad-core/src/lib.rs+1
@@ -29,6 +29,7 @@ pub mod zones; pub mod ipc; pub mod uninstall; pub mod ab;+pub mod doc_users; pub mod windows_model; pub mod window_memory; pub mod progress;