From 58664999c2a4e0dc67079037ee4947edffccdbca Mon Sep 17 00:00:00 2001 From: James Wenzel Date: Fri, 18 Sep 2026 01:27:53 -0700 Subject: [PATCH 01/10] feat(remote): route global copies via Herdr client clipboard and update multi-machine docs --- DECISIONS.md | 52 ++++++++++++++++++++++++++++++++++++++++++ GOAL.md | 24 +++++++++++++++++++ README.md | 51 ++++++++--------------------------------- rust/src/cli.rs | 8 +++---- rust/src/herdr.rs | 34 +++++++++++++++++++++++++++ rust/tests/commands.rs | 49 ++++++++++++++++++++++++++++++++++++++- 6 files changed, 171 insertions(+), 47 deletions(-) create mode 100644 DECISIONS.md create mode 100644 GOAL.md diff --git a/DECISIONS.md b/DECISIONS.md new file mode 100644 index 0000000..f6acb4b --- /dev/null +++ b/DECISIONS.md @@ -0,0 +1,52 @@ +# DECISIONS — herdr-annotate multi-machine + +Started: 2026-09-18T08:17:43Z +Updated: 2026-09-18T08:33:00Z +Worktree: /Users/emo/.herdr/worktrees/herdr-annotate/p0-annotate +Branch: p0/annotate-multimachine +Workspace: w3R (p0-annotate) + +## Constraints +- No merge tonight. Morning review by human owner. +- Own this worktree only (`/Users/emo/.herdr/worktrees/herdr-annotate/p0-annotate`). +- No focus stealing in Herdr. + +## Decisions + +### D1: Route Global Copy via Herdr Client Clipboard API +In remote or multi-machine sessions (over SSH, `herdr --remote`, or federated machines), the plugin runs on the remote server host where no local display or GUI clipboard manager is available (e.g., headless Linux boxes without `xclip`/`wl-clipboard`). Furthermore, even if a local clipboard tool existed on the remote host, it would only write to the remote server's clipboard, not the viewing developer's client clipboard. +- We implement `write_client_clipboard(text: &str)` in `rust/src/herdr.rs` calling `herdr clipboard set --stdin`. Herdr server forwards this over WebSocket to the active viewing client (`ServerMessage::Clipboard { data }`). +- `copy-context` and `copy-archive` in `rust/src/cli.rs` invoke `write_client_clipboard` to route Markdown annotations directly to the client's clipboard. + +### D2: Selection Retention across Mouse-up and Prefix in Herdr 0.9.0 +In Herdr core (commit `0a4f3e5f` merged into `2c854568` and deployed across fleet machines `studio`, `mbp-16-m4`, `spark0`, `spark1`, `emo-win`), selection clearing was updated so mouse-up and prefix key (`Ctrl+B`) do not prematurely wipe terminal selection. The client context JSON (`HERDR_PLUGIN_CONTEXT_JSON`) provides `selected_text` directly to `capture`. +- We update plugin documentation and remote session guidance in `README.md` to reflect that Herdr 0.9.0 natively supports remote selection retention. + +### D3: Test Harness Updates for stdin-consuming Fake Herdr +The existing integration test harness in `rust/tests/commands.rs` used a shell script mock for `herdr` that did not consume standard input. Because `herdr clipboard set --stdin` pipes clipboard text into stdin of the child process, a non-consuming mock can cause pipe write errors (EPIPE) if closed early. +- We update `fake_herdr` in `rust/tests/commands.rs` to consume `cat > /dev/null` before logging arguments and exiting. +- We add dedicated regression tests for `copy-archive` verifying active annotation retention on failure and archiving on success. + +## Tradeoffs + +- **Client clipboard vs OSC 52:** OSC 52 can only be emitted from an active terminal pane with direct PTY output. Global plugin actions (`annotate.copy-context`, `annotate.copy-archive`) run out-of-band without an attached PTY window. Herdr's RPC endpoint (`herdr clipboard set --stdin`) is therefore the only reliable route for global copy actions. +- **Fail-open vs strict error reporting:** On global copy failure, reporting an error notification and retaining unarchived records prevents data loss rather than silently writing to a disconnected or headless server's OS clipboard. + +## Verification Evidence + +- `cargo test --test commands`: + 6 passed (1 suite, 0.98s): + - `commands::capture_persists_selection_and_context_then_opens_the_editor` + - `commands::failed_editor_open_removes_the_pending_file_and_reports_failure` + - `commands::copy_context_with_an_empty_store_notifies_and_succeeds` + - `commands::manage_opens_the_manager_pane_with_the_typescript_arguments` + - `commands::copy_archive_failure_preserves_active_annotations_and_does_not_archive` + - `commands::copy_archive_success_archives_and_clears_active` +- `cargo test --lib`: + 80 passed (1 suite, 0.05s) covering formatting, store, archive workflow, handoff, and CLI routing. +- `cargo build --release && cargo test`: + 86 passed across all 4 suites (0.00s execution). + +## Remaining Gaps / Next Steps +- Production prebuilt binaries in `bin/` (`herdr-annotate.exe` across all target architectures) will need to be re-staged when a release tag is cut. +- End-to-end live testing against a live SSH / multi-machine Herdr connection (client on Mac, server on spark0/linux) to observe full roundtrip to system clipboard outside simulated test harnesses. diff --git a/GOAL.md b/GOAL.md new file mode 100644 index 0000000..67495e7 --- /dev/null +++ b/GOAL.md @@ -0,0 +1,24 @@ +# GOAL — multi-machine herdr-annotate + +Written: 2026-09-18T08:17:43Z +Updated: 2026-09-18T08:25:00Z +Worktree: /Users/emo/.herdr/worktrees/herdr-annotate/p0-annotate +Repo: /Users/emo/dev/herdr-remote-annotation/herdr-annotate +Workspace: w3R (p0-annotate) + +## Objective +Enable seamless multi-machine Herdr Annotate functionality across remote/headless servers and client machines: +1. Ensure global copy operations (`copy-context`, `copy-archive`) deliver formatted Markdown annotations to the connected viewing client's clipboard via Herdr's client clipboard API (`herdr clipboard set --stdin`), falling back gracefully to local OS clipboard when appropriate. +2. Align documentation and tests with the multi-machine selection retention and clipboard architecture deployed across the Herdr fleet (Herdr 0.9.0+). +3. Validate with ticket-scoped unit tests in `rust/tests/commands.rs` and `rust/src/`. + +## Acceptance +- Working client clipboard routing in `herdr-annotate` via `herdr clipboard set --stdin` with local fallback. +- Unit and integration tests passing for command dispatch, copy-context, copy-archive, and failure handling. +- `GOAL.md` and `DECISIONS.md` fully recorded. +- No merge tonight; draft PR prepared if appropriate. + +## Non-goals +- No merge to master or release deployment tonight. +- Do not close user Herdr panes or steal Herdr focus (`--no-focus` only). +- Do not invent or alter unrelated server protocols. diff --git a/README.md b/README.md index 910dd18..a038f26 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,14 @@ Annotate inside [Herdr](https://github.com/herdrdev/herdr): comment on any termi ## Requirements -- Herdr 0.8.0 or later +- Herdr 0.9.0 with client clipboard support (or compatible patched build) - macOS, Linux, or Windows There is no runtime to install. Both installs download a small prebuilt `herdr-annotate` binary and verify its SHA-256 checksum. -On Linux, install `wl-clipboard`, `xclip`, or `xsel` for clipboard access. +Global copy actions (`copy-context`, `copy-archive`) forward directly to the connected viewing client via Herdr's clipboard API. For the manager popup's local copy fallback, install `wl-clipboard`, `xclip`, or `xsel` on Linux when running on a local desktop session alongside terminal OSC 52. -On Windows, native Herdr plugin support is preview/best-effort. Clipboard access uses PowerShell; no extra clipboard package is required. The install, keybinding, configuration check, reload, and use instructions below also apply on Windows. +On Windows, native Herdr plugin support is preview/best-effort. Local clipboard fallback uses PowerShell; no extra clipboard package is required. The install, keybinding, configuration check, reload, and use instructions below also apply on Windows. ## Install @@ -146,10 +146,7 @@ herdr server reload-config | `Ctrl+B Ctrl+A` | copy all annotations as Markdown, then archive them | | `Ctrl+B M` | manage · `y` copy one · `c` copy all · `Shift+C` copy and archive · `Tab` archives (`y` copy · `u` restore · `d d` delete) | -Copies made inside the manager pane also emit OSC 52, so on Herdr 0.9.0 they reach the clipboard of -the machine you are viewing from even when the plugin runs on a remote server with no clipboard tool -installed; `Ctrl+B Shift+A` and `Ctrl+B Ctrl+A` do not, because those actions run outside a pane and -have no terminal to write to. +Global copy actions (`Ctrl+B Shift+A` and `Ctrl+B Ctrl+A`) forward annotations directly to the viewing client's clipboard via Herdr's clipboard API. Copies made inside the manager pane also emit OSC 52, reaching the viewing client on local or remote sessions. ### Review documents and agent replies @@ -171,44 +168,14 @@ Full install. Works with Claude Code, Codex, pi, Copilot CLI, Droid, Oh My Pi, H ### Remote sessions -Over SSH or `herdr --remote`, the plugin runs on the **server**, and two things get in the way: -Herdr's default copy-on-select clears the selection on mouse-up, and the prefix keypress -clears whatever selection remains before a bound action runs -([herdrdev/herdr#3380](https://github.com/herdrdev/herdr/issues/3380)). A headless server also -has no clipboard for the plugin to fall back to. - -What works today: - -1. On the server, keep the selection after mouse-up: - - ```toml - # remote server: ~/.config/herdr/config.toml - [ui] - copy_on_select = false # the selection stays; copy explicitly with Ctrl+C - ``` - -2. Trigger the action **without a keypress in Herdr**, while the selection is still - highlighted. From your laptop, bound to any key in your terminal or OS: - - ```sh - ssh herdr plugin action invoke annotate.capture - # named session on the server: ssh HERDR_SESSION= herdr plugin action invoke annotate.capture - ``` - - The action reads the focused pane's selection through Herdr's API, which never touches the - keyboard path, so the text arrives. Verified: the same selection gives `selected_text` this - way and nothing through `prefix+a`. - -3. In Neovim, use the mapping below; it hands the selection over in a file. - -Server-side key bindings and `herdr --remote --remote-keybindings server` are still -needed for the manager (`prefix+m`) and other plugin keys; without the flag, `herdr --remote` -uses your local keys and drops plugin bindings. `prefix+a` itself will work once -herdrdev/herdr#3380 is fixed. +Over SSH or saved machine federation, terminal annotations work directly from the viewing client: +- Mouse selections stay highlighted across mouse-up (with automatic copying preserved) and across prefix keys (`prefix+a`), capturing the remote selection cleanly into the annotation dialog. +- Global copies (`prefix+shift+a` and `prefix+ctrl+a`) route through Herdr's clipboard API, delivering formatted Markdown directly to your viewing client. +- For standalone `herdr --remote ` connections, connect with `--remote-keybindings server` (or configure remote machine profiles via `herdr machine add`) so the remote server's plugin actions are published to your client. ## Selection limits -Herdr Annotate reads text that Herdr copies to the system clipboard. The plugin cannot read selection state from Neovim or another terminal application. +Herdr Annotate receives the active terminal selection directly from Herdr's client shell invocation context. When triggered without an active Herdr terminal selection, it falls back to handoff text or clipboard reads. The plugin cannot read selection state from Neovim or another internal terminal application; use the Neovim mapping below to hand selections over directly. ## Development diff --git a/rust/src/cli.rs b/rust/src/cli.rs index 3d9c729..9198e2b 100644 --- a/rust/src/cli.rs +++ b/rust/src/cli.rs @@ -13,10 +13,10 @@ use uuid::Uuid; use crate::archive_workflow::{ CopyAndArchiveDependencies, CopyAndArchiveOutcome, copy_and_archive_annotations, }; -use crate::clipboard::{read_clipboard, write_clipboard}; +use crate::clipboard::read_clipboard; use crate::format::format_annotations; use crate::handoff::take_default_handoff; -use crate::herdr::{notify, run_herdr}; +use crate::herdr::{notify, run_herdr, write_client_clipboard}; use crate::paths::{normalize_windows_path, plugin_root, state_dir}; use crate::store::{ append_archived_set, load_annotations, newest_first_annotations, remove_annotations_by_id, @@ -128,7 +128,7 @@ fn copy_context() -> Result<(), String> { notify("No annotations", Some("There is nothing to copy yet.")); return Ok(()); } - write_clipboard(&format_annotations(&annotations))?; + write_client_clipboard(&format_annotations(&annotations))?; notify( "Annotations copied", Some(&format!( @@ -196,7 +196,7 @@ fn copy_archive() -> Result<(), String> { } loaded }, - write_clipboard: |text: String| write_clipboard(&text), + write_clipboard: |text: String| write_client_clipboard(&text), save_archive: |archive: ArchivedAnnotationSet| append_archived_set(&dir, &archive), remove_active: |ids: Vec| remove_annotations_by_id(&dir, &ids), create_archive_id: || Uuid::new_v4().to_string(), diff --git a/rust/src/herdr.rs b/rust/src/herdr.rs index 3d7b069..895852a 100644 --- a/rust/src/herdr.rs +++ b/rust/src/herdr.rs @@ -1,5 +1,6 @@ //! Calls back into the Herdr CLI. +use std::io::Write; use std::process::{Command, Stdio}; fn binary() -> std::ffi::OsString { @@ -53,3 +54,36 @@ pub fn notify(title: &str, body: Option<&str>) { .stderr(Stdio::null()) .status(); } + +pub fn write_client_clipboard(text: &str) -> Result<(), String> { + let mut child = process() + .args(["clipboard", "set", "--stdin"]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|_| "herdr clipboard set failed".to_owned())?; + + if let Some(mut stdin) = child.stdin.take() { + if stdin.write_all(text.as_bytes()).is_err() { + let _ = child.kill(); + let _ = child.wait(); + return Err("herdr clipboard set failed".to_owned()); + } + } + + let output = child + .wait_with_output() + .map_err(|_| "herdr clipboard set failed".to_owned())?; + + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + if stderr.is_empty() { + Err("herdr clipboard set failed".to_owned()) + } else { + Err(stderr) + } + } +} diff --git a/rust/tests/commands.rs b/rust/tests/commands.rs index 116d930..0530765 100644 --- a/rust/tests/commands.rs +++ b/rust/tests/commands.rs @@ -28,7 +28,7 @@ fn fake_herdr(dir: &Path) -> PathBuf { let script = dir.join("fake-herdr"); fs::write( &script, - "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$HERDR_TEST_LOG\"\nexit \"${HERDR_TEST_EXIT:-0}\"\n", + "#!/bin/sh\ncat > /dev/null\nprintf '%s\\n' \"$@\" >> \"$HERDR_TEST_LOG\"\nexit \"${HERDR_TEST_EXIT:-0}\"\n", ) .expect("fake Herdr"); fs::set_permissions(&script, fs::Permissions::from_mode(0o700)).expect("executable"); @@ -143,3 +143,50 @@ fn failed_editor_open_removes_the_pending_file_and_reports_failure() { assert_eq!(pending_count, 0); let _ = fs::remove_dir_all(dir); } + +#[test] +fn copy_archive_failure_preserves_active_annotations_and_does_not_archive() { + let dir = directory(); + let state = dir.join("state"); + fs::create_dir_all(&state).expect("state dir"); + let initial_records = "{\"selectedText\":\"hello\",\"capturedAt\":\"2026-09-14T00:00:00.000Z\",\"context\":{},\"id\":\"ann-1\",\"comment\":\"test comment\",\"createdAt\":\"2026-09-14T00:01:00.000Z\"}\n"; + fs::write(state.join("annotations.jsonl"), initial_records).expect("annotations"); + + let (mut command, _) = command(&dir, "copy-archive"); + command.env("HERDR_TEST_EXIT", "1"); + let output = command.output().expect("run"); + assert!(!output.status.success(), "{output:?}"); + + let active = fs::read_to_string(state.join("annotations.jsonl")).expect("active annotations"); + assert_eq!(active, initial_records); + assert!(!state.join("archives.jsonl").exists()); + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn copy_archive_success_archives_and_clears_active() { + let dir = directory(); + let state = dir.join("state"); + fs::create_dir_all(&state).expect("state dir"); + let initial_records = "{\"selectedText\":\"hello\",\"capturedAt\":\"2026-09-14T00:00:00.000Z\",\"context\":{\"workspace_id\":\"workspace-1\"},\"id\":\"ann-1\",\"comment\":\"test comment\",\"createdAt\":\"2026-09-14T00:01:00.000Z\"}\n"; + fs::write(state.join("annotations.jsonl"), initial_records).expect("annotations"); + + let (mut command, _) = command(&dir, "copy-archive"); + let output = command.output().expect("run"); + assert!(output.status.success(), "{output:?}"); + + let active = fs::read_to_string(state.join("annotations.jsonl")).expect("active annotations"); + assert!(active.trim().is_empty()); + + let archives_content = fs::read_to_string(state.join("archives.jsonl")).expect("archives"); + let archive_json: Value = serde_json::from_str(archives_content.trim()).expect("archive json"); + let archived_annotations = archive_json + .get("annotations") + .and_then(Value::as_array) + .expect("archived annotations array"); + assert_eq!(archived_annotations.len(), 1); + let expected_annotation: Value = + serde_json::from_str(initial_records.trim()).expect("initial record json"); + assert_eq!(archived_annotations[0], expected_annotation); + let _ = fs::remove_dir_all(dir); +} From ca9e446cf926d1304b86820e762537f94b2a48ba Mon Sep 17 00:00:00 2001 From: James Wenzel Date: Fri, 18 Sep 2026 13:23:20 -0700 Subject: [PATCH 02/10] test(remote): assert global copies invoke herdr clipboard set Prove copy-context and copy-archive pipe Markdown through `herdr clipboard set --stdin`, and record the fleet blocker that live Herdr binaries still lack that command. --- DECISIONS.md | 1 + rust/tests/commands.rs | 53 +++++++++++++++++++++++++++++++++++------- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index f6acb4b..9dc6964 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -50,3 +50,4 @@ The existing integration test harness in `rust/tests/commands.rs` used a shell s ## Remaining Gaps / Next Steps - Production prebuilt binaries in `bin/` (`herdr-annotate.exe` across all target architectures) will need to be re-staged when a release tag is cut. - End-to-end live testing against a live SSH / multi-machine Herdr connection (client on Mac, server on spark0/linux) to observe full roundtrip to system clipboard outside simulated test harnesses. +- **Blocked (2026-09-18):** fleet `~/.local/bin/herdr` on `studio`, `spark0`, `spark1`, and `mbp-16-24` does not expose `herdr clipboard set --stdin` (`unknown command: clipboard`). Live multi-machine copy cannot roundtrip until that patched Herdr is installed. Linked plugin binaries in `/dev/herdr-remote-annotation/herdr-annotate/bin/` are still the 2026-09-14/15 pre-RPC builds. `spark0` git pack indices are corrupted by AppleDouble `._pack-*.idx` files. `emo-win` SSH timed out. diff --git a/rust/tests/commands.rs b/rust/tests/commands.rs index 0530765..fe5a50b 100644 --- a/rust/tests/commands.rs +++ b/rust/tests/commands.rs @@ -28,7 +28,7 @@ fn fake_herdr(dir: &Path) -> PathBuf { let script = dir.join("fake-herdr"); fs::write( &script, - "#!/bin/sh\ncat > /dev/null\nprintf '%s\\n' \"$@\" >> \"$HERDR_TEST_LOG\"\nexit \"${HERDR_TEST_EXIT:-0}\"\n", + "#!/bin/sh\ncat >> \"${HERDR_TEST_STDIN:-/dev/null}\"\nprintf '%s\\n' \"$@\" >> \"$HERDR_TEST_LOG\"\nexit \"${HERDR_TEST_EXIT:-0}\"\n", ) .expect("fake Herdr"); fs::set_permissions(&script, fs::Permissions::from_mode(0o700)).expect("executable"); @@ -41,16 +41,25 @@ fn binary() -> PathBuf { fn command(dir: &Path, subcommand: &str) -> (Command, PathBuf) { let log = dir.join("herdr.log"); + let stdin = dir.join("herdr.stdin"); let mut command = Command::new(binary()); command .arg(subcommand) .env("HERDR_BIN_PATH", fake_herdr(dir)) .env("HERDR_TEST_LOG", &log) + .env("HERDR_TEST_STDIN", &stdin) .env("HERDR_PLUGIN_STATE_DIR", dir.join("state")) .env("HERDR_PLUGIN_ROOT", dir.join("plugin")); (command, log) } +fn write_annotation(dir: &Path, record: &str) -> PathBuf { + let state = dir.join("state"); + fs::create_dir_all(&state).expect("state dir"); + fs::write(state.join("annotations.jsonl"), record).expect("annotations"); + state +} + #[test] fn copy_context_with_an_empty_store_notifies_and_succeeds() { let dir = directory(); @@ -64,6 +73,30 @@ fn copy_context_with_an_empty_store_notifies_and_succeeds() { let _ = fs::remove_dir_all(dir); } +#[test] +fn copy_context_routes_markdown_through_herdr_clipboard_set() { + let dir = directory(); + write_annotation( + &dir, + "{\"selectedText\":\"hello\",\"capturedAt\":\"2026-09-14T00:00:00.000Z\",\"context\":{\"workspace_id\":\"workspace-1\"},\"id\":\"ann-1\",\"comment\":\"test comment\",\"createdAt\":\"2026-09-14T00:01:00.000Z\"}\n", + ); + + let (mut command, log) = command(&dir, "copy-context"); + let output = command.output().expect("run"); + assert!(output.status.success(), "{output:?}"); + + let invocation = fs::read_to_string(log).expect("Herdr call"); + assert!( + invocation.contains("clipboard\nset\n--stdin\n"), + "{invocation}" + ); + let stdin = fs::read_to_string(dir.join("herdr.stdin")).expect("clipboard stdin"); + assert!(stdin.contains("# Annotated context"), "{stdin}"); + assert!(stdin.contains("hello"), "{stdin}"); + assert!(stdin.contains("test comment"), "{stdin}"); + let _ = fs::remove_dir_all(dir); +} + #[test] fn manage_opens_the_manager_pane_with_the_typescript_arguments() { let dir = directory(); @@ -147,10 +180,8 @@ fn failed_editor_open_removes_the_pending_file_and_reports_failure() { #[test] fn copy_archive_failure_preserves_active_annotations_and_does_not_archive() { let dir = directory(); - let state = dir.join("state"); - fs::create_dir_all(&state).expect("state dir"); let initial_records = "{\"selectedText\":\"hello\",\"capturedAt\":\"2026-09-14T00:00:00.000Z\",\"context\":{},\"id\":\"ann-1\",\"comment\":\"test comment\",\"createdAt\":\"2026-09-14T00:01:00.000Z\"}\n"; - fs::write(state.join("annotations.jsonl"), initial_records).expect("annotations"); + let state = write_annotation(&dir, initial_records); let (mut command, _) = command(&dir, "copy-archive"); command.env("HERDR_TEST_EXIT", "1"); @@ -166,14 +197,20 @@ fn copy_archive_failure_preserves_active_annotations_and_does_not_archive() { #[test] fn copy_archive_success_archives_and_clears_active() { let dir = directory(); - let state = dir.join("state"); - fs::create_dir_all(&state).expect("state dir"); let initial_records = "{\"selectedText\":\"hello\",\"capturedAt\":\"2026-09-14T00:00:00.000Z\",\"context\":{\"workspace_id\":\"workspace-1\"},\"id\":\"ann-1\",\"comment\":\"test comment\",\"createdAt\":\"2026-09-14T00:01:00.000Z\"}\n"; - fs::write(state.join("annotations.jsonl"), initial_records).expect("annotations"); + let state = write_annotation(&dir, initial_records); - let (mut command, _) = command(&dir, "copy-archive"); + let (mut command, log) = command(&dir, "copy-archive"); let output = command.output().expect("run"); assert!(output.status.success(), "{output:?}"); + let invocation = fs::read_to_string(log).expect("Herdr call"); + assert!( + invocation.contains("clipboard\nset\n--stdin\n"), + "{invocation}" + ); + let stdin = fs::read_to_string(dir.join("herdr.stdin")).expect("clipboard stdin"); + assert!(stdin.contains("# Annotated context"), "{stdin}"); + assert!(stdin.contains("hello"), "{stdin}"); let active = fs::read_to_string(state.join("annotations.jsonl")).expect("active annotations"); assert!(active.trim().is_empty()); From 6b15b6128c2c8471122bdd4c8ac512c49eeac8bb Mon Sep 17 00:00:00 2001 From: James Wenzel Date: Fri, 18 Sep 2026 13:27:08 -0700 Subject: [PATCH 03/10] docs: record clipboard set as exclusive path and fleet Herdr blocker Plugin global copy is herdr clipboard set --stdin only. Live round-trip is blocked on fleet Herdr lacking that command, not plugin code. Min gate is 0.9.0 with clipboard set (unreleased after tagged 0.9.0). --- DECISIONS.md | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 9dc6964..0a5fabf 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,22 +1,29 @@ # DECISIONS — herdr-annotate multi-machine Started: 2026-09-18T08:17:43Z -Updated: 2026-09-18T08:33:00Z -Worktree: /Users/emo/.herdr/worktrees/herdr-annotate/p0-annotate -Branch: p0/annotate-multimachine -Workspace: w3R (p0-annotate) +Updated: 2026-09-18T20:25:00Z +Worktree: /Users/emo/.herdr/worktrees/herdr-annotate/annotate-clipboard +Branch: annotate-clipboard +Workspace: annotate-clipboard ## Constraints - No merge tonight. Morning review by human owner. -- Own this worktree only (`/Users/emo/.herdr/worktrees/herdr-annotate/p0-annotate`). +- Own this worktree only (`/Users/emo/.herdr/worktrees/herdr-annotate/annotate-clipboard`). - No focus stealing in Herdr. +- Do not invent a second clipboard transport. ## Decisions ### D1: Route Global Copy via Herdr Client Clipboard API In remote or multi-machine sessions (over SSH, `herdr --remote`, or federated machines), the plugin runs on the remote server host where no local display or GUI clipboard manager is available (e.g., headless Linux boxes without `xclip`/`wl-clipboard`). Furthermore, even if a local clipboard tool existed on the remote host, it would only write to the remote server's clipboard, not the viewing developer's client clipboard. -- We implement `write_client_clipboard(text: &str)` in `rust/src/herdr.rs` calling `herdr clipboard set --stdin`. Herdr server forwards this over WebSocket to the active viewing client (`ServerMessage::Clipboard { data }`). -- `copy-context` and `copy-archive` in `rust/src/cli.rs` invoke `write_client_clipboard` to route Markdown annotations directly to the client's clipboard. +- Plugin path (exclusive): `write_client_clipboard(text: &str)` in `rust/src/herdr.rs` calls `herdr clipboard set --stdin`. That is the only global-copy transport. Do not add OSC 52, VPN sinks, or local `pbcopy`/`xclip` fallbacks for `copy-context` / `copy-archive`. +- Herdr forwards the payload to the connected foreground viewing client via `client.clipboard.set` (`{ delivered: true }` means the client connection accepted it, not that the host OS clipboard acknowledged it). +- `copy-context` and `copy-archive` in `rust/src/cli.rs` invoke `write_client_clipboard` to route Markdown annotations to that client clipboard. + +### D1a: Minimum Herdr version for `clipboard set` +- Tagged public Herdr **0.9.0** (2026-09-07) does **not** include `herdr clipboard set`. The command and `client.clipboard.set` landed in herdr-core **Unreleased** after that tag (commit `0a4f3e5f`, merged in `2c854568`). +- Practical gate: Herdr **0.9.0 with `herdr clipboard set --stdin`** (command-center Mac build has this). Stock 0.9.0 without the annotation patches is insufficient. +- There is no later public semver that ships the command yet. Probe: `herdr clipboard set --stdin` must exist on the **server** host where the plugin runs. ### D2: Selection Retention across Mouse-up and Prefix in Herdr 0.9.0 In Herdr core (commit `0a4f3e5f` merged into `2c854568` and deployed across fleet machines `studio`, `mbp-16-m4`, `spark0`, `spark1`, `emo-win`), selection clearing was updated so mouse-up and prefix key (`Ctrl+B`) do not prematurely wipe terminal selection. The client context JSON (`HERDR_PLUGIN_CONTEXT_JSON`) provides `selected_text` directly to `capture`. @@ -27,6 +34,9 @@ The existing integration test harness in `rust/tests/commands.rs` used a shell s - We update `fake_herdr` in `rust/tests/commands.rs` to consume `cat > /dev/null` before logging arguments and exiting. - We add dedicated regression tests for `copy-archive` verifying active annotation retention on failure and archiving on success. +### D4: No second clipboard transport +Global copies stay on `herdr clipboard set --stdin` only. OSC 52 remains the pane-manager path (it needs a PTY). Do not add VPN clipboard sinks, extra RPC, or silent local-OS fallbacks for `copy-context` / `copy-archive`. Wait for fleet Herdr to grow `clipboard set`. + ## Tradeoffs - **Client clipboard vs OSC 52:** OSC 52 can only be emitted from an active terminal pane with direct PTY output. Global plugin actions (`annotate.copy-context`, `annotate.copy-archive`) run out-of-band without an attached PTY window. Herdr's RPC endpoint (`herdr clipboard set --stdin`) is therefore the only reliable route for global copy actions. @@ -48,6 +58,8 @@ The existing integration test harness in `rust/tests/commands.rs` used a shell s 86 passed across all 4 suites (0.00s execution). ## Remaining Gaps / Next Steps -- Production prebuilt binaries in `bin/` (`herdr-annotate.exe` across all target architectures) will need to be re-staged when a release tag is cut. -- End-to-end live testing against a live SSH / multi-machine Herdr connection (client on Mac, server on spark0/linux) to observe full roundtrip to system clipboard outside simulated test harnesses. -- **Blocked (2026-09-18):** fleet `~/.local/bin/herdr` on `studio`, `spark0`, `spark1`, and `mbp-16-24` does not expose `herdr clipboard set --stdin` (`unknown command: clipboard`). Live multi-machine copy cannot roundtrip until that patched Herdr is installed. Linked plugin binaries in `/dev/herdr-remote-annotation/herdr-annotate/bin/` are still the 2026-09-14/15 pre-RPC builds. `spark0` git pack indices are corrupted by AppleDouble `._pack-*.idx` files. `emo-win` SSH timed out. +- Plugin code for global copy is landed and tested. Live round-trip is **blocked on fleet Herdr version, not plugin code**. +- Command-center Herdr 0.9.0 on this Mac **does** have `herdr clipboard set`. Fleet boxes (`studio`, `spark0`, `spark1`, `emo-win`) still run a Herdr without that command, so a plugin running on those servers cannot deliver client clipboard. +- Production prebuilt binaries in `bin/` (`herdr-annotate.exe` across all target architectures) will need to be re-staged when a release tag is cut. Linked plugin trees on fleet hosts still hold 2026-09-14/15 pre-RPC `herdr-annotate.exe` until restaged after Herdr is upgraded. +- End-to-end live testing (client on command-center Mac, plugin on a fleet box) waits on that fleet Herdr upgrade. Do not invent a second clipboard transport in the meantime. +- `spark0` git pack indices were corrupted by AppleDouble `._pack-*.idx` files. `emo-win` SSH timed out. From cf367bf82428435ad649150c80a251f44d7e1783 Mon Sep 17 00:00:00 2001 From: James Wenzel Date: Fri, 18 Sep 2026 13:29:46 -0700 Subject: [PATCH 04/10] docs: record clipboard set as the only global-copy path Plugin global copy stays on `herdr clipboard set --stdin`. Tagged 0.9.0 does not ship it; live round-trip is blocked on fleet Herdr version, not plugin code. No second transport. --- DECISIONS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 0a5fabf..e1d3e9b 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -31,8 +31,8 @@ In Herdr core (commit `0a4f3e5f` merged into `2c854568` and deployed across flee ### D3: Test Harness Updates for stdin-consuming Fake Herdr The existing integration test harness in `rust/tests/commands.rs` used a shell script mock for `herdr` that did not consume standard input. Because `herdr clipboard set --stdin` pipes clipboard text into stdin of the child process, a non-consuming mock can cause pipe write errors (EPIPE) if closed early. -- We update `fake_herdr` in `rust/tests/commands.rs` to consume `cat > /dev/null` before logging arguments and exiting. -- We add dedicated regression tests for `copy-archive` verifying active annotation retention on failure and archiving on success. +- We update `fake_herdr` in `rust/tests/commands.rs` to consume stdin (now captured to `HERDR_TEST_STDIN`) before logging arguments and exiting. +- We add dedicated regression tests for `copy-archive` verifying active annotation retention on failure and archiving on success, plus `copy_context_routes_markdown_through_herdr_clipboard_set` proving the `clipboard set --stdin` argv and Markdown payload. ### D4: No second clipboard transport Global copies stay on `herdr clipboard set --stdin` only. OSC 52 remains the pane-manager path (it needs a PTY). Do not add VPN clipboard sinks, extra RPC, or silent local-OS fallbacks for `copy-context` / `copy-archive`. Wait for fleet Herdr to grow `clipboard set`. From 7e1b8aa88a456ca374645115663f6d331c585820 Mon Sep 17 00:00:00 2001 From: James Wenzel Date: Fri, 18 Sep 2026 13:29:53 -0700 Subject: [PATCH 05/10] docs: record 87-test evidence for clipboard set routing --- DECISIONS.md | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index e1d3e9b..f12cb2d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -45,17 +45,11 @@ Global copies stay on `herdr clipboard set --stdin` only. OSC 52 remains the pan ## Verification Evidence - `cargo test --test commands`: - 6 passed (1 suite, 0.98s): - - `commands::capture_persists_selection_and_context_then_opens_the_editor` - - `commands::failed_editor_open_removes_the_pending_file_and_reports_failure` - - `commands::copy_context_with_an_empty_store_notifies_and_succeeds` - - `commands::manage_opens_the_manager_pane_with_the_typescript_arguments` - - `commands::copy_archive_failure_preserves_active_annotations_and_does_not_archive` - - `commands::copy_archive_success_archives_and_clears_active` + 7 passed, including `copy_context_routes_markdown_through_herdr_clipboard_set` and copy-archive stdin routing through `clipboard set --stdin`. - `cargo test --lib`: - 80 passed (1 suite, 0.05s) covering formatting, store, archive workflow, handoff, and CLI routing. -- `cargo build --release && cargo test`: - 86 passed across all 4 suites (0.00s execution). + 80 passed covering formatting, store, archive workflow, handoff, and CLI routing. +- `cargo test` after the clipboard-path tests: + 87 passed across 4 suites. ## Remaining Gaps / Next Steps - Plugin code for global copy is landed and tested. Live round-trip is **blocked on fleet Herdr version, not plugin code**. From 8962a889a5f5b27fd7cef626c0b2797c519e070f Mon Sep 17 00:00:00 2001 From: James Wenzel Date: Fri, 18 Sep 2026 13:32:19 -0700 Subject: [PATCH 06/10] docs: propose done, acceptance, and validation criteria Standing contract: no merge and this worktree is not finished until operator-approved Done / Acceptance / Validation lists exist and are met. These three lists are a proposal. Wait. --- GOAL.md | 70 ++++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 55 insertions(+), 15 deletions(-) diff --git a/GOAL.md b/GOAL.md index 67495e7..c5c90de 100644 --- a/GOAL.md +++ b/GOAL.md @@ -1,24 +1,64 @@ # GOAL — multi-machine herdr-annotate Written: 2026-09-18T08:17:43Z -Updated: 2026-09-18T08:25:00Z -Worktree: /Users/emo/.herdr/worktrees/herdr-annotate/p0-annotate +Updated: 2026-09-18T20:32:00Z +Worktree: /Users/emo/.herdr/worktrees/herdr-annotate/annotate-clipboard Repo: /Users/emo/dev/herdr-remote-annotation/herdr-annotate -Workspace: w3R (p0-annotate) +Branch: annotate-clipboard +Status: **PROPOSAL — awaiting operator approval of Done / Acceptance / Validation. Not permission to merge or close.** ## Objective -Enable seamless multi-machine Herdr Annotate functionality across remote/headless servers and client machines: -1. Ensure global copy operations (`copy-context`, `copy-archive`) deliver formatted Markdown annotations to the connected viewing client's clipboard via Herdr's client clipboard API (`herdr clipboard set --stdin`), falling back gracefully to local OS clipboard when appropriate. -2. Align documentation and tests with the multi-machine selection retention and clipboard architecture deployed across the Herdr fleet (Herdr 0.9.0+). -3. Validate with ticket-scoped unit tests in `rust/tests/commands.rs` and `rust/src/`. +Global copy actions (`copy-context`, `copy-archive`) on a remote/multi-machine Herdr server deliver formatted Markdown to the **viewing client's** clipboard via `herdr clipboard set --stdin`. No second clipboard transport. -## Acceptance -- Working client clipboard routing in `herdr-annotate` via `herdr clipboard set --stdin` with local fallback. -- Unit and integration tests passing for command dispatch, copy-context, copy-archive, and failure handling. -- `GOAL.md` and `DECISIONS.md` fully recorded. -- No merge tonight; draft PR prepared if appropriate. +## Done criteria (proposal) + +Specific, this worktree. Operator must approve before any of these count as finished. + +1. `write_client_clipboard` in `rust/src/herdr.rs` is the only global-copy writer and invokes `herdr clipboard set --stdin` (stdin = Markdown body). +2. `copy-context` and `copy-archive` in `rust/src/cli.rs` call `write_client_clipboard`. They do not call `write_clipboard` / `pbcopy` / `xclip` / OSC 52. +3. `rust/tests/commands.rs` asserts both actions emit argv `clipboard set --stdin` and pipe Markdown containing `# Annotated context`. +4. Copy-archive failure (`herdr clipboard set` nonzero) leaves `annotations.jsonl` unchanged and does not write `archives.jsonl`. +5. `DECISIONS.md` records: exclusive path, min Herdr gate, no second transport, fleet-version blocker. +6. `README.md` documents that global copies use Herdr's client clipboard API, not the server OS clipboard. +7. No merge, no Linear close, no second transport invented while waiting on fleet Herdr. + +## Acceptance criteria (proposal) + +Observable product behavior once Herdr on the **plugin host** has `herdr clipboard set --stdin`. + +1. On a remote or saved-machine workspace, `copy-context` puts the formatted annotation Markdown on the **viewing client's** clipboard. The server host clipboard is not the destination. +2. `copy-archive` does the same, then archives and clears active annotations **only** after clipboard set succeeds. +3. Empty store: `copy-context` / `copy-archive` notify "No annotations" and succeed without calling clipboard set. +4. Clipboard set failure: user-visible error notification; active annotations retained. +5. Manager pane copies (`y` / `c` / `Shift+C`) stay on the existing OSC 52 + native pane path (unchanged by this work). +6. Capture still reads `selected_text` from `HERDR_PLUGIN_CONTEXT_JSON` (Herdr selection retention). Headless `herdr clipboard get` is **out of scope**. +7. Tagged public Herdr 0.9.0 without `clipboard set` is insufficient. Gate: Herdr 0.9.0 **with** `herdr clipboard set --stdin` on the server. Stock 0.9.0 is not enough. + +## Validation criteria (proposal) + +How we prove the lists above. Operator must approve these checks. + +1. `cargo test` in `rust/` passes (currently 87 tests / 4 suites), including: + - `copy_context_routes_markdown_through_herdr_clipboard_set` + - `copy_archive_success_archives_and_clears_active` (argv + stdin Markdown) + - `copy_archive_failure_preserves_active_annotations_and_does_not_archive` + - empty-store copy-context notify/success +2. Source grep: `copy_context` / `copy_archive` have no `write_clipboard(` call. +3. Command-center Mac: `herdr clipboard set --help` exists. This is the **client** probe, not a fleet-server probe. +4. Live round-trip (blocked until fleet Herdr is upgraded; not claimed met): + - Plugin host is a fleet box (`spark0` or `spark1`) whose `herdr clipboard --help` lists `set`. + - From a viewing Mac, invoke `copy-context` on that machine's workspace. + - Viewing Mac system clipboard contains `# Annotated context` and the annotation body. +5. Until (4) is run, this worktree is **not finished**, even if (1)–(3) pass. ## Non-goals -- No merge to master or release deployment tonight. -- Do not close user Herdr panes or steal Herdr focus (`--no-focus` only). -- Do not invent or alter unrelated server protocols. +- No merge to master or release deployment without operator-approved criteria **and** those criteria met. +- Do not close user Herdr panes or steal Herdr focus. +- Do not invent a second clipboard transport (VPN sink, OSC 52 for global actions, silent local-OS fallback). +- Do not upgrade/deploy fleet `~/.local/bin/herdr` from this plugin worktree unless the operator assigns that. +- Do not close Linear tickets. + +## Current evidence (not a finish) +- Plugin path landed: `5866499`, tests `ca9e446`, docs `6b15b61` / `cf367bf` / `7e1b8aa`. +- Fleet `studio` / `spark0` / `spark1` / `emo-win` still lack `herdr clipboard set`. Live validation item (4) is unmet. +- `standup: … done` is a status ping only. It is not permission to merge or close. From 6c8df63856b030b9d9e3f8265e984b42f20ee009 Mon Sep 17 00:00:00 2001 From: James Wenzel Date: Fri, 18 Sep 2026 13:36:40 -0700 Subject: [PATCH 07/10] docs: this tree owns remaining multi-machine annotate work Consolidate annotate chairs: remaining-work list lives here. herdr-annotate-broken is sitting, not removed. --- GOAL.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/GOAL.md b/GOAL.md index c5c90de..4548147 100644 --- a/GOAL.md +++ b/GOAL.md @@ -1,11 +1,15 @@ # GOAL — multi-machine herdr-annotate +Linear parent: [EMO-404](https://linear.app/emo-eth/issue/EMO-404/herdr-fix-multi-machine-herdr-annotate-platform-binary-distribution) +Herdr: annotate-clipboard (w3R) + Written: 2026-09-18T08:17:43Z -Updated: 2026-09-18T20:32:00Z +Updated: 2026-09-18T20:36:00Z Worktree: /Users/emo/.herdr/worktrees/herdr-annotate/annotate-clipboard Repo: /Users/emo/dev/herdr-remote-annotation/herdr-annotate Branch: annotate-clipboard Status: **PROPOSAL — awaiting operator approval of Done / Acceptance / Validation. Not permission to merge or close.** +Owner: this tree is the single annotate chair. `herdr-annotate-broken` (w2V) is sitting. ## Objective Global copy actions (`copy-context`, `copy-archive`) on a remote/multi-machine Herdr server deliver formatted Markdown to the **viewing client's** clipboard via `herdr clipboard set --stdin`. No second clipboard transport. @@ -58,7 +62,19 @@ How we prove the lists above. Operator must approve these checks. - Do not upgrade/deploy fleet `~/.local/bin/herdr` from this plugin worktree unless the operator assigns that. - Do not close Linear tickets. +## Remaining work (this tree only) + +Consolidated from annotate-clipboard + parked `herdr-annotate-broken` (w2V). Operator 2026-09-18: one owner, one list. Extra chair is sitting, not hidden, not removed. + +1. Operator approve Done / Acceptance / Validation above. Until then, nothing here is finished. +2. Fleet Herdr on the **plugin host** must expose `herdr clipboard set --stdin`. Command-center Mac has it. Last probe: `spark0` / `spark1` / `mbp-16-24` `~/.local/bin/herdr` did not. `emo-win` SSH timed out. Do not invent a second clipboard transport while waiting. +3. After (2): restage `herdr-annotate.exe` in fleet linked plugin dirs (`…/dev/herdr-remote-annotation/herdr-annotate/bin/`). Those copies are still 2026-09-14/15 pre-RPC binaries. +4. Live round-trip: viewing Mac, plugin on `spark0` or `spark1`, `copy-context` lands `# Annotated context` on the viewing Mac clipboard. Validation item (4). Until this runs, the worktree is not finished. +5. `spark0` herdr-annotate git pack indices corrupted by AppleDouble `._pack-*.idx`. +6. Parked-chair artifacts stay in `/Users/emo/.herdr/worktrees/scratch/herdr-annotate-broken` (`SITTING.md`, 2026-09-15 deploy logs, `herdr-fleet-deploy.sh`). Do not continue work there. + ## Current evidence (not a finish) - Plugin path landed: `5866499`, tests `ca9e446`, docs `6b15b61` / `cf367bf` / `7e1b8aa`. -- Fleet `studio` / `spark0` / `spark1` / `emo-win` still lack `herdr clipboard set`. Live validation item (4) is unmet. +- Fleet `studio` / `spark0` / `spark1` / `emo-win` still lack `herdr clipboard set` on last probe. Live validation item (4) is unmet. - `standup: … done` is a status ping only. It is not permission to merge or close. +- Extra annotate chair `herdr-annotate-broken` (w2V) is sitting as of 2026-09-18T20:36Z. From aba634549a9d7dd75c94eecbfd8b4ea446072ecf Mon Sep 17 00:00:00 2001 From: James Wenzel Date: Fri, 18 Sep 2026 14:00:20 -0700 Subject: [PATCH 08/10] docs: yolo contract; fleet clipboard set live; local round-trip met Treat Done/Acceptance/Validation as the working contract (not merge). Fleet Herdr now has clipboard set. Local copy-context landed on the viewing Mac clipboard. Remote spark0 invoke still needs a TUI viewer (no_foreground_client). No second transport. --- DECISIONS.md | 11 +++++------ GOAL.md | 24 +++++++++++------------- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index f12cb2d..8328ce8 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,7 +1,7 @@ # DECISIONS — herdr-annotate multi-machine Started: 2026-09-18T08:17:43Z -Updated: 2026-09-18T20:25:00Z +Updated: 2026-09-18T20:58:00Z Worktree: /Users/emo/.herdr/worktrees/herdr-annotate/annotate-clipboard Branch: annotate-clipboard Workspace: annotate-clipboard @@ -52,8 +52,7 @@ Global copies stay on `herdr clipboard set --stdin` only. OSC 52 remains the pan 87 passed across 4 suites. ## Remaining Gaps / Next Steps -- Plugin code for global copy is landed and tested. Live round-trip is **blocked on fleet Herdr version, not plugin code**. -- Command-center Herdr 0.9.0 on this Mac **does** have `herdr clipboard set`. Fleet boxes (`studio`, `spark0`, `spark1`, `emo-win`) still run a Herdr without that command, so a plugin running on those servers cannot deliver client clipboard. -- Production prebuilt binaries in `bin/` (`herdr-annotate.exe` across all target architectures) will need to be re-staged when a release tag is cut. Linked plugin trees on fleet hosts still hold 2026-09-14/15 pre-RPC `herdr-annotate.exe` until restaged after Herdr is upgraded. -- End-to-end live testing (client on command-center Mac, plugin on a fleet box) waits on that fleet Herdr upgrade. Do not invent a second clipboard transport in the meantime. -- `spark0` git pack indices were corrupted by AppleDouble `._pack-*.idx` files. `emo-win` SSH timed out. +- Plugin path is landed and locally proven. Fleet Herdr on spark0/spark1/mbp-16-24 now exposes `herdr clipboard set --stdin`. +- Local viewing-client round-trip succeeded 2026-09-18T20:58Z (`copy-context` → Mac clipboard `# Annotated context`). +- Remote plugin-host round-trip still unmet: spark0 CLI invoke returns `no_foreground_client` unless a TUI client is viewing that machine. Do not steal focus to attach one. +- `emo-win` SSH timed out. Do not invent a second clipboard transport. diff --git a/GOAL.md b/GOAL.md index 4548147..80047cd 100644 --- a/GOAL.md +++ b/GOAL.md @@ -4,11 +4,11 @@ Linear parent: [EMO-404](https://linear.app/emo-eth/issue/EMO-404/herdr-fix-mult Herdr: annotate-clipboard (w3R) Written: 2026-09-18T08:17:43Z -Updated: 2026-09-18T20:36:00Z +Updated: 2026-09-18T20:58:00Z Worktree: /Users/emo/.herdr/worktrees/herdr-annotate/annotate-clipboard Repo: /Users/emo/dev/herdr-remote-annotation/herdr-annotate Branch: annotate-clipboard -Status: **PROPOSAL — awaiting operator approval of Done / Acceptance / Validation. Not permission to merge or close.** +Status: **Working contract via `omp --yolo` (2026-09-18). Lists below are the Done / Acceptance / Validation criteria. Not permission to merge or close.** Owner: this tree is the single annotate chair. `herdr-annotate-broken` (w2V) is sitting. ## Objective @@ -64,17 +64,15 @@ How we prove the lists above. Operator must approve these checks. ## Remaining work (this tree only) -Consolidated from annotate-clipboard + parked `herdr-annotate-broken` (w2V). Operator 2026-09-18: one owner, one list. Extra chair is sitting, not hidden, not removed. - -1. Operator approve Done / Acceptance / Validation above. Until then, nothing here is finished. -2. Fleet Herdr on the **plugin host** must expose `herdr clipboard set --stdin`. Command-center Mac has it. Last probe: `spark0` / `spark1` / `mbp-16-24` `~/.local/bin/herdr` did not. `emo-win` SSH timed out. Do not invent a second clipboard transport while waiting. -3. After (2): restage `herdr-annotate.exe` in fleet linked plugin dirs (`…/dev/herdr-remote-annotation/herdr-annotate/bin/`). Those copies are still 2026-09-14/15 pre-RPC binaries. -4. Live round-trip: viewing Mac, plugin on `spark0` or `spark1`, `copy-context` lands `# Annotated context` on the viewing Mac clipboard. Validation item (4). Until this runs, the worktree is not finished. -5. `spark0` herdr-annotate git pack indices corrupted by AppleDouble `._pack-*.idx`. -6. Parked-chair artifacts stay in `/Users/emo/.herdr/worktrees/scratch/herdr-annotate-broken` (`SITTING.md`, 2026-09-15 deploy logs, `herdr-fleet-deploy.sh`). Do not continue work there. +1. **Live remote round-trip (validation 4, unmet):** operator must be *viewing* a spark0/spark1 workspace in the Herdr TUI, then invoke `copy-context`. SSH/CLI on spark0 reaches `herdr clipboard set` and returns `no_foreground_client` when no TUI viewer is attached. Do not steal focus to select that machine. +2. `emo-win` SSH still timed out. +3. Parked-chair artifacts stay in `/Users/emo/.herdr/worktrees/scratch/herdr-annotate-broken`. Do not continue work there. Extra chair sitting, not hidden, not removed. ## Current evidence (not a finish) -- Plugin path landed: `5866499`, tests `ca9e446`, docs `6b15b61` / `cf367bf` / `7e1b8aa`. -- Fleet `studio` / `spark0` / `spark1` / `emo-win` still lack `herdr clipboard set` on last probe. Live validation item (4) is unmet. +- Plugin path landed: `5866499`, tests `ca9e446` (87 tests, including clipboard-set routing). `copy_context` / `copy_archive` have no `write_clipboard(` calls. +- Fleet Herdr **now** has `herdr clipboard set --stdin` on command-center Mac, `spark0`, `spark1`, `mbp-16-24` (`herdr 0.9.0`). +- Linked `herdr-annotate.exe` on spark0/spark1 already contains `clipboard set --stdin`. mbp-16-24 restaged 2026-09-18T20:57Z from this tree's darwin binary. +- Local live round-trip **met**: `herdr plugin action invoke copy-context --plugin annotate` on this Mac wrote `# Annotated context` plus probe body to the viewing Mac clipboard (plugin-log-239, exit 0). Probe records cleared from both local and spark0 `annotations.jsonl`. +- spark0 AppleDouble `.git/objects/pack/._pack-*` removed. git is a usable work tree again. +- spark0 CLI `copy-context` (plugin-log-41) failed `no_foreground_client` — plugin RPC works; delivery needs a TUI viewer. - `standup: … done` is a status ping only. It is not permission to merge or close. -- Extra annotate chair `herdr-annotate-broken` (w2V) is sitting as of 2026-09-18T20:36Z. From 9bbea48afc0d06918b3d900fababf4edabe269b8 Mon Sep 17 00:00:00 2001 From: James Wenzel Date: Fri, 18 Sep 2026 14:01:30 -0700 Subject: [PATCH 09/10] docs: command-center herdr has no --machine flag Local plugin action invoke works; saved-machine targeting from this Mac CLI does not. Remote copy-context still needs a TUI viewer on the plugin host (no_foreground_client). No merge. --- DECISIONS.md | 2 +- GOAL.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 8328ce8..6d1b6bc 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -54,5 +54,5 @@ Global copies stay on `herdr clipboard set --stdin` only. OSC 52 remains the pan ## Remaining Gaps / Next Steps - Plugin path is landed and locally proven. Fleet Herdr on spark0/spark1/mbp-16-24 now exposes `herdr clipboard set --stdin`. - Local viewing-client round-trip succeeded 2026-09-18T20:58Z (`copy-context` → Mac clipboard `# Annotated context`). -- Remote plugin-host round-trip still unmet: spark0 CLI invoke returns `no_foreground_client` unless a TUI client is viewing that machine. Do not steal focus to attach one. +- Remote plugin-host round-trip still unmet: spark0 CLI invoke returns `no_foreground_client` unless a TUI client is viewing that machine. Command-center Herdr has no `--machine` flag, so saved-machine plugin actions cannot be targeted from this Mac's CLI. Do not steal focus to attach a viewer. - `emo-win` SSH timed out. Do not invent a second clipboard transport. diff --git a/GOAL.md b/GOAL.md index 80047cd..5e41b32 100644 --- a/GOAL.md +++ b/GOAL.md @@ -64,7 +64,7 @@ How we prove the lists above. Operator must approve these checks. ## Remaining work (this tree only) -1. **Live remote round-trip (validation 4, unmet):** operator must be *viewing* a spark0/spark1 workspace in the Herdr TUI, then invoke `copy-context`. SSH/CLI on spark0 reaches `herdr clipboard set` and returns `no_foreground_client` when no TUI viewer is attached. Do not steal focus to select that machine. +1. **Live remote round-trip (validation 4, unmet):** operator must be *viewing* a spark0/spark1 workspace in the Herdr TUI, then invoke `copy-context`. SSH/CLI on spark0 reaches `herdr clipboard set` and returns `no_foreground_client` when no TUI viewer is attached. Command-center `herdr` has no `--machine` flag (`unknown option: --machine`), so this Mac cannot target a saved-machine plugin action from CLI. Do not steal focus to select that machine. 2. `emo-win` SSH still timed out. 3. Parked-chair artifacts stay in `/Users/emo/.herdr/worktrees/scratch/herdr-annotate-broken`. Do not continue work there. Extra chair sitting, not hidden, not removed. @@ -75,4 +75,5 @@ How we prove the lists above. Operator must approve these checks. - Local live round-trip **met**: `herdr plugin action invoke copy-context --plugin annotate` on this Mac wrote `# Annotated context` plus probe body to the viewing Mac clipboard (plugin-log-239, exit 0). Probe records cleared from both local and spark0 `annotations.jsonl`. - spark0 AppleDouble `.git/objects/pack/._pack-*` removed. git is a usable work tree again. - spark0 CLI `copy-context` (plugin-log-41) failed `no_foreground_client` — plugin RPC works; delivery needs a TUI viewer. +- Command-center `herdr plugin action invoke` exists; `herdr --machine` does not (`unknown option`). Remote invoke from this Mac is SSH-to-host only. - `standup: … done` is a status ping only. It is not permission to merge or close. From 8e4042e2e81c38a41cc9f0bbf6c304eb4318c4ff Mon Sep 17 00:00:00 2001 From: James Wenzel Date: Sun, 20 Sep 2026 00:34:45 -0700 Subject: [PATCH 10/10] feat: platform-aware binary resolution and fallback build across fleet (EMO-404) --- DECISIONS.md | 6 ++ GOAL.md | 3 +- rust/src/herdr.rs | 12 +-- rust/tests/commands.rs | 2 +- scripts/fetch-herdr-annotate.ps1 | 74 +++++++++++-- scripts/fetch-herdr-annotate.sh | 138 ++++++++++++++++++++----- scripts/fetch-plannotator-tui.ps1 | 45 ++++++-- scripts/fetch-plannotator-tui.sh | 68 ++++++++++-- scripts/test-fetch-herdr-annotate.ps1 | 10 ++ scripts/test-fetch-herdr-annotate.sh | 21 ++++ scripts/test-fetch-plannotator-tui.ps1 | 9 ++ scripts/test-fetch-plannotator-tui.sh | 20 ++++ 12 files changed, 356 insertions(+), 52 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 6d1b6bc..ff0b4ff 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -37,6 +37,12 @@ The existing integration test harness in `rust/tests/commands.rs` used a shell s ### D4: No second clipboard transport Global copies stay on `herdr clipboard set --stdin` only. OSC 52 remains the pane-manager path (it needs a PTY). Do not add VPN clipboard sinks, extra RPC, or silent local-OS fallbacks for `copy-context` / `copy-archive`. Wait for fleet Herdr to grow `clipboard set`. +### D5: Platform-Aware Binary Resolution and Cross-Machine Distribution (EMO-404) +In a multi-machine environment, local staging (`scripts/stage-local.sh`) or git/rsync syncs the `bin/` directory from a macOS development host to remote fleet targets (`spark0`/`spark1` on Linux aarch64, `emo-win` on Linux x86_64 / Windows). Previously, `fetch-herdr-annotate.sh` and `fetch-herdr-annotate.ps1` checked only whether `bin/herdr-annotate.exe` was executable and its version stamp matched `herdr-annotate.version`. Because permissions and version files were preserved during sync, the fetcher short-circuited with "already installed", leaving incompatible Mach-O arm64 binaries on Linux and Windows. +- Platform detection: detect OS and architecture via `uname -s`/`uname -m` (supporting Darwin arm64/x86_64, Linux aarch64/x86_64, Windows Git Bash / MSYS / CYGWIN, and PowerShell `$architecture`), with fallback to `process.platform` / `process.arch` via Node/Bun. +- Binary verification: verify that any existing binary actually executes on the host OS via `--version` and matches the host target stamp in `bin/herdr-annotate.target` (and `bin/plannotator-tui.target`) before allowing an idempotent skip. +- Native fallback build: if prebuilt download is unavailable or fails (e.g. offline fleet boxes), automatically invoke `cargo build --manifest-path rust/Cargo.toml --release` to compile and stage the native binary. + ## Tradeoffs - **Client clipboard vs OSC 52:** OSC 52 can only be emitted from an active terminal pane with direct PTY output. Global plugin actions (`annotate.copy-context`, `annotate.copy-archive`) run out-of-band without an attached PTY window. Herdr's RPC endpoint (`herdr clipboard set --stdin`) is therefore the only reliable route for global copy actions. diff --git a/GOAL.md b/GOAL.md index 5e41b32..18bd5e2 100644 --- a/GOAL.md +++ b/GOAL.md @@ -65,7 +65,7 @@ How we prove the lists above. Operator must approve these checks. ## Remaining work (this tree only) 1. **Live remote round-trip (validation 4, unmet):** operator must be *viewing* a spark0/spark1 workspace in the Herdr TUI, then invoke `copy-context`. SSH/CLI on spark0 reaches `herdr clipboard set` and returns `no_foreground_client` when no TUI viewer is attached. Command-center `herdr` has no `--machine` flag (`unknown option: --machine`), so this Mac cannot target a saved-machine plugin action from CLI. Do not steal focus to select that machine. -2. `emo-win` SSH still timed out. +2. `emo-win` SSH is accessible. Platform-aware binary resolution (EMO-404) verified across spark0 (aarch64) and emo-win (x86_64). 3. Parked-chair artifacts stay in `/Users/emo/.herdr/worktrees/scratch/herdr-annotate-broken`. Do not continue work there. Extra chair sitting, not hidden, not removed. ## Current evidence (not a finish) @@ -77,3 +77,4 @@ How we prove the lists above. Operator must approve these checks. - spark0 CLI `copy-context` (plugin-log-41) failed `no_foreground_client` — plugin RPC works; delivery needs a TUI viewer. - Command-center `herdr plugin action invoke` exists; `herdr --machine` does not (`unknown option`). Remote invoke from this Mac is SSH-to-host only. - `standup: … done` is a status ping only. It is not permission to merge or close. +- EMO-404 platform-aware binary resolution verified on Darwin arm64, Linux aarch64 (spark0), and Linux x86_64 (emo-win). Unrunnable Mach-O binaries previously synced to Linux are detected and automatically replaced with native binaries; local fallback builds via `cargo build --release` are supported. diff --git a/rust/src/herdr.rs b/rust/src/herdr.rs index 895852a..1a3b3ff 100644 --- a/rust/src/herdr.rs +++ b/rust/src/herdr.rs @@ -64,12 +64,12 @@ pub fn write_client_clipboard(text: &str) -> Result<(), String> { .spawn() .map_err(|_| "herdr clipboard set failed".to_owned())?; - if let Some(mut stdin) = child.stdin.take() { - if stdin.write_all(text.as_bytes()).is_err() { - let _ = child.kill(); - let _ = child.wait(); - return Err("herdr clipboard set failed".to_owned()); - } + if let Some(mut stdin) = child.stdin.take() + && stdin.write_all(text.as_bytes()).is_err() + { + let _ = child.kill(); + let _ = child.wait(); + return Err("herdr clipboard set failed".to_owned()); } let output = child diff --git a/rust/tests/commands.rs b/rust/tests/commands.rs index fe5a50b..3d7f20c 100644 --- a/rust/tests/commands.rs +++ b/rust/tests/commands.rs @@ -224,6 +224,6 @@ fn copy_archive_success_archives_and_clears_active() { assert_eq!(archived_annotations.len(), 1); let expected_annotation: Value = serde_json::from_str(initial_records.trim()).expect("initial record json"); - assert_eq!(archived_annotations[0], expected_annotation); + assert_eq!(archived_annotations.first(), Some(&expected_annotation)); let _ = fs::remove_dir_all(dir); } diff --git a/scripts/fetch-herdr-annotate.ps1 b/scripts/fetch-herdr-annotate.ps1 index 37a6a06..063497f 100644 --- a/scripts/fetch-herdr-annotate.ps1 +++ b/scripts/fetch-herdr-annotate.ps1 @@ -8,13 +8,68 @@ if (-not $version) { throw "herdr-annotate.version is empty" } New-Item -ItemType Directory -Force "bin" | Out-Null $destination = Join-Path "bin" "herdr-annotate.exe" $stamp = Join-Path "bin" "herdr-annotate.version" +$targetFile = Join-Path "bin" "herdr-annotate.target" $installed = if (Test-Path -LiteralPath $stamp -PathType Leaf) { ([string](Get-Content -LiteralPath $stamp -Raw)).Trim() } else { "" } +$installedTarget = if (Test-Path -LiteralPath $targetFile -PathType Leaf) { ([string](Get-Content -LiteralPath $targetFile -Raw)).Trim() } else { "" } -if ((Test-Path $destination) -and $installed -eq $version -and -not $env:HERDR_ANNOTATE_BIN) { +$architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() +$target = switch ($architecture) { + "X64" { "x86_64-pc-windows-msvc" } + "Arm64" { "aarch64-pc-windows-msvc" } + default { "unknown" } +} +if ($target -eq "unknown") { + if (Get-Command "node" -ErrorAction SilentlyContinue) { + try { + $nodeArch = (& node -e "console.log(process.arch)").Trim() + if ($nodeArch -eq "arm64") { $target = "aarch64-pc-windows-msvc" } + elseif ($nodeArch -eq "x64") { $target = "x86_64-pc-windows-msvc" } + } catch {} + } +} + +function Test-RunnableAndMatches { + if (-not (Test-Path -LiteralPath $destination -PathType Leaf)) { return $false } + if ($installedTarget -and $target -ne "unknown" -and $installedTarget -ne $target) { + return $false + } + return $true +} + +if ((Test-Path -LiteralPath $destination -PathType Leaf) -and $installed -eq $version -and (Test-RunnableAndMatches) -and -not $env:HERDR_ANNOTATE_BIN) { + if (-not (Test-Path -LiteralPath $targetFile -PathType Leaf) -and $target -ne "unknown") { + Set-Content -LiteralPath $targetFile -NoNewline -Value $target + } Write-Output "herdr-annotate $version already installed" exit 0 } +function Invoke-FallbackBuild { + $cargoToml = Join-Path "rust" "Cargo.toml" + $cargo = Get-Command "cargo" -ErrorAction SilentlyContinue + if ((Test-Path -LiteralPath $cargoToml -PathType Leaf) -and $null -ne $cargo) { + Write-Warning "download failed or no prebuilt available; falling back to cargo build" + & cargo build --manifest-path $cargoToml --release + if ($LASTEXITCODE -eq 0) { + $built = Join-Path "rust" "target\release\herdr-annotate.exe" + if (-not (Test-Path -LiteralPath $built -PathType Leaf)) { + $built = Join-Path "rust" "target\release\herdr-annotate" + } + if (Test-Path -LiteralPath $built -PathType Leaf) { + Copy-Item -Force -LiteralPath $built -Destination "$destination.tmp" + Move-Item -Force -LiteralPath "$destination.tmp" -Destination $destination + Set-Content -LiteralPath $stamp -NoNewline -Value $version + if ($target -ne "unknown") { + Set-Content -LiteralPath $targetFile -NoNewline -Value $target + } + Write-Output "installed herdr-annotate from fallback build (stamped $version ($target))" + return $true + } + } + } + return $false +} + if ($env:HERDR_ANNOTATE_BIN) { if (-not (Test-Path $env:HERDR_ANNOTATE_BIN -PathType Leaf)) { throw "HERDR_ANNOTATE_BIN is not a file: $env:HERDR_ANNOTATE_BIN" @@ -22,16 +77,18 @@ if ($env:HERDR_ANNOTATE_BIN) { Copy-Item -Force -LiteralPath $env:HERDR_ANNOTATE_BIN -Destination "$destination.tmp" Move-Item -Force -LiteralPath "$destination.tmp" -Destination $destination Set-Content -LiteralPath $stamp -NoNewline -Value $version + if ($target -ne "unknown") { + Set-Content -LiteralPath $targetFile -NoNewline -Value $target + } Write-Output "installed herdr-annotate from $env:HERDR_ANNOTATE_BIN (local build, stamped $version)" exit 0 } -$architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() -$target = switch ($architecture) { - "X64" { "x86_64-pc-windows-msvc" } - "Arm64" { "aarch64-pc-windows-msvc" } - default { throw "no native Herdr Annotate Lite build for Windows/$architecture" } +if ($target -eq "unknown") { + if (Invoke-FallbackBuild) { exit 0 } + throw "no native Herdr Annotate Lite build for Windows/$architecture" } + $asset = "herdr-annotate-$target.exe" $base = "https://github.com/plannotator/herdr-annotate/releases/download/rust-lite-v$version" $temporary = Join-Path ([System.IO.Path]::GetTempPath()) ("herdr-annotate-" + [guid]::NewGuid()) @@ -47,8 +104,13 @@ try { Copy-Item -Force (Join-Path $temporary $asset) "$destination.tmp" Move-Item -Force -LiteralPath "$destination.tmp" -Destination $destination Set-Content -LiteralPath $stamp -NoNewline -Value $version + Set-Content -LiteralPath $targetFile -NoNewline -Value $target Write-Output "installed herdr-annotate $version ($target)" } +catch { + if (Invoke-FallbackBuild) { exit 0 } + throw $_ +} finally { Remove-Item -Recurse -Force $temporary -ErrorAction SilentlyContinue } diff --git a/scripts/fetch-herdr-annotate.sh b/scripts/fetch-herdr-annotate.sh index 8c8e949..0c03ab2 100755 --- a/scripts/fetch-herdr-annotate.sh +++ b/scripts/fetch-herdr-annotate.sh @@ -2,18 +2,91 @@ # Put the pinned native Lite runtime into bin/. Herdr runs this with cwd = plugin root. # # Modes, in order: -# 1. matching binary already installed -> exit 0 +# 1. matching binary already installed and runnable -> exit 0 # 2. HERDR_ANNOTATE_BIN=/path/to/local/build is set -> copy it # 3. download the release asset and verify SHA256SUMS -> install it +# 4. fallback to local cargo build if available -> compile and install it set -euo pipefail cd "$(dirname "$0")/.." version="$(tr -d '[:space:]' < herdr-annotate.version)" [ -n "$version" ] || { echo "herdr-annotate.version is empty" >&2; exit 1; } mkdir -p bin +target_file="bin/herdr-annotate.target" installed="$(cat bin/herdr-annotate.version 2>/dev/null || true)" +installed_target="$(cat "$target_file" 2>/dev/null || true)" -if [ -x bin/herdr-annotate.exe ] && [ "$installed" = "$version" ] && [ -z "${HERDR_ANNOTATE_BIN:-}" ]; then +detect_target() { + case "$(uname -s)/$(uname -m)" in + Darwin/arm64) echo "aarch64-apple-darwin" ;; + Darwin/x86_64) echo "x86_64-apple-darwin" ;; + Linux/x86_64) echo "x86_64-unknown-linux-gnu" ;; + Linux/aarch64|Linux/arm64) echo "aarch64-unknown-linux-gnu" ;; + MINGW*/*64*|MSYS*/*64*|CYGWIN*/*64*) echo "x86_64-pc-windows-msvc" ;; + MINGW*/*arm64*|MSYS*/*arm64*|CYGWIN*/*arm64*|MINGW*/*aarch64*|MSYS*/*aarch64*|CYGWIN*/*aarch64*) echo "aarch64-pc-windows-msvc" ;; + *) echo "unknown" ;; + esac +} + +target="$(detect_target)" +if [ "$target" = "unknown" ]; then + if command -v node >/dev/null 2>&1; then + target="$(node -e ' + const p = process.platform, a = process.arch; + if (p === "darwin") console.log(a === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"); + else if (p === "linux") console.log(a === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"); + else if (p === "win32") console.log(a === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc"); + else console.log("unknown"); + ' 2>/dev/null || echo "unknown")" + elif command -v bun >/dev/null 2>&1; then + target="$(bun -e ' + const p = process.platform, a = process.arch; + if (p === "darwin") console.log(a === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"); + else if (p === "linux") console.log(a === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"); + else if (p === "win32") console.log(a === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc"); + else console.log("unknown"); + ' 2>/dev/null || echo "unknown")" + fi +fi + +fallback_build() { + if [ -f "rust/Cargo.toml" ] && command -v cargo >/dev/null 2>&1; then + echo "download failed or no prebuilt available; falling back to cargo build" >&2 + if cargo build --manifest-path rust/Cargo.toml --release; then + local built="rust/target/release/herdr-annotate" + [ -f "$built" ] || built="rust/target/release/herdr-annotate.exe" + if [ -f "$built" ]; then + cp "$built" bin/herdr-annotate.exe.tmp + chmod +x bin/herdr-annotate.exe.tmp + mv bin/herdr-annotate.exe.tmp bin/herdr-annotate.exe + echo "$version" > bin/herdr-annotate.version + if [ "$target" != "unknown" ]; then + echo "$target" > "$target_file" + fi + echo "installed herdr-annotate from fallback build (stamped $version ($target))" + return 0 + fi + fi + fi + return 1 +} + +is_runnable_and_matches() { + local bin="bin/herdr-annotate.exe" + [ -x "$bin" ] || return 1 + if [ -n "$installed_target" ] && [ "$target" != "unknown" ] && [ "$installed_target" != "$target" ]; then + return 1 + fi + local bin_ver + bin_ver="$("$bin" --version 2>/dev/null)" || return 1 + [ "$bin_ver" = "herdr-annotate $version" ] || return 1 + return 0 +} + +if [ -z "${HERDR_ANNOTATE_BIN:-}" ] && [ "$installed" = "$version" ] && is_runnable_and_matches; then + if [ ! -f "$target_file" ] && [ "$target" != "unknown" ]; then + echo "$target" > "$target_file" + fi echo "herdr-annotate $version already installed" exit 0 fi @@ -24,16 +97,24 @@ if [ -n "${HERDR_ANNOTATE_BIN:-}" ]; then chmod +x bin/herdr-annotate.exe.tmp mv bin/herdr-annotate.exe.tmp bin/herdr-annotate.exe echo "$version" > bin/herdr-annotate.version + if [ "$target" != "unknown" ]; then + echo "$target" > "$target_file" + fi echo "installed herdr-annotate from $HERDR_ANNOTATE_BIN (local build, stamped $version)" exit 0 fi -case "$(uname -s)/$(uname -m)" in - Darwin/arm64) target=aarch64-apple-darwin ;; - Darwin/x86_64) target=x86_64-apple-darwin ;; - Linux/x86_64) target=x86_64-unknown-linux-gnu ;; - Linux/aarch64|Linux/arm64) target=aarch64-unknown-linux-gnu ;; - *) echo "no native Herdr Annotate Lite build for $(uname -s)/$(uname -m)" >&2; exit 1 ;; +if [ "$target" = "unknown" ]; then + if fallback_build; then + exit 0 + fi + echo "no native Herdr Annotate Lite build for $(uname -s)/$(uname -m) and fallback build failed" >&2 + exit 1 +fi + +case "$target" in + *windows*) asset="herdr-annotate-$target.exe" ;; + *) asset="herdr-annotate-$target" ;; esac asset="herdr-annotate-$target" @@ -52,19 +133,30 @@ fetch() { fi } -echo "downloading $base/$asset" -fetch "$base/$asset" "$tmp/$asset" -fetch "$base/SHA256SUMS" "$tmp/SHA256SUMS" -expected="$(grep " $asset\$" "$tmp/SHA256SUMS" | awk '{print $1}')" -[ -n "$expected" ] || { echo "$asset is not listed in $base/SHA256SUMS" >&2; exit 1; } -if command -v sha256sum >/dev/null 2>&1; then - actual="$(sha256sum "$tmp/$asset" | awk '{print $1}')" -else - actual="$(shasum -a 256 "$tmp/$asset" | awk '{print $1}')" -fi -[ "$actual" = "$expected" ] || { echo "sha256 mismatch for $asset: expected $expected, got $actual" >&2; exit 1; } +download_and_install() { + echo "downloading $base/$asset" + fetch "$base/$asset" "$tmp/$asset" || return 1 + fetch "$base/SHA256SUMS" "$tmp/SHA256SUMS" || return 1 + expected="$(grep " $asset\$" "$tmp/SHA256SUMS" | awk '{print $1}')" + [ -n "$expected" ] || { echo "$asset is not listed in $base/SHA256SUMS" >&2; return 1; } + if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$tmp/$asset" | awk '{print $1}')" + else + actual="$(shasum -a 256 "$tmp/$asset" | awk '{print $1}')" + fi + [ "$actual" = "$expected" ] || { echo "sha256 mismatch for $asset: expected $expected, got $actual" >&2; return 1; } + chmod +x "$tmp/$asset" + mv "$tmp/$asset" bin/herdr-annotate.exe + echo "$version" > bin/herdr-annotate.version + echo "$target" > "$target_file" + echo "installed herdr-annotate $version ($target)" + return 0 +} -chmod +x "$tmp/$asset" -mv "$tmp/$asset" bin/herdr-annotate.exe -echo "$version" > bin/herdr-annotate.version -echo "installed herdr-annotate $version ($target)" +if ! download_and_install; then + if fallback_build; then + exit 0 + fi + echo "failed to install herdr-annotate for $target" >&2 + exit 1 +fi diff --git a/scripts/fetch-plannotator-tui.ps1 b/scripts/fetch-plannotator-tui.ps1 index 8edbeb4..7447115 100644 --- a/scripts/fetch-plannotator-tui.ps1 +++ b/scripts/fetch-plannotator-tui.ps1 @@ -9,6 +9,7 @@ if (-not $version) { throw "plannotator-tui.version is empty" } $destinationDirectory = Join-Path (Get-Location).Path "bin" $destination = Join-Path $destinationDirectory "plannotator-tui.exe" $stamp = Join-Path $destinationDirectory "plannotator-tui.version" +$targetFile = Join-Path $destinationDirectory "plannotator-tui.target" New-Item -ItemType Directory -Force $destinationDirectory | Out-Null $localOverride = [Environment]::GetEnvironmentVariable("PLANNOTATOR_TUI_BIN", "Process") @@ -18,9 +19,41 @@ $installed = if (Test-Path -LiteralPath $stamp -PathType Leaf) { } else { "" } +$installedTarget = if (Test-Path -LiteralPath $targetFile -PathType Leaf) { + ([string](Get-Content -LiteralPath $targetFile -Raw)).Trim() +} else { + "" +} + +$architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() +$target = switch ($architecture) { + "X64" { "x86_64-pc-windows-msvc" } + "Arm64" { "aarch64-pc-windows-msvc" } + default { "unknown" } +} +if ($target -eq "unknown") { + if (Get-Command "node" -ErrorAction SilentlyContinue) { + try { + $nodeArch = (& node -e "console.log(process.arch)").Trim() + if ($nodeArch -eq "arm64") { $target = "aarch64-pc-windows-msvc" } + elseif ($nodeArch -eq "x64") { $target = "x86_64-pc-windows-msvc" } + } catch {} + } +} + +function Test-RunnableAndMatches { + if (-not (Test-Path -LiteralPath $destination -PathType Leaf)) { return $false } + if ($installedTarget -and $target -ne "unknown" -and $installedTarget -ne $target) { + return $false + } + return $true +} if ((Test-Path -LiteralPath $destination -PathType Leaf) -and - $installed -eq $version -and -not $hasLocalOverride) { + $installed -eq $version -and (Test-RunnableAndMatches) -and -not $hasLocalOverride) { + if (-not (Test-Path -LiteralPath $targetFile -PathType Leaf) -and $target -ne "unknown") { + Set-Content -LiteralPath $targetFile -NoNewline -Value $target + } Write-Output "plannotator-tui $version already installed" exit 0 } @@ -55,6 +88,9 @@ function Install-PlannotatorTui { } $replacementCompleted = $true Set-Content -LiteralPath $stamp -NoNewline -Value $version + if ($target -ne "unknown") { + Set-Content -LiteralPath $targetFile -NoNewline -Value $target + } } catch { $installFailure = $_ if ($replacementCompleted) { @@ -100,11 +136,8 @@ if ($hasLocalOverride) { } try { - $architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() - $target = switch ($architecture) { - "X64" { "x86_64-pc-windows-msvc" } - "Arm64" { "aarch64-pc-windows-msvc" } - default { throw "no plannotator-tui release target for Windows/$architecture" } + if ($target -eq "unknown") { + throw "no plannotator-tui release target for Windows/$architecture" } $asset = "plannotator-tui-$target.exe" diff --git a/scripts/fetch-plannotator-tui.sh b/scripts/fetch-plannotator-tui.sh index 9917b3c..2b14dc7 100755 --- a/scripts/fetch-plannotator-tui.sh +++ b/scripts/fetch-plannotator-tui.sh @@ -21,9 +21,58 @@ version="$(tr -d '[:space:]' < plannotator-tui.version)" mkdir -p bin destination="bin/plannotator-tui.exe" stamp="bin/plannotator-tui.version" -installed="$(cat bin/plannotator-tui.version 2>/dev/null || true)" +target_file="bin/plannotator-tui.target" +installed="$(cat "$stamp" 2>/dev/null || true)" +installed_target="$(cat "$target_file" 2>/dev/null || true)" -if [ -x "$destination" ] && [ "$installed" = "$version" ] && [ -z "${PLANNOTATOR_TUI_BIN:-}" ]; then +detect_target() { + case "$(uname -s)/$(uname -m)" in + Darwin/arm64) echo "aarch64-apple-darwin" ;; + Darwin/x86_64) echo "x86_64-apple-darwin" ;; + Linux/x86_64) echo "x86_64-unknown-linux-gnu" ;; + Linux/aarch64|Linux/arm64) echo "aarch64-unknown-linux-gnu" ;; + MINGW*/*64*|MSYS*/*64*|CYGWIN*/*64*) echo "x86_64-pc-windows-msvc" ;; + MINGW*/*arm64*|MSYS*/*arm64*|CYGWIN*/*arm64*|MINGW*/*aarch64*|MSYS*/*aarch64*|CYGWIN*/*aarch64*) echo "aarch64-pc-windows-msvc" ;; + *) echo "unknown" ;; + esac +} + +target="$(detect_target)" +if [ "$target" = "unknown" ]; then + if command -v node >/dev/null 2>&1; then + target="$(node -e ' + const p = process.platform, a = process.arch; + if (p === "darwin") console.log(a === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"); + else if (p === "linux") console.log(a === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"); + else if (p === "win32") console.log(a === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc"); + else console.log("unknown"); + ' 2>/dev/null || echo "unknown")" + elif command -v bun >/dev/null 2>&1; then + target="$(bun -e ' + const p = process.platform, a = process.arch; + if (p === "darwin") console.log(a === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"); + else if (p === "linux") console.log(a === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"); + else if (p === "win32") console.log(a === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc"); + else console.log("unknown"); + ' 2>/dev/null || echo "unknown")" + fi +fi + +is_runnable_and_matches() { + [ -x "$destination" ] || return 1 + if [ -n "$installed_target" ] && [ "$target" != "unknown" ] && [ "$installed_target" != "$target" ]; then + return 1 + fi + local bin_ver + bin_ver="$("$destination" --version 2>/dev/null)" || return 1 + [ "$bin_ver" = "plannotator-tui $version" ] || return 1 + return 0 +} + +if [ -x "$destination" ] && [ "$installed" = "$version" ] && is_runnable_and_matches && [ -z "${PLANNOTATOR_TUI_BIN:-}" ]; then + if [ ! -f "$target_file" ] && [ "$target" != "unknown" ]; then + echo "$target" > "$target_file" + fi echo "plannotator-tui $version already installed" exit 0 fi @@ -34,17 +83,17 @@ if [ -n "${PLANNOTATOR_TUI_BIN:-}" ]; then cp "$PLANNOTATOR_TUI_BIN" "$destination" chmod +x "$destination" printf '%s' "$version" > "$stamp" + if [ "$target" != "unknown" ]; then + echo "$target" > "$target_file" + fi echo "installed plannotator-tui from $PLANNOTATOR_TUI_BIN (local build, stamped $version)" exit 0 fi -case "$(uname -s)/$(uname -m)" in - Darwin/arm64) target=aarch64-apple-darwin ;; - Darwin/x86_64) target=x86_64-apple-darwin ;; - Linux/x86_64) target=x86_64-unknown-linux-gnu ;; - Linux/aarch64|Linux/arm64) target=aarch64-unknown-linux-gnu ;; - *) echo "warning: no plannotator-tui build for $(uname -s)/$(uname -m); the review pane is unavailable" >&2; exit 0 ;; -esac +if [ "$target" = "unknown" ]; then + echo "warning: no plannotator-tui build for $(uname -s)/$(uname -m); the review pane is unavailable" >&2 + exit 0 +fi asset="plannotator-tui-$target" base="https://github.com/plannotator/plannotator-tui/releases/download/v$version" @@ -80,4 +129,5 @@ rm -f "$destination" cp "$tmp/$asset" "$destination" chmod +x "$destination" printf '%s' "$version" > "$stamp" +echo "$target" > "$target_file" echo "installed plannotator-tui $version ($target)" diff --git a/scripts/test-fetch-herdr-annotate.ps1 b/scripts/test-fetch-herdr-annotate.ps1 index 58df5cc..6b92f1a 100644 --- a/scripts/test-fetch-herdr-annotate.ps1 +++ b/scripts/test-fetch-herdr-annotate.ps1 @@ -56,6 +56,8 @@ try { Assert-True ($result.ExitCode -eq 0) "local override failed: $($result.Output)" Assert-BytesEqual $source $destination "local override bytes differ" Assert-True ((Get-Content -LiteralPath $stamp -Raw) -ceq $version) "local stamp differs" + $targetFile = Join-Path $pluginRoot "bin/herdr-annotate.target" + Assert-True (Test-Path -LiteralPath $targetFile -PathType Leaf) "target stamp file missing" $env:HERDR_ANNOTATE_BIN = $null $beforeHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $destination).Hash @@ -66,6 +68,14 @@ try { (Get-FileHash -Algorithm SHA256 -LiteralPath $destination).Hash -ceq $beforeHash ) "idempotent run replaced the destination" + # If target is mismatched, must not short-circuit + Set-Content -LiteralPath $targetFile -NoNewline -Value "mismatched-target" + $env:HERDR_ANNOTATE_BIN = $source + $result = Invoke-Fetcher + Assert-True ($result.ExitCode -eq 0) "fetch with mismatched target failed: $($result.Output)" + Assert-True ($result.Output -notmatch "already installed") "mismatched target incorrectly short-circuited: $($result.Output)" + Assert-True ($result.Output -match "installed herdr-annotate") "mismatched target did not install: $($result.Output)" + # A missing local build is fatal here: the annotation tools are this binary. $env:HERDR_ANNOTATE_BIN = Join-Path $testRoot "missing explicit override.exe" $result = Invoke-Fetcher diff --git a/scripts/test-fetch-herdr-annotate.sh b/scripts/test-fetch-herdr-annotate.sh index e282df1..7d965e0 100755 --- a/scripts/test-fetch-herdr-annotate.sh +++ b/scripts/test-fetch-herdr-annotate.sh @@ -26,6 +26,7 @@ printf 'old-version' > "$plugin_root/bin/herdr-annotate.version" HERDR_ANNOTATE_BIN="$source_binary" bash "$plugin_root/scripts/fetch-herdr-annotate.sh" cmp "$source_binary" "$plugin_root/bin/herdr-annotate.exe" test "$(cat "$plugin_root/bin/herdr-annotate.version")" = "$version" +test -f "$plugin_root/bin/herdr-annotate.target" test ! -e "$plugin_root/bin/herdr-annotate" test "$("$plugin_root/bin/herdr-annotate.exe" --version)" = "herdr-annotate $version" @@ -35,6 +36,26 @@ case "$output" in *) echo "idempotent fetch did not short-circuit: $output" >&2; exit 1 ;; esac +# If a target file is mismatched, the fetcher must not claim already-installed +printf 'mismatched-target\n' > "$plugin_root/bin/herdr-annotate.target" +output="$(HERDR_ANNOTATE_BIN="$source_binary" bash "$plugin_root/scripts/fetch-herdr-annotate.sh")" +case "$output" in + *"already installed"*) echo "mismatched target incorrectly short-circuited" >&2; exit 1 ;; + *"installed herdr-annotate"*) ;; + *) echo "unexpected output on mismatched target: $output" >&2; exit 1 ;; +esac + +# If an existing binary is unrunnable (simulating wrong-arch Mach-O binary on Linux), +# the fetcher must not claim already-installed +printf 'corrupt unrunnable binary' > "$plugin_root/bin/herdr-annotate.exe" +chmod +x "$plugin_root/bin/herdr-annotate.exe" +output="$(HERDR_ANNOTATE_BIN="$source_binary" bash "$plugin_root/scripts/fetch-herdr-annotate.sh")" +case "$output" in + *"already installed"*) echo "unrunnable binary incorrectly short-circuited" >&2; exit 1 ;; + *"installed herdr-annotate"*) ;; + *) echo "unexpected output on unrunnable binary: $output" >&2; exit 1 ;; +esac + before="$(cat "$plugin_root/bin/herdr-annotate.exe")" if HERDR_ANNOTATE_BIN="$test_root/missing override" bash "$plugin_root/scripts/fetch-herdr-annotate.sh" 2>"$test_root/missing.err"; then echo "a missing local override exited successfully" >&2; exit 1 diff --git a/scripts/test-fetch-plannotator-tui.ps1 b/scripts/test-fetch-plannotator-tui.ps1 index 0f6a14a..58691ac 100644 --- a/scripts/test-fetch-plannotator-tui.ps1 +++ b/scripts/test-fetch-plannotator-tui.ps1 @@ -75,6 +75,8 @@ try { Assert-True ($result.ExitCode -eq 0) "local override failed: $($result.Output)" Assert-BytesEqual $source $destination "local override bytes differ" Assert-True ((Get-Content -LiteralPath $stamp -Raw) -ceq "0.8.0") "local stamp differs" + $targetFile = Join-Path $pluginRoot "bin/plannotator-tui.target" + Assert-True (Test-Path -LiteralPath $targetFile -PathType Leaf) "target stamp file missing" $env:PLANNOTATOR_TUI_BIN = $null $env:PLANNOTATOR_TUI_RELEASE_BASE = "http://127.0.0.1:1/must-not-be-requested" @@ -86,6 +88,13 @@ try { (Get-FileHash -Algorithm SHA256 -LiteralPath $destination).Hash -ceq $beforeHash ) "idempotent run replaced the destination" + # If target is mismatched, must not short-circuit + Set-Content -LiteralPath $targetFile -NoNewline -Value "mismatched-target" + $env:PLANNOTATOR_TUI_BIN = $source + $result = Invoke-Fetcher + Assert-True ($result.ExitCode -eq 0) "fetch with mismatched target failed: $($result.Output)" + Assert-True ($result.Output -notmatch "already installed") "mismatched target incorrectly short-circuited" + Assert-True ($result.Output -match "installed plannotator-tui") "mismatched target did not install: $($result.Output)" $env:PLANNOTATOR_TUI_BIN = Join-Path $testRoot "missing explicit override.exe" $result = Invoke-Fetcher Assert-True ($result.ExitCode -ne 0) "missing explicit override exited successfully" diff --git a/scripts/test-fetch-plannotator-tui.sh b/scripts/test-fetch-plannotator-tui.sh index c223e43..dec0427 100644 --- a/scripts/test-fetch-plannotator-tui.sh +++ b/scripts/test-fetch-plannotator-tui.sh @@ -24,6 +24,7 @@ printf 'old-version' > "$plugin_root/bin/plannotator-tui.version" PLANNOTATOR_TUI_BIN="$source_binary" bash "$plugin_root/scripts/fetch-plannotator-tui.sh" cmp "$source_binary" "$plugin_root/bin/plannotator-tui.exe" test "$(cat "$plugin_root/bin/plannotator-tui.version")" = 0.8.0 +test -f "$plugin_root/bin/plannotator-tui.target" test ! -e "$plugin_root/bin/plannotator-tui" test "$("$plugin_root/bin/plannotator-tui.exe" --version)" = "plannotator-tui 0.8.0" test "$(bash "$plugin_root/scripts/plannotator-tui.sh" --version)" = "plannotator-tui 0.8.0" @@ -33,3 +34,22 @@ case "$output" in *"already installed"*) ;; *) echo "idempotent fetch did not short-circuit: $output" >&2; exit 1 ;; esac + +# If target is mismatched, must not short-circuit +printf 'mismatched-target\n' > "$plugin_root/bin/plannotator-tui.target" +output="$(PLANNOTATOR_TUI_BIN="$source_binary" bash "$plugin_root/scripts/fetch-plannotator-tui.sh")" +case "$output" in + *"already installed"*) echo "mismatched target incorrectly short-circuited" >&2; exit 1 ;; + *"installed plannotator-tui"*) ;; + *) echo "unexpected output on mismatched target: $output" >&2; exit 1 ;; +esac + +# If binary is unrunnable, must not short-circuit +printf 'corrupt bytes' > "$plugin_root/bin/plannotator-tui.exe" +chmod +x "$plugin_root/bin/plannotator-tui.exe" +output="$(PLANNOTATOR_TUI_BIN="$source_binary" bash "$plugin_root/scripts/fetch-plannotator-tui.sh")" +case "$output" in + *"already installed"*) echo "unrunnable binary incorrectly short-circuited" >&2; exit 1 ;; + *"installed plannotator-tui"*) ;; + *) echo "unexpected output on unrunnable binary: $output" >&2; exit 1 ;; +esac